Compare commits
9 Commits
bd715a8473
...
ddee5c00dc
| Author | SHA1 | Date | |
|---|---|---|---|
| ddee5c00dc | |||
| 4c16069f88 | |||
| 22f5c2ff4e | |||
| 19c4b63ade | |||
| 1dc9148ec3 | |||
| ead697d049 | |||
| 65aa8e0b7c | |||
| 6c0b003dee | |||
| d0dc916837 |
@@ -156,7 +156,10 @@ The administrator journey from Core-only bootstrap through online module
|
||||
installation, scale-out, and reversible environment promotion is defined in
|
||||
[System Administrator Lifecycle User Story](docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md).
|
||||
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
|
||||
|
||||
|
||||
@@ -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,
|
||||
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.
|
||||
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
|
||||
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
|
||||
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 40–64 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
|
||||
|
||||
- [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
|
||||
- Annotated source tags `govoplan-core/v0.1.13` and
|
||||
`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
|
||||
workspace:
|
||||
|
||||
@@ -18,6 +18,7 @@ candidate/publish actions:
|
||||
- configure target versions per release unit in the web UI
|
||||
- select the repositories that should advance through checkboxes
|
||||
- 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
|
||||
repositories and publish each branch/tag pair atomically
|
||||
- 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
|
||||
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
|
||||
malformed module contract is returned as a bounded `collection_errors` entry
|
||||
with a remediation, marks the dashboard blocked, and becomes a structured
|
||||
|
||||
148
docs/capability-fit-boundary-evidence.schema.json
Normal file
148
docs/capability-fit-boundary-evidence.schema.json
Normal 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}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -967,10 +967,12 @@
|
||||
],
|
||||
"proof_checks": [
|
||||
"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.",
|
||||
"Drill worker, Redis and ambiguous-delivery failures without duplicate sends.",
|
||||
"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.",
|
||||
"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."
|
||||
]
|
||||
}
|
||||
|
||||
75
docs/capability-fit-proof-authority-keyring.schema.json
Normal file
75
docs/capability-fit-proof-authority-keyring.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
291
docs/installed-composition-evidence.schema.json
Normal file
291
docs/installed-composition-evidence.schema.json
Normal 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}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
190
tests/test_capability_fit_atomic_io.py
Normal file
190
tests/test_capability_fit_atomic_io.py
Normal 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()
|
||||
1458
tests/test_capability_fit_evidence.py
Normal file
1458
tests/test_capability_fit_evidence.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -360,7 +360,8 @@ class CapabilityFitReviewTests(unittest.TestCase):
|
||||
|
||||
self.assertIn("Capability fit rerun: current", rendered)
|
||||
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:
|
||||
|
||||
1509
tests/test_release_run_state.py
Normal file
1509
tests/test_release_run_state.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
@@ -17,11 +18,24 @@ 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 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
|
||||
|
||||
|
||||
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:
|
||||
@@ -71,6 +85,65 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
action="store_true",
|
||||
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(
|
||||
"--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")
|
||||
if args.public and args.keyring is not None:
|
||||
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()
|
||||
trusted_keyring_path = args.trusted_keyring or (
|
||||
Path(configured_trusted_keyring) if configured_trusted_keyring else None
|
||||
@@ -96,8 +192,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
raise SystemExit(
|
||||
f"--trusted-keyring or ${TRUSTED_KEYRING_FILE_ENV} is required"
|
||||
)
|
||||
assessment = read_object(args.assessment, label="assessment")
|
||||
schema = read_object(args.schema, label="schema")
|
||||
assessment = read_object(
|
||||
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:
|
||||
catalog = fetch_public_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",
|
||||
)
|
||||
else:
|
||||
catalog = read_object(args.catalog, label="catalog")
|
||||
published_keyring = read_object(args.keyring, label="published keyring")
|
||||
catalog = read_object(
|
||||
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_path,
|
||||
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(
|
||||
assessment=assessment,
|
||||
schema=schema,
|
||||
@@ -124,27 +296,83 @@ def main(argv: list[str] | None = None) -> int:
|
||||
workspace_root=None
|
||||
if args.skip_tag_provenance
|
||||
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"
|
||||
if args.output is not None:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(encoded, encoding="utf-8")
|
||||
write_object(
|
||||
args.output,
|
||||
report,
|
||||
max_bytes=MAX_REPORT_OUTPUT_BYTES,
|
||||
label="review report",
|
||||
)
|
||||
sys.stdout.write(encoded if args.json else render_review(report))
|
||||
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:
|
||||
raise SystemExit(f"Missing {label} path")
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
if path.stat().st_size > max_bytes:
|
||||
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
|
||||
if not isinstance(payload, dict):
|
||||
raise SystemExit(f"{label.capitalize()} must be a JSON object: {path}")
|
||||
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]:
|
||||
result = fetch_json(url)
|
||||
if result.get("ok") is not True or not isinstance(result.get("payload"), dict):
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Repeatable GovOPlaN product-assessment tooling."""
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
230
tools/assessments/govoplan_assessment/atomic_io.py
Normal file
230
tools/assessments/govoplan_assessment/atomic_io.py
Normal 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)
|
||||
@@ -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.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(
|
||||
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],
|
||||
trusted_keyring: dict[str, Any],
|
||||
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]:
|
||||
"""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_expected=0,
|
||||
local_tag_catalog_scope_complete=False,
|
||||
installed_review=None,
|
||||
boundary_review=None,
|
||||
)
|
||||
|
||||
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(
|
||||
assessment=assessment,
|
||||
changed_repositories=changed_repositories,
|
||||
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(
|
||||
assessment=assessment,
|
||||
catalog=catalog,
|
||||
@@ -355,6 +428,8 @@ def review_capability_fit(
|
||||
local_tag_provenance_attempted=local_tag_provenance_attempted,
|
||||
local_tag_provenance_expected=local_tag_provenance_expected,
|
||||
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] = {
|
||||
"module_id": module_id,
|
||||
"repository": catalog_repository(item),
|
||||
"package": catalog_package(item),
|
||||
"version": _text(item.get("version")),
|
||||
"tags": tuple(
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
match = TAG_PATTERN.search(value)
|
||||
return match.group("tag") if match else None
|
||||
@@ -709,6 +790,51 @@ def conclusions_needing_review(
|
||||
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(
|
||||
*,
|
||||
assessment: dict[str, Any],
|
||||
@@ -720,6 +846,8 @@ def build_report(
|
||||
local_tag_provenance_attempted: int,
|
||||
local_tag_provenance_expected: int,
|
||||
local_tag_catalog_scope_complete: bool,
|
||||
installed_review: InstalledEvidenceReview | None,
|
||||
boundary_review: BoundaryEvidenceReview | None,
|
||||
) -> dict[str, Any]:
|
||||
finding_list = sorted(
|
||||
findings,
|
||||
@@ -736,7 +864,9 @@ def build_report(
|
||||
)
|
||||
catalog_valid = trusted_signature_valid and published_keyring_valid
|
||||
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 = (
|
||||
local_tag_provenance_expected > 0
|
||||
@@ -752,19 +882,36 @@ def build_report(
|
||||
status = "review_required"
|
||||
else:
|
||||
status = "current"
|
||||
return {
|
||||
"report_version": "0.1.0",
|
||||
"status": status,
|
||||
"assessment_id": assessment.get("assessment_id"),
|
||||
"assessment_release": assessment.get("release", {}).get("ref")
|
||||
if isinstance(assessment.get("release"), dict)
|
||||
else None,
|
||||
"catalog": {
|
||||
"channel": catalog.get("channel"),
|
||||
"sequence": catalog.get("sequence"),
|
||||
"generated_at": catalog.get("generated_at"),
|
||||
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,
|
||||
},
|
||||
"proof_scope": {
|
||||
"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,
|
||||
@@ -788,11 +935,22 @@ def build_report(
|
||||
"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},
|
||||
**installed_scope,
|
||||
**boundary_scope,
|
||||
}
|
||||
return {
|
||||
"report_version": "0.4.0",
|
||||
"status": status,
|
||||
"assessment_id": assessment.get("assessment_id"),
|
||||
"assessment_release": assessment.get("release", {}).get("ref")
|
||||
if isinstance(assessment.get("release"), dict)
|
||||
else None,
|
||||
"catalog": {
|
||||
"channel": catalog.get("channel"),
|
||||
"sequence": catalog.get("sequence"),
|
||||
"generated_at": catalog.get("generated_at"),
|
||||
},
|
||||
"proof_scope": proof_scope,
|
||||
"findings": [asdict(item) for item in finding_list],
|
||||
"changes": sorted(
|
||||
changes,
|
||||
@@ -803,12 +961,45 @@ def build_report(
|
||||
|
||||
|
||||
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 = [
|
||||
f"Capability fit rerun: {report['status']}",
|
||||
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 '-'}",
|
||||
"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 []
|
||||
if findings:
|
||||
lines.append("Findings:")
|
||||
@@ -822,9 +1013,23 @@ def render_review(report: dict[str, Any]) -> str:
|
||||
if targets:
|
||||
lines.append("Conclusions requiring review:")
|
||||
lines.extend(f"- {item['id']}: {item['reason']}" for item in targets)
|
||||
lines.append(
|
||||
"No installed-artifact, target-provider, or production proof was performed."
|
||||
unchecked_boundaries = [
|
||||
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"
|
||||
|
||||
|
||||
|
||||
2223
tools/assessments/govoplan_assessment/evidence.py
Normal file
2223
tools/assessments/govoplan_assessment/evidence.py
Normal file
File diff suppressed because it is too large
Load Diff
1972
tools/release/govoplan_release/release_run.py
Normal file
1972
tools/release/govoplan_release/release_run.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
@@ -21,6 +24,12 @@ from govoplan_release import (
|
||||
tag_repositories,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -90,10 +99,47 @@ class RepositoryTagRequest(BaseModel):
|
||||
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.state.workspace_root = workspace_root
|
||||
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")
|
||||
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)
|
||||
|
||||
@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")
|
||||
def catalog_candidates() -> dict[str, object]:
|
||||
return {"candidates": list_catalog_candidates()}
|
||||
@@ -319,6 +494,34 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No
|
||||
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, ...]:
|
||||
if not value:
|
||||
return ()
|
||||
|
||||
@@ -96,7 +96,8 @@
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
input[type="password"],
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
@@ -139,7 +140,7 @@
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
@@ -635,6 +636,31 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="section-head">
|
||||
<h2>Release Workflow</h2>
|
||||
@@ -857,6 +883,11 @@
|
||||
previewPrepare: document.getElementById("previewPrepare"),
|
||||
commitPrepare: document.getElementById("commitPrepare"),
|
||||
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"),
|
||||
selectChangedUnits: document.getElementById("selectChangedUnits"),
|
||||
selectUnreleasedUnits: document.getElementById("selectUnreleasedUnits"),
|
||||
@@ -874,7 +905,12 @@
|
||||
rows: {},
|
||||
dashboard: null,
|
||||
manualRepo: "",
|
||||
currentRun: null,
|
||||
runs: [],
|
||||
runStoreAvailable: true,
|
||||
};
|
||||
const pendingReleaseRunKey = "govoplanReleaseConsolePendingRunCreate";
|
||||
const pendingRunCommandsKey = "govoplanReleaseConsolePendingRunCommands";
|
||||
|
||||
elements.refresh.addEventListener("click", load);
|
||||
elements.generateCandidate.addEventListener("click", generateCandidate);
|
||||
@@ -889,6 +925,9 @@
|
||||
elements.previewPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: false }));
|
||||
elements.commitPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: true }));
|
||||
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.createReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: true, push: false }));
|
||||
elements.publishReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: true, push: true }));
|
||||
@@ -923,7 +962,13 @@
|
||||
return fetch(`${path}${suffix}`, {
|
||||
headers: token ? { "X-Release-Console-Token": token } : {},
|
||||
}).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();
|
||||
});
|
||||
}
|
||||
@@ -939,7 +984,9 @@
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
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();
|
||||
@@ -949,10 +996,25 @@
|
||||
async function load() {
|
||||
elements.statusLine.textContent = "Refreshing...";
|
||||
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);
|
||||
renderReleaseIntelligence(intelligence);
|
||||
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}`;
|
||||
} catch (error) {
|
||||
elements.statusLine.innerHTML = `<span class="error">${escapeHtml(error.message)}</span>`;
|
||||
@@ -977,6 +1039,391 @@
|
||||
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) {
|
||||
const s = data.summary || {};
|
||||
const selected = elements.releaseUnits ? elements.releaseUnits.querySelectorAll("[data-release-check]:checked").length : 0;
|
||||
|
||||
Reference in New Issue
Block a user