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
+304 -24

No files matched your search

+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)