This commit is contained in:
jarek committed 2026-01-02 12:24:43 +01:00
1 parent 659d074d00
commit 607d340b71
15 files changed
+619 -375

No files matched your search

+28 -7
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { listComposeStacks, deployStack, saveStackComposeFile, saveStackEnvVars } from '$lib/server/stacks';
import { listComposeStacks, deployStack, saveStackComposeFile, saveStackEnvVars, 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';
@@ -78,7 +78,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
try {
const body = await request.json();
const { name, compose, start, envVars } = body;
const { name, compose, start, envVars, rawEnvContent } = body;
if (!name || typeof name !== 'string') {
return json({ error: 'Stack name is required' }, { status: 400 });
@@ -95,8 +95,18 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
return json({ error: result.error }, { status: 400 });
}
// Save environment variables if provided (to both DB and .env file)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
// Save environment variables
// NEW SIMPLIFIED: rawEnvContent contains ALL vars including secrets (with real values)
// Secrets are visually masked in the UI but stored with real values in .env file
if (rawEnvContent) {
// Write raw content directly to .env file (includes secrets with real values)
await writeRawStackEnvFile(name, rawEnvContent);
// Save secret metadata to DB (for UI masking purposes)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVarsToDb(name, envVars, envIdNum);
}
} else if (envVars && Array.isArray(envVars) && envVars.length > 0) {
// Fallback: generate from vars (no raw content provided)
await saveStackEnvVars(name, envVars, envIdNum);
}
@@ -111,11 +121,22 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
}
// Save environment variables BEFORE deploying so they're available during start
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) {
// First ensure the stack directory exists by saving compose file
await saveStackComposeFile(name, compose, true);
// Save to both DB and .env file
await saveStackEnvVars(name, envVars, envIdNum);
// NEW SIMPLIFIED: rawEnvContent contains ALL vars including secrets (with real values)
if (rawEnvContent) {
// Write raw content directly to .env file (includes secrets with real values)
await writeRawStackEnvFile(name, rawEnvContent);
// Save secret metadata to DB (for UI masking purposes)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVarsToDb(name, envVars, envIdNum);
}
} else {
// Fallback: generate from vars (no raw content provided)
await saveStackEnvVars(name, envVars, envIdNum);
}
}
// Deploy and start the stack
@@ -19,7 +19,7 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
return json({ error: result.error }, { status: 404 });
}
return json({ content: result.content });
return json({ content: result.content, stackDir: result.stackDir });
} catch (error: any) {
console.error(`Error getting compose file for stack ${name}:`, error);
return json({ error: error.message || 'Failed to get compose file' }, { status: 500 });
+21 -94
View File
@@ -30,68 +30,14 @@ function parseEnvFile(content: string): Record<string, string> {
return result;
}
/**
* Merge new variables into existing .env file content.
* - Keeps comments and formatting
* - Updates values for existing keys
* - REMOVES keys that are not in newVars (user deleted them)
* - Appends new keys at the end
*/
function mergeEnvFileContent(
existingContent: string,
newVars: { key: string; value: string }[]
): string {
const newVarsMap = new Map(newVars.map(v => [v.key, v.value]));
const handledKeys = new Set<string>();
const lines = existingContent.split('\n');
const resultLines: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
// Keep comments and blank lines as-is
if (!trimmed || trimmed.startsWith('#')) {
resultLines.push(line);
continue;
}
// Check if this is a variable line
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
if (newVarsMap.has(key)) {
// Update existing variable with new value from UI
resultLines.push(`${key}=${newVarsMap.get(key)}`);
handledKeys.add(key);
}
// If key not in newVarsMap, it was deleted - skip it (don't add to resultLines)
} else {
// Not a valid variable line, keep as-is
resultLines.push(line);
}
}
// Append any new variables that weren't in the original file
for (const v of newVars) {
if (!handledKeys.has(v.key)) {
resultLines.push(`${v.key}=${v.value}`);
}
}
// Ensure file ends with newline
let result = resultLines.join('\n');
if (!result.endsWith('\n')) {
result += '\n';
}
return result;
}
/**
* GET /api/stacks/[name]/env?env=X
* Get all environment variables for a stack.
* Merges variables from database with .env file (file values shown if different).
* Secrets are masked with '***' in the response.
* Merges variables from database with .env file (file values override for non-secrets).
*
* SECURITY: Secrets are returned as '***' (masked) - they are NEVER sent in plain text.
* Secrets are stored only in the database and injected via shell environment at runtime.
* The .env file only contains non-secret variables.
*/
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
@@ -111,11 +57,11 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const stackName = decodeURIComponent(params.name);
// Get variables from database
// 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]));
// Try to read .env file from stack directory
// Try to read .env file from stack directory (only contains non-secrets)
const stacksDir = getStacksDir();
const envFilePath = join(stacksDir, stackName, '.env');
let fileVars: Record<string, string> = {};
@@ -129,7 +75,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
}
}
// Merge: start with DB variables, add any new keys from file
// 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 }[] = [];
@@ -138,15 +86,14 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const fileValue = fileVars[key];
if (dbVar) {
// Variable exists in DB
if (dbVar.isSecret) {
// Keep secret masked
// Secret: use masked value from DB, ignore any file value
variables.push({ key, value: dbVar.value, isSecret: true });
} else if (fileValue !== undefined && fileValue !== dbVar.value) {
// File has different value - use file value (user may have edited it)
} else if (fileValue !== undefined) {
// Non-secret with file value: file overrides (user may have edited)
variables.push({ key, value: fileValue, isSecret: false });
} else {
// Use DB value
// Non-secret only in DB: use DB value
variables.push({ key, value: dbVar.value, isSecret: false });
}
} else if (fileValue !== undefined) {
@@ -167,8 +114,12 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
* Set/replace all environment variables for a stack.
* Body: { variables: [{ key, value, isSecret? }] }
*
* Note: For secrets, if the value is '***' (the masked placeholder), the original
* 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 '***'.
*
* The .env file only contains non-secret variables (can be edited manually).
* Secrets are injected via shell environment variables at runtime.
*/
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
@@ -234,34 +185,10 @@ 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
await setStackEnvVars(stackName, envIdNum, variablesToSave);
// Also write the .env file to the stack directory
// This allows users to see/edit variables outside of Dockhand
const stacksDir = getStacksDir();
const stackDir = join(stacksDir, stackName);
const envFilePath = join(stackDir, '.env');
// Only write if stack directory exists
if (existsSync(stackDir)) {
// Read existing file to preserve comments and formatting
let existingContent = '';
if (existsSync(envFilePath)) {
try {
existingContent = await Bun.file(envFilePath).text();
} catch {
// File read failed, start fresh
}
}
// Merge UI vars with existing file (preserves comments, keeps file vars)
const envContent = mergeEnvFileContent(
existingContent,
variablesToSave.map((v: { key: string; value: string }) => ({ key: v.key, value: v.value }))
);
await Bun.write(envFilePath, envContent);
}
return json({ success: true, count: variablesToSave.length });
} catch (error) {
console.error('Error setting stack env vars:', error);
+20 -3
View File
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import { getStacksDir } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { existsSync } from 'node:fs';
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import type { RequestHandler } from './$types';
@@ -82,9 +82,26 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
return json({ error: 'Stack directory not found' }, { status: 404 });
}
// Ensure content ends with newline
let content = body.content;
if (content && !content.endsWith('\n')) {
// If content is empty, delete the .env file instead of writing empty file
if (!content || !content.trim()) {
if (existsSync(envFilePath)) {
rmSync(envFilePath);
return json({ success: true, deleted: true });
}
return json({ success: true });
}
// Guard against writing masked secret placeholders (would corrupt the file)
if (content.match(/^[A-Za-z_][A-Za-z0-9_]*=\*\*\*$/m)) {
return json({
error: 'Cannot write masked placeholder "***" to .env file - this would corrupt secret values'
}, { status: 400 });
}
// Ensure content ends with newline
if (!content.endsWith('\n')) {
content += '\n';
}