From 32a86129d0379834c72a04705518c11d793435ad Mon Sep 17 00:00:00 2001 From: jarek Date: Fri, 14 Aug 2026 09:35:36 +0200 Subject: [PATCH] stack secrets from external providers (1Password, HashiCorp Vault, Infisical and Doppler) --- src/lib/components/CodeEditor.svelte | 31 +++- .../components/SecretProviderPicker.svelte | 94 ++++++++++ src/lib/components/StackEnvVarsPanel.svelte | 84 +++++++-- src/lib/server/secretproviders/probe-cache.ts | 44 +++++ src/lib/utils/bulk-selector.ts | 12 ++ src/lib/utils/invault-markers.ts | 40 +++++ .../secret-providers/[id]/probe/+server.ts | 83 +++++++++ src/routes/api/stacks/[name]/env/+server.ts | 13 +- .../settings/secrets/ProviderModal.svelte | 78 ++++++--- src/routes/stacks/GitStackModal.svelte | 53 ++---- src/routes/stacks/StackModal.svelte | 165 ++++++++++++------ 11 files changed, 560 insertions(+), 137 deletions(-) create mode 100644 src/lib/components/SecretProviderPicker.svelte create mode 100644 src/lib/server/secretproviders/probe-cache.ts create mode 100644 src/lib/utils/bulk-selector.ts create mode 100644 src/lib/utils/invault-markers.ts create mode 100644 src/routes/api/secret-providers/[id]/probe/+server.ts diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index 4f90f78..996b439 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -202,7 +202,7 @@ export interface VariableMarker { name: string; - type: 'required' | 'optional' | 'missing'; + type: 'required' | 'optional' | 'missing' | 'invault'; value?: string; // The value provided in env vars editor isSecret?: boolean; // Whether to mask the value defaultValue?: string; // The default value from compose syntax (e.g., ${VAR:-default}) @@ -239,10 +239,10 @@ // Variable marker gutter icons class VariableGutterMarker extends GutterMarker { - type: 'required' | 'optional' | 'missing'; + type: 'required' | 'optional' | 'missing' | 'invault'; hasValue: boolean; - constructor(type: 'required' | 'optional' | 'missing', hasValue: boolean = false) { + constructor(type: 'required' | 'optional' | 'missing' | 'invault', hasValue: boolean = false) { super(); this.type = type; this.hasValue = hasValue; @@ -256,6 +256,7 @@ const dot = document.createElement('span'); dot.className = `var-marker var-marker-${this.type}`; dot.title = this.type === 'missing' ? 'Missing required variable' + : this.type === 'invault' ? 'Present in the bound secret provider' : this.type === 'required' ? 'Required variable (defined)' : 'Optional variable (has default)'; wrapper.appendChild(dot); @@ -278,9 +279,9 @@ class VariableValueWidget extends WidgetType { value: string; isSecret: boolean; - variant: 'provided' | 'default' | 'missing'; + variant: 'provided' | 'default' | 'missing' | 'invault'; - constructor(value: string, isSecret: boolean = false, variant: 'provided' | 'default' | 'missing' = 'provided') { + constructor(value: string, isSecret: boolean = false, variant: 'provided' | 'default' | 'missing' | 'invault' = 'provided') { super(); this.value = value; this.isSecret = isSecret; @@ -295,6 +296,11 @@ // Red MISSING badge with icon span.innerHTML = '⚠ MISSING'; span.title = 'Required variable not defined'; + } else if (this.variant === 'invault') { + // Green IN VAULT badge - the key currently exists in the bound secret + // provider (live probe). Inline lucide KeyRound SVG, currentColor. + span.innerHTML = 'IN VAULT'; + span.title = 'Present in the bound secret provider'; } else { span.textContent = this.isSecret ? '••••••' : this.value; span.title = this.isSecret ? 'Secret value' : this.value; @@ -351,6 +357,9 @@ } else if (marker.defaultValue) { // Has default value from marker -> BLUE widget = new VariableValueWidget(marker.defaultValue, false, 'default'); + } else if (marker.type === 'invault') { + // Present in the bound secret provider (live) -> GREEN + widget = new VariableValueWidget('', false, 'invault'); } else if (marker.type === 'missing') { // Missing required variable -> RED widget = new VariableValueWidget('', false, 'missing'); @@ -930,6 +939,11 @@ box-shadow: 0 0 4px #ef4444; } + div :global(.var-marker-invault) { + background-color: #10b981; /* emerald-500 */ + box-shadow: 0 0 4px #10b981; + } + /* Variable value overlay widget - base styles */ div :global(.var-value-overlay) { display: inline-block; @@ -968,6 +982,13 @@ font-weight: 600; } + div :global(.var-value-invault) { + background-color: rgba(16, 185, 129, 0.15); + color: #10b981; + border: 1px solid rgba(16, 185, 129, 0.3); + font-weight: 600; + } + /* Light theme adjustments */ :global(.cm-editor:not(.cm-dark)) div :global(.var-value-provided) { background-color: rgba(34, 197, 94, 0.1); diff --git a/src/lib/components/SecretProviderPicker.svelte b/src/lib/components/SecretProviderPicker.svelte new file mode 100644 index 0000000..4699422 --- /dev/null +++ b/src/lib/components/SecretProviderPicker.svelte @@ -0,0 +1,94 @@ + + +{#if providers.length > 0 || secretProviderId !== null} +
+
+ + { secretProviderId = v ? parseInt(v) : null; onchange?.(); }} + > + + {#if secretProviderId !== null} + {providers.find((p) => p.id === secretProviderId)?.name ?? 'Unknown provider'} + {:else} + None — disabled + {/if} + + + + None — disabled + + {#each providers as provider (provider.id)} + + {provider.name} + + {/each} + + + {#if bulkSelectorField} + + writeSelector(e.currentTarget.value)} + /> + {/if} +
+ {#if bulkSelectorField?.hint} +

{bulkSelectorField.hint}

+ {/if} +
+{/if} diff --git a/src/lib/components/StackEnvVarsPanel.svelte b/src/lib/components/StackEnvVarsPanel.svelte index 59e4ab5..3e2d05c 100644 --- a/src/lib/components/StackEnvVarsPanel.svelte +++ b/src/lib/components/StackEnvVarsPanel.svelte @@ -4,8 +4,10 @@ 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, Upload, Trash2, List, FileText, AlertTriangle, ShieldAlert, HelpCircle, Info } from 'lucide-svelte'; + import { Plus, Upload, Trash2, List, FileText, AlertTriangle, ShieldAlert, HelpCircle, Info, Check, KeyRound } from 'lucide-svelte'; import * as Tooltip from '$lib/components/ui/tooltip'; + import { getProviderIcon } from '$lib/components/provider-icons'; + import { providerTypeLabel } from '../../routes/settings/secrets/ProviderModal.svelte'; interface Props { variables: EnvVar[]; // Bindable - ALL variables (secrets + non-secrets) @@ -18,6 +20,13 @@ placeholder?: { key: string; value: string }; infoText?: string; existingSecretKeys?: Set; + /** Provider-injected key NAMES from the last deploy (banner). */ + injectedSecretKeys?: string[]; + /** Bound provider type/name, for the injected banner + pills. */ + providerType?: string | null; + providerName?: string | null; + /** Set when the live provider probe failed - shown as an amber line. */ + probeError?: string | null; showInterpolationHint?: boolean; theme?: 'light' | 'dark'; class?: string; @@ -36,6 +45,10 @@ placeholder = { key: 'VARIABLE_NAME', value: 'value' }, infoText, existingSecretKeys = new Set(), + injectedSecretKeys = [], + providerType = null, + providerName = null, + probeError = null, showInterpolationHint = false, theme = 'dark', class: className = '', @@ -43,6 +56,16 @@ headerActions }: Props = $props(); + + // Provider-injected keys are supplied at deploy from the bound secret provider, so + // they are NOT "missing" even when a ${VAR} in compose has no local value. Drop them + // from the missing set (and expose them separately so the editor can mark them). + const injectedSet = $derived(new Set(injectedSecretKeys)); + const effectiveValidation = $derived.by(() => { + if (!validation || injectedSet.size === 0) return validation; + return { ...validation, missing: validation.missing.filter((k) => !injectedSet.has(k)) }; + }); + const STORAGE_KEY_VIEW_MODE = 'dockhand-env-vars-view-mode'; let fileInputRef: HTMLInputElement; @@ -333,21 +356,21 @@ - {#if validation} + {#if effectiveValidation}
- {#if validation.missing.length > 0} + {#if effectiveValidation.missing.length > 0} - {validation.missing.length} missing + {effectiveValidation.missing.length} missing {/if} - {#if validation.required.length > 0} + {#if effectiveValidation.required.length > 0} - {validation.required.length - validation.missing.length} defined + {effectiveValidation.required.length - effectiveValidation.missing.length} defined {/if} - {#if validation.optional.length > 0} + {#if effectiveValidation.optional.length > 0} - {validation.optional.length} optional + {effectiveValidation.optional.length} optional {/if}
@@ -472,11 +495,50 @@ {/if} + + {#if injectedSecretKeys.length > 0} +
+ +
+
+ {injectedSecretKeys.length} secret{injectedSecretKeys.length === 1 ? '' : 's'} loaded + from + {#if providerType} + {@const ProviderIcon = getProviderIcon(providerType)} + + + {providerTypeLabel(providerType)} + + {#if providerName}· {providerName}{/if} + {:else} + the provider + {/if} +
+

Injected into the container at last deploy — never written to .env.

+
+ {#each injectedSecretKeys as key} + + {key} + + {/each} +
+
+
+ {/if} + + {#if probeError} +
+ +
+ Couldn't check {providerName ?? 'the secret provider'}: {probeError} +
+
+ {/if} - {#if viewMode === 'form' && validation && validation.missing.length > 0 && !readonly} + {#if viewMode === 'form' && effectiveValidation && effectiveValidation.missing.length > 0 && !readonly}
Add missing: - {#each validation.missing as missing} + {#each effectiveValidation.missing as missing}