mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
18fe56f948
commit
bcd3a83aa4
8 files changed
+304
-24
No files matched your search
+12
-7
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,16 +898,41 @@ function GitProvidersSection() {
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Link2 size={14} />
|
||||
{formOpen ? 'Close' : 'Connect'}
|
||||
</button>
|
||||
<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"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Link2 size={14} />
|
||||
{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">
|
||||
<Loader2 size={20} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
@@ -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' && (
|
||||
<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"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<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="text-[var(--accent-primary)] hover:underline disabled:opacity-40"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user