Compare commits

...

9 Commits

19 changed files with 9521 additions and 57 deletions

View File

@@ -156,7 +156,10 @@ The administrator journey from Core-only bootstrap through online module
installation, scale-out, and reversible environment promotion is defined in installation, scale-out, and reversible environment promotion is defined in
[System Administrator Lifecycle User Story](docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md). [System Administrator Lifecycle User Story](docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md).
The first Campaign-centric capability and infrastructure fit assessment is in The first Campaign-centric capability and infrastructure fit assessment is in
`docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md`. `docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md`. Its rerun tooling can collect and
verify a bounded installed composition; target, provider and production claims
remain separate, expiring attestations signed by independently scoped proof
authorities.
# GovOPlaN Docker # GovOPlaN Docker

View File

@@ -360,7 +360,11 @@ windows. Only `active` and `next` entries are trusted; unknown statuses,
duplicate key IDs and malformed structured keyrings fail closed, while revoked, duplicate key IDs and malformed structured keyrings fail closed, while revoked,
disabled and retired entries are ignored. Do not establish this trust file by disabled and retired entries are ignored. Do not establish this trust file by
downloading it in the same rerun. Public JSON responses are bounded to 16 MiB. downloading it in the same rerun. Public JSON responses are bounded to 16 MiB.
Use `--json` or `--output PATH` for automation. Use `--json` or `--output PATH` for automation. Evidence and report files are
written through a bounded same-directory atomic replacement, with mode `0600`
and file and directory `fsync`. All parent directories must already exist, no
parent component may be a symlink, and an existing symlink or non-regular output
target is rejected.
Exit status `0` means the assessed release metadata is current, `2` means Exit status `0` means the assessed release metadata is current, `2` means
explicit review is required, and `1` means the schema or trust checks are explicit review is required, and `1` means the schema or trust checks are
@@ -372,6 +376,159 @@ metadata, published-keyring integrity and optional local tag provenance; it does
not prove installed artifacts, target providers, target infrastructure, or not prove installed artifacts, target providers, target infrastructure, or
production fitness. Those remain separate proof checks above. production fitness. Those remain separate proof checks above.
### Installed-composition evidence
The same rerun can inspect the Python environment in which it executes and bind
that observation to the assessment and signed catalog:
```bash
./.venv/bin/python tools/assessments/capability-fit.py \
--public \
--trusted-keyring /srv/govoplan/trust/catalog-keyring.json \
--collect-installed-evidence /var/tmp/govoplan-installed-evidence.json \
--output /var/tmp/govoplan-fit-review.json
```
The collector follows the strict version `0.3.0`
[`installed-composition-evidence.schema.json`](installed-composition-evidence.schema.json)
contract. It enumerates all installed distributions whose normalized name starts
with `govoplan-`, compares the enabled assessed package and module-manifest
versions, and identifies missing, duplicate and extra GovOPlaN distributions.
Those differences produce stable review targets for the affected composition
entries and evidence-backed conclusions.
For each distribution, the collector also:
- reads the installed wheel `RECORD` file directly, with a 4 MiB metadata bound,
rather than relying on an interpreted distribution file list; missing files,
duplicate or malformed declarations, mismatched declared sizes and hashes all
prevent a `verified` result;
- verifies every supported SHA-256 wheel-payload declaration in that local
metadata, within fixed limits of 256 GovOPlaN distributions, 10,000 files and
512 MiB hashed per distribution, 50,000 files and 2 GiB hashed for one
collection, and 64 MiB for any single file;
- accepts package-owned data-file targets below the installation prefix and
declared `console_scripts`, while rejecting other traversal, undeclared
scripts, paths outside the prefix and leaf symlinks before opening a file;
- reports pip-generated, unhashed `__pycache__/*.pyc` entries separately only
when their derived source is a hashed payload entry, including generated
files below a confined package-owned runtime-data prefix;
- labels every PEP 610 observation with the explicit
`local-pep610-metadata` basis and distinguishes coherent declared Git commits
and archive hashes from editable installs, local directories,
package-index/unknown origins and malformed metadata;
- compares a non-editable VCS commit with the assessment commit and, when one is
present, the signed catalog `selected_units` commit;
- records only package/module identifiers, versions, bounded counters, hashes
and stable error codes. It never writes direct URLs, paths, hostnames,
usernames, exception text or file contents.
`RECORD` `verified` means complete matching coverage of the wheel payload
declared by the local metadata, not every runtime byte and not a release-origin
binding. The proof scope exposes the aggregate count of generated unhashed
bytecode; runtime activation remains unchecked. `RECORD` agreement proves that
payload files agree with installed metadata, but does not make self-consistent
metadata a trusted release origin. Similarly, a version string is not artifact
integrity. PEP 610 VCS metadata is accepted only for
`vcs: git`, a full 4064 hexadecimal commit, one mutually exclusive provenance
form and a coherent bounded URL shape. Editable and directory-backed installs
remain mutable even when their small editable-install `RECORD` is valid. Archive
or index artifacts without a hash anchored by the assessed release remain
unanchored. A matching declaration is reported under
`installed_source_provenance` as local consistency only, with
`release_origin_bound: false`. The separate `installed_release_origin` scope
remains unchecked until an independently anchored artifact digest or signed
installer receipt binds each installed payload to the signed catalog. The tool
does not accept a digest merely asserted inside the installed evidence document.
Only evidence produced in-process by `--collect-installed-evidence` is a local
observation, and it must be no more than five minutes old (with at most 30
seconds of future clock skew) at the selected verification time. Use
`--installed-evidence PATH` to compare evidence collected in a separate
environment. Imported JSON is unsigned and therefore always carries a blocker:
it can identify differences but cannot establish checked or valid installed
proof. A future signed collector-attestation format is required to cross that
boundary. The proof scope records observation mode, collection/evaluation time,
freshness, and whether evaluation used current time or an explicit historical
override. Evidence input and output are bounded to 4 MiB. Collection loads
the `govoplan.modules` entry-point factories in order to read module IDs and
manifest versions; that executes installed GovOPlaN manifest code. Run it only
inside the installation being assessed and with the same isolation expected for
other installed-artifact acceptance checks. This collector does not observe
which modules a running service activated, migrations, configuration, health,
or reference-journey behavior. Runtime activation therefore remains an explicit
unchecked boundary.
### Target, provider and production proof boundary
Installed evidence cannot approve a target environment, an external provider,
or production use. These scopes use a separate, expiring
[`capability-fit-boundary-evidence.schema.json`](capability-fit-boundary-evidence.schema.json)
bundle. The bundle is bound to the assessment ID, assessment release and exact
installed-evidence SHA-256 digest. It contains only opaque subject/control/result
IDs and content hashes, not endpoints, credentials, people or raw result files.
Boundary evidence is accepted only when at least one Ed25519 signature validates
against a separately provisioned
[`capability-fit-proof-authority-keyring.schema.json`](capability-fit-proof-authority-keyring.schema.json).
Each authority key explicitly lists the scopes it may attest. Target and provider
claims use `passed` or `failed`; production claims use `approved` or `rejected`.
One claim per scope, unique control/artifact IDs, `issued_at < expires_at`, current
validity and exact digest binding are mandatory. Any schema, binding, time,
signature or authority blocker leaves every supplied boundary claim unchecked;
one malformed authority member or invalid validity interval invalidates the
supplied authority set as a whole. An authorized negative result is checked but
requires review only after every prerequisite, including installed
release-origin binding, is established.
Signatures cover UTF-8 JSON with the `signatures` member omitted, object keys
sorted, compact `,`/`:` separators and non-ASCII characters escaped, matching
the tool's deterministic canonicalization.
```bash
./.venv/bin/python tools/assessments/capability-fit.py \
--public \
--trusted-keyring /srv/govoplan/trust/catalog-keyring.json \
--installed-evidence /srv/govoplan/evidence/installed.json \
--boundary-evidence /srv/govoplan/evidence/target-proof.json \
--boundary-authority-keyring /srv/govoplan/trust/proof-authorities.json \
--expected-external-provider-subject provider-production
```
With `--installed-evidence`, this command performs comparison and proof-binding
diagnostics only: the imported installed document is unsigned, so neither it nor
the boundary claim becomes accepted proof. Direct in-process collection removes
that unsigned-import gap, but still establishes only local metadata consistency.
No boundary claim is admitted by the current format because it has no separately
anchored artifact digest or signed installer receipt. A future evidence contract
must bind such an independently trusted release-artifact receipt to the installed
evidence digest; a signed collector-attestation format should also make that
exchange durable across processes.
Target-environment and production-approval claims must use the assessment's
`deployment_profile.id` as `subject_id`. External-provider claims require the
operator to supply a bounded opaque expected subject with
`--expected-external-provider-subject`; without it, such a claim remains
unchecked and blocks. Expected and observed IDs are retained in proof scope.
The authority keyring is a governance trust root. Do not download or generate it
from the proof bundle being checked. The checker rejects proof-authority public
keys reused by either the published or independently trusted catalog keyring,
and rejects the same proof public-key material assigned to multiple authority
IDs. A malformed key, an invalid or empty validity interval, or ambiguous key
identity invalidates the supplied authority set. Authority `not_after` is
exclusive. A target operator or approver must validate the referenced
drill/result artifacts before signing. The rerun verifies the
attestation and its bindings; it does not fetch or reinterpret those artifacts.
`--verification-time` exists only for reproducible historical review. Supplying
it always marks the machine and human report as a **HISTORICAL override**; callers
cannot relabel it as current. Live admission must omit it and use the actual
current time.
No boundary bundle or production authority has been supplied for this current
assessment. Target environment, provider and production proof therefore remain
explicitly unchecked rather than inferred from the local GreenMail journey,
source tests or signed release metadata.
## Evidence used in this slice ## Evidence used in this slice
- [Production-like profile](../dev/production-like/README.md) and - [Production-like profile](../dev/production-like/README.md) and
@@ -389,6 +546,9 @@ production fitness. Those remain separate proof checks above.
verified against a separately provisioned local trust keyring verified against a separately provisioned local trust keyring
- Annotated source tags `govoplan-core/v0.1.13` and - Annotated source tags `govoplan-core/v0.1.13` and
`govoplan-campaign/v0.1.10`, including their catalogued Python and WebUI refs `govoplan-campaign/v0.1.10`, including their catalogued Python and WebUI refs
- Installed-composition, boundary-proof and independently scoped proof-authority
schemas plus their deterministic review tests; no current target or production
proof bundle is asserted
Checks retained from the first assessment slice against its then-current Checks retained from the first assessment slice against its then-current
workspace: workspace:

View File

