stack secrets from external providers (1Password, HashiCorp Vault, Infisical and Doppler)
This commit is contained in:
11 files changed
+560
-137
No files matched your search
@@ -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}
|
||||
@@ -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">· {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 — 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}
|
||||
|
||||
Reference in new issue
Block a user