From 15d504270f9af525124405432d2ad1b3f2d64927 Mon Sep 17 00:00:00 2001 From: Shaun Smith <51304449+smith-xyz@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:31:38 -0400 Subject: [PATCH] refactor(go)!: one lease lifecycle for both runs; pack and refusals from core; Prow OWNERS for the framework --- AGENTS.md | 38 ++- OWNERS | 5 +- OWNERS_ALIASES | 4 + go/CHANGELOG.md | 3 + go/Makefile | 55 ++-- go/OWNERS | 4 + go/README.md | 23 +- go/internal/generate/openapi/normalize.py | 23 +- go/v1/api/OWNERS | 8 + go/v1/api/client.gen.go | 347 +++++++++++++++++++++- go/v1/api/client.go | 101 +++---- go/v1/api/client_test.go | 38 +++ go/v1/api/e2e_test.go | 4 +- go/v1/enums/OWNERS | 5 + go/v1/ledger/OWNERS | 5 + go/v1/ops/lease.go | 190 ++++++++++++ go/v1/ops/ops.go | 60 ++++ go/v1/ops/pack.go | 109 ++++--- go/v1/ops/pack.schema.json | 195 ++++++++++-- go/v1/ops/pack_test.go | 52 ++++ go/v1/ops/record.go | 86 +++--- go/v1/ops/recording.go | 327 ++++---------------- go/v1/ops/recording_test.go | 13 + go/v1/ops/runner.go | 245 ++++++--------- go/v1/ops/runner_e2e_test.go | 132 +++----- go/v1/ops/runner_test.go | 47 ++- go/v1/ops/testdata/seed_order.py | 30 -- go/v1/skills/OWNERS | 5 + go/v1/storage/OWNERS | 5 + go/v1/types/OWNERS | 5 + go/v1/validate/OWNERS | 5 + 31 files changed, 1389 insertions(+), 780 deletions(-) create mode 100644 go/OWNERS create mode 100644 go/v1/api/OWNERS create mode 100644 go/v1/enums/OWNERS create mode 100644 go/v1/ledger/OWNERS create mode 100644 go/v1/ops/lease.go create mode 100644 go/v1/ops/ops.go create mode 100644 go/v1/ops/pack_test.go delete mode 100644 go/v1/ops/testdata/seed_order.py create mode 100644 go/v1/skills/OWNERS create mode 100644 go/v1/storage/OWNERS create mode 100644 go/v1/types/OWNERS create mode 100644 go/v1/validate/OWNERS diff --git a/AGENTS.md b/AGENTS.md index 9539669..c34dea9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,11 @@ One client for everything you ask traust; one runner for the work you do for it. ```go c, err := api.NewBoundClient(api.ClientConfig{BaseURL: coreURL, Tokens: api.FileTokenSource{Path: tokenPath}}) -r, err := ops.NewRunner(c, myExecutor) // your Executor runs the skill; the runner does the rest +pack, err := ops.LoadPack(packYAML) +r, err := ops.NewRunner(ops.RunnerConfig{ + Client: c, Pack: pack, Executor: myExecutor, Assets: []string{"ledger_audit"}, + LeaseSeconds: 30, PollInterval: time.Second, +}) err = r.Run(ctx) ``` @@ -16,7 +20,9 @@ err = r.Run(ctx) |---|---|---| | onboard a product and its repositories | `c` | `PlanOnboarding` (dry run) → `Onboard` | | browse inventory | `c` | `ListProducts`, `ListRepositories`, `ListRefs`, `ListOwners`, `ListProductsOf` | -| run skills | `ops.Runner` + your `Executor` | `Claim`/`Start` → `Prepare*` → your run → `Record*` → `Complete` (the runner calls these, you don't) | +| ask for a run (a person's token) | `c` | `Submit` with the pack version it must run under | +| run queued pack commands | `ops.Runner` + your `Executor` | `Claim` → `GetOrderManifest` → your run → `Complete` (the runner calls these, you don't) | +| run and record a dispatched order | `ops.RecordingRun` + your `Executor` | `Start` → `Prepare*` → your run → `Record*` → `Complete` (same) | | read what a run produced | `c` | `ListRepositoryAssessments`, `GetAssessment`, `GetArtifact`, `GetArtifactContent` | | work findings (a person's token) | `c` | `ReviewQueue`, `GetFinding`, `Decide`, `ListDecisions`, `Restate` | | check the ledger | `c` | `ListLayers`, `ListEvents`, `VerifyLayer`, `Checkpoint`; core writes ledger events when records and decisions land, you never append them | @@ -27,17 +33,23 @@ Method names are core's operation ids. A long or unclear name is fixed in core's ## How it works +One lifecycle, two ways in. Both hold the order's lease the same way (`v1/ops/lease.go`); nothing else in the SDK heartbeats, records or completes. + ```mermaid sequenceDiagram - participant R as SDK runner (v1/ops) + participant R as SDK (v1/ops) participant API as traust-core REST API participant X as consumer Executor - R->>API: claim (or start a dispatched order) - R->>API: prepare → skill input - R->>X: run(input), heartbeating the lease - X-->>R: output document - R->>R: validate against schema - R->>API: record(document) + alt queued order: ops.Runner + R->>API: claim(assets) + R->>API: manifest: planned with my pinned pack? + else dispatched order: ops.RecordingRun + R->>API: start + R->>API: prepare → skill input + end + R->>X: run, heartbeating the lease + X-->>R: output + JobResult + R->>API: record(output), when the order records a document R->>API: complete(JobResult) ``` @@ -45,18 +57,20 @@ Each step is one core endpoint. A step core doesn't serve is not a step: never s | Step | Core endpoint | |---|---| +| submit (a person) | `POST /v1/orders` with the config (pack version) the order must run under | | claim / start | `POST /v1/orders/claim` (filtered by `assets`; 204 = nothing queued) · `POST /v1/orders/start` (an explicitly dispatched order) | +| pinned pack | `GET /v1/orders/{order_id}/manifest`: a runner runs only orders planned with exactly its pack's digest | | heartbeat | `POST /v1/orders/{order_id}/heartbeat`; a 409 means the lease is lost: stop, record nothing | | prepare | `GET /v1/assessments/{assessment_id}/triage-input`, and each domain's input route as core adds it | | record | `POST /v1/assessments/{assessment_id}/triage-records` · `/v1/validation-records` · `/v1/verification-records` · `/v1/assessment-records` | -| complete | `POST /v1/orders/{order_id}/complete` with `JobResult` (outcome, degraded, usage); repeat-safe | +| complete | `POST /v1/orders/{order_id}/complete` with `JobResult` (outcome, degraded, usage, and a `RunOrderReason` from core's closed vocabulary); repeat-safe | | feed | `GET /v1/feed`, cursor-paged | | config resolve, preflight | not served by core: no such step | | Package | Owns | Consumer writes | |---|---|---| | `v1/api` | the client, types and enums generated from core's OpenAPI; auth, read retries, typed problem errors, one page iterator | a `TokenSource` | -| `v1/ops` | the runner: claim, heartbeat, lease loss, prepare, validate, record, complete | an `Executor`; skill registrations | +| `v1/ops` | the lease (heartbeat, lease loss, complete) and its two ways in: `Runner` (claim, pinned pack, the asset's command) and `RecordingRun` (start, prepare, record) | an `Executor`; the pack file | | `v1/feed` | reading `GET /v1/feed` once per event, resuming from a cursor | a cursor store | | `v1//test` | test doubles for each extension point; the conformance suite every `Executor` passes against `traust serve` | — | @@ -67,7 +81,7 @@ Packages not in this table (`storage`, `ledger`, `skills`, `types`, `enums`, `va - **Every change is one of three things:** an extension point the SDK calls, a lifecycle step in `ops`, or a regenerated `v1/api`. Anything else is glue; don't add it. A missing endpoint is a traust-core change, not a workaround here - **Core decides; the SDK enforces.** Authorization, ordering, budget, classification, identity, IDs and timestamps come from core; the SDK never derives, caches or defaults them. Approvals and decisions come from verified people through core; the runner's identity only claims, heartbeats, records and completes. The SDK's one local check is schema validation, which core re-checks - **Core's REST API is the only way in.** No database handle, DDL, contracts tables, URL building or path splicing: call the generated method -- **Generated, never committed by hand.** `make api-generate` builds core's OpenAPI at the pinned core commit, generates `client.gen.go`, and deletes the spec. Commit the generated Go, the codegen config and the pin, nothing else. Never edit `*.gen.go`; never hand-write a type or enum core already publishes; never wrap generated methods one-for-one +- **Generated, never committed by hand.** Everything the SDK knows about core comes from core at `CORE_REF`: `make api-generate` builds its OpenAPI, generates `client.gen.go` and deletes the spec, and writes `v1/ops/pack.schema.json` from the contracts core validates packs with. Commit those two generated files and the pin, nothing else; there is no second contracts pin. Never edit `*.gen.go`; never hand-write a type or enum core already publishes; never wrap generated methods one-for-one - **Fail closed.** No answer means no run: 5xx, timeout, network failure, malformed body, an unrecognised problem all stop with a typed error. Errors map on HTTP status and, where core sends one, the problem `code`; never on `detail` text. Retry only idempotent reads - **No fallbacks.** A missing or invalid dependency is a constructor error, never a default. Unknown is `nil`, never a zero value - **One config.** The consumer passes core URL, credential source and stage identity; nothing else. Library code never reads env vars or files. No settings that choose between behaviours diff --git a/OWNERS b/OWNERS index 0256800..8571348 100644 --- a/OWNERS +++ b/OWNERS @@ -1,5 +1,8 @@ # Prow (openshift-ci) approval config; see OWNERS_ALIASES for membership. +# The root governs the framework's shape (AGENTS.md, ci/, .github/, go/Makefile, go/go.mod, +# go/internal/generate, go/v1/ops, go/v1/api bindings, new packages). Directories with their own +# OWNERS hand routine changes to the whole team. approvers: - - traust-admins + - sdk-framework reviewers: - traust-admins diff --git a/OWNERS_ALIASES b/OWNERS_ALIASES index 9214d87..8abb8fd 100644 --- a/OWNERS_ALIASES +++ b/OWNERS_ALIASES @@ -2,6 +2,10 @@ # (https://github.com/orgs/traust-security/teams/traust-admins), which also gates # merges via branch protection (CODEOWNERS + required code-owner review). aliases: + # Approves the framework's shape; matches CODEOWNERS. + sdk-framework: + - chubirka + - smith-xyz traust-admins: - chubirka - girish1430 diff --git a/go/CHANGELOG.md b/go/CHANGELOG.md index 445a710..7ab72d8 100644 --- a/go/CHANGELOG.md +++ b/go/CHANGELOG.md @@ -19,6 +19,9 @@ All notable changes to the Go SDK are documented here. ### Changed - `make api-generate` builds core's OpenAPI at `CORE_REF` and deletes it; the spec is no longer committed. +- `JobResult.Reason` is `*api.RunOrderReason` (core's closed vocabulary, `api.RunOrderReason*` constants), not free text. +- `api.Decode` returns a generated response's body or its `*ProblemError`; `ops.LoadPack` validates against the pack schema from core at `CORE_REF`. +- `ops.RecordingRun` completes failed, without recording, when the executor returns an unknown outcome. ## [0.26.0] diff --git a/go/Makefile b/go/Makefile index afbdac4..406aefe 100644 --- a/go/Makefile +++ b/go/Makefile @@ -1,25 +1,23 @@ CONTRACTS_REPO := https://github.com/traust-security/traust-contracts.git CONTRACTS_REF := ac517047c6566b019e63b911f6b7522000826b48 -PACK_SCHEMA_REF := 4b7532bf044571a3a53077b03cf2955066a83374 CONTRACTS_CACHE := $(or $(XDG_CACHE_HOME),$(HOME)/.cache)/traust-sdk/contracts CONTRACTS_SOURCE := $(CONTRACTS_CACHE)/checkout-$(CONTRACTS_REF) -PACK_SCHEMA_SOURCE := $(CONTRACTS_CACHE)/checkout-$(PACK_SCHEMA_REF) ENUMS_DIR := $(CONTRACTS_SOURCE)/enums/v1 SCHEMAS_DIR := $(CONTRACTS_SOURCE)/schemas/v1 STORAGE_DIR := $(CONTRACTS_SOURCE)/storage/v1 FIXTURES_DIR := $(CONTRACTS_SOURCE)/tests/fixtures/storage STORAGE_SAMPLES := $(CONTRACTS_SOURCE)/tests/storage_samples.py CORE_REPO := https://github.com/traust-security/traust-core.git -CORE_REF := 977e8bf22117c2d58ba3c7bdfd38518274bb4f5b +CORE_REF := d06d3afd2b84cb8f4f7aa215172a4c802c108bc7 CORE_SOURCE := $(or $(XDG_CACHE_HOME),$(HOME)/.cache)/traust-sdk/core/checkout-$(CORE_REF) -CORE_PYTHON := uv run --no-dev --quiet --extra rest python +CORE_PYTHON := cd "$(CORE_SOURCE)" && uv run --quiet python OPENAPI_DIR := ./internal/generate/openapi OUT_DIR := ./v1/enums ROOT := .. GO_CI := python3 $(ROOT)/ci/lang/go/ci.py export PATH := $(shell go env GOPATH)/bin:$(PATH) -.PHONY: contracts pack-schema core generate api-generate e2e e2e-runner test test-integration check-drift fmt fmt-check vet lint security tools +.PHONY: contracts core generate api-generate e2e test test-integration check-drift fmt fmt-check vet lint security tools contracts: @set -eu; \ @@ -38,28 +36,14 @@ contracts: generate: contracts go run ./internal/generate -enums "$(ENUMS_DIR)" -out "$(OUT_DIR)" -schemas "$(SCHEMAS_DIR)" -types ./v1/types -validate ./v1/validate/schemas_gen.go -storage "$(STORAGE_DIR)" -storage-out ./v1/storage -storage-fixtures "$(FIXTURES_DIR)" -storage-samples "$(STORAGE_SAMPLES)" -contracts-ref "$(CONTRACTS_REF)" -pack-schema: - @set -eu; \ - if [ ! -d "$(PACK_SCHEMA_SOURCE)/.git" ]; then \ - rm -rf "$(PACK_SCHEMA_SOURCE)"; \ - mkdir -p "$(PACK_SCHEMA_SOURCE)"; \ - git -C "$(PACK_SCHEMA_SOURCE)" init --quiet; \ - git -C "$(PACK_SCHEMA_SOURCE)" remote add origin "$(CONTRACTS_REPO)"; \ - fi; \ - if ! git -C "$(PACK_SCHEMA_SOURCE)" cat-file -e "$(PACK_SCHEMA_REF)^{commit}" 2>/dev/null; then \ - git -C "$(PACK_SCHEMA_SOURCE)" fetch --quiet --no-tags --depth=1 origin "$(PACK_SCHEMA_REF)"; \ - fi; \ - git -C "$(PACK_SCHEMA_SOURCE)" checkout --quiet --detach --force "$(PACK_SCHEMA_REF)"; \ - cp "$(PACK_SCHEMA_SOURCE)/config/v1/pack.schema.json" ./v1/ops/pack.schema.json - test: go test ./... test-integration: go test -count=1 -v ./v1/storage/... -check-drift: generate api-generate pack-schema - @git diff --exit-code $(OUT_DIR) ./v1/types/ ./v1/validate/schemas_gen.go ./v1/storage/operations.go ./v1/storage/projection_identifiers.go ./v1/storage/schema.go ./v1/storage/queries.go ./v1/storage/storagetest/testdata/ ./v1/api/client.gen.go ./v1/ops/pack.schema.json || (echo "ERROR: generated files are out of date; run 'make generate api-generate pack-schema' and commit" && exit 1) +check-drift: generate api-generate + @git diff --exit-code $(OUT_DIR) ./v1/types/ ./v1/validate/schemas_gen.go ./v1/storage/operations.go ./v1/storage/projection_identifiers.go ./v1/storage/schema.go ./v1/storage/queries.go ./v1/storage/storagetest/testdata/ ./v1/api/client.gen.go ./v1/ops/pack.schema.json || (echo "ERROR: generated files are out of date; run 'make generate api-generate' and commit" && exit 1) core: @set -eu; \ @@ -75,15 +59,20 @@ core: git -C "$(CORE_SOURCE)" checkout --quiet --detach --force "$(CORE_REF)"; \ git -C "$(CORE_SOURCE)" clean -ffdq -# The spec is built from core at CORE_REF, used once and deleted; only client.gen.go is committed. +# Everything the SDK knows about core comes from core at CORE_REF: the client from its OpenAPI +# (built, used once, deleted) and the pack schema from the contracts core validates packs with. api-generate: core @set -eu; \ tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \ $(MAKE) -s -C "$(CORE_SOURCE)" api-check; \ python3 $(OPENAPI_DIR)/normalize.py "$(CORE_SOURCE)/.openapi/openapi.json" "$$tmp/openapi.json"; \ - go tool oapi-codegen --config $(OPENAPI_DIR)/oapi-codegen.yaml "$$tmp/openapi.json" + go tool oapi-codegen --config $(OPENAPI_DIR)/oapi-codegen.yaml "$$tmp/openapi.json"; \ + ($(CORE_PYTHON) -c "import json; from traust_core.v1.contracts import config_schema; print(json.dumps(config_schema('pack'), indent=2))") > ./v1/ops/pack.schema.json + +# A local-issuer token for the e2e server: $(1) identity, $(2) machine (True/False), $(3) file. +mint = ($(CORE_PYTHON) -c "from pathlib import Path; from traust_core.v1.context import Config, Context; print(Context(Config.from_file(Path('$$home/config.yaml'))).local_issuer.mint('$(1)', machine=$(2)).token.get_secret_value())") > "$$home/$(3)" -# The generated client against traust-server built from core at CORE_REF (SQLite, local issuer). +# The generated client and the runner against traust-server built from core at CORE_REF. e2e: core @set -eu; \ home="$$(mktemp -d)"; port=18080; \ @@ -92,20 +81,10 @@ e2e: core trap 'kill $$pid 2>/dev/null || true; rm -rf "$$home"' EXIT; \ for _ in $$(seq 60); do curl -sf -o /dev/null http://127.0.0.1:$$port/v1/healthz && break; sleep 1; done; \ curl -sf -o /dev/null http://127.0.0.1:$$port/v1/healthz || { cat "$$home/server.log"; exit 1; }; \ - (cd "$(CORE_SOURCE)" && uv run --quiet python -c "from pathlib import Path; from traust_core.v1.context import Config, Context; print(Context(Config.from_file(Path('$$home/config.yaml'))).local_issuer.mint('e2e@example.com').token.get_secret_value())") > "$$home/token"; \ - TRAUST_E2E_URL=http://127.0.0.1:$$port TRAUST_E2E_TOKEN_FILE="$$home/token" go test -count=1 -tags e2e -run E2E -v ./v1/api/ - -e2e-runner: core - @set -eu; \ - home="$$(mktemp -d)"; port=18081; \ - pack="$(CURDIR)/v1/ops/testdata/local-command.yaml"; \ - printf 'home: %s\nidentity:\n trust_local: true\n' "$$home" > "$$home/config.yaml"; \ - (cd "$(CORE_SOURCE)" && exec $(CORE_PYTHON) -c 'from traust_core.v1.rest.server import main; main()' --config "$$home/config.yaml" --port $$port) > "$$home/server.log" 2>&1 & pid=$$!; \ - trap 'kill $$pid 2>/dev/null || true; rm -rf "$$home"' EXIT; \ - for _ in $$(seq 60); do curl -sf -o /dev/null http://127.0.0.1:$$port/v1/healthz && break; sleep 1; done; \ - curl -sf -o /dev/null http://127.0.0.1:$$port/v1/healthz || { cat "$$home/server.log"; exit 1; }; \ - (cd "$(CORE_SOURCE)" && $(CORE_PYTHON) "$(CURDIR)/v1/ops/testdata/seed_order.py" "$$home/config.yaml" "$$pack" "$$home/token"); \ - TRAUST_E2E_URL=http://127.0.0.1:$$port TRAUST_E2E_TOKEN_FILE="$$home/token" go test -count=1 -tags e2e -run TestE2ERunnerLocalCommand -v ./v1/ops/ + $(call mint,e2e@example.com,False,person); \ + $(call mint,e2e-runner,True,runner); \ + TRAUST_E2E_URL=http://127.0.0.1:$$port TRAUST_E2E_TOKEN_FILE="$$home/person" TRAUST_E2E_RUNNER_TOKEN_FILE="$$home/runner" \ + go test -count=1 -tags e2e -run E2E -v ./v1/api/ ./v1/ops/ fmt: gofmt -w . diff --git a/go/OWNERS b/go/OWNERS new file mode 100644 index 0000000..7999af4 --- /dev/null +++ b/go/OWNERS @@ -0,0 +1,4 @@ +filters: + "^(README\\.md|CHANGELOG\\.md|VERSION)$": + approvers: + - traust-admins diff --git a/go/README.md b/go/README.md index c3bc20f..832a619 100644 --- a/go/README.md +++ b/go/README.md @@ -56,17 +56,18 @@ or cross-field policy are introduced. ## Queued fleet commands -`ops.LoadPack` validates the installed pack against the pinned Contracts -schema and retains its byte digest. Configure `ops.NewRunner` with a -`BoundClient`, the pack, an `Executor`, eligible asset names, lease duration -and poll interval. `RunOnce` claims one order; `Run` keeps claiming until -cancelled. The runner checks Core's run-manifest pack digest, resolves -`{run_id}`, maintains the lease, and completes from the executor's native -`api.JobResult`. The executor runs `Execution.Argv`, returns its outcome, and -stops all work when its context is cancelled. This path currently accepts only fleet-wide -commands without model or repository inputs, such as `ledger_audit`. -The queued fleet-command runner sends only `ProducedReport.Result` to Core; -`RecordingRun` uses `ProducedReport.Payload` for native record doors. +`ops.LoadPack` validates a pack against the schema core loads packs with and pins it by +the digest of its bytes. `ops.NewRunner` takes a `BoundClient`, the pack, an `Executor`, +the assets this host runs, a lease and a poll interval. `RunOnce` claims one order and +`Run` keeps claiming until cancelled. The runner runs an order only when core planned it +with exactly this pack (`GetOrderManifest`), resolves `{run_id}` in the asset's command, +holds the lease while the executor runs `Execution.Argv`, and completes with the +executor's `api.JobResult`. Refusals carry a `api.RunOrderReason`. Fleet-wide commands +only: an asset with partitions, a model or params is refused at `NewRunner`. +The runner sends only `ProducedReport.Result` to core; `RecordingRun` sends +`ProducedReport.Payload` to its record door. + +A person asks for a run with `Submit`, naming the pack version it must run under. ## Native report recording diff --git a/go/internal/generate/openapi/normalize.py b/go/internal/generate/openapi/normalize.py index 67c69ad..d74539f 100644 --- a/go/internal/generate/openapi/normalize.py +++ b/go/internal/generate/openapi/normalize.py @@ -5,8 +5,7 @@ appear in core's document: exclusiveMinimum/exclusiveMaximum as numbers instead of the 3.0 boolean flag, and pydantic's Optional as anyOf: [schema, {type: "null"}] instead of 3.0's nullable: true. This -rewrites those forms and namespaces SourceType to preserve existing Go -SourceChannel constants. Wire values and validation constraints are unchanged. +rewrites those forms and namespaces the enums in NAMESPACED. Wire values and validation constraints are unchanged. Usage: normalize.py """ @@ -52,6 +51,15 @@ def normalize(node: object) -> None: normalize(item) +# Enums whose constants carry their type name, so a value shared with another enum never renames +# existing constants (SourceType beside SourceChannel; RunOrderReason's not_found, bad_request). +NAMESPACED = ("SourceType", "RunOrderReason") + + +def _camel(value: str) -> str: + return "".join(part.title() for part in value.replace("-", "_").split("_")) + + def main(argv: list[str]) -> int: if len(argv) != 3: print(__doc__, file=sys.stderr) @@ -59,13 +67,10 @@ def main(argv: list[str]) -> int: with open(argv[1], encoding="utf-8") as handle: spec = json.load(handle) normalize(spec) - # Avoid renaming existing SourceChannel constants when SourceType is added. - source_type = spec["components"]["schemas"].get("SourceType") - if source_type is not None: - source_type["x-enum-varnames"] = [ - "SourceType" + "".join(part.title() for part in value.split("_")) - for value in source_type["enum"] - ] + for name in NAMESPACED: + schema = spec["components"]["schemas"].get(name) + if schema is not None: + schema["x-enum-varnames"] = [name + _camel(value) for value in schema["enum"]] spec["openapi"] = "3.0.3" with open(argv[2], "w", encoding="utf-8") as handle: json.dump(spec, handle, indent=2) diff --git a/go/v1/api/OWNERS b/go/v1/api/OWNERS new file mode 100644 index 0000000..6c993dd --- /dev/null +++ b/go/v1/api/OWNERS @@ -0,0 +1,8 @@ +# The regenerated client and its tests are routine; the bindings in client.go are the root's. +filters: + "\\.gen\\.go$": + approvers: + - traust-admins + "_test\\.go$": + approvers: + - traust-admins diff --git a/go/v1/api/client.gen.go b/go/v1/api/client.gen.go index 612cabe..00d94ef 100644 --- a/go/v1/api/client.gen.go +++ b/go/v1/api/client.gen.go @@ -236,6 +236,21 @@ const ( Restate Role = "restate" ) +// Defines values for RunOrderReason. +const ( + RunOrderReasonAssetNotInPack RunOrderReason = "asset-not-in-pack" + RunOrderReasonBadRequest RunOrderReason = "bad_request" + RunOrderReasonBudgetExhausted RunOrderReason = "budget-exhausted" + RunOrderReasonBudgetOver RunOrderReason = "budget-over" + RunOrderReasonDiffTooLarge RunOrderReason = "diff-too-large" + RunOrderReasonLedgerUnconfigured RunOrderReason = "ledger_unconfigured" + RunOrderReasonNotFound RunOrderReason = "not_found" + RunOrderReasonRunnerCrashed RunOrderReason = "runner-crashed" + RunOrderReasonSnapshotUnavailable RunOrderReason = "snapshot_unavailable" + RunOrderReasonSourceUnavailable RunOrderReason = "source_unavailable" + RunOrderReasonUpstreamStale RunOrderReason = "upstream-stale" +) + // Defines values for RunOrderState. const ( RunOrderStateAwaitingApproval RunOrderState = "awaiting_approval" @@ -461,6 +476,13 @@ type CompleteRequest struct { Result JobResult `json:"result"` } +// ConfigDigest A config document in force when an order was planned; `pack:` is the pack version. +type ConfigDigest struct { + ArtifactId *openapi_types.UUID `json:"artifact_id"` + Digest string `json:"digest"` + Name string `json:"name"` +} + // Decision A judgement on a finding; corrections are new decisions (restates_id). type Decision struct { Actor *string `json:"actor"` @@ -706,7 +728,7 @@ type IntakeResult struct { type JobResult struct { Degraded *map[string]int `json:"degraded,omitempty"` Outcome Outcome `json:"outcome"` - Reason *string `json:"reason"` + Reason *RunOrderReason `json:"reason,omitempty"` Usage *[]ModelUsage `json:"usage,omitempty"` Written *[]string `json:"written,omitempty"` } @@ -796,6 +818,19 @@ type OnboardingResult struct { RepositoryIds *[]openapi_types.UUID `json:"repository_ids,omitempty"` } +// OrderApproval Who let a human-gated order run, and when (the run manifest's `approval`). +type OrderApproval struct { + ApprovedAt time.Time `json:"approved_at"` + Approver string `json:"approver"` +} + +// OrderManifest What an order's run manifest says about the config it was planned with and its approval. +type OrderManifest struct { + // Approval Who let a human-gated order run, and when (the run manifest's `approval`). + Approval *OrderApproval `json:"approval,omitempty"` + Config *[]ConfigDigest `json:"config,omitempty"` +} + // OrderRequest What admission puts on the queue: one asset for one partition, routed or not. type OrderRequest struct { Asset string `json:"asset"` @@ -1078,6 +1113,9 @@ type RunOrder struct { UpdatedAt *time.Time `json:"updated_at"` } +// RunOrderReason defines model for RunOrderReason. +type RunOrderReason string + // RunOrderState defines model for RunOrderState. type RunOrderState string @@ -1113,6 +1151,14 @@ type SubjectRef struct { Sha string `json:"sha"` } +// SubmitRequest defines model for SubmitRequest. +type SubmitRequest struct { + Config *[]ConfigDigest `json:"config,omitempty"` + + // Order What admission puts on the queue: one asset for one partition, routed or not. + Order OrderRequest `json:"order"` +} + // TargetAttestation defines model for TargetAttestation. type TargetAttestation struct { Attested bool `json:"attested"` @@ -1356,6 +1402,9 @@ type OnboardJSONRequestBody = OnboardingRequest // PlanOnboardingJSONRequestBody defines body for PlanOnboarding for application/json ContentType. type PlanOnboardingJSONRequestBody = OnboardingRequest +// SubmitJSONRequestBody defines body for Submit for application/json ContentType. +type SubmitJSONRequestBody = SubmitRequest + // ClaimJSONRequestBody defines body for Claim for application/json ContentType. type ClaimJSONRequestBody = ClaimRequest @@ -2282,6 +2331,11 @@ type ClientInterface interface { // ListOrders request ListOrders(ctx context.Context, params *ListOrdersParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // SubmitWithBody request with any body + SubmitWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + Submit(ctx context.Context, body SubmitJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ClaimWithBody request with any body ClaimWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2305,6 +2359,9 @@ type ClientInterface interface { Heartbeat(ctx context.Context, orderId openapi_types.UUID, body HeartbeatJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetOrderManifest request + GetOrderManifest(ctx context.Context, orderId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + // FindPackage request FindPackage(ctx context.Context, params *FindPackageParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2739,6 +2796,30 @@ func (c *Client) ListOrders(ctx context.Context, params *ListOrdersParams, reqEd return c.Client.Do(req) } +func (c *Client) SubmitWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSubmitRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) Submit(ctx context.Context, body SubmitJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSubmitRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ClaimWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewClaimRequestWithBody(c.Server, contentType, body) if err != nil { @@ -2847,6 +2928,18 @@ func (c *Client) Heartbeat(ctx context.Context, orderId openapi_types.UUID, body return c.Client.Do(req) } +func (c *Client) GetOrderManifest(ctx context.Context, orderId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetOrderManifestRequest(c.Server, orderId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) FindPackage(ctx context.Context, params *FindPackageParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewFindPackageRequest(c.Server, params) if err != nil { @@ -4349,6 +4442,46 @@ func NewListOrdersRequest(server string, params *ListOrdersParams) (*http.Reques return req, nil } +// NewSubmitRequest calls the generic Submit builder with application/json body +func NewSubmitRequest(server string, body SubmitJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSubmitRequestWithBody(server, "application/json", bodyReader) +} + +// NewSubmitRequestWithBody generates requests for Submit with any type of body +func NewSubmitRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/orders") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewClaimRequest calls the generic Claim builder with application/json body func NewClaimRequest(server string, body ClaimJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -4557,6 +4690,40 @@ func NewHeartbeatRequestWithBody(server string, orderId openapi_types.UUID, cont return req, nil } +// NewGetOrderManifestRequest generates requests for GetOrderManifest +func NewGetOrderManifestRequest(server string, orderId openapi_types.UUID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "order_id", runtime.ParamLocationPath, orderId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/orders/%s/manifest", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewFindPackageRequest generates requests for FindPackage func NewFindPackageRequest(server string, params *FindPackageParams) (*http.Request, error) { var err error @@ -5467,6 +5634,11 @@ type ClientWithResponsesInterface interface { // ListOrdersWithResponse request ListOrdersWithResponse(ctx context.Context, params *ListOrdersParams, reqEditors ...RequestEditorFn) (*ListOrdersResponse, error) + // SubmitWithBodyWithResponse request with any body + SubmitWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SubmitResponse, error) + + SubmitWithResponse(ctx context.Context, body SubmitJSONRequestBody, reqEditors ...RequestEditorFn) (*SubmitResponse, error) + // ClaimWithBodyWithResponse request with any body ClaimWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ClaimResponse, error) @@ -5490,6 +5662,9 @@ type ClientWithResponsesInterface interface { HeartbeatWithResponse(ctx context.Context, orderId openapi_types.UUID, body HeartbeatJSONRequestBody, reqEditors ...RequestEditorFn) (*HeartbeatResponse, error) + // GetOrderManifestWithResponse request + GetOrderManifestWithResponse(ctx context.Context, orderId openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetOrderManifestResponse, error) + // FindPackageWithResponse request FindPackageWithResponse(ctx context.Context, params *FindPackageParams, reqEditors ...RequestEditorFn) (*FindPackageResponse, error) @@ -6175,6 +6350,31 @@ func (r ListOrdersResponse) StatusCode() int { return 0 } +type SubmitResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *RunOrder + JSON401 *Problem + JSON403 *Problem + JSON422 *Problem +} + +// Status returns HTTPResponse.Status +func (r SubmitResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SubmitResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type ClaimResponse struct { Body []byte HTTPResponse *http.Response @@ -6306,6 +6506,31 @@ func (r HeartbeatResponse) StatusCode() int { return 0 } +type GetOrderManifestResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *OrderManifest + JSON401 *Problem + JSON404 *Problem + JSON422 *HTTPValidationError +} + +// Status returns HTTPResponse.Status +func (r GetOrderManifestResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetOrderManifestResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type FindPackageResponse struct { Body []byte HTTPResponse *http.Response @@ -7050,6 +7275,23 @@ func (c *ClientWithResponses) ListOrdersWithResponse(ctx context.Context, params return ParseListOrdersResponse(rsp) } +// SubmitWithBodyWithResponse request with arbitrary body returning *SubmitResponse +func (c *ClientWithResponses) SubmitWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SubmitResponse, error) { + rsp, err := c.SubmitWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSubmitResponse(rsp) +} + +func (c *ClientWithResponses) SubmitWithResponse(ctx context.Context, body SubmitJSONRequestBody, reqEditors ...RequestEditorFn) (*SubmitResponse, error) { + rsp, err := c.Submit(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSubmitResponse(rsp) +} + // ClaimWithBodyWithResponse request with arbitrary body returning *ClaimResponse func (c *ClientWithResponses) ClaimWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ClaimResponse, error) { rsp, err := c.ClaimWithBody(ctx, contentType, body, reqEditors...) @@ -7127,6 +7369,15 @@ func (c *ClientWithResponses) HeartbeatWithResponse(ctx context.Context, orderId return ParseHeartbeatResponse(rsp) } +// GetOrderManifestWithResponse request returning *GetOrderManifestResponse +func (c *ClientWithResponses) GetOrderManifestWithResponse(ctx context.Context, orderId openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetOrderManifestResponse, error) { + rsp, err := c.GetOrderManifest(ctx, orderId, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetOrderManifestResponse(rsp) +} + // FindPackageWithResponse request returning *FindPackageResponse func (c *ClientWithResponses) FindPackageWithResponse(ctx context.Context, params *FindPackageParams, reqEditors ...RequestEditorFn) (*FindPackageResponse, error) { rsp, err := c.FindPackage(ctx, params, reqEditors...) @@ -8475,6 +8726,53 @@ func ParseListOrdersResponse(rsp *http.Response) (*ListOrdersResponse, error) { return response, nil } +// ParseSubmitResponse parses an HTTP response from a SubmitWithResponse call +func ParseSubmitResponse(rsp *http.Response) (*SubmitResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SubmitResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case rsp.Header.Get("Content-Type") == "application/json" && rsp.StatusCode == 401: + var dest Problem + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case rsp.Header.Get("Content-Type") == "application/json" && rsp.StatusCode == 403: + var dest Problem + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case rsp.Header.Get("Content-Type") == "application/json" && rsp.StatusCode == 422: + var dest Problem + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest RunOrder + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + // ParseClaimResponse parses an HTTP response from a ClaimWithResponse call func ParseClaimResponse(rsp *http.Response) (*ClaimResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -8752,6 +9050,53 @@ func ParseHeartbeatResponse(rsp *http.Response) (*HeartbeatResponse, error) { return response, nil } +// ParseGetOrderManifestResponse parses an HTTP response from a GetOrderManifestWithResponse call +func ParseGetOrderManifestResponse(rsp *http.Response) (*GetOrderManifestResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetOrderManifestResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case rsp.Header.Get("Content-Type") == "application/json" && rsp.StatusCode == 401: + var dest Problem + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case rsp.Header.Get("Content-Type") == "application/json" && rsp.StatusCode == 404: + var dest Problem + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest OrderManifest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest HTTPValidationError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON422 = &dest + + } + + return response, nil +} + // ParseFindPackageResponse parses an HTTP response from a FindPackageWithResponse call func ParseFindPackageResponse(rsp *http.Response) (*FindPackageResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/go/v1/api/client.go b/go/v1/api/client.go index 9eab4b6..605e66a 100644 --- a/go/v1/api/client.go +++ b/go/v1/api/client.go @@ -137,8 +137,12 @@ func (e *ProblemError) Error() string { return fmt.Sprintf("traust-core: %d: %s", e.StatusCode, body) } -// problemFromResponse builds the error for a non-2xx generated response. -func problemFromResponse(statusCode int, body []byte, contentType string) *ProblemError { +// CheckResponse returns nil for a 2xx response, otherwise its *ProblemError: the RFC 7807 +// document when the server sent one, else the raw body. +func CheckResponse(statusCode int, body []byte, contentType string) error { + if statusCode >= 200 && statusCode < 300 { + return nil + } out := &ProblemError{StatusCode: statusCode, Body: string(body)} if strings.Contains(contentType, "application/problem+json") || bytes.HasPrefix(bytes.TrimSpace(body), []byte("{")) { var problem Problem @@ -149,23 +153,17 @@ func problemFromResponse(statusCode int, body []byte, contentType string) *Probl return out } -// checkResponse turns a failed generated response into a *ProblemError. -// The generated types expose StatusCode() as a method and Body as a field. -func checkResponse(statusCode int, body []byte, contentType string) error { - if statusCode >= 200 && statusCode < 300 { - return nil - } - return problemFromResponse(statusCode, body, contentType) -} - -// CheckResponse turns a failed generated response into a *ProblemError: nil -// for 2xx, otherwise the RFC 7807 document when the server sent one (or a -// ProblemError carrying the raw body when it did not). Consumers call it -// with the generated response's fields: +// Decode returns a generated response's decoded body, or its *ProblemError when core refused: // -// CheckResponse(resp.StatusCode(), resp.Body, resp.HTTPResponse.Header.Get("Content-Type")) -func CheckResponse(statusCode int, body []byte, contentType string) error { - return checkResponse(statusCode, body, contentType) +// order, err := api.Decode(resp.HTTPResponse, resp.Body, resp.JSON200) +func Decode[T any](response *http.Response, body []byte, value *T) (*T, error) { + if err := CheckResponse(response.StatusCode, body, response.Header.Get("Content-Type")); err != nil { + return nil, err + } + if value == nil || bytes.Equal(bytes.TrimSpace(body), []byte("null")) { + return nil, fmt.Errorf("traust-core: HTTP %d without a JSON body", response.StatusCode) + } + return value, nil } // retryTransport retries idempotent requests (GET/HEAD) on 429 and @@ -209,62 +207,53 @@ func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) { return resp, err } -// EachProduct walks the product listing page by page, following the cursor, -// calling fn for every product; a non-nil error stops the walk. One request -// page size is limit (1..1000; core clamps). +// EachProduct calls fn for every product, page by page; a non-nil error stops the walk. limit is +// the page size (1..1000). func (c *BoundClient) EachProduct(ctx context.Context, limit int, fn func(Product) error) error { - var cursor *string - for { - resp, err := c.ListProductsWithResponse(ctx, &ListProductsParams{ - Limit: &limit, - Cursor: cursor, - }) + return each(ctx, fn, func(ctx context.Context, cursor *string) ([]Product, *string, error) { + response, err := c.ListProductsWithResponse(ctx, &ListProductsParams{Limit: &limit, Cursor: cursor}) if err != nil { - return err - } - if err := checkResponse(resp.StatusCode(), resp.Body, resp.HTTPResponse.Header.Get("Content-Type")); err != nil { - return err + return nil, nil, err } - if resp.JSON200 == nil { - return problemFromResponse(resp.StatusCode(), resp.Body, resp.HTTPResponse.Header.Get("Content-Type")) + page, err := Decode(response.HTTPResponse, response.Body, response.JSON200) + if err != nil { + return nil, nil, err } - for _, item := range resp.JSON200.Items { - if err := fn(item); err != nil { - return err - } + return page.Items, page.Next, nil + }) +} + +// EachRepository calls fn for every repository, the same way. +func (c *BoundClient) EachRepository(ctx context.Context, limit int, fn func(Repository) error) error { + return each(ctx, fn, func(ctx context.Context, cursor *string) ([]Repository, *string, error) { + response, err := c.ListRepositoriesWithResponse(ctx, &ListRepositoriesParams{Limit: &limit, Cursor: cursor}) + if err != nil { + return nil, nil, err } - if resp.JSON200.Next == nil || *resp.JSON200.Next == "" { - return nil + page, err := Decode(response.HTTPResponse, response.Body, response.JSON200) + if err != nil { + return nil, nil, err } - cursor = resp.JSON200.Next - } + return page.Items, page.Next, nil + }) } -// EachRepository walks the repository listing the same way. -func (c *BoundClient) EachRepository(ctx context.Context, limit int, fn func(Repository) error) error { +// each follows a cursor-paged listing until its last page. +func each[T any](ctx context.Context, fn func(T) error, page func(context.Context, *string) ([]T, *string, error)) error { var cursor *string for { - resp, err := c.ListRepositoriesWithResponse(ctx, &ListRepositoriesParams{ - Limit: &limit, - Cursor: cursor, - }) + items, next, err := page(ctx, cursor) if err != nil { return err } - if err := checkResponse(resp.StatusCode(), resp.Body, resp.HTTPResponse.Header.Get("Content-Type")); err != nil { - return err - } - if resp.JSON200 == nil { - return problemFromResponse(resp.StatusCode(), resp.Body, resp.HTTPResponse.Header.Get("Content-Type")) - } - for _, item := range resp.JSON200.Items { + for _, item := range items { if err := fn(item); err != nil { return err } } - if resp.JSON200.Next == nil || *resp.JSON200.Next == "" { + if next == nil || *next == "" { return nil } - cursor = resp.JSON200.Next + cursor = next } } diff --git a/go/v1/api/client_test.go b/go/v1/api/client_test.go index defe6cd..6f94210 100644 --- a/go/v1/api/client_test.go +++ b/go/v1/api/client_test.go @@ -222,3 +222,41 @@ func problem(w http.ResponseWriter, status int, detail string) { } func strp(s string) *string { return &s } + +func TestDecode(t *testing.T) { + order := &RunOrder{Asset: "a"} + for _, tc := range []struct { + name string + status int + contentType string + body string + value *RunOrder + problem bool + }{ + {"decoded body", 200, "application/json", `{"asset":"a"}`, order, false}, + {"problem document", 409, "application/problem+json", `{"title":"Conflict","status":409}`, nil, true}, + {"refusal without a document", 502, "text/plain", "bad gateway", nil, true}, + {"2xx without a body", 200, "application/json", "", nil, false}, + {"2xx with a null body", 200, "application/json", "null", order, false}, + } { + t.Run(tc.name, func(t *testing.T) { + response := &http.Response{StatusCode: tc.status, Header: http.Header{"Content-Type": {tc.contentType}}} + got, err := Decode(response, []byte(tc.body), tc.value) + var problem *ProblemError + switch { + case tc.problem: + if !errors.As(err, &problem) || problem.StatusCode != tc.status || got != nil { + t.Fatalf("got=%v err=%v", got, err) + } + case tc.value == order && tc.body != "null": + if err != nil || got != order { + t.Fatalf("got=%v err=%v", got, err) + } + default: + if err == nil || errors.As(err, &problem) || got != nil { + t.Fatalf("a 2xx without a body must fail as such: got=%v err=%v", got, err) + } + } + }) + } +} diff --git a/go/v1/api/e2e_test.go b/go/v1/api/e2e_test.go index ac27dc1..91a3f7b 100644 --- a/go/v1/api/e2e_test.go +++ b/go/v1/api/e2e_test.go @@ -55,7 +55,7 @@ func TestE2EUnknownResourceIsATypedProblem(t *testing.T) { t.Fatal(err) } var problem *ProblemError - err = checkResponse(resp.StatusCode(), resp.Body, resp.HTTPResponse.Header.Get("Content-Type")) + _, err = Decode(resp.HTTPResponse, resp.Body, resp.JSON200) if !errors.As(err, &problem) || problem.StatusCode != http.StatusNotFound || problem.Problem == nil || problem.Problem.RequestId == "" { t.Fatalf("want a 404 problem with a request id, got %v", err) } @@ -75,7 +75,7 @@ func TestE2EUnverifiedTokenIsUnauthorized(t *testing.T) { t.Fatal(err) } var problem *ProblemError - err = checkResponse(resp.StatusCode(), resp.Body, resp.HTTPResponse.Header.Get("Content-Type")) + _, err = Decode(resp.HTTPResponse, resp.Body, resp.JSON200) if !errors.As(err, &problem) || problem.StatusCode != http.StatusUnauthorized || problem.Problem == nil { t.Fatalf("want a 401 problem, got %v", err) } diff --git a/go/v1/enums/OWNERS b/go/v1/enums/OWNERS new file mode 100644 index 0000000..e63ba2c --- /dev/null +++ b/go/v1/enums/OWNERS @@ -0,0 +1,5 @@ +# Frozen package: bug fixes and regenerated code only. +approvers: + - traust-admins +reviewers: + - traust-admins diff --git a/go/v1/ledger/OWNERS b/go/v1/ledger/OWNERS new file mode 100644 index 0000000..e63ba2c --- /dev/null +++ b/go/v1/ledger/OWNERS @@ -0,0 +1,5 @@ +# Frozen package: bug fixes and regenerated code only. +approvers: + - traust-admins +reviewers: + - traust-admins diff --git a/go/v1/ops/lease.go b/go/v1/ops/lease.go new file mode 100644 index 0000000..6d36b03 --- /dev/null +++ b/go/v1/ops/lease.go @@ -0,0 +1,190 @@ +package ops + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/traust-security/traust-sdk/go/v1/api" +) + +// lease is an order this process holds. Every call after claim or start goes through it, and +// origin keeps the server's id and start time, which no heartbeat may change. +type lease struct { + client *api.BoundClient + origin api.RunOrder + current api.RunOrder + seconds int + interval time.Duration + timeout time.Duration +} + +func hold(client *api.BoundClient, order *api.RunOrder, seconds int, interval, timeout time.Duration) (*lease, error) { + if err := live(order); err != nil { + return nil, &LeaseLostError{Err: err} + } + return &lease{client: client, origin: *order, current: *order, seconds: seconds, interval: interval, timeout: timeout}, nil +} + +func live(order *api.RunOrder) error { + if order == nil || order.State != api.RunOrderStateLeased || + order.Id == nil || *order.Id == uuid.Nil || + order.StartedAt == nil || order.StartedAt.IsZero() || + order.LeaseExpiresAt == nil || !order.LeaseExpiresAt.After(time.Now()) { + return errors.New("core returned no live order lease") + } + return nil +} + +func (l *lease) id() uuid.UUID { return *l.origin.Id } + +// order is the server's original order with its own copies of the pointers an executor could +// otherwise use to rewrite the lease. +func (l *lease) order() api.RunOrder { + order := l.origin + id, started, expires := *l.origin.Id, *l.origin.StartedAt, *l.origin.LeaseExpiresAt + order.Id, order.StartedAt, order.LeaseExpiresAt = &id, &started, &expires + return order +} + +// check fails with lease loss once the lease has expired or the caller has given up. +func (l *lease) check(ctx context.Context) error { + if err := live(&l.current); err != nil { + return &LeaseLostError{Err: err} + } + if err := ctx.Err(); err != nil { + return &LeaseLostError{Err: err} + } + return nil +} + +// call bounds one mutation by the request timeout and the lease; a dead lease makes no call. +func (l *lease) call(ctx context.Context) (context.Context, context.CancelFunc, error) { + if err := l.check(ctx); err != nil { + return nil, nil, err + } + deadline := time.Now().Add(l.timeout) + if l.current.LeaseExpiresAt.Before(deadline) { + deadline = *l.current.LeaseExpiresAt + } + ctx, cancel := context.WithDeadline(ctx, deadline) + return ctx, cancel, nil +} + +// renew heartbeats the lease. Any failure is lease loss; a heartbeat is never retried. +func (l *lease) renew(ctx context.Context) error { + ctx, cancel, err := l.call(ctx) + if err != nil { + return err + } + defer cancel() + response, err := l.client.HeartbeatWithResponse(ctx, l.id(), api.LeaseRequest{LeaseSeconds: l.seconds}) + if err != nil { + return &LeaseLostError{Err: err} + } + order, err := api.Decode(response.HTTPResponse, response.Body, response.JSON200) + if err == nil { + err = live(order) + } + if err == nil && (*order.Id != l.id() || !order.StartedAt.Equal(*l.origin.StartedAt)) { + err = errors.New("heartbeat changed the order's id or start time") + } + if err != nil { + return &LeaseLostError{Err: err} + } + l.current = *order + return nil +} + +// run calls the executor under supervise, with its own copy of the order. +func (l *lease) run(ctx context.Context, executor Executor, execution Execution) (ProducedReport, error) { + if err := l.check(ctx); err != nil { + return ProducedReport{}, err + } + execution.Order = l.order() + return supervise(ctx, l, func(work context.Context) (ProducedReport, error) { + return executor.Execute(work, execution) + }) +} + +// supervise runs work while renewing the lease. Losing the lease cancels work and waits for it to +// stop before returning, so nothing outlives the lease it ran under. +func supervise[T any](ctx context.Context, l *lease, work func(context.Context) (T, error)) (T, error) { + var zero T + if err := l.check(ctx); err != nil { + return zero, err + } + workCtx, stop := context.WithCancel(ctx) + defer stop() + type outcome struct { + value T + err error + } + done := make(chan outcome, 1) + go func() { + value, err := work(workCtx) + done <- outcome{value, err} + }() + abort := func(cause error) (T, error) { + stop() + <-done + if lost(cause) { + return zero, cause + } + return zero, &LeaseLostError{Err: cause} + } + heartbeat := time.NewTicker(l.interval) + defer heartbeat.Stop() + expiry := time.NewTimer(time.Until(*l.current.LeaseExpiresAt)) + defer expiry.Stop() + for { + select { + case <-ctx.Done(): + return abort(ctx.Err()) + case <-expiry.C: + return abort(errors.New("order lease expired")) + case <-heartbeat.C: + if err := l.renew(ctx); err != nil { + return abort(err) + } + expiry.Reset(time.Until(*l.current.LeaseExpiresAt)) + case finished := <-done: + if err := l.check(ctx); err != nil { + return zero, err + } + return finished.value, finished.err + } + } +} + +// complete renews the lease, then finishes the order with result. +func (l *lease) complete(ctx context.Context, result api.JobResult) (*api.RunOrder, error) { + if err := l.renew(ctx); err != nil { + return nil, err + } + ctx, cancel, err := l.call(ctx) + if err != nil { + return nil, err + } + defer cancel() + response, err := l.client.CompleteWithResponse(ctx, l.id(), api.CompleteRequest{Result: result}) + if err != nil { + return nil, err + } + return api.Decode(response.HTTPResponse, response.Body, response.JSON200) +} + +// snapshot is a copy of the lease's current view of the order. +func (l *lease) snapshot() *api.RunOrder { + order := l.current + return &order +} + +// latest is the completed order when there is one, else the lease's current view of it. +func (l *lease) latest(completed *api.RunOrder) api.RunOrder { + if completed != nil { + return *completed + } + return l.current +} diff --git a/go/v1/ops/ops.go b/go/v1/ops/ops.go new file mode 100644 index 0000000..23e68d2 --- /dev/null +++ b/go/v1/ops/ops.go @@ -0,0 +1,60 @@ +// Package ops runs traust work for a consumer: it holds an order's lease, calls the consumer's +// Executor, and records and completes the order through core. Core selects and authorizes work. +package ops + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/traust-security/traust-sdk/go/v1/api" +) + +const ( + defaultRequestTimeout = time.Minute + maxLeaseSeconds = 24 * 60 * 60 +) + +// Executor performs the work. On cancellation it stops the work, including child processes, +// waits for it to stop, and returns. +type Executor interface { + Execute(context.Context, Execution) (ProducedReport, error) +} + +// Execution is what an Executor runs: server context, never credentials. +type Execution struct { + Order api.RunOrder `json:"order"` + TriageInput *api.TriageInput `json:"triage_input,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + Argv []string `json:"argv,omitempty"` +} + +// ProducedReport is the executor's outcome and its original output, byte for byte. RecordingRun +// sends Payload to its record door; Runner sends only Result. +type ProducedReport struct { + Payload []byte + Result api.JobResult +} + +// LeaseLostError means the order is no longer this process's: nothing may be recorded or completed. +type LeaseLostError struct{ Err error } + +func (e *LeaseLostError) Error() string { return "ops: order lease lost: " + e.Err.Error() } +func (e *LeaseLostError) Unwrap() error { return e.Err } + +func lost(err error) bool { + var lease *LeaseLostError + return errors.As(err, &lease) +} + +func checkOutcome(result api.JobResult) error { + switch result.Outcome { + case api.OutcomeSucceeded, api.OutcomeDegraded, api.OutcomeRefused, api.OutcomeFailed: + return nil + } + return fmt.Errorf("ops: executor returned outcome %q", result.Outcome) +} + +func leaseDuration(seconds int) time.Duration { return time.Duration(seconds) * time.Second } diff --git a/go/v1/ops/pack.go b/go/v1/ops/pack.go index b3ad06f..f76a05f 100644 --- a/go/v1/ops/pack.go +++ b/go/v1/ops/pack.go @@ -7,68 +7,109 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strings" + "sync" + "github.com/google/uuid" "github.com/santhosh-tekuri/jsonschema/v6" "gopkg.in/yaml.v3" ) +// The schema core validates packs with, generated from core at CORE_REF (make api-generate). +// //go:embed pack.schema.json -var packSchema []byte +var packSchemaJSON []byte -type packAsset struct { - Run []string `json:"run"` - Params map[string]string `json:"params"` - Partitions string `json:"partitions"` - Model *struct { - Role string `json:"role"` - } `json:"model"` -} +var packSchema = sync.OnceValues(func() (*jsonschema.Schema, error) { + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(packSchemaJSON)) + if err != nil { + return nil, err + } + compiler := jsonschema.NewCompiler() + compiler.AssertFormat() + if err := compiler.AddResource("pack.schema.json", doc); err != nil { + return nil, err + } + return compiler.Compile("pack.schema.json") +}) -// Pack is a validated, pinned workload declaration. +// Pack is a workload pack (assets.yaml), validated and pinned by the digest of its bytes. type Pack struct { name string digest string assets map[string]packAsset } -// LoadPack validates the installed pack against its pinned Contracts schema. +type packAsset struct { + Run []string `json:"run"` + Params map[string]json.RawMessage `json:"params"` + Partitions string `json:"partitions"` + Model json.RawMessage `json:"model"` +} + +// command is an asset's pinned argv. "{run_id}" is the one parameter a fleet-wide command takes. +type command []string + +// LoadPack validates a pack against the schema core loads packs with. func LoadPack(raw []byte) (*Pack, error) { - var yamlDoc any - if err := yaml.Unmarshal(raw, &yamlDoc); err != nil { - return nil, fmt.Errorf("ops: parse pack YAML: %w", err) - } - encoded, err := json.Marshal(yamlDoc) - if err != nil { - return nil, fmt.Errorf("ops: convert pack to JSON: %w", err) + var doc any + if err := yaml.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("ops: parse pack: %w", err) } - compiler := jsonschema.NewCompiler() - compiler.AssertFormat() - schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(packSchema)) + encoded, err := json.Marshal(doc) if err != nil { - return nil, fmt.Errorf("ops: parse pinned pack schema: %w", err) + return nil, fmt.Errorf("ops: parse pack: %w", err) } - const schemaID = "https://example.com/traust-contracts/config/v1/pack.schema.json" - if err := compiler.AddResource(schemaID, schemaDoc); err != nil { - return nil, fmt.Errorf("ops: load pinned pack schema: %w", err) - } - schema, err := compiler.Compile(schemaID) + instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(encoded)) if err != nil { - return nil, fmt.Errorf("ops: compile pinned pack schema: %w", err) + return nil, fmt.Errorf("ops: parse pack: %w", err) } - instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(encoded)) + schema, err := packSchema() if err != nil { - return nil, fmt.Errorf("ops: parse pack JSON: %w", err) + return nil, fmt.Errorf("ops: pack schema: %w", err) } if err := schema.Validate(instance); err != nil { return nil, fmt.Errorf("ops: invalid pack: %w", err) } - var doc struct { - Pack string `json:"pack"` + var pack struct { + Name string `json:"pack"` Assets map[string]packAsset `json:"assets"` } - if err := json.Unmarshal(encoded, &doc); err != nil { + if err := json.Unmarshal(encoded, &pack); err != nil { return nil, fmt.Errorf("ops: decode pack: %w", err) } digest := sha256.Sum256(raw) - return &Pack{name: doc.Pack, digest: hex.EncodeToString(digest[:]), assets: doc.Assets}, nil + return &Pack{name: pack.Name, digest: hex.EncodeToString(digest[:]), assets: pack.Assets}, nil +} + +// ref is the name core records the pack's digest under in an order's manifest. +func (p *Pack) ref() string { return "pack:" + p.name } + +// command returns the asset's argv if a fleet-wide runner can run it: no partitions, model or +// params, and "{run_id}" as a whole argument is its only placeholder. +func (p *Pack) command(name string) (command, error) { + asset, ok := p.assets[name] + switch { + case !ok || len(asset.Run) == 0: + return nil, fmt.Errorf("ops: pack %s has no command for asset %q", p.name, name) + case asset.Partitions != "" || len(asset.Model) != 0 || len(asset.Params) != 0: + return nil, fmt.Errorf("ops: asset %q needs inputs a fleet-wide runner does not resolve", name) + } + for _, arg := range asset.Run { + if arg != "{run_id}" && strings.ContainsAny(arg, "{}") { + return nil, fmt.Errorf("ops: asset %q has an unresolvable argument %q", name, arg) + } + } + return asset.Run, nil +} + +func (c command) resolve(runID uuid.UUID) []string { + argv := make([]string, len(c)) + for i, arg := range c { + if arg == "{run_id}" { + arg = runID.String() + } + argv[i] = arg + } + return argv } diff --git a/go/v1/ops/pack.schema.json b/go/v1/ops/pack.schema.json index 0cb8103..043decd 100644 --- a/go/v1/ops/pack.schema.json +++ b/go/v1/ops/pack.schema.json @@ -4,87 +4,218 @@ "title": "Workload pack asset graph", "description": "A pack's assets.yaml: what continuous ops keeps fresh. traust-engine reads deps, partitions, trigger, priority, freshness and admission to plan and admit orders; runners read run, params, runner, model, writes, riders and outcomes to execute an order from their pinned copy of the pack (the order carries only the asset name and typed params). Strict: an unknown key is an error for both readers.", "type": "object", - "required": ["pack", "assets"], + "required": [ + "pack", + "assets" + ], "additionalProperties": false, "properties": { - "pack": {"$ref": "#/$defs/name"}, - "budget": {"$ref": "#/$defs/name", "description": "The pack's budget; lanes are budgeted in the deployment config."}, - "approvers": {"type": "array", "items": {"$ref": "#/$defs/name"}, "uniqueItems": true}, - "route_on": {"$ref": "#/$defs/name", "description": "The asset whose materialization re-routes a repository through the pack router."}, + "pack": { + "$ref": "#/$defs/name" + }, + "budget": { + "$ref": "#/$defs/name", + "description": "The pack's budget; lanes are budgeted in the deployment config." + }, + "approvers": { + "type": "array", + "items": { + "$ref": "#/$defs/name" + }, + "uniqueItems": true + }, + "route_on": { + "$ref": "#/$defs/name", + "description": "The asset whose materialization re-routes a repository through the pack router." + }, "assets": { "type": "object", "minProperties": 1, - "propertyNames": {"$ref": "#/$defs/name"}, - "additionalProperties": {"$ref": "#/$defs/asset"} + "propertyNames": { + "$ref": "#/$defs/name" + }, + "additionalProperties": { + "$ref": "#/$defs/asset" + } } }, "$defs": { - "name": {"type": "string", "pattern": "^[a-z][a-z0-9_.:-]*$"}, - "duration": {"type": "string", "pattern": "^[1-9][0-9]*[mhd]$", "description": "Minutes, hours or days: 30m, 24h, 7d."}, + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9_.:-]*$" + }, + "duration": { + "type": "string", + "pattern": "^[1-9][0-9]*[mhd]$", + "description": "Minutes, hours or days: 30m, 24h, 7d." + }, "asset": { "type": "object", "additionalProperties": false, "properties": { - "partitions": {"const": "dynamic:repo", "description": "One order per repository in the inventory; absent means one fleet-wide order."}, - "deps": {"type": "array", "items": {"$ref": "#/$defs/name"}, "uniqueItems": true}, - "priority": {"type": "integer", "description": "Lower runs sooner; routed orders take the router's priority."}, + "partitions": { + "const": "dynamic:repo", + "description": "One order per repository in the inventory; absent means one fleet-wide order." + }, + "deps": { + "type": "array", + "items": { + "$ref": "#/$defs/name" + }, + "uniqueItems": true + }, + "priority": { + "type": "integer", + "description": "Lower runs sooner; routed orders take the router's priority." + }, "trigger": { "type": "object", "additionalProperties": false, "maxProperties": 1, "properties": { - "every": {"$ref": "#/$defs/duration", "description": "Due again this long after the last attempt."}, - "on_cron": {"type": "string", "pattern": "^\\S+( \\S+){4}$", "description": "Due at each time this five-field cron expression (minute hour day-of-month month day-of-week, UTC) names, unless attempted since; for jobs with a clock time."}, - "lanes": {"type": "array", "items": {"$ref": "#/$defs/name"}, "minItems": 1, "uniqueItems": true, "description": "Planned only when the pack router routes a repository to one of these lanes."} + "every": { + "$ref": "#/$defs/duration", + "description": "Due again this long after the last attempt." + }, + "on_cron": { + "type": "string", + "pattern": "^\\S+( \\S+){4}$", + "description": "Due at each time this five-field cron expression (minute hour day-of-month month day-of-week, UTC) names, unless attempted since; for jobs with a clock time." + }, + "lanes": { + "type": "array", + "items": { + "$ref": "#/$defs/name" + }, + "minItems": 1, + "uniqueItems": true, + "description": "Planned only when the pack router routes a repository to one of these lanes." + } } }, "freshness": { "type": "object", "additionalProperties": false, "properties": { - "warn": {"$ref": "#/$defs/duration"}, - "fail": {"$ref": "#/$defs/duration"} + "warn": { + "$ref": "#/$defs/duration" + }, + "fail": { + "$ref": "#/$defs/duration" + } } }, "admission": { "type": "object", "additionalProperties": false, "properties": { - "approval": {"$ref": "#/$defs/name", "description": "The approver group that must approve each order."}, - "estimate_usd": {"type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?$", "description": "Cost of one order, charged against its lane's budget; absent means unknown cost, never withheld."}, + "approval": { + "$ref": "#/$defs/name", + "description": "The approver group that must approve each order." + }, + "estimate_usd": { + "type": "string", + "pattern": "^[0-9]+(\\.[0-9]+)?$", + "description": "Cost of one order, charged against its lane's budget; absent means unknown cost, never withheld." + }, "pacing": { "type": "object", "additionalProperties": false, - "required": ["per_tick"], - "properties": {"per_tick": {"type": "integer", "minimum": 1}} + "required": [ + "per_tick" + ], + "properties": { + "per_tick": { + "type": "integer", + "minimum": 1 + } + } }, "quota": { "type": "object", "additionalProperties": false, - "required": ["key", "per_hour"], + "required": [ + "key", + "per_hour" + ], "properties": { - "key": {"type": "string", "minLength": 1, "description": "Token bucket key; {host} is the repository's forge host, e.g. forge:{host}."}, - "per_hour": {"type": "integer", "minimum": 1} + "key": { + "type": "string", + "minLength": 1, + "description": "Token bucket key; {host} is the repository's forge host, e.g. forge:{host}." + }, + "per_hour": { + "type": "integer", + "minimum": 1 + } } } } }, - "run": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1, "description": "argv template, no shell; placeholders name params."}, - "params": {"type": "object", "additionalProperties": {"type": "string", "minLength": 1}, "description": "Param name to its value type, e.g. repo_url: HttpsRepoUrl."}, + "run": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "description": "argv template, no shell; placeholders name params." + }, + "params": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Param name to its value type, e.g. repo_url: HttpsRepoUrl." + }, "model": { "type": "object", "additionalProperties": false, - "required": ["role"], - "properties": {"role": {"$ref": "#/$defs/name"}} + "required": [ + "role" + ], + "properties": { + "role": { + "$ref": "#/$defs/name" + } + } }, "runner": { "type": "object", "additionalProperties": false, - "properties": {"labels": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}} + "properties": { + "labels": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + }, + "writes": { + "type": "array", + "items": { + "$ref": "#/$defs/name" + }, + "uniqueItems": true + }, + "riders": { + "type": "array", + "items": { + "$ref": "#/$defs/name" + }, + "uniqueItems": true }, - "writes": {"type": "array", "items": {"$ref": "#/$defs/name"}, "uniqueItems": true}, - "riders": {"type": "array", "items": {"$ref": "#/$defs/name"}, "uniqueItems": true}, - "outcomes": {"type": "array", "items": {"$ref": "#/$defs/name"}, "uniqueItems": true} + "outcomes": { + "type": "array", + "items": { + "$ref": "#/$defs/name" + }, + "uniqueItems": true + } } } } diff --git a/go/v1/ops/pack_test.go b/go/v1/ops/pack_test.go new file mode 100644 index 0000000..72c1fa2 --- /dev/null +++ b/go/v1/ops/pack_test.go @@ -0,0 +1,52 @@ +package ops + +import ( + "slices" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestPackCommandRunsOnlyFleetWideAssets(t *testing.T) { + asset := func(lines ...string) string { + return "pack: p\nassets:\n a:\n " + strings.Join(lines, "\n ") + "\n" + } + for _, tc := range []struct { + name string + pack string + ok bool + }{ + {"run_id as a whole argument", asset(`run: [tool, "{run_id}"]`), true}, + {"no placeholder", asset("run: [tool, --all]"), true}, + {"repository partitions", asset("run: [tool]", "partitions: dynamic:repo"), false}, + {"other placeholder", asset(`run: [tool, "{repo}"]`), false}, + {"embedded placeholder", asset(`run: [tool, "run-{run_id}"]`), false}, + {"stray brace", asset(`run: [tool, "{"]`), false}, + } { + t.Run(tc.name, func(t *testing.T) { + pack, err := LoadPack([]byte(tc.pack)) + if err != nil { + t.Fatal(err) + } + if _, err := pack.command("a"); (err == nil) != tc.ok { + t.Fatalf("command err=%v, want ok=%v", err, tc.ok) + } + }) + } + pack, err := LoadPack([]byte(asset("run: [tool]"))) + if err != nil { + t.Fatal(err) + } + if _, err := pack.command("missing"); err == nil { + t.Fatal("an asset the pack doesn't define has a command") + } +} + +func TestCommandResolvesRunID(t *testing.T) { + id := uuid.New() + got := command{"tool", "{run_id}", "--flag"}.resolve(id) + if !slices.Equal(got, []string{"tool", id.String(), "--flag"}) { + t.Fatalf("resolved %v", got) + } +} diff --git a/go/v1/ops/record.go b/go/v1/ops/record.go index 099226b..ffbeda9 100644 --- a/go/v1/ops/record.go +++ b/go/v1/ops/record.go @@ -4,80 +4,88 @@ import ( "context" "errors" + "github.com/google/uuid" "github.com/traust-security/traust-sdk/go/v1/api" ) -func (run RecordingRun) record(ctx context.Context, original api.RunOrder, current *api.RunOrder, raw []byte) (RecordingReceipt, error) { +// record posts the executor's bytes through the run's one template. +func (run RecordingRun) record(ctx context.Context, l *lease, raw []byte) (RecordingReceipt, error) { var receipt RecordingReceipt - if err := usableOrder(current); err != nil { - return receipt, &LeaseLostError{Err: err} - } - if err := ctx.Err(); err != nil { - return receipt, &LeaseLostError{Err: err} + ctx, cancel, err := l.call(ctx) + if err != nil { + return receipt, err } - requestCtx, cancel := run.ownedContext(ctx, current) defer cancel() - // Conversion occurs only after utf8.Valid in Run; encoding/json therefore - // carries the exact original bytes instead of replacing malformed sequences. + // raw passed utf8.Valid, so the string carries the exact original bytes. payload := string(raw) + request := run.Request switch { - case run.Request.Assessment != nil: - body := *run.Request.Assessment - provenance, err := bindProvenance(original, body.RunOrderId, body.Provenance) - if err != nil { + case request.Assessment != nil: + body := *request.Assessment + if err := l.stamp(&body.RunOrderId, &body.Provenance, &body.Payload, payload); err != nil { return receipt, err } - body.RunOrderId, body.Provenance, body.Payload = *original.Id, provenance, payload - response, err := run.Client.RecordAssessmentWithResponse(requestCtx, body) + response, err := run.Client.RecordAssessmentWithResponse(ctx, body) if err != nil { return receipt, err } - receipt.Assessment, err = nativeResponse(response.StatusCode(), response.Body, response.JSON200) + receipt.Assessment, err = api.Decode(response.HTTPResponse, response.Body, response.JSON200) return receipt, err - case run.Request.Triage != nil: - body := *run.Request.Triage - provenance, err := bindProvenance(original, body.RunOrderId, body.Provenance) - if err != nil { + case request.Triage != nil: + body := *request.Triage + if err := l.stamp(&body.RunOrderId, &body.Provenance, &body.Payload, payload); err != nil { return receipt, err } - body.RunOrderId, body.Provenance, body.Payload = *original.Id, provenance, payload - response, err := run.Client.RecordTriageWithResponse(requestCtx, run.Request.AssessmentID, body) + response, err := run.Client.RecordTriageWithResponse(ctx, request.AssessmentID, body) if err != nil { return receipt, err } - receipt.Triage, err = nativeResponse(response.StatusCode(), response.Body, response.JSON200) + receipt.Triage, err = api.Decode(response.HTTPResponse, response.Body, response.JSON200) return receipt, err - case run.Request.Validation != nil: - body := *run.Request.Validation - provenance, err := bindProvenance(original, body.RunOrderId, body.Provenance) - if err != nil { + case request.Validation != nil: + body := *request.Validation + if err := l.stamp(&body.RunOrderId, &body.Provenance, &body.Payload, payload); err != nil { return receipt, err } - body.RunOrderId, body.Provenance, body.Payload = *original.Id, provenance, payload - response, err := run.Client.RecordValidationWithResponse(requestCtx, body) + response, err := run.Client.RecordValidationWithResponse(ctx, body) if err != nil { return receipt, err } - receipt.Validation, err = nativeResponse(response.StatusCode(), response.Body, response.JSON200) + receipt.Validation, err = api.Decode(response.HTTPResponse, response.Body, response.JSON200) return receipt, err - case run.Request.Verification != nil: - body := *run.Request.Verification - provenance, err := bindProvenance(original, body.RunOrderId, body.Provenance) - if err != nil { + case request.Verification != nil: + body := *request.Verification + if err := l.stamp(&body.RunOrderId, &body.Provenance, &body.Payload, payload); err != nil { return receipt, err } - body.RunOrderId, body.Provenance, body.Payload = *original.Id, provenance, payload - response, err := run.Client.RecordVerificationWithResponse(requestCtx, body) + response, err := run.Client.RecordVerificationWithResponse(ctx, body) if err != nil { return receipt, err } - receipt.Verification, err = nativeResponse(response.StatusCode(), response.Body, response.JSON200) + receipt.Verification, err = api.Decode(response.HTTPResponse, response.Body, response.JSON200) return receipt, err } - return receipt, errors.New("recording run has no native request template") + return receipt, errors.New("ops: recording run has no request template") +} + +// stamp binds a template to the held order: its id and start time are the record's run identity. +func (l *lease) stamp(runOrderID *uuid.UUID, provenance *api.Provenance, payload *string, raw string) error { + id, started := l.id(), *l.origin.StartedAt + switch { + case *runOrderID != uuid.Nil && *runOrderID != id: + return errors.New("ops: template run_order_id is not the server's order") + case provenance.RunId != uuid.Nil && provenance.RunId != id: + return errors.New("ops: template provenance run_id is not the server's order") + case !provenance.OccurredAt.IsZero() && !provenance.OccurredAt.Equal(started): + return errors.New("ops: template provenance occurred_at is not the server's start time") + case provenance.Source == "": + return errors.New("ops: template needs an explicit provenance source") + } + *runOrderID, provenance.RunId, provenance.OccurredAt, *payload = id, id, started, raw + return nil } -func (request RecordingRequest) templateInput() (string, api.Provenance) { +func (request RecordingRequest) template() (string, api.Provenance) { switch { case request.Assessment != nil: return request.Assessment.Payload, request.Assessment.Provenance diff --git a/go/v1/ops/recording.go b/go/v1/ops/recording.go index 5d61625..6af8db2 100644 --- a/go/v1/ops/recording.go +++ b/go/v1/ops/recording.go @@ -1,9 +1,7 @@ -// Package ops owns order leases and native recording lifecycles. -// Core selects and authorizes work; consumers supply an Executor. package ops import ( - "bytes" + "cmp" "context" "encoding/json" "errors" @@ -15,31 +13,9 @@ import ( "github.com/traust-security/traust-sdk/go/v1/api" ) -// Executor performs the dispatched work. On context cancellation it MUST stop -// actual work (including subprocess descendants), wait for it to stop, and return. -type Executor interface { - Execute(context.Context, Execution) (ProducedReport, error) -} - -// Execution contains only native server context, never authentication material. -type Execution struct { - Order api.RunOrder `json:"order"` - TriageInput *api.TriageInput `json:"triage_input,omitempty"` - Input json.RawMessage `json:"input,omitempty"` - Argv []string `json:"argv,omitempty"` -} - -// ProducedReport carries an outcome and optional native report bytes. -// RecordingRun sends Payload to record doors; queued runs submit only Result. -type ProducedReport struct { - Payload []byte - Result api.JobResult -} - -// RecordingRequest selects exactly one generated request template. Payload must -// be empty; the executor supplies it. RunOrderId and provenance RunId/OccurredAt -// may be unset or match the server's immutable order ID/start time. Source, -// artifact provenance, repository references and bindings are explicit inputs. +// RecordingRequest selects exactly one generated request template. Payload must be empty: the +// executor supplies it. RunOrderId and provenance RunId/OccurredAt may be unset or match the +// server's order; source, artifact provenance, repository references and bindings are explicit. type RecordingRequest struct { Assessment *api.AssessmentRecordRequest `json:"assessment,omitempty"` Triage *api.TriageRecordBody `json:"triage,omitempty"` @@ -49,7 +25,7 @@ type RecordingRequest struct { Input json.RawMessage `json:"input,omitempty"` } -// RecordingReceipt wraps native receipts without translating their IDs or data. +// RecordingReceipt holds core's receipt for the one template that was recorded. type RecordingReceipt struct { Assessment *api.AssessmentRecordResult `json:"assessment,omitempty"` Triage *api.TriageRecordResult `json:"triage,omitempty"` @@ -63,9 +39,8 @@ type RecordingResult struct { Result api.JobResult `json:"result"` } -// RecordingRun starts one explicitly dispatched order, maintains its lease while -// executing, records original bytes, and completes the owned order. There are no -// POST retries. The caller supplies a BoundClient (normally with FileTokenSource). +// RecordingRun starts one explicitly dispatched order, holds its lease while the executor runs, +// records the executor's original bytes and completes the order. No POST is retried. type RecordingRun struct { Client *api.BoundClient Start api.StartRecordingRequest @@ -75,283 +50,109 @@ type RecordingRun struct { RequestTimeout time.Duration } -// LeaseLostError means no recording/completion may follow. Its cause retains -// typed SDK problem details without requiring consumers to infer ownership policy. -type LeaseLostError struct{ Err error } - -func (e *LeaseLostError) Error() string { return "recording run lost its lease" } -func (e *LeaseLostError) Unwrap() error { return e.Err } - func (run RecordingRun) Run(ctx context.Context) (*RecordingResult, error) { if err := run.validate(); err != nil { return nil, err } - if run.RequestTimeout == 0 { - run.RequestTimeout = 60 * time.Second - } - if run.HeartbeatInterval == 0 { - run.HeartbeatInterval = time.Duration(run.Start.LeaseSeconds) * time.Second / 3 - } - requestCtx, cancel := context.WithTimeout(ctx, run.RequestTimeout) - response, err := run.Client.StartWithResponse(requestCtx, run.Start) - cancel() + timeout := cmp.Or(run.RequestTimeout, defaultRequestTimeout) + order, err := run.start(ctx, timeout) if err != nil { return nil, err } - order, err := nativeResponse(response.StatusCode(), response.Body, response.JSON200) + interval := cmp.Or(run.HeartbeatInterval, leaseDuration(run.Start.LeaseSeconds)/3) + l, err := hold(run.Client, order, run.Start.LeaseSeconds, interval, timeout) if err != nil { return nil, err } - if err := usableOrder(order); err != nil { - return nil, &LeaseLostError{Err: err} - } - // Retain this original server snapshot. Heartbeats must never replace the - // provenance origin with their own timestamps or any consumer clock value. - original := *order - var input *api.TriageInput + execution := Execution{Input: run.Request.Input} if run.Request.Triage != nil { - requestCtx, cancel = run.ownedContext(ctx, order) - prepared, prepareErr := run.Client.PrepareTriageWithResponse(requestCtx, run.Request.AssessmentID) - cancel() - if prepareErr == nil { - input, prepareErr = nativeResponse(prepared.StatusCode(), prepared.Body, prepared.JSON200) - } - if prepareErr != nil { - return run.finishFailure(ctx, original, order, api.JobResult{}, prepareErr) + input, err := run.prepareTriage(ctx, l) + if err != nil { + return run.fail(ctx, l, api.JobResult{}, err) } + execution = Execution{TriageInput: input} } - execution := Execution{Order: original, TriageInput: input} - if run.Request.Triage == nil { - execution.Input = run.Request.Input + produced, err := l.run(ctx, run.Executor, execution) + switch { + case lost(err) || ctx.Err() != nil: + return &RecordingResult{Order: *l.snapshot(), Result: produced.Result}, err + case err != nil: + return run.fail(ctx, l, produced.Result, err) + case !utf8.Valid(produced.Payload): + return run.fail(ctx, l, produced.Result, errors.New("ops: executor report is not valid UTF-8")) } - produced, executeErr := run.execute(ctx, original, order, execution) - if executeErr != nil { - var lost *LeaseLostError - if errors.As(executeErr, &lost) || ctx.Err() != nil { - return &RecordingResult{Order: *order, Result: produced.Result}, executeErr - } - return run.finishFailure(ctx, original, order, produced.Result, executeErr) - } - if !utf8.Valid(produced.Payload) { - return run.finishFailure(ctx, original, order, produced.Result, errors.New("executor report is not valid UTF-8")) + if err := checkOutcome(produced.Result); err != nil { + return run.fail(ctx, l, produced.Result, err) } - if produced.Result.Outcome == "" { - return run.finishFailure(ctx, original, order, produced.Result, errors.New("executor did not supply a native outcome")) - } - // Serialize lease checks and all final mutations. A heartbeat failure is - // terminal even if it might have been transient; no ambiguous POST is retried. - order, err = run.heartbeat(ctx, original, order) - if err != nil { - return &RecordingResult{Order: original, Result: produced.Result}, err + if err := l.renew(ctx); err != nil { + return &RecordingResult{Order: *l.snapshot(), Result: produced.Result}, err } - receipt, err := run.record(ctx, original, order, produced.Payload) + receipt, err := run.record(ctx, l, produced.Payload) if err != nil { var refusal *api.ProblemError if errors.As(err, &refusal) && refusal.StatusCode >= 400 && refusal.StatusCode < 500 { - return run.finishFailure(ctx, original, order, produced.Result, err) + return run.fail(ctx, l, produced.Result, err) } - // A transport, decoding or server error may follow a committed write. - // Preserve the run identity for reconciliation; never complete it failed. - return &RecordingResult{Order: *order, Receipt: receipt, Result: produced.Result}, err + // A transport or server error may follow a committed write: keep the order for + // reconciliation and never complete it. + return &RecordingResult{Order: *l.snapshot(), Receipt: receipt, Result: produced.Result}, err } - order, err = run.heartbeat(ctx, original, order) - if err != nil { - return &RecordingResult{Order: original, Receipt: receipt, Result: produced.Result}, err - } - completed, err := run.complete(ctx, order, produced.Result) - return &RecordingResult{Order: valueOrOriginal(completed, original), Receipt: receipt, Result: produced.Result}, err + completed, err := l.complete(ctx, produced.Result) + return &RecordingResult{Order: l.latest(completed), Receipt: receipt, Result: produced.Result}, err } func (run RecordingRun) validate() error { if run.Client == nil || run.Executor == nil { - return errors.New("recording run requires a bound client and executor") + return errors.New("ops: recording run needs a Client and an Executor") } - if run.Start.LeaseSeconds <= 0 || run.RequestTimeout < 0 || run.HeartbeatInterval < 0 || (run.HeartbeatInterval != 0 && run.HeartbeatInterval >= time.Duration(run.Start.LeaseSeconds)*time.Second) { - return errors.New("recording run requires positive bounded lease/request/heartbeat durations") + lease := leaseDuration(run.Start.LeaseSeconds) + if run.Start.LeaseSeconds <= 0 || run.RequestTimeout < 0 || run.HeartbeatInterval < 0 || run.HeartbeatInterval >= lease { + return errors.New("ops: recording run needs a positive lease and a heartbeat shorter than it") } - count := 0 - for _, configured := range []bool{run.Request.Assessment != nil, run.Request.Triage != nil, run.Request.Validation != nil, run.Request.Verification != nil} { - if configured { - count++ + templates := 0 + for _, set := range []bool{run.Request.Assessment != nil, run.Request.Triage != nil, run.Request.Validation != nil, run.Request.Verification != nil} { + if set { + templates++ } } - if count != 1 { - return errors.New("recording run requires exactly one native request template") + if templates != 1 { + return errors.New("ops: recording run needs exactly one request template") } if run.Request.Triage != nil && run.Request.AssessmentID == uuid.Nil { - return errors.New("triage recording requires the native source assessment UUID") + return errors.New("ops: triage recording needs the source assessment id") } - payload, provenance := run.Request.templateInput() - if payload != "" || provenance.Source == "" { - return errors.New("recording run requires an empty payload template and explicit native provenance source") - } - return nil -} - -func usableOrder(order *api.RunOrder) error { - if order == nil || order.State != api.RunOrderStateLeased || order.Id == nil || *order.Id == uuid.Nil || order.StartedAt == nil || order.StartedAt.IsZero() || order.LeaseExpiresAt == nil || !order.LeaseExpiresAt.After(time.Now()) { - return errors.New("Core did not return a concrete live order lease and provenance origin") + if payload, provenance := run.Request.template(); payload != "" || provenance.Source == "" { + return errors.New("ops: request template needs an empty payload and an explicit provenance source") } return nil } -func (run RecordingRun) ownedContext(ctx context.Context, order *api.RunOrder) (context.Context, context.CancelFunc) { - deadline := time.Now().Add(run.RequestTimeout) - if order.LeaseExpiresAt.Before(deadline) { - deadline = *order.LeaseExpiresAt - } - return context.WithDeadline(ctx, deadline) -} - -func (run RecordingRun) heartbeat(ctx context.Context, original api.RunOrder, current *api.RunOrder) (*api.RunOrder, error) { - if err := usableOrder(current); err != nil { - return nil, &LeaseLostError{Err: err} - } - if err := ctx.Err(); err != nil { - return nil, &LeaseLostError{Err: err} - } - requestCtx, cancel := run.ownedContext(ctx, current) +func (run RecordingRun) start(ctx context.Context, timeout time.Duration) (*api.RunOrder, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - response, err := run.Client.HeartbeatWithResponse(requestCtx, *original.Id, api.LeaseRequest{LeaseSeconds: run.Start.LeaseSeconds}) - if err != nil { - return nil, &LeaseLostError{Err: err} - } - order, err := nativeResponse(response.StatusCode(), response.Body, response.JSON200) - if err == nil { - err = usableOrder(order) - } - if err == nil && (*order.Id != *original.Id || !order.StartedAt.Equal(*original.StartedAt)) { - err = errors.New("heartbeat changed immutable server run provenance") - } + response, err := run.Client.StartWithResponse(ctx, run.Start) if err != nil { - return nil, &LeaseLostError{Err: err} + return nil, fmt.Errorf("ops: start order: %w", err) } - return order, nil + return api.Decode(response.HTTPResponse, response.Body, response.JSON200) } -func (run RecordingRun) execute(ctx context.Context, original api.RunOrder, current *api.RunOrder, execution Execution) (ProducedReport, error) { - return superviseLease(ctx, run, original, current, func(workCtx context.Context) (ProducedReport, error) { - executionOrder := original - // Native generated models use pointers; the executor must not mutate lease identity. - id, started, expiresAt := *original.Id, *original.StartedAt, *original.LeaseExpiresAt - executionOrder.Id, executionOrder.StartedAt, executionOrder.LeaseExpiresAt = &id, &started, &expiresAt - execution.Order = executionOrder - return run.Executor.Execute(workCtx, execution) - }) -} - -func superviseLease[T any](ctx context.Context, run RecordingRun, original api.RunOrder, current *api.RunOrder, work func(context.Context) (T, error)) (T, error) { - var zero T - if err := usableOrder(current); err != nil { - return zero, &LeaseLostError{Err: err} - } - if err := ctx.Err(); err != nil { - return zero, &LeaseLostError{Err: err} - } - workCtx, cancel := context.WithCancel(ctx) - defer cancel() - type workResult struct { - value T - err error - } - done := make(chan workResult, 1) - go func() { - value, err := work(workCtx) - done <- workResult{value: value, err: err} - }() - ticker := time.NewTicker(run.HeartbeatInterval) - defer ticker.Stop() - expires := time.NewTimer(time.Until(*current.LeaseExpiresAt)) - defer expires.Stop() - stop := func(cause error) (T, error) { - cancel() - <-done // Work, including executor descendants, has stopped before return. - return zero, cause - } - for { - select { - case <-ctx.Done(): - return stop(&LeaseLostError{Err: ctx.Err()}) - case <-expires.C: - return stop(&LeaseLostError{Err: errors.New("order lease expired")}) - case <-ticker.C: - updated, err := run.heartbeat(ctx, original, current) - if err != nil { - return stop(err) - } - *current = *updated - expires.Reset(time.Until(*current.LeaseExpiresAt)) - case result := <-done: - if ctx.Err() != nil || !current.LeaseExpiresAt.After(time.Now()) { - return zero, &LeaseLostError{Err: errors.New("work ended without a live lease")} - } - return result.value, result.err - } - } -} - -func (run RecordingRun) finishFailure(ctx context.Context, original api.RunOrder, current *api.RunOrder, result api.JobResult, cause error) (*RecordingResult, error) { - owned, err := run.heartbeat(ctx, original, current) +func (run RecordingRun) prepareTriage(ctx context.Context, l *lease) (*api.TriageInput, error) { + ctx, cancel, err := l.call(ctx) if err != nil { - return &RecordingResult{Order: valueOrOriginal(current, original), Result: result}, errors.Join(cause, err) - } - result.Outcome = api.OutcomeFailed - completed, completeErr := run.complete(ctx, owned, result) - return &RecordingResult{Order: valueOrOriginal(completed, original), Result: result}, errors.Join(cause, completeErr) -} - -func (run RecordingRun) complete(ctx context.Context, order *api.RunOrder, result api.JobResult) (*api.RunOrder, error) { - if err := usableOrder(order); err != nil { - return nil, &LeaseLostError{Err: err} - } - if err := ctx.Err(); err != nil { - return nil, &LeaseLostError{Err: err} + return nil, err } - requestCtx, cancel := run.ownedContext(ctx, order) defer cancel() - response, err := run.Client.CompleteWithResponse(requestCtx, *order.Id, api.CompleteRequest{Result: result}) + response, err := run.Client.PrepareTriageWithResponse(ctx, run.Request.AssessmentID) if err != nil { - return nil, err - } - return nativeResponse(response.StatusCode(), response.Body, response.JSON200) -} - -func valueOrOriginal(order *api.RunOrder, original api.RunOrder) api.RunOrder { - if order != nil { - return *order + return nil, fmt.Errorf("ops: prepare triage: %w", err) } - return original + return api.Decode(response.HTTPResponse, response.Body, response.JSON200) } -func nativeResponse[T any](status int, raw []byte, receipt *T) (*T, error) { - if status < 200 || status >= 300 { - failure := &api.ProblemError{StatusCode: status, Body: string(raw)} - var problem api.Problem - if json.Unmarshal(raw, &problem) == nil && (problem.Title != "" || problem.Type != "" || problem.Status != 0) { - failure.Problem = &problem - } - return nil, failure - } - if status != 200 || receipt == nil || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return nil, fmt.Errorf("Core returned HTTP %d without a concrete native JSON200 response", status) - } - return receipt, nil -} - -func bindProvenance(order api.RunOrder, requestOrder uuid.UUID, provenance api.Provenance) (api.Provenance, error) { - if requestOrder != uuid.Nil && requestOrder != *order.Id { - return api.Provenance{}, errors.New("request template run_order_id conflicts with the server order") - } - if provenance.RunId != uuid.Nil && provenance.RunId != *order.Id { - return api.Provenance{}, errors.New("request provenance run_id conflicts with the server order") - } - if !provenance.OccurredAt.IsZero() && !provenance.OccurredAt.Equal(*order.StartedAt) { - return api.Provenance{}, errors.New("request provenance occurred_at conflicts with the server start time") - } - if provenance.Source == "" { - return api.Provenance{}, errors.New("request template requires an explicit native provenance source") - } - provenance.RunId = *order.Id - provenance.OccurredAt = *order.StartedAt - return provenance, nil +// fail completes the order failed while it is still held, keeping the executor's result. +func (run RecordingRun) fail(ctx context.Context, l *lease, result api.JobResult, cause error) (*RecordingResult, error) { + result.Outcome = api.OutcomeFailed + completed, err := l.complete(ctx, result) + return &RecordingResult{Order: l.latest(completed), Result: result}, errors.Join(cause, err) } diff --git a/go/v1/ops/recording_test.go b/go/v1/ops/recording_test.go index 59cf315..7a8b815 100644 --- a/go/v1/ops/recording_test.go +++ b/go/v1/ops/recording_test.go @@ -270,3 +270,16 @@ func TestDeterministicRefusalCanCompleteFailedAfterOwnershipCheck(t *testing.T) t.Fatal("refusal did not recheck ownership and complete using native outcome") } } + +func TestUnknownExecutorOutcomeCompletesFailedWithoutRecording(t *testing.T) { + run, server := newLifecycleRun(t, executorFunc(func(context.Context, Execution) (ProducedReport, error) { + return ProducedReport{Payload: []byte("original report"), Result: api.JobResult{Outcome: "exploded"}}, nil + })) + if _, err := run.Run(context.Background()); err == nil { + t.Fatal("an unknown outcome was accepted") + } + _, records, completions := server.counts() + if records != 0 || completions != 1 || server.completed.Outcome != api.OutcomeFailed { + t.Fatalf("records=%d completions=%d completed=%+v", records, completions, server.completed) + } +} diff --git a/go/v1/ops/runner.go b/go/v1/ops/runner.go index 2fe94d1..180b7f7 100644 --- a/go/v1/ops/runner.go +++ b/go/v1/ops/runner.go @@ -1,25 +1,18 @@ package ops import ( + "cmp" "context" - "encoding/json" "errors" "fmt" "net/http" - "regexp" "slices" - "strings" "time" - "github.com/google/uuid" "github.com/traust-security/traust-sdk/go/v1/api" ) -const defaultRunnerRequestTimeout = time.Minute - -var commandParameter = regexp.MustCompile(`^\{([a-z][a-z0-9_]*)\}$`) - -// RunnerConfig binds a pinned pack and the assets this host can execute. +// RunnerConfig binds a pinned pack and the assets this host runs. type RunnerConfig struct { Client *api.BoundClient Pack *Pack @@ -31,61 +24,40 @@ type RunnerConfig struct { PollInterval time.Duration } -// Runner claims and executes deterministic fleet-wide pack commands. +// Runner claims queued orders for its assets and runs each one's pinned pack command. type Runner struct { config RunnerConfig } -// NewRunner refuses assets this first command runner cannot resolve safely. +// NewRunner refuses assets a fleet-wide runner cannot run from its pinned pack. func NewRunner(cfg RunnerConfig) (*Runner, error) { if cfg.Client == nil || cfg.Pack == nil || cfg.Executor == nil { - return nil, errors.New("ops: runner requires Client, Pack, and Executor") - } - if cfg.LeaseSeconds <= 0 || cfg.LeaseSeconds > 24*60*60 || len(cfg.Assets) == 0 { - return nil, errors.New("ops: runner requires assets and a lease of 1..86400 seconds") + return nil, errors.New("ops: runner needs a Client, a Pack and an Executor") } - if cfg.HeartbeatInterval == 0 { - cfg.HeartbeatInterval = time.Duration(cfg.LeaseSeconds) * time.Second / 3 + if len(cfg.Assets) == 0 || cfg.LeaseSeconds <= 0 || cfg.LeaseSeconds > maxLeaseSeconds { + return nil, fmt.Errorf("ops: runner needs assets and a lease of 1..%d seconds", maxLeaseSeconds) } - if cfg.RequestTimeout == 0 { - cfg.RequestTimeout = defaultRunnerRequestTimeout + lease := leaseDuration(cfg.LeaseSeconds) + cfg.HeartbeatInterval = cmp.Or(cfg.HeartbeatInterval, lease/3) + cfg.RequestTimeout = cmp.Or(cfg.RequestTimeout, defaultRequestTimeout) + if cfg.HeartbeatInterval <= 0 || cfg.HeartbeatInterval >= lease || cfg.RequestTimeout <= 0 || cfg.PollInterval <= 0 { + return nil, errors.New("ops: runner needs positive request and poll intervals and a heartbeat shorter than the lease") } - if cfg.HeartbeatInterval <= 0 || cfg.HeartbeatInterval >= time.Duration(cfg.LeaseSeconds)*time.Second || cfg.RequestTimeout <= 0 || cfg.PollInterval <= 0 { - return nil, errors.New("ops: runner needs positive request and poll durations and a heartbeat shorter than the lease") - } - seen := make(map[string]bool, len(cfg.Assets)) - for _, name := range cfg.Assets { - if seen[name] { - return nil, fmt.Errorf("ops: duplicate asset %q", name) - } - seen[name] = true - asset, ok := cfg.Pack.assets[name] - if !ok || len(asset.Run) == 0 { - return nil, fmt.Errorf("ops: asset %q has no runnable command in the pinned pack", name) + for i, name := range cfg.Assets { + if slices.Contains(cfg.Assets[:i], name) { + return nil, fmt.Errorf("ops: asset %q listed twice", name) } - if asset.Partitions != "" || asset.Model != nil || len(asset.Params) != 0 { - return nil, fmt.Errorf("ops: asset %q needs input resolution beyond this fleet-wide command runner", name) - } - for _, token := range asset.Run { - if match := commandParameter.FindStringSubmatch(token); match != nil { - if match[1] != "run_id" { - return nil, fmt.Errorf("ops: asset %q requires unresolved parameter %q", name, match[1]) - } - } else if strings.ContainsAny(token, "{}") { - return nil, fmt.Errorf("ops: asset %q has an unresolved command token %q", name, token) - } + if _, err := cfg.Pack.command(name); err != nil { + return nil, err } } - cfg.Assets = append([]string(nil), cfg.Assets...) + cfg.Assets = slices.Clone(cfg.Assets) return &Runner{config: cfg}, nil } -// Run drains eligible orders and waits after an empty claim until cancellation. +// Run claims and runs orders until ctx is done, waiting PollInterval after an empty claim. func (r *Runner) Run(ctx context.Context) error { for { - if err := ctx.Err(); err != nil { - return err - } order, err := r.RunOnce(ctx) if err != nil { return err @@ -93,153 +65,112 @@ func (r *Runner) Run(ctx context.Context) error { if order != nil { continue } - timer := time.NewTimer(r.config.PollInterval) select { case <-ctx.Done(): - timer.Stop() return ctx.Err() - case <-timer.C: + case <-time.After(r.config.PollInterval): } } } -// RunOnce claims one eligible order, or returns nil when the queue is empty. +// RunOnce claims and runs one order. It returns nil, nil when the queue has nothing for this runner. func (r *Runner) RunOnce(ctx context.Context) (*api.RunOrder, error) { - requestCtx, cancel := context.WithTimeout(ctx, r.config.RequestTimeout) - assets := append([]string(nil), r.config.Assets...) - response, err := r.config.Client.ClaimWithResponse(requestCtx, api.ClaimRequest{ - Assets: &assets, LeaseSeconds: r.config.LeaseSeconds, - }) - cancel() - if err != nil { - return nil, fmt.Errorf("ops: claim order: %w", err) + if err := ctx.Err(); err != nil { + return nil, err } - if response.StatusCode() == http.StatusNoContent { - return nil, nil + order, err := r.claim(ctx) + if err != nil || order == nil { + return nil, err } - order, err := nativeResponse(response.StatusCode(), response.Body, response.JSON200) + l, err := hold(r.config.Client, order, r.config.LeaseSeconds, r.config.HeartbeatInterval, r.config.RequestTimeout) if err != nil { - return nil, fmt.Errorf("ops: claim order: %w", err) - } - if err := usableOrder(order); err != nil { - return nil, fmt.Errorf("ops: claimed order: %w", err) + return order, err } - asset, allowed := r.config.Pack.assets[order.Asset] - if !allowed || !slices.Contains(r.config.Assets, order.Asset) { - return r.finish(ctx, order, api.JobResult{Outcome: api.OutcomeRefused, Reason: stringPtr("asset-not-in-pack")}) + command, err := r.config.Pack.command(order.Asset) + if err != nil || !slices.Contains(r.config.Assets, order.Asset) { + return r.finish(ctx, l, refused(api.RunOrderReasonAssetNotInPack)) } - if order.ArtifactId == nil || order.RepositoryId != nil { - return r.finish(ctx, order, api.JobResult{Outcome: api.OutcomeRefused, Reason: stringPtr("bad_request")}) + if order.RepositoryId != nil { + return r.finish(ctx, l, refused(api.RunOrderReasonBadRequest)) } - run := RecordingRun{ - Client: r.config.Client, Executor: r.config.Executor, - Start: api.StartRecordingRequest{LeaseSeconds: r.config.LeaseSeconds}, - HeartbeatInterval: r.config.HeartbeatInterval, RequestTimeout: r.config.RequestTimeout, + if err := l.renew(ctx); err != nil { + return l.snapshot(), err } - owned, err := run.heartbeat(ctx, *order, order) + pinned, err := supervise(ctx, l, func(read context.Context) (bool, error) { return r.pinned(read, l) }) if err != nil { - return order, err + return l.snapshot(), err } - match, err := superviseLease(ctx, run, *order, owned, func(workCtx context.Context) (bool, error) { - return r.matchesPack(workCtx, *order.ArtifactId) - }) - if err != nil { - return owned, fmt.Errorf("ops: verify pack for order %s: %w", *order.Id, err) + if !pinned { + return r.finish(ctx, l, refused(api.RunOrderReasonBadRequest)) } - if !match { - return r.finish(ctx, owned, api.JobResult{Outcome: api.OutcomeRefused, Reason: stringPtr("pack version mismatch")}) + if err := l.renew(ctx); err != nil { + return l.snapshot(), err } - argv := make([]string, 0, len(asset.Run)) - for _, token := range asset.Run { - if match := commandParameter.FindStringSubmatch(token); match != nil { - argv = append(argv, order.Id.String()) - } else { - argv = append(argv, token) - } + produced, err := l.run(ctx, r.config.Executor, Execution{Argv: command.resolve(l.id())}) + if lost(err) || ctx.Err() != nil { + return l.snapshot(), err } - updated, err := run.heartbeat(ctx, *order, owned) - if err != nil { - return owned, err + if err == nil { + err = checkOutcome(produced.Result) } - owned = updated - produced, err := run.execute(ctx, *order, owned, Execution{Order: *order, Argv: argv}) if err != nil { - var lost *LeaseLostError - if errors.As(err, &lost) || ctx.Err() != nil { - return owned, err - } - finished, finishErr := r.finish(ctx, owned, api.JobResult{Outcome: api.OutcomeFailed, Reason: stringPtr("runner-crashed")}) - return finished, errors.Join(err, finishErr) - } - if err := validateJobResult(produced.Result); err != nil { - finished, finishErr := r.finish(ctx, owned, api.JobResult{Outcome: api.OutcomeFailed, Reason: stringPtr("runner-crashed")}) + finished, finishErr := r.finish(ctx, l, api.JobResult{Outcome: api.OutcomeFailed, Reason: reason(api.RunOrderReasonRunnerCrashed)}) return finished, errors.Join(err, finishErr) } - return r.finish(ctx, owned, produced.Result) + return r.finish(ctx, l, produced.Result) } -func (r *Runner) matchesPack(ctx context.Context, artifactID uuid.UUID) (bool, error) { - requestCtx, cancel := context.WithTimeout(ctx, r.config.RequestTimeout) - response, err := r.config.Client.GetArtifactContentWithResponse(requestCtx, artifactID) - cancel() +func (r *Runner) claim(ctx context.Context) (*api.RunOrder, error) { + ctx, cancel := context.WithTimeout(ctx, r.config.RequestTimeout) + defer cancel() + assets := slices.Clone(r.config.Assets) + response, err := r.config.Client.ClaimWithResponse(ctx, api.ClaimRequest{Assets: &assets, LeaseSeconds: r.config.LeaseSeconds}) if err != nil { - return false, err + return nil, fmt.Errorf("ops: claim: %w", err) } - if response.HTTPResponse == nil { - return false, errors.New("Core returned a run manifest without an HTTP response") + if response.StatusCode() == http.StatusNoContent { + return nil, nil } - if response.StatusCode() != http.StatusOK { - if err := api.CheckResponse(response.StatusCode(), response.Body, response.HTTPResponse.Header.Get("Content-Type")); err != nil { - return false, err - } - return false, fmt.Errorf("Core returned HTTP %d without a run manifest", response.StatusCode()) - } - var manifest struct { - Artifact string `json:"artifact"` - Config []struct { - Name string `json:"name"` - Digest string `json:"digest"` - } `json:"config"` - } - if err := json.Unmarshal(response.Body, &manifest); err != nil || manifest.Artifact != "run-manifest" { - return false, errors.New("Core returned an invalid run manifest") - } - var found int - for _, item := range manifest.Config { - if item.Name == "pack:"+r.config.Pack.name { - found++ - if item.Digest != r.config.Pack.digest { - return false, nil - } - } + order, err := api.Decode(response.HTTPResponse, response.Body, response.JSON200) + if err != nil { + return nil, fmt.Errorf("ops: claim: %w", err) } - return found == 1, nil + return order, nil } -func (r *Runner) finish(ctx context.Context, order *api.RunOrder, result api.JobResult) (*api.RunOrder, error) { - run := RecordingRun{ - Client: r.config.Client, - Start: api.StartRecordingRequest{LeaseSeconds: r.config.LeaseSeconds}, - RequestTimeout: r.config.RequestTimeout, - } - owned, err := run.heartbeat(ctx, *order, order) +// pinned reports whether core planned the order with exactly this runner's pack. It runs under +// supervise, so a slow read keeps the lease and a lost lease cancels it. +func (r *Runner) pinned(ctx context.Context, l *lease) (bool, error) { + ctx, cancel := context.WithTimeout(ctx, r.config.RequestTimeout) + defer cancel() + response, err := r.config.Client.GetOrderManifestWithResponse(ctx, l.id()) if err != nil { - return order, err + return false, fmt.Errorf("ops: order %s manifest: %w", l.id(), err) } - completed, err := run.complete(ctx, owned, result) + manifest, err := api.Decode(response.HTTPResponse, response.Body, response.JSON200) if err != nil { - return order, err + return false, fmt.Errorf("ops: order %s manifest: %w", l.id(), err) + } + if manifest.Config == nil { + return false, nil + } + var digests []string + for _, config := range *manifest.Config { + if config.Name == r.config.Pack.ref() { + digests = append(digests, config.Digest) + } } - return completed, nil + return len(digests) == 1 && digests[0] == r.config.Pack.digest, nil } -func validateJobResult(result api.JobResult) error { - switch result.Outcome { - case api.OutcomeSucceeded, api.OutcomeDegraded, api.OutcomeRefused, api.OutcomeFailed: - return nil - default: - return fmt.Errorf("ops: invalid job outcome %q", result.Outcome) - } +func (r *Runner) finish(ctx context.Context, l *lease, result api.JobResult) (*api.RunOrder, error) { + completed, err := l.complete(ctx, result) + order := l.latest(completed) + return &order, err +} + +func refused(why api.RunOrderReason) api.JobResult { + return api.JobResult{Outcome: api.OutcomeRefused, Reason: reason(why)} } -func stringPtr(value string) *string { return &value } +func reason(why api.RunOrderReason) *api.RunOrderReason { return &why } diff --git a/go/v1/ops/runner_e2e_test.go b/go/v1/ops/runner_e2e_test.go index a4e77ac..3a516a9 100644 --- a/go/v1/ops/runner_e2e_test.go +++ b/go/v1/ops/runner_e2e_test.go @@ -5,7 +5,6 @@ package ops import ( "bufio" "context" - "encoding/json" "errors" "os" "os/exec" @@ -18,122 +17,77 @@ import ( "github.com/traust-security/traust-sdk/go/v1/api" ) -func processGroupCommand(ctx context.Context, name string, args ...string) *exec.Cmd { - command := exec.CommandContext(ctx, name, args...) - // A lost lease must stop descendants as well as the command process. - command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - command.Cancel = func() error { return syscall.Kill(-command.Process.Pid, syscall.SIGKILL) } - return command -} - -func TestE2ERunnerLedgerAudit(t *testing.T) { - packBytes, err := os.ReadFile(os.Getenv("TRAUST_E2E_PACK")) +// Runs against traust-server built from core at CORE_REF; `make e2e` starts it. +func e2eClient(t *testing.T, tokens string) *api.BoundClient { + t.Helper() + url, path := os.Getenv("TRAUST_E2E_URL"), os.Getenv(tokens) + if url == "" || path == "" { + t.Fatalf("TRAUST_E2E_URL and %s are required; run make e2e", tokens) + } + client, err := api.NewBoundClient(api.ClientConfig{BaseURL: url, Tokens: api.FileTokenSource{Path: path}}) if err != nil { t.Fatal(err) } - pack, err := LoadPack(packBytes) + return client +} + +// processGroupCommand runs argv without a shell; cancel kills the whole process group, so a lost +// lease stops descendants too. +func processGroupCommand(ctx context.Context, name string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, name, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + return cmd +} + +type processExecutor struct{} + +func (processExecutor) Execute(ctx context.Context, execution Execution) (ProducedReport, error) { + output, err := processGroupCommand(ctx, execution.Argv[0], execution.Argv[1:]...).Output() if err != nil { - t.Fatal(err) + return ProducedReport{}, err } - client, err := api.NewBoundClient(api.ClientConfig{ - BaseURL: os.Getenv("TRAUST_E2E_URL"), - Tokens: api.FileTokenSource{Path: os.Getenv("TRAUST_E2E_TOKEN_FILE")}, - }) + return ProducedReport{Payload: output, Result: api.JobResult{Outcome: api.OutcomeSucceeded}}, nil +} + +func TestE2ERunnerRunsASubmittedPackCommand(t *testing.T) { + raw, err := os.ReadFile("testdata/local-command.yaml") if err != nil { t.Fatal(err) } - python, configHome := os.Getenv("TRAUST_E2E_PYTHON"), os.Getenv("TRAUST_E2E_CONFIG_HOME") - if python == "" || configHome == "" { - t.Fatal("e2e requires a Traust Python executable and temporary config home") - } - executor := executorFunc(func(ctx context.Context, execution Execution) (ProducedReport, error) { - if len(execution.Argv) == 0 || execution.Argv[0] != "python3" { - return ProducedReport{}, errors.New("unexpected pack command") - } - command := processGroupCommand(ctx, python, execution.Argv[1:]...) - command.Env = []string{ - "PATH=" + os.Getenv("PATH"), - "PYTHONPATH=" + os.Getenv("TRAUST_E2E_PYTHONPATH"), - "TRAUST_CONFIG_HOME=" + configHome, - "HOME=" + os.Getenv("HOME"), - } - output, err := command.Output() - var exit *exec.ExitError - if err != nil && !errors.As(err, &exit) { - return ProducedReport{}, err - } - var result api.JobResult - if err := json.Unmarshal(output, &result); err != nil { - return ProducedReport{}, err - } - return ProducedReport{Payload: output, Result: result}, nil - }) - runner, err := NewRunner(RunnerConfig{ - Client: client, Pack: pack, Executor: executor, Assets: []string{"ledger_audit"}, - LeaseSeconds: 30, HeartbeatInterval: 5 * time.Second, RequestTimeout: 10 * time.Second, PollInterval: time.Second, - }) + pack, err := LoadPack(raw) if err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - order, err := runner.RunOnce(ctx) - if err != nil || order == nil || order.State != api.RunOrderStateSucceeded { - t.Fatalf("completed order=%+v err=%v", order, err) - } - if idle, err := runner.RunOnce(ctx); err != nil || idle != nil { - t.Fatalf("empty queue order=%+v err=%v", idle, err) - } - if !strings.HasPrefix(order.RequestedBy, "human:") { - t.Fatalf("order lost its planning principal: %q", order.RequestedBy) - } -} -func TestE2ERunnerLocalCommand(t *testing.T) { - packBytes, err := os.ReadFile("testdata/local-command.yaml") - if err != nil { - t.Fatal(err) - } - pack, err := LoadPack(packBytes) + person := e2eClient(t, "TRAUST_E2E_TOKEN_FILE") + submitted, err := person.SubmitWithResponse(ctx, api.SubmitRequest{ + Order: api.OrderRequest{Asset: "command_result", Priority: 1}, + Config: &[]api.ConfigDigest{{Name: pack.ref(), Digest: pack.digest}}, + }) if err != nil { t.Fatal(err) } - client, err := api.NewBoundClient(api.ClientConfig{ - BaseURL: os.Getenv("TRAUST_E2E_URL"), - Tokens: api.FileTokenSource{Path: os.Getenv("TRAUST_E2E_TOKEN_FILE")}, - }) + want, err := api.Decode(submitted.HTTPResponse, submitted.Body, submitted.JSON200) if err != nil { t.Fatal(err) } - executor := executorFunc(func(ctx context.Context, execution Execution) (ProducedReport, error) { - if len(execution.Argv) != 4 || execution.Argv[0] != "python3" || execution.Argv[1] != "-c" || execution.Argv[3] != execution.Order.Id.String() { - return ProducedReport{}, errors.New("unexpected resolved command") - } - command := processGroupCommand(ctx, execution.Argv[0], execution.Argv[1:]...) - output, err := command.Output() - if err != nil { - return ProducedReport{}, err - } - if string(output) != "completed\n" { - return ProducedReport{}, errors.New("unexpected command output") - } - return ProducedReport{Payload: output, Result: api.JobResult{Outcome: api.OutcomeSucceeded}}, nil - }) + runner, err := NewRunner(RunnerConfig{ - Client: client, Pack: pack, Executor: executor, Assets: []string{"command_result"}, - LeaseSeconds: 30, HeartbeatInterval: 5 * time.Second, RequestTimeout: 10 * time.Second, PollInterval: time.Second, + Client: e2eClient(t, "TRAUST_E2E_RUNNER_TOKEN_FILE"), Pack: pack, Executor: processExecutor{}, + Assets: []string{"command_result"}, LeaseSeconds: 30, PollInterval: time.Second, }) if err != nil { t.Fatal(err) } - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() order, err := runner.RunOnce(ctx) - if err != nil || order == nil || order.State != api.RunOrderStateSucceeded { - t.Fatalf("completed order=%+v err=%v", order, err) + if err != nil || order == nil || *order.Id != *want.Id || order.State != api.RunOrderStateSucceeded { + t.Fatalf("order=%+v err=%v", order, err) } if idle, err := runner.RunOnce(ctx); err != nil || idle != nil { - t.Fatalf("empty queue order=%+v err=%v", idle, err) + t.Fatalf("drained queue returned order=%+v err=%v", idle, err) } } diff --git a/go/v1/ops/runner_test.go b/go/v1/ops/runner_test.go index 065ffcf..ecef760 100644 --- a/go/v1/ops/runner_test.go +++ b/go/v1/ops/runner_test.go @@ -53,6 +53,7 @@ type queuedServer struct { pack *Pack empty bool wrongDigest bool + manifest *api.OrderManifest malformed bool heartbeatStatus int claimStatus int @@ -91,7 +92,7 @@ func (server *queuedServer) ServeHTTP(w http.ResponseWriter, request *http.Reque return } _ = json.NewEncoder(w).Encode(server.order) - case strings.HasSuffix(request.URL.Path, "/content"): + case strings.HasSuffix(request.URL.Path, "/manifest"): server.events = append(server.events, "manifest") if server.manifestDelay > 0 { delay, canceled := server.manifestDelay, server.manifestCanceled @@ -118,17 +119,18 @@ func (server *queuedServer) ServeHTTP(w http.ResponseWriter, request *http.Reque return } if server.malformed { - _, _ = w.Write([]byte(`{"artifact":"wrong"}`)) + _, _ = w.Write([]byte(`{"config":`)) + return + } + if server.manifest != nil { + _ = json.NewEncoder(w).Encode(server.manifest) return } digest := server.pack.digest if server.wrongDigest { digest = strings.Repeat("0", 64) } - _ = json.NewEncoder(w).Encode(map[string]any{ - "artifact": "run-manifest", - "config": []map[string]string{{"name": "pack:security", "digest": digest}}, - }) + _ = json.NewEncoder(w).Encode(api.OrderManifest{Config: &[]api.ConfigDigest{{Name: "pack:security", Digest: digest}}}) case strings.HasSuffix(request.URL.Path, "/heartbeat"): server.events = append(server.events, "heartbeat") server.heartbeatCalls++ @@ -527,3 +529,36 @@ func TestQueuedRunnerCompletesFailedAfterInvalidExecution(t *testing.T) { }) } } + +func TestQueuedRunnerRefusesAnOrderNotPinnedToExactlyItsPack(t *testing.T) { + pinned := func(digests ...string) *api.OrderManifest { + config := []api.ConfigDigest{{Name: "pack:other", Digest: strings.Repeat("1", 64)}} + for _, digest := range digests { + config = append(config, api.ConfigDigest{Name: "pack:security", Digest: digest}) + } + return &api.OrderManifest{Config: &config} + } + for _, tc := range []struct { + name string + manifest func(pack string) *api.OrderManifest + }{ + {"no config", func(string) *api.OrderManifest { return &api.OrderManifest{} }}, + {"another pack only", func(string) *api.OrderManifest { return pinned() }}, + {"this pack twice", func(pack string) *api.OrderManifest { return pinned(pack, pack) }}, + } { + t.Run(tc.name, func(t *testing.T) { + runner, server := queuedRunner(t, executorFunc(func(context.Context, Execution) (ProducedReport, error) { + t.Fatal("executor ran an order not pinned to its pack") + return ProducedReport{}, nil + })) + server.manifest = tc.manifest(server.pack.digest) + order, err := runner.RunOnce(context.Background()) + if err != nil || order == nil || order.State != api.RunOrderStateRefused { + t.Fatalf("order=%+v err=%v", order, err) + } + if _, result := server.snapshot(); result.Reason == nil || *result.Reason != api.RunOrderReasonBadRequest { + t.Fatalf("result=%+v", result) + } + }) + } +} diff --git a/go/v1/ops/testdata/seed_order.py b/go/v1/ops/testdata/seed_order.py deleted file mode 100644 index 5c0f0c2..0000000 --- a/go/v1/ops/testdata/seed_order.py +++ /dev/null @@ -1,30 +0,0 @@ -from hashlib import sha256 -from pathlib import Path -from sys import argv - -from traust_core.v1.context import Context -from traust_core.v1.models.identity import IdentitySource, Principal -from traust_core.v1.models.operations import ConfigDigest, OrderRequest -from traust_core.v1.models.vocabulary import ActorKind - -config_path, pack_path, token_path = map(Path, argv[1:]) -context = Context.from_file(config_path) -principal = Principal( - identity="operator", - kind=ActorKind.HUMAN, - display="operator", - verified_by=IdentitySource.LOCAL_USER, -) -digest = sha256(pack_path.read_bytes()).hexdigest() -service = context.services().ops -# Plan/admit has no REST door; only this test seed calls Core's owning service. -planned = service.plan( - OrderRequest(asset="command_result", priority=1), - principal, - [ConfigDigest(name="pack:smoke", digest=digest)], -) -service.admit(planned.id) -token_path.write_text( - context.local_issuer.mint("smoke-runner", machine=True).token.get_secret_value() -) -token_path.chmod(0o600) diff --git a/go/v1/skills/OWNERS b/go/v1/skills/OWNERS new file mode 100644 index 0000000..e63ba2c --- /dev/null +++ b/go/v1/skills/OWNERS @@ -0,0 +1,5 @@ +# Frozen package: bug fixes and regenerated code only. +approvers: + - traust-admins +reviewers: + - traust-admins diff --git a/go/v1/storage/OWNERS b/go/v1/storage/OWNERS new file mode 100644 index 0000000..e63ba2c --- /dev/null +++ b/go/v1/storage/OWNERS @@ -0,0 +1,5 @@ +# Frozen package: bug fixes and regenerated code only. +approvers: + - traust-admins +reviewers: + - traust-admins diff --git a/go/v1/types/OWNERS b/go/v1/types/OWNERS new file mode 100644 index 0000000..e63ba2c --- /dev/null +++ b/go/v1/types/OWNERS @@ -0,0 +1,5 @@ +# Frozen package: bug fixes and regenerated code only. +approvers: + - traust-admins +reviewers: + - traust-admins diff --git a/go/v1/validate/OWNERS b/go/v1/validate/OWNERS new file mode 100644 index 0000000..e63ba2c --- /dev/null +++ b/go/v1/validate/OWNERS @@ -0,0 +1,5 @@ +# Frozen package: bug fixes and regenerated code only. +approvers: + - traust-admins +reviewers: + - traust-admins