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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
@@ -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)}
|
||||
<TriggerIcon class="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
{providerTypeLabel(formType)}
|
||||
<span class="flex items-center gap-2 min-w-0">
|
||||
<TriggerIcon class="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
<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}`}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user