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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user