feat(fleet): Fleet Secrets tab with env-var bundles (v1 MVP) (#965)

* feat(fleet): add Fleet Secrets tab with versioned env-var bundles (Skipper+)

Centralized, encrypted-at-rest secret bundles that can be pushed to labeled
nodes' stacks. Each save bumps a monotonic version; each push records a
per-node-per-version row in `secret_pushes` plus an entry in `audit_log`.
Conflict detection shows added/changed/unchanged/removed (informational)
diffs before write. Overlay merge preserves keys missing from the bundle.

- Adds `secrets`, `secret_versions`, `secret_pushes` tables.
- New `SecretsService` reuses CryptoService for AES-256-GCM, NodeLabelService
  for selectors, and direct fetch + Bearer for outbound calls to remote nodes.
- New `secretsRouter` with 9 endpoints under `/api/secrets`, gated by
  `requirePaid`. Mounted after the auth gate.
- Audit summary patterns added for the new routes.
- New Fleet › Secrets tab with bundle list, editor sheet (key=value rows,
  versions tab), and push wizard (selector, target stack, env file picker,
  per-node diff preview, results pills).
- Documentation: docs/features/fleet-secrets.mdx + docs.json nav entry.
- 26 Vitest cases cover parser, encryption, versioning, push aggregation,
  tier gating.

* fix(fleet): use const for rawValue in env parser

ESLint prefer-const flagged the let declaration as a CI-blocking error;
the variable is never reassigned.
This commit is contained in:
Anso
2026-05-07 06:03:39 -04:00
committed by GitHub
parent 52b46753af
commit 0f0b22c51a
14 changed files with 2550 additions and 21 deletions
+9 -20
View File
@@ -1,4 +1,3 @@
import { useExperimental } from '@/hooks/useExperimental';
import {
RefreshCw, Search, Camera,
Network, SlidersHorizontal,
@@ -20,11 +19,11 @@ import { useLicense } from '@/context/LicenseContext';
import { AdmiralGate } from './AdmiralGate';
import FleetSnapshots from './FleetSnapshots';
import { FleetConfiguration } from './fleet/FleetConfiguration';
import { FleetSoonPlaceholder } from './fleet/FleetSoonPlaceholder';
import { RoutingTab } from './fleet/RoutingTab';
import { FederationTab } from './fleet/FederationTab';
import { DeploymentsTab } from './blueprints/DeploymentsTab';
import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
import { SecretsTab } from './fleet/secrets/SecretsTab';
interface FleetViewProps {
onNavigateToNode: (nodeId: number, stackName: string) => void;
@@ -33,7 +32,6 @@ interface FleetViewProps {
export function FleetView({ onNavigateToNode }: FleetViewProps) {
const { isPaid, license } = useLicense();
const isAdmiral = isPaid && license?.variant === 'admiral';
const experimental = useExperimental();
const { prefs, updatePrefs } = useFleetPreferences();
const updateStatus = useFleetUpdateStatus();
@@ -107,15 +105,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<Wrench className="w-4 h-4 mr-1.5" />Fleet Actions
</TabsTrigger>
</TabsHighlightItem>
{experimental && (
<>
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
<TabsHighlightItem value="secrets">
<TabsTrigger value="secrets">
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
</TabsTrigger>
</TabsHighlightItem>
</>
{isPaid && (
<TabsHighlightItem value="secrets">
<TabsTrigger value="secrets">
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
</TabsTrigger>
</TabsHighlightItem>
)}
</TabsHighlight>
</TabsList>
@@ -198,15 +193,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<TabsContent value="actions">
<FleetActionsTab nodes={overview.allNodes} />
</TabsContent>
{experimental && (
{isPaid && (
<TabsContent value="secrets">
<FleetSoonPlaceholder
icon={<KeyRound className="h-4 w-4" />}
kicker="Secrets"
title="One source of truth for env, creds and certs"
description="Push to selected nodes, rotate centrally, audit who-saw-what. Solves silent drift across copies."
plannedActions={['Sync env', 'Rotate', 'Audit', 'Pin to nodes']}
/>
<SecretsTab />
</TabsContent>
)}
</Tabs>