1.0.18
This commit is contained in:
1 parent
d83ca684d7
commit
8ee4fe4d68
49 files changed
+2126
-261
No files matched your search
@@ -14,6 +14,7 @@ export interface UpdateCheckResult {
|
||||
newDigest?: string;
|
||||
error?: string;
|
||||
isLocalImage?: boolean;
|
||||
systemContainer?: 'dockhand' | 'hawser' | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,8 +40,8 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
|
||||
const allContainers = await listContainers(true, envIdNum);
|
||||
|
||||
// Filter out system containers (Dockhand, Hawser) - they cannot be updated from within Dockhand
|
||||
const containers = allContainers.filter(c => !isSystemContainer(c.image));
|
||||
// Include all containers (system containers get flagged, not filtered)
|
||||
const containers = allContainers;
|
||||
|
||||
// Check container for updates
|
||||
const checkContainer = async (container: typeof containers[0]): Promise<UpdateCheckResult> => {
|
||||
@@ -56,7 +57,8 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
containerName: container.name,
|
||||
imageName: container.image,
|
||||
hasUpdate: false,
|
||||
error: 'Could not determine image name'
|
||||
error: 'Could not determine image name',
|
||||
systemContainer: isSystemContainer(container.image) || null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,7 +73,8 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
currentDigest: result.currentDigest,
|
||||
newDigest: result.registryDigest,
|
||||
error: result.error,
|
||||
isLocalImage: result.isLocalImage
|
||||
isLocalImage: result.isLocalImage,
|
||||
systemContainer: isSystemContainer(imageName) || null
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
@@ -79,7 +82,8 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
containerName: container.name,
|
||||
imageName: container.image,
|
||||
hasUpdate: false,
|
||||
error: error.message
|
||||
error: error.message,
|
||||
systemContainer: isSystemContainer(container.image) || null
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -90,9 +94,10 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
const updatesFound = results.filter(r => r.hasUpdate).length;
|
||||
|
||||
// Save containers with updates to the database for persistence
|
||||
// Skip system containers (Dockhand/Hawser) - they use their own update paths
|
||||
if (envIdNum) {
|
||||
for (const result of results) {
|
||||
if (result.hasUpdate) {
|
||||
if (result.hasUpdate && !result.systemContainer) {
|
||||
await addPendingContainerUpdate(
|
||||
envIdNum,
|
||||
result.containerId,
|
||||
|
||||
@@ -25,11 +25,15 @@ import { parseLabels } from '$lib/utils/label-colors';
|
||||
const SKIP_DF_COLLECTION = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1';
|
||||
|
||||
// Helper to add timeout to promises
|
||||
// IMPORTANT: Clears the timeout to prevent memory leaks from accumulated timer closures
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms))
|
||||
]);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<T>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve(fallback), ms);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
// Disk usage cache - getDiskUsage() is very slow (30s timeout) but data changes rarely
|
||||
|
||||
@@ -70,24 +70,27 @@ export const POST: RequestHandler = async ({ params }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const info = await getDockerInfo(env.id) as any;
|
||||
|
||||
// For Hawser Standard mode, fetch Hawser info (Edge mode handled above with early return)
|
||||
// For Hawser Standard mode, fetch Docker info and Hawser info in parallel
|
||||
// (sequential calls can fail due to Bun TLS connection reuse issues)
|
||||
let info: any;
|
||||
let hawserInfo = null;
|
||||
if (env.connectionType === 'hawser-standard') {
|
||||
try {
|
||||
hawserInfo = await getHawserInfo(id);
|
||||
if (hawserInfo?.hawserVersion) {
|
||||
await updateEnvironment(id, {
|
||||
hawserVersion: hawserInfo.hawserVersion,
|
||||
hawserAgentId: hawserInfo.agentId,
|
||||
hawserAgentName: hawserInfo.agentName,
|
||||
hawserLastSeen: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Hawser info fetch failed, continue without it
|
||||
const [dockerResult, hawserResult] = await Promise.all([
|
||||
getDockerInfo(env.id),
|
||||
getHawserInfo(id)
|
||||
]);
|
||||
info = dockerResult;
|
||||
hawserInfo = hawserResult;
|
||||
if (hawserInfo?.hawserVersion) {
|
||||
await updateEnvironment(id, {
|
||||
hawserVersion: hawserInfo.hawserVersion,
|
||||
hawserAgentId: hawserInfo.agentId,
|
||||
hawserAgentName: hawserInfo.agentName,
|
||||
hawserLastSeen: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
info = await getDockerInfo(env.id);
|
||||
}
|
||||
|
||||
return json({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGitStack } from '$lib/server/db';
|
||||
import { deployGitStack } from '$lib/server/git';
|
||||
import { auditGitStack } from '$lib/server/audit';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
function verifySignature(payload: string, signature: string | null, secret: string): boolean {
|
||||
@@ -26,7 +27,14 @@ function verifySignature(payload: string, signature: string | null, secret: stri
|
||||
return signature === secret;
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
function detectSource(request: Request): string {
|
||||
if (request.headers.get('x-hub-signature-256')) return 'github';
|
||||
if (request.headers.get('x-gitlab-token')) return 'gitlab';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request } = event;
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
@@ -42,6 +50,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
return json({ error: 'Webhook is not enabled for this stack' }, { status: 403 });
|
||||
}
|
||||
|
||||
const source = detectSource(request);
|
||||
|
||||
// Verify webhook secret if set
|
||||
if (gitStack.webhookSecret) {
|
||||
const payload = await request.text();
|
||||
@@ -51,12 +61,18 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
const signature = githubSignature || gitlabToken;
|
||||
|
||||
if (!verifySignature(payload, signature, gitStack.webhookSecret)) {
|
||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||
method: 'POST', source, error: 'invalid_signature'
|
||||
});
|
||||
return json({ error: 'Invalid webhook signature' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy the git stack (syncs and deploys only if there are changes)
|
||||
const result = await deployGitStack(id, { force: false });
|
||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||
method: 'POST', source, result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed'
|
||||
});
|
||||
return json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Webhook error:', error);
|
||||
@@ -65,7 +81,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
};
|
||||
|
||||
// Also support GET for simple polling/manual triggers
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const { params, url } = event;
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
@@ -84,11 +101,17 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
// Verify secret via query parameter for GET requests
|
||||
const secret = url.searchParams.get('secret');
|
||||
if (gitStack.webhookSecret && secret !== gitStack.webhookSecret) {
|
||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||
method: 'GET', source: 'get', error: 'invalid_secret'
|
||||
});
|
||||
return json({ error: 'Invalid webhook secret' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Deploy the git stack (syncs and deploys only if there are changes)
|
||||
const result = await deployGitStack(id, { force: false });
|
||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||
method: 'GET', source: 'get', result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed'
|
||||
});
|
||||
return json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Webhook GET error:', error);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json, text } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGitRepository } from '$lib/server/db';
|
||||
import { deployFromRepository } from '$lib/server/git';
|
||||
import { auditGitRepository } from '$lib/server/audit';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
function verifySignature(payload: string, signature: string | null, secret: string): boolean {
|
||||
@@ -26,7 +27,14 @@ function verifySignature(payload: string, signature: string | null, secret: stri
|
||||
return signature === secret;
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
function detectSource(request: Request): string {
|
||||
if (request.headers.get('x-hub-signature-256')) return 'github';
|
||||
if (request.headers.get('x-gitlab-token')) return 'gitlab';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request } = event;
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
@@ -42,6 +50,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
return json({ error: 'Webhook is not enabled for this repository' }, { status: 403 });
|
||||
}
|
||||
|
||||
const source = detectSource(request);
|
||||
|
||||
// Verify webhook secret if set
|
||||
if (repository.webhookSecret) {
|
||||
const payload = await request.text();
|
||||
@@ -51,6 +61,9 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
const signature = githubSignature || gitlabToken;
|
||||
|
||||
if (!verifySignature(payload, signature, repository.webhookSecret)) {
|
||||
await auditGitRepository(event, 'webhook', id, repository.name, {
|
||||
method: 'POST', source, error: 'invalid_signature'
|
||||
});
|
||||
return json({ error: 'Invalid webhook signature' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -63,6 +76,9 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
|
||||
// Deploy from repository
|
||||
const result = await deployFromRepository(id);
|
||||
await auditGitRepository(event, 'webhook', id, repository.name, {
|
||||
method: 'POST', source, result: result.success ? 'deployed' : 'failed'
|
||||
});
|
||||
return json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Webhook error:', error);
|
||||
@@ -71,7 +87,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
};
|
||||
|
||||
// Also support GET for simple polling/manual triggers
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const { params, url } = event;
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
@@ -90,11 +107,17 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
// Verify secret via query parameter for GET requests
|
||||
const secret = url.searchParams.get('secret');
|
||||
if (repository.webhookSecret && secret !== repository.webhookSecret) {
|
||||
await auditGitRepository(event, 'webhook', id, repository.name, {
|
||||
method: 'GET', source: 'get', error: 'invalid_secret'
|
||||
});
|
||||
return json({ error: 'Invalid webhook secret' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Deploy from repository
|
||||
const result = await deployFromRepository(id);
|
||||
await auditGitRepository(event, 'webhook', id, repository.name, {
|
||||
method: 'GET', source: 'get', result: result.success ? 'deployed' : 'failed'
|
||||
});
|
||||
return json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Webhook GET error:', error);
|
||||
|
||||
@@ -78,7 +78,15 @@ export const POST: RequestHandler = async (event) => {
|
||||
// Audit log
|
||||
await auditNotification(event, 'create', setting.id, setting.name);
|
||||
|
||||
return json(setting);
|
||||
// Don't expose passwords in response
|
||||
const safeSetting = setting.type === 'smtp' ? {
|
||||
...setting,
|
||||
config: {
|
||||
...setting.config,
|
||||
password: setting.config.password ? '********' : undefined
|
||||
}
|
||||
} : setting;
|
||||
return json(safeSetting);
|
||||
} catch (error: any) {
|
||||
console.error('Error creating notification setting:', error);
|
||||
return json({ error: error.message || 'Failed to create notification setting' }, { status: 500 });
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getOwnContainerId, getHostDockerSocket } from '$lib/server/host-path';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const UPDATER_IMAGE = 'fnsys/dockhand-updater:latest';
|
||||
const UPDATER_LABEL = 'dockhand.updater';
|
||||
|
||||
/**
|
||||
* Fetch from the local Docker socket directly.
|
||||
* Self-update always operates on the local engine — no environment routing needed.
|
||||
*/
|
||||
async function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return fetch(`http://localhost${path}`, {
|
||||
...options,
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull an image via local Docker socket, streaming progress via callback.
|
||||
*/
|
||||
async function pullImageLocal(imageName: string, onProgress?: (line: string) => void): Promise<void> {
|
||||
let fromImage = imageName;
|
||||
let tag = 'latest';
|
||||
if (imageName.includes(':')) {
|
||||
const lastColon = imageName.lastIndexOf(':');
|
||||
const potentialTag = imageName.substring(lastColon + 1);
|
||||
if (!potentialTag.includes('/')) {
|
||||
fromImage = imageName.substring(0, lastColon);
|
||||
tag = potentialTag;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await localDockerFetch(
|
||||
`/images/create?fromImage=${encodeURIComponent(fromImage)}&tag=${encodeURIComponent(tag)}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to pull image: ${text}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!onProgress || !value) continue;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.error) {
|
||||
onProgress(`Error: ${json.error}`);
|
||||
} else if (json.status) {
|
||||
let msg = json.status;
|
||||
if (json.id) msg = `${json.id}: ${msg}`;
|
||||
if (json.progress) msg += ` ${json.progress}`;
|
||||
onProgress(msg);
|
||||
}
|
||||
} catch {
|
||||
onProgress(line.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Docker socket is mounted read-write
|
||||
*/
|
||||
async function isDockerSocketWritable(containerId: string): Promise<boolean> {
|
||||
const response = await localDockerFetch(`/containers/${containerId}/json`);
|
||||
if (!response.ok) return false;
|
||||
|
||||
const info = await response.json() as {
|
||||
Mounts?: Array<{ Source: string; Destination: string; RW: boolean }>;
|
||||
};
|
||||
|
||||
const socketDest = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
const socketMount = info.Mounts?.find(m => m.Destination === socketDest);
|
||||
return socketMount?.RW ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any existing updater containers
|
||||
*/
|
||||
async function cleanupExistingUpdaters(): Promise<void> {
|
||||
const response = await localDockerFetch(
|
||||
`/containers/json?all=true&filters=${encodeURIComponent(JSON.stringify({ label: [UPDATER_LABEL + '=true'] }))}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const containers = await response.json() as Array<{ Id: string; State: string }>;
|
||||
for (const container of containers) {
|
||||
if (container.State === 'running') {
|
||||
await localDockerFetch(`/containers/${container.Id}/stop`, { method: 'POST' });
|
||||
}
|
||||
await localDockerFetch(`/containers/${container.Id}?force=true`, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the container create config from inspect data (same logic as recreateContainerFromInspect).
|
||||
* Does NOT include NetworkingConfig — the new container is created without networks
|
||||
* to avoid static IP conflicts with the still-running old container.
|
||||
*/
|
||||
function buildCreateConfig(inspectData: any, newImage: string): any {
|
||||
const config = inspectData.Config || {};
|
||||
const hostConfig = inspectData.HostConfig || {};
|
||||
|
||||
const createConfig: any = {
|
||||
...config,
|
||||
Image: newImage,
|
||||
HostConfig: { ...hostConfig }
|
||||
};
|
||||
|
||||
// Clear MacAddress for Docker API < 1.44 compatibility
|
||||
delete createConfig.MacAddress;
|
||||
|
||||
// Clear Hostname so Docker assigns the new container's own ID
|
||||
// Otherwise the old container's hostname is inherited, breaking self-identification
|
||||
delete createConfig.Hostname;
|
||||
|
||||
// Preserve anonymous volumes from Mounts not in HostConfig.Binds
|
||||
const existingBinds = new Set((hostConfig.Binds || []).map((b: string) => {
|
||||
const parts = b.split(':');
|
||||
return parts.length >= 2 ? parts[1] : parts[0];
|
||||
}));
|
||||
const mounts = inspectData.Mounts || [];
|
||||
const additionalBinds: string[] = [];
|
||||
for (const mount of mounts) {
|
||||
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
|
||||
if (!existingBinds.has(mount.Destination)) {
|
||||
additionalBinds.push(`${mount.Name}:${mount.Destination}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (additionalBinds.length > 0) {
|
||||
createConfig.HostConfig = {
|
||||
...createConfig.HostConfig,
|
||||
Binds: [...(createConfig.HostConfig.Binds || []), ...additionalBinds]
|
||||
};
|
||||
}
|
||||
|
||||
// No NetworkingConfig — avoids static IP conflicts with still-running old container.
|
||||
// Networks are connected by the sidecar after the old container is removed.
|
||||
|
||||
return createConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build NETWORKS and NETWORK_OPTS_* env vars from inspect data's NetworkSettings.
|
||||
* The sidecar uses these to reconnect networks via `docker network connect` CLI.
|
||||
*/
|
||||
function buildNetworkEnvVars(inspectData: any): string[] {
|
||||
const networks: Record<string, any> = inspectData.NetworkSettings?.Networks || {};
|
||||
const entries = Object.entries(networks);
|
||||
if (entries.length === 0) return [];
|
||||
|
||||
const networkNames: string[] = [];
|
||||
const envVars: string[] = [];
|
||||
|
||||
for (const [netName, netConfig] of entries) {
|
||||
networkNames.push(netName);
|
||||
|
||||
const nc = netConfig as any;
|
||||
const opts: string[] = [];
|
||||
|
||||
if (nc.IPAMConfig?.IPv4Address) {
|
||||
opts.push(`--ip ${nc.IPAMConfig.IPv4Address}`);
|
||||
}
|
||||
if (nc.IPAMConfig?.IPv6Address) {
|
||||
opts.push(`--ip6 ${nc.IPAMConfig.IPv6Address}`);
|
||||
}
|
||||
if (nc.Aliases && nc.Aliases.length > 0) {
|
||||
for (const alias of nc.Aliases) {
|
||||
opts.push(`--alias ${alias}`);
|
||||
}
|
||||
}
|
||||
if (nc.Links && nc.Links.length > 0) {
|
||||
for (const link of nc.Links) {
|
||||
opts.push(`--link ${link}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.length > 0) {
|
||||
// Env var name: dots and dashes become underscores
|
||||
const safeNetName = netName.replace(/[.-]/g, '_');
|
||||
envVars.push(`NETWORK_OPTS_${safeNetName}=${opts.join(' ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
envVars.unshift(`NETWORKS=${networkNames.join(' ')}`);
|
||||
return envVars;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE stream endpoint for self-update.
|
||||
* Pulls image, creates new container, then launches minimal sidecar.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !auth.isAdmin) {
|
||||
return json({ error: 'Admin access required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({})) as { newImage?: string };
|
||||
const newImage = body.newImage;
|
||||
if (!newImage) {
|
||||
return json({ error: 'newImage is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fail-fast validation before starting SSE stream
|
||||
const containerId = getOwnContainerId();
|
||||
if (!containerId) {
|
||||
return json({ error: 'Not running in Docker' }, { status: 400 });
|
||||
}
|
||||
|
||||
const writable = await isDockerSocketWritable(containerId);
|
||||
if (!writable) {
|
||||
return json({
|
||||
error: 'Docker socket is mounted read-only. Self-update requires read-write Docker socket access.'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const nameResponse = await localDockerFetch(`/containers/${containerId}/json`);
|
||||
if (!nameResponse.ok) {
|
||||
return json({ error: 'Failed to inspect own container' }, { status: 500 });
|
||||
}
|
||||
const nameInfo = await nameResponse.json() as { Name?: string };
|
||||
const containerName = nameInfo.Name?.replace(/^\//, '') || '';
|
||||
if (!containerName) {
|
||||
return json({ error: 'Failed to determine container name' }, { status: 500 });
|
||||
}
|
||||
|
||||
const socketHostPath = getHostDockerSocket();
|
||||
|
||||
// Start SSE stream for preparation progress
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (event: string, data: any) => {
|
||||
if (controllerClosed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
};
|
||||
|
||||
const sendStep = (step: string, status: string, message: string) => {
|
||||
send('step', { step, status, message });
|
||||
};
|
||||
|
||||
let newContainerId: string | null = null;
|
||||
|
||||
try {
|
||||
// Step 1: Pull the new Dockhand image
|
||||
sendStep('pulling_image', 'active', `Pulling ${newImage}...`);
|
||||
send('log', { message: `Pulling ${newImage}...` });
|
||||
await pullImageLocal(newImage, (msg) => send('log', { message: msg }));
|
||||
sendStep('pulling_image', 'completed', 'Image pulled');
|
||||
send('log', { message: 'Image pulled successfully' });
|
||||
|
||||
// Step 2: Build container config from self-inspect
|
||||
sendStep('building_config', 'active', 'Building container config...');
|
||||
send('log', { message: `Inspecting container ${containerId.substring(0, 12)}...` });
|
||||
const inspectResponse = await localDockerFetch(`/containers/${containerId}/json`);
|
||||
if (!inspectResponse.ok) {
|
||||
throw new Error('Failed to inspect own container');
|
||||
}
|
||||
const inspectData = await inspectResponse.json();
|
||||
const createConfig = buildCreateConfig(inspectData, newImage);
|
||||
const networkEnvVars = buildNetworkEnvVars(inspectData);
|
||||
send('log', { message: `Networks: ${networkEnvVars.length > 0 ? networkEnvVars[0] : 'default'}` });
|
||||
sendStep('building_config', 'completed', 'Config ready');
|
||||
|
||||
// Step 3: Pull the updater image
|
||||
sendStep('pulling_updater', 'active', 'Pulling updater image...');
|
||||
send('log', { message: `Pulling ${UPDATER_IMAGE}...` });
|
||||
await pullImageLocal(UPDATER_IMAGE, (msg) => send('log', { message: msg }));
|
||||
sendStep('pulling_updater', 'completed', 'Updater ready');
|
||||
send('log', { message: 'Updater image ready' });
|
||||
|
||||
// Step 4: Create new container with temp name (no NetworkingConfig)
|
||||
sendStep('creating_container', 'active', 'Creating new container...');
|
||||
send('log', { message: 'Cleaning up previous updater containers...' });
|
||||
await cleanupExistingUpdaters();
|
||||
|
||||
// Also clean up any leftover -updating containers from previous attempts
|
||||
const staleResponse = await localDockerFetch(
|
||||
`/containers/json?all=true&filters=${encodeURIComponent(JSON.stringify({ name: [`${containerName}-updating`] }))}`
|
||||
);
|
||||
if (staleResponse.ok) {
|
||||
const stale = await staleResponse.json() as Array<{ Id: string; State: string }>;
|
||||
for (const c of stale) {
|
||||
if (c.State === 'running') {
|
||||
await localDockerFetch(`/containers/${c.Id}/stop`, { method: 'POST' }).catch(() => {});
|
||||
}
|
||||
await localDockerFetch(`/containers/${c.Id}?force=true`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const tempName = `${containerName}-updating`;
|
||||
const createResponse = await localDockerFetch(
|
||||
`/containers/create?name=${encodeURIComponent(tempName)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(createConfig)
|
||||
}
|
||||
);
|
||||
|
||||
if (!createResponse.ok) {
|
||||
const errText = await createResponse.text();
|
||||
throw new Error(`Failed to create container: ${errText}`);
|
||||
}
|
||||
|
||||
const createResult = await createResponse.json() as { Id: string };
|
||||
newContainerId = createResult.Id;
|
||||
console.log(`[SelfUpdate] New container created: ${newContainerId.substring(0, 12)} (${tempName})`);
|
||||
send('log', { message: `Container created: ${newContainerId.substring(0, 12)} (${tempName})` });
|
||||
sendStep('creating_container', 'completed', 'Container created');
|
||||
|
||||
// Step 5: Launch updater sidecar (point of no return)
|
||||
sendStep('launching_updater', 'active', 'Launching updater...');
|
||||
|
||||
const updaterEnv = [
|
||||
`OLD_CONTAINER_ID=${containerId}`,
|
||||
`NEW_CONTAINER_ID=${newContainerId}`,
|
||||
`CONTAINER_NAME=${containerName}`,
|
||||
...networkEnvVars
|
||||
];
|
||||
|
||||
console.log('[SelfUpdate] Creating updater container...');
|
||||
const updaterResponse = await localDockerFetch('/containers/create?name=dockhand-updater', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
Image: UPDATER_IMAGE,
|
||||
Env: updaterEnv,
|
||||
Labels: {
|
||||
[UPDATER_LABEL]: 'true'
|
||||
},
|
||||
HostConfig: {
|
||||
AutoRemove: true,
|
||||
Binds: [
|
||||
`${socketHostPath}:/var/run/docker.sock`
|
||||
]
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!updaterResponse.ok) {
|
||||
const errText = await updaterResponse.text();
|
||||
throw new Error(`Failed to create updater container: ${errText}`);
|
||||
}
|
||||
|
||||
const { Id: updaterId } = await updaterResponse.json() as { Id: string };
|
||||
|
||||
// Start the updater
|
||||
const startResponse = await localDockerFetch(`/containers/${updaterId}/start`, { method: 'POST' });
|
||||
if (!startResponse.ok) {
|
||||
await localDockerFetch(`/containers/${updaterId}?force=true`, { method: 'DELETE' });
|
||||
throw new Error('Failed to start updater container');
|
||||
}
|
||||
|
||||
console.log(`[SelfUpdate] Updater started (${updaterId.substring(0, 12)}). Dockhand will be stopped shortly.`);
|
||||
send('log', { message: `Updater started: ${updaterId.substring(0, 12)}` });
|
||||
send('log', { message: 'Handing off to updater sidecar...' });
|
||||
sendStep('launching_updater', 'completed', 'Updater launched');
|
||||
send('launched', { updaterId });
|
||||
} catch (err: any) {
|
||||
console.error('[SelfUpdate] Error:', err);
|
||||
send('error', { step: 'preparation', message: err.message || String(err) });
|
||||
|
||||
// Clean up the pre-created container on failure
|
||||
if (newContainerId) {
|
||||
await localDockerFetch(`/containers/${newContainerId}?force=true`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
if (!controllerClosed) {
|
||||
try { controller.close(); } catch { /* already closed */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getOwnContainerId } from '$lib/server/host-path';
|
||||
import { getRegistryManifestDigest } from '$lib/server/docker';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Fetch from the local Docker socket directly (not through environment routing)
|
||||
*/
|
||||
async function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return fetch(`http://localhost${path}`, {
|
||||
...options,
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Dockhand update is available.
|
||||
* Admin-only. Auto-checked when Settings > About is opened.
|
||||
*
|
||||
* Uses localDockerFetch exclusively to avoid environment routing issues
|
||||
* when the image comes from a private registry.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !auth.isAdmin) {
|
||||
return json({ error: 'Admin access required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const containerId = getOwnContainerId();
|
||||
if (!containerId) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
error: 'Not running in Docker'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Inspect own container to get current image info
|
||||
const inspectResponse = await localDockerFetch(`/containers/${containerId}/json`);
|
||||
if (!inspectResponse.ok) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
error: 'Failed to inspect own container'
|
||||
});
|
||||
}
|
||||
|
||||
const inspectData = await inspectResponse.json() as {
|
||||
Config?: { Image?: string; Labels?: Record<string, string> };
|
||||
Image?: string;
|
||||
Name?: string;
|
||||
};
|
||||
|
||||
const currentImage = inspectData.Config?.Image || '';
|
||||
const currentImageId = inspectData.Image || '';
|
||||
const containerName = inspectData.Name?.replace(/^\//, '') || '';
|
||||
|
||||
if (!currentImage) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
error: 'Could not determine current image'
|
||||
});
|
||||
}
|
||||
|
||||
// Detect if managed by Docker Compose
|
||||
const isComposeManaged = !!inspectData.Config?.Labels?.['com.docker.compose.project'];
|
||||
|
||||
// Digest-based images (e.g. image@sha256:...) can't be checked for updates
|
||||
if (currentImage.includes('@sha256:')) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
currentDigest: currentImage.split('@')[1],
|
||||
containerName,
|
||||
isComposeManaged
|
||||
});
|
||||
}
|
||||
|
||||
// Inspect image via local Docker socket to get RepoDigests
|
||||
const imageResponse = await localDockerFetch(`/images/${encodeURIComponent(currentImageId)}/json`);
|
||||
if (!imageResponse.ok) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
error: 'Could not inspect current image'
|
||||
});
|
||||
}
|
||||
|
||||
const imageInfo = await imageResponse.json() as { RepoDigests?: string[] };
|
||||
const repoDigests = imageInfo.RepoDigests || [];
|
||||
|
||||
// Extract local digests from RepoDigests entries (format: "registry/image@sha256:...")
|
||||
const localDigests = repoDigests
|
||||
.map((rd: string) => {
|
||||
const at = rd.lastIndexOf('@');
|
||||
return at > -1 ? rd.substring(at + 1) : null;
|
||||
})
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
if (localDigests.length === 0) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
isLocalImage: true
|
||||
});
|
||||
}
|
||||
|
||||
// Query registry for latest digest
|
||||
const registryDigest = await getRegistryManifestDigest(currentImage);
|
||||
if (!registryDigest) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
error: 'Could not query registry'
|
||||
});
|
||||
}
|
||||
|
||||
const hasUpdate = !localDigests.includes(registryDigest);
|
||||
|
||||
return json({
|
||||
updateAvailable: hasUpdate,
|
||||
currentImage,
|
||||
currentDigest: localDigests[0],
|
||||
newDigest: registryDigest,
|
||||
containerName,
|
||||
isComposeManaged
|
||||
});
|
||||
} catch (err) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
error: 'Check failed: ' + String(err)
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Fetch from the local Docker socket directly
|
||||
*/
|
||||
async function localDockerFetch(path: string): Promise<Response> {
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return fetch(`http://localhost${path}`, {
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Docker log stream multiplexing headers.
|
||||
* Docker prefixes each frame with an 8-byte header:
|
||||
* [stream_type(1)] [0(3)] [size(4 big-endian)]
|
||||
*/
|
||||
function stripDockerLogHeaders(raw: Uint8Array): string {
|
||||
const lines: string[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < raw.length) {
|
||||
// Check if we have a Docker stream header (8 bytes)
|
||||
if (offset + 8 <= raw.length) {
|
||||
const streamType = raw[offset];
|
||||
// Stream type should be 0 (stdin), 1 (stdout), or 2 (stderr)
|
||||
if (streamType <= 2) {
|
||||
// Read the 4-byte big-endian size
|
||||
const size = (raw[offset + 4] << 24) | (raw[offset + 5] << 16) | (raw[offset + 6] << 8) | raw[offset + 7];
|
||||
if (size > 0 && offset + 8 + size <= raw.length) {
|
||||
const frameData = new TextDecoder().decode(raw.slice(offset + 8, offset + 8 + size));
|
||||
const frameLines = frameData.split('\n');
|
||||
for (const line of frameLines) {
|
||||
if (line.trim()) {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
offset += 8 + size;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: decode remaining as plain text
|
||||
const remaining = new TextDecoder().decode(raw.slice(offset));
|
||||
const remainingLines = remaining.split('\n');
|
||||
for (const line of remainingLines) {
|
||||
if (line.trim()) {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll updater container logs and status for progress tracking.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !auth.isAdmin) {
|
||||
return json({ error: 'Admin access required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const containerId = url.searchParams.get('id');
|
||||
if (!containerId) {
|
||||
return json({ error: 'Container ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check container state
|
||||
const inspectResponse = await localDockerFetch(`/containers/${containerId}/json`);
|
||||
|
||||
if (!inspectResponse.ok) {
|
||||
if (inspectResponse.status === 404) {
|
||||
// Container removed (AutoRemove after exit)
|
||||
return json({ logs: '', status: 'removed' });
|
||||
}
|
||||
return json({ error: 'Failed to inspect container' }, { status: 500 });
|
||||
}
|
||||
|
||||
const info = await inspectResponse.json() as {
|
||||
State?: { Status: string; ExitCode: number; Running: boolean };
|
||||
};
|
||||
|
||||
const status = info.State?.Running ? 'running' : 'exited';
|
||||
const exitCode = info.State?.ExitCode ?? 0;
|
||||
|
||||
// Fetch logs
|
||||
const logsResponse = await localDockerFetch(
|
||||
`/containers/${containerId}/logs?stdout=true&stderr=true×tamps=false`
|
||||
);
|
||||
|
||||
let logs = '';
|
||||
if (logsResponse.ok && logsResponse.body) {
|
||||
const rawBytes = new Uint8Array(await logsResponse.arrayBuffer());
|
||||
logs = stripDockerLogHeaders(rawBytes);
|
||||
}
|
||||
|
||||
return json({ logs, status, exitCode });
|
||||
} catch (err) {
|
||||
return json({ error: 'Failed to fetch progress: ' + String(err) }, { status: 500 });
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -128,7 +128,7 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
|
||||
|
||||
// Only write if we have a valid path
|
||||
if (!envFilePath) {
|
||||
return json({ error: 'Stack directory not found' }, { status: 404 });
|
||||
return json({ success: true });
|
||||
}
|
||||
|
||||
let content = body.content;
|
||||
|
||||
Reference in new issue
Block a user