@@ -18,6 +18,7 @@ candidate/publish actions:
- configure target versions per release unit in the web UI - configure target versions per release unit in the web UI
- select the repositories that should advance through checkboxes - select the repositories that should advance through checkboxes
- generate dry-run selective release plans for independently versioned packages - generate dry-run selective release plans for independently versioned packages
- freeze a selective plan as a durable, resumable local release run
- create annotated source release tags for selected clean, version-aligned - create annotated source release tags for selected clean, version-aligned
repositories and publish each branch/tag pair atomically repositories and publish each branch/tag pair atomically
- generate signed catalog candidates for the selected release units - generate signed catalog candidates for the selected release units
@@ -49,6 +50,119 @@ tag. `source_preflight_ready` means that the plan-visible source gates pass; the
non-mutating `Preview Tag + Publish` remains mandatory for remote, manifest, and non-mutating `Preview Tag + Publish` remains mandatory for remote, manifest, and
immutable-tag checks. immutable-tag checks.
## Durable release runs
The **Durable Run State** card turns the current repository/version selection
into a versioned local run record. The server rebuilds the selective plan and
requires the plan to resolve exactly the requested repositories and target
versions; the browser cannot submit or replace the plan snapshot. The input and
plan are then immutable and covered by a canonical SHA-256 integrity digest.
The complete record also has a checksum so a valid-looking manual edit to its
mutable state fails closed. File permissions remain the authority boundary;
these digests detect accidental or manual corruption, not an attacker who can
replace the private record and recompute its checksums. Changing a target,
channel, or gate input requires a new run.
Creation requires a caller-generated `request_id`. Its SHA-256 fingerprint is
the private, workspace-scoped durable mapping to exactly one run; the raw
identifier is never persisted. Repeating the same identifier with the same
immutable inputs returns that run without rebuilding the plan, even if the
live dashboard has since drifted. Reusing it with different inputs fails
closed. The browser keeps an uncertain create identifier in session storage,
replays it after reload, and selects the known run returned by the server. A
successful create remains shown as saved if only the subsequent list refresh
fails.
Run records survive console restarts, but remain local operator state rather
than a signed release artifact or the system audit log. The default location is
`$XDG_STATE_HOME/govoplan/release-console/workspace-<sha256>/release-runs/`, or
`~/.local/state/...` when `XDG_STATE_HOME` is unset or relative. It is outside
the source checkout so filesystems without enforceable POSIX modes cannot
silently weaken the journal. Newly created state directories use mode `0700`
and records use `0600`. Writes use a cross-process lock, a same-directory
temporary file, `fsync`, atomic replacement, and directory/parent `fsync`.
Symbolic-link paths, untrusted owners or writable ancestry, overly broad record
modes, malformed schemas, unknown fields, invalid state combinations,
oversized files, and digest mismatches fail closed. A bad record is neither
rewritten nor automatically quarantined.
The full resolved-workspace SHA-256 is part of both the private storage
namespace and immutable input snapshot. Every list, read, and state transition
checks it. An alternate workspace therefore cannot list or resume another
workspace's runs. This remains true for an embedding/test `run_state_root`
override: the server always appends
`workspace-<full-sha256>/release-runs/` rather than treating the override as a
shared record directory. A corrupt record from one workspace therefore cannot
leak even its identifier or an integrity error into another workspace.
Each frozen plan step has an explicit `pending`, `running`, `succeeded`,
`failed`, or `interrupted` state. Plan order remains a prerequisite: a later
step is unavailable until earlier steps have succeeded. Exact attempt and
resume/retry/reconciliation request identifiers are fingerprinted so delayed
repetitions remain idempotent for the run's lifetime. These fingerprints are
never evicted: the store fails closed before accepting more than 2,048 commands
or 2,048 attempts and asks the operator to create a fresh run. The display
record keeps at most 256 server-generated state events. Events contain only an
enum event type, timestamp, step identifier, and bounded result code—never
commands, process output, confirmation text, credentials, bearer tokens, or
signing material.
Before a step can enter `running`, the store reserves the two command-ledger
slots needed for worst-case recovery. It also projects the serialized record
through start, finish/failure, resume, and the required terminal reconciliation
or read-only retry; the start is rejected unless every required atomic write
fits the record-size bound. An explicit resume consumes one slot and is
accepted only while a persisted attempt is actually running. A mutating attempt
always retains its final `effect_absent` or `effect_succeeded` slot;
`unresolved` may be recorded at most once for that attempt and only when an
additional ledger slot and serialized terminal-write capacity are available.
Read-only interruption and known failure retain the slot and byte capacity
needed to prepare a retry. Capacity exhaustion is therefore detected before
starting an effect rather than stranding an ambiguous attempt.
An explicit resume after a process restart converts every persisted `running`
step to `interrupted`; it never guesses whether an external effect happened.
An interrupted read-only step can be prepared for retry. An interrupted
mutating step remains unavailable until the operator independently reconciles
local and remote state, selects `effect_absent`, `effect_succeeded`, or
`unresolved`, and types `RECONCILE`. `effect_absent` prepares a safe new
attempt, `effect_succeeded` advances the run without repeating the external
effect, and `unresolved` keeps the run blocked. Each outcome emits a bounded,
code-only state event. A known failed attempt can likewise be prepared for
retry. The UI keeps unavailable controls visible and disabled.
The browser likewise retains the request identifier for an uncertain
resume/retry/reconciliation response and replays it after reload. A successful
replay selects the returned run state. Transport and server failures retain the
identifier; a deterministic `4xx` rejection clears it so a stale command cannot
poison a later attempt.
The run API is covered by the same local console token middleware as every
other `/api/` route:
- `POST /api/release-runs` requires `request_id`, then idempotently rebuilds and
freezes a selective plan only when that creation is not already known.
- `GET /api/release-runs` lists bounded summaries ordered by verified
`updated_at`, then `created_at` and run identifier. Unreadable entries are
appended deterministically when room remains instead of displacing newer
verified runs.
- `GET /api/release-runs/{run_id}` reads and verifies one exact record.
- `POST /api/release-runs/{run_id}/resume` records explicit recovery.
- `POST /api/release-runs/{run_id}/steps/{step_id}/retry` prepares a failed or
read-only interrupted step for another attempt.
- `POST /api/release-runs/{run_id}/steps/{step_id}/reconcile` records a
confirmed observed outcome for an interrupted mutating step.
Run-storage errors are confined to the Durable Run State card; dashboard and
release-preview collection continue and show the bounded storage remediation.
This foundation tracks state only. It deliberately does not run a plan step or
infer success from a button click. Existing preview and executor controls stay
separate and continue to require `COMMIT`, `TAG`, `PUBLISH`, `APPLY`, or `PUSH`
at their existing narrow boundaries. Wiring confirmed executors to claim and
finish run steps, plus package/install verification, remains follow-up work;
until then the console must not present a run as execution evidence.
Dashboard collection is also fail closed. Unreadable Core version metadata or a Dashboard collection is also fail closed. Unreadable Core version metadata or a
malformed module contract is returned as a bounded `collection_errors` entry malformed module contract is returned as a bounded `collection_errors` entry
with a remediation, marks the dashboard blocked, and becomes a structured with a remediation, marks the dashboard blocked, and becomes a structured

View File

@@ -0,0 +1,148 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/capability-fit-boundary-evidence.schema.json",
"title": "GovOPlaN externally issued capability-fit boundary evidence",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"evidence_kind",
"proof_id",
"assessment_id",
"assessment_release",
"installed_evidence_sha256",
"issued_at",
"expires_at",
"claims",
"signatures"
],
"properties": {
"$schema": {
"type": "string",
"format": "uri-reference"
},
"schema_version": {
"const": "0.1.0"
},
"evidence_kind": {
"const": "govoplan.capability-fit-boundary-proof"
},
"proof_id": {
"$ref": "#/$defs/opaque_id"
},
"assessment_id": {
"$ref": "#/$defs/opaque_id"
},
"assessment_release": {
"$ref": "#/$defs/opaque_id"
},
"installed_evidence_sha256": {
"$ref": "#/$defs/sha256"
},
"issued_at": {
"type": "string",
"format": "date-time"
},
"expires_at": {
"type": "string",
"format": "date-time"
},
"claims": {
"type": "array",
"minItems": 1,
"maxItems": 3,
"items": {
"$ref": "#/$defs/claim"
}
},
"signatures": {
"type": "array",
"minItems": 1,
"maxItems": 16,
"items": {
"$ref": "#/$defs/signature"
}
}
},
"$defs": {
"opaque_id": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"claim": {
"type": "object",
"additionalProperties": false,
"required": ["scope", "result", "subject_id", "control_ids", "artifacts"],
"properties": {
"scope": {
"enum": [
"target_environment",
"external_providers",
"production_approval"
]
},
"result": {
"enum": ["passed", "failed", "approved", "rejected"]
},
"subject_id": {
"$ref": "#/$defs/opaque_id"
},
"control_ids": {
"type": "array",
"minItems": 1,
"maxItems": 256,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/opaque_id"
}
},
"artifacts": {
"type": "array",
"minItems": 1,
"maxItems": 256,
"items": {
"$ref": "#/$defs/evidence_artifact"
}
}
}
},
"evidence_artifact": {
"type": "object",
"additionalProperties": false,
"required": ["artifact_id", "sha256"],
"properties": {
"artifact_id": {
"$ref": "#/$defs/opaque_id"
},
"sha256": {
"$ref": "#/$defs/sha256"
}
}
},
"signature": {
"type": "object",
"additionalProperties": false,
"required": ["algorithm", "key_id", "value"],
"properties": {
"algorithm": {
"const": "ed25519"
},
"key_id": {
"$ref": "#/$defs/opaque_id"
},
"value": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"contentEncoding": "base64"
}
}
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
}
}
}

View File

@@ -967,10 +967,12 @@
], ],
"proof_checks": [ "proof_checks": [
"Materialize the signed catalog into an isolated installation and rerun contract, migration and module-permutation gates against the installed artifacts.", "Materialize the signed catalog into an isolated installation and rerun contract, migration and module-permutation gates against the installed artifacts.",
"Collect the isolated installation with the bounded installed-composition evidence contract; require exact enabled package/module versions, complete RECORD verification and immutable provenance anchored to this assessment.",
"Run a safe target-like Campaign through SMTP acceptance, IMAP append, reporting and audit.", "Run a safe target-like Campaign through SMTP acceptance, IMAP append, reporting and audit.",
"Drill worker, Redis and ambiguous-delivery failures without duplicate sends.", "Drill worker, Redis and ambiguous-delivery failures without duplicate sends.",
"Restore PostgreSQL, managed files, configuration and encrypted credentials and measure RPO/RTO.", "Restore PostgreSQL, managed files, configuration and encrypted credentials and measure RPO/RTO.",
"Validate proxy/TLS, cookies/CORS, account bootstrap, secret redaction, monitoring and alert delivery.", "Validate proxy/TLS, cookies/CORS, account bootstrap, secret redaction, monitoring and alert delivery.",
"Measure representative Campaign/file/queue/database load and external throttling." "Measure representative Campaign/file/queue/database load and external throttling.",
"Require separately issued, expiring and independently scope-authorized evidence before marking target environment, external provider or production approval proof as checked."
] ]
} }

View File

@@ -0,0 +1,75 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/capability-fit-proof-authority-keyring.schema.json",
"title": "GovOPlaN capability-fit proof authority keyring",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "purpose", "keys"],
"properties": {
"$schema": {
"type": "string",
"format": "uri-reference"
},
"schema_version": {
"const": "0.1.0"
},
"purpose": {
"const": "govoplan.capability-fit-proof-authorities"
},
"keys": {
"type": "array",
"maxItems": 64,
"items": {
"$ref": "#/$defs/key"
}
}
},
"$defs": {
"opaque_id": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"key": {
"type": "object",
"additionalProperties": false,
"required": ["key_id", "status", "public_key", "allowed_scopes"],
"properties": {
"key_id": {
"$ref": "#/$defs/opaque_id"
},
"status": {
"enum": ["active", "next", "revoked", "disabled", "retired"]
},
"public_key": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"contentEncoding": "base64"
},
"allowed_scopes": {
"type": "array",
"minItems": 1,
"maxItems": 3,
"uniqueItems": true,
"items": {
"enum": [
"target_environment",
"external_providers",
"production_approval"
]
}
},
"not_before": {
"type": "string",
"format": "date-time"
},
"not_after": {
"type": "string",
"format": "date-time"
}
}
}
}
}

View File

