From 6e5186401e78cc8ba7b6ffe18eb24712ef9263b1 Mon Sep 17 00:00:00 2001 From: Hannes Zietsman Date: Wed, 4 Nov 2026 19:41:12 +0200 Subject: [PATCH] feat: expose per-service pinned and stable-follow policies --- .../auto-advance-20261104/anchors-final.md | 2 + .../auto-advance-20261104/authority-gate.json | 11 + .../auto-advance-20261104/authority.json | 19 + .../browser-verification.json | 16 + .../final-annotated-tests.log | 28 + .../full-tests-final.log | 72 +++ .../auto-advance-20261104/native-negative.log | 1 + .../native-source-equivalence.json | 21 + .../native-verification.json | 1 + .../auto-advance-20261104/owner-approval.json | 8 + .traceweaver/auto-advance-20261104/plan.md | 50 ++ .../auto-advance-20261104/profile.json | 25 + .../auto-advance-20261104/red-integration.log | 579 ++++++++++++++++++ .../red-selected-card.log | 23 + .../review-correctness.json | 23 + .../review-definition.json | 18 + .../review-route-request.json | 17 + .../review-route-result.json | 1 + .../review-security.json | 15 + .../auto-advance-20261104/source-hashes.json | 7 + .../auto-advance-20261104/validation.md | 3 + .../auto-advance-20261104/verification.json | 40 ++ .../auto-advance-20261104/vv-capsule.json | 148 +++++ .traceweaver/intent-contract.yml | 7 + landing-page/index.html | 103 +++- requirements.md | 36 ++ src/cryptolabs_proxy/auth.py | 8 + src/cryptolabs_proxy/release_client.py | 12 +- tests/test_auth.py | 60 ++ tests/test_release_policy_ui.py | 71 +++ traceability-matrix.md | 35 +- 31 files changed, 1451 insertions(+), 9 deletions(-) create mode 100644 .traceweaver/auto-advance-20261104/anchors-final.md create mode 100644 .traceweaver/auto-advance-20261104/authority-gate.json create mode 100644 .traceweaver/auto-advance-20261104/authority.json create mode 100644 .traceweaver/auto-advance-20261104/browser-verification.json create mode 100644 .traceweaver/auto-advance-20261104/final-annotated-tests.log create mode 100644 .traceweaver/auto-advance-20261104/full-tests-final.log create mode 100644 .traceweaver/auto-advance-20261104/native-negative.log create mode 100644 .traceweaver/auto-advance-20261104/native-source-equivalence.json create mode 100644 .traceweaver/auto-advance-20261104/native-verification.json create mode 100644 .traceweaver/auto-advance-20261104/owner-approval.json create mode 100644 .traceweaver/auto-advance-20261104/plan.md create mode 100644 .traceweaver/auto-advance-20261104/profile.json create mode 100644 .traceweaver/auto-advance-20261104/red-integration.log create mode 100644 .traceweaver/auto-advance-20261104/red-selected-card.log create mode 100644 .traceweaver/auto-advance-20261104/review-correctness.json create mode 100644 .traceweaver/auto-advance-20261104/review-definition.json create mode 100644 .traceweaver/auto-advance-20261104/review-route-request.json create mode 100644 .traceweaver/auto-advance-20261104/review-route-result.json create mode 100644 .traceweaver/auto-advance-20261104/review-security.json create mode 100644 .traceweaver/auto-advance-20261104/source-hashes.json create mode 100644 .traceweaver/auto-advance-20261104/validation.md create mode 100644 .traceweaver/auto-advance-20261104/verification.json create mode 100644 .traceweaver/auto-advance-20261104/vv-capsule.json create mode 100644 tests/test_release_policy_ui.py diff --git a/.traceweaver/auto-advance-20261104/anchors-final.md b/.traceweaver/auto-advance-20261104/anchors-final.md new file mode 100644 index 0000000..0a6ac2a --- /dev/null +++ b/.traceweaver/auto-advance-20261104/anchors-final.md @@ -0,0 +1,2 @@ +# Code Traceability Findings + diff --git a/.traceweaver/auto-advance-20261104/authority-gate.json b/.traceweaver/auto-advance-20261104/authority-gate.json new file mode 100644 index 0000000..bbbc0fd --- /dev/null +++ b/.traceweaver/auto-advance-20261104/authority-gate.json @@ -0,0 +1,11 @@ +{ + "decision": "Proceed", + "scope": "exactapprovedplanC1-C8andnamedfiles;noinferrednewrequirements", + "authority": "authority.json andowner-approval.json", + "profile": "profile.json/frozenbeforeproductionmutations", + "vv": "vv-capsule.json/review-definition.json", + "baseline_hash": "94b783d925834004cd3f111dd2437f8ae531850f0184aaa29c52f89f84267795", + "must_not_change": "otherroles/modes/pins/runtimes/accounts/data;existingtrustandlifecycleguardcontracts;installationmanual", + "publication": "separatecontrolledrouteaftercleanreview/verification", + "runtime_acceptance": "helduntilactualnative/browser/publisher/bridgeevidence" +} diff --git a/.traceweaver/auto-advance-20261104/authority.json b/.traceweaver/auto-advance-20261104/authority.json new file mode 100644 index 0000000..2acb99a --- /dev/null +++ b/.traceweaver/auto-advance-20261104/authority.json @@ -0,0 +1,19 @@ +{ + "mode": "approved implementation and controlled publication/LTX activation", + "baseline_id": "AUTOADVANCE-20261104", + "approved_plan_sha256": "94b783d925834004cd3f111dd2437f8ae531850f0184aaa29c52f89f84267795", + "owner_record": "owner-approval.json", + "ids_are_administrative_mappings": true, + "requirements": { + "REQ-AUTOADVANCE-001": "exact approved plan C1", + "REQ-AUTOADVANCE-002": "exact approved plan C2", + "REQ-AUTOADVANCE-003": "exact approved plan C3", + "REQ-AUTOADVANCE-004": "exact approved plan C4", + "REQ-AUTOADVANCE-005": "exact approved plan C5", + "REQ-AUTOADVANCE-006": "exact approved plan C6", + "REQ-AUTOADVANCE-007": "exact approved plan C7", + "REQ-AUTOADVANCE-008": "exact approved plan C8" + }, + "must_preserve": "allother service modes,pins,data,accounts,lifecycleowner/trustcontracts;manualinstallation;no prerelease/majorautoadvance", + "existing_Fleet_dependency": "native/publisher acceptance still required beforeactivation" +} diff --git a/.traceweaver/auto-advance-20261104/browser-verification.json b/.traceweaver/auto-advance-20261104/browser-verification.json new file mode 100644 index 0000000..9eb8d35 --- /dev/null +++ b/.traceweaver/auto-advance-20261104/browser-verification.json @@ -0,0 +1,16 @@ +{ + "scope": "real Chrome on actual candidate page; synthetic API fixture; no LTX operation", + "result": "passed", + "steps": [ + "default pinned", + "select Follow stable", + "Check Updates selects synthetic1.4.1 without changing installed1.4.0", + "reload retains mode and pending target", + "repin returns installed1.4.0", + "next check stays pinned" + ], + "requests": 4, + "install_requests": 0, + "pending_screenshot": "follow-stable-pending.jpg", + "repin_screenshot": "repinned.jpg" +} diff --git a/.traceweaver/auto-advance-20261104/final-annotated-tests.log b/.traceweaver/auto-advance-20261104/final-annotated-tests.log new file mode 100644 index 0000000..fd8f8cd --- /dev/null +++ b/.traceweaver/auto-advance-20261104/final-annotated-tests.log @@ -0,0 +1,28 @@ +........................................................................ [ 59%] +.................................................. [100%] +=============================== warnings summary =============================== +tests/test_auth.py: 68 warnings + /private/tmp/ltx-auto-advance-20261104/cryptolabs-proxy/tests/../src/cryptolabs_proxy/auth.py:375: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + 'created_at': datetime.utcnow().isoformat(), + +tests/test_auth.py: 58 warnings + /private/tmp/ltx-auto-advance-20261104/cryptolabs-proxy/tests/../src/cryptolabs_proxy/auth.py:472: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + users[username]['last_login'] = datetime.utcnow().isoformat() + +tests/test_auth.py::TestAccountLockout::test_lockout_after_max_attempts +tests/test_auth.py::TestAccountLockout::test_lockout_expires + /private/tmp/ltx-auto-advance-20261104/cryptolabs-proxy/tests/../src/cryptolabs_proxy/auth.py:491: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + datetime.utcnow() + timedelta(minutes=lockout_mins) + +tests/test_auth.py::TestAccountLockout::test_lockout_after_max_attempts +tests/test_auth.py::TestAccountLockout::test_lockout_expires +tests/test_auth.py::TestAccountLockout::test_lockout_expires + /private/tmp/ltx-auto-advance-20261104/cryptolabs-proxy/tests/../src/cryptolabs_proxy/auth.py:463: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + if datetime.fromisoformat(locked_until) > datetime.utcnow(): + +tests/test_auth.py::TestAccountLockout::test_lockout_expires + /private/tmp/ltx-auto-advance-20261104/cryptolabs-proxy/tests/test_auth.py:442: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + datetime.utcnow() - timedelta(minutes=5) + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +122 passed, 132 warnings in 6.64s diff --git a/.traceweaver/auto-advance-20261104/full-tests-final.log b/.traceweaver/auto-advance-20261104/full-tests-final.log new file mode 100644 index 0000000..4e99a26 --- /dev/null +++ b/.traceweaver/auto-advance-20261104/full-tests-final.log @@ -0,0 +1,72 @@ +........................................................................ [ 6%] +........................................................................ [ 12%] +........................................................................ [ 19%] +........................................................................ [ 25%] +........................................................................ [ 32%] +........................................................................ [ 38%] +........................................................................ [ 45%] +..s..................................................................... [ 51%] +........................................................................ [ 58%] +........................................................................ [ 64%] +........................................................................ [ 71%] +........................................................................ [ 77%] +........................................................................ [ 84%] +........................................................................ [ 90%] +..............................................................s......... [ 97%] +............................ [100%] +=============================== warnings summary =============================== +tests/test_account_link.py: 54 warnings +tests/test_auth.py: 68 warnings +tests/test_exporter_auth_protections.py: 131 warnings +tests/test_landing_support.py: 2 warnings +tests/test_netbox_link.py: 41 warnings +tests/test_proxy_customer_route_repairs.py: 28 warnings +tests/test_proxy_lifecycle_review_repairs.py: 21 warnings +tests/test_proxy_recovery_tls_repairs.py: 54 warnings +tests/test_release_selection.py: 1 warning +tests/test_routes.py: 73 warnings +tests/test_updates.py: 1 warning +tests/test_vast_price_manager.py: 3 warnings + /private/tmp/ltx-auto-advance-20261104/cryptolabs-proxy/tests/../src/cryptolabs_proxy/auth.py:375: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + 'created_at': datetime.utcnow().isoformat(), + +tests/test_account_link.py: 49 warnings +tests/test_auth.py: 58 warnings +tests/test_exporter_auth_protections.py: 60 warnings +tests/test_netbox_link.py: 42 warnings +tests/test_proxy_lifecycle_review_repairs.py: 2 warnings +tests/test_release_selection.py: 1 warning +tests/test_routes.py: 61 warnings +tests/test_updates.py: 1 warning +tests/test_vast_price_manager.py: 3 warnings + /private/tmp/ltx-auto-advance-20261104/cryptolabs-proxy/tests/../src/cryptolabs_proxy/auth.py:472: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + users[username]['last_login'] = datetime.utcnow().isoformat() + +tests/test_auth.py::TestAccountLockout::test_lockout_after_max_attempts +tests/test_auth.py::TestAccountLockout::test_lockout_expires +tests/test_routes.py::TestVastPriceManagerFleetSession::test_reauth_reports_detectable_password_throttling + /private/tmp/ltx-auto-advance-20261104/cryptolabs-proxy/tests/../src/cryptolabs_proxy/auth.py:491: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + datetime.utcnow() + timedelta(minutes=lockout_mins) + +tests/test_auth.py::TestAccountLockout::test_lockout_after_max_attempts +tests/test_auth.py::TestAccountLockout::test_lockout_expires +tests/test_auth.py::TestAccountLockout::test_lockout_expires + /private/tmp/ltx-auto-advance-20261104/cryptolabs-proxy/tests/../src/cryptolabs_proxy/auth.py:463: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + if datetime.fromisoformat(locked_until) > datetime.utcnow(): + +tests/test_auth.py::TestAccountLockout::test_lockout_expires + /private/tmp/ltx-auto-advance-20261104/cryptolabs-proxy/tests/test_auth.py:442: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + datetime.utcnow() - timedelta(minutes=5) + +tests/test_routes.py::TestVastPriceManagerFleetSession::test_reauth_reports_detectable_password_throttling + /private/tmp/ltx-auto-advance-20261104/cryptolabs-proxy/tests/../src/cryptolabs_proxy/auth.py:1884: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + datetime.fromisoformat(current['locked_until']) > datetime.utcnow() + +tests/test_vast_price_manager.py::test_concurrent_cli_registrations_preserve_both_services +tests/test_vast_price_manager.py::test_concurrent_cli_registrations_preserve_both_services +tests/test_vast_price_manager.py::test_manual_rollback_blocks_lifecycle_command_until_runtime_and_registry_restore_finish + /Users/hanneszietsman/.local/share/uv/python/cpython-3.12.12-macos-aarch64-none/lib/python3.12/multiprocessing/popen_fork.py:66: DeprecationWarning: This process (pid=98944) is multi-threaded, use of fork() may lead to deadlocks in the child. + self.pid = os.fork() + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +1106 passed, 2 skipped, 765 warnings in 105.97s (0:01:45) diff --git a/.traceweaver/auto-advance-20261104/native-negative.log b/.traceweaver/auto-advance-20261104/native-negative.log new file mode 100644 index 0000000..c7a9731 --- /dev/null +++ b/.traceweaver/auto-advance-20261104/native-negative.log @@ -0,0 +1 @@ +{"scope": "disposable Linux fixture; no production key or catalog", "cases": [{"case": "bad-real-signature-no-partial-persistence", "result": "passed"}, {"case": "real-SO_PEERCRED-rejects-UID65534-claiming-root", "result": "passed", "restored_socket_mode": "0600"}]} diff --git a/.traceweaver/auto-advance-20261104/native-source-equivalence.json b/.traceweaver/auto-advance-20261104/native-source-equivalence.json new file mode 100644 index 0000000..4719dba --- /dev/null +++ b/.traceweaver/auto-advance-20261104/native-source-equivalence.json @@ -0,0 +1,21 @@ +{ + "method": "Both Python3.12 AST without comments/locations; trace-comment changes after wheel build", + "matched": true, + "initial_attempt": "cross-version Python3.14/3.12 AST hashes incomparable; rerun with matching Python3.12", + "guest": { + "cryptolabs_proxy/auth.py": "c40e04b318910ce1a2c1eedfac710d1f8c5ce60959da43f5316979229a21b42d", + "cryptolabs_proxy/release_client.py": "66dd68c121586470256923237436033d60e308e7c60f84ad6141d42c4bf63fe7", + "dc_overview/cli.py": "3255b052cac6e7dd165189fc95f5357c92a32854254c4ea392b24122c303f04c", + "dc_overview/fleet_upgrade.py": "5f696be2b9c06780b76e8c617485857752d171f025f27425ef292089997894dc", + "dc_overview/release_broker.py": "afb854ac3640f6dcc528cf5aa8fce8f19eacc0916f0fecc7bfa2ceff8000cc1e", + "dc_overview/release_policy.py": "b72cf1d61337b9dbcf2cb9bb0ecb9c95659efde0d1074e1ede22d730392c86ee" + }, + "local": { + "cryptolabs_proxy/auth.py": "c40e04b318910ce1a2c1eedfac710d1f8c5ce60959da43f5316979229a21b42d", + "cryptolabs_proxy/release_client.py": "66dd68c121586470256923237436033d60e308e7c60f84ad6141d42c4bf63fe7", + "dc_overview/cli.py": "3255b052cac6e7dd165189fc95f5357c92a32854254c4ea392b24122c303f04c", + "dc_overview/fleet_upgrade.py": "5f696be2b9c06780b76e8c617485857752d171f025f27425ef292089997894dc", + "dc_overview/release_broker.py": "afb854ac3640f6dcc528cf5aa8fce8f19eacc0916f0fecc7bfa2ceff8000cc1e", + "dc_overview/release_policy.py": "b72cf1d61337b9dbcf2cb9bb0ecb9c95659efde0d1074e1ede22d730392c86ee" + } +} diff --git a/.traceweaver/auto-advance-20261104/native-verification.json b/.traceweaver/auto-advance-20261104/native-verification.json new file mode 100644 index 0000000..8fac944 --- /dev/null +++ b/.traceweaver/auto-advance-20261104/native-verification.json @@ -0,0 +1 @@ +{"application_health":"not exercised; two controller images kept idle","discovery":"synthetic signed raw bundles; actual signature verification; no production catalog claim","production":false,"results":[{"case":"real-docker-pin-follow-refresh","result":"passed"},{"case":"repin-installed-no-advance","result":"passed"},{"case":"abrupt-death-before-native","exit":77,"recovered_mode":"pinned","result":"passed"},{"case":"abrupt-death-after-selection","exit":77,"recovered_mode":"pinned","result":"passed"},{"case":"abrupt-death-after-public","exit":77,"recovered_mode":"follow-stable","result":"passed"},{"case":"abrupt-death-before-policy","exit":77,"recovered_mode":"follow-stable","result":"passed"},{"case":"competing-process-and-repin-cas","result":"passed"},{"case":"actual-root-socket-cli-and-systemd-refresh","result":"passed","service_status":"Result=success\nExecMainStatus=0","timer":"NextElapseUSecRealtime=Thu 2026-11-05 00:10:52 SAST\nActiveState=active","unauthorized_uid_denied":65534},{"case":"zero-container-replacement-or-restarts","identities":{"cryptolabs-proxy":{"Id":"9a0b552ae7fc78d9e19334ad0e16133f28b777c3ae9679996be57ab00b50b180","Image":"sha256:8fc51cde1555f7b6a3b21faf20740cae3eff145c6d661868c4a1ad6a07dd7874","RestartCount":0,"StartedAt":"2026-11-04T17:31:14.768729077Z"},"ipmi-monitor":{"Id":"a6d86e80adb3d052b96fde553957106d9a795e2bae983f7cb1761c1006178d81","Image":"sha256:84563cf838bd34c12cc83239c06c44f9a69c01b9dfff570e35ca913ef1825dac","RestartCount":0,"StartedAt":"2026-11-04T17:31:16.84448682Z"}},"result":"passed"}]} \ No newline at end of file diff --git a/.traceweaver/auto-advance-20261104/owner-approval.json b/.traceweaver/auto-advance-20261104/owner-approval.json new file mode 100644 index 0000000..3dd0cf1 --- /dev/null +++ b/.traceweaver/auto-advance-20261104/owner-approval.json @@ -0,0 +1,8 @@ +{ + "utc": "2026-11-04T16:34:29.958547+00:00", + "owner": "Hannes Zietsman", + "message": "Yes approve the plan", + "approved_plan_sha256": "94b783d925834004cd3f111dd2437f8ae531850f0184aaa29c52f89f84267795", + "scope": "all candidatebehavior C1-C8, numberedimplementation andCI/release/LTXverification inexactplan", + "no_repeat_approval_needed": true +} diff --git a/.traceweaver/auto-advance-20261104/plan.md b/.traceweaver/auto-advance-20261104/plan.md new file mode 100644 index 0000000..58083ab --- /dev/null +++ b/.traceweaver/auto-advance-20261104/plan.md @@ -0,0 +1,50 @@ +# Per-service pin removal and stable release following — candidate plan + +Status: prepared for owner plan approval; no implementation or target changes. +Source request: owner approved verified stable selection advancement with manual installation, and added that users must be able to remove a pin and follow stable releases. +Scope: LTX Fleet website, reversible per-service administrator choice, trusted selection advancement, manual Update action, CI/release and LTX verification through the existing controlled routes. Existing services keep their current mode until an administrator changes it. + +## Observed source boundaries + +- Deployed Proxy1.6.2 source main c5e88a99b4da6fe37cacc64fc0cda5a59e3617de; upstream dev10f48b4474a1c2c771bf72449d22efddd91f0d02 contains additional unreleased release-client/auth bridge work. Do not import dev wholesale. +- Current production release_selection.py permits baseline/customer-pin/unmanaged, and release-selection docs keep selection files root-owned/read-only in Proxy. A browser toggle cannot safely be a direct file write. +- Existing DC Fleet source worktree dc-overview-fleet-service-recovery-20261102 at01011f44d93ec86027db1c0d33238b4ea90e6880 includes release_broker.py, release_catalog.py, release_candidates.py and fixed systemd socket transport. Its authority explicitly retains native/publisher/deployment holds. This is reusable source context, not accepted live infrastructure. +- Existing broker previews update a pinned identity when explicitly selecting a candidate; durable follow/pin intent and selection-only refresh are missing. Current list/preview/apply/status/recover protocol must be extended deliberately. +- Second Brain did not locate relevant current release-policy context; direct source and exact GitHub refs supplied these findings. Existing project graph covers older monitoring/exporter paths, not this feature. + +## Candidate behavior and acceptance criteria + +C1. An authenticated Fleet administrator can switch one supported managed service between Pinned and Follow stable; the visible installed/selected version and mode remain distinct. +C2. Removing a pin opts only that service into verified stable candidates. Existing pins remain pinned by default. Pin current version freezes the verified installed digest; if its identity/provenance cannot be established, the operation fails without changing policy. +C3. Follow stable selects the newest admitted compatible non-prerelease version within the installed major version. No downgrade; a major upgrade requires explicit selection. No usable newer candidate leaves the current selection intact and reports why. +C4. Selection refresh occurs on Check Updates and a daily host-side check, including when no browser is open. It changes only opted-in services. Mode changes and refresh never install or restart containers. The existing Update action handles installation. +C5. Policy mutation uses the existing host owner boundary, authenticated administrator/session/CSRF/origin checks, explicit service scope, bounded fields, selection compare-and-swap, and existing lifecycle locks. A native CLI exposes the same choices. Failed persistence, stale requests, active operations, unavailable discovery or failed trust verification preserve prior selections and modes. +C6. Use verified published release/admission metadata, fixed trusted repository/channel sources and exact immutable digests. Do not replace digests with mutable :latest tags or loosen publisher/signature checks. External-owned services retain their owning lifecycle workflow; unsupported services show an explanation rather than pretending the change succeeded. +C7. Show current mode, installed version, selected target and clear failure/progress states; refreshes cannot silently re-pin or cross major versions. Re-pinning prevents a concurrent scheduled refresh from advancing afterward. +C8. TDD, independent correctness/security review, native Linux lifecycle/policy verification and real-browser checks precede release. Existing LTX data/accounts/settings and unrelated pins/runtimes remain preserved. + +## Numbered implementation plan + +1. Write failing tests first. DC: new tests/test_release_policy.py plus existing tests/test_release_broker.py, test_release_candidates.py and test_scoped_release_selection.py. Proxy: API/client tests for the existing release bridge plus tests/test_latest_status.py. Cover pin removal/re-pinning, stable choice, same-major rule, downgrade/prerelease rejection, no eligible candidate, mode persistence, no restarts, selection races, active lifecycle locks, unauthorized/CSRF/origin rejection, interrupted publication and unrelated-service preservation. +2. Add the smallest per-service policy owner module src/dc_overview/release_policy.py. Extend src/dc_overview/release_broker.py and release_catalog.py with bounded policy read/change and selection-only refresh, reusing existing trusted candidate discovery and atomic lifecycle publication. Add CLI parity in cli.py and a daily installed systemd timer/service invoking that exact owner operation. Policy intent lives in a separate root-owned versioned record; do not add unrecognized fields to the existing selection schema. Preserve all existing source policies on first install. +3. Extend Proxy src/cryptolabs_proxy/release_client.py and the existing administrator/CSRF-protected release routes in auth.py. Add mode and target controls to landing-page/index.html and consistent status in scripts/health-api.py/updates.py where required. Browser sends only bounded service/mode/expected identity fields; it cannot supply file paths, commands or arbitrary registry URLs. No new public trust source or credential store. +4. Verify Red-Green-Refactor, focused and appropriate regression suites, independent correctness/security/API review and real Linux owner tests. Native scenarios include two competing refresh/pin requests, crash/restart, unavailable catalog, invalid admission, persistent daily refresh without a browser, and zero lifecycle calls for policy-only operations. Run real Chrome with pending and installed targets; prove unpin -> verified target -> manual update -> healthy, then re-pin -> no subsequent advance. Do not manufacture a production downgrade or extra hardware restart solely for a demo. +5. Complete the existing broker/bridge native and publisher acceptance prerequisites before activation. Publish only the reviewed required source on dev, release through main with normal versioning and GitHub CI, inspect immutable official artifacts, deploy through native LTX ownership, then verify UI persistence, exact selected digests, original history/accounts and unrelated containers. Keep automatic installation off. If prerequisite infrastructure remains held, report that specific deployment hold without claiming the feature is live. + +## Dependencies, risks and decisions + +No new third-party service or Python dependency is proposed. Host systemd timer and the accepted broker/bridge runtime are infrastructure changes. The existing Fleet broker source is not currently accepted on LTX; finishing that dependency is required and must not be hidden as a simple UI-only change. + +Selected approach: immutable digests plus explicit persisted follow intent. A mutable :latest tag loses the selected artifact contract; a frontend-only toggle cannot cross root custody safely; duplicating a second lifecycle controller creates competing writers. Reuse and extend the existing owner and release pipeline instead. + +The principal risks are choosing an untrusted/incompatible release, advancing a freshly re-pinned service, lost multi-component updates, and selection/installation confusion. Mitigations are admitted candidates, same-major/no-downgrade rules, explicit compare-and-swap plus existing locks, transactional policy publication/recovery, strict one-service scope, clear UI and native concurrency/failure tests. + +## Verification and validation plan + +Verification owner: main implementation chat. Every result records tested source, command/scenario, expected/actual state and evidence path under this directory. A local synthetic UI tests controls/request contracts only; actual Linux owner evidence is required for root custody, sockets, timer, atomic changes and lifecycle preservation. + +Stakeholder acceptance owner/rater: Hannes Zietsman, operator requesting the behavior, independent of implementation. Observable acceptance: an administrator removes IPMI's pin in Fleet, sees Follow stable persist across reload, sees a verified compatible stable target chosen without an automatic restart, can install through Update, and can re-pin so future checks keep the version. If no genuine newer admitted release exists on LTX, pending-release installation is demonstrated on native disposable staging and LTX validates current mode/identity/preservation; no false production upgrade claim. + +## Authority and publication status + +Source request supports the feature intent. This plan introduces concrete files, API/root-owner behavior and scheduling details, so it remains candidate authority until explicit plan approval under project AGENTS.md section2. No production source, selection, service, credential, CI publication or deployment has changed in this planning turn. Current implementation baseline documents are read for constraints; existing Fleet-held acceptance claims are not promoted by this plan. diff --git a/.traceweaver/auto-advance-20261104/profile.json b/.traceweaver/auto-advance-20261104/profile.json new file mode 100644 index 0000000..9744cf2 --- /dev/null +++ b/.traceweaver/auto-advance-20261104/profile.json @@ -0,0 +1,25 @@ +{ + "revision": 1, + "level": "L3", + "reason": "root-owned release policy, authenticated write API, scheduled selections, external artifact trust and deployment", + "review_scopes": [ + "correctness/concurrency/recovery", + "security/API/auth/trust" + ], + "review_repair_caps": "initial reviews plus at most two targeted repair reviews per changed scope; unresolved findings hold publication", + "controls": [ + "TDD", + "strict candidate trust", + "native Linux concurrency/crash/timer tests", + "browser admin controls", + "normal CI publication", + "native LTX deployment preservation" + ], + "subagent_builders": false, + "models": "current inherited model; review perspectives only if native review workflow dispatched", + "frozen_for_build": true, + "estimate_delivery_minutes": 240, + "estimate_process_target_minutes": 45, + "estimate_process_ceiling_minutes": 120, + "profile_hash": "3ac80e3c2632f12a44fa3e984bb69840579edca0e0bfb492fb635321c9f28a83" +} diff --git a/.traceweaver/auto-advance-20261104/red-integration.log b/.traceweaver/auto-advance-20261104/red-integration.log new file mode 100644 index 0000000..8a68a59 --- /dev/null +++ b/.traceweaver/auto-advance-20261104/red-integration.log @@ -0,0 +1,579 @@ +FFFF....FFFFFFFFFFFF [100%] +=================================== FAILURES =================================== +_ test_release_policy_routes_require_live_admin_origin_and_session_token[policy-set-body0] _ + +app = +logged_in_admin = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x106a98a10> +action = 'policy-set' +body = {'component': 'ipmi-monitor', 'mode': 'follow-stable', 'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'} + + @pytest.mark.parametrize('action,body', [ + ('policy-set', {'component':'ipmi-monitor','mode':'follow-stable','expected_selection_id':'fixture','expected_selection_sha256':'a'*64}), + ('policy-set', {'component':'ipmi-monitor','mode':'pinned','expected_selection_id':'fixture','expected_selection_sha256':'a'*64}), + ('policy-refresh', {'expected_selection_id':'fixture','expected_selection_sha256':'a'*64}), + ]) + def test_release_policy_routes_require_live_admin_origin_and_session_token(app, logged_in_admin, monkeypatch, action, body): + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN'] = 'http://localhost' + calls = [] + monkeypatch.setattr(ReleaseBrokerClient, 'request', lambda self, action, params, actor: calls.append((action,params,actor)) or {'components':{}}) + token = logged_in_admin.get('/api/releases/session').json['csrf_token'] + path = '/api/releases/' + action +> assert logged_in_admin.post(path,json=body,headers={'Origin':'http://evil.test','X-CSRF-Token':token}).status_code == 403 +E AssertionError: assert 404 == 403 +E + where 404 = .status_code +E + where = post('/api/releases/policy-set', json={'component': 'ipmi-monitor', 'mode': 'follow-stable', 'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}, headers={'Origin': 'http://evil.test', 'X-CSRF-Token': 'f4e2819467eff10cd0adc6c93495491bf360f7ee11f37006fe739334c0809e4b'}) +E + where post = >.post + +tests/test_auth.py:24: AssertionError +_ test_release_policy_routes_require_live_admin_origin_and_session_token[policy-set-body1] _ + +app = +logged_in_admin = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x1076c3740> +action = 'policy-set' +body = {'component': 'ipmi-monitor', 'mode': 'pinned', 'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'} + + @pytest.mark.parametrize('action,body', [ + ('policy-set', {'component':'ipmi-monitor','mode':'follow-stable','expected_selection_id':'fixture','expected_selection_sha256':'a'*64}), + ('policy-set', {'component':'ipmi-monitor','mode':'pinned','expected_selection_id':'fixture','expected_selection_sha256':'a'*64}), + ('policy-refresh', {'expected_selection_id':'fixture','expected_selection_sha256':'a'*64}), + ]) + def test_release_policy_routes_require_live_admin_origin_and_session_token(app, logged_in_admin, monkeypatch, action, body): + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN'] = 'http://localhost' + calls = [] + monkeypatch.setattr(ReleaseBrokerClient, 'request', lambda self, action, params, actor: calls.append((action,params,actor)) or {'components':{}}) + token = logged_in_admin.get('/api/releases/session').json['csrf_token'] + path = '/api/releases/' + action +> assert logged_in_admin.post(path,json=body,headers={'Origin':'http://evil.test','X-CSRF-Token':token}).status_code == 403 +E AssertionError: assert 404 == 403 +E + where 404 = .status_code +E + where = post('/api/releases/policy-set', json={'component': 'ipmi-monitor', 'mode': 'pinned', 'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}, headers={'Origin': 'http://evil.test', 'X-CSRF-Token': '489030e5a5d7a99ca6689cb0b5d0a2ec43807461971d77d97e56579a6185ca56'}) +E + where post = >.post + +tests/test_auth.py:24: AssertionError +_ test_release_policy_routes_require_live_admin_origin_and_session_token[policy-refresh-body2] _ + +app = +logged_in_admin = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10779e5a0> +action = 'policy-refresh' +body = {'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'} + + @pytest.mark.parametrize('action,body', [ + ('policy-set', {'component':'ipmi-monitor','mode':'follow-stable','expected_selection_id':'fixture','expected_selection_sha256':'a'*64}), + ('policy-set', {'component':'ipmi-monitor','mode':'pinned','expected_selection_id':'fixture','expected_selection_sha256':'a'*64}), + ('policy-refresh', {'expected_selection_id':'fixture','expected_selection_sha256':'a'*64}), + ]) + def test_release_policy_routes_require_live_admin_origin_and_session_token(app, logged_in_admin, monkeypatch, action, body): + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN'] = 'http://localhost' + calls = [] + monkeypatch.setattr(ReleaseBrokerClient, 'request', lambda self, action, params, actor: calls.append((action,params,actor)) or {'components':{}}) + token = logged_in_admin.get('/api/releases/session').json['csrf_token'] + path = '/api/releases/' + action +> assert logged_in_admin.post(path,json=body,headers={'Origin':'http://evil.test','X-CSRF-Token':token}).status_code == 403 +E AssertionError: assert 404 == 403 +E + where 404 = .status_code +E + where = post('/api/releases/policy-refresh', json={'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}, headers={'Origin': 'http://evil.test', 'X-CSRF-Token': 'c1ea9f93eadc8ed1b01d53419b234eb5d7d1eb9f446fe684549bddadc5942441'}) +E + where post = >.post + +tests/test_auth.py:24: AssertionError +______________ test_release_policy_listing_rejects_readonly_users ______________ + +logged_in_readonly = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10782e8d0> + + def test_release_policy_listing_rejects_readonly_users(logged_in_readonly, monkeypatch): + from cryptolabs_proxy.release_client import ReleaseBrokerClient + monkeypatch.setattr(ReleaseBrokerClient,'request',lambda *a,**k:pytest.fail('readonly request reached host')) +> assert logged_in_readonly.get('/api/releases/policies').status_code == 403 +E AssertionError: assert 404 == 403 +E + where 404 = .status_code +E + where = get('/api/releases/policies') +E + where get = >.get + +tests/test_auth.py:36: AssertionError +__ test_policy_mutations_cannot_expand_browser_authority[readonly-policy-set] __ + +app = +logged_in_admin = > +logged_in_readonly = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10781f020> +action = 'policy-set', failure = 'readonly' + + @pytest.mark.parametrize('action', ['policy-set','policy-refresh']) + @pytest.mark.parametrize('failure',['readonly','missing-origin','missing-token','other-session','revoked']) + def test_policy_mutations_cannot_expand_browser_authority(app,logged_in_admin,logged_in_readonly,monkeypatch,action,failure): + import cryptolabs_proxy.auth as auth + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN']='http://localhost';calls=[] + monkeypatch.setattr(ReleaseBrokerClient,'request',lambda *a,**kw:calls.append(a) or {}) + # The two fixture clients may share the session; establish an independent admin session. + client=app.test_client();client.post('/auth/login',data={'username':'admin','password':'adminpass'}) + token=client.get('/api/releases/session').json['csrf_token'] + headers={'Origin':'http://localhost','X-CSRF-Token':token} + body={'expected_selection_id':'fixture','expected_selection_sha256':'a'*64} + if action=='policy-set':body.update(component='ipmi-monitor',mode='follow-stable') + if failure=='readonly':client=logged_in_readonly + if failure=='missing-origin':headers.pop('Origin') + if failure=='missing-token':headers.pop('X-CSRF-Token') + if failure=='other-session': + other=app.test_client();other.post('/auth/login',data={'username':'admin','password':'adminpass'}) + headers['X-CSRF-Token']=other.get('/api/releases/session').json['csrf_token'] + if failure=='revoked':auth.update_user('admin',role='readonly') +> assert client.post('/api/releases/'+action,json=body,headers=headers).status_code in (401,403) +E AssertionError: assert 404 in (401, 403) +E + where 404 = .status_code +E + where = post(('/api/releases/' + 'policy-set'), json={'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'component': 'ipmi-monitor', 'mode': 'follow-stable'}, headers={'Origin': 'http://localhost', 'X-CSRF-Token': '266fb2d5cd34333d0f0c434569ecc163dd624450f502ef1437532baa17c3b2b8'}) +E + where post = >.post + +tests/test_auth.py:67: AssertionError +_ test_policy_mutations_cannot_expand_browser_authority[readonly-policy-refresh] _ + +app = +logged_in_admin = > +logged_in_readonly = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10779f8c0> +action = 'policy-refresh', failure = 'readonly' + + @pytest.mark.parametrize('action', ['policy-set','policy-refresh']) + @pytest.mark.parametrize('failure',['readonly','missing-origin','missing-token','other-session','revoked']) + def test_policy_mutations_cannot_expand_browser_authority(app,logged_in_admin,logged_in_readonly,monkeypatch,action,failure): + import cryptolabs_proxy.auth as auth + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN']='http://localhost';calls=[] + monkeypatch.setattr(ReleaseBrokerClient,'request',lambda *a,**kw:calls.append(a) or {}) + # The two fixture clients may share the session; establish an independent admin session. + client=app.test_client();client.post('/auth/login',data={'username':'admin','password':'adminpass'}) + token=client.get('/api/releases/session').json['csrf_token'] + headers={'Origin':'http://localhost','X-CSRF-Token':token} + body={'expected_selection_id':'fixture','expected_selection_sha256':'a'*64} + if action=='policy-set':body.update(component='ipmi-monitor',mode='follow-stable') + if failure=='readonly':client=logged_in_readonly + if failure=='missing-origin':headers.pop('Origin') + if failure=='missing-token':headers.pop('X-CSRF-Token') + if failure=='other-session': + other=app.test_client();other.post('/auth/login',data={'username':'admin','password':'adminpass'}) + headers['X-CSRF-Token']=other.get('/api/releases/session').json['csrf_token'] + if failure=='revoked':auth.update_user('admin',role='readonly') +> assert client.post('/api/releases/'+action,json=body,headers=headers).status_code in (401,403) +E AssertionError: assert 404 in (401, 403) +E + where 404 = .status_code +E + where = post(('/api/releases/' + 'policy-refresh'), json={'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}, headers={'Origin': 'http://localhost', 'X-CSRF-Token': '9fba1e4efa15964bb18bdd87ee1d94b5d13c25b94b5a45476e90122a85305c92'}) +E + where post = >.post + +tests/test_auth.py:67: AssertionError +_ test_policy_mutations_cannot_expand_browser_authority[missing-origin-policy-set] _ + +app = +logged_in_admin = > +logged_in_readonly = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x107775bb0> +action = 'policy-set', failure = 'missing-origin' + + @pytest.mark.parametrize('action', ['policy-set','policy-refresh']) + @pytest.mark.parametrize('failure',['readonly','missing-origin','missing-token','other-session','revoked']) + def test_policy_mutations_cannot_expand_browser_authority(app,logged_in_admin,logged_in_readonly,monkeypatch,action,failure): + import cryptolabs_proxy.auth as auth + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN']='http://localhost';calls=[] + monkeypatch.setattr(ReleaseBrokerClient,'request',lambda *a,**kw:calls.append(a) or {}) + # The two fixture clients may share the session; establish an independent admin session. + client=app.test_client();client.post('/auth/login',data={'username':'admin','password':'adminpass'}) + token=client.get('/api/releases/session').json['csrf_token'] + headers={'Origin':'http://localhost','X-CSRF-Token':token} + body={'expected_selection_id':'fixture','expected_selection_sha256':'a'*64} + if action=='policy-set':body.update(component='ipmi-monitor',mode='follow-stable') + if failure=='readonly':client=logged_in_readonly + if failure=='missing-origin':headers.pop('Origin') + if failure=='missing-token':headers.pop('X-CSRF-Token') + if failure=='other-session': + other=app.test_client();other.post('/auth/login',data={'username':'admin','password':'adminpass'}) + headers['X-CSRF-Token']=other.get('/api/releases/session').json['csrf_token'] + if failure=='revoked':auth.update_user('admin',role='readonly') +> assert client.post('/api/releases/'+action,json=body,headers=headers).status_code in (401,403) +E AssertionError: assert 404 in (401, 403) +E + where 404 = .status_code +E + where = post(('/api/releases/' + 'policy-set'), json={'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'component': 'ipmi-monitor', 'mode': 'follow-stable'}, headers={'X-CSRF-Token': '18541673df1f071d51c5137d40e8c63cb97cc048b9422d2ab99c8bf069005f22'}) +E + where post = >.post + +tests/test_auth.py:67: AssertionError +_ test_policy_mutations_cannot_expand_browser_authority[missing-origin-policy-refresh] _ + +app = +logged_in_admin = > +logged_in_readonly = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10783a360> +action = 'policy-refresh', failure = 'missing-origin' + + @pytest.mark.parametrize('action', ['policy-set','policy-refresh']) + @pytest.mark.parametrize('failure',['readonly','missing-origin','missing-token','other-session','revoked']) + def test_policy_mutations_cannot_expand_browser_authority(app,logged_in_admin,logged_in_readonly,monkeypatch,action,failure): + import cryptolabs_proxy.auth as auth + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN']='http://localhost';calls=[] + monkeypatch.setattr(ReleaseBrokerClient,'request',lambda *a,**kw:calls.append(a) or {}) + # The two fixture clients may share the session; establish an independent admin session. + client=app.test_client();client.post('/auth/login',data={'username':'admin','password':'adminpass'}) + token=client.get('/api/releases/session').json['csrf_token'] + headers={'Origin':'http://localhost','X-CSRF-Token':token} + body={'expected_selection_id':'fixture','expected_selection_sha256':'a'*64} + if action=='policy-set':body.update(component='ipmi-monitor',mode='follow-stable') + if failure=='readonly':client=logged_in_readonly + if failure=='missing-origin':headers.pop('Origin') + if failure=='missing-token':headers.pop('X-CSRF-Token') + if failure=='other-session': + other=app.test_client();other.post('/auth/login',data={'username':'admin','password':'adminpass'}) + headers['X-CSRF-Token']=other.get('/api/releases/session').json['csrf_token'] + if failure=='revoked':auth.update_user('admin',role='readonly') +> assert client.post('/api/releases/'+action,json=body,headers=headers).status_code in (401,403) +E AssertionError: assert 404 in (401, 403) +E + where 404 = .status_code +E + where = post(('/api/releases/' + 'policy-refresh'), json={'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}, headers={'X-CSRF-Token': 'b652c4f1120b56f4f834eaea249db279a0518ff5b1a1f12d3a3819188fbd3743'}) +E + where post = >.post + +tests/test_auth.py:67: AssertionError +_ test_policy_mutations_cannot_expand_browser_authority[missing-token-policy-set] _ + +app = +logged_in_admin = > +logged_in_readonly = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x1079fa540> +action = 'policy-set', failure = 'missing-token' + + @pytest.mark.parametrize('action', ['policy-set','policy-refresh']) + @pytest.mark.parametrize('failure',['readonly','missing-origin','missing-token','other-session','revoked']) + def test_policy_mutations_cannot_expand_browser_authority(app,logged_in_admin,logged_in_readonly,monkeypatch,action,failure): + import cryptolabs_proxy.auth as auth + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN']='http://localhost';calls=[] + monkeypatch.setattr(ReleaseBrokerClient,'request',lambda *a,**kw:calls.append(a) or {}) + # The two fixture clients may share the session; establish an independent admin session. + client=app.test_client();client.post('/auth/login',data={'username':'admin','password':'adminpass'}) + token=client.get('/api/releases/session').json['csrf_token'] + headers={'Origin':'http://localhost','X-CSRF-Token':token} + body={'expected_selection_id':'fixture','expected_selection_sha256':'a'*64} + if action=='policy-set':body.update(component='ipmi-monitor',mode='follow-stable') + if failure=='readonly':client=logged_in_readonly + if failure=='missing-origin':headers.pop('Origin') + if failure=='missing-token':headers.pop('X-CSRF-Token') + if failure=='other-session': + other=app.test_client();other.post('/auth/login',data={'username':'admin','password':'adminpass'}) + headers['X-CSRF-Token']=other.get('/api/releases/session').json['csrf_token'] + if failure=='revoked':auth.update_user('admin',role='readonly') +> assert client.post('/api/releases/'+action,json=body,headers=headers).status_code in (401,403) +E AssertionError: assert 404 in (401, 403) +E + where 404 = .status_code +E + where = post(('/api/releases/' + 'policy-set'), json={'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'component': 'ipmi-monitor', 'mode': 'follow-stable'}, headers={'Origin': 'http://localhost'}) +E + where post = >.post + +tests/test_auth.py:67: AssertionError +_ test_policy_mutations_cannot_expand_browser_authority[missing-token-policy-refresh] _ + +app = +logged_in_admin = > +logged_in_readonly = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x1079b6720> +action = 'policy-refresh', failure = 'missing-token' + + @pytest.mark.parametrize('action', ['policy-set','policy-refresh']) + @pytest.mark.parametrize('failure',['readonly','missing-origin','missing-token','other-session','revoked']) + def test_policy_mutations_cannot_expand_browser_authority(app,logged_in_admin,logged_in_readonly,monkeypatch,action,failure): + import cryptolabs_proxy.auth as auth + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN']='http://localhost';calls=[] + monkeypatch.setattr(ReleaseBrokerClient,'request',lambda *a,**kw:calls.append(a) or {}) + # The two fixture clients may share the session; establish an independent admin session. + client=app.test_client();client.post('/auth/login',data={'username':'admin','password':'adminpass'}) + token=client.get('/api/releases/session').json['csrf_token'] + headers={'Origin':'http://localhost','X-CSRF-Token':token} + body={'expected_selection_id':'fixture','expected_selection_sha256':'a'*64} + if action=='policy-set':body.update(component='ipmi-monitor',mode='follow-stable') + if failure=='readonly':client=logged_in_readonly + if failure=='missing-origin':headers.pop('Origin') + if failure=='missing-token':headers.pop('X-CSRF-Token') + if failure=='other-session': + other=app.test_client();other.post('/auth/login',data={'username':'admin','password':'adminpass'}) + headers['X-CSRF-Token']=other.get('/api/releases/session').json['csrf_token'] + if failure=='revoked':auth.update_user('admin',role='readonly') +> assert client.post('/api/releases/'+action,json=body,headers=headers).status_code in (401,403) +E AssertionError: assert 404 in (401, 403) +E + where 404 = .status_code +E + where = post(('/api/releases/' + 'policy-refresh'), json={'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}, headers={'Origin': 'http://localhost'}) +E + where post = >.post + +tests/test_auth.py:67: AssertionError +_ test_policy_mutations_cannot_expand_browser_authority[other-session-policy-set] _ + +app = +logged_in_admin = > +logged_in_readonly = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x107836270> +action = 'policy-set', failure = 'other-session' + + @pytest.mark.parametrize('action', ['policy-set','policy-refresh']) + @pytest.mark.parametrize('failure',['readonly','missing-origin','missing-token','other-session','revoked']) + def test_policy_mutations_cannot_expand_browser_authority(app,logged_in_admin,logged_in_readonly,monkeypatch,action,failure): + import cryptolabs_proxy.auth as auth + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN']='http://localhost';calls=[] + monkeypatch.setattr(ReleaseBrokerClient,'request',lambda *a,**kw:calls.append(a) or {}) + # The two fixture clients may share the session; establish an independent admin session. + client=app.test_client();client.post('/auth/login',data={'username':'admin','password':'adminpass'}) + token=client.get('/api/releases/session').json['csrf_token'] + headers={'Origin':'http://localhost','X-CSRF-Token':token} + body={'expected_selection_id':'fixture','expected_selection_sha256':'a'*64} + if action=='policy-set':body.update(component='ipmi-monitor',mode='follow-stable') + if failure=='readonly':client=logged_in_readonly + if failure=='missing-origin':headers.pop('Origin') + if failure=='missing-token':headers.pop('X-CSRF-Token') + if failure=='other-session': + other=app.test_client();other.post('/auth/login',data={'username':'admin','password':'adminpass'}) + headers['X-CSRF-Token']=other.get('/api/releases/session').json['csrf_token'] + if failure=='revoked':auth.update_user('admin',role='readonly') +> assert client.post('/api/releases/'+action,json=body,headers=headers).status_code in (401,403) +E AssertionError: assert 404 in (401, 403) +E + where 404 = .status_code +E + where = post(('/api/releases/' + 'policy-set'), json={'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'component': 'ipmi-monitor', 'mode': 'follow-stable'}, headers={'Origin': 'http://localhost', 'X-CSRF-Token': '21fce137f67bdee3000fcd95684511c8dfb1b0f3457fbb1725e16a6839e473e6'}) +E + where post = >.post + +tests/test_auth.py:67: AssertionError +_ test_policy_mutations_cannot_expand_browser_authority[other-session-policy-refresh] _ + +app = +logged_in_admin = > +logged_in_readonly = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x1078a6a50> +action = 'policy-refresh', failure = 'other-session' + + @pytest.mark.parametrize('action', ['policy-set','policy-refresh']) + @pytest.mark.parametrize('failure',['readonly','missing-origin','missing-token','other-session','revoked']) + def test_policy_mutations_cannot_expand_browser_authority(app,logged_in_admin,logged_in_readonly,monkeypatch,action,failure): + import cryptolabs_proxy.auth as auth + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN']='http://localhost';calls=[] + monkeypatch.setattr(ReleaseBrokerClient,'request',lambda *a,**kw:calls.append(a) or {}) + # The two fixture clients may share the session; establish an independent admin session. + client=app.test_client();client.post('/auth/login',data={'username':'admin','password':'adminpass'}) + token=client.get('/api/releases/session').json['csrf_token'] + headers={'Origin':'http://localhost','X-CSRF-Token':token} + body={'expected_selection_id':'fixture','expected_selection_sha256':'a'*64} + if action=='policy-set':body.update(component='ipmi-monitor',mode='follow-stable') + if failure=='readonly':client=logged_in_readonly + if failure=='missing-origin':headers.pop('Origin') + if failure=='missing-token':headers.pop('X-CSRF-Token') + if failure=='other-session': + other=app.test_client();other.post('/auth/login',data={'username':'admin','password':'adminpass'}) + headers['X-CSRF-Token']=other.get('/api/releases/session').json['csrf_token'] + if failure=='revoked':auth.update_user('admin',role='readonly') +> assert client.post('/api/releases/'+action,json=body,headers=headers).status_code in (401,403) +E AssertionError: assert 404 in (401, 403) +E + where 404 = .status_code +E + where = post(('/api/releases/' + 'policy-refresh'), json={'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}, headers={'Origin': 'http://localhost', 'X-CSRF-Token': 'efa27ff08281c6cbd5d79b899777303fd00120db4371cffd35e6217156214231'}) +E + where post = >.post + +tests/test_auth.py:67: AssertionError +__ test_policy_mutations_cannot_expand_browser_authority[revoked-policy-set] ___ + +app = +logged_in_admin = > +logged_in_readonly = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10794be90> +action = 'policy-set', failure = 'revoked' + + @pytest.mark.parametrize('action', ['policy-set','policy-refresh']) + @pytest.mark.parametrize('failure',['readonly','missing-origin','missing-token','other-session','revoked']) + def test_policy_mutations_cannot_expand_browser_authority(app,logged_in_admin,logged_in_readonly,monkeypatch,action,failure): + import cryptolabs_proxy.auth as auth + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN']='http://localhost';calls=[] + monkeypatch.setattr(ReleaseBrokerClient,'request',lambda *a,**kw:calls.append(a) or {}) + # The two fixture clients may share the session; establish an independent admin session. + client=app.test_client();client.post('/auth/login',data={'username':'admin','password':'adminpass'}) + token=client.get('/api/releases/session').json['csrf_token'] + headers={'Origin':'http://localhost','X-CSRF-Token':token} + body={'expected_selection_id':'fixture','expected_selection_sha256':'a'*64} + if action=='policy-set':body.update(component='ipmi-monitor',mode='follow-stable') + if failure=='readonly':client=logged_in_readonly + if failure=='missing-origin':headers.pop('Origin') + if failure=='missing-token':headers.pop('X-CSRF-Token') + if failure=='other-session': + other=app.test_client();other.post('/auth/login',data={'username':'admin','password':'adminpass'}) + headers['X-CSRF-Token']=other.get('/api/releases/session').json['csrf_token'] + if failure=='revoked':auth.update_user('admin',role='readonly') +> assert client.post('/api/releases/'+action,json=body,headers=headers).status_code in (401,403) +E AssertionError: assert 404 in (401, 403) +E + where 404 = .status_code +E + where = post(('/api/releases/' + 'policy-set'), json={'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'component': 'ipmi-monitor', 'mode': 'follow-stable'}, headers={'Origin': 'http://localhost', 'X-CSRF-Token': 'ff6acaf5a5e71acafb263e87243c84934abcc074d077a9bb9cafdfe98a50d3db'}) +E + where post = >.post + +tests/test_auth.py:67: AssertionError +_ test_policy_mutations_cannot_expand_browser_authority[revoked-policy-refresh] _ + +app = +logged_in_admin = > +logged_in_readonly = > +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x1078c2ed0> +action = 'policy-refresh', failure = 'revoked' + + @pytest.mark.parametrize('action', ['policy-set','policy-refresh']) + @pytest.mark.parametrize('failure',['readonly','missing-origin','missing-token','other-session','revoked']) + def test_policy_mutations_cannot_expand_browser_authority(app,logged_in_admin,logged_in_readonly,monkeypatch,action,failure): + import cryptolabs_proxy.auth as auth + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN']='http://localhost';calls=[] + monkeypatch.setattr(ReleaseBrokerClient,'request',lambda *a,**kw:calls.append(a) or {}) + # The two fixture clients may share the session; establish an independent admin session. + client=app.test_client();client.post('/auth/login',data={'username':'admin','password':'adminpass'}) + token=client.get('/api/releases/session').json['csrf_token'] + headers={'Origin':'http://localhost','X-CSRF-Token':token} + body={'expected_selection_id':'fixture','expected_selection_sha256':'a'*64} + if action=='policy-set':body.update(component='ipmi-monitor',mode='follow-stable') + if failure=='readonly':client=logged_in_readonly + if failure=='missing-origin':headers.pop('Origin') + if failure=='missing-token':headers.pop('X-CSRF-Token') + if failure=='other-session': + other=app.test_client();other.post('/auth/login',data={'username':'admin','password':'adminpass'}) + headers['X-CSRF-Token']=other.get('/api/releases/session').json['csrf_token'] + if failure=='revoked':auth.update_user('admin',role='readonly') +> assert client.post('/api/releases/'+action,json=body,headers=headers).status_code in (401,403) +E AssertionError: assert 404 in (401, 403) +E + where 404 = .status_code +E + where = post(('/api/releases/' + 'policy-refresh'), json={'expected_selection_id': 'fixture', 'expected_selection_sha256': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}, headers={'Origin': 'http://localhost', 'X-CSRF-Token': 'c1535ddcb1f0440216a1e5309e666f57f988916d80a6347ff97fda75ed2f093c'}) +E + where post = >.post + +tests/test_auth.py:67: AssertionError +______ test_policy_action_uses_displayed_identity_and_never_calls_install ______ + + def test_policy_action_uses_displayed_identity_and_never_calls_install(): + page = (Path(__file__).resolve().parents[1] / 'landing-page/index.html').read_text() + script = '\n'.join(re.findall(r']*>(.*?)',page,re.S)) + # Browser bootstrap does not run in this DOM fixture; call the actual public action. + fixture = r''' + const assert = require('assert'); const calls=[]; + const nodes = new Map(); + function node(){return {innerHTML:'',textContent:'',disabled:false,value:'main',hidden:false, + style:{},dataset:{},appendChild(){},append(){},replaceChildren(){},remove(){},after(){}, + addEventListener(){},querySelector(){return node()},querySelectorAll(){return []}}} + global.document={getElementById(id){if(!nodes.has(id))nodes.set(id,node());return nodes.get(id)}, + querySelector(){return node()},querySelectorAll(){return []},createElement:node,addEventListener(){}}; + global.window={addEventListener(){},location:{origin:'http://fixture.test'}}; + global.localStorage={getItem(){return null},setItem(){},removeItem(){}}; + global.setInterval=()=>0;global.setTimeout=()=>0;global.clearTimeout=()=>{}; + global.fetch=async(path,options={})=>{calls.push([path,options.body ? JSON.parse(options.body):null]); + return {ok:true,json:async()=>path.includes('session') ? {csrf_token:'fixture-token'} : { + selection_id:'displayed-release',selection_sha256:'a'.repeat(64),components:{'ipmi-monitor':{mode:'follow-stable',supported:true,installed_release:'1.4.0',selected_release:'1.4.0'}}}}}; + ''' + check = r''' + (async()=>{await loadReleasePolicies();await setReleasePolicy('ipmi-monitor','follow-stable'); + const request=calls.find(x=>x[0]==='/api/releases/policy-set');assert(request); + assert.deepEqual(request[1],{component:'ipmi-monitor',mode:'follow-stable',expected_selection_id:'displayed-release',expected_selection_sha256:'a'.repeat(64)}); + assert(!calls.some(x=>x[0]==='/api/update'||x[0]==='/api/releases/apply')); + console.log('policy action bound to displayed selection; no install');})().catch(e=>{console.error(e);process.exitCode=1}); + ''' + result = subprocess.run(['node'],input=fixture+script+check,text=True,capture_output=True) +> assert result.returncode == 0, result.stderr +E AssertionError: ReferenceError: loadReleasePolicies is not defined +E at [stdin]:1283:12 +E at [stdin]:1287:73 +E at runScriptInThisContext (node:internal/vm:219:10) +E at node:internal/process/execution:483:12 +E at [stdin]-wrapper:6:24 +E at runScriptInContext (node:internal/process/execution:481:60) +E at evalFunction (node:internal/process/execution:315:30) +E at evalTypeScript (node:internal/process/execution:327:3) +E at node:internal/main/eval_stdin:51:5 +E at Socket. (node:internal/process/execution:237:5) +E Failed to load versions: TypeError: wdCard.cloneNode is not a function +E at loadVersions ([stdin]:675:53) +E at process.processTicksAndRejections (node:internal/process/task_queues:104:5) +E Failed to load versions: TypeError: wdCard.cloneNode is not a function +E at loadVersions ([stdin]:675:53) +E at process.processTicksAndRejections (node:internal/process/task_queues:104:5) +E +E assert 1 == 0 +E + where 1 = CompletedProcess(args=['node'], returncode=1, stdout='', stderr='ReferenceError: loadReleasePolicies is not defined\n ...t loadVersions ([stdin]:675:53)\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n').returncode + +tests/test_release_policy_ui.py:35: AssertionError +_____ test_check_updates_refreshes_policy_before_pull_and_does_not_install _____ + + def test_check_updates_refreshes_policy_before_pull_and_does_not_install(): + page=(Path(__file__).resolve().parents[1]/'landing-page/index.html').read_text() + # A focused real-function execution avoids stubbing the rest of the dashboard. + start=page.index('async function checkForUpdates()') + function=page[start:page.index('\n async function updateService',start)] + script=r''' + const assert=require('assert');const calls=[]; + async function refreshReleasePolicies(){calls.push('advance')} + async function loadVersions(){calls.push('versions')} + async function startUpdateJob(action,service){calls.push([action,service])} + function showUpdateStatus(){throw Error('unexpected failure')} + '''+function+r''' + (async()=>{await checkForUpdates();assert.deepEqual(calls,['advance','versions',['pull','all']])})().catch(e=>{console.error(e);process.exitCode=1}); + ''' + result=subprocess.run(['node'],input=script,text=True,capture_output=True) +> assert result.returncode==0,result.stderr +E AssertionError: AssertionError [ERR_ASSERTION]: Expected values to be loosely deep-equal: +E +E [ +E [ +E 'pull', +E 'all' +E ] +E ] +E +E should loosely deep-equal +E +E [ +E 'advance', +E 'versions', +E [ +E 'pull', +E 'all' +E ] +E ] +E at [stdin]:11:43 +E at process.processTicksAndRejections (node:internal/process/task_queues:104:5) { +E generatedMessage: true, +E code: 'ERR_ASSERTION', +E actual: [Array], +E expected: [Array], +E operator: 'deepEqual', +E diff: 'simple' +E } +E +E assert 1 == 0 +E + where 1 = CompletedProcess(args=['node'], returncode=1, stdout='', stderr="AssertionError [ERR_ASSERTION]: Expected values to be...\n code: 'ERR_ASSERTION',\n actual: [Array],\n expected: [Array],\n operator: 'deepEqual',\n diff: 'simple'\n}\n").returncode + +tests/test_release_policy_ui.py:53: AssertionError +=============================== warnings summary =============================== +tests/test_auth.py: 24 warnings + /private/tmp/ltx-auto-advance-20261104/cryptolabs-proxy/tests/../src/cryptolabs_proxy/auth.py:375: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + 'created_at': datetime.utcnow().isoformat(), + +tests/test_auth.py: 36 warnings + /private/tmp/ltx-auto-advance-20261104/cryptolabs-proxy/tests/../src/cryptolabs_proxy/auth.py:472: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + users[username]['last_login'] = datetime.utcnow().isoformat() + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +=========================== short test summary info ============================ +FAILED tests/test_auth.py::test_release_policy_routes_require_live_admin_origin_and_session_token[policy-set-body0] +FAILED tests/test_auth.py::test_release_policy_routes_require_live_admin_origin_and_session_token[policy-set-body1] +FAILED tests/test_auth.py::test_release_policy_routes_require_live_admin_origin_and_session_token[policy-refresh-body2] +FAILED tests/test_auth.py::test_release_policy_listing_rejects_readonly_users +FAILED tests/test_auth.py::test_policy_mutations_cannot_expand_browser_authority[readonly-policy-set] +FAILED tests/test_auth.py::test_policy_mutations_cannot_expand_browser_authority[readonly-policy-refresh] +FAILED tests/test_auth.py::test_policy_mutations_cannot_expand_browser_authority[missing-origin-policy-set] +FAILED tests/test_auth.py::test_policy_mutations_cannot_expand_browser_authority[missing-origin-policy-refresh] +FAILED tests/test_auth.py::test_policy_mutations_cannot_expand_browser_authority[missing-token-policy-set] +FAILED tests/test_auth.py::test_policy_mutations_cannot_expand_browser_authority[missing-token-policy-refresh] +FAILED tests/test_auth.py::test_policy_mutations_cannot_expand_browser_authority[other-session-policy-set] +FAILED tests/test_auth.py::test_policy_mutations_cannot_expand_browser_authority[other-session-policy-refresh] +FAILED tests/test_auth.py::test_policy_mutations_cannot_expand_browser_authority[revoked-policy-set] +FAILED tests/test_auth.py::test_policy_mutations_cannot_expand_browser_authority[revoked-policy-refresh] +FAILED tests/test_release_policy_ui.py::test_policy_action_uses_displayed_identity_and_never_calls_install +FAILED tests/test_release_policy_ui.py::test_check_updates_refreshes_policy_before_pull_and_does_not_install +16 failed, 4 passed, 70 deselected, 60 warnings in 2.79s diff --git a/.traceweaver/auto-advance-20261104/red-selected-card.log b/.traceweaver/auto-advance-20261104/red-selected-card.log new file mode 100644 index 0000000..6c1cbcb --- /dev/null +++ b/.traceweaver/auto-advance-20261104/red-selected-card.log @@ -0,0 +1,23 @@ +.................................F [100%] +=================================== FAILURES =================================== +____ test_selected_pinned_service_is_not_misreported_as_managed_separately _____ + + def test_selected_pinned_service_is_not_misreported_as_managed_separately(): + from tests.test_latest_status import landing_script + script=landing_script()+r''' + loadWatchdogStatus=()=>{};const grid={innerHTML:''}; + global.document={getElementById:id=>id==='versionsGrid'?grid:id==='targetBranch'?{value:'main'}:null, + querySelector:()=>({style:{}})}; + global.fetch=async()=>({ok:true,json:async()=>({'ipmi-monitor':{running:true,version:'1.4.0',selected_release:'1.4.0',selected_channel:'main',pinned:true,update_available:false}})}); + loadVersions().then(()=>{if(grid.innerHTML.includes('managed separately')||!grid.innerHTML.includes('Selected release installed'))process.exitCode=1}); + ''' + result=subprocess.run(['node'],input=script,text=True,capture_output=True) +> assert result.returncode==0,result.stderr +E AssertionError: +E assert 1 == 0 +E + where 1 = CompletedProcess(args=['node'], returncode=1, stdout='', stderr='').returncode + +tests/test_release_policy_ui.py:68: AssertionError +=========================== short test summary info ============================ +FAILED tests/test_release_policy_ui.py::test_selected_pinned_service_is_not_misreported_as_managed_separately +1 failed, 33 passed in 1.09s diff --git a/.traceweaver/auto-advance-20261104/review-correctness.json b/.traceweaver/auto-advance-20261104/review-correctness.json new file mode 100644 index 0000000..ebbac5a --- /dev/null +++ b/.traceweaver/auto-advance-20261104/review-correctness.json @@ -0,0 +1,23 @@ +{ + "reviewer": "advance_vv_correctness", + "findings": [], + "scope": "correctness/concurrency/recovery", + "closed_findings": [ + "policy journal created after native CAS under lifecycle/updater locks; postcommit policy state written before locks release", + "installed major derived from verified running image and advanced only after explicit major installation" + ], + "independent_tests": "91 policy tests passed", + "status": "passed within source scope", + "production_acceptance": false, + "held": [ + "trusted publisher/installed broker prerequisite", + "native and production acceptance separately required" + ], + "final_ui_delta_review": { + "reviewer": "advance_vv_correctness", + "findings": [], + "scope": "selected-card wording and actual renderer regression after backend review", + "result": "passed", + "limit": "No publication or production acceptance assessed" + } +} diff --git a/.traceweaver/auto-advance-20261104/review-definition.json b/.traceweaver/auto-advance-20261104/review-definition.json new file mode 100644 index 0000000..686daeb --- /dev/null +++ b/.traceweaver/auto-advance-20261104/review-definition.json @@ -0,0 +1,18 @@ +{ + "utc": "2026-11-04T16:45:13.521568+00:00", + "status": "passed", + "reviewers": [ + "advance_vv_correctness", + "advance_vv_security" + ], + "scope": "expanded pre-implementation tests/V&V + validationdefinition againstapproved C1-C8", + "source_results": "DC59failed16pass;Proxy16failed4pass beforeproductionintegration;selector12passfromearlierTDDslice", + "residual_gates": [ + "Linuxpeer/rootcustody", + "abruptprocesscrashrecovery", + "actualtimer", + "actualbrowser", + "existingpublisherandbridgeacceptance" + ], + "followups_in_tests": "positiveinstalledcase,specificfaultactivation,modeonlyCAS,validclientcontrol added before relatedproductionimplementation" +} diff --git a/.traceweaver/auto-advance-20261104/review-route-request.json b/.traceweaver/auto-advance-20261104/review-route-request.json new file mode 100644 index 0000000..04c1dd8 --- /dev/null +++ b/.traceweaver/auto-advance-20261104/review-route-request.json @@ -0,0 +1,17 @@ +{ + "schema_version": "tw-review-route/1", + "accepted": { + "semantic_authority_digest": "94b783d925834004cd3f111dd2437f8ae531850f0184aaa29c52f89f84267795", + "behavior_digest": "089c1017874e768e526e3d8793c21450273fa09246d482946f1b7aed2c17ee56", + "verification_digest": "e9257ad2c43fdb4e719f8111e1142dde8c5eb947db23138b68244fe571555d4d", + "bookkeeping_digest": "before-annotations-and-receipts", + "session_id": "independent-backend-review-plus-final-ui-delta" + }, + "candidate": { + "semantic_authority_digest": "94b783d925834004cd3f111dd2437f8ae531850f0184aaa29c52f89f84267795", + "behavior_digest": "089c1017874e768e526e3d8793c21450273fa09246d482946f1b7aed2c17ee56", + "verification_digest": "e9257ad2c43fdb4e719f8111e1142dde8c5eb947db23138b68244fe571555d4d", + "bookkeeping_digest": "4b3c5e45c299371418a6b98ae5218c4220dba9ab1e33b5d5fce579caf802581e", + "session_id": "source-publication-preparation" + } +} diff --git a/.traceweaver/auto-advance-20261104/review-route-result.json b/.traceweaver/auto-advance-20261104/review-route-result.json new file mode 100644 index 0000000..aca3396 --- /dev/null +++ b/.traceweaver/auto-advance-20261104/review-route-result.json @@ -0,0 +1 @@ +{"schema_version":"tw-review-route-result/1","route":"mechanical_closure","reason":"bookkeeping_only_changed","changed_fields":["bookkeeping_digest","session_id"],"reviewer_dispatch_count":0,"new_review_required":false} diff --git a/.traceweaver/auto-advance-20261104/review-security.json b/.traceweaver/auto-advance-20261104/review-security.json new file mode 100644 index 0000000..4547d89 --- /dev/null +++ b/.traceweaver/auto-advance-20261104/review-security.json @@ -0,0 +1,15 @@ +{ + "reviewer": "advance_vv_security", + "findings": [], + "scope": "security/API/auth/trust", + "closed_findings": [ + "installed provenance rechecked under lifecycle/updater locks before policy journal creation" + ], + "reviewed_tests": "350 affected tests passed", + "status": "passed within source scope", + "production_acceptance": false, + "held": [ + "trusted publisher/installed broker prerequisite", + "native and production acceptance separately required" + ] +} diff --git a/.traceweaver/auto-advance-20261104/source-hashes.json b/.traceweaver/auto-advance-20261104/source-hashes.json new file mode 100644 index 0000000..7e5b4fb --- /dev/null +++ b/.traceweaver/auto-advance-20261104/source-hashes.json @@ -0,0 +1,7 @@ +{ + "landing-page/index.html": "1d88cf658c71b58af29d501837e876c0f48c0e7d5083a0f90fab4ba8e6774552", + "src/cryptolabs_proxy/auth.py": "dd0149b619ee37aced86cd8de4829e3487b476217cd0733a570c727bff1be08d", + "src/cryptolabs_proxy/release_client.py": "dd496ad4d007cb98039e5ba8bdf899398dc576d2448e199be6e875b7c1d75fec", + "tests/test_auth.py": "223e6326f75ed320d7ebe62e232a61d478b9a83d8f2d98a971b48b1b0a25bec7", + "tests/test_release_policy_ui.py": "f687b25ce5ec67085ed3cb970b11248871888e5ac47382073fa25d2fd8616322" +} diff --git a/.traceweaver/auto-advance-20261104/validation.md b/.traceweaver/auto-advance-20261104/validation.md new file mode 100644 index 0000000..b7dffa7 --- /dev/null +++ b/.traceweaver/auto-advance-20261104/validation.md @@ -0,0 +1,3 @@ +Validation definition for VAL-AUTOADVANCE-001 through -008 + +Owner/rater: Hannes Zietsman, requesting Fleet administrator, independent of implementation. Scenario and observable criteria are exact approvedplan C1-C8 and Validationsection. In native staging a signed synthetic newerstable release exercises unpin -> auto selection withoutrestart -> manualUpdate -> healthy -> repin -> noadvance, failure/concurrency/restart/CSRFcases. On LTX verify correctcurrentversion, persistentmode choice, dailytimer and unchangedotherroles/history/accounts. Localfrontendfixtures prove onlyrequest/rendercontracts; do notrepresentLinuxrootcustody oractualupgrades. Evidence includes exactsourcehash, UI screenshots/requestreceipts, nativepolicy/selectionhashes, unchangedruntimeIDs andhealth. Noacceptanceclaimuntilobserved. Newpublisher/bridge dependencies stayhelduntilindependentlyverified. diff --git a/.traceweaver/auto-advance-20261104/verification.json b/.traceweaver/auto-advance-20261104/verification.json new file mode 100644 index 0000000..8f5bd44 --- /dev/null +++ b/.traceweaver/auto-advance-20261104/verification.json @@ -0,0 +1,40 @@ +{ + "scope": "source verification; production and future genuine release acceptance held", + "full_regression": { + "passed": 1106, + "skipped": 2, + "log": "full-tests-final.log" + }, + "final_focus": { + "passed": 122, + "log": "final-annotated-tests.log" + }, + "log_hashes": { + "red-integration.log": "2f14dd2aa83df060d8ac95291b56e9719436d9ebe6108a7402c687163252e89f", + "red-selected-card.log": "e3347830274ae1d3dd4af94ae7aff184cf199ee2c1b9b5ec7f1c7fb9068a7d8b", + "red-check-wiring.log": "1ae3af7fc1a5d1643358054df2f687dc10c2e0c7d88e65f5ea7c6c5209786799", + "final-annotated-tests.log": "688b4395d4c085425fb5704a83c9cba94f609d4372aa372dc84e86e1ba893327", + "full-tests-final.log": "f0a7a4287f15fe93dda90d406f1f8e504e9ae83e3c257de8f458bbc005be600e", + "red-tests.log": "ddf92415c07dbcf98a6c24332f32a9e985642c72bf4b0dd0129df63ceedaa309" + }, + "native": { + "passed_cases": 11, + "receipt_sha256": "06ceabc99113de2f13d90ac7013a64e16c2a79b66f656a69ed6f0f9c7a546158", + "scope": "real Linux root/Docker inspection/locks/crash recovery/socket/CLI/systemd; synthetic signed admissions and idle app containers", + "application_installation_health": false, + "negative_cases": "native-negative.log: bad signature and real SO_PEERCRED rejection passed" + }, + "browser": { + "passed": true, + "scope": "actual candidate page in Chrome, synthetic API; pin/follow/check/reload/repin, no install calls", + "receipt_sha256": "c1d146fa4d93014eeb743cc586ef620ad38e47770b7c993dcc93d4427f3c990b" + }, + "live_activation_blockers": [ + "trusted current-pack discovery is404; production issuer custody/publisher unaccepted", + "existing Fleet PR34 native scoped-select recovery continuity gate failed; do not bypass" + ], + "independent_review": [ + "review-correctness.json", + "review-security.json" + ] +} diff --git a/.traceweaver/auto-advance-20261104/vv-capsule.json b/.traceweaver/auto-advance-20261104/vv-capsule.json new file mode 100644 index 0000000..caab6b3 --- /dev/null +++ b/.traceweaver/auto-advance-20261104/vv-capsule.json @@ -0,0 +1,148 @@ +{ + "schema_version": "tw-vv-capsule/1", + "workflow_profile": "L3", + "work_item_id": "AUTOADVANCE-20261104", + "plan_ref": ".traceweaver/auto-advance-20261104/plan.md", + "high_risk_controls": { + "rollback": "Unpublishedlocalchangesreversible; policyjournalmustreconcileprior/newselectionandmode atomically; LTXactivationusesnativeexistingownerwithhealth/recovery andpreservationevidence. Nojournalclearingorcredentialreset.", + "owner_decision": "Exact userapproval owner-approval.json; same-majorverifiedstablefollow,manualinstallation;completeexistingacceptanceprerequisitesbeforeactivation." + }, + "requirements": [ + { + "req_id": "REQ-AUTOADVANCE-001", + "trace_id": "TRACE-AUTOADVANCE-001", + "ver_id": "VER-AUTOADVANCE-001", + "val_id": "VAL-AUTOADVANCE-001", + "verification_artifact": "tests/test_release_policy_ui.py", + "red_evidence": ".traceweaver/auto-advance-20261104/red-integration.log", + "validation_artifact": ".traceweaver/auto-advance-20261104/validation.md", + "reviews": [ + { + "gate": "tw-code-review / tw-doc-review", + "id": "AUTOADVANCE-VV-REVIEW", + "status": "passed", + "evidence_path": ".traceweaver/auto-advance-20261104/review-definition.json" + } + ] + }, + { + "req_id": "REQ-AUTOADVANCE-002", + "trace_id": "TRACE-AUTOADVANCE-002", + "ver_id": "VER-AUTOADVANCE-002", + "val_id": "VAL-AUTOADVANCE-002", + "verification_artifact": "tests/test_auth.py", + "red_evidence": ".traceweaver/auto-advance-20261104/red-integration.log", + "validation_artifact": ".traceweaver/auto-advance-20261104/validation.md", + "reviews": [ + { + "gate": "tw-code-review / tw-doc-review", + "id": "AUTOADVANCE-VV-REVIEW", + "status": "passed", + "evidence_path": ".traceweaver/auto-advance-20261104/review-definition.json" + } + ] + }, + { + "req_id": "REQ-AUTOADVANCE-003", + "trace_id": "TRACE-AUTOADVANCE-003", + "ver_id": "VER-AUTOADVANCE-003", + "val_id": "VAL-AUTOADVANCE-003", + "verification_artifact": "tests/test_auth.py", + "red_evidence": ".traceweaver/auto-advance-20261104/red-integration.log", + "validation_artifact": ".traceweaver/auto-advance-20261104/validation.md", + "reviews": [ + { + "gate": "tw-code-review / tw-doc-review", + "id": "AUTOADVANCE-VV-REVIEW", + "status": "passed", + "evidence_path": ".traceweaver/auto-advance-20261104/review-definition.json" + } + ] + }, + { + "req_id": "REQ-AUTOADVANCE-004", + "trace_id": "TRACE-AUTOADVANCE-004", + "ver_id": "VER-AUTOADVANCE-004", + "val_id": "VAL-AUTOADVANCE-004", + "verification_artifact": "tests/test_release_policy_ui.py", + "red_evidence": ".traceweaver/auto-advance-20261104/red-integration.log", + "validation_artifact": ".traceweaver/auto-advance-20261104/validation.md", + "reviews": [ + { + "gate": "tw-code-review / tw-doc-review", + "id": "AUTOADVANCE-VV-REVIEW", + "status": "passed", + "evidence_path": ".traceweaver/auto-advance-20261104/review-definition.json" + } + ] + }, + { + "req_id": "REQ-AUTOADVANCE-005", + "trace_id": "TRACE-AUTOADVANCE-005", + "ver_id": "VER-AUTOADVANCE-005", + "val_id": "VAL-AUTOADVANCE-005", + "verification_artifact": "tests/test_auth.py", + "red_evidence": ".traceweaver/auto-advance-20261104/red-integration.log", + "validation_artifact": ".traceweaver/auto-advance-20261104/validation.md", + "reviews": [ + { + "gate": "tw-code-review / tw-doc-review", + "id": "AUTOADVANCE-VV-REVIEW", + "status": "passed", + "evidence_path": ".traceweaver/auto-advance-20261104/review-definition.json" + } + ] + }, + { + "req_id": "REQ-AUTOADVANCE-006", + "trace_id": "TRACE-AUTOADVANCE-006", + "ver_id": "VER-AUTOADVANCE-006", + "val_id": "VAL-AUTOADVANCE-006", + "verification_artifact": "tests/test_auth.py", + "red_evidence": ".traceweaver/auto-advance-20261104/red-integration.log", + "validation_artifact": ".traceweaver/auto-advance-20261104/validation.md", + "reviews": [ + { + "gate": "tw-code-review / tw-doc-review", + "id": "AUTOADVANCE-VV-REVIEW", + "status": "passed", + "evidence_path": ".traceweaver/auto-advance-20261104/review-definition.json" + } + ] + }, + { + "req_id": "REQ-AUTOADVANCE-007", + "trace_id": "TRACE-AUTOADVANCE-007", + "ver_id": "VER-AUTOADVANCE-007", + "val_id": "VAL-AUTOADVANCE-007", + "verification_artifact": "tests/test_release_policy_ui.py", + "red_evidence": ".traceweaver/auto-advance-20261104/red-integration.log", + "validation_artifact": ".traceweaver/auto-advance-20261104/validation.md", + "reviews": [ + { + "gate": "tw-code-review / tw-doc-review", + "id": "AUTOADVANCE-VV-REVIEW", + "status": "passed", + "evidence_path": ".traceweaver/auto-advance-20261104/review-definition.json" + } + ] + }, + { + "req_id": "REQ-AUTOADVANCE-008", + "trace_id": "TRACE-AUTOADVANCE-008", + "ver_id": "VER-AUTOADVANCE-008", + "val_id": "VAL-AUTOADVANCE-008", + "verification_artifact": "tests/test_auth.py", + "red_evidence": ".traceweaver/auto-advance-20261104/red-integration.log", + "validation_artifact": ".traceweaver/auto-advance-20261104/validation.md", + "reviews": [ + { + "gate": "tw-code-review / tw-doc-review", + "id": "AUTOADVANCE-VV-REVIEW", + "status": "passed", + "evidence_path": ".traceweaver/auto-advance-20261104/review-definition.json" + } + ] + } + ] +} diff --git a/.traceweaver/intent-contract.yml b/.traceweaver/intent-contract.yml index f60fc51..febed74 100644 --- a/.traceweaver/intent-contract.yml +++ b/.traceweaver/intent-contract.yml @@ -141,3 +141,10 @@ "status": "owner-approvedscope; REDdefinitionphase; runtimeheld" } } + +auto_advance_20261104: + authority: .traceweaver/auto-advance-20261104/authority.json + approved_plan_sha256: 94b783d925834004cd3f111dd2437f8ae531850f0184aaa29c52f89f84267795 + owner_approval: .traceweaver/auto-advance-20261104/owner-approval.json + scope: per-servicepinremoval/stable-following/manualinstall + runtime_acceptance: pending diff --git a/landing-page/index.html b/landing-page/index.html index 4f0b239..271609a 100644 --- a/landing-page/index.html +++ b/landing-page/index.html @@ -789,7 +789,7 @@

