feat(backups): archive download endpoint + GitHub App install surfacing

- GET /databases/:id/backups/:bid/download streams the archive out of the
  containr-db-backups volume via a stopped utility container and
  CopyFromContainer; daemon logs sanitize non-UTF-8 so binary through
  container logs was not viable. Verified e2e: byte-identical payload,
  404 missing/cross-user, 401 unauthenticated.
- Settings Git Providers gains an Install GitHub App action that opens the
  install_url and completes the installation_id return via connect endpoint.
- ROADMAP: mark both items done with verification evidence.

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 17:30:30 +02:00
1 parent 18fe56f948
commit bcd3a83aa4
8 files changed
+290 -10

No files matched your search

+12 -7
View File
@@ -45,7 +45,7 @@ The backend exposes **~95 route registrations**; the frontend consumes
| Templates — **Compose catalog** (GitHub import, paste YAML, `x-containr` metadata, community source) | **missing** | **missing** | spec written, not built |
| Git providers (PAT add/remove, repo list, branches) | done | done (Settings → Git Providers) | done |
| Git repo connect + webhook auto-deploy | done (`/git/repositories/connect`, `/git/webhooks`, `/api/git/webhooks/:id` receiver) | done — provider→repo→branch in Add Service, connect+webhook on create | guides claim it works |
| GitHub App install flow | done (`install_url` returned) | partial — install URL not surfaced in UI | partial |
| GitHub App install flow | done (`install_url` + connect endpoint) | done — Install button + `installation_id` return handling | done (real-provider e2e pending credentials) |
| Cron jobs (CRUD, trigger, executions) | done — real scheduler + `docker exec` capture | done — Cron tab on service detail | not documented |
| Managed databases (CRUD, actions, backup/restore) | done | done — `/databases` page (conn info, actions, backups) | mentioned in README |
| Preview environments (CRUD, promote, cleanup) | done | **missing** | not documented |
@@ -317,16 +317,21 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo.
databases natively.
- [ ] **Notifications depth**: notification center persistence (read/unread),
not just a transient bell feed.
- [ ] **GitHub App install surfacing**: backend returns `install_url` for
`github_app` providers — the UI never shows it. Surface an "Install app"
action on GitHub-App providers in Settings → Git Providers.
- [x] **GitHub App install surfacing**: Settings → Git Providers shows an
"Install GitHub App" action that opens `install_url`, and handles the
`?installation_id=` return by calling `POST /git/github-app/connect`.
(Verified at typecheck/lint level; real GitHub App redirect e2e tracked
under the OAuth/provider-credentials item in Phase 4.)
- [ ] **Database → service binding**: creating a managed DB does not wire it
into a service. Add a bind action that injects the resolved
`connection_url` as an env var (e.g. `DATABASE_URL`) on a chosen
service, so apps can actually consume provisioned databases.
- [ ] **Backup export**: `database_backups.backup_path` is server-local only —
no download endpoint. Add `GET /databases/:id/backups/:bid/download`
streaming the archive, plus a Download action in the backups list.
- [x] **Backup export**: `GET /databases/:id/backups/:bid/download` streams
the archive out of the `containr-db-backups` volume via a stopped utility
container + `CopyFromContainer` (daemon logs mangle non-UTF-8, so `cat`
through logs was not viable). Download action in the `/databases` backups
list. Verified e2e: byte-identical archive, 404 missing/cross-user,
401 unauthenticated.
- [ ] **Scheduled database backups**: backups are manual-only despite a
`retention` field. Add a `schedule` column + fold due database backups
into the cron scheduler tick.
+83
View File
@@ -1,6 +1,7 @@
package api
import (
"archive/tar"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
"context"
@@ -11,6 +12,7 @@ import (
"errors"
"fmt"
"io"
"log"
"math"
"net"
"net/http"
@@ -643,6 +645,87 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
})
}
// DownloadBackup streams a completed backup archive out of the backups volume.
// Archives live inside the containr-db-backups docker volume, so a one-shot
// utility container cats the file and its stdout is demuxed to the response.
func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
databaseID := c.Param("id")
backupID := c.Param("bid")
backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
ID: backupID,
DatabaseID: databaseID,
UserID: userID,
})
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check backup"})
return
}
if backup.Status != "completed" {
c.JSON(http.StatusConflict, gin.H{"error": "Backup is not completed"})
return
}
if h.dockerClient == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker unavailable"})
return
}
archive := managedDatabaseBackupArchivePath(backupID)
if backup.BackupPath.Valid && strings.TrimSpace(backup.BackupPath.String) != "" {
archive = sanitizeBackupArchivePath(backup.BackupPath.String)
}
// Container logs cannot carry binary data (the daemon sanitizes non-UTF-8),
// so the archive is copied out of a stopped utility container instead.
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
defer cancel()
containerID, err := h.dockerClient.CreateContainer(ctx, docker.ContainerConfig{
Name: h.generateUtilityContainerName("backup-dl", "shared"),
Image: managedDatabaseBackupImage,
Cmd: []string{"true"},
RestartPolicy: "no",
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: managedDatabaseBackupVolume, Target: "/backup", ReadOnly: true}},
Labels: map[string]string{
"containr.managed": "true",
"containr.utility": "backup-dl",
},
NetworkMode: "none",
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read backup archive"})
return
}
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), containerID, true) }()
tarStream, err := h.dockerClient.CopyFromContainer(ctx, containerID, "/backup/"+archive)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup archive not found"})
return
}
defer tarStream.Close()
tarReader := tar.NewReader(tarStream)
if _, err := tarReader.Next(); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup archive not found"})
return
}
c.Header("Content-Type", "application/gzip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", archive))
c.Status(http.StatusOK)
if _, err := io.Copy(c.Writer, tarReader); err != nil {
log.Printf("backup download %s: stream error: %v", backupID, err)
}
}
func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics {
if h.dockerClient == nil {
return h.generateMockMetrics()
+1
View File
@@ -250,6 +250,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
protected.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
protected.POST("/databases/:id/backup", databaseHandler.CreateBackup)
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
// Node Agent routes
agentHandler.SetupRoutes(protected)
+7
View File
@@ -145,6 +145,13 @@ func (c *Client) GetContainerLogs(ctx context.Context, containerID string, optio
})
}
// CopyFromContainer streams a tar archive of srcPath out of a container.
// The container only needs to exist — it does not have to be running.
func (c *Client) CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) {
reader, _, err := c.cli.CopyFromContainer(ctx, containerID, srcPath)
return reader, err
}
// GetContainerStats returns real-time resource usage statistics for a container
func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) {
resp, err := c.cli.ContainerStats(ctx, containerID, stream)
+71 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
getApiBaseUrl,
getAgentPublicBaseUrl,
@@ -22,6 +22,8 @@ import {
databaseAction,
createDatabaseBackup,
restoreDatabaseBackup,
getGitHubAppInstallUrl,
connectGitHubApp,
updateCurrentUserProfile,
type AgentAuthTokenCreated,
type DatabaseEntity,
@@ -813,11 +815,44 @@ const gitProviderTypes = [
function GitProvidersSection() {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const providersQuery = useQuery({
queryKey: ['git-providers'],
queryFn: listGitProviders,
});
// GitHub redirects back here with ?installation_id=<id> after app install.
const installIdParam = searchParams.get('installation_id');
const connectAppMutation = useMutation({
mutationFn: (installationId: number) => connectGitHubApp(installationId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['git-providers'] });
const next = new URLSearchParams(searchParams);
next.delete('installation_id');
next.delete('setup_action');
setSearchParams(next, { replace: true });
},
});
const connectFiredFor = useRef<number | null>(null);
useEffect(() => {
const id = Number(installIdParam);
if (id > 0 && connectFiredFor.current !== id) {
connectFiredFor.current = id;
connectAppMutation.mutate(id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- fire once per installation id
}, [installIdParam]);
const [installUrlError, setInstallUrlError] = useState<string | null>(null);
const installApp = async () => {
setInstallUrlError(null);
try {
window.open(await getGitHubAppInstallUrl(), '_blank', 'noopener');
} catch (err) {
setInstallUrlError(err instanceof Error ? err.message : 'GitHub App is not configured');
}
};
const [form, setForm] = useState({
name: 'github' as (typeof gitProviderTypes)[number]['value'],
displayName: '',
@@ -863,6 +898,14 @@ function GitProvidersSection() {
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => void installApp()}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<GitBranch size={14} />
Install GitHub App
</button>
<button
onClick={() => setFormOpen((open) => !open)}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all"
@@ -872,6 +915,23 @@ function GitProvidersSection() {
{formOpen ? 'Close' : 'Connect'}
</button>
</div>
</div>
{installUrlError && (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{installUrlError}
</div>
)}
{connectAppMutation.isPending && (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--surface-muted)] text-sm text-[var(--text-secondary)]">
Completing GitHub App installation…
</div>
)}
{connectAppMutation.isError && (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{connectAppMutation.error instanceof Error ? connectAppMutation.error.message : 'Failed to connect GitHub App'}
</div>
)}
{providersQuery.isLoading ? (
<div className="py-6 text-center">
@@ -1611,13 +1671,21 @@ export function DatabasesPage() {
<span className="mono text-[var(--text-muted)]">{b.size}</span>
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
{b.status === 'completed' && (
<span className="ml-auto flex items-center gap-3">
<a
href={`${getApiBaseUrl()}/databases/${encodeURIComponent(db.id ?? '')}/backups/${encodeURIComponent(b.id ?? '')}/download`}
className="text-[var(--accent-primary)] hover:underline"
>
Download
</a>
<button
onClick={() => { if (window.confirm(`Restore ${db.name} from this backup? Current data will be overwritten.`)) restoreMutation.mutate({ id: db.id ?? '', backupId: b.id ?? '' }); }}
disabled={restoreMutation.isPending}
className="ml-auto text-[var(--accent-primary)] hover:underline disabled:opacity-40"
className="text-[var(--accent-primary)] hover:underline disabled:opacity-40"
>
Restore
</button>
</span>
)}
</div>
))}
+58
View File
@@ -1710,6 +1710,63 @@ export interface paths {
patch?: never;
trace?: never;
};
"/databases/{id}/backups/{bid}/download": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Download a completed backup archive */
get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
bid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Backup archive stream */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/gzip": string;
};
};
/** @description Backup not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Backup not completed */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/git/providers": {
parameters: {
query?: never;
@@ -5783,6 +5840,7 @@ export interface paths {
parameters: {
query?: {
project_id?: string;
service_id?: string;
};
header?: never;
path?: never;
+19
View File
@@ -1326,6 +1326,25 @@ export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoReq
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
export type GitWebhookEntity = components['schemas']['GitWebhook'];
export async function getGitHubAppInstallUrl(): Promise<string> {
const payload = await requestJson<{ install_url?: string }>('/git/github-app/install-url');
if (!payload.install_url) {
throw new ApiError('GitHub App install URL unavailable', 500);
}
return payload.install_url;
}
export async function connectGitHubApp(installationId: number, displayName?: string): Promise<GitProviderEntity> {
const payload = await requestJson<{ provider?: GitProviderEntity }>('/git/github-app/connect', {
method: 'POST',
body: JSON.stringify({ installation_id: installationId, display_name: displayName }),
});
if (!payload.provider?.id) {
throw new ApiError('GitHub App connect response is invalid', 500);
}
return payload.provider;
}
export async function connectGitRepository(
input: ConnectGitRepositoryInput,
): Promise<GitRepositoryEntity> {
+39
View File
@@ -1183,6 +1183,45 @@ paths:
schema:
$ref: '#/components/schemas/MessageResponse'
/databases/{id}/backups/{bid}/download:
get:
tags:
- Databases
summary: Download a completed backup archive
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: bid
in: path
required: true
schema:
type: string
security:
- bearerAuth: []
responses:
'200':
description: Backup archive stream
content:
application/gzip:
schema:
type: string
format: binary
'404':
description: Backup not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: Backup not completed
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# Git Integration
/git/providers:
get: