1.0.5
This commit is contained in:
1 parent
659d074d00
commit
607d340b71
15 files changed
+619
-375
No files matched your search
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user