mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1.0.5
This commit is contained in:
+114
-46
@@ -1,6 +1,75 @@
|
||||
# Build stage - using Debian to avoid Alpine musl thread creation issues
|
||||
# syntax=docker/dockerfile:1.4
|
||||
# =============================================================================
|
||||
# Dockhand Docker Image - Security-Hardened Build
|
||||
# =============================================================================
|
||||
# This Dockerfile builds a custom Wolfi OS from scratch using apko, ensuring:
|
||||
# - Full transparency (no dependency on pre-built Chainguard images)
|
||||
# - Reproducible builds from open-source Wolfi packages
|
||||
# - Minimal attack surface with only required packages
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 1: OS Generator (Alpine + apko tool)
|
||||
# -----------------------------------------------------------------------------
|
||||
# We use Alpine because it has a shell. This lets us download and run apko
|
||||
# to build our custom Wolfi OS from scratch using open-source packages.
|
||||
FROM alpine:3.21 AS os-builder
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
# Install apko tool (latest stable release)
|
||||
# apko is the tool Chainguard uses to build their images - we use it directly
|
||||
ARG APKO_VERSION=0.30.34
|
||||
ARG TARGETARCH
|
||||
RUN apk add --no-cache curl \
|
||||
&& ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") \
|
||||
&& curl -sL "https://github.com/chainguard-dev/apko/releases/download/v${APKO_VERSION}/apko_${APKO_VERSION}_linux_${ARCH}.tar.gz" \
|
||||
| tar -xz --strip-components=1 -C /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/apko
|
||||
|
||||
# Generate apko.yaml for current target architecture only
|
||||
# We build single-arch to avoid multi-arch layer confusion in extraction
|
||||
RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") \
|
||||
&& printf '%s\n' \
|
||||
"contents:" \
|
||||
" repositories:" \
|
||||
" - https://packages.wolfi.dev/os" \
|
||||
" keyring:" \
|
||||
" - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub" \
|
||||
" packages:" \
|
||||
" - wolfi-base" \
|
||||
" - ca-certificates" \
|
||||
" - busybox" \
|
||||
" - tzdata" \
|
||||
" - bun" \
|
||||
" - docker-cli" \
|
||||
" - docker-compose" \
|
||||
" - sqlite" \
|
||||
" - git" \
|
||||
" - openssh-client" \
|
||||
" - curl" \
|
||||
" - tini" \
|
||||
" - su-exec" \
|
||||
"entrypoint:" \
|
||||
" command: /bin/sh -l" \
|
||||
"archs:" \
|
||||
" - ${APKO_ARCH}" \
|
||||
> apko.yaml
|
||||
|
||||
# Build the OS tarball and extract rootfs
|
||||
# apko creates an OCI tarball - we need to extract the actual filesystem layer
|
||||
RUN apko build apko.yaml dockhand-base:latest output.tar \
|
||||
&& mkdir -p rootfs \
|
||||
&& tar -xf output.tar \
|
||||
&& LAYER=$(tar -tf output.tar | grep '.tar.gz$' | head -1) \
|
||||
&& tar -xzf "$LAYER" -C rootfs
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2: Application Builder
|
||||
# -----------------------------------------------------------------------------
|
||||
# Using Debian to avoid Alpine musl thread creation issues
|
||||
# Alpine's musl libc causes rayon/tokio thread pool panics during svelte-adapter-bun build
|
||||
FROM oven/bun:1.3.5-debian AS builder
|
||||
FROM oven/bun:1.3.5-debian AS app-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -15,72 +84,71 @@ RUN bun install --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
# Build with parallelism - dedicated build VM has 16 CPUs and 32GB RAM
|
||||
# Increased memory limits for parallel compilation with larger semi-space for GC
|
||||
RUN NODE_OPTIONS="--max-old-space-size=8192 --max-semi-space-size=128" bun run build
|
||||
|
||||
# Production stage - minimal Alpine with Bun runtime
|
||||
FROM oven/bun:1.3.5-alpine
|
||||
# Prepare production node_modules (do this in builder where we have compilers)
|
||||
# This ensures native addons compile correctly before copying to hardened runtime
|
||||
RUN rm -rf node_modules && bun install --production --frozen-lockfile \
|
||||
&& rm -rf node_modules/@types node_modules/bun-types
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 3: Final Image (Scratch + Custom Wolfi OS)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM scratch
|
||||
|
||||
# Install our custom-built Wolfi OS (now we have /bin/sh!)
|
||||
COPY --from=os-builder /work/rootfs/ /
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies, create user
|
||||
# Add sqlite for emergency scripts, git for stack git operations, curl for healthchecks
|
||||
# Add docker-cli and docker-cli-compose for stack management (uses host's docker socket)
|
||||
# Add openssh-client for SSH key authentication with git repositories
|
||||
# Upgrade all packages to latest versions for security patches
|
||||
RUN apk upgrade --no-cache \
|
||||
&& apk add --no-cache curl git tini su-exec sqlite docker-cli docker-cli-compose openssh-client iproute2 \
|
||||
&& addgroup -g 1001 dockhand \
|
||||
# Set up environment variables
|
||||
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
|
||||
NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
HOST=0.0.0.0 \
|
||||
DATA_DIR=/app/data \
|
||||
HOME=/home/dockhand \
|
||||
PUID=1001 \
|
||||
PGID=1001
|
||||
|
||||
# Create docker compose plugin symlink (we use `docker compose` syntax, Wolfi has standalone binary)
|
||||
RUN mkdir -p /usr/libexec/docker/cli-plugins \
|
||||
&& ln -s /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
|
||||
|
||||
# Create dockhand user and group (using busybox commands)
|
||||
RUN addgroup -g 1001 dockhand \
|
||||
&& adduser -u 1001 -G dockhand -h /home/dockhand -D dockhand
|
||||
|
||||
# Copy package files and install production dependencies
|
||||
# This is needed because svelte-adapter-bun externalizes some packages (croner, etc.)
|
||||
# that need to be available at runtime. Installing at build time is more reliable
|
||||
# than Bun's auto-install which requires network access and writable cache.
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --production --frozen-lockfile
|
||||
|
||||
# Copy built application (Bun adapter output)
|
||||
COPY --from=builder /app/build ./build
|
||||
|
||||
# Copy bundled subprocess scripts (built by scripts/build-subprocesses.ts)
|
||||
COPY --from=builder /app/build/subprocesses/ ./subprocesses/
|
||||
# Copy application files with correct ownership (avoids layer duplication from chown -R)
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/node_modules ./node_modules
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/package.json ./
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/build ./build
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/build/subprocesses/ ./subprocesses/
|
||||
|
||||
# Copy database migrations
|
||||
COPY drizzle/ ./drizzle/
|
||||
COPY drizzle-pg/ ./drizzle-pg/
|
||||
COPY --chown=dockhand:dockhand drizzle/ ./drizzle/
|
||||
COPY --chown=dockhand:dockhand drizzle-pg/ ./drizzle-pg/
|
||||
|
||||
# Copy legal documents
|
||||
COPY LICENSE.txt PRIVACY.txt ./
|
||||
COPY --chown=dockhand:dockhand LICENSE.txt PRIVACY.txt ./
|
||||
|
||||
# Copy entrypoint script
|
||||
# Copy entrypoint script (root-owned, executable)
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Copy emergency scripts (only the emergency subfolder, not license generation scripts)
|
||||
COPY scripts/emergency/ ./scripts/
|
||||
# Copy emergency scripts
|
||||
COPY --chown=dockhand:dockhand scripts/emergency/ ./scripts/
|
||||
RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true
|
||||
|
||||
# Create directories with proper ownership
|
||||
# Create data directories with correct ownership
|
||||
RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
|
||||
&& chown -R dockhand:dockhand /app /home/dockhand
|
||||
&& chown dockhand:dockhand /app/data /home/dockhand /home/dockhand/.dockhand /home/dockhand/.dockhand/stacks
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Runtime configuration
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV HOME=/home/dockhand
|
||||
|
||||
# User/group IDs - customize with -e PUID=1000 -e PGID=1000
|
||||
# The entrypoint will recreate the dockhand user with these IDs
|
||||
ENV PUID=1001
|
||||
ENV PGID=1001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/ || exit 1
|
||||
CMD curl -f http://localhost:3000/ || exit 1
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["bun", "run", "./build/index.js"]
|
||||
|
||||
@@ -80,7 +80,7 @@ else
|
||||
if [ "$PUID" != "1001" ] || [ "$PGID" != "1001" ]; then
|
||||
echo "Configuring user with PUID=$PUID PGID=$PGID"
|
||||
|
||||
# Remove existing dockhand user/group (only dockhand, not others)
|
||||
# Remove existing dockhand user/group (using busybox commands)
|
||||
deluser dockhand 2>/dev/null || true
|
||||
delgroup dockhand 2>/dev/null || true
|
||||
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
</html>
|
||||
|
||||
|
||||
// Build trigger: 20260102-121809
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view';
|
||||
// Note: Secret masking was removed - secrets are now excluded from the raw editor entirely
|
||||
// and are only stored in the database (never written to .env file)
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language';
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
@@ -787,6 +789,21 @@
|
||||
updateVariableMarkers(markers);
|
||||
}
|
||||
});
|
||||
|
||||
// Sync external value changes to the editor (e.g., when parent clears the content)
|
||||
$effect(() => {
|
||||
const externalValue = value;
|
||||
if (view) {
|
||||
const currentContent = view.state.doc.toString();
|
||||
// Only update if the external value differs from editor content
|
||||
// This prevents feedback loops from editor changes
|
||||
if (externalValue !== currentContent) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: currentContent.length, insert: externalValue }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import StackEnvVarsEditor, { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
import CodeEditor from '$lib/components/CodeEditor.svelte';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { Plus, Info, Upload, Trash2, List, FileText, AlertTriangle } from 'lucide-svelte';
|
||||
import { Plus, Info, Upload, Trash2, List, FileText, AlertTriangle, ShieldAlert } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
interface Props {
|
||||
variables: EnvVar[]; // Bindable - kept in sync with rawContent
|
||||
rawContent?: string; // The actual content saved to disk - source of truth
|
||||
variables: EnvVar[]; // Bindable - ALL variables (secrets + non-secrets)
|
||||
rawContent: string; // Bindable - raw .env file content (comments preserved, no secrets)
|
||||
validation?: ValidationResult | null;
|
||||
readonly?: boolean;
|
||||
showSource?: boolean;
|
||||
@@ -45,14 +45,43 @@
|
||||
let contentAreaRef: HTMLDivElement;
|
||||
let parseWarnings = $state<string[]>([]);
|
||||
let editorTheme = $state<'light' | 'dark'>('dark');
|
||||
let hasMergedOnLoad = $state(false);
|
||||
|
||||
// Track previous variables to detect form changes
|
||||
let prevVariablesJson = $state('');
|
||||
// Count of secrets (for display in hint)
|
||||
const secretCount = $derived(variables.filter(v => v.isSecret && v.key.trim()).length);
|
||||
|
||||
// Track if initial sync has been done (to distinguish initial load from user action)
|
||||
let initialized = $state(false);
|
||||
/**
|
||||
* Merge variables and rawContent on initial load.
|
||||
* Called by parent after setting both variables and rawContent.
|
||||
* This ensures both are in sync regardless of which view mode is active.
|
||||
*/
|
||||
export function mergeOnLoad() {
|
||||
if (hasMergedOnLoad) return;
|
||||
hasMergedOnLoad = true;
|
||||
|
||||
// Parse raw content to EnvVar array
|
||||
// If rawContent exists, parse it and merge with variables (which may have secrets from DB)
|
||||
if (rawContent.trim()) {
|
||||
const { vars: rawVars } = parseRawContent(rawContent);
|
||||
const rawVarsByKey = new Map(rawVars.map(v => [v.key, v]));
|
||||
|
||||
// Secrets come from variables (DB), non-secrets come from rawContent (file)
|
||||
// But if a var exists in variables but not in rawContent, keep it (could be new)
|
||||
const secrets = variables.filter(v => v.isSecret);
|
||||
const nonSecretsFromRaw = rawVars;
|
||||
|
||||
// Also keep non-secrets from variables that aren't in raw (new vars added before first save)
|
||||
const rawKeys = new Set(rawVars.map(v => v.key));
|
||||
const newNonSecrets = variables.filter(v => !v.isSecret && v.key.trim() && !rawKeys.has(v.key));
|
||||
|
||||
variables = [...nonSecretsFromRaw, ...newNonSecrets, ...secrets];
|
||||
}
|
||||
// If no rawContent, variables is already correct (from DB), just need to generate raw
|
||||
// for when user switches to text view (done in handleViewModeChange)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw content to extract non-secret variables.
|
||||
*/
|
||||
function parseRawContent(content: string): { vars: EnvVar[], warnings: string[] } {
|
||||
const result: EnvVar[] = [];
|
||||
const warnings: string[] = [];
|
||||
@@ -82,123 +111,124 @@
|
||||
warnings.push(`Line ${lineNum}: "${key}" (invalid variable name)`);
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
key,
|
||||
value,
|
||||
isSecret: existingSecretKeys.has(key) || false
|
||||
});
|
||||
result.push({ key, value, isSecret: false });
|
||||
}
|
||||
}
|
||||
|
||||
return { vars: result, warnings };
|
||||
}
|
||||
|
||||
// Update rawContent when variables change - replace var lines by position, preserve comments
|
||||
function syncRawContentFromVariables(newVars: EnvVar[]) {
|
||||
/**
|
||||
* Sync variables (non-secrets) TO rawContent.
|
||||
* Preserves comments and formatting. Secrets are excluded.
|
||||
*/
|
||||
function syncVariablesToRaw() {
|
||||
const nonSecretVars = variables.filter(v => v.key.trim() && !v.isSecret);
|
||||
|
||||
// If no raw content exists, generate fresh
|
||||
if (!rawContent.trim()) {
|
||||
if (nonSecretVars.length > 0) {
|
||||
rawContent = nonSecretVars.map(v => `${v.key.trim()}=${v.value}`).join('\n') + '\n';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Update existing raw content - preserve comments, update/add/remove variables
|
||||
const varMap = new Map(nonSecretVars.map(v => [v.key.trim(), v]));
|
||||
const usedKeys = new Set<string>();
|
||||
const lines = rawContent.split('\n');
|
||||
const resultLines: string[] = [];
|
||||
const varsWithKeys = newVars.filter(v => v.key.trim());
|
||||
let varIdx = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Keep comments and blank lines
|
||||
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.slice(0, eqIndex).trim();
|
||||
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
// This is a valid variable line - replace with var at current index
|
||||
if (varIdx < varsWithKeys.length) {
|
||||
const v = varsWithKeys[varIdx];
|
||||
resultLines.push(`${v.key.trim()}=${v.value}`);
|
||||
varIdx++;
|
||||
const varData = varMap.get(key);
|
||||
if (varData) {
|
||||
// Update value
|
||||
resultLines.push(`${key}=${varData.value}`);
|
||||
usedKeys.add(key);
|
||||
}
|
||||
// If we have fewer vars, this line is deleted
|
||||
// If not in varMap, variable was deleted - skip line
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Keep invalid lines as-is
|
||||
|
||||
resultLines.push(line);
|
||||
}
|
||||
|
||||
// Append any new variables
|
||||
while (varIdx < varsWithKeys.length) {
|
||||
const v = varsWithKeys[varIdx];
|
||||
resultLines.push(`${v.key.trim()}=${v.value}`);
|
||||
varIdx++;
|
||||
// Append new variables
|
||||
for (const v of nonSecretVars) {
|
||||
if (!usedKeys.has(v.key.trim())) {
|
||||
resultLines.push(`${v.key.trim()}=${v.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
let result = resultLines.join('\n');
|
||||
if (result && !result.endsWith('\n')) {
|
||||
result += '\n';
|
||||
}
|
||||
return result;
|
||||
rawContent = result;
|
||||
}
|
||||
|
||||
// When rawContent changes externally (text view, file load), update variables
|
||||
$effect(() => {
|
||||
/**
|
||||
* Sync rawContent TO variables.
|
||||
* Parses raw content for non-secrets, preserves existing secrets.
|
||||
*/
|
||||
function syncRawToVariables() {
|
||||
const { vars, warnings } = parseRawContent(rawContent);
|
||||
parseWarnings = warnings;
|
||||
|
||||
// Initial load with no .env file: don't overwrite DB-loaded variables
|
||||
// Let the second $effect generate rawContent from the existing variables instead
|
||||
if (!initialized && !rawContent.trim() && variables.length > 0) {
|
||||
initialized = true;
|
||||
return;
|
||||
// Preserve existing secrets (they're not in rawContent)
|
||||
const existingSecrets = variables.filter(v => v.isSecret);
|
||||
|
||||
// Merge: non-secrets from raw + existing secrets
|
||||
variables = [...vars, ...existingSecrets];
|
||||
}
|
||||
|
||||
/**
|
||||
* Call before saving. Ensures variables and rawContent are in sync.
|
||||
* Always syncs variables→raw to get proper .env content for disk.
|
||||
*/
|
||||
export function prepareForSave(): { rawContent: string; variables: EnvVar[] } {
|
||||
// If in text view, first sync raw→variables to capture edits
|
||||
if (viewMode === 'text') {
|
||||
syncRawToVariables();
|
||||
}
|
||||
initialized = true;
|
||||
// Then sync variables→raw to ensure rawContent is up to date
|
||||
syncVariablesToRaw();
|
||||
|
||||
// When rawContent has content, merge parsed vars with existing DB secrets
|
||||
// This handles the case where .env file exists but DB has additional secrets
|
||||
let finalVars = vars;
|
||||
if (rawContent.trim()) {
|
||||
const parsedKeys = new Set(vars.map(v => v.key));
|
||||
const existingSecrets = untrack(() =>
|
||||
variables.filter(v => v.isSecret && !parsedKeys.has(v.key))
|
||||
);
|
||||
if (existingSecrets.length > 0) {
|
||||
finalVars = [...vars, ...existingSecrets];
|
||||
}
|
||||
}
|
||||
|
||||
const newJson = JSON.stringify(finalVars.map(v => ({ key: v.key, value: v.value })));
|
||||
// Use untrack to read variables without creating a dependency on it
|
||||
// This prevents the effect from running when variables changes (only rawContent should trigger it)
|
||||
const currentNonEmptyJson = untrack(() =>
|
||||
JSON.stringify(variables.filter(v => v.key.trim()).map(v => ({ key: v.key, value: v.value })))
|
||||
);
|
||||
|
||||
if (newJson !== currentNonEmptyJson) {
|
||||
variables = finalVars;
|
||||
prevVariablesJson = newJson;
|
||||
}
|
||||
});
|
||||
|
||||
// When variables change from form edits, update rawContent
|
||||
$effect(() => {
|
||||
const currentJson = JSON.stringify(variables.map(v => ({ key: v.key, value: v.value })));
|
||||
|
||||
// Only sync if variables actually changed (not from parsing rawContent)
|
||||
if (currentJson !== prevVariablesJson) {
|
||||
prevVariablesJson = currentJson;
|
||||
const newRaw = syncRawContentFromVariables(variables);
|
||||
if (newRaw !== rawContent) {
|
||||
rawContent = newRaw;
|
||||
}
|
||||
}
|
||||
});
|
||||
return {
|
||||
rawContent,
|
||||
variables: variables.filter(v => v.key.trim())
|
||||
};
|
||||
}
|
||||
|
||||
function handleTextChange(value: string) {
|
||||
rawContent = value;
|
||||
syncRawToVariables(); // Sync to variables so parent's envVars updates (for compose decorations)
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
function handleViewModeChange(newMode: 'form' | 'text') {
|
||||
if (newMode === 'text' && viewMode === 'form') {
|
||||
// Form → Text: sync variables to raw (preserves comments)
|
||||
syncVariablesToRaw();
|
||||
} else if (newMode === 'form' && viewMode === 'text') {
|
||||
// Text → Form: sync raw to variables (preserves secrets)
|
||||
syncRawToVariables();
|
||||
}
|
||||
|
||||
viewMode = newMode;
|
||||
localStorage.setItem(STORAGE_KEY_VIEW_MODE, newMode);
|
||||
}
|
||||
@@ -233,6 +263,11 @@
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
rawContent = e.target?.result as string;
|
||||
// Parse and merge with existing secrets
|
||||
syncRawToVariables();
|
||||
// Switch to text view to show loaded content
|
||||
viewMode = 'text';
|
||||
localStorage.setItem(STORAGE_KEY_VIEW_MODE, 'text');
|
||||
onchange?.();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
@@ -333,9 +368,14 @@
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:-default}`}</span> optional</span>
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:?error}`}</span> required w/ error</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-2xs text-zinc-400 dark:text-zinc-500">
|
||||
Raw .env file (comments preserved, saved exactly as typed)
|
||||
{:else if secretCount > 0}
|
||||
<!-- Text view hint about secrets (only shown when secrets exist) -->
|
||||
<div class="flex items-start gap-2 px-2 py-1.5 rounded bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
|
||||
<ShieldAlert class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div class="text-2xs text-amber-700 dark:text-amber-300">
|
||||
<span class="font-medium">{secretCount} secret{secretCount === 1 ? '' : 's'} not shown.</span>
|
||||
<span class="text-amber-600 dark:text-amber-400">Secrets are never written to disk and are injected via shell environment when the stack starts.</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Parse warnings (form mode only) -->
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "1.0.5",
|
||||
"date": "2026-01-01",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "Custom hardened image built from scratch using Wolfi packages, eliminating Alpine vulnerabilities" },
|
||||
{ "type": "feature", "text": "Clicking container name opens container details" },
|
||||
{ "type": "feature", "text": "Clicking stack name opens stack editor (internal stacks)" },
|
||||
{ "type": "feature", "text": "Stack env editor now supports freestyle text entry for pasting env contents" },
|
||||
|
||||
@@ -4064,6 +4064,39 @@ export async function getStackEnvVarsAsRecord(
|
||||
return Object.fromEntries(vars.map(v => [v.key, v.value]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only SECRET environment variables as a key-value record (for shell injection).
|
||||
* Returns unmasked real values - used to inject secrets via shell environment at runtime.
|
||||
* These secrets are NEVER written to .env files on disk.
|
||||
* @param stackName - Name of the stack
|
||||
* @param environmentId - Optional environment ID
|
||||
*/
|
||||
export async function getSecretEnvVarsAsRecord(
|
||||
stackName: string,
|
||||
environmentId?: number | null
|
||||
): Promise<Record<string, string>> {
|
||||
const vars = await getStackEnvVars(stackName, environmentId, false);
|
||||
return Object.fromEntries(
|
||||
vars.filter(v => v.isSecret).map(v => [v.key, v.value])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only NON-SECRET environment variables as a key-value record.
|
||||
* Used for .env file operations where secrets should be excluded.
|
||||
* @param stackName - Name of the stack
|
||||
* @param environmentId - Optional environment ID
|
||||
*/
|
||||
export async function getNonSecretEnvVarsAsRecord(
|
||||
stackName: string,
|
||||
environmentId?: number | null
|
||||
): Promise<Record<string, string>> {
|
||||
const vars = await getStackEnvVars(stackName, environmentId, false);
|
||||
return Object.fromEntries(
|
||||
vars.filter(v => !v.isSecret).map(v => [v.key, v.value])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set/replace all environment variables for a stack.
|
||||
* Deletes existing vars and inserts new ones in a transaction-like manner.
|
||||
|
||||
+122
-43
@@ -10,6 +10,9 @@ import { join, resolve } from 'node:path';
|
||||
import {
|
||||
getEnvironment,
|
||||
getStackEnvVarsAsRecord,
|
||||
getSecretEnvVarsAsRecord,
|
||||
getNonSecretEnvVarsAsRecord,
|
||||
getStackEnvVars,
|
||||
setStackEnvVars,
|
||||
getStackSource,
|
||||
upsertStackSource,
|
||||
@@ -257,7 +260,7 @@ export function listManagedStacks(): string[] {
|
||||
*/
|
||||
export async function getStackComposeFile(
|
||||
stackName: string
|
||||
): Promise<{ success: boolean; content?: string; error?: string }> {
|
||||
): Promise<{ success: boolean; content?: string; stackDir?: string; error?: string }> {
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
const composeFile = join(stackDir, 'docker-compose.yml');
|
||||
@@ -266,7 +269,8 @@ export async function getStackComposeFile(
|
||||
if (await ymlFile.exists()) {
|
||||
return {
|
||||
success: true,
|
||||
content: await ymlFile.text()
|
||||
content: await ymlFile.text(),
|
||||
stackDir
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,7 +278,8 @@ export async function getStackComposeFile(
|
||||
if (await yamlFile.exists()) {
|
||||
return {
|
||||
success: true,
|
||||
content: await yamlFile.text()
|
||||
content: await yamlFile.text(),
|
||||
stackDir
|
||||
};
|
||||
}
|
||||
|
||||
@@ -351,7 +356,10 @@ interface ComposeCommandOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a docker compose command locally via Bun.spawn
|
||||
* Execute a docker compose command locally via Bun.spawn.
|
||||
*
|
||||
* @param envVars - Non-secret environment variables (from .env file, passed for backward compat)
|
||||
* @param secretVars - Secret environment variables (injected via shell env, NEVER written to disk)
|
||||
*/
|
||||
async function executeLocalCompose(
|
||||
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull',
|
||||
@@ -359,6 +367,7 @@ async function executeLocalCompose(
|
||||
composeContent: string,
|
||||
dockerHost?: string,
|
||||
envVars?: Record<string, string>,
|
||||
secretVars?: Record<string, string>,
|
||||
forceRecreate?: boolean,
|
||||
removeVolumes?: boolean
|
||||
): Promise<StackOperationResult> {
|
||||
@@ -370,17 +379,23 @@ 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)
|
||||
// Build spawn environment:
|
||||
// 1. Start with process.env
|
||||
// 2. Add DOCKER_HOST if specified
|
||||
// 3. Add non-secret envVars (for backward compat when .env file doesn't exist)
|
||||
// 4. Add secret envVars (CRITICAL: these are NEVER written to disk, only passed via shell env)
|
||||
const spawnEnv: Record<string, string> = { ...(process.env as Record<string, string>) };
|
||||
if (dockerHost) {
|
||||
spawnEnv.DOCKER_HOST = dockerHost;
|
||||
}
|
||||
// Non-secret vars (backup for when .env file doesn't exist yet)
|
||||
if (envVars) {
|
||||
Object.assign(spawnEnv, envVars);
|
||||
}
|
||||
// SECRET vars: injected via shell environment at runtime (NEVER written to .env file)
|
||||
if (secretVars) {
|
||||
Object.assign(spawnEnv, secretVars);
|
||||
}
|
||||
|
||||
// Build command based on operation
|
||||
const args = ['docker', 'compose', '-p', stackName, '-f', composeFile];
|
||||
@@ -505,7 +520,10 @@ async function executeLocalCompose(
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a docker compose command via Hawser agent
|
||||
* Execute a docker compose command via Hawser agent.
|
||||
*
|
||||
* @param envVars - Non-secret environment variables (from .env file)
|
||||
* @param secretVars - Secret environment variables (injected via shell env on Hawser, NEVER in .env)
|
||||
*/
|
||||
async function executeComposeViaHawser(
|
||||
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull',
|
||||
@@ -513,6 +531,7 @@ async function executeComposeViaHawser(
|
||||
composeContent: string,
|
||||
envId: number,
|
||||
envVars?: Record<string, string>,
|
||||
secretVars?: Record<string, string>,
|
||||
forceRecreate?: boolean,
|
||||
removeVolumes?: boolean,
|
||||
stackFiles?: Record<string, string>
|
||||
@@ -521,6 +540,11 @@ async function executeComposeViaHawser(
|
||||
// Import dockerFetch dynamically to avoid circular dependency
|
||||
const { dockerFetch } = await import('./docker.js');
|
||||
|
||||
// Merge envVars and secretVars for passing to Hawser
|
||||
// Hawser will inject ALL these as shell environment variables (secrets are NOT written to .env)
|
||||
const allEnvVars = { ...(envVars || {}), ...(secretVars || {}) };
|
||||
const secretCount = secretVars ? Object.keys(secretVars).length : 0;
|
||||
|
||||
console.log(`${logPrefix} ----------------------------------------`);
|
||||
console.log(`${logPrefix} EXECUTE COMPOSE VIA HAWSER`);
|
||||
console.log(`${logPrefix} ----------------------------------------`);
|
||||
@@ -528,9 +552,10 @@ async function executeComposeViaHawser(
|
||||
console.log(`${logPrefix} Environment ID:`, envId);
|
||||
console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false);
|
||||
console.log(`${logPrefix} Remove volumes:`, removeVolumes ?? false);
|
||||
console.log(`${logPrefix} Env vars count:`, envVars ? Object.keys(envVars).length : 0);
|
||||
if (envVars && Object.keys(envVars).length > 0) {
|
||||
console.log(`${logPrefix} Env vars being sent (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
|
||||
console.log(`${logPrefix} Non-secret env vars count:`, envVars ? Object.keys(envVars).length : 0);
|
||||
console.log(`${logPrefix} Secret env vars count:`, secretCount);
|
||||
if (allEnvVars && Object.keys(allEnvVars).length > 0) {
|
||||
console.log(`${logPrefix} All env vars being sent (masked):`, JSON.stringify(maskSecrets(allEnvVars), null, 2));
|
||||
}
|
||||
console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars');
|
||||
console.log(`${logPrefix} Stack files count:`, stackFiles ? Object.keys(stackFiles).length : 0);
|
||||
@@ -539,22 +564,30 @@ async function executeComposeViaHawser(
|
||||
}
|
||||
|
||||
try {
|
||||
// Build files map - include .env file if envVars provided
|
||||
// Build files map - include .env file ONLY for non-secret envVars
|
||||
// Secrets are passed separately via allEnvVars and injected via shell env
|
||||
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`);
|
||||
if (files['.env']) {
|
||||
// stackFiles already has .env (e.g., from git repo with comments)
|
||||
// Don't overwrite - the envVars are already passed separately for variable substitution
|
||||
console.log(`${logPrefix} Preserving existing .env from stackFiles (${files['.env'].length} chars), envVars passed separately for substitution`);
|
||||
} else {
|
||||
// No .env in stackFiles - generate one from NON-SECRET envVars only
|
||||
const envContent = Object.entries(envVars)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n');
|
||||
files['.env'] = envContent;
|
||||
console.log(`${logPrefix} Generated .env file with ${Object.keys(envVars).length} non-secret variables`);
|
||||
}
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
operation,
|
||||
projectName: stackName,
|
||||
composeFile: composeContent,
|
||||
envVars: envVars || {},
|
||||
files, // All files including .env
|
||||
envVars: allEnvVars, // All vars (including secrets) - Hawser injects via shell env
|
||||
files, // Files including .env (secrets NOT in .env file)
|
||||
forceRecreate: forceRecreate || false,
|
||||
removeVolumes: removeVolumes || false
|
||||
});
|
||||
@@ -610,13 +643,17 @@ async function executeComposeViaHawser(
|
||||
}
|
||||
|
||||
/**
|
||||
* Route compose command to appropriate executor based on connection type
|
||||
* Route compose command to appropriate executor based on connection type.
|
||||
*
|
||||
* @param envVars - Non-secret environment variables (from .env file)
|
||||
* @param secretVars - Secret environment variables (from DB, injected via shell env)
|
||||
*/
|
||||
async function executeComposeCommand(
|
||||
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull',
|
||||
options: ComposeCommandOptions,
|
||||
composeContent: string,
|
||||
envVars?: Record<string, string>
|
||||
envVars?: Record<string, string>,
|
||||
secretVars?: Record<string, string>
|
||||
): Promise<StackOperationResult> {
|
||||
const { stackName, envId, forceRecreate, removeVolumes, stackFiles } = options;
|
||||
|
||||
@@ -631,6 +668,7 @@ async function executeComposeCommand(
|
||||
composeContent,
|
||||
undefined,
|
||||
envVars,
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes
|
||||
);
|
||||
@@ -645,6 +683,7 @@ async function executeComposeCommand(
|
||||
composeContent,
|
||||
envId!,
|
||||
envVars,
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes,
|
||||
stackFiles
|
||||
@@ -659,6 +698,7 @@ async function executeComposeCommand(
|
||||
composeContent,
|
||||
dockerHost,
|
||||
envVars,
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes
|
||||
);
|
||||
@@ -672,6 +712,7 @@ async function executeComposeCommand(
|
||||
composeContent,
|
||||
undefined,
|
||||
envVars,
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes
|
||||
);
|
||||
@@ -842,12 +883,20 @@ async function withContainerFallback(
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Ensure we have a compose file for operations, throw appropriate error if not
|
||||
* Ensure we have a compose file for operations, throw appropriate error if not.
|
||||
*
|
||||
* Returns:
|
||||
* - content: The compose file content
|
||||
* - envVars: Non-secret variables (from .env file, with DB fallback)
|
||||
* - secretVars: Secret variables (from DB only, for shell injection)
|
||||
*
|
||||
* SECURITY: Secrets are NEVER written to .env files. They are stored in the database
|
||||
* and injected via shell environment variables at runtime.
|
||||
*/
|
||||
async function requireComposeFile(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<{ content: string; envVars: Record<string, string> }> {
|
||||
): Promise<{ content: string; envVars: Record<string, string>; secretVars: Record<string, string> }> {
|
||||
const composeResult = await getStackComposeFile(stackName);
|
||||
|
||||
if (!composeResult.success) {
|
||||
@@ -859,11 +908,14 @@ async function requireComposeFile(
|
||||
throw new ComposeFileNotFoundError(stackName);
|
||||
}
|
||||
|
||||
// Get environment variables from database
|
||||
const dbEnvVars = await getStackEnvVarsAsRecord(stackName, envId);
|
||||
// Get SECRET variables from database (for shell injection at runtime)
|
||||
// These are NEVER written to disk
|
||||
const secretVars = await getSecretEnvVarsAsRecord(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
|
||||
// Get non-secret variables from database (for backward compatibility)
|
||||
const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
|
||||
|
||||
// Read non-secret vars from .env file (user can edit this file manually)
|
||||
const stackDir = join(getStacksDir(), stackName);
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
let fileEnvVars: Record<string, string> = {};
|
||||
@@ -890,10 +942,11 @@ async function requireComposeFile(
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: file values as base, DB values override (DB is authoritative for managed vars)
|
||||
const envVars = { ...fileEnvVars, ...dbEnvVars };
|
||||
// Merge non-secret vars: DB as fallback, file values override
|
||||
// This ensures external edits to .env are respected during deployment
|
||||
const envVars = { ...dbNonSecretVars, ...fileEnvVars };
|
||||
|
||||
return { content: composeResult.content!, envVars };
|
||||
return { content: composeResult.content!, envVars, secretVars };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -905,8 +958,8 @@ export async function startStack(
|
||||
envId?: number | null
|
||||
): Promise<StackOperationResult> {
|
||||
try {
|
||||
const { content, envVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('up', { stackName, envId }, content, envVars);
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('up', { stackName, envId }, content, envVars, secretVars);
|
||||
} catch (err) {
|
||||
if (err instanceof ExternalStackError) {
|
||||
return withContainerFallback(stackName, envId, 'start');
|
||||
@@ -924,8 +977,8 @@ export async function stopStack(
|
||||
envId?: number | null
|
||||
): Promise<StackOperationResult> {
|
||||
try {
|
||||
const { content, envVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('stop', { stackName, envId }, content, envVars);
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('stop', { stackName, envId }, content, envVars, secretVars);
|
||||
} catch (err) {
|
||||
if (err instanceof ExternalStackError) {
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
@@ -943,8 +996,8 @@ export async function restartStack(
|
||||
envId?: number | null
|
||||
): Promise<StackOperationResult> {
|
||||
try {
|
||||
const { content, envVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('restart', { stackName, envId }, content, envVars);
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('restart', { stackName, envId }, content, envVars, secretVars);
|
||||
} catch (err) {
|
||||
if (err instanceof ExternalStackError) {
|
||||
return withContainerFallback(stackName, envId, 'restart');
|
||||
@@ -963,8 +1016,8 @@ export async function downStack(
|
||||
removeVolumes = false
|
||||
): Promise<StackOperationResult> {
|
||||
try {
|
||||
const { content, envVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('down', { stackName, envId, removeVolumes }, content, envVars);
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('down', { stackName, envId, removeVolumes }, content, envVars, secretVars);
|
||||
} catch (err) {
|
||||
if (err instanceof ExternalStackError) {
|
||||
// For external stacks, down is the same as stop (no compose file to tear down)
|
||||
@@ -989,12 +1042,14 @@ export async function removeStack(
|
||||
|
||||
// If compose file exists, run docker compose down first
|
||||
if (composeResult.success) {
|
||||
const envVars = await getStackEnvVarsAsRecord(stackName, envId);
|
||||
const envVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
|
||||
const secretVars = await getSecretEnvVarsAsRecord(stackName, envId);
|
||||
const downResult = await executeComposeCommand(
|
||||
'down',
|
||||
{ stackName, envId },
|
||||
composeResult.content!,
|
||||
envVars
|
||||
envVars,
|
||||
secretVars
|
||||
);
|
||||
if (!downResult.success && !force) {
|
||||
return downResult;
|
||||
@@ -1198,9 +1253,9 @@ export async function pullStackImages(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<{ success: boolean; output?: string; error?: string }> {
|
||||
const { content, envVars } = await requireComposeFile(stackName, envId);
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
|
||||
return executeComposeCommand('pull', { stackName, envId }, content, envVars);
|
||||
return executeComposeCommand('pull', { stackName, envId }, content, envVars, secretVars);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -1242,6 +1297,30 @@ export async function writeStackEnvFile(
|
||||
await Bun.write(envFilePath, rawContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write raw environment content directly to the .env file (preserves comments/formatting)
|
||||
*/
|
||||
export async function writeRawStackEnvFile(
|
||||
stackName: string,
|
||||
rawContent: string
|
||||
): Promise<void> {
|
||||
// Guard against writing masked secret placeholders (would corrupt the file)
|
||||
if (rawContent.match(/^[A-Za-z_][A-Za-z0-9_]*=\*\*\*$/m)) {
|
||||
throw new Error('Cannot write masked placeholder "***" to .env file - this would corrupt secret values');
|
||||
}
|
||||
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
|
||||
// Ensure stack directory exists
|
||||
if (!existsSync(stackDir)) {
|
||||
mkdirSync(stackDir, { recursive: true });
|
||||
}
|
||||
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
await Bun.write(envFilePath, rawContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save environment variables for a stack (both to database and .env 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
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Plus, Check, RefreshCw, Mail, Zap, Info, Send, CheckCircle2, XCircle, Key, ChevronDown } from 'lucide-svelte';
|
||||
import { Plus, Check, RefreshCw, Mail, Zap, Info, Send, CheckCircle2, XCircle, Key, ChevronDown, HelpCircle } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
|
||||
@@ -343,7 +344,20 @@
|
||||
|
||||
{#if formType === 'smtp'}
|
||||
<div class="space-y-4 border-t pt-4 min-h-[380px]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">SMTP configuration</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">SMTP configuration</p>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground cursor-help" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="right" class="w-80">
|
||||
<p class="text-xs"><span class="font-semibold">Gmail:</span> smtp.gmail.com, port 587, TLS/SSL off. Use an App Password.</p>
|
||||
<p class="text-xs mt-1"><span class="font-semibold">Outlook:</span> smtp.office365.com, port 587, TLS/SSL off.</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="space-y-2 col-span-2">
|
||||
<Label for="notif-smtp-host">SMTP host *</Label>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import { DataGrid } from '$lib/components/data-grid';
|
||||
import type { DataGridSortState } from '$lib/components/data-grid/types';
|
||||
import { ErrorDialog } from '$lib/components/ui/error-dialog';
|
||||
|
||||
type SortField = 'name' | 'containers' | 'status' | 'cpu' | 'memory';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
@@ -281,14 +282,13 @@
|
||||
let confirmPauseContainerId = $state<string | null>(null);
|
||||
|
||||
// Operation error state (for stack and container operations)
|
||||
let operationError = $state<{ id: string; message: string } | null>(null);
|
||||
let errorTimeouts: ReturnType<typeof setTimeout>[] = [];
|
||||
let operationError = $state<{ id: string; title: string; message: string } | null>(null);
|
||||
|
||||
function clearErrorAfterDelay(id: string) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (operationError?.id === id) operationError = null;
|
||||
}, 5000);
|
||||
errorTimeouts.push(timeoutId);
|
||||
// Error dialog state (for showing detailed errors)
|
||||
let errorDialogData = $state<{ title: string; message: string } | null>(null);
|
||||
|
||||
function showErrorDialog(title: string, message: string) {
|
||||
errorDialogData = { title, message };
|
||||
}
|
||||
|
||||
// Container inspect modal state
|
||||
@@ -673,9 +673,7 @@
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
const errorMsg = data.error || 'Failed to start stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
showErrorDialog(`Failed to start ${name}`, errorMsg);
|
||||
return;
|
||||
}
|
||||
toast.success(`Started ${name}`);
|
||||
@@ -683,9 +681,7 @@
|
||||
} catch (error) {
|
||||
console.error('Failed to start stack:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to start stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
showErrorDialog(`Failed to start ${name}`, errorMsg);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
}
|
||||
@@ -699,9 +695,7 @@
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
const errorMsg = data.error || 'Failed to stop stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
showErrorDialog(`Failed to stop ${name}`, errorMsg);
|
||||
return;
|
||||
}
|
||||
toast.success(`Stopped ${name}`);
|
||||
@@ -709,9 +703,7 @@
|
||||
} catch (error) {
|
||||
console.error('Failed to stop stack:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to stop stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
showErrorDialog(`Failed to stop ${name}`, errorMsg);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
}
|
||||
@@ -725,9 +717,7 @@
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
const errorMsg = data.error || 'Failed to restart stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
showErrorDialog(`Failed to restart ${name}`, errorMsg);
|
||||
return;
|
||||
}
|
||||
toast.success(`Restarted ${name}`);
|
||||
@@ -735,9 +725,7 @@
|
||||
} catch (error) {
|
||||
console.error('Failed to restart stack:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to restart stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
showErrorDialog(`Failed to restart ${name}`, errorMsg);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
}
|
||||
@@ -751,9 +739,7 @@
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
const errorMsg = data.error || 'Failed to bring down stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
showErrorDialog(`Failed to bring down ${name}`, errorMsg);
|
||||
return;
|
||||
}
|
||||
toast.success(`Brought down ${name}`);
|
||||
@@ -761,9 +747,7 @@
|
||||
} catch (error) {
|
||||
console.error('Failed to bring down stack:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to bring down stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
showErrorDialog(`Failed to bring down ${name}`, errorMsg);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
}
|
||||
@@ -789,9 +773,7 @@
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
const errorMsg = data.error || 'Failed to remove stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
showErrorDialog(`Failed to remove ${name}`, errorMsg);
|
||||
return;
|
||||
}
|
||||
toast.success(`Removed ${name}`);
|
||||
@@ -799,9 +781,7 @@
|
||||
} catch (error) {
|
||||
console.error('Failed to remove stack:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to remove stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
showErrorDialog(`Failed to remove ${name}`, errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1970,3 +1950,12 @@
|
||||
onClose={() => showBatchOpModal = false}
|
||||
onComplete={handleBatchComplete}
|
||||
/>
|
||||
|
||||
{#if errorDialogData}
|
||||
<ErrorDialog
|
||||
open={true}
|
||||
title={errorDialogData.title}
|
||||
message={errorDialogData.message}
|
||||
onClose={() => errorDialogData = null}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
import CodeEditor, { type VariableMarker } from '$lib/components/CodeEditor.svelte';
|
||||
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
|
||||
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, ChevronsLeft, ChevronsRight, Variable, HelpCircle, GripVertical } from 'lucide-svelte';
|
||||
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, ChevronsLeft, ChevronsRight, Variable, HelpCircle, GripVertical, FolderOpen } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { ErrorDialog } from '$lib/components/ui/error-dialog';
|
||||
import ComposeGraphViewer from './ComposeGraphViewer.svelte';
|
||||
|
||||
// localStorage key for persisted split ratio
|
||||
@@ -41,20 +42,30 @@
|
||||
|
||||
// Environment variables state
|
||||
let envVars = $state<EnvVar[]>([]);
|
||||
let rawEnvContent = $state('');
|
||||
let rawEnvContent = $state(''); // Raw .env file content (comments preserved)
|
||||
let envValidation = $state<ValidationResult | null>(null);
|
||||
let validating = $state(false);
|
||||
let existingSecretKeys = $state<Set<string>>(new Set());
|
||||
let hadExistingDbVars = $state(false); // Track if DB had any vars on load (for proper cleanup)
|
||||
|
||||
// Simple dirty flag - only set when user touches something
|
||||
let isDirty = $state(false);
|
||||
|
||||
// Error dialog state
|
||||
let operationError = $state<{ title: string; message: string; details?: string } | null>(null);
|
||||
|
||||
// Stack location (for edit mode)
|
||||
let stackLocation = $state<string | null>(null);
|
||||
|
||||
// CodeEditor reference for explicit marker updates
|
||||
let codeEditorRef: CodeEditor | null = $state(null);
|
||||
|
||||
// ComposeGraphViewer reference for resize on panel toggle
|
||||
let graphViewerRef: ComposeGraphViewer | null = $state(null);
|
||||
|
||||
// EnvVarsPanel reference for sync before save
|
||||
let envVarsPanelRef: StackEnvVarsPanel | null = $state(null);
|
||||
|
||||
// Resizable split panel state
|
||||
let splitRatio = $state(60); // percentage for compose panel
|
||||
let isDraggingSplit = $state(false);
|
||||
@@ -239,33 +250,37 @@ services:
|
||||
}
|
||||
|
||||
composeContent = data.content;
|
||||
stackLocation = data.stackDir || null;
|
||||
|
||||
// Load environment variables (parsed)
|
||||
const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId));
|
||||
if (envResponse.ok) {
|
||||
const envData = await envResponse.json();
|
||||
envVars = envData.variables || [];
|
||||
// Track if DB had any vars (for proper cleanup on clear-all)
|
||||
hadExistingDbVars = envVars.length > 0;
|
||||
// Track existing secret keys (secrets loaded from DB cannot have visibility toggled)
|
||||
existingSecretKeys = new Set(
|
||||
envVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim())
|
||||
);
|
||||
}
|
||||
|
||||
// Load raw .env file content
|
||||
// Load raw .env file content (for preserving comments)
|
||||
const rawEnvResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId));
|
||||
if (rawEnvResponse.ok) {
|
||||
const rawEnvData = await rawEnvResponse.json();
|
||||
rawEnvContent = rawEnvData.content || '';
|
||||
console.log('[loadComposeFile] rawEnvContent loaded:', rawEnvContent);
|
||||
}
|
||||
|
||||
// Wait for $effects in StackEnvVarsPanel to settle (parses raw content, syncs variables)
|
||||
await tick();
|
||||
// Reset dirty flag after loading completes
|
||||
isDirty = false;
|
||||
} catch (e: any) {
|
||||
loadError = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
// Merge variables and rawContent after both are loaded
|
||||
await tick();
|
||||
envVarsPanelRef?.mergeOnLoad();
|
||||
// Reset dirty flag after loading completes
|
||||
isDirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,13 +343,13 @@ services:
|
||||
saving = true;
|
||||
error = null;
|
||||
|
||||
// Prepare env vars for creating - syncs variables and rawContent
|
||||
const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] };
|
||||
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
|
||||
// Collect environment variables
|
||||
const definedVars = envVars.filter(v => v.key.trim());
|
||||
|
||||
// Create the stack (include env vars so they're available before start)
|
||||
// Create the stack (include env vars and raw content for .env file)
|
||||
const response = await fetch(appendEnvParam('/api/stacks', envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -342,7 +357,10 @@ services:
|
||||
name: newStackName.trim(),
|
||||
compose: content,
|
||||
start,
|
||||
envVars: definedVars.length > 0 ? definedVars.map(v => ({
|
||||
// Send raw env content (non-secrets only, preserves comments/formatting)
|
||||
rawEnvContent: prepared.rawContent.trim() ? prepared.rawContent : undefined,
|
||||
// Also send parsed vars for DB secret tracking (includes secrets)
|
||||
envVars: prepared.variables.length > 0 ? prepared.variables.map(v => ({
|
||||
key: v.key.trim(),
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
@@ -358,7 +376,11 @@ services:
|
||||
onSuccess();
|
||||
handleClose();
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
operationError = {
|
||||
title: 'Failed to create stack',
|
||||
message: e.message || 'An error occurred while creating the stack',
|
||||
details: e.details
|
||||
};
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -375,6 +397,9 @@ services:
|
||||
saving = true;
|
||||
error = null;
|
||||
|
||||
// Prepare env vars for saving - syncs variables and rawContent
|
||||
const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] };
|
||||
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
|
||||
@@ -397,56 +422,50 @@ services:
|
||||
throw new Error(data.error || 'Failed to save compose file');
|
||||
}
|
||||
|
||||
// Save environment variables
|
||||
// Always save to raw endpoint for consistency
|
||||
// If no raw content but has env vars, generate raw content from vars (backward compat)
|
||||
const definedVars = envVars.filter(v => v.key.trim());
|
||||
let contentToSave = rawEnvContent;
|
||||
// Save raw content to .env file (non-secrets only, comments preserved)
|
||||
const rawEnvResponse = await fetch(
|
||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId),
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: prepared.rawContent })
|
||||
}
|
||||
);
|
||||
|
||||
// Backward compatibility: if no raw file but has DB envs, generate raw content
|
||||
if (!contentToSave.trim() && definedVars.length > 0) {
|
||||
contentToSave = definedVars.map(v => `${v.key.trim()}=${v.value}`).join('\n') + '\n';
|
||||
if (!rawEnvResponse.ok) {
|
||||
const rawEnvError = await rawEnvResponse.json().catch(() => ({ error: 'Failed to save environment file' }));
|
||||
throw new Error(rawEnvError.error || 'Failed to save environment file');
|
||||
}
|
||||
|
||||
// Save if there's any content
|
||||
if (contentToSave.trim() || definedVars.length > 0) {
|
||||
const rawEnvResponse = await fetch(
|
||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId),
|
||||
// Save ALL vars to DB (includes secrets with real values)
|
||||
const definedVars = prepared.variables;
|
||||
if (definedVars.length > 0 || hadExistingDbVars) {
|
||||
const envResponse = await fetch(
|
||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId),
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: contentToSave })
|
||||
body: JSON.stringify({
|
||||
variables: definedVars.map(v => ({
|
||||
key: v.key.trim(),
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
}))
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!rawEnvResponse.ok) {
|
||||
console.error('Failed to save environment file');
|
||||
if (!envResponse.ok) {
|
||||
// Log but don't fail - DB stores secret metadata
|
||||
console.warn('Failed to save environment variable metadata to database');
|
||||
}
|
||||
|
||||
// Also save to DB for secret tracking
|
||||
if (definedVars.some(v => v.isSecret)) {
|
||||
const envResponse = await fetch(
|
||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId),
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
variables: definedVars.map(v => ({
|
||||
key: v.key.trim(),
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
}))
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!envResponse.ok) {
|
||||
console.error('Failed to save secret markers to database');
|
||||
}
|
||||
}
|
||||
hadExistingDbVars = definedVars.length > 0;
|
||||
existingSecretKeys = new Set(
|
||||
definedVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim())
|
||||
);
|
||||
}
|
||||
|
||||
rawEnvContent = contentToSave; // Sync raw content if it was generated
|
||||
isDirty = false; // Reset dirty flag after successful save
|
||||
onSuccess();
|
||||
|
||||
@@ -457,7 +476,11 @@ services:
|
||||
handleClose();
|
||||
}
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
operationError = {
|
||||
title: restart ? 'Failed to apply stack' : 'Failed to save stack',
|
||||
message: e.message || (restart ? 'An error occurred while applying the stack' : 'An error occurred while saving the stack'),
|
||||
details: e.details
|
||||
};
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -481,16 +504,19 @@ services:
|
||||
newStackName = '';
|
||||
error = null;
|
||||
loadError = null;
|
||||
rawEnvContent = '';
|
||||
errors = {};
|
||||
composeContent = '';
|
||||
envVars = [];
|
||||
rawEnvContent = '';
|
||||
envValidation = null;
|
||||
isDirty = false;
|
||||
existingSecretKeys = new Set();
|
||||
hadExistingDbVars = false;
|
||||
activeTab = 'editor';
|
||||
showConfirmClose = false;
|
||||
codeEditorRef = null;
|
||||
operationError = null;
|
||||
stackLocation = null;
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -575,6 +601,11 @@ services:
|
||||
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{#if mode === 'create'}
|
||||
Create a new Docker Compose stack
|
||||
{:else if stackLocation}
|
||||
<span class="flex items-center gap-1">
|
||||
<FolderOpen class="w-3 h-3" />
|
||||
<code class="bg-zinc-200 dark:bg-zinc-700 px-1 rounded text-2xs">{stackLocation}</code>
|
||||
</span>
|
||||
{:else}
|
||||
Edit compose file and view stack structure
|
||||
{/if}
|
||||
@@ -629,13 +660,6 @@ services:
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
{#if error}
|
||||
<Alert.Root variant="destructive" class="mx-6 mt-4">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
{#if errors.compose}
|
||||
<Alert.Root variant="destructive" class="mx-6 mt-4">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
@@ -757,6 +781,7 @@ services:
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<StackEnvVarsPanel
|
||||
bind:this={envVarsPanelRef}
|
||||
bind:variables={envVars}
|
||||
bind:rawContent={rawEnvContent}
|
||||
validation={envValidation}
|
||||
@@ -858,3 +883,15 @@ services:
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Error dialog for failed operations -->
|
||||
{#if operationError}
|
||||
{@const errorDialogOpen = true}
|
||||
<ErrorDialog
|
||||
open={errorDialogOpen}
|
||||
title={operationError.title}
|
||||
message={operationError.message}
|
||||
details={operationError.details}
|
||||
onClose={() => operationError = null}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user