From c2cb295d425d2733ce5494da6926d7187ac42629 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 15 Jul 2026 07:45:47 +0200 Subject: [PATCH] Revert "feat(hcloud): add generic provider setup flow (#26)" This reverts commit 3d448719949369d6d328c00e8fe8e19ade83ae3d. --- AGENTS.md | 6 +- docs/architecture.md | 83 ++-- ...-14-agent-os-hcloud-provider-loader-cli.md | 87 ++++ .../plans/2026-07-14-generic-hcloud-setup.md | 49 --- .../2026-07-14-generic-hcloud-setup-design.md | 45 -- src/bin/akua.ts | 8 +- src/commands/agent-os.ts | 164 +++++++ src/commands/auth.ts | 8 +- src/commands/hcloud.ts | 68 --- src/runtime/hcloud-setup.ts | 338 -------------- src/runtime/platform-client.ts | 235 ++++++++++ src/runtime/secure-token-file.ts | 8 +- test/agent-os-loader.test.ts | 414 ++++++++++++++++++ test/cli.test.ts | 13 +- test/hcloud-setup.test.ts | 194 -------- test/secure-token-file.test.ts | 12 +- 16 files changed, 984 insertions(+), 748 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md delete mode 100644 docs/superpowers/plans/2026-07-14-generic-hcloud-setup.md delete mode 100644 docs/superpowers/specs/2026-07-14-generic-hcloud-setup-design.md create mode 100644 src/commands/agent-os.ts delete mode 100644 src/commands/hcloud.ts delete mode 100644 src/runtime/hcloud-setup.ts create mode 100644 src/runtime/platform-client.ts create mode 100644 test/agent-os-loader.test.ts delete mode 100644 test/hcloud-setup.test.ts diff --git a/AGENTS.md b/AGENTS.md index 8a29463..f9f2e41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,9 +28,9 @@ Run `mise run check` and `mise run generate:check` for every change. Release changes also require the focused release/workflow tests and a current-host compiled archive smoke through `mise run release:smoke`. -The hand-written HCloud setup flow and its security suites are specified in -`docs/architecture.md`, `test/secure-token-file.test.ts`, and -`test/hcloud-setup.test.ts`. +The hand-written Agent OS provider-ingress command and its security suites are +specified in `docs/architecture.md`, `test/secure-token-file.test.ts`, and +`test/agent-os-loader.test.ts`. ## Maintaining this file diff --git a/docs/architecture.md b/docs/architecture.md index fbdc0af..dde632b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,36 +16,63 @@ Status: greenfield scaffold with local auth/config MVP. spec fetch task performs only a read-only OpenAPI GET and rejects non-HTTPS source URLs. -## Generic HCloud Setup - -`akua hcloud setup --workspace --token-file ` is the -compiled local setup flow for an HCloud bring-your-own credential. It reads -caller auth from protected local Akua configuration and rejects environment -authentication. The provider token is accepted only from the hardened absolute -file descriptor reader; it never enters command arguments, stdin, environment, -children, logs, errors, configuration, cache, or rendered output. - -Before cnap persistence, the command makes one bounded attempt for each HCloud -validation request: account authentication, complete paginated user inventory, -server quota, and the live `fsn1` / `CPX32` catalog and price checks. Incomplete, -unexpected, or ambiguous responses fail closed. The preflight never provisions -or spends. - -After a successful preflight, the command uses only public generic cnap -`/v1/secrets` and `/v1/compute_configs` resources. Stable workspace-derived -idempotency keys create `cloud_provider/hcloud` only when an exact reusable -secret is absent, then retain its exact enabled version 1 ID. The compute config -uses `credential_scope.byom` with both that secret ID and version ID. Existing -resources are never changed. On a definite downstream rejection, compensation -deletes only a resource created and confirmed by this invocation; uncertain -outcomes are neither retried nor compensated. - -The descriptor reader accepts only an absolute, caller-owned, regular `0600` +## Agent OS HCloud Provider Loader Companion + +The one exception to the generated-public-command boundary is the compiled, +non-generated command: + +```sh +akua agent-os load-hcloud-provider \ + --workspace \ + --token-file \ + [--expected-ssh-key-fingerprint \ + [--expected-ssh-key-name ]] +``` + +It is a deliberately thin local companion to the server-owned cnap Agent OS +provider-loader transaction (`POST /v1/agent_os/hcloud_provider_loads`). The +cnap transaction is the canonical source of truth for workspace authorization, +provider identity and inventory validation, storage, idempotency, compensation, +revocation, and all provider policy. This CLI never implements an inventory, +uses generic `/secrets` or `/compute_configs` calls, opens a browser, runs a +shell child, or falls back to another endpoint. + +The command requires workspace and token-file flags and rejects positional +input, `--token`, stdin, provider-token environment/profile input, API URL +overrides, debug body output, and retry transports. A provider-returned SSH key +fingerprint is optional and may be sent only when predeclared; its optional name +requires the fingerprint. With no expected key, cnap requires a fully empty +inventory. The CLI never derives identity from the provider token. It reads +normal Akua caller authentication only from the protected local Akua config; +`AKUA_API_TOKEN` is rejected for this command. +It sends that authentication in `Authorization`, the explicit selection in +`Akua-Context`, a newly generated `Idempotency-Key`, and a body containing the +provider token plus optional `expected_ssh_key_fingerprint` and +`expected_ssh_key_name`. The production base URL and route are fixed; tests may +inject a fake HTTPS transport only through an internal dependency seam. The +client allowlists only `loader_id`, `attestation_id`, `secret_id`, +`secret_version_id`, `compute_config_id`, and `expected_ssh_key_fingerprint`, +preserving the secret-version continuity field before spend. + +The provider file is opened exactly once in the compiled process by a dedicated +Unix reader. The reader accepts only an absolute, caller-owned, regular `0600` file. It obtains pre-open `lstat` metadata, opens with `O_NOFOLLOW | O_CLOEXEC`, compares device/inode/UID/mode with `fstat`, performs one bounded descriptor -read, and closes before HTTP submission. The mutable source buffer is cleared -after the flow. Bun cannot promise physical heap zeroisation; the contract is no -deliberate secret persistence or exposure through CLI interfaces. +read, and closes before HTTP submission. It rejects symlinks, substitutions, +directories, devices, FIFOs, sockets, wrong owners, empty input, and oversized +input. The token is held only in a mutable byte buffer for request assembly; +the buffer is overwritten immediately after the single request attempt. Bun +cannot promise physical heap zeroisation, so the security contract is no +deliberate secret persistence or exposure through CLI interfaces, logs, reports, +or configuration. A stronger heap guarantee requires a reviewed native module, +not a weaker file or API contract. + +The endpoint is a release dependency delivered by +[cnap #545](https://github.com/akua-dev/cnap/pull/545), implementing +`agentOs.hcloudProviderLoads.create`: its production delivery must complete and +its released route contract must exactly match this companion before CLI +publication or Phase A invocation. The companion's eventual CLI-owned release is coordinated as +`0.9.0`; it does not duplicate the multi-platform distribution scope in PR #21. ## Current Repo Boundary diff --git a/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md new file mode 100644 index 0000000..7856233 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md @@ -0,0 +1,87 @@ +# Agent OS HCloud Provider Loader CLI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the one compiled `akua agent-os load-hcloud-provider` command that reads one protected local provider file once and relays it with a workspace-bound issuer attestation and optional predeclared anchor fingerprint to the dedicated cnap transaction without exposing or retaining token contents. + +**Architecture:** A command parser validates only the two required flags and obtains normal caller authentication from protected local config. A Unix descriptor reader performs the file security checks and returns a single mutable byte buffer. A fixed-route HTTPS client builds the request in-process, sends precisely once with an idempotency key, clears the buffer, and projects the response through a fixed non-secret allowlist. The cnap server remains the owner of validation, persistence, inventory, rollback, and revocation. + +**Tech Stack:** Bun 1.3, TypeScript, Node-compatible `fs` descriptor APIs, Bun test, built-in `fetch` HTTPS transport, synthetic fixtures only. + +## Global Constraints + +- The only added non-generated command is `akua agent-os load-hcloud-provider --workspace --token-file --project-identity-attestation [--project-anchor-ssh-key-fingerprint ]`. +- Held production request contract: `POST https://api.akua.dev/v1/agent_os/hcloud_provider_loads`, `Authorization` from protected config only, `Akua-Context: `, generated `Idempotency-Key`, and JSON body with `provider_token`, verbatim non-secret `project_identity_attestation`, and optional predeclared `project_anchor_ssh_key_fingerprint`. cnap must verify the attestation binds the exact workspace and one-shot request; no-anchor requires fully empty inventory. Never derive either identity input from the token; do not add a project-anchor name unless cnap #540's released contract requires it. Preserve allowlisted `secret_version_id` and `transaction_id` for pre-spend continuity checks. +- No provider secret may enter argv, stdin, environment, profile, browser, shell child, curl, config, cache, log, stdout, stderr, error message, crash report, or test report. +- Reject `AKUA_API_TOKEN` and all provider-token/environment/API-base override inputs for this command; do not retry a request after a transport outcome is uncertain. +- The reader must require absolute, own-UID regular `0600` files, use `O_NOFOLLOW | O_CLOEXEC`, compare `lstat` and `fstat` dev/inode/uid/mode, read one bounded descriptor once, and close before networking. +- Clear every mutable token/request byte buffer in `finally`; no JavaScript runtime can prove physical heap zeroisation. +- Output may contain only the allowlisted server result or a fixed failure code, status, request ID, and opaque resource IDs. +- [cnap #540](https://github.com/akua-dev/cnap/issues/540) is a hard merge and Phase-A dependency. Do not open a CLI PR, publish, tag, deploy, merge, or create a release until its HCloud project-identity security gate resolves and its released route contract exactly matches this client. Coordinate the first CLI-owned release as `0.9.0`, separate from PR #21's distribution scope. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `src/runtime/secure-token-file.ts` | Secure Unix metadata/open/read/close primitive and buffer clear helper. | +| `src/runtime/platform-client.ts` | Fixed-route, one-attempt HTTPS request and allowlisted response projection. | +| `src/commands/agent-os.ts` | Flag parser, auth ordering, request orchestration, and safe command envelope. | +| `src/commands/auth.ts` | Expose a protected-config-only caller credential reader without changing auth command precedence. | +| `src/bin/akua.ts` | Route and help entry for the single command. | +| `test/secure-token-file.test.ts` | Synthetic descriptor, mode, owner, one-read, and swap tests. | +| `test/agent-os-loader.test.ts` | Fake HTTPS transport, no-retention, ordering, idempotency, allowlist, revocation, and no-retry tests. | +| `test/cli.test.ts` | Entrypoint routing and public help regression coverage. | +| `docs/architecture.md` | CLI ownership, endpoint, release handoff, and security boundary. | + +### Task 1: Secure descriptor reader + +**Files:** Create `src/runtime/secure-token-file.ts`; create `test/secure-token-file.test.ts`. + +**Interfaces:** Produce `readSecureTokenFile(path, dependencies?): Promise` and `clearBytes(bytes): void`. Dependencies expose `lstat`, `open`, `fstat`, and one descriptor `read` only to make substitution/read-count tests deterministic. + +- [x] **Step 1: Write failing secure-reader tests** for a synthetic `0600` regular file, rejected relative/symlink/directory/FIFO/device/wrong-owner/wrong-mode/empty/oversize files, exactly one descriptor read, close-before-return, and a hook that swaps a path between `lstat` and `open`. +- [x] **Step 2: Run `bun test test/secure-token-file.test.ts`** and confirm failures name the missing module/functions rather than fixture setup. +- [x] **Step 3: Implement the minimal reader** with absolute-path validation; `lstat`; numeric `O_RDONLY | O_NOFOLLOW | O_CLOEXEC`; `fstat` identity/mode/UID checks; exactly one bounded `read`; `finally` close; and fixed `AkuaCliError` codes/messages that never interpolate the supplied path. +- [x] **Step 4: Re-run `bun test test/secure-token-file.test.ts`** and confirm all focused cases pass. +- [x] **Step 5: Commit the reader and its tests** with a focused conventional commit. + +### Task 2: Fixed transport and response projection + +**Files:** Create `src/runtime/platform-client.ts`; create `test/agent-os-loader.test.ts`. + +**Interfaces:** Produce `submitHcloudProviderLoad({ workspace, callerToken, providerToken, idempotencyKey }, dependencies?): Promise`. The request is one POST to the fixed route; dependency injection may replace transport for a local fake HTTPS server, never expose a CLI option. + +- [x] **Step 1: Write failing transport tests** using synthetic sentinels and a fake HTTPS server/transport: fixed method/path/headers/body shape, one submission, idempotency-key relay, response field allowlist, server fixed-error projection, no retry after a thrown/ambiguous submission, and byte clearing after both success and failure. +- [x] **Step 2: Run `bun test test/agent-os-loader.test.ts`** and confirm the expected module/function failures. +- [x] **Step 3: Implement the minimal client** with a fixed HTTPS URL, manual byte-oriented JSON quoting, one `fetch` invocation, no debug/body logging, `finally` buffer overwrites, and strict success/failure schema projection that discards unknown server fields. +- [x] **Step 4: Re-run `bun test test/agent-os-loader.test.ts`** and confirm the focused transport suite passes. +- [x] **Step 5: Commit the transport and focused tests** with a focused conventional commit. + +### Task 3: Command, protected auth, and routing + +**Files:** Modify `src/commands/auth.ts`, `src/commands/agent-os.ts`, `src/bin/akua.ts`, `test/agent-os-loader.test.ts`, and `test/cli.test.ts`. + +**Interfaces:** `agentOsView(argv, env, dependencies?): Promise` accepts only the exact command flags. `readProtectedCallerToken(env)` returns the locally stored caller token or a fixed auth error and never reads `AKUA_API_TOKEN` for this command. + +- [x] **Step 1: Write failing command tests** for required explicit workspace/token file, safe rejection of token/stdin/positionals/env/profile/API URL flags, config-only auth, auth-before-file/network ordering, file-before-network ordering, no child-process calls, stdout/stderr/error sentinel absence, fixed exit codes, success allowlist, and a fake-server revoke then post-revoke failure. +- [x] **Step 2: Run `bun test test/agent-os-loader.test.ts test/cli.test.ts`** and confirm failures are caused by the absent command behavior. +- [x] **Step 3: Implement the minimal orchestration**: parse only the two flags, reject environment auth, read protected config auth before opening the provider file, create one UUID idempotency key, read/close token file, call fixed transport once, and emit only the projected result through the existing renderer. +- [x] **Step 4: Re-run `bun test test/agent-os-loader.test.ts test/cli.test.ts`** and confirm all loader and routing tests pass. +- [x] **Step 5: Commit the command/routing slice** with a focused conventional commit. + +### Task 4: Contract documentation and full verification + +**Files:** Modify `docs/architecture.md`; this plan; optionally `AGENTS.md` only if the repository lacks durable command/testing guidance. + +- [x] **Step 1: Write failing documentation/contract assertions** where practical (route absence from generated registry; command appears in help; no deprecated provider-input path appears in the architecture document). +- [x] **Step 2: Run the focused assertions** and observe the expected red condition before any production behavior they cover. +- [x] **Step 3: Update architecture documentation** with the exact non-secret route, thin-client/server ownership, safe file contract, no-retention constraints, cnap-first release ordering, PR #21 boundary, and `0.9.0` coordination. +- [x] **Step 4: Run `mise run check` and `mise run build:binary`**; inspect the compiled help and the full test output for failures or sentinel leakage. +- [x] **Step 5: Commit remaining documentation and run `git diff --check`, `git status --short`, and the full validation commands** before reporting completion. + +## Self-Review + +- [x] Coverage: Tasks 1–3 cover every local CLI requirement: exact flags, protected config authentication, one bounded secure read, fixed single HTTPS submission, buffer clear, output projection, ordering, idempotency, no retry, revocation, and synthetic-only security regression tests. Task 4 covers architecture/release boundaries and whole-repo validation. +- [x] Dependency: [cnap #540](https://github.com/akua-dev/cnap/issues/540) is the canonical server task. Its HCloud project-identity security gate and exact released route contract are mandatory before any CLI PR, merge, release, or Phase-A invocation. +- [x] Placeholder scan: no `TODO`, `TBD`, “implement later,” or undefined interface names remain. +- [x] Consistency: `readSecureTokenFile` produces the mutable bytes consumed by `submitHcloudProviderLoad`; `agentOsView` is the only caller and clears/handles failure before rendering. diff --git a/docs/superpowers/plans/2026-07-14-generic-hcloud-setup.md b/docs/superpowers/plans/2026-07-14-generic-hcloud-setup.md deleted file mode 100644 index 94d2693..0000000 --- a/docs/superpowers/plans/2026-07-14-generic-hcloud-setup.md +++ /dev/null @@ -1,49 +0,0 @@ -# Generic HCloud Setup Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the removed provider-loader with a secret-safe generic HCloud setup command. - -**Architecture:** A command owns protected local inputs and delegates to an in-process HCloud preflight plus a generic cnap resource client. Both transports are injected in tests; production uses fixed HTTPS URLs and never retries an uncertain request. - -**Tech Stack:** Bun, TypeScript, Bun test, cnap public OpenAPI endpoints. - -## Global Constraints - -- The only hand-written public command is `akua hcloud setup --workspace --token-file `. -- Provider bytes are read only by `readSecureTokenFile`, never logged or passed to children, and cleared after use. -- Persistence uses only `/v1/secrets` and `/v1/compute_configs`, each with deterministic idempotency keys. -- Preflight never creates provider resources; uncertain outcomes are not retried or compensated. - ---- - -### Task 1: Replace product-specific routing and documentation - -**Files:** -- Delete: the released product-specific command, transport, and test files -- Modify: `src/bin/akua.ts`, `test/cli.test.ts`, `docs/architecture.md` - -- [ ] Write a failing help/routing test for `akua hcloud setup` and absence of the removed command. -- [ ] Run `bun test test/cli.test.ts` and confirm the new assertion fails. -- [ ] Route the new command and replace documentation with its generic contract. -- [ ] Run `bun test test/cli.test.ts` and confirm it passes. - -### Task 2: Add HCloud preflight and generic cnap setup tests - -**Files:** -- Create: `test/hcloud-setup.test.ts` -- Create: `src/commands/hcloud.ts`, `src/runtime/hcloud-setup.ts` - -- [ ] Write synthetic transport tests for pagination, invalid auth, inventory, quota/catalog/price rejection, no uncertain retry, version continuity, reuse, success, and ownership-safe compensation. -- [ ] Run `bun test test/hcloud-setup.test.ts` and confirm it fails because the command is absent. -- [ ] Implement the smallest injected transport interfaces and fixed-route production clients that satisfy the tests. -- [ ] Run `bun test test/hcloud-setup.test.ts` and confirm it passes. - -### Task 3: Verify generated and release surfaces - -**Files:** -- Modify: `docs/architecture.md`, `AGENTS.md` only if durable repository guidance changes - -- [ ] Confirm no product-specific route or naming remains in source, tests, or docs. -- [ ] Run `mise run check`, `mise run generate:check`, focused release/workflow tests, and `mise run release:smoke`. -- [ ] Commit the implementation, integrate current `origin/main`, and re-run the same verification. diff --git a/docs/superpowers/specs/2026-07-14-generic-hcloud-setup-design.md b/docs/superpowers/specs/2026-07-14-generic-hcloud-setup-design.md deleted file mode 100644 index 1ac5048..0000000 --- a/docs/superpowers/specs/2026-07-14-generic-hcloud-setup-design.md +++ /dev/null @@ -1,45 +0,0 @@ -# Generic HCloud Setup Design - -## Goal - -Replace the released product-specific provider loader with `akua hcloud setup`, -a local, no-provisioning flow that validates an HCloud account and creates (or -reuses) generic cnap secret and compute-config resources. - -## Command and data flow - -`akua hcloud setup --workspace --token-file ` accepts no -provider token value, stdin, environment token, base-URL override, or shell -fallback. Protected local Akua auth is read before the hardened descriptor -reader returns a single mutable token buffer. The buffer is used only by -in-process HCloud and cnap transports and is cleared in a `finally` block. - -The HCloud preflight uses one attempt per request: `/me`, every paginated -user-resource inventory collection, `/limits`, and the `fsn1`, `CPX32`, and -price catalog inputs. Missing, duplicated, malformed, unexpected, or failed -responses stop before cnap persistence. No preflight operation provisions or -spends. - -After preflight, the cnap client lists paginated HCloud secrets and compute -configs. It reuses only one exact matching resource. A missing secret is -created as `cloud_provider/hcloud` with a deterministic per-workspace -idempotency key, then its complete version list is inspected to retain exactly -version 1's ID. A missing config is created with `credential_scope` set to -`byom`, the exact secret ID, and that exact version ID. Both resources use the -same stable setup names and deterministic resource-specific idempotency keys. - -## Failure handling - -No uncertain transport outcome is retried or compensated. A definite downstream -client rejection after a newly confirmed secret create causes best-effort delete -of only that invocation-owned secret, guarded by its returned ETag. A newly -confirmed config is similarly deleted before its invocation-owned secret. The -flow never changes or deletes a pre-existing or reused resource. - -## Testing - -Synthetic transports exercise HCloud pagination, invalid token, unexpected -inventory, quota/catalog/price rejection, no retry after an uncertain outcome, -exact version continuity, reuse, successful generic setup, and compensation -ownership. Source-level tests ensure the removed route and product naming do -not remain in CLI source, tests, or architecture documentation. diff --git a/src/bin/akua.ts b/src/bin/akua.ts index b651c6c..faa2931 100644 --- a/src/bin/akua.ts +++ b/src/bin/akua.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun import { authView } from "../commands/auth"; -import { hcloudView } from "../commands/hcloud"; +import { agentOsView } from "../commands/agent-os"; import { buildHomeView } from "../commands/home"; import { commandRegistry } from "../generated/commands.gen"; import { AkuaCliError, commandNotImplemented, usageError } from "../runtime/errors"; @@ -48,8 +48,8 @@ async function route(argv: readonly string[], env: Record arg.startsWith("-")); @@ -100,7 +100,7 @@ function helpView(): RenderEnvelope { " akua auth login Save a local API token", " akua auth status Show local authentication status", " akua auth logout Remove the saved local API token", - " akua hcloud setup --workspace --token-file ", + " akua agent-os load-hcloud-provider --workspace --token-file [--expected-ssh-key-fingerprint [--expected-ssh-key-name ]]", " akua commands List generated public OpenAPI command registry", " akua --help Show help", " akua --version Show version", diff --git a/src/commands/agent-os.ts b/src/commands/agent-os.ts new file mode 100644 index 0000000..9885743 --- /dev/null +++ b/src/commands/agent-os.ts @@ -0,0 +1,164 @@ +import { randomUUID } from "node:crypto"; +import { isAbsolute } from "node:path"; + +import { readProtectedCallerToken } from "./auth"; +import { usageError, AkuaCliError } from "../runtime/errors"; +import { + submitHcloudProviderLoad, + type HCloudProviderLoadInput, + type HCloudProviderLoadResult, +} from "../runtime/platform-client"; +import { clearBytes, readSecureTokenFile } from "../runtime/secure-token-file"; +import type { RenderEnvelope } from "../runtime/render"; + +export interface AgentOsDependencies { + readProtectedCallerToken(env: Record): Promise; + readSecureTokenFile(path: string): Promise; + submit(input: HCloudProviderLoadInput): Promise; + createIdempotencyKey(): string; +} + +const productionDependencies: AgentOsDependencies = { + readProtectedCallerToken, + readSecureTokenFile, + submit: submitHcloudProviderLoad, + createIdempotencyKey: randomUUID, +}; + +export async function agentOsView( + argv: readonly string[], + env: Record, + dependencies: AgentOsDependencies = productionDependencies, +): Promise { + if (argv[0] !== "load-hcloud-provider") { + throw usageError("Unknown agent-os subcommand."); + } + const options = parseLoadHcloudProviderFlags(argv.slice(1)); + if (env.AKUA_API_TOKEN !== undefined && env.AKUA_API_TOKEN !== "") { + throw new AkuaCliError({ + type: "usage_error", + code: "AKUA_LOADER_ENV_AUTH_FORBIDDEN", + message: "Environment authentication is not accepted for this provider loader.", + exitCode: 2, + }); + } + + const callerToken = await dependencies.readProtectedCallerToken(env); + const providerToken = await dependencies.readSecureTokenFile(options.tokenFile); + try { + const data = await dependencies.submit({ + workspace: options.workspace, + callerToken, + providerToken, + expectedSshKeyFingerprint: options.expectedSshKeyFingerprint, + expectedSshKeyName: options.expectedSshKeyName, + idempotencyKey: dependencies.createIdempotencyKey(), + }); + return { + command: "akua agent-os load-hcloud-provider", + data, + }; + } finally { + clearBytes(providerToken); + } +} + +interface LoadHcloudProviderOptions { + workspace: string; + tokenFile: string; + expectedSshKeyFingerprint?: string; + expectedSshKeyName?: string; +} + +function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProviderOptions { + let workspace: string | undefined; + let tokenFile: string | undefined; + let expectedSshKeyFingerprint: string | undefined; + let expectedSshKeyName: string | undefined; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (!value.startsWith("-")) { + throw usageError("Unexpected argument for agent-os provider loader."); + } + const name = flagName(value); + if ( + name !== "--workspace" && + name !== "--token-file" && + name !== "--expected-ssh-key-fingerprint" && + name !== "--expected-ssh-key-name" + ) { + throw usageError("Unsupported agent-os provider loader option."); + } + const parsed = readFlagValue(argv, index, name); + if (parsed.value === undefined || parsed.value === "") { + throw usageError(`Missing value for ${name}.`); + } + if (parsed.consumedNext) { + index += 1; + } + if (name === "--workspace") { + if (workspace !== undefined) { + throw usageError("The workspace may be specified only once."); + } + workspace = parsed.value; + } else if (name === "--token-file") { + if (tokenFile !== undefined) { + throw usageError("The token file may be specified only once."); + } + tokenFile = parsed.value; + } else if (name === "--expected-ssh-key-fingerprint") { + if (expectedSshKeyFingerprint !== undefined) { + throw usageError("The expected SSH key fingerprint may be specified only once."); + } + expectedSshKeyFingerprint = parsed.value; + } else { + if (expectedSshKeyName !== undefined) { + throw usageError("The expected SSH key name may be specified only once."); + } + expectedSshKeyName = parsed.value; + } + } + if (workspace === undefined) { + throw usageError("Missing required --workspace flag."); + } + if (tokenFile === undefined) { + throw usageError("Missing required --token-file flag."); + } + if (tokenFile === "-" || !isAbsolute(tokenFile)) { + throw usageError("The provider token must be supplied through an absolute file path."); + } + if (expectedSshKeyFingerprint !== undefined && !isSafeExpectedSshField(expectedSshKeyFingerprint)) { + throw usageError("The expected SSH key fingerprint is malformed."); + } + if (expectedSshKeyName !== undefined && !isSafeExpectedSshField(expectedSshKeyName)) { + throw usageError("The expected SSH key name is malformed."); + } + if (expectedSshKeyName !== undefined && expectedSshKeyFingerprint === undefined) { + throw usageError("--expected-ssh-key-name requires --expected-ssh-key-fingerprint."); + } + return { workspace, tokenFile, expectedSshKeyFingerprint, expectedSshKeyName }; +} + +function isSafeExpectedSshField(value: string): boolean { + return /^[\x21-\x7e]{1,200}$/.test(value); +} + +function readFlagValue( + argv: readonly string[], + index: number, + flag: string, +): { value: string | undefined; consumedNext: boolean } { + const value = argv[index]; + if (value === flag) { + const next = argv[index + 1]; + if (next === undefined || next.startsWith("-")) { + return { value: undefined, consumedNext: false }; + } + return { value: next, consumedNext: true }; + } + return { value: value.slice(flag.length + 1), consumedNext: false }; +} + +function flagName(value: string): string { + return value.includes("=") ? value.slice(0, value.indexOf("=")) : value; +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 6a510f3..18a78be 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -45,8 +45,8 @@ export async function readProtectedCallerToken(env: Record): Promise; - readSecureTokenFile(path: string): Promise; - setup(input: HcloudSetupInput): Promise; - clearBytes(bytes: Uint8Array): void; - createIdempotencyKey(workspace: string): string; -} - -const productionDependencies: HcloudCommandDependencies = { - readProtectedCallerToken, - readSecureTokenFile, - setup: setupHcloud, - clearBytes, - createIdempotencyKey: (workspace) => `hcloud-setup-${createHash("sha256").update(workspace).digest("hex").slice(0, 40)}`, -}; - -export async function hcloudView( - argv: readonly string[], - env: Record, - dependencies: HcloudCommandDependencies = productionDependencies, -): Promise { - if (argv[0] !== "setup") throw usageError("Unknown hcloud subcommand."); - const options = parseSetupFlags(argv.slice(1)); - const callerToken = await dependencies.readProtectedCallerToken(env); - const providerToken = await dependencies.readSecureTokenFile(options.tokenFile); - try { - const data = await dependencies.setup({ workspace: options.workspace, callerToken, providerToken, idempotencyKey: dependencies.createIdempotencyKey(options.workspace) }); - return { command: "akua hcloud setup", data }; - } finally { - dependencies.clearBytes(providerToken); - } -} - -function parseSetupFlags(argv: readonly string[]): { workspace: string; tokenFile: string } { - let workspace: string | undefined; - let tokenFile: string | undefined; - for (let index = 0; index < argv.length; index += 1) { - const value = argv[index]; - if (!value.startsWith("-")) throw usageError("Unexpected argument for hcloud setup."); - const name = value.includes("=") ? value.slice(0, value.indexOf("=")) : value; - if (name !== "--workspace" && name !== "--token-file") throw usageError("Unsupported hcloud setup option."); - const inline = value.includes("=") ? value.slice(value.indexOf("=") + 1) : undefined; - const next = inline === undefined ? argv[index + 1] : undefined; - const parsed = inline ?? (next === undefined || next.startsWith("-") ? undefined : next); - if (parsed === undefined || parsed === "") throw usageError(`Missing value for ${name}.`); - if (inline === undefined) index += 1; - if (name === "--workspace") { - if (workspace !== undefined) throw usageError("The workspace may be specified only once."); - workspace = parsed; - } else { - if (tokenFile !== undefined) throw usageError("The token file may be specified only once."); - tokenFile = parsed; - } - } - if (workspace === undefined) throw usageError("Missing required --workspace flag."); - if (tokenFile === undefined) throw usageError("Missing required --token-file flag."); - if (tokenFile === "-" || !isAbsolute(tokenFile)) throw usageError("The provider token must be supplied through an absolute file path."); - return { workspace, tokenFile }; -} diff --git a/src/runtime/hcloud-setup.ts b/src/runtime/hcloud-setup.ts deleted file mode 100644 index 225df6a..0000000 --- a/src/runtime/hcloud-setup.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { AkuaCliError } from "./errors"; - -const HCloudBaseUrl = "https://api.hetzner.cloud/v1"; -const CnapBaseUrl = "https://api.akua.dev"; -const SECRET_NAME = "hcloud"; -const CONFIG_NAME = "hcloud-fsn1-cpx32"; -const INITIAL_VERSION = 1; -const MAX_PAGES = 1000; - -const inventoryRequests = [ - { collection: "servers", query: {} }, - { collection: "volumes", query: {} }, - { collection: "primary_ips", query: {} }, - { collection: "floating_ips", query: {} }, - { collection: "load_balancers", query: {} }, - { collection: "networks", query: {} }, - { collection: "firewalls", query: {} }, - { collection: "placement_groups", query: {} }, - { collection: "ssh_keys", query: {} }, - { collection: "certificates", query: {} }, - { collection: "images", query: { type: "snapshot" } }, - { collection: "images", query: { type: "backup" } }, -] as const; - -export interface SetupRequest { - method: "GET" | "POST" | "DELETE"; - path: string; - query?: Readonly>; - headers?: Readonly>; - body?: unknown; -} - -interface SetupResponse { - status: number; - body: unknown; -} - -export interface HcloudSetupDependencies { - sendHcloud(request: SetupRequest, providerToken: Uint8Array): Promise; - sendCnap(request: SetupRequest): Promise; -} - -export interface HcloudSetupInput { - workspace: string; - callerToken: string; - providerToken: Uint8Array; - idempotencyKey: string; -} - -export interface HcloudSetupResult { - secret_id: string; - secret_version_id: string; - compute_config_id: string; -} - -export class HcloudSetupError extends AkuaCliError {} - -const productionDependencies: HcloudSetupDependencies = { - sendHcloud: async (request, providerToken) => sendJson(HCloudBaseUrl, request, providerAuthorization(providerToken)), - sendCnap: async (request) => sendJson(CnapBaseUrl, request), -}; - -export async function setupHcloud( - input: HcloudSetupInput, - dependencies: HcloudSetupDependencies = productionDependencies, -): Promise { - await validateHcloud(input.providerToken, dependencies); - - const cnap = cnapClient(input, dependencies); - let secret: SecretReference; - let secretOwned = false; - try { - const existingSecrets = await listAll(cnap, "/v1/secrets", { kind: "cloud_provider/hcloud", limit: "100" }); - const matchingSecrets = existingSecrets.filter(isMatchingSecret); - if (matchingSecrets.length > 1) { - throw rejected("The generic HCloud secret is ambiguous."); - } - if (matchingSecrets.length === 1) { - secret = await resolveInitialVersion(matchingSecrets[0] as Record, cnap); - } else { - const created = await createSecret(cnap, input.providerToken); - secretOwned = true; - secret = await resolveInitialVersion(created, cnap); - } - } catch (error) { - throw normalizeCnapError(error); - } - - try { - const configs = await listAll(cnap, "/v1/compute_configs", { view: "full", limit: "100" }); - const namedConfigs = configs.filter((value) => isRecord(value) && value.name === CONFIG_NAME); - if (namedConfigs.length > 1) { - throw rejected("The generic HCloud compute config is ambiguous."); - } - if (namedConfigs.length === 1) { - const config = namedConfigs[0] as Record; - if (!isMatchingConfig(config, secret)) { - throw rejected("The existing HCloud compute config does not match this setup."); - } - return result(secret, requiredString(config, "id")); - } - - const created = await createConfig(cnap, secret); - return result(secret, requiredString(created, "id")); - } catch (error) { - const normalized = normalizeCnapError(error); - if (secretOwned && normalized.code !== "AKUA_CNAP_OUTCOME_UNKNOWN") { - await deleteOwned(cnap, secret).catch(() => undefined); - } - throw normalized; - } -} - -async function validateHcloud(providerToken: Uint8Array, dependencies: HcloudSetupDependencies): Promise { - try { - const me = await dependencies.sendHcloud({ method: "GET", path: "/me" }, providerToken); - if (me.status === 401 || me.status === 403) { - throw new HcloudSetupError({ type: "authentication_error", code: "AKUA_HCLOUD_AUTH_REJECTED", message: "HCloud rejected the provider credential.", exitCode: 3 }); - } - requireHcloudSuccess(me); - if (!isRecord(me.body) || !isRecord(me.body.customer)) { - throw preflightRejected(); - } - for (const request of inventoryRequests) { - await requireEmptyInventory(request.collection, request.query, providerToken, dependencies); - } - await requireQuota(providerToken, dependencies); - await requireCatalogAndPrice(providerToken, dependencies); - } catch (error) { - if (error instanceof HcloudSetupError) { - throw error; - } - throw new HcloudSetupError({ type: "transport_error", code: "AKUA_HCLOUD_OUTCOME_UNKNOWN", message: "The HCloud preflight outcome is unknown and was not retried.", exitCode: 1 }); - } -} - -async function requireEmptyInventory( - collection: string, - query: Readonly>, - providerToken: Uint8Array, - dependencies: HcloudSetupDependencies, -): Promise { - let page = 1; - for (let count = 0; count < MAX_PAGES; count += 1) { - const response = await dependencies.sendHcloud({ method: "GET", path: `/${collection}`, query: { ...query, page: String(page), per_page: "50" } }, providerToken); - requireHcloudSuccess(response); - if (!isRecord(response.body) || !Array.isArray(response.body[collection])) { - throw preflightRejected(); - } - if (response.body[collection].length > 0) { - throw new HcloudSetupError({ type: "validation_error", code: "AKUA_HCLOUD_INVENTORY_NOT_EMPTY", message: "HCloud inventory must be empty before setup.", exitCode: 2 }); - } - const next = nextPage(response.body, page); - if (next === null) return; - page = next; - } - throw preflightRejected(); -} - -async function requireQuota(providerToken: Uint8Array, dependencies: HcloudSetupDependencies): Promise { - const response = await dependencies.sendHcloud({ method: "GET", path: "/limits" }, providerToken); - requireHcloudSuccess(response); - const limits = isRecord(response.body) ? response.body.limits : undefined; - if (!Array.isArray(limits)) throw preflightRejected(); - const servers = limits.filter((value) => isRecord(value) && value.name === "server"); - if (servers.length !== 1 || !isRecord(servers[0]) || !hasCapacity(servers[0])) throw preflightRejected(); -} - -async function requireCatalogAndPrice(providerToken: Uint8Array, dependencies: HcloudSetupDependencies): Promise { - const [locations, serverTypes, pricing] = await Promise.all([ - dependencies.sendHcloud({ method: "GET", path: "/locations", query: { name: "fsn1" } }, providerToken), - dependencies.sendHcloud({ method: "GET", path: "/server_types", query: { name: "CPX32" } }, providerToken), - dependencies.sendHcloud({ method: "GET", path: "/pricing" }, providerToken), - ]); - requireHcloudSuccess(locations); - requireHcloudSuccess(serverTypes); - requireHcloudSuccess(pricing); - if (!hasSingleLocation(locations.body) || !hasFsn1Cpx32(serverTypes.body) || !hasFsn1Price(pricing.body)) throw preflightRejected(); -} - -function cnapClient(input: HcloudSetupInput, dependencies: HcloudSetupDependencies) { - const headers = { authorization: `Bearer ${input.callerToken}`, "akua-context": input.workspace }; - return { - list: async (path: string, query: Readonly>) => dependencies.sendCnap({ method: "GET", path, query, headers }), - create: async (path: string, body: unknown, scope: string) => dependencies.sendCnap({ method: "POST", path, headers: { ...headers, "idempotency-key": `${input.idempotencyKey}-${scope}`, "content-type": "application/json" }, body }), - delete: async (path: string, etag: string, scope: string) => dependencies.sendCnap({ method: "DELETE", path, headers: { ...headers, "if-match": etag, "idempotency-key": `${input.idempotencyKey}-${scope}` } }), - }; -} - -type CnapClient = ReturnType; - -async function listAll(client: CnapClient, path: string, initialQuery: Readonly>): Promise { - const results: unknown[] = []; - let cursor: string | undefined; - for (let count = 0; count < MAX_PAGES; count += 1) { - const response = await client.list(path, cursor === undefined ? initialQuery : { ...initialQuery, cursor }); - requireCnapStatus(response, 200); - if (!isRecord(response.body) || !Array.isArray(response.body.data) || typeof response.body.has_more !== "boolean" || (response.body.next_cursor !== null && typeof response.body.next_cursor !== "string")) { - throw rejected("cnap returned an incomplete paginated response."); - } - results.push(...response.body.data); - if (!response.body.has_more) { - if (response.body.next_cursor !== null) throw rejected("cnap returned an ambiguous paginated response."); - return results; - } - if (typeof response.body.next_cursor !== "string" || response.body.next_cursor === "") throw rejected("cnap returned an incomplete paginated response."); - cursor = response.body.next_cursor; - } - throw rejected("cnap pagination exceeded its safety bound."); -} - -async function createSecret(client: CnapClient, providerToken: Uint8Array): Promise> { - const response = await client.create("/v1/secrets", { name: SECRET_NAME, kind: "cloud_provider/hcloud", value: new TextDecoder().decode(providerToken) }, "secret"); - requireCnapStatus(response, 201); - if (!isRecord(response.body) || !isMatchingSecret(response.body)) throw rejected("cnap returned an invalid HCloud secret."); - return response.body; -} - -async function resolveInitialVersion(secret: Record, client: CnapClient): Promise { - const id = requiredString(secret, "id"); - const versions = await listAll(client, `/v1/secrets/${encodeURIComponent(id)}/versions`, { limit: "100" }); - const initial = versions.filter((value) => isRecord(value) && value.secret_id === id && value.version_number === INITIAL_VERSION && value.state === "enabled"); - if (initial.length !== 1 || !isRecord(initial[0])) throw rejected("The HCloud secret has no unambiguous initial version."); - return { id, versionId: requiredString(initial[0], "id"), etag: requiredString(secret, "etag") }; -} - -async function createConfig(client: CnapClient, secret: SecretReference): Promise> { - const response = await client.create("/v1/compute_configs", { - name: CONFIG_NAME, - provider: "hcloud", - provider_config: { region: "fsn1", image: "ubuntu-24.04", machine_type_filter: "CPX32" }, - credential_scope: { kind: "byom", secret_id: secret.id, secret_version_id: secret.versionId }, - }, "compute-config"); - requireCnapStatus(response, 201); - if (!isRecord(response.body) || !isMatchingConfig(response.body, secret)) throw rejected("cnap returned an invalid HCloud compute config."); - return response.body; -} - -async function deleteOwned(client: CnapClient, secret: SecretReference): Promise { - const response = await client.delete(`/v1/secrets/${encodeURIComponent(secret.id)}`, secret.etag, "compensate-secret"); - requireCnapStatus(response, 200); -} - -function requireHcloudSuccess(response: SetupResponse): void { - if (response.status < 200 || response.status >= 300) throw preflightRejected(); -} - -function requireCnapStatus(response: SetupResponse, expected: number): void { - if (response.status === expected) return; - if (response.status >= 500) throw outcomeUnknown(); - throw rejected("cnap rejected the generic HCloud setup request.", response.status); -} - -function nextPage(body: Record, requested: number): number | null { - const pagination = isRecord(body.meta) && isRecord(body.meta.pagination) ? body.meta.pagination : undefined; - const lastPage = pagination?.last_page; - const nextPage = pagination?.next_page; - if (!pagination || pagination.page !== requested || typeof lastPage !== "number" || !Number.isInteger(lastPage) || (nextPage !== null && (typeof nextPage !== "number" || !Number.isInteger(nextPage)))) throw preflightRejected(); - if (lastPage < requested) throw preflightRejected(); - if (requested === lastPage) return nextPage === null ? null : failPreflight(); - return nextPage === requested + 1 ? nextPage : failPreflight(); -} - -function hasCapacity(limit: Record): boolean { - return typeof limit.limit === "number" && Number.isFinite(limit.limit) && typeof limit.used === "number" && Number.isFinite(limit.used) && limit.limit > limit.used; -} - -function hasSingleLocation(body: unknown): boolean { - if (!isRecord(body) || !Array.isArray(body.locations) || body.locations.length !== 1 || !isRecord(body.locations[0])) return false; - const location = body.locations[0]; - return location.name === "fsn1" && (location.status === "online" || location.status === "running"); -} - -function hasFsn1Cpx32(body: unknown): boolean { - if (!isRecord(body) || !Array.isArray(body.server_types) || body.server_types.length !== 1 || !isRecord(body.server_types[0])) return false; - const serverType = body.server_types[0]; - return typeof serverType.name === "string" && serverType.name.toLowerCase() === "cpx32" && Array.isArray(serverType.locations) && serverType.locations.some((location) => location === "fsn1" || (isRecord(location) && location.name === "fsn1")); -} - -function hasFsn1Price(body: unknown): boolean { - if (!isRecord(body) || !isRecord(body.pricing) || body.pricing.currency !== "EUR" || !Array.isArray(body.pricing.prices)) return false; - return body.pricing.prices.some((entry) => isRecord(entry) && typeof entry.location === "string" && entry.location.startsWith("fsn1") && isRecord(entry.price) && isRecord(entry.price.gross) && typeof entry.price.gross.amount === "string" && Number.isFinite(Number(entry.price.gross.amount)) && Number(entry.price.gross.amount) > 0); -} - -function isMatchingSecret(value: unknown): value is Record { - return isRecord(value) && value.name === SECRET_NAME && value.kind === "cloud_provider/hcloud" && value.state === "active" && typeof value.id === "string" && typeof value.etag === "string"; -} - -function isMatchingConfig(value: Record, secret: SecretReference): boolean { - const config = isRecord(value.provider_config) ? value.provider_config : undefined; - const credential = isRecord(value.credential_scope) ? value.credential_scope : undefined; - return value.name === CONFIG_NAME && value.provider === "hcloud" && isRecord(config) && config.region === "fsn1" && config.image === "ubuntu-24.04" && config.machine_type_filter === "CPX32" && isRecord(credential) && credential.kind === "byom" && credential.secret_id === secret.id && credential.secret_version_id === secret.versionId && typeof value.id === "string"; -} - -interface SecretReference { id: string; versionId: string; etag: string; } - -function result(secret: SecretReference, computeConfigId: string): HcloudSetupResult { - return { secret_id: secret.id, secret_version_id: secret.versionId, compute_config_id: computeConfigId }; -} - -function requiredString(value: Record, field: string): string { - if (typeof value[field] !== "string" || value[field] === "") throw rejected("cnap returned an incomplete resource."); - return value[field]; -} - -function rejected(message: string, status?: number): HcloudSetupError { - return new HcloudSetupError({ type: "api_error", code: "AKUA_CNAP_REJECTED", message, status, exitCode: status === 401 || status === 403 ? 3 : 1 }); -} - -function preflightRejected(): HcloudSetupError { - return new HcloudSetupError({ type: "validation_error", code: "AKUA_HCLOUD_PREFLIGHT_REJECTED", message: "HCloud preflight did not produce a complete safe result.", exitCode: 2 }); -} - -function failPreflight(): never { throw preflightRejected(); } - -function outcomeUnknown(): HcloudSetupError { - return new HcloudSetupError({ type: "transport_error", code: "AKUA_CNAP_OUTCOME_UNKNOWN", message: "The cnap setup outcome is unknown and was not retried.", exitCode: 1 }); -} - -function normalizeCnapError(error: unknown): HcloudSetupError { - if (error instanceof HcloudSetupError) return error; - return outcomeUnknown(); -} - -function providerAuthorization(providerToken: Uint8Array): string { - return `Bearer ${new TextDecoder().decode(providerToken)}`; -} - -async function sendJson(baseUrl: string, request: SetupRequest, authorization?: string): Promise { - const url = new URL(request.path, baseUrl); - for (const [key, value] of Object.entries(request.query ?? {})) url.searchParams.set(key, value); - const response = await fetch(url, { method: request.method, headers: { ...(authorization === undefined ? {} : { authorization }), ...request.headers }, ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }) }); - const text = await response.text(); - if (text.length > 16_384) throw new Error("response too large"); - return { status: response.status, body: text === "" ? {} : JSON.parse(text) }; -} - -function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/runtime/platform-client.ts b/src/runtime/platform-client.ts new file mode 100644 index 0000000..ca9296b --- /dev/null +++ b/src/runtime/platform-client.ts @@ -0,0 +1,235 @@ +import { AkuaCliError } from "./errors"; +import { clearBytes } from "./secure-token-file"; + +const HCloudProviderLoadUrl = "https://api.akua.dev/v1/agent_os/hcloud_provider_loads"; +const responseFields = new Set([ + "loader_id", + "attestation_id", + "secret_id", + "secret_version_id", + "compute_config_id", + "expected_ssh_key_fingerprint", +]); + +export interface HCloudProviderLoadRequest { + url: typeof HCloudProviderLoadUrl; + method: "POST"; + headers: Readonly>; + body: Uint8Array; +} + +interface HCloudProviderLoadResponse { + status: number; + body: unknown; +} + +export interface HCloudProviderLoadDependencies { + send(request: HCloudProviderLoadRequest): Promise; +} + +export interface HCloudProviderLoadInput { + workspace: string; + callerToken: string; + providerToken: Uint8Array; + expectedSshKeyFingerprint?: string; + expectedSshKeyName?: string; + idempotencyKey: string; +} + +export type HCloudProviderLoadResult = Readonly>; + +export class HCloudProviderLoadError extends AkuaCliError {} + +const productionDependencies: HCloudProviderLoadDependencies = { send: sendHttpsRequest }; + +export async function submitHcloudProviderLoad( + input: HCloudProviderLoadInput, + dependencies: HCloudProviderLoadDependencies = productionDependencies, +): Promise { + let body: Uint8Array | undefined; + try { + body = encodeProviderTokenBody(input.providerToken, input.expectedSshKeyFingerprint, input.expectedSshKeyName); + const response = await dependencies.send({ + url: HCloudProviderLoadUrl, + method: "POST", + headers: { + authorization: `Bearer ${input.callerToken}`, + "akua-context": input.workspace, + "idempotency-key": input.idempotencyKey, + "content-type": "application/json", + }, + body, + }); + if (response.status !== 201) { + throw serverRejectedError(response.status, response.body); + } + return allowlistedResult(response.body); + } catch (error) { + if (error instanceof HCloudProviderLoadError) { + throw error; + } + throw new HCloudProviderLoadError({ + type: "transport_error", + code: "AKUA_LOADER_SUBMISSION_UNKNOWN", + message: "The provider-load submission outcome is unknown and was not retried.", + exitCode: 1, + }); + } finally { + clearBytes(input.providerToken); + if (body) { + clearBytes(body); + } + } +} + +async function sendHttpsRequest(request: HCloudProviderLoadRequest): Promise { + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body as unknown as BodyInit, + }); + const text = await response.text(); + if (text.length > 16_384) { + throw invalidServerResponseError(); + } + try { + return { status: response.status, body: text === "" ? {} : JSON.parse(text) }; + } catch { + throw invalidServerResponseError(); + } +} + +function encodeProviderTokenBody( + providerToken: Uint8Array, + expectedSshKeyFingerprint: string | undefined, + expectedSshKeyName: string | undefined, +): Uint8Array { + const encoder = new TextEncoder(); + const fields: Array = [ + [encoder.encode("provider_token"), providerToken], + ...(expectedSshKeyFingerprint === undefined + ? [] + : [[encoder.encode("expected_ssh_key_fingerprint"), encoder.encode(expectedSshKeyFingerprint)] as const]), + ...(expectedSshKeyName === undefined + ? [] + : [[encoder.encode("expected_ssh_key_name"), encoder.encode(expectedSshKeyName)] as const]), + ]; + let encodedLength = 2 + Math.max(0, fields.length - 1); + for (const [name, value] of fields) { + encodedLength += name.byteLength + 5; + for (const byte of value) { + encodedLength += escapedLength(byte); + } + } + const body = new Uint8Array(encodedLength); + let cursor = 0; + body[cursor++] = 123; + for (const [index, [name, value]] of fields.entries()) { + if (index > 0) { + body[cursor++] = 44; + } + body[cursor++] = 34; + body.set(name, cursor); + cursor += name.byteLength; + body.set([34, 58, 34], cursor); + cursor += 3; + cursor = writeEscapedBytes(body, cursor, value); + body[cursor++] = 34; + } + body[cursor] = 125; + return body; +} + +function writeEscapedBytes(body: Uint8Array, start: number, bytes: Uint8Array): number { + let cursor = start; + for (const byte of bytes) { + if (byte === 34 || byte === 92) { + body[cursor++] = 92; + body[cursor++] = byte; + } else if (byte === 8) { + body.set([92, 98], cursor); + cursor += 2; + } else if (byte === 9) { + body.set([92, 116], cursor); + cursor += 2; + } else if (byte === 10) { + body.set([92, 110], cursor); + cursor += 2; + } else if (byte === 12) { + body.set([92, 102], cursor); + cursor += 2; + } else if (byte === 13) { + body.set([92, 114], cursor); + cursor += 2; + } else if (byte < 32) { + body.set([92, 117, 48, 48, hex(byte >> 4), hex(byte & 15)], cursor); + cursor += 6; + } else { + body[cursor++] = byte; + } + } + return cursor; +} + +function escapedLength(byte: number): number { + if (byte === 34 || byte === 92 || byte === 8 || byte === 9 || byte === 10 || byte === 12 || byte === 13) { + return 2; + } + return byte < 32 ? 6 : 1; +} + +function hex(value: number): number { + return value < 10 ? 48 + value : 87 + value; +} + +function allowlistedResult(body: unknown): HCloudProviderLoadResult { + if (!isRecord(body)) { + throw invalidServerResponseError(); + } + const result: Record = {}; + for (const [field, value] of Object.entries(body)) { + if (!responseFields.has(field)) { + continue; + } + if (typeof value === "string" || (field === "expected_ssh_key_fingerprint" && value === null)) { + result[field] = value; + } + } + if ( + typeof result.loader_id !== "string" || + typeof result.attestation_id !== "string" || + typeof result.secret_id !== "string" || + typeof result.secret_version_id !== "string" || + typeof result.compute_config_id !== "string" || + (typeof result.expected_ssh_key_fingerprint !== "string" && result.expected_ssh_key_fingerprint !== null) + ) { + throw invalidServerResponseError(); + } + return result; +} + +function serverRejectedError(status: number, body: unknown): HCloudProviderLoadError { + const error = isRecord(body) && isRecord(body.error) ? body.error : {}; + const requestId = typeof error.request_id === "string" ? error.request_id : undefined; + return new HCloudProviderLoadError({ + type: "api_error", + code: "AKUA_LOADER_SERVER_REJECTED", + status, + requestId, + message: "The provider-load server rejected the request.", + exitCode: status === 401 || status === 403 ? 3 : 1, + }); +} + +function invalidServerResponseError(): HCloudProviderLoadError { + return new HCloudProviderLoadError({ + type: "api_error", + code: "AKUA_LOADER_SERVER_RESPONSE_INVALID", + message: "The provider-load server returned an invalid response.", + exitCode: 1, + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/runtime/secure-token-file.ts b/src/runtime/secure-token-file.ts index 90c2013..17e3890 100644 --- a/src/runtime/secure-token-file.ts +++ b/src/runtime/secure-token-file.ts @@ -52,7 +52,7 @@ export async function readSecureTokenFile( if (!isAbsolute(path)) { throw new AkuaCliError({ type: "validation_error", - code: "AKUA_HCLOUD_TOKEN_PATH_INVALID", + code: "AKUA_LOADER_TOKEN_PATH_INVALID", message: "The provider token file must use an absolute path.", exitCode: 2, }); @@ -124,7 +124,7 @@ function validateSize(size: number): void { if (!Number.isSafeInteger(size) || size < 1 || size > MAX_PROVIDER_TOKEN_BYTES) { throw new AkuaCliError({ type: "validation_error", - code: "AKUA_HCLOUD_TOKEN_FILE_SIZE_INVALID", + code: "AKUA_LOADER_TOKEN_FILE_SIZE_INVALID", message: "The provider token file size is invalid.", exitCode: 2, }); @@ -144,7 +144,7 @@ function sameFile(before: SecureTokenFileStat, opened: SecureTokenFileStat): boo function unsafeFileError(): AkuaCliError { return new AkuaCliError({ type: "validation_error", - code: "AKUA_HCLOUD_TOKEN_FILE_UNSAFE", + code: "AKUA_LOADER_TOKEN_FILE_UNSAFE", message: "The provider token file does not meet the required security checks.", exitCode: 2, }); @@ -153,7 +153,7 @@ function unsafeFileError(): AkuaCliError { function changedFileError(): AkuaCliError { return new AkuaCliError({ type: "validation_error", - code: "AKUA_HCLOUD_TOKEN_FILE_CHANGED", + code: "AKUA_LOADER_TOKEN_FILE_CHANGED", message: "The provider token file changed while it was being opened.", exitCode: 2, }); diff --git a/test/agent-os-loader.test.ts b/test/agent-os-loader.test.ts new file mode 100644 index 0000000..c465414 --- /dev/null +++ b/test/agent-os-loader.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; + +import { agentOsView, type AgentOsDependencies } from "../src/commands/agent-os"; +import { + HCloudProviderLoadError, + type HCloudProviderLoadInput, + submitHcloudProviderLoad, + type HCloudProviderLoadRequest, +} from "../src/runtime/platform-client"; +import { renderError, renderSuccess } from "../src/runtime/render"; + +const SYNTHETIC_TOKEN = new Uint8Array([115, 121, 110, 116, 104, 101, 116, 105, 99]); +const SYNTHETIC_ECHO = "synthetic-response-field"; +const PROJECT_ANCHOR_FINGERPRINT = `SHA256:${"A".repeat(43)}`; +const PROJECT_ANCHOR_NAME = "agent-os-production-anchor"; + +describe("submitHcloudProviderLoad", () => { + test("submits one fixed-route request without an optional anchor, explicit context, and relayed idempotency", async () => { + const requests: HCloudProviderLoadRequest[] = []; + let bodyHasProviderField = false; + + const result = await submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken: SYNTHETIC_TOKEN.slice(), + idempotencyKey: "00000000-0000-4000-8000-000000000001", + }, + { + send: async (request) => { + requests.push(request); + const body = new TextDecoder().decode(request.body); + bodyHasProviderField = + body.includes('"provider_token":"synthetic"') && + !body.includes("expected_ssh_key_fingerprint") && + !body.includes("expected_ssh_key_name"); + return successResponse(); + }, + }, + ); + + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("https://api.akua.dev/v1/agent_os/hcloud_provider_loads"); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.headers).toMatchObject({ + authorization: "Bearer caller-auth-fixture", + "akua-context": "ws_synthetic", + "idempotency-key": "00000000-0000-4000-8000-000000000001", + "content-type": "application/json", + }); + expect(bodyHasProviderField).toBe(true); + expect(result).toEqual({ + loader_id: "pvl_synthetic", + attestation_id: "att_synthetic", + secret_id: "sec_synthetic", + secret_version_id: "secver_synthetic", + compute_config_id: "cfg_synthetic", + expected_ssh_key_fingerprint: null, + }); + }); + + test("relays a predeclared optional SSH anchor without deriving identity from provider bytes", async () => { + let bodyHasOptionalAnchor = false; + + await submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken: SYNTHETIC_TOKEN.slice(), + expectedSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, + expectedSshKeyName: PROJECT_ANCHOR_NAME, + idempotencyKey: "00000000-0000-4000-8000-000000000002", + }, + { + send: async (request) => { + const body = new TextDecoder().decode(request.body); + bodyHasOptionalAnchor = + body.includes(`"expected_ssh_key_fingerprint":"${PROJECT_ANCHOR_FINGERPRINT}"`) && + body.includes(`"expected_ssh_key_name":"${PROJECT_ANCHOR_NAME}"`); + return successResponse(); + }, + }, + ); + + expect(bodyHasOptionalAnchor).toBe(true); + }); + + test("projects success through a strict response allowlist", async () => { + const result = await submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken: SYNTHETIC_TOKEN.slice(), + idempotencyKey: "00000000-0000-4000-8000-000000000002", + }, + { + send: async () => ({ + status: 201, + body: { + ...successResponse().body, + echoed_provider_token: SYNTHETIC_ECHO, + project_identity_attestation: "synthetic-attestation-echo", + transaction_id: "txn_synthetic", + nested: { secret: SYNTHETIC_ECHO }, + }, + }), + }, + ); + + expect(JSON.stringify(result)).not.toContain(SYNTHETIC_ECHO); + expect(result).not.toHaveProperty("echoed_provider_token"); + expect(result).not.toHaveProperty("project_identity_attestation"); + expect(result).not.toHaveProperty("transaction_id"); + expect(result).not.toHaveProperty("nested"); + }); + + test("clears provider and request byte buffers after a successful send", async () => { + const providerToken = SYNTHETIC_TOKEN.slice(); + let submittedBody: Uint8Array | undefined; + + await submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken, + idempotencyKey: "00000000-0000-4000-8000-000000000003", + }, + { + send: async (request) => { + submittedBody = request.body; + return successResponse(); + }, + }, + ); + + expect([...providerToken].every((byte) => byte === 0)).toBe(true); + expect([...(submittedBody ?? [])].every((byte) => byte === 0)).toBe(true); + }); + + test("makes no retry after an uncertain transport failure and clears buffers", async () => { + const providerToken = SYNTHETIC_TOKEN.slice(); + let attempts = 0; + + await expect( + submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken, + idempotencyKey: "00000000-0000-4000-8000-000000000004", + }, + { + send: async () => { + attempts += 1; + throw new Error("synthetic transport interruption"); + }, + }, + ), + ).rejects.toMatchObject({ code: "AKUA_LOADER_SUBMISSION_UNKNOWN" }); + + expect(attempts).toBe(1); + expect([...providerToken].every((byte) => byte === 0)).toBe(true); + }); + + test("projects server failures to fixed safe fields", async () => { + await expect( + submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken: SYNTHETIC_TOKEN.slice(), + idempotencyKey: "00000000-0000-4000-8000-000000000005", + }, + { + send: async () => ({ + status: 403, + body: { + error: { + code: "SERVER_PRIVATE_CODE", + message: SYNTHETIC_ECHO, + request_id: "req_synthetic_denied", + secret_id: "sec_synthetic", + }, + }, + }), + }, + ), + ).rejects.toMatchObject({ + code: "AKUA_LOADER_SERVER_REJECTED", + status: 403, + requestId: "req_synthetic_denied", + } satisfies Partial); + }); +}); + +describe("agent-os load-hcloud-provider", () => { + test("accepts no anchor and rejects malformed anchor inputs before auth, file, or network access", async () => { + const dependencies = fakeCommandDependencies(); + const rejectedValue = "synthetic-argv-value"; + + await expect(agentOsView(commandArgs(), {}, dependencies.dependencies)).resolves.toMatchObject({ + data: successResponse().body, + }); + await expect( + agentOsView( + [ + "load-hcloud-provider", + "--workspace", + "ws_synthetic", + "--token-file", + "/synthetic/provider", + "--expected-ssh-key-fingerprint", + "bad\u0000fingerprint", + ], + {}, + dependencies.dependencies, + ), + ).rejects.toMatchObject({ code: "AKUA_USAGE_ERROR" }); + await expect( + agentOsView( + [ + "load-hcloud-provider", + "--workspace", + "ws_synthetic", + "--token-file", + "/synthetic/provider", + "--expected-ssh-key-name", + PROJECT_ANCHOR_NAME, + ], + {}, + dependencies.dependencies, + ), + ).rejects.toMatchObject({ code: "AKUA_USAGE_ERROR" }); + + const error = await captureError(() => agentOsView(["load-hcloud-provider", "--workspace", "ws_synthetic", "--token", rejectedValue], {}, dependencies.dependencies)); + expect(renderError(error, "json")).not.toContain(rejectedValue); + expect(dependencies.fileReads).toBe(1); + expect(dependencies.submissions).toBe(1); + }); + + test("rejects environment caller authentication before config, file, or network access", async () => { + const dependencies = fakeCommandDependencies(); + + await expect( + agentOsView(commandArgs(), { AKUA_API_TOKEN: "synthetic-environment-auth" }, dependencies.dependencies), + ).rejects.toMatchObject({ code: "AKUA_LOADER_ENV_AUTH_FORBIDDEN" }); + expect(dependencies.events).toEqual([]); + }); + + test("authenticates from protected config before reading the provider file and relays idempotency once", async () => { + const dependencies = fakeCommandDependencies(); + + const view = await agentOsView(commandArgs(), {}, dependencies.dependencies); + + expect(dependencies.events).toEqual(["auth", "file", "network"]); + expect(dependencies.submissions).toBe(1); + expect(dependencies.input?.workspace).toBe("ws_synthetic"); + expect(dependencies.input?.expectedSshKeyFingerprint).toBeUndefined(); + expect(dependencies.input?.expectedSshKeyName).toBeUndefined(); + expect(dependencies.input?.idempotencyKey).toMatch(/^[0-9a-f]{8}-/); + expect(view.data).toEqual(successResponse().body); + expect(renderSuccess(view, "json")).not.toContain(SYNTHETIC_ECHO); + }); + + test("clears the reader bytes and renders only fixed safe failures", async () => { + const providerToken = new Uint8Array([112, 114, 111, 118, 105, 100, 101, 114, 45, 115, 101, 99, 114, 101, 116]); + const providerMarker = "provider-secret"; + const dependencies = fakeCommandDependencies({ + readSecureTokenFile: async () => providerToken, + submit: async () => { + throw new HCloudProviderLoadError({ + type: "api_error", + code: "AKUA_LOADER_SERVER_REJECTED", + status: 403, + requestId: "req_synthetic_denied", + message: "The provider-load server rejected the request.", + }); + }, + }); + + const error = await captureError(() => agentOsView(commandArgs(), {}, dependencies.dependencies)); + + expect([...providerToken].every((byte) => byte === 0)).toBe(true); + expect(renderError(error, "json")).not.toContain(providerMarker); + expect(error).toMatchObject({ code: "AKUA_LOADER_SERVER_REJECTED", status: 403 }); + }); + + test("fails post-revocation against the fake HTTPS server without a retry or leaked prior result", async () => { + const server = new FakeHttpsServer(); + const dependencies = fakeCommandDependencies({ + submit: async (input) => server.submit(input), + }); + + const first = await agentOsView(commandArgs(), {}, dependencies.dependencies); + server.revoke(); + const error = await captureError(() => agentOsView(commandArgs(), {}, dependencies.dependencies)); + + expect(first.data).toEqual(successResponse().body); + expect(error).toMatchObject({ code: "AKUA_LOADER_SERVER_REJECTED", status: 403 }); + expect(server.requests).toEqual([ + { workspace: "ws_synthetic", tokenLength: SYNTHETIC_TOKEN.byteLength }, + { workspace: "ws_synthetic", tokenLength: SYNTHETIC_TOKEN.byteLength }, + ]); + }); + + test("contains no child-process or shell fallback implementation", async () => { + const [commandSource, transportSource] = await Promise.all([ + readFile("src/commands/agent-os.ts", "utf8"), + readFile("src/runtime/platform-client.ts", "utf8"), + ]); + + for (const source of [commandSource, transportSource]) { + expect(source).not.toContain("Bun.spawn"); + expect(source).not.toContain("node:child_process"); + expect(source).not.toContain("curl"); + } + }); +}); + +function successResponse() { + return { + status: 201, + body: { + loader_id: "pvl_synthetic", + attestation_id: "att_synthetic", + secret_id: "sec_synthetic", + secret_version_id: "secver_synthetic", + compute_config_id: "cfg_synthetic", + expected_ssh_key_fingerprint: null, + }, + }; +} + +function commandArgs(): string[] { + return [ + "load-hcloud-provider", + "--workspace", + "ws_synthetic", + "--token-file", + "/synthetic/provider", + ]; +} + +function fakeCommandDependencies(overrides: Partial = {}) { + const events: string[] = []; + let fileReads = 0; + let submissions = 0; + let input: HCloudProviderLoadInput | undefined; + const dependencies: AgentOsDependencies = { + readProtectedCallerToken: async () => { + events.push("auth"); + return "caller-auth-fixture"; + }, + readSecureTokenFile: async () => { + events.push("file"); + fileReads += 1; + return SYNTHETIC_TOKEN.slice(); + }, + submit: async (submitted) => { + events.push("network"); + submissions += 1; + input = submitted; + return successResponse().body; + }, + createIdempotencyKey: () => "00000000-0000-4000-8000-000000000006", + ...overrides, + }; + return { + dependencies, + events, + get fileReads() { + return fileReads; + }, + get submissions() { + return submissions; + }, + get input() { + return input; + }, + }; +} + +async function captureError(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + return error as HCloudProviderLoadError; + } + throw new Error("Expected the loader action to fail."); +} + +class FakeHttpsServer { + readonly requests: Array<{ workspace: string; tokenLength: number }> = []; + #revoked = false; + + revoke(): void { + this.#revoked = true; + } + + async submit(input: HCloudProviderLoadInput) { + this.requests.push({ workspace: input.workspace, tokenLength: input.providerToken.byteLength }); + if (this.#revoked) { + throw new HCloudProviderLoadError({ + type: "api_error", + code: "AKUA_LOADER_SERVER_REJECTED", + status: 403, + requestId: "req_synthetic_revoked", + message: "The provider-load server rejected the request.", + }); + } + return successResponse().body; + } +} diff --git a/test/cli.test.ts b/test/cli.test.ts index 4f0c9e3..2858b5d 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -18,13 +18,16 @@ describe("akua entrypoint", () => { }); }); - test("documents generic HCloud setup without exposing a credential input", async () => { + test("documents the compiled Agent OS provider loader without exposing a credential input", async () => { const { stdout, exitCode } = await runAkua(["--help", "--json"]); expect(exitCode).toBe(0); - expect(stdout).toContain("akua hcloud setup"); + expect(stdout).toContain("akua agent-os load-hcloud-provider"); expect(stdout).toContain("--token-file"); - expect(stdout).not.toContain("load-hcloud-provider"); + expect(stdout).toContain("--expected-ssh-key-fingerprint"); + expect(stdout).toContain("--expected-ssh-key-name"); + expect(stdout).not.toContain("--project-identity-attestation"); + expect(stdout).not.toContain("--project-anchor-ssh-key-fingerprint"); expect(stdout).not.toContain("--token <"); }); @@ -425,7 +428,7 @@ describe("akua entrypoint", () => { } }); - test("HCloud setup caller authentication reads only the protected local config", async () => { + test("loader caller authentication reads only the protected local config", async () => { const home = await makeTempHome(); try { const configDir = join(home, ".config", "akua"); @@ -437,7 +440,7 @@ describe("akua entrypoint", () => { await expect(readProtectedCallerToken({ HOME: home })).resolves.toBe("caller-auth-fixture"); await expect(readProtectedCallerToken({ HOME: home, AKUA_API_TOKEN: "environment-auth-fixture" })).rejects.toMatchObject({ - code: "AKUA_HCLOUD_ENV_AUTH_FORBIDDEN", + code: "AKUA_LOADER_ENV_AUTH_FORBIDDEN", }); } finally { await rm(home, { recursive: true, force: true }); diff --git a/test/hcloud-setup.test.ts b/test/hcloud-setup.test.ts deleted file mode 100644 index 435dfc7..0000000 --- a/test/hcloud-setup.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { hcloudView, type HcloudCommandDependencies } from "../src/commands/hcloud"; -import { - HcloudSetupError, - setupHcloud, - type HcloudSetupDependencies, - type SetupRequest, -} from "../src/runtime/hcloud-setup"; -import { renderError } from "../src/runtime/render"; - -const TOKEN = new Uint8Array([115, 121, 110, 116, 104, 101, 116, 105, 99]); -const WORKSPACE = "ws_synthetic"; - -describe("generic HCloud setup", () => { - test("paginates every inventory collection before creating generic resources", async () => { - const fixture = makeFixture({ inventoryPages: 2 }); - - await setupHcloud(input(), fixture.dependencies); - - for (const collection of fixture.inventoryCollections.filter((collection) => collection !== "images")) { - expect(fixture.hcloudRequests.filter((request) => request.path === `/${collection}`)).toHaveLength(2); - } - expect(fixture.hcloudRequests.filter((request) => request.path === "/images")).toHaveLength(4); - expect(fixture.cnapRequests.map((request) => request.path)).toContain("/v1/secrets"); - expect(fixture.cnapRequests.map((request) => request.path)).toContain("/v1/compute_configs"); - }); - - test("rejects an invalid token before persistence", async () => { - const fixture = makeFixture({ meStatus: 401 }); - - await expect(setupHcloud(input(), fixture.dependencies)).rejects.toMatchObject({ code: "AKUA_HCLOUD_AUTH_REJECTED" }); - expect(fixture.cnapRequests).toHaveLength(0); - }); - - test("fails closed on unexpected inventory", async () => { - const fixture = makeFixture({ inventory: { servers: [{ id: 1 }] } }); - - await expect(setupHcloud(input(), fixture.dependencies)).rejects.toMatchObject({ code: "AKUA_HCLOUD_INVENTORY_NOT_EMPTY" }); - expect(fixture.cnapRequests).toHaveLength(0); - }); - - test("fails closed on unusable quota, catalog, or price", async () => { - for (const failure of ["quota", "catalog", "price"] as const) { - const fixture = makeFixture({ failure }); - await expect(setupHcloud(input(), fixture.dependencies)).rejects.toMatchObject({ code: "AKUA_HCLOUD_PREFLIGHT_REJECTED" }); - expect(fixture.cnapRequests).toHaveLength(0); - } - }); - - test("does not retry an ambiguous HCloud transport outcome", async () => { - const fixture = makeFixture({ throwHcloudPath: "/me" }); - - await expect(setupHcloud(input(), fixture.dependencies)).rejects.toMatchObject({ code: "AKUA_HCLOUD_OUTCOME_UNKNOWN" }); - expect(fixture.hcloudRequests.filter((request) => request.path === "/me")).toHaveLength(1); - expect(fixture.cnapRequests).toHaveLength(0); - }); - - test("keeps provider bytes out of request metadata and rendered failures", async () => { - const marker = "hcloud-token-must-not-render"; - const fixture = makeFixture({ throwHcloudPath: "/me" }); - const error = await captureError(() => setupHcloud({ ...input(), providerToken: new TextEncoder().encode(marker) }, fixture.dependencies)); - - expect(JSON.stringify(fixture.hcloudRequests)).not.toContain(marker); - expect(renderError(error, "json")).not.toContain(marker); - }); - - test("reuses exact existing generic resources without mutation", async () => { - const fixture = makeFixture({ existing: true }); - - const result = await setupHcloud(input(), fixture.dependencies); - - expect(result).toEqual({ secret_id: "sec_existing", secret_version_id: "secver_initial", compute_config_id: "cfg_existing" }); - expect(fixture.cnapRequests.filter((request) => request.method !== "GET")).toHaveLength(0); - }); - - test("creates the compute config with the exact initial secret version", async () => { - const fixture = makeFixture(); - - const result = await setupHcloud(input(), fixture.dependencies); - const createConfig = fixture.cnapRequests.find((request) => request.method === "POST" && request.path === "/v1/compute_configs"); - - expect(result).toEqual({ secret_id: "sec_created", secret_version_id: "secver_initial", compute_config_id: "cfg_created" }); - expect(createConfig?.body).toMatchObject({ - credential_scope: { kind: "byom", secret_id: "sec_created", secret_version_id: "secver_initial" }, - }); - }); - - test("compensates only invocation-owned state after a definite downstream rejection", async () => { - const fixture = makeFixture({ configStatus: 400 }); - - await expect(setupHcloud(input(), fixture.dependencies)).rejects.toMatchObject({ code: "AKUA_CNAP_REJECTED" }); - expect(fixture.cnapRequests.filter((request) => request.method === "DELETE").map((request) => request.path)).toEqual(["/v1/secrets/sec_created"]); - - const reused = makeFixture({ existing: true, configStatus: 400 }); - await expect(setupHcloud(input(), reused.dependencies)).resolves.toBeDefined(); - expect(reused.cnapRequests.filter((request) => request.method === "DELETE")).toHaveLength(0); - }); - - test("does not compensate after an ambiguous cnap outcome", async () => { - const fixture = makeFixture({ throwCnapPath: "/v1/compute_configs" }); - - await expect(setupHcloud(input(), fixture.dependencies)).rejects.toMatchObject({ code: "AKUA_CNAP_OUTCOME_UNKNOWN" }); - expect(fixture.cnapRequests.filter((request) => request.method === "DELETE")).toHaveLength(0); - }); - - test("uses the descriptor reader, protected caller auth, and fixed-safe errors", async () => { - let cleared = false; - const fixture = makeFixture(); - const dependencies: HcloudCommandDependencies = { - readProtectedCallerToken: async () => "caller-auth-fixture", - readSecureTokenFile: async () => TOKEN, - setup: async (value) => { - expect(value.providerToken).toBe(TOKEN); - return setupHcloud(value, fixture.dependencies); - }, - clearBytes: (bytes) => { - cleared = bytes === TOKEN; - bytes.fill(0); - }, - createIdempotencyKey: (scope) => `stable-${scope}`, - }; - - const result = await hcloudView(["setup", "--workspace", WORKSPACE, "--token-file", "/synthetic/token"], {}, dependencies); - - expect(result.command).toBe("akua hcloud setup"); - expect(cleared).toBe(true); - expect(Array.from(TOKEN)).toEqual(Array(TOKEN.byteLength).fill(0)); - }); -}); - -function input() { - return { workspace: WORKSPACE, callerToken: "caller-auth-fixture", providerToken: TOKEN.slice(), idempotencyKey: "stable-setup" }; -} - -function makeFixture(options: { - inventoryPages?: number; - inventory?: Record; - meStatus?: number; - failure?: "quota" | "catalog" | "price"; - throwHcloudPath?: string; - throwCnapPath?: string; - existing?: boolean; - configStatus?: number; -} = {}) { - const hcloudRequests: SetupRequest[] = []; - const cnapRequests: SetupRequest[] = []; - const inventoryCollections = ["servers", "volumes", "primary_ips", "floating_ips", "load_balancers", "networks", "firewalls", "placement_groups", "ssh_keys", "certificates", "images"]; - const dependencies: HcloudSetupDependencies = { - sendHcloud: async (request) => { - hcloudRequests.push(request); - if (request.path === options.throwHcloudPath) throw new Error("synthetic transport"); - if (request.path === "/me") return { status: options.meStatus ?? 200, body: { customer: { id: 1 } } }; - const collection = request.path.slice(1); - if (inventoryCollections.includes(collection)) { - const page = Number(request.query?.page ?? "1"); - const last = options.inventoryPages ?? 1; - return { status: 200, body: { [collection]: options.inventory?.[collection] ?? [], meta: { pagination: { page, last_page: last, next_page: page < last ? page + 1 : null } } } }; - } - if (request.path === "/limits") return { status: 200, body: options.failure === "quota" ? { limits: [] } : { limits: [{ name: "server", limit: 2, used: 0 }] } }; - if (request.path === "/locations") return { status: 200, body: options.failure === "catalog" ? { locations: [] } : { locations: [{ name: "fsn1", status: "online" }] } }; - if (request.path === "/server_types") return { status: 200, body: options.failure === "catalog" ? { server_types: [] } : { server_types: [{ name: "cpx32", locations: [{ name: "fsn1" }] }] } }; - if (request.path === "/pricing") { - return options.failure === "price" - ? { status: 200, body: { pricing: { currency: "EUR", prices: [] } } } - : { status: 200, body: { pricing: { currency: "EUR", prices: [{ location: "fsn1", price: { gross: { amount: "10.00" } } }] } } }; - } - throw new Error(`unexpected HCloud request ${request.path}`); - }, - sendCnap: async (request) => { - cnapRequests.push(request); - if (request.path === options.throwCnapPath) throw new Error("synthetic transport"); - if (request.method === "GET" && request.path === "/v1/secrets") return { status: 200, body: page(options.existing ? [secret()] : []) }; - if (request.method === "GET" && request.path === "/v1/secrets/sec_created/versions") return { status: 200, body: page([version("sec_created")]) }; - if (request.method === "GET" && request.path === "/v1/secrets/sec_existing/versions") return { status: 200, body: page([version("sec_existing")]) }; - if (request.method === "GET" && request.path === "/v1/compute_configs") return { status: 200, body: page(options.existing ? [config()] : []) }; - if (request.method === "POST" && request.path === "/v1/secrets") return { status: 201, body: secret("sec_created") }; - if (request.method === "POST" && request.path === "/v1/compute_configs") return { status: options.configStatus ?? 201, body: options.configStatus ? { error: { request_id: "req_rejected" } } : config("cfg_created", "sec_created") }; - if (request.method === "DELETE") return { status: 200, body: {} }; - throw new Error(`unexpected cnap request ${request.path}`); - }, - }; - return { dependencies, hcloudRequests, cnapRequests, inventoryCollections }; -} - -function page(data: unknown[]) { return { data, has_more: false, next_cursor: null }; } -function secret(id = "sec_existing") { return { id, name: "hcloud", kind: "cloud_provider/hcloud", state: "active", etag: "secret-etag" }; } -function version(secretId: string) { return { id: "secver_initial", secret_id: secretId, version_number: 1, state: "enabled" }; } -function config(id = "cfg_existing", secretId = "sec_existing") { return { id, name: "hcloud-fsn1-cpx32", provider: "hcloud", provider_config: { region: "fsn1", image: "ubuntu-24.04", machine_type_filter: "CPX32" }, credential_scope: { kind: "byom", secret_id: secretId, secret_version_id: "secver_initial" }, etag: "config-etag" }; } - -async function captureError(action: () => Promise): Promise { - try { await action(); } catch (error) { return error as HcloudSetupError; } - throw new Error("Expected setup to fail."); -} diff --git a/test/secure-token-file.test.ts b/test/secure-token-file.test.ts index 53d453a..7438bd1 100644 --- a/test/secure-token-file.test.ts +++ b/test/secure-token-file.test.ts @@ -33,7 +33,7 @@ describe("readSecureTokenFile", () => { const fixture = makeFakeFixture(); await expect(readSecureTokenFile("relative/provider", fixture.dependencies)).rejects.toMatchObject({ - code: "AKUA_HCLOUD_TOKEN_PATH_INVALID", + code: "AKUA_LOADER_TOKEN_PATH_INVALID", }); expect(fixture.opens).toBe(0); }); @@ -42,7 +42,7 @@ describe("readSecureTokenFile", () => { const fixture = makeFakeFixture({ pre: fakeStat({ symbolicLink: true }) }); await expect(readSecureTokenFile("/synthetic/provider", fixture.dependencies)).rejects.toMatchObject({ - code: "AKUA_HCLOUD_TOKEN_FILE_UNSAFE", + code: "AKUA_LOADER_TOKEN_FILE_UNSAFE", }); expect(fixture.opens).toBe(0); }); @@ -56,7 +56,7 @@ describe("readSecureTokenFile", () => { ]) { const fixture = makeFakeFixture({ pre }); await expect(readSecureTokenFile("/synthetic/provider", fixture.dependencies)).rejects.toMatchObject({ - code: "AKUA_HCLOUD_TOKEN_FILE_UNSAFE", + code: "AKUA_LOADER_TOKEN_FILE_UNSAFE", }); expect(fixture.opens).toBe(0); } @@ -66,7 +66,7 @@ describe("readSecureTokenFile", () => { for (const size of [0, MAX_PROVIDER_TOKEN_BYTES + 1]) { const fixture = makeFakeFixture({ pre: fakeStat({ size }), opened: fakeStat({ size }) }); await expect(readSecureTokenFile("/synthetic/provider", fixture.dependencies)).rejects.toMatchObject({ - code: "AKUA_HCLOUD_TOKEN_FILE_SIZE_INVALID", + code: "AKUA_LOADER_TOKEN_FILE_SIZE_INVALID", }); expect(fixture.reads).toBe(0); expect(fixture.closed).toBe(0); @@ -77,7 +77,7 @@ describe("readSecureTokenFile", () => { const fixture = makeFakeFixture({ opened: fakeStat({ ino: 99 }) }); await expect(readSecureTokenFile("/synthetic/provider", fixture.dependencies)).rejects.toMatchObject({ - code: "AKUA_HCLOUD_TOKEN_FILE_CHANGED", + code: "AKUA_LOADER_TOKEN_FILE_CHANGED", }); expect(fixture.reads).toBe(0); expect(fixture.closed).toBe(1); @@ -92,7 +92,7 @@ describe("readSecureTokenFile", () => { await chmod(target, 0o600); await symlink(target, link); - await expect(readSecureTokenFile(link)).rejects.toMatchObject({ code: "AKUA_HCLOUD_TOKEN_FILE_UNSAFE" }); + await expect(readSecureTokenFile(link)).rejects.toMatchObject({ code: "AKUA_LOADER_TOKEN_FILE_UNSAFE" }); } finally { await rm(directory, { recursive: true, force: true }); }