From bcd3a83aa4716acdbb218b90eb624671a0438b75 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 17 Sep 2026 17:30:30 +0200 Subject: [PATCH] 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 --- ROADMAP.md | 19 ++-- app/backend/internal/api/databases.go | 83 ++++++++++++++ app/backend/internal/api/routes.go | 1 + app/backend/internal/docker/client.go | 7 ++ app/frontend/src/features/secondary/pages.tsx | 102 +++++++++++++++--- app/frontend/src/generated/api-types.ts | 58 ++++++++++ app/frontend/src/lib/api-client.ts | 19 ++++ docs/api/openapi.yaml | 39 +++++++ 8 files changed, 304 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 39ad868..518ba97 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/app/backend/internal/api/databases.go b/app/backend/internal/api/databases.go index 1c3bfd0..e4d7fd6 100644 --- a/app/backend/internal/api/databases.go +++ b/app/backend/internal/api/databases.go @@ -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() diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 41083f3..70990be 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -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) diff --git a/app/backend/internal/docker/client.go b/app/backend/internal/docker/client.go index 080f072..131c76a 100644 --- a/app/backend/internal/docker/client.go +++ b/app/backend/internal/docker/client.go @@ -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) diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 41ac217..0a5b5c3 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -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= 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(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(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,16 +898,41 @@ function GitProvidersSection() {

Connect accounts to deploy services from repositories

- +
+ + +
+ {installUrlError && ( +
+ {installUrlError} +
+ )} + {connectAppMutation.isPending && ( +
+ Completing GitHub App installation… +
+ )} + {connectAppMutation.isError && ( +
+ {connectAppMutation.error instanceof Error ? connectAppMutation.error.message : 'Failed to connect GitHub App'} +
+ )} + {providersQuery.isLoading ? (
@@ -1611,13 +1671,21 @@ export function DatabasesPage() { {b.size} {b.status} {b.status === 'completed' && ( - + + + Download + + + )}
))} diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index e276e2e..0dd9ebf 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -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; diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 0cbd491..c9df94c 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -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 { + 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 { + 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 { diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2ab6e3e..28dd7ba 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: