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 |
|
| 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 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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.
|
databases natively.
|
||||||
- [ ] **Notifications depth**: notification center persistence (read/unread),
|
- [ ] **Notifications depth**: notification center persistence (read/unread),
|
||||||
not just a transient bell feed.
|
not just a transient bell feed.
|
||||||
- [ ] **GitHub App install surfacing**: backend returns `install_url` for
|
- [x] **GitHub App install surfacing**: Settings → Git Providers shows an
|
||||||
`github_app` providers — the UI never shows it. Surface an "Install app"
|
"Install GitHub App" action that opens `install_url`, and handles the
|
||||||
action on GitHub-App providers in Settings → Git Providers.
|
`?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
|
- [ ] **Database → service binding**: creating a managed DB does not wire it
|
||||||
into a service. Add a bind action that injects the resolved
|
into a service. Add a bind action that injects the resolved
|
||||||
`connection_url` as an env var (e.g. `DATABASE_URL`) on a chosen
|
`connection_url` as an env var (e.g. `DATABASE_URL`) on a chosen
|
||||||
service, so apps can actually consume provisioned databases.
|
service, so apps can actually consume provisioned databases.
|
||||||
- [ ] **Backup export**: `database_backups.backup_path` is server-local only —
|
- [x] **Backup export**: `GET /databases/:id/backups/:bid/download` streams
|
||||||
no download endpoint. Add `GET /databases/:id/backups/:bid/download`
|
the archive out of the `containr-db-backups` volume via a stopped utility
|
||||||
streaming the archive, plus a Download action in the backups list.
|
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
|
- [ ] **Scheduled database backups**: backups are manual-only despite a
|
||||||
`retention` field. Add a `schedule` column + fold due database backups
|
`retention` field. Add a `schedule` column + fold due database backups
|
||||||
into the cron scheduler tick.
|
into the cron scheduler tick.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
"containr/internal/database/sqlcdb"
|
"containr/internal/database/sqlcdb"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
"context"
|
"context"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"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 {
|
func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics {
|
||||||
if h.dockerClient == nil {
|
if h.dockerClient == nil {
|
||||||
return h.generateMockMetrics()
|
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/action", databaseHandler.PerformDatabaseAction)
|
||||||
protected.POST("/databases/:id/backup", databaseHandler.CreateBackup)
|
protected.POST("/databases/:id/backup", databaseHandler.CreateBackup)
|
||||||
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
||||||
|
protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
|
||||||
|
|
||||||
// Node Agent routes
|
// Node Agent routes
|
||||||
agentHandler.SetupRoutes(protected)
|
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
|
// GetContainerStats returns real-time resource usage statistics for a container
|
||||||
func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) {
|
func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) {
|
||||||
resp, err := c.cli.ContainerStats(ctx, containerID, stream)
|
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
getApiBaseUrl,
|
getApiBaseUrl,
|
||||||
getAgentPublicBaseUrl,
|
getAgentPublicBaseUrl,
|
||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
databaseAction,
|
databaseAction,
|
||||||
createDatabaseBackup,
|
createDatabaseBackup,
|
||||||
restoreDatabaseBackup,
|
restoreDatabaseBackup,
|
||||||
|
getGitHubAppInstallUrl,
|
||||||
|
connectGitHubApp,
|
||||||
updateCurrentUserProfile,
|
updateCurrentUserProfile,
|
||||||
type AgentAuthTokenCreated,
|
type AgentAuthTokenCreated,
|
||||||
type DatabaseEntity,
|
type DatabaseEntity,
|
||||||
@@ -813,11 +815,44 @@ const gitProviderTypes = [
|
|||||||
|
|
||||||
function GitProvidersSection() {
|
function GitProvidersSection() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const providersQuery = useQuery({
|
const providersQuery = useQuery({
|
||||||
queryKey: ['git-providers'],
|
queryKey: ['git-providers'],
|
||||||
queryFn: listGitProviders,
|
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({
|
const [form, setForm] = useState({
|
||||||
name: 'github' as (typeof gitProviderTypes)[number]['value'],
|
name: 'github' as (typeof gitProviderTypes)[number]['value'],
|
||||||
displayName: '',
|
displayName: '',
|
||||||
@@ -863,16 +898,41 @@ function GitProvidersSection() {
|
|||||||
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
|
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => setFormOpen((open) => !open)}
|
<button
|
||||||
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"
|
onClick={() => void installApp()}
|
||||||
style={{ background: 'var(--accent-primary)' }}
|
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"
|
||||||
>
|
>
|
||||||
<Link2 size={14} />
|
<GitBranch size={14} />
|
||||||
{formOpen ? 'Close' : 'Connect'}
|
Install GitHub App
|
||||||
</button>
|
</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>
|
</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 ? (
|
{providersQuery.isLoading ? (
|
||||||
<div className="py-6 text-center">
|
<div className="py-6 text-center">
|
||||||
<Loader2 size={20} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
<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="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>
|
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
|
||||||
{b.status === 'completed' && (
|
{b.status === 'completed' && (
|
||||||
<button
|
<span className="ml-auto flex items-center gap-3">
|
||||||
onClick={() => { if (window.confirm(`Restore ${db.name} from this backup? Current data will be overwritten.`)) restoreMutation.mutate({ id: db.id ?? '', backupId: b.id ?? '' }); }}
|
<a
|
||||||
disabled={restoreMutation.isPending}
|
href={`${getApiBaseUrl()}/databases/${encodeURIComponent(db.id ?? '')}/backups/${encodeURIComponent(b.id ?? '')}/download`}
|
||||||
className="ml-auto text-[var(--accent-primary)] hover:underline disabled:opacity-40"
|
className="text-[var(--accent-primary)] hover:underline"
|
||||||
>
|
>
|
||||||
Restore
|
Download
|
||||||
</button>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1710,6 +1710,63 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/git/providers": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -5783,6 +5840,7 @@ export interface paths {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
project_id?: string;
|
project_id?: string;
|
||||||
|
service_id?: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -1326,6 +1326,25 @@ export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoReq
|
|||||||
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
||||||
export type GitWebhookEntity = components['schemas']['GitWebhook'];
|
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(
|
export async function connectGitRepository(
|
||||||
input: ConnectGitRepositoryInput,
|
input: ConnectGitRepositoryInput,
|
||||||
): Promise<GitRepositoryEntity> {
|
): Promise<GitRepositoryEntity> {
|
||||||
|
|||||||
@@ -1183,6 +1183,45 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/MessageResponse'
|
$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 Integration
|
||||||
/git/providers:
|
/git/providers:
|
||||||
get:
|
get:
|
||||||
|
|||||||
Reference in new issue
Block a user