@@ -0,0 +1,291 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/installed-composition-evidence.schema.json",
"title": "GovOPlaN installed composition evidence",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"evidence_kind",
"assessment_id",
"assessment_release",
"collected_at",
"scope",
"artifacts",
"collection_issues"
],
"properties": {
"$schema": {
"type": "string",
"format": "uri-reference"
},
"schema_version": {
"const": "0.3.0"
},
"evidence_kind": {
"const": "govoplan.installed-composition"
},
"assessment_id": {
"$ref": "#/$defs/opaque_id"
},
"assessment_release": {
"$ref": "#/$defs/opaque_id"
},
"collected_at": {
"type": "string",
"format": "date-time"
},
"scope": {
"const": "current-python-environment.govoplan-distributions"
},
"artifacts": {
"type": "array",
"maxItems": 256,
"items": {
"$ref": "#/$defs/artifact"
}
},
"collection_issues": {
"type": "array",
"maxItems": 256,
"items": {
"$ref": "#/$defs/collection_issue"
}
}
},
"$defs": {
"opaque_id": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"package_name": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._+!-]*$"
},
"artifact": {
"type": "object",
"additionalProperties": false,
"required": [
"package_name",
"package_version",
"modules",
"source_provenance",
"record_integrity"
],
"properties": {
"package_name": {
"$ref": "#/$defs/package_name"
},
"package_version": {
"$ref": "#/$defs/version"
},
"modules": {
"type": "array",
"maxItems": 16,
"items": {
"$ref": "#/$defs/module"
}
},
"source_provenance": {
"$ref": "#/$defs/source_provenance"
},
"record_integrity": {
"$ref": "#/$defs/record_integrity"
}
}
},
"module": {
"type": "object",
"additionalProperties": false,
"required": ["module_id", "manifest_version"],
"properties": {
"module_id": {
"$ref": "#/$defs/opaque_id"
},
"manifest_version": {
"$ref": "#/$defs/version"
}
}
},
"source_provenance": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": ["basis", "kind", "commit"],
"properties": {
"basis": {
"const": "local-pep610-metadata"
},
"kind": {
"const": "vcs-commit"
},
"commit": {
"type": "string",
"pattern": "^[0-9a-f]{40,64}$"
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["basis", "kind", "sha256"],
"properties": {
"basis": {
"const": "local-pep610-metadata"
},
"kind": {
"const": "archive"
},
"sha256": {
"$ref": "#/$defs/sha256"
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["basis", "kind"],
"properties": {
"basis": {
"const": "local-pep610-metadata"
},
"kind": {
"enum": [
"editable-local",
"local-directory",
"index-or-unknown",
"malformed-direct-url"
]
}
}
}
]
},
"record_integrity": {
"type": "object",
"additionalProperties": false,
"required": [
"status",
"hashed_file_count",
"permitted_unhashed_file_count",
"generated_unhashed_file_count",
"unverifiable_file_count",
"missing_file_count",
"mismatched_file_count"
],
"properties": {
"status": {
"enum": [
"verified",
"partial",
"mismatch",
"unavailable",
"limit-exceeded"
]
},
"hashed_file_count": {
"$ref": "#/$defs/count"
},
"permitted_unhashed_file_count": {
"$ref": "#/$defs/count"
},
"generated_unhashed_file_count": {
"$ref": "#/$defs/count"
},
"unverifiable_file_count": {
"$ref": "#/$defs/count"
},
"missing_file_count": {
"$ref": "#/$defs/count"
},
"mismatched_file_count": {
"$ref": "#/$defs/count"
}
},
"allOf": [
{
"if": { "properties": { "status": { "const": "verified" } } },
"then": {
"properties": {
"hashed_file_count": { "minimum": 1 },
"permitted_unhashed_file_count": { "minimum": 1 },
"unverifiable_file_count": { "const": 0 },
"missing_file_count": { "const": 0 },
"mismatched_file_count": { "const": 0 }
}
}
},
{
"if": { "properties": { "status": { "const": "partial" } } },
"then": {
"properties": {
"hashed_file_count": { "minimum": 1 },
"unverifiable_file_count": { "minimum": 1 },
"missing_file_count": { "const": 0 },
"mismatched_file_count": { "const": 0 }
}
}
},
{
"if": { "properties": { "status": { "const": "mismatch" } } },
"then": {
"anyOf": [
{ "properties": { "missing_file_count": { "minimum": 1 } } },
{ "properties": { "mismatched_file_count": { "minimum": 1 } } }
]
}
},
{
"if": { "properties": { "status": { "const": "unavailable" } } },
"then": {
"properties": {
"hashed_file_count": { "const": 0 },
"missing_file_count": { "const": 0 },
"mismatched_file_count": { "const": 0 }
}
}
}
]
},
"collection_issue": {
"type": "object",
"additionalProperties": false,
"required": ["code", "package_name"],
"properties": {
"code": {
"enum": [
"distribution-metadata-invalid",
"duplicate-distribution",
"module-entry-point-load-failed",
"module-entry-point-invalid",
"module-entry-point-limit-exceeded",
"collection-limit-exceeded"
]
},
"package_name": {
"$ref": "#/$defs/package_name"
}
}
},
"count": {
"type": "integer",
"minimum": 0,
"maximum": 1000000
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
}
}
}

View File

