1.0.16
This commit is contained in:
1 parent
1cb47eaa9c
commit
48b9bde8ae
10 files changed
+291
-145
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dockhand",
|
"name": "dockhand",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.15",
|
"version": "1.0.16",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bunx --bun vite dev",
|
"dev": "bunx --bun vite dev",
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "1.0.16",
|
||||||
|
"date": "2026-02-09",
|
||||||
|
"changes": [
|
||||||
|
{ "type": "feature", "text": "Support Docker Compose override files when deploying stacks" },
|
||||||
|
{ "type": "fix", "text": "Fix Hawser stack deploy failing when compose file not present on remote host" },
|
||||||
|
{ "type": "fix", "text": "Fix Hawser Standard TLS test connection sending HTTP to HTTPS server" },
|
||||||
|
{ "type": "fix", "text": "Fix .env variables not applied on save & redeploy" },
|
||||||
|
{ "type": "fix", "text": "Fix single Hawser node failure cascading offline state to all environments" }
|
||||||
|
],
|
||||||
|
"imageTag": "fnsys/dockhand:v1.0.16"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.15",
|
"version": "1.0.15",
|
||||||
"date": "2026-02-08",
|
"date": "2026-02-08",
|
||||||
|
|||||||
+24
-10
@@ -549,16 +549,26 @@ export async function dockerFetch(
|
|||||||
if (config.type === 'https') {
|
if (config.type === 'https') {
|
||||||
const tlsOptions: Record<string, unknown> = {};
|
const tlsOptions: Record<string, unknown> = {};
|
||||||
|
|
||||||
// DISABLE TLS SESSION CACHING: Bun reuses TLS sessions across different hosts,
|
// Detect if mutual TLS (client certificate authentication) is in use
|
||||||
// which causes client certificate mismatches in mTLS scenarios. By setting
|
const isMtls = !!(config.cert && config.key);
|
||||||
// sessionTimeout to 0, we force a fresh TLS handshake for every connection.
|
|
||||||
tlsOptions.sessionTimeout = 0;
|
|
||||||
|
|
||||||
// Set explicit servername for SNI - helps isolate TLS contexts per host
|
if (isMtls) {
|
||||||
|
// mTLS: Disable session caching to prevent Bun from reusing a TLS session
|
||||||
|
// with wrong client certificates (pool key doesn't include certs)
|
||||||
|
tlsOptions.sessionTimeout = 0;
|
||||||
|
} else {
|
||||||
|
// Non-mTLS HTTPS (CA-only or skip-verify): Allow short-lived session reuse.
|
||||||
|
// Without this, every fetch allocates a new native TLS context in BoringSSL.
|
||||||
|
// Native memory (mmap) is never returned to the OS, causing RSS to grow
|
||||||
|
// continuously in long-running subprocesses (metrics, events).
|
||||||
|
// 30s allows sessions to be reused within one metrics cycle, then expire.
|
||||||
|
tlsOptions.sessionTimeout = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set explicit servername for SNI - isolates TLS contexts per host
|
||||||
tlsOptions.servername = config.host;
|
tlsOptions.servername = config.host;
|
||||||
|
|
||||||
// Load CA certificate (just this environment's CA, not composite)
|
// Load CA certificate (just this environment's CA, not composite)
|
||||||
// The sessionTimeout=0 should prevent session reuse across hosts
|
|
||||||
if (config.ca) {
|
if (config.ca) {
|
||||||
tlsOptions.ca = [config.ca];
|
tlsOptions.ca = [config.ca];
|
||||||
}
|
}
|
||||||
@@ -581,10 +591,14 @@ export async function dockerFetch(
|
|||||||
if (Object.keys(tlsOptions).length > 0) {
|
if (Object.keys(tlsOptions).length > 0) {
|
||||||
// @ts-ignore - Bun supports tls options with string certs
|
// @ts-ignore - Bun supports tls options with string certs
|
||||||
finalOptions.tls = tlsOptions;
|
finalOptions.tls = tlsOptions;
|
||||||
// Force new connection for each request to prevent Bun from reusing
|
if (isMtls) {
|
||||||
// a TLS session with wrong client certificates (pool key doesn't include certs)
|
// mTLS: Force new connection for each request to prevent Bun from
|
||||||
// @ts-ignore - Bun supports keepalive option
|
// reusing a TLS session with wrong client certificates
|
||||||
finalOptions.keepalive = false;
|
// @ts-ignore - Bun supports keepalive option
|
||||||
|
finalOptions.keepalive = false;
|
||||||
|
}
|
||||||
|
// Non-mTLS: Use Bun's default keepalive (connection reuse) to avoid
|
||||||
|
// allocating a new native TLS context per request
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional verbose TLS debugging
|
// Optional verbose TLS debugging
|
||||||
|
|||||||
+101
-2
@@ -765,6 +765,26 @@ interface ComposeCommandOptions {
|
|||||||
composeFileName?: string;
|
composeFileName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a Docker Compose override file alongside the main compose file.
|
||||||
|
* Docker Compose auto-discovers these when no -f flag is used, but when -f is required
|
||||||
|
* we need to explicitly include the override file.
|
||||||
|
*/
|
||||||
|
function findComposeOverrideFile(stackDir: string, composeFileName: string): string | null {
|
||||||
|
const overrideMap: Record<string, string[]> = {
|
||||||
|
'compose.yaml': ['compose.override.yaml', 'compose.override.yml'],
|
||||||
|
'compose.yml': ['compose.override.yaml', 'compose.override.yml'],
|
||||||
|
'docker-compose.yaml': ['docker-compose.override.yaml', 'docker-compose.override.yml'],
|
||||||
|
'docker-compose.yml': ['docker-compose.override.yaml', 'docker-compose.override.yml'],
|
||||||
|
};
|
||||||
|
const candidates = overrideMap[composeFileName] || [];
|
||||||
|
for (const name of candidates) {
|
||||||
|
const fullPath = join(stackDir, name);
|
||||||
|
if (existsSync(fullPath)) return fullPath;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a docker compose command locally via Bun.spawn.
|
* Execute a docker compose command locally via Bun.spawn.
|
||||||
*
|
*
|
||||||
@@ -910,7 +930,38 @@ async function executeLocalCompose(
|
|||||||
// Build command based on operation
|
// Build command based on operation
|
||||||
// If we have modified compose content (host path translation), use stdin instead of file
|
// If we have modified compose content (host path translation), use stdin instead of file
|
||||||
const useStdin = finalComposeContent !== composeContent;
|
const useStdin = finalComposeContent !== composeContent;
|
||||||
const args = ['docker', 'compose', '-p', stackName, '-f', useStdin ? '-' : composeFile];
|
const args = ['docker', 'compose', '-p', stackName];
|
||||||
|
|
||||||
|
// Temp file for path-translated override content (cleaned up in finally block)
|
||||||
|
let tempOverridePath: string | undefined;
|
||||||
|
|
||||||
|
if (useStdin) {
|
||||||
|
// Host path translation: must pipe modified content via stdin
|
||||||
|
args.push('-f', '-');
|
||||||
|
// Also include override file if it exists (needs path translation too)
|
||||||
|
const overrideFile = findComposeOverrideFile(stackDir, basename(composeFile));
|
||||||
|
if (overrideFile) {
|
||||||
|
let overrideContent = await Bun.file(overrideFile).text();
|
||||||
|
if (getHostDataDir()) {
|
||||||
|
const rewrite = rewriteComposeVolumePaths(overrideContent, stackDir);
|
||||||
|
if (rewrite.modified) overrideContent = rewrite.content;
|
||||||
|
}
|
||||||
|
tempOverridePath = join(stackDir, '.compose.override.translated.yaml');
|
||||||
|
await Bun.write(tempOverridePath, overrideContent);
|
||||||
|
args.push('-f', tempOverridePath);
|
||||||
|
console.log(`${logPrefix} Including override file (path-translated): ${basename(overrideFile)}`);
|
||||||
|
}
|
||||||
|
} else if (customComposePath) {
|
||||||
|
// Custom path (imported/adopted stacks): must use -f to point to non-standard location
|
||||||
|
args.push('-f', composeFile);
|
||||||
|
const overrideFile = findComposeOverrideFile(stackDir, basename(composeFile));
|
||||||
|
if (overrideFile) {
|
||||||
|
args.push('-f', overrideFile);
|
||||||
|
console.log(`${logPrefix} Including override file: ${basename(overrideFile)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// else: internal stack without path translation - no -f needed.
|
||||||
|
// Docker Compose auto-discovers compose.yaml + compose.override.yaml from cwd.
|
||||||
|
|
||||||
// Always auto-detect .env in compose directory (defaultEnvPath already defined above)
|
// Always auto-detect .env in compose directory (defaultEnvPath already defined above)
|
||||||
if (existsSync(defaultEnvPath)) {
|
if (existsSync(defaultEnvPath)) {
|
||||||
@@ -1078,6 +1129,15 @@ async function executeLocalCompose(
|
|||||||
error: `Failed to run docker compose ${operation}: ${err.message}`
|
error: `Failed to run docker compose ${operation}: ${err.message}`
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
// Cleanup temp override file from host path translation
|
||||||
|
if (tempOverridePath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempOverridePath);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup TLS temp directory (always runs, even on exception)
|
// Cleanup TLS temp directory (always runs, even on exception)
|
||||||
if (tlsCertDir) {
|
if (tlsCertDir) {
|
||||||
activeTlsDirs.delete(tlsCertDir);
|
activeTlsDirs.delete(tlsCertDir);
|
||||||
@@ -1293,6 +1353,24 @@ async function executeComposeCommand(
|
|||||||
console.warn(`[Stack:${stackName}] Failed to read .env file at ${envPath}:`, err);
|
console.warn(`[Stack:${stackName}] Failed to read .env file at ${envPath}:`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include compose override file if it exists alongside the compose file
|
||||||
|
let hawserStackFiles = stackFiles;
|
||||||
|
const composeDir = workingDir || (composePath ? dirname(composePath) : null);
|
||||||
|
const composeBaseName = composePath ? basename(composePath) : 'compose.yaml';
|
||||||
|
if (composeDir) {
|
||||||
|
const overridePath = findComposeOverrideFile(composeDir, composeBaseName);
|
||||||
|
if (overridePath) {
|
||||||
|
try {
|
||||||
|
const overrideContent = await Bun.file(overridePath).text();
|
||||||
|
hawserStackFiles = { ...(hawserStackFiles || {}), [basename(overridePath)]: overrideContent };
|
||||||
|
console.log(`[Stack:${stackName}] Including override file for Hawser: ${basename(overridePath)}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[Stack:${stackName}] Failed to read override file at ${overridePath}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return executeComposeViaHawser(
|
return executeComposeViaHawser(
|
||||||
operation,
|
operation,
|
||||||
stackName,
|
stackName,
|
||||||
@@ -1302,7 +1380,7 @@ async function executeComposeCommand(
|
|||||||
secretVars,
|
secretVars,
|
||||||
forceRecreate,
|
forceRecreate,
|
||||||
removeVolumes,
|
removeVolumes,
|
||||||
stackFiles,
|
hawserStackFiles,
|
||||||
serviceName,
|
serviceName,
|
||||||
composeFileName
|
composeFileName
|
||||||
);
|
);
|
||||||
@@ -2037,6 +2115,27 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
|||||||
workingDir = await getStackDir(name, envId);
|
workingDir = await getStackDir(name, envId);
|
||||||
console.log(`${logPrefix} Using internal stack directory:`, workingDir);
|
console.log(`${logPrefix} Using internal stack directory:`, workingDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Hawser deployments: include compose and .env in stackFiles
|
||||||
|
// Hawser writes files from the files map to disk at STACKS_DIR/{stackName}/
|
||||||
|
if (!stackFiles) {
|
||||||
|
stackFiles = {};
|
||||||
|
}
|
||||||
|
const composeFilename = actualComposePath ? basename(actualComposePath) : 'compose.yaml';
|
||||||
|
if (!stackFiles[composeFilename]) {
|
||||||
|
stackFiles[composeFilename] = compose;
|
||||||
|
console.log(`${logPrefix} Added ${composeFilename} to stackFiles for Hawser (${compose.length} chars)`);
|
||||||
|
}
|
||||||
|
if (actualEnvPath && existsSync(actualEnvPath) && !stackFiles['.env']) {
|
||||||
|
try {
|
||||||
|
const envContent = await Bun.file(actualEnvPath).text();
|
||||||
|
stackFiles['.env'] = envContent;
|
||||||
|
console.log(`${logPrefix} Added .env to stackFiles for Hawser (${envContent.length} chars)`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`${logPrefix} Failed to read .env file at ${actualEnvPath}:`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${logPrefix} Compose content length:`, compose.length, 'chars');
|
console.log(`${logPrefix} Compose content length:`, compose.length, 'chars');
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface DockerClientConfig {
|
|||||||
ca?: string;
|
ca?: string;
|
||||||
cert?: string;
|
cert?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
|
skipVerify?: boolean;
|
||||||
hawserToken?: string;
|
hawserToken?: string;
|
||||||
environmentId?: number;
|
environmentId?: number;
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,7 @@ async function getDockerConfig(envId?: number | null): Promise<DockerClientConfi
|
|||||||
ca: env.tlsCa || undefined,
|
ca: env.tlsCa || undefined,
|
||||||
cert: env.tlsCert || undefined,
|
cert: env.tlsCert || undefined,
|
||||||
key: env.tlsKey || undefined,
|
key: env.tlsKey || undefined,
|
||||||
|
skipVerify: env.tlsSkipVerify || undefined,
|
||||||
hawserToken: env.connectionType === 'hawser-standard' ? env.hawserToken || undefined : undefined
|
hawserToken: env.connectionType === 'hawser-standard' ? env.hawserToken || undefined : undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -288,14 +290,15 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
const fetchOpts: any = { headers: inspectHeaders };
|
const fetchOpts: any = { headers: inspectHeaders };
|
||||||
if (config.type === 'https') {
|
if (config.type === 'https') {
|
||||||
fetchOpts.tls = {
|
fetchOpts.tls = {
|
||||||
sessionTimeout: 0, // Disable TLS session caching for mTLS
|
sessionTimeout: 0,
|
||||||
servername: config.host,
|
servername: config.host,
|
||||||
rejectUnauthorized: true
|
rejectUnauthorized: !config.skipVerify
|
||||||
};
|
};
|
||||||
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
||||||
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
||||||
if (config.key) fetchOpts.tls.key = config.key;
|
if (config.key) fetchOpts.tls.key = config.key;
|
||||||
fetchOpts.keepalive = false;
|
fetchOpts.keepalive = false;
|
||||||
|
if (process.env.DEBUG_TLS) fetchOpts.verbose = true;
|
||||||
}
|
}
|
||||||
inspectResponse = await fetch(inspectUrl, fetchOpts);
|
inspectResponse = await fetch(inspectUrl, fetchOpts);
|
||||||
}
|
}
|
||||||
@@ -355,14 +358,15 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
};
|
};
|
||||||
if (config.type === 'https') {
|
if (config.type === 'https') {
|
||||||
fetchOpts.tls = {
|
fetchOpts.tls = {
|
||||||
sessionTimeout: 0, // Disable TLS session caching for mTLS
|
sessionTimeout: 0,
|
||||||
servername: config.host,
|
servername: config.host,
|
||||||
rejectUnauthorized: true
|
rejectUnauthorized: !config.skipVerify
|
||||||
};
|
};
|
||||||
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
||||||
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
||||||
if (config.key) fetchOpts.tls.key = config.key;
|
if (config.key) fetchOpts.tls.key = config.key;
|
||||||
fetchOpts.keepalive = false;
|
fetchOpts.keepalive = false;
|
||||||
|
if (process.env.DEBUG_TLS) fetchOpts.verbose = true;
|
||||||
}
|
}
|
||||||
response = await fetch(logsUrl, fetchOpts);
|
response = await fetch(logsUrl, fetchOpts);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,6 +314,11 @@ async function getEnvironmentStatsProgressive(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return images;
|
return images;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
envStats.loading!.images = false;
|
||||||
|
onPartialUpdate({ id: env.id, loading: { ...envStats.loading! } });
|
||||||
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const networksPromise = withTimeout(listNetworks(env.id).catch(() => []), 10000, [])
|
const networksPromise = withTimeout(listNetworks(env.id).catch(() => []), 10000, [])
|
||||||
@@ -328,6 +333,11 @@ async function getEnvironmentStatsProgressive(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return networks;
|
return networks;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
envStats.loading!.networks = false;
|
||||||
|
onPartialUpdate({ id: env.id, loading: { ...envStats.loading! } });
|
||||||
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const stacksPromise = withTimeout(listComposeStacks(env.id).catch(() => []), 10000, [])
|
const stacksPromise = withTimeout(listComposeStacks(env.id).catch(() => []), 10000, [])
|
||||||
@@ -345,6 +355,11 @@ async function getEnvironmentStatsProgressive(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return stacks;
|
return stacks;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
envStats.loading!.stacks = false;
|
||||||
|
onPartialUpdate({ id: env.id, loading: { ...envStats.loading! } });
|
||||||
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// PHASE 3: Disk usage (slow - includes volumes) - uses cache for better performance
|
// PHASE 3: Disk usage (slow - includes volumes) - uses cache for better performance
|
||||||
@@ -390,6 +405,12 @@ async function getEnvironmentStatsProgressive(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return diskUsage;
|
return diskUsage;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
envStats.loading!.volumes = false;
|
||||||
|
envStats.loading!.diskUsage = false;
|
||||||
|
onPartialUpdate({ id: env.id, loading: { ...envStats.loading! } });
|
||||||
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// PHASE 4: Top containers (slow - requires per-container stats)
|
// PHASE 4: Top containers (slow - requires per-container stats)
|
||||||
@@ -436,10 +457,14 @@ async function getEnvironmentStatsProgressive(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return envStats.topContainers;
|
return envStats.topContainers;
|
||||||
|
}).catch(() => {
|
||||||
|
envStats.loading!.topContainers = false;
|
||||||
|
onPartialUpdate({ id: env.id, loading: { ...envStats.loading! } });
|
||||||
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for all to complete
|
// Wait for all to complete
|
||||||
await Promise.all([
|
await Promise.allSettled([
|
||||||
containersPromise,
|
containersPromise,
|
||||||
imagesPromise,
|
imagesPromise,
|
||||||
networksPromise,
|
networksPromise,
|
||||||
@@ -572,7 +597,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Wait for all to complete
|
// Wait for all to complete
|
||||||
await Promise.all(promises);
|
await Promise.allSettled(promises);
|
||||||
|
|
||||||
// Send done event and close
|
// Send done event and close
|
||||||
if (!controllerClosed) {
|
if (!controllerClosed) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getEnvironment, updateEnvironment } from '$lib/server/db';
|
import { getEnvironment, updateEnvironment } from '$lib/server/db';
|
||||||
import { getDockerInfo } from '$lib/server/docker';
|
import { getDockerInfo, getHawserInfo } from '$lib/server/docker';
|
||||||
import { edgeConnections, isEdgeConnected } from '$lib/server/hawser';
|
import { edgeConnections, isEdgeConnected } from '$lib/server/hawser';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params }) => {
|
export const POST: RequestHandler = async ({ params }) => {
|
||||||
@@ -75,28 +75,15 @@ export const POST: RequestHandler = async ({ params }) => {
|
|||||||
// For Hawser Standard mode, fetch Hawser info (Edge mode handled above with early return)
|
// For Hawser Standard mode, fetch Hawser info (Edge mode handled above with early return)
|
||||||
let hawserInfo = null;
|
let hawserInfo = null;
|
||||||
if (env.connectionType === 'hawser-standard') {
|
if (env.connectionType === 'hawser-standard') {
|
||||||
// Standard mode: fetch via HTTP
|
|
||||||
try {
|
try {
|
||||||
const protocol = env.useTls ? 'https' : 'http';
|
hawserInfo = await getHawserInfo(id);
|
||||||
const headers: Record<string, string> = {};
|
if (hawserInfo?.hawserVersion) {
|
||||||
if (env.hawserToken) {
|
await updateEnvironment(id, {
|
||||||
headers['X-Hawser-Token'] = env.hawserToken;
|
hawserVersion: hawserInfo.hawserVersion,
|
||||||
}
|
hawserAgentId: hawserInfo.agentId,
|
||||||
const hawserResp = await fetch(`${protocol}://${env.host}:${env.port || 2376}/_hawser/info`, {
|
hawserAgentName: hawserInfo.agentName,
|
||||||
headers,
|
hawserLastSeen: new Date().toISOString()
|
||||||
signal: AbortSignal.timeout(5000)
|
});
|
||||||
});
|
|
||||||
if (hawserResp.ok) {
|
|
||||||
hawserInfo = await hawserResp.json();
|
|
||||||
// Save hawser info to database
|
|
||||||
if (hawserInfo?.hawserVersion) {
|
|
||||||
await updateEnvironment(id, {
|
|
||||||
hawserVersion: hawserInfo.hawserVersion,
|
|
||||||
hawserAgentId: hawserInfo.agentId,
|
|
||||||
hawserAgentName: hawserInfo.agentName,
|
|
||||||
hawserLastSeen: new Date().toISOString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Hawser info fetch failed, continue without it
|
// Hawser info fetch failed, continue without it
|
||||||
|
|||||||
@@ -14,6 +14,39 @@ interface TestConnectionRequest {
|
|||||||
hawserToken?: string;
|
hawserToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanPem(pem: string): string {
|
||||||
|
return pem
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTlsOptions(config: TestConnectionRequest): Record<string, any> | undefined {
|
||||||
|
const protocol = config.protocol || 'http';
|
||||||
|
if (protocol !== 'https') return undefined;
|
||||||
|
|
||||||
|
const tls: Record<string, any> = {
|
||||||
|
sessionTimeout: 0,
|
||||||
|
servername: config.host
|
||||||
|
};
|
||||||
|
if (config.tlsSkipVerify) {
|
||||||
|
tls.rejectUnauthorized = false;
|
||||||
|
} else {
|
||||||
|
tls.rejectUnauthorized = true;
|
||||||
|
if (config.tlsCa) {
|
||||||
|
tls.ca = [cleanPem(config.tlsCa)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.tlsCert) {
|
||||||
|
tls.cert = [cleanPem(config.tlsCert)];
|
||||||
|
}
|
||||||
|
if (config.tlsKey) {
|
||||||
|
tls.key = cleanPem(config.tlsKey);
|
||||||
|
}
|
||||||
|
return tls;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Docker connection with provided configuration (without saving to database)
|
* Test Docker connection with provided configuration (without saving to database)
|
||||||
*/
|
*/
|
||||||
@@ -55,78 +88,23 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add Hawser token if present
|
|
||||||
if (config.connectionType === 'hawser-standard' && config.hawserToken) {
|
if (config.connectionType === 'hawser-standard' && config.hawserToken) {
|
||||||
headers['X-Hawser-Token'] = config.hawserToken;
|
headers['X-Hawser-Token'] = config.hawserToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For HTTPS with custom CA, client certs, or skip verification, use subprocess to avoid Vite dev server TLS issues
|
const fetchOptions: any = {
|
||||||
if (protocol === 'https' && (config.tlsCa || config.tlsCert || config.tlsSkipVerify)) {
|
headers,
|
||||||
// Clean PEM content (remove extra whitespace)
|
signal: AbortSignal.timeout(10000),
|
||||||
const cleanPem = (pem: string) => pem
|
keepalive: false
|
||||||
.split('\n')
|
};
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
// Pass config as base64-encoded JSON to avoid escaping issues
|
const tls = buildTlsOptions(config);
|
||||||
const tlsConfig = {
|
if (tls) {
|
||||||
url: `https://${host}:${port}/info`,
|
fetchOptions.tls = tls;
|
||||||
headers,
|
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
|
||||||
tlsSkipVerify: config.tlsSkipVerify || false,
|
|
||||||
ca: config.tlsCa && !config.tlsSkipVerify ? cleanPem(config.tlsCa) : null,
|
|
||||||
cert: config.tlsCert ? cleanPem(config.tlsCert) : null,
|
|
||||||
key: config.tlsKey ? cleanPem(config.tlsKey) : null,
|
|
||||||
host
|
|
||||||
};
|
|
||||||
const configBase64 = Buffer.from(JSON.stringify(tlsConfig)).toString('base64');
|
|
||||||
|
|
||||||
// Inline script with config embedded (bun -e doesn't pass argv correctly)
|
|
||||||
const scriptContent = `
|
|
||||||
const config = JSON.parse(Buffer.from('${configBase64}', 'base64').toString());
|
|
||||||
try {
|
|
||||||
const tls = {
|
|
||||||
sessionTimeout: 0,
|
|
||||||
servername: config.host,
|
|
||||||
rejectUnauthorized: !config.tlsSkipVerify
|
|
||||||
};
|
|
||||||
if (config.ca) tls.ca = [config.ca];
|
|
||||||
if (config.cert) tls.cert = [config.cert];
|
|
||||||
if (config.key) tls.key = config.key;
|
|
||||||
const response = await fetch(config.url, {
|
|
||||||
headers: config.headers,
|
|
||||||
tls,
|
|
||||||
keepalive: false
|
|
||||||
});
|
|
||||||
const body = await response.text();
|
|
||||||
console.log(JSON.stringify({ status: response.status, body }));
|
|
||||||
} catch (e) {
|
|
||||||
console.log(JSON.stringify({ error: e.message }));
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
const proc = Bun.spawn(['bun', '-e', scriptContent], { stdout: 'pipe', stderr: 'pipe' });
|
|
||||||
const output = await new Response(proc.stdout).text();
|
|
||||||
const stderr = await new Response(proc.stderr).text();
|
|
||||||
|
|
||||||
if (!output.trim()) {
|
|
||||||
throw new Error(stderr || 'Empty response from TLS test subprocess');
|
|
||||||
}
|
|
||||||
const result = JSON.parse(output.trim());
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
response = new Response(result.body, {
|
|
||||||
status: result.status,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
response = await fetch(url, {
|
|
||||||
headers,
|
|
||||||
signal: AbortSignal.timeout(10000)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
response = await fetch(url, fetchOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -141,17 +119,25 @@ try {
|
|||||||
if (config.connectionType === 'hawser-standard' && config.host) {
|
if (config.connectionType === 'hawser-standard' && config.host) {
|
||||||
try {
|
try {
|
||||||
const protocol = config.protocol || 'http';
|
const protocol = config.protocol || 'http';
|
||||||
const headers: Record<string, string> = {};
|
const hawserHeaders: Record<string, string> = {};
|
||||||
if (config.hawserToken) {
|
if (config.hawserToken) {
|
||||||
headers['X-Hawser-Token'] = config.hawserToken;
|
hawserHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||||
}
|
}
|
||||||
const hawserResp = await fetch(
|
const hawserUrl = `${protocol}://${config.host}:${config.port || 2375}/_hawser/info`;
|
||||||
`${protocol}://${config.host}:${config.port || 2375}/_hawser/info`,
|
|
||||||
{
|
const fetchOptions: any = {
|
||||||
headers,
|
headers: hawserHeaders,
|
||||||
signal: AbortSignal.timeout(5000)
|
signal: AbortSignal.timeout(5000),
|
||||||
}
|
keepalive: false
|
||||||
);
|
};
|
||||||
|
|
||||||
|
const tls = buildTlsOptions(config);
|
||||||
|
if (tls) {
|
||||||
|
fetchOptions.tls = tls;
|
||||||
|
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hawserResp = await fetch(hawserUrl, fetchOptions);
|
||||||
if (hawserResp.ok) {
|
if (hawserResp.ok) {
|
||||||
hawserInfo = await hawserResp.json();
|
hawserInfo = await hawserResp.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface DockerClientConfig {
|
|||||||
ca?: string;
|
ca?: string;
|
||||||
cert?: string;
|
cert?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
|
skipVerify?: boolean;
|
||||||
hawserToken?: string;
|
hawserToken?: string;
|
||||||
environmentId?: number;
|
environmentId?: number;
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,7 @@ async function getDockerConfig(envId?: number | null): Promise<DockerClientConfi
|
|||||||
ca: env.tlsCa || undefined,
|
ca: env.tlsCa || undefined,
|
||||||
cert: env.tlsCert || undefined,
|
cert: env.tlsCert || undefined,
|
||||||
key: env.tlsKey || undefined,
|
key: env.tlsKey || undefined,
|
||||||
|
skipVerify: env.tlsSkipVerify || undefined,
|
||||||
hawserToken: env.connectionType === 'hawser-standard' ? env.hawserToken || undefined : undefined
|
hawserToken: env.connectionType === 'hawser-standard' ? env.hawserToken || undefined : undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -432,13 +434,21 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
|||||||
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
|
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||||
|
|
||||||
// Build fetch options - only include tls for HTTPS
|
// Build fetch options - only include tls for HTTPS
|
||||||
const fetchOptions: RequestInit & { tls?: unknown } = {
|
const fetchOptions: any = {
|
||||||
headers: inspectHeaders,
|
headers: inspectHeaders,
|
||||||
signal: AbortSignal.timeout(30000) // 30 second timeout for inspect
|
signal: AbortSignal.timeout(30000)
|
||||||
};
|
};
|
||||||
if (config.type === 'https' && config.ca) {
|
if (config.type === 'https') {
|
||||||
// @ts-ignore - Bun TLS option
|
fetchOptions.tls = {
|
||||||
fetchOptions.tls = { ca: config.ca, cert: config.cert, key: config.key };
|
sessionTimeout: 0,
|
||||||
|
servername: config.host,
|
||||||
|
rejectUnauthorized: !config.skipVerify
|
||||||
|
};
|
||||||
|
if (config.ca) fetchOptions.tls.ca = [config.ca];
|
||||||
|
if (config.cert) fetchOptions.tls.cert = [config.cert];
|
||||||
|
if (config.key) fetchOptions.tls.key = config.key;
|
||||||
|
fetchOptions.keepalive = false;
|
||||||
|
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
inspectResponse = await fetch(inspectUrl, fetchOptions);
|
inspectResponse = await fetch(inspectUrl, fetchOptions);
|
||||||
@@ -470,13 +480,21 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
|||||||
|
|
||||||
// For logs streaming, use the cleanup abort controller without a timeout
|
// For logs streaming, use the cleanup abort controller without a timeout
|
||||||
// (the stream needs to stay open indefinitely)
|
// (the stream needs to stay open indefinitely)
|
||||||
const fetchOptions: RequestInit & { tls?: unknown } = {
|
const fetchOptions: any = {
|
||||||
headers: logsHeaders,
|
headers: logsHeaders,
|
||||||
signal: abortController.signal
|
signal: abortController.signal
|
||||||
};
|
};
|
||||||
if (config.type === 'https' && config.ca) {
|
if (config.type === 'https') {
|
||||||
// @ts-ignore - Bun TLS option
|
fetchOptions.tls = {
|
||||||
fetchOptions.tls = { ca: config.ca, cert: config.cert, key: config.key };
|
sessionTimeout: 0,
|
||||||
|
servername: config.host,
|
||||||
|
rejectUnauthorized: !config.skipVerify
|
||||||
|
};
|
||||||
|
if (config.ca) fetchOptions.tls.ca = [config.ca];
|
||||||
|
if (config.cert) fetchOptions.tls.cert = [config.cert];
|
||||||
|
if (config.key) fetchOptions.tls.key = config.key;
|
||||||
|
fetchOptions.keepalive = false;
|
||||||
|
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
logsResponse = await fetch(logsUrl, fetchOptions);
|
logsResponse = await fetch(logsUrl, fetchOptions);
|
||||||
|
|||||||
@@ -284,7 +284,7 @@
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
throw new Error(data.error || 'Failed to move files');
|
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to move files');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -766,7 +766,7 @@ services:
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(data.error || 'Failed to load compose file');
|
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to load compose file');
|
||||||
}
|
}
|
||||||
|
|
||||||
composeContent = data.content;
|
composeContent = data.content;
|
||||||
@@ -931,7 +931,7 @@ services:
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
throw new Error(data.error || 'Failed to create stack');
|
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to create stack');
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
@@ -1038,22 +1038,7 @@ services:
|
|||||||
requestBody.moveFromDir = moveFromDir;
|
requestBody.moveFromDir = moveFromDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save compose file (with optional paths)
|
// Save env files BEFORE compose to ensure deploy reads fresh values
|
||||||
const response = await fetch(
|
|
||||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId),
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(requestBody)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || 'Failed to save compose file');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save raw content to .env file (non-secrets only, comments preserved)
|
// Save raw content to .env file (non-secrets only, comments preserved)
|
||||||
const rawEnvResponse = await fetch(
|
const rawEnvResponse = await fetch(
|
||||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId),
|
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId),
|
||||||
@@ -1066,7 +1051,7 @@ services:
|
|||||||
|
|
||||||
if (!rawEnvResponse.ok) {
|
if (!rawEnvResponse.ok) {
|
||||||
const rawEnvError = await rawEnvResponse.json().catch(() => ({ error: 'Failed to save environment file' }));
|
const rawEnvError = await rawEnvResponse.json().catch(() => ({ error: 'Failed to save environment file' }));
|
||||||
throw new Error(rawEnvError.error || 'Failed to save environment file');
|
throw new Error((typeof rawEnvError.error === 'string' ? rawEnvError.error : rawEnvError.message) || 'Failed to save environment file');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save only secrets to DB (non-secrets are in the .env file written above)
|
// Save only secrets to DB (non-secrets are in the .env file written above)
|
||||||
@@ -1098,6 +1083,22 @@ services:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save compose file (with optional paths) - after env so deploy reads fresh .env
|
||||||
|
const response = await fetch(
|
||||||
|
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId),
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to save compose file');
|
||||||
|
}
|
||||||
|
|
||||||
isDirty = false; // Reset dirty flag after successful save
|
isDirty = false; // Reset dirty flag after successful save
|
||||||
onSuccess();
|
onSuccess();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user