⚙️ System Updates

- +
@@ -801,6 +801,9 @@

⚙️ System Updates

+
+

Loading update policies…

+
@@ -1425,12 +1428,14 @@

${service.displayName}

} if (info.lifecycle_manager) { updateBtn += 'Managed by Server Manager'; + } else if (info.selected_release && name === 'cryptolabs-proxy') { + updateBtn += 'Managed by host updater'; + } else if (info.selected_release) { + updateBtn += `${info.update_available === true ? 'Selected update ready · review above' : info.update_available === false ? 'Selected release installed' : 'Selected release identity unavailable'}`; } else if (info.pinned) { updateBtn += 'Pinned image · managed separately'; } else if (!info.running) { updateBtn = 'Use service setup to start'; - } else if (info.selected_release) { - updateBtn += 'Use admitted releases above'; } else if (info.update_available === true) { updateBtn += ``; updatableCount++; @@ -1570,7 +1575,14 @@

${service.displayName}

} async function checkForUpdates() { - return startUpdateJob('pull', 'all'); + try { + await refreshReleasePolicies(); + await loadVersions(); + return await loadReleaseCandidates(); + } catch (error) { + showUpdateStatus('error', error.message || 'Could not refresh stable release selections.'); + return; + } } async function updateService(serviceName) { @@ -1603,6 +1615,87 @@

${service.displayName}

} const RELEASE_OPERATION_KEY = 'fleet-release-operation'; + let releasePolicies = null; + let releasePolicyEpoch = 0; + let releasePolicyBusy = false; + + function renderReleasePolicies() { + const container = document.getElementById('releasePolicyControls'); + container.replaceChildren(); + if (!releasePolicies) return; + const description = document.createElement('p'); + description.textContent = 'Pinned keeps the installed version. Follow stable selects compatible releases when checked. Installation remains manual.'; + container.appendChild(description); + for (const [name, policy] of Object.entries(releasePolicies.components || {})) { + const row = document.createElement('div'); + row.style.cssText = 'display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;margin:.75rem 0'; + const label = document.createElement('label'); + label.textContent = (DEFAULT_SERVICES[name] || {}).displayName || name.replace(/-/g, ' '); + const choice = document.createElement('select'); + choice.id = 'release-policy-' + name; + label.htmlFor = choice.id; + for (const [value, title] of [['pinned','Pinned'],['follow-stable','Follow stable']]) { + const option = document.createElement('option'); + option.value = value; option.textContent = title; + choice.appendChild(option); + } + choice.value = policy.mode; + choice.disabled = releasePolicyBusy || !policy.supported || releasePolicies.writes_enabled === false; + choice.addEventListener('change', () => setReleasePolicy(name, choice.value)); + const details = document.createElement('span'); + details.textContent = policy.reason || `Installed: ${policy.installed_release || 'unknown'} · Selected: ${policy.selected_release || 'unknown'}`; + row.appendChild(label); row.appendChild(choice); row.appendChild(details); + container.appendChild(row); + } + } + + async function loadReleasePolicies() { + const epoch = ++releasePolicyEpoch; + try { + const result = await releaseRequest('/api/releases/policies'); + if (epoch !== releasePolicyEpoch) return releasePolicies; + releasePolicies = result; + renderReleasePolicies(); + return result; + } catch (error) { + if (epoch === releasePolicyEpoch) { + releasePolicies = null; + document.getElementById('releasePolicyControls').textContent = error.message || 'Update policies unavailable.'; + } + throw error; + } + } + + async function setReleasePolicy(component, mode) { + if (releasePolicyBusy || !releasePolicies) return; + const expected = {expected_selection_id:releasePolicies.selection_id, + expected_selection_sha256:releasePolicies.selection_sha256}; + releasePolicyBusy = true; + renderReleasePolicies(); + try { + const result = await releaseRequest('/api/releases/policy-set', {component, mode, ...expected}); + releasePolicies = result; + await loadVersions(); + showUpdateStatus('success', mode === 'pinned' ? 'Installed version pinned.' : 'Following compatible stable releases. Use Check Updates to refresh the selected target.'); + } catch (error) { + showUpdateStatus('error', error.message || 'Could not confirm the update policy. Refresh before retrying.'); + } finally { + try { await loadReleasePolicies(); } catch (error) { /* Keep the explicit unavailable state. */ } + releasePolicyBusy = false; + if (releasePolicies) renderReleasePolicies(); + } + } + + async function refreshReleasePolicies() { + if (releasePolicyBusy) throw new Error('An update policy change is in progress.'); + const current = await loadReleasePolicies(); + const result = await releaseRequest('/api/releases/policy-refresh', { + expected_selection_id:current.selection_id, expected_selection_sha256:current.selection_sha256}); + releasePolicies = result; + renderReleasePolicies(); + return result; + } + const RELEASE_APPLY_KEY = 'fleet-release-apply'; let releaseSession = null; let releaseListing = null; @@ -2056,6 +2149,7 @@