@@ -0,0 +1,190 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import stat
import sys
import tempfile
import unittest
from unittest import mock
META_ROOT = Path(__file__).resolve().parents[1]
ASSESSMENT_TOOLS_ROOT = META_ROOT / "tools" / "assessments"
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
for tools_root in (ASSESSMENT_TOOLS_ROOT, RELEASE_TOOLS_ROOT):
if str(tools_root) not in sys.path:
sys.path.insert(0, str(tools_root))
from govoplan_assessment.atomic_io import ( # noqa: E402
AtomicJsonWriteError,
atomic_write_json,
)
class CapabilityFitAtomicIOTests(unittest.TestCase):
def test_writes_private_json_with_expected_content(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
target = Path(temporary_directory) / "evidence.json"
payload = {"z": [1, 2], "message": "Grüße"}
created_in: list[Path] = []
original_mkstemp = tempfile.mkstemp
def observed_mkstemp(*args, **kwargs):
created_in.append(Path(kwargs["dir"]))
return original_mkstemp(*args, **kwargs)
with (
mock.patch(
"govoplan_assessment.atomic_io.tempfile.mkstemp",
side_effect=observed_mkstemp,
),
mock.patch(
"govoplan_assessment.atomic_io.os.fsync",
wraps=os.fsync,
) as fsync,
):
atomic_write_json(target, payload, max_bytes=1024)
self.assertEqual(payload, json.loads(target.read_text(encoding="utf-8")))
self.assertTrue(target.read_bytes().endswith(b"\n"))
self.assertEqual(0o600, stat.S_IMODE(target.stat().st_mode))
self.assertEqual([target.parent], created_in)
self.assertEqual(2, fsync.call_count)
def test_replaces_existing_regular_file_and_secures_mode(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
target = Path(temporary_directory) / "report.json"
target.write_text('{"old": true}\n', encoding="utf-8")
target.chmod(0o644)
old_handle = target.open("rb")
self.addCleanup(old_handle.close)
atomic_write_json(target, {"new": True}, max_bytes=1024)
self.assertEqual(b'{"old": true}\n', old_handle.read())
self.assertEqual({"new": True}, json.loads(target.read_text("utf-8")))
self.assertEqual(0o600, stat.S_IMODE(target.stat().st_mode))
def test_size_bound_leaves_existing_target_unchanged(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
target = directory / "evidence.json"
original = b'{"original": true}\n'
target.write_bytes(original)
with self.assertRaisesRegex(AtomicJsonWriteError, "size limit"):
atomic_write_json(target, {"large": "x" * 100}, max_bytes=32)
self.assertEqual(original, target.read_bytes())
self.assertEqual([], list(directory.glob(".govoplan-json-*.tmp")))
@unittest.skipUnless(hasattr(os, "symlink"), "symbolic links are unavailable")
def test_rejects_symlink_without_modifying_link_or_referent(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
referent = directory / "outside.json"
referent.write_bytes(b'{"keep": true}\n')
target = directory / "evidence.json"
target.symlink_to(referent.name)
with self.assertRaisesRegex(AtomicJsonWriteError, "symbolic link"):
atomic_write_json(target, {"replace": True}, max_bytes=1024)
self.assertTrue(target.is_symlink())
self.assertEqual(b'{"keep": true}\n', referent.read_bytes())
self.assertEqual([], list(directory.glob(".govoplan-json-*.tmp")))
@unittest.skipUnless(hasattr(os, "symlink"), "symbolic links are unavailable")
def test_rejects_symlinked_parent_component(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
real_parent = directory / "real-parent"
real_parent.mkdir()
linked_parent = directory / "linked-parent"
linked_parent.symlink_to(real_parent, target_is_directory=True)
target = linked_parent / "new" / "evidence.json"
with self.assertRaisesRegex(AtomicJsonWriteError, "parent path"):
atomic_write_json(target, {"unsafe": True}, max_bytes=1024)
self.assertFalse((real_parent / "new").exists())
def test_requires_existing_parent_without_creating_directories(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
missing_parent = directory / "missing" / "nested"
target = missing_parent / "evidence.json"
with self.assertRaisesRegex(AtomicJsonWriteError, "already exist"):
atomic_write_json(target, {"safe": True}, max_bytes=1024)
self.assertFalse(missing_parent.exists())
def test_rejects_post_replace_mode_or_identity_change(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
target = directory / "evidence.json"
original_replace = os.replace
def insecure_replace(source, destination):
original_replace(source, destination)
Path(destination).chmod(0o644)
with mock.patch(
"govoplan_assessment.atomic_io.os.replace",
side_effect=insecure_replace,
):
with self.assertRaisesRegex(
AtomicJsonWriteError,
"permissions changed and were restored",
):
atomic_write_json(target, {"safe": True}, max_bytes=1024)
self.assertEqual(0o600, stat.S_IMODE(target.stat().st_mode))
self.assertEqual({"safe": True}, json.loads(target.read_text("utf-8")))
def test_does_not_unlink_unknown_post_replace_inode(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
target = directory / "evidence.json"
original_replace = os.replace
def replaced_again(source, destination):
original_replace(source, destination)
Path(destination).unlink()
Path(destination).write_bytes(b"unknown replacement\n")
with mock.patch(
"govoplan_assessment.atomic_io.os.replace",
side_effect=replaced_again,
):
with self.assertRaisesRegex(
AtomicJsonWriteError,
"did not preserve",
):
atomic_write_json(target, {"secret": True}, max_bytes=1024)
self.assertEqual(b"unknown replacement\n", target.read_bytes())
def test_replace_failure_removes_private_temporary_file(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
target = directory / "evidence.json"
with mock.patch(
"govoplan_assessment.atomic_io.os.replace",
side_effect=OSError("simulated replacement failure"),
):
with self.assertRaisesRegex(
AtomicJsonWriteError, "could not be written atomically"
):
atomic_write_json(target, {"safe": True}, max_bytes=1024)
self.assertFalse(target.exists())
self.assertEqual([], list(directory.glob(".govoplan-json-*.tmp")))
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

View File

@@ -360,7 +360,8 @@ class CapabilityFitReviewTests(unittest.TestCase):
self.assertIn("Capability fit rerun: current", rendered) self.assertIn("Capability fit rerun: current", rendered)
self.assertIn( self.assertIn(
"No installed-artifact, target-provider, or production proof", rendered "No installed-composition, installed-release-origin, target-environment, external-provider, production-approval proof",
rendered,
) )
def test_public_fetch_failure_is_generic_and_blocking(self) -> None: def test_public_fetch_failure_is_generic_and_blocking(self) -> None:

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import argparse
import json import json
import os import os
from pathlib import Path from pathlib import Path
import re
import sys import sys
@@ -17,11 +18,24 @@ for tools_root in (ASSESSMENT_TOOLS_ROOT, RELEASE_TOOLS_ROOT):
if str(tools_root) not in sys.path: if str(tools_root) not in sys.path:
sys.path.insert(0, str(tools_root)) sys.path.insert(0, str(tools_root))
from govoplan_assessment import render_review, review_capability_fit # noqa: E402 from govoplan_assessment import ( # noqa: E402
collect_installed_composition,
render_review,
review_capability_fit,
)
from govoplan_assessment.atomic_io import ( # noqa: E402
AtomicJsonWriteError,
atomic_write_json,
)
from govoplan_assessment.evidence import validate_payload # noqa: E402
from govoplan_release.catalog import DEFAULT_PUBLIC_BASE_URL, fetch_json # noqa: E402 from govoplan_release.catalog import DEFAULT_PUBLIC_BASE_URL, fetch_json # noqa: E402
TRUSTED_KEYRING_FILE_ENV = "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE" TRUSTED_KEYRING_FILE_ENV = "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE"
MAX_ASSESSMENT_INPUT_BYTES = 16 * 1024 * 1024
MAX_EVIDENCE_INPUT_BYTES = 4 * 1024 * 1024
MAX_REPORT_OUTPUT_BYTES = 16 * 1024 * 1024
OPAQUE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace: def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
@@ -71,6 +85,65 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
action="store_true", action="store_true",
help="Compare signed metadata without checking local annotated tags.", help="Compare signed metadata without checking local annotated tags.",
) )
installed = parser.add_mutually_exclusive_group()
installed.add_argument(
"--installed-evidence",
type=Path,
help="Validate an existing bounded installed-composition evidence document.",
)
installed.add_argument(
"--collect-installed-evidence",
type=Path,
help=(
"Collect the current interpreter's GovOPlaN distributions, write the "
"bounded evidence document, and include it in this review. Loading "
"module entry points executes installed GovOPlaN manifest factories."
),
)
parser.add_argument(
"--installed-evidence-schema",
type=Path,
default=META_ROOT / "docs" / "installed-composition-evidence.schema.json",
)
parser.add_argument(
"--boundary-evidence",
type=Path,
help=(
"Externally issued target/provider/production proof bound to the "
"installed-evidence digest."
),
)
parser.add_argument(
"--boundary-evidence-schema",
type=Path,
default=META_ROOT / "docs" / "capability-fit-boundary-evidence.schema.json",
)
parser.add_argument(
"--boundary-authority-keyring",
type=Path,
help=(
"Independently provisioned keyring authorizing proof signers for "
"specific target/provider/production scopes."
),
)
parser.add_argument(
"--boundary-authority-keyring-schema",
type=Path,
default=META_ROOT
/ "docs"
/ "capability-fit-proof-authority-keyring.schema.json",
)
parser.add_argument(
"--verification-time",
help="Optional ISO-8601 time for reproducible boundary-proof verification.",
)
parser.add_argument(
"--expected-external-provider-subject",
help=(
"Opaque provider-environment ID that external-provider proof must match; "
"never supply a URL, credential, or endpoint identifier."
),
)
parser.add_argument( parser.add_argument(
"--json", action="store_true", help="Print the machine-readable review report." "--json", action="store_true", help="Print the machine-readable review report."
) )
@@ -88,6 +161,29 @@ def main(argv: list[str] | None = None) -> int:
raise SystemExit("--keyring is required with --catalog") raise SystemExit("--keyring is required with --catalog")
if args.public and args.keyring is not None: if args.public and args.keyring is not None:
raise SystemExit("--keyring cannot be combined with --public") raise SystemExit("--keyring cannot be combined with --public")
if (args.boundary_evidence is None) != (args.boundary_authority_keyring is None):
raise SystemExit(
"--boundary-evidence and --boundary-authority-keyring are required together"
)
if args.boundary_evidence is not None and (
args.installed_evidence is None and args.collect_installed_evidence is None
):
raise SystemExit(
"--boundary-evidence requires --installed-evidence or --collect-installed-evidence"
)
if (
args.expected_external_provider_subject is not None
and OPAQUE_ID_PATTERN.fullmatch(args.expected_external_provider_subject) is None
):
raise SystemExit(
"--expected-external-provider-subject must be a bounded opaque ID"
)
if (
args.collect_installed_evidence is not None
and args.output is not None
and args.collect_installed_evidence.resolve() == args.output.resolve()
):
raise SystemExit("Evidence and review output paths must differ")
configured_trusted_keyring = os.getenv(TRUSTED_KEYRING_FILE_ENV, "").strip() configured_trusted_keyring = os.getenv(TRUSTED_KEYRING_FILE_ENV, "").strip()
trusted_keyring_path = args.trusted_keyring or ( trusted_keyring_path = args.trusted_keyring or (
Path(configured_trusted_keyring) if configured_trusted_keyring else None Path(configured_trusted_keyring) if configured_trusted_keyring else None
@@ -96,8 +192,16 @@ def main(argv: list[str] | None = None) -> int:
raise SystemExit( raise SystemExit(
f"--trusted-keyring or ${TRUSTED_KEYRING_FILE_ENV} is required" f"--trusted-keyring or ${TRUSTED_KEYRING_FILE_ENV} is required"
) )
assessment = read_object(args.assessment, label="assessment") assessment = read_object(
schema = read_object(args.schema, label="schema") args.assessment,
label="assessment",
max_bytes=MAX_ASSESSMENT_INPUT_BYTES,
)
schema = read_object(
args.schema,
label="schema",
max_bytes=MAX_ASSESSMENT_INPUT_BYTES,
)
if args.public: if args.public:
catalog = fetch_public_json( catalog = fetch_public_json(
f"{DEFAULT_PUBLIC_BASE_URL}/catalogs/v1/channels/stable.json", f"{DEFAULT_PUBLIC_BASE_URL}/catalogs/v1/channels/stable.json",
@@ -108,13 +212,81 @@ def main(argv: list[str] | None = None) -> int:
label="published keyring", label="published keyring",
) )
else: else:
catalog = read_object(args.catalog, label="catalog") catalog = read_object(
published_keyring = read_object(args.keyring, label="published keyring") args.catalog, label="catalog", max_bytes=MAX_ASSESSMENT_INPUT_BYTES
)
published_keyring = read_object(
args.keyring,
label="published keyring",
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
)
trusted_keyring = read_object( trusted_keyring = read_object(
trusted_keyring_path, trusted_keyring_path,
label="trusted keyring", label="trusted keyring",
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
) )
installed_evidence = None
installed_evidence_schema = None
if (
args.installed_evidence is not None
or args.collect_installed_evidence is not None
):
installed_evidence_schema = read_object(
args.installed_evidence_schema,
label="installed evidence schema",
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
)
if args.installed_evidence is not None:
installed_evidence = read_object(
args.installed_evidence,
label="installed evidence",
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
)
else:
installed_evidence = collect_installed_composition(assessment=assessment)
collection_schema_errors = validate_payload(
payload=installed_evidence,
schema=installed_evidence_schema,
)
if collection_schema_errors:
raise SystemExit(
"Collected installed evidence did not satisfy its bounded schema"
)
write_object(
args.collect_installed_evidence,
installed_evidence,
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
label="installed evidence",
)
boundary_evidence = None
boundary_evidence_schema = None
boundary_authority_keyring = None
boundary_authority_keyring_schema = None
if args.boundary_evidence is not None:
boundary_evidence = read_object(
args.boundary_evidence,
label="boundary evidence",
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
)
boundary_evidence_schema = read_object(
args.boundary_evidence_schema,
label="boundary evidence schema",
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
)
boundary_authority_keyring = read_object(
args.boundary_authority_keyring,
label="boundary authority keyring",
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
)
boundary_authority_keyring_schema = read_object(
args.boundary_authority_keyring_schema,
label="boundary authority keyring schema",
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
)
verification_time = parse_datetime(args.verification_time)
report = review_capability_fit( report = review_capability_fit(
assessment=assessment, assessment=assessment,
schema=schema, schema=schema,
@@ -124,27 +296,83 @@ def main(argv: list[str] | None = None) -> int:
workspace_root=None workspace_root=None
if args.skip_tag_provenance if args.skip_tag_provenance
else args.workspace_root.resolve(), else args.workspace_root.resolve(),
installed_evidence=installed_evidence,
installed_evidence_schema=installed_evidence_schema,
boundary_evidence=boundary_evidence,
boundary_evidence_schema=boundary_evidence_schema,
boundary_authority_keyring=boundary_authority_keyring,
boundary_authority_keyring_schema=boundary_authority_keyring_schema,
verification_time=verification_time,
installed_evidence_mode=(
"direct_local"
if args.collect_installed_evidence is not None
else "imported_unsigned"
),
expected_external_provider_subject=args.expected_external_provider_subject,
) )
encoded = json.dumps(report, indent=2, sort_keys=True) + "\n" encoded = json.dumps(report, indent=2, sort_keys=True) + "\n"
if args.output is not None: if args.output is not None:
args.output.parent.mkdir(parents=True, exist_ok=True) write_object(
args.output.write_text(encoded, encoding="utf-8") args.output,
report,
max_bytes=MAX_REPORT_OUTPUT_BYTES,
label="review report",
)
sys.stdout.write(encoded if args.json else render_review(report)) sys.stdout.write(encoded if args.json else render_review(report))
return {"current": 0, "blocked": 1, "review_required": 2}[report["status"]] return {"current": 0, "blocked": 1, "review_required": 2}[report["status"]]
def read_object(path: Path | None, *, label: str) -> dict[str, object]: def read_object(path: Path | None, *, label: str, max_bytes: int) -> dict[str, object]:
if path is None: if path is None:
raise SystemExit(f"Missing {label} path") raise SystemExit(f"Missing {label} path")
try: try:
payload = json.loads(path.read_text(encoding="utf-8")) if path.stat().st_size > max_bytes:
except (OSError, json.JSONDecodeError) as exc: raise SystemExit(
f"{label.capitalize()} exceeds the {max_bytes}-byte input limit"
)
with path.open("rb") as handle:
encoded = handle.read(max_bytes + 1)
if len(encoded) > max_bytes:
raise SystemExit(
f"{label.capitalize()} exceeds the {max_bytes}-byte input limit"
)
payload = json.loads(encoded.decode("utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SystemExit(f"Could not read {label} {path}: {exc}") from exc raise SystemExit(f"Could not read {label} {path}: {exc}") from exc
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise SystemExit(f"{label.capitalize()} must be a JSON object: {path}") raise SystemExit(f"{label.capitalize()} must be a JSON object: {path}")
return payload return payload
def write_object(
path: Path | None,
payload: dict[str, object],
*,
max_bytes: int,
label: str,
) -> None:
if path is None:
raise SystemExit(f"Missing {label} output path")
try:
atomic_write_json(path, payload, max_bytes=max_bytes)
except AtomicJsonWriteError as exc:
raise SystemExit(f"Could not securely write {label}") from exc
def parse_datetime(value: str | None):
if value is None:
return None
from datetime import datetime
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise SystemExit("--verification-time must be an ISO-8601 timestamp") from exc
if parsed.tzinfo is None:
raise SystemExit("--verification-time must include a timezone")
return parsed
def fetch_public_json(url: str, *, label: str) -> dict[str, object]: def fetch_public_json(url: str, *, label: str) -> dict[str, object]:
result = fetch_json(url) result = fetch_json(url)
if result.get("ok") is not True or not isinstance(result.get("payload"), dict): if result.get("ok") is not True or not isinstance(result.get("payload"), dict):

View File

@@ -1,5 +1,10 @@
"""Repeatable GovOPlaN product-assessment tooling.""" """Repeatable GovOPlaN product-assessment tooling."""
from .capability_fit import review_capability_fit, render_review from .capability_fit import review_capability_fit, render_review
from .evidence import collect_installed_composition
__all__ = ("render_review", "review_capability_fit") __all__ = (
"collect_installed_composition",
"render_review",
"review_capability_fit",
)

View File

@@ -0,0 +1,230 @@
"""Bounded, private, atomic JSON output for assessment evidence."""
from __future__ import annotations
import json
import os
from pathlib import Path
import stat
import tempfile
from typing import Any
class AtomicJsonWriteError(RuntimeError):
"""Raised when JSON output cannot be written with the required guarantees."""
def atomic_write_json(
path: str | os.PathLike[str],
payload: Any,
*,
max_bytes: int,
) -> None:
"""Write bounded JSON through a private same-directory temporary file.
A successful return means the file contents and the containing directory
entry have both been flushed. If flushing the directory fails after the
atomic replacement, the replacement may be visible but its crash
durability is unknown, so the function reports failure. Every parent must
already exist as a real directory; symbolic-link components are rejected.
"""
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
raise ValueError("max_bytes must be a positive integer")
encoded = _encode_bounded_json(payload, max_bytes=max_bytes)
target = Path(os.path.abspath(os.fspath(path)))
parent = target.parent
descriptor = -1
verification_descriptor = -1
temporary_path: Path | None = None
temporary_identity: tuple[int, int] | None = None
try:
_assert_real_parent(parent)
_assert_replaceable_target(target)
try:
descriptor, temporary_name = tempfile.mkstemp(
dir=parent,
prefix=".govoplan-json-",
suffix=".tmp",
)
temporary_path = Path(temporary_name)
os.fchmod(descriptor, 0o600)
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or stat.S_IMODE(metadata.st_mode) != 0o600
):
raise AtomicJsonWriteError(
"Atomic JSON temporary file could not be secured."
)
temporary_identity = (metadata.st_dev, metadata.st_ino)
verification_descriptor = os.dup(descriptor)
handle = os.fdopen(descriptor, "wb")
descriptor = -1
with handle:
handle.write(encoded)
handle.flush()
os.fsync(handle.fileno())
# Recheck after creating and flushing the temporary file so an
# unsafe target discovered before replacement is never followed.
_assert_real_parent(parent)
_assert_replaceable_target(target)
os.replace(temporary_path, target)
temporary_path = None
_assert_installed_target(
target,
expected_identity=temporary_identity,
recovery_descriptor=verification_descriptor,
)
_fsync_directory(parent)
finally:
try:
if descriptor >= 0:
os.close(descriptor)
finally:
try:
if verification_descriptor >= 0:
os.close(verification_descriptor)
finally:
if temporary_path is not None:
try:
temporary_path.unlink()
except FileNotFoundError:
pass
except AtomicJsonWriteError:
raise
except OSError as exc:
raise AtomicJsonWriteError(
"JSON output could not be written atomically."
) from exc
def _encode_bounded_json(payload: Any, *, max_bytes: int) -> bytes:
encoder = json.JSONEncoder(
allow_nan=False,
ensure_ascii=False,
indent=2,
sort_keys=True,
)
chunks: list[bytes] = []
encoded_size = 1 # The document always ends with one newline.
for chunk in encoder.iterencode(payload):
encoded_chunk = chunk.encode("utf-8")
encoded_size += len(encoded_chunk)
if encoded_size > max_bytes:
raise AtomicJsonWriteError("JSON output exceeds its size limit.")
chunks.append(encoded_chunk)
return b"".join(chunks) + b"\n"
def _assert_replaceable_target(path: Path) -> None:
try:
metadata = path.lstat()
except FileNotFoundError:
return
except OSError as exc:
raise AtomicJsonWriteError(
"JSON output target could not be inspected safely."
) from exc
if stat.S_ISLNK(metadata.st_mode):
raise AtomicJsonWriteError("JSON output target must not be a symbolic link.")
if not stat.S_ISREG(metadata.st_mode):
raise AtomicJsonWriteError("JSON output target must be a regular file.")
def _assert_real_parent(path: Path) -> None:
current = Path(path.anchor)
for part in path.parts[1:]:
current /= part
try:
metadata = current.lstat()
except FileNotFoundError as exc:
raise AtomicJsonWriteError(
"JSON output parent directory must already exist."
) from exc
except OSError as exc:
raise AtomicJsonWriteError(
"JSON output parent directory could not be inspected safely."
) from exc
if stat.S_ISLNK(metadata.st_mode):
raise AtomicJsonWriteError(
"JSON output parent path must not contain symbolic links."
)
if not stat.S_ISDIR(metadata.st_mode):
raise AtomicJsonWriteError(
"JSON output parent path must contain only directories."
)
def _assert_installed_target(
path: Path,
*,
expected_identity: tuple[int, int] | None,
recovery_descriptor: int,
) -> None:
try:
metadata = path.lstat()
except OSError as exc:
raise AtomicJsonWriteError(
"Atomic JSON replacement could not be verified."
) from exc
observed_identity = (metadata.st_dev, metadata.st_ino)
if (
expected_identity is None
or observed_identity != expected_identity
or not stat.S_ISREG(metadata.st_mode)
):
raise AtomicJsonWriteError(
"Atomic JSON replacement did not preserve the secured regular file."
)
if stat.S_IMODE(metadata.st_mode) == 0o600:
return
try:
recovery_metadata = os.fstat(recovery_descriptor)
if (
not stat.S_ISREG(recovery_metadata.st_mode)
or (recovery_metadata.st_dev, recovery_metadata.st_ino) != expected_identity
):
raise AtomicJsonWriteError(
"Atomic JSON replacement recovery descriptor is invalid."
)
os.fchmod(recovery_descriptor, 0o600)
os.fsync(recovery_descriptor)
recovered_metadata = os.fstat(recovery_descriptor)
installed_metadata = path.lstat()
except OSError as exc:
raise AtomicJsonWriteError(
"Atomic JSON replacement permissions could not be restored."
) from exc
if (
stat.S_IMODE(recovered_metadata.st_mode) != 0o600
or (installed_metadata.st_dev, installed_metadata.st_ino) != expected_identity
or not stat.S_ISREG(installed_metadata.st_mode)
or stat.S_IMODE(installed_metadata.st_mode) != 0o600
):
raise AtomicJsonWriteError(
"Atomic JSON replacement permissions could not be restored."
)
_fsync_directory(path.parent)
raise AtomicJsonWriteError(
"Atomic JSON replacement permissions changed and were restored."
)
def _fsync_directory(path: Path) -> None:
flags = os.O_RDONLY
if hasattr(os, "O_DIRECTORY"):
flags |= os.O_DIRECTORY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags)
try:
if not stat.S_ISDIR(os.fstat(descriptor).st_mode):
raise AtomicJsonWriteError("JSON output parent path must be a directory.")
os.fsync(descriptor)
finally:
os.close(descriptor)

View File

@@ -25,6 +25,14 @@ import govoplan_core.core.module_package_catalog as module_package_catalog
from govoplan_release.source_provenance import catalog_source_selection from govoplan_release.source_provenance import catalog_source_selection
from govoplan_release.version_alignment import candidate_catalog_version_issues from govoplan_release.version_alignment import candidate_catalog_version_issues
from .evidence import (
BoundaryEvidenceReview,
InstalledEvidenceReview,
public_key_material_from_keyrings,
review_boundary_evidence,
review_installed_composition,
)
RELEASE_REF_PATTERN = re.compile( RELEASE_REF_PATTERN = re.compile(
r"^(?P<channel>[a-z][a-z0-9_-]*)-catalog-(?P<sequence>[0-9]+)$" r"^(?P<channel>[a-z][a-z0-9_-]*)-catalog-(?P<sequence>[0-9]+)$"
@@ -49,6 +57,15 @@ def review_capability_fit(
published_keyring: dict[str, Any], published_keyring: dict[str, Any],
trusted_keyring: dict[str, Any], trusted_keyring: dict[str, Any],
workspace_root: Path | None = None, workspace_root: Path | None = None,
installed_evidence: dict[str, Any] | None = None,
installed_evidence_schema: dict[str, Any] | None = None,
boundary_evidence: dict[str, Any] | None = None,
boundary_evidence_schema: dict[str, Any] | None = None,
boundary_authority_keyring: dict[str, Any] | None = None,
boundary_authority_keyring_schema: dict[str, Any] | None = None,
verification_time: datetime | None = None,
installed_evidence_mode: str = "imported_unsigned",
expected_external_provider_subject: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return a secret-free release-drift report for one fit assessment.""" """Return a secret-free release-drift report for one fit assessment."""
@@ -68,6 +85,8 @@ def review_capability_fit(
local_tag_provenance_attempted=0, local_tag_provenance_attempted=0,
local_tag_provenance_expected=0, local_tag_provenance_expected=0,
local_tag_catalog_scope_complete=False, local_tag_catalog_scope_complete=False,
installed_review=None,
boundary_review=None,
) )
catalog_validation = validate_catalog( catalog_validation = validate_catalog(
@@ -340,11 +359,65 @@ def review_capability_fit(
) )
) )
evidence_verification_time = verification_time or datetime.now(tz=UTC)
evidence_verification_mode = (
"historical_override" if verification_time is not None else "current"
)
catalog_dependency_trusted = not any(
item.severity == "blocker" for item in findings
)
installed_review = review_installed_composition(
assessment=assessment,
catalog_entries=catalog_entries,
selected_versions=source_selection.selected_versions,
selected_commits=source_selection.selected_commits,
evidence=installed_evidence,
schema=installed_evidence_schema,
observation_mode=installed_evidence_mode,
verification_time=evidence_verification_time,
verification_mode=evidence_verification_mode,
catalog_trusted=catalog_dependency_trusted,
)
findings.extend(
Finding(item.severity, item.code, item.message, item.assessment_ids)
for item in installed_review.findings
)
changes.extend(installed_review.changes)
changed_repositories.update(installed_review.changed_repositories)
boundary_review = review_boundary_evidence(
assessment=assessment,
installed_review=installed_review,
evidence=boundary_evidence,
evidence_schema=boundary_evidence_schema,
authority_keyring=boundary_authority_keyring,
authority_keyring_schema=boundary_authority_keyring_schema,
verification_time=evidence_verification_time,
verification_mode=evidence_verification_mode,
expected_external_provider_subject=expected_external_provider_subject,
forbidden_authority_public_keys=public_key_material_from_keyrings(
published_keyring,
trusted_keyring,
),
)
findings.extend(
Finding(item.severity, item.code, item.message, item.assessment_ids)
for item in boundary_review.findings
)
review_targets = conclusions_needing_review( review_targets = conclusions_needing_review(
assessment=assessment, assessment=assessment,
changed_repositories=changed_repositories, changed_repositories=changed_repositories,
release_changed=assessed_sequence != catalog_sequence, release_changed=assessed_sequence != catalog_sequence,
) )
review_targets = merge_review_targets(
review_targets,
installed_evidence_review_targets(
assessment=assessment,
installed_review=installed_review,
),
boundary_review.review_targets,
)
return build_report( return build_report(
assessment=assessment, assessment=assessment,
catalog=catalog, catalog=catalog,
@@ -355,6 +428,8 @@ def review_capability_fit(
local_tag_provenance_attempted=local_tag_provenance_attempted, local_tag_provenance_attempted=local_tag_provenance_attempted,
local_tag_provenance_expected=local_tag_provenance_expected, local_tag_provenance_expected=local_tag_provenance_expected,
local_tag_catalog_scope_complete=local_tag_catalog_scope_complete, local_tag_catalog_scope_complete=local_tag_catalog_scope_complete,
installed_review=installed_review,
boundary_review=boundary_review,
) )
@@ -512,6 +587,7 @@ def catalog_entries_by_id(
entries[module_id] = { entries[module_id] = {
"module_id": module_id, "module_id": module_id,
"repository": catalog_repository(item), "repository": catalog_repository(item),
"package": catalog_package(item),
"version": _text(item.get("version")), "version": _text(item.get("version")),
"tags": tuple( "tags": tuple(
tag for tag in (catalog_ref_tag(value) for value in refs) if tag tag for tag in (catalog_ref_tag(value) for value in refs) if tag
@@ -534,6 +610,11 @@ def catalog_repository(entry: dict[str, Any]) -> str | None:
return None return None
def catalog_package(entry: dict[str, Any]) -> str | None:
package = _text(entry.get("python_package"))
return package.split("[", 1)[0].strip() if package else None
def catalog_ref_tag(value: str) -> str | None: def catalog_ref_tag(value: str) -> str | None:
match = TAG_PATTERN.search(value) match = TAG_PATTERN.search(value)
return match.group("tag") if match else None return match.group("tag") if match else None
@@ -709,6 +790,51 @@ def conclusions_needing_review(
return [targets[key] for key in sorted(targets)] return [targets[key] for key in sorted(targets)]
def installed_evidence_review_targets(
*,
assessment: dict[str, Any],
installed_review: InstalledEvidenceReview,
) -> list[dict[str, Any]]:
if not installed_review.findings:
return []
targets: dict[str, dict[str, Any]] = {
"assessment.installed_composition": {
"id": "assessment.installed_composition",
"section": "installed_composition",
"reason": "Installed-composition evidence is incomplete, mutable, extra, missing, or inconsistent with the assessed release.",
}
}
components = {
str(item.get("module_id")): item
for item in assessment.get("composition", [])
if isinstance(item, dict)
}
for module_id in sorted(installed_review.affected_module_ids):
component = components.get(module_id)
target_id = f"composition.{module_id}"
targets[target_id] = {
"id": target_id,
"section": "composition",
"repositories": [component.get("repository")]
if component is not None
else [],
"reason": "The observed installed distribution, manifest, RECORD integrity, or immutable provenance differs from the assessed composition.",
}
return [targets[key] for key in sorted(targets)]
def merge_review_targets(
*groups: Iterable[dict[str, Any]],
) -> list[dict[str, Any]]:
merged: dict[str, dict[str, Any]] = {}
for group in groups:
for item in group:
item_id = str(item.get("id") or "")
if item_id and item_id not in merged:
merged[item_id] = item
return [merged[key] for key in sorted(merged)]
def build_report( def build_report(
*, *,
assessment: dict[str, Any], assessment: dict[str, Any],
@@ -720,6 +846,8 @@ def build_report(
local_tag_provenance_attempted: int, local_tag_provenance_attempted: int,
local_tag_provenance_expected: int, local_tag_provenance_expected: int,
local_tag_catalog_scope_complete: bool, local_tag_catalog_scope_complete: bool,
installed_review: InstalledEvidenceReview | None,
boundary_review: BoundaryEvidenceReview | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
finding_list = sorted( finding_list = sorted(
findings, findings,
@@ -736,7 +864,9 @@ def build_report(
) )
catalog_valid = trusted_signature_valid and published_keyring_valid catalog_valid = trusted_signature_valid and published_keyring_valid
release_valid = catalog_valid and not any( release_valid = catalog_valid and not any(
item.severity in {"blocker", "review"} for item in finding_list item.severity in {"blocker", "review"}
and not item.code.startswith(("installed_", "boundary_"))
for item in finding_list
) )
tag_provenance_checked = ( tag_provenance_checked = (
local_tag_provenance_expected > 0 local_tag_provenance_expected > 0
@@ -752,8 +882,64 @@ def build_report(
status = "review_required" status = "review_required"
else: else:
status = "current" status = "current"
installed_scope = (
installed_review.proof_scope
if installed_review is not None
else {
"installed_artifacts": {"checked": False, "valid": None},
"installed_record_integrity": {"checked": False, "valid": None},
"installed_source_provenance": {
"checked": False,
"valid": None,
"basis": "local-pep610-metadata",
"release_origin_bound": False,
},
"installed_release_origin": {
"checked": False,
"valid": None,
"release_origin_bound": False,
},
"runtime_activation": {"checked": False, "valid": None},
}
)
boundary_scope = (
boundary_review.proof_scope
if boundary_review is not None
else {
"target_environment": {"checked": False, "valid": None},
"external_providers": {"checked": False, "valid": None},
"production_approval": {"checked": False, "valid": None},
}
)
proof_scope = {
"assessment_schema": {"checked": True, "valid": schema_valid},
"catalog_signature_and_keyring": {
"checked": catalog_checked,
"valid": catalog_valid if catalog_checked else None,
},
"catalog_signature_and_trusted_keyring": {
"checked": catalog_checked,
"valid": trusted_signature_valid if catalog_checked else None,
},
"published_keyring_hash": {
"checked": catalog_checked,
"valid": published_keyring_valid if catalog_checked else None,
},
"release_metadata": {
"checked": catalog_checked,
"valid": release_valid if catalog_checked else None,
},
"local_tag_provenance": {
"checked": tag_provenance_checked,
"valid": tag_provenance_valid if tag_provenance_checked else None,
"attempted_count": local_tag_provenance_attempted,
"expected_count": local_tag_provenance_expected,
},
**installed_scope,
**boundary_scope,
}
return { return {
"report_version": "0.1.0", "report_version": "0.4.0",
"status": status, "status": status,
"assessment_id": assessment.get("assessment_id"), "assessment_id": assessment.get("assessment_id"),
"assessment_release": assessment.get("release", {}).get("ref") "assessment_release": assessment.get("release", {}).get("ref")
@@ -764,35 +950,7 @@ def build_report(
"sequence": catalog.get("sequence"), "sequence": catalog.get("sequence"),
"generated_at": catalog.get("generated_at"), "generated_at": catalog.get("generated_at"),
}, },
"proof_scope": { "proof_scope": proof_scope,
"assessment_schema": {"checked": True, "valid": schema_valid},
"catalog_signature_and_keyring": {
"checked": catalog_checked,
"valid": catalog_valid if catalog_checked else None,
},
"catalog_signature_and_trusted_keyring": {
"checked": catalog_checked,
"valid": trusted_signature_valid if catalog_checked else None,
},
"published_keyring_hash": {
"checked": catalog_checked,
"valid": published_keyring_valid if catalog_checked else None,
},
"release_metadata": {
"checked": catalog_checked,
"valid": release_valid if catalog_checked else None,
},
"local_tag_provenance": {
"checked": tag_provenance_checked,
"valid": tag_provenance_valid if tag_provenance_checked else None,
"attempted_count": local_tag_provenance_attempted,
"expected_count": local_tag_provenance_expected,
},
"installed_artifacts": {"checked": False, "valid": None},
"target_environment": {"checked": False, "valid": None},
"external_providers": {"checked": False, "valid": None},
"production_approval": {"checked": False, "valid": None},
},
"findings": [asdict(item) for item in finding_list], "findings": [asdict(item) for item in finding_list],
"changes": sorted( "changes": sorted(
changes, changes,
@@ -803,12 +961,45 @@ def build_report(
def render_review(report: dict[str, Any]) -> str: def render_review(report: dict[str, Any]) -> str:
proof_scope = report.get("proof_scope", {})
installed_scope = proof_scope.get("installed_artifacts", {})
installed_checked = installed_scope.get("checked") is True
release_origin_checked = (
proof_scope.get("installed_release_origin", {}).get("checked") is True
)
installed_supplied = bool(installed_scope.get("evidence_sha256")) or (
"installed_evidence_observation" in proof_scope
)
lines = [ lines = [
f"Capability fit rerun: {report['status']}", f"Capability fit rerun: {report['status']}",
f"Assessment: {report.get('assessment_id') or '-'} ({report.get('assessment_release') or '-'})", f"Assessment: {report.get('assessment_id') or '-'} ({report.get('assessment_release') or '-'})",
f"Catalog: {report['catalog'].get('channel') or '-'} sequence {report['catalog'].get('sequence') or '-'}", f"Catalog: {report['catalog'].get('channel') or '-'} sequence {report['catalog'].get('sequence') or '-'}",
"Scope: schema, signed release metadata, and optional local tag provenance only.", (
"Scope: schema, signed release metadata, optional local tag provenance, "
+ (
"and locally observed installed-composition consistency evidence; release origin remains a separate proof boundary."
if installed_checked
else (
"and supplied installed-composition evidence that did not establish local proof."
if installed_supplied
else "and no installed-composition evidence."
)
)
),
] ]
verification = proof_scope.get("boundary_evidence_verification") or proof_scope.get(
"installed_evidence_observation"
)
if isinstance(verification, dict) and verification.get("evaluated_at"):
mode = verification.get("verification_mode")
mode_label = (
"HISTORICAL override"
if mode == "historical_override"
else "current-time evaluation"
)
lines.append(
f"Evidence verification: {mode_label} at {verification['evaluated_at']}."
)
findings = report.get("findings") or [] findings = report.get("findings") or []
if findings: if findings:
lines.append("Findings:") lines.append("Findings:")
@@ -822,9 +1013,23 @@ def render_review(report: dict[str, Any]) -> str:
if targets: if targets:
lines.append("Conclusions requiring review:") lines.append("Conclusions requiring review:")
lines.extend(f"- {item['id']}: {item['reason']}" for item in targets) lines.extend(f"- {item['id']}: {item['reason']}" for item in targets)
lines.append( unchecked_boundaries = [
"No installed-artifact, target-provider, or production proof was performed." label
) for key, label in (
("target_environment", "target-environment"),
("external_providers", "external-provider"),
("production_approval", "production-approval"),
)
if report.get("proof_scope", {}).get(key, {}).get("checked") is not True
]
if not installed_checked:
unchecked_boundaries.insert(0, "installed-composition")
if not release_origin_checked:
unchecked_boundaries.insert(
1 if not installed_checked else 0, "installed-release-origin"
)
if unchecked_boundaries:
lines.append("No " + ", ".join(unchecked_boundaries) + " proof was performed.")
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import os
from pathlib import Path from pathlib import Path
from typing import Literal
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
@@ -21,6 +24,12 @@ from govoplan_release import (
tag_repositories, tag_repositories,
) )
from govoplan_release.model import to_jsonable from govoplan_release.model import to_jsonable
from govoplan_release.release_run import (
ReleaseRunConflict,
ReleaseRunCorrupt,
ReleaseRunNotFound,
ReleaseRunStore,
)
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT
@@ -90,10 +99,47 @@ class RepositoryTagRequest(BaseModel):
confirm: str = "" confirm: str = ""
def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | None = None) -> FastAPI: class ReleaseRunCreateRequest(BaseModel):
request_id: str = Field(min_length=8, max_length=128)
repo_versions: dict[str, str] = Field(default_factory=dict)
channel: str = Field(default="stable", min_length=1, max_length=64)
online: bool = False
remote_tags: bool = False
public_catalog: bool = True
include_migrations: bool = False
class ReleaseRunCommandRequest(BaseModel):
request_id: str = Field(min_length=8, max_length=128)
class ReleaseRunReconcileRequest(BaseModel):
request_id: str = Field(min_length=8, max_length=128)
outcome: Literal["effect_absent", "effect_succeeded", "unresolved"]
confirm: str = Field(default="", max_length=32)
def create_app(
*,
workspace_root: Path = DEFAULT_WORKSPACE_ROOT,
token: str | None = None,
run_state_root: Path | None = None,
) -> FastAPI:
app = FastAPI(title="GovOPlaN Release Console", version="0.1.0") app = FastAPI(title="GovOPlaN Release Console", version="0.1.0")
app.state.workspace_root = workspace_root app.state.workspace_root = workspace_root
app.state.token = token app.state.token = token
app.state.workspace_fingerprint = release_workspace_fingerprint(workspace_root)
release_run_root = (
workspace_release_run_root(
run_state_root, app.state.workspace_fingerprint
)
if run_state_root is not None
else default_release_run_root(workspace_root)
)
app.state.release_runs = ReleaseRunStore(
release_run_root,
expected_workspace_fingerprint=app.state.workspace_fingerprint,
)
@app.middleware("http") @app.middleware("http")
async def require_token(request: Request, call_next): # type: ignore[no-untyped-def] async def require_token(request: Request, call_next): # type: ignore[no-untyped-def]
@@ -202,6 +248,135 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No
) )
return to_jsonable(release_plan) return to_jsonable(release_plan)
@app.get("/api/release-runs")
def release_runs(limit: int = 20) -> dict[str, object]:
try:
return {"runs": app.state.release_runs.list(limit=limit)}
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.post("/api/release-runs")
def create_release_run(request: ReleaseRunCreateRequest) -> dict[str, object]:
if not request.repo_versions:
raise HTTPException(
status_code=400, detail="At least one repository version is required"
)
input_snapshot = {
"channel": request.channel,
"repo_versions": dict(request.repo_versions),
"online": request.online,
"remote_tags": request.remote_tags,
"public_catalog": request.public_catalog,
"include_migrations": request.include_migrations,
"workspace_fingerprint": app.state.workspace_fingerprint,
}
try:
existing = app.state.release_runs.find_created_run(
request_id=request.request_id,
input_snapshot=input_snapshot,
)
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if existing is not None:
return existing
snapshot = build_dashboard(
workspace_root=app.state.workspace_root,
online=request.online,
check_remote_tags=request.online or request.remote_tags,
check_public_catalog=request.public_catalog,
include_migrations=request.include_migrations,
channel=request.channel,
)
plan = build_selective_release_plan(
snapshot,
selected_repos=tuple(request.repo_versions),
repo_versions=dict(request.repo_versions),
channel=request.channel,
)
plan_payload = to_jsonable(plan)
planned_units = plan_payload.get("units")
planned_versions = (
{
unit.get("repo"): unit.get("target_version")
for unit in planned_units
if isinstance(unit, dict)
and isinstance(unit.get("repo"), str)
and isinstance(unit.get("target_version"), str)
}
if isinstance(planned_units, list)
else {}
)
if planned_versions != dict(request.repo_versions) or len(
planned_versions
) != len(planned_units or []):
raise HTTPException(
status_code=409,
detail=(
"The release plan did not resolve exactly the requested repository "
"versions; refresh the dashboard and select known release units."
),
)
try:
return app.state.release_runs.create(
request_id=request.request_id,
input_snapshot=input_snapshot,
plan_snapshot=plan_payload,
)
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.get("/api/release-runs/{run_id}")
def release_run(run_id: str) -> dict[str, object]:
try:
return app.state.release_runs.get(run_id)
except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.post("/api/release-runs/{run_id}/resume")
def resume_release_run(
run_id: str, request: ReleaseRunCommandRequest
) -> dict[str, object]:
try:
return app.state.release_runs.resume(run_id, request_id=request.request_id)
except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.post("/api/release-runs/{run_id}/steps/{step_id}/retry")
def retry_release_run_step(
run_id: str, step_id: str, request: ReleaseRunCommandRequest
) -> dict[str, object]:
try:
return app.state.release_runs.retry_step(
run_id,
step_id,
request_id=request.request_id,
)
except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.post("/api/release-runs/{run_id}/steps/{step_id}/reconcile")
def reconcile_release_run_step(
run_id: str, step_id: str, request: ReleaseRunReconcileRequest
) -> dict[str, object]:
try:
return app.state.release_runs.reconcile_step(
run_id,
step_id,
request_id=request.request_id,
outcome=request.outcome,
confirmation=request.confirm,
)
except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.get("/api/catalog-candidates") @app.get("/api/catalog-candidates")
def catalog_candidates() -> dict[str, object]: def catalog_candidates() -> dict[str, object]:
return {"candidates": list_catalog_candidates()} return {"candidates": list_catalog_candidates()}
@@ -319,6 +494,34 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No
return app return app
def default_release_run_root(workspace_root: Path) -> Path:
configured_state_home = os.environ.get("XDG_STATE_HOME", "").strip()
state_home = Path(configured_state_home).expanduser()
if not configured_state_home or not state_home.is_absolute():
state_home = Path.home() / ".local" / "state"
fingerprint = release_workspace_fingerprint(workspace_root)
return (
state_home
/ "govoplan"
/ "release-console"
/ f"workspace-{fingerprint}"
/ "release-runs"
)
def workspace_release_run_root(state_root: Path, workspace_fingerprint: str) -> Path:
return (
state_root.expanduser()
/ f"workspace-{workspace_fingerprint}"
/ "release-runs"
)
def release_workspace_fingerprint(workspace_root: Path) -> str:
canonical = str(workspace_root.expanduser().resolve()).encode("utf-8")
return hashlib.sha256(canonical).hexdigest()
def parse_csv(value: str | None) -> tuple[str, ...]: def parse_csv(value: str | None) -> tuple[str, ...]:
if not value: if not value:
return () return ()

View File

@@ -96,7 +96,8 @@
} }
input[type="text"], input[type="text"],
input[type="password"] { input[type="password"],
select {
width: 100%; width: 100%;
min-height: 36px; min-height: 36px;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -139,7 +140,7 @@
} }
button:disabled { button:disabled {
cursor: wait; cursor: not-allowed;
opacity: 0.65; opacity: 0.65;
} }
@@ -635,6 +636,31 @@
</div> </div>
<div class="side"> <div class="side">
<section>
<div class="section-head">
<h2>Durable Run State</h2>
<small id="runStatus">no run selected</small>
</div>
<div class="details">
<p class="hint"><strong>State tracking only.</strong> A run freezes the selected inputs and plan, guides recovery, and records bounded state events. It does not execute or confirm tags, signing, publication, or catalog changes.</p>
<div class="form-grid">
<div>
<label for="releaseRun">Saved run</label>
<select id="releaseRun">
<option value="">No saved run selected</option>
</select>
</div>
</div>
<div class="button-row">
<button id="createReleaseRun">Create Run from Selection</button>
<button class="secondary" id="resumeReleaseRun" disabled>Resume Run</button>
</div>
<div class="actions" id="runOutput">
<div class="muted">Create a run after selecting exact repository versions, or choose a saved run.</div>
</div>
</div>
</section>
<section> <section>
<div class="section-head"> <div class="section-head">
<h2>Release Workflow</h2> <h2>Release Workflow</h2>
@@ -857,6 +883,11 @@
previewPrepare: document.getElementById("previewPrepare"), previewPrepare: document.getElementById("previewPrepare"),
commitPrepare: document.getElementById("commitPrepare"), commitPrepare: document.getElementById("commitPrepare"),
buildSelectedPlan: document.getElementById("buildSelectedPlan"), buildSelectedPlan: document.getElementById("buildSelectedPlan"),
releaseRun: document.getElementById("releaseRun"),
runStatus: document.getElementById("runStatus"),
runOutput: document.getElementById("runOutput"),
createReleaseRun: document.getElementById("createReleaseRun"),
resumeReleaseRun: document.getElementById("resumeReleaseRun"),
selectUnpushedUnits: document.getElementById("selectUnpushedUnits"), selectUnpushedUnits: document.getElementById("selectUnpushedUnits"),
selectChangedUnits: document.getElementById("selectChangedUnits"), selectChangedUnits: document.getElementById("selectChangedUnits"),
selectUnreleasedUnits: document.getElementById("selectUnreleasedUnits"), selectUnreleasedUnits: document.getElementById("selectUnreleasedUnits"),
@@ -874,7 +905,12 @@
rows: {}, rows: {},
dashboard: null, dashboard: null,
manualRepo: "", manualRepo: "",
currentRun: null,
runs: [],
runStoreAvailable: true,
}; };
const pendingReleaseRunKey = "govoplanReleaseConsolePendingRunCreate";
const pendingRunCommandsKey = "govoplanReleaseConsolePendingRunCommands";
elements.refresh.addEventListener("click", load); elements.refresh.addEventListener("click", load);
elements.generateCandidate.addEventListener("click", generateCandidate); elements.generateCandidate.addEventListener("click", generateCandidate);
@@ -889,6 +925,9 @@
elements.previewPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: false })); elements.previewPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: false }));
elements.commitPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: true })); elements.commitPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: true }));
elements.buildSelectedPlan.addEventListener("click", buildSelectedPlan); elements.buildSelectedPlan.addEventListener("click", buildSelectedPlan);
elements.createReleaseRun.addEventListener("click", createReleaseRun);
elements.resumeReleaseRun.addEventListener("click", resumeReleaseRun);
elements.releaseRun.addEventListener("change", () => loadReleaseRun(elements.releaseRun.value));
elements.previewReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: false, push: true })); elements.previewReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: false, push: true }));
elements.createReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: true, push: false })); elements.createReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: true, push: false }));
elements.publishReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: true, push: true })); elements.publishReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: true, push: true }));
@@ -923,7 +962,13 @@
return fetch(`${path}${suffix}`, { return fetch(`${path}${suffix}`, {
headers: token ? { "X-Release-Console-Token": token } : {}, headers: token ? { "X-Release-Console-Token": token } : {},
}).then((response) => { }).then((response) => {
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); if (!response.ok) {
return response.json().catch(() => ({})).then((payload) => {
const error = new Error(payload.detail || `${response.status} ${response.statusText}`);
error.status = response.status;
throw error;
});
}
return response.json(); return response.json();
}); });
} }
@@ -939,7 +984,9 @@
}).then((response) => { }).then((response) => {
if (!response.ok) { if (!response.ok) {
return response.json().catch(() => ({})).then((payload) => { return response.json().catch(() => ({})).then((payload) => {
throw new Error(payload.detail || `${response.status} ${response.statusText}`); const error = new Error(payload.detail || `${response.status} ${response.statusText}`);
error.status = response.status;
throw error;
}); });
} }
return response.json(); return response.json();
@@ -949,10 +996,25 @@
async function load() { async function load() {
elements.statusLine.textContent = "Refreshing..."; elements.statusLine.textContent = "Refreshing...";
try { try {
const [dashboard, intelligence] = await Promise.all([api("/api/dashboard"), api("/api/release-intelligence")]); const runsRequest = api("/api/release-runs")
.then((data) => ({ data, error: null }))
.catch((error) => ({ data: null, error }));
const [dashboard, intelligence, runsResult] = await Promise.all([api("/api/dashboard"), api("/api/release-intelligence"), runsRequest]);
renderDashboard(dashboard); renderDashboard(dashboard);
renderReleaseIntelligence(intelligence); renderReleaseIntelligence(intelligence);
renderIdlePlan(); if (runsResult.error) {
renderReleaseRunStoreUnavailable(runsResult.error);
} else {
renderReleaseRunList(runsResult.data?.runs || []);
const pendingCreateResult = await recoverPendingReleaseRun();
const pendingCommandResult = await recoverPendingRunCommands();
const recoveryAttempted = pendingCreateResult !== null || pendingCommandResult !== null;
if (!recoveryAttempted && releaseState.currentRun?.run_id) {
await loadReleaseRun(releaseState.currentRun.run_id, { restoreSelection: false });
} else if (!recoveryAttempted) {
renderIdlePlan();
}
}
elements.statusLine.textContent = `Generated ${dashboard.generated_at}`; elements.statusLine.textContent = `Generated ${dashboard.generated_at}`;
} catch (error) { } catch (error) {
elements.statusLine.innerHTML = `<span class="error">${escapeHtml(error.message)}</span>`; elements.statusLine.innerHTML = `<span class="error">${escapeHtml(error.message)}</span>`;
@@ -977,6 +1039,391 @@
renderCatalog(data.catalog, data.target_version); renderCatalog(data.catalog, data.target_version);
} }
function renderReleaseRunList(runs) {
releaseState.runStoreAvailable = true;
releaseState.runs = Array.isArray(runs) ? runs : [];
elements.releaseRun.disabled = false;
elements.createReleaseRun.disabled = false;
const selectedId = releaseState.currentRun?.run_id || "";
const options = releaseState.runs.map((run) => {
const repositories = run.repo_versions ? Object.keys(run.repo_versions).length : 0;
const label = `${run.run_id} · ${run.status || "unknown"} · ${repositories} repos`;
return `<option value="${escapeAttr(run.run_id)}" ${run.run_id === selectedId ? "selected" : ""}>${escapeHtml(label)}</option>`;
}).join("");
elements.releaseRun.innerHTML = `<option value="">No saved run selected</option>${options}`;
if (selectedId && releaseState.runs.some((run) => run.run_id === selectedId)) {
elements.releaseRun.value = selectedId;
}
}
function renderReleaseRunStoreUnavailable(error) {
releaseState.runStoreAvailable = false;
releaseState.runs = [];
releaseState.currentRun = null;
elements.releaseRun.innerHTML = `<option value="">Run storage unavailable</option>`;
elements.releaseRun.disabled = true;
elements.createReleaseRun.disabled = true;
elements.resumeReleaseRun.disabled = true;
elements.runStatus.textContent = "unavailable";
elements.runOutput.innerHTML = `<div class="action"><h3>${pill("unavailable", "block")} Durable run storage cannot be read</h3><p>${escapeHtml(error.message)}</p><p class="action-meta">Dashboard and release previews remain available. Correct the private state-directory problem, then refresh.</p></div>`;
}
async function createReleaseRun() {
if (!releaseState.runStoreAvailable) return;
const repoVersions = selectedRepoVersions();
if (!Object.keys(repoVersions).length) {
elements.runStatus.textContent = "selection required";
elements.runOutput.innerHTML = `<div class="action"><h3>${pill("unavailable", "warn")} Select release units</h3><p>Select one or more repositories and exact target versions before creating a durable run.</p></div>`;
return;
}
setBusy([elements.createReleaseRun], true);
elements.runStatus.textContent = "freezing plan...";
try {
const payload = pendingReleaseRunPayload({
repo_versions: repoVersions,
channel: elements.channel.value.trim() || "stable",
online: false,
remote_tags: elements.online.checked,
public_catalog: elements.publicCatalog.checked,
include_migrations: elements.migrations.checked,
});
await submitReleaseRunCreate(payload, { automatic: false });
} catch (error) {
elements.runStatus.textContent = "error";
elements.runOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Run creation failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.createReleaseRun], false);
}
}
function pendingReleaseRunPayload(input) {
const normalized = {
...input,
repo_versions: Object.fromEntries(Object.entries(input.repo_versions || {}).sort(([left], [right]) => left.localeCompare(right))),
};
const signature = JSON.stringify(normalized);
const pending = readPendingReleaseRun();
if (pending?.signature === signature) return pending.payload;
const payload = { ...normalized, request_id: requestId("create-run") };
sessionStorage.setItem(pendingReleaseRunKey, JSON.stringify({ signature, payload }));
return payload;
}
function readPendingReleaseRun() {
try {
const pending = JSON.parse(sessionStorage.getItem(pendingReleaseRunKey) || "null");
if (!pending || typeof pending.signature !== "string" || typeof pending.payload?.request_id !== "string") return null;
return pending;
} catch (_error) {
sessionStorage.removeItem(pendingReleaseRunKey);
return null;
}
}
function clearPendingReleaseRun(requestIdToClear) {
const pending = readPendingReleaseRun();
if (pending?.payload?.request_id === requestIdToClear) {
sessionStorage.removeItem(pendingReleaseRunKey);
}
}
async function recoverPendingReleaseRun() {
const pending = readPendingReleaseRun();
if (!pending) return null;
if (!releaseState.runStoreAvailable) return false;
elements.runStatus.textContent = "recovering saved create...";
return submitReleaseRunCreate(pending.payload, { automatic: true });
}
async function submitReleaseRunCreate(payload, options = {}) {
try {
const run = await postJson("/api/release-runs", payload);
clearPendingReleaseRun(payload.request_id);
releaseState.currentRun = run;
restoreReleaseRunSelection(run);
renderReleaseRun(run);
await refreshReleaseRunListAfterCreate(run);
return true;
} catch (error) {
const deterministicClientError = error.status >= 400 && error.status < 500 && error.status !== 409;
const conflictingReuse = error.status === 409 && error.message.includes("already used with different inputs");
if (deterministicClientError || conflictingReuse) clearPendingReleaseRun(payload.request_id);
elements.runStatus.textContent = options.automatic ? "recovery pending" : "error";
elements.runOutput.innerHTML = `<div class="action"><h3>${pill(options.automatic ? "pending" : "error", options.automatic ? "warn" : "block")} ${options.automatic ? "Saved run recovery is still pending" : "Run creation failed"}</h3><p>${escapeHtml(error.message)}</p><p class="action-meta">The same private request identifier will be reused after an uncertain response.</p></div>`;
return false;
}
}
async function refreshReleaseRunListAfterCreate(run) {
try {
const runs = await api("/api/release-runs");
renderReleaseRunList(runs.runs || []);
elements.releaseRun.value = run.run_id;
} catch (error) {
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("saved", "warn")} Run saved; list refresh unavailable</h3><p>${escapeHtml(error.message)}</p><p class="action-meta">The persisted run remains selected and can be reloaded after refresh.</p></div>`);
}
}
async function loadReleaseRun(runId, options = {}) {
if (!runId) {
releaseState.currentRun = null;
elements.runStatus.textContent = "no run selected";
elements.resumeReleaseRun.disabled = true;
elements.runOutput.innerHTML = `<div class="muted">Create a run after selecting exact repository versions, or choose a saved run.</div>`;
return;
}
elements.runStatus.textContent = "loading...";
try {
const run = await api(`/api/release-runs/${encodeURIComponent(runId)}`);
releaseState.currentRun = run;
if (options.restoreSelection !== false) restoreReleaseRunSelection(run);
renderReleaseRun(run);
elements.releaseRun.value = run.run_id;
} catch (error) {
releaseState.currentRun = null;
elements.runStatus.textContent = "unavailable";
elements.resumeReleaseRun.disabled = true;
elements.runOutput.innerHTML = `<div class="action"><h3>${pill("unavailable", "block")} Run cannot be read</h3><p>${escapeHtml(error.message)}</p></div>`;
}
}
function restoreReleaseRunSelection(run) {
const input = run.immutable?.input || {};
const repoVersions = input.repo_versions || {};
elements.channel.value = input.channel || "stable";
elements.publicCatalog.checked = input.public_catalog === true;
elements.online.checked = input.remote_tags === true || input.online === true;
elements.migrations.checked = input.include_migrations === true;
for (const repo of Object.keys(releaseState.rows)) {
releaseState.rows[repo] = {
...releaseState.rows[repo],
selected: Object.prototype.hasOwnProperty.call(repoVersions, repo),
...(Object.prototype.hasOwnProperty.call(repoVersions, repo) ? { target: repoVersions[repo] } : {}),
};
}
if (releaseState.dashboard) renderReleaseUnits(releaseState.dashboard);
if (run.immutable?.plan) renderPlan(run.immutable.plan);
}
function renderReleaseRun(run) {
const state = run.state || {};
const recommendation = run.recommended_next || {};
const steps = Array.isArray(state.steps) ? state.steps : [];
const events = Array.isArray(state.events) ? state.events.slice(-5).reverse() : [];
const recommendationKind = state.status === "blocked" ? "block" : state.status === "attention" ? "warn" : recommendation.available ? "ok" : "warn";
const recommendationHtml = recommendation.id ? `<div class="action recommended">
<h3>${pill("recommended next", recommendationKind)} ${escapeHtml(recommendation.title || recommendation.id)}</h3>
<p>${escapeHtml(recommendation.detail || "")}</p>
<p class="action-meta"><strong>How to continue:</strong> ${escapeHtml(recommendation.remediation || "")}</p>
</div>` : "";
const stepHtml = steps.map((step) => {
const kind = step.state === "succeeded" ? "ok" : ["failed", "interrupted"].includes(step.state) ? "block" : step.available ? "ok" : "warn";
const retryReason = step.retry_available ? "Prepare this known failed or read-only interrupted step for an explicit retry." : (step.disabled_reason || "Retry is unavailable for this state.");
const reconciliationHtml = step.reconciliation_required ? `<div class="form-grid" data-run-reconcile-step="${escapeAttr(step.id)}">
<div><label>Observed external effect</label><select data-run-reconcile-outcome><option value="">Choose verified outcome</option><option value="effect_absent">Effect is absent — retry is safe</option><option value="effect_succeeded">Effect succeeded — advance the run</option><option value="unresolved">Still unresolved — keep blocked</option></select></div>
<div><label>Confirmation</label><input type="text" data-run-reconcile-confirm placeholder="Type RECONCILE" autocomplete="off" /></div>
<div class="button-row"><button class="secondary" data-run-reconcile-submit disabled title="Verify local and remote state independently, choose the observed outcome, and type RECONCILE.">Record Reconciliation</button></div>
</div>` : "";
return `<div class="action">
<div class="stage-title"><h3>${escapeHtml(`${step.order}. ${step.title || step.id}`)}</h3>${pill(step.state, kind)}</div>
<p>${escapeHtml(step.detail || "")}</p>
${step.result_code ? `<p class="action-meta">Result code: ${escapeHtml(step.result_code)}</p>` : ""}
<div class="button-row"><button class="secondary" data-run-retry-step="${escapeAttr(step.id)}" ${step.retry_available ? "" : "disabled"} title="${escapeAttr(retryReason)}">Prepare Retry</button></div>
${reconciliationHtml}
</div>`;
}).join("");
const eventHtml = events.length ? `<div class="action"><h3>Recent bounded state events</h3>${events.map((event) => `<p class="action-meta">${escapeHtml(`${event.at} · ${event.type}${event.step_id ? ` · ${event.step_id}` : ""}${event.result_code ? ` · ${event.result_code}` : ""}`)}</p>`).join("")}</div>` : "";
elements.runStatus.textContent = state.status || "unknown";
elements.resumeReleaseRun.disabled = state.status !== "running";
elements.runOutput.innerHTML = `<div class="action"><h3>${pill(state.status || "unknown", state.status === "blocked" ? "block" : state.status === "completed" ? "ok" : "warn")} ${escapeHtml(run.run_id)}</h3><p>Frozen plan ${escapeHtml((run.immutable?.digest || "").slice(0, 16))}… · updated ${escapeHtml(run.updated_at || "-")}</p><p class="action-meta">Executor controls remain separate and keep their existing confirmations.</p></div>${recommendationHtml}${stepHtml}${eventHtml}`;
for (const button of elements.runOutput.querySelectorAll("[data-run-retry-step]")) {
button.addEventListener("click", () => retryReleaseRunStep(button.dataset.runRetryStep));
}
for (const controls of elements.runOutput.querySelectorAll("[data-run-reconcile-step]")) {
const outcome = controls.querySelector("[data-run-reconcile-outcome]");
const confirmation = controls.querySelector("[data-run-reconcile-confirm]");
const submit = controls.querySelector("[data-run-reconcile-submit]");
const refresh = () => {
submit.disabled = !outcome.value || confirmation.value.trim() !== "RECONCILE";
};
outcome.addEventListener("change", refresh);
confirmation.addEventListener("input", refresh);
submit.addEventListener("click", () => reconcileReleaseRunStep(controls.dataset.runReconcileStep, outcome.value, confirmation.value.trim(), submit));
}
}
async function resumeReleaseRun() {
const run = releaseState.currentRun;
if (!run) return;
const commandKey = `resume:${run.run_id}`;
const commandRequestId = inFlightRunCommandId(commandKey, "resume");
setBusy([elements.resumeReleaseRun], true);
try {
const resumed = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/resume`, { request_id: commandRequestId });
clearInFlightRunCommand(commandKey, commandRequestId);
releaseState.currentRun = resumed;
renderReleaseRun(resumed);
} catch (error) {
if (deterministicRunCommandError(error)) clearInFlightRunCommand(commandKey, commandRequestId);
elements.runStatus.textContent = "error";
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("error", "block")} Resume failed</h3><p>${escapeHtml(error.message)}</p></div>`);
} finally {
elements.resumeReleaseRun.disabled = releaseState.currentRun?.state?.status !== "running";
}
}
async function retryReleaseRunStep(stepId) {
const run = releaseState.currentRun;
if (!run || !stepId) return;
const commandKey = `retry:${run.run_id}:${stepId}`;
const commandRequestId = inFlightRunCommandId(commandKey, "retry");
try {
const retried = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/steps/${encodeURIComponent(stepId)}/retry`, { request_id: commandRequestId });
clearInFlightRunCommand(commandKey, commandRequestId);
releaseState.currentRun = retried;
renderReleaseRun(retried);
} catch (error) {
if (deterministicRunCommandError(error)) clearInFlightRunCommand(commandKey, commandRequestId);
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("error", "block")} Retry preparation failed</h3><p>${escapeHtml(error.message)}</p></div>`);
}
}
async function reconcileReleaseRunStep(stepId, outcome, confirmation, button) {
const run = releaseState.currentRun;
if (!run || !stepId || !outcome || confirmation !== "RECONCILE") return;
const commandKey = `reconcile:${run.run_id}:${stepId}:${outcome}`;
const commandRequestId = inFlightRunCommandId(commandKey, "reconcile");
setBusy([button], true);
try {
const reconciled = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/steps/${encodeURIComponent(stepId)}/reconcile`, {
request_id: commandRequestId,
outcome,
confirm: confirmation,
});
clearInFlightRunCommand(commandKey, commandRequestId);
releaseState.currentRun = reconciled;
renderReleaseRun(reconciled);
} catch (error) {
if (deterministicRunCommandError(error)) clearInFlightRunCommand(commandKey, commandRequestId);
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("error", "block")} Reconciliation failed</h3><p>${escapeHtml(error.message)}</p></div>`);
button.disabled = false;
}
}
function readInFlightRunCommands() {
try {
const commands = JSON.parse(sessionStorage.getItem(pendingRunCommandsKey) || "{}");
return commands && typeof commands === "object" && !Array.isArray(commands) ? commands : {};
} catch (_error) {
sessionStorage.removeItem(pendingRunCommandsKey);
return {};
}
}
function pendingRunCommandRequest(commandKey, requestIdToReplay) {
if (typeof commandKey !== "string" || typeof requestIdToReplay !== "string") return null;
if (commandKey.startsWith("resume:")) {
const runId = commandKey.slice("resume:".length);
if (!runId || runId.includes(":")) return null;
return {
path: `/api/release-runs/${encodeURIComponent(runId)}/resume`,
body: { request_id: requestIdToReplay },
};
}
if (commandKey.startsWith("retry:")) {
const remainder = commandKey.slice("retry:".length);
const separator = remainder.indexOf(":");
const runId = remainder.slice(0, separator);
const stepId = remainder.slice(separator + 1);
if (separator < 1 || !stepId) return null;
return {
path: `/api/release-runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepId)}/retry`,
body: { request_id: requestIdToReplay },
};
}
if (commandKey.startsWith("reconcile:")) {
const remainder = commandKey.slice("reconcile:".length);
const runSeparator = remainder.indexOf(":");
const outcomeSeparator = remainder.lastIndexOf(":");
const runId = remainder.slice(0, runSeparator);
const stepId = remainder.slice(runSeparator + 1, outcomeSeparator);
const outcome = remainder.slice(outcomeSeparator + 1);
if (runSeparator < 1 || outcomeSeparator <= runSeparator + 1 || !["effect_absent", "effect_succeeded", "unresolved"].includes(outcome)) return null;
return {
path: `/api/release-runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepId)}/reconcile`,
body: { request_id: requestIdToReplay, outcome, confirm: "RECONCILE" },
};
}
return null;
}
async function recoverPendingRunCommands() {
const commands = readInFlightRunCommands();
const entries = Object.entries(commands);
if (!entries.length) return null;
let attempted = false;
let recoveredRun = null;
let pendingError = null;
let rejectedError = null;
for (const [commandKey, requestIdToReplay] of entries) {
const command = pendingRunCommandRequest(commandKey, requestIdToReplay);
if (!command) {
clearInFlightRunCommand(commandKey, requestIdToReplay);
continue;
}
attempted = true;
try {
recoveredRun = await postJson(command.path, command.body);
clearInFlightRunCommand(commandKey, requestIdToReplay);
} catch (error) {
if (deterministicRunCommandError(error)) {
clearInFlightRunCommand(commandKey, requestIdToReplay);
rejectedError = error;
} else {
pendingError = error;
}
}
}
if (recoveredRun) {
releaseState.currentRun = recoveredRun;
restoreReleaseRunSelection(recoveredRun);
renderReleaseRun(recoveredRun);
elements.releaseRun.value = recoveredRun.run_id;
}
if (pendingError) {
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("pending", "warn")} Saved command recovery is still pending</h3><p>${escapeHtml(pendingError.message)}</p><p class="action-meta">The same private request identifier will be replayed after refresh.</p></div>`);
} else if (rejectedError) {
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("rejected", "block")} Saved command was not accepted</h3><p>${escapeHtml(rejectedError.message)}</p><p class="action-meta">The server returned a deterministic client conflict; the stale request identifier was cleared.</p></div>`);
}
return attempted ? Boolean(recoveredRun) : null;
}
function deterministicRunCommandError(error) {
return error?.status >= 400 && error.status < 500;
}
function inFlightRunCommandId(commandKey, prefix) {
const commands = readInFlightRunCommands();
if (typeof commands[commandKey] === "string") return commands[commandKey];
const identifier = requestId(prefix);
commands[commandKey] = identifier;
sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands));
return identifier;
}
function clearInFlightRunCommand(commandKey, requestIdToClear) {
const commands = readInFlightRunCommands();
if (commands[commandKey] !== requestIdToClear) return;
delete commands[commandKey];
if (Object.keys(commands).length) sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands));
else sessionStorage.removeItem(pendingRunCommandsKey);
}
function requestId(prefix) {
const identifier = window.crypto?.randomUUID ? window.crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}-${identifier}`;
}
function renderWorkflowStages(data) { function renderWorkflowStages(data) {
const s = data.summary || {}; const s = data.summary || {};
const selected = elements.releaseUnits ? elements.releaseUnits.querySelectorAll("[data-release-check]:checked").length : 0; const selected = elements.releaseUnits ? elements.releaseUnits.querySelectorAll("[data-release-check]:checked").length : 0;