diff --git a/AGENTS.md b/AGENTS.md index f9f2e41..8a29463 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 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`. +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`. ## Maintaining this file diff --git a/docs/architecture.md b/docs/architecture.md index dde632b..fbdc0af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,63 +16,36 @@ Status: greenfield scaffold with local auth/config MVP. spec fetch task performs only a read-only OpenAPI GET and rejects non-HTTPS source URLs. -## 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` +## 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` 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. 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. +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. ## 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 deleted file mode 100644 index 7856233..0000000 --- a/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md +++ /dev/null @@ -1,87 +0,0 @@ -# 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 new file mode 100644 index 0000000..94d2693 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-generic-hcloud-setup.md @@ -0,0 +1,49 @@ +# 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 new file mode 100644 index 0000000..1ac5048 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-generic-hcloud-setup-design.md @@ -0,0 +1,45 @@ +# 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 faa2931..b651c6c 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 { agentOsView } from "../commands/agent-os"; +import { hcloudView } from "../commands/hcloud"; 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 agent-os load-hcloud-provider --workspace --token-file [--expected-ssh-key-fingerprint [--expected-ssh-key-name ]]", + " akua hcloud setup --workspace --token-file ", " 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 deleted file mode 100644 index 9885743..0000000 --- a/src/commands/agent-os.ts +++ /dev/null @@ -1,164 +0,0 @@ -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 18a78be..6a510f3 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 new file mode 100644 index 0000000..225df6a --- /dev/null +++ b/src/runtime/hcloud-setup.ts @@ -0,0 +1,338 @@ +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 deleted file mode 100644 index ca9296b..0000000 --- a/src/runtime/platform-client.ts +++ /dev/null @@ -1,235 +0,0 @@ -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 17e3890..90c2013 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_LOADER_TOKEN_PATH_INVALID", + code: "AKUA_HCLOUD_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_LOADER_TOKEN_FILE_SIZE_INVALID", + code: "AKUA_HCLOUD_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_LOADER_TOKEN_FILE_UNSAFE", + code: "AKUA_HCLOUD_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_LOADER_TOKEN_FILE_CHANGED", + code: "AKUA_HCLOUD_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 deleted file mode 100644 index c465414..0000000 --- a/test/agent-os-loader.test.ts +++ /dev/null @@ -1,414 +0,0 @@ -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 2858b5d..4f0c9e3 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -18,16 +18,13 @@ describe("akua entrypoint", () => { }); }); - test("documents the compiled Agent OS provider loader without exposing a credential input", async () => { + test("documents generic HCloud setup without exposing a credential input", async () => { const { stdout, exitCode } = await runAkua(["--help", "--json"]); expect(exitCode).toBe(0); - expect(stdout).toContain("akua agent-os load-hcloud-provider"); + expect(stdout).toContain("akua hcloud setup"); expect(stdout).toContain("--token-file"); - 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("load-hcloud-provider"); expect(stdout).not.toContain("--token <"); }); @@ -428,7 +425,7 @@ describe("akua entrypoint", () => { } }); - test("loader caller authentication reads only the protected local config", async () => { + test("HCloud setup caller authentication reads only the protected local config", async () => { const home = await makeTempHome(); try { const configDir = join(home, ".config", "akua"); @@ -440,7 +437,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_LOADER_ENV_AUTH_FORBIDDEN", + code: "AKUA_HCLOUD_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 new file mode 100644 index 0000000..435dfc7 --- /dev/null +++ b/test/hcloud-setup.test.ts @@ -0,0 +1,194 @@ +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 7438bd1..53d453a 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_LOADER_TOKEN_PATH_INVALID", + code: "AKUA_HCLOUD_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_LOADER_TOKEN_FILE_UNSAFE", + code: "AKUA_HCLOUD_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_LOADER_TOKEN_FILE_UNSAFE", + code: "AKUA_HCLOUD_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_LOADER_TOKEN_FILE_SIZE_INVALID", + code: "AKUA_HCLOUD_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_LOADER_TOKEN_FILE_CHANGED", + code: "AKUA_HCLOUD_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_LOADER_TOKEN_FILE_UNSAFE" }); + await expect(readSecureTokenFile(link)).rejects.toMatchObject({ code: "AKUA_HCLOUD_TOKEN_FILE_UNSAFE" }); } finally { await rm(directory, { recursive: true, force: true }); }