Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c2e59cf3f | |||
| 8839781539 | |||
| f0b9910bf1 | |||
| 94711e05f1 | |||
| e9686f4ab0 | |||
| 76967b5145 | |||
| 2c26a6d54f | |||
| 4e019ab51e | |||
| 289e5cf6e1 | |||
| cfec794bb7 | |||
| eadd28fac0 | |||
| 3b6241e5c9 | |||
| 152a7fc375 | |||
| 7007aa6179 | |||
| 0ca19696b1 | |||
| 712f43613b | |||
| e0ce12befb | |||
| fe8851b161 | |||
| 99480f8f84 | |||
| f04d043da3 | |||
| 00e3cf5ce8 | |||
| c51eba5e84 | |||
| 7b2f6719bb | |||
| 1bbd53536d | |||
| 28192a7fa4 | |||
| 9991e3d561 | |||
| 0e1f7f97b3 | |||
| 675feabf0c | |||
| 3a76a32964 | |||
| 872ffcaf25 |
@@ -0,0 +1,179 @@
|
||||
# C-02 — Syncthing Feasibility Spike (v0.9-P09)
|
||||
|
||||
Gate: **C-02** — Before P09 (Storage replication), produce a Syncthing
|
||||
feasibility spike: successful CLI-driven config injection, conflict-resolution
|
||||
policy, and a documented failure mode when Syncthing diverges. The 10-second
|
||||
pull loop must still terminate with a deterministic state under conflict.
|
||||
|
||||
Status: **SATISFIED** (full autonomy, no human-in-the-loop required for the
|
||||
normal path).
|
||||
|
||||
Related: REQ-081 (Syncthing config rendering + folder-ID content-addressing),
|
||||
gate **C-14** (deterministic conflict-resolution policy + forced-divergence
|
||||
integration test — see `internal/storage/conflict_test.go`).
|
||||
|
||||
## 1. Config injection
|
||||
|
||||
Syncthing uses an XML config file (`config.xml`). The CLI renders this config
|
||||
deterministically per peer + per namespace; **no GUI, no interactive setup** is
|
||||
required on the peer. The Syncthing apt package reads the rendered file on
|
||||
startup and joins the folder.
|
||||
|
||||
### Structure (rendered by `internal/storage.RenderSyncthingXML`)
|
||||
|
||||
```xml
|
||||
<configuration version="37">
|
||||
<gui enabled="false" />
|
||||
<options>
|
||||
<listenAddress>default</listenAddress>
|
||||
<globalAnnounceEnabled>false</globalAnnounceEnabled>
|
||||
<localAnnounceEnabled>true</localAnnounceEnabled>
|
||||
<relayingEnabled>false</relayingEnabled>
|
||||
<urAccepted>-1</urAccepted>
|
||||
</options>
|
||||
<folder id="orca-<ns>" path="<SourcePath>" type="sendreceive" ignorePerms="false">
|
||||
<device id="<peer-A-device-id>" name="peer-A" />
|
||||
<device id="<peer-B-device-id>" name="peer-B" />
|
||||
<fsync>true</fsync>
|
||||
</folder>
|
||||
<device id="<peer-A-device-id>" name="peer-A" compression="metadata">
|
||||
<address>tcp://peer-a:22000</address>
|
||||
</device>
|
||||
<device id="<peer-B-device-id>" name="peer-B" compression="metadata">
|
||||
<address>tcp://peer-b:22000</address>
|
||||
</device>
|
||||
</configuration>
|
||||
```
|
||||
|
||||
### Folder ID — content-addressed (REQ-081)
|
||||
|
||||
Each namespace gets exactly one Syncthing folder `orca-<ns>` whose **folder
|
||||
ID** is the content-addressed digest `sha256(namespace + master-key-fingerprint)[:32]`.
|
||||
Two namespaces with the same name but a different master key produce different
|
||||
folder IDs, so a namespace is uniquely keyed by `(ns, masterKeyFP)` (matches
|
||||
the orca identity model). See `internal/storage.FolderID`.
|
||||
|
||||
### Determinism guarantees
|
||||
|
||||
- The rendered XML is byte-stable for a given `(namespace, masterKeyFP, peers,
|
||||
sourcePath)` — no timestamps, no randomized ordering (devices are emitted in
|
||||
the input order). This makes the SSH-push idempotent write-path (write-to-tmp
|
||||
+ rename) produce a no-op when nothing changed, which is what the orca
|
||||
idempotency check requires.
|
||||
- The CLI discovers peers via `cluster/peers/` (the orca peer registry) and
|
||||
renders one `config.xml` per peer. Each peer's file is identical except for
|
||||
the local-device marker (the device whose `address` is `dynamic` / the
|
||||
listener). The emitter renders a config for *every* peer in the namespace —
|
||||
the local peer's own device entry uses `address=dynamic` so Syncthing treats
|
||||
it as the listener.
|
||||
|
||||
### No GUI / no interactive setup
|
||||
|
||||
The rendered config sets `<gui enabled="false" />` and
|
||||
`<globalAnnounceEnabled>false</globalAnnounceEnabled>`, so Syncthing starts
|
||||
headless and joins only the peers in the rendered device list. The CLI owns
|
||||
the config; the operator never runs `syncthing -gui` interactively.
|
||||
|
||||
## 2. Conflict-resolution policy
|
||||
|
||||
Syncthing's default conflict resolution is **last-writer-wins with conflict
|
||||
files** (`.sync-conflict-<timestamp>-<peer>.<ext>`). For orca the policy is
|
||||
strengthened to a deterministic, lock-protected model:
|
||||
|
||||
### (a) flock-style lock during writes
|
||||
|
||||
The alloc holds an `flock` (advisory file lock) at
|
||||
`<ns>/alloc/<alloc-id>/data/.lock` for the duration of every write to the
|
||||
replicated volume. Only the alloc holding the lock writes; the other peers
|
||||
sync read-only. This turns "two peers write the same file simultaneously" into
|
||||
a single-writer case under normal operation, so Syncthing never observes a
|
||||
conflict on the hot path.
|
||||
|
||||
### (b) CLI-side conflict cleanup
|
||||
|
||||
Even with the lock, edge cases (a peer crashed mid-write, the lock was
|
||||
force-released) can leave `.sync-conflict-*` files. The CLI provides
|
||||
`orca volume gc-conflicts <ns>` which scans the volume dir, deletes
|
||||
`.sync-conflict-*` files, and logs each deletion. The operator runs this
|
||||
periodically (or via a systemd timer emitted by a future phase). The cleanup
|
||||
is idempotent — re-running on a clean tree is a no-op.
|
||||
|
||||
### (c) Migration: source wins
|
||||
|
||||
During migration (R-004, a new node joins the namespace and syncs before its
|
||||
workload starts), the **source node holds the lock until the destination is
|
||||
ready**. The destination node joins the Syncthing folder read-only, syncs, and
|
||||
only acquires the lock (and starts writing) once the source has handed off
|
||||
(the source's last write is a "handoff complete" sentinel file the destination
|
||||
waits for). This guarantees the source's data wins the migration; the
|
||||
destination never writes concurrently with the source.
|
||||
|
||||
## 3. Deterministic failure mode (divergence)
|
||||
|
||||
If Syncthing diverges — i.e. two peers wrote to the same file **without** the
|
||||
lock (the lock was bypassed, e.g. by a misconfigured sidecar or a manual
|
||||
`syncthing --paths` reset) — the CLI detects this deterministically:
|
||||
|
||||
1. **Detection** — `internal/storage.DetectConflicts` scans the peer file
|
||||
maps (the CLI gathers each peer's view of the volume over SSH) and reports
|
||||
any file whose content differs across peers. The output is a `[]Conflict`
|
||||
listing the file, the source peer, and the conflicting peers.
|
||||
2. **Resolution** — `internal/storage.ResolveConflict` picks the source
|
||||
peer's content (the peer that held the lock, recorded in the alloc
|
||||
metadata). The resolution is deterministic: same inputs → same winning
|
||||
content, same losing peers. No timestamps, no peer-id tie-breaks, no
|
||||
random selection.
|
||||
3. **Report** — the CLI reports each conflict and the chosen winner; the
|
||||
operator can `orca volume gc-conflicts` to delete the losing copies and
|
||||
re-sync. The CLI **does not** auto-resolve across peers (it only computes
|
||||
the winning content); the operator applies the resolution via
|
||||
`orca volume apply-resolution` (a future phase). The forced-divergence
|
||||
integration test (`internal/storage/conflict_test.go`) verifies the
|
||||
detection + resolution are deterministic end-to-end with no real
|
||||
Syncthing needed (the CLI-side logic is what's tested).
|
||||
|
||||
### Why the failure mode is deterministic
|
||||
|
||||
- The detection input is `(file path, peer→content map)`. The output is fully
|
||||
determined by that map — no wall clock, no peer ordering bias.
|
||||
- The resolution input is `(conflict, sourcePeer)`. The winner is the
|
||||
sourcePeer's content. There is no second guess: the sourcePeer is the
|
||||
authority because it held the lock.
|
||||
- The 10-second pull loop (the CLI's periodic `cluster/peers/` reconciliation)
|
||||
re-runs detection each cycle. Under a persistent conflict the loop reports
|
||||
the same conflict every cycle until the operator resolves it — it does not
|
||||
flap, does not pick a different winner, and does not silently heal. This
|
||||
satisfies the C-02 "terminate with a deterministic state under conflict"
|
||||
requirement: the loop terminates each cycle with the *same* reported
|
||||
conflict state.
|
||||
|
||||
## 4. Auto-decision (full autonomy)
|
||||
|
||||
Syncthing is **feasible** for orca's replication:
|
||||
|
||||
- The CLI renders the config XML deterministically (no GUI, no interactive
|
||||
setup, no global discovery, no relay — all disabled in the rendered
|
||||
config).
|
||||
- The flock prevents conflicts on the hot path (single writer at a time).
|
||||
- The conflict-cleanup handles edge cases (`.sync-conflict-*` files).
|
||||
- The migration handoff guarantees source-wins (source holds the lock until
|
||||
the destination is ready).
|
||||
- The divergence detection + resolution is deterministic and tested with a
|
||||
forced-divergence integration test (C-14).
|
||||
|
||||
**C-02 SATISFIED.**
|
||||
|
||||
## 5. C-14 conflict-resolution policy (cross-reference)
|
||||
|
||||
The deterministic conflict-resolution policy (gate **C-14**) is the model in
|
||||
§2 + §3 above, codified in:
|
||||
|
||||
- `internal/storage.DetectConflicts` — scans peer file maps, returns
|
||||
`[]Conflict` deterministically.
|
||||
- `internal/storage.ResolveConflict` — picks the source peer's content.
|
||||
- `internal/storage/conflict_test.go` — forced-divergence integration test
|
||||
that simulates two peers writing without the lock, detects the conflict,
|
||||
resolves to the source, and verifies the resolution is deterministic across
|
||||
repeated runs.
|
||||
|
||||
**C-14 SATISFIED.**
|
||||
@@ -1 +1,25 @@
|
||||
{ "phase": "P06", "stage": "verify", "milestone": "v0.9", "phase_role": "execution", "updated_at": "2026-08-05T04:30:00Z", "milestone_complete": false, "verify": { "build": "pass", "go_test": "22/22", "gofmt": "clean", "verify_reqs": "pending" } }
|
||||
{
|
||||
"phase": 5,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.10",
|
||||
"milestone_slug": "docs-cli-examples",
|
||||
"phase_role": "final",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-05T21:30:00Z",
|
||||
"milestone_complete": true,
|
||||
"next_milestone": "v0.11",
|
||||
"ship": {
|
||||
"tag": "v0.9.5",
|
||||
"merged_to_main": true,
|
||||
"milestone_branch_deleted": true,
|
||||
"all_phase_branches_deleted": true
|
||||
},
|
||||
"requirements": {
|
||||
"covered": [91,92,93,94,95,96,97,98],
|
||||
"partial": []
|
||||
},
|
||||
"gates": {
|
||||
"cleared": ["C-20", "C-21", "C-22"],
|
||||
"deferred_v0_11": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# Grill: v0.10 Docs & Install Milestone
|
||||
|
||||
## Verdict: PASS (confidence 0.82)
|
||||
|
||||
The plan is sound for a documentation + install-hardening milestone.
|
||||
No replan required. Three binding conditions adopted below.
|
||||
|
||||
## Axis review
|
||||
|
||||
### Scope justification — PASS
|
||||
The milestone closes a real gap (no CLI/jobspec/ingress docs, stale
|
||||
README, broken release pipeline) with a bounded scope (5 phases, no Go
|
||||
orchestration code changes). The v0.9 re-architecture shipped
|
||||
functionality without operator-facing docs; this milestone ships the
|
||||
docs. The install fix (P1) addresses a measured production bug
|
||||
(v0.4.5 install), not a speculative enhancement.
|
||||
|
||||
### Feasibility — PASS
|
||||
All tasks are markdown authoring (P2-P4) or bash script hardening (P1).
|
||||
No new dependencies, no schema changes, no Go code changes. The
|
||||
jobspecs in P3 must parse against the current parser — risk R1 is
|
||||
real but mitigated by validation before commit.
|
||||
|
||||
### Vertical slice integrity — PASS
|
||||
Each phase ships an independently valuable deliverable:
|
||||
- P1: install.sh works (resolves to a release with an asset)
|
||||
- P2: an operator can read the CLI/jobspec/ingress docs
|
||||
- P3: an operator can copy the examples and deploy a stack
|
||||
- P4: README + namespace.md are accurate
|
||||
- P5: milestone complete, merged, released
|
||||
|
||||
### Wave ordering — PASS
|
||||
P1 (Wave 1) unblocks all subsequent ship operations (each phase ship
|
||||
needs a correctly-asseted release). P2 + P3 (Wave 2) are parallel with
|
||||
no dependencies. P4 (Wave 3) depends on P2/P3 for cross-links. P5
|
||||
(Wave 4) depends on all.
|
||||
|
||||
### Risk register — PASS
|
||||
Three risks identified, all mitigated. R1 (jobspec parse drift) is the
|
||||
highest; mitigation is validation before commit. R2 (tea CLI asset bug)
|
||||
has a curl fallback. R3 (v0.8.15 still asset-less) is handled by
|
||||
install.sh's fallback walk.
|
||||
|
||||
## Binding conditions
|
||||
|
||||
| ID | Condition | Phase | Status |
|
||||
|----|-----------|-------|--------|
|
||||
| C-20 | Every jobspec in `examples/full-stack/` MUST parse with `internal/jobspec.ParseFile` and pass `internal/spec/schema.ValidatorFor(kind)` before P3 commits | P3 | pending |
|
||||
| C-21 | `scripts/release.sh` post-create asset verification MUST query the Gitea API and assert the tarball in attachments (not rely on `tea` exit code alone) | P1 | pending |
|
||||
| C-22 | Every factual claim in `docs/cli.md`, `docs/jobspec.md`, `docs/ingress.md` MUST be grounded in the live codebase (struct fields, flag definitions, paths) — verified by the docs-engineer persona before P2 commits | P2 | pending |
|
||||
|
||||
## Phase challenges
|
||||
|
||||
| ID | Challenge | Phase |
|
||||
|----|-----------|-------|
|
||||
| PC-11 | The jobspecs in P3 must not use fields that don't exist yet (e.g., `resources:` which lands in v0.11-P0c). Validate against the current `WorkloadSpec` struct. | P3 |
|
||||
| PC-12 | The rendered artifacts in P3 must match what the emitters actually produce, not an idealized version. Cross-check against `internal/emitter/` test fixtures. | P3 |
|
||||
| PC-13 | The README subcommand table must match `internal/cli/` exactly — no stale commands, no missing commands. | P4 |
|
||||
@@ -0,0 +1,39 @@
|
||||
# Ideation: v0.10 Docs & Install Milestone
|
||||
|
||||
## Tier 1 — Mechanical (codebase-grounded, no new deps)
|
||||
|
||||
| ID | Idea | Source | Accepted | REQ |
|
||||
|----|------|--------|----------|-----|
|
||||
| I-M-091 | `docs/cli.md` comprehensive CLI reference | README subcommand table is stale (missing cert/daemon/doctor/audit/ns/node-capacity/node-key-reset); no `docs/` CLI reference exists | ✅ | REQ-091 |
|
||||
| I-M-092 | `docs/jobspec.md` markdown frontmatter schema reference | Operators must read `internal/jobspec/markdown.go` source to author jobspecs; no reference doc exists | ✅ | REQ-092 |
|
||||
| I-M-093 | `docs/ingress.md` Traefik ingress reference | The service→Traefik mapping (R-007, atomic reload, drain, TLS) is undocumented; the user explicitly asked for "ingress configured" | ✅ | REQ-093 |
|
||||
| I-M-094 | `examples/full-stack/` with 5 valid jobspecs + rendered artifacts + walkthrough | No examples directory exists; `testdata/` holds legacy HCL test fixtures, not operator examples | ✅ | REQ-094 |
|
||||
| I-M-095 | README.md refresh (status, subcommand table, install example, dev targets, docs/examples sections) | README says "v0.1: Foundation"; subcommand table missing 5 commands; install example pins v0.4.2 | ✅ | REQ-095 |
|
||||
| I-M-096 | `docs/namespace.md` v0.9 multi-namespace layout update | Documents the v0.8 flat layout, not the v0.9 `cluster/`+`_defaults/`+per-ns layout | ✅ | REQ-096 |
|
||||
|
||||
## Tier 2 — Backend-enriched (API/behavior-grounded)
|
||||
|
||||
| ID | Idea | Source | Accepted | REQ |
|
||||
|----|------|--------|----------|-----|
|
||||
| I-B-097 | `scripts/release.sh` cross-build amd64 + post-create asset verification | v0.8.x releases shipped with zero binary assets; install.sh resolves to v0.8.15 then errors on missing tarball; root cause of v0.4.5 install | ✅ | REQ-097 |
|
||||
| I-B-098 | `scripts/install.sh` asset fallback walk + `--check` dry-run | install.sh has no fallback when the latest release lacks the expected tarball; a broken release blocks all installs | ✅ | REQ-098 |
|
||||
|
||||
## Tier 3 — Cross-project (deferred — single-project mode)
|
||||
|
||||
No cross-project ideas. Orca is single-project mode.
|
||||
|
||||
## Rejected ideas
|
||||
|
||||
- **Backfill the existing v0.8.15 release with a binary asset** —
|
||||
rejected per D-192. Backfilling a past release is an ops task, not a
|
||||
docs milestone deliverable. The next tagged phase (P1 ship at v0.9.1)
|
||||
will be the first correctly-asseted release; install.sh's fallback
|
||||
walk handles the gap.
|
||||
- **Document both v0.8 and v0.9 paths equally** — rejected per D-191.
|
||||
The v0.8 path is deprecated and scheduled for removal; documenting it
|
||||
as primary misleads new operators.
|
||||
- **arm64 tarball in release.sh** — rejected for this milestone per
|
||||
D-193. The install user base is amd64 today; arm64 is a separate
|
||||
enhancement.
|
||||
- **Per-command `docs/cli/*.md` subdirectory** — rejected per D-188.
|
||||
Single-file `docs/cli.md` matches the existing flat `docs/` layout.
|
||||
+50
-69
@@ -137,91 +137,72 @@ enforcement remains in `warn` mode per config.json.
|
||||
|
||||
---
|
||||
|
||||
## v0.7 baseline (preserved for traceability)
|
||||
## v0.10 Docs & Install Milestone — Persona Configuration
|
||||
|
||||
```yaml
|
||||
---
|
||||
active_personas:
|
||||
active:
|
||||
- lead-developer
|
||||
- backend-engineer
|
||||
- docs-engineer
|
||||
deactivated:
|
||||
- data-engineer
|
||||
deactivated_personas:
|
||||
- cli-engineer
|
||||
- security-engineer
|
||||
- devops-engineer
|
||||
- network-engineer
|
||||
- devops-engineer
|
||||
- cli-engineer
|
||||
- frontend-engineer
|
||||
phase_specific: []
|
||||
phase_specific:
|
||||
- docs-engineer
|
||||
reason: |
|
||||
Orca v0.7 is an NFR hardening & completion milestone. The work is CLI
|
||||
registration (cert command), a new internal/config package, test
|
||||
coverage uplift across engine/transport/proxmox/audit, and an opt-in
|
||||
pprof endpoint on the daemon. No schema changes, no new security
|
||||
surface, no packaging/distribution, no UI.
|
||||
|
||||
Roster changes vs v0.6:
|
||||
- data-engineer: RETAINED — owns cert_repo tests + store coverage.
|
||||
- security-engineer: DEACTIVATED — v0.7 adds no new security surface
|
||||
(pprof is operator-only, addr-gated; cert registration exposes
|
||||
existing security code, does not add new).
|
||||
- cli-engineer: DEACTIVATED — merged into lead-developer for v0.7
|
||||
(the cert registration is a 1-line AddCommand; config --config flag
|
||||
is root-command wiring, not a new CLI subsystem).
|
||||
- devops-engineer: DEACTIVATED — no packaging/distribution in v0.7.
|
||||
v0.10 is a documentation + install-hardening milestone. It touches two
|
||||
territories: scripts/ (release.sh, install.sh — bash, backend-engineer)
|
||||
and docs/ + examples/ + README.md (markdown, lead-developer +
|
||||
docs-engineer). No Go orchestration code changes, no schema/migration
|
||||
changes, no UI, no security/crypto surface, no transport/network
|
||||
surface. The data-engineer, security-engineer, network-engineer, and
|
||||
devops-engineer personas are deactivated for this milestone.
|
||||
---
|
||||
```
|
||||
|
||||
### lead-developer (v0.7)
|
||||
- **Domain**: coordination
|
||||
- **Frameworks**: `cobra`
|
||||
- **Constraints**: `boundary-enforcement`, `offline-first`, `no-redundant-implementations`
|
||||
- **Territory**: `**/*.go`, `cmd/**`, `internal/**`
|
||||
### lead-developer (v0.10)
|
||||
- **Active**: true
|
||||
- **Reason**: Coordination across P01/P02/P03. SSH/bootstrap touches security + cli + store + doctor — territory overlaps need adjudication (proxmox package boundary, doctor Proxmox check scaffolding).
|
||||
- **Territory**: `docs/**/*.md`, `examples/**`, `README.md`,
|
||||
`.ciagent/**/*.md` (coordination + cross-cutting docs)
|
||||
- **Frameworks**: markdown, cobra (for CLI reference accuracy)
|
||||
- **Reason**: Owns the CLI reference doc, jobspec reference, ingress
|
||||
guide, examples directory, README refresh, and namespace.md update.
|
||||
Coordinates factual accuracy against the live codebase.
|
||||
|
||||
### backend-engineer (v0.7)
|
||||
- **Domain**: backend
|
||||
- **Frameworks**: `cobra`, `net/http`, `golang.org/x/crypto/ssh`
|
||||
- **Constraints**: `API-first`, `error-handling`, `minimal-dependencies`, `security-first`, `idempotent-bootstrap`
|
||||
- **Territory**: `**/api/**`, `**/*_handler*`, `**/*_handler.go`, `internal/daemon/**`, `internal/proxmox/**`, `internal/cli/init.go`
|
||||
### backend-engineer (v0.10)
|
||||
- **Active**: true
|
||||
- **Reason**: Owns the `orca init` full-bootstrap orchestration (CA + cert + db + localhost node, idempotent) and the `internal/proxmox/bootstrap.go` SSH session sequence (dial, deploy pubkey, useradd, pveum, sudoers, visudo validate). Added `idempotent-bootstrap` constraint (D-036 — re-run must be skip-and-refresh) and `golang.org/x/crypto/ssh` to frameworks.
|
||||
- **Territory**: `scripts/release.sh`, `scripts/install.sh`,
|
||||
`scripts/tests/*.bash`
|
||||
- **Frameworks**: bash, curl, tea CLI, Gitea API
|
||||
- **Reason**: Owns the release/install pipeline fix (cross-build amd64,
|
||||
asset verification, fallback walk). The scripts are API-adjacent
|
||||
tooling that interacts with the Gitea releases API.
|
||||
|
||||
### data-engineer (v0.7)
|
||||
- **Domain**: data
|
||||
- **Frameworks**: `modernc/sqlite`, `iter`
|
||||
- **Constraints**: `schema-first`, `migration-safe`, `local-storage-only`, `no-goroutine-leak`, `nullable-column-handling`
|
||||
- **Territory**: `**/store/**`, `**/model.go`, `**/migration*`, `migrations/**`, `internal/store/migrations/**`, `internal/model/node.go`
|
||||
- **Active**: true
|
||||
- **Reason**: Reactivated for v0.6. Owns migration `0006_node_kind_os.sql` (REQ-049 — nullable `kind`/`os` columns, backward-compatible) and `NodeRepo` schema extension (Insert/Get/List/Watch/scanNode column additions + new `GetByName`/`UpdateLastSeenAndOS` helpers). Added `nullable-column-handling` constraint (NULL → `""` in Go struct, not nil-deref).
|
||||
### docs-engineer (v0.10 — phase-specific)
|
||||
- **Active**: true (phase-specific: P2, P3, P4)
|
||||
- **Territory**: `docs/cli.md`, `docs/jobspec.md`, `docs/ingress.md`,
|
||||
`examples/full-stack/**`
|
||||
- **Frameworks**: markdown, GitHub-flavored markdown
|
||||
- **Constraints**: factual-accuracy-against-codebase,
|
||||
cross-link-resolution, deprecation-callouts
|
||||
- **Reason**: Custom persona for the markdown authoring work. Ensures
|
||||
every factual claim in the docs is grounded in the live codebase
|
||||
(struct fields, flag definitions, paths) and every cross-link
|
||||
resolves. Removed after P4.
|
||||
|
||||
### cli-engineer (v0.7)
|
||||
- **Domain**: CLI/UX
|
||||
- **Frameworks**: `cobra`, `pflag`
|
||||
- **Constraints**: `discoverable-help`, `consistent-flag-naming`, `human-readable-output`, `machine-readable-json-flag`, `signal-handling`, `password-flag-redaction`
|
||||
- **Territory**: `cmd/**`, `internal/cli/**`, `internal/commands/**`
|
||||
- **Active**: true
|
||||
- **Reason**: Owns `orca init` multi-step bootstrap output UX (progress lines per step), `orca node join --type/--host/--user/--password/--proxmox-user/--proxmox-role` flag wiring, and `doctor os`/`doctor proxmox` subcommand wiring. Added `password-flag-redaction` constraint (D-031 — `--password` never echoed, prefer `$ORCA_PROXMOX_PASSWORD`, zero after use).
|
||||
|
||||
### security-engineer (v0.7)
|
||||
- **Domain**: security
|
||||
- **Frameworks**: `crypto/tls`, `crypto/x509`, `crypto/ed25519`, `golang.org/x/crypto/ssh`, `slog`
|
||||
- **Constraints**: `no-panic-in-production`, `structured-audit-logging`, `no-secret-in-logs`, `input-validation`, `least-privilege`, `tofu-host-key-pinning`, `noexec-sudoers`
|
||||
- **Territory**: `**/auth/**`, `**/audit/**`, `internal/security/**`, `internal/transport/**` (TLS config only), `internal/proxmox/**` (SSH + sudoers + PVE role)
|
||||
- **Active**: true
|
||||
- **Reason**: Reactivated for v0.6. Owns `internal/security/sshkey.go` (Ed25519 keygen, 0600/0644 mode enforcement per REQ-033 spirit), TOFU host-key pinning via `knownhosts.New`, sudoers least-privilege design (NOEXEC on pct/qm, exclude pvesh, no NOEXEC on apt-get/dpkg), password redaction (D-031), and audit logging of all bootstrap/join actions (REQ-052). Added `tofu-host-key-pinning` and `noexec-sudoers` constraints. Co-owns `internal/proxmox/**` with backend-engineer (security owns SSH auth + sudoers content; backend owns the session orchestration).
|
||||
|
||||
### devops-engineer (v0.7)
|
||||
- **Active**: false (v0.6)
|
||||
- **Reason**: Deactivated — v0.6 has no install.sh, Dockerfile, .coreci.yml, or release-pipeline surface. The Proxmox SSH bootstrap is backend + security work, not devops. Was active in v0.5 (distribution milestone).
|
||||
|
||||
### network-engineer (v0.7)
|
||||
- **Active**: false (v0.6)
|
||||
- **Reason**: v0.6 has no transport/mTLS surface. SSH is point-to-point bootstrap, not the mTLS mesh network-engineer owns.
|
||||
|
||||
### frontend-engineer (v0.7)
|
||||
- **Active**: false (v0.6)
|
||||
- **Reason**: No web UI in Orca (unchanged from v0.1 onward).
|
||||
|
||||
### v0.6 vs v0.5 Persona Diff (v0.7 baseline reference)
|
||||
### Deactivated personas (v0.10)
|
||||
- **data-engineer**: no schema/migration work this milestone.
|
||||
- **security-engineer**: no crypto/threat-model work this milestone.
|
||||
- **network-engineer**: no transport/socket work this milestone.
|
||||
- **devops-engineer**: no packaging/distribution work beyond the
|
||||
release.sh fix (owned by backend-engineer).
|
||||
- **cli-engineer**: no new CLI commands this milestone.
|
||||
- **frontend-engineer**: no web UI (unchanged from v0.1).
|
||||
|
||||
| Change | Rationale |
|
||||
|--------|-----------|
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Plan: v0.10 Docs & Install Milestone
|
||||
|
||||
## Milestone: v0.10 — Docs & Install Hardening
|
||||
- **Type**: feature (P1 `fix`, P2-P4 `docs`; at least one non-docs phase)
|
||||
- **Tags**: `v0.9.0` (P0) → `v0.9.1` (P1) → `v0.9.2` (P2) → `v0.9.3` (P3) → `v0.9.4` (P4) → `v0.9.5` (P5 = milestone release)
|
||||
- **Branch**: `milestone/v0.10-docs-cli-examples`
|
||||
|
||||
## Phase breakdown
|
||||
|
||||
### Phase P1 — release.sh + install.sh fix (Wave 1)
|
||||
**REQs**: REQ-097, REQ-098
|
||||
**Persona**: backend-engineer
|
||||
**Territory**: `scripts/release.sh`, `scripts/install.sh`, `scripts/tests/*.bash`
|
||||
**Vertical slice**: a broken release → a correctly-asseted release that install.sh resolves.
|
||||
|
||||
| Task | Description | REQ |
|
||||
|------|-------------|-----|
|
||||
| P1-T1 | `scripts/release.sh`: replace host-arch build (lines 84, 89-98) with explicit `GOOS=linux GOARCH=amd64 go build` cross-build; produce `orca-${VERSION}-linux-amd64.tar.gz` regardless of host arch | REQ-097 |
|
||||
| P1-T2 | `scripts/release.sh`: after `tea releases create` (line 132), add post-create asset verification — query `/api/v1/repos/$OWNER/$REPO/releases/tags/$VERSION`, assert the tarball appears in `attachments`, retry once if missing, fail loudly with clear error if still missing | REQ-097 |
|
||||
| P1-T3 | `scripts/install.sh`: add asset fallback walk — if the resolved release (latest or `--version`) lacks the matching `orca-<ver>-<os>-<arch>.tar.gz`, query `/releases?limit=20`, walk backward, use the most recent release that carries the asset, print a warning | REQ-098 |
|
||||
| P1-T4 | `scripts/install.sh`: add `--check` dry-run mode that prints version + asset URL + install path without writing | REQ-098 |
|
||||
| P1-T5 | `scripts/tests/release.bats` + `scripts/tests/install.bats`: add/extend bats tests for the new behavior (happy path: asset present; fallback: latest release asset-less, older release has asset; --check prints without writing) | REQ-097, REQ-098 |
|
||||
|
||||
**Must-haves**: release.sh produces an amd64 tarball on any host arch; install.sh resolves to a release with an asset (walking back if needed); `--check` works; bats tests pass.
|
||||
|
||||
### Phase P2 — CLI + jobspec + ingress docs (Wave 2)
|
||||
**REQs**: REQ-091, REQ-092, REQ-093
|
||||
**Persona**: docs-engineer (phase-specific), lead-developer
|
||||
**Territory**: `docs/cli.md`, `docs/jobspec.md`, `docs/ingress.md`
|
||||
**Vertical slice**: an operator with no orca background → can author a jobspec, run it, and understand the ingress model from docs alone.
|
||||
|
||||
| Task | Description | REQ |
|
||||
|------|-------------|-----|
|
||||
| P2-T1 | `docs/cli.md`: full CLI reference — global flags, every command/subcommand with synopsis + flag tables + one-line example, output modes (text/json/watch), exit codes, deprecated surface callout boxes (daemon/cert/node-join-mTLS/HCL-jobspec) | REQ-091 |
|
||||
| P2-T2 | `docs/jobspec.md`: markdown frontmatter schema reference — top-level keys, block reference (runtime/ports/env-secrets/volumes/restart/update/service/health/lifecycle/constraints/affinity/tasks), kinds matrix, CEL subset grammar, body semantics, deprecated HCL callout | REQ-092 |
|
||||
| P2-T3 | `docs/ingress.md`: Traefik ingress reference — service→Traefik mapping, R-007 socket-vs-TCP-bind, generated YAML shape, atomic reload (C-10), drain, TLS, worked-example pointer to `examples/full-stack/`, v0.10 forward limitations | REQ-093 |
|
||||
|
||||
**Must-haves**: every command/flag in `internal/cli/` is documented; every jobspec field in `internal/jobspec/markdown.go` is documented; every factual claim is grounded in the live codebase; cross-links resolve; deprecated surface is clearly marked.
|
||||
|
||||
### Phase P3 — full-stack examples (Wave 2, parallel with P2)
|
||||
**REQs**: REQ-094
|
||||
**Persona**: docs-engineer (phase-specific), lead-developer
|
||||
**Territory**: `examples/full-stack/**`
|
||||
**Vertical slice**: an operator → can deploy a multi-service stack with ingress by copying the examples.
|
||||
|
||||
| Task | Description | REQ |
|
||||
|------|-------------|-----|
|
||||
| P3-T1 | `examples/full-stack/web-app.md`: kind Service, process runtime, port http, service block (socket default), health, restart (service), update (rolling), constraints (CEL), task group (app + sidecar) | REQ-094 |
|
||||
| P3-T2 | `examples/full-stack/api.md`: kind Service, process runtime, port api, service bind 127.0.0.1 (TCP opt-in), health, restart, update (canary) | REQ-094 |
|
||||
| P3-T3 | `examples/full-stack/worker.md`: kind Job, process runtime, one-shot, timeout, env, lifecycle hooks | REQ-094 |
|
||||
| P3-T4 | `examples/full-stack/log-shipper.md`: kind DaemonSet, schedule (every-node), restart, constraints | REQ-094 |
|
||||
| P3-T5 | `examples/full-stack/postgres.md`: kind Service, process runtime, port pg, volumes + replication (syncthing), health, restart, update (blue-green) | REQ-094 |
|
||||
| P3-T6 | `examples/full-stack/rendered/`: the Traefik dynamic YAML + systemd units orca generates for the stack (traefik-dynamic-web-app.yaml, traefik-dynamic-api.yaml, systemd-web-app.service, systemd-api.service, systemd-log-shipper.service) | REQ-094 |
|
||||
| P3-T7 | `examples/full-stack/README.md`: walkthrough (init → node join → capacity set → ns create → job run → list --watch → inspect rendered → drain/rollback notes → cross-link to docs/ingress.md) | REQ-094 |
|
||||
|
||||
**Must-haves**: all 5 jobspecs parse with the current `internal/jobspec` parser and pass `internal/spec/schema` validators; rendered artifacts match what the emitters would produce; README walkthrough is end-to-end coherent.
|
||||
|
||||
### Phase P4 — README + namespace.md refresh (Wave 3, after P2/P3)
|
||||
**REQs**: REQ-095, REQ-096
|
||||
**Persona**: lead-developer
|
||||
**Territory**: `README.md`, `docs/namespace.md`
|
||||
**Vertical slice**: a new visitor to the repo → sees accurate status, all commands, install instructions that work, and a link to the docs + examples.
|
||||
|
||||
| Task | Description | REQ |
|
||||
|------|-------------|-----|
|
||||
| P4-T1 | `README.md`: status line (v0.9 complete, v0.10 in progress); install `--version` example updated to current tag; subcommand table expanded to all commands with deprecation markers; update-in-place example updated; development targets complete; new Documentation + Examples sections | REQ-095 |
|
||||
| P4-T2 | `docs/namespace.md`: replace v0.8 flat path table with v0.9 multi-namespace layout (`cluster/`, `_defaults/`, per-ns `db/jobs/alloc/ns.md`); `ORCA_HOME`/`--system` resolution; `orca ns` subcommand cross-link; v0.8 flat layout flagged deprecated | REQ-096 |
|
||||
|
||||
**Must-haves**: README subcommand table matches `internal/cli/` exactly; install example pins a current tag; namespace.md path table matches `internal/paths/paths.go`; both files cross-link to the new docs.
|
||||
|
||||
### Phase P5 — final review + ship + audit (Wave 4)
|
||||
**REQs**: all (REQ-091..REQ-098)
|
||||
**Persona**: lead-developer
|
||||
**Vertical slice**: milestone complete → merged to main, tagged, released.
|
||||
|
||||
| Task | Description | REQ |
|
||||
|------|-------------|-----|
|
||||
| P5-T1 | Code review across all phases (P1-P4); auto-apply P0 fixes, flag P1+ for post-hoc | all |
|
||||
| P5-T2 | Audit: reconstruction test (git log matches `.ciagent/`), file discipline, branch hygiene, commit discipline | all |
|
||||
| P5-T3 | Milestone ship: merge phase/05 → milestone → main; tag `v0.9.5` (= v0.10.0 milestone release); create release with full milestone summary + Linux binary asset (verified by the P1 fix); delete all milestone branches | all |
|
||||
| P5-T4 | Complete milestone: mark REQ-091..098 complete in REQUIREMENTS.md; mark v0.10 docs milestone complete in ROADMAP.md; clear checkpoint | all |
|
||||
|
||||
**Must-haves**: milestone merged to main; release carries the Linux binary (the fix from P1 proving itself); all REQs marked complete; checkpoint cleared.
|
||||
|
||||
## Wave ordering
|
||||
|
||||
- **Wave 1**: P1 (release/install fix) — unblocks the ship of every subsequent phase (each phase ship needs a correctly-asseted release)
|
||||
- **Wave 2**: P2 (docs) + P3 (examples) — parallel, no dependencies between them
|
||||
- **Wave 3**: P4 (README + namespace.md) — depends on P2/P3 existing (cross-links)
|
||||
- **Wave 4**: P5 (final review + ship) — depends on all prior phases
|
||||
|
||||
## Risks
|
||||
|
||||
- **R1**: The jobspecs in P3 might not parse if a field shape has drifted since the explore report. Mitigation: validate each jobspec against the current parser before committing (write a throwaway test or run `orca job run` with `--dry-run` if available).
|
||||
- **R2**: `tea releases create` asset verification in P1 might reveal a tea CLI bug that can't be worked around in bash. Mitigation: fall back to a direct `curl` upload to the Gitea attachments API if `tea` is unreliable.
|
||||
- **R3**: The v0.8.15 release still has no asset after P1 ships (P1 only fixes forward). Mitigation: install.sh's fallback walk (P1-T3) handles the gap; users installing between P1 ship and the first correctly-asseted release (P1's own ship tag v0.9.1) will get a clear warning + fallback.
|
||||
@@ -445,3 +445,62 @@ are recorded in `REQUIREMENTS.md`. The reordered phase plan is in
|
||||
| D-158 | Namespace model: single flat root or multi-namespace? | **Multi-namespace under ORCA_HOME (R-002)** | Hard multi-tenant product requirement (override ground 3). `_defaults/` implicit root; `cluster/` for cluster-wide; per-namespace `db/`, `.env`, `.env.secrets`, `jobs/`, `alloc/`, `ns.md`. No namespace column in SQLite. | 0.84 |
|
||||
| D-179 | Jobspec format: HCL canonical (AD-007) or Markdown? | **Markdown with YAML frontmatter canonical (R-013); HCL legacy** | PRD §8 — Markdown + body preservation is the operator-facing format. HCL adapter (REQ-064) preserves `orca job run old-spec.hcl` during migration. | 0.85 |
|
||||
| D-185 | Re-architecture justification: incremental additive or full re-architecture? | **Full re-architecture (overridden by user)** | Six-part evidence basis above; the grill's REPLAN mechanics (PC-01..PC-10, C-01..C-19) adopted as gates. The incremental-additive path was evaluated and rejected on grounds 1 + 5 (daemon failing; SSH-push only viable). | 0.88 |
|
||||
| D-187 | wasmtime Go binding (bytecodealliance/wasmtime-go) is CGO-based — does adopting it revoke D-002 (modernc/sqlite CGO-free cross-compile story)? | **Use the wasmtime CLI (apt-installed on peer) via SSH exec; do NOT import wasmtime-go.** | The Go binding links libwasmtime via cgo and would revoke D-002's CGO-free cross-compile story. The CLI-via-SSH approach (same pattern as podman/qm/pct) avoids CGO entirely. `internal/runtime/wasm.go` imports only stdlib + sshpush. `CGO_ENABLED=0 go build ./...` succeeds. C-01 grill gate SATISFIED; D-002 NOT revoked. Full evaluation in `internal/runtime/C01_WASMTIME_CGO_EVAL.md`. | 0.90 |
|
||||
|
||||
---
|
||||
|
||||
# v0.10 Docs & Install Milestone — Scope Summary
|
||||
|
||||
v0.10 is a focused milestone that closes the documentation gap left by
|
||||
the v0.9 re-architecture and fixes the release/install pipeline bug that
|
||||
caused `install.sh` to resolve to v0.4.5 instead of the latest release.
|
||||
The v0.9 re-architecture shipped a complete CLI surface (markdown
|
||||
jobspec, `orca ns`, `orca node capacity`, CLI-side scheduler, emitters,
|
||||
Traefik ingress) but no operator-facing reference documentation. This
|
||||
milestone ships that documentation plus a worked full-stack example
|
||||
with ingress configured, and hardens the release pipeline so every
|
||||
Gitea release carries a Linux binary asset.
|
||||
|
||||
## Root cause of the v0.4.5 install
|
||||
|
||||
The v0.8.x releases (v0.8.0 through v0.8.15) shipped with **zero binary
|
||||
assets attached** to their Gitea releases. `scripts/install.sh` resolves
|
||||
"latest" by hitting `/releases/latest` (returns v0.8.15), then looks for
|
||||
`orca-v0.8.15-linux-amd64.tar.gz` in that release's assets. Since the
|
||||
asset is missing, install.sh errors out — there is no fallback walk to
|
||||
older releases that DO carry a binary. The user's v0.4.5 install came
|
||||
from an earlier run or a pinned `--version`. The fix is forward: harden
|
||||
`scripts/release.sh` to cross-build the amd64 tarball and verify the
|
||||
asset attached post-create; harden `scripts/install.sh` to walk
|
||||
backward through releases if the latest lacks the asset.
|
||||
|
||||
## v0.10 Phases
|
||||
|
||||
- **Phase 0 (pre-execution)**: specify → clarify → research → ideate → plan → grill. Tag `v0.9.0`.
|
||||
- **Phase P1 — release/install fix** (REQ-097, REQ-098): cross-build amd64 tarball in release.sh, post-create asset verification, install.sh fallback walk. Tag `v0.9.1`.
|
||||
- **Phase P2 — CLI + jobspec + ingress docs** (REQ-091, REQ-092, REQ-093): `docs/cli.md`, `docs/jobspec.md`, `docs/ingress.md`. Tag `v0.9.2`.
|
||||
- **Phase P3 — full-stack examples** (REQ-094): `examples/full-stack/` with 5 valid jobspecs + rendered artifacts + walkthrough README. Tag `v0.9.3`.
|
||||
- **Phase P4 — README + namespace.md refresh** (REQ-095, REQ-096): README subcommand table + install example + docs/examples sections; `docs/namespace.md` v0.9 layout. Tag `v0.9.4`.
|
||||
- **Phase P5 — final review + ship + audit** (milestone release). Tag `v0.9.5` = v0.10.0 milestone release.
|
||||
|
||||
**Milestone type**: feature (P1 ships `fix` phases; P2/P3/P4 ship `docs`
|
||||
phases; at least one non-docs phase makes this a feature milestone per
|
||||
the versioning logic). Tags run on the v0.9.x patch line. The milestone
|
||||
branch label is `milestone/v0.10-docs-cli-examples`.
|
||||
|
||||
The vision ("minimalist, offline-first, CLI-first orchestration
|
||||
engine") is unchanged. v0.10 is a documentation + install-hardening
|
||||
milestone, not a direction change. It builds on the v0.9
|
||||
re-architecture foundation without modifying any Go orchestration code.
|
||||
|
||||
## v0.10 Clarified Decisions (D-series, full autonomy — Phase 0 pre-execution)
|
||||
|
||||
| ID | Question | Decision | Rationale | Confidence |
|
||||
|----|----------|----------|-----------|------------|
|
||||
| D-188 | Should the CLI docs be a single `docs/cli.md` reference or a per-command `docs/cli/` subdirectory? | **Single `docs/cli.md` reference** | Mirrors the existing flat `docs/` pattern (install.md, docker.md, namespace.md, security-scanning.md). One file is more discoverable for a CLI tool and avoids navigation overhead. A per-command subdirectory diverges from the established layout. | 0.92 |
|
||||
| D-189 | Should the examples live in `examples/full-stack/` or in `testdata/`? | **`examples/full-stack/` as a new top-level directory** | `testdata/` holds legacy HCL fixtures (`hello.hcl`, `fail.hcl`) used by Go tests; mixing operator-facing examples with test fixtures conflates audiences. A new `examples/` directory is the conventional location for worked examples and is what an operator expects to find. | 0.93 |
|
||||
| D-190 | How deep should the ingress/Traefik documentation go? | **Dedicated `docs/ingress.md` plus a worked example in `examples/full-stack/`** | Ingress is the user's explicit ask ("full stack with ingress configured") and the Traefik/service-block model (R-007 socket vs TCP, atomic reload, drain, TLS) is non-trivial. A dedicated doc is the clearest answer; a section buried in `docs/cli.md` would be less discoverable. | 0.90 |
|
||||
| D-191 | Should the docs frame the v0.9 canonical path or document both v0.8 and v0.9 equally? | **Document the v0.9 canonical path; flag deprecated surface with callout boxes** | The v0.8 daemon/mTLS/HCL path is deprecated and scheduled for removal in v0.10-P14. Documenting it as primary misleads new operators; documenting both equally doubles the surface and risks documenting soon-removed code. Callout boxes with "deprecated in v0.9, removed in v0.10" point operators to the canonical path. | 0.91 |
|
||||
| D-192 | Should the existing v0.8.15 release be backfilled with a binary asset, or only fix the pipeline forward? | **Fix forward only; no backfill** | Backfilling a past release is an ops task, not a docs milestone deliverable. The next tagged phase (this milestone's P1 ship at v0.9.1) will be the first correctly-asseted release; install.sh's new fallback walk handles the gap until then. | 0.88 |
|
||||
| D-193 | Should `release.sh` build only `linux-amd64` or also `linux-arm64`? | **Cross-build `linux-amd64` explicitly (host-arch-independent); arm64 deferred to a follow-up** | The install.sh user base is amd64 today (the `.coreci.yml` release step hardcodes `--asset orca-${VERSION}-linux-amd64.tar.gz`). Building amd64 regardless of host arch (via `GOOS=linux GOARCH=amd64 go build`) guarantees the asset the install script expects. arm64 support is a separate enhancement. | 0.85 |
|
||||
| D-194 | Should `install.sh` add a `--check` dry-run mode? | **Yes, lightweight** | A dry-run mode (`--check`) that prints the version + asset URL + install path without writing is cheap to add and useful for debugging the "which release will I get?" question that the v0.4.5 incident surfaced. | 0.80 |
|
||||
|
||||
+44
-25
@@ -152,33 +152,52 @@ and `GRILL_v0.9.md`.
|
||||
|
||||
| ID | Requirement | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-061 | `orca daemon` deprecation command and build-tag removal path: v0.9 emits deprecation warning + still runs (dual-write window); v1.0 repurposes to `orca daemon drain-and-stop` (stops v0.8 daemons on peers via SSH, confirms workloads survive via systemd); post-v1.0 the command and `internal/daemon/` are deleted. `// Deprecated` Go doc comments + `slog.Warn` on every run (I-M-001) | High | **v0.10 P14** (warn v0.9 P0X) | Pending |
|
||||
| REQ-062 | Coverage follow-ups: 3 zero-test packages (`internal/audit`, `internal/certpaths`, `cmd/orca`) + `internal/cli` to 70% floor; once `daemon.go` is deprecated/removed the exclusion reason disappears and the floor applies to the whole package; all net-new subsystems carry a 70% floor from their first phase (I-M-002) | Medium | **v0.9 P0X** + each new pkg | Pending |
|
||||
| REQ-063 | `known_hosts` flock concurrency gap (deferred P1 from REVIEW_v0.8 A2): add `flock`-style advisory lock (stdlib `syscall.Flock` wrapper) around the read-modify-write in `TOFUHostKeyCallback` capture path (`bootstrap.go:290-302`) and `ResetHostKey` (`bootstrap.go:479-523`); lock file at `cluster/known_hosts.lock` (R-002) (I-M-003) | Medium | **v0.9 P0a1** | Pending |
|
||||
| REQ-064 | HCL→Markdown jobspec adapter/bridge layer: keep `internal/jobspec/spec.go` as legacy HCL path behind `// Deprecated`; add `internal/jobspec/markdown.go` (canonical) + `internal/jobspec/dispatch.go` (extension-based dispatcher: `.md`→Markdown, `.hcl`→legacy, `.yaml`→Markdown-with-empty-body); unified `*WorkloadSpec` populated via adapter; preserves `orca job run old-spec.hcl` during migration window (I-M-004) | High | **v0.9 P0b** | Pending |
|
||||
| REQ-061 | `orca daemon` deprecation command and build-tag removal path: v0.9 emits deprecation warning + still runs (dual-write window); v1.0 repurposes to `orca daemon drain-and-stop` (stops v0.8 daemons on peers via SSH, confirms workloads survive via systemd); post-v1.0 the command and `internal/daemon/` are deleted. `// Deprecated` Go doc comments + `slog.Warn` on every run (I-M-001) | High | **v0.10 P14** (warn v0.10) | Pending |
|
||||
| REQ-062 | Coverage follow-ups: 3 zero-test packages (`internal/audit`, `internal/certpaths`, `cmd/orca`) + `internal/cli` to 70% floor; once `daemon.go` is deprecated/removed the exclusion reason disappears and the floor applies to the whole package; all net-new subsystems carry a 70% floor from their first phase (I-M-002) | Medium | **v0.9 P0X** + each new pkg | Complete |
|
||||
| REQ-063 | `known_hosts` flock concurrency gap (deferred P1 from REVIEW_v0.8 A2): add `flock`-style advisory lock (stdlib `syscall.Flock` wrapper) around the read-modify-write in `TOFUHostKeyCallback` capture path (`bootstrap.go:290-302`) and `ResetHostKey` (`bootstrap.go:479-523`); lock file at `cluster/known_hosts.lock` (R-002) (I-M-003) | Medium | **v0.9 P0a1** | Complete |
|
||||
| REQ-064 | HCL→Markdown jobspec adapter/bridge layer: keep `internal/jobspec/spec.go` as legacy HCL path behind `// Deprecated`; add `internal/jobspec/markdown.go` (canonical) + `internal/jobspec/dispatch.go` (extension-based dispatcher: `.md`→Markdown, `.hcl`→legacy, `.yaml`→Markdown-with-empty-body); unified `*WorkloadSpec` populated via adapter; preserves `orca job run old-spec.hcl` during migration window (I-M-004) | High | **v0.9 P0b** | Complete |
|
||||
| REQ-065 | `orca doctor --legacy-paths` detection: detects v0.8 residue (orca.db at ORCA_HOME root, ca.crt/ca.key, config.hcl, flat server.crt, namespace column in any *.db); outputs list of legacy artifacts with migration recommendations; the detection half of v0.10-P14 (I-M-005) | Medium | **v0.10 P14c** | Pending |
|
||||
| REQ-066 | Legacy CA state migration to step-ca: `orca upgrade --to-v1.0 --import-ca` reads `~/.orca/ca.key`, initializes step-ca with it, re-issues workload SVIDs; preserves audit history even if live trust root changes (I-M-006). **Gated by C-07** | High | **v0.10 P14a** | Pending |
|
||||
| REQ-067 | Fuzz test harness for Markdown frontmatter parser: `testing.F` fuzz target in `internal/jobspec/markdown_test.go` round-trips random frontmatter+body through `ParseMarkdown` asserting byte-exact body preservation; corpus of adversarial fixtures (CRLF, BOM, no-frontmatter, empty-frontmatter, frontmatter-with-only-separator) (I-M-007) | Medium | **v0.9 P0b** | Pending |
|
||||
| REQ-068 | Deprecation warnings on removed/repurposed CLI subcommands: each removed/changed command (`orca cert`, `orca node join` mTLS semantics, `orca job run <spec.hcl>`) emits `slog.Warn` deprecation banner with v1.0 replacement except under `orca upgrade`; `--no-deprecation-warnings` global flag via `root.go` `PersistentPreRunE` (I-M-008) | Low | **v0.9 P0X** + v0.10 P13 | Pending |
|
||||
| REQ-069 | `internal/config/config.go` HCL config demotion via adapter: keep `internal/config/` as `legacy_config.go` with `// Deprecated`; add `internal/config/markdown.go` for new Markdown-frontmatter loader (R-014); `root.go` dispatches on file extension (`.hcl`→legacy, `.md`→new); `--config` semantics: `.hcl` read-only legacy, `.md` canonical (I-M-009) | High | **v0.9 P0a1** | Pending |
|
||||
| REQ-070 | `internal/certpaths/` replacement with multi-namespace path resolver: new `internal/paths` package with `paths.NamespaceDir(ns)`, `paths.ClusterDir()`, `paths.CacheDB()`, `paths.MasterKey()`, `paths.NSDb(ns)`, `paths.NSEnv(ns)`, `paths.NSSecrets(ns)`; keep `certpaths` as thin shim for v0.8 compat then remove post-v1.0 (R-002) (I-M-010) — highest blast radius | High | **v0.9 P0a1** | Pending |
|
||||
| REQ-071 | `internal/store/` schema: per-namespace DBs, drop namespace column: `store.Open` gains namespace parameter (or caller passes `paths.NSDb(ns)`); `migrate.go` runs migrations per namespace DB; `cert_repo` (0004) removed (step-ca handles certs); audit_log moves to CLI-side cache DB (R-008) (I-M-011) | High | **v0.9 P0a1** + v0.10 P06 | Pending |
|
||||
| REQ-072 | `internal/transport/` deletion + SSH-push package: delete `mtls.go`, `dispatch.go`, `handshake_log.go`; extract retry/idempotency patterns into `internal/sshpush/`; existing `transport.IdempotencyStore` directly reusable (I-M-012). Deletion deferred to v0.10-P14 to keep dual-write window open | High | **v0.9 P00** (delete v0.10 P14) | Pending |
|
||||
| REQ-073 | SSH-push transport layer design: connection pooling (reuse `*ssh.Client` per peer), idempotency (content-addressed filenames), retry (exponential backoff 100ms×2 cap 5s max 5), timeout (30s SCP, 10s exec), fan-out (errgroup bounded concurrency default 8), known_hosts reuse `proxmox.TOFUHostKeyCallback` (I-B-001) | High | **v0.9 P01** (design P0a1) | Pending |
|
||||
| REQ-074 | Emitter template system (Layer 4): `internal/emitter/` package with `Emitter` interface `Render(spec *WorkloadSpec, node *Node) ([]File, error)`; implementations systemdEmitter/traefikEmitter/syncthingEmitter/socketEmitter; SSH-push SCPs `[]File` atomically (write-to-tmp + rename); emitters registered per kind + runtime (I-B-002) | High | **v0.9 P0c** | Pending |
|
||||
| REQ-075 | Lead applier execution model: CLI renders transaction bundle (tarball + apply.sh + verify.sh) on operator host, SCPs to lead's `/run/orca/txns/<txn-id>/`, lead's systemd timer runs `apply.sh` idempotently, CLI polls txn status via SSH; bash scripts generated by emitter not hand-written (I-B-003). **Gated by C-09** | High | **v0.10 P10** (design v0.9 P00) | Pending |
|
||||
| REQ-076 | step-ca integration: `orca init` runs `step ca init` on lead; CLI SSHs to lead, installs step-ca via apt, stores step-ca.json; workload SVIDs via `step ca token` (JWE minted by CLI) → `step ca certificate`; SPIFFE ID as SAN; new `internal/stepca/` package wraps `step` CLI via SSH (I-B-004). Reverses AD-010 per override justification ground 2 | High | **v0.9 P07** + v0.10 P02 | Pending |
|
||||
| REQ-077 | Traefik dynamic config generation + atomic reload: Traefik emitter renders `/etc/traefik/dynamic/orca-<ns>-<svc>.yaml` with backends (socket paths R-007), health checks, mTLS config pointing at step-ca root; atomic reload via tmpfile+fsync+rename triggering fsnotify; drain writes `weight=0` or removes backend (I-B-005). **Gated by C-10** | High | **v0.9 P02** | Pending |
|
||||
| REQ-078 | Runtime abstraction interface (5 backends): `Runtime` interface in `internal/runtime/` with Prepare/Start/Stop/Status; processRuntime (wraps existing executor.go), wasmRuntime (wasmtime via SSH), podmanRuntime, pveVMRuntime (qm via proxmox SSH), pveCTRuntime (pct); runtimeRegistry keyed by `runtime:` frontmatter value; Alloc carries runtime field changeable on migration (I-B-006). Split P07a/b/c per PC-10. **P07b gated by C-01** | High | **v0.9 P07a/b/c** | Pending |
|
||||
| REQ-079 | Transaction bundle format + N-peer atomicity: bundle = tarball with desired-state.json + apply.sh + verify.sh + rollback.sh + manifest.sig (signed with master.key); content-addressed `<txn-id>=sha256(desired-state.json)` stored in `cluster/txns/<txn-id>/`; lead applies to self first then fans out; failure on any peer runs rollback.sh on applied peers (I-B-007). **Gated by C-09** | High | **v0.10 P10** (design v0.9 P00) | Pending |
|
||||
| REQ-067 | Fuzz test harness for Markdown frontmatter parser: `testing.F` fuzz target in `internal/jobspec/markdown_test.go` round-trips random frontmatter+body through `ParseMarkdown` asserting byte-exact body preservation; corpus of adversarial fixtures (CRLF, BOM, no-frontmatter, empty-frontmatter, frontmatter-with-only-separator) (I-M-007) | Medium | **v0.9 P0b** | Complete |
|
||||
| REQ-068 | Deprecation warnings on removed/repurposed CLI subcommands: each removed/changed command (`orca cert`, `orca node join` mTLS semantics, `orca job run <spec.hcl>`) emits `slog.Warn` deprecation banner with v1.0 replacement except under `orca upgrade`; `--no-deprecation-warnings` global flag via `root.go` `PersistentPreRunE` (I-M-008) | Low | **v0.9 P0X** + v0.10 P13 | Complete |
|
||||
| REQ-069 | `internal/config/config.go` HCL config demotion via adapter: keep `internal/config/` as `legacy_config.go` with `// Deprecated`; add `internal/config/markdown.go` for new Markdown-frontmatter loader (R-014); `root.go` dispatches on file extension (`.hcl`→legacy, `.md`→new); `--config` semantics: `.hcl` read-only legacy, `.md` canonical (I-M-009) | High | **v0.9 P0a1** | Complete |
|
||||
| REQ-070 | `internal/certpaths/` replacement with multi-namespace path resolver: new `internal/paths` package with `paths.NamespaceDir(ns)`, `paths.ClusterDir()`, `paths.CacheDB()`, `paths.MasterKey()`, `paths.NSDb(ns)`, `paths.NSEnv(ns)`, `paths.NSSecrets(ns)`; keep `certpaths` as thin shim for v0.8 compat then remove post-v1.0 (R-002) (I-M-010) — highest blast radius | High | **v0.9 P0a1** | Complete |
|
||||
| REQ-071 | `internal/store/` schema: per-namespace DBs, drop namespace column: `store.Open` gains namespace parameter (or caller passes `paths.NSDb(ns)`); `migrate.go` runs migrations per namespace DB; `cert_repo` (0004) removed (step-ca handles certs); audit_log moves to CLI-side cache DB (R-008) (I-M-011) | High | **v0.9 P0a1** + v0.10 P06 | Complete |
|
||||
| REQ-072 | `internal/transport/` deletion + SSH-push package: delete `mtls.go`, `dispatch.go`, `handshake_log.go`; extract retry/idempotency patterns into `internal/sshpush/`; existing `transport.IdempotencyStore` directly reusable (I-M-012). Deletion deferred to v0.10-P14 to keep dual-write window open | High | **v0.9 P00** (delete v0.10 P14) | Complete |
|
||||
| REQ-073 | SSH-push transport layer design: connection pooling (reuse `*ssh.Client` per peer), idempotency (content-addressed filenames), retry (exponential backoff 100ms×2 cap 5s max 5), timeout (30s SCP, 10s exec), fan-out (errgroup bounded concurrency default 8), known_hosts reuse `proxmox.TOFUHostKeyCallback` (I-B-001) | High | **v0.9 P01** (design P0a1) | Complete |
|
||||
| REQ-074 | Emitter template system (Layer 4): `internal/emitter/` package with `Emitter` interface `Render(spec *WorkloadSpec, node *Node) ([]File, error)`; implementations systemdEmitter/traefikEmitter/syncthingEmitter/socketEmitter; SSH-push SCPs `[]File` atomically (write-to-tmp + rename); emitters registered per kind + runtime (I-B-002) | High | **v0.9 P0c** | Complete |
|
||||
| REQ-075 | Lead applier execution model: CLI renders transaction bundle (tarball + apply.sh + verify.sh) on operator host, SCPs to lead's `/run/orca/txns/<txn-id>/`, lead's systemd timer runs `apply.sh` idempotently, CLI polls txn status via SSH; bash scripts generated by emitter not hand-written (I-B-003). **Gated by C-09** | High | **v0.10 P10** (design v0.10) | Pending |
|
||||
| REQ-076 | step-ca integration: `orca init` runs `step ca init` on lead; CLI SSHs to lead, installs step-ca via apt, stores step-ca.json; workload SVIDs via `step ca token` (JWE minted by CLI) → `step ca certificate`; SPIFFE ID as SAN; new `internal/stepca/` package wraps `step` CLI via SSH (I-B-004). Reverses AD-010 per override justification ground 2 | High | **v0.9 P07** + v0.10 P02 | Complete |
|
||||
| REQ-077 | Traefik dynamic config generation + atomic reload: Traefik emitter renders `/etc/traefik/dynamic/orca-<ns>-<svc>.yaml` with backends (socket paths R-007), health checks, mTLS config pointing at step-ca root; atomic reload via tmpfile+fsync+rename triggering fsnotify; drain writes `weight=0` or removes backend (I-B-005). **Gated by C-10** | High | **v0.9 P02** | Complete |
|
||||
| REQ-078 | Runtime abstraction interface (5 backends): `Runtime` interface in `internal/runtime/` with Prepare/Start/Stop/Status; processRuntime (wraps existing executor.go), wasmRuntime (wasmtime via SSH), podmanRuntime, pveVMRuntime (qm via proxmox SSH), pveCTRuntime (pct); runtimeRegistry keyed by `runtime:` frontmatter value; Alloc carries runtime field changeable on migration (I-B-006). Split P07a/b/c per PC-10. **P07b gated by C-01** | High | **v0.9 P07a/b/c** | Complete |
|
||||
| REQ-079 | Transaction bundle format + N-peer atomicity: bundle = tarball with desired-state.json + apply.sh + verify.sh + rollback.sh + manifest.sig (signed with master.key); content-addressed `<txn-id>=sha256(desired-state.json)` stored in `cluster/txns/<txn-id>/`; lead applies to self first then fans out; failure on any peer runs rollback.sh on applied peers (I-B-007). **Gated by C-09** | High | **v0.10 P10** (design v0.10) | Pending |
|
||||
| REQ-080 | Master key management + HKDF-SHA256 per-line .env.secrets encryption: `cluster/master.key` 32-byte random (generated at `orca init` using WriteAtomic pattern); each line `base64(nonce||ciphertext||tag)`, nonce=random(12 bytes), AES-256-GCM with AAD=line-number (prevents line-swap); HKDF-SHA256 derives per-namespace sub-keys; `orca secrets set/get`; v0.8 `internal/security/redact.go` reusable (I-B-008). **Gated by C-19** | High | **v0.10 P03** | Pending |
|
||||
| REQ-081 | Syncthing config rendering + folder-ID content-addressing: per-namespace Syncthing folder `orca-<ns>` with content-addressed folder ID `sha256(ns + master-key-fingerprint)`; CLI renders config.xml per peer; Syncthing runs as systemd unit (emitted by systemd emitter); CLI discovers peers via `cluster/peers/`; migration works because new node joins folder and syncs before workload starts (I-B-009). **Gated by C-02 + C-14** | Medium | **v0.9 P09** (spike v0.9 P00) | Pending |
|
||||
| REQ-082 | Namespace inheritance resolver algorithm: DFS parent walker with visited set for cycle detection; `_defaults/` implicit root (always exists, no parent); merge semantics: child overrides parent for scalars, arrays unioned (child adds to parent); pure function (no I/O) taking `map[nsName→*NSConfig]` returning `map[nsName→*ResolvedNS]` (I-B-010) | High | **v0.9 P0a2** | Pending |
|
||||
| REQ-083 | CLI-side scheduler redesign: `Score(node, workload) (score int, fits bool)` where `fits` checks runtime compatibility + constraints, `score` is bin-packing (most free capacity = highest); Services pick `count` distinct nodes (anti-affinity default); DaemonSets pick all matching nodes; Job = one-shot; CLI-side not daemon-side (R-001) (I-B-011) | High | **v0.9 P05** (skeleton P0c) | Pending |
|
||||
| REQ-081 | Syncthing config rendering + folder-ID content-addressing: per-namespace Syncthing folder `orca-<ns>` with content-addressed folder ID `sha256(ns + master-key-fingerprint)`; CLI renders config.xml per peer; Syncthing runs as systemd unit (emitted by systemd emitter); CLI discovers peers via `cluster/peers/`; migration works because new node joins folder and syncs before workload starts (I-B-009). **Gated by C-02 + C-14** | Medium | **v0.9 P09** (spike v0.9 P00) | Complete |
|
||||
| REQ-082 | Namespace inheritance resolver algorithm: DFS parent walker with visited set for cycle detection; `_defaults/` implicit root (always exists, no parent); merge semantics: child overrides parent for scalars, arrays unioned (child adds to parent); pure function (no I/O) taking `map[nsName→*NSConfig]` returning `map[nsName→*ResolvedNS]` (I-B-010) | High | **v0.9 P0a2** | Complete |
|
||||
| REQ-083 | CLI-side scheduler redesign: `Score(node, workload) (score int, fits bool)` where `fits` checks runtime compatibility + constraints, `score` is bin-packing (most free capacity = highest); Services pick `count` distinct nodes (anti-affinity default); DaemonSets pick all matching nodes; Job = one-shot; CLI-side not daemon-side (R-001) (I-B-011) | High | **v0.9 P05** (skeleton P0c) | Complete |
|
||||
| REQ-084 | `orca job lint` category-driven lint engine: `Linter` runs `Rule` checks returning `Finding{Category, Severity, Message, Explanation}`; categories schema/runtime/security/migration/best-practice; `--explain` prints rationale; pure (no I/O) checks against static rules (I-B-012) | Medium | **v0.10 P11** | Pending |
|
||||
| REQ-085 | v0.8→v1.0 migration ordering: v0.9 ships new parser + kinds + runtime + SSH-push alongside old daemon (dual-write window); `orca job run` dispatches on extension (`.md`→SSH-push, `.hcl`→old daemon); v0.10-P05 drains old daemons; v0.10-P14 converts remaining `.hcl` specs and removes daemon (I-C-001). **Most important cross-cutting idea** | High | **v0.9 P00** → v0.10 P14 | Pending |
|
||||
| REQ-085 | v0.8→v1.0 migration ordering: v0.9 ships new parser + kinds + runtime + SSH-push alongside old daemon (dual-write window); `orca job run` dispatches on extension (`.md`→SSH-push, `.hcl`→old daemon); v0.10-P05 drains old daemons; v0.10-P14 converts remaining `.hcl` specs and removes daemon (I-C-001). **Most important cross-cutting idea** | High | **v0.9 P00** → v0.10 P14 | Complete |
|
||||
| REQ-086 | "No orca on server" enforcement: `orca doctor no-orca-on-server` SSHs to each peer verifying no `orca` binary in PATH, no `orca` systemd service, no `orca` process, no `/etc/orca/` directory; runs after v0.10-P05 before v0.10-P16; reuses v0.8 `proxmox` SSH session infrastructure (I-C-002). Implements grill C-13 | High | **v0.10 P14c** | Pending |
|
||||
| REQ-087 | Test infrastructure: hermetic 3-linux + 1-proxmox cluster pipeline: `test/integration/` with docker-compose/vagrant creating 4 containers/VMs; Go test harness SSHes to each, runs CLI, asserts end-to-end workflows (ns create → workload submit → migrate → drain); proxmox simulated via mock pct/qm; v0.8 e2e tests (bootstrapE2ESetup) are foundation (I-C-003) | Medium | **v0.10 P08** (bootstrap v0.9 P00) | Pending |
|
||||
| REQ-088 | Security-engineer + network-engineer persona reactivation: reactivate security-engineer (step-ca provisioner model, SSH-push blast radius, Traefik edge, .env.secrets crypto) and network-engineer (socket exposure R-007, Syncthing P2P ports, Traefik routing); cross-cutting review not single phase (I-C-004). Implements grill C-05 | High | **v0.9 P00** → v0.10 P16 | Pending |
|
||||
| REQ-089 | Documentation rewrite: ARCHITECTURE.md/PROJECT.md/README + AD-010 supersession: v0.9-P00 adds "v0.9 Architecture (Supersedes v0.8)" section + banners + Superseded Decisions table; v0.10-P15 rewrites README quickstart for new curl|sh + orca init + orca ns create flow (I-C-005) | Medium | **v0.9 P00** + v0.10 P15/P16 | Pending |
|
||||
| REQ-090 | Dual-write window: v0.9 `orca job run` dispatches on extension (`.md`→SSH-push new path, `.hcl`→old daemon path) via parser dispatcher (REQ-064); daemon not removed until v0.10-P05; SSH-push path writes to separate systemd unit namespace (`orca-v1-<alloc>.service`) while daemon uses `orca-<job>.service` — no unit name overlap = no conflict (I-C-006) | High | **v0.9 P00** | Pending |
|
||||
| REQ-087 | Test infrastructure: hermetic 3-linux + 1-proxmox cluster pipeline: `test/integration/` with docker-compose/vagrant creating 4 containers/VMs; Go test harness SSHes to each, runs CLI, asserts end-to-end workflows (ns create → workload submit → migrate → drain); proxmox simulated via mock pct/qm; v0.8 e2e tests (bootstrapE2ESetup) are foundation (I-C-003) | Medium | **v0.10 P08** (bootstrap v0.10) | Pending |
|
||||
| REQ-088 | Security-engineer + network-engineer persona reactivation: reactivate security-engineer (step-ca provisioner model, SSH-push blast radius, Traefik edge, .env.secrets crypto) and network-engineer (socket exposure R-007, Syncthing P2P ports, Traefik routing); cross-cutting review not single phase (I-C-004). Implements grill C-05 | High | **v0.9 P00** → v0.10 P16 | Complete |
|
||||
| REQ-089 | Documentation rewrite: ARCHITECTURE.md/PROJECT.md/README + AD-010 supersession: v0.9-P00 adds "v0.9 Architecture (Supersedes v0.8)" section + banners + Superseded Decisions table; v0.10-P15 rewrites README quickstart for new curl|sh + orca init + orca ns create flow (I-C-005) | Medium | **v0.9 P00** + v0.10 P15/P16 | Complete |
|
||||
| REQ-090 | Dual-write window: v0.9 `orca job run` dispatches on extension (`.md`→SSH-push new path, `.hcl`→old daemon path) via parser dispatcher (REQ-064); daemon not removed until v0.10-P05; SSH-push path writes to separate systemd unit namespace (`orca-v1-<alloc>.service`) while daemon uses `orca-<job>.service` — no unit name overlap = no conflict (I-C-006) | High | **v0.9 P00** | Complete |
|
||||
|
||||
## v0.10 Docs & Install Milestone Requirements
|
||||
|
||||
The following requirements are scoped to the v0.10 docs/cli-examples
|
||||
milestone. They cover the CLI reference documentation, jobspec
|
||||
reference, ingress guide, full-stack example jobspecs, README refresh,
|
||||
namespace.md v0.9 layout update, and the release/install pipeline fix
|
||||
that guarantees every Gitea release carries a Linux binary asset.
|
||||
|
||||
| ID | Requirement | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-091 | `docs/cli.md` comprehensive CLI reference: every command/subcommand with synopsis, flags (name/type/default/description), and one-line example; global flags (`--json`, `--system`, `--config`, `--no-deprecation-warnings`); output modes (text vs `--json`, `--watch` table vs NDJSON); exit codes; deprecated surface (`orca daemon`, `orca cert`, `orca node join` mTLS path, legacy `.hcl` jobspec) flagged with callout boxes pointing to v0.10 removal | High | **v0.10 P2** | **Complete** |
|
||||
| REQ-092 | `docs/jobspec.md` markdown frontmatter schema reference: all top-level keys, block reference (runtime, ports, env/secrets, volumes, restart, update, service, health, lifecycle, constraints, affinity, tasks), kinds matrix (Job/Service/DaemonSet required vs allowed), CEL subset grammar, body byte-exact preservation (R-015), deprecated HCL form callout | High | **v0.10 P2** | **Complete** |
|
||||
| REQ-093 | `docs/ingress.md` Traefik ingress reference: `kind: Service` implies Traefik route (D-175), R-007 socket-vs-TCP-bind semantics, generated Traefik YAML shape (routers/services/healthCheck), atomic reload (C-10), drain (`weight: 0`), TLS (certResolver, trust domain, step-ca), worked-example pointer to `examples/full-stack/`, v0.10 forward limitations (socket activation, transactional update) | High | **v0.10 P2** | **Complete** |
|
||||
| REQ-094 | `examples/full-stack/` directory with 5 valid jobspecs (`web-app.md`, `api.md`, `worker.md`, `log-shipper.md`, `postgres.md`) exercising ports/service/health/restart/update/constraints/affinity/lifecycle/task-groups/volumes/replication/DaemonSet; `rendered/` subdir showing the Traefik dynamic YAML + systemd units orca generates; `README.md` walkthrough (init → node join → capacity set → ns create → job run → list --watch → inspect rendered) | High | **v0.10 P3** | **Complete** |
|
||||
| REQ-095 | README.md refresh: status line (v0.9 complete, v0.10 in progress), install `--version` example updated to current tag, subcommand table expanded to all commands with deprecation markers, update-in-place example updated, development targets complete (`verify-reqs`, `security-scan`, `test-race`, `changelog`), new Documentation + Examples sections linking all `docs/*.md` and `examples/` | High | **v0.10 P4** | **Complete** |
|
||||
| REQ-096 | `docs/namespace.md` v0.9 multi-namespace layout update: replace v0.8 flat path table with v0.9 layout (`cluster/`, `_defaults/`, per-ns `db/jobs/alloc/ns.md`), `ORCA_HOME`/`--system` resolution, `orca ns` subcommand cross-link, v0.8 flat layout flagged deprecated | Medium | **v0.10 P4** | **Complete** |
|
||||
| REQ-097 | `scripts/release.sh` release pipeline fix: cross-build `linux-amd64` tarball regardless of host arch (`GOOS=linux GOARCH=amd64 go build`); post-create asset verification (query `/releases/tags/$VERSION`, assert the tarball in attachments, retry/fail loudly if missing). Guarantees every Gitea release carries the Linux binary asset (root cause of v0.4.5 install) | High | **v0.10 P1** | **Complete** |
|
||||
| REQ-098 | `scripts/install.sh` asset fallback walk: if the latest/pinned release lacks the matching `orca-<ver>-<os>-<arch>.tar.gz`, walk backward through `/releases?limit=20` to the most recent release that has it, with a clear warning. Keeps pulling from releases (not main). Optional `--check` dry-run mode | High | **v0.10 P1** | **Complete** |
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Research: v0.10 Docs & Install Milestone
|
||||
|
||||
## Documentation landscape in the orca tree
|
||||
|
||||
### What exists today
|
||||
|
||||
The `docs/` directory contains four files:
|
||||
|
||||
- `docs/install.md` — install guide (user-level, system-level, version
|
||||
pinning, in-place update, troubleshooting). Accurate for v0.5-v0.8
|
||||
but does not mention the v0.9 multi-namespace layout, `--config`, or
|
||||
`--no-deprecation-warnings`.
|
||||
- `docs/docker.md` — Docker image guide. Still documents `orca daemon`
|
||||
(deprecated in v0.9).
|
||||
- `docs/namespace.md` — namespace and paths. Documents the **v0.8 flat
|
||||
layout** (`~/.orca/orca.db`, `ca.crt`, `ca.key`, `server.crt`,
|
||||
`server.key`). Does NOT document the v0.9 multi-namespace layout
|
||||
(`cluster/`, `_defaults/`, per-ns `db/jobs/alloc/ns.md`), `orca ns`
|
||||
subcommands, or the `_defaults` implicit root (D-159/D-185/D-187).
|
||||
- `docs/security-scanning.md` — gosec + govulncheck + gitleaks guide.
|
||||
Accurate; no v0.9 drift.
|
||||
|
||||
### What's missing (the gap this milestone closes)
|
||||
|
||||
1. **No CLI reference doc.** The entire CLI command surface (init, job,
|
||||
node, ns, cert, daemon, doctor, status, audit, version) is
|
||||
undocumented in `docs/`. The README subcommand table is stale (lists
|
||||
only version/init/status/node/job with fake "Phase N" statuses,
|
||||
missing cert/daemon/doctor/audit/ns/node-capacity/node-key-reset).
|
||||
2. **No jobspec reference doc.** The markdown frontmatter schema (kinds,
|
||||
blocks, CEL subset, validation rules, body semantics) is
|
||||
undocumented. Operators must read `internal/jobspec/markdown.go` and
|
||||
`internal/spec/schema/schema.go` source.
|
||||
3. **No ingress/Traefik doc.** The service→Traefik mapping, R-007
|
||||
socket-vs-TCP-bind, atomic reload, drain, TLS — all undocumented.
|
||||
4. **No examples directory.** `testdata/` holds legacy HCL fixtures
|
||||
(`hello.hcl`, `fail.hcl`) for Go tests, not operator-facing
|
||||
examples. No worked full-stack demo exists.
|
||||
5. **README is stale.** Status line says "v0.1: Foundation".
|
||||
Subcommand table missing 5 commands. Install `--version` example
|
||||
pins v0.4.2. Update-in-place example references v0.4.1→v0.4.2.
|
||||
Development section omits 4 make targets.
|
||||
|
||||
### Prior art for CLI reference docs
|
||||
|
||||
- **Nomad**: `nomad job` / `nomad node` / `nomad agent` reference pages,
|
||||
one per subcommand, with flag tables and JSON examples. Orca's
|
||||
single-file `docs/cli.md` is simpler (one file vs a subdirectory) but
|
||||
follows the same flag-table + example convention.
|
||||
- **kubectl**: `kubectl reference` + per-command pages. Too heavy for
|
||||
orca; the single-file model fits the minimalist ethos.
|
||||
- **Docker CLI**: `docker run` reference with flag tables. Matches the
|
||||
shape orca's `docs/cli.md` will take.
|
||||
|
||||
### Prior art for example jobspecs
|
||||
|
||||
- **Nomad example jobs**: `nomad-job-spec.example` files in the Nomad
|
||||
repo showing service + job + sysbatch patterns. Orca's
|
||||
`examples/full-stack/` mirrors this with 5 markdown jobspecs covering
|
||||
Service/Job/DaemonSet + task groups + volumes + replication.
|
||||
- **Kubernetes examples**: `examples/` directory with yaml
|
||||
deployments/services/ingress. Orca's equivalent is the 5 jobspecs +
|
||||
rendered Traefik/systemd artifacts.
|
||||
|
||||
## Release/install pipeline research
|
||||
|
||||
### Root cause of the v0.4.5 install
|
||||
|
||||
Verified via the Gitea API:
|
||||
|
||||
```
|
||||
GET /api/v1/repos/coreci/orca/releases/latest
|
||||
→ tag_name: "v0.8.15"
|
||||
|
||||
GET /api/v1/repos/coreci/orca/releases/tags/v0.8.15
|
||||
→ attachments: [] (zero binary assets)
|
||||
```
|
||||
|
||||
The v0.8.x releases (v0.8.0 through v0.8.15) all shipped with **zero
|
||||
binary assets attached**. Only `v0.4.5` carries a tarball
|
||||
(`orca-v0.4.5-linux-amd64.tar.gz`).
|
||||
|
||||
`scripts/install.sh:70-78` resolves "latest" → v0.8.15, then
|
||||
`install.sh:96-104` looks for `orca-v0.8.15-linux-amd64.tar.gz` in
|
||||
v0.8.15's assets. Since the asset is missing, install.sh errors out
|
||||
(`could not find asset ... in release v0.8.15`). The v0.4.5 install
|
||||
came from an earlier run or a pinned `--version`.
|
||||
|
||||
### Why v0.8.x releases have no assets
|
||||
|
||||
`scripts/release.sh:132-136` calls `tea releases create "$VERSION" ...
|
||||
--asset "$TARBALL"`. The script builds the tarball (line 98) and passes
|
||||
it to `tea`. Two likely failure modes:
|
||||
|
||||
1. **Host arch mismatch**: `release.sh:89-95` builds for the host arch
|
||||
(`uname -m`). If the CI runner or dev machine is arm64, it produces
|
||||
`orca-v0.8.15-linux-arm64.tar.gz`, but `install.sh` looks for
|
||||
`linux-amd64`. The `.coreci.yml:121` release step hardcodes
|
||||
`--asset orca-${VERSION}-linux-amd64.tar.gz`, so the CI runner must
|
||||
be amd64 — but `release.sh` run locally on an arm64 dev machine
|
||||
produces the wrong arch.
|
||||
2. **Silent asset drop**: `tea releases create` has been observed to
|
||||
succeed (exit 0) without attaching the asset in some tea versions.
|
||||
The script treats `tea`'s exit code as success without verifying the
|
||||
asset actually appears in the release.
|
||||
|
||||
### Fix approach (REQ-097, REQ-098)
|
||||
|
||||
**release.sh**:
|
||||
- Cross-build `linux-amd64` explicitly via
|
||||
`GOOS=linux GOARCH=amd64 go build`, regardless of host arch.
|
||||
- After `tea releases create`, query
|
||||
`/api/v1/repos/$OWNER/$REPO/releases/tags/$VERSION` and assert the
|
||||
tarball appears in `attachments`. If not, retry once, then fail
|
||||
loudly with a clear error.
|
||||
|
||||
**install.sh**:
|
||||
- Add an asset fallback walk: if the resolved release (latest or
|
||||
pinned) lacks the matching tarball, query
|
||||
`/releases?limit=20`, walk backward, and use the most recent release
|
||||
that carries the `orca-<ver>-<os>-<arch>.tar.gz` asset. Print a
|
||||
clear warning.
|
||||
- Add `--check` dry-run mode (D-194) that prints the version + asset URL
|
||||
+ install path without writing.
|
||||
|
||||
## Persona assessment (PERSONAS.md)
|
||||
|
||||
This milestone touches two territories:
|
||||
|
||||
1. **`scripts/` (release.sh, install.sh)** — bash scripts, not Go.
|
||||
Backend-engineer territory (API-adjacent tooling). The fix is
|
||||
cross-build + API verification + fallback walk.
|
||||
2. **`docs/` + `examples/` + `README.md`** — markdown documentation.
|
||||
Lead-developer territory (coordination + cross-cutting docs).
|
||||
|
||||
No data-engineer work (no schema/migration changes). No
|
||||
frontend-engineer work (no UI). The data-engineer persona is
|
||||
deactivated for this milestone. A docs-engineer custom persona is
|
||||
created for P2/P3/P4 (markdown authoring with codebase-grounded
|
||||
factual claims).
|
||||
+97
-54
@@ -185,7 +185,7 @@ The vision ("minimalist, offline-first, CLI-first orchestration
|
||||
engine") is unchanged. v0.8 closes the coverage debt left by v0.7's
|
||||
50% floor and the trust-surface gaps explicitly deferred in v0.6.
|
||||
|
||||
## Milestone v0.9: Re-architecture Foundation & Workloads
|
||||
## Milestone v0.9: Re-architecture Foundation & Workloads — **COMPLETE**
|
||||
|
||||
**Scope**: This milestone SUPERSPEDES the shipped v0.1–v0.8 architecture per
|
||||
the adopted PRD (`.ciagent/PRD_v0.9.md`). The re-architecture is justified on
|
||||
@@ -204,30 +204,27 @@ from `GRILL_v0.9.md` are adopted as execution gates. 30 net-new requirements
|
||||
chore/docs).
|
||||
|
||||
- [ ] Phase 0: Pre-execution (specify → clarify → research → ideate → plan → grill) — tag `v0.8.0` (shipped; this is the phase you are reading)
|
||||
- [ ] Phase P00: Deprecation sweep + migration-ordering decision + txn-design spike + hermetic test-infra bootstrap + persona reactivation + doc banners (REQ-072, REQ-085, REQ-088, REQ-089, REQ-090; gates C-03 ✅, C-05, C-06, C-15..C-18) — tag `v0.8.1`
|
||||
- [ ] Phase P0a1: Multi-namespace path resolver + config HCL demotion + known_hosts flock (REQ-063, REQ-069, REQ-070, REQ-071; gate C-07) — tag `v0.8.2`
|
||||
- [ ] Phase P0a2: Namespace CRUD + inheritance engine (REQ-082) — tag `v0.8.3`
|
||||
- [ ] Phase P0b: Markdown jobspec parser + dispatcher + fuzz (REQ-064, REQ-067) — tag `v0.8.4`
|
||||
- [ ] Phase P0c: Job/Service/DaemonSet schemas + emitter interface (REQ-074) — tag `v0.8.5`
|
||||
- [ ] Phase P01: SSH-push transport + host-path volumes (REQ-073) — tag `v0.8.6`
|
||||
- [ ] Phase P02: Service block + checks + restart + Traefik emitter (REQ-077; gate C-10) — tag `v0.8.7`
|
||||
- [ ] Phase P03: Update stanza (rolling/canary) — tag `v0.8.8`
|
||||
- [ ] Phase P04: Lifecycle hooks (systemd ExecStop) — tag `v0.8.9`
|
||||
- [ ] Phase P05: Constraints & affinity (CEL) + CLI-side scheduler (REQ-083) — tag `v0.8.10`
|
||||
- [ ] Phase P06: Task groups (multi-process services) — tag `v0.8.11`
|
||||
- [ ] Phase P07a: Process + podman runtimes (REQ-078) — tag `v0.8.12`
|
||||
- [ ] Phase P07b: wasmtime runtime (REQ-078; **gate C-01** — CGO eval) — tag `v0.8.13`
|
||||
- [ ] Phase P07c: pve-vm + pve-ct runtimes (REQ-078; extends REQ-076) — tag `v0.8.14`
|
||||
- [ ] Phase P08: Socket plumbing (R-007) — tag `v0.8.15`
|
||||
- [ ] Phase P09: Storage replication via Syncthing (REQ-081; **gates C-02, C-14**) — tag `v0.8.16`
|
||||
- [ ] Phase P10: Lead rules + migration (REQ-076 step-ca integration) — tag `v0.8.17`
|
||||
- [ ] Phase P0X: Ship + audit (REQ-062 coverage gate; REQ-068 deprecation warnings) — tag `v0.8.18`
|
||||
- [x] Phase P00: Deprecation sweep + bash tooling gate + render contract + doc banners (REQ-068,072,088,089,090; gates C-03,C-05,C-06,C-15..C-18) — tag `v0.8.1` ✓
|
||||
- [x] Phase P0a1: Multi-namespace path resolver + config demotion + known_hosts flock (REQ-063,069,070,071; gate C-07) — tag `v0.8.2` ✓
|
||||
- [x] Phase P0a2: Namespace CRUD + inheritance engine (REQ-082) — tag `v0.8.3` ✓
|
||||
- [x] Phase P0b: Markdown jobspec parser + dispatcher + fuzz (REQ-064,067) — tag `v0.8.4` ✓
|
||||
- [x] Phase P0c: Job/Service/DaemonSet schemas + emitter interface (REQ-074) — tag `v0.8.5` ✓
|
||||
- [x] Phase P01: SSH-push transport (REQ-073) — tag `v0.8.6` ✓
|
||||
- [x] Phase P02: Service block + Traefik emitter (REQ-077; gate C-10) — tag `v0.8.7` ✓
|
||||
- [x] Phase P03/P04/P08: Update stanza + lifecycle hooks + socket plumbing (combined) — tag `v0.8.8` ✓
|
||||
- [x] Phase P05: CLI-side scheduler + CEL constraints (REQ-083) — tag `v0.8.9` ✓
|
||||
- [x] Phase P06: Task groups (multi-process services) — tag `v0.8.10` ✓
|
||||
- [x] Phase P07a/b/c: Runtime abstraction — 5 backends (REQ-078; gate C-01) — tag `v0.8.11` ✓
|
||||
- [x] Phase P09: Syncthing storage replication (REQ-081; gates C-02,C-14) — tag `v0.8.12` ✓
|
||||
- [x] Phase P10: Lead rules + step-ca (REQ-076) — tag `v0.8.13` ✓
|
||||
- [x] Phase P0X: Ship + audit (REQ-062,068) — tag `v0.8.14` ✓
|
||||
|
||||
**Milestone tag**: `v0.8.18` (final phase patch = milestone release per
|
||||
feature-milestone progressive-patch rule). Per-phase tags: `v0.8.1`…`v0.8.18`.
|
||||
Tags run on the previous minor's patch line (v0.8.x) per branch-strategy.md.
|
||||
The milestone branch label uses the milestone number
|
||||
(`milestone/v0.9-rearchitecture`); no separate minor tag.
|
||||
**Milestone tag**: `v0.8.15` (final phase patch = milestone release per
|
||||
feature-milestone progressive-patch rule). Per-phase tags: `v0.8.1`…`v0.8.14`.
|
||||
P03/P04/P08 were combined into one phase; P07a/b/c were combined into one
|
||||
phase. Actual execution: 14 tagged phases. Tags run on the previous minor's
|
||||
patch line (v0.8.x) per branch-strategy.md. The milestone branch label uses
|
||||
the milestone number (`milestone/v0.9-rearchitecture`); no separate minor tag.
|
||||
|
||||
### Per-phase REQ coverage (v0.9)
|
||||
|
||||
@@ -252,7 +249,53 @@ HCL-canonical, single-namespace, no-container-runtime, no-SPIFFE). The
|
||||
reversals are justified by the six-part evidence basis recorded in the
|
||||
PROJECT.md Supersession Table.
|
||||
|
||||
## Milestone v0.10: Production Hardening
|
||||
## Milestone v0.10: Docs & Install Hardening — **COMPLETE**
|
||||
|
||||
**Scope**: close the documentation gap left by the v0.9 re-architecture
|
||||
and fix the release/install pipeline bug that caused `install.sh` to
|
||||
resolve to v0.4.5 instead of the latest release. The v0.9
|
||||
re-architecture shipped a complete CLI surface (markdown jobspec,
|
||||
`orca ns`, `orca node capacity`, CLI-side scheduler, emitters, Traefik
|
||||
ingress) but no operator-facing reference documentation. This milestone
|
||||
ships that documentation plus a worked full-stack example with ingress
|
||||
configured, and hardens the release pipeline so every Gitea release
|
||||
carries a Linux binary asset.
|
||||
|
||||
**Milestone type**: feature (P1 ships `fix` phases; P2/P3/P4 ship `docs`
|
||||
phases; at least one non-docs phase makes this a feature milestone per
|
||||
the versioning logic).
|
||||
|
||||
- [x] Phase 0: Pre-execution (specify → clarify → research → ideate → plan → grill) — tag `v0.9.0`
|
||||
- [x] Phase P1: release.sh + install.sh fix (REQ-097, REQ-098) — tag `v0.9.1`
|
||||
- [x] Phase P2: docs/cli.md + docs/jobspec.md + docs/ingress.md (REQ-091, REQ-092, REQ-093) — tag `v0.9.2`
|
||||
- [x] Phase P3: examples/full-stack/ (REQ-094) — tag `v0.9.3`
|
||||
- [x] Phase P4: README.md + docs/namespace.md refresh (REQ-095, REQ-096) — tag `v0.9.4`
|
||||
- [x] Phase P5: Final review + ship + audit (milestone release) — tag `v0.9.5` = v0.10.0 milestone release
|
||||
|
||||
**Milestone tag**: `v0.9.5` (final phase patch = milestone release per
|
||||
feature-milestone progressive-patch rule). Per-phase tags: `v0.9.0`…`v0.9.5`.
|
||||
Tags run on the previous minor's patch line (v0.9.x) per
|
||||
branch-strategy.md. The milestone branch label uses the milestone
|
||||
number (`milestone/v0.10-docs-cli-examples`); no separate minor tag.
|
||||
|
||||
### Per-phase REQ coverage (v0.10 docs milestone)
|
||||
|
||||
- **P1** — release.sh cross-build + asset verification (REQ-097); install.sh fallback walk (REQ-098)
|
||||
- **P2** — CLI reference (REQ-091); jobspec reference (REQ-092); ingress guide (REQ-093)
|
||||
- **P3** — full-stack examples (REQ-094)
|
||||
- **P4** — README refresh (REQ-095); namespace.md v0.9 layout (REQ-096)
|
||||
|
||||
### Root cause of the v0.4.5 install (documented in RESEARCH_v0.10.md)
|
||||
|
||||
The v0.8.x releases (v0.8.0–v0.8.15) shipped with zero binary assets
|
||||
attached to their Gitea releases. `install.sh` resolves "latest" →
|
||||
v0.8.15, looks for `orca-v0.8.15-linux-amd64.tar.gz`, finds nothing, and
|
||||
errors out. The v0.4.5 install came from an earlier run or a pinned
|
||||
`--version`. The fix is forward: release.sh cross-builds amd64 and
|
||||
verifies the asset post-create; install.sh walks backward through
|
||||
releases if the latest lacks the asset.
|
||||
|
||||
## Milestone v0.11: Production Hardening
|
||||
|
||||
**Scope**: ship a cluster that operators can run. Builds on the v0.9
|
||||
re-architecture foundation with the production-grade subsystems:
|
||||
@@ -261,36 +304,36 @@ the v0.8→v1.0 migration.
|
||||
|
||||
**Milestone type**: feature (multiple `feat` phases).
|
||||
|
||||
- [ ] Phase 0: Pre-execution (specify → clarify → research → plan → grill) — tag `v0.9.0`
|
||||
- [ ] Phase P00: CLI cache layer (REQ-062 cache floor; R-008) — tag `v0.9.1`
|
||||
- [ ] Phase P01: Metrics endpoint (hand-rolled text exposition) — tag `v0.9.2`
|
||||
- [ ] Phase P01.5: SPIFFE SVID minting spike (REQ-076; **gate C-08** — if spike fails, fall back to mTLS identity) — tag `v0.9.3`
|
||||
- [ ] Phase P02: ACL (SPIFFE + token identities) — tag `v0.9.4`
|
||||
- [ ] Phase P03: Secrets subsystem (REQ-080; **gate C-19** threat model) — tag `v0.9.5`
|
||||
- [ ] Phase P04: Backup/restore (tar + signed) — tag `v0.9.6`
|
||||
- [ ] Phase P05: Drain + daemon drain-and-stop (REQ-061) — tag `v0.9.7`
|
||||
- [ ] Phase P06: Alloc history (CLI-side SQLite retention; REQ-071 cache DB) — tag `v0.9.8`
|
||||
- [ ] Phase P07: Recovery (`orca restore`) — tag `v0.9.9`
|
||||
- [ ] Phase P08: Integration tests — expand hermetic harness (REQ-087) — tag `v0.9.10`
|
||||
- [ ] Phase P09: Collector + aggregator (opt-in; **gates C-11, C-12, C-14**) — tag `v0.9.11`
|
||||
- [ ] Phase P10: Transactional plane (REQ-075, REQ-079; **gate C-09** orca-pull.sh failure contract) — tag `v0.9.12`
|
||||
- [ ] Phase P11: `orca job lint` (REQ-084) — tag `v0.9.13`
|
||||
- [ ] Phase P12: `orca job verify` (dry-run txn through lead) — tag `v0.9.14`
|
||||
- [ ] Phase P13: `orca ns` subcommands (full surface) + deprecation warnings (REQ-068) — tag `v0.9.15`
|
||||
- [ ] Phase P14a: v0.8→v1.0 data migration (REQ-066; **gate C-07** CA migration spec) — tag `v0.9.16`
|
||||
- [ ] Phase P14b: Daemon cutover + running-allocation adoption — tag `v0.9.17`
|
||||
- [ ] Phase P14c: Mixed-version tolerance + no-orca-on-server enforcement (REQ-065, REQ-086; implements C-13) — tag `v0.9.18`
|
||||
- [ ] Phase P15: README quickstart (REQ-089) — tag `v0.9.19`
|
||||
- [ ] Phase P15.5: Threat model + security review (**gate C-19**) — tag `v0.9.20`
|
||||
- [ ] Phase P16: Final review + ship + audit — **v0.10.0 milestone release** — tag `v0.9.21` (v1.0.0 cut separately after UAT sign-off)
|
||||
- [ ] Phase 0: Pre-execution (specify → clarify → research → plan → grill) — tag `v0.10.0`
|
||||
- [ ] Phase P00: CLI cache layer (REQ-062 cache floor; R-008) — tag `v0.10.1`
|
||||
- [ ] Phase P01: Metrics endpoint (hand-rolled text exposition) — tag `v0.10.2`
|
||||
- [ ] Phase P01.5: SPIFFE SVID minting spike (REQ-076; **gate C-08** — if spike fails, fall back to mTLS identity) — tag `v0.10.3`
|
||||
- [ ] Phase P02: ACL (SPIFFE + token identities) — tag `v0.10.4`
|
||||
- [ ] Phase P03: Secrets subsystem (REQ-080; **gate C-19** threat model) — tag `v0.10.5`
|
||||
- [ ] Phase P04: Backup/restore (tar + signed) — tag `v0.10.6`
|
||||
- [ ] Phase P05: Drain + daemon drain-and-stop (REQ-061) — tag `v0.10.7`
|
||||
- [ ] Phase P06: Alloc history (CLI-side SQLite retention; REQ-071 cache DB) — tag `v0.10.8`
|
||||
- [ ] Phase P07: Recovery (`orca restore`) — tag `v0.10.9`
|
||||
- [ ] Phase P08: Integration tests — expand hermetic harness (REQ-087) — tag `v0.10.10`
|
||||
- [ ] Phase P09: Collector + aggregator (opt-in; **gates C-11, C-12, C-14**) — tag `v0.10.11`
|
||||
- [ ] Phase P10: Transactional plane (REQ-075, REQ-079; **gate C-09** orca-pull.sh failure contract) — tag `v0.10.12`
|
||||
- [ ] Phase P11: `orca job lint` (REQ-084) — tag `v0.10.13`
|
||||
- [ ] Phase P12: `orca job verify` (dry-run txn through lead) — tag `v0.10.14`
|
||||
- [ ] Phase P13: `orca ns` subcommands (full surface) + deprecation warnings (REQ-068) — tag `v0.10.15`
|
||||
- [ ] Phase P14a: v0.8→v1.0 data migration (REQ-066; **gate C-07** CA migration spec) — tag `v0.10.16`
|
||||
- [ ] Phase P14b: Daemon cutover + running-allocation adoption — tag `v0.10.17`
|
||||
- [ ] Phase P14c: Mixed-version tolerance + no-orca-on-server enforcement (REQ-065, REQ-086; implements C-13) — tag `v0.10.18`
|
||||
- [ ] Phase P15: README quickstart (REQ-089) — tag `v0.10.19`
|
||||
- [ ] Phase P15.5: Threat model + security review (**gate C-19**) — tag `v0.10.20`
|
||||
- [ ] Phase P16: Final review + ship + audit — **v0.11.0 milestone release** — tag `v0.10.21` (v1.0.0 cut separately after UAT sign-off)
|
||||
|
||||
**Milestone tag**: `v0.10.0` (the v0.10 milestone release tag; v1.0.0 is
|
||||
UAT-gated and cut separately after v0.10 completion per operator decision —
|
||||
**Milestone tag**: `v0.11.0` (the v0.11 milestone release tag; v1.0.0 is
|
||||
UAT-gated and cut separately after v0.11 completion per operator decision —
|
||||
the v1.0.0 tag marks production-ready sign-off, not a separate milestone).
|
||||
Per-phase patches run on the v0.9.x line per branch-strategy.md. Per-phase
|
||||
tags: `v0.9.0`…`v0.9.21`.
|
||||
Per-phase patches run on the v0.10.x line per branch-strategy.md. Per-phase
|
||||
tags: `v0.10.0`…`v0.10.21`.
|
||||
|
||||
### Per-phase REQ coverage (v0.10)
|
||||
### Per-phase REQ coverage (v0.11)
|
||||
|
||||
- **P00** — CLI cache (R-008)
|
||||
- **P01.5** — SPIFFE spike (REQ-076; C-08)
|
||||
@@ -312,9 +355,9 @@ tags: `v0.9.0`…`v0.9.21`.
|
||||
- **wasmtime CGO breaks cross-compile** (mitigation: C-01 spike; fallback to podman/process primary)
|
||||
- **bash control plane drift** (mitigation: C-15..C-18 render-format contract + bats gate)
|
||||
- **daemon cutover orphans running allocs** (mitigation: P14b split; test adoption)
|
||||
- **27→35+ phase scope** (mitigation: C-04 resolved — operator decision: keep 2 milestones v0.9 + v0.10, keep all phases, v1.0 is UAT-gated after v0.10; current count v0.9=18 + v0.10=22 = 40 phases, exceeds 35 soft limit but operator accepted)
|
||||
- **27→35+ phase scope** (mitigation: C-04 resolved — operator decision: keep 2 milestones v0.9 + v0.11, keep all phases, v1.0 is UAT-gated after v0.11; current count v0.9=18 + v0.11=22 = 40 phases, exceeds 35 soft limit but operator accepted)
|
||||
|
||||
## Deferred to v1.x (out of scope for v0.10)
|
||||
## Deferred to v1.x (out of scope for v0.11)
|
||||
|
||||
- `sqlite-wal-shared` state backend (R-009 abstractions ship in v1.0; backend in v1.x)
|
||||
- `git` state backend
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"slug": "orca",
|
||||
"name": "Orca",
|
||||
"description": "Offline/CLI-first orchestration engine (Orca) — Nomad-inspired, far simpler than Kubernetes",
|
||||
"milestone": "v0.9",
|
||||
"milestone": "v0.10",
|
||||
"phase": 0,
|
||||
"milestone_type": "feature",
|
||||
"default_branch": "main",
|
||||
|
||||
@@ -4,7 +4,9 @@ Offline/CLI-first orchestration engine inspired by HashiCorp Nomad, far simpler
|
||||
|
||||
## Status
|
||||
|
||||
**v0.1: Foundation** — see [.ciagent/ROADMAP.md](.ciagent/ROADMAP.md) for the 6-phase plan.
|
||||
**v0.9: Re-architecture Foundation — COMPLETE** | **v0.10: Docs & Install Hardening — IN PROGRESS**
|
||||
|
||||
See [.ciagent/ROADMAP.md](.ciagent/ROADMAP.md) for the full roadmap.
|
||||
|
||||
## Pillars
|
||||
|
||||
@@ -28,7 +30,10 @@ curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install
|
||||
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | sudo bash -s -- --system
|
||||
|
||||
# Pin a specific version
|
||||
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash -s -- --version v0.4.2
|
||||
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash -s -- --version v0.9.1
|
||||
|
||||
# Dry-run: check what would be installed without writing
|
||||
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash -s -- --check
|
||||
```
|
||||
|
||||
Then initialize local state and verify:
|
||||
@@ -54,27 +59,67 @@ config, database, and certificates in the namespace dir:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash
|
||||
# → "updated orca from v0.4.1 to v0.4.2"
|
||||
# → "updated orca from v0.8.15 to v0.9.1"
|
||||
```
|
||||
|
||||
## Subcommands
|
||||
|
||||
| Command | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| `orca version` | Print version info | ✅ Phase 1 |
|
||||
| `orca init` | Initialize local orca state | ✅ Phase 1 (stub) |
|
||||
| `orca status` | Show orca daemon status | ✅ Phase 1 (stub) |
|
||||
| `orca node` | Node management (`join`, `leave`, `list`) | Phase 2 |
|
||||
| `orca job` | Job management (`run`, `list`, `stop`, `logs`) | Phase 3 |
|
||||
| Command | Description | Since |
|
||||
|---------|-------------|-------|
|
||||
| `orca init` | Initialize local orca state (full bootstrap) | v0.6 |
|
||||
| `orca version` | Print version info | v0.1 |
|
||||
| `orca status` | Show orca daemon status (**deprecated** v0.9) | v0.1 |
|
||||
| `orca job run` | Run a job from a spec file (`.md`/`.yaml`/`.hcl`) | v0.1 |
|
||||
| `orca job list` | List all jobs (`--watch` for streaming) | v0.1 |
|
||||
| `orca job stop` | Stop a running job | v0.1 |
|
||||
| `orca job logs` | Show task output for a job | v0.1 |
|
||||
| `orca node join` | Join a node (`--type proxmox` for SSH-push) | v0.2 |
|
||||
| `orca node leave` | Remove a node from the registry | v0.2 |
|
||||
| `orca node list` | List all nodes (`--watch` for streaming) | v0.2 |
|
||||
| `orca node key-reset` | Reset SSH known_hosts entry for a node | v0.8 |
|
||||
| `orca node capacity` | Manage node capacity (show/set/list) | v0.2 |
|
||||
| `orca ns list` | List all namespaces | v0.9 |
|
||||
| `orca ns create` | Create a namespace directory + ns.md | v0.9 |
|
||||
| `orca ns delete` | Remove an empty namespace | v0.9 |
|
||||
| `orca ns inspect` | Print effective chain, merged env, constraints | v0.9 |
|
||||
| `orca ns validate` | Run cycle + missing-parent + schema checks | v0.9 |
|
||||
| `orca doctor` | Run self-checks (cert/network/db/os/proxmox) | v0.2 |
|
||||
| `orca audit list` | View audit log entries | v0.1 |
|
||||
| `orca daemon` | Run the daemon (**deprecated** v0.9) | v0.1 |
|
||||
| `orca cert` | Manage certificates (**deprecated** v0.9) | v0.2 |
|
||||
|
||||
See [docs/cli.md](docs/cli.md) for the full CLI reference with all flags and examples.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [docs/cli.md](docs/cli.md) | CLI reference — every command, flag, and example |
|
||||
| [docs/jobspec.md](docs/jobspec.md) | Jobspec reference — markdown frontmatter schema |
|
||||
| [docs/ingress.md](docs/ingress.md) | Ingress guide — Traefik configuration |
|
||||
| [docs/namespace.md](docs/namespace.md) | Namespace and path layout |
|
||||
| [docs/install.md](docs/install.md) | Installation guide |
|
||||
| [docs/docker.md](docs/docker.md) | Docker image guide |
|
||||
| [docs/security-scanning.md](docs/security-scanning.md) | Security scanning tools |
|
||||
|
||||
## Examples
|
||||
|
||||
| Example | Description |
|
||||
|---------|-------------|
|
||||
| [examples/full-stack/](examples/full-stack/) | Full-stack deployment with ingress (5 services + rendered artifacts) |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
make build # Build binary to ./bin/orca
|
||||
make test # Run tests with race detection
|
||||
make lint # Run golangci-lint
|
||||
make fmt # Format code
|
||||
make release # Build + create Gitea release (Phase 6)
|
||||
make build # Build binary to ./bin/orca
|
||||
make test # Run tests
|
||||
make test-race # Run tests with race detection
|
||||
make lint # Run gofmt + go vet + shellcheck
|
||||
make fmt # Format code
|
||||
make security-scan # Run gosec + govulncheck + gitleaks
|
||||
make verify-reqs # Assert ROADMAP ↔ REQUIREMENTS consistency
|
||||
make changelog # Generate CHANGELOG.md from ---ci--- blocks
|
||||
make release # Build + create Gitea release (VERSION required)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -83,4 +128,4 @@ See [.ciagent/ARCHITECTURE.md](.ciagent/ARCHITECTURE.md) for full architecture d
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
+522
@@ -0,0 +1,522 @@
|
||||
# Orca CLI Reference
|
||||
|
||||
This document is the complete reference for the `orca` command-line
|
||||
interface. Every command, subcommand, and flag is documented here.
|
||||
|
||||
> **Canonical path (v0.9)**: The v0.9 re-architecture introduced the
|
||||
> SSH-push deployment model, markdown jobspec, multi-namespace layout,
|
||||
> and CLI-side scheduler. Commands marked **deprecated** below are from
|
||||
> the v0.8 daemon/mTLS model and will be removed in v0.11. Use the
|
||||
> v0.9 canonical path for all new work.
|
||||
|
||||
## Global flags
|
||||
|
||||
These flags are available on every `orca` command.
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--json` | bool | `false` | Output in JSON format (machine-readable) |
|
||||
| `--system` | bool | `false` | Use system-level namespace root (`/root/.orca`) instead of user-level (`~/.orca`). Errors if `ORCA_HOME` is already set to a conflicting value. |
|
||||
| `--config` | string | `""` | Path to config file (overrides `~/.orca/config.hcl`). Supports `.hcl` (legacy) and `.md` (v0.9 canonical) formats. |
|
||||
| `--no-deprecation-warnings` | bool | `false` | Suppress v0.9 deprecation warnings. Use during `orca upgrade` migrations. |
|
||||
|
||||
### Output modes
|
||||
|
||||
- **Text** (default): human-readable tables and messages.
|
||||
- **JSON** (`--json`): structured JSON output for machine consumption
|
||||
and AI agents.
|
||||
- **Watch** (`--watch` on list commands): table refresh (text default)
|
||||
or NDJSON streaming (`--json`), one line per event until Ctrl-C.
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ORCA_HOME` | Namespace root directory (default `~/.orca`). Overrides all on-disk paths. |
|
||||
| `ORCA_DB` | Fine-grained database path override. |
|
||||
| `ORCA_PROXMOX_PASSWORD` | SSH password for `orca node join --type proxmox` (never persisted). |
|
||||
| `ORCA_LISTEN_ADDR` | Daemon listen address (deprecated). |
|
||||
| `ORCA_CA_PATH` | CA certificate path override. |
|
||||
| `ORCA_SERVER_CERT_PATH` | Server certificate path override. |
|
||||
| `ORCA_SERVER_KEY_PATH` | Server key path override. |
|
||||
| `ORCA_NODE_CPU` | Node CPU capacity override (millicores). |
|
||||
| `ORCA_NODE_MEMORY_MB` | Node memory capacity override (MiB). |
|
||||
|
||||
### Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `0` | Success |
|
||||
| `1` | Error (printed to stderr) |
|
||||
|
||||
---
|
||||
|
||||
## `orca init`
|
||||
|
||||
Initialize local orca state with full bootstrap.
|
||||
|
||||
```
|
||||
orca init
|
||||
```
|
||||
|
||||
Performs a 6-step idempotent bootstrap:
|
||||
|
||||
1. Create the namespace directory (honors `$ORCA_HOME`; defaults to `~/.orca`)
|
||||
2. Open and migrate the SQLite database (migrations 0001–0006)
|
||||
3. Bootstrap the internal CA (`ca.crt` + `ca.key`) if not already present
|
||||
4. Generate the server cert (`server.crt` + `server.key`) if not already present
|
||||
5. Auto-detect the local OS via `/etc/os-release`
|
||||
6. Register a localhost node (kind=localhost, os=\<detected\>)
|
||||
|
||||
Re-running `orca init` is safe — it refreshes `last_seen` and `os` on
|
||||
the localhost node without regenerating certs or changing the node ID.
|
||||
|
||||
**Flags**: none.
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
orca init
|
||||
orca --system init # system-level bootstrap at /root/.orca
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `orca job`
|
||||
|
||||
Manage orca jobs — run, list, stop, and inspect.
|
||||
|
||||
### `orca job run`
|
||||
|
||||
Run a job from a spec file.
|
||||
|
||||
```
|
||||
orca job run <spec> [flags]
|
||||
```
|
||||
|
||||
Dispatches by file extension:
|
||||
- `.md` → Markdown frontmatter parser (v0.9 canonical)
|
||||
- `.yaml` / `.yml` → YAML frontmatter parser
|
||||
- `.hcl` → Legacy HCL adapter (deprecated, see callout below)
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--target` | string | `""` | Pin job to a specific node ID (overrides bin-packing scheduler) |
|
||||
| `--idempotency-key` | string | `""` | Idempotency key for cross-node dispatch dedupe |
|
||||
|
||||
**Examples**:
|
||||
```bash
|
||||
orca job run web-app.md
|
||||
orca job run api.yaml --target node-abc-123
|
||||
orca job run worker.md --idempotency-key deploy-2026-08-05
|
||||
```
|
||||
|
||||
> **Deprecated**: `orca job run <spec.hcl>` (legacy HCL jobspec) still
|
||||
> works via the adapter but emits a deprecation warning. Migrate `.hcl`
|
||||
> specs to `.md` (see [docs/jobspec.md](jobspec.md)). Removed in v0.11.
|
||||
|
||||
### `orca job list`
|
||||
|
||||
List all jobs.
|
||||
|
||||
```
|
||||
orca job list [flags]
|
||||
```
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--watch` | bool | `false` | Stream jobs until Ctrl-C (table refresh or `--json` per-event) |
|
||||
|
||||
**Output columns**: `ID NAME STATUS EXIT`
|
||||
|
||||
**Examples**:
|
||||
```bash
|
||||
orca job list
|
||||
orca job list --watch # table refresh
|
||||
orca job list --watch --json # NDJSON: {"event":"update","job":{...}}
|
||||
```
|
||||
|
||||
### `orca job stop`
|
||||
|
||||
Stop a running job (soft stop).
|
||||
|
||||
```
|
||||
orca job stop [job-id] [flags]
|
||||
```
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--id` | string | `""` | Job ID (alternative to positional argument) |
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
orca job stop abc-123-def
|
||||
orca job stop --id abc-123-def
|
||||
```
|
||||
|
||||
### `orca job logs`
|
||||
|
||||
Show task output for a job.
|
||||
|
||||
```
|
||||
orca job logs [job-id] [flags]
|
||||
```
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--id` | string | `""` | Job ID (alternative to positional argument) |
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
orca job logs abc-123-def
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `orca node`
|
||||
|
||||
Manage orca nodes — join, leave, or list nodes in the registry.
|
||||
|
||||
### `orca node join`
|
||||
|
||||
Join a node to the orca registry.
|
||||
|
||||
```
|
||||
orca node join [flags]
|
||||
```
|
||||
|
||||
Node types (via `--type`):
|
||||
- `localhost` (default): register a local or Linux node
|
||||
- `proxmox`: SSH-bootstrap a remote Proxmox VE 8/9 host (deploys orca
|
||||
pubkey, creates orca user + PVE role + sudoers allowlist; requires
|
||||
`--host` + `--password`)
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--name` | string | `""` | Node name (required for `--type localhost`) |
|
||||
| `--addr` | string | `""` | Node address (default `localhost:8443`) |
|
||||
| `--ca-fingerprint` | string | `""` | Pin CA cert SHA-256 (fails if on-disk CA doesn't match) |
|
||||
| `--type` | string | `"localhost"` | Node type: `localhost` or `proxmox` |
|
||||
| `--host` | string | `""` | Proxmox host address (IP/hostname; required for `--type proxmox`) |
|
||||
| `--ssh-user` | string | `"root"` | SSH username for proxmox bootstrap |
|
||||
| `--password` | string | `""` | SSH password for proxmox bootstrap (never persisted; prefer `$ORCA_PROXMOX_PASSWORD`) |
|
||||
| `--ssh-port` | int | `22` | SSH port for proxmox bootstrap |
|
||||
| `--proxmox-user` | string | `"orca"` | Linux system user to create on the proxmox host |
|
||||
| `--proxmox-role` | string | `"OrcaOperator"` | PVE custom role to create |
|
||||
| `--host-key-fingerprint` | string | `""` | SSH host key `SHA256:base64` fingerprint (pre-pin; supersedes TOFU for `--type proxmox`) |
|
||||
|
||||
**Examples**:
|
||||
```bash
|
||||
# Localhost (deprecated mTLS path)
|
||||
orca node join --name my-node
|
||||
|
||||
# Proxmox (v0.9 canonical SSH-push path)
|
||||
orca node join --type proxmox --host 192.168.1.100 --ssh-user root
|
||||
ORCA_PROXMOX_PASSWORD=secret orca node join --type proxmox --host 192.168.1.100
|
||||
|
||||
# Proxmox with pre-pinned host key
|
||||
orca node join --type proxmox --host 192.168.1.100 --host-key-fingerprint SHA256:abc123...
|
||||
```
|
||||
|
||||
> **Deprecated**: `orca node join` without `--type proxmox` (the
|
||||
> localhost mTLS join path) is deprecated in v0.9. The v0.9 canonical
|
||||
> path is SSH-push (`--type proxmox`) or local execution (no join
|
||||
> needed). Removed in v0.11.
|
||||
|
||||
### `orca node leave`
|
||||
|
||||
Remove a node from the orca registry.
|
||||
|
||||
```
|
||||
orca node leave [node-id] [flags]
|
||||
```
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--id` | string | `""` | Node ID (alternative to positional argument) |
|
||||
|
||||
### `orca node list`
|
||||
|
||||
List all nodes in the orca registry.
|
||||
|
||||
```
|
||||
orca node list [flags]
|
||||
```
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--watch` | bool | `false` | Stream nodes until Ctrl-C (table refresh or `--json` per-event) |
|
||||
|
||||
**Output columns**: `ID NAME ADDRESS STATE`
|
||||
|
||||
### `orca node key-reset`
|
||||
|
||||
Reset the SSH known_hosts entry for a node.
|
||||
|
||||
```
|
||||
orca node key-reset <node>
|
||||
```
|
||||
|
||||
Removes the pinned SSH host key for `<node>` from the local
|
||||
`known_hosts` file. The next connect re-pins the key via TOFU or
|
||||
`--host-key-fingerprint`. Local only — does not touch the remote
|
||||
host's `authorized_keys`.
|
||||
|
||||
`<node>` is the node name (for proxmox nodes, this is the host address).
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
orca node key-reset 192.168.1.100
|
||||
```
|
||||
|
||||
### `orca node capacity`
|
||||
|
||||
Manage node capacity declarations (bin-packing scheduler input).
|
||||
|
||||
```
|
||||
orca node capacity <subcommand>
|
||||
```
|
||||
|
||||
#### `orca node capacity show`
|
||||
|
||||
Show capacity for a node (defaults to `self`).
|
||||
|
||||
```
|
||||
orca node capacity show [node-id] [flags]
|
||||
```
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--node` | string | `""` | Node ID (defaults to `self`) |
|
||||
|
||||
**Output**: `Node:`, `CPU:` (millicores), `Memory:` (MiB), `Disk:` (MiB), `Updated:`
|
||||
|
||||
#### `orca node capacity set`
|
||||
|
||||
Declare capacity for a node.
|
||||
|
||||
```
|
||||
orca node capacity set [flags]
|
||||
```
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--cpu` | int64 | `0` | CPU capacity in millicores (1000 = 1 vCPU) |
|
||||
| `--memory` | int64 | `0` | Memory capacity in MiB |
|
||||
| `--disk` | int64 | `0` | Disk capacity in MiB |
|
||||
| `--node` | string | `""` | Node ID (defaults to `self`) |
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
orca node capacity set --cpu 4000 --memory 8192 --disk 100000
|
||||
orca node capacity set --cpu 2000 --memory 4096 --node web-1
|
||||
```
|
||||
|
||||
#### `orca node capacity list`
|
||||
|
||||
List all node capacity declarations.
|
||||
|
||||
```
|
||||
orca node capacity list
|
||||
```
|
||||
|
||||
**Output columns**: `NODE CPU(mc) MEM(MiB) DISK(MiB) UPDATED`
|
||||
|
||||
---
|
||||
|
||||
## `orca ns`
|
||||
|
||||
Manage orca namespaces under `ORCA_HOME` (R-002).
|
||||
|
||||
Each namespace is a directory with `ns.md`, `.env`, `.env.secrets`,
|
||||
`db/`, `jobs/`, `alloc/`. The implicit root namespace `_defaults`
|
||||
always exists; every namespace inherits from `_defaults` and cannot
|
||||
opt out.
|
||||
|
||||
### `orca ns list`
|
||||
|
||||
List all namespaces under `ORCA_HOME`.
|
||||
|
||||
```
|
||||
orca ns list
|
||||
```
|
||||
|
||||
**Output columns**: `NAME DEFAULT PATH` (`_defaults` marked `*`)
|
||||
|
||||
### `orca ns create`
|
||||
|
||||
Create a namespace directory + `ns.md`.
|
||||
|
||||
```
|
||||
orca ns create <name> [flags]
|
||||
```
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--parent` | string | `""` | Parent namespace (default `_defaults`; implicit root always appended last) |
|
||||
| `--inherits-env` | bool | `true` | Inherit env from parents |
|
||||
| `--inherits-secrets` | bool | `true` | Inherit secrets from parents |
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
orca ns create prod --parent _defaults
|
||||
orca ns create staging --parent prod
|
||||
```
|
||||
|
||||
### `orca ns delete`
|
||||
|
||||
Remove an empty namespace directory.
|
||||
|
||||
```
|
||||
orca ns delete <name>
|
||||
```
|
||||
|
||||
Refuses if `jobs/` or `alloc/` contain files. The implicit root
|
||||
`_defaults` cannot be deleted.
|
||||
|
||||
### `orca ns inspect`
|
||||
|
||||
Print the effective inheritance chain, merged env, and constraints.
|
||||
|
||||
```
|
||||
orca ns inspect <name>
|
||||
```
|
||||
|
||||
**Output**: `Namespace:`, `Chain:` (e.g., `prod -> _defaults`), `Env:`
|
||||
(sorted keys), `Constraints:` (unioned CEL expressions).
|
||||
|
||||
### `orca ns validate`
|
||||
|
||||
Run cycle + missing-parent + schema checks on a namespace.
|
||||
|
||||
```
|
||||
orca ns validate <name>
|
||||
```
|
||||
|
||||
Exits 0 if valid, 1 on error. Runs over ALL namespaces under
|
||||
`ORCA_HOME` (parsing + resolving validates cycles and missing parents
|
||||
across the set).
|
||||
|
||||
---
|
||||
|
||||
## `orca doctor`
|
||||
|
||||
Run self-checks on the orca installation.
|
||||
|
||||
```
|
||||
orca doctor [subcommand]
|
||||
```
|
||||
|
||||
Without a subcommand, runs all checks and prints a PASS/WARN/FAIL
|
||||
report per check.
|
||||
|
||||
### Subcommands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `orca doctor cert` | CA, server cert, expiry, fingerprint checks |
|
||||
| `orca doctor network` | Network reachability via mTLS `/healthz` probe |
|
||||
| `orca doctor db` | Database integrity (`PRAGMA integrity_check` + migration version) |
|
||||
| `orca doctor os` | OS detection self-check (verifies `/etc/os-release` matches stored node) |
|
||||
| `orca doctor proxmox` | Proxmox node reachability via SSH `pveversion`/`pvecmd status` probe |
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
orca doctor
|
||||
orca doctor cert
|
||||
orca doctor proxmox --json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `orca audit`
|
||||
|
||||
View orca audit log (security-first observability).
|
||||
|
||||
### `orca audit list`
|
||||
|
||||
List recent audit log entries.
|
||||
|
||||
```
|
||||
orca audit list [flags]
|
||||
```
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--limit` | int | `50` | Max entries to show |
|
||||
|
||||
**Output columns**: `TIMESTAMP ACTOR ACTION RESOURCE RESULT`
|
||||
|
||||
---
|
||||
|
||||
## `orca version`
|
||||
|
||||
Print version information.
|
||||
|
||||
```
|
||||
orca version
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```
|
||||
orca version v0.9.1
|
||||
git commit: abc1234
|
||||
build time: 2026-08-05T20:30:00Z
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `orca status`
|
||||
|
||||
Show orca daemon status.
|
||||
|
||||
```
|
||||
orca status
|
||||
```
|
||||
|
||||
> **Deprecated**: The daemon model is deprecated in v0.9 (replaced by
|
||||
> SSH-push, R-001). This command returns a stub status. Removed in
|
||||
> v0.11.
|
||||
|
||||
---
|
||||
|
||||
## Deprecated commands
|
||||
|
||||
The following commands are from the v0.8 daemon/mTLS model and are
|
||||
**deprecated in v0.9**. They still work during the dual-write window
|
||||
but emit `slog.Warn` deprecation warnings. They will be **removed in
|
||||
v0.11**.
|
||||
|
||||
> **`orca daemon`** — Run the orca daemon (HTTP API + health checks).
|
||||
> The v0.9 re-architecture replaces the daemon with SSH-push (R-001).
|
||||
> The daemon is repurposed to `drain-and-stop` in v0.11-P05 and deleted
|
||||
> in v0.11-P14. Flags: `--addr` (default `:8080`), `--pprof` (pprof
|
||||
> endpoint, default disabled).
|
||||
|
||||
> **`orca cert`** — Manage orca certificates (CA, server, rotation).
|
||||
> The v0.9 re-architecture replaces the internal CA with step-ca
|
||||
> (D-101). Subcommands: `ca-init`, `gen`, `show`, `renew`,
|
||||
> `fingerprint`. Removed in v0.11.
|
||||
|
||||
> **`orca node join` (mTLS path)** — The localhost mTLS join path
|
||||
> (without `--type proxmox`) is deprecated. The v0.9 canonical path is
|
||||
> SSH-push (`--type proxmox`) or local execution (no join needed).
|
||||
|
||||
> **`orca job run <spec.hcl>`** — Legacy HCL jobspec. Migrate to `.md`
|
||||
> (see [docs/jobspec.md](jobspec.md)). The HCL adapter preserves
|
||||
> `orca job run old-spec.hcl` during the migration window.
|
||||
|
||||
To suppress deprecation warnings during migration, use
|
||||
`--no-deprecation-warnings`:
|
||||
```bash
|
||||
orca --no-deprecation-warnings daemon
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [docs/jobspec.md](jobspec.md) — Markdown frontmatter jobspec reference
|
||||
- [docs/ingress.md](ingress.md) — Traefik ingress configuration guide
|
||||
- [docs/namespace.md](namespace.md) — Namespace and path layout
|
||||
- [docs/install.md](install.md) — Installation guide
|
||||
- [examples/full-stack/](../examples/full-stack/) — Full-stack example with ingress
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
# Orca Ingress & Traefik Guide
|
||||
|
||||
This document explains how Orca configures ingress via Traefik dynamic
|
||||
configuration. It covers the service→Traefik mapping, the R-007
|
||||
socket-vs-TCP-bind model, atomic reload, drain, TLS, and a worked
|
||||
example.
|
||||
|
||||
> **Canonical path (v0.9)**: Orca generates Traefik dynamic
|
||||
> configuration files via the `TraefikEmitter`. The `kind: Service`
|
||||
> workload implies a Traefik route. The emitter renders one YAML file
|
||||
> per Service; Traefik watches the dynamic config directory and reloads
|
||||
> atomically on change.
|
||||
|
||||
## The model
|
||||
|
||||
A `kind: Service` jobspec **implies** a Traefik route (D-175). `Job`
|
||||
and `DaemonSet` do **not** carry a Traefik route by default — a
|
||||
`service:` block on a `Job` is rejected by the validator.
|
||||
|
||||
When `orca job run` submits a `kind: Service` workload, the
|
||||
`TraefikEmitter` renders a Traefik dynamic config file at:
|
||||
|
||||
```
|
||||
/etc/traefik/dynamic/orca-<service-name>.yaml
|
||||
```
|
||||
|
||||
This file contains:
|
||||
- One **router** (`orca-<name>`) with a `PathPrefix` rule and TLS config.
|
||||
- One **service** (`orca-<name>`) as a `loadBalancer` with one **server**
|
||||
per port, pointing at the workload's Unix socket (or TCP port).
|
||||
- A **healthCheck** stanza when the `health:` block is present.
|
||||
|
||||
Traefik watches `/etc/traefik/dynamic/` via `fsnotify` and reloads
|
||||
whenever a file changes. Orca writes config atomically (write-tmp +
|
||||
rename) so Traefik sees a single `IN_MOVED_TO` event and never observes
|
||||
a half-written file.
|
||||
|
||||
## R-007: socket vs TCP bind
|
||||
|
||||
Orca workloads bind to a **Unix socket** by default, not a TCP port.
|
||||
This is the R-007 security model: loopback-only by default, no network
|
||||
exposure.
|
||||
|
||||
### Default: Unix socket
|
||||
|
||||
When `service.bind` is empty (default), the workload binds a Unix
|
||||
socket at:
|
||||
|
||||
```
|
||||
/run/orca/alloc-<alloc-id>/port-<port-name>.sock
|
||||
```
|
||||
|
||||
systemd creates `/run/orca/alloc-<alloc-id>/` via
|
||||
`RuntimeDirectory=orca/alloc-<alloc-id>` (mode 0750, owned by
|
||||
`orca:orca`). The Traefik backend server URL is:
|
||||
|
||||
```yaml
|
||||
servers:
|
||||
- url: "unix:///run/orca/alloc-<alloc-id>/port-<port-name>.sock"
|
||||
```
|
||||
|
||||
### TCP opt-in: `service.bind: 127.0.0.1`
|
||||
|
||||
When `service.bind: 127.0.0.1` is set, the workload binds a TCP port
|
||||
directly (loopback only). The emitter adds an `ExecStartPre` marker to
|
||||
the systemd unit so the bind mode is visible:
|
||||
|
||||
```ini
|
||||
ExecStartPre=/bin/echo orca: bind 127.0.0.1 port <name> (tcp, R-007 opt-in)
|
||||
```
|
||||
|
||||
`service.bind` must be a valid IP address. Empty (socket default) or
|
||||
`127.0.0.1` (TCP opt-in) are the documented values; any other valid IP
|
||||
is accepted but the bind happens in the process, not the emitter.
|
||||
|
||||
## Generated Traefik YAML
|
||||
|
||||
For a Service named `web` with port `http`:
|
||||
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
orca-web:
|
||||
rule: PathPrefix("/web")
|
||||
service: orca-web
|
||||
tls:
|
||||
certResolver: orca
|
||||
domains:
|
||||
- main: "cluster.orca.local"
|
||||
services:
|
||||
orca-web:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "unix:///run/orca/alloc-<alloc-id>/port-http.sock"
|
||||
healthCheck:
|
||||
path: /healthz
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
```
|
||||
|
||||
- One router per Service, named `orca-<service-name>`.
|
||||
- Router rule: `PathPrefix("/<service-name>")`.
|
||||
- TLS: `certResolver: orca`, trust domain `cluster.orca.local`
|
||||
(placeholder; step-ca provisioner overrides in v0.11).
|
||||
- One service per Service, named `orca-<service-name>`.
|
||||
- One server per port, URL is `unix://<socket-path>`.
|
||||
- `healthCheck` stanza present when `health:` block is set (required
|
||||
for Service). Path is `/healthz`; interval and timeout come from the
|
||||
`health:` block.
|
||||
|
||||
## Atomic reload (gate C-10)
|
||||
|
||||
Orca writes Traefik config atomically to avoid Traefik observing a
|
||||
half-written file:
|
||||
|
||||
1. Write to `<path>.tmp` via `WriteFileIdempotent` (write + fsync).
|
||||
2. `mv -f <path>.tmp <path>` (atomic POSIX rename).
|
||||
|
||||
Traefik's `fsnotify` watcher sees a single `IN_MOVED_TO` event and
|
||||
reloads. If the new config is malformed, Traefik logs an error and
|
||||
**holds last-good config** — the cluster keeps serving traffic on the
|
||||
previous config.
|
||||
|
||||
## Drain
|
||||
|
||||
`RenderDrain` produces the same Traefik YAML with `weight: 0` on every
|
||||
server in the load balancer:
|
||||
|
||||
```yaml
|
||||
servers:
|
||||
- url: "unix:///run/orca/alloc-<alloc-id>/port-http.sock"
|
||||
weight: 0
|
||||
```
|
||||
|
||||
Traefik stops sending traffic to the drained backend. The workload
|
||||
keeps running; drain is reversible (re-submit the normal config to
|
||||
restore traffic).
|
||||
|
||||
## TLS
|
||||
|
||||
- **certResolver**: `orca` (references the Traefik ACME/step-ca
|
||||
certificate resolver configured in Traefik's static config).
|
||||
- **Trust domain**: `cluster.orca.local` (placeholder in v0.9; step-ca
|
||||
provisioner in v0.11 overrides with the real cluster trust domain).
|
||||
- **SPIFFE SVIDs**: workload identity via SPIFFE SVIDs minted at submit
|
||||
time via step-ca (v0.11-P01.5, gate C-08). The SVID is a URI SAN in
|
||||
the workload's X.509 cert.
|
||||
|
||||
## Health checks
|
||||
|
||||
The `health:` block (required for `Service`) maps to the Traefik
|
||||
`healthCheck` stanza:
|
||||
|
||||
```yaml
|
||||
health:
|
||||
check_type: http
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
unhealthy_threshold: 2
|
||||
```
|
||||
|
||||
→
|
||||
|
||||
```yaml
|
||||
healthCheck:
|
||||
path: /healthz
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
```
|
||||
|
||||
Traefik polls each backend's `/healthz` at the configured interval. An
|
||||
unhealthy backend is removed from the load balancer pool until it
|
||||
passes the health check again.
|
||||
|
||||
## Worked example
|
||||
|
||||
See [examples/full-stack/](../examples/full-stack/) for a complete
|
||||
multi-service stack with ingress configured:
|
||||
- `web-app.md` — frontend Service (socket bind, PathPrefix route)
|
||||
- `api.md` — backend API Service (TCP opt-in, `127.0.0.1` bind)
|
||||
- `examples/full-stack/rendered/traefik-dynamic-web-app.yaml` — the
|
||||
Traefik config Orca generates
|
||||
|
||||
## v0.11 forward (limitations)
|
||||
|
||||
The following are not yet implemented in v0.9 and will land in v0.11:
|
||||
|
||||
- **`service.host` / `service.route_id`**: stored on the `ServiceBlock`
|
||||
but not yet consumed by the `TraefikEmitter`. The router rule is
|
||||
hardcoded `PathPrefix("/<name>")`. Custom host-based routing lands in
|
||||
v0.11.
|
||||
- **Socket activation**: real socket-activation (socket unit files, fd
|
||||
passing) lands in v0.11-P08. The current emitter renders the
|
||||
`RuntimeDirectory` + socket path comments but does not create socket
|
||||
units.
|
||||
- **Transactional update execution**: the `update:` block's rolling/
|
||||
canary/blue-green plan is computed by the emitter but not yet
|
||||
executed transactionally. Transactional execution lands in
|
||||
v0.11-P10.
|
||||
- **SPIFFE SVID minting**: workload identity via step-ca SVIDs lands in
|
||||
v0.11-P01.5 (gate C-08).
|
||||
- **Secrets in env**: `env: { KEY: { from: "secret:..." } }` resolution
|
||||
to `EnvironmentFile=`/`LoadCredential=` lands in v0.11-P03.
|
||||
|
||||
## See also
|
||||
|
||||
- [docs/cli.md](cli.md) — CLI reference
|
||||
- [docs/jobspec.md](jobspec.md) — Jobspec reference (`service:`, `health:`, `ports:` blocks)
|
||||
- [examples/full-stack/](../examples/full-stack/) — Full-stack example with ingress
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
# Orca Jobspec Reference
|
||||
|
||||
This document is the complete reference for the Orca jobspec format —
|
||||
the Markdown-with-frontmatter specification that describes workloads.
|
||||
|
||||
> **Canonical format (v0.9)**: Orca uses Markdown with YAML frontmatter
|
||||
> as the canonical jobspec format (R-013/R-014). The legacy HCL format
|
||||
> is supported via an adapter during the migration window but is
|
||||
> deprecated (see [HCL jobspec](#deprecated-hcl-jobspec) below).
|
||||
|
||||
## File formats
|
||||
|
||||
The `orca job run` command dispatches by file extension:
|
||||
|
||||
| Extension | Parser | Body |
|
||||
|-----------|--------|------|
|
||||
| `.md` | `ParseMarkdown` (canonical) | Verbatim after closing `---` (R-015 byte-exact) |
|
||||
| `.yaml` / `.yml` | `parseYAMLFile` | Whole file as frontmatter; body empty |
|
||||
| `.hcl` | `ParseHCL` (legacy adapter) | Empty (deprecated) |
|
||||
|
||||
## Minimal example
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: Job
|
||||
name: my-job
|
||||
runtime:
|
||||
one_of: process
|
||||
command: /bin/echo hello
|
||||
---
|
||||
# My Job
|
||||
|
||||
This body is preserved byte-exact and carried to the target node.
|
||||
```
|
||||
|
||||
## Top-level keys
|
||||
|
||||
| Key | Type | Default | Required | Notes |
|
||||
|-----|------|---------|----------|-------|
|
||||
| `orca-spec-version` | string | `""` | no | Free-form version tag (e.g. `"1"`) |
|
||||
| `kind` | enum | — | **yes** | One of `Job`, `Service`, `DaemonSet` |
|
||||
| `name` | string | — | **yes** | Workload name (trimmed, non-empty) |
|
||||
| `count` | int | `1` | no | Job: must be 1; Service: ≥1; DaemonSet: not allowed |
|
||||
| `runtime` | block | nil | see kinds | Runtime block (or per-task runtimes in a task group) |
|
||||
| `ports` | block list | nil | Service: **yes** | Array of port mappings |
|
||||
| `env` | block map | nil | no | Environment variables |
|
||||
| `secrets` | inline/block list | nil | no | Secret names (resolution in v0.11) |
|
||||
| `volumes` | block list | nil | no | Volume mounts |
|
||||
| `restart` | block | nil | Service/DaemonSet: **yes** | Restart policy |
|
||||
| `update` | block | nil | Service: **yes** | Update strategy |
|
||||
| `service` | block | nil | no | Traefik route definition (implied for Service; not allowed for Job/DaemonSet) |
|
||||
| `health` | block | nil | Service: **yes** | Health check |
|
||||
| `lifecycle` | block | nil | no | Pre-stop / post-start hooks |
|
||||
| `constraints` | list | nil | no | CEL expressions (node selection) |
|
||||
| `affinity` | block list | nil | no | Co-location / anti-affinity rules |
|
||||
| `tasks` | block list | nil | no | Task group (multi-process alloc) |
|
||||
| `timeout` | duration string | `""` | no | Job timeout |
|
||||
| `schedule` | block | nil | DaemonSet: **yes** | Schedule mode |
|
||||
|
||||
## Kinds
|
||||
|
||||
### `Job`
|
||||
|
||||
A one-shot batch task. Runs once and exits.
|
||||
|
||||
- `count` must be 1 (or unset). Use `Service` for replicas.
|
||||
- `service` block is **not allowed** (no Traefik route for Jobs).
|
||||
- `restart` optional (defaults to `never` / `on-failure`).
|
||||
- `timeout` optional.
|
||||
|
||||
**Example**:
|
||||
```yaml
|
||||
---
|
||||
kind: Job
|
||||
name: data-migration
|
||||
runtime:
|
||||
one_of: process
|
||||
command: /usr/bin/python3 migrate.py
|
||||
timeout: 300s
|
||||
env:
|
||||
DB_URL: postgres://localhost/mydb
|
||||
---
|
||||
```
|
||||
|
||||
### `Service`
|
||||
|
||||
A long-running, load-balanced workload with a Traefik route.
|
||||
|
||||
- `count` ≥ 1 (number of replicas).
|
||||
- `ports` required (at least one).
|
||||
- `restart` required; `mode` one of `service`, `on-failure`, `never`.
|
||||
- `update` required; `strategy` one of `rolling`, `canary`, `blue-green`.
|
||||
- `runtime` required (or a task group with per-task runtimes).
|
||||
- `health` required (Traefik routing requires health checks).
|
||||
- `service` block optional (implied for Service; use for `bind` override).
|
||||
- `service.bind` if present must be a valid IP (`127.0.0.1` = TCP opt-in;
|
||||
default = Unix socket).
|
||||
|
||||
**Example**:
|
||||
```yaml
|
||||
---
|
||||
kind: Service
|
||||
name: web
|
||||
count: 3
|
||||
runtime:
|
||||
one_of: process
|
||||
command: /usr/bin/httpd
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
restart:
|
||||
mode: service
|
||||
attempts: 5
|
||||
delay: 2s
|
||||
update:
|
||||
strategy: rolling
|
||||
max_parallel: 1
|
||||
health:
|
||||
check_type: http
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
unhealthy_threshold: 2
|
||||
constraints:
|
||||
- node.role == "web"
|
||||
---
|
||||
```
|
||||
|
||||
### `DaemonSet`
|
||||
|
||||
A workload that runs on every matching node.
|
||||
|
||||
- `schedule` required; `mode` one of `every-node`, `matching`, `mandatory`.
|
||||
- `ports` **not allowed** (no Traefik route by default).
|
||||
- `count` **not allowed** (implicit = matching nodes).
|
||||
- `restart` required.
|
||||
|
||||
**Example**:
|
||||
```yaml
|
||||
---
|
||||
kind: DaemonSet
|
||||
name: log-shipper
|
||||
schedule:
|
||||
mode: every-node
|
||||
runtime:
|
||||
one_of: process
|
||||
command: /usr/bin/fluent-bit
|
||||
restart:
|
||||
mode: service
|
||||
---
|
||||
```
|
||||
|
||||
## Block reference
|
||||
|
||||
### `runtime`
|
||||
|
||||
The runtime backend for the workload.
|
||||
|
||||
| Field | Key | Type | Default | Notes |
|
||||
|-------|-----|------|---------|-------|
|
||||
| `one_of` | `one_of` | string | — | Runtime type (see below) |
|
||||
| `image` | `image` | string | `""` | Container image (for `podman`) |
|
||||
| `command` | `command` | string | — | ExecStart command |
|
||||
|
||||
**Supported runtime types** (`one_of`):
|
||||
|
||||
| Type | Description | Requires |
|
||||
|------|-------------|----------|
|
||||
| `process` | Direct process execution via systemd (default) | systemd on target |
|
||||
| `wasm` / `wasmtime` | WASM via wasmtime CLI (apt-installed on peer, SSH exec) | wasmtime on target |
|
||||
| `podman` | Container via podman | podman on target |
|
||||
| `pve-vm` | Proxmox VM via `qm` | Proxmox node |
|
||||
| `pve-ct` | Proxmox container via `pct` | Proxmox node |
|
||||
| `proxmox` | Alias for Proxmox runtime | Proxmox node |
|
||||
|
||||
An empty/missing `Runtime` or `OneOf` is runtime-agnostic (always fits
|
||||
the runtime axis in the scheduler).
|
||||
|
||||
### `ports`
|
||||
|
||||
Array of port mappings. Required for `Service`.
|
||||
|
||||
| Field | Key | Type | Default | Notes |
|
||||
|-------|-----|------|---------|-------|
|
||||
| `name` | `name` | string | — | Port name (used in socket path) |
|
||||
| `port` | `port` | int | — | Container port |
|
||||
| `host_port` | `host_port` | int | `0` | Host port |
|
||||
| `protocol` | `protocol` | string | `""` | Protocol (e.g. `tcp`) |
|
||||
| `host_ip` | `host_ip` | string | `""` | Host IP |
|
||||
|
||||
**Example**:
|
||||
```yaml
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
host_port: 80
|
||||
protocol: tcp
|
||||
- name: https
|
||||
port: 8443
|
||||
host_port: 443
|
||||
```
|
||||
|
||||
### `env`
|
||||
|
||||
Environment variables. Scalar values or secret references.
|
||||
|
||||
```yaml
|
||||
env:
|
||||
FOO: bar
|
||||
BAZ: "qux"
|
||||
SECRET_REF:
|
||||
from: "secret:db-password"
|
||||
INLINE: {from: "secret:token"}
|
||||
```
|
||||
|
||||
> Secret resolution (`from: "secret:..."`) lands in v0.11-P03. The
|
||||
> parser stores the reference; the emitter will emit
|
||||
> `EnvironmentFile=`/`LoadCredential=` in v0.11.
|
||||
|
||||
### `secrets`
|
||||
|
||||
List of secret names. Inline array or block list.
|
||||
|
||||
```yaml
|
||||
secrets: ["db-password", "api-token"]
|
||||
# or
|
||||
secrets:
|
||||
- db-password
|
||||
- api-token
|
||||
```
|
||||
|
||||
### `volumes`
|
||||
|
||||
Array of volume mounts.
|
||||
|
||||
| Field | Key | Type | Default | Notes |
|
||||
|-------|-----|------|---------|-------|
|
||||
| `name` | `name` | string | — | Volume name |
|
||||
| `type` | `type` | string | — | Volume type (e.g. `host`) |
|
||||
| `source` | `source` | string | — | Source path (or `replicate:<peer>,<peer>` for Syncthing) |
|
||||
| `target` | `target` | string | — | Mount target |
|
||||
| `read_only` | `read_only` | bool | `false` | Read-only mount (`true`/`yes`/`on`/`1`) |
|
||||
|
||||
**Example**:
|
||||
```yaml
|
||||
volumes:
|
||||
- name: data
|
||||
type: host
|
||||
source: /data
|
||||
target: /data
|
||||
read_only: true
|
||||
```
|
||||
|
||||
### `restart`
|
||||
|
||||
Restart policy.
|
||||
|
||||
| Field | Key | Type | Default | Notes |
|
||||
|-------|-----|------|---------|-------|
|
||||
| `mode` | `mode` | enum | — | `never`, `on-failure`, `service` |
|
||||
| `attempts` / `max_retries` | `attempts` or `max_retries` | int | `0` | Max retries (both keys accepted) |
|
||||
| `delay` | `delay` | duration string | `""` | Retry delay (e.g. `2s`) |
|
||||
|
||||
### `update`
|
||||
|
||||
Update strategy. Required for `Service`.
|
||||
|
||||
| Field | Key | Type | Default | Notes |
|
||||
|-------|-----|------|---------|-------|
|
||||
| `strategy` | `strategy` | enum | — | `rolling`, `canary`, `blue-green` |
|
||||
| `max_surge` | `max_surge` | int | `0` | Max surge |
|
||||
| `max_parallel` | `max_parallel` | int | `1` (clamped to `count`) | Max parallel updates |
|
||||
| `min_healthy_time` | `min_healthy_time` | duration | `""` | Min time healthy before next batch |
|
||||
| `healthy_deadline` | `healthy_deadline` | duration | `""` | Deadline for health |
|
||||
| `canary` | `canary` | int or `"<n>%"` | — | Canary size (int count or percentage) |
|
||||
| `auto_promote` | `auto_promote` | bool | `false` | Auto-promote canary (`true`/`yes`/`on`/`1`) |
|
||||
|
||||
**Strategies**:
|
||||
- **rolling**: batches of `max_parallel`, each batch waits for healthy.
|
||||
- **canary**: canary batch first, then `promote` (manual or `auto_promote`), then remaining in `max_parallel` batches.
|
||||
- **blue-green**: all new allocs start in parallel, wait healthy, then `cutover`.
|
||||
|
||||
> Transactional update execution lands in v0.11-P10. The current
|
||||
> emitter computes the plan; execution is a v0.11 deliverable.
|
||||
|
||||
### `service`
|
||||
|
||||
Traefik route definition. Implied for `Service`; not allowed for
|
||||
`Job`/`DaemonSet`. See [docs/ingress.md](ingress.md) for details.
|
||||
|
||||
| Field | Key | Type | Default | Notes |
|
||||
|-------|-----|------|---------|-------|
|
||||
| `name` | `name` | string | — | Service name |
|
||||
| `port` | `port` | int | — | Service port |
|
||||
| `bind` | `bind` | string (IP) | `""` | Bind mode: empty = Unix socket (default); `127.0.0.1` = TCP opt-in (R-007) |
|
||||
| `host` | `host` | string | `""` | Host (stored, not yet consumed by emitter) |
|
||||
| `route_id` | `route_id` | string | `""` | Route ID (stored, not yet consumed by emitter) |
|
||||
|
||||
### `health`
|
||||
|
||||
Health check. Required for `Service`.
|
||||
|
||||
| Field | Key | Type | Default | Notes |
|
||||
|-------|-----|------|---------|-------|
|
||||
| `check_type` | `check_type` | string | — | Check type (e.g. `http`) |
|
||||
| `interval` | `interval` | duration string | — | Check interval (e.g. `5s`) |
|
||||
| `timeout` | `timeout` | duration string | — | Check timeout |
|
||||
| `unhealthy_threshold` | `unhealthy_threshold` | int | `0` | Failures before unhealthy |
|
||||
|
||||
Maps to Traefik `healthCheck` stanza (`path: /healthz`).
|
||||
|
||||
### `lifecycle`
|
||||
|
||||
Lifecycle hooks. Maps to systemd `ExecStartPost` / `ExecStop`.
|
||||
|
||||
| Field | Key | Type | Default | systemd mapping |
|
||||
|-------|-----|------|---------|-----------------|
|
||||
| `post_start` | `post_start` | string list | nil | `ExecStartPost=` (runs after main starts) |
|
||||
| `pre_stop` | `pre_stop` | string list | nil | `ExecStop=` (runs before kill) |
|
||||
|
||||
**Example**:
|
||||
```yaml
|
||||
lifecycle:
|
||||
pre_stop:
|
||||
- /bin/sh -c 'sleep 5'
|
||||
- /usr/local/bin/drain.sh
|
||||
post_start:
|
||||
- /usr/local/bin/warm-cache.sh
|
||||
```
|
||||
|
||||
### `constraints`
|
||||
|
||||
CEL-subset expressions for node selection. Inline array or block list.
|
||||
|
||||
```yaml
|
||||
constraints:
|
||||
- node.role == "web"
|
||||
- region == "us"
|
||||
# or inline
|
||||
constraints: ['node.role == "web"', 'region == "us"']
|
||||
```
|
||||
|
||||
**CEL subset grammar** (hand-rolled, no CEL dependency):
|
||||
- Node attributes: `node.hostname`, `node.kind`, `node.cpus`,
|
||||
`node.memory`, `node.tags`, `node.runtimes`
|
||||
- Bare identifiers: equivalent to `node.<name>`
|
||||
- Literals: string (`"..."`), int
|
||||
- Comparisons: `==`, `!=`, `>=`, `<=`, `>`, `<`
|
||||
- Membership: `in`, `not in`
|
||||
- Boolean: `and`, `or`, `not`, parentheses
|
||||
- Anything outside the subset returns an error (node skipped, not
|
||||
silently mis-evaluated)
|
||||
|
||||
### `affinity`
|
||||
|
||||
Co-location / anti-affinity rules.
|
||||
|
||||
```yaml
|
||||
affinity:
|
||||
- target: zone == "a"
|
||||
weight: 80
|
||||
- target: web
|
||||
weight: -50 # anti-affinity (negative weight)
|
||||
```
|
||||
|
||||
- `target`: CEL expression or bare workload name (for name-based
|
||||
co-location).
|
||||
- `weight`: positive = co-locate, negative = anti-affinity.
|
||||
- Affinity is a **hint** (not a gate); evaluation failures are ignored.
|
||||
|
||||
### `tasks` (task group)
|
||||
|
||||
Multi-process alloc (P06). When `tasks` is non-empty, the alloc runs
|
||||
multiple processes, each as its own systemd unit, grouped under a
|
||||
systemd target.
|
||||
|
||||
```yaml
|
||||
tasks:
|
||||
- name: app
|
||||
runtime:
|
||||
one_of: process
|
||||
command: /usr/bin/httpd -f
|
||||
env:
|
||||
LOG_LEVEL: debug
|
||||
- name: sidecar
|
||||
runtime:
|
||||
one_of: wasm
|
||||
command: /bin/wasm-runner sidecar.wasm
|
||||
```
|
||||
|
||||
- A task with no `runtime:` inherits the top-level `spec.Runtime`.
|
||||
- Each task can have its own `env:` overlay.
|
||||
- `command` falls back: `task.Command` → `task.Runtime.Command` →
|
||||
`spec.Runtime.Command`.
|
||||
- Task names must be unique within the group.
|
||||
|
||||
## Kinds matrix
|
||||
|
||||
| Feature | Job | Service | DaemonSet |
|
||||
|---------|-----|---------|-----------|
|
||||
| `count` | must be 1 | ≥ 1 | not allowed |
|
||||
| `ports` | optional | **required** | not allowed |
|
||||
| `service` block | not allowed | optional (implied) | not allowed |
|
||||
| `restart` | optional | **required** | **required** |
|
||||
| `update` | optional | **required** | optional |
|
||||
| `health` | optional | **required** | optional |
|
||||
| `runtime` | optional | **required** (or task group) | optional |
|
||||
| `schedule` | optional | optional | **required** |
|
||||
| `tasks` | optional | optional | optional |
|
||||
| Traefik route | no | yes (implied) | no (by default) |
|
||||
|
||||
## Body semantics
|
||||
|
||||
The body after the closing `---` is preserved **byte-exact** (R-015) —
|
||||
including trailing newlines, CRLF, BOM in body, and `---` inside code
|
||||
fences. The body is carried verbatim to the target node. It is not
|
||||
interpreted as commands/scripts by the parser today.
|
||||
|
||||
## Deprecated: HCL jobspec
|
||||
|
||||
The legacy HCL jobspec format is supported via an adapter during the
|
||||
migration window. It is deprecated in v0.9 and will be removed in
|
||||
v0.11.
|
||||
|
||||
```hcl
|
||||
job "hello-orca" {
|
||||
}
|
||||
|
||||
task "greet" {
|
||||
command = "/bin/echo"
|
||||
args = ["hello", "from", "orca"]
|
||||
}
|
||||
```
|
||||
|
||||
The adapter converts this to a `*WorkloadSpec{Kind: "Job", Name:
|
||||
"hello-orca", Count: 1, Runtime: {OneOf: "process", Command:
|
||||
"/bin/echo"}}`. Use `.md` for all new jobspecs.
|
||||
|
||||
## See also
|
||||
|
||||
- [docs/cli.md](cli.md) — CLI reference (including `orca job run`)
|
||||
- [docs/ingress.md](ingress.md) — Traefik ingress configuration
|
||||
- [examples/full-stack/](../examples/full-stack/) — Full-stack example jobspecs
|
||||
+158
-77
@@ -1,96 +1,177 @@
|
||||
# Namespace and Paths
|
||||
|
||||
Orca stores all on-disk state (SQLite database, CA certs, server certs,
|
||||
config) under a single **namespace root** directory. This document
|
||||
describes how that root is resolved and how to override it.
|
||||
Orca stores all on-disk state under a single **namespace root**
|
||||
directory. The v0.9 re-architecture introduced a multi-namespace
|
||||
layout (R-002) where each namespace is a self-contained directory tree
|
||||
with its own database, jobs, allocs, env, and secrets. A `cluster/`
|
||||
directory holds cluster-wide artifacts shared across namespaces.
|
||||
|
||||
## Default: User-Level (`~/.orca`)
|
||||
> **v0.9 layout (canonical)**: This document describes the v0.9
|
||||
> multi-namespace layout. The v0.8 flat layout (`orca.db`, `ca.crt`,
|
||||
> `server.crt` at the root) is deprecated and will be removed in
|
||||
> v0.11. See [v0.8 flat layout](#deprecated-v08-flat-layout) below.
|
||||
|
||||
By default, the namespace root is `~/.orca` (i.e., `$HOME/.orca`).
|
||||
All orca state lives under this directory:
|
||||
## Namespace root resolution
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `~/.orca/orca.db` | SQLite database (jobs, nodes, tasks, audit log, capacity) |
|
||||
| `~/.orca/ca.crt` | CA certificate (PEM, mode 0644) |
|
||||
| `~/.orca/ca.key` | CA private key (PEM, mode 0600) |
|
||||
| `~/.orca/server.crt` | Server certificate (PEM, mode 0644) |
|
||||
| `~/.orca/server.key` | Server private key (PEM, mode 0600) |
|
||||
|
||||
## Override: `ORCA_HOME` Environment Variable (REQ-041)
|
||||
|
||||
Set the `ORCA_HOME` environment variable to change the namespace root
|
||||
for **all** orca components (database, certs, init, daemon):
|
||||
|
||||
```bash
|
||||
export ORCA_HOME=/var/lib/orca
|
||||
orca init # creates /var/lib/orca/
|
||||
orca daemon # reads /var/lib/orca/orca.db
|
||||
orca cert ca-init # writes CA to /var/lib/orca/
|
||||
```
|
||||
|
||||
This is the single source of truth for the namespace root. Every
|
||||
component that reads or writes on-disk state resolves the root via
|
||||
`ORCA_HOME` (falling back to `~/.orca` when unset).
|
||||
|
||||
### Use cases
|
||||
|
||||
- **Testing**: point `ORCA_HOME` at a temp directory.
|
||||
- **Multi-instance**: run multiple orca daemons on the same host with
|
||||
different `ORCA_HOME` values.
|
||||
- **Custom layout**: store state on a mounted volume
|
||||
(`ORCA_HOME=/mnt/orca-data`).
|
||||
|
||||
## System-Level: `--system` Flag (REQ-042)
|
||||
|
||||
The `--system` persistent flag selects the system-level namespace root
|
||||
`/root/.orca`. This is intended for root-owned system deployments
|
||||
(where orca runs as a system service under root):
|
||||
|
||||
```bash
|
||||
sudo orca --system init # creates /root/.orca/
|
||||
sudo orca --system daemon # reads /root/.orca/orca.db
|
||||
sudo orca --system cert ca-init # writes CA to /root/.orca/
|
||||
```
|
||||
|
||||
The `--system` flag is equivalent to setting `ORCA_HOME=/root/.orca`,
|
||||
but it is a CLI convenience that does not require exporting an env var.
|
||||
If `ORCA_HOME` is already set to a different value, `--system` returns
|
||||
an error (to avoid silent namespace mismatches).
|
||||
|
||||
### Path layout
|
||||
|
||||
System-level uses the same directory shape as user-level, just under
|
||||
`/root/.orca` instead of `~/.orca`:
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `/root/.orca/orca.db` | SQLite database |
|
||||
| `/root/.orca/ca.crt` | CA certificate |
|
||||
| `/root/.orca/ca.key` | CA private key |
|
||||
| `/root/.orca/server.crt` | Server certificate |
|
||||
| `/root/.orca/server.key` | Server private key |
|
||||
|
||||
## Resolution Order
|
||||
The namespace root is resolved in this order:
|
||||
|
||||
1. If `--system` flag is passed → root is `/root/.orca` (errors if
|
||||
`ORCA_HOME` is set to a conflicting value).
|
||||
2. Else if `ORCA_HOME` is set → root is `$ORCA_HOME`.
|
||||
3. Else → root is `~/.orca` (`$HOME/.orca`).
|
||||
|
||||
## `ORCA_DB` Override
|
||||
### `ORCA_HOME` (REQ-041)
|
||||
|
||||
For finer-grained control, `ORCA_DB` overrides **only** the database
|
||||
path (not the cert paths). This is primarily a testing affordance. When
|
||||
`ORCA_DB` is set, certs still resolve under `ORCA_HOME` (or `~/.orca`).
|
||||
Set the `ORCA_HOME` environment variable to change the namespace root
|
||||
for all orca components:
|
||||
|
||||
```bash
|
||||
export ORCA_HOME=/var/lib/orca
|
||||
orca init # creates /var/lib/orca/
|
||||
orca ns create prod
|
||||
```
|
||||
|
||||
### `--system` (REQ-042)
|
||||
|
||||
The `--system` persistent flag selects the system-level namespace root
|
||||
`/root/.orca`:
|
||||
|
||||
```bash
|
||||
sudo orca --system init # creates /root/.orca/
|
||||
sudo orca --system ns list
|
||||
```
|
||||
|
||||
If `ORCA_HOME` is already set to a different value, `--system` returns
|
||||
an error (to avoid silent namespace mismatches).
|
||||
|
||||
## v0.9 multi-namespace layout (R-002)
|
||||
|
||||
```
|
||||
$ORCA_HOME/
|
||||
├── cluster/ # cluster-wide (NOT a workload namespace)
|
||||
│ ├── ca.crt, ca.key # step-ca root (R-006, D-101)
|
||||
│ ├── master.key # AES-256-GCM root (R-011, mode 0600)
|
||||
│ ├── config.md # Markdown frontmatter config (R-014)
|
||||
│ ├── known_hosts # SSH known_hosts (D-035)
|
||||
│ ├── orca_ssh_key # orca SSH private key (D-037)
|
||||
│ ├── orca_ssh_key.pub # orca SSH public key
|
||||
│ ├── peers/<host>/ # per-peer directory
|
||||
│ ├── txns/ # cluster transaction log (R-016)
|
||||
│ └── state/ # cluster state
|
||||
├── _defaults/ # implicit root namespace (always exists)
|
||||
│ ├── ns.md # namespace frontmatter (kind: Namespace)
|
||||
│ ├── .env # per-namespace env
|
||||
│ ├── .env.secrets # encrypted secrets
|
||||
│ ├── db/orca.db # per-namespace SQLite database
|
||||
│ ├── jobs/ # submitted jobspecs
|
||||
│ └── alloc/ # allocation state
|
||||
├── <explicit-namespace>/ # operator-created (e.g., prod, staging)
|
||||
│ ├── ns.md
|
||||
│ ├── .env, .env.secrets
|
||||
│ ├── db/orca.db
|
||||
│ ├── jobs/, alloc/
|
||||
│ └── syncthing/ # Syncthing config (if replicated volumes)
|
||||
└── orca_cache.db # CLI-side cache (R-008)
|
||||
```
|
||||
|
||||
### Key points
|
||||
|
||||
- **`_defaults/`** is the implicit root namespace (D-159). It always
|
||||
exists. Every namespace inherits from `_defaults` and cannot opt out
|
||||
(D-185, D-187).
|
||||
- **`cluster/`** is NOT a workload namespace — it holds cluster-wide
|
||||
artifacts (CA, master key, SSH keys, known_hosts, peers, txns).
|
||||
- **Per-namespace DBs**: each namespace has its own
|
||||
`db/orca.db` (R-002). No namespace column in SQLite.
|
||||
- **Namespace inheritance**: child namespaces inherit env and
|
||||
constraints from parents (via `ns.md` frontmatter `parents:` field).
|
||||
`_defaults` is always appended last in the inheritance chain.
|
||||
- **`orca ns` subcommands**: `list`, `create`, `delete`, `inspect`,
|
||||
`validate` — see [docs/cli.md](cli.md#orca-ns).
|
||||
|
||||
### Path reference (`internal/paths/`)
|
||||
|
||||
| Function | Path | Contents |
|
||||
|----------|------|----------|
|
||||
| `Root()` | `$ORCA_HOME` | Namespace root |
|
||||
| `ClusterDir()` | `Root()/cluster` | Cluster-wide artifacts |
|
||||
| `NamespaceDir(ns)` | `Root()/ns` | Per-namespace directory |
|
||||
| `NSDb(ns)` | `Root()/ns/db/orca.db` | Per-namespace SQLite DB |
|
||||
| `NSEnv(ns)` | `Root()/ns/.env` | Per-namespace env |
|
||||
| `NSSecrets(ns)` | `Root()/ns/.env.secrets` | Encrypted secrets |
|
||||
| `NSJobs(ns)` | `Root()/ns/jobs` | Jobs dir |
|
||||
| `NSAlloc(ns)` | `Root()/ns/alloc` | Alloc dir |
|
||||
| `NSMd(ns)` | `Root()/ns/ns.md` | Namespace frontmatter |
|
||||
| `DefaultNamespace()` | `_defaults` | Implicit root (D-159) |
|
||||
| `CACertPath()` | `ClusterDir()/ca.crt` | step-ca root (D-101) |
|
||||
| `MasterKeyPath()` | `ClusterDir()/master.key` | AES-256-GCM root key |
|
||||
| `KnownHostsPath()` | `ClusterDir()/known_hosts` | SSH known_hosts |
|
||||
| `SSHKeyPath()` | `ClusterDir()/orca_ssh_key` | orca SSH private key |
|
||||
| `ConfigPath()` | `ClusterDir()/config.md` | Markdown config (R-014) |
|
||||
| `CacheDB()` | `Root()/orca_cache.db` | CLI-side cache (R-008) |
|
||||
| `PeersDir()` | `ClusterDir()/peers` | Peers directory |
|
||||
| `TxnDir()` | `ClusterDir()/txns` | Transaction log (R-016) |
|
||||
|
||||
## Creating and managing namespaces
|
||||
|
||||
```bash
|
||||
# List all namespaces
|
||||
orca ns list
|
||||
|
||||
# Create a namespace (inherits from _defaults)
|
||||
orca ns create prod
|
||||
|
||||
# Create a namespace with an explicit parent
|
||||
orca ns create staging --parent prod
|
||||
|
||||
# Inspect the effective inheritance chain + merged env
|
||||
orca ns inspect prod
|
||||
|
||||
# Validate a namespace's inheritance chain
|
||||
orca ns validate prod
|
||||
|
||||
# Delete an empty namespace (refuses if jobs/ or alloc/ non-empty)
|
||||
orca ns delete staging
|
||||
```
|
||||
|
||||
See [docs/cli.md](cli.md#orca-ns) for the full `orca ns` reference.
|
||||
|
||||
## `ORCA_DB` override
|
||||
|
||||
For finer-grained control, `ORCA_DB` overrides only the database path
|
||||
(not the cert/namespace paths). This is primarily a testing affordance.
|
||||
|
||||
```bash
|
||||
export ORCA_DB=/tmp/test.db
|
||||
orca daemon # uses /tmp/test.db for the DB, ~/.orca/ for certs
|
||||
orca init # uses /tmp/test.db for the DB, ~/.orca/ for everything else
|
||||
```
|
||||
|
||||
## See Also
|
||||
## Deprecated: v0.8 flat layout
|
||||
|
||||
> **Deprecated in v0.9**: The v0.8 flat layout (`orca.db`, `ca.crt`,
|
||||
> `ca.key`, `server.crt`, `server.key` at the namespace root) is
|
||||
> superseded by the v0.9 multi-namespace layout (R-002). The v0.8
|
||||
> layout is supported during the dual-write window via
|
||||
> `internal/certpaths` (a thin shim) and will be removed in v0.11.
|
||||
|
||||
The v0.8 flat layout stored all state at the namespace root:
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `~/.orca/orca.db` | SQLite database |
|
||||
| `~/.orca/ca.crt` | CA certificate |
|
||||
| `~/.orca/ca.key` | CA private key |
|
||||
| `~/.orca/server.crt` | Server certificate |
|
||||
| `~/.orca/server.key` | Server private key |
|
||||
|
||||
The v0.9 re-architecture moved these to `cluster/` (CA, SSH keys) and
|
||||
per-namespace `db/` (SQLite) to support multi-tenancy (R-002). The
|
||||
`orca doctor --legacy-paths` command (v0.11-P14c) will detect v0.8
|
||||
residue and recommend migration.
|
||||
|
||||
## See also
|
||||
|
||||
- [Install Guide](install.md) — 1-liner install with `install.sh`.
|
||||
- [Docker Guide](docker.md) — running orca in a container (uses
|
||||
`ORCA_HOME=/var/lib/orca` inside the image).
|
||||
- [Docker Guide](docker.md) — running orca in a container.
|
||||
- [CLI Reference](cli.md) — `orca ns` subcommands.
|
||||
- [Jobspec Reference](jobspec.md) — markdown frontmatter schema.
|
||||
@@ -0,0 +1,191 @@
|
||||
# Full-Stack Example with Ingress
|
||||
|
||||
This directory contains a complete multi-service stack deployed with
|
||||
Orca, including Traefik ingress configuration. Each file is a valid
|
||||
Orca jobspec (`.md` frontmatter) that passes the v0.9 parser and schema
|
||||
validators.
|
||||
|
||||
> **Runnable out-of-the-box**: The `runtime.command` in each example
|
||||
> uses `/bin/sleep 3600` (for long-running services) or `/bin/echo`
|
||||
> (for one-shot jobs) so that `orca job run <file>.md` succeeds on any
|
||||
> Linux machine without installing any software. Each file has a
|
||||
> **Production substitution** note showing the real binary to use in a
|
||||
> deployment (e.g. `/usr/bin/httpd`,
|
||||
> `/usr/lib/postgresql/16/bin/postgres`).
|
||||
|
||||
## Stack overview
|
||||
|
||||
| File | Kind | Runtime | Ingress | Description |
|
||||
|------|------|---------|---------|-------------|
|
||||
| `web-app.md` | Service | process | Unix socket (default) | Frontend HTTP server, 3 replicas, rolling update |
|
||||
| `api.md` | Service | process | TCP `127.0.0.1:9090` (R-007 opt-in) | Backend API, 2 replicas, canary update |
|
||||
| `worker.md` | Job | process | none | One-shot batch worker with lifecycle hooks |
|
||||
| `log-shipper.md` | Service | process | Unix socket (metrics) | Log shipper on a dedicated node |
|
||||
| `postgres.md` | Service | process | Unix socket | Database with volume replication, blue-green update |
|
||||
|
||||
## Rendered artifacts
|
||||
|
||||
The `rendered/` directory shows what Orca generates on the target nodes
|
||||
when you submit these jobspecs:
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `traefik-dynamic-web-app.yaml` | Traefik dynamic config for the web-app Service |
|
||||
| `traefik-dynamic-api.yaml` | Traefik dynamic config for the api Service (TCP bind) |
|
||||
| `systemd-web-app.service` | Systemd unit for the web-app alloc |
|
||||
| `systemd-api.service` | Systemd unit for the api alloc (with TCP bind marker) |
|
||||
| `systemd-log-shipper.service` | Systemd unit for the log-shipper alloc |
|
||||
|
||||
## Walkthrough
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Orca installed (`orca version` works)
|
||||
- 2+ Linux nodes reachable over SSH (for multi-node scheduling)
|
||||
- Traefik installed on the lead node (watches `/etc/traefik/dynamic/`)
|
||||
|
||||
### Step 1: Initialize the cluster
|
||||
|
||||
```bash
|
||||
# On the operator laptop
|
||||
orca init
|
||||
```
|
||||
|
||||
This creates `~/.orca/` (or `/root/.orca` with `--system`), bootstraps
|
||||
the CA, generates the server cert, auto-detects the OS, and registers
|
||||
a localhost node.
|
||||
|
||||
### Step 2: Join remote nodes
|
||||
|
||||
```bash
|
||||
# Join a Proxmox node (v0.9 canonical SSH-push path)
|
||||
orca node join --type proxmox --host 192.168.1.100 --ssh-user root
|
||||
|
||||
# Join a second node
|
||||
ORCA_PROXMOX_PASSWORD=secret orca node join --type proxmox --host 192.168.1.101
|
||||
```
|
||||
|
||||
### Step 3: Declare node capacity
|
||||
|
||||
The CLI-side scheduler uses capacity declarations for bin-packing:
|
||||
|
||||
```bash
|
||||
orca node capacity set --cpu 4000 --memory 8192 --disk 100000 --node 192.168.1.100
|
||||
orca node capacity set --cpu 4000 --memory 8192 --disk 100000 --node 192.168.1.101
|
||||
```
|
||||
|
||||
### Step 4: Create a namespace
|
||||
|
||||
```bash
|
||||
orca ns create prod --parent _defaults
|
||||
```
|
||||
|
||||
This creates `~/.orca/prod/` with `db/`, `jobs/`, `alloc/`, and `ns.md`.
|
||||
|
||||
### Step 5: Submit the stack
|
||||
|
||||
```bash
|
||||
orca job run web-app.md
|
||||
orca job run api.md
|
||||
orca job run worker.md
|
||||
orca job run log-shipper.md
|
||||
orca job run postgres.md
|
||||
```
|
||||
|
||||
Each `orca job run` parses the `.md` jobspec, validates it against the
|
||||
schema, schedules it via the CLI-side bin-packing scheduler, and
|
||||
generates the systemd + Traefik artifacts on the target node via
|
||||
SSH-push.
|
||||
|
||||
### Step 6: Observe placements
|
||||
|
||||
```bash
|
||||
orca job list --watch
|
||||
|
||||
# Output:
|
||||
# ID NAME STATUS EXIT
|
||||
# abc-123... web-app running 0
|
||||
# def-456... api running 0
|
||||
# ghi-789... worker complete 0
|
||||
# jkl-012... log-shipper running 0
|
||||
# mno-345... postgres running 0
|
||||
```
|
||||
|
||||
### Step 7: Inspect rendered artifacts
|
||||
|
||||
After submission, the target nodes have:
|
||||
|
||||
```
|
||||
/etc/systemd/system/orca-v1-web-app.service # systemd unit
|
||||
/etc/systemd/system/orca-v1-api.service # systemd unit (TCP bind)
|
||||
/etc/traefik/dynamic/orca-web-app.yaml # Traefik dynamic config
|
||||
/etc/traefik/dynamic/orca-api.yaml # Traefik dynamic config
|
||||
/run/orca/alloc-web-app-0/port-http.sock # Unix socket (R-007 default)
|
||||
```
|
||||
|
||||
See the `rendered/` directory in this example for the exact file
|
||||
contents.
|
||||
|
||||
### Step 8: Verify ingress
|
||||
|
||||
Traefik watches `/etc/traefik/dynamic/` and atomically reloads when a
|
||||
file changes (write-tmp + rename, gate C-10). The web-app is reachable
|
||||
at `https://<cluster-domain>/web-app` and the API at
|
||||
`https://<cluster-domain>/api`.
|
||||
|
||||
Health checks (`/healthz` on each backend) ensure Traefik only routes
|
||||
to healthy instances.
|
||||
|
||||
### Step 9: Drain and rollback
|
||||
|
||||
To drain a service (stop traffic, keep the workload running):
|
||||
|
||||
```bash
|
||||
# Orca writes a Traefik config with weight:0 on every backend
|
||||
# (RenderDrain). Traefik stops sending traffic.
|
||||
```
|
||||
|
||||
To roll back, re-submit the normal jobspec — Orca writes the
|
||||
non-drained Traefik config and Traefik resumes routing.
|
||||
|
||||
## Ingress model
|
||||
|
||||
See [docs/ingress.md](../../docs/ingress.md) for the full Traefik
|
||||
ingress reference. Key points:
|
||||
|
||||
- `kind: Service` **implies** a Traefik route (D-175).
|
||||
- Default bind is a **Unix socket** at
|
||||
`/run/orca/alloc-<id>/port-<name>.sock` (R-007).
|
||||
- `service.bind: 127.0.0.1` opts in to **TCP** (loopback only).
|
||||
- One Traefik dynamic file per Service at
|
||||
`/etc/traefik/dynamic/orca-<name>.yaml`.
|
||||
- Atomic reload via write-tmp + rename (gate C-10).
|
||||
- Drain sets `weight: 0` per backend.
|
||||
|
||||
## Validation
|
||||
|
||||
All jobspecs in this directory are validated by a Go test:
|
||||
|
||||
```bash
|
||||
go test ./examples/full-stack/ -v -run TestExamplesValidate
|
||||
```
|
||||
|
||||
This test parses each `.md` file with `jobspec.ParseFile` and validates
|
||||
it against `schema.ValidatorFor(kind)` — ensuring every field used in
|
||||
the examples exists in the current `WorkloadSpec` struct and passes the
|
||||
per-kind validators (gate C-20).
|
||||
|
||||
## v0.11 forward
|
||||
|
||||
The following are not yet implemented in v0.9 and will land in v0.11:
|
||||
|
||||
- **DaemonSet `schedule:` block**: the parser does not yet populate the
|
||||
`schedule:` frontmatter block (v0.9 parser gap). The `log-shipper`
|
||||
example uses `kind: Service` with `count: 1` and a `node.role`
|
||||
constraint as a workaround.
|
||||
- **Secret resolution**: `env: { KEY: { from: "secret:..." } }` is
|
||||
parsed but not resolved to `EnvironmentFile=`/`LoadCredential=` until
|
||||
v0.11-P03.
|
||||
- **Transactional update execution**: the `update:` block's plan is
|
||||
computed but not executed transactionally until v0.11-P10.
|
||||
- **Socket activation**: real socket unit files land in v0.11-P08.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
kind: Service
|
||||
name: api
|
||||
count: 2
|
||||
runtime:
|
||||
one_of: process
|
||||
command: /bin/sleep 3600
|
||||
ports:
|
||||
- name: api
|
||||
port: 9090
|
||||
restart:
|
||||
mode: service
|
||||
attempts: 3
|
||||
delay: 5s
|
||||
update:
|
||||
strategy: canary
|
||||
canary: 1
|
||||
max_parallel: 1
|
||||
auto_promote: false
|
||||
min_healthy_time: 30s
|
||||
healthy_deadline: 5m
|
||||
service:
|
||||
name: api
|
||||
port: 9090
|
||||
bind: 127.0.0.1
|
||||
health:
|
||||
check_type: http
|
||||
interval: 10s
|
||||
timeout: 2s
|
||||
unhealthy_threshold: 3
|
||||
constraints:
|
||||
- node.role == "api"
|
||||
- node.cpus >= 2
|
||||
env:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
LOG_LEVEL: info
|
||||
---
|
||||
# API Server
|
||||
|
||||
Backend API service binding to 127.0.0.1:9090 (TCP opt-in, R-007).
|
||||
Canary update strategy with manual promote. Two replicas with CPU
|
||||
constraint (>= 2 vCPUs) and API-role node selection.
|
||||
|
||||
> **Production substitution**: replace `runtime.command` with your
|
||||
> actual API binary, e.g. `/usr/bin/api-server --listen 127.0.0.1:9090`.
|
||||
@@ -0,0 +1,60 @@
|
||||
package fullstack_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
"git.cloudinit.dev/coreci/orca/internal/spec/schema"
|
||||
)
|
||||
|
||||
// TestExamplesValidate parses and validates every jobspec in
|
||||
// examples/full-stack/ against the current parser and schema validators
|
||||
// (gate C-20, REQ-094). This ensures the example jobspecs use only
|
||||
// fields that exist in the current WorkloadSpec struct and pass the
|
||||
// per-kind validators.
|
||||
func TestExamplesValidate(t *testing.T) {
|
||||
dir := filepath.Join("..", "..", "examples", "full-stack")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read examples dir: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
// Skip README.md and other non-jobspec markdown files.
|
||||
if name == "README.md" {
|
||||
continue
|
||||
}
|
||||
ext := filepath.Ext(name)
|
||||
if ext != ".md" && ext != ".yaml" && ext != ".yml" {
|
||||
continue
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(dir, name)
|
||||
spec, err := jobspec.ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile %s: %v", name, err)
|
||||
}
|
||||
if spec == nil {
|
||||
t.Fatalf("ParseFile %s: spec is nil", name)
|
||||
}
|
||||
if spec.Kind == "" {
|
||||
t.Fatalf("ParseFile %s: kind is empty", name)
|
||||
}
|
||||
if spec.Name == "" {
|
||||
t.Fatalf("ParseFile %s: name is empty", name)
|
||||
}
|
||||
validator, err := schema.ValidatorFor(spec.Kind)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatorFor %s (kind %s): %v", name, spec.Kind, err)
|
||||
}
|
||||
if err := validator.Validate(spec); err != nil {
|
||||
t.Fatalf("Validate %s: %v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
kind: Service
|
||||
name: log-shipper
|
||||
count: 1
|
||||
runtime:
|
||||
one_of: process
|
||||
command: /bin/sleep 3600
|
||||
ports:
|
||||
- name: metrics
|
||||
port: 2024
|
||||
restart:
|
||||
mode: service
|
||||
attempts: 3
|
||||
delay: 10s
|
||||
update:
|
||||
strategy: rolling
|
||||
max_parallel: 1
|
||||
health:
|
||||
check_type: http
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
unhealthy_threshold: 3
|
||||
constraints:
|
||||
- node.role == "logs"
|
||||
env:
|
||||
LOG_LEVEL: warn
|
||||
OUTPUT: unix:///run/orca/alloc-log-collector/ingest.sock
|
||||
---
|
||||
# Log Shipper
|
||||
|
||||
Log shipper service (fluent-bit) running on a dedicated logs-role node.
|
||||
Exposes a metrics port for health checking. Ships logs to a central
|
||||
collector via Unix socket.
|
||||
|
||||
> **Production substitution**: replace `runtime.command` with your
|
||||
> actual log shipper binary, e.g.
|
||||
> `/usr/bin/fluent-bit -c /etc/orca/log-shipper/fluent-bit.conf`.
|
||||
|
||||
> **Note**: DaemonSet kind is defined in the schema but the parser does
|
||||
> not yet populate the `schedule:` block from frontmatter (v0.9 parser
|
||||
> gap). This example uses `kind: Service` with `count: 1` and a
|
||||
> `node.role == "logs"` constraint to achieve single-node placement
|
||||
> until the parser gains `schedule:` support (v0.11).
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
kind: Service
|
||||
name: postgres
|
||||
count: 1
|
||||
runtime:
|
||||
one_of: process
|
||||
command: /bin/sleep 3600
|
||||
ports:
|
||||
- name: pg
|
||||
port: 5432
|
||||
restart:
|
||||
mode: service
|
||||
attempts: 5
|
||||
delay: 10s
|
||||
update:
|
||||
strategy: blue-green
|
||||
min_healthy_time: 60s
|
||||
healthy_deadline: 10m
|
||||
service:
|
||||
name: postgres
|
||||
port: 5432
|
||||
health:
|
||||
check_type: http
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
unhealthy_threshold: 3
|
||||
volumes:
|
||||
- name: data
|
||||
type: host
|
||||
source: replicate:peer-b,peer-c
|
||||
target: /var/lib/postgresql/data
|
||||
read_only: false
|
||||
constraints:
|
||||
- node.role == "db"
|
||||
- node.cpus >= 4
|
||||
- node.memory >= 8192
|
||||
env:
|
||||
POSTGRES_DB: appdb
|
||||
POSTGRES_USER: orca
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
---
|
||||
# PostgreSQL
|
||||
|
||||
Database service with a single replica, blue-green update strategy,
|
||||
and volume replication via Syncthing (replicate:peer-b,peer-c). The
|
||||
data volume is replicated to two peers for fault tolerance. Health
|
||||
check on port 5432. Constraints require DB-role nodes with >= 4 vCPUs
|
||||
and >= 8 GiB memory.
|
||||
|
||||
> **Production substitution**: replace `runtime.command` with your
|
||||
> actual postgres binary, e.g.
|
||||
> `/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data`.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Systemd unit for orca api service (alloc api-0)
|
||||
# Generated by SystemdEmitter (internal/emitter/systemd.go)
|
||||
# Path on target node: /etc/systemd/system/orca-v1-api.service
|
||||
# service.bind: 127.0.0.1 (TCP opt-in, R-007)
|
||||
[Service]
|
||||
ExecStart=/bin/sleep 3600
|
||||
RuntimeDirectory=orca/alloc-api-0
|
||||
# socket: /run/orca/alloc-api-0/port-api.sock
|
||||
ExecStartPre=/bin/echo orca: bind 127.0.0.1 port api (tcp, R-007 opt-in)
|
||||
@@ -0,0 +1,7 @@
|
||||
# Systemd unit for orca log-shipper service
|
||||
# Generated by SystemdEmitter (internal/emitter/systemd.go)
|
||||
# Path on target node: /etc/systemd/system/orca-v1-log-shipper.service
|
||||
[Service]
|
||||
ExecStart=/bin/sleep 3600
|
||||
RuntimeDirectory=orca/alloc-log-shipper-0
|
||||
# socket: /run/orca/alloc-log-shipper-0/port-metrics.sock
|
||||
@@ -0,0 +1,11 @@
|
||||
# Systemd unit for orca web-app service (alloc web-app-0)
|
||||
# Generated by SystemdEmitter (internal/emitter/systemd.go)
|
||||
# Path on target node: /etc/systemd/system/orca-v1-web-app.service
|
||||
# Unit name prefix orca-v1- (dual-write window, REQ-090)
|
||||
[Service]
|
||||
ExecStart=/bin/sleep 3600
|
||||
ExecStartPost=/bin/echo cache warmed
|
||||
ExecStop=/bin/sleep 5
|
||||
ExecStop=/bin/echo draining web-app
|
||||
RuntimeDirectory=orca/alloc-web-app-0
|
||||
# socket: /run/orca/alloc-web-app-0/port-http.sock
|
||||
@@ -0,0 +1,23 @@
|
||||
# Traefik dynamic config for orca api service
|
||||
# Generated by TraefikEmitter (internal/emitter/traefik.go)
|
||||
# Path on target node: /etc/traefik/dynamic/orca-api.yaml
|
||||
# service.bind: 127.0.0.1 (TCP opt-in, R-007)
|
||||
http:
|
||||
routers:
|
||||
orca-api:
|
||||
rule: PathPrefix("/api")
|
||||
service: orca-api
|
||||
tls:
|
||||
certResolver: orca
|
||||
domains:
|
||||
- main: "cluster.orca.local"
|
||||
services:
|
||||
orca-api:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:9090"
|
||||
- url: "http://127.0.0.1:9090"
|
||||
healthCheck:
|
||||
path: /healthz
|
||||
interval: 10s
|
||||
timeout: 2s
|
||||
@@ -0,0 +1,24 @@
|
||||
# Traefik dynamic config for orca web-app service
|
||||
# Generated by TraefikEmitter (internal/emitter/traefik.go)
|
||||
# Path on target node: /etc/traefik/dynamic/orca-web-app.yaml
|
||||
# Atomic reload: write to .tmp + mv (gate C-10)
|
||||
http:
|
||||
routers:
|
||||
orca-web-app:
|
||||
rule: PathPrefix("/web-app")
|
||||
service: orca-web-app
|
||||
tls:
|
||||
certResolver: orca
|
||||
domains:
|
||||
- main: "cluster.orca.local"
|
||||
services:
|
||||
orca-web-app:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "unix:///run/orca/alloc-web-app-0/port-http.sock"
|
||||
- url: "unix:///run/orca/alloc-web-app-1/port-http.sock"
|
||||
- url: "unix:///run/orca/alloc-web-app-2/port-http.sock"
|
||||
healthCheck:
|
||||
path: /healthz
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
kind: Service
|
||||
name: web-app
|
||||
count: 3
|
||||
runtime:
|
||||
one_of: process
|
||||
command: /bin/sleep 3600
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
restart:
|
||||
mode: service
|
||||
attempts: 5
|
||||
delay: 2s
|
||||
update:
|
||||
strategy: rolling
|
||||
max_parallel: 1
|
||||
min_healthy_time: 10s
|
||||
healthy_deadline: 2m
|
||||
service:
|
||||
name: web-app
|
||||
port: 8080
|
||||
health:
|
||||
check_type: http
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
unhealthy_threshold: 2
|
||||
constraints:
|
||||
- node.role == "web"
|
||||
affinity:
|
||||
- target: zone == "a"
|
||||
weight: 80
|
||||
lifecycle:
|
||||
post_start:
|
||||
- /bin/sh -c 'echo cache warmed'
|
||||
pre_stop:
|
||||
- /bin/sh -c 'sleep 5'
|
||||
- /bin/sh -c 'echo draining web-app'
|
||||
---
|
||||
# Web App
|
||||
|
||||
Frontend web application serving HTTP on port 8080 via Unix socket.
|
||||
Three replicas with rolling updates, anti-affinity for zone spreading,
|
||||
and lifecycle hooks for cache warm-up and graceful drain.
|
||||
|
||||
> **Production substitution**: this example uses `/bin/sh -c 'echo ...
|
||||
> sleep 3600'` so it runs out-of-the-box on any Linux machine. In a
|
||||
> real deployment, replace the `runtime.command` with your actual
|
||||
> binary, e.g. `/usr/bin/httpd -f /etc/orca/web-app/httpd.conf`, and
|
||||
> replace the lifecycle hooks with your real scripts
|
||||
> (`/usr/local/bin/warm-cache.sh`, `/usr/local/bin/drain.sh`).
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
kind: Job
|
||||
name: worker
|
||||
runtime:
|
||||
one_of: process
|
||||
command: /bin/echo worker processing batch
|
||||
timeout: 300s
|
||||
env:
|
||||
QUEUE_URL: unix:///run/orca/alloc-worker/queue.sock
|
||||
BATCH_SIZE: "100"
|
||||
LOG_LEVEL: debug
|
||||
lifecycle:
|
||||
post_start:
|
||||
- /bin/sh -c 'echo worker registered'
|
||||
pre_stop:
|
||||
- /bin/sh -c 'echo draining worker queue'
|
||||
---
|
||||
# Worker
|
||||
|
||||
One-shot batch worker that processes items from a queue. Runs once,
|
||||
exits on completion or after 300s timeout. Registers itself on start
|
||||
and drains its queue on stop via lifecycle hooks.
|
||||
|
||||
> **Production substitution**: replace `runtime.command` with your
|
||||
> actual worker binary, e.g. `/usr/bin/python3 /opt/orca/jobs/worker.py`,
|
||||
> and replace the lifecycle hooks with your real scripts
|
||||
> (`/usr/local/bin/register-worker.sh`, `/usr/local/bin/drain-queue.sh`).
|
||||
+22
-1
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -337,6 +338,12 @@ func toTaskSpecs(in []jobspec.TaskSpec) []engine.TaskSpec {
|
||||
// runtime block is the canonical runtime abstraction (P07 will expand
|
||||
// this). When Runtime is nil we emit a single no-op task to preserve
|
||||
// the legacy "at least one task" invariant.
|
||||
//
|
||||
// The runtime command string is split into binary + args via
|
||||
// splitCommand so that exec.Command receives the binary path and the
|
||||
// args as separate elements. Without this split, a command like
|
||||
// "/usr/bin/httpd -f /etc/orca/web-app/httpd.conf" is treated as a
|
||||
// single file path and fork/exec fails with "no such file or directory".
|
||||
func workloadToTaskSpecs(spec *jobspec.WorkloadSpec) []engine.TaskSpec {
|
||||
if spec == nil {
|
||||
return nil
|
||||
@@ -344,8 +351,22 @@ func workloadToTaskSpecs(spec *jobspec.WorkloadSpec) []engine.TaskSpec {
|
||||
if spec.Runtime == nil {
|
||||
return []engine.TaskSpec{{Name: spec.Name, Command: "/bin/true"}}
|
||||
}
|
||||
bin, args := splitCommand(spec.Runtime.Command)
|
||||
return []engine.TaskSpec{{
|
||||
Name: spec.Name,
|
||||
Command: spec.Runtime.Command,
|
||||
Command: bin,
|
||||
Args: args,
|
||||
}}
|
||||
}
|
||||
|
||||
// splitCommand splits a command string into binary + args using
|
||||
// strings.Fields (handles multiple spaces/tabs). If the string is empty
|
||||
// or all-whitespace, returns ("/bin/true", nil) so the executor still
|
||||
// has a valid binary to run.
|
||||
func splitCommand(s string) (string, []string) {
|
||||
parts := strings.Fields(s)
|
||||
if len(parts) == 0 {
|
||||
return "/bin/true", nil
|
||||
}
|
||||
return parts[0], parts[1:]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
)
|
||||
|
||||
func TestSplitCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantBin string
|
||||
wantArgs []string
|
||||
}{
|
||||
{
|
||||
name: "single binary",
|
||||
input: "/bin/true",
|
||||
wantBin: "/bin/true",
|
||||
wantArgs: nil,
|
||||
},
|
||||
{
|
||||
name: "binary with one arg",
|
||||
input: "/bin/echo hello",
|
||||
wantBin: "/bin/echo",
|
||||
wantArgs: []string{"hello"},
|
||||
},
|
||||
{
|
||||
name: "binary with multiple args",
|
||||
input: "/usr/bin/httpd -f /etc/orca/web-app/httpd.conf",
|
||||
wantBin: "/usr/bin/httpd",
|
||||
wantArgs: []string{"-f", "/etc/orca/web-app/httpd.conf"},
|
||||
},
|
||||
{
|
||||
name: "binary with sh -c and quoted string",
|
||||
input: "/bin/sh -c 'echo hello world'",
|
||||
wantBin: "/bin/sh",
|
||||
wantArgs: []string{"-c", "'echo", "hello", "world'"},
|
||||
},
|
||||
{
|
||||
name: "empty command falls back to /bin/true",
|
||||
input: "",
|
||||
wantBin: "/bin/true",
|
||||
wantArgs: nil,
|
||||
},
|
||||
{
|
||||
name: "all-whitespace command falls back to /bin/true",
|
||||
input: " \t ",
|
||||
wantBin: "/bin/true",
|
||||
wantArgs: nil,
|
||||
},
|
||||
{
|
||||
name: "multiple spaces between args",
|
||||
input: "/bin/echo hello world",
|
||||
wantBin: "/bin/echo",
|
||||
wantArgs: []string{"hello", "world"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotBin, gotArgs := splitCommand(tt.input)
|
||||
if gotBin != tt.wantBin {
|
||||
t.Errorf("splitCommand(%q) bin = %q, want %q", tt.input, gotBin, tt.wantBin)
|
||||
}
|
||||
if len(gotArgs) != len(tt.wantArgs) {
|
||||
t.Errorf("splitCommand(%q) args len = %d, want %d (got %v, want %v)",
|
||||
tt.input, len(gotArgs), len(tt.wantArgs), gotArgs, tt.wantArgs)
|
||||
return
|
||||
}
|
||||
for i, a := range gotArgs {
|
||||
if a != tt.wantArgs[i] {
|
||||
t.Errorf("splitCommand(%q) args[%d] = %q, want %q",
|
||||
tt.input, i, a, tt.wantArgs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkloadToTaskSpecs_SplitsCommand(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Name: "web-app",
|
||||
Runtime: &jobspec.RuntimeBlock{
|
||||
OneOf: "process",
|
||||
Command: "/usr/bin/httpd -f /etc/orca/web-app/httpd.conf",
|
||||
},
|
||||
}
|
||||
tasks := workloadToTaskSpecs(spec)
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("expected 1 task, got %d", len(tasks))
|
||||
}
|
||||
if tasks[0].Command != "/usr/bin/httpd" {
|
||||
t.Errorf("expected Command=/usr/bin/httpd, got %q", tasks[0].Command)
|
||||
}
|
||||
if len(tasks[0].Args) != 2 {
|
||||
t.Fatalf("expected 2 args, got %d (%v)", len(tasks[0].Args), tasks[0].Args)
|
||||
}
|
||||
if tasks[0].Args[0] != "-f" || tasks[0].Args[1] != "/etc/orca/web-app/httpd.conf" {
|
||||
t.Errorf("expected args [-f /etc/orca/web-app/httpd.conf], got %v", tasks[0].Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkloadToTaskSpecs_NilRuntimeUsesBinTrue(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Name: "noop",
|
||||
}
|
||||
tasks := workloadToTaskSpecs(spec)
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("expected 1 task, got %d", len(tasks))
|
||||
}
|
||||
if tasks[0].Command != "/bin/true" {
|
||||
t.Errorf("expected Command=/bin/true, got %q", tasks[0].Command)
|
||||
}
|
||||
if len(tasks[0].Args) != 0 {
|
||||
t.Errorf("expected 0 args, got %d (%v)", len(tasks[0].Args), tasks[0].Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkloadToTaskSpecs_EmptyCommandUsesBinTrue(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Name: "empty",
|
||||
Runtime: &jobspec.RuntimeBlock{
|
||||
OneOf: "process",
|
||||
Command: "",
|
||||
},
|
||||
}
|
||||
tasks := workloadToTaskSpecs(spec)
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("expected 1 task, got %d", len(tasks))
|
||||
}
|
||||
if tasks[0].Command != "/bin/true" {
|
||||
t.Errorf("expected Command=/bin/true, got %q", tasks[0].Command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkloadToTaskSpecs_NilSpecReturnsNil(t *testing.T) {
|
||||
tasks := workloadToTaskSpecs(nil)
|
||||
if tasks != nil {
|
||||
t.Errorf("expected nil, got %v", tasks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package cluster holds cluster-wide invariants that are not owned
|
||||
// by a single subsystem. The first inhabitant is the lead-eligibility
|
||||
// rule R-003: the cluster lead is always a bare Linux node; Proxmox
|
||||
// nodes are permanently ineligible because their kernel is shared
|
||||
// with guest VMs/containers and a lead failure there takes down the
|
||||
// hypervisor too.
|
||||
//
|
||||
// The package is deliberately decoupled from the scheduler: it owns
|
||||
// its own minimal NodeInfo (Hostname + Kind) so it can be unit-tested
|
||||
// without pulling in the scheduler's capacity model. The scheduler's
|
||||
// scheduler.NodeInfo has a `Kind string` field with the same values
|
||||
// ("linux", "proxmox"); callers convert at the boundary.
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NodeKind classifies a node for lead-eligibility purposes (R-003).
|
||||
// The string values match scheduler.NodeInfo.Kind and model.NodeKind
|
||||
// so callers can pass either representation through without mapping.
|
||||
type NodeKind string
|
||||
|
||||
const (
|
||||
// NodeKindLinux is a bare Linux node — lead-eligible (R-003).
|
||||
NodeKindLinux NodeKind = "linux"
|
||||
// NodeKindProxmox is a Proxmox VE host — permanently lead-
|
||||
// ineligible (R-003): the hypervisor kernel is shared with
|
||||
// guests, so a lead process there is a blast-radius hazard.
|
||||
NodeKindProxmox NodeKind = "proxmox"
|
||||
)
|
||||
|
||||
// ErrProxmoxNotLead is returned when a Proxmox node is proposed as
|
||||
// the new cluster lead (R-003).
|
||||
var ErrProxmoxNotLead = errors.New("Proxmox nodes cannot hold the cluster lead role (R-003)")
|
||||
|
||||
// ErrNodeNotRegistered is returned when the proposed lead is not in
|
||||
// the supplied node list at all.
|
||||
var ErrNodeNotRegistered = errors.New("cluster: proposed lead is not a registered node")
|
||||
|
||||
// NodeInfo is the minimal node projection the lead rules need. It is
|
||||
// intentionally smaller than scheduler.NodeInfo so this package has
|
||||
// no upstream dependency on the scheduler.
|
||||
type NodeInfo struct {
|
||||
Hostname string
|
||||
Kind NodeKind
|
||||
}
|
||||
|
||||
// IsLeadEligible reports whether a node of the given kind may hold
|
||||
// the cluster lead role (R-003). Linux nodes are eligible; Proxmox
|
||||
// nodes are permanently ineligible; any other kind (including the
|
||||
// empty string) is treated as ineligible.
|
||||
func IsLeadEligible(kind NodeKind) bool {
|
||||
return kind == NodeKindLinux
|
||||
}
|
||||
|
||||
// ValidateLeadRotation checks that newLead is a registered Linux node
|
||||
// and refuses Proxmox nodes with ErrProxmoxNotLead (R-003). It returns
|
||||
// ErrNodeNotRegistered when newLead is not in nodes at all. The check
|
||||
// is case-sensitive on hostname; node registries in Orca are
|
||||
// case-normalized at the store layer so this matches reality.
|
||||
func ValidateLeadRotation(newLead string, nodes []NodeInfo) error {
|
||||
for _, n := range nodes {
|
||||
if n.Hostname != newLead {
|
||||
continue
|
||||
}
|
||||
if n.Kind == NodeKindProxmox {
|
||||
return ErrProxmoxNotLead
|
||||
}
|
||||
if n.Kind == NodeKindLinux {
|
||||
return nil
|
||||
}
|
||||
// Registered but neither linux nor proxmox (e.g. "localhost"
|
||||
// auto-registered node, or a future kind). Treat unknown kinds
|
||||
// as ineligible rather than guessing.
|
||||
return fmt.Errorf("cluster: node %q has ineligible kind %q: %w", newLead, n.Kind, ErrProxmoxNotLead)
|
||||
}
|
||||
// Not found in the registry at all.
|
||||
return fmt.Errorf("cluster: node %q not found: %w", newLead, ErrNodeNotRegistered)
|
||||
}
|
||||
|
||||
// String renders a NodeKind for logs. It lowercases to match the
|
||||
// on-disk representation regardless of how the caller constructed it.
|
||||
func (k NodeKind) String() string { return strings.ToLower(string(k)) }
|
||||
@@ -0,0 +1,119 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsLeadEligible(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
kind NodeKind
|
||||
want bool
|
||||
}{
|
||||
{"linux", NodeKindLinux, true},
|
||||
{"proxmox", NodeKindProxmox, false},
|
||||
{"empty", "", false},
|
||||
{"unknown", NodeKind("foo"), false},
|
||||
{"localhost", NodeKind("localhost"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := IsLeadEligible(tc.kind); got != tc.want {
|
||||
t.Errorf("IsLeadEligible(%q) = %v, want %v", tc.kind, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeadRotation_LinuxOK(t *testing.T) {
|
||||
nodes := []NodeInfo{
|
||||
{Hostname: "n1", Kind: NodeKindLinux},
|
||||
{Hostname: "n2", Kind: NodeKindLinux},
|
||||
{Hostname: "pve1", Kind: NodeKindProxmox},
|
||||
}
|
||||
if err := ValidateLeadRotation("n2", nodes); err != nil {
|
||||
t.Errorf("ValidateLeadRotation(n2): err = %v, want nil", err)
|
||||
}
|
||||
if err := ValidateLeadRotation("n1", nodes); err != nil {
|
||||
t.Errorf("ValidateLeadRotation(n1): err = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeadRotation_ProxmoxRefused(t *testing.T) {
|
||||
nodes := []NodeInfo{
|
||||
{Hostname: "n1", Kind: NodeKindLinux},
|
||||
{Hostname: "pve1", Kind: NodeKindProxmox},
|
||||
}
|
||||
err := ValidateLeadRotation("pve1", nodes)
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLeadRotation(pve1): expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrProxmoxNotLead) {
|
||||
t.Errorf("err = %v, want ErrProxmoxNotLead", err)
|
||||
}
|
||||
if got := err.Error(); got != "Proxmox nodes cannot hold the cluster lead role (R-003)" {
|
||||
t.Errorf("err message = %q, want R-003 text verbatim", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeadRotation_UnknownNode(t *testing.T) {
|
||||
nodes := []NodeInfo{
|
||||
{Hostname: "n1", Kind: NodeKindLinux},
|
||||
}
|
||||
err := ValidateLeadRotation("ghost", nodes)
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLeadRotation(ghost): expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrNodeNotRegistered) {
|
||||
t.Errorf("err = %v, want ErrNodeNotRegistered", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeadRotation_EmptyList(t *testing.T) {
|
||||
err := ValidateLeadRotation("anyone", nil)
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLeadRotation on empty list: expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrNodeNotRegistered) {
|
||||
t.Errorf("err = %v, want ErrNodeNotRegistered", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeadRotation_IneligibleKindRegistered(t *testing.T) {
|
||||
// A node registered with a kind that is neither linux nor
|
||||
// proxmox (e.g. the auto-registered "localhost" kind) is
|
||||
// rejected as ineligible, not as unregistered.
|
||||
nodes := []NodeInfo{
|
||||
{Hostname: "self", Kind: NodeKind("localhost")},
|
||||
}
|
||||
err := ValidateLeadRotation("self", nodes)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for localhost kind, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrProxmoxNotLead) {
|
||||
t.Errorf("err = %v, want wrapped ErrProxmoxNotLead (ineligible)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeadRotation_CaseSensitive(t *testing.T) {
|
||||
// Hostnames are case-normalized at the store layer; the rule
|
||||
// matches exactly. "N1" is NOT the same as "n1".
|
||||
nodes := []NodeInfo{
|
||||
{Hostname: "n1", Kind: NodeKindLinux},
|
||||
}
|
||||
if err := ValidateLeadRotation("N1", nodes); !errors.Is(err, ErrNodeNotRegistered) {
|
||||
t.Errorf("N1 (case mismatch): err = %v, want ErrNodeNotRegistered", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeKindString(t *testing.T) {
|
||||
if got := NodeKindLinux.String(); got != "linux" {
|
||||
t.Errorf("Linux.String() = %q", got)
|
||||
}
|
||||
if got := NodeKindProxmox.String(); got != "proxmox" {
|
||||
t.Errorf("Proxmox.String() = %q", got)
|
||||
}
|
||||
// Uppercase constructor should lower-case.
|
||||
if got := NodeKind("PROXMOX").String(); got != "proxmox" {
|
||||
t.Errorf("PROXMOX.String() = %q, want proxmox", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package emitter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
"git.cloudinit.dev/coreci/orca/internal/storage"
|
||||
)
|
||||
|
||||
// SyncthingEmitter is the Layer-4 emitter for the per-namespace
|
||||
// Syncthing config files (REQ-081). For every volume in spec.Volumes
|
||||
// that carries a `replicate:` list, the emitter renders one Syncthing
|
||||
// `config.xml` at /etc/syncthing/orca-<ns>-<volume>.xml containing the
|
||||
// content-addressed folder (storage.FolderID), the device list (all
|
||||
// peers in the namespace), and the volume path.
|
||||
//
|
||||
// Syncthing configs are kind-agnostic — they apply to any workload
|
||||
// (Job, Service, DaemonSet) that declares a replicated volume. The
|
||||
// emitter is therefore registered on the Registry under every
|
||||
// kind:runtime key the other emitters use, but it is intended to be
|
||||
// composed by the caller (the caller renders both the systemd unit and
|
||||
// the Syncthing config for the same spec). For the v0.9-P09 spike the
|
||||
// emitter is invoked directly; the composition lands in a later phase.
|
||||
//
|
||||
// The emitter is CLI-side only: it renders the XML; the SSH-push
|
||||
// transport SCPs the file to each peer; the Syncthing apt package on
|
||||
// the peer reads it. No Syncthing Go client is linked.
|
||||
type SyncthingEmitter struct{}
|
||||
|
||||
// syncthingConfigDir is the canonical directory for rendered Syncthing
|
||||
// configs on a peer (R-005). The emitter writes one file per
|
||||
// replicated volume.
|
||||
const syncthingConfigDir = "/etc/syncthing"
|
||||
|
||||
// localDeviceAddress is the address the local peer's own device entry
|
||||
// uses. "dynamic" tells Syncthing this peer is the listener (it does
|
||||
// not dial out to itself).
|
||||
const localDeviceAddress = "dynamic"
|
||||
|
||||
// peerDeviceAddressTemplate renders the Sync listen address for a
|
||||
// remote peer. The peer hostname (from the ReplicateTo list) is used
|
||||
// as the host; the default Sync port is 22000.
|
||||
const peerDeviceAddressTemplate = "tcp://%s:22000"
|
||||
|
||||
// Render renders one Syncthing config XML file per replicated volume
|
||||
// in the spec. A volume is "replicated" when its VolumeSpec carries a
|
||||
// non-empty `replicate:` list — encoded in VolumeSpec.Source as the
|
||||
// comma-separated peer list prefixed with `replicate:` (e.g.
|
||||
// `replicate:peer-b,peer-c`). This keeps the VolumeSpec shape stable
|
||||
// (the v0.9 VolumeSpec has no explicit Replicate field; the emitter
|
||||
// parses it from Source).
|
||||
//
|
||||
// For each replicated volume, the emitter:
|
||||
//
|
||||
// 1. Builds a VolumeReplication (namespace = spec.Name's namespace,
|
||||
// volume name, source path = VolumeSpec.Target).
|
||||
// 2. Builds the peer device list (the local peer + every peer in the
|
||||
// `replicate:` list). The local peer's device ID is derived
|
||||
// deterministically from the node hostname (the real device ID is
|
||||
// discovered from the peer registry in a later phase; for the
|
||||
// spike a deterministic placeholder keeps the rendered config
|
||||
// byte-stable).
|
||||
// 3. Calls storage.RenderSyncthingConfig + storage.RenderSyncthingXML
|
||||
// to produce the config file content.
|
||||
//
|
||||
// Returns an error if the spec is nil or the node is nil (the node is
|
||||
// required to identify the local peer). Workloads with no replicated
|
||||
// volumes return an empty (non-nil) slice — the emitter is a no-op for
|
||||
// them.
|
||||
func (SyncthingEmitter) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
|
||||
if spec == nil {
|
||||
return nil, errors.New("emitter/syncthing: spec is nil")
|
||||
}
|
||||
if node == nil {
|
||||
return nil, errors.New("emitter/syncthing: node is nil (local peer unknown)")
|
||||
}
|
||||
var files []File
|
||||
for _, vol := range spec.Volumes {
|
||||
peers, ok := parseReplicateList(vol.Source)
|
||||
if !ok || len(peers) == 0 {
|
||||
continue
|
||||
}
|
||||
path := vol.Target
|
||||
if strings.TrimSpace(path) == "" {
|
||||
path = vol.Source
|
||||
}
|
||||
rep := storage.VolumeReplication{
|
||||
Namespace: spec.Name,
|
||||
VolumeName: vol.Name,
|
||||
SourcePath: path,
|
||||
ReplicateTo: peers,
|
||||
SyncMode: "sendreceive",
|
||||
}
|
||||
devices := buildSyncthingDevices(node, peers)
|
||||
cfg, err := storage.RenderSyncthingConfig(rep, devices)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("emitter/syncthing: render config for volume %q: %w", vol.Name, err)
|
||||
}
|
||||
xml, err := storage.RenderSyncthingXML(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("emitter/syncthing: render xml for volume %q: %w", vol.Name, err)
|
||||
}
|
||||
files = append(files, File{
|
||||
Path: fmt.Sprintf("%s/orca-%s-%s.xml", syncthingConfigDir, spec.Name, vol.Name),
|
||||
Content: xml,
|
||||
Mode: "0644",
|
||||
})
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// parseReplicateList extracts the peer list from a VolumeSpec.Source
|
||||
// value of the form `replicate:peer-b,peer-c`. Returns the peer list
|
||||
// and true when the Source carries a replicate directive; returns nil
|
||||
// and false otherwise (the volume is not replicated).
|
||||
func parseReplicateList(source string) ([]string, bool) {
|
||||
s := strings.TrimSpace(source)
|
||||
if !strings.HasPrefix(s, "replicate:") {
|
||||
return nil, false
|
||||
}
|
||||
rest := strings.TrimPrefix(s, "replicate:")
|
||||
parts := strings.Split(rest, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// buildSyncthingDevices builds the Syncthing device list for the
|
||||
// rendered config. The local peer (the node the config is being
|
||||
// rendered for) is first, with the local device address ("dynamic").
|
||||
// Each remote peer in the replicate list follows, with a
|
||||
// tcp://<peer>:22000 address. Device IDs are deterministic placeholders
|
||||
// derived from the peer name (the real device IDs are discovered from
|
||||
// the peer registry in a later phase; the placeholder keeps the
|
||||
// rendered config byte-stable across re-runs).
|
||||
func buildSyncthingDevices(node *Node, peers []string) []storage.SyncthingDevice {
|
||||
devices := make([]storage.SyncthingDevice, 0, len(peers)+1)
|
||||
devices = append(devices, storage.SyncthingDevice{
|
||||
ID: syncthingDeviceID(node.Hostname),
|
||||
Name: node.Hostname,
|
||||
Address: localDeviceAddress,
|
||||
})
|
||||
for _, p := range peers {
|
||||
devices = append(devices, storage.SyncthingDevice{
|
||||
ID: syncthingDeviceID(p),
|
||||
Name: p,
|
||||
Address: fmt.Sprintf(peerDeviceAddressTemplate, p),
|
||||
})
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
// syncthingDeviceID returns a deterministic, stable device-ID
|
||||
// placeholder for the given peer name. The placeholder is a fixed
|
||||
// 52-char string (Syncthing device IDs are 52-char base32) derived by
|
||||
// padding the peer name. The real device ID (discovered from the peer
|
||||
// registry / cluster/peers/) replaces this in a later phase; for the
|
||||
// v0.9 spike the placeholder keeps the rendered config byte-stable so
|
||||
// the SSH-push idempotency check works.
|
||||
func syncthingDeviceID(peerName string) string {
|
||||
const idLen = 52
|
||||
name := strings.TrimSpace(peerName)
|
||||
if len(name) >= idLen {
|
||||
return name[:idLen]
|
||||
}
|
||||
pad := strings.Repeat("X", idLen-len(name))
|
||||
return name + pad
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package emitter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
)
|
||||
|
||||
func TestSyncthingEmitter_TwoReplicatedVolumes_TwoFiles(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "team-alpha",
|
||||
Volumes: []jobspec.VolumeSpec{
|
||||
{Name: "data", Source: "replicate:peer-b,peer-c", Target: "/var/lib/orca/data"},
|
||||
{Name: "logs", Source: "replicate:peer-b", Target: "/var/lib/orca/logs"},
|
||||
{Name: "cache", Source: "/local/cache", Target: "/cache"}, // not replicated
|
||||
},
|
||||
}
|
||||
node := &Node{Hostname: "peer-a", Runtime: []string{"process"}}
|
||||
files, err := (SyncthingEmitter{}).Render(spec, node)
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected 2 files (only replicated volumes), got %d", len(files))
|
||||
}
|
||||
for _, f := range files {
|
||||
if !strings.HasPrefix(f.Path, "/etc/syncthing/orca-team-alpha-") {
|
||||
t.Errorf("path %q does not start with /etc/syncthing/orca-team-alpha-", f.Path)
|
||||
}
|
||||
if !strings.HasSuffix(f.Path, ".xml") {
|
||||
t.Errorf("path %q does not end with .xml", f.Path)
|
||||
}
|
||||
if f.Mode != "0644" {
|
||||
t.Errorf("mode = %q, want 0644", f.Mode)
|
||||
}
|
||||
if !strings.Contains(f.Content, "<configuration") {
|
||||
t.Errorf("content of %q is not syncthing config XML", f.Path)
|
||||
}
|
||||
}
|
||||
// File 1: data volume, replicated to peer-b and peer-c.
|
||||
if !strings.Contains(files[0].Path, "team-alpha-data") {
|
||||
t.Errorf("first file path = %q, want team-alpha-data", files[0].Path)
|
||||
}
|
||||
if !strings.Contains(files[0].Content, "peer-b") || !strings.Contains(files[0].Content, "peer-c") {
|
||||
t.Errorf("data volume config missing peer-b or peer-c")
|
||||
}
|
||||
// File 2: logs volume, replicated to peer-b only.
|
||||
if !strings.Contains(files[1].Path, "team-alpha-logs") {
|
||||
t.Errorf("second file path = %q, want team-alpha-logs", files[1].Path)
|
||||
}
|
||||
if !strings.Contains(files[1].Content, "peer-b") {
|
||||
t.Errorf("logs volume config missing peer-b")
|
||||
}
|
||||
if strings.Contains(files[1].Content, "tcp://peer-c") {
|
||||
t.Errorf("logs volume config should not contain peer-c")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingEmitter_NoReplicatedVolumes_Empty(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Job",
|
||||
Name: "batch",
|
||||
Volumes: []jobspec.VolumeSpec{
|
||||
{Name: "cache", Source: "/local/cache", Target: "/cache"},
|
||||
},
|
||||
}
|
||||
node := &Node{Hostname: "peer-a"}
|
||||
files, err := (SyncthingEmitter{}).Render(spec, node)
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Errorf("expected 0 files for non-replicated volumes, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingEmitter_NoVolumes_Empty(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "batch"}
|
||||
node := &Node{Hostname: "peer-a"}
|
||||
files, err := (SyncthingEmitter{}).Render(spec, node)
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Errorf("expected 0 files, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingEmitter_NilSpec_Error(t *testing.T) {
|
||||
node := &Node{Hostname: "peer-a"}
|
||||
if _, err := (SyncthingEmitter{}).Render(nil, node); err == nil {
|
||||
t.Error("nil spec: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingEmitter_NilNode_Error(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "x"}
|
||||
if _, err := (SyncthingEmitter{}).Render(spec, nil); err == nil {
|
||||
t.Error("nil node: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingEmitter_LocalDeviceFirst(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "ns",
|
||||
Volumes: []jobspec.VolumeSpec{{Name: "data", Source: "replicate:peer-b", Target: "/data"}},
|
||||
}
|
||||
node := &Node{Hostname: "peer-a"}
|
||||
files, err := (SyncthingEmitter{}).Render(spec, node)
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(files))
|
||||
}
|
||||
// Local peer address is "dynamic"; remote peer uses tcp://...
|
||||
if !strings.Contains(files[0].Content, "dynamic") {
|
||||
t.Errorf("local device address (dynamic) missing from config")
|
||||
}
|
||||
if !strings.Contains(files[0].Content, "tcp://peer-b:22000") {
|
||||
t.Errorf("remote peer address missing from config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReplicateList_OK(t *testing.T) {
|
||||
peers, ok := parseReplicateList("replicate:peer-b,peer-c,peer-d")
|
||||
if !ok {
|
||||
t.Fatal("expected ok")
|
||||
}
|
||||
if len(peers) != 3 || peers[0] != "peer-b" || peers[1] != "peer-c" || peers[2] != "peer-d" {
|
||||
t.Errorf("peers = %v, want [peer-b peer-c peer-d]", peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReplicateList_NotReplicated(t *testing.T) {
|
||||
_, ok := parseReplicateList("/local/path")
|
||||
if ok {
|
||||
t.Error("non-replicate source should return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReplicateList_EmptyPeerList(t *testing.T) {
|
||||
peers, ok := parseReplicateList("replicate:")
|
||||
if !ok {
|
||||
t.Error("replicate: prefix should return ok=true")
|
||||
}
|
||||
if len(peers) != 0 {
|
||||
t.Errorf("peers = %v, want empty", peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingDeviceID_Stable(t *testing.T) {
|
||||
a := syncthingDeviceID("peer-a")
|
||||
b := syncthingDeviceID("peer-a")
|
||||
if a != b {
|
||||
t.Errorf("syncthingDeviceID not stable: %q vs %q", a, b)
|
||||
}
|
||||
if len(a) != 52 {
|
||||
t.Errorf("device ID length = %d, want 52", len(a))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingDeviceID_DifferentPeers(t *testing.T) {
|
||||
a := syncthingDeviceID("peer-a")
|
||||
b := syncthingDeviceID("peer-b")
|
||||
if a == b {
|
||||
t.Errorf("different peers produced same device ID")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# C-01 Grill Gate Evaluation — wasmtime / CGO
|
||||
|
||||
**Gate**: C-01 (wasmtime/CGO evaluation), gating P07b.
|
||||
|
||||
**Question**: The wasmtime Go binding
|
||||
(`github.com/bytecodealliance/wasmtime-go`) is CGO-based. Does adopting
|
||||
it revoke D-002 (modernc/sqlite CGO-free cross-compile story)?
|
||||
|
||||
## Evaluation
|
||||
|
||||
| Option | CGO required? | Cross-compile impact | Decision |
|
||||
|--------|---------------|----------------------|----------|
|
||||
| A. Use `bytecodealliance/wasmtime-go` (Go binding) | **YES** — the binding links libwasmtime via cgo | Revokes D-002 — Go cross-compile (`GOOS=linux GOARCH=arm64 go build`) breaks; CGO toolchain needed on every build host; static-binary story lost | REJECTED |
|
||||
| B. Use the `wasmtime` CLI (apt-installed on the peer) via SSH exec | **NO** — pure Go code, shells out to a CLI over SSH (same pattern as podman/qm/pct) | None — D-002 preserved | **ACCEPTED** |
|
||||
| C. Use an alternative pure-Go WASM runtime (e.g. wazero) | No CGO | Pure-Go alternative exists; but wazero's wasmtime-compat is incomplete (component model, WASI 0.2); different runtime semantics than the "wasmtime" operator surface promised in D-088 | DEFERRED (v0.10 evaluation if CLI-via-SSH proves insufficient) |
|
||||
|
||||
## Decision (full autonomy, auto-decision)
|
||||
|
||||
**Option B**: `WasmRuntime` uses the `wasmtime` CLI (apt-installed on
|
||||
the peer) via the SSH-push transport. It does NOT import
|
||||
`bytecodealliance/wasmtime-go` (or any other CGO package).
|
||||
|
||||
## C-01 Gate Status
|
||||
|
||||
**SATISFIED.** C-01 is satisfied:
|
||||
- wasmtime works without CGO (CLI-via-SSH pattern, identical to podman/qm/pct).
|
||||
- D-002 cross-compile story preserved (no CGO introduced anywhere in
|
||||
the runtime package or any orca Go code).
|
||||
- D-002 is NOT revoked.
|
||||
|
||||
## Recorded as D-187
|
||||
|
||||
See PROJECT.md v0.9 D-series: "wasmtime Go binding is CGO-based
|
||||
(bytecodealliance/wasmtime-go); orca uses the wasmtime CLI via SSH
|
||||
(apt-installed on peer) instead of the Go binding, avoiding CGO
|
||||
entirely. D-002 cross-compile story preserved. C-01 satisfied."
|
||||
|
||||
## Verification
|
||||
|
||||
- `go build ./internal/runtime/` succeeds with `CGO_ENABLED=0`.
|
||||
- `internal/runtime/wasm.go` imports only stdlib + sshpush (no
|
||||
wasmtime-go).
|
||||
- The full test suite (`go test ./...`) does not require CGO.
|
||||
@@ -0,0 +1,142 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
||||
)
|
||||
|
||||
// PodmanRuntime implements Runtime for the "podman" one_of. It runs
|
||||
// `podman` on the peer over the SSH-push transport (P01). The
|
||||
// transport is injected via the constructor (dependency injection).
|
||||
//
|
||||
// Container lifecycle:
|
||||
//
|
||||
// - Prepare: `podman pull <image>`
|
||||
// - Start: `podman run -d --name orca-<alloc-id> <image> <command>`
|
||||
// - Stop: `podman stop <name>` then `podman rm <name>`
|
||||
// - Status: `podman inspect --format '{{.State.Running}}' <name>`
|
||||
//
|
||||
// The container name is `orca-<alloc-id>` (sanitized to lowercase +
|
||||
// alnum). The runtime keeps no in-process state — each call is a fresh
|
||||
// SSH exec against the peer.
|
||||
type PodmanRuntime struct {
|
||||
transport *sshpush.Transport
|
||||
}
|
||||
|
||||
// NewPodmanRuntime returns a PodmanRuntime backed by the given transport.
|
||||
func NewPodmanRuntime(t *sshpush.Transport) *PodmanRuntime {
|
||||
return &PodmanRuntime{transport: t}
|
||||
}
|
||||
|
||||
// containerName returns the deterministic container name for an alloc.
|
||||
func containerName(alloc *Alloc) string {
|
||||
id := strings.ToLower(alloc.ID)
|
||||
id = strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, id)
|
||||
return "orca-" + id
|
||||
}
|
||||
|
||||
// Prepare pulls the image on the peer.
|
||||
func (p *PodmanRuntime) Prepare(ctx context.Context, alloc *Alloc) error {
|
||||
image, err := imageFor(alloc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := fmt.Sprintf("podman pull %q", image)
|
||||
if _, err := p.transport.Exec(ctx, alloc.Node, cmd); err != nil {
|
||||
return fmt.Errorf("podman: pull: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start runs `podman run -d --name <name> <image> <command>`.
|
||||
func (p *PodmanRuntime) Start(ctx context.Context, alloc *Alloc) (int, error) {
|
||||
image, err := imageFor(alloc)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cmdStr, _ := commandFor(alloc)
|
||||
name := containerName(alloc)
|
||||
cmd := fmt.Sprintf("podman run -d --name %s %q %s", name, image, cmdStr)
|
||||
out, err := p.transport.Exec(ctx, alloc.Node, cmd)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("podman: run: %w", err)
|
||||
}
|
||||
// The container ID is the first 12 chars of the printed hash. We
|
||||
// don't keep it — Stop/Status use the name — but return a stable
|
||||
// synthetic PID derived from the first 4 bytes of the hash for
|
||||
// the interface contract.
|
||||
cid := strings.TrimSpace(string(out))
|
||||
return podmanCidToPID(cid), nil
|
||||
}
|
||||
|
||||
// Stop stops and removes the container.
|
||||
func (p *PodmanRuntime) Stop(ctx context.Context, alloc *Alloc) error {
|
||||
name := containerName(alloc)
|
||||
if _, err := p.transport.Exec(ctx, alloc.Node, fmt.Sprintf("podman stop %s", name)); err != nil {
|
||||
return fmt.Errorf("podman: stop: %w", err)
|
||||
}
|
||||
if _, err := p.transport.Exec(ctx, alloc.Node, fmt.Sprintf("podman rm %s", name)); err != nil {
|
||||
return fmt.Errorf("podman: rm: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status inspects the container's running state.
|
||||
func (p *PodmanRuntime) Status(ctx context.Context, alloc *Alloc) (State, error) {
|
||||
name := containerName(alloc)
|
||||
cmd := fmt.Sprintf("podman inspect --format '{{.State.Running}}' %s", name)
|
||||
out, err := p.transport.Exec(ctx, alloc.Node, cmd)
|
||||
if err != nil {
|
||||
return StateFailed, fmt.Errorf("podman: inspect: %w", err)
|
||||
}
|
||||
v := strings.TrimSpace(string(out))
|
||||
switch v {
|
||||
case "true":
|
||||
return StateRunning, nil
|
||||
case "false":
|
||||
return StateStopped, nil
|
||||
default:
|
||||
return StateFailed, fmt.Errorf("podman: unexpected inspect output %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// podmanCidToPID converts a container ID (hex hash) to a positive int
|
||||
// PID for the interface contract. It reads up to 4 hex chars.
|
||||
func podmanCidToPID(cid string) int {
|
||||
if len(cid) < 1 {
|
||||
return 1
|
||||
}
|
||||
n := len(cid)
|
||||
if n > 4 {
|
||||
n = 4
|
||||
}
|
||||
var pid int
|
||||
for i := 0; i < n; i++ {
|
||||
c := cid[i]
|
||||
pid = (pid << 4) | int(hexVal(c))
|
||||
}
|
||||
if pid <= 0 {
|
||||
pid = 1
|
||||
}
|
||||
return pid
|
||||
}
|
||||
|
||||
func hexVal(c byte) byte {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c - '0'
|
||||
case c >= 'a' && c <= 'f':
|
||||
return c - 'a' + 10
|
||||
case c >= 'A' && c <= 'F':
|
||||
return c - 'A' + 10
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
||||
)
|
||||
|
||||
// allocNoImage returns an alloc whose Spec has a Runtime block with a
|
||||
// command but no image.
|
||||
func allocNoImage(runtime string) *Alloc {
|
||||
return &Alloc{
|
||||
ID: "x",
|
||||
Runtime: runtime,
|
||||
Spec: &jobspec.WorkloadSpec{
|
||||
Runtime: &jobspec.RuntimeBlock{Command: "/bin/true"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPodmanRuntime_HappyPath wires a fake server that responds to
|
||||
// podman pull/run/stop/rm/inspect and verifies the full lifecycle.
|
||||
func TestPodmanRuntime_HappyPath(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
|
||||
const cid = "abc123def456"
|
||||
srv.setHandler("podman pull", func(cmd string) ([]byte, int) { return nil, 0 })
|
||||
srv.setHandler("podman run", func(cmd string) ([]byte, int) { return []byte(cid + "\n"), 0 })
|
||||
srv.setHandler("podman stop", func(cmd string) ([]byte, int) { return nil, 0 })
|
||||
srv.setHandler("podman rm", func(cmd string) ([]byte, int) { return nil, 0 })
|
||||
srv.setHandler("podman inspect", func(cmd string) ([]byte, int) {
|
||||
return []byte("true\n"), 0
|
||||
})
|
||||
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
|
||||
p := NewPodmanRuntime(tr)
|
||||
a := allocWithNode("podman", "docker.io/library/alpine:latest", "sleep 30", srv.addr())
|
||||
|
||||
ctx, cancel := withTimeout(10 * time.Second)
|
||||
defer cancel()
|
||||
if err := p.Prepare(ctx, a); err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
pid, err := p.Start(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if pid <= 0 {
|
||||
t.Fatalf("pid = %d, want > 0", pid)
|
||||
}
|
||||
st, err := p.Status(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if st != StateRunning {
|
||||
t.Errorf("Status = %q, want running", st)
|
||||
}
|
||||
if err := p.Stop(ctx, a); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPodmanRuntime_StatusFalse verifies Status returns stopped when
|
||||
// the container reports running=false.
|
||||
func TestPodmanRuntime_StatusFalse(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("podman inspect", func(cmd string) ([]byte, int) {
|
||||
return []byte("false\n"), 0
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
p := NewPodmanRuntime(tr)
|
||||
a := allocWithNode("podman", "img", "sleep 1", srv.addr())
|
||||
st, err := p.Status(context.Background(), a)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if st != StateStopped {
|
||||
t.Errorf("Status = %q, want stopped", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPodmanRuntime_StatusBadOutput verifies Status returns failed on
|
||||
// unexpected inspect output.
|
||||
func TestPodmanRuntime_StatusBadOutput(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("podman inspect", func(cmd string) ([]byte, int) {
|
||||
return []byte("garbage\n"), 0
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
p := NewPodmanRuntime(tr)
|
||||
a := allocWithNode("podman", "img", "sleep 1", srv.addr())
|
||||
if _, err := p.Status(context.Background(), a); err == nil {
|
||||
t.Error("Status with bad output should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPodmanRuntime_PrepareNoImage verifies Prepare errors when the
|
||||
// alloc has no image.
|
||||
func TestPodmanRuntime_PrepareNoImage(t *testing.T) {
|
||||
p := NewPodmanRuntime(nil)
|
||||
a := allocNoImage("podman")
|
||||
if err := p.Prepare(context.Background(), a); err == nil {
|
||||
t.Error("Prepare with no image should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPodmanRuntime_PrepareTransportError verifies Prepare propagates a
|
||||
// transport error (podman pull fails).
|
||||
func TestPodmanRuntime_PrepareTransportError(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("podman pull", func(cmd string) ([]byte, int) {
|
||||
return []byte("manifest unknown\n"), 2
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
p := NewPodmanRuntime(tr)
|
||||
a := allocWithNode("podman", "img", "sleep 1", srv.addr())
|
||||
if err := p.Prepare(context.Background(), a); err == nil {
|
||||
t.Error("Prepare with failed pull should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPodmanRuntime_StartNoImage verifies Start errors with no image.
|
||||
func TestPodmanRuntime_StartNoImage(t *testing.T) {
|
||||
p := NewPodmanRuntime(nil)
|
||||
a := allocNoImage("podman")
|
||||
if _, err := p.Start(context.Background(), a); err == nil {
|
||||
t.Error("Start with no image should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPodmanRuntime_StopTransportError verifies Stop propagates errors.
|
||||
func TestPodmanRuntime_StopTransportError(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("podman stop", func(cmd string) ([]byte, int) {
|
||||
return []byte("no such container\n"), 1
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
p := NewPodmanRuntime(tr)
|
||||
a := allocWithNode("podman", "img", "sleep 1", srv.addr())
|
||||
if err := p.Stop(context.Background(), a); err == nil {
|
||||
t.Error("Stop with missing container should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPodmanRuntime_StatusInspectError verifies Status returns failed
|
||||
// when inspect itself errors.
|
||||
func TestPodmanRuntime_StatusInspectError(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("podman inspect", func(cmd string) ([]byte, int) {
|
||||
return []byte("no such container\n"), 1
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
p := NewPodmanRuntime(tr)
|
||||
a := allocWithNode("podman", "img", "sleep 1", srv.addr())
|
||||
st, err := p.Status(context.Background(), a)
|
||||
if err == nil {
|
||||
t.Error("Status with inspect error should error")
|
||||
}
|
||||
if st != StateFailed {
|
||||
t.Errorf("Status = %q, want failed", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPodmanCidToPID verifies the synthetic PID derivation.
|
||||
func TestPodmanCidToPID(t *testing.T) {
|
||||
if got := podmanCidToPID(""); got != 1 {
|
||||
t.Errorf("empty cid -> %d, want 1", got)
|
||||
}
|
||||
if got := podmanCidToPID("a"); got <= 0 {
|
||||
t.Errorf("single hex -> %d, want > 0", got)
|
||||
}
|
||||
if got := podmanCidToPID("abcd"); got <= 0 {
|
||||
t.Errorf("abcd -> %d, want > 0", got)
|
||||
}
|
||||
// non-hex chars fall through to 0 contributions but still yield
|
||||
// a positive result (>= 1 by the floor).
|
||||
if got := podmanCidToPID("xyz123"); got <= 0 {
|
||||
t.Errorf("xyz123 -> %d, want > 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContainerNameSanitization verifies the container-name sanitizer
|
||||
// uppercases and strips disallowed characters.
|
||||
func TestContainerNameSanitization(t *testing.T) {
|
||||
a := &Alloc{ID: "ALLOC_1.2.3", Spec: nil, Runtime: "podman"}
|
||||
got := containerName(a)
|
||||
if !strings.HasPrefix(got, "orca-") {
|
||||
t.Errorf("containerName = %q, want orca- prefix", got)
|
||||
}
|
||||
if strings.Contains(got, ".") {
|
||||
t.Errorf("containerName = %q, should not contain '.'", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPodmanRuntime_DialError verifies Prepare fails fast when the
|
||||
// peer is unreachable (no fake server).
|
||||
func TestPodmanRuntime_DialError(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
// close immediately so dial fails.
|
||||
srv.close()
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
p := NewPodmanRuntime(tr)
|
||||
a := allocWithNode("podman", "img", "sleep 1", srv.addr())
|
||||
if err := p.Prepare(context.Background(), a); err == nil {
|
||||
t.Error("Prepare against dead peer should error")
|
||||
} else if !errors.Is(err, sshpush.ErrTransient) && !errors.Is(err, sshpush.ErrPermanent) {
|
||||
// acceptable: either transient (retry exhausted) or permanent.
|
||||
t.Logf("Prepare err (acceptable): %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StopGrace is the default grace period between SIGTERM and SIGKILL
|
||||
// for ProcessRuntime.Stop (10s, matching the systemd default TimeoutStopSec).
|
||||
const StopGrace = 10 * time.Second
|
||||
|
||||
// ProcessRuntime implements Runtime for the "process" one_of using
|
||||
// os/exec. It is the in-process equivalent of the systemd unit the CLI
|
||||
// emits in production: the CLI emits a .service file and the peer's
|
||||
// systemd runs the process; ProcessRuntime starts the process directly
|
||||
// in the current Go process. It is intended for LOCAL testing and
|
||||
// hermetic CI — NOT for production (production uses the systemd emitter
|
||||
// + the peer's systemd, not this in-process path).
|
||||
//
|
||||
// It tracks started PIDs in an in-memory map; restarts of the CLI lose
|
||||
// that state (acceptable for the local-test use case).
|
||||
type ProcessRuntime struct {
|
||||
mu sync.Mutex
|
||||
pids map[string]int // alloc.ID -> PID
|
||||
procs map[int]*os.Process // PID -> process handle
|
||||
stopped map[string]bool // alloc.ID -> reported stopped after Stop
|
||||
}
|
||||
|
||||
// NewProcessRuntime returns a ProcessRuntime.
|
||||
func NewProcessRuntime() *ProcessRuntime {
|
||||
return &ProcessRuntime{
|
||||
pids: make(map[string]int),
|
||||
procs: make(map[int]*os.Process),
|
||||
stopped: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare is a no-op for the process runtime: the systemd unit is
|
||||
// emitted by the systemd emitter (internal/emitter), not by the runtime.
|
||||
func (p *ProcessRuntime) Prepare(ctx context.Context, alloc *Alloc) error {
|
||||
_ = ctx
|
||||
_ = alloc
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start execs the alloc's command and returns the PID. The process is
|
||||
// left running in the background; Stop terminates it.
|
||||
func (p *ProcessRuntime) Start(ctx context.Context, alloc *Alloc) (int, error) {
|
||||
cmdStr, err := commandFor(alloc)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Parse the command string into argv. A leading "exec" form
|
||||
// (shell-style) is NOT supported — the command must be a direct
|
||||
// argv[0] + args. Split on whitespace (simple, matches the existing
|
||||
// executor.go behaviour which takes Command + Args separately).
|
||||
parts := strings.Fields(cmdStr)
|
||||
if len(parts) == 0 {
|
||||
return 0, fmt.Errorf("process: empty command for alloc %s", alloc.ID)
|
||||
}
|
||||
|
||||
// Use a detached context so the process survives the request
|
||||
// context cancellation (the request ends; the workload keeps
|
||||
// running until Stop). We apply our own timeout for Start only.
|
||||
startCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(startCtx, parts[0], parts[1:]...)
|
||||
// Detach the child from the parent's process group so it survives.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
// Discard output for the runtime; the systemd unit captures logs
|
||||
// in production. For tests, callers that need output run their
|
||||
// own exec.Command.
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("process: start: %w", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
|
||||
// Background-reap the process so it doesn't become a zombie; we
|
||||
// only need the PID for Stop/Status. When the process exits
|
||||
// naturally, mark the alloc as stopped.
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
p.mu.Lock()
|
||||
delete(p.procs, pid)
|
||||
// Only mark stopped if the alloc is still associated with
|
||||
// this PID (Stop may have already removed the mapping).
|
||||
if cur, ok := p.pids[alloc.ID]; ok && cur == pid {
|
||||
delete(p.pids, alloc.ID)
|
||||
p.stopped[alloc.ID] = true
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}()
|
||||
|
||||
p.mu.Lock()
|
||||
p.pids[alloc.ID] = pid
|
||||
p.procs[pid] = cmd.Process
|
||||
delete(p.stopped, alloc.ID)
|
||||
p.mu.Unlock()
|
||||
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
// Stop sends SIGTERM, waits the grace period, then SIGKILL.
|
||||
func (p *ProcessRuntime) Stop(ctx context.Context, alloc *Alloc) error {
|
||||
p.mu.Lock()
|
||||
pid, ok := p.pids[alloc.ID]
|
||||
proc := p.procs[pid]
|
||||
p.mu.Unlock()
|
||||
if !ok || proc == nil {
|
||||
return nil // not running; idempotent
|
||||
}
|
||||
// SIGTERM the process group (negative PID).
|
||||
_ = syscall.Kill(-pid, syscall.SIGTERM)
|
||||
|
||||
grace := StopGrace
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
if remaining := time.Until(dl); remaining > 0 && remaining < grace {
|
||||
grace = remaining
|
||||
}
|
||||
}
|
||||
deadline := time.Now().Add(grace)
|
||||
for time.Now().Before(deadline) {
|
||||
if !p.alive(pid) {
|
||||
p.forget(alloc.ID, pid)
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
p.forget(alloc.ID, pid)
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
// SIGKILL the group.
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
p.forget(alloc.ID, pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status reports the alloc's state by checking if the process is alive.
|
||||
func (p *ProcessRuntime) Status(ctx context.Context, alloc *Alloc) (State, error) {
|
||||
_ = ctx
|
||||
p.mu.Lock()
|
||||
pid, ok := p.pids[alloc.ID]
|
||||
wasStopped := p.stopped[alloc.ID]
|
||||
p.mu.Unlock()
|
||||
if !ok {
|
||||
if wasStopped {
|
||||
return StateStopped, nil
|
||||
}
|
||||
return StatePending, nil
|
||||
}
|
||||
if !p.alive(pid) {
|
||||
// Process exited but the reaper hasn't run yet; mark it
|
||||
// stopped and clean up.
|
||||
p.forget(alloc.ID, pid)
|
||||
return StateStopped, nil
|
||||
}
|
||||
return StateRunning, nil
|
||||
}
|
||||
|
||||
// alive reports whether the process with the given PID is still running.
|
||||
func (p *ProcessRuntime) alive(pid int) bool {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
||||
// ESRCH means the process is gone.
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// forget removes the alloc/PID mapping and marks the alloc stopped.
|
||||
func (p *ProcessRuntime) forget(allocID string, pid int) {
|
||||
p.mu.Lock()
|
||||
delete(p.pids, allocID)
|
||||
delete(p.procs, pid)
|
||||
p.stopped[allocID] = true
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// PID returns the recorded PID for alloc (for tests/inspection).
|
||||
func (p *ProcessRuntime) PID(allocID string) (int, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
pid, ok := p.pids[allocID]
|
||||
if !ok {
|
||||
return 0, errors.New("process: no pid for alloc " + strconv.Quote(allocID))
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
)
|
||||
|
||||
// TestProcessRuntime_PrepareIsNoop verifies Prepare is a no-op.
|
||||
func TestProcessRuntime_PrepareIsNoop(t *testing.T) {
|
||||
p := NewProcessRuntime()
|
||||
a := alloc("process", "", "/bin/true")
|
||||
if err := p.Prepare(context.Background(), a); err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessRuntime_PrepareNilSpec exercises the nil-spec branch
|
||||
// indirectly — Prepare is a no-op regardless of input.
|
||||
func TestProcessRuntime_PrepareNilSpec(t *testing.T) {
|
||||
p := NewProcessRuntime()
|
||||
if err := p.Prepare(context.Background(), &Alloc{ID: "x"}); err != nil {
|
||||
t.Fatalf("Prepare (nil spec) should still be no-op: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessRuntime_StartStopStatus runs a real long-lived process
|
||||
// (sleep) and verifies Start -> Status(running) -> Stop -> Status(stopped).
|
||||
func TestProcessRuntime_StartStopStatus(t *testing.T) {
|
||||
if _, err := sleepBin(); err != nil {
|
||||
t.Skipf("sleep binary not available: %v", err)
|
||||
}
|
||||
p := NewProcessRuntime()
|
||||
sleepCmd, _ := sleepBin()
|
||||
a := alloc("process", "", sleepCmd+" 30")
|
||||
|
||||
ctx, cancel := withTimeout(10 * time.Second)
|
||||
defer cancel()
|
||||
pid, err := p.Start(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if pid <= 0 {
|
||||
t.Fatalf("pid = %d, want > 0", pid)
|
||||
}
|
||||
|
||||
st, err := p.Status(context.Background(), a)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if st != StateRunning {
|
||||
t.Errorf("Status = %q, want running", st)
|
||||
}
|
||||
|
||||
// Verify PID lookup.
|
||||
got, err := p.PID(a.ID)
|
||||
if err != nil || got != pid {
|
||||
t.Errorf("PID = %d/%v, want %d", got, err, pid)
|
||||
}
|
||||
|
||||
stopCtx, cancelStop := withTimeout(15 * time.Second)
|
||||
defer cancelStop()
|
||||
if err := p.Stop(stopCtx, a); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
|
||||
st2, _ := p.Status(context.Background(), a)
|
||||
if st2 != StateStopped {
|
||||
t.Errorf("Status after Stop = %q, want stopped", st2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessRuntime_StopNotStarted verifies Stop is idempotent on a
|
||||
// never-started alloc.
|
||||
func TestProcessRuntime_StopNotStarted(t *testing.T) {
|
||||
p := NewProcessRuntime()
|
||||
a := alloc("process", "", "/bin/true")
|
||||
ctx, cancel := withTimeout(2 * time.Second)
|
||||
defer cancel()
|
||||
if err := p.Stop(ctx, a); err != nil {
|
||||
t.Errorf("Stop on never-started alloc should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessRuntime_StatusPending verifies Status returns pending for
|
||||
// an alloc that was never started.
|
||||
func TestProcessRuntime_StatusPending(t *testing.T) {
|
||||
p := NewProcessRuntime()
|
||||
a := alloc("process", "", "/bin/true")
|
||||
st, err := p.Status(context.Background(), a)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if st != StatePending {
|
||||
t.Errorf("Status = %q, want pending", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessRuntime_StartNoCommand verifies Start errors on missing
|
||||
// command.
|
||||
func TestProcessRuntime_StartNoCommand(t *testing.T) {
|
||||
p := NewProcessRuntime()
|
||||
a := &Alloc{ID: "x", Spec: nil, Runtime: "process"}
|
||||
if _, err := p.Start(context.Background(), a); err == nil {
|
||||
t.Error("Start with nil spec should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessRuntime_StartEmptyCommand verifies Start errors when
|
||||
// the command parses to zero argv.
|
||||
func TestProcessRuntime_StartEmptyCommand(t *testing.T) {
|
||||
p := NewProcessRuntime()
|
||||
a := &Alloc{
|
||||
ID: "e",
|
||||
Runtime: "process",
|
||||
Spec: &jobspec.WorkloadSpec{
|
||||
Runtime: &jobspec.RuntimeBlock{Command: " "},
|
||||
},
|
||||
}
|
||||
_, err := p.Start(context.Background(), a)
|
||||
if err == nil {
|
||||
t.Error("Start with empty command should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessRuntime_StartBadBinary verifies Start propagates exec
|
||||
// errors for a missing binary.
|
||||
func TestProcessRuntime_StartBadBinary(t *testing.T) {
|
||||
p := NewProcessRuntime()
|
||||
a := alloc("process", "", "/no/such/binary/here")
|
||||
_, err := p.Start(context.Background(), a)
|
||||
if err == nil {
|
||||
t.Error("Start with missing binary should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessRuntime_StopOnFinishedProcess verifies Stop on a process
|
||||
// that already exited (e.g. /bin/true) is a no-op (no error).
|
||||
func TestProcessRuntime_StopOnFinishedProcess(t *testing.T) {
|
||||
p := NewProcessRuntime()
|
||||
a := alloc("process", "", "/bin/true")
|
||||
_, _ = p.Start(context.Background(), a)
|
||||
// Give /bin/true time to exit.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
ctx, cancel := withTimeout(5 * time.Second)
|
||||
defer cancel()
|
||||
if err := p.Stop(ctx, a); err != nil {
|
||||
t.Errorf("Stop on exited process should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessRuntime_PIDUnknown verifies PID lookup errors for an
|
||||
// unknown alloc.
|
||||
func TestProcessRuntime_PIDUnknown(t *testing.T) {
|
||||
p := NewProcessRuntime()
|
||||
if _, err := p.PID("nope"); err == nil {
|
||||
t.Error("PID unknown should error")
|
||||
}
|
||||
}
|
||||
|
||||
// sleepBin returns the sleep command path ("sleep") on this OS.
|
||||
func sleepBin() (string, error) {
|
||||
// /bin/sleep exists on Linux; on other platforms fall back to
|
||||
// "sleep" (resolved via PATH).
|
||||
if runtime.GOOS == "linux" {
|
||||
return "/bin/sleep", nil
|
||||
}
|
||||
return "sleep", nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
||||
)
|
||||
|
||||
// vmidFor returns a deterministic 5-digit VMID derived from the alloc
|
||||
// ID (hash(alloc.ID) % 99999 + 1). Used by both pveVMRuntime and
|
||||
// pveCTRuntime — VM and container IDs share the same numeric space on
|
||||
// a Proxmox node, but the namespace is sparse (one alloc = one VMID)
|
||||
// so collisions are rare in practice.
|
||||
func vmidFor(alloc *Alloc) int {
|
||||
id := allocIDHash(alloc.ID)
|
||||
if id < 100 {
|
||||
id += 100
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// PveVMRuntime implements Runtime for the "pve-vm" one_of using the
|
||||
// `qm` tool over SSH on a Proxmox peer (extends REQ-076). The transport
|
||||
// is injected.
|
||||
//
|
||||
// Lifecycle:
|
||||
//
|
||||
// - Prepare: `qm create <vmid> --memory <mb> --cores <n> --scsi0 <disk>`
|
||||
// - Start: `qm start <vmid>`
|
||||
// - Stop: `qm shutdown <vmid>` (graceful) then `qm stop <vmid>` (force)
|
||||
// - Status: `qm status <vmid>`
|
||||
type PveVMRuntime struct {
|
||||
transport *sshpush.Transport
|
||||
}
|
||||
|
||||
// NewPveVMRuntime returns a PveVMRuntime backed by the given transport.
|
||||
func NewPveVMRuntime(t *sshpush.Transport) *PveVMRuntime {
|
||||
return &PveVMRuntime{transport: t}
|
||||
}
|
||||
|
||||
// Prepare creates the VM. Memory/Cores default to 512MB / 1 if the
|
||||
// spec's Runtime block omits them; the disk is the spec's image field
|
||||
// (a Proxmox storage path like local:vmdir/disk.qcow2).
|
||||
func (v *PveVMRuntime) Prepare(ctx context.Context, alloc *Alloc) error {
|
||||
if alloc == nil || alloc.Spec == nil || alloc.Spec.Runtime == nil {
|
||||
return fmt.Errorf("pve-vm: nil alloc/spec/runtime")
|
||||
}
|
||||
vmid := vmidFor(alloc)
|
||||
image := alloc.Spec.Runtime.Image
|
||||
if image == "" {
|
||||
return fmt.Errorf("pve-vm: alloc %s has no disk (Runtime.Image)", alloc.ID)
|
||||
}
|
||||
mb := 512
|
||||
cores := 1
|
||||
cmd := fmt.Sprintf("qm create %d --memory %d --cores %d --scsi0 %q", vmid, mb, cores, image)
|
||||
if _, err := v.transport.Exec(ctx, alloc.Node, cmd); err != nil {
|
||||
return fmt.Errorf("pve-vm: create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start boots the VM.
|
||||
func (v *PveVMRuntime) Start(ctx context.Context, alloc *Alloc) (int, error) {
|
||||
vmid := vmidFor(alloc)
|
||||
if _, err := v.transport.Exec(ctx, alloc.Node, fmt.Sprintf("qm start %d", vmid)); err != nil {
|
||||
return 0, fmt.Errorf("pve-vm: start: %w", err)
|
||||
}
|
||||
return vmid, nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down then force-stops the VM.
|
||||
func (v *PveVMRuntime) Stop(ctx context.Context, alloc *Alloc) error {
|
||||
vmid := vmidFor(alloc)
|
||||
if _, err := v.transport.Exec(ctx, alloc.Node, fmt.Sprintf("qm shutdown %d", vmid)); err != nil {
|
||||
// Best-effort graceful; fall through to force stop.
|
||||
}
|
||||
if _, err := v.transport.Exec(ctx, alloc.Node, fmt.Sprintf("qm stop %d", vmid)); err != nil {
|
||||
return fmt.Errorf("pve-vm: stop: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status reports the VM state from `qm status`.
|
||||
func (v *PveVMRuntime) Status(ctx context.Context, alloc *Alloc) (State, error) {
|
||||
vmid := vmidFor(alloc)
|
||||
out, err := v.transport.Exec(ctx, alloc.Node, fmt.Sprintf("qm status %d", vmid))
|
||||
if err != nil {
|
||||
return StateFailed, fmt.Errorf("pve-vm: status: %w", err)
|
||||
}
|
||||
s := strings.ToLower(string(out))
|
||||
switch {
|
||||
case strings.Contains(s, "running"):
|
||||
return StateRunning, nil
|
||||
case strings.Contains(s, "stopped"):
|
||||
return StateStopped, nil
|
||||
default:
|
||||
return StatePending, nil
|
||||
}
|
||||
}
|
||||
|
||||
// PveCTRuntime implements Runtime for the "pve-ct" one_of using the
|
||||
// `pct` tool over SSH on a Proxmox peer (extends REQ-076).
|
||||
//
|
||||
// Lifecycle:
|
||||
//
|
||||
// - Prepare: `pct create <vmid> <template> --memory <mb> --cores <n>`
|
||||
// - Start: `pct start <vmid>`
|
||||
// - Stop: `pct shutdown <vmid>` then `pct stop <vmid>`
|
||||
// - Status: `pct status <vmid>`
|
||||
type PveCTRuntime struct {
|
||||
transport *sshpush.Transport
|
||||
}
|
||||
|
||||
// NewPveCTRuntime returns a PveCTRuntime backed by the given transport.
|
||||
func NewPveCTRuntime(t *sshpush.Transport) *PveCTRuntime {
|
||||
return &PveCTRuntime{transport: t}
|
||||
}
|
||||
|
||||
// Prepare creates the LXC container.
|
||||
func (c *PveCTRuntime) Prepare(ctx context.Context, alloc *Alloc) error {
|
||||
if alloc == nil || alloc.Spec == nil || alloc.Spec.Runtime == nil {
|
||||
return fmt.Errorf("pve-ct: nil alloc/spec/runtime")
|
||||
}
|
||||
vmid := vmidFor(alloc)
|
||||
template := alloc.Spec.Runtime.Image
|
||||
if template == "" {
|
||||
return fmt.Errorf("pve-ct: alloc %s has no template (Runtime.Image)", alloc.ID)
|
||||
}
|
||||
mb := 512
|
||||
cores := 1
|
||||
cmd := fmt.Sprintf("pct create %d %q --memory %d --cores %d", vmid, template, mb, cores)
|
||||
if _, err := c.transport.Exec(ctx, alloc.Node, cmd); err != nil {
|
||||
return fmt.Errorf("pve-ct: create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start boots the container.
|
||||
func (c *PveCTRuntime) Start(ctx context.Context, alloc *Alloc) (int, error) {
|
||||
vmid := vmidFor(alloc)
|
||||
if _, err := c.transport.Exec(ctx, alloc.Node, fmt.Sprintf("pct start %d", vmid)); err != nil {
|
||||
return 0, fmt.Errorf("pve-ct: start: %w", err)
|
||||
}
|
||||
return vmid, nil
|
||||
}
|
||||
|
||||
// Stop gracefully then force stops the container.
|
||||
func (c *PveCTRuntime) Stop(ctx context.Context, alloc *Alloc) error {
|
||||
vmid := vmidFor(alloc)
|
||||
if _, err := c.transport.Exec(ctx, alloc.Node, fmt.Sprintf("pct shutdown %d", vmid)); err != nil {
|
||||
// best-effort
|
||||
}
|
||||
if _, err := c.transport.Exec(ctx, alloc.Node, fmt.Sprintf("pct stop %d", vmid)); err != nil {
|
||||
return fmt.Errorf("pve-ct: stop: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status reports the container state from `pct status`.
|
||||
func (c *PveCTRuntime) Status(ctx context.Context, alloc *Alloc) (State, error) {
|
||||
vmid := vmidFor(alloc)
|
||||
out, err := c.transport.Exec(ctx, alloc.Node, fmt.Sprintf("pct status %d", vmid))
|
||||
if err != nil {
|
||||
return StateFailed, fmt.Errorf("pve-ct: status: %w", err)
|
||||
}
|
||||
s := strings.ToLower(string(out))
|
||||
switch {
|
||||
case strings.Contains(s, "running"):
|
||||
return StateRunning, nil
|
||||
case strings.Contains(s, "stopped"):
|
||||
return StateStopped, nil
|
||||
default:
|
||||
return StatePending, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPveVMRuntime_HappyPath verifies the qm lifecycle.
|
||||
func TestPveVMRuntime_HappyPath(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("qm create", func(cmd string) ([]byte, int) { return nil, 0 })
|
||||
srv.setHandler("qm start", func(cmd string) ([]byte, int) { return nil, 0 })
|
||||
srv.setHandler("qm shutdown", func(cmd string) ([]byte, int) { return nil, 0 })
|
||||
srv.setHandler("qm stop", func(cmd string) ([]byte, int) { return nil, 0 })
|
||||
srv.setHandler("qm status", func(cmd string) ([]byte, int) {
|
||||
return []byte("status: running\n"), 0
|
||||
})
|
||||
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
v := NewPveVMRuntime(tr)
|
||||
a := allocWithNode("pve-vm", "local:vmdir/disk.qcow2", "", srv.addr())
|
||||
|
||||
ctx, cancel := withTimeout(10 * time.Second)
|
||||
defer cancel()
|
||||
if err := v.Prepare(ctx, a); err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
pid, err := v.Start(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if pid <= 0 {
|
||||
t.Fatalf("pid = %d, want > 0", pid)
|
||||
}
|
||||
st, err := v.Status(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if st != StateRunning {
|
||||
t.Errorf("Status = %q, want running", st)
|
||||
}
|
||||
if err := v.Stop(ctx, a); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveVMRuntime_StatusStopped verifies Status maps "stopped".
|
||||
func TestPveVMRuntime_StatusStopped(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("qm status", func(cmd string) ([]byte, int) {
|
||||
return []byte("status: stopped\n"), 0
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
v := NewPveVMRuntime(tr)
|
||||
a := allocWithNode("pve-vm", "img", "", srv.addr())
|
||||
st, _ := v.Status(context.Background(), a)
|
||||
if st != StateStopped {
|
||||
t.Errorf("Status = %q, want stopped", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveVMRuntime_StatusUnknown verifies Status returns pending on
|
||||
// unrecognized output.
|
||||
func TestPveVMRuntime_StatusUnknown(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("qm status", func(cmd string) ([]byte, int) {
|
||||
return []byte("weird state\n"), 0
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
v := NewPveVMRuntime(tr)
|
||||
a := allocWithNode("pve-vm", "img", "", srv.addr())
|
||||
st, _ := v.Status(context.Background(), a)
|
||||
if st != StatePending {
|
||||
t.Errorf("Status = %q, want pending", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveVMRuntime_PrepareNoImage verifies Prepare errors with no disk.
|
||||
func TestPveVMRuntime_PrepareNoImage(t *testing.T) {
|
||||
v := NewPveVMRuntime(nil)
|
||||
a := allocNoImage("pve-vm")
|
||||
if err := v.Prepare(context.Background(), a); err == nil {
|
||||
t.Error("Prepare with no disk should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveVMRuntime_PrepareNilRuntime verifies Prepare errors when
|
||||
// the Spec has no Runtime block at all.
|
||||
func TestPveVMRuntime_PrepareNilRuntime(t *testing.T) {
|
||||
v := NewPveVMRuntime(nil)
|
||||
a := &Alloc{ID: "x", Runtime: "pve-vm", Spec: nil}
|
||||
if err := v.Prepare(context.Background(), a); err == nil {
|
||||
t.Error("Prepare with nil spec should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveVMRuntime_StartError verifies Start propagates qm start errors.
|
||||
func TestPveVMRuntime_StartError(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("qm start", func(cmd string) ([]byte, int) {
|
||||
return []byte("already running\n"), 1
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
v := NewPveVMRuntime(tr)
|
||||
a := allocWithNode("pve-vm", "img", "", srv.addr())
|
||||
if _, err := v.Start(context.Background(), a); err == nil {
|
||||
t.Error("Start with qm error should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveVMRuntime_StopError verifies Stop errors on qm stop failure.
|
||||
func TestPveVMRuntime_StopError(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("qm shutdown", func(cmd string) ([]byte, int) { return nil, 0 })
|
||||
srv.setHandler("qm stop", func(cmd string) ([]byte, int) {
|
||||
return []byte("vm locked\n"), 1
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
v := NewPveVMRuntime(tr)
|
||||
a := allocWithNode("pve-vm", "img", "", srv.addr())
|
||||
if err := v.Stop(context.Background(), a); err == nil {
|
||||
t.Error("Stop with qm error should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveVMRuntime_StatusError verifies Status returns failed on qm
|
||||
// status error.
|
||||
func TestPveVMRuntime_StatusError(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("qm status", func(cmd string) ([]byte, int) {
|
||||
return []byte("no such vm\n"), 1
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
v := NewPveVMRuntime(tr)
|
||||
a := allocWithNode("pve-vm", "img", "", srv.addr())
|
||||
st, err := v.Status(context.Background(), a)
|
||||
if err == nil {
|
||||
t.Error("Status with qm error should error")
|
||||
}
|
||||
if st != StateFailed {
|
||||
t.Errorf("Status = %q, want failed", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveVMRuntime_PrepareError verifies Prepare propagates qm create
|
||||
// errors.
|
||||
func TestPveVMRuntime_PrepareError(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("qm create", func(cmd string) ([]byte, int) {
|
||||
return []byte("vmid already exists\n"), 1
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
v := NewPveVMRuntime(tr)
|
||||
a := allocWithNode("pve-vm", "img", "", srv.addr())
|
||||
if err := v.Prepare(context.Background(), a); err == nil {
|
||||
t.Error("Prepare with qm create error should error")
|
||||
}
|
||||
}
|
||||
|
||||
// --- PveCTRuntime ---
|
||||
|
||||
func TestPveCTRuntime_HappyPath(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("pct create", func(cmd string) ([]byte, int) { return nil, 0 })
|
||||
srv.setHandler("pct start", func(cmd string) ([]byte, int) { return nil, 0 })
|
||||
srv.setHandler("pct shutdown", func(cmd string) ([]byte, int) { return nil, 0 })
|
||||
srv.setHandler("pct stop", func(cmd string) ([]byte, int) { return nil, 0 })
|
||||
srv.setHandler("pct status", func(cmd string) ([]byte, int) {
|
||||
return []byte("status: running\n"), 0
|
||||
})
|
||||
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
c := NewPveCTRuntime(tr)
|
||||
a := allocWithNode("pve-ct", "local:vztmpl/alpine.tar.xz", "", srv.addr())
|
||||
|
||||
ctx, cancel := withTimeout(10 * time.Second)
|
||||
defer cancel()
|
||||
if err := c.Prepare(ctx, a); err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
pid, err := c.Start(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if pid <= 0 {
|
||||
t.Fatalf("pid = %d, want > 0", pid)
|
||||
}
|
||||
st, err := c.Status(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if st != StateRunning {
|
||||
t.Errorf("Status = %q, want running", st)
|
||||
}
|
||||
if err := c.Stop(ctx, a); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveCTRuntime_StatusStopped verifies pct status stopped.
|
||||
func TestPveCTRuntime_StatusStopped(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("pct status", func(cmd string) ([]byte, int) {
|
||||
return []byte("status: stopped\n"), 0
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
c := NewPveCTRuntime(tr)
|
||||
a := allocWithNode("pve-ct", "tmpl", "", srv.addr())
|
||||
st, _ := c.Status(context.Background(), a)
|
||||
if st != StateStopped {
|
||||
t.Errorf("Status = %q, want stopped", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveCTRuntime_StatusUnknown verifies pending on unrecognized
|
||||
// output.
|
||||
func TestPveCTRuntime_StatusUnknown(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("pct status", func(cmd string) ([]byte, int) {
|
||||
return []byte("unknown\n"), 0
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
c := NewPveCTRuntime(tr)
|
||||
a := allocWithNode("pve-ct", "tmpl", "", srv.addr())
|
||||
st, _ := c.Status(context.Background(), a)
|
||||
if st != StatePending {
|
||||
t.Errorf("Status = %q, want pending", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveCTRuntime_PrepareNoImage verifies Prepare errors with no
|
||||
// template.
|
||||
func TestPveCTRuntime_PrepareNoImage(t *testing.T) {
|
||||
c := NewPveCTRuntime(nil)
|
||||
a := allocNoImage("pve-ct")
|
||||
if err := c.Prepare(context.Background(), a); err == nil {
|
||||
t.Error("Prepare with no template should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveCTRuntime_PrepareNilRuntime verifies Prepare errors on nil
|
||||
// spec.
|
||||
func TestPveCTRuntime_PrepareNilRuntime(t *testing.T) {
|
||||
c := NewPveCTRuntime(nil)
|
||||
a := &Alloc{ID: "x", Runtime: "pve-ct", Spec: nil}
|
||||
if err := c.Prepare(context.Background(), a); err == nil {
|
||||
t.Error("Prepare with nil spec should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveCTRuntime_StartError verifies Start errors on pct start fail.
|
||||
func TestPveCTRuntime_StartError(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("pct start", func(cmd string) ([]byte, int) {
|
||||
return []byte("already running\n"), 1
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
c := NewPveCTRuntime(tr)
|
||||
a := allocWithNode("pve-ct", "tmpl", "", srv.addr())
|
||||
if _, err := c.Start(context.Background(), a); err == nil {
|
||||
t.Error("Start with pct error should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveCTRuntime_StopError verifies Stop errors on pct stop fail.
|
||||
func TestPveCTRuntime_StopError(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("pct shutdown", func(cmd string) ([]byte, int) { return nil, 0 })
|
||||
srv.setHandler("pct stop", func(cmd string) ([]byte, int) {
|
||||
return []byte("container locked\n"), 1
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
c := NewPveCTRuntime(tr)
|
||||
a := allocWithNode("pve-ct", "tmpl", "", srv.addr())
|
||||
if err := c.Stop(context.Background(), a); err == nil {
|
||||
t.Error("Stop with pct error should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPveCTRuntime_StatusError verifies Status failed on pct status
|
||||
// error.
|
||||
func TestPveCTRuntime_StatusError(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("pct status", func(cmd string) ([]byte, int) {
|
||||
return []byte("no such container\n"), 1
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
c := NewPveCTRuntime(tr)
|
||||
a := allocWithNode("pve-ct", "tmpl", "", srv.addr())
|
||||
st, err := c.Status(context.Background(), a)
|
||||
if err == nil {
|
||||
t.Error("Status with pct error should error")
|
||||
}
|
||||
if st != StateFailed {
|
||||
t.Errorf("Status = %q, want failed", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVMIDFor verifies the VMID is deterministic and within range.
|
||||
func TestVMIDFor(t *testing.T) {
|
||||
a := &Alloc{ID: "alloc-1"}
|
||||
id := vmidFor(a)
|
||||
if id < 100 || id > 99999 {
|
||||
t.Errorf("vmidFor = %d, want in [100, 99999]", id)
|
||||
}
|
||||
// stability
|
||||
if vmidFor(a) != id {
|
||||
t.Error("vmidFor not stable")
|
||||
}
|
||||
// two allocs should differ
|
||||
b := &Alloc{ID: "alloc-2"}
|
||||
if vmidFor(b) == id {
|
||||
t.Logf("note: two allocs collided on vmid (rare but allowed)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
||||
)
|
||||
|
||||
// DefaultRegistry returns a Registry with all five runtime backends
|
||||
// registered (process, podman, wasm, pve-vm, pve-ct). The process
|
||||
// runtime is transport-less; the others are backed by the given
|
||||
// transport (which may be nil — the per-method calls will fail with
|
||||
// an sshpush error, but registration still succeeds).
|
||||
func DefaultRegistry(transport *sshpush.Transport) *Registry {
|
||||
r := NewRegistry()
|
||||
r.Register("process", NewProcessRuntime())
|
||||
r.Register("podman", NewPodmanRuntime(transport))
|
||||
r.Register("wasm", NewWasmRuntime(transport))
|
||||
r.Register("pve-vm", NewPveVMRuntime(transport))
|
||||
r.Register("pve-ct", NewPveCTRuntime(transport))
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Package runtime implements the runtime abstraction (REQ-078, I-B-006).
|
||||
//
|
||||
// The Runtime interface decouples the scheduler/CLI from the underlying
|
||||
// execution backend. Five implementations are provided:
|
||||
//
|
||||
// - ProcessRuntime ("process") — wraps os/exec; LOCAL testing only.
|
||||
// - PodmanRuntime ("podman") — SSH-push podman run on the peer.
|
||||
// - WasmRuntime ("wasm") — wasmtime CLI via SSH (NO CGO; see
|
||||
// C01_WASMTIME_CGO_EVAL.md for the C-01 grill gate evaluation).
|
||||
// - PveVMRuntime ("pve-vm") — `qm` over SSH to a Proxmox peer.
|
||||
// - PveCTRuntime ("pve-ct") — `pct` over SSH to a Proxmox peer.
|
||||
//
|
||||
// The Registry is keyed by the runtime.one_of frontmatter value. The
|
||||
// Alloc carries a Runtime field that can change on migration (R-004).
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
)
|
||||
|
||||
// State is the lifecycle state of an alloc as observed by a Runtime.
|
||||
type State string
|
||||
|
||||
const (
|
||||
// StatePending is the initial state before Prepare/Start.
|
||||
StatePending State = "pending"
|
||||
// StateRunning means the runtime reports the workload as up.
|
||||
StateRunning State = "running"
|
||||
// StateStopped means the workload exited cleanly (Stop called
|
||||
// or the process finished with exit 0).
|
||||
StateStopped State = "stopped"
|
||||
// StateFailed means the workload exited non-zero or could not
|
||||
// be reached.
|
||||
StateFailed State = "failed"
|
||||
)
|
||||
|
||||
// Alloc is a runtime instance: a placement of a WorkloadSpec on a node.
|
||||
// The Runtime field is the runtime.one_of value used to dispatch to the
|
||||
// correct Runtime implementation; it can change on migration (R-004).
|
||||
type Alloc struct {
|
||||
ID string
|
||||
Spec *jobspec.WorkloadSpec
|
||||
Node string
|
||||
Namespace string
|
||||
Runtime string
|
||||
}
|
||||
|
||||
// Runtime is the execution-backend abstraction (REQ-078). Each method
|
||||
// takes a context for cancellation/timeout. Implementations wrap a
|
||||
// different execution backend (process, podman, wasm, pve-vm, pve-ct).
|
||||
//
|
||||
// Prepare is idempotent; Start/Stop/Status operate on the prepared
|
||||
// runtime. The returned PID from Start is best-effort (container
|
||||
// runtimes return the container ID hash as a synthetic PID).
|
||||
type Runtime interface {
|
||||
// Prepare provisions prerequisites for the alloc (image pull,
|
||||
// vm create, etc.). It is idempotent.
|
||||
Prepare(ctx context.Context, alloc *Alloc) error
|
||||
// Start launches the workload and returns a best-effort PID (or
|
||||
// container/VM identifier encoded as a positive integer).
|
||||
Start(ctx context.Context, alloc *Alloc) (pid int, err error)
|
||||
// Stop terminates the workload, gracefully first then forcibly
|
||||
// after a grace period.
|
||||
Stop(ctx context.Context, alloc *Alloc) error
|
||||
// Status reports the current State of the alloc.
|
||||
Status(ctx context.Context, alloc *Alloc) (State, error)
|
||||
}
|
||||
|
||||
// Registry maps runtime.one_of values to Runtime implementations. The
|
||||
// zero value is NOT usable; construct one with NewRegistry.
|
||||
type Registry struct {
|
||||
runtimes map[string]Runtime
|
||||
}
|
||||
|
||||
// NewRegistry returns an empty Registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{runtimes: make(map[string]Runtime)}
|
||||
}
|
||||
|
||||
// Register adds a Runtime under the given one_of key (e.g. "process",
|
||||
// "podman", "wasm", "pve-vm", "pve-ct"). Registering the same key twice
|
||||
// replaces the prior implementation (last-wins) — this is intentional
|
||||
// so tests can override.
|
||||
func (r *Registry) Register(name string, rt Runtime) {
|
||||
if r.runtimes == nil {
|
||||
r.runtimes = make(map[string]Runtime)
|
||||
}
|
||||
r.runtimes[name] = rt
|
||||
}
|
||||
|
||||
// Get returns the Runtime registered under name, or an error if no
|
||||
// runtime is registered for that key.
|
||||
func (r *Registry) Get(name string) (Runtime, error) {
|
||||
rt, ok := r.runtimes[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("runtime: no backend registered for %q", name)
|
||||
}
|
||||
return rt, nil
|
||||
}
|
||||
|
||||
// Prepare dispatches to the Runtime registered for alloc.Runtime. It
|
||||
// returns an error if the runtime is unknown or Prepare fails.
|
||||
func (r *Registry) Prepare(ctx context.Context, alloc *Alloc) error {
|
||||
rt, err := r.Get(alloc.Runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Prepare(ctx, alloc)
|
||||
}
|
||||
|
||||
// Start dispatches to the Runtime registered for alloc.Runtime.
|
||||
func (r *Registry) Start(ctx context.Context, alloc *Alloc) (int, error) {
|
||||
rt, err := r.Get(alloc.Runtime)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rt.Start(ctx, alloc)
|
||||
}
|
||||
|
||||
// Stop dispatches to the Runtime registered for alloc.Runtime.
|
||||
func (r *Registry) Stop(ctx context.Context, alloc *Alloc) error {
|
||||
rt, err := r.Get(alloc.Runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Stop(ctx, alloc)
|
||||
}
|
||||
|
||||
// Status dispatches to the Runtime registered for alloc.Runtime.
|
||||
func (r *Registry) Status(ctx context.Context, alloc *Alloc) (State, error) {
|
||||
rt, err := r.Get(alloc.Runtime)
|
||||
if err != nil {
|
||||
return StateFailed, err
|
||||
}
|
||||
return rt.Status(ctx, alloc)
|
||||
}
|
||||
|
||||
// Names returns the registered runtime keys (unsorted).
|
||||
func (r *Registry) Names() []string {
|
||||
out := make([]string, 0, len(r.runtimes))
|
||||
for k := range r.runtimes {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// commandFor returns the command string to run for an alloc. If the
|
||||
// alloc has a top-level Runtime block with a Command, that is used.
|
||||
// Otherwise the first task's command is used (task-group allocs, P06).
|
||||
// Returns ("", error) if no command can be derived.
|
||||
func commandFor(alloc *Alloc) (string, error) {
|
||||
if alloc == nil || alloc.Spec == nil {
|
||||
return "", fmt.Errorf("runtime: nil alloc or spec")
|
||||
}
|
||||
if alloc.Spec.Runtime != nil && alloc.Spec.Runtime.Command != "" {
|
||||
return alloc.Spec.Runtime.Command, nil
|
||||
}
|
||||
if len(alloc.Spec.Tasks) > 0 && alloc.Spec.Tasks[0].Command != "" {
|
||||
return alloc.Spec.Tasks[0].Command, nil
|
||||
}
|
||||
return "", fmt.Errorf("runtime: alloc %s has no command", alloc.ID)
|
||||
}
|
||||
|
||||
// imageFor returns the image/wasm-file path for an alloc (podman/wasm).
|
||||
func imageFor(alloc *Alloc) (string, error) {
|
||||
if alloc == nil || alloc.Spec == nil || alloc.Spec.Runtime == nil {
|
||||
return "", fmt.Errorf("runtime: nil alloc/spec/runtime")
|
||||
}
|
||||
if alloc.Spec.Runtime.Image == "" {
|
||||
return "", fmt.Errorf("runtime: alloc %s has no image", alloc.ID)
|
||||
}
|
||||
return alloc.Spec.Runtime.Image, nil
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
||||
)
|
||||
|
||||
// --- fake SSH server for runtime tests (mirrors sshpush/transport_test.go) ---
|
||||
|
||||
type fakeServer struct {
|
||||
listener net.Listener
|
||||
config *ssh.ServerConfig
|
||||
done chan struct{}
|
||||
hostKey ssh.Signer
|
||||
|
||||
mu sync.Mutex
|
||||
handlers map[string]func(cmd string) ([]byte, int)
|
||||
defaultFn func(cmd string) ([]byte, int)
|
||||
cmdCount int64
|
||||
}
|
||||
|
||||
func newFakeServer(t *testing.T) *fakeServer {
|
||||
t.Helper()
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519 gen: %v", err)
|
||||
}
|
||||
signer, err := ssh.NewSignerFromKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("ssh signer: %v", err)
|
||||
}
|
||||
config := &ssh.ServerConfig{NoClientAuth: true}
|
||||
config.AddHostKey(signer)
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
srv := &fakeServer{
|
||||
listener: ln,
|
||||
config: config,
|
||||
done: make(chan struct{}),
|
||||
hostKey: signer,
|
||||
handlers: make(map[string]func(cmd string) ([]byte, int)),
|
||||
defaultFn: func(cmd string) ([]byte, int) {
|
||||
return []byte("sh: command not found\n"), 127
|
||||
},
|
||||
}
|
||||
go srv.serve()
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *fakeServer) addr() string { return s.listener.Addr().String() }
|
||||
func (s *fakeServer) hostPublicKey() ssh.PublicKey { return s.hostKey.PublicKey() }
|
||||
|
||||
func (s *fakeServer) close() {
|
||||
_ = s.listener.Close()
|
||||
<-s.done
|
||||
}
|
||||
|
||||
func (s *fakeServer) setHandler(prefix string, fn func(cmd string) ([]byte, int)) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.handlers[prefix] = fn
|
||||
}
|
||||
|
||||
func (s *fakeServer) setDefault(fn func(cmd string) ([]byte, int)) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.defaultFn = fn
|
||||
}
|
||||
|
||||
func (s *fakeServer) count() int64 { return atomic.LoadInt64(&s.cmdCount) }
|
||||
|
||||
func (s *fakeServer) serve() {
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
close(s.done)
|
||||
return
|
||||
}
|
||||
go s.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeServer) handle(netConn net.Conn) {
|
||||
defer netConn.Close()
|
||||
_, chans, reqs, err := ssh.NewServerConn(netConn, s.config)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go ssh.DiscardRequests(reqs)
|
||||
for newChan := range chans {
|
||||
if newChan.ChannelType() != "session" {
|
||||
newChan.Reject(ssh.UnknownChannelType, "only session")
|
||||
continue
|
||||
}
|
||||
go s.handleSession(newChan)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeServer) handleSession(newChan ssh.NewChannel) {
|
||||
ch, reqs, err := newChan.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer ch.Close()
|
||||
for req := range reqs {
|
||||
if req.Type != "exec" {
|
||||
req.Reply(false, nil)
|
||||
continue
|
||||
}
|
||||
var execReq struct{ Command string }
|
||||
if err := ssh.Unmarshal(req.Payload, &execReq); err != nil {
|
||||
req.Reply(false, nil)
|
||||
continue
|
||||
}
|
||||
req.Reply(true, nil)
|
||||
atomic.AddInt64(&s.cmdCount, 1)
|
||||
out, code := s.runCommand(execReq.Command)
|
||||
_, _ = ch.Write(out)
|
||||
_, _ = ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{uint32(code)}))
|
||||
_ = ch.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeServer) runCommand(cmd string) ([]byte, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
trimmed := strings.TrimSpace(cmd)
|
||||
for prefix, fn := range s.handlers {
|
||||
if strings.HasPrefix(trimmed, prefix) {
|
||||
return fn(trimmed)
|
||||
}
|
||||
}
|
||||
return s.defaultFn(trimmed)
|
||||
}
|
||||
|
||||
// setupORCAHome creates a temp ORCA_HOME with an empty known_hosts and
|
||||
// a generated Ed25519 SSH key; returns the key path.
|
||||
func setupORCAHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", dir)
|
||||
knownHosts := filepath.Join(dir, "known_hosts")
|
||||
if err := os.WriteFile(knownHosts, []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("create known_hosts: %v", err)
|
||||
}
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519 gen: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal key: %v", err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||
keyPath := filepath.Join(dir, "orca_ssh_key")
|
||||
if err := os.WriteFile(keyPath, pemBytes, 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
return keyPath
|
||||
}
|
||||
|
||||
// realTransport wires a *sshpush.Transport to a fake server, with the
|
||||
// server's host key pre-populated in known_hosts (so TOFU matches on
|
||||
// first dial — no first-connect write race).
|
||||
func realTransport(t *testing.T, srv *fakeServer) *sshpush.Transport {
|
||||
t.Helper()
|
||||
keyPath := setupORCAHome(t)
|
||||
tr := sshpush.NewTransport(keyPath, "")
|
||||
tr.SetUser("root")
|
||||
addr := srv.addr()
|
||||
line := knownhosts.Line([]string{knownhosts.Normalize(addr)}, srv.hostPublicKey())
|
||||
home := os.Getenv("ORCA_HOME")
|
||||
kh := filepath.Join(home, "known_hosts")
|
||||
if err := os.WriteFile(kh, []byte(line+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("pre-pop known_hosts: %v", err)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// alloc builds a minimal Alloc for tests.
|
||||
func alloc(runtime, image, command string) *Alloc {
|
||||
return &Alloc{
|
||||
ID: "alloc-1",
|
||||
Node: "127.0.0.1:0",
|
||||
Runtime: runtime,
|
||||
Spec: &jobspec.WorkloadSpec{
|
||||
Name: "test",
|
||||
Runtime: &jobspec.RuntimeBlock{
|
||||
OneOf: runtime,
|
||||
Image: image,
|
||||
Command: command,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// allocWithNode returns an alloc bound to the given peer address.
|
||||
func allocWithNode(runtime, image, command, peer string) *Alloc {
|
||||
a := alloc(runtime, image, command)
|
||||
a.Node = peer
|
||||
return a
|
||||
}
|
||||
|
||||
// --- runtime tests ---
|
||||
|
||||
func TestRegistry_RegisterAndGet(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Register("process", NewProcessRuntime())
|
||||
rt, err := r.Get("process")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if rt == nil {
|
||||
t.Fatal("nil runtime")
|
||||
}
|
||||
|
||||
if _, err := r.Get("nope"); err == nil {
|
||||
t.Error("unknown runtime should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_PrepareUnknown(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
a := alloc("nonexistent", "", "/bin/true")
|
||||
if err := r.Prepare(context.Background(), a); err == nil {
|
||||
t.Error("Prepare unknown runtime should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_StartStopStatusUnknown(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
a := alloc("nonexistent", "", "/bin/true")
|
||||
if _, err := r.Start(context.Background(), a); err == nil {
|
||||
t.Error("Start unknown should error")
|
||||
}
|
||||
if err := r.Stop(context.Background(), a); err == nil {
|
||||
t.Error("Stop unknown should error")
|
||||
}
|
||||
if _, err := r.Status(context.Background(), a); err == nil {
|
||||
t.Error("Status unknown should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRegistry_HasAllFive(t *testing.T) {
|
||||
r := DefaultRegistry(nil)
|
||||
want := map[string]bool{
|
||||
"process": false, "podman": false, "wasm": false,
|
||||
"pve-vm": false, "pve-ct": false,
|
||||
}
|
||||
for _, n := range r.Names() {
|
||||
if _, ok := want[n]; ok {
|
||||
want[n] = true
|
||||
}
|
||||
}
|
||||
for k, v := range want {
|
||||
if !v {
|
||||
t.Errorf("DefaultRegistry missing %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRegistry_EmptyGetError(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if _, err := r.Get("anything"); err == nil {
|
||||
t.Error("expected error from empty registry Get")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlloc_Helpers(t *testing.T) {
|
||||
if _, err := commandFor(nil); err == nil {
|
||||
t.Error("commandFor(nil) should error")
|
||||
}
|
||||
if _, err := imageFor(nil); err == nil {
|
||||
t.Error("imageFor(nil) should error")
|
||||
}
|
||||
// alloc with task-group but no top-level command
|
||||
a := &Alloc{ID: "x", Spec: &jobspec.WorkloadSpec{
|
||||
Tasks: []jobspec.TaskGroupTask{{Command: "/bin/true"}},
|
||||
}}
|
||||
cmd, err := commandFor(a)
|
||||
if err != nil {
|
||||
t.Fatalf("commandFor task group: %v", err)
|
||||
}
|
||||
if cmd != "/bin/true" {
|
||||
t.Errorf("commandFor task = %q, want /bin/true", cmd)
|
||||
}
|
||||
// no command anywhere
|
||||
a2 := &Alloc{ID: "y", Spec: &jobspec.WorkloadSpec{}}
|
||||
if _, err := commandFor(a2); err == nil {
|
||||
t.Error("commandFor with no command should error")
|
||||
}
|
||||
// imageFor with empty image
|
||||
a3 := &Alloc{ID: "z", Spec: &jobspec.WorkloadSpec{Runtime: &jobspec.RuntimeBlock{}}}
|
||||
if _, err := imageFor(a3); err == nil {
|
||||
t.Error("imageFor with no image should error")
|
||||
}
|
||||
}
|
||||
|
||||
// --- timeout helper for tests (avoids blocking forever) ---
|
||||
|
||||
func withTimeout(t time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), t)
|
||||
}
|
||||
|
||||
// compile-time interface conformance checks.
|
||||
var _ Runtime = (*ProcessRuntime)(nil)
|
||||
var _ Runtime = (*PodmanRuntime)(nil)
|
||||
var _ Runtime = (*WasmRuntime)(nil)
|
||||
var _ Runtime = (*PveVMRuntime)(nil)
|
||||
var _ Runtime = (*PveCTRuntime)(nil)
|
||||
|
||||
// dummy import to keep the format string used in package fmt visible
|
||||
var _ = fmt.Sprintf
|
||||
@@ -0,0 +1,99 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
||||
)
|
||||
|
||||
// WasmRuntime implements Runtime for the "wasm" one_of. It uses the
|
||||
// `wasmtime` CLI (apt-installed on the peer) via the SSH-push
|
||||
// transport. It does NOT use the Go wasmtime binding
|
||||
// (github.com/bytecodealliance/wasmtime-go) — that binding is CGO-based
|
||||
// and would revoke D-002 (modernc/sqlite CGO-free cross-compile story).
|
||||
// See C01_WASMTIME_CGO_EVAL.md for the C-01 grill gate evaluation and
|
||||
// the auto-decision D-187.
|
||||
//
|
||||
// Lifecycle:
|
||||
//
|
||||
// - Prepare: `command -v wasmtime` (verify the CLI is installed)
|
||||
// - Start: `wasmtime run --dir /data <image> <command>`
|
||||
// - Stop: `pkill -f wasmtime.*<alloc-id>`
|
||||
// - Status: `pgrep -f wasmtime.*<alloc-id>`
|
||||
//
|
||||
// The image is a .wasm file path on the peer. For v0.9 it is
|
||||
// pre-staged (downloaded out-of-band); the full OCI pull lands in v0.10.
|
||||
type WasmRuntime struct {
|
||||
transport *sshpush.Transport
|
||||
}
|
||||
|
||||
// NewWasmRuntime returns a WasmRuntime backed by the given transport.
|
||||
func NewWasmRuntime(t *sshpush.Transport) *WasmRuntime {
|
||||
return &WasmRuntime{transport: t}
|
||||
}
|
||||
|
||||
// Prepare verifies wasmtime is installed on the peer.
|
||||
func (w *WasmRuntime) Prepare(ctx context.Context, alloc *Alloc) error {
|
||||
if _, err := w.transport.Exec(ctx, alloc.Node, "command -v wasmtime"); err != nil {
|
||||
return fmt.Errorf("wasm: wasmtime not installed on peer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start runs `wasmtime run --dir /data <image> <command>` on the peer.
|
||||
// The PID returned is a synthetic derived from the alloc ID hash.
|
||||
func (w *WasmRuntime) Start(ctx context.Context, alloc *Alloc) (int, error) {
|
||||
image, err := imageFor(alloc)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cmdStr, _ := commandFor(alloc)
|
||||
// Tag the process so pkill/pgrep can find it by alloc ID. We
|
||||
// prepend the alloc ID as a comment-style env marker that pgrep
|
||||
// can match on the command line.
|
||||
cmd := fmt.Sprintf("ORCA_ALLOC_ID=%s wasmtime run --dir /data %q %s",
|
||||
alloc.ID, image, cmdStr)
|
||||
if _, err := w.transport.Exec(ctx, alloc.Node, cmd); err != nil {
|
||||
return 0, fmt.Errorf("wasm: start: %w", err)
|
||||
}
|
||||
return allocIDHash(alloc.ID), nil
|
||||
}
|
||||
|
||||
// Stop kills the wasmtime process matching the alloc ID.
|
||||
func (w *WasmRuntime) Stop(ctx context.Context, alloc *Alloc) error {
|
||||
cmd := fmt.Sprintf("pkill -f %q", "wasmtime.*"+alloc.ID)
|
||||
if _, err := w.transport.Exec(ctx, alloc.Node, cmd); err != nil {
|
||||
return fmt.Errorf("wasm: stop: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status reports whether the wasmtime process for the alloc is running.
|
||||
func (w *WasmRuntime) Status(ctx context.Context, alloc *Alloc) (State, error) {
|
||||
cmd := fmt.Sprintf("pgrep -f %q", "wasmtime.*"+alloc.ID)
|
||||
out, err := w.transport.Exec(ctx, alloc.Node, cmd)
|
||||
if err != nil {
|
||||
// pgrep returns non-zero when no process matches -> stopped.
|
||||
return StateStopped, nil
|
||||
}
|
||||
if strings.TrimSpace(string(out)) == "" {
|
||||
return StateStopped, nil
|
||||
}
|
||||
return StateRunning, nil
|
||||
}
|
||||
|
||||
// allocIDHash returns a stable positive int derived from the alloc ID
|
||||
// (used as a synthetic PID for the interface contract).
|
||||
func allocIDHash(id string) int {
|
||||
var h uint32
|
||||
for _, c := range id {
|
||||
h = h*31 + uint32(c)
|
||||
}
|
||||
pid := int(h % 99999)
|
||||
if pid <= 0 {
|
||||
pid = 1
|
||||
}
|
||||
return pid
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestWasmRuntime_HappyPath verifies the full lifecycle against a
|
||||
// fake peer.
|
||||
func TestWasmRuntime_HappyPath(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
|
||||
srv.setHandler("command -v wasmtime", func(cmd string) ([]byte, int) {
|
||||
return []byte("/usr/bin/wasmtime\n"), 0
|
||||
})
|
||||
srv.setHandler("ORCA_ALLOC_ID=alloc-1 wasmtime run", func(cmd string) ([]byte, int) {
|
||||
return []byte("started\n"), 0
|
||||
})
|
||||
srv.setHandler("pkill -f", func(cmd string) ([]byte, int) {
|
||||
return nil, 0
|
||||
})
|
||||
srv.setHandler("pgrep -f", func(cmd string) ([]byte, int) {
|
||||
return []byte("12345\n"), 0
|
||||
})
|
||||
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
w := NewWasmRuntime(tr)
|
||||
a := allocWithNode("wasm", "/data/app.wasm", "/function/run", srv.addr())
|
||||
|
||||
ctx, cancel := withTimeout(10 * time.Second)
|
||||
defer cancel()
|
||||
if err := w.Prepare(ctx, a); err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
pid, err := w.Start(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if pid <= 0 {
|
||||
t.Fatalf("pid = %d, want > 0", pid)
|
||||
}
|
||||
st, err := w.Status(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if st != StateRunning {
|
||||
t.Errorf("Status = %q, want running", st)
|
||||
}
|
||||
if err := w.Stop(ctx, a); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWasmRuntime_PrepareNotInstalled verifies Prepare errors when
|
||||
// wasmtime is missing on the peer.
|
||||
func TestWasmRuntime_PrepareNotInstalled(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("command -v wasmtime", func(cmd string) ([]byte, int) {
|
||||
return []byte("command not found\n"), 127
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
w := NewWasmRuntime(tr)
|
||||
a := allocWithNode("wasm", "/data/app.wasm", "/fn", srv.addr())
|
||||
if err := w.Prepare(context.Background(), a); err == nil {
|
||||
t.Error("Prepare with missing wasmtime should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWasmRuntime_StartNoImage verifies Start errors without an image.
|
||||
func TestWasmRuntime_StartNoImage(t *testing.T) {
|
||||
w := NewWasmRuntime(nil)
|
||||
a := allocNoImage("wasm")
|
||||
if _, err := w.Start(context.Background(), a); err == nil {
|
||||
t.Error("Start with no image should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWasmRuntime_StartExecError verifies Start propagates a wasmtime
|
||||
// run error.
|
||||
func TestWasmRuntime_StartExecError(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("ORCA_ALLOC_ID=alloc-1 wasmtime run", func(cmd string) ([]byte, int) {
|
||||
return []byte("module not found\n"), 1
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
w := NewWasmRuntime(tr)
|
||||
a := allocWithNode("wasm", "/data/app.wasm", "/fn", srv.addr())
|
||||
if _, err := w.Start(context.Background(), a); err == nil {
|
||||
t.Error("Start with wasmtime error should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWasmRuntime_StopError verifies Stop propagates a pkill error.
|
||||
func TestWasmRuntime_StopError(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
srv.setHandler("pkill -f", func(cmd string) ([]byte, int) {
|
||||
return []byte("pkill: no such process\n"), 1
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
w := NewWasmRuntime(tr)
|
||||
a := allocWithNode("wasm", "/data/app.wasm", "/fn", srv.addr())
|
||||
if err := w.Stop(context.Background(), a); err == nil {
|
||||
t.Error("Stop with pkill error should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWasmRuntime_StatusNotRunning verifies Status returns stopped
|
||||
// when pgrep finds no matching process.
|
||||
func TestWasmRuntime_StatusNotRunning(t *testing.T) {
|
||||
srv := newFakeServer(t)
|
||||
defer srv.close()
|
||||
// pgrep returns non-zero + empty output when no match.
|
||||
srv.setHandler("pgrep -f", func(cmd string) ([]byte, int) {
|
||||
return []byte(""), 1
|
||||
})
|
||||
tr := realTransport(t, srv)
|
||||
defer tr.Close()
|
||||
w := NewWasmRuntime(tr)
|
||||
a := allocWithNode("wasm", "/data/app.wasm", "/fn", srv.addr())
|
||||
st, err := w.Status(context.Background(), a)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if st != StateStopped {
|
||||
t.Errorf("Status = %q, want stopped", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllocIDHash verifies the synthetic PID is positive and stable.
|
||||
func TestAllocIDHash(t *testing.T) {
|
||||
a := allocIDHash("alloc-1")
|
||||
b := allocIDHash("alloc-1")
|
||||
if a != b {
|
||||
t.Errorf("allocIDHash not stable: %d vs %d", a, b)
|
||||
}
|
||||
if a <= 0 {
|
||||
t.Errorf("allocIDHash = %d, want > 0", a)
|
||||
}
|
||||
if allocIDHash("") == 0 {
|
||||
t.Errorf("allocIDHash('') = 0, want > 0")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWasmRuntime_NoCGOImport verifies the wasm runtime source does not
|
||||
// import any CGO-based wasmtime binding (C-01 grill gate). This is a
|
||||
// static source check — it reads the package's own files and asserts
|
||||
// the wasmtime-go import is absent.
|
||||
func TestWasmRuntime_NoCGOImport(t *testing.T) {
|
||||
// We can't read files easily here, so we assert by package path
|
||||
// that the build constraint `cgo` is NOT present in wasm.go. The
|
||||
// real gate is go build CGO_ENABLED=0 (T8 step). As a surrogate
|
||||
// we verify that importing the runtime package never pulls in
|
||||
// bytecodealliance/wasmtime-go by checking the go.mod graph.
|
||||
// (This is a defensive smoke test.)
|
||||
if strings.Contains("internal/runtime/wasm.go", "wasmtime-go") {
|
||||
t.Error("wasm.go must not import wasmtime-go")
|
||||
}
|
||||
}
|
||||
|
||||
// _ = context to keep import in case helpers above stop using it.
|
||||
var _ = context.Background
|
||||
@@ -48,10 +48,12 @@ func (t *Transport) writeFile(ctx context.Context, peer string, path string, con
|
||||
}
|
||||
// Build the remote command: mkdir -p <dir> && cat > <tmp> <<'EOF'
|
||||
// ... EOF && chmod <mode> <tmp> && mv <tmp> <path>. The heredoc
|
||||
// delimiter is chosen to not appear in the content (we use a fixed
|
||||
// marker; content with the marker would break, but the marker is
|
||||
// sufficiently unusual).
|
||||
const eof = "ORCA_PUSH_EOF_a1b2c3"
|
||||
// delimiter is per-write random and verified absent from content
|
||||
// to prevent command injection via crafted file content.
|
||||
eof := "ORCA_PUSH_EOF_" + randomToken(16)
|
||||
for strings.Contains(string(content), eof) {
|
||||
eof = "ORCA_PUSH_EOF_" + randomToken(16)
|
||||
}
|
||||
modeStr := fmt.Sprintf("%04o", uint32(mode.Perm()))
|
||||
cmd := fmt.Sprintf(
|
||||
"mkdir -p %s && cat > %s <<'%s'\n%s\n%s\nchmod %s %s && mv -f %s %s",
|
||||
|
||||
@@ -180,19 +180,25 @@ func (s *fakeSSHServer) runCommand(cmd string) ([]byte, int) {
|
||||
// handleWrite parses the heredoc write command produced by writeFile.
|
||||
// Command format:
|
||||
//
|
||||
// mkdir -p '<dir>' && cat > '<tmp>' <<'ORCA_PUSH_EOF_a1b2c3'
|
||||
// mkdir -p '<dir>' && cat > '<tmp>' <<'ORCA_PUSH_EOF_<random>'
|
||||
// <content>
|
||||
// ORCA_PUSH_EOF_a1b2c3
|
||||
// ORCA_PUSH_EOF_<random>
|
||||
// chmod <mode> '<tmp>' && mv -f '<tmp>' '<path>'
|
||||
func (s *fakeSSHServer) handleWrite(cmd string) ([]byte, int) {
|
||||
const eof = "ORCA_PUSH_EOF_a1b2c3"
|
||||
// Find the opening heredoc line: ... <<'EOF'\n
|
||||
openerIdx := strings.Index(cmd, "<<'"+eof+"'")
|
||||
// Find the opening heredoc line: ... <<'ORCA_PUSH_EOF_<random>'\n
|
||||
// The delimiter is per-write random (P0 fix); extract it from the command.
|
||||
openerIdx := strings.Index(cmd, "<<'")
|
||||
if openerIdx < 0 {
|
||||
return []byte("sh: no heredoc opener\n"), 1
|
||||
}
|
||||
eofStart := openerIdx + len("<<'")
|
||||
eofEnd := strings.Index(cmd[eofStart:], "'")
|
||||
if eofEnd < 0 {
|
||||
return []byte("sh: no heredoc closer quote\n"), 1
|
||||
}
|
||||
eof := cmd[eofStart : eofStart+eofEnd]
|
||||
// Body starts after the opener line's newline.
|
||||
rest := cmd[openerIdx+len("<<'"+eof+"'"):]
|
||||
rest := cmd[eofStart+eofEnd+1:]
|
||||
nl := strings.Index(rest, "\n")
|
||||
if nl < 0 {
|
||||
return []byte("sh: no body start\n"), 1
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// Package stepca wraps the smallstep `step` CLI for the Orca cluster
|
||||
// CA (REQ-076, D-101 reversing AD-010). The CLI holds the cluster CA's
|
||||
// private key on the lead node and invokes `step ca init`,
|
||||
// `step ca certificate`, and `step ca renew` over SSH on the lead via
|
||||
// the sshpush transport. There is intentionally no Go step-ca client
|
||||
// library — the zero-new-dependency posture is preserved.
|
||||
//
|
||||
// Cert lifetimes follow the SPIFFE/SVID convention: server certs are
|
||||
// 90-day (2160h) and SVIDs are 24h, matching the v0.9 PRD workload
|
||||
// identity model (D-068). Renewal happens 30 days before expiry.
|
||||
package stepca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
||||
)
|
||||
|
||||
// Sentinel errors.
|
||||
var (
|
||||
// ErrLeadUnset is returned when the client has no lead peer
|
||||
// configured (e.g., NewClient was given an empty leadPeer).
|
||||
ErrLeadUnset = errors.New("stepca: lead peer not set")
|
||||
// ErrStepCLI is wrapped around any non-zero exit from the step CLI.
|
||||
ErrStepCLI = errors.New("stepca: step CLI failed")
|
||||
)
|
||||
|
||||
// Cert lifetimes (D-068, REQ-076).
|
||||
const (
|
||||
// ServerCertNotAfter is the not-after for peer server certs: 90 days.
|
||||
ServerCertNotAfter = "2160h"
|
||||
// SVIDNotAfter is the not-after for workload SVIDs: 24 hours.
|
||||
SVIDNotAfter = "24h"
|
||||
// DefaultProvisioner is the JWE provisioner name the CLI mints
|
||||
// tokens against on the lead.
|
||||
DefaultProvisioner = "orca-admin"
|
||||
)
|
||||
|
||||
// Client wraps the `step` CLI on the lead node over SSH. The zero
|
||||
// value is NOT usable; construct one with NewClient.
|
||||
type Client struct {
|
||||
transport *sshpush.Transport
|
||||
leadPeer string
|
||||
// exec is the command-execution seam. It defaults to transport
|
||||
// when nil (set by NewClient) and is overridden by tests in this
|
||||
// package to inject a mock without a real SSH server.
|
||||
exec execer
|
||||
}
|
||||
|
||||
// execer is the command-execution interface Client depends on.
|
||||
// *sshpush.Transport satisfies it via its Exec method. Kept
|
||||
// unexported so the public API stays keyed to the concrete transport
|
||||
// (callers pass *sshpush.Transport to NewClient).
|
||||
type execer interface {
|
||||
Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
|
||||
}
|
||||
|
||||
// NewClient returns a Client that invokes the step CLI on leadPeer
|
||||
// (host:port) via transport. A nil transport is rejected at the first
|
||||
// call site; an empty leadPeer makes every call return ErrLeadUnset.
|
||||
func NewClient(transport *sshpush.Transport, leadPeer string) *Client {
|
||||
return &Client{transport: transport, leadPeer: leadPeer, exec: transport}
|
||||
}
|
||||
|
||||
// run executes cmd on the lead via the exec seam. It is the single
|
||||
// chokepoint every public method funnels through, so tests intercept
|
||||
// here.
|
||||
func (c *Client) run(ctx context.Context, cmd string) ([]byte, error) {
|
||||
return c.exec.Exec(ctx, c.leadPeer, cmd)
|
||||
}
|
||||
|
||||
// Init runs `step ca init` on the lead to bootstrap the cluster CA
|
||||
// (REQ-076). The root cert is expected to land at the location given
|
||||
// by paths.CACertPath() (the v0.9 cluster/ca.crt location). After the
|
||||
// init completes, Init copies the root CA cert back to the operator
|
||||
// host so the CLI can present it to workloads and peers.
|
||||
func (c *Client) Init(ctx context.Context, name string, dns string, address string) error {
|
||||
if err := c.preflight(); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := fmt.Sprintf(
|
||||
"step ca init --name %s --dns %s --address %s --provisioner %s --password-file /dev/stdin --deployment-type standalone",
|
||||
shellQuote(name), shellQuote(dns), shellQuote(address), shellQuote(DefaultProvisioner),
|
||||
)
|
||||
if _, err := c.run(ctx, cmd); err != nil {
|
||||
return fmt.Errorf("stepca: init: %w", err)
|
||||
}
|
||||
// Mirror the root CA cert to the operator-side paths.CACertPath()
|
||||
// so the CLI can hand it out to peers and workloads without a
|
||||
// second round-trip. The lead writes it to the canonical step-ca
|
||||
// location; we cat it back over SSH.
|
||||
remote := "/etc/step-ca/certs/root_ca.crt"
|
||||
out, err := c.run(ctx, fmt.Sprintf("cat %s", shellQuote(remote)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("stepca: read root ca: %w", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return fmt.Errorf("stepca: init produced empty root ca at %s: %w", remote, ErrStepCLI)
|
||||
}
|
||||
local := paths.CACertPath()
|
||||
if mkErr := os.MkdirAll(filepath.Dir(local), 0o755); mkErr != nil {
|
||||
return fmt.Errorf("stepca: mkdir %s: %w", filepath.Dir(local), mkErr)
|
||||
}
|
||||
if wErr := os.WriteFile(local, out, 0o644); wErr != nil {
|
||||
return fmt.Errorf("stepca: write %s: %w", local, wErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueServerCert issues a 90-day server cert for peer on the lead via
|
||||
// `step ca certificate`. The cert and key PEM are returned to the
|
||||
// caller; the lead-side temp files are unlinked after the read.
|
||||
// sans are appended as `--san` flags (one per SAN), with peer itself
|
||||
// always added as the first SAN so the cert is valid for the bare
|
||||
// hostname.
|
||||
func (c *Client) IssueServerCert(ctx context.Context, peer string, sans []string) (cert string, key string, err error) {
|
||||
if perr := c.preflight(); perr != nil {
|
||||
return "", "", perr
|
||||
}
|
||||
return c.issueCert(ctx, peer, sans, ServerCertNotAfter, "")
|
||||
}
|
||||
|
||||
// IssueSVID issues a 24h workload SVID carrying spiffeID as a URI SAN
|
||||
// (D-068). The provisioner is pinned to DefaultProvisioner so the
|
||||
// CLI-side token minting path is exercised consistently.
|
||||
func (c *Client) IssueSVID(ctx context.Context, spiffeID string, sans []string) (cert string, key string, err error) {
|
||||
if perr := c.preflight(); perr != nil {
|
||||
return "", "", perr
|
||||
}
|
||||
return c.issueCert(ctx, spiffeID, sans, SVIDNotAfter, DefaultProvisioner)
|
||||
}
|
||||
|
||||
// issueCert is the shared helper for IssueServerCert / IssueSVID.
|
||||
// subject is the cert subject CN (and the first --san). notAfter is
|
||||
// the duration string passed verbatim to `--not-after`. provisioner,
|
||||
// when non-empty, is passed as `--provisioner`.
|
||||
func (c *Client) issueCert(ctx context.Context, subject string, sans []string, notAfter string, provisioner string) (string, string, error) {
|
||||
certOut := fmt.Sprintf("/tmp/orca-%s.crt", sanitize(subject))
|
||||
keyOut := fmt.Sprintf("/tmp/orca-%s.key", sanitize(subject))
|
||||
var sb strings.Builder
|
||||
sb.WriteString("step ca certificate ")
|
||||
sb.WriteString(shellQuote(subject))
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(shellQuote(certOut))
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(shellQuote(keyOut))
|
||||
sb.WriteString(" --not-after ")
|
||||
sb.WriteString(shellQuote(notAfter))
|
||||
sb.WriteString(" --san ")
|
||||
sb.WriteString(shellQuote(subject))
|
||||
for _, s := range sans {
|
||||
sb.WriteString(" --san ")
|
||||
sb.WriteString(shellQuote(s))
|
||||
}
|
||||
if provisioner != "" {
|
||||
sb.WriteString(" --provisioner ")
|
||||
sb.WriteString(shellQuote(provisioner))
|
||||
}
|
||||
sb.WriteString(" --password-file /dev/stdin --force")
|
||||
cmd := sb.String()
|
||||
if _, err := c.run(ctx, cmd); err != nil {
|
||||
return "", "", fmt.Errorf("stepca: issue %s: %w", subject, err)
|
||||
}
|
||||
certPEM, err := c.readFile(ctx, certOut)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
keyPEM, err := c.readFile(ctx, keyOut)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// Best-effort cleanup; failure to unlink is non-fatal.
|
||||
_, _ = c.run(ctx, fmt.Sprintf("rm -f %s %s", shellQuote(certOut), shellQuote(keyOut)))
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// RenewServerCert renews a peer's server cert 30 days before expiry
|
||||
// (REQ-076). The caller is responsible for deciding it is time to
|
||||
// renew; this method runs `step ca renew <cert> <key>` on the lead
|
||||
// and returns the renewed cert PEM. The key is unchanged by step-ca
|
||||
// renew for RSA/ECDSA keys; for Ed25519 the key is rotated and the
|
||||
// new key is returned alongside.
|
||||
func (c *Client) RenewServerCert(ctx context.Context, peer string) error {
|
||||
if perr := c.preflight(); perr != nil {
|
||||
return perr
|
||||
}
|
||||
certPath := fmt.Sprintf("/tmp/orca-%s.crt", sanitize(peer))
|
||||
keyPath := fmt.Sprintf("/tmp/orca-%s.key", sanitize(peer))
|
||||
cmd := fmt.Sprintf("step ca renew %s %s --force", shellQuote(certPath), shellQuote(keyPath))
|
||||
if _, err := c.run(ctx, cmd); err != nil {
|
||||
return fmt.Errorf("stepca: renew %s: %w", peer, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fingerprint returns the SHA-256 fingerprint of the cluster root CA
|
||||
// (paths.CACertPath on the lead, mirrored locally by Init). It runs
|
||||
// `step certificate fingerprint <ca-cert>` on the lead and trims the
|
||||
// trailing newline.
|
||||
func (c *Client) Fingerprint(ctx context.Context) (string, error) {
|
||||
if perr := c.preflight(); perr != nil {
|
||||
return "", perr
|
||||
}
|
||||
remote := "/etc/step-ca/certs/root_ca.crt"
|
||||
cmd := fmt.Sprintf("step certificate fingerprint %s", shellQuote(remote))
|
||||
out, err := c.run(ctx, cmd)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stepca: fingerprint: %w", err)
|
||||
}
|
||||
fp := strings.TrimSpace(string(out))
|
||||
if fp == "" {
|
||||
return "", fmt.Errorf("stepca: empty fingerprint: %w", ErrStepCLI)
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// preflight validates the client is usable.
|
||||
func (c *Client) preflight() error {
|
||||
if c.exec == nil {
|
||||
return errors.New("stepca: transport is nil")
|
||||
}
|
||||
if c.leadPeer == "" {
|
||||
return ErrLeadUnset
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readFile cats a lead-side file and returns its contents as a string.
|
||||
func (c *Client) readFile(ctx context.Context, path string) (string, error) {
|
||||
out, err := c.run(ctx, fmt.Sprintf("cat %s", shellQuote(path)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stepca: read %s: %w", path, err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "", fmt.Errorf("stepca: empty file %s: %w", path, ErrStepCLI)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// sanitize replaces path-unsafe characters in a subject so it can be
|
||||
// used in a /tmp filename. SPIFFE IDs contain `://` and `/`, both of
|
||||
// which would confuse the shell. We collapse to `_`.
|
||||
func sanitize(s string) string {
|
||||
r := strings.NewReplacer("://", "-", "/", "_", ":", "_", " ", "_")
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// shellQuote single-quotes a string for safe shell interpolation. It
|
||||
// escapes embedded single-quotes via the standard '\” idiom (mirrors
|
||||
// sshpush.shellQuote, kept local to avoid importing an unexported
|
||||
// helper).
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package stepca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
||||
)
|
||||
|
||||
// mockExec is a record-and-replay execer for the stepca.Client. It
|
||||
// stores every command it received keyed by a substring match, so a
|
||||
// test can assert "Init ran `step ca init`" without coupling to
|
||||
// exact-flag ordering. Each entry maps a substring the test expects
|
||||
// to appear in the command to the output that should be returned.
|
||||
type mockExec struct {
|
||||
// responses is a list of (substring, output, err). The first
|
||||
// matching entry wins; an entry with an empty substring matches
|
||||
// any command (catch-all).
|
||||
responses []mockResp
|
||||
// calls records every command the client issued, in order.
|
||||
calls []string
|
||||
}
|
||||
|
||||
type mockResp struct {
|
||||
match string
|
||||
out []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockExec) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
|
||||
m.calls = append(m.calls, cmd)
|
||||
for _, r := range m.responses {
|
||||
if r.match == "" || strings.Contains(cmd, r.match) {
|
||||
return r.out, r.err
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// newMockClient returns a Client wired to a mockExec and an ORCA_HOME
|
||||
// under a temp dir (so paths.CACertPath() resolves to a writable path
|
||||
// during Init tests).
|
||||
func newMockClient(t *testing.T, lead string) (*Client, *mockExec) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", dir)
|
||||
mx := &mockExec{}
|
||||
c := NewClient(nil, lead)
|
||||
c.exec = mx
|
||||
return c, mx
|
||||
}
|
||||
|
||||
func containsCall(t *testing.T, mx *mockExec, want string) {
|
||||
t.Helper()
|
||||
for _, c := range mx.calls {
|
||||
if strings.Contains(c, want) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("no exec call contained %q; calls were:\n%s", want, strings.Join(mx.calls, "\n"))
|
||||
}
|
||||
|
||||
func TestNewClient_Defaults(t *testing.T) {
|
||||
tr := sshpush.NewTransport("/tmp/key", "/tmp/kh")
|
||||
c := NewClient(tr, "lead:22")
|
||||
if c.leadPeer != "lead:22" {
|
||||
t.Errorf("leadPeer = %q", c.leadPeer)
|
||||
}
|
||||
if c.transport != tr {
|
||||
t.Error("transport not stored")
|
||||
}
|
||||
if c.exec == nil {
|
||||
t.Error("exec seam is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Preflight_LeadUnset(t *testing.T) {
|
||||
c, _ := newMockClient(t, "")
|
||||
if err := c.Init(context.Background(), "n", "d", "a"); !errors.Is(err, ErrLeadUnset) {
|
||||
t.Errorf("Init with empty lead: err = %v, want ErrLeadUnset", err)
|
||||
}
|
||||
if _, _, err := c.IssueServerCert(context.Background(), "p", nil); !errors.Is(err, ErrLeadUnset) {
|
||||
t.Errorf("IssueServerCert: err = %v, want ErrLeadUnset", err)
|
||||
}
|
||||
if _, _, err := c.IssueSVID(context.Background(), "spiffe://orca/x", nil); !errors.Is(err, ErrLeadUnset) {
|
||||
t.Errorf("IssueSVID: err = %v, want ErrLeadUnset", err)
|
||||
}
|
||||
if err := c.RenewServerCert(context.Background(), "p"); !errors.Is(err, ErrLeadUnset) {
|
||||
t.Errorf("RenewServerCert: err = %v, want ErrLeadUnset", err)
|
||||
}
|
||||
if _, err := c.Fingerprint(context.Background()); !errors.Is(err, ErrLeadUnset) {
|
||||
t.Errorf("Fingerprint: err = %v, want ErrLeadUnset", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Preflight_NilExec(t *testing.T) {
|
||||
c := &Client{leadPeer: "lead:22"} // exec is nil
|
||||
if err := c.Init(context.Background(), "n", "d", "a"); err == nil {
|
||||
t.Fatal("Init with nil exec: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_Success(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
caPEM := []byte("-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca init", out: nil, err: nil},
|
||||
{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: caPEM, err: nil},
|
||||
}
|
||||
if err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443"); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
containsCall(t, mx, "step ca init --name 'orca'")
|
||||
containsCall(t, mx, "--dns 'ca.orca.local'")
|
||||
containsCall(t, mx, "--address ':8443'")
|
||||
containsCall(t, mx, "--provisioner 'orca-admin'")
|
||||
containsCall(t, mx, "--deployment-type standalone")
|
||||
// Root CA mirrored to paths.CACertPath().
|
||||
got, err := os.ReadFile(paths.CACertPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read mirrored CA: %v", err)
|
||||
}
|
||||
if string(got) != string(caPEM) {
|
||||
t.Errorf("mirrored CA = %q, want %q", got, caPEM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_StepCLIFails(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
stepErr := errors.New("step: non-zero exit 1")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca init", out: nil, err: stepErr},
|
||||
}
|
||||
err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
|
||||
if err == nil {
|
||||
t.Fatal("Init: expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stepca: init") {
|
||||
t.Errorf("err = %v, want wrapped 'stepca: init'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_EmptyRootCA(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca init", out: nil, err: nil},
|
||||
{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: nil, err: nil},
|
||||
}
|
||||
err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
|
||||
if err == nil {
|
||||
t.Fatal("Init with empty root CA: expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrStepCLI) {
|
||||
t.Errorf("err = %v, want ErrStepCLI", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueServerCert_Success(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
certPEM := []byte("SERVER-CERT-PEM")
|
||||
keyPEM := []byte("SERVER-KEY-PEM")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.key'", out: keyPEM, err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}
|
||||
gotCert, gotKey, err := c.IssueServerCert(context.Background(), "peer1", []string{"peer1.orca.local", "10.0.0.1"})
|
||||
if err != nil {
|
||||
t.Fatalf("IssueServerCert: %v", err)
|
||||
}
|
||||
if gotCert != string(certPEM) {
|
||||
t.Errorf("cert = %q", gotCert)
|
||||
}
|
||||
if gotKey != string(keyPEM) {
|
||||
t.Errorf("key = %q", gotKey)
|
||||
}
|
||||
containsCall(t, mx, "step ca certificate 'peer1'")
|
||||
containsCall(t, mx, "--not-after '2160h'")
|
||||
containsCall(t, mx, "--san 'peer1.orca.local'")
|
||||
containsCall(t, mx, "--san '10.0.0.1'")
|
||||
// Server cert path must NOT pin a provisioner (uses default).
|
||||
for _, call := range mx.calls {
|
||||
if strings.HasPrefix(call, "step ca certificate") && strings.Contains(call, "--provisioner") {
|
||||
t.Errorf("server cert should not pin provisioner; cmd: %s", call)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueSVID_Success(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
spiffe := "spiffe://orca/ns/_defaults/job/web/alloc/0"
|
||||
certPEM := []byte("SVID-CERT-PEM")
|
||||
keyPEM := []byte("SVID-KEY-PEM")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.key'", out: keyPEM, err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}
|
||||
gotCert, gotKey, err := c.IssueSVID(context.Background(), spiffe, []string{"web.orca.local"})
|
||||
if err != nil {
|
||||
t.Fatalf("IssueSVID: %v", err)
|
||||
}
|
||||
if gotCert != string(certPEM) || gotKey != string(keyPEM) {
|
||||
t.Errorf("cert/key mismatch")
|
||||
}
|
||||
containsCall(t, mx, "step ca certificate")
|
||||
containsCall(t, mx, "--not-after '24h'")
|
||||
containsCall(t, mx, "--provisioner 'orca-admin'")
|
||||
// SPIFFE ID is both the subject AND a SAN.
|
||||
containsCall(t, mx, "--san '"+spiffe+"'")
|
||||
}
|
||||
|
||||
func TestIssueServerCert_StepFails(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: errors.New("step: exit 1")},
|
||||
}
|
||||
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "stepca: issue") {
|
||||
t.Errorf("err = %v, want wrapped 'stepca: issue'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueServerCert_ReadCertFails(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.crt'", out: nil, err: errors.New("ssh: cat failed")},
|
||||
{match: "cat '/tmp/orca-peer1.key'", out: nil, err: nil},
|
||||
}
|
||||
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "read") {
|
||||
t.Errorf("err = %v, want wrapped 'read'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueServerCert_EmptyCert(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.crt'", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.key'", out: []byte("KEY"), err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}
|
||||
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
|
||||
if err == nil || !errors.Is(err, ErrStepCLI) {
|
||||
t.Errorf("err = %v, want ErrStepCLI", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewServerCert_Success(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca renew", out: nil, err: nil},
|
||||
}
|
||||
if err := c.RenewServerCert(context.Background(), "peer1"); err != nil {
|
||||
t.Fatalf("RenewServerCert: %v", err)
|
||||
}
|
||||
containsCall(t, mx, "step ca renew '/tmp/orca-peer1.crt' '/tmp/orca-peer1.key' --force")
|
||||
}
|
||||
|
||||
func TestRenewServerCert_Fails(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca renew", out: nil, err: errors.New("step: renew failed")},
|
||||
}
|
||||
err := c.RenewServerCert(context.Background(), "peer1")
|
||||
if err == nil || !strings.Contains(err.Error(), "stepca: renew") {
|
||||
t.Errorf("err = %v, want wrapped 'stepca: renew'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprint_Success(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step certificate fingerprint", out: []byte("a1b2c3d4e5f6\n"), err: nil},
|
||||
}
|
||||
fp, err := c.Fingerprint(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint: %v", err)
|
||||
}
|
||||
if fp != "a1b2c3d4e5f6" {
|
||||
t.Errorf("fp = %q, want a1b2c3d4e5f6 (trimmed)", fp)
|
||||
}
|
||||
containsCall(t, mx, "step certificate fingerprint '/etc/step-ca/certs/root_ca.crt'")
|
||||
}
|
||||
|
||||
func TestFingerprint_Empty(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step certificate fingerprint", out: []byte(""), err: nil},
|
||||
}
|
||||
_, err := c.Fingerprint(context.Background())
|
||||
if err == nil || !errors.Is(err, ErrStepCLI) {
|
||||
t.Errorf("err = %v, want ErrStepCLI", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprint_Fails(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step certificate fingerprint", out: nil, err: errors.New("ssh: exec failed")},
|
||||
}
|
||||
_, err := c.Fingerprint(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "stepca: fingerprint") {
|
||||
t.Errorf("err = %v, want wrapped 'stepca: fingerprint'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_MkdirFails(t *testing.T) {
|
||||
// Point ORCA_HOME at a path that cannot be created under to
|
||||
// force MkdirAll failure. We use a file as the parent.
|
||||
dir := t.TempDir()
|
||||
blocker := filepath.Join(dir, "block")
|
||||
if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write blocker: %v", err)
|
||||
}
|
||||
t.Setenv("ORCA_HOME", filepath.Join(blocker, "sub"))
|
||||
// Construct the client directly (not newMockClient, which
|
||||
// resets ORCA_HOME to a fresh temp dir).
|
||||
mx := &mockExec{}
|
||||
caPEM := []byte("FAKE")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca init", out: nil, err: nil},
|
||||
{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: caPEM, err: nil},
|
||||
}
|
||||
c := NewClient(nil, "lead:22")
|
||||
c.exec = mx
|
||||
err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
|
||||
if err == nil {
|
||||
t.Fatal("Init: expected mkdir error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mkdir") {
|
||||
t.Errorf("err = %v, want 'mkdir'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellQuote(t *testing.T) {
|
||||
got := shellQuote("a'b")
|
||||
want := "'a'\\''b'"
|
||||
if got != want {
|
||||
t.Errorf("shellQuote = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitize(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"spiffe://orca/ns/_defaults/job/web/alloc/0",
|
||||
"spiffe-orca_ns__defaults_job_web_alloc_0"},
|
||||
{"plain-host", "plain-host"},
|
||||
{"a b", "a_b"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := sanitize(tc.in); got != tc.want {
|
||||
t.Errorf("sanitize(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestForcedDivergence_ConflictDetectedAndResolved is the gate C-14
|
||||
// forced-divergence integration test. It simulates two peers writing to
|
||||
// the same file *without* the flock (the lock was bypassed), detects the
|
||||
// conflict via DetectConflicts, resolves it via ResolveConflict to the
|
||||
// source peer's content, and verifies the resolution is deterministic
|
||||
// across repeated runs.
|
||||
//
|
||||
// The test uses in-memory file maps (no real Syncthing is involved — it
|
||||
// tests the CLI-side conflict detection + resolution logic, which is
|
||||
// what orca runs on the lead node over the SSH-gathered peer views).
|
||||
func TestForcedDivergence_ConflictDetectedAndResolved(t *testing.T) {
|
||||
// Two peers, same file, different content — no lock held.
|
||||
peerA := []byte("source-writes-this")
|
||||
peerB := []byte("peer-b-writes-that")
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"data/db.sqlite": peerA},
|
||||
"peer-b": {"data/db.sqlite": peerB},
|
||||
}
|
||||
const sourcePeer = "peer-a"
|
||||
const ns = "divergence-ns"
|
||||
|
||||
// Detect.
|
||||
conflicts, err := DetectConflicts(ns, peerFiles)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectConflicts: %v", err)
|
||||
}
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("expected 1 conflict, got %d", len(conflicts))
|
||||
}
|
||||
c := conflicts[0]
|
||||
if c.Path != "data/db.sqlite" {
|
||||
t.Errorf("conflict path = %q, want %q", c.Path, "data/db.sqlite")
|
||||
}
|
||||
if len(c.Versions) != 2 {
|
||||
t.Errorf("conflict has %d versions, want 2", len(c.Versions))
|
||||
}
|
||||
|
||||
// Resolve — source peer (the one holding the lock) wins.
|
||||
winner, losers := ResolveConflict(c, sourcePeer)
|
||||
if !bytes.Equal(winner, peerA) {
|
||||
t.Errorf("winning content = %q, want %q (source peer)", winner, peerA)
|
||||
}
|
||||
if len(losers) != 1 || losers[0] != "peer-b" {
|
||||
t.Errorf("losing peers = %v, want [peer-b]", losers)
|
||||
}
|
||||
|
||||
// Deterministic: re-run detection + resolution, expect identical output.
|
||||
conflicts2, _ := DetectConflicts(ns, peerFiles)
|
||||
winner2, losers2 := ResolveConflict(conflicts2[0], sourcePeer)
|
||||
if !bytes.Equal(winner2, winner) {
|
||||
t.Errorf("non-deterministic winner: %q vs %q", winner2, winner)
|
||||
}
|
||||
if !equalStringSlices(losers2, losers) {
|
||||
t.Errorf("non-deterministic losers: %v vs %v", losers2, losers)
|
||||
}
|
||||
|
||||
// Three-way divergence: source still wins deterministically.
|
||||
peerFiles3 := map[string]map[string][]byte{
|
||||
"peer-a": {"data/db.sqlite": peerA},
|
||||
"peer-b": {"data/db.sqlite": peerB},
|
||||
"peer-c": {"data/db.sqlite": []byte("peer-c-writes-something-else")},
|
||||
}
|
||||
conflicts3, _ := DetectConflicts(ns, peerFiles3)
|
||||
if len(conflicts3) != 1 {
|
||||
t.Fatalf("3-way: expected 1 conflict, got %d", len(conflicts3))
|
||||
}
|
||||
winner3, losers3 := ResolveConflict(conflicts3[0], sourcePeer)
|
||||
if !bytes.Equal(winner3, peerA) {
|
||||
t.Errorf("3-way winner = %q, want %q", winner3, peerA)
|
||||
}
|
||||
if len(losers3) != 2 {
|
||||
t.Errorf("3-way losers = %v, want 2 entries", losers3)
|
||||
}
|
||||
// Losers must be sorted for deterministic ordering.
|
||||
if !isSorted(losers3) {
|
||||
t.Errorf("losers not sorted: %v", losers3)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForcedDivergence_UnknownSource_Unresolved verifies the
|
||||
// deterministic failure mode: when the source peer is unknown (the lock
|
||||
// holder is not in the versions map), ResolveConflict returns (nil, nil)
|
||||
// so the CLI can flag the conflict for manual resolution. No silent
|
||||
// winner is picked.
|
||||
func TestForcedDivergence_UnknownSource_Unresolved(t *testing.T) {
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"f": []byte("a")},
|
||||
"peer-b": {"f": []byte("b")},
|
||||
}
|
||||
conflicts, _ := DetectConflicts("ns", peerFiles)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("expected 1 conflict, got %d", len(conflicts))
|
||||
}
|
||||
// Source peer is "peer-z" — not in the versions map.
|
||||
winner, losers := ResolveConflict(conflicts[0], "peer-z")
|
||||
if winner != nil {
|
||||
t.Errorf("unknown source: winner = %v, want nil", winner)
|
||||
}
|
||||
if losers != nil {
|
||||
t.Errorf("unknown source: losers = %v, want nil", losers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForcedDivergence_NoLockNoConflict simulates the normal path: two
|
||||
// peers hold the same content for the same file (one wrote under the
|
||||
// lock, the other synced read-only). DetectConflicts reports no
|
||||
// conflict.
|
||||
func TestForcedDivergence_NoLockNoConflict(t *testing.T) {
|
||||
content := []byte("same-content")
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"data/f": content},
|
||||
"peer-b": {"data/f": content},
|
||||
}
|
||||
conflicts, err := DetectConflicts("ns", peerFiles)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectConflicts: %v", err)
|
||||
}
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("expected 0 conflicts, got %d: %+v", len(conflicts), conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
// equalStringSlices reports whether two string slices are equal in order.
|
||||
func equalStringSlices(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isSorted reports whether the string slice is in ascending order.
|
||||
func isSorted(s []string) bool {
|
||||
for i := 1; i < len(s); i++ {
|
||||
if s[i-1] > s[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// Package storage implements the CLI-side Syncthing replication logic
|
||||
// for per-namespace storage replication (REQ-081, R-005). It renders the
|
||||
// Syncthing config (XML) for each peer's per-namespace instance and
|
||||
// provides the deterministic conflict-detection + resolution used by the
|
||||
// orca reconciliation loop (gate C-14).
|
||||
//
|
||||
// The package is CLI-side only — it does not run Syncthing and does not
|
||||
// link a Syncthing Go client. The CLI renders the config XML; the
|
||||
// Syncthing apt package on each peer reads it and joins the folder. The
|
||||
// conflict logic operates on in-memory peer file maps gathered over SSH,
|
||||
// so it is fully testable without a real Syncthing instance.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VolumeReplication is the volume-level replication declaration derived
|
||||
// from a jobspec volume entry with a `replicate:` list. The CLI builds one
|
||||
// of these per replicated volume and feeds it to RenderSyncthingConfig.
|
||||
//
|
||||
// - Namespace is the orca namespace the volume belongs to (the
|
||||
// Syncthing folder is per-namespace).
|
||||
// - VolumeName is the volume's name within the jobspec (used in the
|
||||
// rendered config filename).
|
||||
// - SourcePath is the absolute host path the volume is mounted at on
|
||||
// the source peer (the Syncthing folder `path`).
|
||||
// - ReplicateTo is the list of peer identifiers the volume is
|
||||
// replicated to (peer hostnames or device IDs). The source peer is
|
||||
// NOT in this list (the source is implicit — it holds the lock).
|
||||
// - SyncMode is "sendreceive" (default) or "sendonly". Migration uses
|
||||
// sendonly on the destination until the sync completes, then flips
|
||||
// to sendreceive.
|
||||
type VolumeReplication struct {
|
||||
Namespace string
|
||||
VolumeName string
|
||||
SourcePath string
|
||||
ReplicateTo []string
|
||||
SyncMode string
|
||||
}
|
||||
|
||||
// SyncthingConfig is the rendered (in-memory) Syncthing config for a
|
||||
// single peer's per-namespace folder. The XML emitter
|
||||
// (RenderSyncthingXML) serializes this into the `config.xml` file.
|
||||
//
|
||||
// - FolderID is the content-addressed folder ID
|
||||
// (sha256(namespace + masterKeyFingerprint)[:32], see FolderID).
|
||||
// - Path is the on-disk path the folder is rooted at (SourcePath).
|
||||
// - Devices is the full device list for the folder (all peers in the
|
||||
// namespace, including the local peer). The emitter renders one
|
||||
// <device> entry per element inside <folder> and one <device> block
|
||||
// at the top level per Syncthing's config schema.
|
||||
type SyncthingConfig struct {
|
||||
FolderID string
|
||||
Path string
|
||||
Devices []SyncthingDevice
|
||||
}
|
||||
|
||||
// SyncthingDevice is a single peer device entry in the rendered config.
|
||||
//
|
||||
// - ID is the Syncthing device ID (a 52-char base32 string; the CLI
|
||||
// discovers it from the peer registry / cluster/peers/).
|
||||
// - Name is the human-readable peer name (the orca node hostname).
|
||||
// - Address is the Sync listening address for the peer
|
||||
// ("tcp://host:22000" for remote peers, "dynamic" for the local
|
||||
// peer).
|
||||
type SyncthingDevice struct {
|
||||
ID string
|
||||
Name string
|
||||
Address string
|
||||
}
|
||||
|
||||
// Conflict is a single detected file-level conflict across peers. A
|
||||
// conflict exists when two or more peers hold different content for the
|
||||
// same file path within the replicated volume.
|
||||
//
|
||||
// - Path is the file path relative to the volume root (the same key
|
||||
// used in the peer file maps).
|
||||
// - SourcePeer is the peer that held the flock at the time of the
|
||||
// conflict (the authority for resolution). Empty when the source is
|
||||
// unknown (the lock was bypassed — operator resolves manually).
|
||||
// - Versions maps peer → content for every peer that holds a copy of
|
||||
// the file. Two entries with equal []byte are not a conflict even if
|
||||
// they come from different peers.
|
||||
type Conflict struct {
|
||||
Path string
|
||||
SourcePeer string
|
||||
Versions map[string][]byte
|
||||
}
|
||||
|
||||
// masterKeyFingerprintPlaceholder is the default master-key fingerprint
|
||||
// used when the caller has not yet wired the real step-ca master key
|
||||
// (P10). It is a fixed, stable string so the FolderID is deterministic
|
||||
// across re-runs during the v0.9 development window. P10 replaces this
|
||||
// with the real fingerprint derived from the step-ca root CA.
|
||||
const masterKeyFingerprintPlaceholder = "orca-master-key-fp-placeholder-v0.9"
|
||||
|
||||
// FolderID returns the content-addressed Syncthing folder ID for the
|
||||
// given namespace + master-key fingerprint (REQ-081). The folder ID is
|
||||
// the first 32 hex characters of sha256(namespace + masterKeyFP). The
|
||||
// fingerprint is optional — when empty, the v0.9 placeholder is used so
|
||||
// the function is callable before P10 wires the real key.
|
||||
//
|
||||
// The folder ID is deterministic: the same (namespace, masterKeyFP)
|
||||
// always produces the same ID, and different namespaces (or different
|
||||
// master keys) always produce different IDs. The 32-char prefix is
|
||||
// well within Syncthing's folder-ID length limit (Syncthing accepts any
|
||||
// printable ASCII string up to 64 chars).
|
||||
func FolderID(namespace string, masterKeyFP string) string {
|
||||
if strings.TrimSpace(masterKeyFP) == "" {
|
||||
masterKeyFP = masterKeyFingerprintPlaceholder
|
||||
}
|
||||
h := sha256.Sum256([]byte(namespace + masterKeyFP))
|
||||
return hex.EncodeToString(h[:16])
|
||||
}
|
||||
|
||||
// RenderSyncthingConfig builds the in-memory SyncthingConfig for the
|
||||
// given volume replication + peer device list. The folder ID is
|
||||
// content-addressed (FolderID); the devices are copied verbatim from
|
||||
// the input (the caller is responsible for ordering — the emitter
|
||||
// preserves input order for byte-stable output).
|
||||
//
|
||||
// Returns an error if the replication has no namespace, no source path,
|
||||
// or no devices (a folder with zero devices is not a replication).
|
||||
func RenderSyncthingConfig(rep VolumeReplication, peers []SyncthingDevice) (*SyncthingConfig, error) {
|
||||
if strings.TrimSpace(rep.Namespace) == "" {
|
||||
return nil, errors.New("storage: replication namespace is empty")
|
||||
}
|
||||
if strings.TrimSpace(rep.SourcePath) == "" {
|
||||
return nil, errors.New("storage: replication source path is empty")
|
||||
}
|
||||
if len(peers) == 0 {
|
||||
return nil, errors.New("storage: replication has no peer devices")
|
||||
}
|
||||
mode := strings.TrimSpace(rep.SyncMode)
|
||||
if mode == "" {
|
||||
mode = "sendreceive"
|
||||
}
|
||||
_ = mode
|
||||
cfg := &SyncthingConfig{
|
||||
FolderID: FolderID(rep.Namespace, ""),
|
||||
Path: rep.SourcePath,
|
||||
Devices: append([]SyncthingDevice(nil), peers...),
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// syncthingXMLFolder is the <folder> element in the rendered config.
|
||||
type syncthingXMLFolder struct {
|
||||
XMLName xml.Name `xml:"folder"`
|
||||
ID string `xml:"id,attr"`
|
||||
Path string `xml:"path,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
IgnorePerms bool `xml:"ignorePerms,attr"`
|
||||
Devices []syncthingXMLDevice `xml:"device"`
|
||||
FSync bool `xml:"fsync"`
|
||||
}
|
||||
|
||||
// syncthingXMLDevice is the <device> element (both inside <folder> and
|
||||
// at the top level; the top-level form carries the address).
|
||||
type syncthingXMLDevice struct {
|
||||
XMLName xml.Name `xml:"device"`
|
||||
ID string `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
Compression string `xml:"compression,attr,omitempty"`
|
||||
Address string `xml:"address,omitempty"`
|
||||
}
|
||||
|
||||
// syncthingXMLOptions is the <options> element.
|
||||
type syncthingXMLOptions struct {
|
||||
XMLName xml.Name `xml:"options"`
|
||||
ListenAddress string `xml:"listenAddress"`
|
||||
GlobalAnnounceEnabled bool `xml:"globalAnnounceEnabled"`
|
||||
LocalAnnounceEnabled bool `xml:"localAnnounceEnabled"`
|
||||
RelayingEnabled bool `xml:"relayingEnabled"`
|
||||
URAccepted int `xml:"urAccepted"`
|
||||
}
|
||||
|
||||
// syncthingXMLGUI is the <gui> element (disabled — no GUI).
|
||||
type syncthingXMLGUI struct {
|
||||
XMLName xml.Name `xml:"gui"`
|
||||
Enabled bool `xml:"enabled,attr"`
|
||||
}
|
||||
|
||||
// syncthingXMLConfig is the root <configuration> element.
|
||||
type syncthingXMLConfig struct {
|
||||
XMLName xml.Name `xml:"configuration"`
|
||||
Version int `xml:"version,attr"`
|
||||
GUI syncthingXMLGUI `xml:"gui"`
|
||||
Options syncthingXMLOptions `xml:"options"`
|
||||
Folders []syncthingXMLFolder `xml:"folder"`
|
||||
Devices []syncthingXMLDevice `xml:"device"`
|
||||
}
|
||||
|
||||
// RenderSyncthingXML serializes the SyncthingConfig into the
|
||||
// `config.xml` file content. The output is valid Syncthing config XML:
|
||||
// the root <configuration> carries the folder (with the content-addressed
|
||||
// folder ID, the path, and the device list) and the top-level device
|
||||
// blocks (with their listening addresses). The GUI is disabled, global
|
||||
// announce is disabled, relaying is disabled, and the usage-reporting
|
||||
// consent is set to -1 (declined) — the rendered config is fully
|
||||
// headless and cluster-local.
|
||||
//
|
||||
// The output is byte-stable for a given SyncthingConfig: devices are
|
||||
// emitted in slice order, XML attributes are emitted in struct-field
|
||||
// order, and no timestamps or random values are inserted. This makes
|
||||
// the SSH-push idempotent write-path a no-op when nothing changed.
|
||||
//
|
||||
// Returns an error if the config is nil, the folder ID is empty, or
|
||||
// the device list is empty.
|
||||
func RenderSyncthingXML(cfg *SyncthingConfig) (string, error) {
|
||||
if cfg == nil {
|
||||
return "", errors.New("storage: syncthing config is nil")
|
||||
}
|
||||
if strings.TrimSpace(cfg.FolderID) == "" {
|
||||
return "", errors.New("storage: syncthing folder ID is empty")
|
||||
}
|
||||
if len(cfg.Devices) == 0 {
|
||||
return "", errors.New("storage: syncthing config has no devices")
|
||||
}
|
||||
folderDevs := make([]syncthingXMLDevice, 0, len(cfg.Devices))
|
||||
topDevs := make([]syncthingXMLDevice, 0, len(cfg.Devices))
|
||||
for _, d := range cfg.Devices {
|
||||
folderDevs = append(folderDevs, syncthingXMLDevice{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
})
|
||||
topDevs = append(topDevs, syncthingXMLDevice{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
Compression: "metadata",
|
||||
Address: d.Address,
|
||||
})
|
||||
}
|
||||
doc := syncthingXMLConfig{
|
||||
Version: 37,
|
||||
GUI: syncthingXMLGUI{Enabled: false},
|
||||
Options: syncthingXMLOptions{
|
||||
ListenAddress: "default",
|
||||
GlobalAnnounceEnabled: false,
|
||||
LocalAnnounceEnabled: true,
|
||||
RelayingEnabled: false,
|
||||
URAccepted: -1,
|
||||
},
|
||||
Folders: []syncthingXMLFolder{{
|
||||
ID: cfg.FolderID,
|
||||
Path: cfg.Path,
|
||||
Type: "sendreceive",
|
||||
IgnorePerms: false,
|
||||
Devices: folderDevs,
|
||||
FSync: true,
|
||||
}},
|
||||
Devices: topDevs,
|
||||
}
|
||||
out, err := xml.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("storage: marshal syncthing xml: %w", err)
|
||||
}
|
||||
return xml.Header + string(out) + "\n", nil
|
||||
}
|
||||
|
||||
// DetectConflicts scans the peer file maps for conflicting versions of
|
||||
// the same file (gate C-14). A file is in conflict when two or more
|
||||
// peers hold *different* content for the same path. Files that only one
|
||||
// peer holds are NOT conflicts (the other peers simply haven't synced
|
||||
// yet — Syncthing will catch up). Files that all peers hold with equal
|
||||
// content are NOT conflicts.
|
||||
//
|
||||
// The peerFiles map is peer → (path → content). The sourcePeer is the
|
||||
// peer that held the flock at the time of the scan (the authority for
|
||||
// resolution); it may be empty when the source is unknown (the lock was
|
||||
// bypassed — the conflict is reported with SourcePeer="" and the
|
||||
// operator resolves manually).
|
||||
//
|
||||
// The returned conflicts are sorted by path for deterministic ordering
|
||||
// (the same input always produces the same output slice — no map-iteration
|
||||
// nondeterminism leaks out).
|
||||
func DetectConflicts(namespace string, peerFiles map[string]map[string][]byte) ([]Conflict, error) {
|
||||
_ = namespace
|
||||
if len(peerFiles) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// path -> peer -> content
|
||||
byPath := make(map[string]map[string][]byte)
|
||||
for peer, files := range peerFiles {
|
||||
for path, content := range files {
|
||||
if byPath[path] == nil {
|
||||
byPath[path] = make(map[string][]byte)
|
||||
}
|
||||
byPath[path][peer] = append([]byte(nil), content...)
|
||||
}
|
||||
}
|
||||
paths := make([]string, 0, len(byPath))
|
||||
for p := range byPath {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
var conflicts []Conflict
|
||||
for _, path := range paths {
|
||||
versions := byPath[path]
|
||||
if len(versions) < 2 {
|
||||
continue
|
||||
}
|
||||
if !contentsDiffer(versions) {
|
||||
continue
|
||||
}
|
||||
c := Conflict{
|
||||
Path: path,
|
||||
Versions: versions,
|
||||
}
|
||||
conflicts = append(conflicts, c)
|
||||
}
|
||||
return conflicts, nil
|
||||
}
|
||||
|
||||
// contentsDiffer reports whether the peer→content map holds at least
|
||||
// two distinct content values.
|
||||
func contentsDiffer(versions map[string][]byte) bool {
|
||||
var seen []byte
|
||||
first := true
|
||||
for _, content := range versions {
|
||||
if first {
|
||||
seen = content
|
||||
first = false
|
||||
continue
|
||||
}
|
||||
if !bytesEqual(seen, content) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bytesEqual is a thin wrapper over bytes.Equal kept for testability
|
||||
// and to avoid importing bytes at the call site of contentsDiffer.
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ResolveConflict resolves a single conflict by picking the source
|
||||
// peer's content (the peer that held the lock). The resolution is
|
||||
// deterministic: the same (conflict, sourcePeer) always produces the
|
||||
// same (winningContent, losingPeers). Returns the winning content and
|
||||
// the list of peers whose content differs from the winner (the losing
|
||||
// peers). The losingPeers list is sorted for deterministic ordering.
|
||||
//
|
||||
// If the sourcePeer is not in the conflict's Versions map, the conflict
|
||||
// is unresolved — the function returns (nil, nil) so the caller can
|
||||
// flag it for manual resolution. This is the only non-deterministic
|
||||
// path and it is by design: when the lock holder is unknown, no peer
|
||||
// has the authority, so the CLI refuses to pick a winner.
|
||||
func ResolveConflict(conflict Conflict, sourcePeer string) (winningContent []byte, losingPeers []string) {
|
||||
content, ok := conflict.Versions[sourcePeer]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
winningContent = append([]byte(nil), content...)
|
||||
losing := make([]string, 0, len(conflict.Versions))
|
||||
for peer, c := range conflict.Versions {
|
||||
if peer == sourcePeer {
|
||||
continue
|
||||
}
|
||||
if !bytesEqual(c, winningContent) {
|
||||
losing = append(losing, peer)
|
||||
}
|
||||
}
|
||||
sort.Strings(losing)
|
||||
return winningContent, losing
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFolderID_Deterministic(t *testing.T) {
|
||||
a := FolderID("team-alpha", "fp1")
|
||||
b := FolderID("team-alpha", "fp1")
|
||||
if a != b {
|
||||
t.Errorf("FolderID not deterministic: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderID_DifferentNamespaces(t *testing.T) {
|
||||
a := FolderID("team-alpha", "fp1")
|
||||
b := FolderID("team-beta", "fp1")
|
||||
if a == b {
|
||||
t.Errorf("different namespaces produced same folder ID: %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderID_DifferentMasterKeys(t *testing.T) {
|
||||
a := FolderID("team-alpha", "fp1")
|
||||
b := FolderID("team-alpha", "fp2")
|
||||
if a == b {
|
||||
t.Errorf("different master keys produced same folder ID: %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderID_EmptyFingerprintUsesPlaceholder(t *testing.T) {
|
||||
a := FolderID("team-alpha", "")
|
||||
b := FolderID("team-alpha", masterKeyFingerprintPlaceholder)
|
||||
if a != b {
|
||||
t.Errorf("empty fingerprint did not match placeholder: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderID_Length(t *testing.T) {
|
||||
id := FolderID("ns", "fp")
|
||||
if len(id) != 32 {
|
||||
t.Errorf("folder ID length = %d, want 32", len(id))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSyncthingConfig_OK(t *testing.T) {
|
||||
rep := VolumeReplication{
|
||||
Namespace: "team-alpha",
|
||||
VolumeName: "data",
|
||||
SourcePath: "/var/lib/orca/volumes/data",
|
||||
ReplicateTo: []string{"peer-b", "peer-c"},
|
||||
SyncMode: "sendreceive",
|
||||
}
|
||||
peers := []SyncthingDevice{
|
||||
{ID: "dev-source", Name: "peer-a", Address: "dynamic"},
|
||||
{ID: "dev-b", Name: "peer-b", Address: "tcp://peer-b:22000"},
|
||||
{ID: "dev-c", Name: "peer-c", Address: "tcp://peer-c:22000"},
|
||||
}
|
||||
cfg, err := RenderSyncthingConfig(rep, peers)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderSyncthingConfig: %v", err)
|
||||
}
|
||||
if cfg.FolderID != FolderID("team-alpha", "") {
|
||||
t.Errorf("folder ID = %q, want %q", cfg.FolderID, FolderID("team-alpha", ""))
|
||||
}
|
||||
if cfg.Path != rep.SourcePath {
|
||||
t.Errorf("path = %q, want %q", cfg.Path, rep.SourcePath)
|
||||
}
|
||||
if len(cfg.Devices) != 3 {
|
||||
t.Errorf("devices = %d, want 3", len(cfg.Devices))
|
||||
}
|
||||
if cfg.Devices[0].ID != "dev-source" {
|
||||
t.Errorf("first device = %q, want dev-source", cfg.Devices[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSyncthingConfig_Errors(t *testing.T) {
|
||||
peers := []SyncthingDevice{{ID: "d", Name: "n", Address: "dynamic"}}
|
||||
if _, err := RenderSyncthingConfig(VolumeReplication{}, peers); err == nil {
|
||||
t.Error("empty namespace: expected error")
|
||||
}
|
||||
if _, err := RenderSyncthingConfig(VolumeReplication{Namespace: "ns"}, peers); err == nil {
|
||||
t.Error("empty source path: expected error")
|
||||
}
|
||||
if _, err := RenderSyncthingConfig(VolumeReplication{Namespace: "ns", SourcePath: "/p"}, nil); err == nil {
|
||||
t.Error("no peers: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSyncthingXML_Valid(t *testing.T) {
|
||||
cfg := &SyncthingConfig{
|
||||
FolderID: "abcd1234abcd1234abcd1234abcd1234",
|
||||
Path: "/var/lib/orca/data",
|
||||
Devices: []SyncthingDevice{
|
||||
{ID: "dev-source", Name: "peer-a", Address: "dynamic"},
|
||||
{ID: "dev-b", Name: "peer-b", Address: "tcp://peer-b:22000"},
|
||||
},
|
||||
}
|
||||
out, err := RenderSyncthingXML(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderSyncthingXML: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(out, xml.Header) {
|
||||
t.Errorf("output missing XML header")
|
||||
}
|
||||
if !strings.Contains(out, `id="abcd1234abcd1234abcd1234abcd1234"`) {
|
||||
t.Errorf("folder ID not in output")
|
||||
}
|
||||
if !strings.Contains(out, "dev-source") {
|
||||
t.Errorf("source device ID not in output")
|
||||
}
|
||||
if !strings.Contains(out, "dev-b") {
|
||||
t.Errorf("peer device ID not in output")
|
||||
}
|
||||
if !strings.Contains(out, "/var/lib/orca/data") {
|
||||
t.Errorf("path not in output")
|
||||
}
|
||||
if !strings.Contains(out, `tcp://peer-b:22000`) {
|
||||
t.Errorf("peer address not in output")
|
||||
}
|
||||
if !strings.Contains(out, `<gui enabled="false"`) {
|
||||
t.Errorf("GUI not disabled in output")
|
||||
}
|
||||
// Must be parseable as XML.
|
||||
var doc syncthingXMLConfig
|
||||
if err := xml.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("output is not valid XML: %v", err)
|
||||
}
|
||||
if doc.Folders[0].ID != cfg.FolderID {
|
||||
t.Errorf("parsed folder ID = %q, want %q", doc.Folders[0].ID, cfg.FolderID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSyncthingXML_Deterministic(t *testing.T) {
|
||||
cfg := &SyncthingConfig{
|
||||
FolderID: "abcd1234abcd1234abcd1234abcd1234",
|
||||
Path: "/data",
|
||||
Devices: []SyncthingDevice{
|
||||
{ID: "dev-a", Name: "a", Address: "dynamic"},
|
||||
{ID: "dev-b", Name: "b", Address: "tcp://b:22000"},
|
||||
},
|
||||
}
|
||||
a, _ := RenderSyncthingXML(cfg)
|
||||
b, _ := RenderSyncthingXML(cfg)
|
||||
if a != b {
|
||||
t.Errorf("RenderSyncthingXML not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSyncthingXML_Errors(t *testing.T) {
|
||||
if _, err := RenderSyncthingXML(nil); err == nil {
|
||||
t.Error("nil config: expected error")
|
||||
}
|
||||
if _, err := RenderSyncthingXML(&SyncthingConfig{Path: "/p", Devices: []SyncthingDevice{{ID: "d"}}}); err == nil {
|
||||
t.Error("empty folder ID: expected error")
|
||||
}
|
||||
if _, err := RenderSyncthingXML(&SyncthingConfig{FolderID: "f", Path: "/p"}); err == nil {
|
||||
t.Error("no devices: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectConflicts_Differ(t *testing.T) {
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"f": []byte("a"), "shared": []byte("same")},
|
||||
"peer-b": {"f": []byte("b"), "shared": []byte("same")},
|
||||
}
|
||||
conflicts, err := DetectConflicts("ns", peerFiles)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectConflicts: %v", err)
|
||||
}
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("expected 1 conflict, got %d", len(conflicts))
|
||||
}
|
||||
if conflicts[0].Path != "f" {
|
||||
t.Errorf("conflict path = %q, want f", conflicts[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectConflicts_AllAgree(t *testing.T) {
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"f": []byte("same"), "g": []byte("x")},
|
||||
"peer-b": {"f": []byte("same"), "g": []byte("x")},
|
||||
}
|
||||
conflicts, err := DetectConflicts("ns", peerFiles)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectConflicts: %v", err)
|
||||
}
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("expected 0 conflicts, got %d", len(conflicts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectConflicts_SinglePeerNoConflict(t *testing.T) {
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"f": []byte("a")},
|
||||
}
|
||||
conflicts, err := DetectConflicts("ns", peerFiles)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectConflicts: %v", err)
|
||||
}
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("single peer should not produce conflict, got %d", len(conflicts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectConflicts_SortedOutput(t *testing.T) {
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"z": []byte("a"), "a": []byte("a"), "m": []byte("a")},
|
||||
"peer-b": {"z": []byte("b"), "a": []byte("b"), "m": []byte("b")},
|
||||
}
|
||||
conflicts, _ := DetectConflicts("ns", peerFiles)
|
||||
if len(conflicts) != 3 {
|
||||
t.Fatalf("expected 3 conflicts, got %d", len(conflicts))
|
||||
}
|
||||
if conflicts[0].Path != "a" || conflicts[1].Path != "m" || conflicts[2].Path != "z" {
|
||||
t.Errorf("conflicts not sorted: %v", []string{conflicts[0].Path, conflicts[1].Path, conflicts[2].Path})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectConflicts_EmptyInput(t *testing.T) {
|
||||
conflicts, err := DetectConflicts("ns", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectConflicts: %v", err)
|
||||
}
|
||||
if conflicts != nil {
|
||||
t.Errorf("empty input should return nil, got %v", conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConflict_SourceWins(t *testing.T) {
|
||||
c := Conflict{
|
||||
Path: "f",
|
||||
Versions: map[string][]byte{
|
||||
"peer-a": []byte("source-content"),
|
||||
"peer-b": []byte("other-content"),
|
||||
},
|
||||
}
|
||||
winner, losers := ResolveConflict(c, "peer-a")
|
||||
if string(winner) != "source-content" {
|
||||
t.Errorf("winner = %q, want source-content", winner)
|
||||
}
|
||||
if len(losers) != 1 || losers[0] != "peer-b" {
|
||||
t.Errorf("losers = %v, want [peer-b]", losers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConflict_PeersAgreeNotLosers(t *testing.T) {
|
||||
c := Conflict{
|
||||
Path: "f",
|
||||
Versions: map[string][]byte{
|
||||
"peer-a": []byte("source"),
|
||||
"peer-b": []byte("source"), // agrees with source
|
||||
"peer-c": []byte("differ"), // differs
|
||||
},
|
||||
}
|
||||
winner, losers := ResolveConflict(c, "peer-a")
|
||||
if string(winner) != "source" {
|
||||
t.Errorf("winner = %q, want source", winner)
|
||||
}
|
||||
if len(losers) != 1 || losers[0] != "peer-c" {
|
||||
t.Errorf("losers = %v, want [peer-c]", losers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConflict_Deterministic(t *testing.T) {
|
||||
c := Conflict{
|
||||
Path: "f",
|
||||
Versions: map[string][]byte{
|
||||
"peer-a": []byte("a"),
|
||||
"peer-b": []byte("b"),
|
||||
"peer-c": []byte("c"),
|
||||
},
|
||||
}
|
||||
w1, l1 := ResolveConflict(c, "peer-a")
|
||||
w2, l2 := ResolveConflict(c, "peer-a")
|
||||
if string(w1) != string(w2) {
|
||||
t.Errorf("non-deterministic winner")
|
||||
}
|
||||
if !equalStringSlices(l1, l2) {
|
||||
t.Errorf("non-deterministic losers: %v vs %v", l1, l2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConflict_UnknownSource(t *testing.T) {
|
||||
c := Conflict{
|
||||
Path: "f",
|
||||
Versions: map[string][]byte{
|
||||
"peer-a": []byte("a"),
|
||||
"peer-b": []byte("b"),
|
||||
},
|
||||
}
|
||||
winner, losers := ResolveConflict(c, "peer-z")
|
||||
if winner != nil {
|
||||
t.Errorf("unknown source winner should be nil, got %q", winner)
|
||||
}
|
||||
if losers != nil {
|
||||
t.Errorf("unknown source losers should be nil, got %v", losers)
|
||||
}
|
||||
}
|
||||
+61
-7
@@ -9,11 +9,16 @@
|
||||
# Options:
|
||||
# --system Install at system level (/usr/local/bin/orca, namespace /root/.orca). Requires root.
|
||||
# --version <tag> Pin a specific version (e.g. v0.4.2). Default: latest release.
|
||||
# --check Dry-run: print the version + asset URL + install path without writing.
|
||||
# --help, -h Show this help.
|
||||
#
|
||||
# Behavior:
|
||||
# - Downloads the release tarball from the public Gitea release URL.
|
||||
# - Extracts the orca binary to the install path.
|
||||
# - If the resolved release (latest or pinned) has no matching binary
|
||||
# asset, walks backward through recent releases to find one that does,
|
||||
# and prints a warning. (REQ-098 — the v0.8.x releases shipped with
|
||||
# zero binary assets, causing install to resolve to v0.4.5.)
|
||||
# - If an existing orca binary is found, reads its version and prints
|
||||
# "updated from X to Y" (in-place update; preserves config/db/certs).
|
||||
# - Idempotent: re-running with the same version reinstalls the binary.
|
||||
@@ -27,6 +32,7 @@ GITEA_REPO="${GITEA_REPO:-orca}"
|
||||
|
||||
SYSTEM=false
|
||||
VERSION=""
|
||||
CHECK=false
|
||||
INSTALL_BIN=""
|
||||
NAMESPACE_DIR=""
|
||||
|
||||
@@ -45,6 +51,7 @@ while [ $# -gt 0 ]; do
|
||||
--system) SYSTEM=true; shift ;;
|
||||
--version) VERSION="${2:-}"; shift 2 ;;
|
||||
--version=*) VERSION="${1#*=}"; shift ;;
|
||||
--check) CHECK=true; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) err "unknown argument: $1 (try --help)" ;;
|
||||
esac
|
||||
@@ -91,16 +98,46 @@ esac
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
TARBALL="orca-${VERSION}-${OS}-${ARCH}.tar.gz"
|
||||
|
||||
# --- find asset download URL ----------------------------------------------
|
||||
# --- find asset download URL (with fallback walk — REQ-098) --------------
|
||||
#
|
||||
# The v0.8.x releases shipped with zero binary assets attached, causing
|
||||
# install to error out on the latest release. If the resolved release
|
||||
# (latest or --version) lacks the matching tarball, walk backward through
|
||||
# recent releases to find one that carries it, and print a warning.
|
||||
|
||||
info "locating asset ${TARBALL}..."
|
||||
ASSET_URL="$(curl -fsSL "${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/tags/${VERSION}" \
|
||||
| sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
|
||||
| grep "/${TARBALL}\$" \
|
||||
| head -1)"
|
||||
find_asset_url() {
|
||||
# $1 = tag. Prints the browser_download_url for the matching tarball, or empty.
|
||||
# The `|| true` prevents set -e + pipefail from exiting the script when
|
||||
# grep finds no match (exit 1) — an empty result is a valid outcome.
|
||||
local tag="$1"
|
||||
curl -fsSL "${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/tags/${tag}" \
|
||||
| sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
|
||||
| grep "/${TARBALL}\$" \
|
||||
| head -1 || true
|
||||
}
|
||||
|
||||
info "locating asset ${TARBALL} in release ${VERSION}..."
|
||||
ASSET_URL="$(find_asset_url "$VERSION")"
|
||||
|
||||
if [ -z "$ASSET_URL" ]; then
|
||||
err "could not find asset ${TARBALL} in release ${VERSION}. Check that the release exists and has a linux-${ARCH} tarball."
|
||||
info "WARNING: release ${VERSION} has no ${TARBALL} asset. Walking back through recent releases..."
|
||||
# The /releases list endpoint returns assets inline (browser_download_url
|
||||
# appears within each release's assets array). Extract all download URLs
|
||||
# from the list response and find the first (newest) one matching our
|
||||
# OS+arch tarball pattern (any version). This avoids per-release API calls.
|
||||
ASSET_URL="$(curl -fsSL "${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases?limit=50" \
|
||||
| grep -oE '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||||
| sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
|
||||
| grep -E "/orca-[^/]*-${OS}-${ARCH}\.tar\.gz$" \
|
||||
| head -1 || true)"
|
||||
if [ -n "$ASSET_URL" ]; then
|
||||
# Extract the version from the URL (e.g. .../download/v0.4.5/orca-...)
|
||||
FALLBACK_VERSION="$(echo "$ASSET_URL" | sed -n 's|.*/download/\([^/]*\)/.*|\1|p')"
|
||||
info "WARNING: latest release ${VERSION} has no binary asset; falling back to ${FALLBACK_VERSION} which has orca-${FALLBACK_VERSION}-${OS}-${ARCH}.tar.gz."
|
||||
VERSION="$FALLBACK_VERSION"
|
||||
else
|
||||
err "could not find any release with a ${OS}-${ARCH} tarball in the last 50 releases. Check that a release exists with a linux-${ARCH} binary."
|
||||
fi
|
||||
fi
|
||||
info "asset: ${ASSET_URL}"
|
||||
|
||||
@@ -111,6 +148,23 @@ if [ -x "$INSTALL_BIN" ]; then
|
||||
OLD_VERSION="$("$INSTALL_BIN" version --json 2>/dev/null | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1 || echo "")"
|
||||
fi
|
||||
|
||||
# --- --check dry-run (D-194) ---------------------------------------------
|
||||
# Print what would be installed without writing anything.
|
||||
|
||||
if [ "$CHECK" = "true" ]; then
|
||||
info "dry-run (--check): no files will be written"
|
||||
info " would install: orca ${VERSION}"
|
||||
info " asset: ${ASSET_URL}"
|
||||
info " binary path: ${INSTALL_BIN}"
|
||||
info " namespace root: ${NAMESPACE_DIR}"
|
||||
if [ -n "$OLD_VERSION" ]; then
|
||||
info " current: ${OLD_VERSION} (would update to ${VERSION})"
|
||||
else
|
||||
info " current: (not installed)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- download + extract ---------------------------------------------------
|
||||
|
||||
TMPDIR="$(mktemp -d)"
|
||||
|
||||
+43
-12
@@ -75,26 +75,26 @@ info "version: $VERSION"
|
||||
info "building..."
|
||||
|
||||
# --- build with version injection ----------------------------------------
|
||||
# Cross-build linux-amd64 regardless of host arch (D-193). The install.sh
|
||||
# user base is amd64; the .coreci.yml release step hardcodes the amd64
|
||||
# tarball name. Building for the host arch produced the wrong tarball when
|
||||
# the release was cut from an arm64 dev machine — the root cause of the
|
||||
# v0.4.5 install incident (REQ-097).
|
||||
|
||||
GIT_COMMIT="$(git rev-parse --short HEAD)"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
LDFLAGS="-s -w -X git.cloudinit.dev/coreci/orca/internal/cli.version=$VERSION -X git.cloudinit.dev/coreci/orca/internal/cli.gitCommit=$GIT_COMMIT -X git.cloudinit.dev/coreci/orca/internal/cli.buildTime=$BUILD_TIME"
|
||||
|
||||
mkdir -p bin
|
||||
go build -trimpath -ldflags="$LDFLAGS" -o bin/orca ./cmd/orca
|
||||
info "built: bin/orca"
|
||||
info "building orca-${VERSION}-linux-amd64 (cross-compile, CGO_ENABLED=0)..."
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$LDFLAGS" -o bin/orca ./cmd/orca
|
||||
info "built: bin/orca (linux-amd64)"
|
||||
|
||||
# --- tarball --------------------------------------------------------------
|
||||
# Always produce the linux-amd64 tarball name that install.sh looks for.
|
||||
# (D-193: arm64 is a separate enhancement; this milestone ships amd64 only.)
|
||||
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH=amd64 ;;
|
||||
aarch64) ARCH=arm64 ;;
|
||||
armv7l) ARCH=armv7 ;;
|
||||
esac
|
||||
|
||||
TARBALL="orca-${VERSION}-${OS}-${ARCH}.tar.gz"
|
||||
TARBALL="orca-${VERSION}-linux-amd64.tar.gz"
|
||||
tar -czf "$TARBALL" -C bin orca
|
||||
info "packaged: $TARBALL ($(du -h "$TARBALL" | cut -f1))"
|
||||
|
||||
@@ -135,7 +135,38 @@ tea releases create "$VERSION" \
|
||||
--note-file "$NOTES_FILE" \
|
||||
--asset "$TARBALL"
|
||||
|
||||
info "✓ release $VERSION published"
|
||||
# --- post-create asset verification (REQ-097, gate C-21) ------------------
|
||||
# tea releases create has been observed to exit 0 without attaching the
|
||||
# asset in some versions. Verify the asset actually appears in the release
|
||||
# via the Gitea API; retry once if missing; fail loudly if still missing.
|
||||
# This is the root-cause fix for the v0.8.x releases that shipped with zero
|
||||
# binary assets.
|
||||
|
||||
verify_asset() {
|
||||
local tag="$1" want="$2"
|
||||
curl -fsSL "${GITEA_URL:-https://git.cloudinit.dev}/api/v1/repos/${GITEA_OWNER:-coreci}/${GITEA_REPO:-orca}/releases/tags/${tag}" \
|
||||
| sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
|
||||
| grep -qx "$want"
|
||||
}
|
||||
|
||||
info "verifying asset ${TARBALL} attached to release ${VERSION}..."
|
||||
if verify_asset "$VERSION" "$TARBALL"; then
|
||||
info "✓ asset verified: ${TARBALL}"
|
||||
else
|
||||
info "asset missing after tea releases create; retrying upload..."
|
||||
# Retry: re-add the asset via tea releases edit
|
||||
tea release edit "$VERSION" --repo "$REPO" --asset "$TARBALL" 2>/dev/null \
|
||||
|| tea releases edit "$VERSION" --repo "$REPO" --asset "$TARBALL" 2>/dev/null \
|
||||
|| true
|
||||
sleep 2
|
||||
if verify_asset "$VERSION" "$TARBALL"; then
|
||||
info "✓ asset verified on retry: ${TARBALL}"
|
||||
else
|
||||
err "asset ${TARBALL} NOT attached to release ${VERSION} after retry — the release exists but has no binary. Run 'tea releases edit ${VERSION} --repo $REPO --asset $TARBALL' manually. (REQ-097, C-21)"
|
||||
fi
|
||||
fi
|
||||
|
||||
info "✓ release $VERSION published with binary asset"
|
||||
|
||||
# --- publish container image to gitea registry (REQ-046) ------------------
|
||||
# Skipped gracefully if docker is not on PATH (e.g. local dev without docker).
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bats
|
||||
# Tests for scripts/install.sh (REQ-098: fallback walk + --check dry-run).
|
||||
# Hermetic: tests the argument parsing, arch detection, and --check
|
||||
# output formatting without hitting the Gitea API. The network-dependent
|
||||
# fallback walk is tested via a mock curl in a separate test.
|
||||
|
||||
load test_helper
|
||||
|
||||
@test "install.sh --help exits 0 and shows usage" {
|
||||
run "$SCRIPTS_DIR/install.sh" --help
|
||||
assert_status 0 "$status"
|
||||
assert_contains "$output" "--system"
|
||||
assert_contains "$output" "--version"
|
||||
assert_contains "$output" "--check"
|
||||
assert_contains "$output" "--help"
|
||||
}
|
||||
|
||||
@test "install.sh --check flag is parsed without error" {
|
||||
# --check with a pinned version that exists (v0.4.5) should succeed
|
||||
# and print the dry-run block. This is a live integration test against
|
||||
# the public Gitea API; skip if network is unavailable.
|
||||
skip_if_no_network
|
||||
run "$SCRIPTS_DIR/install.sh" --check --version v0.4.5
|
||||
assert_status 0 "$status"
|
||||
assert_contains "$output" "dry-run (--check)"
|
||||
assert_contains "$output" "would install: orca v0.4.5"
|
||||
assert_contains "$output" "no files will be written"
|
||||
}
|
||||
|
||||
@test "install.sh --check falls back when latest release has no asset" {
|
||||
# v0.9.0 is a pre-execution release with no binary asset. --check
|
||||
# should walk back and find v0.4.5 (which has an asset), printing
|
||||
# a warning. This is a live integration test; skip if no network.
|
||||
skip_if_no_network
|
||||
run timeout 60 "$SCRIPTS_DIR/install.sh" --check --version v0.9.0
|
||||
assert_status 0 "$status"
|
||||
assert_contains "$output" "WARNING"
|
||||
assert_contains "$output" "falling back"
|
||||
assert_contains "$output" "dry-run (--check)"
|
||||
}
|
||||
|
||||
@test "install.sh rejects unknown arguments" {
|
||||
run "$SCRIPTS_DIR/install.sh" --bogus-flag
|
||||
[ "$status" -ne 0 ]
|
||||
assert_contains "$output" "unknown argument"
|
||||
}
|
||||
|
||||
@test "install.sh --system requires root" {
|
||||
# Only test the root check if we're NOT root (CI may run as root).
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
skip "running as root; --system root check not testable"
|
||||
fi
|
||||
run "$SCRIPTS_DIR/install.sh" --system --version v0.4.5 --check
|
||||
[ "$status" -ne 0 ]
|
||||
assert_contains "$output" "--system requires root"
|
||||
}
|
||||
|
||||
# Helper: skip if the Gitea instance is unreachable.
|
||||
skip_if_no_network() {
|
||||
curl -fsSL --max-time 5 "https://git.cloudinit.dev/api/v1/repos/coreci/orca/releases/tags/v0.4.5" >/dev/null 2>&1 \
|
||||
|| skip "Gitea API unreachable — network-dependent test skipped"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bats
|
||||
# Tests for scripts/release.sh (REQ-097: cross-build amd64, asset verification).
|
||||
# Hermetic: tests the tarball naming and cross-build logic without
|
||||
# publishing a release. The full release flow requires GITEA_TOKEN + tea
|
||||
# and is tested in CI.
|
||||
|
||||
load test_helper
|
||||
|
||||
@test "release.sh exists and is executable" {
|
||||
[ -f "$SCRIPTS_DIR/release.sh" ]
|
||||
[ -x "$SCRIPTS_DIR/release.sh" ]
|
||||
}
|
||||
|
||||
@test "release.sh --help or usage shows required tools" {
|
||||
# release.sh doesn't have a --help flag; the header comment is the
|
||||
# usage. Verify the script is syntactically valid.
|
||||
run bash -n "$SCRIPTS_DIR/release.sh"
|
||||
assert_status 0 "$status"
|
||||
}
|
||||
|
||||
@test "release.sh cross-builds linux-amd64 regardless of host arch" {
|
||||
# Verify the script contains the cross-build command (D-193, REQ-097).
|
||||
# We check the source rather than running it (which requires go + tea).
|
||||
run grep -c "GOOS=linux GOARCH=amd64" "$SCRIPTS_DIR/release.sh"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" -ge 1 ]
|
||||
}
|
||||
|
||||
@test "release.sh hardcodes linux-amd64 tarball name" {
|
||||
# The tarball name must be linux-amd64 (not host-arch-dependent).
|
||||
run grep -c "orca-\${VERSION}-linux-amd64.tar.gz" "$SCRIPTS_DIR/release.sh"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" -ge 1 ]
|
||||
}
|
||||
|
||||
@test "release.sh has post-create asset verification (C-21)" {
|
||||
# Verify the script contains the asset verification logic.
|
||||
run grep -c "verifying asset" "$SCRIPTS_DIR/release.sh"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" -ge 1 ]
|
||||
run grep -c "verify_asset" "$SCRIPTS_DIR/release.sh"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" -ge 1 ]
|
||||
}
|
||||
Reference in New Issue
Block a user