stack secrets from external providers (1Password, HashiCorp Vault, Infisical and Doppler)

This commit is contained in:
jarek committed 2026-08-14 09:35:36 +02:00
1 parent 78d67d2dc1
commit 32a86129d0
11 files changed
+560 -137

No files matched your search

+26 -5
View File
@@ -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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:0.85em;height:0.85em;display:inline-block;vertical-align:-0.12em;margin-right:3px"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/></svg>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);
@@ -0,0 +1,94 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import { BULK_SELECTOR_FIELDS } from '../../routes/settings/secrets/ProviderModal.svelte';
import { SELECTOR_VARS, BULK_SELECTOR_VAR } from '$lib/utils/bulk-selector';
import type { EnvVar } from '$lib/components/StackEnvVarsEditor.svelte';
type ProviderOption = { id: number; name: string; type: string };
interface Props {
secretProviderId: number | null;
/** ALL env vars - the bulk-selector field is a live view of the
* DOCKHAND_SECRET_SELECTOR row within this list. */
envVars: EnvVar[];
providers: ProviderOption[];
/** Suffix for element ids so two pickers on one page don't collide. */
idSuffix?: string;
onchange?: () => void;
}
let {
secretProviderId = $bindable(null),
envVars = $bindable([]),
providers,
idSuffix = '',
onchange
}: Props = $props();
const selectedType = $derived(providers.find((p) => p.id === secretProviderId)?.type ?? null);
const bulkSelectorField = $derived(selectedType ? BULK_SELECTOR_FIELDS[selectedType] ?? null : null);
// Live view of the DOCKHAND_SECRET_SELECTOR (or legacy OP_ENVIRONMENT_ID) row.
const bulkSelectorValue = $derived.by(() => {
for (const name of SELECTOR_VARS) {
const hit = envVars.find((v) => v.key.trim() === name);
if (hit) return hit.value;
}
return '';
});
function writeSelector(value: string) {
const others = envVars.filter((v) => !SELECTOR_VARS.includes(v.key.trim()));
envVars = value.trim()
? [...others, { key: BULK_SELECTOR_VAR, value, isSecret: false }]
: others;
onchange?.();
}
</script>
{#if providers.length > 0 || secretProviderId !== null}
<div class="px-3 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-100/60 dark:bg-zinc-800/60 flex flex-col gap-1.5 text-xs">
<div class="flex items-center gap-2">
<Label for="secret-provider-select{idSuffix}" class="text-xs text-muted-foreground shrink-0">Secret provider</Label>
<Select.Root
type="single"
value={secretProviderId !== null ? String(secretProviderId) : ''}
onValueChange={(v) => { secretProviderId = v ? parseInt(v) : null; onchange?.(); }}
>
<Select.Trigger id="secret-provider-select{idSuffix}" class="h-7 text-xs flex-1 min-w-0 max-w-xs overflow-hidden">
{#if secretProviderId !== null}
<span class="truncate min-w-0">{providers.find((p) => p.id === secretProviderId)?.name ?? 'Unknown provider'}</span>
{:else}
<span class="text-muted-foreground truncate">None — disabled</span>
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value="" label="None">
<span class="text-muted-foreground">None — disabled</span>
</Select.Item>
{#each providers as provider (provider.id)}
<Select.Item value={String(provider.id)} label={provider.name}>
{provider.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if bulkSelectorField}
<Label for="bulk-selector-input{idSuffix}" class="text-xs text-muted-foreground shrink-0 ml-1">
{bulkSelectorField.label}
</Label>
<Input
id="bulk-selector-input{idSuffix}"
class="h-7 text-xs font-mono flex-1 min-w-0 max-w-xs"
placeholder={bulkSelectorField.placeholder ?? ''}
value={bulkSelectorValue}
oninput={(e) => writeSelector(e.currentTarget.value)}
/>
{/if}
</div>
{#if bulkSelectorField?.hint}
<p class="text-2xs text-muted-foreground/70 pl-0.5">{bulkSelectorField.hint}</p>
{/if}
</div>
{/if}
+73 -11
View File
@@ -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<string>;
/** 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<string>(),
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<ValidationResult | null>(() => {
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 @@
</button>
</div>
<!-- Validation status pills -->
{#if validation}
{#if effectiveValidation}
<div class="flex gap-1 flex-wrap">
{#if validation.missing.length > 0}
{#if effectiveValidation.missing.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
{validation.missing.length} missing
{effectiveValidation.missing.length} missing
</span>
{/if}
{#if validation.required.length > 0}
{#if effectiveValidation.required.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
{validation.required.length - validation.missing.length} defined
{effectiveValidation.required.length - effectiveValidation.missing.length} defined
</span>
{/if}
{#if validation.optional.length > 0}
{#if effectiveValidation.optional.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
{validation.optional.length} optional
{effectiveValidation.optional.length} optional
</span>
{/if}
</div>
@@ -472,11 +495,50 @@
</div>
</div>
{/if}
<!-- Provider-injected secrets loaded at the last deploy -->
{#if injectedSecretKeys.length > 0}
<div class="flex items-start gap-2 px-2.5 py-2 rounded bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800/50">
<Check class="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />
<div class="text-xs text-emerald-700 dark:text-emerald-300 min-w-0">
<div class="flex items-center gap-1.5 flex-wrap">
<span class="font-semibold">{injectedSecretKeys.length} secret{injectedSecretKeys.length === 1 ? '' : 's'} loaded</span>
<span class="text-emerald-600/70 dark:text-emerald-400/70">from</span>
{#if providerType}
{@const ProviderIcon = getProviderIcon(providerType)}
<span class="inline-flex items-center gap-1 font-medium">
<ProviderIcon class="w-3.5 h-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
{providerTypeLabel(providerType)}
</span>
{#if providerName}<span class="text-emerald-600/70 dark:text-emerald-400/70">&middot; {providerName}</span>{/if}
{:else}
<span class="font-medium">the provider</span>
{/if}
</div>
<p class="text-emerald-600 dark:text-emerald-400 mt-0.5">Injected into the container at last deploy &mdash; never written to <code>.env</code>.</p>
<div class="flex flex-wrap gap-1.5 mt-1.5">
{#each injectedSecretKeys as key}
<span class="inline-flex items-center gap-1 font-mono text-2xs font-semibold px-2 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-800/40 border border-emerald-300 dark:border-emerald-700">
<KeyRound class="w-2.5 h-2.5" />{key}
</span>
{/each}
</div>
</div>
</div>
{/if}
<!-- Live provider probe couldn't reach the provider: keys fall back to MISSING -->
{#if probeError}
<div class="flex items-start gap-2 px-2.5 py-2 rounded bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
<AlertTriangle class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<div class="text-xs text-amber-700 dark:text-amber-300 min-w-0">
Couldn't check {providerName ?? 'the secret provider'}: {probeError}
</div>
</div>
{/if}
<!-- Add missing variables (form mode only) -->
{#if viewMode === 'form' && validation && validation.missing.length > 0 && !readonly}
{#if viewMode === 'form' && effectiveValidation && effectiveValidation.missing.length > 0 && !readonly}
<div class="flex flex-wrap gap-1 items-center">
<span class="text-xs text-muted-foreground mr-1">Add missing:</span>
{#each validation.missing as missing}
{#each effectiveValidation.missing as missing}
<button
type="button"
onclick={() => addMissingVariable(missing)}
@@ -493,7 +555,7 @@
{#if viewMode === 'form'}
<StackEnvVarsEditor
bind:variables
{validation}
validation={effectiveValidation}
{readonly}
{showSource}
{sources}