diff --git a/Dockerfile b/Dockerfile index 39d9156..5d09f4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ COPY .forgejo/workflows/image.yaml ./.forgejo/workflows/image.yaml COPY schemas/ ./schemas/ COPY k8s/ ./k8s/ COPY scripts/render_gitops.py scripts/check_gitops_promotion.py ./scripts/ -RUN uv sync --frozen --extra dev && uv run --no-sync python scripts/render_gitops.py --check --verify-frontend --verify-platform && uv run --no-sync pytest -q -p no:cacheprovider tests/test_frontend_patterns.py tests/test_instruction_evaluation.py tests/test_admin_sync_api.py tests/test_gitops_release.py +RUN uv sync --frozen --extra dev && uv run --no-sync python scripts/render_gitops.py --check --verify-frontend --verify-platform && uv run --no-sync pytest -q -p no:cacheprovider tests/test_frontend_patterns.py tests/test_instruction_evaluation.py tests/test_admin_sync_api.py tests/test_gitops_release.py tests/test_release_broker.py # Stage 2 — runtime image FROM python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea AS runtime diff --git a/docs/release-broker.md b/docs/release-broker.md new file mode 100644 index 0000000..c699569 --- /dev/null +++ b/docs/release-broker.md @@ -0,0 +1,72 @@ +# Image-only release broker core + +ACTIVITY-WP-0041-T03 owns this implementation. **Not activated in production.** +The new modules are not wired to an API, worker, schedule or credential source. +Construction requires `admitted=True` from trusted deployment configuration; that +switch is a local guard, not proof that an identity has actually been admitted. +The current deployed revision is unchanged, so this code does not restart soak. + +## Implemented boundary + +`activity_core.release_broker.Receipts` checks Ed25519 signatures against a locally +configured key-to-principal/role registry. Requests cannot introduce trusted keys. +Build, independent review, health and retention attestations must all name the +same fixed repository/application, full candidate and rollback commits, and exact +before/after manifest hashes. Evidence expires within five minutes. Build and +review require different principals **and keys**. Build evidence must cover exact +candidate image digests and all three mandatory CI contexts. Retention must cover +both live and rollback images. Health must attest continuous healthy observation +of the prior revision for at least 24 hours. The trusted issuers must verify these +facts against their authorities; signatures alone cannot make assertions true. + +`gitops_policy` is the shared image-only validator used by both broker and the +existing CLI. It forbids non-image deployment changes and resource-set changes. +`release_operations` constructs a one-field update of the validated platform child +Application and fixed selective root/child sync payloads. No arbitrary command, +repository path, application, prune flag or override is exposed by these builders. + +## Durable recovery + +A local SQLite ledger stores signed receipts, their hashes, the release binding, +phase and transition history. One active row and an immediate transaction serialize +all releases and adapter calls on that ledger. The database must reside on durable, +operator-owned local storage, shared by all instances handling this application; +this is not a distributed lock across independent databases or network filesystems. + +Phases are planned → publish_pending → published → synced → complete. Publication +intent is committed before calling the adapter. Lost responses therefore cannot +turn a possibly published change into an assumed cancellation. A release that +expires before any attempt is cancelled; after intent, timeout or failed health +enters rollback_planned → rollback_published → rollback_synced → rolled_back. +Failed rollback retains the active slot. Retried publication/sync must be +idempotent, using compare-and-swap and accepting already-at-target as success. +The adapter must refuse any unexpected third revision rather than overwriting it. + +Tests use generated fixture keys and an in-memory adapter. They prove policy and +state-machine behavior, including restart and failed-health rollback; they are +**not** proof of production Git/ArgoCD rollback or admitted authority. + +## Still required before activation + +1. Implement and verify an authenticated transport adapter that resolves exact + source commits, compares rendered manifests to the signed hashes, publishes + only the platform child revision field, and runs the fixed selective syncs. + Every network operation needs a bounded timeout; health must wait within that + bound for the exact revision and verify deployments, report sink and schedules. + It must persist/recover the platform commit across lost responses and serialize + with other writers. A generic repository-write token is not path enforcement. +2. Admit the dedicated principal and credential custody through the owner lane. + ArgoCD Core has no API-server token lane. Kubernetes Application patch RBAC + alone cannot restrict fields: keep it behind the reviewed broker boundary. + Publish negative access tests and revocation behavior; no broad operator key. +3. Supply independent trusted build/review/health/retention issuers and their key + custody/rotation. The observer must measure continuous health; it must not + manufacture a 24-hour interval from two snapshots. Preserve signing public + keys for audit and protect the ledger from producer writes. +4. Connect the durable dispatcher through activity-core/Temporal and sanitized + evidence sinks. Prove authenticated transport failure, concurrency with other + publishers, restart, denial and rollback in an isolated deployment environment. + Then finish the production observation gate and enable the bounded scope. + +Platform enforcement contract: `railiance-platform/docs/activity-core-release-admission.md`. +These requirements remain live work in ACTIVITY-WP-0041-T03 and RPF-WP-0048-T02. diff --git a/pyproject.toml b/pyproject.toml index 79c0999..d0d063a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "nats-py>=2.7", "httpx>=0.27", "pyyaml>=6.0", + "cryptography>=44.0", ] [project.scripts] diff --git a/scripts/check_gitops_promotion.py b/scripts/check_gitops_promotion.py index 34813a1..f542e1f 100644 --- a/scripts/check_gitops_promotion.py +++ b/scripts/check_gitops_promotion.py @@ -1,63 +1,19 @@ -"""Fail-closed admission check for a candidate image-only GitOps release. - -This is a validator, not an authority issuer or a cluster/Git credential broker. -Evidence must be supplied by the separately admitted release identity. -""" +"""Compatibility CLI for the shared image-only release admission policy.""" import argparse -import copy -from datetime import datetime, timedelta, timezone import json from pathlib import Path -import re import yaml -IMAGE=re.compile(r'forgejo\.coulomb\.social/coulomb/activity-core@sha256:[0-9a-f]{64}') -SHA=re.compile(r'[0-9a-f]{40}') -DEPLOYS={'actcore-api','actcore-worker','actcore-event-router'} -def require(condition,message): - if not condition: raise ValueError(message) -def indexed(docs): - out={} - for d in docs: - require(isinstance(d,dict),'invalid resource') - key=(d['kind'],d['metadata']['name']) - require(key not in out and d['metadata'].get('namespace')=='activity-core','duplicate or foreign resource') - out[key]=copy.deepcopy(d) - return out +from activity_core.gitops_policy import validate -def validate(before,after,evidence,now=None): - now=now or datetime.now(timezone.utc) - require(evidence.get('schema_version')==1,'unknown evidence schema') - require(evidence.get('identity_admitted') is True,'release identity not admitted') - require(evidence.get('authority')=='ACTIVITY-WP-0041-image-only-v1','unknown authority') - require(evidence.get('review_result')=='pass' and evidence.get('checks')=='pass','checks/review not passed') - require(evidence.get('reviewer') and evidence.get('reviewer')!=evidence.get('producer'),'independent review required') - require(bool(SHA.fullmatch(evidence.get('candidate_commit',''))),'full candidate revision required') - require(bool(SHA.fullmatch(evidence.get('rollback_commit',''))),'rollback revision required') - require(evidence['candidate_commit']!=evidence['rollback_commit'],'rollback must name prior revision') - observed=datetime.fromisoformat(evidence['healthy_since'].replace('Z','+00:00')) - measured=datetime.fromisoformat(evidence['observed_at'].replace('Z','+00:00')) - require(observed.tzinfo is not None and measured.tzinfo is not None,'timestamps need timezone') - require(now-timedelta(minutes=5)<=measured<=now,'health evidence stale or future') - require(measured-observed>=timedelta(hours=24),'24-hour healthy observation period incomplete') - require(evidence.get('argo_synced') is True and evidence.get('argo_healthy') is True,'Argo not healthy/synced') - left,right=indexed(before),indexed(after) - require(left.keys()==right.keys(),'resource inventory change') - changed=[] - for key,a in left.items(): - b=right[key] - if a==b: continue - require(key[0]=='Deployment' and key[1] in DEPLOYS,'only runtime images may change') - ac=a['spec']['template']['spec']['containers'];bc=b['spec']['template']['spec']['containers'] - require(len(ac)==len(bc)==1,'container inventory change') - require(bool(IMAGE.fullmatch(bc[0].get('image',''))),'registry digest required') - require(bc[0].get('imagePullPolicy')=='IfNotPresent','pull policy must support registry recovery') - ac[0]['image']=bc[0]['image'];ac[0]['imagePullPolicy']=bc[0]['imagePullPolicy'] - require(a==b,'non-image deployment change') - changed.append(key[1]) - require(bool(changed),'no release change') - return {'admitted':True,'deployments':changed,'candidate_commit':evidence['candidate_commit'],'rollback_commit':evidence['rollback_commit']} - -if __name__=='__main__': - p=argparse.ArgumentParser();p.add_argument('before',type=Path);p.add_argument('after',type=Path);p.add_argument('evidence',type=Path);a=p.parse_args() - try: print(json.dumps(validate(list(yaml.safe_load_all(a.before.read_text())),list(yaml.safe_load_all(a.after.read_text())),json.loads(a.evidence.read_text())))) - except (ValueError,KeyError,TypeError) as e: raise SystemExit(f'refused: {e}') +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("before", type=Path) + parser.add_argument("after", type=Path) + parser.add_argument("evidence", type=Path) + args = parser.parse_args() + try: + print(json.dumps(validate(list(yaml.safe_load_all(args.before.read_text())), + list(yaml.safe_load_all(args.after.read_text())), + json.loads(args.evidence.read_text())))) + except (ValueError, KeyError, TypeError) as exc: + raise SystemExit(f"refused: {exc}") diff --git a/src/activity_core/gitops_policy.py b/src/activity_core/gitops_policy.py new file mode 100644 index 0000000..9fdcb4b --- /dev/null +++ b/src/activity_core/gitops_policy.py @@ -0,0 +1,55 @@ +"""Fail-closed admission check for a candidate image-only GitOps release. + +This is a validator, not an authority issuer or a cluster/Git credential broker. +Evidence must be supplied by the separately admitted release identity. +""" +import copy +from datetime import datetime, timedelta, timezone +import re +import yaml +IMAGE=re.compile(r'forgejo\.coulomb\.social/coulomb/activity-core@sha256:[0-9a-f]{64}') +SHA=re.compile(r'[0-9a-f]{40}') +DEPLOYS={'actcore-api','actcore-worker','actcore-event-router'} +def require(condition,message): + if not condition: raise ValueError(message) +def indexed(docs): + out={} + for d in docs: + require(isinstance(d,dict),'invalid resource') + key=(d['kind'],d['metadata']['name']) + require(key not in out and d['metadata'].get('namespace')=='activity-core','duplicate or foreign resource') + out[key]=copy.deepcopy(d) + return out + +def validate(before,after,evidence,now=None): + now=now or datetime.now(timezone.utc) + require(evidence.get('schema_version')==1,'unknown evidence schema') + require(evidence.get('identity_admitted') is True,'release identity not admitted') + require(evidence.get('authority')=='ACTIVITY-WP-0041-image-only-v1','unknown authority') + require(evidence.get('review_result')=='pass' and evidence.get('checks')=='pass','checks/review not passed') + require(evidence.get('reviewer') and evidence.get('reviewer')!=evidence.get('producer'),'independent review required') + require(bool(SHA.fullmatch(evidence.get('candidate_commit',''))),'full candidate revision required') + require(bool(SHA.fullmatch(evidence.get('rollback_commit',''))),'rollback revision required') + require(evidence['candidate_commit']!=evidence['rollback_commit'],'rollback must name prior revision') + observed=datetime.fromisoformat(evidence['healthy_since'].replace('Z','+00:00')) + measured=datetime.fromisoformat(evidence['observed_at'].replace('Z','+00:00')) + require(observed.tzinfo is not None and measured.tzinfo is not None,'timestamps need timezone') + require(now-timedelta(minutes=5)<=measured<=now,'health evidence stale or future') + require(measured-observed>=timedelta(hours=24),'24-hour healthy observation period incomplete') + require(evidence.get('argo_synced') is True and evidence.get('argo_healthy') is True,'Argo not healthy/synced') + left,right=indexed(before),indexed(after) + require(left.keys()==right.keys(),'resource inventory change') + changed=[] + for key,a in left.items(): + b=right[key] + if a==b: continue + require(key[0]=='Deployment' and key[1] in DEPLOYS,'only runtime images may change') + ac=a['spec']['template']['spec']['containers'];bc=b['spec']['template']['spec']['containers'] + require(len(ac)==len(bc)==1,'container inventory change') + require(bool(IMAGE.fullmatch(bc[0].get('image',''))),'registry digest required') + require(bc[0].get('imagePullPolicy')=='IfNotPresent','pull policy must support registry recovery') + ac[0]['image']=bc[0]['image'];ac[0]['imagePullPolicy']=bc[0]['imagePullPolicy'] + require(a==b,'non-image deployment change') + changed.append(key[1]) + require(bool(changed),'no release change') + return {'admitted':True,'deployments':changed,'candidate_commit':evidence['candidate_commit'],'rollback_commit':evidence['rollback_commit']} diff --git a/src/activity_core/release_broker.py b/src/activity_core/release_broker.py new file mode 100644 index 0000000..c65f733 --- /dev/null +++ b/src/activity_core/release_broker.py @@ -0,0 +1,218 @@ +"""Durable image-release coordinator; production adapters are deliberately separate. + +Trust keys and role bindings come from operator-owned configuration, never requests. +Adapters must implement the fixed Git/ArgoCD contract documented in release admission. +No credential loading, generic command execution, HTTP endpoint or live adapter here. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import sqlite3 +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Protocol + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from .gitops_policy import validate + +ROLES = {"build", "review", "health", "retention"} +AUTHORITY = "ACTIVITY-WP-0041-image-only-v1" +TERMINAL = {"complete", "rolled_back", "cancelled"} +REQUIRED_CHECKS = {"CI Smoke / host-smoke (push)", "CI Smoke / container-smoke (push)", + "Build and Publish Container Image / build-and-push (push)"} + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode() + + +def fingerprint(value): + return hashlib.sha256(canonical(value)).hexdigest() + + +def timestamp(value): + result = datetime.fromisoformat(value.replace("Z", "+00:00")) + if result.tzinfo is None: + raise ValueError("timezone required") + return result + + +class Receipts: + """Verify Ed25519 envelopes against locally bound principals and roles.""" + + def __init__(self, trusted): + # key id -> {public_key: raw 32 bytes, principal: str, roles: set[str]} + self.trusted = trusted + + def verify(self, envelope, role, binding, now): + if set(envelope) != {"key_id", "payload", "signature"}: + raise ValueError("unexpected envelope fields") + trust = self.trusted.get(envelope["key_id"]) + if trust is None or role not in trust["roles"]: + raise ValueError("untrusted receipt role") + payload = envelope["payload"] + signature = base64.b64decode(envelope["signature"], validate=True) + Ed25519PublicKey.from_public_bytes(trust["public_key"]).verify(signature, canonical(payload)) + if (payload.get("role") != role or payload.get("schema") != 1 + or payload.get("authority") != AUTHORITY or payload.get("binding") != binding + or payload.get("result") != "pass"): + raise ValueError("receipt scope/result mismatch") + issued, expires = timestamp(payload["issued_at"]), timestamp(payload["expires_at"]) + if not now - timedelta(minutes=5) <= issued <= now < expires <= issued + timedelta(minutes=5): + raise ValueError("stale or future receipt") + return trust["principal"], payload + + +class Backend(Protocol): + """Trusted adapter, never implemented from caller-provided URLs or commands. + + publish_revision is a CAS on the single platform child targetRevision field; + already-at-target is success, any third revision is a conflict. It must retain + source signatures/checks, serialize with other writers and return only after + durable Git publication. sync_revision selects ONLY the activity-core child in + the root, then ONLY the child application, with prune false and no overrides. + Both methods must be idempotent after a lost response or broker restart. + healthy includes exact revision, deployment health, report/schedule invariants. + """ + def publish_revision(self, expected: str, target: str) -> None: ... + def sync_revision(self, target: str) -> None: ... + def healthy(self, target: str) -> bool: ... + + +class Broker: + def __init__(self, database: Path, receipts: Receipts, *, admitted=False): + self.database, self.receipts, self.admitted = database, receipts, admitted + with self.connect() as db: + db.execute("CREATE TABLE IF NOT EXISTS releases (id TEXT PRIMARY KEY, plan TEXT NOT NULL, phase TEXT NOT NULL, active INTEGER UNIQUE, deadline TEXT NOT NULL, error TEXT)") + db.execute("CREATE TABLE IF NOT EXISTS transitions (sequence INTEGER PRIMARY KEY, release_id TEXT NOT NULL, phase TEXT NOT NULL, observed_at TEXT NOT NULL)") + + def connect(self): + db = sqlite3.connect(self.database, timeout=5, isolation_level=None) + db.execute("PRAGMA synchronous=FULL") + return db + + def admit(self, before, after, candidate, rollback, envelopes, now=None): + if not self.admitted: + raise ValueError("broker identity not admitted") + now = now or datetime.now(timezone.utc) + binding = {"repository": "coulomb/activity-core", "application": "activity-core", + "candidate": candidate, "rollback": rollback, + "before_sha256": fingerprint(before), "after_sha256": fingerprint(after)} + if set(envelopes) != ROLES: + raise ValueError("all independent receipt roles required") + verified = {r: self.receipts.verify(envelopes[r], r, binding, now) for r in ROLES} + if (verified["build"][0] == verified["review"][0] + or self.receipts.trusted[envelopes["build"]["key_id"]]["public_key"] + == self.receipts.trusted[envelopes["review"]["key_id"]]["public_key"]): + raise ValueError("reviewer must be independent of producer") + health = verified["health"][1] + # A signed continuous observation attestation must name the prior revision. + if health.get("revision") != rollback or health.get("continuous") is not True: + raise ValueError("continuous prior-revision observation required") + if health.get("synced") is not True or health.get("healthy") is not True: + raise ValueError("prior revision unhealthy") + evidence = dict(schema_version=1, identity_admitted=True, authority=AUTHORITY, + checks="pass", review_result="pass", producer=verified["build"][0], + reviewer=verified["review"][0], candidate_commit=candidate, + rollback_commit=rollback, healthy_since=health["healthy_since"], + observed_at=health["issued_at"], argo_synced=True, argo_healthy=True) + validate(before, after, evidence, now) + required = sorted({c["image"] for docs in (before, after) for d in docs + if d["kind"] == "Deployment" + for c in d["spec"]["template"]["spec"]["containers"]}) + if sorted(verified["retention"][1].get("images", [])) != required: + raise ValueError("live and rollback image retention coverage required") + checks = verified["build"][1].get("checks", {}) + if not isinstance(checks, dict) or any(checks.get(name) != "success" for name in REQUIRED_CHECKS): + raise ValueError("required build and smoke checks missing") + # Signed build receipt binds each exact candidate digest, not just a green status. + candidate_images = sorted({c["image"] for d in after if d["kind"] == "Deployment" + for c in d["spec"]["template"]["spec"]["containers"]}) + if sorted(verified["build"][1].get("images", [])) != candidate_images: + raise ValueError("build digest binding missing") + plan = {**binding, "receipts": envelopes, "receipt_hashes": {r: fingerprint(envelopes[r]) for r in ROLES}} + release_id = fingerprint(binding) + db = self.connect() + try: + db.execute("BEGIN IMMEDIATE") + existing = db.execute("SELECT id FROM releases WHERE id=?", (release_id,)).fetchone() + if existing: + db.rollback() + return release_id + db.execute("INSERT INTO releases VALUES (?, ?, 'planned', 1, ?, NULL)", + (release_id, canonical(plan).decode(), min(timestamp(verified[r][1]["expires_at"]) for r in ROLES).isoformat())) + db.execute("INSERT INTO transitions(release_id,phase,observed_at) VALUES (?, 'planned', ?)", (release_id, now.isoformat())) + db.commit() + finally: + db.close() + return release_id + + def advance(self, release_id, backend: Backend, now=None): + """One durable step. Adapter exceptions preserve intent for safe retry. + + Failure/timeout after publication moves to rollback; rollback failure keeps + the unique active slot, halting all new releases until recovery succeeds. + A database write lock serializes adapter calls across broker processes. + """ + if not self.admitted: + raise ValueError("broker identity not admitted") + now = now or datetime.now(timezone.utc) + db = self.connect() + try: + db.execute("BEGIN IMMEDIATE") + row = db.execute("SELECT plan, phase, deadline FROM releases WHERE id=?", (release_id,)).fetchone() + if row is None: + raise ValueError("unknown release") + plan, phase, deadline = json.loads(row[0]), row[1], timestamp(row[2]) + candidate, rollback = plan["candidate"], plan["rollback"] + if phase in TERMINAL: + db.rollback() + return phase + next_phase = phase + if phase == "planned": + if now >= deadline: + # No publication attempted; safe terminal cancellation. + next_phase = "cancelled" + else: + # Persist intent BEFORE the external CAS; a lost response + # must never be mistaken for an unpublished cancellation. + next_phase = "publish_pending" + elif phase == "publish_pending": + if now >= deadline: + next_phase = "rollback_planned" + else: + backend.publish_revision(rollback, candidate) + next_phase = "published" + elif phase == "published": + if now >= deadline: + next_phase = "rollback_planned" + else: + backend.sync_revision(candidate) + next_phase = "synced" + elif phase == "synced": + next_phase = "complete" if now < deadline and backend.healthy(candidate) is True else "rollback_planned" + elif phase == "rollback_planned": + backend.publish_revision(candidate, rollback) + next_phase = "rollback_published" + elif phase == "rollback_published": + backend.sync_revision(rollback) + next_phase = "rollback_synced" + elif phase == "rollback_synced": + if backend.healthy(rollback) is True: + next_phase = "rolled_back" + else: + raise ValueError("unknown durable phase") + db.execute("UPDATE releases SET phase=?, active=? WHERE id=?", + (next_phase, None if next_phase in TERMINAL else 1, release_id)) + if next_phase != phase: + db.execute("INSERT INTO transitions(release_id,phase,observed_at) VALUES (?, ?, ?)", (release_id, next_phase, now.isoformat())) + db.commit() + return next_phase + except Exception: + db.rollback() + raise + finally: + db.close() diff --git a/src/activity_core/release_operations.py b/src/activity_core/release_operations.py new file mode 100644 index 0000000..0d0eddf --- /dev/null +++ b/src/activity_core/release_operations.py @@ -0,0 +1,63 @@ +"""Fixed mutation shapes for the trusted Git/ArgoCD release adapter. + +These builders confer no authority. The admitted adapter must authenticate its +transport, enforce compare-and-swap, and never accept arbitrary path/body inputs. +""" +import copy +import re +import yaml + +REVISION = re.compile(r'[0-9a-f]{40}') +APPLICATION_PATH = 'argocd/railiance01/applications/activity-core.application.yaml' +ROOT_APPLICATION = 'railiance-apps-root' +APPLICATION = 'activity-core' +NAMESPACE = 'argocd' + + +def revision(value): + if not isinstance(value,str) or REVISION.fullmatch(value) is None: + raise ValueError('full commit revision required') + return value + + +def update_child(document, expected, target): + """Change only targetRevision in the one validated child Application.""" + revision(expected);revision(target) + before = yaml.safe_load(document) + if not isinstance(before,dict):raise ValueError('invalid child declaration') + spec=before.get('spec',{});source=spec.get('source',{}) + if (before.get('apiVersion')!='argoproj.io/v1alpha1' or before.get('kind')!='Application' + or before.get('metadata',{}).get('name')!=APPLICATION + or before.get('metadata',{}).get('namespace')!=NAMESPACE + or spec.get('project')!='activity-core' + or source.get('repoURL')!='https://forgejo.coulomb.social/coulomb/activity-core.git' + or source.get('path')!='k8s/gitops' + or spec.get('destination')!={'namespace':'activity-core','server':'https://kubernetes.default.svc'} + or spec.get('syncPolicy',{}).get('automated') is not None + or set(spec.get('syncPolicy',{}).get('syncOptions',[])) != {'CreateNamespace=false','ApplyOutOfSyncOnly=true','PruneLast=true','FailOnSharedResource=true'} + or 'sources' in spec or before.get('metadata',{}).get('finalizers')): + raise ValueError('child outside release contract') + if source.get('targetRevision') not in {expected,target}: + raise ValueError('child revision CAS conflict') + if source['targetRevision']==target:return document + # Preserve comments and all other bytes; ambiguous duplicate YAML keys cannot + # silently widen this one-field text patch. + pattern=re.compile(r'(?m)^ targetRevision: '+re.escape(expected)+r'[ \t]*$') + if len(pattern.findall(document))!=1:raise ValueError('ambiguous child revision field') + updated=pattern.sub(' targetRevision: '+target,document) + wanted=copy.deepcopy(before);wanted['spec']['source']['targetRevision']=target + if yaml.safe_load(updated)!=wanted:raise ValueError('unexpected child mutation') + return updated + + +def sync_operations(platform_revision, activity_revision): + """Return fixed root-selective and child sync bodies, never prune/overrides.""" + revision(platform_revision);revision(activity_revision) + def body(value,resources=None): + sync={'revision':value,'prune':False,'syncStrategy':{'apply':{}}} + if resources is not None:sync['resources']=resources + return {'operation':{'initiatedBy':{'username':'activity-core-release-broker'},'sync':sync}} + return [ + (ROOT_APPLICATION,body(platform_revision,[{'group':'argoproj.io','kind':'Application','name':APPLICATION,'namespace':NAMESPACE}])), + (APPLICATION,body(activity_revision)), + ] diff --git a/tests/test_release_broker.py b/tests/test_release_broker.py new file mode 100644 index 0000000..b4e61a5 --- /dev/null +++ b/tests/test_release_broker.py @@ -0,0 +1,202 @@ +"""Isolated failure proofs: generated test keys and an in-memory deployment adapter.""" +import base64 +import copy +import json +import sqlite3 +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest +import yaml +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + +from activity_core.release_broker import AUTHORITY, REQUIRED_CHECKS, Broker, Receipts, canonical, fingerprint + +ROOT = Path(__file__).resolve().parents[1] +NOW = datetime(2026, 9, 28, 16, tzinfo=timezone.utc) + + +@pytest.fixture +def bundle(tmp_path): + before = list(yaml.safe_load_all((ROOT / 'k8s/gitops/runtime.yaml').read_text())) + after = copy.deepcopy(before) + next(d for d in after if d['metadata']['name'] == 'actcore-worker')['spec']['template']['spec']['containers'][0]['image'] = 'forgejo.coulomb.social/coulomb/activity-core@sha256:'+'f'*64 + keys = {r: Ed25519PrivateKey.generate() for r in ['build','review','health','retention']} + trusted = {r: {'public_key': k.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw), + 'principal':r, 'roles':{r}} for r,k in keys.items()} + broker = Broker(tmp_path/'release.sqlite', Receipts(trusted), admitted=True) + return broker, before, after, keys + + +def images(docs): + return {c['image'] for d in docs if d['kind']=='Deployment' for c in d['spec']['template']['spec']['containers']} + + +def envelopes(before, after, keys, candidate='a'*40, rollback='b'*40): + binding = dict(repository='coulomb/activity-core',application='activity-core', + candidate=candidate,rollback=rollback,before_sha256=fingerprint(before),after_sha256=fingerprint(after)) + out={} + for role,key in keys.items(): + payload=dict(schema=1,role=role,authority=AUTHORITY,binding=binding,result='pass', + issued_at=NOW.isoformat(),expires_at=(NOW+timedelta(minutes=5)).isoformat()) + if role=='health':payload.update(revision=rollback,continuous=True,healthy=True,synced=True,healthy_since=(NOW-timedelta(hours=25)).isoformat()) + if role=='build': + payload['images']=sorted(images(after)) + payload['checks']={name:'success' for name in REQUIRED_CHECKS} + if role=='retention':payload['images']=sorted(images(before)|images(after)) + out[role]={'key_id':role,'payload':payload,'signature':base64.b64encode(key.sign(canonical(payload))).decode()} + return out + + +def resign(receipt,key): + receipt['signature']=base64.b64encode(key.sign(canonical(receipt['payload']))).decode() + + +def admit(bundle, **kwargs): + broker,before,after,keys=bundle + return broker.admit(before,after,'a'*40,'b'*40,envelopes(before,after,keys),now=NOW,**kwargs) + + +class FakeBackend: + def __init__(self): + self.revision='b'*40;self.calls=[];self.fail_health=False;self.fail_rollback=False;self.lose_response=False + def publish_revision(self,expected,target): + if self.revision not in {expected,target}:raise ValueError('CAS conflict') + self.revision=target;self.calls.append(('publish',target)) + if self.lose_response: + self.lose_response=False + raise ConnectionError('lost response after durable publication') + def sync_revision(self,target): + assert self.revision==target + self.calls.append(('sync',target)) + def healthy(self,target): + assert self.revision==target + return not (self.fail_health if target=='a'*40 else self.fail_rollback) + + +def drive(broker, rid, backend, now=NOW): + phases=[] + for _ in range(9): + phase=broker.advance(rid,backend,now);phases.append(phase) + if phase in {'complete','rolled_back','cancelled'}:break + return phases + + +def test_normal_release_restart_and_idempotent_delivery(bundle): + broker,*_=bundle;rid=admit(bundle);backend=FakeBackend() + assert broker.advance(rid,backend,NOW)=='publish_pending' + assert not backend.calls + restarted=Broker(broker.database,broker.receipts,admitted=True) + assert drive(restarted,rid,backend)==['published','synced','complete'] + assert admit(bundle)==rid + previous=list(backend.calls) + assert restarted.advance(rid,backend,NOW)=='complete' + assert backend.calls==previous + with broker.connect() as db: + assert [r[0] for r in db.execute('SELECT phase FROM transitions ORDER BY sequence')]==['planned','publish_pending','published','synced','complete'] + + +def test_health_failure_rolls_back_through_same_adapter(bundle): + broker,*_=bundle;rid=admit(bundle);backend=FakeBackend();backend.fail_health=True + assert drive(broker,rid,backend)[-1]=='rolled_back' + assert backend.calls==[('publish','a'*40),('sync','a'*40),('publish','b'*40),('sync','b'*40)] + + +def test_failed_rollback_holds_exclusive_slot(bundle): + broker,before,after,keys=bundle;rid=admit(bundle);backend=FakeBackend();backend.fail_health=True;backend.fail_rollback=True + assert drive(broker,rid,backend)[-1]=='rollback_synced' + with pytest.raises(sqlite3.IntegrityError): + broker.admit(before,after,'c'*40,'b'*40,envelopes(before,after,keys,candidate='c'*40),now=NOW) + backend.fail_rollback=False + assert broker.advance(rid,backend,NOW)=='rolled_back' + + +def test_lost_publish_response_and_expiry_recovers_prior_revision(bundle): + broker,*_=bundle;rid=admit(bundle);backend=FakeBackend();backend.lose_response=True + assert broker.advance(rid,backend,NOW)=='publish_pending' + with pytest.raises(ConnectionError):broker.advance(rid,backend,NOW) + assert backend.revision=='a'*40 + restarted=Broker(broker.database,broker.receipts,admitted=True) + assert drive(restarted,rid,backend,NOW+timedelta(minutes=6))[-1]=='rolled_back' + assert backend.revision=='b'*40 + + +def test_expired_plan_never_publishes(bundle): + broker,*_=bundle;rid=admit(bundle);backend=FakeBackend() + assert broker.advance(rid,backend,NOW+timedelta(minutes=6))=='cancelled' + assert backend.calls==[] + + +@pytest.mark.parametrize('kind',['signature','commit','stale','future','key','role','scope','retention','build','independence','health','checks']) +def test_rejects_untrusted_or_incomplete_evidence(bundle,kind): + broker,before,after,keys=bundle;e=envelopes(before,after,keys);r=e['review'] + if kind=='signature':r['payload']['result']='fail' + elif kind=='commit':r['payload']['binding']['candidate']='c'*40;resign(r,keys['review']) + elif kind=='stale':r['payload']['issued_at']=(NOW-timedelta(minutes=6)).isoformat();resign(r,keys['review']) + elif kind=='future':r['payload']['issued_at']=(NOW+timedelta(seconds=1)).isoformat();resign(r,keys['review']) + elif kind=='key':r['key_id']='caller-provided' + elif kind=='role':r['key_id']='build' + elif kind=='scope':r['payload']['authority']='admin';resign(r,keys['review']) + elif kind=='retention':e['retention']['payload']['images']=[];resign(e['retention'],keys['retention']) + elif kind=='build':e['build']['payload']['images']=[];resign(e['build'],keys['build']) + elif kind=='independence':broker.receipts.trusted['review']['principal']='build' + elif kind=='checks':e['build']['payload']['checks']={};resign(e['build'],keys['build']) + elif kind=='health':e['health']['payload']['continuous']=False;resign(e['health'],keys['health']) + with pytest.raises((ValueError,InvalidSignature)): + broker.admit(before,after,'a'*40,'b'*40,e,now=NOW) + with broker.connect() as db:assert db.execute('SELECT count(*) FROM releases').fetchone()[0]==0 + + +def test_signed_out_of_scope_manifest_still_refused(bundle): + broker,before,after,keys=bundle + next(d for d in after if d['kind']=='Deployment')['spec']['replicas']=99 + with pytest.raises(ValueError,match='non-image'): + broker.admit(before,after,'a'*40,'b'*40,envelopes(before,after,keys),now=NOW) + + +def test_unadmitted_identity_cannot_start_or_resume(bundle): + broker,*_=bundle;rid=admit(bundle);broker.admitted=False + with pytest.raises(ValueError,match='not admitted'):admit(bundle) + with pytest.raises(ValueError,match='not admitted'):broker.advance(rid,FakeBackend(),NOW) + + +def test_fixed_git_and_argo_mutation_shapes(): + from activity_core.release_operations import update_child, sync_operations + declaration = '''apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: activity-core + namespace: argocd +spec: + project: activity-core + source: + repoURL: https://forgejo.coulomb.social/coulomb/activity-core.git + targetRevision: BBBB + path: k8s/gitops + destination: + namespace: activity-core + server: https://kubernetes.default.svc + syncPolicy: + syncOptions: [CreateNamespace=false, ApplyOutOfSyncOnly=true, PruneLast=true, FailOnSharedResource=true] +'''.replace('BBBB','b'*40) + updated=update_child(declaration,'b'*40,'a'*40) + assert updated==declaration.replace('b'*40,'a'*40) + assert update_child(updated,'b'*40,'a'*40)==updated + with pytest.raises(ValueError,match='CAS'):update_child(declaration,'c'*40,'a'*40) + with pytest.raises(ValueError):update_child(declaration.replace('namespace: activity-core','namespace: foreign'),'b'*40,'a'*40) + with pytest.raises(ValueError):update_child(declaration,'b'*40,'main') + root,child=sync_operations('c'*40,'a'*40) + assert root[0]=='railiance-apps-root' and child[0]=='activity-core' + assert root[1]['operation']['sync']['resources']==[{'group':'argoproj.io','kind':'Application','name':'activity-core','namespace':'argocd'}] + assert all(op['operation']['sync']['prune'] is False for _,op in [root,child]) + + +def test_different_principals_cannot_share_review_signing_key(bundle): + broker,before,after,keys=bundle + e=envelopes(before,after,keys) + broker.receipts.trusted['review']['public_key']=broker.receipts.trusted['build']['public_key'] + resign(e['review'],keys['build']) + with pytest.raises(ValueError,match='independent'): + broker.admit(before,after,'a'*40,'b'*40,e,now=NOW) diff --git a/uv.lock b/uv.lock index a93be59..92f7718 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,7 @@ source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "asyncpg" }, + { name = "cryptography" }, { name = "fastapi" }, { name = "httpx" }, { name = "nats-py" }, @@ -29,6 +30,7 @@ dev = [ requires-dist = [ { name = "alembic", specifier = ">=1.14" }, { name = "asyncpg", specifier = ">=0.29" }, + { name = "cryptography", specifier = ">=44.0" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.27" }, { name = "nats-py", specifier = ">=2.7" }, @@ -144,6 +146,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707 }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838 }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168 }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805 }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716 }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569 }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907 }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807 }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252 }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214 }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408 }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470 }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096 }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941 }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821 }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719 }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799 }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389 }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249 }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775 }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822 }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232 }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597 }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292 }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919 }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093 }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248 }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908 }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805 }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764 }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722 }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369 }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175 }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670 }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824 }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148 }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564 }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263 }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688 }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078 }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064 }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720 }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964 }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962 }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328 }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985 }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530 }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525 }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053 }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213 }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682 }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949 }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947 }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504 }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259 }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864 }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538 }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688 }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803 }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763 }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688 }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868 }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104 }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402 }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043 }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737 }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933 }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002 }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271 }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919 }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529 }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630 }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134 }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197 }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683 }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897 }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935 }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464 }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262 }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779 }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520 }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673 }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835 }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705 }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539 }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707 }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772 }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360 }, +] + [[package]] name = "click" version = "8.3.1" @@ -165,6 +265,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153 }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133 }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478 }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726 }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524 }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720 }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866 }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028 }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405 }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230 }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596 }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082 }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826 }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525 }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817 }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300 }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039 }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388 }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293 }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031 }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344 }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201 }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023 }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362 }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247 }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806 }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307 }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900 }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357 }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474 }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862 }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942 }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347 }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050 }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955 }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694 }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413 }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355 }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429 }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350 }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675 }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410 }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378 }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889 }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006 }, +] + [[package]] name = "fastapi" version = "0.135.1" @@ -464,6 +620,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687 }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + [[package]] name = "pydantic" version = "2.12.5" diff --git a/workplans/ACTIVITY-WP-0041-gitops-adoption.md b/workplans/ACTIVITY-WP-0041-gitops-adoption.md index b0999ed..8bd28ca 100644 --- a/workplans/ACTIVITY-WP-0041-gitops-adoption.md +++ b/workplans/ACTIVITY-WP-0041-gitops-adoption.md @@ -150,3 +150,22 @@ scoped source/sync broker: ArgoCD Core offers no API-server token lane, and a Kubernetes Application patch grant cannot restrict fields. Concrete enforcement contract is platform docs/activity-core-release-admission.md. Authenticated receipts and automatic rollback proof remain required; no broad token is admitted. + +## Broker core and isolated recovery proof — 2026-09-27 + +Implemented Ed25519 receipt verification with trusted role/principal bindings, +independent producer/reviewer keys, exact commit/manifest/image bindings, mandatory +CI contexts, freshness, 24-hour signed observation and live/rollback retention +coverage. Shared image policy remains the admission gate. Fixed mutation builders +restrict platform edits to the child revision and ArgoCD operations to selective +root/child sync without pruning. SQLite serializes releases and records signed +receipts plus transitions; publication intent precedes external calls. Isolated +fixtures prove restart, lost publication responses, failed-health rollback and +failed rollback holding the release lock. No production authority is inferred. + +See docs/release-broker.md for implemented behavior and exact remaining work: +authenticated Git/ArgoCD adapter, custody/admission, real attestation issuers and +continuous observer, Temporal dispatch and isolated transport/rollback proof. +The broker is disabled by default and not connected to live credentials or a +production schedule. Current deployed revision and healthy-soak clock are unchanged. +T03 stays progress; the full unattended acceptance is not complete.