${service.displayName}

loadBuildInfo(); loadVersions(); // This calls loadWatchdogStatus() at the end loadReleaseCandidates(); + loadReleasePolicies().catch(() => {}); try { const pendingUpdate = localStorage.getItem(UPDATE_JOB_KEY); if (pendingUpdate) pollUpdateJob(pendingUpdate); @@ -2067,6 +2161,7 @@

${service.displayName}

// Auto-refresh every 30 seconds setInterval(loadServices, 30000); setInterval(loadVersions, 60000); // loadVersions calls loadWatchdogStatus at the end + setInterval(() => loadReleasePolicies().catch(() => {}), 60000); diff --git a/requirements.md b/requirements.md index 76d304a..b61caf0 100644 --- a/requirements.md +++ b/requirements.md @@ -114,3 +114,39 @@ Authority: `.traceweaver/updater-stop-20261104/authority.json`, unchanged approv Recognize one explicitly named released-standard1.5.4 source via complete ordered structural equality and immutable eight-template historical closure; preserve current candidate rendering, auth/module identity, selection/source drift denial, declaration and recovery guards. Verify matching official wheel/container and disposable staging adoption before bounded separate official hostvenv provisioning, fixed native Proxy adoption and the already-approved single LTX IPMI retry. Authority: `.traceweaver/updater-stop-20261104/compatibility/owner-approval.json` and approved addendum. Verification `VER-UPDATER-003`; validation `VAL-UPDATER-003`. Operational outcomes remain held. + +## Owner-approved per-service stable following (4 November2026) + +Exact authority: `.traceweaver/auto-advance-20261104/plan.md`, SHA256 `94b783d925834004cd3f111dd2437f8ae531850f0184aaa29c52f89f84267795`; receipt `owner-approval.json`. Administrative REQ/TRACE/VER/VAL-AUTOADVANCE-001 through -008 map one-to-one to approved C1-C8 without changing their meaning. Source and verification may proceed; publication/runtime claims require their own current checks. Existing Fleet dependency holds remain. + +### REQ-AUTOADVANCE-001 — approved C1 + +An authenticated Fleet administrator can switch one supported managed service between Pinned and Follow stable; the visible installed/selected version and mode remain distinct. + +### REQ-AUTOADVANCE-002 — approved C2 + +Removing a pin opts only that service into verified stable candidates. Existing pins remain pinned by default. Pin current version freezes the verified installed digest; if its identity/provenance cannot be established, the operation fails without changing policy. + +### REQ-AUTOADVANCE-003 — approved C3 + +Follow stable selects the newest admitted compatible non-prerelease version within the installed major version. No downgrade; a major upgrade requires explicit selection. No usable newer candidate leaves the current selection intact and reports why. + +### REQ-AUTOADVANCE-004 — approved C4 + +Selection refresh occurs on Check Updates and a daily host-side check, including when no browser is open. It changes only opted-in services. Mode changes and refresh never install or restart containers. The existing Update action handles installation. + +### REQ-AUTOADVANCE-005 — approved C5 + +Policy mutation uses the existing host owner boundary, authenticated administrator/session/CSRF/origin checks, explicit service scope, bounded fields, selection compare-and-swap, and existing lifecycle locks. A native CLI exposes the same choices. Failed persistence, stale requests, active operations, unavailable discovery or failed trust verification preserve prior selections and modes. + +### REQ-AUTOADVANCE-006 — approved C6 + +Use verified published release/admission metadata, fixed trusted repository/channel sources and exact immutable digests. Do not replace digests with mutable :latest tags or loosen publisher/signature checks. External-owned services retain their owning lifecycle workflow; unsupported services show an explanation rather than pretending the change succeeded. + +### REQ-AUTOADVANCE-007 — approved C7 + +Show current mode, installed version, selected target and clear failure/progress states; refreshes cannot silently re-pin or cross major versions. Re-pinning prevents a concurrent scheduled refresh from advancing afterward. + +### REQ-AUTOADVANCE-008 — approved C8 + +TDD, independent correctness/security review, native Linux lifecycle/policy verification and real-browser checks precede release. Existing LTX data/accounts/settings and unrelated pins/runtimes remain preserved. diff --git a/src/cryptolabs_proxy/auth.py b/src/cryptolabs_proxy/auth.py index d136d76..0209405 100644 --- a/src/cryptolabs_proxy/auth.py +++ b/src/cryptolabs_proxy/auth.py @@ -1363,6 +1363,8 @@ def release_candidates(): @app.route('/api/releases/preview',methods=['POST']) @app.route('/api/releases/apply',methods=['POST']) + @app.route('/api/releases/policy-set',methods=['POST']) + @app.route('/api/releases/policy-refresh',methods=['POST']) def release_mutation(): from .release_client import ReleaseBrokerError try: @@ -1370,6 +1372,12 @@ def release_mutation(): return release_dispatch(request.path.rsplit('/',1)[-1],release_body()) except ReleaseBrokerError as error: return release_error(error) + @app.route('/api/releases/policies') +# TRACEWEAVER: entrypoint=release_policies; req=REQ-AUTOADVANCE-005; trace=TRACE-AUTOADVANCE-005; ver=VER-AUTOADVANCE-005 + def release_policies(): + if request.args: return jsonify({'error':'Invalid policy query.'}),400 + return release_dispatch('policy-list',{}) + @app.route('/api/releases/operations/') def release_operation_status(operation_id): return release_dispatch('status',{'operation_id':operation_id}) diff --git a/src/cryptolabs_proxy/release_client.py b/src/cryptolabs_proxy/release_client.py index 6fe64db..194bbdd 100644 --- a/src/cryptolabs_proxy/release_client.py +++ b/src/cryptolabs_proxy/release_client.py @@ -35,11 +35,19 @@ def unique(pairs): def validate_params(action, params): fields = {'list': {'channel'}, 'preview': {'candidate_ids','components','expected_selection_id','expected_selection_sha256','operation_kind'}, 'apply': {'operation_id','preview_id','preview_sha256','expected_selection_id','expected_selection_sha256'}, - 'status': {'operation_id'}, 'recover': {'operation_id'}} + 'status': {'operation_id'}, 'recover': {'operation_id'}, 'policy-list':set(), + 'policy-set':{'component','mode','expected_selection_id','expected_selection_sha256'}, + 'policy-refresh':{'expected_selection_id','expected_selection_sha256'}} if action not in fields or not isinstance(params, dict) or set(params) != fields[action]: raise ReleaseBrokerError('invalid_request', 'Invalid release request fields.', 400) for key, value in params.items(): - if key in ('candidate_ids','components'): + if key == 'component': + if not isinstance(value,str) or value not in COMPONENTS: + raise ReleaseBrokerError('invalid_request','Invalid release component.',400) + elif key == 'mode': + if value not in ('pinned','follow-stable'): + raise ReleaseBrokerError('invalid_request','Invalid update policy.',400) + elif key in ('candidate_ids','components'): if (not isinstance(value, list) or not 1 <= len(value) <= 32 or any(not isinstance(x,str) or not 1 <= len(x) <= 128 for x in value) or len(set(value)) != len(value) or key == 'components' and any(x not in COMPONENTS for x in value)): diff --git a/tests/test_auth.py b/tests/test_auth.py index 033a404..4d50854 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -9,6 +9,66 @@ import pytest +@pytest.mark.parametrize('action,body', [ + ('policy-set', {'component':'ipmi-monitor','mode':'follow-stable','expected_selection_id':'fixture','expected_selection_sha256':'a'*64}), + ('policy-set', {'component':'ipmi-monitor','mode':'pinned','expected_selection_id':'fixture','expected_selection_sha256':'a'*64}), + ('policy-refresh', {'expected_selection_id':'fixture','expected_selection_sha256':'a'*64}), +]) +def test_release_policy_routes_require_live_admin_origin_and_session_token(app, logged_in_admin, monkeypatch, action, body): + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN'] = 'http://localhost' + calls = [] + monkeypatch.setattr(ReleaseBrokerClient, 'request', lambda self, action, params, actor: calls.append((action,params,actor)) or {'components':{}}) + token = logged_in_admin.get('/api/releases/session').json['csrf_token'] + path = '/api/releases/' + action + assert logged_in_admin.post(path,json=body,headers={'Origin':'http://evil.test','X-CSRF-Token':token}).status_code == 403 + assert logged_in_admin.post(path,json=body,headers={'Origin':'http://localhost','X-CSRF-Token':'bad'}).status_code == 403 + assert calls == [] + response = logged_in_admin.post(path,json=body,headers={'Origin':'http://localhost','X-CSRF-Token':token}) + assert response.status_code == 200 + assert calls[-1][0:2] == (action,body) + assert calls[-1][2]['kind'] == 'fleet-admin' + + +def test_release_policy_listing_rejects_readonly_users(logged_in_readonly, monkeypatch): + from cryptolabs_proxy.release_client import ReleaseBrokerClient + monkeypatch.setattr(ReleaseBrokerClient,'request',lambda *a,**k:pytest.fail('readonly request reached host')) + assert logged_in_readonly.get('/api/releases/policies').status_code == 403 + + +@pytest.mark.parametrize('change', [{'mode':'latest'}, {'component':'../../etc/passwd'}, {'url':'https://evil.test'}, {'mode':True}]) +def test_policy_client_rejects_unknown_modes_paths_and_fields(change): + from cryptolabs_proxy.release_client import validate_params,ReleaseBrokerError + body = {'component':'ipmi-monitor','mode':'follow-stable','expected_selection_id':'fixture','expected_selection_sha256':'a'*64} + validate_params('policy-set',body) + body.update(change) + with pytest.raises(ReleaseBrokerError): validate_params('policy-set',body) + + +@pytest.mark.parametrize('action', ['policy-set','policy-refresh']) +@pytest.mark.parametrize('failure',['readonly','missing-origin','missing-token','other-session','revoked']) +def test_policy_mutations_cannot_expand_browser_authority(app,logged_in_admin,logged_in_readonly,monkeypatch,action,failure): + import cryptolabs_proxy.auth as auth + from cryptolabs_proxy.release_client import ReleaseBrokerClient + app.config['RELEASE_PUBLIC_ORIGIN']='http://localhost';calls=[] + monkeypatch.setattr(ReleaseBrokerClient,'request',lambda *a,**kw:calls.append(a) or {}) + # The two fixture clients may share the session; establish an independent admin session. + client=app.test_client();client.post('/auth/login',data={'username':'admin','password':'adminpass'}) + token=client.get('/api/releases/session').json['csrf_token'] + headers={'Origin':'http://localhost','X-CSRF-Token':token} + body={'expected_selection_id':'fixture','expected_selection_sha256':'a'*64} + if action=='policy-set':body.update(component='ipmi-monitor',mode='follow-stable') + if failure=='readonly':client=logged_in_readonly + if failure=='missing-origin':headers.pop('Origin') + if failure=='missing-token':headers.pop('X-CSRF-Token') + if failure=='other-session': + other=app.test_client();other.post('/auth/login',data={'username':'admin','password':'adminpass'}) + headers['X-CSRF-Token']=other.get('/api/releases/session').json['csrf_token'] + if failure=='revoked':auth.update_user('admin',role='readonly') + assert client.post('/api/releases/'+action,json=body,headers=headers).status_code in (401,403) + assert calls==[] + + # --------------------------------------------------------------------------- # Token management # --------------------------------------------------------------------------- diff --git a/tests/test_release_policy_ui.py b/tests/test_release_policy_ui.py new file mode 100644 index 0000000..8539e7f --- /dev/null +++ b/tests/test_release_policy_ui.py @@ -0,0 +1,71 @@ +# TRACEWEAVER: file-role=policy-browser-contract; req=REQ-AUTOADVANCE-008; trace=TRACE-AUTOADVANCE-008; ver=VER-AUTOADVANCE-008 +# TRACEWEAVER: verifies=VER-AUTOADVANCE-008; req=REQ-AUTOADVANCE-008; trace=TRACE-AUTOADVANCE-008 +# TRACEWEAVER: file-role=policy-browser-contract; req=REQ-AUTOADVANCE-008; trace=TRACE-AUTOADVANCE-008; ver=VER-AUTOADVANCE-008 +"""Run the actual page's policy action in Node; synthetic transport, no LTX calls.""" +import json +from pathlib import Path +import re +import subprocess + + +def test_policy_action_uses_displayed_identity_and_never_calls_install(): + page = (Path(__file__).resolve().parents[1] / 'landing-page/index.html').read_text() + script = '\n'.join(re.findall(r']*>(.*?)',page,re.S)) + # Browser bootstrap does not run in this DOM fixture; call the actual public action. + fixture = r''' +const assert = require('assert'); const calls=[]; +const nodes = new Map(); +function node(){return {innerHTML:'',textContent:'',disabled:false,value:'main',hidden:false, +style:{},dataset:{},appendChild(){},append(){},replaceChildren(){},remove(){},after(){}, +addEventListener(){},querySelector(){return node()},querySelectorAll(){return []}}} +global.document={getElementById(id){if(!nodes.has(id))nodes.set(id,node());return nodes.get(id)}, +querySelector(){return node()},querySelectorAll(){return []},createElement:node,addEventListener(){}}; +global.window={addEventListener(){},location:{origin:'http://fixture.test'}}; +global.localStorage={getItem(){return null},setItem(){},removeItem(){}}; +global.setInterval=()=>0;global.setTimeout=()=>0;global.clearTimeout=()=>{}; +global.fetch=async(path,options={})=>{calls.push([path,options.body ? JSON.parse(options.body):null]); +return {ok:true,json:async()=>path.includes('session') ? {csrf_token:'fixture-token'} : { +selection_id:'displayed-release',selection_sha256:'a'.repeat(64),components:{'ipmi-monitor':{mode:'follow-stable',supported:true,installed_release:'1.4.0',selected_release:'1.4.0'}}}}}; +''' + check = r''' +(async()=>{await loadReleasePolicies();await setReleasePolicy('ipmi-monitor','follow-stable'); +const request=calls.find(x=>x[0]==='/api/releases/policy-set');assert(request); +assert.deepEqual(request[1],{component:'ipmi-monitor',mode:'follow-stable',expected_selection_id:'displayed-release',expected_selection_sha256:'a'.repeat(64)}); +assert(!calls.some(x=>x[0]==='/api/update'||x[0]==='/api/releases/apply')); +console.log('policy action bound to displayed selection; no install');})().catch(e=>{console.error(e);process.exitCode=1}); +''' + result = subprocess.run(['node'],input=fixture+script+check,text=True,capture_output=True) + assert result.returncode == 0, result.stderr + + +def test_check_updates_refreshes_policy_before_discovery_and_does_not_install(): + page=(Path(__file__).resolve().parents[1]/'landing-page/index.html').read_text() + assert 'onclick="checkForUpdates()"' in page + # A focused real-function execution avoids stubbing the rest of the dashboard. + start=page.index('async function checkForUpdates()') + function=page[start:page.index('\n async function updateService',start)] + script=r''' +const assert=require('assert');const calls=[]; +async function refreshReleasePolicies(){calls.push('advance')} +async function loadVersions(){calls.push('versions')} +async function loadReleaseCandidates(){calls.push('discovery')} +async function startUpdateJob(action,service){calls.push([action,service])} +function showUpdateStatus(){throw Error('unexpected failure')} +'''+function+r''' +(async()=>{await checkForUpdates();assert.deepEqual(calls,['advance','versions','discovery'])})().catch(e=>{console.error(e);process.exitCode=1}); +''' + result=subprocess.run(['node'],input=script,text=True,capture_output=True) + assert result.returncode==0,result.stderr + + +def test_selected_pinned_service_is_not_misreported_as_managed_separately(): + from tests.test_latest_status import landing_script + script=landing_script()+r''' +loadWatchdogStatus=()=>{};const grid={innerHTML:''}; +global.document={getElementById:id=>id==='versionsGrid'?grid:id==='targetBranch'?{value:'main'}:null, +querySelector:()=>({style:{}})}; +global.fetch=async()=>({ok:true,json:async()=>({'ipmi-monitor':{running:true,version:'1.4.0',selected_release:'1.4.0',selected_channel:'main',pinned:true,update_available:false}})}); +loadVersions().then(()=>{if(grid.innerHTML.includes('managed separately')||!grid.innerHTML.includes('Selected release installed'))process.exitCode=1}); +''' + result=subprocess.run(['node'],input=script,text=True,capture_output=True) + assert result.returncode==0,result.stderr diff --git a/traceability-matrix.md b/traceability-matrix.md index fcf9231..bf2da62 100644 --- a/traceability-matrix.md +++ b/traceability-matrix.md @@ -27,6 +27,12 @@ Implementation candidate; all installed/runtime/native/browser/requirement closu | Artifact Path | Requirement | Trace | Verification | Anchor Type | Role | | --- | --- | --- | --- | --- | --- | +| tests/test_release_policy_ui.py | REQ-AUTOADVANCE-008 | TRACE-AUTOADVANCE-008 | VER-AUTOADVANCE-008 | file-role | policy-browser-contract | +| tests/test_release_policy_ui.py | REQ-AUTOADVANCE-008 | TRACE-AUTOADVANCE-008 | VER-AUTOADVANCE-008 | verifies | policy-browser-contract | +| src/cryptolabs_proxy/auth.py | REQ-AUTOADVANCE-005 | TRACE-AUTOADVANCE-005 | VER-AUTOADVANCE-005 | entrypoint | policy-entrypoint | + +| Artifact Path | Requirement | Trace | Verification | Anchor Type | Role | +| --- | --- | --- | --- | --- | --- | --- | | src/cryptolabs_proxy/auth.py | REQ-FLEETUPDATE-001 | TRACE-FLEETUPDATE-001 | VER-FLEETUPDATE-001 | file-role | fleet-release-implementation | | src/cryptolabs_proxy/release_client.py | REQ-FLEETUPDATE-001 | TRACE-FLEETUPDATE-001 | VER-FLEETUPDATE-001 | file-role | fleet-release-implementation | | src/cryptolabs_proxy/release_selection.py | REQ-FLEETUPDATE-001 | TRACE-FLEETUPDATE-001 | VER-FLEETUPDATE-001 | file-role | fleet-release-implementation | @@ -97,7 +103,7 @@ The TRACE-PAIR rows identify owner-approved and quality-reviewed source implemen These are generated raw data copies within the approved verification scope. Inline trace fields would change the canonical producer/specification artifact. The external mapping is the supported generated-file exception, not a claim of inline source coverage. Exact producer bytes are emitted by the actual WordPress handler under synthetic credentials and compared by consumer tests. No API body, assertion or scanner rule changes. Source implementation and installed acceptance remain separately gated. | Artifact Path | Requirement | Trace | Verification | Anchor Type | Provenance / acceptance | -| --- | --- | --- | --- | --- | --- | +| --- | --- | --- | --- | --- | --- | --- | | tests/fixtures/pairing-producer-v1.json | REQ-PAIR-001; REQ-PAIR-003; REQ-PAIR-004; REQ-PAIR-005; REQ-PAIR-006 | TRACE-PAIR-001; TRACE-PAIR-003; TRACE-PAIR-004; TRACE-PAIR-005; TRACE-PAIR-006 | VER-PAIR-001; VER-PAIR-003; VER-PAIR-004; VER-PAIR-005; VER-PAIR-006 | Exception | Generated data approved for exact-byte provenance mapping; SHA256 d58a17f90c33e935b4307b5816e462d0445ee984b72e1d03948dfa3cd9d9404d; regenerate and drift-check from wordpress/actual-producer-fixture.json | | tests/fixtures/pairing-wire-v1.json | REQ-PAIR-001; REQ-PAIR-003; REQ-PAIR-004; REQ-PAIR-005; REQ-PAIR-006 | TRACE-PAIR-001; TRACE-PAIR-003; TRACE-PAIR-004; TRACE-PAIR-005; TRACE-PAIR-006 | VER-PAIR-001; VER-PAIR-003; VER-PAIR-004; VER-PAIR-005; VER-PAIR-006 | Exception | Generated data approved for exact-byte provenance mapping; SHA256 8ccd5db5c28c55da4e9118c7f913e45532e3213007b8e98a4af214f4bb20d652; regenerate and drift-check from fixtures/pairing-wire-v1.json | @@ -136,7 +142,7 @@ producer/template dependency identities are in `../artifacts/ipmi-readiness-2026 Root approved the frozen fixture scope in `../artifacts/ipmi-readiness-20261026/fleet-ui-20261102/execution/main16-compat/plan/root-test-only-go.json`. | Artifact Path | Requirement | Trace | Verification | Anchor Type | Provenance / acceptance | -| --- | --- | --- | --- | --- | --- | +| --- | --- | --- | --- | --- | --- | --- | | src/cryptolabs_proxy/templates/previous-standard-nginx-8e2b421.conf.j2 | REQ-FLEETUPDATE-001 | TRACE-FLEETUPDATE-001 | VER-FLEETUPDATE-001 | Exception | Immutable released Main producer data; SHA256 05828b78143c5d6cd7cbe60700cfc54b627cb4efff9a7af43439fdb56e25edab; exact-byte provenance and adoption/corruption tests; native/site acceptance held | | tests/fixtures/main16-self-signed.conf | REQ-FLEETUPDATE-001 | TRACE-FLEETUPDATE-001 | VER-FLEETUPDATE-001 | Exception | Immutable released Main producer data; SHA256 f1f570ca848b5dbf15d635bf4d3de90cbb89a68e2c9da0761d6295b7d46a80b6; exact-byte provenance and adoption/corruption tests; native/site acceptance held | | tests/fixtures/main16-letsencrypt.conf | REQ-FLEETUPDATE-001 | TRACE-FLEETUPDATE-001 | VER-FLEETUPDATE-001 | Exception | Immutable released Main producer data; SHA256 5cad780fddc4aea67b2a13e26b808e9b1454f9a33abd6d459b82d8066423cbb5; exact-byte provenance and adoption/corruption tests; native/site acceptance held | @@ -145,7 +151,7 @@ Root approved the frozen fixture scope in `../artifacts/ipmi-readiness-20261026/ ## Updater source integration Code Anchor Evidence | Artifact Path | Requirement | Trace | Verification | Anchor Type | Role | -| --- | --- | --- | --- | --- | --- | +| --- | --- | --- | --- | --- | --- | --- | | src/cryptolabs_proxy/migration.py | REQ-UPDATER-001 | TRACE-UPDATER-001 | VER-UPDATER-001 | file-role | bounded-docker-stop-transport | | src/cryptolabs_proxy/migration.py | REQ-UPDATER-001 | TRACE-UPDATER-001 | VER-UPDATER-001 | entrypoint | bounded-docker-stop-transport | | src/cryptolabs_proxy/updates.py | REQ-UPDATER-002 | TRACE-UPDATER-002 | VER-UPDATER-002 | file-role | safe-update-recovery-diagnostics | @@ -170,3 +176,26 @@ Root approved the frozen fixture scope in `../artifacts/ipmi-readiness-20261026/ | tests/fixtures/released-standard-1.5.4-declaration.json | REQ-UPDATER-003 | TRACE-UPDATER-003 | VER-UPDATER-003 | Exception | Immutable released/synthetic resource; SHA256 c4f4f3e80ff3920522502f47335120e133bca6d8e8245133d0112b550f382274; root-precode-review.json generated_resource_anchor_exception; external provenance mapping, no inline mutation | | tests/fixtures/released-standard-1.5.4-provenance.json | REQ-UPDATER-003 | TRACE-UPDATER-003 | VER-UPDATER-003 | Exception | Immutable released/synthetic resource; SHA256 a3203f4e7ef156b403cd039d53c7dc2f02940499c1abb1c862cd52fa5524bff3; root-precode-review.json generated_resource_anchor_exception; external provenance mapping, no inline mutation | | tests/fixtures/released-standard-1.5.4.nginx.conf | REQ-UPDATER-003 | TRACE-UPDATER-003 | VER-UPDATER-003 | Exception | Immutable released/synthetic resource; SHA256 f1aa7a0ed767e7432dd21123fc0086e0cc98e0b26ad3c95eff5fe233095e3e72; root-precode-review.json generated_resource_anchor_exception; external provenance mapping, no inline mutation | + +## Approved auto-advance work-item mapping + +| Trace | Requirement | Implementation | Verification | Validation | Status | +| --- | --- | --- | --- | --- | --- | --- | +| TRACE-AUTOADVANCE-001 | REQ-AUTOADVANCE-001 | approved plan step1-5; file anchors added as implemented | VER-AUTOADVANCE-001 | VAL-AUTOADVANCE-001 | owner-approved / verification pending | +| TRACE-AUTOADVANCE-002 | REQ-AUTOADVANCE-002 | approved plan step1-5; file anchors added as implemented | VER-AUTOADVANCE-002 | VAL-AUTOADVANCE-002 | owner-approved / verification pending | +| TRACE-AUTOADVANCE-003 | REQ-AUTOADVANCE-003 | approved plan step1-5; file anchors added as implemented | VER-AUTOADVANCE-003 | VAL-AUTOADVANCE-003 | owner-approved / verification pending | +| TRACE-AUTOADVANCE-004 | REQ-AUTOADVANCE-004 | approved plan step1-5; file anchors added as implemented | VER-AUTOADVANCE-004 | VAL-AUTOADVANCE-004 | owner-approved / verification pending | +| TRACE-AUTOADVANCE-005 | REQ-AUTOADVANCE-005 | approved plan step1-5; file anchors added as implemented | VER-AUTOADVANCE-005 | VAL-AUTOADVANCE-005 | owner-approved / verification pending | +| TRACE-AUTOADVANCE-006 | REQ-AUTOADVANCE-006 | approved plan step1-5; file anchors added as implemented | VER-AUTOADVANCE-006 | VAL-AUTOADVANCE-006 | owner-approved / verification pending | +| TRACE-AUTOADVANCE-007 | REQ-AUTOADVANCE-007 | approved plan step1-5; file anchors added as implemented | VER-AUTOADVANCE-007 | VAL-AUTOADVANCE-007 | owner-approved / verification pending | +| TRACE-AUTOADVANCE-008 | REQ-AUTOADVANCE-008 | approved plan step1-5; file anchors added as implemented | VER-AUTOADVANCE-008 | VAL-AUTOADVANCE-008 | owner-approved / verification pending | + +## Traceability Matrix + +| Implementation | Requirement | Trace | Verification | Validation | Status | +| --- | --- | --- | --- | --- | --- | +| landing-page/index.html | REQ-AUTOADVANCE-001 | TRACE-AUTOADVANCE-001 | VER-AUTOADVANCE-001 | VAL-AUTOADVANCE-001 | owner-approved | +| src/cryptolabs_proxy/auth.py | REQ-AUTOADVANCE-005 | TRACE-AUTOADVANCE-005 | VER-AUTOADVANCE-005 | VAL-AUTOADVANCE-005 | owner-approved | +| src/cryptolabs_proxy/release_client.py | REQ-AUTOADVANCE-005 | TRACE-AUTOADVANCE-005 | VER-AUTOADVANCE-005 | VAL-AUTOADVANCE-005 | owner-approved | +| tests/test_auth.py | REQ-AUTOADVANCE-005 | TRACE-AUTOADVANCE-005 | VER-AUTOADVANCE-005 | VAL-AUTOADVANCE-005 | owner-approved | +| tests/test_release_policy_ui.py | REQ-AUTOADVANCE-008 | TRACE-AUTOADVANCE-008 | VER-AUTOADVANCE-008 | VAL-AUTOADVANCE-008 | owner-approved |