This commit is contained in:
jarek committed 2026-01-01 16:00:34 +01:00
1 parent 5475112806
commit 242f8df49d
35 files changed
+1892 -1987

No files matched your search

+116 -13
View File
@@ -747,12 +747,70 @@ function getAttributeValue(entry: any, attribute: string): string | undefined {
}
// ============================================
// MFA (TOTP)
// MFA (TOTP) with Backup Codes
// ============================================
import * as OTPAuth from 'otpauth';
import * as QRCode from 'qrcode';
// MFA data stored in mfaSecret field as JSON
interface MfaData {
secret: string; // TOTP secret (base32)
backupCodes: string[]; // Hashed backup codes (unused ones)
}
/**
* Generate 10 random backup codes (8 characters each, alphanumeric)
*/
function generateBackupCodes(): string[] {
const codes: string[] = [];
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusable chars: 0, O, 1, I
for (let i = 0; i < 10; i++) {
let code = '';
for (let j = 0; j < 8; j++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
codes.push(code);
}
return codes;
}
/**
* Hash a backup code for storage
*/
async function hashBackupCode(code: string): Promise<string> {
// Normalize: uppercase, remove spaces and dashes
const normalized = code.toUpperCase().replace(/[\s-]/g, '');
const hasher = new Bun.CryptoHasher('sha256');
hasher.update(normalized);
return hasher.digest('hex');
}
/**
* Parse MFA data from database field
*/
function parseMfaData(mfaSecret: string | null | undefined): MfaData | null {
if (!mfaSecret) return null;
try {
// Try parsing as JSON first (new format)
const parsed = JSON.parse(mfaSecret);
if (parsed && typeof parsed.secret === 'string') {
return {
secret: parsed.secret,
backupCodes: parsed.backupCodes || []
};
}
} catch {
// Legacy format: plain base32 secret string
return {
secret: mfaSecret,
backupCodes: []
};
}
return null;
}
/**
* Generate MFA secret and QR code for setup
*/
@@ -787,18 +845,24 @@ export async function generateMfaSetup(userId: number): Promise<{
margin: 2
});
// Store secret temporarily (user must verify before it's enabled)
await updateUser(userId, { mfaSecret: secretBase32 });
// Store secret temporarily as JSON (user must verify before it's enabled)
// Backup codes will be generated after verification
const mfaData: MfaData = { secret: secretBase32, backupCodes: [] };
await updateUser(userId, { mfaSecret: JSON.stringify(mfaData) });
return { secret: secretBase32, qrDataUrl };
}
/**
* Verify MFA token and enable MFA if valid
* Returns backup codes on success (shown only once)
*/
export async function verifyAndEnableMfa(userId: number, token: string): Promise<boolean> {
export async function verifyAndEnableMfa(userId: number, token: string): Promise<{ success: false } | { success: true; backupCodes: string[] }> {
const user = await getUser(userId);
if (!user || !user.mfaSecret) return false;
if (!user || !user.mfaSecret) return { success: false };
const mfaData = parseMfaData(user.mfaSecret);
if (!mfaData) return { success: false };
const totp = new OTPAuth.TOTP({
issuer: 'Dockhand',
@@ -806,35 +870,74 @@ export async function verifyAndEnableMfa(userId: number, token: string): Promise
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(user.mfaSecret)
secret: OTPAuth.Secret.fromBase32(mfaData.secret)
});
const delta = totp.validate({ token, window: 1 });
if (delta === null) return false;
if (delta === null) return { success: false };
// Enable MFA
await updateUser(userId, { mfaEnabled: true });
return true;
// Generate backup codes
const plainBackupCodes = generateBackupCodes();
const hashedBackupCodes = await Promise.all(plainBackupCodes.map(hashBackupCode));
// Update MFA data with hashed backup codes and enable MFA
const updatedMfaData: MfaData = {
secret: mfaData.secret,
backupCodes: hashedBackupCodes
};
await updateUser(userId, {
mfaEnabled: true,
mfaSecret: JSON.stringify(updatedMfaData)
});
// Return plain backup codes (shown only once)
return { success: true, backupCodes: plainBackupCodes };
}
/**
* Verify MFA token during login
* Verify MFA token during login (accepts TOTP code or backup code)
*/
export async function verifyMfaToken(userId: number, token: string): Promise<boolean> {
const user = await getUser(userId);
if (!user || !user.mfaEnabled || !user.mfaSecret) return false;
const mfaData = parseMfaData(user.mfaSecret);
if (!mfaData) return false;
// First, try TOTP verification
const totp = new OTPAuth.TOTP({
issuer: 'Dockhand',
label: user.username,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(user.mfaSecret)
secret: OTPAuth.Secret.fromBase32(mfaData.secret)
});
const delta = totp.validate({ token, window: 1 });
return delta !== null;
if (delta !== null) return true;
// If TOTP fails, try backup code
if (mfaData.backupCodes && mfaData.backupCodes.length > 0) {
const hashedInput = await hashBackupCode(token);
const codeIndex = mfaData.backupCodes.indexOf(hashedInput);
if (codeIndex !== -1) {
// Remove used backup code
const updatedBackupCodes = [...mfaData.backupCodes];
updatedBackupCodes.splice(codeIndex, 1);
const updatedMfaData: MfaData = {
secret: mfaData.secret,
backupCodes: updatedBackupCodes
};
await updateUser(userId, { mfaSecret: JSON.stringify(updatedMfaData) });
return true;
}
}
return false;
}
/**
+2
View File
@@ -156,6 +156,7 @@ export async function updateEnvironment(id: number, env: Partial<Environment>):
if (env.tlsCa !== undefined) updateData.tlsCa = env.tlsCa;
if (env.tlsCert !== undefined) updateData.tlsCert = env.tlsCert;
if (env.tlsKey !== undefined) updateData.tlsKey = env.tlsKey;
if (env.tlsSkipVerify !== undefined) updateData.tlsSkipVerify = env.tlsSkipVerify;
if (env.icon !== undefined) updateData.icon = env.icon;
if (env.socketPath !== undefined) updateData.socketPath = env.socketPath;
if (env.collectActivity !== undefined) updateData.collectActivity = env.collectActivity;
@@ -808,6 +809,7 @@ export interface SmtpConfig {
from_email: string;
from_name?: string;
to_emails: string[];
skipTlsVerify?: boolean; // Skip TLS certificate verification (useful for self-signed certs)
}
export interface AppriseConfig {
+1 -1
View File
@@ -181,7 +181,7 @@ export const users = sqliteTable('users', {
avatar: text('avatar'),
authProvider: text('auth_provider').default('local'), // e.g., 'local', 'oidc:Keycloak', 'ldap:AD'
mfaEnabled: integer('mfa_enabled', { mode: 'boolean' }).default(false),
mfaSecret: text('mfa_secret'),
mfaSecret: text('mfa_secret'), // JSON: { secret: string, backupCodes: string[] }
isActive: integer('is_active', { mode: 'boolean' }).default(true),
lastLogin: text('last_login'),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
+1 -1
View File
@@ -184,7 +184,7 @@ export const users = pgTable('users', {
avatar: text('avatar'),
authProvider: text('auth_provider').default('local'), // e.g., 'local', 'oidc:Keycloak', 'ldap:AD'
mfaEnabled: boolean('mfa_enabled').default(false),
mfaSecret: text('mfa_secret'),
mfaSecret: text('mfa_secret'), // JSON: { secret: string, backupCodes: string[] }
isActive: boolean('is_active').default(true),
lastLogin: timestamp('last_login', { mode: 'string' }),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
+13 -21
View File
@@ -677,10 +677,13 @@ export async function listContainers(all = true, envId?: number | null): Promise
}));
// Extract health status from Status string
// Docker formats: "(healthy)", "(unhealthy)", "(health: starting)"
let health: string | undefined;
const healthMatch = container.Status?.match(/\((healthy|unhealthy|starting)\)/i);
const healthMatch = container.Status?.match(/\((healthy|unhealthy|health:\s*starting)\)/i);
if (healthMatch) {
health = healthMatch[1].toLowerCase();
const matched = healthMatch[1].toLowerCase();
// Normalize "health: starting" to just "starting"
health = matched.includes('starting') ? 'starting' : matched;
}
return {
@@ -803,6 +806,7 @@ export interface CreateContainerOptions {
labels?: { [key: string]: string };
cmd?: string[];
restartPolicy?: string;
restartMaxRetries?: number;
networkMode?: string;
networks?: string[];
user?: string;
@@ -831,7 +835,10 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
Labels: options.labels || {},
HostConfig: {
RestartPolicy: {
Name: options.restartPolicy || 'no'
Name: options.restartPolicy || 'no',
...(options.restartPolicy === 'on-failure' && options.restartMaxRetries !== undefined
? { MaximumRetryCount: options.restartMaxRetries }
: {})
}
}
};
@@ -888,9 +895,9 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
if (options.networks && options.networks.length > 0) {
containerConfig.HostConfig.NetworkMode = options.networks[0];
containerConfig.NetworkingConfig = {
EndpointsConfig: {
[options.networks[0]]: {}
}
EndpointsConfig: Object.fromEntries(
options.networks.map(network => [network, {}])
)
};
}
@@ -963,21 +970,6 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
envId
);
// Connect to additional networks after container creation
if (options.networks && options.networks.length > 1) {
for (let i = 1; i < options.networks.length; i++) {
await dockerFetch(
`/networks/${options.networks[i]}/connect`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Container: result.Id })
},
envId
);
}
}
return { id: result.Id, start: () => startContainer(result.Id, envId) };
}
+18 -4
View File
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { join, resolve, dirname } from 'node:path';
import {
getGitRepository,
getGitCredential,
@@ -134,7 +134,9 @@ export interface SyncResult {
success: boolean;
commit?: string;
composeContent?: string;
composeDir?: string; // Directory containing the compose file (for copying all files)
envFileVars?: Record<string, string>; // Variables from .env file in repo
envFileContent?: string; // Raw .env file content (for Hawser deployments)
error?: string;
updated?: boolean;
}
@@ -609,16 +611,21 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} Compose content:`);
console.log(composeContent);
// Determine the compose directory (for copying all files)
const composeDir = dirname(composePath);
console.log(`${logPrefix} Compose directory:`, composeDir);
// Read env file if configured (optional - don't fail if missing)
let envFileVars: Record<string, string> | undefined;
let envFileContent: string | undefined;
if (gitStack.envFilePath) {
const envFilePath = join(repoPath, gitStack.envFilePath);
console.log(`${logPrefix} Looking for env file at:`, envFilePath);
if (existsSync(envFilePath)) {
try {
console.log(`${logPrefix} Reading env file...`);
const envContent = await Bun.file(envFilePath).text();
envFileVars = parseEnvFileContent(envContent, gitStack.stackName);
envFileContent = await Bun.file(envFilePath).text();
envFileVars = parseEnvFileContent(envFileContent, gitStack.stackName);
console.log(`${logPrefix} Env file parsed, vars count:`, Object.keys(envFileVars).length);
} catch (err) {
// Log but don't fail - env file is optional
@@ -653,6 +660,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
success: true,
commit: currentCommit,
composeContent,
composeDir,
envFileVars,
updated
};
@@ -719,11 +727,13 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
// This ensures containers pick up new env var values even if compose file didn't change
// Note: Without this, docker compose only detects compose file changes, not env var changes
console.log(`${logPrefix} Calling deployStack...`);
console.log(`${logPrefix} Source directory (composeDir):`, syncResult.composeDir);
const result = await deployStack({
name: gitStack.stackName,
compose: syncResult.composeContent!,
envId: gitStack.environmentId,
envFileVars: syncResult.envFileVars,
sourceDir: syncResult.composeDir, // Copy entire directory from git repo
forceRecreate
});
@@ -917,6 +927,9 @@ export async function deployGitStackWithProgress(
const composeContent = await Bun.file(composePath).text();
// Determine the compose directory (for copying all files)
const composeDir = dirname(composePath);
// Read env file if configured (optional - don't fail if missing)
let envFileVars: Record<string, string> | undefined;
if (gitStack.envFilePath) {
@@ -951,7 +964,8 @@ export async function deployGitStackWithProgress(
name: gitStack.stackName,
compose: composeContent,
envId: gitStack.environmentId,
envFileVars
envFileVars,
sourceDir: composeDir // Copy entire directory from git repo
});
if (result.success) {
+3
View File
@@ -27,6 +27,9 @@ async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPay
auth: config.username ? {
user: config.username,
pass: config.password
} : undefined,
tls: config.skipTlsVerify ? {
rejectUnauthorized: false
} : undefined
});
+177 -13
View File
@@ -5,11 +5,12 @@
* All lifecycle operations use docker compose commands.
*/
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';
import {
getEnvironment,
getStackEnvVarsAsRecord,
setStackEnvVars,
getStackSource,
upsertStackSource,
deleteStackSource,
@@ -76,6 +77,7 @@ export interface DeployStackOptions {
compose: string;
envId?: number | null;
envFileVars?: Record<string, string>;
sourceDir?: string; // Directory to copy all files from (for git stacks)
forceRecreate?: boolean;
}
@@ -156,6 +158,35 @@ async function withStackLock<T>(stackName: string, fn: () => Promise<T>): Promis
const COMPOSE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const COMPOSE_KILL_GRACE_MS = 5000; // 5 seconds grace period before SIGKILL
/**
* Read all files from a directory as a map of relative path -> content.
* Used to send files to Hawser for remote deployments.
*/
async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string>> {
const files: Record<string, string> = {};
async function scanDir(currentPath: string, relativePath: string = ''): Promise<void> {
const entries = readdirSync(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(currentPath, entry.name);
const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
// Skip .git directory
if (entry.name === '.git') continue;
await scanDir(fullPath, relPath);
} else if (entry.isFile()) {
// Read file content
const content = await Bun.file(fullPath).text();
files[relPath] = content;
}
}
}
await scanDir(dirPath);
return files;
}
// =============================================================================
// DEBUG UTILITIES
// =============================================================================
@@ -316,6 +347,7 @@ interface ComposeCommandOptions {
envId?: number | null;
forceRecreate?: boolean;
removeVolumes?: boolean;
stackFiles?: Record<string, string>; // All files to send to Hawser
}
/**
@@ -338,11 +370,14 @@ async function executeLocalCompose(
const composeFile = join(stackDir, 'docker-compose.yml');
await Bun.write(composeFile, composeContent);
// Note: .env file is written when env vars are saved via API
// Docker compose automatically reads .env from the stack directory
// We only need to pass env vars to process environment for variable substitution
// in case the .env file doesn't exist yet (e.g., first deploy)
const spawnEnv: Record<string, string> = { ...(process.env as Record<string, string>) };
if (dockerHost) {
spawnEnv.DOCKER_HOST = dockerHost;
}
// Add stack-specific environment variables
if (envVars) {
Object.assign(spawnEnv, envVars);
}
@@ -479,7 +514,8 @@ async function executeComposeViaHawser(
envId: number,
envVars?: Record<string, string>,
forceRecreate?: boolean,
removeVolumes?: boolean
removeVolumes?: boolean,
stackFiles?: Record<string, string>
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
// Import dockerFetch dynamically to avoid circular dependency
@@ -497,13 +533,28 @@ async function executeComposeViaHawser(
console.log(`${logPrefix} Env vars being sent (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
}
console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars');
console.log(`${logPrefix} Stack files count:`, stackFiles ? Object.keys(stackFiles).length : 0);
if (stackFiles && Object.keys(stackFiles).length > 0) {
console.log(`${logPrefix} Stack files:`, Object.keys(stackFiles).join(', '));
}
try {
// Build files map - include .env file if envVars provided
const files: Record<string, string> = { ...(stackFiles || {}) };
if (envVars && Object.keys(envVars).length > 0) {
const envContent = Object.entries(envVars)
.map(([key, value]) => `${key}=${value}`)
.join('\n');
files['.env'] = envContent;
console.log(`${logPrefix} Added .env file to files map with ${Object.keys(envVars).length} variables`);
}
const body = JSON.stringify({
operation,
projectName: stackName,
composeFile: composeContent,
envVars: envVars || {},
files, // All files including .env
forceRecreate: forceRecreate || false,
removeVolumes: removeVolumes || false
});
@@ -567,7 +618,7 @@ async function executeComposeCommand(
composeContent: string,
envVars?: Record<string, string>
): Promise<StackOperationResult> {
const { stackName, envId, forceRecreate, removeVolumes } = options;
const { stackName, envId, forceRecreate, removeVolumes, stackFiles } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -595,7 +646,8 @@ async function executeComposeCommand(
envId!,
envVars,
forceRecreate,
removeVolumes
removeVolumes,
stackFiles
);
case 'direct': {
@@ -808,7 +860,38 @@ async function requireComposeFile(
}
// Get environment variables from database
const envVars = await getStackEnvVarsAsRecord(stackName, envId);
const dbEnvVars = await getStackEnvVarsAsRecord(stackName, envId);
// Also read from .env file and merge (file + DB are equal sources)
// DB values take precedence for secrets, file values for new/changed vars
const stackDir = join(getStacksDir(), stackName);
const envFilePath = join(stackDir, '.env');
let fileEnvVars: Record<string, string> = {};
if (existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1);
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
fileEnvVars[key] = value;
}
}
} catch {
// Ignore file read errors
}
}
// Merge: file values as base, DB values override (DB is authoritative for managed vars)
const envVars = { ...fileEnvVars, ...dbEnvVars };
return { content: composeResult.content!, envVars };
}
@@ -1018,7 +1101,7 @@ export async function removeStack(
* Uses stack locking to prevent concurrent deployments.
*/
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, envFileVars, forceRecreate } = options;
const { name, compose, envId, envFileVars, sourceDir, forceRecreate } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -1026,6 +1109,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
console.log(`${logPrefix} ========================================`);
console.log(`${logPrefix} Environment ID:`, envId ?? '(none - local)');
console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false);
console.log(`${logPrefix} Source directory:`, sourceDir ?? '(none)');
console.log(`${logPrefix} Env file vars provided:`, envFileVars ? Object.keys(envFileVars).length : 0);
if (envFileVars && Object.keys(envFileVars).length > 0) {
console.log(`${logPrefix} Env file var keys:`, Object.keys(envFileVars).join(', '));
@@ -1043,14 +1127,34 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
}
return withStackLock(name, async () => {
// Ensure stack directory exists and write compose file (for local reference)
const stacksDir = getStacksDir();
const stackDir = join(stacksDir, name);
mkdirSync(stackDir, { recursive: true });
const composeFile = join(stackDir, 'docker-compose.yml');
await Bun.write(composeFile, compose);
console.log(`${logPrefix} Compose file written to:`, composeFile);
// Read all files from source directory if provided (for Hawser deployments)
let stackFiles: Record<string, string> | undefined;
if (sourceDir && existsSync(sourceDir)) {
stackFiles = await readDirFilesAsMap(sourceDir);
console.log(`${logPrefix} Read ${Object.keys(stackFiles).length} files from source directory`);
console.log(`${logPrefix} Files:`, Object.keys(stackFiles).join(', '));
}
// Handle stack directory setup
if (sourceDir && existsSync(sourceDir)) {
// Copy entire source directory to stack directory (for git stacks)
console.log(`${logPrefix} Copying source directory to stack directory...`);
if (existsSync(stackDir)) {
rmSync(stackDir, { recursive: true, force: true });
}
cpSync(sourceDir, stackDir, { recursive: true });
console.log(`${logPrefix} Copied ${sourceDir} -> ${stackDir}`);
} else {
// Traditional behavior: create directory and write compose file only
mkdirSync(stackDir, { recursive: true });
const composeFile = join(stackDir, 'docker-compose.yml');
await Bun.write(composeFile, compose);
console.log(`${logPrefix} Compose file written to:`, composeFile);
}
console.log(`${logPrefix} Compose content length:`, compose.length, 'chars');
console.log(`${logPrefix} Compose content (full):`);
console.log(compose);
@@ -1072,7 +1176,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
}
console.log(`${logPrefix} Calling executeComposeCommand...`);
const result = await executeComposeCommand('up', { stackName: name, envId, forceRecreate }, compose, envVars);
const result = await executeComposeCommand('up', { stackName: name, envId, forceRecreate, stackFiles }, compose, envVars);
console.log(`${logPrefix} ========================================`);
console.log(`${logPrefix} DEPLOY STACK RESULT`);
console.log(`${logPrefix} ========================================`);
@@ -1099,6 +1203,66 @@ export async function pullStackImages(
return executeComposeCommand('pull', { stackName, envId }, content, envVars);
}
// =============================================================================
// ENVIRONMENT VARIABLE HELPERS
// =============================================================================
/**
* Save environment variables for a stack to the database (for secret tracking)
*/
export async function saveStackEnvVarsToDb(
stackName: string,
variables: { key: string; value: string; isSecret?: boolean }[],
envId?: number | null
): Promise<void> {
await setStackEnvVars(stackName, envId ?? null, variables);
}
/**
* Write environment variables to the .env file on disk (simple key=value format)
*
* WARNING: This generates a simple key=value file WITHOUT comments or formatting.
* ONLY use during initial stack CREATION when no .env file exists.
*
* For EDITS, use PUT /api/stacks/[name]/env/raw which preserves the raw content
* including all comments, formatting, and structure.
*/
export async function writeStackEnvFile(
stackName: string,
variables: { key: string; value: string; isSecret?: boolean }[]
): Promise<void> {
const stacksDir = getStacksDir();
const envFilePath = join(stacksDir, stackName, '.env');
const rawContent = variables
.filter(v => v.key?.trim())
.map(v => `${v.key.trim()}=${v.value}`)
.join('\n') + '\n';
await Bun.write(envFilePath, rawContent);
}
/**
* Save environment variables for a stack (both to database and .env file)
*
* WARNING: Only use during initial stack CREATION - this generates a simple
* key=value file that does NOT preserve comments or formatting.
*
* For EDITS, the StackModal saves to:
* - PUT /api/stacks/[name]/env/raw (preserves raw content with comments)
* - PUT /api/stacks/[name]/env (updates secret flags in DB only)
*/
export async function saveStackEnvVars(
stackName: string,
variables: { key: string; value: string; isSecret?: boolean }[],
envId?: number | null
): Promise<void> {
// Save to database for secret tracking
await saveStackEnvVarsToDb(stackName, variables, envId);
// Write .env file to disk for Docker Compose
await writeStackEnvFile(stackName, variables);
}
// =============================================================================
// RE-EXPORTS FOR BACKWARDS COMPATIBILITY
// =============================================================================
@@ -134,10 +134,16 @@ function processEvent(event: DockerEvent, envId: number) {
if (event.Type !== 'container') return;
// Map Docker action to our action type
const action = event.Action.split(':')[0] as ContainerEventAction;
// For health_status events, Docker sends "health_status: unhealthy" or "health_status: healthy"
// We need to preserve the full string for notifications to distinguish healthy vs unhealthy
const rawAction = event.Action;
const baseAction = rawAction.split(':')[0] as ContainerEventAction;
// Skip actions we don't care about
if (!CONTAINER_ACTIONS.includes(action)) return;
if (!CONTAINER_ACTIONS.includes(baseAction)) return;
// For notifications, preserve full action for health_status to enable proper mapping
const action = rawAction.startsWith('health_status') ? rawAction : baseAction;
const containerId = event.Actor?.ID;
const containerName = event.Actor?.Attributes?.name;
@@ -169,14 +175,17 @@ function processEvent(event: DockerEvent, envId: number) {
const timestamp = new Date(Math.floor(event.timeNano / 1000000)).toISOString();
// Prepare notification data
const actionLabel = action.charAt(0).toUpperCase() + action.slice(1);
// For health_status events, create a cleaner label
const actionLabel = action.startsWith('health_status')
? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy'
: action.charAt(0).toUpperCase() + action.slice(1);
const containerLabel = containerName || containerId.substring(0, 12);
const notificationType =
action === 'die' || action === 'kill' || action === 'oom'
action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy')
? 'error'
: action === 'stop'
? 'warning'
: action === 'start'
: action === 'start' || (action.includes('healthy') && !action.includes('unhealthy'))
? 'success'
: 'info';