This commit is contained in:
jarek committed 2026-05-30 08:42:21 +02:00
1 parent e7100f8926
commit c8b3acc07e
49 files changed
+1414 -492

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
/**
* Merge container env vars with new image env vars during auto-update.
* Image-baked vars get updated to the new image's values.
* User-set vars (not present in old image) are preserved.
* Env vars removed from the new image are dropped.
*/
export function mergeImageEnvVars(
containerEnv: string[],
oldImageEnv: string[],
newImageEnv: string[]
): string[] {
const getKey = (entry: string) => entry.split('=')[0];
const oldImageKeys = new Set(oldImageEnv.map(getKey));
const merged: string[] = [];
// Keep user-set env vars (key not present in old image)
for (const entry of containerEnv) {
if (!oldImageKeys.has(getKey(entry))) {
merged.push(entry);
}
}
// Add all new image env vars (updates changed values, adds new ones)
for (const entry of newImageEnv) {
const key = getKey(entry);
// Skip if user already set this key (user wins)
if (!merged.some(e => getKey(e) === key)) {
merged.push(entry);
}
}
return merged;
}
+13
View File
@@ -7,6 +7,7 @@
* - dockhand.notify=false — Suppress notifications for this container's events
* - dockhand.url=<url> — Custom clickable URL displayed alongside container ports
* - dockhand.port.<hostPort>.url=<url> — Override the click URL for a specific published port
* - dockhand.order=<int> — Controls display order within a stack (lower = first, default 0)
*
* All label values are case-insensitive and accept: true/yes/1 and false/no/0.
* The opt-out model means labels override DB settings (label wins).
@@ -18,6 +19,7 @@ export const DOCKHAND_LABELS = {
HIDDEN: 'dockhand.hidden',
NOTIFY: 'dockhand.notify',
URL: 'dockhand.url',
ORDER: 'dockhand.order',
} as const;
const TRUTHY_VALUES = new Set(['true', 'yes', '1']);
@@ -84,6 +86,17 @@ export function getCustomUrl(labels: Record<string, string> | undefined | null):
return value?.trim() || undefined;
}
/**
* Get the sort order value from dockhand.order label.
* Returns the parsed integer, or 0 for missing/invalid values.
*/
export function getOrderValue(labels: Record<string, string> | undefined | null): number {
const value = getLabel(labels, DOCKHAND_LABELS.ORDER);
if (value == null) return 0;
const parsed = parseInt(value.trim(), 10);
return Number.isNaN(parsed) ? 0 : parsed;
}
/**
* Extract all Dockhand label states from a container's labels.
* Useful for including in API responses so the frontend knows about label overrides.
+32 -8
View File
@@ -6,6 +6,7 @@
*/
import { homedir } from 'node:os';
import { mergeImageEnvVars } from './container-env-merge';
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import * as http from 'node:http';
@@ -1215,13 +1216,26 @@ export async function renameContainer(id: string, newName: string, envId?: numbe
await assertDockerResponse(response);
}
export async function getContainerLogs(id: string, tail = 100, envId?: number | null): Promise<string> {
export async function getContainerLogs(id: string, tail: number | 'all' = 100, envId?: number | null, since?: string, until?: string): Promise<string> {
// Check if container has TTY enabled
const info = await inspectContainer(id, envId);
const hasTty = info.Config?.Tty ?? false;
let query = `stdout=true&stderr=true&timestamps=true`;
if (tail === 'all') {
query += `&tail=all`;
} else {
query += `&tail=${tail}`;
}
if (since) {
query += `&since=${since}`;
}
if (until) {
query += `&until=${until}`;
}
const response = await dockerFetch(
`/containers/${id}/logs?stdout=true&stderr=true&tail=${tail}&timestamps=true`,
`/containers/${id}/logs?${query}`,
{},
envId
);
@@ -1887,15 +1901,17 @@ export async function recreateContainerFromInspect(
HostConfig: hostConfig
};
// 4a. Update image-embedded labels to match the new image.
// Docker's create API uses exactly the labels you pass, ignoring the new image's
// embedded labels. We inspect both old and new images to distinguish image-origin
// labels from user-set labels, then merge accordingly.
// 4a. Update image-embedded labels and env vars to match the new image.
// Docker's create API uses exactly the labels/env you pass, ignoring the new image's
// embedded values. We inspect both old and new images to distinguish image-origin
// values from user-set values, then merge accordingly.
try {
const [oldImageInspect, newImageInspect] = await Promise.all([
inspectImage(config.Image, envId),
inspectImage(newImage, envId)
]);
// Merge labels
const oldImageLabels: Record<string, string> = (oldImageInspect as any)?.Config?.Labels || {};
const newImageLabels: Record<string, string> = (newImageInspect as any)?.Config?.Labels || {};
const containerLabels: Record<string, string> = createConfig.Labels || {};
@@ -1916,9 +1932,17 @@ export async function recreateContainerFromInspect(
createConfig.Labels = mergedLabels;
log?.(`Updated image labels: ${Object.keys(newImageLabels).length} from new image, ${Object.keys(mergedLabels).length} total`);
// Merge env vars (same logic: image-baked vars get updated, user-set vars preserved)
const oldImageEnv: string[] = (oldImageInspect as any)?.Config?.Env || [];
const newImageEnv: string[] = (newImageInspect as any)?.Config?.Env || [];
const containerEnv: string[] = createConfig.Env || [];
createConfig.Env = mergeImageEnvVars(containerEnv, oldImageEnv, newImageEnv);
log?.(`Updated image env vars: ${newImageEnv.length} from new image, ${createConfig.Env.length} total`);
} catch (e) {
log?.(`Warning: could not update image labels: ${e}`);
// Fall through with old labels — non-fatal
log?.(`Warning: could not update image labels/env: ${e}`);
// Fall through with old values — non-fatal
}
// Strip default MemorySwappiness — Podman + cgroupv2 rejects it.
+25
View File
@@ -0,0 +1,25 @@
/**
* Parse .env file content into key-value pairs.
* Preserves values exactly as written — no quote stripping.
* Docker Compose handles its own quote interpretation at runtime.
*/
export function parseEnvVars(content: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.substring(0, eqIndex).trim();
const value = trimmed.substring(eqIndex + 1).trim();
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
result[key] = value;
}
}
return result;
}
+27 -23
View File
@@ -224,6 +224,20 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
// Ensure current UID is resolvable for SSH/git operations
await ensurePasswdEntry(env);
// For HTTPS password/token auth, inject credentials via http.extraHeader env vars
// instead of embedding them in the URL (which leaks via /proc/<pid>/cmdline, #1081).
// Uses GIT_CONFIG_COUNT mechanism (git >= 2.31) to set Authorization header.
if (credential?.authType === 'password' && (credential.username || credential.password)) {
const token = credential.password || '';
const username = credential.username || '';
// Use Basic auth (base64 of username:password) — works with GitHub PATs,
// GitLab tokens, Gitea tokens, and standard username/password combos.
const basicAuth = Buffer.from(`${username}:${token}`).toString('base64');
env.GIT_CONFIG_COUNT = '1';
env.GIT_CONFIG_KEY_0 = 'http.extraHeader';
env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${basicAuth}`;
}
if (credential?.authType === 'ssh' && credential.sshPrivateKey) {
// Write SSH key to /tmp instead of data volume — some filesystems (TrueNAS ZFS,
// NFS, CIFS) silently ignore chmod, leaving the key group-readable (e.g. 0670).
@@ -278,24 +292,20 @@ function cleanupSshKey(credential: GitCredential | null): void {
}
function buildRepoUrl(url: string, credential: GitCredential | null): string {
// For SSH URLs or no auth, return as-is
if (!credential || credential.authType !== 'password' || url.startsWith('git@')) {
return url;
}
// For HTTPS with password auth, embed credentials
try {
const parsed = new URL(url);
if (credential.username) {
parsed.username = credential.username;
// Never embed credentials in the URL — they leak via /proc/<pid>/cmdline (see #1081).
// HTTPS credentials are injected via GIT_CONFIG_COUNT env vars in buildGitEnv().
// Strip any existing credentials from the URL for safety.
if (credential?.authType === 'password' && !url.startsWith('git@')) {
try {
const parsed = new URL(url);
parsed.username = '';
parsed.password = '';
return parsed.toString();
} catch {
return url;
}
if (credential.password) {
parsed.password = credential.password;
}
return parsed.toString();
} catch {
return url;
}
return url;
}
async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdout: string; stderr: string; code: number }> {
@@ -1447,13 +1457,7 @@ export function parseEnvFileContent(content: string, stackName?: string): Record
}
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1).trim();
// Handle quoted values
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
const value = trimmed.substring(eqIndex + 1).trim();
// Only add if key is valid env var name
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
+5 -4
View File
@@ -311,21 +311,22 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
// Gotify
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const url = buildGotifyUrl(appriseUrl);
if (!url) {
const parsed = buildGotifyUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2;
try {
const response = await fetch(url, {
const response = await fetch(parsed.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: titleWithEnv,
message: payload.message,
priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2
priority: parsed.priority ?? defaultPriority
})
});
+36 -2
View File
@@ -31,6 +31,7 @@ import { unregisterSchedule } from './scheduler';
import { deleteGitStackFiles, parseEnvFileContent } from './git';
import { cleanPem } from '$lib/utils/pem';
import { rewriteComposeVolumePaths, getHostDataDir } from './host-path';
import { getOrderValue } from './container-labels';
// =============================================================================
// TYPES
@@ -229,8 +230,14 @@ function collectProcess(proc: ChildProcess): Promise<{ exitCode: number; stdout:
* Used to send files to Hawser for remote deployments.
* Binary files are base64-encoded with a "base64:" prefix to preserve all bytes.
*/
// Max file size: 10 MB per file, 256 MB total payload
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const MAX_TOTAL_SIZE = 256 * 1024 * 1024;
async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string>> {
const files: Record<string, string> = {};
let totalSize = 0;
const skipped: string[] = [];
async function scanDir(currentPath: string, relativePath: string = ''): Promise<void> {
const entries = readdirSync(currentPath, { withFileTypes: true });
@@ -243,7 +250,21 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
if (entry.name === '.git') continue;
await scanDir(fullPath, relPath);
} else if (entry.isFile()) {
const fileSize = statSync(fullPath).size;
if (fileSize > MAX_FILE_SIZE) {
skipped.push(`${relPath} (${(fileSize / 1024 / 1024).toFixed(1)} MB)`);
continue;
}
if (totalSize + fileSize > MAX_TOTAL_SIZE) {
skipped.push(`${relPath} (would exceed ${MAX_TOTAL_SIZE / 1024 / 1024} MB total limit)`);
continue;
}
const bytes = readFileSync(fullPath);
totalSize += fileSize;
if (isBinaryContent(bytes)) {
files[relPath] = `base64:${bytes.toString('base64')}`;
} else {
@@ -254,6 +275,11 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
}
await scanDir(dirPath);
if (skipped.length > 0) {
console.log(`[readDirFilesAsMap] Skipped ${skipped.length} file(s) exceeding size limits: ${skipped.join(', ')}`);
}
return files;
}
@@ -1346,10 +1372,13 @@ async function executeComposeViaHawser(
}
} catch (err: any) {
console.log(`${logPrefix} EXCEPTION in executeComposeViaHawser:`, err.message);
const isStringLength = err.message?.includes('Invalid string length');
return {
success: false,
output: '',
error: `Failed to ${operation} via Hawser: ${err.message}`
error: isStringLength
? `Stack files too large to send via Hawser. The repository may contain large binary files. Consider using a .dockerignore or moving large files out of the compose directory.`
: `Failed to ${operation} via Hawser: ${err.message}`
};
}
}
@@ -1593,7 +1622,12 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
labels: c.labels || {}
};
})
.sort((a, b) => a.service.localeCompare(b.service));
.sort((a, b) => {
const orderA = getOrderValue(a.labels);
const orderB = getOrderValue(b.labels);
if (orderA !== orderB) return orderA - orderB;
return a.service.localeCompare(b.service);
});
return {
name,