Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions agent/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ pub struct MasterBundle {
pub garage_rpc_secret: String,
pub patroni_superuser_password: String,
pub patroni_replication_password: String,
pub openbao_seal_key: String,
pub openbao_role_id: String,
pub openbao_secret_id: String,
pub etcd_name: String,
pub etcd_initial_cluster: String,
#[serde(default)]
Expand Down
9 changes: 9 additions & 0 deletions agent/src/master_join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const ETCD_JOIN_ENV_PATH: &str = "/etc/csfx/etcd-join.env";
const PATRONI_SUPERUSER_PATH: &str = "/etc/csfx/patroni-superuser-password";
const PATRONI_REPLICATION_PATH: &str = "/etc/csfx/patroni-replication-password";
const PATRONI_GROUP: &str = "patroni";
const OPENBAO_SEAL_KEY_PATH: &str = "/etc/csfx/openbao-seal-key";
const OPENBAO_ROLE_ID_PATH: &str = "/etc/csfx/openbao-role-id";
const OPENBAO_SECRET_ID_PATH: &str = "/etc/csfx/openbao-secret-id";
const ETCD_DATA_DIR: &str = "/var/lib/etcd";
const LOCAL_STATE_DIRS: [&str; 6] = [
ETCD_DATA_DIR,
Expand Down Expand Up @@ -162,6 +165,9 @@ async fn apply_bundle(bundle: &MasterBundle) -> Result<()> {
&[PATRONI_SUPERUSER_PATH, PATRONI_REPLICATION_PATH],
)
.await?;
write_file(OPENBAO_SEAL_KEY_PATH, &bundle.openbao_seal_key, 0o600)?;
write_file(OPENBAO_ROLE_ID_PATH, &bundle.openbao_role_id, 0o600)?;
write_file(OPENBAO_SECRET_ID_PATH, &bundle.openbao_secret_id, 0o600)?;
write_file(SETUP_DONE_FILE, "", 0o600)
}

Expand Down Expand Up @@ -283,6 +289,9 @@ mod tests {
garage_rpc_secret: "rpc".into(),
patroni_superuser_password: "super".into(),
patroni_replication_password: "repl".into(),
openbao_seal_key: "seal".into(),
openbao_role_id: "role".into(),
openbao_secret_id: "secret".into(),
etcd_name: "node-b".into(),
etcd_initial_cluster: "node-a=http://10.100.0.1:2380,node-b=http://10.100.0.2:2380"
.into(),
Expand Down
143 changes: 143 additions & 0 deletions app/src/lib/api/vaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { authedFetch } from './http';

const API_BASE = '/api';

export interface Vault {
id: string;
resource_group_id: string;
name: string;
sealed: boolean;
soft_delete_days: number;
created_at: string;
}

export interface VaultOverview extends Vault {
secret_count: number;
grant_count: number;
reads_24h: number;
denied_24h: number;
}

export interface SecretSummary {
name: string;
version: number;
updated_at: string;
expires_at: string | null;
deleted: boolean;
}

export interface SecretInput {
name?: string;
value: string;
expires_at: string | null;
}

export interface RevealInput {
reason: string;
code: string;
version?: number;
}

export interface RevealedSecret {
name: string;
version: number;
value: string;
}

export type Capability = 'read_secrets' | 'reveal_secrets' | 'manage';

export interface VaultGrant {
id: string;
vault_id: string;
principal: string;
capability: Capability;
scope: string;
requires_approval: boolean;
created_at: string;
}

export interface GrantInput {
principal: string;
capability: Capability;
scope: string;
requires_approval: boolean;
}

export interface VaultAuditEntry {
id: string;
occurred_at: string;
actor: string;
action: string;
target: string;
source: string;
result: string;
}

function vaultsPath(rgId: string, vaultId?: string): string {
const root = `${API_BASE}/resource-groups/${rgId}/vaults`;
return vaultId ? `${root}/${vaultId}` : root;
}

function headers(token: string): HeadersInit {
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
}

async function failure(res: Response, fallback: string): Promise<Error> {
const body = await res.json().catch(() => ({}));
return new Error(body.error ?? `${fallback}: ${res.status}`);
}

async function send<T>(token: string, url: string, method: string, fallback: string, body?: unknown): Promise<T> {
const res = await authedFetch(url, {
method,
headers: headers(token),
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) throw await failure(res, fallback);
return res.status === 204 ? (undefined as T) : res.json();
}

export const listVaults = (token: string, rgId: string) =>
send<Vault[]>(token, vaultsPath(rgId), 'GET', 'Failed to list vaults');

export const createVault = (token: string, rgId: string, name: string) =>
send<Vault>(token, vaultsPath(rgId), 'POST', 'Failed to create vault', { name });

export const getVault = (token: string, rgId: string, vaultId: string) =>
send<VaultOverview>(token, vaultsPath(rgId, vaultId), 'GET', 'Failed to load vault');

export const deleteVault = (token: string, rgId: string, vaultId: string) =>
send<void>(token, vaultsPath(rgId, vaultId), 'DELETE', 'Failed to delete vault');

export const setVaultSealed = (token: string, rgId: string, vaultId: string, sealed: boolean) =>
send<Vault>(token, `${vaultsPath(rgId, vaultId)}/${sealed ? 'seal' : 'unseal'}`, 'POST', 'Failed to change vault state');

export const listSecrets = (token: string, rgId: string, vaultId: string) =>
send<SecretSummary[]>(token, `${vaultsPath(rgId, vaultId)}/secrets`, 'GET', 'Failed to list secrets');

export const createSecret = (token: string, rgId: string, vaultId: string, input: SecretInput) =>
send<{ name: string; version: number }>(token, `${vaultsPath(rgId, vaultId)}/secrets`, 'POST', 'Failed to create secret', input);

export const updateSecret = (token: string, rgId: string, vaultId: string, name: string, input: SecretInput) =>
send<{ name: string; version: number }>(token, `${vaultsPath(rgId, vaultId)}/secrets/${name}`, 'PUT', 'Failed to update secret', input);

export const deleteSecret = (token: string, rgId: string, vaultId: string, name: string) =>
send<void>(token, `${vaultsPath(rgId, vaultId)}/secrets/${name}`, 'DELETE', 'Failed to delete secret');

export const restoreSecret = (token: string, rgId: string, vaultId: string, name: string, version: number) =>
send<void>(token, `${vaultsPath(rgId, vaultId)}/secrets/${name}/restore`, 'POST', 'Failed to restore secret', { version });

export const revealSecret = (token: string, rgId: string, vaultId: string, name: string, input: RevealInput) =>
send<RevealedSecret>(token, `${vaultsPath(rgId, vaultId)}/secrets/${name}/reveal`, 'POST', 'Failed to reveal secret', input);

export const listGrants = (token: string, rgId: string, vaultId: string) =>
send<VaultGrant[]>(token, `${vaultsPath(rgId, vaultId)}/grants`, 'GET', 'Failed to list access grants');

export const createGrant = (token: string, rgId: string, vaultId: string, input: GrantInput) =>
send<VaultGrant>(token, `${vaultsPath(rgId, vaultId)}/grants`, 'POST', 'Failed to grant access', input);

export const deleteGrant = (token: string, rgId: string, vaultId: string, grantId: string) =>
send<void>(token, `${vaultsPath(rgId, vaultId)}/grants/${grantId}`, 'DELETE', 'Failed to revoke access');

export const listVaultAudit = (token: string, rgId: string, vaultId: string) =>
send<VaultAuditEntry[]>(token, `${vaultsPath(rgId, vaultId)}/audit`, 'GET', 'Failed to load audit log');
55 changes: 55 additions & 0 deletions app/src/lib/components/resource-group/create-vault-dialog.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script lang="ts">
import { auth } from "$lib/auth/store.svelte";
import { createVault, type Vault } from "$lib/api/vaults";
import ModalDialog from "./modal-dialog.svelte";

let { rgId, onCreated }: { rgId: string; onCreated: (vault: Vault) => Promise<void> } = $props();

let dialog = $state<ModalDialog | null>(null);
let name = $state("");
let creating = $state(false);
let error = $state<string | null>(null);

export function open() {
dialog?.open();
}

async function submit() {
if (!auth.token || !name.trim()) return;
creating = true;
error = null;
try {
const vault = await createVault(auth.token, rgId, name.trim());
dialog?.close();
name = "";
await onCreated(vault);
} catch (e) {
error = e instanceof Error ? e.message : "Failed to create vault";
} finally {
creating = false;
}
}
</script>

<ModalDialog bind:this={dialog} title="Create key vault" subtitle="Secrets stay encrypted. Resources read them at start." onclose={() => (error = null)}>
<form
class="md__fields"
onsubmit={(event) => {
event.preventDefault();
submit();
}}
>
<div class="cx-field">
<label class="cx-label" for="kv-name">Name</label>
<input class="cx-input" id="kv-name" placeholder="kv-prod" autocomplete="off" bind:value={name} />
<p class="cx-hint">Lowercase letters, digits and hyphens.</p>
</div>
{#if error}<p class="cx-hint cx-hint--error">{error}</p>{/if}
<div class="md__foot">
<button class="cx-btn cx-btn--ghost cx-btn--sm" type="button" onclick={() => dialog?.close()}>Cancel</button>
<button class="cx-btn cx-btn--primary cx-btn--sm" type="submit" disabled={creating || !name.trim()} aria-busy={creating}>
{creating ? "Creating" : "Create"}
</button>
</div>
</form>
</ModalDialog>
14 changes: 12 additions & 2 deletions app/src/lib/components/resource-group/resource-table.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
type Volume,
type Workload,
} from "$lib/api/resource-groups";
import { deleteVault, type Vault } from "$lib/api/vaults";
import { buildResourceItems, filterResources, workloadHref, type ResourceTab } from "$lib/utils/resource-items";
import BucketRow from "./bucket-row.svelte";
import StackRow from "./stack-row.svelte";
import VaultRow from "./vault-row.svelte";
import VolumeRow from "./volume-row.svelte";
import WorkloadRow from "./workload-row.svelte";

Expand All @@ -26,6 +28,7 @@
{ value: "compute", label: "Compute" },
{ value: "containers", label: "Containers" },
{ value: "storage", label: "Storage" },
{ value: "security", label: "Security" },
];
const NODE_ID_LENGTH = 8;

Expand All @@ -35,18 +38,19 @@
workloads: Workload[];
volumes: Volume[];
buckets: Bucket[];
vaults: Vault[];
gateways: Gateway[];
nodeNames: Map<string, string>;
onChanged: () => Promise<void>;
onError: (message: string) => void;
};

let { groupName, base, workloads, volumes, buckets, gateways, nodeNames, onChanged, onError }: Props = $props();
let { groupName, base, workloads, volumes, buckets, vaults, gateways, nodeNames, onChanged, onError }: Props = $props();

let activeTab = $state<ResourceTab>("all");
let expandedStacks = $state<Set<string>>(new Set());

const allResources = $derived(buildResourceItems(workloads, volumes, buckets));
const allResources = $derived(buildResourceItems(workloads, volumes, buckets, vaults));
const visible = $derived(filterResources(allResources, activeTab));

function token(): string {
Expand Down Expand Up @@ -148,6 +152,12 @@
{/if}
{:else if item.kind === "volume"}
<VolumeRow v={item.data} onDelete={() => run(() => deleteVolume(token(), item.data.id), "Failed to delete volume")} />
{:else if item.kind === "vault"}
<VaultRow
v={item.data}
href="{base}/vault/{item.data.id}"
onDelete={() => run(() => deleteVault(token(), item.data.resource_group_id, item.data.id), "Failed to delete vault")}
/>
{:else}
<BucketRow
b={item.data}
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/components/resource-group/resource-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const RESOURCE_CATEGORIES = [
{ id: "containers", label: "Containers" },
{ id: "storage", label: "Storage" },
{ id: "networking", label: "Networking" },
{ id: "security", label: "Security" },
] as const;

export type ResourceCategory = (typeof RESOURCE_CATEGORIES)[number]["id"];
Expand All @@ -13,6 +14,7 @@ export const RESOURCE_TYPES = [
{ key: "docker-compose", service: "docker-compose", category: "containers", label: "Docker Compose", description: "Deploy related containers as one stack.", equivalent: "COMPOSE.YML · MULTI-CONTAINER STACKS" },
{ key: "bucket", service: "object-storage", category: "storage", label: "Object Storage", description: "S3-compatible buckets for files and backups.", equivalent: "S3 · BLOB STORAGE · CLOUD STORAGE" },
{ key: "volume", service: "block-storage", category: "storage", label: "Block Storage", description: "Attach a volume to a VM or container.", equivalent: "EBS · MANAGED DISK · PERSISTENT DISK" },
{ key: "key-vault", service: "key-vault", category: "security", label: "Key Vault", description: "Store secrets that resources read at start.", equivalent: "KEY VAULT · SECRETS MANAGER · SECRET MANAGER" },
{ key: "api-gateway", service: "load-balancer", category: "networking", label: "API Gateway", description: "Publish services under local or public domains.", equivalent: "API GATEWAY · APIM · REVERSE PROXY" },
] as const;

Expand Down
5 changes: 4 additions & 1 deletion app/src/lib/components/resource-group/rg-state.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type Volume,
type Workload,
} from "$lib/api/resource-groups";
import { listVaults, type Vault } from "$lib/api/vaults";
import { groupHealth } from "$lib/utils/group-health";
import { isTransientStatus } from "$lib/utils/status.js";

Expand All @@ -22,6 +23,7 @@ export class ResourceGroupState {
volumes = $state<Volume[]>([]);
buckets = $state<Bucket[]>([]);
gateways = $state<Gateway[]>([]);
vaults = $state<Vault[]>([]);
loading = $state(true);
error = $state<string | null>(null);

Expand All @@ -44,12 +46,13 @@ export class ResourceGroupState {
load = async () => {
if (!auth.token) return;
try {
[this.group, this.workloads, this.volumes, this.buckets, this.gateways] = await Promise.all([
[this.group, this.workloads, this.volumes, this.buckets, this.gateways, this.vaults] = await Promise.all([
getResourceGroup(auth.token, this.rgId),
listResourceGroupWorkloads(auth.token, this.rgId),
listResourceGroupVolumes(auth.token, this.rgId),
listResourceGroupBuckets(auth.token, this.rgId),
listGateways(auth.token, this.rgId),
listVaults(auth.token, this.rgId).catch((): Vault[] => []),
]);
} catch (e) {
this.error = e instanceof Error ? e.message : "Failed to load";
Expand Down
34 changes: 34 additions & 0 deletions app/src/lib/components/resource-group/vault-row.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script lang="ts">
import { KeyRound, Trash2 } from "@lucide/svelte";
import { goto } from "$app/navigation";
import type { Vault } from "$lib/api/vaults";
import ActionMenu from "$lib/components/resource-group/action-menu.svelte";
import type { MenuAction } from "$lib/components/resource-group/menu-action";
import ServiceIcon from "$lib/components/shell/service-icon.svelte";

let { v, href, onDelete }: { v: Vault; href: string; onDelete: () => void } = $props();

const actions = $derived<MenuAction[]>([
{ label: "Open", icon: KeyRound, onselect: () => goto(href) },
{ label: "Delete", icon: Trash2, onselect: onDelete, danger: true },
]);
</script>

<tr>
<td>
<div class="rs-name">
<ServiceIcon name="key-vault" />
<span class="rs-text">
<button class="rs-link" type="button" onclick={() => goto(href)}>{v.name}</button>
<span>Key Vault</span>
</span>
</div>
</td>
<td>
<span class="cx-badge {v.sealed ? 'cx-badge--warning' : 'cx-badge--success'}">{v.sealed ? "Sealed" : "Unsealed"}</span>
</td>
<td class="cx-soft">Soft delete {v.soft_delete_days} days</td>
<td class="cx-soft">&mdash;</td>
<td class="cx-num">{v.created_at.slice(0, 10)}</td>
<td><ActionMenu label="Actions for {v.name}" {actions} /></td>
</tr>
Loading
Loading