OpenAPI spec at /api/docs with a Scalar viewer, opt-in via FEAT_API_DOCS
This commit is contained in:
251 files changed
+32497
-66
No files matched your search
+8
-2
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx vite dev",
|
||||
"prebuild": "npx license-checker --json --production | jq 'to_entries | map({name: (.key | split(\"@\")[0:-1] | join(\"@\")), version: (.key | split(\"@\")[-1]), license: .value.licenses, repository: .value.repository}) | sort_by(.name)' > src/lib/data/dependencies.json.tmp && mv src/lib/data/dependencies.json.tmp src/lib/data/dependencies.json || true",
|
||||
"prebuild": "npx license-checker --json --production | jq 'to_entries | map({name: (.key | split(\"@\")[0:-1] | join(\"@\")), version: (.key | split(\"@\")[-1]), license: .value.licenses, repository: .value.repository}) | sort_by(.name)' > src/lib/data/dependencies.json.tmp && mv src/lib/data/dependencies.json.tmp src/lib/data/dependencies.json || true && npm run generate:openapi",
|
||||
"build": "npx vite build",
|
||||
"start": "node ./server.js",
|
||||
"preview": "node ./build/index.js",
|
||||
@@ -50,7 +50,9 @@
|
||||
"test:quick": "bun test tests/api-smoke.test.ts tests/notifications.test.ts",
|
||||
"test:integration": "bun test tests/api-smoke.test.ts tests/crud-operations.test.ts tests/scheduling.test.ts tests/hawser-connection.test.ts",
|
||||
"test:e2e": "npx playwright test tests/e2e/",
|
||||
"generate:legal": "node scripts/generate-legal-pages.ts"
|
||||
"generate:legal": "node scripts/generate-legal-pages.ts",
|
||||
"generate:openapi": "npx tsx scripts/generate-openapi.ts",
|
||||
"generate:openapi:check": "npx tsx scripts/generate-openapi.ts --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@1password/sdk": "0.4.1-beta.1",
|
||||
@@ -82,8 +84,10 @@
|
||||
"devalue": "5.8.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"fast-xml-parser": "5.7.3",
|
||||
"isomorphic-dompurify": "3.22.0",
|
||||
"js-yaml": "4.3.1",
|
||||
"ldapts": "8.1.3",
|
||||
"marked": "18.0.9",
|
||||
"modern-tar": "0.8.0",
|
||||
"nodemailer": "9.0.1",
|
||||
"otpauth": "9.4.1",
|
||||
@@ -100,6 +104,7 @@
|
||||
"@layerstack/tailwind": "^1.0.1",
|
||||
"@lucide/svelte": "^0.562.0",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@scalar/api-reference": "1.64.0",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "2.50.0",
|
||||
"@sveltejs/vite-plugin-svelte": "6.2.4",
|
||||
@@ -131,6 +136,7 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "4.23.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
|
||||
+14
-2
@@ -19,7 +19,7 @@ import type { HandleServerError, Handle } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { startRssTracker, stopRssTracker, rssBeforeOp, rssAfterOp } from '$lib/server/rss-tracker';
|
||||
import { getClientIp } from '$lib/server/client-ip';
|
||||
import { BACKUPS_ENABLED } from '$lib/server/features';
|
||||
import { BACKUPS_ENABLED, API_DOCS_ENABLED } from '$lib/server/features';
|
||||
// Side-effect import: installs globalThis.__authenticateWsUpgrade and
|
||||
// globalThis.__canAccessEnvForUser used by the raw WS upgrade handlers in
|
||||
// server.js / vite.config.ts to authenticate /api/containers/*/exec.
|
||||
@@ -268,7 +268,8 @@ const PUBLIC_PATHS = [
|
||||
'/api/changelog',
|
||||
'/api/dependencies',
|
||||
'/api/health',
|
||||
'/api/settings/theme'
|
||||
'/api/settings/theme',
|
||||
'/api/docs'
|
||||
];
|
||||
|
||||
// Check if path is public
|
||||
@@ -310,6 +311,17 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// API docs gate (see $lib/server/features.ts). Enforced HERE, not in the
|
||||
// docs +page.server load, because the app runs ssr=false so a page load
|
||||
// never fires on a cold server render. Single chokepoint covers the spec
|
||||
// endpoint and the Scalar viewer.
|
||||
if (!API_DOCS_ENABLED) {
|
||||
const p = event.url.pathname.replace(/\/$/, '');
|
||||
if (p === '/api/docs' || p === '/api/docs/ui') {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const httpBefore = rssBeforeOp();
|
||||
try {
|
||||
// Check if auth is enabled
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,3 +13,10 @@
|
||||
* 5. Drop the BACKUPS_ENABLED guards in src/lib/server/scheduler/index.ts.
|
||||
*/
|
||||
export const BACKUPS_ENABLED = process.env.FEAT_BACKUPS_ENABLED === 'true';
|
||||
|
||||
/**
|
||||
* API docs (OpenAPI spec + Scalar viewer at /api/docs and /api/docs/ui).
|
||||
* Off by default: the docs routes are unauthenticated, so exposing the full
|
||||
* API surface is opt-in per instance with FEAT_API_DOCS=true. Read once at boot.
|
||||
*/
|
||||
export const API_DOCS_ENABLED = process.env.FEAT_API_DOCS === 'true';
|
||||
@@ -2,10 +2,11 @@ import type { LayoutServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { isAuthEnabled, validateSession } from '$lib/server/auth';
|
||||
import { hasAdminUser } from '$lib/server/db';
|
||||
import { BACKUPS_ENABLED } from '$lib/server/features';
|
||||
import { BACKUPS_ENABLED, API_DOCS_ENABLED } from '$lib/server/features';
|
||||
|
||||
// Routes that don't require authentication
|
||||
const PUBLIC_PATHS = ['/login'];
|
||||
// Routes that don't require authentication. The API docs viewer is public only
|
||||
// when opt-in via FEAT_API_DOCS (the route 404s otherwise; see features.ts).
|
||||
const PUBLIC_PATHS = ['/login', ...(API_DOCS_ENABLED ? ['/api/docs'] : [])];
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies, url }) => {
|
||||
const authEnabled = await isAuthEnabled();
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
|
||||
// Check if current route is login page (no sidebar needed)
|
||||
const isLoginPage = $derived($page.url.pathname === '/login');
|
||||
// Same for the OpenAPI docs viewer (Scalar) — it brings its own
|
||||
// full-page chrome. Matches with or without a trailing slash.
|
||||
const isDocsPage = $derived($page.url.pathname.replace(/\/$/, '') === '/api/docs/ui');
|
||||
const noSidebar = $derived(isLoginPage || isDocsPage);
|
||||
|
||||
let { children, data } = $props();
|
||||
let envId = $state<number | null>(null);
|
||||
@@ -130,8 +134,8 @@
|
||||
<title>Dockhand - Docker Management</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if isLoginPage}
|
||||
<!-- Login page: no sidebar, no header -->
|
||||
{#if noSidebar}
|
||||
<!-- Login page / OpenAPI docs viewer: no sidebar, no header -->
|
||||
{@render children?.()}
|
||||
<Toaster richColors position="bottom-right" />
|
||||
{:else}
|
||||
|
||||
@@ -3,6 +3,22 @@ import { getContainerEvents, getContainerEventContainers, getContainerEventActio
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Query container activity events with filters and pagination
|
||||
* query: environmentId:integer Filter to a single environment (from GET /api/environments)
|
||||
* query: containerId:string Filter by container ID (from GET /api/containers)
|
||||
* query: containerName:string Filter by container name
|
||||
* query: actions:string Comma-separated event actions to filter by
|
||||
* query: labels:string Comma-separated labels to filter by
|
||||
* query: fromDate:string Start of the date range (ISO 8601)
|
||||
* query: toDate:string End of the date range (ISO 8601)
|
||||
* query: limit:integer Maximum number of events to return
|
||||
* query: offset:integer Number of events to skip (pagination)
|
||||
* resp-200: {events:array<{id:integer!, containerName:string, action:string, timestamp:string}>, total:integer!, limit:integer!, offset:integer!}
|
||||
* resp-403: Permission denied (requires the activity:view permission)
|
||||
* resp-500: Failed to fetch container events
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -69,6 +85,14 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Clear all stored container activity events
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-403: Permission denied (requires the activity:delete permission)
|
||||
* resp-500: Failed to clear container events
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -3,6 +3,15 @@ import { getContainerEventContainers } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: List distinct container names that appear in the activity log, for filter dropdowns
|
||||
* query: environment_id:integer Filter to a single environment (from GET /api/environments)
|
||||
* resp-200: array<string>
|
||||
* resp-200-example: ["web-1","db-1","cache-1"]
|
||||
* resp-403: Permission denied (requires the activity:view permission)
|
||||
* resp-500: Failed to fetch container names
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Stream live container activity and environment-status events over Server-Sent Events
|
||||
* resp-200: Server-Sent Events stream (text/event-stream) emitting connected, heartbeat, activity and env_status events
|
||||
* resp-403: Permission denied (requires the activity:view permission)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -3,6 +3,15 @@ import { getContainerEventStats } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get aggregate container activity statistics (totals and counts by action)
|
||||
* query: environment_id:integer Filter to a single environment (from GET /api/environments)
|
||||
* resp-200: {total:integer!, today:integer!, byAction:{}}
|
||||
* resp-200-example: {"total":128,"today":7,"byAction":{"start":40,"stop":30}}
|
||||
* resp-403: Permission denied (requires the activity:view permission)
|
||||
* resp-500: Failed to fetch stats
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -3,6 +3,25 @@ import { authorize, enterpriseRequired } from '$lib/server/authorize';
|
||||
import { getAuditLogs, getAuditLogUsers, type AuditLogFilters, type AuditEntityType, type AuditAction } from '$lib/server/db';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Query the audit log with filters and pagination (Enterprise only)
|
||||
* query: usernames:string Comma-separated usernames to filter by
|
||||
* query: entityTypes:string Comma-separated entity types to filter by
|
||||
* query: actions:string Comma-separated actions to filter by
|
||||
* query: username:string Legacy single-username filter
|
||||
* query: entityType:string Legacy single entity-type filter
|
||||
* query: action:string Legacy single-action filter
|
||||
* query: environmentId:integer Filter to a single environment (from GET /api/environments)
|
||||
* query: labels:string Comma-separated labels to filter by
|
||||
* query: fromDate:string Start of the date range (ISO 8601)
|
||||
* query: toDate:string End of the date range (ISO 8601)
|
||||
* query: limit:integer Maximum number of entries to return
|
||||
* query: offset:integer Number of entries to skip (pagination)
|
||||
* resp-200: {logs:array<{id:integer!, username:string, action:string, entityType:string, entityName:string, createdAt:string}>, total:integer}
|
||||
* resp-403: Enterprise required, or permission denied
|
||||
* resp-500: Failed to fetch audit logs
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { RequestHandler } from './$types';
|
||||
import { authorize, enterpriseRequired } from '$lib/server/authorize';
|
||||
import { auditEvents, type AuditEventData } from '$lib/server/audit-events';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Stream live audit-log events over Server-Sent Events (Enterprise only)
|
||||
* resp-200: Server-Sent Events stream (text/event-stream) emitting connected, heartbeat and audit events
|
||||
* resp-403: Enterprise required, or permission denied
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -84,6 +84,20 @@ function formatToMarkdown(logs: AuditLog[]): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Export the audit log as a downloadable file in JSON, CSV or Markdown (Enterprise only)
|
||||
* query: username:string Filter by username
|
||||
* query: entityType:string Filter by entity type
|
||||
* query: action:string Filter by action
|
||||
* query: environmentId:integer Filter to a single environment (from GET /api/environments)
|
||||
* query: fromDate:string Start of the date range (ISO 8601)
|
||||
* query: toDate:string End of the date range (ISO 8601)
|
||||
* query: format:string Output format — json (default), csv or md
|
||||
* resp-200: Downloadable audit-log export (Content-Disposition attachment; JSON, CSV or Markdown)
|
||||
* resp-403: Enterprise required, or permission denied
|
||||
* resp-500: Failed to export audit logs
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -3,6 +3,14 @@ import { authorize, enterpriseRequired } from '$lib/server/authorize';
|
||||
import { getAuditLogUsers } from '$lib/server/db';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: List the distinct usernames that appear in the audit log, for filter dropdowns (Enterprise only)
|
||||
* resp-200: array<string>
|
||||
* resp-200-example: ["admin","ci-bot","alice"]
|
||||
* resp-403: Enterprise required, or permission denied
|
||||
* resp-500: Failed to fetch audit log users
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -5,6 +5,15 @@ import { getLdapConfigs, createLdapConfig } from '$lib/server/db';
|
||||
import { auditLdapConfig } from '$lib/server/audit';
|
||||
|
||||
// GET /api/auth/ldap - List all LDAP configurations
|
||||
/**
|
||||
* @openapi
|
||||
* summary: List all configured LDAP providers (enterprise only; bind passwords are masked)
|
||||
* resp-200: array<{id:integer!, name:string!, enabled:boolean!, serverUrl:string!, baseDn:string!}>
|
||||
* resp-200-example: [{"id":1,"name":"Corporate LDAP","enabled":true,"serverUrl":"ldaps://ldap.example.com:636","baseDn":"dc=example,dc=com"}]
|
||||
* resp-401: Authentication required (auth is enabled and the caller is not an authenticated admin)
|
||||
* resp-403: Enterprise license required
|
||||
* resp-500: Failed to read the LDAP configurations
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -32,6 +41,18 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
};
|
||||
|
||||
// POST /api/auth/ldap - Create a new LDAP configuration
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Create a new LDAP provider configuration (enterprise only)
|
||||
* body: {name:string!, serverUrl:string!, baseDn:string!, enabled:boolean, bindDn:string, bindPassword:string, userFilter:string, usernameAttribute:string, emailAttribute:string, displayNameAttribute:string, groupBaseDn:string, groupFilter:string, adminGroup:string, roleMappings:{}, tlsEnabled:boolean, tlsCa:string}
|
||||
* body-example: {"name":"Corporate LDAP","serverUrl":"ldaps://ldap.example.com:636","baseDn":"dc=example,dc=com","bindDn":"cn=admin,dc=example,dc=com","bindPassword":"***","enabled":true,"tlsEnabled":true}
|
||||
* resp-201: {id:integer!, name:string!, enabled:boolean!, serverUrl:string!, baseDn:string!}
|
||||
* resp-201-desc: LDAP configuration created (bindPassword is masked in the response)
|
||||
* resp-400: Name, server URL, and base DN are required
|
||||
* resp-401: Authentication required (auth is enabled and the caller is not an authenticated admin)
|
||||
* resp-403: Enterprise license required
|
||||
* resp-500: Failed to create the LDAP configuration
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -6,6 +6,17 @@ import { auditLdapConfig } from '$lib/server/audit';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
|
||||
// GET /api/auth/ldap/[id] - Get a specific LDAP configuration
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get a single LDAP provider configuration by id (enterprise only; bind password is masked)
|
||||
* path: id:integer! Numeric id of the LDAP configuration (from GET /api/auth/ldap)
|
||||
* resp-200: {id:integer!, name:string!, enabled:boolean!, serverUrl:string!, baseDn:string!}
|
||||
* resp-400: Invalid id (not a number)
|
||||
* resp-401: Authentication required (auth is enabled and the caller is not an authenticated admin)
|
||||
* resp-403: Enterprise license required
|
||||
* resp-404: LDAP configuration not found
|
||||
* resp-500: Failed to read the LDAP configuration
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -40,6 +51,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
};
|
||||
|
||||
// PUT /api/auth/ldap/[id] - Update a LDAP configuration
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Update an existing LDAP provider configuration (only supplied fields change; a masked bindPassword is ignored)
|
||||
* path: id:integer! Numeric id of the LDAP configuration (from GET /api/auth/ldap)
|
||||
* body: {name:string, enabled:boolean, serverUrl:string, bindDn:string, bindPassword:string, baseDn:string, userFilter:string, usernameAttribute:string, emailAttribute:string, displayNameAttribute:string, groupBaseDn:string, groupFilter:string, adminGroup:string, roleMappings:{}, tlsEnabled:boolean, tlsCa:string}
|
||||
* body-example: {"enabled":false,"userFilter":"(sAMAccountName={{username}})","bindPassword":"***"}
|
||||
* resp-200: {id:integer!, name:string!, enabled:boolean!, serverUrl:string!, baseDn:string!}
|
||||
* resp-400: Invalid id (not a number)
|
||||
* resp-401: Authentication required (auth is enabled and the caller is not an authenticated admin)
|
||||
* resp-403: Enterprise license required
|
||||
* resp-404: LDAP configuration not found
|
||||
* resp-500: Failed to update the LDAP configuration
|
||||
*/
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
@@ -111,6 +135,17 @@ export const PUT: RequestHandler = async (event) => {
|
||||
};
|
||||
|
||||
// DELETE /api/auth/ldap/[id] - Delete a LDAP configuration
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Delete an LDAP provider configuration by id (enterprise only)
|
||||
* path: id:integer! Numeric id of the LDAP configuration (from GET /api/auth/ldap)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-400: Invalid id (not a number)
|
||||
* resp-401: Authentication required (auth is enabled and the caller is not an authenticated admin)
|
||||
* resp-403: Enterprise license required
|
||||
* resp-404: LDAP configuration not found
|
||||
* resp-500: Failed to delete the LDAP configuration
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -5,6 +5,17 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { getLdapConfig } from '$lib/server/db';
|
||||
|
||||
// POST /api/auth/ldap/[id]/test - Test LDAP connection
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Test connectivity of a stored LDAP configuration by id (enterprise only)
|
||||
* path: id:integer! Numeric id of the LDAP configuration to test (from GET /api/auth/ldap)
|
||||
* resp-200: Connection test result (success flag plus diagnostic detail from the LDAP server)
|
||||
* resp-400: Invalid id (not a number)
|
||||
* resp-401: Authentication required (auth is enabled and the caller is not an authenticated admin)
|
||||
* resp-403: Enterprise license required
|
||||
* resp-404: LDAP configuration not found
|
||||
* resp-500: Failed to test the LDAP connection
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -15,7 +15,20 @@ import { getUser, getUserByUsername } from '$lib/server/db';
|
||||
import { auditAuth } from '$lib/server/audit';
|
||||
import { getClientIp } from '$lib/server/client-ip';
|
||||
|
||||
// POST /api/auth/login - Authenticate user
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Authenticate with username/password (local or LDAP) and set the session cookie
|
||||
* body: {username:string!, password:string!, mfaToken:string, provider:string}
|
||||
* body-example: {"username":"admin","password":"correct horse battery staple","provider":"local"}
|
||||
* resp-200: {success:boolean!, user:{id:integer!, username:string!, email:string, displayName:string, isAdmin:boolean!}}
|
||||
* resp-200-desc: Login succeeded and dockhand_session cookie was set — OR requiresMfa:true if a second factor is needed first
|
||||
* resp-200-example: {"success":true,"user":{"id":1,"username":"admin","email":"admin@example.com","displayName":"Admin","isAdmin":true}}
|
||||
* resp-400: Authentication disabled, or username/password missing
|
||||
* resp-401: Invalid credentials or invalid MFA code
|
||||
* resp-403: Local login disabled via DISABLE_LOCAL_LOGIN
|
||||
* resp-429: Rate-limited (too many attempts for this IP+username)
|
||||
* resp-500: Unexpected error during authentication
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies } = event;
|
||||
// Check if auth is enabled
|
||||
|
||||
@@ -5,7 +5,13 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { auditAuth } from '$lib/server/audit';
|
||||
import { getClientIp } from '$lib/server/client-ip';
|
||||
|
||||
// POST /api/auth/logout - End session
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Destroy the current session (clears the dockhand_session cookie)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-500: Unexpected error while destroying the session
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { cookies } = event;
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,15 @@ import {
|
||||
import { auditOidcProvider } from '$lib/server/audit';
|
||||
|
||||
// GET /api/auth/oidc - List all OIDC configurations
|
||||
/**
|
||||
* @openapi
|
||||
* summary: List all configured OIDC providers (client secrets are masked)
|
||||
* resp-200: array<{id:integer!, name:string!, enabled:boolean!, issuerUrl:string!, clientId:string!}>
|
||||
* resp-200-example: [{"id":1,"name":"Authentik","enabled":true,"issuerUrl":"https://auth.example.com/application/o/dockhand/","clientId":"dockhand"}]
|
||||
* resp-401: Authentication required (auth is enabled and the caller is not authenticated)
|
||||
* resp-403: Permission denied (missing settings:view)
|
||||
* resp-500: Failed to read the OIDC configurations
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -37,6 +46,18 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
};
|
||||
|
||||
// POST /api/auth/oidc - Create new OIDC configuration
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Create a new OIDC provider configuration
|
||||
* body: {name:string!, issuerUrl:string!, clientId:string!, clientSecret:string!, redirectUri:string!, enabled:boolean, scopes:string, usernameClaim:string, emailClaim:string, displayNameClaim:string, adminClaim:string, adminValue:string, roleMappingsClaim:string, roleMappings:{}}
|
||||
* body-example: {"name":"Authentik","issuerUrl":"https://auth.example.com/application/o/dockhand/","clientId":"dockhand","clientSecret":"***","redirectUri":"https://dockhand.example.com/api/auth/oidc/callback","enabled":true,"scopes":"openid profile email"}
|
||||
* resp-201: {id:integer!, name:string!, enabled:boolean!, issuerUrl:string!, clientId:string!}
|
||||
* resp-201-desc: OIDC configuration created (clientSecret is masked in the response)
|
||||
* resp-400: A required field is missing (name, issuerUrl, clientId, clientSecret, redirectUri)
|
||||
* resp-401: Authentication required (auth is enabled and the caller is not authenticated)
|
||||
* resp-403: Permission denied (missing settings:edit)
|
||||
* resp-500: Failed to create the OIDC configuration
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -10,6 +10,17 @@ import { auditOidcProvider } from '$lib/server/audit';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
|
||||
// GET /api/auth/oidc/[id] - Get specific OIDC configuration
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get a single OIDC provider configuration by id (client secret is masked)
|
||||
* path: id:integer! Numeric id of the OIDC configuration (from GET /api/auth/oidc)
|
||||
* resp-200: {id:integer!, name:string!, enabled:boolean!, issuerUrl:string!, clientId:string!}
|
||||
* resp-400: Invalid configuration id (not a number)
|
||||
* resp-401: Authentication required (auth is enabled and the caller is not authenticated)
|
||||
* resp-403: Permission denied (missing settings:view)
|
||||
* resp-404: OIDC configuration not found
|
||||
* resp-500: Failed to read the OIDC configuration
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -45,6 +56,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
};
|
||||
|
||||
// PUT /api/auth/oidc/[id] - Update OIDC configuration
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Update an existing OIDC provider configuration (only supplied fields change; a masked clientSecret is ignored)
|
||||
* path: id:integer! Numeric id of the OIDC configuration (from GET /api/auth/oidc)
|
||||
* body: {name:string, enabled:boolean, issuerUrl:string, clientId:string, clientSecret:string, redirectUri:string, scopes:string, usernameClaim:string, emailClaim:string, displayNameClaim:string, adminClaim:string, adminValue:string, roleMappingsClaim:string, roleMappings:{}}
|
||||
* body-example: {"enabled":true,"scopes":"openid profile email groups","clientSecret":"***"}
|
||||
* resp-200: {id:integer!, name:string!, enabled:boolean!, issuerUrl:string!, clientId:string!}
|
||||
* resp-400: Invalid configuration id (not a number)
|
||||
* resp-401: Authentication required (auth is enabled and the caller is not authenticated)
|
||||
* resp-403: Permission denied (missing settings:edit)
|
||||
* resp-404: OIDC configuration not found
|
||||
* resp-500: Failed to update the OIDC configuration
|
||||
*/
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
@@ -115,6 +139,17 @@ export const PUT: RequestHandler = async (event) => {
|
||||
};
|
||||
|
||||
// DELETE /api/auth/oidc/[id] - Delete OIDC configuration
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Delete an OIDC provider configuration by id
|
||||
* path: id:integer! Numeric id of the OIDC configuration (from GET /api/auth/oidc)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-400: Invalid configuration id (not a number)
|
||||
* resp-401: Authentication required (auth is enabled and the caller is not authenticated)
|
||||
* resp-403: Permission denied (missing settings:edit)
|
||||
* resp-404: OIDC configuration not found
|
||||
* resp-500: Failed to delete the OIDC configuration
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -5,6 +5,16 @@ import { getOidcConfig } from '$lib/server/db';
|
||||
import { safeRedirectOrRoot } from '$lib/utils/safe-redirect';
|
||||
|
||||
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Start the OIDC login flow for a provider — on success throws a 302 redirect to the IdP authorization URL
|
||||
* path: id:integer! Numeric id of the OIDC provider (from GET /api/auth/oidc)
|
||||
* query: redirect:string Post-login destination path to return to (defaults to /)
|
||||
* resp-302: Redirect to the IdP's authorization URL
|
||||
* resp-400: Authentication is not enabled, or the configuration id is invalid
|
||||
* resp-404: OIDC provider not found or disabled
|
||||
* resp-500: Failed to build the authorization URL / initiate SSO
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
// Check if auth is enabled
|
||||
if (!await isAuthEnabled()) {
|
||||
@@ -44,6 +54,17 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
};
|
||||
|
||||
// POST /api/auth/oidc/[id]/initiate - Get authorization URL without redirect
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Return the OIDC authorization URL for a provider without redirecting (JSON response)
|
||||
* path: id:integer! Numeric id of the OIDC provider (from GET /api/auth/oidc)
|
||||
* body: {redirect:string}
|
||||
* body-example: {"redirect":"/dashboard"}
|
||||
* resp-200: {url:string!}
|
||||
* resp-400: Authentication is not enabled, or the configuration id is invalid
|
||||
* resp-404: OIDC provider not found or disabled
|
||||
* resp-500: Failed to build the authorization URL / initiate SSO
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
// Check if auth is enabled
|
||||
if (!await isAuthEnabled()) {
|
||||
|
||||
@@ -3,6 +3,15 @@ import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { validateSession, testOidcConnection, isAuthEnabled } from '$lib/server/auth';
|
||||
|
||||
// POST /api/auth/oidc/[id]/test - Test OIDC connection
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Test the discovery/connection of a stored OIDC configuration by id
|
||||
* path: id:integer! Numeric id of the OIDC configuration to test (from GET /api/auth/oidc)
|
||||
* resp-200: Connection test result (success flag plus diagnostic detail from the provider)
|
||||
* resp-400: Invalid configuration id (not a number)
|
||||
* resp-403: Admin access required (auth is enabled and the caller is not an admin)
|
||||
* resp-500: Failed to test the OIDC connection
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
// When auth is disabled, allow access (for initial setup)
|
||||
// When auth is enabled, require admin
|
||||
|
||||
@@ -6,6 +6,15 @@ import { getClientIp } from '$lib/server/client-ip';
|
||||
import { safeRedirectOrRoot } from '$lib/utils/safe-redirect';
|
||||
|
||||
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Handle the OIDC redirect callback from the IdP — always responds with a 302 redirect (to the original destination on success, or to /login with an error query param on failure)
|
||||
* query: code:string Authorization code returned by the IdP
|
||||
* query: state:string Opaque state value used to correlate the request and carry the post-login redirect
|
||||
* query: error:string Error code returned by the IdP when authentication failed
|
||||
* query: error_description:string Human-readable error detail returned by the IdP
|
||||
* resp-302: Always a redirect — on success a session cookie is set and the caller is redirected to the original destination; on any error the caller is redirected to /login with an error query param
|
||||
*/
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const { url, cookies } = event;
|
||||
// Check if auth is enabled
|
||||
|
||||
@@ -5,6 +5,12 @@ import { getAuthSettings } from '$lib/server/db';
|
||||
import { isEnterprise } from '$lib/server/license';
|
||||
|
||||
// GET /api/auth/providers - Get available authentication providers
|
||||
/**
|
||||
* @openapi
|
||||
* summary: List the authentication providers offered on the login page (local, LDAP, OIDC), plus the default provider
|
||||
* resp-200: {providers:array<{id:string!, name:string!, type:string!, initiateUrl:string}>, defaultProvider:string}
|
||||
* resp-200-example: {"providers":[{"id":"local","name":"Local","type":"local"},{"id":"oidc:1","name":"Authentik","type":"oidc","initiateUrl":"/api/auth/oidc/1/initiate"}],"defaultProvider":"local"}
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
if (!(await isAuthEnabled())) {
|
||||
return json({ providers: [] });
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { validateSession, isAuthEnabled } from '$lib/server/auth';
|
||||
import { getAuthSettings } from '$lib/server/db';
|
||||
|
||||
// GET /api/auth/session - Get current session/user
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get the current session (public — used by the frontend to bootstrap auth state)
|
||||
* resp-200: {authenticated:boolean!, authEnabled:boolean!, user:{id:integer, username:string, email:string, displayName:string, avatar:string, isAdmin:boolean, provider:string}}
|
||||
* resp-200-desc: user is present only when authenticated:true
|
||||
* resp-200-example: {"authenticated":true,"authEnabled":true,"user":{"id":1,"username":"admin","email":"admin@example.com","displayName":"Admin","avatar":null,"isAdmin":true,"provider":"local"}}
|
||||
* resp-500: Unexpected error while validating the session
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
try {
|
||||
const authEnabled = await isAuthEnabled();
|
||||
|
||||
@@ -6,6 +6,15 @@ import { authorize } from '$lib/server/authorize';
|
||||
|
||||
// GET /api/auth/settings - Get auth settings
|
||||
// Public when auth is disabled, requires authentication when enabled
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get the global authentication settings (whether auth is enabled and the default provider)
|
||||
* resp-200: {authEnabled:boolean!, defaultProvider:string}
|
||||
* resp-200-example: {"authEnabled":true,"defaultProvider":"local"}
|
||||
* resp-401: Authentication required (auth is enabled and the caller is not authenticated)
|
||||
* resp-403: Permission denied (missing settings:view)
|
||||
* resp-500: Failed to read the auth settings
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -30,6 +39,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
|
||||
// PUT /api/auth/settings - Update auth settings
|
||||
// Requires authentication and settings:edit permission
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Update the global authentication settings (enabling auth requires at least one admin/user to exist)
|
||||
* body: {authEnabled:boolean, defaultProvider:string}
|
||||
* body-example: {"authEnabled":true,"defaultProvider":"local"}
|
||||
* resp-200: {authEnabled:boolean!, defaultProvider:string}
|
||||
* resp-400: Cannot enable authentication without an existing user/admin (response includes requiresUser:true)
|
||||
* resp-401: Authentication required (auth is enabled and the caller is not authenticated)
|
||||
* resp-403: Permission denied (missing settings:edit)
|
||||
* resp-500: Failed to update the auth settings
|
||||
*/
|
||||
export const PUT: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -53,6 +53,13 @@ function recordPwFailure(userId: number): void {
|
||||
|
||||
/**
|
||||
* GET /api/auth/tokens - List the current user's API tokens
|
||||
*
|
||||
* @openapi
|
||||
* summary: List the authenticated user's API tokens (never returns the hash/secret)
|
||||
* resp-200: array<{id:integer!, name:string!, tokenPrefix:string!, lastUsed:string, expiresAt:string, createdAt:string!}>
|
||||
* resp-200-example: [{"id":1,"name":"CI/CD Pipeline","tokenPrefix":"a1b2c3d4","lastUsed":"2027-01-02T08:00:00Z","expiresAt":"2027-01-01T00:00:00Z","createdAt":"2026-06-01T10:00:00Z"}]
|
||||
* resp-400: Authentication is not enabled
|
||||
* resp-401: Not authenticated
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const authEnabled = await isAuthEnabled();
|
||||
@@ -71,6 +78,19 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
|
||||
/**
|
||||
* POST /api/auth/tokens - Create a new API token
|
||||
*
|
||||
* @openapi
|
||||
* summary: Create a new API token for the authenticated user (requires a session, not a Bearer token)
|
||||
* body: {name:string!, expiresAt:string, password:string}
|
||||
* body-example: {"name":"CI/CD Pipeline","expiresAt":"2027-01-01T00:00:00Z","password":"correct horse battery staple"}
|
||||
* resp-201: {token:string!, id:integer!, tokenPrefix:string!}
|
||||
* resp-201-desc: Token created — the plaintext value is shown ONLY this once and cannot be retrieved again
|
||||
* resp-201-example: {"token":"dh_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","id":42,"tokenPrefix":"a1b2c3d4"}
|
||||
* resp-400: Invalid body, missing password for local users, or MAX_TOKENS_PER_USER (25) reached
|
||||
* resp-401: Not authenticated
|
||||
* resp-403: Token creation attempted via Bearer auth (session login required) or wrong password
|
||||
* resp-404: User record not found while confirming the password (local provider)
|
||||
* resp-429: Too many failed password confirmation attempts (rate limited)
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { cookies, request } = event;
|
||||
|
||||
@@ -8,6 +8,16 @@ import { audit } from '$lib/server/audit';
|
||||
|
||||
/**
|
||||
* DELETE /api/auth/tokens/[id] - Revoke an API token
|
||||
*
|
||||
* @openapi
|
||||
* summary: Revoke (permanently delete) one of the authenticated user's API tokens
|
||||
* path: id:integer! Token id (from GET /api/auth/tokens)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-400: Authentication not enabled, or id is not a valid integer
|
||||
* resp-401: Not authenticated
|
||||
* resp-403: Attempted via Bearer auth (a leaked token cannot revoke other tokens — session required)
|
||||
* resp-404: Token not found, or belongs to a different (non-admin) user
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { cookies, params } = event;
|
||||
|
||||
@@ -5,6 +5,13 @@ import { getAutoUpdateSettings } from '$lib/server/db';
|
||||
/**
|
||||
* Batch endpoint to get all auto-update settings for an environment.
|
||||
* Returns a map of containerName -> settings for efficient lookup.
|
||||
*
|
||||
* @openapi
|
||||
* summary: Get all enabled auto-update settings for an environment, keyed by container name
|
||||
* query: env:integer Environment ID to read settings for (from GET /api/environments)
|
||||
* resp-200: {}
|
||||
* resp-200-example: {"web-1":{"enabled":true,"scheduleType":"daily","cronExpression":"0 3 * * *","vulnerabilityCriteria":"never"}}
|
||||
* resp-500: Failed to get auto-update settings
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,15 @@ import {
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get the auto-update setting for a container (returns sensible defaults when none is stored)
|
||||
* path: containerName:string! Container name (URL-encoded)
|
||||
* query: env:integer Environment the container belongs to (from GET /api/environments)
|
||||
* resp-200: {enabled:boolean!, scheduleType:string!, cronExpression:string, vulnerabilityCriteria:string!}
|
||||
* resp-200-example: {"enabled":false,"scheduleType":"daily","cronExpression":"0 3 * * *","vulnerabilityCriteria":"never"}
|
||||
* resp-500: Failed to get auto-update setting
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -47,6 +56,17 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Create or update a container's auto-update setting; enabled=false deletes it and its schedule
|
||||
* path: containerName:string! Container name (URL-encoded)
|
||||
* query: env:integer Environment the container belongs to (from GET /api/environments)
|
||||
* body: {enabled:boolean, cronExpression:string, cron_expression:string, vulnerabilityCriteria:string, vulnerability_criteria:string}
|
||||
* body-example: {"enabled":true,"cronExpression":"0 3 * * *","vulnerabilityCriteria":"never"}
|
||||
* resp-200: {id:integer!, enabled:boolean!, scheduleType:string!, cronExpression:string, vulnerabilityCriteria:string!, deleted:boolean, success:boolean}
|
||||
* resp-200-example: {"success":true,"deleted":true}
|
||||
* resp-500: Failed to save auto-update setting
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -118,6 +138,15 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Delete a container's auto-update setting and unregister its schedule
|
||||
* path: containerName:string! Container name (URL-encoded)
|
||||
* query: env:integer Environment the container belongs to (from GET /api/environments)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-500: Failed to delete auto-update setting
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -11,6 +11,17 @@ import { registerSchedule, isValidCron } from '$lib/server/scheduler';
|
||||
import { assertStackBackupable } from '$lib/server/backups';
|
||||
import { validateRetention, retentionToStore } from '$lib/server/backups/helpers';
|
||||
|
||||
/**
|
||||
* GET /api/backup/configs - List backup configurations
|
||||
*
|
||||
* @openapi
|
||||
* summary: List backup configurations, optionally filtered by type, target and environment (enterprise callers only see configs for environments they can access)
|
||||
* description: Permission denial (403, "backups:view") is produced by the shared requireBackups route guard.
|
||||
* query: type:string Filter by backup config type (e.g. "container", "stack")
|
||||
* query: target:string Filter by the backed-up target name (container or stack name)
|
||||
* query: env:integer Filter by environment id (from GET /api/environments)
|
||||
* resp-200: Array of backup configuration objects
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
@@ -42,6 +53,19 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
return json(configs);
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/backup/configs - Create a backup configuration
|
||||
*
|
||||
* @openapi
|
||||
* summary: Create a backup configuration for a container or stack, optionally scheduled, and register its cron schedule when enabled
|
||||
* description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. A requested environmentId the caller can't access also 403s (enterprise). destinationId from GET /api/backup/destinations. environmentId from GET /api/environments.
|
||||
* body: {destinationId:integer!, targetName:string!, type:string, environmentId:integer, enabled:boolean, allVolumes:boolean, selectedVolumes:array<string>, stopBeforeBackup:boolean, schedule:string, retention:{keepLast:integer, keepDaily:integer, keepWeekly:integer, keepMonthly:integer, keepYearly:integer}, options:{}, tags:array<string>}
|
||||
* body-example: {"destinationId":3,"targetName":"nextcloud","type":"container","environmentId":1,"enabled":true,"allVolumes":true,"stopBeforeBackup":false,"schedule":"0 3 * * *","retention":{"keepDaily":7,"keepWeekly":4}}
|
||||
* resp-201: The created backup configuration object
|
||||
* resp-400: Invalid input — missing destinationId/targetName, invalid targetName, invalid cron expression, invalid retention, or a local repository paired with a remote environment / non-backupable stack
|
||||
* resp-403: Access denied to the requested environment (enterprise)
|
||||
* resp-500: Failed to create the backup configuration (persistence error)
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -11,6 +11,15 @@ import { isBackupRunning } from '$lib/server/backups';
|
||||
import { validateRetention, retentionToStore, resolveEnabledOnScheduleChange } from '$lib/server/backups/helpers';
|
||||
import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards';
|
||||
|
||||
/**
|
||||
* GET /api/backup/configs/{id} - Get a single backup configuration
|
||||
*
|
||||
* @openapi
|
||||
* summary: Fetch a single backup configuration by id, enforcing environment-scoped access
|
||||
* description: Permission ("backups:view") and environment-access denials (403) and not-found (404) are produced by the shared route guards.
|
||||
* path: id:integer! Backup configuration id (from GET /api/backup/configs)
|
||||
* resp-200: The backup configuration object
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
@@ -24,6 +33,20 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
return json(gated.config);
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /api/backup/configs/{id} - Update a backup configuration
|
||||
*
|
||||
* @openapi
|
||||
* summary: Update a backup configuration and re-register or remove its cron schedule accordingly
|
||||
* description: Permission ("backups:manage") and environment-access denials (403) and not-found (404) are produced by the shared route guards. The environment is fixed at creation and cannot be changed here. destinationId from GET /api/backup/destinations.
|
||||
* path: id:integer! Backup configuration id (from GET /api/backup/configs)
|
||||
* body: {destinationId:integer, enabled:boolean, allVolumes:boolean, selectedVolumes:array<string>, stopBeforeBackup:boolean, schedule:string, retention:{keepLast:integer, keepDaily:integer, keepWeekly:integer, keepMonthly:integer, keepYearly:integer}, options:{}, tags:array<string>}
|
||||
* body-example: {"schedule":"0 4 * * *","enabled":true,"stopBeforeBackup":true,"retention":{"keepDaily":14}}
|
||||
* resp-200: The updated backup configuration object
|
||||
* resp-400: Invalid input — invalid cron expression, invalid retention, or a local repository paired with a remote environment
|
||||
* resp-409: Cannot change the destination while a backup is running for this config
|
||||
* resp-500: Update failed (persistence error)
|
||||
*/
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
@@ -101,6 +124,17 @@ export const PUT: RequestHandler = async (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/backup/configs/{id} - Delete a backup configuration
|
||||
*
|
||||
* @openapi
|
||||
* summary: Delete a backup configuration and unregister its schedule
|
||||
* description: Permission ("backups:manage") and environment-access denials (403) and not-found (404) are produced by the shared route guards.
|
||||
* path: id:integer! Backup configuration id (from GET /api/backup/configs)
|
||||
* resp-200: Returns { success: true } once the configuration is deleted
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-409: A backup is currently running for this config — stop it before deleting
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -6,6 +6,15 @@ import { createJobResponse } from '$lib/server/sse';
|
||||
import { auditBackup } from '$lib/server/audit';
|
||||
import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards';
|
||||
|
||||
/**
|
||||
* POST /api/backup/configs/{id}/run - Run a backup now (streamed job)
|
||||
*
|
||||
* @openapi
|
||||
* summary: Trigger a manual backup run for a configuration, streaming progress as a Server-Sent Events job
|
||||
* description: Returns a text/event-stream that emits `progress` events during the run and a final `result` event. Permission ("backups:manage") and environment-access denials (403) and not-found (404) are produced by the shared route guards.
|
||||
* path: id:integer! Backup configuration id (from GET /api/backup/configs)
|
||||
* resp-200: Server-Sent Events stream of progress and a final result event (status "success", "warning", "skipped" or "error")
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -5,6 +5,17 @@ import { auditBackup } from '$lib/server/audit';
|
||||
import { cancelBackup } from '$lib/server/backups';
|
||||
import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards';
|
||||
|
||||
/**
|
||||
* POST /api/backup/configs/{id}/stop - Cancel a running backup
|
||||
*
|
||||
* @openapi
|
||||
* summary: Cancel the in-flight backup for a configuration
|
||||
* description: Permission ("backups:manage") and environment-access denials (403) and not-found (404) are produced by the shared route guards.
|
||||
* path: id:integer! Backup configuration id (from GET /api/backup/configs)
|
||||
* resp-200: Returns { success: true, stopped } where "stopped" indicates whether a running backup helper was actually killed
|
||||
* resp-200-example: {"success":true,"stopped":true}
|
||||
* resp-500: Failed to cancel the backup (internal error)
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -36,6 +36,14 @@ function prepareDestination(dest: any, opts: { includeEnvVars: boolean }): any {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/backup/destinations - List backup destinations
|
||||
*
|
||||
* @openapi
|
||||
* summary: List all backup destinations (restic repositories); the password is stripped and cloud-credential env vars are omitted from the list view
|
||||
* description: Permission denial (403, "backups:view") is produced by the shared requireBackups route guard.
|
||||
* resp-200: Array of backup destination objects without secrets (no password, no envVars)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
@@ -46,6 +54,19 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
return json(destinations.map(d => prepareDestination(d, { includeEnvVars: false })));
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/backup/destinations - Create a backup destination
|
||||
*
|
||||
* @openapi
|
||||
* summary: Create a restic backup destination, auto-initialize and test the repository, and register its default maintenance schedules
|
||||
* description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard.
|
||||
* body: {name:string!, repository:string!, password:string!, envVars:{}, flags:string, hostPath:string, policies:string}
|
||||
* body-example: {"name":"S3 Offsite","repository":"s3:s3.amazonaws.com/my-bucket/restic","password":"***","envVars":{"AWS_ACCESS_KEY_ID":"***","AWS_SECRET_ACCESS_KEY":"***"}}
|
||||
* resp-201: The created backup destination object (includes decrypted envVars since the caller just supplied them; password is stripped)
|
||||
* resp-400: Invalid input — missing name/repository/password, unsupported/SSRF-blocked repository, invalid restic flags, or an invalid cron schedule in the policies
|
||||
* resp-409: A destination with this name already exists
|
||||
* resp-500: Failed to create the destination (persistence error)
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -37,6 +37,17 @@ function prepareDestination(dest: any, includeSecrets: boolean): any {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/backup/destinations/{id} - Get a single backup destination
|
||||
*
|
||||
* @openapi
|
||||
* summary: Fetch a single backup destination; decrypted cloud-credential env vars are only included for callers who can manage backups, and the password is always stripped
|
||||
* description: Permission denial (403, "backups:view") is produced by the shared requireBackups route guard.
|
||||
* path: id:integer! Backup destination id (from GET /api/backup/destinations)
|
||||
* resp-200: The backup destination object (envVars included only for "backups:manage" callers; password always stripped)
|
||||
* resp-400: Invalid id (not a number)
|
||||
* resp-404: Destination not found
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
@@ -54,6 +65,21 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
return json(prepareDestination(destination, canManage));
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /api/backup/destinations/{id} - Update a backup destination
|
||||
*
|
||||
* @openapi
|
||||
* summary: Update a backup destination, re-validating repository and flags when supplied and re-registering maintenance schedules when policies change
|
||||
* description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard.
|
||||
* path: id:integer! Backup destination id (from GET /api/backup/destinations)
|
||||
* body: {name:string, repository:string, password:string, envVars:{}, flags:string, hostPath:string, policies:string}
|
||||
* body-example: {"name":"S3 Offsite (renamed)","policies":"{\"pruneEnabled\":true,\"pruneSchedule\":\"0 0 1 * *\"}"}
|
||||
* resp-200: The updated backup destination object (password stripped, envVars echoed back to the managing caller)
|
||||
* resp-400: Invalid input — invalid id, unsupported/SSRF-blocked repository, invalid restic flags, invalid policy cron, or switching to a local repository used by a remote-environment config
|
||||
* resp-404: Destination not found
|
||||
* resp-409: A backup using this destination is currently running, or a destination with the new name already exists
|
||||
* resp-500: Update failed (persistence error)
|
||||
*/
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
@@ -140,6 +166,19 @@ export const PUT: RequestHandler = async (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/backup/destinations/{id} - Delete a backup destination
|
||||
*
|
||||
* @openapi
|
||||
* summary: Delete a backup destination and unregister all its maintenance and dependent backup-config schedules
|
||||
* description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard.
|
||||
* path: id:integer! Backup destination id (from GET /api/backup/destinations)
|
||||
* resp-200: Returns { success: true } once the destination is deleted
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-400: Invalid id (not a number)
|
||||
* resp-404: Destination not found
|
||||
* resp-409: A backup using this destination is currently running — try again once it finishes
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -6,6 +6,19 @@ import { auditBackupDestination } from '$lib/server/audit';
|
||||
import { getBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db';
|
||||
import { initRepository } from '$lib/server/backups';
|
||||
|
||||
/**
|
||||
* POST /api/backup/destinations/{id}/init - Initialize the restic repository
|
||||
*
|
||||
* @openapi
|
||||
* summary: Initialize the restic repository for a destination and record the resulting test status
|
||||
* description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard.
|
||||
* path: id:integer! Backup destination id (from GET /api/backup/destinations)
|
||||
* resp-200: Returns { success: true, message } when the repository is initialized
|
||||
* resp-200-example: {"success":true,"message":"Repository initialized"}
|
||||
* resp-400: Invalid id (not a number)
|
||||
* resp-404: Destination not found
|
||||
* resp-500: Repository initialization failed; returns { success: false, error }
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -9,14 +9,18 @@ import { rotateDestinationPassword } from '$lib/server/backups';
|
||||
/**
|
||||
* Rotate the restic repository password for a destination.
|
||||
*
|
||||
* Body: { currentPassword: string, newPassword: string }
|
||||
*
|
||||
* 200 — rotated, DB updated
|
||||
* 400 — input invalid or current password wrong
|
||||
* 404 — destination not found
|
||||
* 409 — restic rotated but DB write failed (manual recovery needed; details
|
||||
* include dbOutOfSync: true)
|
||||
* 500 — restic call failed for an unrelated reason
|
||||
* @openapi
|
||||
* summary: Rotate the restic repository password for a destination and persist the new password
|
||||
* description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard.
|
||||
* path: id:integer! Backup destination id (from GET /api/backup/destinations)
|
||||
* body: {currentPassword:string!, newPassword:string!}
|
||||
* body-example: {"currentPassword":"***","newPassword":"***"}
|
||||
* resp-200: Returns { success: true } once the password is rotated and the database is updated
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-400: Invalid input — invalid id, missing passwords, or the current password is incorrect
|
||||
* resp-404: Destination not found
|
||||
* resp-409: restic rotated the key but the database write failed (manual recovery needed; the response includes dbOutOfSync:true)
|
||||
* resp-500: restic call failed for an unrelated reason
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
|
||||
@@ -7,6 +7,20 @@ import { getBackupDestination, getBackupConfigs } from '$lib/server/db';
|
||||
import { runRepoTask } from '$lib/server/backups';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
/**
|
||||
* POST /api/backup/destinations/{id}/task - Run a repository maintenance task
|
||||
*
|
||||
* @openapi
|
||||
* summary: Run a restic repository maintenance task (unlock, check, prune, stats, repair-index or repair-snapshots) against a destination
|
||||
* description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. Streamed via createJobResponse — a task failure never surfaces as an HTTP 500; it comes back as a 200 with a failed job result (JSON-preferring callers) or a job/SSE 'result' event with success:false (streaming callers).
|
||||
* path: id:integer! Backup destination id (from GET /api/backup/destinations)
|
||||
* body: {task:string!}
|
||||
* body-example: {"task":"check"}
|
||||
* resp-200: The task result object — { success: true, ... } on success, or { success: false, error } when the task itself fails
|
||||
* resp-400: Invalid input — invalid destination id, or an unknown task name (must be one of unlock, check, prune, stats, repair-index, repair-snapshots)
|
||||
* resp-403: No access to an environment whose configs use this destination (enterprise)
|
||||
* resp-404: Destination not found
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -8,6 +8,18 @@ import {
|
||||
} from '$lib/server/db';
|
||||
import { testRepository } from '$lib/server/backups';
|
||||
|
||||
/**
|
||||
* POST /api/backup/destinations/{id}/test - Test a saved backup destination
|
||||
*
|
||||
* @openapi
|
||||
* summary: Test connectivity to a saved backup destination's repository and update its stored test status
|
||||
* description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard.
|
||||
* path: id:integer! Backup destination id (from GET /api/backup/destinations)
|
||||
* resp-200: Test result — { success: true, status: "success" } when reachable, or { success: false, status: "needs_init" | "failed", error } otherwise
|
||||
* resp-200-example: {"success":true,"status":"success"}
|
||||
* resp-400: Invalid id (not a number)
|
||||
* resp-404: Destination not found
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'manage');
|
||||
|
||||
@@ -7,6 +7,18 @@ import { getBackupDestination } from '$lib/server/db';
|
||||
import { verifyBackup } from '$lib/server/backups';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
/**
|
||||
* POST /api/backup/destinations/{id}/verify - Verify backup integrity (streamed job)
|
||||
*
|
||||
* @openapi
|
||||
* summary: Verify the integrity of a destination's backups by reading a data subset, streaming progress as a Server-Sent Events job
|
||||
* description: Returns a text/event-stream that emits `progress` events and a final `result` event. Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard.
|
||||
* path: id:integer! Backup destination id (from GET /api/backup/destinations)
|
||||
* body: {dataSubset:string}
|
||||
* body-example: {"dataSubset":"5%"}
|
||||
* resp-200: Server-Sent Events stream of progress and a final result event ({ success, ... })
|
||||
* resp-400: Invalid destination id (not a number)
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -11,6 +11,16 @@ import { withGcsCredFile } from '$lib/server/backups/restic';
|
||||
* Test a backup destination configuration.
|
||||
* - With `destinationId`: uses saved credentials from DB
|
||||
* - Without: uses inline credentials from request body (for testing before save)
|
||||
*
|
||||
* @openapi
|
||||
* summary: Test a backup repository — either a saved destination (by destinationId) or inline credentials supplied for a not-yet-saved destination
|
||||
* description: Provide `destinationId` to test a saved destination using its stored credentials, or omit it and supply `repository`/`password`/`envVars` to test before saving. Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. destinationId from GET /api/backup/destinations.
|
||||
* body: {destinationId:integer, repository:string, password:string, envVars:{}}
|
||||
* body-example: {"repository":"s3:s3.amazonaws.com/my-bucket/restic","password":"***","envVars":{"AWS_ACCESS_KEY_ID":"***","AWS_SECRET_ACCESS_KEY":"***"}}
|
||||
* resp-200: Test result — { success: true } when the repository is reachable, or { success: false, needsInit?, error } otherwise
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-400: Invalid input — missing repository/password for an inline test, or an unsupported/SSRF-blocked repository
|
||||
* resp-404: Destination not found (when testing a saved destinationId)
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -9,6 +9,12 @@ import { getInstanceId } from '$lib/server/backups/identity';
|
||||
// abort the sync `restic snapshots` at ~15s) and headers don't survive polling. The
|
||||
// client fetches the id here once and matches it against each snapshot's
|
||||
// dockhand:instance= tag to tell own snapshots from foreign orphans (#1351).
|
||||
/**
|
||||
* @openapi
|
||||
* summary: This install's stable backup instance id, used to tell own snapshots from foreign orphans
|
||||
* description: Requires backups:view; 403 (no permission) and 404 (backup feature disabled) come from the shared requireBackups guard.
|
||||
* resp-200: {instanceId:string!}
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
|
||||
@@ -8,6 +8,18 @@ import { createJobResponse } from '$lib/server/sse';
|
||||
import { auditRestore } from '$lib/server/audit';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
|
||||
/**
|
||||
* POST /api/backup/restore - Restore a snapshot (streamed job)
|
||||
*
|
||||
* @openapi
|
||||
* summary: Restore a backup snapshot in-place or to a new location, streaming progress as a Server-Sent Events job
|
||||
* description: Returns a text/event-stream that emits `progress` events and a final `result` event. An in-place restore is destructive and requires confirmOverwrite:true. Authorization and snapshot-environment access are checked before the request shape is validated. destinationId from GET /api/backup/destinations. snapshotId from GET /api/backup/snapshots. environmentId from GET /api/environments.
|
||||
* body: {destinationId:integer!, snapshotId:string!, mode:string, targetType:string, volumes:array<string>, environmentId:integer, confirmOverwrite:boolean, targetPath:string, targetName:string, postRestore:string, volumeDestinations:array<{}>}
|
||||
* body-example: {"destinationId":3,"snapshotId":"a1b2c3d4","mode":"new-location","targetType":"container","targetName":"nextcloud-restored","environmentId":1,"confirmOverwrite":false}
|
||||
* resp-200: Server-Sent Events stream of progress and a final result event (status "success" or "error")
|
||||
* resp-400: Invalid restore request; the response "issues" array lists the validation problems
|
||||
* resp-403: Permission denied — requires "backups:manage", or no access to the target or snapshot-owning environment
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -7,6 +7,16 @@ import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { jobResult } from '$lib/server/sse';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Preview what a restore would write (resolved targets + volume/stack contents)
|
||||
* description: With includeTargets it also resolves the concrete restore targets (host paths / volumes); without it, returns the metadata-only preview the initial modal load relies on.
|
||||
* body: {destinationId:integer!, snapshotId:string!, includeTargets:boolean, targetEnvId:integer}
|
||||
* resp-200: object
|
||||
* resp-200-desc: The snapshot preview, optionally with a resolved targets list
|
||||
* resp-400: Missing required fields (destinationId, snapshotId)
|
||||
* resp-403: Permission denied (needs backups:view) or access denied to the target environment
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const rbacDenied = await requireBackups(auth, 'manage');
|
||||
|
||||
@@ -11,6 +11,17 @@ import { isValidSnapshotId } from '$lib/server/backups/helpers';
|
||||
* the helper container was never targeted by any stop path. Kills the restore
|
||||
* helper for the given snapshotId (or all restore helpers if omitted) so restic
|
||||
* exits and runRestore's failure/cleanup path runs.
|
||||
*
|
||||
* @openapi
|
||||
* summary: Cancel a running restore — kills the restore helper for a given snapshotId, or all restore helpers when snapshotId is omitted
|
||||
* description: snapshotId from GET /api/backup/snapshots. environmentId from GET /api/environments.
|
||||
* body: {snapshotId:string, environmentId:integer}
|
||||
* body-example: {"snapshotId":"a1b2c3d4"}
|
||||
* resp-200: Returns { success: true, stopped } where "stopped" indicates whether a running restore helper was actually killed
|
||||
* resp-200-example: {"success":true,"stopped":true}
|
||||
* resp-400: Invalid snapshotId
|
||||
* resp-403: Permission denied — requires "backups:manage", or no access to the targeted environment
|
||||
* resp-500: Failed to cancel the restore (internal error)
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies } = event;
|
||||
|
||||
@@ -7,6 +7,17 @@ import { listSnapshots } from '$lib/server/backups';
|
||||
import { filterSnapshotsByEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { jobResult } from '$lib/server/sse';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: List restic snapshots for a backup config or a single destination (job-polled)
|
||||
* description: Pass configId to list across the config's destinations, or destinationId for one. Job-polled so a reverse proxy can't abort the sync restic snapshots read at ~15s; match each snapshot's instance tag against GET /api/backup/instance to tell own snapshots from foreign orphans.
|
||||
* query: configId:integer Backup config id (mutually exclusive with destinationId)
|
||||
* query: destinationId:integer Single destination id (mutually exclusive with configId)
|
||||
* query: allDestinations:boolean With configId, include every destination of the config
|
||||
* resp-400: Invalid or missing configId/destinationId
|
||||
* resp-403: Permission denied (needs backups:view) or environment access denied
|
||||
* resp-404: Backup config not found
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
|
||||
@@ -8,6 +8,21 @@ import { getBackupDestination } from '$lib/server/db';
|
||||
import { forgetSnapshot } from '$lib/server/backups';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
|
||||
/**
|
||||
* DELETE /api/backup/snapshots/{id} - Forget (delete) a snapshot
|
||||
*
|
||||
* @openapi
|
||||
* summary: Forget and prune a single snapshot from a destination (destructive restic forget --prune), with a server-authoritative environment access gate
|
||||
* path: id:string! Restic snapshot id to forget (from GET /api/backup/snapshots)
|
||||
* query: destinationId:integer! Destination holding the snapshot (required) (from GET /api/backup/destinations)
|
||||
* query: env:integer Optional environment id for an early enterprise access check; the authoritative gate resolves the snapshot's owning environment server-side (from GET /api/environments)
|
||||
* resp-200: Returns { success: true } once the snapshot is forgotten and pruned
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-400: Missing/invalid destinationId or an invalid snapshot id
|
||||
* resp-403: Permission denied — requires "backups:manage", or no access to the snapshot's owning environment
|
||||
* resp-404: Snapshot not found for this installation (not owned)
|
||||
* resp-500: Failed to forget the snapshot (restic error)
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -7,6 +7,17 @@ import { browseSnapshot } from '$lib/server/backups';
|
||||
import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { jobResult } from '$lib/server/sse';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: List directory entries at a path inside a snapshot (job-polled)
|
||||
* description: Job-polled so a proxy can't abort the restic read at ~15s.
|
||||
* path: id:string The restic snapshot id
|
||||
* query: destinationId:integer Destination the snapshot lives in
|
||||
* query: path:string Directory path inside the snapshot to list
|
||||
* query: env:integer Environment context for access checks
|
||||
* resp-400: Missing/invalid destinationId
|
||||
* resp-403: Permission denied (needs backups:view) or environment access denied
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
|
||||
@@ -8,6 +8,19 @@ import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { parseSnapshotLayout, redactSnapshotLayout } from '$lib/server/backups/snapshot-layout';
|
||||
import { jobResult } from '$lib/server/sse';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Read a single file (or archive) out of a snapshot (job-polled restic dump)
|
||||
* description: Job-polled so a proxy can't abort the restic dump at ~15s. type selects the payload (inline preview vs archive); download requests a raw file/directory tar. A raw metadata.json download is refused (403) - it would bypass redaction.
|
||||
* path: id:string The restic snapshot id
|
||||
* query: destinationId:integer Destination the snapshot lives in
|
||||
* query: path:string Path inside the snapshot (must resolve under /volumes or /metadata)
|
||||
* query: type:string Payload kind (e.g. inline preview vs archive)
|
||||
* query: download:string When set, stream the file/directory as a tar
|
||||
* resp-400: Missing/invalid destinationId or path
|
||||
* resp-403: Permission denied (needs backups:view), or a raw metadata.json download was refused
|
||||
* resp-500: The restic dump failed
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
|
||||
@@ -8,6 +8,14 @@ import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { redactSnapshotLayout } from '$lib/server/backups/snapshot-layout';
|
||||
import { jobResult } from '$lib/server/sse';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: The snapshot's redacted metadata layout (job-polled restic dump)
|
||||
* description: Job-polled so a proxy can't abort the restic dump at ~15s. The layout is redacted (stack secrets and container Config.Env/Labels stripped) before it leaves the server.
|
||||
* path: id:string The restic snapshot id
|
||||
* query: destinationId:integer Destination the snapshot lives in
|
||||
* resp-400: Missing/invalid destinationId
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
|
||||
@@ -7,6 +7,15 @@ import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||
import { jobResult } from '$lib/server/sse';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Diff two snapshots' file trees (added/changed/removed) - job-polled
|
||||
* description: Job-polled so a proxy can't abort the restic read at ~15s.
|
||||
* query: destinationId:integer Destination both snapshots live in
|
||||
* query: snapshotA:string The baseline snapshot id
|
||||
* query: snapshotB:string The snapshot to compare against the baseline
|
||||
* resp-400: Missing destinationId, snapshotA, or snapshotB
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
|
||||
@@ -9,6 +9,15 @@ import { probeStackDir } from '$lib/server/backups';
|
||||
// create/edit backup dialog can show the resolved host path + let the user pick which
|
||||
// stack-dir entries to back up. Returns { kind:'listed', hostPath, entries } or
|
||||
// { kind:'unknown', reason } (never throws for an operational failure).
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Probe the target host and list the actual contents of a stack's directory, for the backup create/edit dialog's file picker
|
||||
* query: target:string! Target stack name
|
||||
* query: env:integer Environment ID the stack belongs to (from GET /api/environments)
|
||||
* resp-200: The stack directory probe result — kind "listed"/"tar" with hostPath/localStackDir and entries, or kind "helper-failed"/"unknown" with a reason (never throws for an operational failure)
|
||||
* resp-400: target is required
|
||||
* resp-403: Permission denied (requires backups:view), or no access to the target's environment (enterprise)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
|
||||
@@ -8,6 +8,15 @@ import { previewStackBackupPath } from '$lib/server/backups';
|
||||
// Preview WHERE a stack's directory will be captured from on the host (the resolved host
|
||||
// bind path), or `unknown` with a reason - so the create-schedule dialog can show it up front
|
||||
// and warn before scheduling a backup that would hard-fail the stack-dir probe.
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Preview where a stack's directory will be captured from on the host, without running a backup
|
||||
* query: target:string! Target stack name
|
||||
* query: env:integer Environment ID the stack belongs to (from GET /api/environments)
|
||||
* resp-200: The resolved capture path preview — kind "candidate" with hostPath, kind "tar" with composeFile only, or kind "unknown" with a reason
|
||||
* resp-400: target is required
|
||||
* resp-403: Permission denied (requires backups:view), or no access to the target's environment (enterprise)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const denied = await requireBackups(auth, 'view');
|
||||
|
||||
@@ -126,8 +126,18 @@ async function processWithConcurrency<T>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified batch operations endpoint (job pattern).
|
||||
* Handles bulk operations for containers, images, volumes, networks, and stacks.
|
||||
* Unified batch operations endpoint (job pattern) for containers, images,
|
||||
* volumes, networks, and stacks.
|
||||
*
|
||||
* @openapi
|
||||
* summary: Run a bulk operation (start/stop/restart/pause/unpause/remove/down, depending on entity type) across many containers, images, volumes, networks, or stacks; clients that send Accept application/json get a synchronous summary, others get a jobId and progress over Server-Sent Events
|
||||
* query: env:integer Environment ID the entities belong to (from GET /api/environments)
|
||||
* body: {operation:string!, entityType:string!, items:array<{id:string!, name:string!}>!, options:{force:boolean, removeVolumes:boolean}}
|
||||
* body-example: {"operation":"restart","entityType":"containers","items":[{"id":"abc123","name":"web"}],"options":{"force":false}}
|
||||
* resp-200: Either a synchronous completion summary ({type:"complete", summary:{total, success, failed}}) or an async job handle ({jobId}) whose progress streams over SSE
|
||||
* resp-200-example: {"jobId":"job_abc123"}
|
||||
* resp-400: Invalid JSON body, invalid entity type, invalid operation for the entity type, or empty items array
|
||||
* resp-403: Permission denied for the requested operation, or environment access denied on enterprise
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -2,6 +2,14 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import changelog from '$lib/data/changelog.json';
|
||||
|
||||
/**
|
||||
* GET /api/changelog - Dockhand changelog
|
||||
*
|
||||
* @openapi
|
||||
* summary: Return the bundled Dockhand changelog data
|
||||
* resp-200: array<{version:string!, date:string, changes:array<string>}>
|
||||
* resp-200-example: [{"version":"1.0.39","date":"2026-06-01","changes":["Fixed image export streaming"]}]
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
return json(changelog);
|
||||
};
|
||||
@@ -4,6 +4,14 @@ import { getConfigSets, createConfigSet } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditConfigSet } from '$lib/server/audit';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: List all config sets (reusable bundles of env vars, labels, ports, volumes, and runtime defaults)
|
||||
* resp-200: array<{id:integer!, name:string!, description:string, envVars:array<{key:string!, value:string!}>, labels:array<{key:string!, value:string!}>, ports:array<{hostPort:string!, containerPort:string!, protocol:string!}>, volumes:array<{hostPath:string!, containerPath:string!, mode:string!}>, networkMode:string!, restartPolicy:string!, createdAt:string!, updatedAt:string!}>
|
||||
* resp-200-example: [{"id":1,"name":"web-defaults","description":"Defaults for web stacks","envVars":[{"key":"TZ","value":"UTC"}],"labels":[],"ports":[],"volumes":[],"networkMode":"bridge","restartPolicy":"unless-stopped","createdAt":"2026-06-01T10:00:00Z","updatedAt":"2026-06-01T10:00:00Z"}]
|
||||
* resp-403: Permission denied (requires configsets:view)
|
||||
* resp-500: Failed to fetch config sets
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('configsets', 'view')) {
|
||||
@@ -19,6 +27,18 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Create a new config set
|
||||
* body: {name:string!, description:string, envVars:array<{key:string!, value:string!}>, labels:array<{key:string!, value:string!}>, ports:array<{hostPort:string!, containerPort:string!, protocol:string!}>, volumes:array<{hostPath:string!, containerPath:string!, mode:string!}>, networkMode:string, restartPolicy:string}
|
||||
* body-example: {"name":"web-defaults","description":"Defaults for web stacks","envVars":[{"key":"TZ","value":"UTC"}],"networkMode":"bridge","restartPolicy":"unless-stopped"}
|
||||
* resp-201: {id:integer!, name:string!, description:string, envVars:array<{key:string!, value:string!}>, labels:array<{key:string!, value:string!}>, ports:array<{hostPort:string!, containerPort:string!, protocol:string!}>, volumes:array<{hostPath:string!, containerPath:string!, mode:string!}>, networkMode:string!, restartPolicy:string!, createdAt:string!, updatedAt:string!}
|
||||
* resp-201-desc: Config set created
|
||||
* resp-201-example: {"id":1,"name":"web-defaults","description":"Defaults for web stacks","envVars":[{"key":"TZ","value":"UTC"}],"labels":[],"ports":[],"volumes":[],"networkMode":"bridge","restartPolicy":"unless-stopped","createdAt":"2026-06-01T10:00:00Z","updatedAt":"2026-06-01T10:00:00Z"}
|
||||
* resp-400: Name is required, or a config set with this name already exists
|
||||
* resp-403: Permission denied (requires configsets:create)
|
||||
* resp-500: Failed to create config set
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -5,6 +5,17 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { auditConfigSet } from '$lib/server/audit';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Fetch a single config set by its numeric ID
|
||||
* path: id:integer! Config set ID (from GET /api/config-sets)
|
||||
* resp-200: {id:integer!, name:string!, description:string, envVars:array<{key:string!, value:string!}>, labels:array<{key:string!, value:string!}>, ports:array<{hostPort:string!, containerPort:string!, protocol:string!}>, volumes:array<{hostPath:string!, containerPath:string!, mode:string!}>, networkMode:string!, restartPolicy:string!, createdAt:string!, updatedAt:string!}
|
||||
* resp-200-example: {"id":1,"name":"web-defaults","description":"Defaults for web stacks","envVars":[{"key":"TZ","value":"UTC"}],"labels":[],"ports":[],"volumes":[],"networkMode":"bridge","restartPolicy":"unless-stopped","createdAt":"2026-06-01T10:00:00Z","updatedAt":"2026-06-01T10:00:00Z"}
|
||||
* resp-400: Invalid ID
|
||||
* resp-403: Permission denied (requires configsets:view)
|
||||
* resp-404: Config set not found
|
||||
* resp-500: Failed to fetch config set
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('configsets', 'view')) {
|
||||
@@ -29,6 +40,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Update an existing config set by ID
|
||||
* path: id:integer! Config set ID (from GET /api/config-sets)
|
||||
* body: {name:string, description:string, envVars:array<{key:string!, value:string!}>, labels:array<{key:string!, value:string!}>, ports:array<{hostPort:string!, containerPort:string!, protocol:string!}>, volumes:array<{hostPath:string!, containerPath:string!, mode:string!}>, networkMode:string, restartPolicy:string}
|
||||
* body-example: {"name":"web-defaults","networkMode":"host","restartPolicy":"always"}
|
||||
* resp-200: {id:integer!, name:string!, description:string, envVars:array<{key:string!, value:string!}>, labels:array<{key:string!, value:string!}>, ports:array<{hostPort:string!, containerPort:string!, protocol:string!}>, volumes:array<{hostPath:string!, containerPath:string!, mode:string!}>, networkMode:string!, restartPolicy:string!, createdAt:string!, updatedAt:string!}
|
||||
* resp-200-example: {"id":1,"name":"web-defaults","description":"Defaults for web stacks","envVars":[],"labels":[],"ports":[],"volumes":[],"networkMode":"host","restartPolicy":"always","createdAt":"2026-06-01T10:00:00Z","updatedAt":"2026-06-02T12:00:00Z"}
|
||||
* resp-400: Invalid ID, or a config set with this name already exists
|
||||
* resp-403: Permission denied (requires configsets:edit)
|
||||
* resp-404: Config set not found
|
||||
* resp-500: Failed to update config set
|
||||
*/
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
@@ -81,6 +105,17 @@ export const PUT: RequestHandler = async (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Delete a config set by ID
|
||||
* path: id:integer! Config set ID (from GET /api/config-sets)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-400: Invalid ID
|
||||
* resp-403: Permission denied (requires configsets:delete)
|
||||
* resp-404: Config set not found
|
||||
* resp-500: Failed to delete config set
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -6,6 +6,16 @@ import { hasEnvironments } from '$lib/server/db';
|
||||
import { isHiddenByLabel } from '$lib/server/container-labels';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: List containers for one environment (dockhand.hidden=true label is filtered out)
|
||||
* query: env:integer Environment id — an empty array is returned if omitted (from GET /api/environments)
|
||||
* query: all:boolean Include stopped containers (default true; pass "false" to only show running ones)
|
||||
* resp-200: array<{id:string!, name:string!, image:string!, state:string!, status:string!}>
|
||||
* resp-200-example: [{"id":"a1b2c3d4e5f6","name":"immich_server","image":"ghcr.io/immich-app/immich-server:latest","state":"running","status":"Up 2 hours (healthy)"}]
|
||||
* resp-403: Permission denied, or access denied to this environment
|
||||
* resp-404: Environment not found
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -51,6 +61,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Create a new container in an environment (auto-pulls the image first if it isn't present locally), optionally starting it right away
|
||||
* query: env:integer Environment id (from GET /api/environments)
|
||||
* body: {name:string!, image:string!, ports:{}, volumes:{}, volumeBinds:array<string>, env:array<string>, labels:{}, cmd:array<string>, entrypoint:array<string>, workingDir:string, restartPolicy:string, restartMaxRetries:integer, networkMode:string, additionalNetworks:array<string>, networkAliases:array<string>, networkIpv4Address:string, networkIpv6Address:string, startAfterCreate:boolean}
|
||||
* body-example: {"name":"my-app","image":"nginx:latest","startAfterCreate":true}
|
||||
* resp-200: {success:boolean!, id:string!, imagePulled:boolean}
|
||||
* resp-403: Permission denied, or access denied to this environment
|
||||
* resp-500: Container creation failed, or the image could not be pulled
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, url, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -12,6 +12,18 @@ import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* GET /api/containers/{id} - Inspect a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Return the full Docker inspect payload for a single container
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: The raw Docker inspect object for the container
|
||||
* resp-403: Permission denied, or (enterprise) no access to the requested environment
|
||||
* resp-404: Container not found
|
||||
* resp-500: Failed to inspect the container (e.g. it no longer exists or the daemon is unreachable)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
@@ -64,6 +76,20 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/containers/{id} - Remove a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Remove a container and clean up its auto-update schedule and pending-update record
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* query: force:boolean Force removal of a running container (Docker force flag)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-403: Permission denied, or (enterprise) no access to the requested environment
|
||||
* resp-404: Container not found
|
||||
* resp-500: Failed to remove the container
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
|
||||
@@ -11,6 +11,20 @@ import { createExec, getDockerConnectionInfo } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
/**
|
||||
* POST /api/containers/{id}/exec - Create an exec instance for terminal attachment
|
||||
*
|
||||
* @openapi
|
||||
* summary: Create a Docker exec instance in a container and return its ID plus the Docker connection info for a terminal WebSocket
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: envId:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* body: {shell:string, user:string}
|
||||
* body-example: {"shell":"/bin/sh","user":"root"}
|
||||
* resp-200: {execId:string!, connectionInfo:{type:string!, host:string, port:integer}}
|
||||
* resp-401: Not authenticated
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to create the exec instance
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -4,6 +4,20 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* GET /api/containers/{id}/files - List a directory inside a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: List the contents of a directory inside a container's filesystem
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* query: path:string Absolute directory path inside the container (default "/")
|
||||
* query: simpleLs:boolean Use a lightweight `ls` listing instead of a full stat of each entry
|
||||
* resp-200: Directory listing (entries with name, type, size and permission metadata)
|
||||
* resp-403: Permission denied
|
||||
* resp-404: Container not found
|
||||
* resp-500: Failed to list the directory
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -4,6 +4,22 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* POST /api/containers/{id}/files/chmod - Change permissions of a path in a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Change the mode (permissions) of a file or directory inside a container (requires the 'exec' permission)
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* body: {path:string!, mode:string!, recursive:boolean}
|
||||
* body-example: {"path":"/app/entrypoint.sh","mode":"755","recursive":false}
|
||||
* resp-200: {success:boolean!, path:string!, mode:string!, recursive:boolean!}
|
||||
* resp-200-example: {"success":true,"path":"/app/entrypoint.sh","mode":"755","recursive":false}
|
||||
* resp-400: Path or mode missing, an invalid chmod mode, or the container is not running
|
||||
* resp-403: Permission denied, read-only file system, or operation not permitted
|
||||
* resp-404: Path not found
|
||||
* resp-500: Failed to change permissions
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -7,6 +7,21 @@ import type { RequestHandler } from './$types';
|
||||
// Max file size for reading (1MB)
|
||||
const MAX_FILE_SIZE = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* GET /api/containers/{id}/files/content - Read a file's content from a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Read the content of a single file inside a container (max 1 MB)
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* query: path:string! Absolute file path inside the container
|
||||
* resp-200: {content:string!, path:string!}
|
||||
* resp-400: Path is missing, the target is a directory, or the container is not running
|
||||
* resp-403: Permission denied to read the file
|
||||
* resp-404: File not found
|
||||
* resp-413: File is larger than the 1 MB read limit
|
||||
* resp-500: Failed to read the file
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
@@ -63,6 +78,25 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /api/containers/{id}/files/content - Write a file's content in a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Overwrite the content of a file inside a container (max 1 MB, requires the 'exec' permission)
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* query: path:string! Absolute file path inside the container
|
||||
* body: {content:string!}
|
||||
* body-example: {"content":"server {\n listen 80;\n}\n"}
|
||||
* resp-200: {success:boolean!, path:string!}
|
||||
* resp-200-example: {"success":true,"path":"/etc/nginx/nginx.conf"}
|
||||
* resp-400: Path is missing, content is missing/not a string, or the container is not running
|
||||
* resp-403: Permission denied, or the target file system is read-only
|
||||
* resp-404: Target directory not found
|
||||
* resp-413: Content is larger than the 1 MB write limit
|
||||
* resp-500: Failed to write the file
|
||||
* resp-507: No space left on device
|
||||
*/
|
||||
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -4,6 +4,23 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* POST /api/containers/{id}/files/create - Create a file or directory in a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Create an empty file or a directory inside a container (requires the 'exec' permission)
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* body: {path:string!, type:string!}
|
||||
* body-example: {"path":"/app/data","type":"directory"}
|
||||
* resp-200: {success:boolean!, path:string!, type:string!}
|
||||
* resp-200-example: {"success":true,"path":"/app/data","type":"directory"}
|
||||
* resp-400: Path missing, type not "file" or "directory", or the container is not running
|
||||
* resp-403: Permission denied
|
||||
* resp-404: Parent directory not found
|
||||
* resp-409: Path already exists
|
||||
* resp-500: Failed to create the path
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -4,6 +4,21 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* DELETE /api/containers/{id}/files/delete - Delete a path in a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Delete a file or directory inside a container (requires the 'exec' permission)
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* query: path:string! Absolute path inside the container to delete
|
||||
* resp-200: {success:boolean!, path:string!}
|
||||
* resp-200-example: {"success":true,"path":"/app/tmp/old.log"}
|
||||
* resp-400: Path missing, a refused critical-path delete, a non-empty directory, or the container is not running
|
||||
* resp-403: Permission denied, or read-only file system
|
||||
* resp-404: Path not found
|
||||
* resp-500: Failed to delete the path
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -6,6 +6,21 @@ import { extractFirstFileFromTar } from '$lib/server/tar-extract';
|
||||
import { attachmentContentDisposition } from '$lib/server/content-disposition';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* GET /api/containers/{id}/files/download - Download a path from a container as an archive
|
||||
*
|
||||
* @openapi
|
||||
* summary: Download a file or directory from a container as a tar (optionally gzip-compressed) archive attachment
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* query: path:string! Absolute path inside the container to archive and download
|
||||
* query: format:string Archive format, "tar" (default) or "tar.gz"
|
||||
* resp-200: Archive stream (application/x-tar or application/gzip) as a file attachment
|
||||
* resp-400: Path is missing
|
||||
* resp-403: Permission denied to access the path
|
||||
* resp-404: File not found
|
||||
* resp-500: Failed to download the file
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -4,6 +4,23 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* POST /api/containers/{id}/files/rename - Rename/move a path in a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Rename or move a file or directory inside a container (requires the 'exec' permission)
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* body: {oldPath:string!, newPath:string!}
|
||||
* body-example: {"oldPath":"/app/config.yaml","newPath":"/app/config.old.yaml"}
|
||||
* resp-200: {success:boolean!, oldPath:string!, newPath:string!}
|
||||
* resp-200-example: {"success":true,"oldPath":"/app/config.yaml","newPath":"/app/config.old.yaml"}
|
||||
* resp-400: oldPath or newPath missing, or the container is not running
|
||||
* resp-403: Permission denied, or read-only file system
|
||||
* resp-404: Source path not found
|
||||
* resp-409: Destination already exists
|
||||
* resp-500: Failed to rename the path
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -84,6 +84,22 @@ function createTarArchive(filename: string, content: Uint8Array): Uint8Array {
|
||||
return tar;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/containers/{id}/files/upload - Upload files into a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Upload one or more files into a container directory (requires the 'exec' permission)
|
||||
* description: Send `multipart/form-data` with one or more `files` parts; each file is tar-wrapped and written into the target directory. Partial success is reported per file in `uploaded`/`errors`.
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* query: path:string! Absolute target directory inside the container
|
||||
* resp-200: {success:boolean!, uploaded:array<string>!, errors:array<string>}
|
||||
* resp-200-example: {"success":true,"uploaded":["app.conf"]}
|
||||
* resp-400: Target path missing, or no files provided
|
||||
* resp-403: Permission denied to write to the path
|
||||
* resp-404: Target directory not found
|
||||
* resp-500: Failed to upload the files
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -10,6 +10,17 @@ import {
|
||||
detectImageLabelDivergence
|
||||
} from '$lib/server/container-image-divergence';
|
||||
|
||||
/**
|
||||
* GET /api/containers/{id}/inspect - Inspect a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Return the full Docker inspect payload for a container (requires the 'inspect' permission)
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: The raw Docker inspect object for the container
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to inspect the container
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -4,6 +4,20 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* GET /api/containers/{id}/logs - Read container logs (non-streaming)
|
||||
*
|
||||
* @openapi
|
||||
* summary: Return the last N lines of a container's combined stdout/stderr logs as a single string
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* query: tail:integer Number of trailing log lines to return (default 100)
|
||||
* query: since:string Only return logs since this time (Unix timestamp or Docker duration, e.g. 10m)
|
||||
* query: until:string Only return logs before this time (Unix timestamp or Docker duration)
|
||||
* resp-200: {logs:string!}
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to read the container logs
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -254,6 +254,20 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/containers/{id}/logs/stream - Stream container logs (Server-Sent Events)
|
||||
*
|
||||
* @openapi
|
||||
* summary: Stream a container's logs live as Server-Sent Events (connected/log/end/error events, with keepalive heartbeats)
|
||||
* description: Returns a `text/event-stream`. For Hawser Edge environments whose agent is not connected the stream body carries an error event. Docker stdout/stderr frames are demultiplexed server-side; TTY containers stream raw text.
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* query: tail:string Number of trailing log lines to prime the stream with (default "100")
|
||||
* query: since:string Only stream logs since this time (Unix timestamp or Docker duration, e.g. 10m)
|
||||
* query: until:string Only stream logs before this time (Unix timestamp or Docker duration)
|
||||
* resp-200: Server-Sent Events stream (text/event-stream) of log lines
|
||||
* resp-403: Permission denied
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -5,6 +5,18 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
/**
|
||||
* POST /api/containers/{id}/pause - Pause a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Pause all processes in a running container (requires the container 'stop' permission)
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to pause the container
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
|
||||
@@ -6,6 +6,22 @@ import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* POST /api/containers/{id}/rename - Rename a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Rename a container and update any associated auto-update schedule (requires the 'create' permission)
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* body: {name:string!}
|
||||
* body-example: {"name":"my-renamed-container"}
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-400: The new name is missing or not a string
|
||||
* resp-403: Permission denied
|
||||
* resp-404: Container not found
|
||||
* resp-500: Failed to rename the container
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
|
||||
@@ -5,6 +5,19 @@ import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* POST /api/containers/{id}/restart - Restart a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Restart a container
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-403: Permission denied, or (enterprise) no access to the requested environment
|
||||
* resp-404: Container not found
|
||||
* resp-500: Failed to restart the container
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
|
||||
@@ -12,6 +12,18 @@ const SHELLS_TO_CHECK = [
|
||||
{ path: '/bin/ash', label: 'Ash (Alpine)' }
|
||||
];
|
||||
|
||||
/**
|
||||
* GET /api/containers/{id}/shells - Detect available shells in a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Probe a container for available shells (bash/sh/zsh/ash) and report the preferred default (requires the 'exec' permission)
|
||||
* description: On probe failure the endpoint still returns 200 with empty results rather than an error, so callers always get a usable shell list.
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: {shells:array<string>!, defaultShell:string, allShells:array<{path:string!, label:string!, available:boolean!}>!}
|
||||
* resp-200-example: {"shells":["/bin/sh"],"defaultShell":"/bin/sh","allShells":[{"path":"/bin/bash","label":"Bash","available":false},{"path":"/bin/sh","label":"Shell (sh)","available":true}]}
|
||||
* resp-403: Permission denied, or (enterprise) no access to the requested environment
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -5,6 +5,19 @@ import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* POST /api/containers/{id}/start - Start a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Start a stopped container
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-403: Permission denied, or (enterprise) no access to the requested environment
|
||||
* resp-404: Container not found
|
||||
* resp-500: Failed to start the container
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
|
||||
@@ -70,6 +70,18 @@ function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; c
|
||||
return { usage, raw, cache };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/containers/{id}/stats - Get a single container's resource stats
|
||||
*
|
||||
* @openapi
|
||||
* summary: Return a one-shot CPU/memory/network/block-IO stats snapshot for a container (Docker-CLI-equivalent memory accounting)
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: {cpuPercent:number!, memoryUsage:integer!, memoryRaw:integer!, memoryCache:integer!, memoryLimit:integer!, memoryPercent:number!, networkRx:integer!, networkTx:integer!, blockRead:integer!, blockWrite:integer!, timestamp:integer!}
|
||||
* resp-403: Permission denied
|
||||
* resp-404: No environment configured, the environment was not found, or the container was not found
|
||||
* resp-500: Failed to read the container stats
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -5,6 +5,19 @@ import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* POST /api/containers/{id}/stop - Stop a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Stop a running container
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-403: Permission denied, or (enterprise) no access to the requested environment
|
||||
* resp-404: Container not found
|
||||
* resp-500: Failed to stop the container
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
|
||||
@@ -28,6 +28,18 @@ function parsePsOutput(output: string): { Titles: string[]; Processes: string[][
|
||||
return { Titles: headers, Processes: processes };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/containers/{id}/top - List processes running in a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: List the processes running inside a container (via `ps` in the container, falling back to the Docker top API)
|
||||
* description: Uses the container 'inspect' permission. The `source` field indicates whether the result came from an in-container `ps` (`"ps"`) or the Docker top API fallback (`"top"`).
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: {Titles:array<string>!, Processes:array<array<string>>!, source:string!}
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to read the container process list
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
@@ -5,6 +5,18 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
/**
|
||||
* POST /api/containers/{id}/unpause - Unpause a container
|
||||
*
|
||||
* @openapi
|
||||
* summary: Resume all processes in a paused container (requires the container 'start' permission)
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to unpause the container
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
|
||||
@@ -17,6 +17,19 @@ import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import { inspectContainer, updateContainerRuntime, IN_PLACE_UPDATE_FIELDS, type InPlaceUpdateField } from '$lib/server/docker';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Update a running container's restart policy and/or CPU/memory/blkio/pids limits in-place (no recreate)
|
||||
* path: id:string! Container id (from GET /api/containers)
|
||||
* query: env:integer Environment id (from GET /api/environments)
|
||||
* body: {RestartPolicy:{}, CpuShares:integer, CpuPeriod:integer, CpuQuota:integer, CpuRealtimePeriod:integer, CpuRealtimeRuntime:integer, CpusetCpus:string, CpusetMems:string, NanoCpus:integer, Memory:integer, MemorySwap:integer, MemoryReservation:integer, MemorySwappiness:integer, KernelMemory:integer, BlkioWeight:integer, BlkioWeightDevice:array<string>, BlkioDeviceReadBps:array<string>, BlkioDeviceWriteBps:array<string>, BlkioDeviceReadIOps:array<string>, BlkioDeviceWriteIOps:array<string>, PidsLimit:integer} (unsupported fields are silently dropped)
|
||||
* body-example: {"Memory":536870912,"PidsLimit":512}
|
||||
* resp-200: {success:boolean!, warnings:array<string>}
|
||||
* resp-400: Invalid JSON body, or no supported fields were provided
|
||||
* resp-403: Permission denied
|
||||
* resp-404: Container not found
|
||||
* resp-500: Update failed
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
|
||||
@@ -6,6 +6,22 @@ import { removePendingContainerUpdate } from '$lib/server/db';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* POST /api/containers/{id}/update - Recreate a container with new settings/image
|
||||
*
|
||||
* @openapi
|
||||
* summary: Recreate a container with updated create-options (optionally re-pulling the image first), preserving its configuration (requires the 'create' permission)
|
||||
* description: The body carries the container create-options (image, name, env, ports, volumes, …) alongside the two control flags below; `repullImage` pulls the image before recreation and `startAfterUpdate` starts the new container once created.
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* body: {image:string, name:string, repullImage:boolean, startAfterUpdate:boolean}
|
||||
* body-example: {"image":"nginx:latest","name":"web","repullImage":true,"startAfterUpdate":true}
|
||||
* resp-200: {success:boolean!, id:string!}
|
||||
* resp-200-example: {"success":true,"id":"3f4a1c2b9d8e"}
|
||||
* resp-403: Permission denied
|
||||
* resp-404: Container not found
|
||||
* resp-500: Failed to update the container (e.g. the image pull or recreation failed)
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
|
||||
@@ -73,6 +73,16 @@ export interface UpdateProgress {
|
||||
/**
|
||||
* Batch update containers with streaming progress.
|
||||
* Expects JSON body: { containerIds: string[], vulnerabilityCriteria?: VulnerabilityCriteria }
|
||||
*
|
||||
* @openapi
|
||||
* summary: Recreate a set of containers with live streaming progress (Server-Sent Events), optionally blocking on vulnerability criteria (requires the 'create' permission)
|
||||
* description: Returns a `text/event-stream` reporting per-container progress. `vulnerabilityCriteria` (default "never") can block an update when a container's image scan exceeds the configured severity threshold. containerIds from GET /api/containers.
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* body: {containerIds:array<string>!, vulnerabilityCriteria:string}
|
||||
* body-example: {"containerIds":["3f4a1c2b9d8e"],"vulnerabilityCriteria":"never"}
|
||||
* resp-200: Server-Sent Events stream (text/event-stream) of per-container update progress
|
||||
* resp-400: Invalid JSON body, or the containerIds array is missing or empty
|
||||
* resp-403: Permission denied
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { url, cookies, request } = event;
|
||||
|
||||
@@ -18,6 +18,17 @@ export interface BatchUpdateResult {
|
||||
* Preserves ALL container settings including health checks, resource limits,
|
||||
* capabilities, DNS, security options, ulimits, and network connections.
|
||||
* Expects JSON body: { containerIds: string[] }
|
||||
*
|
||||
* @openapi
|
||||
* summary: Recreate a set of containers with their latest images, preserving all settings (requires the 'create' permission)
|
||||
* description: Containers are processed sequentially; the response reports per-container success/failure plus a summary. Use the streaming variant for live progress. containerIds from GET /api/containers.
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* body: {containerIds:array<string>!}
|
||||
* body-example: {"containerIds":["3f4a1c2b9d8e","a1b2c3d4e5f6"]}
|
||||
* resp-200: {success:boolean!, results:array<{containerId:string!, containerName:string!, success:boolean!, error:string}>!, summary:{total:integer!, success:integer!, failed:integer!}!}
|
||||
* resp-400: The containerIds array is missing or empty
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to run the batch update
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { url, cookies, request } = event;
|
||||
|
||||
@@ -3,6 +3,16 @@ import type { RequestHandler } from './$types';
|
||||
import { listContainersWithSize } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
/**
|
||||
* GET /api/containers/sizes - List containers with their on-disk sizes
|
||||
*
|
||||
* @openapi
|
||||
* summary: List all containers in an environment together with their writable-layer and root-filesystem sizes (requires the 'view' permission)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: Array of containers with size metadata (SizeRw / SizeRootFs)
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to get container sizes (returns an empty object)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -78,6 +78,18 @@ function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/containers/stats - Get resource stats for all running containers
|
||||
*
|
||||
* @openapi
|
||||
* summary: Return a CPU/memory/network/block-IO stats snapshot for every running container in an environment (requires the 'view' permission)
|
||||
* description: Returns an empty array when no environment is configured or specified. With `debug=<name>` it returns the raw memory_stats for a single container instead. On internal error it returns an empty array with status 200.
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* query: debug:string Return raw Docker stats for the single container with this name instead of the aggregate list
|
||||
* resp-200: Array of per-container stats snapshots (or, with `debug`, the raw stats for one container)
|
||||
* resp-403: Permission denied
|
||||
* resp-404: The requested debug container was not found
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -65,6 +65,13 @@ function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Stream live CPU/memory/network/block-IO stats for all running containers in an environment (one snapshot round, then closes)
|
||||
* query: env:integer Environment id — an immediate "done" event is sent if omitted or no environments are configured (from GET /api/environments)
|
||||
* resp-200: text/event-stream SSE stream ("stat" events per container with {id,name,cpuPercent,memoryUsage,memoryRaw,memoryCache,memoryLimit,memoryPercent,networkRx,networkTx,blockRead,blockWrite}, an "error" event on environment-not-found, then a final "done" event)
|
||||
* resp-403: Permission denied
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -48,6 +48,13 @@ async function savePrefs(userId: number | null, prefs: StoredDashboardPrefs): Pr
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get the current user's dashboard preferences (grid layout, lock state, view mode)
|
||||
* resp-200: {gridLayout:array<{}>, locked:boolean!, viewMode:string!}
|
||||
* resp-200-example: {"gridLayout":[],"locked":false,"viewMode":"grid"}
|
||||
* resp-500: Failed to get dashboard preferences
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -61,6 +68,14 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Update the current user's dashboard preferences (only provided fields are merged)
|
||||
* body: {gridLayout:array<{}>, locked:boolean, viewMode:string}
|
||||
* body-example: {"locked":true,"viewMode":"list"}
|
||||
* resp-200: {gridLayout:array<{}>, locked:boolean!, viewMode:string!}
|
||||
* resp-500: Failed to save dashboard preferences
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -117,6 +117,16 @@ export interface EnvironmentStats {
|
||||
loading?: LoadingStates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get aggregated dashboard statistics per environment (containers, images, volumes, stacks, metrics)
|
||||
* description: Returns an array of per-environment stats. When env is supplied and matches, a single object is returned instead of an array. An empty array is returned on a fresh install with no environments.
|
||||
* query: env:integer Restrict the stats to a single environment (returns one object) (from GET /api/environments)
|
||||
* resp-200: array<{id:integer!, name:string!, online:boolean, containers:{}, images:{}, volumes:{}, networks:{}, stacks:{}, metrics:{}, events:{}}>
|
||||
* resp-403: Permission denied (requires the environments:view permission)
|
||||
* resp-404: Environment not found (when env is supplied)
|
||||
* resp-500: Failed to get dashboard stats
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies, url }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
|
||||
@@ -563,6 +563,13 @@ async function getEnvironmentStatsProgressive(
|
||||
return envStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Stream per-environment dashboard stats progressively over Server-Sent Events
|
||||
* description: Emits environments, partial, complete, error and done events as each environment's stats resolve. When the client sends Accept application/json, the stream is collected and returned as a single JSON payload instead.
|
||||
* resp-200: Server-Sent Events stream (text/event-stream), or a JSON payload when Accept application/json is requested
|
||||
* resp-403: Permission denied (requires the environments:view permission)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
||||
|
||||
@@ -19,6 +19,17 @@ import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rs
|
||||
const startupTime = Date.now();
|
||||
const startupRss = process.memoryUsage().rss;
|
||||
|
||||
/**
|
||||
* GET /api/debug/memory - Node.js memory diagnostics (opt-in)
|
||||
*
|
||||
* @openapi
|
||||
* summary: Return Node.js/V8 memory diagnostics; only enabled when MEMORY_MONITOR=true. Also supports ?snapshot and ?snapshots to dump/list heap snapshots.
|
||||
* query: gc:boolean Force garbage collection before reporting when true (requires Node started with --expose-gc)
|
||||
* resp-200: {timestamp:string!, uptime:object!, gcForced:boolean!, gcAvailable:boolean!, process:object!, growth:object!, v8Heap:object!, system:object!, rssTracker:object}
|
||||
* resp-200-desc: With ?snapshot returns {snapshot}, with ?snapshots returns {snapshots}; otherwise the full memory report
|
||||
* resp-200-example: {"timestamp":"2026-07-01T10:00:00.000Z","uptime":{"ms":3600000,"hours":1,"human":"1h 0m"},"gcForced":false,"gcAvailable":false,"process":{"rss":"180 MB"},"growth":{"rssPerHour":"2 MB"},"v8Heap":{"usedHeapSize":"90 MB"},"system":{"cpus":8,"platform":"linux"}}
|
||||
* resp-403: Memory monitor not enabled (set MEMORY_MONITOR=true)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
if (process.env.MEMORY_MONITOR !== 'true') {
|
||||
return json({ error: 'Memory monitor not enabled. Set MEMORY_MONITOR=true.' }, { status: 403 });
|
||||
|
||||
@@ -23,6 +23,14 @@ const externalTools = [
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* GET /api/dependencies - Dependency and external-tool inventory
|
||||
*
|
||||
* @openapi
|
||||
* summary: Return the combined list of npm dependencies and external tool images (grype, trivy), sorted by name, excluding Dockhand itself
|
||||
* resp-200: array<{name:string!, version:string!, license:string, repository:string}>
|
||||
* resp-200-example: [{"name":"anchore/grype","version":"v0.110.0","license":"Apache-2.0","repository":"https://github.com/anchore/grype"}]
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
// Combine npm dependencies with external tools, exclude dockhand itself
|
||||
const allDependencies = [...dependencies, ...externalTools]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { API_DOCS_ENABLED } from '$lib/server/features';
|
||||
// Written by `npm run generate:openapi` (scripts/generate-openapi.ts), which
|
||||
// runs as part of the regular build (see package.json "prebuild"). Imported
|
||||
// as a module rather than read from disk at request time: the production
|
||||
// Docker image only copies build/ (not static/), so a static/-only spec
|
||||
// would 404 in the container even though it's present in the repo. Importing
|
||||
// it makes Vite bundle the spec straight into the built server output,
|
||||
// so it's always available regardless of what ships alongside build/.
|
||||
import spec from '$lib/openapi.generated.json';
|
||||
|
||||
// The spec is immutable at runtime (baked into the build), so serialize it and
|
||||
// hash its ETag ONCE at module load - not on every request. The doc is ~800 KB;
|
||||
// re-stringifying it per request is wasted CPU, and without an ETag the Scalar
|
||||
// viewer re-downloads the whole thing on every open.
|
||||
const SPEC_JSON = JSON.stringify(spec);
|
||||
const SPEC_ETAG = `"${createHash('sha1').update(SPEC_JSON).digest('base64url')}"`;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: The full OpenAPI 3.0 specification for the Dockhand REST API (unauthenticated)
|
||||
* resp-200: object
|
||||
* resp-200-desc: The generated OpenAPI document — see GET /api/docs/ui for an interactive viewer
|
||||
* resp-304: Not modified — the client's If-None-Match matches the current spec ETag
|
||||
* resp-404: Not found — API docs are disabled (set FEAT_API_DOCS=true to enable)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ request }) => {
|
||||
// Gated: unauthenticated route, opt-in per instance (see features.ts).
|
||||
if (!API_DOCS_ENABLED) throw error(404, 'Not found');
|
||||
|
||||
// max-age is short so a redeploy (new spec, new ETag) is picked up quickly;
|
||||
// the ETag then serves 304s within that window instead of re-sending ~800 KB.
|
||||
const headers = {
|
||||
'content-type': 'application/json',
|
||||
etag: SPEC_ETAG,
|
||||
'cache-control': 'public, max-age=300'
|
||||
};
|
||||
|
||||
if (request.headers.get('if-none-match') === SPEC_ETAG) {
|
||||
return new Response(null, { status: 304, headers });
|
||||
}
|
||||
return new Response(SPEC_JSON, { headers });
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// No data needed server-side — the client-side Scalar bundle fetches
|
||||
// GET /api/docs itself. The FEAT_API_DOCS gate is enforced in hooks.server.ts
|
||||
// (a page load can't gate here: the app runs ssr=false, so this never fires on
|
||||
// a cold server render).
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {};
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
|
||||
// Self-hosted assets only (static/scalar/, copied from the
|
||||
// @scalar/api-reference package by `npm run generate:openapi` — see
|
||||
// scripts/generate-openapi.ts copyScalarAssets()). No CDN, works
|
||||
// offline / behind an internal-only Dockhand deployment.
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||
document.body.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await loadScript('/scalar/standalone.js');
|
||||
// @ts-expect-error — window.Scalar is a global set by the script above
|
||||
window.Scalar.createApiReference(container, {
|
||||
url: '/api/docs',
|
||||
// Explicitly disabled: the default points at Scalar's hosted
|
||||
// proxy.scalar.com, which we never want to reach (self-hosted,
|
||||
// no external fetch — CSP would block it anyway, and the
|
||||
// Dockhand API is same-origin so no proxy is needed).
|
||||
proxyUrl: ''
|
||||
});
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dockhand API Docs</title>
|
||||
</svelte:head>
|
||||
|
||||
<div bind:this={container}></div>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,15 @@ import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-color
|
||||
import { cleanPem } from '$lib/utils/pem';
|
||||
import { validateEnvName } from '$lib/utils/env-name';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: List all Docker environments (hosts) known to Dockhand
|
||||
* resp-200: array<{id:integer!, name:string!, connectionType:string!, host:string, port:integer, protocol:string, icon:string, publicIp:string, timezone:string}>
|
||||
* resp-200-desc: Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition)
|
||||
* resp-200-example: [{"id":1,"name":"hhdocker01","connectionType":"socket","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":"203.0.113.10","timezone":"Europe/Berlin"},{"id":2,"name":"hhdocker02","connectionType":"hawser-edge","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":null,"timezone":"Europe/Berlin"}]
|
||||
* resp-403: Permission denied (RBAC 'environments:view' missing)
|
||||
* resp-500: Unexpected error while loading environments
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
||||
@@ -63,6 +72,18 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Register a new Docker environment (host)
|
||||
* body: {name:string!, connectionType:string, host:string, port:integer, protocol:string, socketPath:string, icon:string, publicIp:string, labels:string}
|
||||
* body-example: {"name":"hhdocker03","connectionType":"socket","socketPath":"/var/run/docker.sock","icon":"server"}
|
||||
* resp-200: {id:integer!, name:string!, connectionType:string!}
|
||||
* resp-200-example: {"id":3,"name":"hhdocker03","connectionType":"socket"}
|
||||
* resp-400: Invalid name, invalid connectionType, or host missing for direct/hawser-standard
|
||||
* resp-403: Permission denied (RBAC 'environments:create' missing)
|
||||
* resp-409: An environment with this name already exists
|
||||
* resp-500: Unexpected error while creating the environment
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -18,6 +18,15 @@ import { closeEdgeConnection } from '$lib/server/hawser';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
import { deleteEnvironmentIcon } from '$lib/server/env-icons';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get a single environment by id, including its parsed labels and public IP
|
||||
* path: id:integer! Environment id (from GET /api/environments)
|
||||
* resp-200: {id:integer!, name:string!, connectionType:string!, labels:array<string>, publicIp:string}
|
||||
* resp-403: Permission denied (RBAC 'environments:view' missing)
|
||||
* resp-404: Environment not found
|
||||
* resp-500: Unexpected error while loading the environment
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
||||
@@ -48,6 +57,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Update an environment; renaming also renames its on-disk stacks/git-repos directories
|
||||
* path: id:integer! Environment id (from GET /api/environments)
|
||||
* body: {name:string, host:string, port:integer, protocol:string, tlsCa:string, tlsCert:string, tlsKey:string, tlsSkipVerify:boolean, icon:string, socketPath:string, collectActivity:boolean, collectMetrics:boolean, highlightChanges:boolean, labels:string, connectionType:string, hawserToken:string, publicIp:string}
|
||||
* body-example: {"name":"hhdocker03","collectMetrics":true}
|
||||
* resp-200: {id:integer!, name:string!, connectionType:string!, labels:array<string>, publicIp:string}
|
||||
* resp-400: Invalid new name (rename validation)
|
||||
* resp-403: Permission denied (RBAC 'environments:edit' missing)
|
||||
* resp-404: Environment not found
|
||||
* resp-409: Rename target directory already exists, or the on-disk rename failed (e.g. EXDEV across filesystems)
|
||||
* resp-500: Unexpected error while updating the environment
|
||||
*/
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
@@ -195,6 +217,16 @@ export const PUT: RequestHandler = async (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Delete an environment and all its associated git stacks, schedules, icons, and on-disk directories
|
||||
* path: id:integer! Environment id (from GET /api/environments)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-400: Invalid environment id, or the environment could not be deleted
|
||||
* resp-403: Permission denied (RBAC 'environments:delete' missing)
|
||||
* resp-404: Environment not found
|
||||
* resp-500: Environment name is empty/whitespace (refuses to delete to avoid an unsafe directory cleanup), or an unexpected error
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -3,6 +3,16 @@ import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getEnvironment, getEnvSetting, setEnvSetting } from '$lib/server/db';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get the disk-space warning thresholds for an environment
|
||||
* path: id:integer! Environment id (from GET /api/environments)
|
||||
* resp-200: {enabled:boolean!, mode:string!, threshold:integer!, thresholdGb:integer!}
|
||||
* resp-200-example: {"enabled":true,"mode":"percentage","threshold":80,"thresholdGb":50}
|
||||
* resp-403: Permission denied (RBAC 'environments:view' missing)
|
||||
* resp-404: Environment not found
|
||||
* resp-500: Unexpected error while loading the settings
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('environments', 'view'))) {
|
||||
@@ -28,6 +38,17 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Save the disk-space warning thresholds for an environment (each field optional/independent)
|
||||
* path: id:integer! Environment id (from GET /api/environments)
|
||||
* body: {enabled:boolean, mode:string, threshold:integer, thresholdGb:integer}
|
||||
* body-example: {"enabled":true,"mode":"percentage","threshold":85}
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-403: Permission denied (RBAC 'environments:edit' missing)
|
||||
* resp-404: Environment not found
|
||||
* resp-500: Unexpected error while saving the settings
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('environments', 'edit'))) {
|
||||
|
||||
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user