This commit is contained in:
jarek committed 2026-01-28 07:34:12 +01:00
1 parent a88d3d5788
commit 8f3a7eb435
96 files changed
+5235 -1870

No files matched your search

+39 -22
View File
@@ -1,8 +1,9 @@
import { json } from '@sveltejs/kit';
import { listComposeStacks, deployStack, saveStackComposeFile, saveStackEnvVars, writeRawStackEnvFile, saveStackEnvVarsToDb } from '$lib/server/stacks';
import { listComposeStacks, deployStack, saveStackComposeFile, writeStackEnvFile, writeRawStackEnvFile, saveStackEnvVarsToDb } from '$lib/server/stacks';
import { EnvironmentNotFoundError } from '$lib/server/docker';
import { upsertStackSource, getStackSources } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
@@ -34,6 +35,14 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
const stackSources = await getStackSources(envIdNum);
const existingNames = new Set(stacks.map((s) => s.name));
// Enrich Docker-discovered stacks with source type from DB
for (const stack of stacks) {
const source = stackSources.find(s => s.stackName === stack.name);
if (source) {
(stack as any).sourceType = source.sourceType;
}
}
for (const source of stackSources) {
// Add stacks from database that aren't already in the Docker list
// This includes internal, git, and external (adopted) stacks that are currently down
@@ -42,8 +51,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
name: source.stackName,
containers: [],
containerDetails: [],
status: 'created' as any
});
status: 'created' as any,
sourceType: source.sourceType
} as any);
}
}
@@ -58,7 +68,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -97,19 +108,20 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
}
// Save environment variables
// - rawEnvContent: non-secret vars with comments → .env file
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
// - rawEnvContent → .env file (non-secrets with comments)
// - secrets only → DB (for shell injection at runtime)
if (rawEnvContent) {
// Write raw content to .env file (should NOT contain secrets)
await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
}
// Save ALL vars to DB (secrets for shell injection at runtime)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVarsToDb(name, envVars, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
const secrets = envVars.filter((v: any) => v.isSecret);
if (secrets.length > 0) {
await saveStackEnvVarsToDb(name, secrets, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent) {
await writeStackEnvFile(name, envVars, envIdNum, envPath || undefined);
}
}
// Record the stack as internally created with custom paths if provided
@@ -121,6 +133,9 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
envPath: envPath || undefined
});
// Audit log
await auditStack(event, 'create', name, envIdNum);
return json({ success: true, started: false });
}
@@ -132,19 +147,18 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
// Save environment variables BEFORE deploying so they're available during start
if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) {
// - rawEnvContent: non-secret vars with comments → .env file
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
if (rawEnvContent) {
// Write raw content to .env file (should NOT contain secrets)
await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
}
// Save ALL vars to DB (secrets for shell injection at runtime)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVarsToDb(name, envVars, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
const secrets = envVars.filter((v: any) => v.isSecret);
if (secrets.length > 0) {
await saveStackEnvVarsToDb(name, secrets, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent) {
await writeStackEnvFile(name, envVars, envIdNum, envPath || undefined);
}
}
}
@@ -170,6 +184,9 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
envPath: envPath || undefined
});
// Audit log (create + deploy in one action)
await auditStack(event, 'deploy', name, envIdNum);
return json({ success: true, started: true, output: result.output });
} catch (error: any) {
console.error('Error creating compose stack:', error);
@@ -70,7 +70,6 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
if (restart) {
// Deploy with docker compose up -d --force-recreate
// Force recreate ensures env var changes are applied
// Note: deployStack uses requireComposeFile which will use saved paths
// Save paths first if provided
if (pathOptions) {
const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
@@ -78,11 +77,15 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
return json({ error: saveResult.error }, { status: 500 });
}
}
// Get authoritative paths from DB/filesystem for deploy
const composeInfo = await getStackComposeFile(name, envIdNum);
result = await deployStack({
name,
compose: content,
envId: envIdNum,
forceRecreate: true
forceRecreate: true,
composePath: composeInfo.composePath || undefined,
envPath: composeInfo.envPath || undefined
});
} else {
// Just save the file without restarting (update operation, not create)
+33 -55
View File
@@ -57,9 +57,8 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const stackName = decodeURIComponent(params.name);
// Get variables from database (masked - secrets show as '***')
const dbVariables = await getStackEnvVars(stackName, envIdNum, true);
const dbByKey = new Map(dbVariables.map(v => [v.key, v]));
// Get secrets from database (masked - values show as '***')
const dbSecrets = await getStackEnvVars(stackName, envIdNum, true);
// Check if this stack has a custom compose path configured
const source = await getStackSource(stackName, envIdNum);
@@ -67,63 +66,49 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
// Determine the env file path based on path resolution rules:
// - envPath = '' (empty string) → explicitly no env file
// - envPath = '/path/.env' → use custom path
// - envPath = null with composePath → suggest .env next to compose (but don't auto-load)
// - envPath = null with composePath → .env next to compose
// - envPath = null without composePath → use default location
let envFilePath: string | null = null;
if (source?.envPath === '') {
// Empty string = explicitly no env file
envFilePath = null;
} else if (source?.envPath) {
// Custom env path specified
envFilePath = source.envPath;
} else if (source?.composePath) {
// Custom compose path but no env path - suggest .env next to compose
// For loading, check if it exists (but don't fail if it doesn't)
envFilePath = join(dirname(source.composePath), '.env');
} else {
// Default location - .env in stack directory
const stackDir = await findStackDir(stackName, envIdNum);
if (stackDir) {
envFilePath = join(stackDir, '.env');
}
}
let fileVars: Record<string, string> = {};
if (envFilePath && existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
fileVars = parseEnvFile(content);
} catch (e) {
// Ignore file read errors
}
}
// Merge: DB variables (with secrets masked) + file variables (non-secrets only)
// For non-secrets: file value overrides DB value (user may have edited file)
// For secrets: only DB value exists (masked as '***')
const mergedKeys = new Set([...dbByKey.keys(), ...Object.keys(fileVars)]);
const variables: { key: string; value: string; isSecret: boolean }[] = [];
for (const key of mergedKeys) {
const dbVar = dbByKey.get(key);
const fileValue = fileVars[key];
if (source?.sourceType === 'git') {
// Git stacks: ALL vars (overrides + secrets) come from DB
for (const dbVar of dbSecrets) {
variables.push({ key: dbVar.key, value: dbVar.value, isSecret: dbVar.isSecret });
}
} else {
// Internal/adopted stacks: non-secrets from file, secrets from DB
if (envFilePath && existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
const fileVars = parseEnvFile(content);
for (const [key, value] of Object.entries(fileVars)) {
variables.push({ key, value, isSecret: false });
}
} catch {
// Ignore file read errors
}
}
if (dbVar) {
if (dbVar.isSecret) {
// Secret: use masked value from DB, ignore any file value
variables.push({ key, value: dbVar.value, isSecret: true });
} else if (fileValue !== undefined) {
// Non-secret with file value: file overrides (user may have edited)
variables.push({ key, value: fileValue, isSecret: false });
} else {
// Non-secret only in DB: use DB value
variables.push({ key, value: dbVar.value, isSecret: false });
// Secrets come from the database (never written to file)
for (const secret of dbSecrets) {
if (secret.isSecret) {
variables.push({ key: secret.key, value: secret.value, isSecret: true });
}
} else if (fileValue !== undefined) {
// Variable only in file - add it as non-secret
variables.push({ key, value: fileValue, isSecret: false });
}
}
@@ -136,15 +121,14 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
/**
* PUT /api/stacks/[name]/env?env=X
* Set/replace all environment variables for a stack.
* Body: { variables: [{ key, value, isSecret? }] }
* Save secret environment variables for a stack.
* Body: { variables: [{ key, value, isSecret }] }
*
* SECURITY: Secrets are stored ONLY in the database, NEVER written to .env file.
* For secrets, if the value is '***' (the masked placeholder), the original
* secret value from the database is preserved instead of overwriting with '***'.
* Only secrets are stored in the database. Non-secret variables live in the
* .env file (written by PUT /env/raw) and are read directly by Docker Compose.
*
* The .env file only contains non-secret variables (can be edited manually).
* Secrets are injected via shell environment variables at runtime.
* If a secret's value is '***' (masked placeholder), the original value
* from the database is preserved.
*/
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
@@ -177,14 +161,12 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
if (typeof v.value !== 'string') {
return json({ error: `Invalid variable "${v.key}": value must be a string` }, { status: 400 });
}
// Validate key format (env var naming convention)
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(v.key)) {
return json({ error: `Invalid variable name "${v.key}": must start with a letter or underscore and contain only alphanumeric characters and underscores` }, { status: 400 });
}
}
// Check if any secrets have the masked placeholder '***'
// If so, we need to preserve their original values from the database
// Preserve masked secret values ('***') from the database
const secretsWithMaskedValue = body.variables.filter(
(v: { key: string; value: string; isSecret?: boolean }) =>
v.isSecret && v.value === '***'
@@ -193,16 +175,13 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
let variablesToSave = body.variables;
if (secretsWithMaskedValue.length > 0) {
// Get existing variables (unmasked) to preserve secret values
const existingVars = await getStackEnvVars(stackName, envIdNum, false);
const existingByKey = new Map(existingVars.map(v => [v.key, v]));
// Replace masked secrets with their original values
variablesToSave = body.variables.map((v: { key: string; value: string; isSecret?: boolean }) => {
if (v.isSecret && v.value === '***') {
const existing = existingByKey.get(v.key);
if (existing && existing.isSecret) {
// Preserve the original secret value
return { ...v, value: existing.value };
}
}
@@ -210,8 +189,7 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
});
}
// Save ALL variables (including secrets) to database
// Note: The .env file is written by PUT /env/raw endpoint, which preserves comments
// Save secrets to database (non-secrets live in the .env file)
await setStackEnvVars(stackName, envIdNum, variablesToSave);
return json({ success: true, count: variablesToSave.length });
@@ -49,6 +49,6 @@ export const GET: RequestHandler = async ({ url }) => {
stackDir,
composePath: `${stackDir}/compose.yaml`,
envPath: `${stackDir}/.env`,
source: location ? 'custom' : 'default'
source: 'default'
});
};