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
+559 -136

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}
@@ -0,0 +1,44 @@
/**
* Short-lived cache for live provider probes.
*
* The stack editor probes the bound provider on every debounced keystroke to
* classify a compose ${VAR} as present ("IN VAULT") or missing. Without a cache
* a fast typist would hammer the provider's API. This caches ONLY the KEY NAMES
* of a bulk selector for a few seconds - never the secret values.
*/
import type { SecretProvider, SecretProviderConfig } from './shared';
const TTL_MS = 30_000;
interface Entry {
keys: string[];
at: number;
}
// Keyed by `${providerId}:${selector}`. Bounded by (provider count * distinct
// selectors seen in the TTL window); stale entries are dropped lazily on read.
const cache = new Map<string, Entry>();
/**
* Returns the KEY NAMES available under a provider's bulk selector, cached for
* ~30s per (providerId, selector). Values are discarded immediately - only the
* names are stored and returned. Propagates the provider's errors to the caller.
*/
export async function probeBulkKeysCached(
providerId: number,
provider: SecretProvider,
config: SecretProviderConfig,
selector: string
): Promise<string[]> {
const cacheKey = `${providerId}:${selector}`;
const now = Date.now();
const hit = cache.get(cacheKey);
if (hit && now - hit.at < TTL_MS) {
return hit.keys;
}
const bulk = await provider.resolveBulk(config, selector);
const keys = Object.keys(bulk);
cache.set(cacheKey, { keys, at: now });
return keys;
}
+12
View File
@@ -0,0 +1,12 @@
// The stack env-var name(s) that carry the external-secret-provider bulk selector.
// OP_ENVIRONMENT_ID is the legacy 1Password name; DOCKHAND_SECRET_SELECTOR is the
// generic one the UI writes now. The server resolves either (BULK_SELECTOR_VARS in
// stacks.ts), so both keep working.
//
// The stack modal shows the selector both as a labelled field AND as its plain
// DOCKHAND_SECRET_SELECTOR env row - they are one and the same value (the field is a
// live view of the env var), so the user sees plainly that it IS that variable.
export const SELECTOR_VARS = ['OP_ENVIRONMENT_ID', 'DOCKHAND_SECRET_SELECTOR'];
/** The canonical name the field writes to (a legacy OP_ENVIRONMENT_ID is normalized to this). */
export const BULK_SELECTOR_VAR = 'DOCKHAND_SECRET_SELECTOR';
+40
View File
@@ -0,0 +1,40 @@
/**
* Pure classification for the stack editor's live provider marker.
*
* A compose ${VAR} that is not defined locally is normally red MISSING. When a
* secret provider is bound, we live-probe it: if the key exists in the provider
* it becomes green IN VAULT instead. If the probe failed (provider unreachable /
* bad token / bad path) we keep the old red MISSING - never a false green.
*/
export type InVaultMarkerType = 'required' | 'missing' | 'invault';
/**
* Classifies one required compose var. `providerKeySet` is the set of key names
* the live probe confirmed present in the bound provider. `probeFailed` forces
* MISSING (we can't vouch for the key when the probe didn't succeed).
*/
export function classifyMarker(
name: string,
isMissing: boolean,
providerKeySet: Set<string>,
probeFailed: boolean
): InVaultMarkerType {
if (!isMissing) return 'required';
if (!probeFailed && providerKeySet.has(name)) return 'invault';
return 'missing';
}
/**
* Given the inline op:// (or other provider) references the client submitted as
* `{ varName, ref }` pairs and the SUBSET of ref STRINGS the provider resolved,
* returns the VAR NAMES whose ref resolved. `resolveSecretReferences` keys its
* result by the ref string, not the var name, so the caller maps it back here.
*/
export function resolvedRefVarNames(
refPairs: { varName: string; ref: string }[],
resolvedRefs: string[]
): string[] {
const resolved = new Set(resolvedRefs);
return refPairs.filter((p) => resolved.has(p.ref)).map((p) => p.varName);
}
@@ -0,0 +1,83 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getSecretProviderById } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { getProvider } from '$lib/server/secretproviders';
import { UnsupportedOperationError, parseProviderError } from '$lib/server/secretproviders/shared';
import { probeBulkKeysCached } from '$lib/server/secretproviders/probe-cache';
/**
* Live probe of a stored provider: given a bulk selector and/or inline op://
* references, return which KEY NAMES currently exist in the provider. Only names
* are returned - secret values never leave the server. Powers the editor's live
* "IN VAULT" marker.
*/
export const POST: RequestHandler = async ({ params, cookies, request }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !(await auth.can('secrets', 'view'))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = Number.parseInt(params.id);
if (Number.isNaN(id)) {
return json({ error: 'Invalid secret provider ID' }, { status: 400 });
}
const row = await getSecretProviderById(id);
if (!row) {
return json({ error: 'Secret provider not found' }, { status: 404 });
}
const provider = getProvider(row.type);
if (!provider) {
return json({ error: `Unknown secret provider type: ${row.type}` }, { status: 400 });
}
let selector: string | undefined;
let refs: string[] = [];
try {
const body = await request.json();
if (typeof body?.selector === 'string' && body.selector.trim()) {
selector = body.selector.trim();
}
if (Array.isArray(body?.refs)) {
refs = body.refs.filter((r: unknown): r is string => typeof r === 'string' && !!r.trim());
}
} catch {
// empty/invalid body: nothing to probe
}
let bulkKeys: string[] = [];
let resolvedRefs: string[] = [];
// A provider missing a mode throws UnsupportedOperationError; that is not a
// failure - it just means that mode contributes no keys. Any other throw
// (network, bad token, bad path) is a real probe failure.
if (selector && provider.supportsBulk) {
try {
bulkKeys = await probeBulkKeysCached(id, provider, row.config, selector);
} catch (e) {
if (!(e instanceof UnsupportedOperationError)) {
return json({ ok: false, error: shortError(e) }, { status: 200 });
}
}
}
if (refs.length && provider.supportsReferences) {
try {
const resolved = await provider.resolveSecretReferences(row.config, refs);
resolvedRefs = [...resolved.keys()];
} catch (e) {
if (!(e instanceof UnsupportedOperationError)) {
return json({ ok: false, error: shortError(e) }, { status: 200 });
}
}
}
return json({ ok: true, bulkKeys, resolvedRefs });
};
/** A short, non-reflecting error message for the probe-failed UI line. */
function shortError(e: unknown): string {
const raw = e instanceof Error ? e.message : String(e);
return parseProviderError(raw) ?? raw.slice(0, 200);
}
+11 -2
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { getStackEnvVars, setStackEnvVars, getStackSource } from '$lib/server/db';
import { getStackEnvVars, setStackEnvVars, getStackSource, getStackInjectedSecretKeys, getSecretProviderById } from '$lib/server/db';
import { findStackDir } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { existsSync, readFileSync } from 'node:fs';
@@ -107,7 +107,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
}
}
return json({ variables });
// Provider-injected key NAMES from the last deploy (no values) + the bound
// provider's type/name, so the UI can show "these were loaded from <provider>".
const injectedSecretKeys = [...await getStackInjectedSecretKeys(stackName, envIdNum ?? undefined)];
let secretProvider: { type: string; name: string } | null = null;
if (source?.secretProviderId) {
const p = await getSecretProviderById(source.secretProviderId);
if (p) secretProvider = { type: p.type, name: p.name };
}
return json({ variables, injectedSecretKeys, secretProvider });
} catch (error) {
console.error('Error getting stack env vars:', error);
return json({ error: 'Failed to get environment variables' }, { status: 500 });
@@ -30,35 +30,58 @@
// secretproviders/shared.ts. Non-required fields are optional overrides.
export const PROVIDER_FIELDS: Record<string, ProviderField[]> = {
'op-service-account': [
{ key: 'token', label: 'Service account token', type: 'password', required: true, placeholder: 'ops_eyJ...' },
{ key: 'token', label: 'Service account token', type: 'password', required: true, placeholder: 'ops_eyJ...', hint: 'A 1Password service account token (starts with ops_).' },
],
'op-connect': [
{ key: 'host', label: 'Connect host URL', type: 'text', required: true, placeholder: 'https://connect.example.com' },
{ key: 'token', label: 'Connect token', type: 'password', required: true, placeholder: 'eyJ...' },
{ key: 'host', label: 'Connect host URL', type: 'text', required: true, placeholder: 'https://connect.example.com', hint: 'URL of your 1Password Connect server.' },
{ key: 'token', label: 'Connect token', type: 'password', required: true, placeholder: 'eyJ...', hint: 'A Connect access token with read access to the vault.' },
],
infisical: [
{ key: 'host', label: 'API host', type: 'text', required: true, placeholder: 'https://app.infisical.com' },
{ key: 'token', label: 'Access token', type: 'password', required: true, placeholder: 'st...' },
{ key: 'projectId', label: 'Project ID', type: 'text', required: true, placeholder: 'workspace / project id' },
{ key: 'environment', label: 'Environment', type: 'text', required: true, placeholder: 'prod' },
{ key: 'path', label: 'Secret path', type: 'text', required: false, placeholder: '/' },
{ key: 'host', label: 'API host', type: 'text', required: true, placeholder: 'https://app.infisical.com', hint: 'Infisical Cloud or your self-hosted URL.' },
{ key: 'token', label: 'Access token', type: 'password', required: true, placeholder: 'st...', hint: 'A service token or machine-identity access token.' },
{ key: 'projectId', label: 'Project ID', type: 'text', required: true, placeholder: 'workspace / project id', hint: 'The workspace/project the secrets live in.' },
{ key: 'environment', label: 'Environment', type: 'text', required: true, placeholder: 'prod', hint: 'Environment slug, e.g. prod / staging.' },
{ key: 'path', label: 'Secret path', type: 'text', required: false, placeholder: '/', hint: 'Folder path within the project. Defaults to /.' },
],
vault: [
{ key: 'address', label: 'Vault address', type: 'text', required: true, placeholder: 'https://vault.example.com' },
{ key: 'token', label: 'Vault token', type: 'password', required: true, placeholder: 'hvs...' },
{ key: 'namespace', label: 'Namespace', type: 'text', required: false, placeholder: 'admin (Enterprise / HCP)' },
{ key: 'mount', label: 'KV mount', type: 'text', required: false, placeholder: 'secret' },
{ key: 'address', label: 'Vault address', type: 'text', required: true, placeholder: 'https://vault.example.com', hint: 'Base URL of your Vault server.' },
{ key: 'token', label: 'Vault token', type: 'password', required: true, placeholder: 'hvs...', hint: 'A token with read access to the KV path.' },
{ key: 'namespace', label: 'Namespace', type: 'text', required: false, placeholder: 'admin (Enterprise / HCP)', hint: 'Vault Enterprise / HCP only.' },
{ key: 'mount', label: 'KV mount', type: 'text', required: false, placeholder: 'secret', hint: 'KV v2 mount path. Defaults to "secret".' },
],
doppler: [
{ key: 'token', label: 'Token', type: 'password', required: true, placeholder: 'dp.st.... or dp.pt....', hint: 'A service token (dp.st.) already targets one config. A personal token (dp.pt.) also needs the project and config below.' },
{ key: 'project', label: 'Project', type: 'text', required: false, placeholder: 'only for a personal token (dp.pt.)' },
{ key: 'config', label: 'Config', type: 'text', required: false, placeholder: 'e.g. prd' },
{ key: 'project', label: 'Project', type: 'text', required: false, placeholder: 'only for a personal token (dp.pt.)', hint: 'Doppler project slug. Only needed with a personal token.' },
{ key: 'config', label: 'Config', type: 'text', required: false, placeholder: 'e.g. prd', hint: 'Config within the project. Only needed with a personal token.' },
],
};
export function providerTypeLabel(type: string): string {
return PROVIDER_TYPES.find((t) => t.value === type)?.label ?? type;
}
// Per-stack bulk-selector field metadata (UI-only, like PROVIDER_FIELDS above).
// A provider type with no entry shows no selector field: doppler ignores the
// selector, connect has no bulk pull. The field's value is written to the stack
// env as DOCKHAND_SECRET_SELECTOR (consumed by resolveProviderEnvVars).
export type BulkSelectorField = { label: string; placeholder?: string; hint?: string };
export const BULK_SELECTOR_FIELDS: Record<string, BulkSelectorField> = {
'op-service-account': {
label: 'Environment',
placeholder: '1Password Environment id',
hint: 'Bulk-load every secret from this 1Password Environment. Leave blank to inject only inline op:// references.'
},
'vault': {
label: 'KV v2 path',
placeholder: 'path/to/secret',
hint: 'Bulk-load every key at this KV v2 path (under the configured mount).'
},
'infisical': {
label: 'Secret path',
placeholder: '/',
hint: 'Bulk-load every secret at this path (project and environment come from the provider config).'
}
};
</script>
<script lang='ts'>
@@ -102,6 +125,11 @@
let testOkTimer: ReturnType<typeof setTimeout> | undefined;
const fields = $derived(PROVIDER_FIELDS[formType] ?? []);
// Providers whose config fields read better stacked one per row rather than in the
// 2-column grid (per-field hints, or fields long enough to want full width).
const stackConfigFields = $derived(
formType === 'op-connect' || formType === 'doppler'
);
function resetConfig() {
formConfig = {};
@@ -299,7 +327,7 @@
}
}}
>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Content class="sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<KeyRound class="w-5 h-5 text-muted-foreground" />
@@ -320,7 +348,7 @@
placeholder="Production secrets"
/>
</div>
<div class="space-y-2 sm:w-[calc(50%-0.5rem)]">
<div class="space-y-2">
<FieldLabel label="Provider" forId="provider-type" required showOptional={false} />
<Select.Root
type="single"
@@ -328,10 +356,12 @@
onValueChange={onTypeChange}
disabled={isEditing}
>
<Select.Trigger id="provider-type" class="flex w-full items-center gap-2">
<Select.Trigger id="provider-type" class="w-full justify-between gap-2">
{@const TriggerIcon = getProviderIcon(formType)}
<span class="flex items-center gap-2 min-w-0">
<TriggerIcon class="w-4 h-4 shrink-0 text-muted-foreground" />
{providerTypeLabel(formType)}
<span class="truncate">{providerTypeLabel(formType)}</span>
</span>
</Select.Trigger>
<Select.Content>
{#each PROVIDER_TYPES as t (t.value)}
@@ -346,12 +376,12 @@
</Select.Content>
</Select.Root>
</div>
<!-- Provider config fields in a 2-column grid. min-height fits the tallest
provider (5 fields = 3 rows) so the modal keeps a fixed height and does
not jump when switching providers. -->
<div class="grid grid-cols-2 gap-x-4 gap-y-3" style="min-height: 13.5rem;">
<!-- Provider config fields: a 2-column grid, or one per row for providers whose
fields read better stacked (Vault, Connect, Doppler). min-height +
content-start keep the dialog a stable height while laying rows top-aligned. -->
<div class="grid {stackConfigFields ? 'grid-cols-1' : 'grid-cols-2'} gap-x-4 gap-y-3 content-start" style="min-height: 21rem;">
{#each fields as field (field.key)}
<div class="space-y-2">
<div class="space-y-1.5 self-start {fields.length === 1 ? 'col-span-full' : ''}">
<FieldLabel label={field.label} forId={`provider-${field.key}`} required={field.required} />
<Input
id={`provider-${field.key}`}
+13 -40
View File
@@ -16,6 +16,7 @@
import { copyToClipboard } from '$lib/utils/clipboard';
import CronEditor from '$lib/components/cron-editor.svelte';
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
import SecretProviderPicker from '$lib/components/SecretProviderPicker.svelte';
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
import { toast } from 'svelte-sonner';
import { focusFirstInput } from '$lib/utils';
@@ -155,9 +156,10 @@
let copiedWebhookSecret = $state<'ok' | 'error' | null>(null);
// Secret providers
type SecretProviderOption = { id: number; name: string };
type SecretProviderOption = { id: number; name: string; type: string };
let secretProviders = $state<SecretProviderOption[]>([]);
let formSecretProviderId = $state<number | null>(null);
let injectedSecretKeys = $state<string[]>([]);
// Environment variables state
let formEnvFilePath = $state<string | null>(null);
@@ -219,7 +221,7 @@
const response = await fetch('/api/secret-providers');
if (!response.ok) return;
const data = await response.json();
secretProviders = (data ?? []).map((p: any) => ({ id: p.id, name: p.name }));
secretProviders = (data ?? []).map((p: any) => ({ id: p.id, name: p.name, type: p.type }));
} catch (e) {
console.warn('Failed to load secret providers:', e);
}
@@ -332,6 +334,7 @@
);
// Set envVars - the panel's $effect will auto-sync rawContent for text view
envVars = loadedVars;
injectedSecretKeys = data.injectedSecretKeys ?? [];
}
} catch (e) {
console.error('Failed to load env var overrides:', e);
@@ -1188,46 +1191,16 @@
<!-- Right column: Environment Variables -->
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
<!-- Secret provider selector - hidden when no external provider is configured
(still shown if this stack already has one bound, so it can be cleared). -->
{#if secretProviders.length > 0 || formSecretProviderId !== 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 items-center gap-2 text-xs">
<Label for="secret-provider-select-git" class="text-xs text-muted-foreground shrink-0">Secret provider</Label>
<Select.Root
type="single"
value={formSecretProviderId !== null ? String(formSecretProviderId) : ''}
onValueChange={(v) => { formSecretProviderId = v ? parseInt(v) : null; }}
>
<Select.Trigger id="secret-provider-select-git" class="h-7 text-xs">
{#if formSecretProviderId !== null}
{secretProviders.find((p) => p.id === formSecretProviderId)?.name ?? 'Unknown provider'}
{:else}
<span class="text-muted-foreground">None — disabled</span>
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value="" label="None">
<span class="text-muted-foreground">None — disabled</span>
</Select.Item>
{#each secretProviders as provider (provider.id)}
<Select.Item value={String(provider.id)} label={provider.name}>
{provider.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-xs text-xs">
Inline references (e.g. <code>op://</code> for 1Password) are still resolved. For bulk pull, set <code>OP_ENVIRONMENT_ID</code> (1Password Environment) or the generic <code>DOCKHAND_SECRET_SELECTOR</code> in env vars; the provider's secrets are loaded and injected.
</Tooltip.Content>
</Tooltip.Root>
</div>
{/if}
<SecretProviderPicker
bind:secretProviderId={formSecretProviderId}
bind:envVars
providers={secretProviders}
/>
<StackEnvVarsPanel
bind:variables={envVars}
injectedSecretKeys={gitStack !== null ? injectedSecretKeys : []}
providerType={secretProviders.find((p) => p.id === formSecretProviderId)?.type ?? null}
providerName={secretProviders.find((p) => p.id === formSecretProviderId)?.name ?? null}
placeholder={{ key: 'MY_VAR', value: 'value' }}
infoText="Override variables from your repository env files. Non-secrets are saved to <code class='bg-muted px-1 rounded'>.env.dockhand</code> in the stack directory. Secrets are stored in the database and injected via shell environment at deploy time.<br/><br/>Variables are available for <strong>compose file interpolation</strong> using <code class='bg-muted px-1 rounded'>${'{VAR_NAME}'}</code> syntax. They are not automatically injected into containers — use <code class='bg-muted px-1 rounded'>environment:</code> or reference <code class='bg-muted px-1 rounded'>.env.dockhand</code> in <code class='bg-muted px-1 rounded'>env_file:</code> to pass them through."
existingSecretKeys={gitStack !== null ? existingSecretKeys : new Set()}
+110 -55
View File
@@ -7,7 +7,10 @@
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, GitBranch, GitCommitHorizontal, Github, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, HelpCircle, Archive } from 'lucide-svelte';
import SecretProviderPicker from '$lib/components/SecretProviderPicker.svelte';
import { SELECTOR_VARS } from '$lib/utils/bulk-selector';
import { classifyMarker, resolvedRefVarNames } from '$lib/utils/invault-markers';
import { Layers, Save, Play, Code, GitGraph, GitBranch, GitCommitHorizontal, Github, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, Archive } from 'lucide-svelte';
import BackupPanel from '../containers/BackupPanel.svelte';
import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
import { fetchBackupExecutions } from '$lib/utils/backup';
@@ -15,7 +18,6 @@
import FilesystemBrowser from './FilesystemBrowser.svelte';
import PathBarItem from './PathBarItem.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Select from '$lib/components/ui/select';
import { Badge } from '$lib/components/ui/badge';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { appSettings } from '$lib/stores/settings';
@@ -71,9 +73,24 @@
let backupPanelRef = $state<BackupPanel | undefined>(undefined);
// Secret providers
type SecretProviderOption = { id: number; name: string };
type SecretProviderOption = { id: number; name: string; type: string };
let secretProviders = $state<SecretProviderOption[]>([]);
let formSecretProviderId = $state<number | null>(null);
// Provider-injected key NAMES from the last deploy (banner)
let injectedSecretKeys = $state<string[]>([]);
// Provider type/name for the injected-secrets banner in the env panel.
const selectedProviderType = $derived(
secretProviders.find((p) => p.id === formSecretProviderId)?.type ?? null
);
const selectedProviderName = $derived(
secretProviders.find((p) => p.id === formSecretProviderId)?.name ?? null
);
// Live probe of the bound provider: key NAMES currently present (bulk + resolved
// inline refs). Drives the editor's green IN VAULT marker. Empty when no provider
// is bound or the probe failed; probeError holds the reason on failure.
let providerKeySet = $state<Set<string>>(new Set());
let probeError = $state<string | null>(null);
let probeSeq = 0;
// Environment variables state
let envVars = $state<EnvVar[]>([]);
@@ -81,11 +98,9 @@
let envValidation = $state<ValidationResult | null>(null);
let validating = $state(false);
// The bulk-pull selector vars (OP_ENVIRONMENT_ID for 1Password, the generic
// DOCKHAND_SECRET_SELECTOR for other providers) are consumed by the secret
// provider, not the compose file, so they only count as "used" when a provider
// is bound to the stack.
const SELECTOR_VARS = ['OP_ENVIRONMENT_ID', 'DOCKHAND_SECRET_SELECTOR'];
// SELECTOR_VARS (OP_ENVIRONMENT_ID / DOCKHAND_SECRET_SELECTOR) are consumed by the
// secret provider, not the compose file, so they only count as "used" when a
// provider is bound to the stack.
const effectiveValidation = $derived.by<ValidationResult | null>(() => {
if (!envValidation || formSecretProviderId === null) return envValidation;
if (!envValidation.unused.some((v) => SELECTOR_VARS.includes(v))) return envValidation;
@@ -619,12 +634,14 @@
const markers: VariableMarker[] = [];
// Add missing required variables
// Add missing required variables - but a var the bound provider currently has
// (live probe) is 'invault' (green), not 'missing' (red). A failed probe forces
// MISSING so we never show a false green.
for (const name of envValidation.missing) {
const env = envVarMap.get(name);
markers.push({
name,
type: 'missing',
type: classifyMarker(name, true, providerKeySet, probeError !== null),
value: env?.value,
isSecret: env?.isSecret
});
@@ -664,14 +681,80 @@
debouncedValidate();
}
// Debounced validation to avoid too many API calls while typing
// Debounced validation to avoid too many API calls while typing. The live
// provider probe rides the same cadence so it doesn't hammer the provider.
function debouncedValidate() {
if (validateTimer) clearTimeout(validateTimer);
validateTimer = setTimeout(() => {
validateEnvVars();
runProbe();
}, 1000);
}
// op://... inline references in the current env vars, mapped var -> ref, so a
// resolved ref (the provider returns ref STRINGS) maps back to its var name.
function inlineRefPairs(): { varName: string; ref: string }[] {
const pairs: { varName: string; ref: string }[] = [];
for (const v of envVars) {
const key = v.key.trim();
const val = (v.value ?? '').trim();
if (key && val.startsWith('op://')) pairs.push({ varName: key, ref: val });
}
return pairs;
}
// Live-probe the bound provider for which required keys exist RIGHT NOW. Only
// key NAMES cross the wire. Guardrails: a provider must be selected; on any
// failure the key set is emptied and probeError is set (-> everything MISSING,
// never a false green). Guarded by probeSeq to drop stale responses.
async function runProbe() {
if (formSecretProviderId === null) {
providerKeySet = new Set();
probeError = null;
return;
}
const selector = (() => {
for (const name of SELECTOR_VARS) {
const hit = envVars.find((v) => v.key.trim() === name);
if (hit && hit.value.trim()) return hit.value.trim();
}
return undefined;
})();
const refPairs = inlineRefPairs();
if (!selector && refPairs.length === 0) {
providerKeySet = new Set();
probeError = null;
updateEditorMarkers();
return;
}
const seq = ++probeSeq;
try {
const response = await fetch(`/api/secret-providers/${formSecretProviderId}/probe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selector, refs: refPairs.map((p) => p.ref) })
});
if (seq !== probeSeq) return; // a newer probe superseded this one
const data = await response.json();
if (!response.ok || !data.ok) {
providerKeySet = new Set();
probeError = data.error || `Provider check failed (${response.status})`;
} else {
const names = [
...(data.bulkKeys ?? []),
...resolvedRefVarNames(refPairs, data.resolvedRefs ?? [])
];
providerKeySet = new Set(names);
probeError = null;
}
} catch (e) {
if (seq !== probeSeq) return;
providerKeySet = new Set();
probeError = e instanceof Error ? e.message : 'Provider check failed';
}
updateEditorMarkers();
}
// Explicitly push markers to the editor (immediate=true since this is called after validation)
function updateEditorMarkers() {
if (!codeEditorRef) return;
@@ -722,7 +805,7 @@
const response = await fetch('/api/secret-providers');
if (!response.ok) return;
const data = await response.json();
secretProviders = (data ?? []).map((p: any) => ({ id: p.id, name: p.name }));
secretProviders = (data ?? []).map((p: any) => ({ id: p.id, name: p.name, type: p.type }));
} catch (e) {
console.warn('Failed to load secret providers:', e);
}
@@ -877,6 +960,8 @@
existingSecretKeys = new Set(
loadedVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim())
);
// Provider-injected key names from the last deploy (banner)
injectedSecretKeys = envData.injectedSecretKeys ?? [];
}
// Process raw .env file content
@@ -1319,6 +1404,7 @@
loadComposeFile().then(() => {
// Auto-validate after loading
validateEnvVars();
runProbe();
});
} else if (mode === 'create') {
// Set default compose content for create mode (library templates override default)
@@ -1330,6 +1416,7 @@
loading = false;
// Auto-validate default compose
validateEnvVars();
runProbe();
}
} else if (!open) {
hasInitialized = false; // Reset when modal closes
@@ -1345,6 +1432,7 @@
// Debounce to avoid too many API calls while typing
const timeout = setTimeout(() => {
validateEnvVars();
runProbe();
}, 800);
return () => clearTimeout(timeout);
@@ -1758,55 +1846,22 @@
</div>
<!-- Environment variables panel -->
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
<!-- Secret provider selector - hidden when no external provider is configured
(still shown if this stack already has one bound, so it can be cleared). -->
{#if secretProviders.length > 0 || formSecretProviderId !== 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 items-center gap-2 text-xs">
<Label for="secret-provider-select" class="text-xs text-muted-foreground shrink-0">Secret provider</Label>
<Select.Root
type="single"
value={formSecretProviderId !== null ? String(formSecretProviderId) : ''}
onValueChange={(v) => {
formSecretProviderId = v ? parseInt(v) : null;
markDirty();
}}
>
<Select.Trigger id="secret-provider-select" class="h-7 text-xs">
{#if formSecretProviderId !== null}
{secretProviders.find((p) => p.id === formSecretProviderId)?.name ?? 'Unknown provider'}
{:else}
<span class="text-muted-foreground">None — disabled</span>
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value="" label="None">
<span class="text-muted-foreground">None — disabled</span>
</Select.Item>
{#each secretProviders as provider (provider.id)}
<Select.Item value={String(provider.id)} label={provider.name}>
{provider.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Tooltip.Provider delayDuration={150}>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-xs text-xs">
Inline references (e.g. <code>op://</code> for 1Password) are still resolved. For bulk pull, set <code>OP_ENVIRONMENT_ID</code> (1Password Environment) or the generic <code>DOCKHAND_SECRET_SELECTOR</code> in env vars; the provider's secrets are loaded and injected.
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
{/if}
<SecretProviderPicker
bind:secretProviderId={formSecretProviderId}
bind:envVars
providers={secretProviders}
onchange={() => { markDirty(); debouncedValidate(); }}
/>
<StackEnvVarsPanel
bind:this={envVarsPanelRef}
bind:variables={envVars}
bind:rawContent={rawEnvContent}
validation={effectiveValidation}
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
injectedSecretKeys={mode === 'edit' ? injectedSecretKeys : []}
providerType={selectedProviderType}
providerName={selectedProviderName}
{probeError}
{readonly}
onchange={() => { markDirty(); debouncedValidate(); }}
theme={editorTheme}