From f848c041e210f72e880d15460336be8880da2d24 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:07:52 +0100 Subject: [PATCH 01/72] docs(spec): establish v0.9.1 readiness package --- .../change-impact.md | 90 ++++++++ .../design.md | 194 ++++++++++++++++ .../requirements.md | 214 ++++++++++++++++++ .../tasks.md | 177 +++++++++++++++ .../traceability.md | 68 ++++++ .../verification.md | 171 ++++++++++++++ docs/specs/README.md | 12 +- 7 files changed, 923 insertions(+), 3 deletions(-) create mode 100644 docs/specs/007-release-readiness-stabilization/change-impact.md create mode 100644 docs/specs/007-release-readiness-stabilization/design.md create mode 100644 docs/specs/007-release-readiness-stabilization/requirements.md create mode 100644 docs/specs/007-release-readiness-stabilization/tasks.md create mode 100644 docs/specs/007-release-readiness-stabilization/traceability.md create mode 100644 docs/specs/007-release-readiness-stabilization/verification.md diff --git a/docs/specs/007-release-readiness-stabilization/change-impact.md b/docs/specs/007-release-readiness-stabilization/change-impact.md new file mode 100644 index 0000000..9f705f1 --- /dev/null +++ b/docs/specs/007-release-readiness-stabilization/change-impact.md @@ -0,0 +1,90 @@ +--- +title: Release readiness stabilization change impact +doc_type: spec +artifact_type: change-impact +status: active +owner: Auriora Team +last_reviewed: 2026-07-18 +--- + +# Change Impact + +## Purpose + +Record the durable behavior and documentation changed while preparing the +bounded `v0.9.1` stabilization release. + +## Durable Source Mapping + +| Source | Current behavior relied on | Confidence | Notes | +|--------|----------------------------|------------|-------| +| `.github/workflows/test-suite.yml` | Normal CI runs all non-performance, non-stress tests but provisions no MinIO. | high | Current failure source. | +| `.github/workflows/release.yml` | A version tag triggers tests, build, wheel smoke, artifact upload, and GitHub release creation. | high | Must be rehearsed without publication. | +| `pyproject.toml` | Version `0.9.0`, Python range, package metadata, scripts, markers, and coverage threshold. | high | Will move to `0.9.1`. | +| `docs/guides/user/installation.md` | Current source install and test guidance. | high | Must reflect only verified artifact and platform behavior. | +| `CHARTER.md` | PyPI distribution is outside current project state. | high | Remains unchanged. | + +## Change Type + +- **Primary type:** operational +- **Breaking change:** no +- **Durable docs required:** yes +- **External behavior affected:** yes, CI and release artifacts + +## Proposed Changes + +| Change | Type | Source of truth | New durable destination | Promotion required | +|--------|------|-----------------|-------------------------|-------------------| +| Separate normal and MinIO-dependent test ownership | modify | `.github/workflows/test-suite.yml` | `docs/4-testing/README.md` and workflow | yes | +| Stabilize the selection stress signal | bug_fix | GitHub issue #68 | test code and durable testing guidance | yes | +| Bump package version to `0.9.1` | modify | `pyproject.toml` and package version module | same files, README or changelog where appropriate | yes | +| Validate sdist and wheel installs | add | release evidence | `docs/guides/user/installation.md` | yes | +| Define the release operator procedure | add | Spec 007 design and rehearsal evidence | `docs/processes/` | yes | +| Publish accurate `v0.9.1` communications | add | Git history and verification evidence | `CHANGELOG.md` and durable release notes | yes | +| Defer PyPI and `1.0.0` | clarify | `CHARTER.md`, milestone decision | release process and release notes | yes | + +## Promotion Targets + +| Spec content | Durable destination | Promotion status | Notes | +|--------------|---------------------|------------------|-------| +| Test profile contract and commands | `docs/4-testing/README.md` | pending | Include MinIO prerequisites and extended profile. | +| Verified install matrix and prerequisites | `docs/guides/user/installation.md` | pending | Do not claim untested platforms. | +| Release procedure and rollback boundary | new current-state document under `docs/processes/` | pending | Link from process index. | +| Release contents and limitations | `CHANGELOG.md` and durable `v0.9.1` release notes | pending | Evidence-backed claims only. | +| Current version and release path | `README.md` where needed | pending | Keep front door concise. | + +## Unchanged Durable Areas + +| Durable area | Reviewed source | Reason unchanged | +|--------------|-----------------|------------------| +| Product scope | `CHARTER.md` | Stabilization does not expand the product or publication boundary. | +| Application architecture | `docs/2-architecture/` | No runtime component boundary changes are intended. | +| Credential handling | durable security and user guidance | CI uses only ephemeral MinIO values; repository credential behavior is out of scope. | +| CLI feature backlog | GitHub issues #5, #7, #9, #11, #28-#30, #33-#34, #54-#56 | These are reconciled but not pulled into the patch release spec. | + +## Bug Fix Details + +- **Observed behavior:** GitHub Actions run 29653160911 failed with one failure + and four setup errors because MinIO tests attempted to resolve an unavailable + endpoint; 1310 tests passed before the maximum-failure stop. +- **Expected behavior:** Each CI profile provisions every external dependency + needed by its selected tests and fails dependency preflight clearly. +- **Root cause evidence:** `.github/workflows/test-suite.yml` runs + `pytest -m "not performance and not stress"` and contains no MinIO service or + exclusion for tests under the MinIO integration class. +- **Regression risk:** An over-broad marker expression could hide integration + coverage; collection comparison and the explicit profile mitigate it. +- **Durable doc update needed:** Yes, testing profile and prerequisite guidance. + +## Open Questions + +None block implementation. Platform claims are reconciled from current +metadata and executable evidence in T007. + +## Related Artifacts + +- Requirements: `requirements.md` +- Design: `design.md` +- Tasks: `tasks.md` +- Verification: `verification.md` +- Traceability: `traceability.md` diff --git a/docs/specs/007-release-readiness-stabilization/design.md b/docs/specs/007-release-readiness-stabilization/design.md new file mode 100644 index 0000000..fe5ce8e --- /dev/null +++ b/docs/specs/007-release-readiness-stabilization/design.md @@ -0,0 +1,194 @@ +--- +title: Release readiness stabilization design +doc_type: spec +artifact_type: design +status: active +owner: Auriora Team +last_reviewed: 2026-07-18 +--- + +# Technical Design + +## Overview + +The release is prepared as a sequence of independently verifiable gates. CI +profiles first become dependency-correct; the known stress signal is resolved +through issue #68; versioned artifacts are then built once and installed into +clean environments; finally, the existing release workflow is rehearsed and +the evidence is promoted into durable guidance and release communications. + +## Requirement Coverage + +| Requirement | Acceptance Criteria | Design Coverage | Validation Approach | +|-------------|---------------------|-----------------|---------------------| +| R1 | AC1-AC4 | Marker/profile ownership plus explicit MinIO dependency gate | Workflow review, normal CI, MinIO profile | +| R2 | AC1-AC3 | Issue #68 remains the single execution record; Spec 007 consumes its evidence | Issue acceptance review, extended profile | +| R3 | AC1-AC4 | One version guard and one artifact set reused by smoke validation | Build, metadata inspection, hashes, CLI version | +| R4 | AC1-AC4 | Clean environment matrix derived from current support claims | Wheel and sdist installs, CLI smoke tests | +| R5 | AC1-AC5 | Non-publishing rehearsal followed by durable process and release-note promotion | Workflow lint/review, dry run, docs review | + +## Correctness Property Coverage + +| Property | Design Behavior | Validation Direction | Notes | +|----------|-----------------|----------------------|-------| +| CP-001 | Tests requiring MinIO are collected into a dependency-owning profile; normal CI excludes only that explicit integration class | Collection checks plus both workflow profiles | Marker selection must not hide unrelated integration tests. | +| CP-002 | A shared version verification command compares intended tag, `pyproject.toml`, import version, and installed CLI | Negative and positive version checks | Production tag is never needed for rehearsal. | +| CP-003 | The same smoke contract is run against wheel and sdist installs | Clean virtual environments and supported platform jobs | System prerequisites remain explicit. | +| CP-004 | Rehearsal stops before tag creation and uses workflow validation or a non-publishing harness | Command review and absence of new tag/release | Any external write needs separate release approval. | +| CP-005 | Release-note items link to commits, specs, issues, tests, or known limitations | Documentation and release review | Generated notes may be input, not sole evidence. | + +## High-Level Design + +### Release Gate Flow + +```text +CI profile repair + -> MinIO profile proof + -> issue #68 stress evidence + -> version bump and artifact build + -> clean-install matrix + -> release workflow rehearsal + -> durable docs and release notes + -> human release decision +``` + +### Components and Changes + +- `.github/workflows/test-suite.yml`: make normal and external-service test + ownership explicit; add or invoke a MinIO-capable profile. +- Test markers and MinIO fixtures: expose dependency requirements before a + network call and provide actionable failure behavior. +- GitHub issue #68: own calibration of the selection stress threshold; this + spec links its result instead of duplicating implementation tasks. +- `pyproject.toml` and `src/TimeLocker/__init__.py`: move together to `0.9.1`. +- Build and smoke tooling: build once, inspect both artifacts, and install each + in isolated environments. +- `.github/workflows/release.yml`: preserve tag-triggered publication while + extracting or documenting a safe pre-tag rehearsal path where practical. +- `CHANGELOG.md`, release notes, installation guide, and release process: + receive accepted current-state guidance before spec closure. + +### Data Models + +No application data model changes are required. Release evidence uses files and +external records: workflow runs, `dist/` artifacts, `SHA256SUMS`, clean-install +logs, issue #68, changelog text, and release review notes. Generated `dist/` +content remains untracked unless repository policy explicitly says otherwise. + +### Data Flow + +Source metadata determines the build version. A clean checkout produces sdist +and wheel artifacts plus hashes. Each artifact is installed into a fresh +environment and queried through both console entry points. CI and external +issue results feed the verification record. Accepted operator and user guidance +is promoted to durable docs, while the spec remains the temporary coordination +surface until closure. + +## Low-Level Design + +### CI Profile Logic + +1. Identify MinIO-dependent tests by marker, path, or dedicated pytest + collection contract. +2. Make normal CI exclude that exact external-service class while retaining all + other non-performance, non-stress tests. +3. Add a job or documented command that provisions MinIO, waits for readiness, + exports the endpoint and credentials, and runs only the MinIO class. +4. Ensure missing dependency state produces a clear preflight failure. +5. Compare collected test counts before and after the profile change to catch + accidental test loss. + +### Version and Artifact Guard + +```text +expected = "0.9.1" +assert pyproject_version == expected +assert imported_version == expected +build sdist and wheel once +for artifact in [wheel, sdist]: + install artifact in a fresh environment + assert timelocker version --short == expected + assert tl version --short == expected +record metadata and SHA-256 +``` + +### Clean-Install Matrix + +The matrix is derived from `requires-python`, classifiers, workflow coverage, +and installation claims. At minimum it covers Python 3.12 and 3.13. Operating +systems are either validated or their claims are narrowed; the spec does not +manufacture support from unexecuted workflow branches. + +### Release Rehearsal + +The rehearsal validates checkout depth, Restic acquisition and checksum, +version guard, normal tests, artifact build, smoke install, artifact upload +configuration, release-note inputs, permissions, and rollback instructions. +The publishing boundary is a hard stop before `git tag`, tag push, +`gh release create`, or any package-index upload. + +### Error Handling + +- Missing MinIO fails at dependency preflight in the MinIO profile. +- Test collection drift blocks the CI-profile task. +- Version mismatch or artifact-install failure blocks downstream release tasks. +- Unsupported platform results are recorded as blocking support-claim gaps, not + silently ignored. +- Rehearsal or workflow uncertainty remains a release blocker until reviewed. + +### Security, Trust, and Access + +MinIO CI credentials must be ephemeral non-production values. Logs and +artifacts must not contain repository passwords, tokens, or callback material. +The rehearsal requires read access only; tag push and GitHub release creation +remain separately authorized release actions. PyPI credentials are neither +required nor accessed. + +### Migration and Compatibility + +This is a patch release. No application data migration or intended breaking CLI +change is included. Any discovered breaking change is removed from the release +or escalated for a new requirement and explicit versioning decision. + +## Validation Strategy + +| Validation | Covers | Evidence Location | Residual Risk | +|------------|--------|-------------------|---------------| +| Normal CI and collection comparison | R1, CP-001 | `verification.md`, Actions run | Hosted-runner variance | +| Provisioned MinIO profile and dependency preflight | R1, CP-001 | `verification.md`, Actions run | Service image drift | +| Issue #68 evidence and extended profile | R2 | GitHub issue #68, `verification.md` | Hardware variance | +| Build, metadata, hashes, wheel and sdist installs | R3, R4, CP-002, CP-003 | `verification.md`, artifacts | OS coverage limits | +| Non-publishing workflow rehearsal | R5, CP-004 | `verification.md`, review record | Tag-only behavior not executed until release approval | +| Changelog, release notes, install and process review | R4, R5, CP-005 | durable docs and review | Human wording error | + +## Downstream Task Guidance + +- Repair CI before treating any release validation as authoritative. +- Do not start artifact release validation until issue #68 has a disposition. +- Build once and reuse artifacts across clean-install checks. +- Stop for human release approval after rehearsal and documentation; this spec + does not authorize tagging or publishing. +- Reconcile requirements, design, tasks, verification, and traceability after + any support-matrix or workflow-scope change. + +## Operational Considerations + +The first real tag remains a controlled external change. A failed release must +leave the existing code and documentation recoverable by correcting the source, +incrementing version if necessary, and creating a new tag; published tags or +releases must not be silently overwritten. Exact policy is promoted to the +durable release procedure. + +## Open Questions + +None block implementation. The supported OS matrix is resolved from current +metadata and executable evidence during T006; any mismatch becomes an explicit +task result rather than an implicit assumption. + +## Related Artifacts + +- Requirements: `requirements.md` +- Change Impact: `change-impact.md` +- Tasks: `tasks.md` +- Verification: `verification.md` +- Traceability: `traceability.md` diff --git a/docs/specs/007-release-readiness-stabilization/requirements.md b/docs/specs/007-release-readiness-stabilization/requirements.md new file mode 100644 index 0000000..2205432 --- /dev/null +++ b/docs/specs/007-release-readiness-stabilization/requirements.md @@ -0,0 +1,214 @@ +--- +title: Release readiness stabilization requirements +doc_type: spec +artifact_type: requirements +status: active +owner: Auriora Team +last_reviewed: 2026-07-18 +--- + +# Requirements + +## Introduction + +TimeLocker is versioned as `0.9.0`, has no published release tags, and its +normal GitHub Actions test profile currently fails because MinIO integration +tests run without a reachable MinIO service. The next milestone is a bounded +`v0.9.1` stabilization release that restores trustworthy CI, validates built +artifacts in clean environments, rehearses the tag-triggered release path, and +publishes evidence-backed release notes. + +## Goals + +- Restore a green, deterministic normal CI profile without silently discarding + MinIO integration coverage. +- Stabilize the separate selection stress signal tracked by GitHub issue #68. +- Build and validate source and wheel artifacts for version `0.9.1`. +- Prove the supported installation and CLI smoke paths in clean environments. +- Rehearse the release workflow without creating a production tag. +- Produce accurate changelog, release-note, and operator documentation. + +## Non-Goals + +- Publishing to PyPI or configuring PyPI credentials or trusted publishing. +- Declaring TimeLocker `1.0.0` or promising a stable public Python API. +- Implementing unrelated feature, CLI, configuration, or performance backlog. +- Creating a release tag or GitHub release during implementation rehearsal. +- Weakening tests, coverage, or supported-platform claims to obtain a pass. + +## Glossary + +| Term | Definition | +|------|------------| +| Normal CI | The test profile run for pushes and pull requests: tests excluding the `performance` and `stress` markers. | +| MinIO profile | Integration tests that require an explicitly provisioned S3-compatible MinIO endpoint. | +| Release rehearsal | Non-publishing validation of the release workflow, commands, inputs, artifacts, and permissions. | +| Release evidence | CI runs, commands, artifact metadata, hashes, install results, and review records supporting a release decision. | + +## Durable Source Baseline + +| Source | Current behavior relied on | Confidence | Notes | +|--------|----------------------------|------------|-------| +| `CHARTER.md` | TimeLocker is a local-first CLI and is not currently distributed through PyPI. | high | Product and distribution boundary. | +| `README.md` | Version, installation, test, and project maturity front door. | high | Must remain aligned with verified behavior. | +| `pyproject.toml` | Package version, Python support, dependencies, console scripts, test markers, and coverage configuration. | high | Authoritative build metadata. | +| `.github/workflows/test-suite.yml` | Normal and manually dispatched extended test profiles. | high | Normal CI currently lacks MinIO provisioning or isolation. | +| `.github/workflows/release.yml` | Tag-triggered version check, tests, build, smoke install, artifact upload, and GitHub release. | high | Exists but has not been exercised by a repository release. | +| `docs/guides/user/installation.md` | Current installation and validation guidance. | high | Promotion target for verified clean-install behavior. | +| `docs/processes/README.md` | Durable process index. | high | Target for the release operator procedure. | +| `CHANGELOG.md` | Durable project change history. | high | Target for the `v0.9.1` entry. | +| `docs/history/spec-closure-log.md` | Records the waived selection stress threshold from Spec 001. | high | Follow-up is GitHub issue #68. | + +## Durable Impact + +See `change-impact.md`. This spec modifies test workflow behavior, package +version metadata, installation guidance, the release process, and release +communications. It does not change product architecture or the supported +credential model. + +## Staged Readiness + +- **Current stage:** implementation-ready +- **Next stage:** implementation +- **Ready to implement when:** package lint, traceability, task dependency, and + agent-readiness checks pass. +- **Design-first exception:** no +- **Optional artifacts included:** `change-impact.md`, `verification.md`, + `traceability.md` +- **Downstream review needed:** verification and release readiness + +## Requirements + +### Requirement 1: Deterministic CI profiles + +**User Story:** As a maintainer, I want normal CI to exercise only tests whose +dependencies it provisions, so that a green result is a trustworthy release +signal and integration coverage remains explicit. + +#### Acceptance Criteria + +1. GIVEN a push or pull request, WHEN normal CI runs, THEN it SHALL complete + without attempting to contact an unprovisioned MinIO endpoint. +2. WHERE MinIO integration tests are retained, THE SYSTEM SHALL provide an + explicit profile that provisions or validates MinIO before those tests run. +3. IF the MinIO service is unavailable in its explicit profile, THEN the job + SHALL fail with a clear dependency error rather than an ambiguous test + failure or silent skip. +4. THE SYSTEM SHALL retain the configured coverage threshold and SHALL NOT + exclude unrelated correctness tests to make CI pass. + +### Requirement 2: Stable performance and stress signal + +**User Story:** As a maintainer, I want the known host-sensitive selection +stress threshold resolved, so that the extended profile detects regressions +without producing routine false failures. + +#### Acceptance Criteria + +1. GIVEN representative supported hosts, WHEN the selection stress scenario is + measured, THEN issue #68 SHALL record timings and the chosen tolerance or + baseline strategy. +2. WHERE correctness and throughput assertions are combined, THE TEST SUITE + SHALL separate deterministic correctness from environment-sensitive timing. +3. WHILE stress tests remain opt-in, THE RELEASE EVIDENCE SHALL record their + result or an explicit, owner-approved residual risk. + +### Requirement 3: Reproducible release artifacts + +**User Story:** As a release operator, I want version-consistent source and +wheel artifacts, so that the GitHub release contains installable outputs built +from the tagged source. + +#### Acceptance Criteria + +1. GIVEN a clean checkout prepared for `v0.9.1`, WHEN the package is built, + THEN both sdist and wheel SHALL be produced successfully. +2. THE package version, importable `__version__`, intended tag version, and + installed CLI version SHALL all equal `0.9.1`. +3. THE artifacts SHALL contain the declared package data, both `timelocker` and + `tl` entry points, valid metadata, and recorded SHA-256 hashes. +4. IF artifact validation fails, THEN no release tag SHALL be created. + +### Requirement 4: Clean installation validation + +**User Story:** As a user, I want verified installation instructions and +artifacts, so that I can install TimeLocker on a supported environment without +undeclared dependencies. + +#### Acceptance Criteria + +1. GIVEN each supported Python version in project metadata, WHEN the wheel and + sdist are installed into fresh environments, THEN installation SHALL + complete without undeclared Python dependencies. +2. GIVEN each claimed CI operating system, WHEN the supported smoke path runs, + THEN `timelocker`, `tl`, version output, and root help SHALL work. +3. WHERE a platform requires Restic or another system prerequisite, THE + INSTALLATION GUIDE SHALL state the verified prerequisite and limitation. +4. IF a declared support claim cannot be validated, THEN the claim SHALL be + corrected or the release SHALL retain an explicit blocking risk. + +### Requirement 5: Safe release rehearsal and communications + +**User Story:** As a release operator, I want a rehearsed process and accurate +release notes, so that `v0.9.1` can be published deliberately and recovered +from failures. + +#### Acceptance Criteria + +1. GIVEN the tag-triggered workflow, WHEN it is rehearsed, THEN every step + before tag publication SHALL be validated without creating a production tag + or GitHub release. +2. THE durable release procedure SHALL identify prerequisites, authorized + operator, commands, checks, failure handling, and rollback boundaries. +3. THE `CHANGELOG.md` entry and release notes SHALL describe only changes and + limitations supported by repository evidence. +4. BEFORE release approval, THE VERIFICATION RECORD SHALL link required CI, + artifact, clean-install, stress, documentation, and review evidence. +5. PyPI publication and `1.0.0` SHALL remain explicitly deferred. + +## Correctness Properties + +- **CP-001:** Every test in normal CI either has all external dependencies + provisioned by the job or is assigned to an explicit dependency-owning + profile. +- **CP-002:** A version mismatch among tag intent, package metadata, + `TimeLocker.__version__`, or installed CLI output always blocks release. +- **CP-003:** Installing either release artifact in a clean supported + environment yields the same version and console entry-point behavior. +- **CP-004:** Rehearsal cannot create a production tag, GitHub release, or PyPI + publication as a side effect. +- **CP-005:** Each public release claim maps to a recorded validation result or + an explicit known limitation. + +## Technical Context + +- **Language/Version:** Python 3.12 and 3.13 as declared in `pyproject.toml`. +- **Primary Dependencies:** pytest, coverage, build, GitHub Actions, Restic, + MinIO for S3 integration tests. +- **Target Platform:** Linux normal CI plus every operating system explicitly + claimed by current project metadata or installation documentation. +- **Constraints:** No secrets in logs or artifacts; no production tag during + rehearsal; coverage threshold remains 50 percent; PyPI is deferred. +- **Performance Goals:** Stress thresholds must distinguish regression from + normal host variance; no new absolute target is invented by this spec. + +## Success Criteria + +- **SC-001:** Normal GitHub Actions CI passes from a clean checkout. +- **SC-002:** The explicit MinIO profile passes with a provisioned endpoint and + fails clearly when its dependency is unavailable. +- **SC-003:** Issue #68 has closure-quality threshold evidence or an explicit + release-blocking disposition. +- **SC-004:** Both `0.9.1` artifacts pass metadata, hash, and clean-install + validation. +- **SC-005:** Release rehearsal completes without external publication. +- **SC-006:** Durable operator guidance, installation guidance, changelog, and + release notes are ready for human release approval. + +## Related Artifacts + +- Change Impact: `change-impact.md` +- Design: `design.md` +- Tasks: `tasks.md` +- Verification: `verification.md` +- Traceability: `traceability.md` diff --git a/docs/specs/007-release-readiness-stabilization/tasks.md b/docs/specs/007-release-readiness-stabilization/tasks.md new file mode 100644 index 0000000..652145f --- /dev/null +++ b/docs/specs/007-release-readiness-stabilization/tasks.md @@ -0,0 +1,177 @@ +--- +title: Release readiness stabilization tasks +doc_type: spec +artifact_type: tasks +status: active +owner: Auriora Team +last_reviewed: 2026-07-18 +--- + +# Tasks + +**Input:** `docs/specs/007-release-readiness-stabilization/` + +## Task Dependency Graph + +```text +T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 -> T009 -> T010 +``` + +## Phase 1: Restore Trustworthy Validation + +- [ ] T001 Repair normal CI ownership of MinIO integration tests. + - Depends on: none + - Requirement: Requirement 1 + - Acceptance Criteria: Requirement 1 AC1, AC2, AC4 + - Properties: CP-001 + - Files: `.github/workflows/test-suite.yml`, pytest marker or fixture files, + `tests/TimeLocker/integration/` + - Acceptance: Normal CI does not contact an unprovisioned MinIO endpoint, + unrelated test coverage is retained, and collection-count evidence is + recorded. + - Validation: Focused MinIO collection, normal pytest profile, workflow run. + - Evidence: Pending. + - [ ] T001.1 Capture current normal and MinIO test collections and failing-run evidence. + - [ ] T001.2 Add an explicit MinIO dependency classification without hiding other integration tests. + - [ ] T001.3 Update normal CI selection and add regression coverage for profile ownership. + - [ ] T001.4 Run the normal profile locally and in GitHub Actions. + +- [ ] T002 Add and validate the provisioned MinIO profile. + - Depends on: T001 + - Requirement: Requirement 1 + - Acceptance Criteria: Requirement 1 AC2, AC3 + - Properties: CP-001 + - Files: `.github/workflows/test-suite.yml`, MinIO fixtures or preflight tests, + `docs/4-testing/` + - Acceptance: The explicit profile provisions or validates MinIO, passes its + tests, and reports an actionable dependency error when unavailable. + - Validation: Provisioned profile plus a negative preflight test. + - Evidence: Pending. + - [ ] T002.1 Define ephemeral endpoint and credential inputs. + - [ ] T002.2 Provision MinIO and wait for readiness before pytest. + - [ ] T002.3 Add clear dependency-preflight failure behavior. + - [ ] T002.4 Run and record the explicit profile. + +- [ ] T003 Checkpoint - CI profile validation. + - Depends on: T002 + - Requirement: Requirement 1 + - Acceptance: Normal and MinIO profiles pass, collected-test drift is + explained, coverage remains at least 50 percent, and no unrelated tests are + excluded. + - Validation: GitHub Actions evidence, pytest collection comparison, coverage report. + - Evidence: Pending. + +## Phase 2: Stabilize the Extended Signal + +- [ ] T004 Verify completion of the selection stress-threshold work in GitHub issue #68. + - Depends on: T003 + - Requirement: Requirement 2 + - Acceptance Criteria: Requirement 2 AC1, AC2, AC3 + - Files: GitHub issue #68 and affected stress tests; implementation remains + owned by the issue to avoid duplicate active work. + - Acceptance: Issue #68 contains representative timings, separated + correctness and timing semantics, the chosen regression strategy, and a + repeatable validation result or an explicit release-blocking disposition. + - Evidence mode: validation + - Destination: + - Evidence: Pending. + +- [ ] T005 Checkpoint - Release validation prerequisites. + - Depends on: T004 + - Requirements: Requirements 1 and 2 + - Acceptance: Normal CI is green, explicit external-service coverage is + green, and stress evidence is acceptable for release preparation. + - Validation: Review T003 evidence and issue #68 acceptance criteria. + - Evidence: Pending. + +## Phase 3: Build and Install v0.9.1 + +- [ ] T006 Set version `0.9.1` and build reproducible release artifacts. + - Depends on: T005 + - Requirement: Requirement 3 + - Acceptance Criteria: Requirement 3 AC1, AC2, AC3, AC4 + - Properties: CP-002 + - Files: `pyproject.toml`, `src/TimeLocker/__init__.py`, build and release tooling + - Acceptance: Version sources agree; sdist, wheel, metadata, entry points, + package data, and SHA-256 hashes validate from a clean checkout. + - Validation: Version guard, `python -m build`, artifact inspection. + - Evidence: Pending. + - [ ] T006.1 Update and test all authoritative version sources. + - [ ] T006.2 Build sdist and wheel once from a clean source state. + - [ ] T006.3 Inspect metadata, contents, entry points, and hashes. + - [ ] T006.4 Prove a version mismatch blocks the release guard. + +- [ ] T007 Validate wheel and sdist in clean supported environments. + - Depends on: T006 + - Requirement: Requirement 4 + - Acceptance Criteria: Requirement 4 AC1, AC2, AC3, AC4 + - Properties: CP-003 + - Files: `.github/workflows/`, smoke tooling, `docs/guides/user/installation.md` + - Acceptance: Both artifact types pass the shared CLI smoke contract on the + supported Python and OS matrix, or unsupported claims are corrected and + reviewed before proceeding. + - Validation: Fresh-environment installs for wheel and sdist; both console entry points. + - Evidence: Pending. + - [ ] T007.1 Reconcile Python and OS claims from metadata, workflows, and docs. + - [ ] T007.2 Install wheel and run version, root help, and safe quick-start smoke checks. + - [ ] T007.3 Install sdist and run the same smoke contract. + - [ ] T007.4 Record or correct platform prerequisites and limitations. + +- [ ] T008 Checkpoint - Artifact and installation readiness. + - Depends on: T007 + - Requirements: Requirements 3 and 4 + - Acceptance: Artifact identity, hashes, installation results, platform + coverage, and residual risk are recorded before release rehearsal. + - Validation: Review artifact and clean-install evidence against CP-002 and CP-003. + - Evidence: Pending. + +## Phase 4: Rehearse, Promote, and Review + +- [ ] T009 Rehearse the release workflow and promote durable release guidance. + - Depends on: T008 + - Requirement: Requirement 5 + - Acceptance Criteria: Requirement 5 AC1, AC2, AC3, AC4, AC5 + - Properties: CP-004, CP-005 + - Files: `.github/workflows/release.yml`, `CHANGELOG.md`, release notes, + `docs/processes/`, `docs/guides/user/installation.md`, `README.md` + - Acceptance: Every pre-publication release step is validated without a + production tag; durable operator and user guidance and evidence-backed + `v0.9.1` communications are complete; PyPI and `1.0.0` remain deferred. + - Validation: Workflow validation, non-publishing rehearsal, links and docs review. + - Evidence: Pending. + - [ ] T009.1 Establish one safe pre-tag validation command or workflow path. + - [ ] T009.2 Rehearse build, smoke, artifact, permissions, and failure paths without publishing. + - [ ] T009.3 Write the durable release operator procedure and rollback boundary. + - [ ] T009.4 Update installation guidance, changelog, and `v0.9.1` release notes. + - [ ] T009.5 Perform release-readiness documentation and security review. + +- [ ] T010 Checkpoint - Human release decision and spec closure readiness. + - Depends on: T009 + - Requirements: Requirements 1 through 5 + - Acceptance: All required evidence is linked; durable content is promoted; + residual risks and owners are explicit; no tag or release has been created; + and the package is ready for human release approval and lifecycle closure. + - Validation: Lifecycle lint, readiness and traceability checks, full required + test profiles, internal-link check, `git diff --check`, expert review. + - Evidence: Pending. + +## Execution Rules + +- Read the linked row in `traceability.md` and the relevant requirements, + design, change-impact, and verification sections before starting a task. +- Mark a selected task `[~]` before implementation and record evidence before + marking it `[x]`. +- Do not create a production tag, GitHub release, or PyPI publication under + this package without a separate explicit release approval. +- GitHub issue #68 owns stress-threshold implementation; T004 consumes and + verifies its evidence rather than restating its engineering work. +- A failed prerequisite blocks downstream release tasks; it is not waived by + reducing test or support scope without an approved spec reconciliation. + +## Related Artifacts + +- Requirements: `requirements.md` +- Change Impact: `change-impact.md` +- Design: `design.md` +- Verification: `verification.md` +- Traceability: `traceability.md` diff --git a/docs/specs/007-release-readiness-stabilization/traceability.md b/docs/specs/007-release-readiness-stabilization/traceability.md new file mode 100644 index 0000000..5f3573e --- /dev/null +++ b/docs/specs/007-release-readiness-stabilization/traceability.md @@ -0,0 +1,68 @@ +--- +title: Release readiness stabilization traceability +doc_type: spec +artifact_type: traceability +status: active +owner: Auriora Team +last_reviewed: 2026-07-18 +--- + +# Traceability Matrix + +## Task To Context Matrix + +| Task ID | Requirements | Acceptance Criteria | Design Sections | Change Impact | Verification | Durable Targets | Open Decisions | +|---------|--------------|---------------------|-----------------|---------------|--------------|-----------------|----------------| +| T001 | Requirement 1 | AC1, AC2, AC4 | CI Profile Logic | Test profile change; bug fix details | normal profile and collection | workflow, testing guide | none | +| T002 | Requirement 1 | AC2, AC3 | CI Profile Logic; Error Handling; Security | Test profile change | MinIO profile and preflight | workflow, testing guide | none | +| T003 | Requirement 1 | AC1, AC2, AC3, AC4 | Validation Strategy | Test profile change | CI quality gate | testing guide | none | +| T004 | Requirement 2 | AC1, AC2, AC3 | Components; Validation Strategy | Stress signal bug fix | issue #68 and extended profile | tests, testing guide | none | +| T005 | Requirements 1 and 2 | all | Downstream Task Guidance | CI and stress readiness | prerequisite checkpoint | none | none | +| T006 | Requirement 3 | AC1, AC2, AC3, AC4 | Version and Artifact Guard | Version and artifact changes | build, metadata, version guard | metadata, changelog | none | +| T007 | Requirement 4 | AC1, AC2, AC3, AC4 | Clean-Install Matrix | Install validation | clean install matrix | installation guide | none | +| T008 | Requirements 3 and 4 | all | Validation Strategy | Artifact and install readiness | artifact checkpoint | installation guide | none | +| T009 | Requirement 5 | AC1, AC2, AC3, AC4, AC5 | Release Rehearsal; Operational Considerations | Process and communications | rehearsal and docs review | process, changelog, release notes, install guide | none | +| T010 | Requirements 1 through 5 | all | Validation Strategy; Downstream Task Guidance | all promotion targets | lifecycle and expert review | all listed targets | none | + +## Requirement To Delivery Matrix + +| Requirement | Acceptance Criteria | Design Sections | Tasks | Verification | Durable Targets | +|-------------|---------------------|-----------------|-------|--------------|-----------------| +| Requirement 1 | AC1, AC2, AC3, AC4 | CI Profile Logic; Error Handling | T001-T003 | normal and MinIO profiles, coverage, collection | workflow, `docs/4-testing/README.md` | +| Requirement 2 | AC1, AC2, AC3 | Components; Validation Strategy | T004-T005 | issue #68, extended profile | tests and testing guide | +| Requirement 3 | AC1, AC2, AC3, AC4 | Version and Artifact Guard | T006, T008 | build, metadata, hashes, version guard | metadata, changelog | +| Requirement 4 | AC1, AC2, AC3, AC4 | Clean-Install Matrix | T007, T008 | artifact install matrix | installation guide | +| Requirement 5 | AC1, AC2, AC3, AC4, AC5 | Release Rehearsal; Operational Considerations | T009-T010 | rehearsal, docs, expert review | process, changelog, release notes, README if needed | + +## Correctness Property Coverage + +| Property | Requirements | Design Sections | Tasks | Tests Or Verification | Residual Risk | +|----------|--------------|-----------------|-------|-----------------------|---------------| +| CP-001 | Requirement 1 | CI Profile Logic | T001-T003 | collection comparison and both CI profiles | marker drift | +| CP-002 | Requirement 3 | Version and Artifact Guard | T006, T008 | positive and negative version guard | none expected | +| CP-003 | Requirement 4 | Clean-Install Matrix | T007, T008 | wheel and sdist smoke matrix | OS scope | +| CP-004 | Requirement 5 | Release Rehearsal | T009-T010 | side-effect review and non-publishing rehearsal | tag-only external behavior | +| CP-005 | Requirement 5 | Validation Strategy | T009-T010 | release-note evidence review | human review quality | + +## Design To Implementation Matrix + +| Design Section | Requirements | Tasks | Interfaces Or Files | Verification | +|----------------|--------------|-------|---------------------|--------------| +| CI Profile Logic | Requirement 1 | T001-T003 | workflow, markers, fixtures, integration tests | collection, normal CI, MinIO CI | +| Version and Artifact Guard | Requirement 3 | T006, T008 | metadata, package version, build output | guard, build, metadata, hashes | +| Clean-Install Matrix | Requirement 4 | T007-T008 | workflows, smoke tooling, installation guide | isolated artifact installs | +| Release Rehearsal | Requirement 5 | T009-T010 | release workflow, process docs, release docs | dry rehearsal and review | +| Security, Trust, and Access | Requirements 1 and 5 | T002, T009-T010 | workflow permissions and ephemeral MinIO values | secrets and permissions review | + +## Open Decision Impact + +There are no unresolved decisions blocking implementation. Any newly discovered +support or publication decision must be recorded and reconciled across this +package before downstream tasks continue. + +## Maintenance Notes + +- Update this matrix whenever acceptance criteria, task IDs, support claims, + validation profiles, or durable destinations change. +- Treat missing issue #68 evidence as a release-readiness gap, not as implicit + completion. diff --git a/docs/specs/007-release-readiness-stabilization/verification.md b/docs/specs/007-release-readiness-stabilization/verification.md new file mode 100644 index 0000000..ac95b34 --- /dev/null +++ b/docs/specs/007-release-readiness-stabilization/verification.md @@ -0,0 +1,171 @@ +--- +title: Release readiness stabilization verification +doc_type: spec +artifact_type: verification +status: active +owner: Auriora Team +last_reviewed: 2026-07-18 +--- + +# Verification + +## Scope + +This record covers Spec 007 requirements R1-R5 and tasks T001-T010. It records +release-preparation evidence only; creating a production tag or release requires +separate explicit approval. + +## Quality Gates + +| Gate | Required? | Status | Evidence | +|------|-----------|--------|----------| +| Requirements acceptance criteria reviewed | yes | passed | Lifecycle stage readiness reports all 20 acceptance criteria explicitly covered. | +| Task evidence complete | yes | pending | T001-T010 pending. | +| Normal and dependency-owning test profiles pass | yes | pending | Current normal run 29653160911 fails on unavailable MinIO. | +| Stress signal disposition recorded | yes | pending | GitHub issue #68. | +| Artifacts and clean installs validate | yes | pending | T006-T008. | +| Release workflow rehearsed without publication | yes | pending | T009. | +| Durable documentation promoted | yes | pending | Promotion table below. | +| Lifecycle checks and expert review pass | yes | pending | T010. | + +## Validation Commands + +| Command | Purpose | Result | Evidence | +|---------|---------|--------|----------| +| `python -m pytest -m "not performance and not stress"` | Normal correctness and coverage profile | blocked | GitHub run 29653160911: MinIO unavailable; 1 failed, 1310 passed, 53 deselected, 4 errors before stop. | +| `python -m pytest --collect-only -q ...` | Compare normal and MinIO-owned collections | pending | T001. | +| explicit provisioned MinIO pytest command | Validate S3 integration and dependency preflight | pending | T002. | +| `python -m pytest -m "performance or stress" --no-cov` | Extended performance and stress profile | pending | T004 and issue #68. | +| `python -m build` | Build sdist and wheel | pending | T006. | +| version and metadata guard | Prove CP-002 | pending | T006. | +| wheel and sdist clean-install matrix | Prove CP-003 and platform claims | pending | T007. | +| non-publishing release rehearsal | Prove CP-004 | pending | T009. | +| repository link check and `git diff --check` | Validate documentation and patch hygiene | pending | T010. | + +## Requirement Coverage + +| Requirement | Acceptance criteria covered | Evidence | Residual risk | +|-------------|-----------------------------|----------|---------------| +| R1 | AC1-AC4 | T001-T003 pending; failing run captured | Profile changes may hide tests unless collection is compared. | +| R2 | AC1-AC3 | Issue #68 and T004-T005 pending | Host variance. | +| R3 | AC1-AC4 | T006-T008 pending | Tag-only workflow behavior remains unreleased. | +| R4 | AC1-AC4 | T007-T008 pending | OS runner availability. | +| R5 | AC1-AC5 | T009-T010 pending | Human operator error at first actual tag. | + +## Correctness Property Coverage + +| Property | Covered by | Evidence | Residual risk | +|----------|------------|----------|---------------| +| CP-001 | T001-T003, collection and workflow runs | pending | Marker drift. | +| CP-002 | T006 version guard and negative test | pending | None expected after automated guard. | +| CP-003 | T007 clean artifact matrix | pending | Platform scope must be explicit. | +| CP-004 | T009 side-effect review and rehearsal | pending | External publication remains human-controlled. | +| CP-005 | T009 documentation review | pending | Review quality. | + +## Agent Readiness Evidence + +| Field | Evidence | Residual risk | +|-------|----------|---------------| +| Scope and out-of-scope files | Requirements goals, non-goals, change impact, and task file lists | Newly discovered release blockers require reconciliation. | +| Must-read and optional context | Full Spec 007 package, `CHARTER.md`, workflows, metadata, install and process docs, issue #68 | GitHub evidence can change. | +| Permissions and approval points | Branch work approved; tag, GitHub release, and PyPI publication excluded pending separate approval | Do not infer release authority. | +| Validation commands and expected signals | Validation table plus task-specific commands | Exact MinIO command is resolved in T002. | +| Review needs | CI, packaging, security, operations, and documentation review at T010 | Human release decision remains. | +| Durable-doc or closure impact | Promotion table and `change-impact.md` | Package cannot close before promotion. | +| Optional repo-evidence provider caveats | Agent Workbench returned stale deleted-plan paths during intake; direct repository evidence is authoritative | Recheck provider before relying on suggestions. | + +## Task Evidence + +| Task ID | Status | Evidence | Notes | +|---------|--------|----------|-------| +| T001 | pending | Failing CI root cause captured | Implementation not started. | +| T002 | pending | | | +| T003 | pending | | | +| T004 | pending | GitHub issue #68 created and assigned | Issue implementation remains pending. | +| T005 | pending | | | +| T006 | pending | | | +| T007 | pending | | | +| T008 | pending | | | +| T009 | pending | | | +| T010 | pending | | | + +## Evidence Log + +| Date | Evidence | Result | Notes | +|------|----------|--------|-------| +| 2026-07-18 | GitHub Actions run 29653160911 | failed | Unprovisioned MinIO caused one failure and four setup errors; normal CI is not release-ready. | +| 2026-07-18 | Open-issue reconciliation | passed | All 27 inherited open issues reviewed; 9 closed, 18 retained with current scope. | +| 2026-07-18 | GitHub milestone `v0.9.1` | created | PyPI and `1.0.0` explicitly deferred. | +| 2026-07-18 | GitHub issue #68 | created and assigned | Owns selection stress-threshold stabilization. | +| 2026-07-18 | Spec Lifecycle Manager package lint | passed | Zero errors, warnings, or informational diagnostics. | +| 2026-07-18 | Spec Lifecycle Manager stage readiness | passed | Ready for agent and implementation; zero blocking, context, property, or acceptance gaps. | +| 2026-07-18 | Agent readiness packet for T001 | passed | Requirement, design, verification, durable targets, and traceability resolve without gaps. | +| 2026-07-18 | Documentation link check and `git diff --check` | passed | No broken links in the changed spec set and no whitespace errors; repository-wide checker reported only pre-existing canonical-style suggestions. | + +## Manual Or External Verification + +GitHub issue and milestone state is externally authoritative. GitHub Actions +runs and eventual release artifacts must be linked here before release +readiness can be approved. + +## Residual Risks + +- Normal CI is currently red and blocks every downstream release claim. +- MinIO profile design can accidentally reduce coverage if test collection is + not compared explicitly. +- Stress thresholds can remain host-sensitive without the evidence in #68. +- The first actual tag exercises external publication behavior that rehearsal + cannot reproduce fully; it remains a human-controlled release risk. + +## Durable Promotion And Cleanup + +| Spec content | Durable destination or deferral | Status | Evidence | +|--------------|---------------------------------|--------|----------| +| Test profile contract | `docs/4-testing/README.md` | pending | T002 and T003. | +| Verified installation matrix | `docs/guides/user/installation.md` | pending | T007. | +| Release procedure and rollback | new document under `docs/processes/` | pending | T009. | +| Version and release contents | `CHANGELOG.md`, release notes, `README.md` if needed | pending | T009. | +| PyPI and `1.0.0` deferral | GitHub issue #22, milestone description, release process | partial | GitHub scope updated; durable process pending. | +| Follow-up work | GitHub issues outside milestone or an approved successor spec | pending | T010. | + +### Spec Cleanup Decision + +- **Cleanup action:** keep active +- **Reason:** Implementation and release preparation have not started. +- **Final spec commit:** pending +- **Closure log path:** `docs/history/spec-closure-log.md` +- **Closure log entry updated:** no +- **Closure cleanup commit:** pending +- **Active indexes updated:** yes for package creation +- **Durable docs linked back to evidence where useful:** no +- **Residual spec-only content:** all intended content remains active + +## Ship Or Closure Risk + +- **Risk level:** high +- **Breaking change:** no +- **Blast radius checked:** partial +- **Rollback path:** to be documented in T009 +- **Requires human review:** yes +- **Release notes needed:** yes +- **Follow-up issue or spec needed:** issue #68 already created + +### Risk Rationale + +Normal CI currently fails, the tag-triggered release workflow has no repository +release history, and artifact or clean-install evidence for `0.9.1` does not +exist. No release should proceed until the required gates are complete. + +## Readiness Decision + +- **Ready for promotion:** no +- **Ready for release:** no +- **Ready for closure:** no + +## Related Artifacts + +- Requirements: `requirements.md` +- Change Impact: `change-impact.md` +- Design: `design.md` +- Tasks: `tasks.md` +- Traceability: `traceability.md` diff --git a/docs/specs/README.md b/docs/specs/README.md index 22a4e53..0780efb 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -15,12 +15,18 @@ accepted content has been promoted and the package is closed. ## Current Packages -None. +- [`007-release-readiness-stabilization`](./007-release-readiness-stabilization/requirements.md) + — active package for restoring trustworthy CI, stabilizing release signals, + validating `v0.9.1` artifacts, rehearsing release operations, and promoting + durable release guidance. ## Active-Package Sequencing -There are no active packages. Closed package identity and recovery commits are -recorded in `docs/history/` rather than kept in this active documentation path. +Spec 007 is the only active package. GitHub milestone `v0.9.1` and issue #68 +provide external scheduling and stress-test evidence; the spec remains +authoritative for implementation sequencing, acceptance, validation, and +promotion. Closed package identity and recovery commits remain recorded in +`docs/history/` rather than kept in this active documentation path. ## When a Spec Is Needed From 0c9b29118f113c98b0b8bd1653b6dc692674c880 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:29:33 +0100 Subject: [PATCH 02/72] docs(spec): reconcile release readiness findings Make version preparation side-effect safe, move stress work under spec authority, define the support and MinIO test contracts, and split release preparation into evidence-bearing gates. --- .../change-impact.md | 29 ++- .../design.md | 105 +++++--- .../requirements.md | 59 +++-- .../tasks.md | 244 ++++++++++++------ .../traceability.md | 57 ++-- .../verification.md | 153 ++++++----- 6 files changed, 404 insertions(+), 243 deletions(-) diff --git a/docs/specs/007-release-readiness-stabilization/change-impact.md b/docs/specs/007-release-readiness-stabilization/change-impact.md index 9f705f1..c2d95cc 100644 --- a/docs/specs/007-release-readiness-stabilization/change-impact.md +++ b/docs/specs/007-release-readiness-stabilization/change-impact.md @@ -21,7 +21,10 @@ bounded `v0.9.1` stabilization release. | `.github/workflows/test-suite.yml` | Normal CI runs all non-performance, non-stress tests but provisions no MinIO. | high | Current failure source. | | `.github/workflows/release.yml` | A version tag triggers tests, build, wheel smoke, artifact upload, and GitHub release creation. | high | Must be rehearsed without publication. | | `pyproject.toml` | Version `0.9.0`, Python range, package metadata, scripts, markers, and coverage threshold. | high | Will move to `0.9.1`. | +| `scripts/bump_version.py` and `.bumpversion.cfg` | Version bumping commits and tags by default unless both are disabled. | high | Preparation must use `--no-commit --no-tag`. | | `docs/guides/user/installation.md` | Current source install and test guidance. | high | Must reflect only verified artifact and platform behavior. | +| `docs/processes/version-management.md` | Existing version and release procedure. | high | Correct in place rather than creating a duplicate process. | +| `docs/processes/README.md` | Existing process index. | high | Must link the corrected procedure. | | `CHARTER.md` | PyPI distribution is outside current project state. | high | Remains unchanged. | ## Change Type @@ -35,13 +38,13 @@ bounded `v0.9.1` stabilization release. | Change | Type | Source of truth | New durable destination | Promotion required | |--------|------|-----------------|-------------------------|-------------------| -| Separate normal and MinIO-dependent test ownership | modify | `.github/workflows/test-suite.yml` | `docs/4-testing/README.md` and workflow | yes | -| Stabilize the selection stress signal | bug_fix | GitHub issue #68 | test code and durable testing guidance | yes | -| Bump package version to `0.9.1` | modify | `pyproject.toml` and package version module | same files, README or changelog where appropriate | yes | -| Validate sdist and wheel installs | add | release evidence | `docs/guides/user/installation.md` | yes | -| Define the release operator procedure | add | Spec 007 design and rehearsal evidence | `docs/processes/` | yes | -| Publish accurate `v0.9.1` communications | add | Git history and verification evidence | `CHANGELOG.md` and durable release notes | yes | -| Defer PyPI and `1.0.0` | clarify | `CHARTER.md`, milestone decision | release process and release notes | yes | +| Separate normal and live MinIO test ownership with collection safety | modify | `pyproject.toml`, tests, `.github/workflows/test-suite.yml` | `docs/4-testing/README.md` and workflow | yes | +| Stabilize the selection stress signal under spec authority | bug_fix | Spec 007; issue #68 tracks assignment and evidence | test code and durable testing guidance | yes | +| Prepare version `0.9.1` without commit, tag, or release side effects | modify | `scripts/bump_version.py`, `.bumpversion.cfg`, package version sources | same files and corrected version process | yes | +| Bound Python support and validate six OS/Python combinations | modify | `pyproject.toml` and release evidence | `docs/guides/user/installation.md` | yes | +| Correct the release operator procedure | modify | Spec 007 design and rehearsal evidence | `docs/processes/version-management.md` and process index | yes | +| Publish accurate `v0.9.1` communications | add | Git history and verification evidence | `CHANGELOG.md`; GitHub release body derived from its version section | yes | +| Defer PyPI and `1.0.0` | clarify | `CHARTER.md`, milestone decision | version process and changelog | yes | ## Promotion Targets @@ -49,8 +52,8 @@ bounded `v0.9.1` stabilization release. |--------------|---------------------|------------------|-------| | Test profile contract and commands | `docs/4-testing/README.md` | pending | Include MinIO prerequisites and extended profile. | | Verified install matrix and prerequisites | `docs/guides/user/installation.md` | pending | Do not claim untested platforms. | -| Release procedure and rollback boundary | new current-state document under `docs/processes/` | pending | Link from process index. | -| Release contents and limitations | `CHANGELOG.md` and durable `v0.9.1` release notes | pending | Evidence-backed claims only. | +| Release procedure and rollback boundary | `docs/processes/version-management.md` | pending | Correct in place and link from `docs/processes/README.md`. | +| Release contents and limitations | `CHANGELOG.md` | pending | Canonical checked-in source; derive the GitHub release body from the `v0.9.1` section. | | Current version and release path | `README.md` where needed | pending | Keep front door concise. | ## Unchanged Durable Areas @@ -71,15 +74,17 @@ bounded `v0.9.1` stabilization release. needed by its selected tests and fails dependency preflight clearly. - **Root cause evidence:** `.github/workflows/test-suite.yml` runs `pytest -m "not performance and not stress"` and contains no MinIO service or - exclusion for tests under the MinIO integration class. + dedicated marker; the live suite also performs configuration work during + import/collection while mocked MinIO contracts are not a live-service class. - **Regression risk:** An over-broad marker expression could hide integration coverage; collection comparison and the explicit profile mitigate it. - **Durable doc update needed:** Yes, testing profile and prerequisite guidance. ## Open Questions -None block implementation. Platform claims are reconciled from current -metadata and executable evidence in T007. +None block implementation. The declared validation contract is Python 3.12 and +3.13 on Linux, macOS, and Windows. T007 must validate all six combinations or +correct the affected claim before downstream work continues. ## Related Artifacts diff --git a/docs/specs/007-release-readiness-stabilization/design.md b/docs/specs/007-release-readiness-stabilization/design.md index fe5ce8e..5f41df0 100644 --- a/docs/specs/007-release-readiness-stabilization/design.md +++ b/docs/specs/007-release-readiness-stabilization/design.md @@ -13,7 +13,8 @@ last_reviewed: 2026-07-18 The release is prepared as a sequence of independently verifiable gates. CI profiles first become dependency-correct; the known stress signal is resolved -through issue #68; versioned artifacts are then built once and installed into +under this spec with issue #68 retaining assignment and evidence history; +versioned artifacts are then built once and installed into clean environments; finally, the existing release workflow is rehearsed and the evidence is promoted into durable guidance and release communications. @@ -21,17 +22,17 @@ the evidence is promoted into durable guidance and release communications. | Requirement | Acceptance Criteria | Design Coverage | Validation Approach | |-------------|---------------------|-----------------|---------------------| -| R1 | AC1-AC4 | Marker/profile ownership plus explicit MinIO dependency gate | Workflow review, normal CI, MinIO profile | -| R2 | AC1-AC3 | Issue #68 remains the single execution record; Spec 007 consumes its evidence | Issue acceptance review, extended profile | -| R3 | AC1-AC4 | One version guard and one artifact set reused by smoke validation | Build, metadata inspection, hashes, CLI version | -| R4 | AC1-AC4 | Clean environment matrix derived from current support claims | Wheel and sdist installs, CLI smoke tests | -| R5 | AC1-AC5 | Non-publishing rehearsal followed by durable process and release-note promotion | Workflow lint/review, dry run, docs review | +| R1 | AC1-AC6 | Dedicated live-service marker, collection-safe fixtures, and explicit MinIO dependency gate | Workflow review, collection partition, normal CI, MinIO profile | +| R2 | AC1-AC4 | Spec-owned stress implementation and validation; issue #68 tracks assignment and chronological evidence | Stress tests, issue evidence, extended profile | +| R3 | AC1-AC5 | Side-effect-safe version preparation, one version guard, and one artifact set reused by smoke validation | Git-state comparison, build, metadata inspection, hashes, CLI version | +| R4 | AC1-AC5 | Explicit six-combination support contract | Wheel and sdist installs, CLI smoke matrix | +| R5 | AC1-AC6 | Non-publishing rehearsal followed by in-place process updates and changelog-derived communications | Workflow lint/review, rehearsal, docs review | ## Correctness Property Coverage | Property | Design Behavior | Validation Direction | Notes | |----------|-----------------|----------------------|-------| -| CP-001 | Tests requiring MinIO are collected into a dependency-owning profile; normal CI excludes only that explicit integration class | Collection checks plus both workflow profiles | Marker selection must not hide unrelated integration tests. | +| CP-001 | Only live-service tests carry `minio`; collection is side-effect-free; normal CI excludes that marker while retaining mocked S3/MinIO contract tests | Collection partition checks plus both workflow profiles | Marker selection must not hide unrelated integration tests. | | CP-002 | A shared version verification command compares intended tag, `pyproject.toml`, import version, and installed CLI | Negative and positive version checks | Production tag is never needed for rehearsal. | | CP-003 | The same smoke contract is run against wheel and sdist installs | Clean virtual environments and supported platform jobs | System prerequisites remain explicit. | | CP-004 | Rehearsal stops before tag creation and uses workflow validation or a non-publishing harness | Command review and absence of new tag/release | Any external write needs separate release approval. | @@ -44,11 +45,11 @@ the evidence is promoted into durable guidance and release communications. ```text CI profile repair -> MinIO profile proof - -> issue #68 stress evidence + -> Spec 007 stress implementation and issue #68 evidence -> version bump and artifact build -> clean-install matrix -> release workflow rehearsal - -> durable docs and release notes + -> durable docs and changelog-derived release communications -> human release decision ``` @@ -56,17 +57,22 @@ CI profile repair - `.github/workflows/test-suite.yml`: make normal and external-service test ownership explicit; add or invoke a MinIO-capable profile. -- Test markers and MinIO fixtures: expose dependency requirements before a - network call and provide actionable failure behavior. -- GitHub issue #68: own calibration of the selection stress threshold; this - spec links its result instead of duplicating implementation tasks. -- `pyproject.toml` and `src/TimeLocker/__init__.py`: move together to `0.9.1`. +- Test markers and MinIO fixtures: register `minio`, mark only live-service + tests, keep collection free of configuration failures and network calls, and + provide actionable runtime dependency behavior. +- Selection stress tests and tooling: implement the calibrated baseline and + deterministic/timing separation under Spec 007; GitHub issue #68 tracks + assignment, state, representative timings, and chronological evidence. +- `pyproject.toml` and `src/TimeLocker/__init__.py`: move together to `0.9.1` + using the version helper with commit and tag side effects disabled; bound + Python support to `>=3.12,<3.14` and align classifiers. - Build and smoke tooling: build once, inspect both artifacts, and install each in isolated environments. - `.github/workflows/release.yml`: preserve tag-triggered publication while extracting or documenting a safe pre-tag rehearsal path where practical. -- `CHANGELOG.md`, release notes, installation guide, and release process: - receive accepted current-state guidance before spec closure. +- `CHANGELOG.md`, installation guide, and the existing version-management + process receive accepted current-state guidance before spec closure. The + GitHub release body is derived from the `v0.9.1` changelog section. ### Data Models @@ -79,8 +85,9 @@ content remains untracked unless repository policy explicitly says otherwise. Source metadata determines the build version. A clean checkout produces sdist and wheel artifacts plus hashes. Each artifact is installed into a fresh -environment and queried through both console entry points. CI and external -issue results feed the verification record. Accepted operator and user guidance +environment and queried through both console entry points. CI, stress-test +results, and linked issue evidence feed the verification record. Accepted +operator and user guidance is promoted to durable docs, while the spec remains the temporary coordination surface until closure. @@ -88,20 +95,33 @@ surface until closure. ### CI Profile Logic -1. Identify MinIO-dependent tests by marker, path, or dedicated pytest - collection contract. -2. Make normal CI exclude that exact external-service class while retaining all - other non-performance, non-stress tests. -3. Add a job or documented command that provisions MinIO, waits for readiness, - exports the endpoint and credentials, and runs only the MinIO class. -4. Ensure missing dependency state produces a clear preflight failure. -5. Compare collected test counts before and after the profile change to catch - accidental test loss. +1. Register a dedicated `minio` pytest marker in `pyproject.toml`. +2. Apply it only to tests that contact a live MinIO service. Keep mocked + credential, backend, and protocol-contract tests unmarked in normal CI. +3. Move configuration validation and client/network access out of module import + and collection into fixtures or an explicit runtime preflight. +4. Run normal CI with + `pytest -m "not performance and not stress and not minio"`. +5. Add a job that provisions MinIO, waits for readiness, exports ephemeral + endpoint and credential inputs, and runs `pytest -m minio`. +6. Ensure missing dependency state produces a clear preflight failure. +7. Compare the complete collection with the normal, MinIO, performance, and + stress selections so every intended node is accounted for and no mocked + contract test moves out of normal CI. ### Version and Artifact Guard ```text expected = "0.9.1" +before_commit = git_head +before_tags = git_tags +before_release_runs = tag_triggered_release_workflow_runs +before_releases = github_releases +run "python scripts/bump_version.py bump patch --no-commit --no-tag" +assert git_head == before_commit +assert git_tags == before_tags +assert tag_triggered_release_workflow_runs == before_release_runs +assert github_releases == before_releases assert pyproject_version == expected assert imported_version == expected build sdist and wheel once @@ -114,18 +134,24 @@ record metadata and SHA-256 ### Clean-Install Matrix -The matrix is derived from `requires-python`, classifiers, workflow coverage, -and installation claims. At minimum it covers Python 3.12 and 3.13. Operating -systems are either validated or their claims are narrowed; the spec does not -manufacture support from unexecuted workflow branches. +The release contract is exactly Python 3.12 and 3.13 on Linux, macOS, and +Windows. `requires-python` becomes `>=3.12,<3.14`; Python classifiers list 3.12 +and 3.13; OS classifiers name the three supported systems and remove +`Operating System :: OS Independent`. +Artifact smoke validation covers all six OS/Python combinations. The normal +correctness suite runs on Ubuntu for both Python versions; artifact smoke +coverage on every declared OS is mandatory. If a runner cannot validate a +combination, the support claim must be corrected before release or readiness +remains blocked. ### Release Rehearsal The rehearsal validates checkout depth, Restic acquisition and checksum, version guard, normal tests, artifact build, smoke install, artifact upload configuration, release-note inputs, permissions, and rollback instructions. -The publishing boundary is a hard stop before `git tag`, tag push, -`gh release create`, or any package-index upload. +It records pre/post commit, tag, and GitHub-release identity. The publishing +boundary is a hard stop before any commit, `git tag`, tag push, +`gh release create`, or package-index upload. ### Error Handling @@ -156,15 +182,16 @@ or escalated for a new requirement and explicit versioning decision. |------------|--------|-------------------|---------------| | Normal CI and collection comparison | R1, CP-001 | `verification.md`, Actions run | Hosted-runner variance | | Provisioned MinIO profile and dependency preflight | R1, CP-001 | `verification.md`, Actions run | Service image drift | -| Issue #68 evidence and extended profile | R2 | GitHub issue #68, `verification.md` | Hardware variance | +| Spec-owned stress implementation, issue #68 evidence, and extended profile | R2 | tests, GitHub issue #68, `verification.md` | Hardware variance | | Build, metadata, hashes, wheel and sdist installs | R3, R4, CP-002, CP-003 | `verification.md`, artifacts | OS coverage limits | | Non-publishing workflow rehearsal | R5, CP-004 | `verification.md`, review record | Tag-only behavior not executed until release approval | -| Changelog, release notes, install and process review | R4, R5, CP-005 | durable docs and review | Human wording error | +| Changelog-derived communications, install, and process review | R4, R5, CP-005 | durable docs and review | Human wording error | ## Downstream Task Guidance - Repair CI before treating any release validation as authoritative. -- Do not start artifact release validation until issue #68 has a disposition. +- Do not start artifact release validation until Spec 007 stress acceptance is + met and issue #68 contains the linked evidence or blocking disposition. - Build once and reuse artifacts across clean-install checks. - Stop for human release approval after rehearsal and documentation; this spec does not authorize tagging or publishing. @@ -181,9 +208,9 @@ durable release procedure. ## Open Questions -None block implementation. The supported OS matrix is resolved from current -metadata and executable evidence during T006; any mismatch becomes an explicit -task result rather than an implicit assumption. +None block implementation. The support contract is Python 3.12 and 3.13 on +Linux, macOS, and Windows; T007 must validate all six combinations or correct +the associated claim before release preparation can continue. ## Related Artifacts diff --git a/docs/specs/007-release-readiness-stabilization/requirements.md b/docs/specs/007-release-readiness-stabilization/requirements.md index 2205432..9c43696 100644 --- a/docs/specs/007-release-readiness-stabilization/requirements.md +++ b/docs/specs/007-release-readiness-stabilization/requirements.md @@ -26,7 +26,7 @@ publishes evidence-backed release notes. - Build and validate source and wheel artifacts for version `0.9.1`. - Prove the supported installation and CLI smoke paths in clean environments. - Rehearse the release workflow without creating a production tag. -- Produce accurate changelog, release-note, and operator documentation. +- Produce accurate changelog-derived release communications and operator documentation. ## Non-Goals @@ -40,10 +40,11 @@ publishes evidence-backed release notes. | Term | Definition | |------|------------| -| Normal CI | The test profile run for pushes and pull requests: tests excluding the `performance` and `stress` markers. | -| MinIO profile | Integration tests that require an explicitly provisioned S3-compatible MinIO endpoint. | +| Normal CI | The test profile run for pushes and pull requests: tests excluding the `performance`, `stress`, and `minio` markers. | +| MinIO profile | Tests marked `minio` that contact an explicitly provisioned S3-compatible MinIO endpoint. Mocked S3/MinIO contract tests remain in normal CI. | | Release rehearsal | Non-publishing validation of the release workflow, commands, inputs, artifacts, and permissions. | | Release evidence | CI runs, commands, artifact metadata, hashes, install results, and review records supporting a release decision. | +| Release notes | The eventual GitHub release body derived from the canonical `v0.9.1` section in `CHANGELOG.md`, not a separate durable document. | ## Durable Source Baseline @@ -54,8 +55,10 @@ publishes evidence-backed release notes. | `pyproject.toml` | Package version, Python support, dependencies, console scripts, test markers, and coverage configuration. | high | Authoritative build metadata. | | `.github/workflows/test-suite.yml` | Normal and manually dispatched extended test profiles. | high | Normal CI currently lacks MinIO provisioning or isolation. | | `.github/workflows/release.yml` | Tag-triggered version check, tests, build, smoke install, artifact upload, and GitHub release. | high | Exists but has not been exercised by a repository release. | +| `scripts/bump_version.py` and `.bumpversion.cfg` | The version helper commits and tags by default unless both side effects are disabled. | high | Release preparation must use `--no-commit --no-tag`. | | `docs/guides/user/installation.md` | Current installation and validation guidance. | high | Promotion target for verified clean-install behavior. | -| `docs/processes/README.md` | Durable process index. | high | Target for the release operator procedure. | +| `docs/processes/version-management.md` | Current version-bump and release procedure. | high | Must be corrected in place and linked from the process index. | +| `docs/processes/README.md` | Durable process index. | high | Must link the corrected version-management procedure. | | `CHANGELOG.md` | Durable project change history. | high | Target for the `v0.9.1` entry. | | `docs/history/spec-closure-log.md` | Records the waived selection stress threshold from Spec 001. | high | Follow-up is GitHub issue #68. | @@ -96,6 +99,13 @@ signal and integration coverage remains explicit. failure or silent skip. 4. THE SYSTEM SHALL retain the configured coverage threshold and SHALL NOT exclude unrelated correctness tests to make CI pass. +5. THE `minio` marker SHALL identify only tests that contact a live MinIO + service; mocked credential, backend, and protocol-contract tests SHALL + remain in normal CI. +6. GIVEN a checkout without MinIO configuration, WHEN pytest collects the + suite, THEN collection SHALL complete without a module-import exception or + network access and the normal, MinIO, performance, and stress selections + SHALL form an auditable ownership map for the intended suite. ### Requirement 2: Stable performance and stress signal @@ -106,12 +116,15 @@ without producing routine false failures. #### Acceptance Criteria 1. GIVEN representative supported hosts, WHEN the selection stress scenario is - measured, THEN issue #68 SHALL record timings and the chosen tolerance or - baseline strategy. + measured under Spec 007, THEN issue #68 SHALL record timings and the chosen + tolerance or baseline strategy as chronological evidence. 2. WHERE correctness and throughput assertions are combined, THE TEST SUITE SHALL separate deterministic correctness from environment-sensitive timing. 3. WHILE stress tests remain opt-in, THE RELEASE EVIDENCE SHALL record their result or an explicit, owner-approved residual risk. +4. THE active spec SHALL own the approved stress-test implementation scope, + acceptance criteria, sequencing, and validation; issue #68 SHALL track + assignment, state, and linked evidence without overriding this contract. ### Requirement 3: Reproducible release artifacts @@ -128,6 +141,12 @@ from the tagged source. 3. THE artifacts SHALL contain the declared package data, both `timelocker` and `tl` entry points, valid metadata, and recorded SHA-256 hashes. 4. IF artifact validation fails, THEN no release tag SHALL be created. +5. GIVEN the repository's side-effecting version helper, WHEN version sources + are prepared for `0.9.1`, THEN the operator SHALL use + `python scripts/bump_version.py bump patch --no-commit --no-tag` (or an + equivalently proven non-publishing operation) and SHALL record unchanged + pre/post commit, tag, tag-triggered release-workflow run, and GitHub-release + state. ### Requirement 4: Clean installation validation @@ -137,15 +156,20 @@ undeclared dependencies. #### Acceptance Criteria -1. GIVEN each supported Python version in project metadata, WHEN the wheel and - sdist are installed into fresh environments, THEN installation SHALL - complete without undeclared Python dependencies. -2. GIVEN each claimed CI operating system, WHEN the supported smoke path runs, - THEN `timelocker`, `tl`, version output, and root help SHALL work. +1. GIVEN Python 3.12 and 3.13, WHEN the wheel and sdist are installed into fresh + environments, THEN installation SHALL complete without undeclared Python + dependencies. +2. GIVEN each of Linux, macOS, and Windows on Python 3.12 and 3.13, WHEN the + supported smoke path runs, THEN `timelocker`, `tl`, version output, and root + help SHALL work in all six combinations. 3. WHERE a platform requires Restic or another system prerequisite, THE INSTALLATION GUIDE SHALL state the verified prerequisite and limitation. 4. IF a declared support claim cannot be validated, THEN the claim SHALL be - corrected or the release SHALL retain an explicit blocking risk. + corrected before release or the release SHALL remain blocked. +5. THE package metadata SHALL express the bounded Python support range + `>=3.12,<3.14`, and its Python and operating-system classifiers SHALL agree + with the six-combination validation contract without retaining the broader + `Operating System :: OS Independent` classifier. ### Requirement 5: Safe release rehearsal and communications @@ -165,6 +189,9 @@ from failures. 4. BEFORE release approval, THE VERIFICATION RECORD SHALL link required CI, artifact, clean-install, stress, documentation, and review evidence. 5. PyPI publication and `1.0.0` SHALL remain explicitly deferred. +6. `CHANGELOG.md` SHALL be the checked-in canonical source for `v0.9.1` + release communications; the eventual GitHub release body SHALL be derived + from that version section rather than a second durable release-note file. ## Correctness Properties @@ -176,17 +203,17 @@ from failures. - **CP-003:** Installing either release artifact in a clean supported environment yields the same version and console entry-point behavior. - **CP-004:** Rehearsal cannot create a production tag, GitHub release, or PyPI - publication as a side effect. + publication, commit, or tag as a side effect. - **CP-005:** Each public release claim maps to a recorded validation result or an explicit known limitation. ## Technical Context -- **Language/Version:** Python 3.12 and 3.13 as declared in `pyproject.toml`. +- **Language/Version:** Python 3.12 and 3.13 only, expressed as + `requires-python = ">=3.12,<3.14"` and matching classifiers. - **Primary Dependencies:** pytest, coverage, build, GitHub Actions, Restic, MinIO for S3 integration tests. -- **Target Platform:** Linux normal CI plus every operating system explicitly - claimed by current project metadata or installation documentation. +- **Target Platform:** Linux, macOS, and Windows, each on Python 3.12 and 3.13. - **Constraints:** No secrets in logs or artifacts; no production tag during rehearsal; coverage threshold remains 50 percent; PyPI is deferred. - **Performance Goals:** Stress thresholds must distinguish regression from diff --git a/docs/specs/007-release-readiness-stabilization/tasks.md b/docs/specs/007-release-readiness-stabilization/tasks.md index 652145f..834fb3a 100644 --- a/docs/specs/007-release-readiness-stabilization/tasks.md +++ b/docs/specs/007-release-readiness-stabilization/tasks.md @@ -14,37 +14,41 @@ last_reviewed: 2026-07-18 ## Task Dependency Graph ```text -T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 -> T009 -> T010 +T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 + -> T009 -> T010 -> T011 -> T012 -> T013 ``` ## Phase 1: Restore Trustworthy Validation -- [ ] T001 Repair normal CI ownership of MinIO integration tests. +- [ ] T001 Classify live MinIO tests and repair normal CI ownership. - Depends on: none - Requirement: Requirement 1 - - Acceptance Criteria: Requirement 1 AC1, AC2, AC4 + - Acceptance Criteria: Requirement 1 AC1, AC4, AC5, AC6 - Properties: CP-001 - - Files: `.github/workflows/test-suite.yml`, pytest marker or fixture files, - `tests/TimeLocker/integration/` - - Acceptance: Normal CI does not contact an unprovisioned MinIO endpoint, - unrelated test coverage is retained, and collection-count evidence is - recorded. - - Validation: Focused MinIO collection, normal pytest profile, workflow run. + - Files: `pyproject.toml`, `.github/workflows/test-suite.yml`, + `tests/TimeLocker/integration/test_s3_minio.py`, + `tests/TimeLocker/integration/test_minio_connection.py`, MinIO fixtures + - Acceptance: `minio` marks only live-service tests; collection performs no + configuration failure or network access; mocked S3/MinIO contract tests + remain in normal CI; every intended node is accounted for. + - Validation: Complete and partitioned collection, focused mocked tests, + `pytest -m "not performance and not stress and not minio"`. - Evidence: Pending. - - [ ] T001.1 Capture current normal and MinIO test collections and failing-run evidence. - - [ ] T001.2 Add an explicit MinIO dependency classification without hiding other integration tests. - - [ ] T001.3 Update normal CI selection and add regression coverage for profile ownership. - - [ ] T001.4 Run the normal profile locally and in GitHub Actions. + - [ ] T001.1 Capture complete, normal, MinIO, performance, and stress collections and failing-run evidence. + - [ ] T001.2 Register `minio` and mark only tests that contact the live service. + - [ ] T001.3 Move configuration and network access from import/collection into fixtures or runtime preflight. + - [ ] T001.4 Prove mocked MinIO contract tests remain in normal CI and collection nodes are not lost. - [ ] T002 Add and validate the provisioned MinIO profile. - Depends on: T001 - Requirement: Requirement 1 - - Acceptance Criteria: Requirement 1 AC2, AC3 + - Acceptance Criteria: Requirement 1 AC2, AC3, AC6 - Properties: CP-001 - Files: `.github/workflows/test-suite.yml`, MinIO fixtures or preflight tests, - `docs/4-testing/` - - Acceptance: The explicit profile provisions or validates MinIO, passes its - tests, and reports an actionable dependency error when unavailable. + `docs/4-testing/README.md` + - Acceptance: The explicit profile provisions or validates MinIO, runs + `pytest -m minio`, passes its tests, and reports an actionable dependency + error when unavailable. - Validation: Provisioned profile plus a negative preflight test. - Evidence: Pending. - [ ] T002.1 Define ephemeral endpoint and credential inputs. @@ -55,104 +59,171 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 -> T009 -> T010 - [ ] T003 Checkpoint - CI profile validation. - Depends on: T002 - Requirement: Requirement 1 - - Acceptance: Normal and MinIO profiles pass, collected-test drift is - explained, coverage remains at least 50 percent, and no unrelated tests are - excluded. - - Validation: GitHub Actions evidence, pytest collection comparison, coverage report. + - Acceptance Criteria: Requirement 1 AC1, AC2, AC3, AC4, AC5, AC6 + - Acceptance: Normal and MinIO profiles pass, all intended test nodes are + partitioned or intentionally shared, mocked contracts remain normal, + coverage remains at least 50 percent, and no unrelated test is excluded. + - Validation: GitHub Actions evidence, pytest collection partition, coverage report. - Evidence: Pending. ## Phase 2: Stabilize the Extended Signal -- [ ] T004 Verify completion of the selection stress-threshold work in GitHub issue #68. +- [ ] T004 Implement and validate the selection stress-threshold contract. - Depends on: T003 - Requirement: Requirement 2 - - Acceptance Criteria: Requirement 2 AC1, AC2, AC3 - - Files: GitHub issue #68 and affected stress tests; implementation remains - owned by the issue to avoid duplicate active work. - - Acceptance: Issue #68 contains representative timings, separated - correctness and timing semantics, the chosen regression strategy, and a - repeatable validation result or an explicit release-blocking disposition. - - Evidence mode: validation + - Acceptance Criteria: Requirement 2 AC1, AC2, AC3, AC4 + - Files: `tests/TimeLocker/selection/test_performance_stress.py`, + `src/TimeLocker/selection_testing_harness.py`, related test tooling, + `docs/4-testing/README.md` + - Acceptance: Spec 007 owns the implementation and validation; deterministic + correctness is separated from timing; a representative baseline and + tolerance are implemented; the repeatable extended profile passes or a + release-blocking disposition is recorded. + - Evidence mode: implementation - Destination: - Evidence: Pending. + - [ ] T004.1 Capture representative host timings and environment context in issue #68. + - [ ] T004.2 Separate deterministic correctness assertions from environment-sensitive timing assertions. + - [ ] T004.3 Implement the evidence-backed baseline and tolerance strategy. + - [ ] T004.4 Run a repeatable extended profile and link results from issue #68. - [ ] T005 Checkpoint - Release validation prerequisites. - Depends on: T004 - Requirements: Requirements 1 and 2 - Acceptance: Normal CI is green, explicit external-service coverage is - green, and stress evidence is acceptable for release preparation. - - Validation: Review T003 evidence and issue #68 acceptance criteria. + green, Spec 007 stress acceptance is met, and issue #68 contains linked + evidence or an explicit release-blocking disposition. + - Validation: Review T003 and T004 evidence and the linked issue history. - Evidence: Pending. ## Phase 3: Build and Install v0.9.1 -- [ ] T006 Set version `0.9.1` and build reproducible release artifacts. +- [ ] T006 Prepare version `0.9.1` safely and build reproducible artifacts. - Depends on: T005 - Requirement: Requirement 3 - - Acceptance Criteria: Requirement 3 AC1, AC2, AC3, AC4 - - Properties: CP-002 - - Files: `pyproject.toml`, `src/TimeLocker/__init__.py`, build and release tooling - - Acceptance: Version sources agree; sdist, wheel, metadata, entry points, - package data, and SHA-256 hashes validate from a clean checkout. - - Validation: Version guard, `python -m build`, artifact inspection. + - Acceptance Criteria: Requirement 3 AC1, AC2, AC3, AC4, AC5 + - Properties: CP-002, CP-004 + - Files: `pyproject.toml`, `src/TimeLocker/__init__.py`, + `scripts/bump_version.py`, `.bumpversion.cfg`, build and release tooling + - Acceptance: The non-publishing version command changes only versioned + working-tree files; commit, tag, and GitHub-release identity remain + unchanged; version sources, sdist, wheel, metadata, entry points, package + data, and SHA-256 hashes validate from a clean source baseline. + - Validation: Pre/post Git and release-state comparison, + `python scripts/bump_version.py bump patch --no-commit --no-tag`, version + guard, `python -m build`, artifact inspection. - Evidence: Pending. - - [ ] T006.1 Update and test all authoritative version sources. - - [ ] T006.2 Build sdist and wheel once from a clean source state. - - [ ] T006.3 Inspect metadata, contents, entry points, and hashes. - - [ ] T006.4 Prove a version mismatch blocks the release guard. + - [ ] T006.1 Record pre-change commit, tag, tag-triggered release-workflow run, and GitHub-release identity. + - [ ] T006.2 Run the version helper with both commit and tag side effects disabled. + - [ ] T006.3 Update `requires-python` to `>=3.12,<3.14`, remove `OS Independent`, and reconcile Python and OS classifiers. + - [ ] T006.4 Build sdist and wheel once; inspect metadata, contents, entry points, and hashes. + - [ ] T006.5 Prove a version mismatch blocks the guard and prove commit, tag, tag-triggered release-workflow run, and release identity did not change. -- [ ] T007 Validate wheel and sdist in clean supported environments. +- [ ] T007 Validate wheel and sdist across the declared support matrix. - Depends on: T006 - Requirement: Requirement 4 - - Acceptance Criteria: Requirement 4 AC1, AC2, AC3, AC4 + - Acceptance Criteria: Requirement 4 AC1, AC2, AC3, AC4, AC5 - Properties: CP-003 - - Files: `.github/workflows/`, smoke tooling, `docs/guides/user/installation.md` - - Acceptance: Both artifact types pass the shared CLI smoke contract on the - supported Python and OS matrix, or unsupported claims are corrected and - reviewed before proceeding. - - Validation: Fresh-environment installs for wheel and sdist; both console entry points. + - Files: `.github/workflows/`, smoke tooling, + `docs/guides/user/installation.md`, `pyproject.toml` + - Acceptance: Wheel and sdist pass the shared CLI smoke contract on Linux, + macOS, and Windows for Python 3.12 and 3.13; an unvalidated combination + blocks readiness until its support claim is corrected and reviewed. + - Validation: Six OS/Python combinations, both artifact types, both console entry points. - Evidence: Pending. - - [ ] T007.1 Reconcile Python and OS claims from metadata, workflows, and docs. - - [ ] T007.2 Install wheel and run version, root help, and safe quick-start smoke checks. - - [ ] T007.3 Install sdist and run the same smoke contract. - - [ ] T007.4 Record or correct platform prerequisites and limitations. + - [ ] T007.1 Add or reconcile the six-combination Linux/macOS/Windows and Python 3.12/3.13 smoke matrix. + - [ ] T007.2 Install the wheel and run version, root help, and safe quick-start smoke checks in every combination. + - [ ] T007.3 Install the sdist and run the identical smoke contract in every combination. + - [ ] T007.4 Record platform prerequisites and correct any support claim that cannot be validated. - [ ] T008 Checkpoint - Artifact and installation readiness. - Depends on: T007 - Requirements: Requirements 3 and 4 - - Acceptance: Artifact identity, hashes, installation results, platform - coverage, and residual risk are recorded before release rehearsal. - - Validation: Review artifact and clean-install evidence against CP-002 and CP-003. + - Acceptance: Side-effect safety, artifact identity, hashes, six-combination + installation results, platform coverage, and residual risk are recorded + before release rehearsal. + - Validation: Review artifact and clean-install evidence against CP-002, CP-003, and CP-004. - Evidence: Pending. ## Phase 4: Rehearse, Promote, and Review -- [ ] T009 Rehearse the release workflow and promote durable release guidance. +- [ ] T009 Implement a safe pre-tag validation interface. - Depends on: T008 - Requirement: Requirement 5 - - Acceptance Criteria: Requirement 5 AC1, AC2, AC3, AC4, AC5 - - Properties: CP-004, CP-005 - - Files: `.github/workflows/release.yml`, `CHANGELOG.md`, release notes, - `docs/processes/`, `docs/guides/user/installation.md`, `README.md` - - Acceptance: Every pre-publication release step is validated without a - production tag; durable operator and user guidance and evidence-backed - `v0.9.1` communications are complete; PyPI and `1.0.0` remain deferred. - - Validation: Workflow validation, non-publishing rehearsal, links and docs review. + - Acceptance Criteria: Requirement 5 AC1, AC5 + - Properties: CP-004 + - Files: `.github/workflows/release.yml`, release validation scripts or tests + - Acceptance: A reusable pre-tag path validates release inputs and steps but + contains no commit, tag, release, or package-index publication action. + - Evidence mode: implementation + - Validation: Workflow syntax, focused script tests, publication-boundary review. - Evidence: Pending. - - [ ] T009.1 Establish one safe pre-tag validation command or workflow path. - - [ ] T009.2 Rehearse build, smoke, artifact, permissions, and failure paths without publishing. - - [ ] T009.3 Write the durable release operator procedure and rollback boundary. - - [ ] T009.4 Update installation guidance, changelog, and `v0.9.1` release notes. - - [ ] T009.5 Perform release-readiness documentation and security review. + - [ ] T009.1 Identify and isolate every pre-publication release step. + - [ ] T009.2 Implement a manual or local validation entry point with read-only permissions. + - [ ] T009.3 Add regression coverage for the publication boundary and failure propagation. -- [ ] T010 Checkpoint - Human release decision and spec closure readiness. +- [ ] T010 Execute and record a non-publishing release rehearsal. - Depends on: T009 - - Requirements: Requirements 1 through 5 + - Requirement: Requirement 5 + - Acceptance Criteria: Requirement 5 AC1, AC4, AC5 + - Properties: CP-004 + - Files: `verification.md`, workflow-run or local rehearsal evidence + - Acceptance: Build, smoke, artifact configuration, permissions, and failure + paths are exercised; pre/post commit, tag, and GitHub-release identity are + unchanged; no external publication occurs. + - Evidence mode: validation + - Validation: Non-publishing rehearsal and external-state comparison. + - Evidence: Pending. + - [ ] T010.1 Capture pre-rehearsal commit, tag, release, and permission state. + - [ ] T010.2 Exercise successful build, smoke, artifact, and release-note inputs. + - [ ] T010.3 Exercise version mismatch, missing prerequisite, and permission failure paths. + - [ ] T010.4 Capture unchanged post-rehearsal external state and link all logs. + +- [ ] T011 Update existing durable release and installation procedures. + - Depends on: T010 + - Requirements: Requirements 4 and 5 + - Acceptance Criteria: Requirement 4 AC3, AC4, AC5; Requirement 5 AC2, AC5 + - Files: `docs/processes/version-management.md`, `docs/processes/README.md`, + `docs/guides/user/installation.md`, `README.md` if required + - Acceptance: The existing version-management procedure documents the safe + preparation command, authorized publication boundary, checks, failure and + rollback handling, and is indexed; installation guidance reflects only + the validated support matrix and prerequisites. + - Evidence mode: implementation + - Validation: Procedure review, Markdown and internal-link checks, command review. + - Evidence: Pending. + - [ ] T011.1 Correct `version-management.md` in place; do not create a duplicate release procedure. + - [ ] T011.2 Link the procedure from `docs/processes/README.md`. + - [ ] T011.3 Update installation and front-door claims from T007 evidence. + +- [ ] T012 Prepare evidence-backed `v0.9.1` communications. + - Depends on: T011 + - Requirement: Requirement 5 + - Acceptance Criteria: Requirement 5 AC3, AC5, AC6 + - Properties: CP-005 + - Files: `CHANGELOG.md`, GitHub release-body input or derivation tooling + - Acceptance: The `v0.9.1` changelog section is the single checked-in + canonical release-note source; every claim maps to evidence or a known + limitation; the eventual GitHub release body is derived from that section. + - Evidence mode: implementation + - Validation: Claim-to-evidence review and release-body derivation preview. + - Evidence: Pending. + - [ ] T012.1 Draft the changelog section from verified changes and limitations. + - [ ] T012.2 Map each public claim to verification, commits, specs, or issues. + - [ ] T012.3 Preview the GitHub release body without creating a release. + +- [ ] T013 Checkpoint - Human release decision and spec closure readiness. + - Depends on: T012 + - Requirements: Requirement 1, Requirement 2, Requirement 3, Requirement 4, + Requirement 5 - Acceptance: All required evidence is linked; durable content is promoted; - residual risks and owners are explicit; no tag or release has been created; - and the package is ready for human release approval and lifecycle closure. - - Validation: Lifecycle lint, readiness and traceability checks, full required - test profiles, internal-link check, `git diff --check`, expert review. + residual risks and owners are explicit; no commit, tag, GitHub release, or + PyPI publication was created by preparation or rehearsal; and the package + is ready for separate human release approval and lifecycle closure. + - Decision owner: release maintainer + - Validation: Lifecycle lint, readiness, traceability and evidence checks, + required test profiles, Markdown and internal-link checks, + `git diff --check`, security and release-readiness expert review. - Evidence: Pending. ## Execution Rules @@ -161,13 +232,26 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 -> T009 -> T010 design, change-impact, and verification sections before starting a task. - Mark a selected task `[~]` before implementation and record evidence before marking it `[x]`. -- Do not create a production tag, GitHub release, or PyPI publication under - this package without a separate explicit release approval. -- GitHub issue #68 owns stress-threshold implementation; T004 consumes and - verifies its evidence rather than restating its engineering work. +- Do not create a commit, production tag, GitHub release, or PyPI publication + as a side effect of version preparation or rehearsal. A normal task commit + may occur only after validation and separate explicit commit instruction; + tagging and publication always require separate release approval. +- Spec 007 owns stress-threshold scope, implementation, sequencing, + acceptance, and validation. GitHub issue #68 tracks assignment, state, and + chronological evidence. - A failed prerequisite blocks downstream release tasks; it is not waived by reducing test or support scope without an approved spec reconciliation. +## Rules Consulted + +Rules consulted and applied: General Preferences (priority 50), Operational +Best Practices (priority 40), Planning Protocol (priority 30), Testing +Conventions (priority 25), and Documentation Conventions (priority 20). +Override: the user already approved remediation by requesting that the review +findings be addressed, so no repeated approval gate was required. +Final downstream review confirmed these tasks implement the reconciled +requirements and design, including the changelog-derived communications model. + ## Related Artifacts - Requirements: `requirements.md` diff --git a/docs/specs/007-release-readiness-stabilization/traceability.md b/docs/specs/007-release-readiness-stabilization/traceability.md index 5f3573e..19d1be9 100644 --- a/docs/specs/007-release-readiness-stabilization/traceability.md +++ b/docs/specs/007-release-readiness-stabilization/traceability.md @@ -13,46 +13,50 @@ last_reviewed: 2026-07-18 | Task ID | Requirements | Acceptance Criteria | Design Sections | Change Impact | Verification | Durable Targets | Open Decisions | |---------|--------------|---------------------|-----------------|---------------|--------------|-----------------|----------------| -| T001 | Requirement 1 | AC1, AC2, AC4 | CI Profile Logic | Test profile change; bug fix details | normal profile and collection | workflow, testing guide | none | -| T002 | Requirement 1 | AC2, AC3 | CI Profile Logic; Error Handling; Security | Test profile change | MinIO profile and preflight | workflow, testing guide | none | -| T003 | Requirement 1 | AC1, AC2, AC3, AC4 | Validation Strategy | Test profile change | CI quality gate | testing guide | none | -| T004 | Requirement 2 | AC1, AC2, AC3 | Components; Validation Strategy | Stress signal bug fix | issue #68 and extended profile | tests, testing guide | none | +| T001 | Requirement 1 | AC1, AC4, AC5, AC6 | CI Profile Logic | Live MinIO classification and collection safety | normal profile and collection partition | workflow, testing guide | none | +| T002 | Requirement 1 | AC2, AC3, AC6 | CI Profile Logic; Error Handling; Security | Provisioned MinIO profile | MinIO profile and negative preflight | workflow, testing guide | none | +| T003 | Requirement 1 | AC1, AC2, AC3, AC4, AC5, AC6 | Validation Strategy | CI profile readiness | CI quality gate, coverage, partition proof | testing guide | none | +| T004 | Requirement 2 | AC1, AC2, AC3, AC4 | Components; Validation Strategy | Spec-owned stress bug fix | representative timings, tests, issue #68, extended profile | tests, testing guide | none | | T005 | Requirements 1 and 2 | all | Downstream Task Guidance | CI and stress readiness | prerequisite checkpoint | none | none | -| T006 | Requirement 3 | AC1, AC2, AC3, AC4 | Version and Artifact Guard | Version and artifact changes | build, metadata, version guard | metadata, changelog | none | -| T007 | Requirement 4 | AC1, AC2, AC3, AC4 | Clean-Install Matrix | Install validation | clean install matrix | installation guide | none | -| T008 | Requirements 3 and 4 | all | Validation Strategy | Artifact and install readiness | artifact checkpoint | installation guide | none | -| T009 | Requirement 5 | AC1, AC2, AC3, AC4, AC5 | Release Rehearsal; Operational Considerations | Process and communications | rehearsal and docs review | process, changelog, release notes, install guide | none | -| T010 | Requirements 1 through 5 | all | Validation Strategy; Downstream Task Guidance | all promotion targets | lifecycle and expert review | all listed targets | none | +| T006 | Requirement 3 | AC1, AC2, AC3, AC4, AC5 | Version and Artifact Guard; Security | Side-effect-safe version and artifact changes | Git/release-state comparison, build, metadata, version guard | metadata, version process, changelog | none | +| T007 | Requirement 4 | AC1, AC2, AC3, AC4, AC5 | Clean-Install Matrix | Exact support matrix and install validation | six-combination wheel and sdist smoke matrix | metadata, installation guide | none | +| T008 | Requirements 3 and 4 | all | Validation Strategy | Artifact and install readiness | artifact checkpoint and side-effect proof | installation guide | none | +| T009 | Requirement 5 | AC1, AC5 | Release Rehearsal; Security | Safe pre-tag interface | syntax, tests, publication-boundary review | release workflow | none | +| T010 | Requirement 5 | AC1, AC4, AC5 | Release Rehearsal; Error Handling | Non-publishing rehearsal | rehearsal, failure paths, external-state comparison | verification record | none | +| T011 | Requirements 4 and 5 | R4 AC3, AC4, AC5; R5 AC2, AC5 | Operational Considerations; Clean-Install Matrix | Existing process and install guidance | command, Markdown, and link review | version process, process index, install guide, README if needed | none | +| T012 | Requirement 5 | AC3, AC5, AC6 | Validation Strategy | Canonical release communications | claim-to-evidence review and release-body preview | changelog | none | +| T013 | Requirement 1, Requirement 2, Requirement 3, Requirement 4, Requirement 5 | all | Validation Strategy; Downstream Task Guidance | all promotion targets | lifecycle, evidence, security, and expert review | all listed targets | none | ## Requirement To Delivery Matrix | Requirement | Acceptance Criteria | Design Sections | Tasks | Verification | Durable Targets | |-------------|---------------------|-----------------|-------|--------------|-----------------| -| Requirement 1 | AC1, AC2, AC3, AC4 | CI Profile Logic; Error Handling | T001-T003 | normal and MinIO profiles, coverage, collection | workflow, `docs/4-testing/README.md` | -| Requirement 2 | AC1, AC2, AC3 | Components; Validation Strategy | T004-T005 | issue #68, extended profile | tests and testing guide | -| Requirement 3 | AC1, AC2, AC3, AC4 | Version and Artifact Guard | T006, T008 | build, metadata, hashes, version guard | metadata, changelog | -| Requirement 4 | AC1, AC2, AC3, AC4 | Clean-Install Matrix | T007, T008 | artifact install matrix | installation guide | -| Requirement 5 | AC1, AC2, AC3, AC4, AC5 | Release Rehearsal; Operational Considerations | T009-T010 | rehearsal, docs, expert review | process, changelog, release notes, README if needed | +| Requirement 1 | AC1-AC6 | CI Profile Logic; Error Handling | T001-T003 | normal and MinIO profiles, coverage, complete collection partition | workflow, `docs/4-testing/README.md` | +| Requirement 2 | AC1-AC4 | Components; Validation Strategy | T004-T005 | stress tests, issue #68 evidence, extended profile | tests and testing guide | +| Requirement 3 | AC1-AC5 | Version and Artifact Guard | T006, T008 | side-effect proof, build, metadata, hashes, version guard | metadata, version process, changelog | +| Requirement 4 | AC1-AC5 | Clean-Install Matrix | T007-T008, T011 | six-combination artifact install matrix and support-claim review | metadata, installation guide | +| Requirement 5 | AC1-AC6 | Release Rehearsal; Operational Considerations | T009-T013 | interface tests, rehearsal, docs, communications, expert review | version process, process index, changelog, README if needed | ## Correctness Property Coverage | Property | Requirements | Design Sections | Tasks | Tests Or Verification | Residual Risk | |----------|--------------|-----------------|-------|-----------------------|---------------| -| CP-001 | Requirement 1 | CI Profile Logic | T001-T003 | collection comparison and both CI profiles | marker drift | +| CP-001 | Requirement 1 | CI Profile Logic | T001-T003 | collection partition and both CI profiles | marker drift | | CP-002 | Requirement 3 | Version and Artifact Guard | T006, T008 | positive and negative version guard | none expected | -| CP-003 | Requirement 4 | Clean-Install Matrix | T007, T008 | wheel and sdist smoke matrix | OS scope | -| CP-004 | Requirement 5 | Release Rehearsal | T009-T010 | side-effect review and non-publishing rehearsal | tag-only external behavior | -| CP-005 | Requirement 5 | Validation Strategy | T009-T010 | release-note evidence review | human review quality | +| CP-003 | Requirement 4 | Clean-Install Matrix | T007-T008 | wheel and sdist smoke across six combinations | runner availability blocks support claim | +| CP-004 | Requirements 3 and 5 | Version and Artifact Guard; Release Rehearsal | T006, T008-T010, T013 | pre/post commit, tag, and release-state identity | tag-only external behavior | +| CP-005 | Requirement 5 | Validation Strategy | T012-T013 | changelog claim evidence and derived release-body review | human review quality | ## Design To Implementation Matrix | Design Section | Requirements | Tasks | Interfaces Or Files | Verification | |----------------|--------------|-------|---------------------|--------------| -| CI Profile Logic | Requirement 1 | T001-T003 | workflow, markers, fixtures, integration tests | collection, normal CI, MinIO CI | -| Version and Artifact Guard | Requirement 3 | T006, T008 | metadata, package version, build output | guard, build, metadata, hashes | -| Clean-Install Matrix | Requirement 4 | T007-T008 | workflows, smoke tooling, installation guide | isolated artifact installs | -| Release Rehearsal | Requirement 5 | T009-T010 | release workflow, process docs, release docs | dry rehearsal and review | -| Security, Trust, and Access | Requirements 1 and 5 | T002, T009-T010 | workflow permissions and ephemeral MinIO values | secrets and permissions review | +| CI Profile Logic | Requirement 1 | T001-T003 | workflow, marker registry, fixtures, live and mocked integration tests | collection partition, normal CI, MinIO CI | +| Version and Artifact Guard | Requirement 3 | T006, T008 | helper, bump config, metadata, package version, build output | external-state identity, guard, build, metadata, hashes | +| Clean-Install Matrix | Requirement 4 | T007-T008, T011 | metadata, workflows, smoke tooling, installation guide | isolated artifact installs on six combinations | +| Release Rehearsal | Requirement 5 | T009-T010, T013 | release workflow, rehearsal evidence | non-publishing interface, rehearsal, external-state identity | +| Operational Considerations | Requirements 4 and 5 | T011-T013 | existing version process, process index, installation guide, changelog | docs, command, link, communications, and expert review | +| Security, Trust, and Access | Requirements 1, 3, and 5 | T002, T006, T009-T010, T013 | workflow permissions, ephemeral MinIO values, version helper | secrets, permissions, and side-effect review | ## Open Decision Impact @@ -64,5 +68,8 @@ package before downstream tasks continue. - Update this matrix whenever acceptance criteria, task IDs, support claims, validation profiles, or durable destinations change. -- Treat missing issue #68 evidence as a release-readiness gap, not as implicit - completion. +- Requirements and design, including the changelog-derived communications + decision, were re-reviewed against this matrix after the TLR-001 through + TLR-006 remediation; all acceptance mappings are explicit. +- Spec 007 owns stress implementation and acceptance; issue #68 is the linked + assignment, state, and chronological-evidence record. diff --git a/docs/specs/007-release-readiness-stabilization/verification.md b/docs/specs/007-release-readiness-stabilization/verification.md index ac95b34..9f9a6c4 100644 --- a/docs/specs/007-release-readiness-stabilization/verification.md +++ b/docs/specs/007-release-readiness-stabilization/verification.md @@ -11,7 +11,7 @@ last_reviewed: 2026-07-18 ## Scope -This record covers Spec 007 requirements R1-R5 and tasks T001-T010. It records +This record covers Spec 007 requirements R1-R5 and tasks T001-T013. It records release-preparation evidence only; creating a production tag or release requires separate explicit approval. @@ -19,114 +19,125 @@ separate explicit approval. | Gate | Required? | Status | Evidence | |------|-----------|--------|----------| -| Requirements acceptance criteria reviewed | yes | passed | Lifecycle stage readiness reports all 20 acceptance criteria explicitly covered. | -| Task evidence complete | yes | pending | T001-T010 pending. | +| Acceptance traceability complete | yes | passed | Lifecycle stage readiness reports zero acceptance, property, context, downstream-review, or blocking gaps after reconciliation. | +| Substantive requirements and design review | yes | passed | Review on 2026-07-18 produced TLR-001 through TLR-006; all six findings were reconciled into the package before implementation. | +| Task evidence complete | yes | pending | T001-T013 pending. | | Normal and dependency-owning test profiles pass | yes | pending | Current normal run 29653160911 fails on unavailable MinIO. | -| Stress signal disposition recorded | yes | pending | GitHub issue #68. | -| Artifacts and clean installs validate | yes | pending | T006-T008. | -| Release workflow rehearsed without publication | yes | pending | T009. | -| Durable documentation promoted | yes | pending | Promotion table below. | -| Lifecycle checks and expert review pass | yes | pending | T010. | - -## Validation Commands - -| Command | Purpose | Result | Evidence | -|---------|---------|--------|----------| -| `python -m pytest -m "not performance and not stress"` | Normal correctness and coverage profile | blocked | GitHub run 29653160911: MinIO unavailable; 1 failed, 1310 passed, 53 deselected, 4 errors before stop. | -| `python -m pytest --collect-only -q ...` | Compare normal and MinIO-owned collections | pending | T001. | -| explicit provisioned MinIO pytest command | Validate S3 integration and dependency preflight | pending | T002. | -| `python -m pytest -m "performance or stress" --no-cov` | Extended performance and stress profile | pending | T004 and issue #68. | -| `python -m build` | Build sdist and wheel | pending | T006. | -| version and metadata guard | Prove CP-002 | pending | T006. | -| wheel and sdist clean-install matrix | Prove CP-003 and platform claims | pending | T007. | -| non-publishing release rehearsal | Prove CP-004 | pending | T009. | -| repository link check and `git diff --check` | Validate documentation and patch hygiene | pending | T010. | +| Stress implementation and disposition recorded | yes | pending | T004 and GitHub issue #68. | +| Artifacts and six-combination clean installs validate | yes | pending | T006-T008. | +| Release interface and rehearsal prove no publication side effect | yes | pending | T009-T010. | +| Durable documentation and communications promoted | yes | pending | T011-T012 and promotion table below. | +| Final lifecycle checks and expert review pass | yes | pending | T013; package-creation review does not replace final implementation review. | + +## Validation Commands And Methods + +| Command Or Method | Purpose | Result | Evidence | +|-------------------|---------|--------|----------| +| `python -m pytest -m "not performance and not stress and not minio"` | Normal correctness and coverage profile | pending | Replaces current failing selector in T001. | +| complete collection compared with normal, `minio`, performance, and stress selections | Prove collection safety and node ownership | pending | T001-T003. | +| `python -m pytest -m minio` with provisioned service | Validate live S3 integration and dependency preflight | pending | T002. | +| `python -m pytest -m "performance or stress" --no-cov` | Extended performance and stress profile | pending | T004 and issue #68; final evidence must explain the coverage exception for this opt-in profile. | +| `python scripts/bump_version.py bump patch --no-commit --no-tag` plus pre/post commit, tag, tag-triggered release-workflow run, and release identity | Prepare `0.9.1` without publication side effects | pending | T006. | +| `python -m build`, version guard, metadata inspection, and SHA-256 generation | Build and prove artifact identity | pending | T006. | +| wheel and sdist smoke installs on Linux, macOS, and Windows for Python 3.12 and 3.13 | Prove CP-003 and all declared support claims | pending | T007 six-combination matrix. | +| safe pre-tag interface tests and non-publishing rehearsal | Prove CP-004, including failure paths and unchanged external state | pending | T009-T010. | +| repository Markdown/link checks and `git diff --check` | Validate specification and durable-doc hygiene | pending | Package reconciliation and T011-T013. | ## Requirement Coverage -| Requirement | Acceptance criteria covered | Evidence | Residual risk | -|-------------|-----------------------------|----------|---------------| -| R1 | AC1-AC4 | T001-T003 pending; failing run captured | Profile changes may hide tests unless collection is compared. | -| R2 | AC1-AC3 | Issue #68 and T004-T005 pending | Host variance. | -| R3 | AC1-AC4 | T006-T008 pending | Tag-only workflow behavior remains unreleased. | -| R4 | AC1-AC4 | T007-T008 pending | OS runner availability. | -| R5 | AC1-AC5 | T009-T010 pending | Human operator error at first actual tag. | +| Requirement | Acceptance Criteria Covered | Evidence | Residual Risk | +|-------------|------------------------------|----------|---------------| +| R1 | AC1-AC6 | T001-T003 pending; failing run captured | Marker or collection drift could hide tests. | +| R2 | AC1-AC4 | Spec-owned T004-T005 and issue #68 pending | Host variance. | +| R3 | AC1-AC5 | T006 and T008 pending | Side-effecting defaults must remain disabled. | +| R4 | AC1-AC5 | T007-T008 and T011 pending | Unavailable runner blocks the associated support claim. | +| R5 | AC1-AC6 | T009-T013 pending | Human operator error at first actual tag. | ## Correctness Property Coverage -| Property | Covered by | Evidence | Residual risk | +| Property | Covered By | Evidence | Residual Risk | |----------|------------|----------|---------------| -| CP-001 | T001-T003, collection and workflow runs | pending | Marker drift. | +| CP-001 | T001-T003, collection partition and workflow runs | pending | Marker drift. | | CP-002 | T006 version guard and negative test | pending | None expected after automated guard. | -| CP-003 | T007 clean artifact matrix | pending | Platform scope must be explicit. | -| CP-004 | T009 side-effect review and rehearsal | pending | External publication remains human-controlled. | -| CP-005 | T009 documentation review | pending | Review quality. | +| CP-003 | T007 six-combination artifact matrix | pending | Runner availability is a blocking support gap. | +| CP-004 | T006, T008-T010, and T013 external-state comparisons | pending | Actual tag behavior remains separately controlled. | +| CP-005 | T012-T013 changelog and derived release-body review | pending | Review quality. | ## Agent Readiness Evidence -| Field | Evidence | Residual risk | +| Field | Evidence | Residual Risk | |-------|----------|---------------| | Scope and out-of-scope files | Requirements goals, non-goals, change impact, and task file lists | Newly discovered release blockers require reconciliation. | -| Must-read and optional context | Full Spec 007 package, `CHARTER.md`, workflows, metadata, install and process docs, issue #68 | GitHub evidence can change. | -| Permissions and approval points | Branch work approved; tag, GitHub release, and PyPI publication excluded pending separate approval | Do not infer release authority. | -| Validation commands and expected signals | Validation table plus task-specific commands | Exact MinIO command is resolved in T002. | -| Review needs | CI, packaging, security, operations, and documentation review at T010 | Human release decision remains. | +| Must-read and optional context | Full Spec 007 package, `CHARTER.md`, workflows, metadata, version helper/config, install and process docs, issue #68 | GitHub evidence can change. | +| Permissions and approval points | Branch work approved; task commits require explicit commit instruction; tag, GitHub release, and PyPI publication require separate release approval | Do not infer publication authority. | +| Validation commands and expected signals | Validation table plus task-specific commands | Hosted services and runners remain external. | +| Review needs | CI, packaging, security, operations, and documentation review at T013 | Human release decision remains. | | Durable-doc or closure impact | Promotion table and `change-impact.md` | Package cannot close before promotion. | -| Optional repo-evidence provider caveats | Agent Workbench returned stale deleted-plan paths during intake; direct repository evidence is authoritative | Recheck provider before relying on suggestions. | +| Optional repo-evidence provider caveats | Agent Workbench routing is advisory and has stale deleted-path candidates; direct repository and lifecycle evidence are authoritative | Recheck provider before relying on suggestions. | ## Task Evidence | Task ID | Status | Evidence | Notes | |---------|--------|----------|-------| -| T001 | pending | Failing CI root cause captured | Implementation not started. | -| T002 | pending | | | -| T003 | pending | | | -| T004 | pending | GitHub issue #68 created and assigned | Issue implementation remains pending. | -| T005 | pending | | | -| T006 | pending | | | -| T007 | pending | | | -| T008 | pending | | | -| T009 | pending | | | -| T010 | pending | | | +| T001 | pending | Failing CI root cause captured | Live-versus-mocked classification and collection safety pending. | +| T002 | pending | | Provisioned profile pending. | +| T003 | pending | | CI checkpoint pending. | +| T004 | pending | GitHub issue #68 created and assigned | Spec owns implementation; issue tracks state and evidence. | +| T005 | pending | | Prerequisite checkpoint pending. | +| T006 | pending | Side-effecting helper defaults identified | Safe bump, artifact, and external-state evidence pending. | +| T007 | pending | Six-combination contract defined | Artifact matrix pending. | +| T008 | pending | | Artifact checkpoint pending. | +| T009 | pending | | Safe pre-tag interface pending. | +| T010 | pending | | Non-publishing rehearsal pending. | +| T011 | pending | Existing version process selected as promotion target | Durable updates pending. | +| T012 | pending | `CHANGELOG.md` selected as canonical source | Communications pending. | +| T013 | pending | | Final review and human decision pending. | ## Evidence Log | Date | Evidence | Result | Notes | |------|----------|--------|-------| | 2026-07-18 | GitHub Actions run 29653160911 | failed | Unprovisioned MinIO caused one failure and four setup errors; normal CI is not release-ready. | +| 2026-07-18 | Focused local mocked MinIO contract test | passed | Controlled environment passed, supporting separation of mocked contracts from live-service tests. | | 2026-07-18 | Open-issue reconciliation | passed | All 27 inherited open issues reviewed; 9 closed, 18 retained with current scope. | | 2026-07-18 | GitHub milestone `v0.9.1` | created | PyPI and `1.0.0` explicitly deferred. | -| 2026-07-18 | GitHub issue #68 | created and assigned | Owns selection stress-threshold stabilization. | -| 2026-07-18 | Spec Lifecycle Manager package lint | passed | Zero errors, warnings, or informational diagnostics. | -| 2026-07-18 | Spec Lifecycle Manager stage readiness | passed | Ready for agent and implementation; zero blocking, context, property, or acceptance gaps. | -| 2026-07-18 | Agent readiness packet for T001 | passed | Requirement, design, verification, durable targets, and traceability resolve without gaps. | -| 2026-07-18 | Documentation link check and `git diff --check` | passed | No broken links in the changed spec set and no whitespace errors; repository-wide checker reported only pre-existing canonical-style suggestions. | +| 2026-07-18 | GitHub issue #68 | created and assigned | Tracks selection stress assignment, state, and chronological evidence; Spec 007 owns delivery authority. | +| 2026-07-18 | Substantive Spec 007 review | findings addressed | TLR-001 through TLR-006 reconciled safe versioning, stress authority, support matrix, MinIO ownership, release-task decomposition, and review evidence. | +| 2026-07-18 | Downstream task and verification review | passed | Tasks and verification were rechecked after the final requirements and design reconciliation, including the changelog-derived communications model. | +| 2026-07-18 | Spec Lifecycle Manager package checks | passed | Package lint has zero diagnostics; stage readiness is implementation-ready with zero gaps; sampled T001, T004, T006, T007, T009, and T013 lookups and T001 readiness resolve without gaps. | +| 2026-07-18 | Documentation and patch checks | passed with advisory warnings | No structural Markdown findings, broken links, or whitespace errors; 135 table-readability warnings and 25 pre-existing canonical-link style suggestions remain non-blocking. | ## Manual Or External Verification -GitHub issue and milestone state is externally authoritative. GitHub Actions -runs and eventual release artifacts must be linked here before release -readiness can be approved. +GitHub issue and milestone state is externally authoritative for assignment and +chronology. The active spec remains authoritative for approved scope, +sequencing, acceptance, and validation. GitHub Actions runs and eventual +release artifacts must be linked here before release readiness can be approved. ## Residual Risks - Normal CI is currently red and blocks every downstream release claim. -- MinIO profile design can accidentally reduce coverage if test collection is - not compared explicitly. -- Stress thresholds can remain host-sensitive without the evidence in #68. +- MinIO marker or collection changes can reduce coverage unless the complete + node partition and mocked-contract placement are proved. +- Stress thresholds can remain host-sensitive until T004 evidence is accepted. +- Version tooling commits and tags by default; every preparation run must use + both disabling flags and prove external state is unchanged. +- All six declared OS/Python combinations are release blockers until validated + or their support claims are corrected. - The first actual tag exercises external publication behavior that rehearsal cannot reproduce fully; it remains a human-controlled release risk. ## Durable Promotion And Cleanup -| Spec content | Durable destination or deferral | Status | Evidence | +| Spec Content | Durable Destination Or Deferral | Status | Evidence | |--------------|---------------------------------|--------|----------| -| Test profile contract | `docs/4-testing/README.md` | pending | T002 and T003. | -| Verified installation matrix | `docs/guides/user/installation.md` | pending | T007. | -| Release procedure and rollback | new document under `docs/processes/` | pending | T009. | -| Version and release contents | `CHANGELOG.md`, release notes, `README.md` if needed | pending | T009. | -| PyPI and `1.0.0` deferral | GitHub issue #22, milestone description, release process | partial | GitHub scope updated; durable process pending. | -| Follow-up work | GitHub issues outside milestone or an approved successor spec | pending | T010. | +| Test profile contract | `docs/4-testing/README.md` | pending | T002-T003. | +| Verified installation matrix | `docs/guides/user/installation.md` | pending | T007 and T011. | +| Version preparation, release procedure, and rollback | `docs/processes/version-management.md`, linked from `docs/processes/README.md` | pending | T011; no duplicate process document. | +| Version contents and release communications | `CHANGELOG.md`; GitHub release body derived from its `v0.9.1` section | pending | T012. | +| Front-door support and version claims | `README.md` if current text requires correction | pending | T011. | +| PyPI and `1.0.0` deferral | GitHub issue #22, milestone description, version process | partial | GitHub scope updated; durable process pending. | +| Follow-up work | GitHub issues outside milestone or an approved successor spec | pending | T013. | ### Spec Cleanup Decision @@ -145,10 +156,10 @@ readiness can be approved. - **Risk level:** high - **Breaking change:** no - **Blast radius checked:** partial -- **Rollback path:** to be documented in T009 +- **Rollback path:** existing version process to be corrected and validated in T011 - **Requires human review:** yes -- **Release notes needed:** yes -- **Follow-up issue or spec needed:** issue #68 already created +- **Release notes needed:** yes, in `CHANGELOG.md` +- **Follow-up issue or spec needed:** issue #68 already tracks stress evidence ### Risk Rationale From 2abdeb615d4a2c743f06edab0c7492144ea62f92 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:54:38 +0100 Subject: [PATCH 03/72] test(ci): isolate live MinIO profile Keep mocked MinIO contracts in normal CI while assigning the four live-service tests to an explicit marker. Move service configuration and reachability checks to runtime fixtures and record the verified node partition in Spec 007. --- .github/workflows/test-suite.yml | 2 +- .../tasks.md | 29 ++- .../verification.md | 29 ++- pyproject.toml | 1 + .../integration/test_minio_connection.py | 76 +++--- .../test_minio_profile_contract.py | 51 ++++ tests/TimeLocker/integration/test_s3_minio.py | 225 +++++++++++------- 7 files changed, 271 insertions(+), 142 deletions(-) create mode 100644 tests/TimeLocker/integration/test_minio_profile_contract.py diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index f37e3fd..ca146e4 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -66,7 +66,7 @@ jobs: - name: Run tests with coverage env: PYTHONPATH: src - run: python -m pytest -m "not performance and not stress" + run: python -m pytest -m "not performance and not stress and not minio" - name: Upload coverage reports to Codecov if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' diff --git a/docs/specs/007-release-readiness-stabilization/tasks.md b/docs/specs/007-release-readiness-stabilization/tasks.md index 834fb3a..f31f0b8 100644 --- a/docs/specs/007-release-readiness-stabilization/tasks.md +++ b/docs/specs/007-release-readiness-stabilization/tasks.md @@ -20,7 +20,7 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 ## Phase 1: Restore Trustworthy Validation -- [ ] T001 Classify live MinIO tests and repair normal CI ownership. +- [x] T001 Classify live MinIO tests and repair normal CI ownership. - Depends on: none - Requirement: Requirement 1 - Acceptance Criteria: Requirement 1 AC1, AC4, AC5, AC6 @@ -33,11 +33,28 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 remain in normal CI; every intended node is accounted for. - Validation: Complete and partitioned collection, focused mocked tests, `pytest -m "not performance and not stress and not minio"`. - - Evidence: Pending. - - [ ] T001.1 Capture complete, normal, MinIO, performance, and stress collections and failing-run evidence. - - [ ] T001.2 Register `minio` and mark only tests that contact the live service. - - [ ] T001.3 Move configuration and network access from import/collection into fixtures or runtime preflight. - - [ ] T001.4 Prove mocked MinIO contract tests remain in normal CI and collection nodes are not lost. + - Evidence: `.github/workflows/test-suite.yml:69` owns the corrected CI + selector. Its local execution produced 2,754 successful tests and 52.13% + coverage. Collection found 2,812 nodes: 2,755 in the CI profile, 53 in the + performance/stress profile, and four in the live MinIO profile. + - Status: Complete on 2026-07-18; provisioned live-service execution remains T002. + - Evidence mode: implementation + - [x] T001.1 Capture complete, normal, MinIO, performance, and stress collections and failing-run evidence. + - Evidence: Full collection found 2,812 nodes; selector counts were 2,755, + 53, and four respectively. GitHub Actions run 29653160911 recorded the + original one failure and four setup errors. + - [x] T001.2 Register `minio` and mark only tests that contact the live service. + - Evidence: `pyproject.toml` registers `minio`; contract test + `test_only_live_service_tests_use_minio_marker` passed for the four named + live-service nodes. + - [x] T001.3 Move configuration and network access from import/collection into fixtures or runtime preflight. + - Evidence: Clean-environment collection reported `4/2812`; runtime fixtures + at `tests/TimeLocker/integration/test_s3_minio.py:45` and line 58 load + settings and perform reachability checks. + - [x] T001.4 Prove mocked MinIO contract tests remain in normal CI and collection nodes are not lost. + - Evidence: `test_mocked_minio_contracts_remain_in_normal_profile` passed; + the focused profile produced nine successful tests, and the full profile + produced 2,754 successful tests at 52.13% coverage. - [ ] T002 Add and validate the provisioned MinIO profile. - Depends on: T001 diff --git a/docs/specs/007-release-readiness-stabilization/verification.md b/docs/specs/007-release-readiness-stabilization/verification.md index 9f9a6c4..1fd2a04 100644 --- a/docs/specs/007-release-readiness-stabilization/verification.md +++ b/docs/specs/007-release-readiness-stabilization/verification.md @@ -21,8 +21,8 @@ separate explicit approval. |------|-----------|--------|----------| | Acceptance traceability complete | yes | passed | Lifecycle stage readiness reports zero acceptance, property, context, downstream-review, or blocking gaps after reconciliation. | | Substantive requirements and design review | yes | passed | Review on 2026-07-18 produced TLR-001 through TLR-006; all six findings were reconciled into the package before implementation. | -| Task evidence complete | yes | pending | T001-T013 pending. | -| Normal and dependency-owning test profiles pass | yes | pending | Current normal run 29653160911 fails on unavailable MinIO. | +| Task evidence complete | yes | pending | T001 passed; T002-T013 pending. | +| Normal and dependency-owning test profiles pass | yes | partial | The corrected normal profile passes locally; provisioned MinIO execution remains T002. | | Stress implementation and disposition recorded | yes | pending | T004 and GitHub issue #68. | | Artifacts and six-combination clean installs validate | yes | pending | T006-T008. | | Release interface and rehearsal prove no publication side effect | yes | pending | T009-T010. | @@ -33,8 +33,8 @@ separate explicit approval. | Command Or Method | Purpose | Result | Evidence | |-------------------|---------|--------|----------| -| `python -m pytest -m "not performance and not stress and not minio"` | Normal correctness and coverage profile | pending | Replaces current failing selector in T001. | -| complete collection compared with normal, `minio`, performance, and stress selections | Prove collection safety and node ownership | pending | T001-T003. | +| `python -m pytest -m "not performance and not stress and not minio"` | Normal correctness and coverage profile | passed | 2,754 passed, one skipped, 57 deselected; 52.13% coverage. | +| complete collection compared with normal, `minio`, performance, and stress selections | Prove collection safety and node ownership | passed | 2,812 total nodes partition into 2,755 normal, 53 performance/stress, and four live MinIO nodes. | | `python -m pytest -m minio` with provisioned service | Validate live S3 integration and dependency preflight | pending | T002. | | `python -m pytest -m "performance or stress" --no-cov` | Extended performance and stress profile | pending | T004 and issue #68; final evidence must explain the coverage exception for this opt-in profile. | | `python scripts/bump_version.py bump patch --no-commit --no-tag` plus pre/post commit, tag, tag-triggered release-workflow run, and release identity | Prepare `0.9.1` without publication side effects | pending | T006. | @@ -47,7 +47,7 @@ separate explicit approval. | Requirement | Acceptance Criteria Covered | Evidence | Residual Risk | |-------------|------------------------------|----------|---------------| -| R1 | AC1-AC6 | T001-T003 pending; failing run captured | Marker or collection drift could hide tests. | +| R1 | AC1-AC6 | T001 passed; T002-T003 pending | Provisioned MinIO and hosted workflow evidence remain. | | R2 | AC1-AC4 | Spec-owned T004-T005 and issue #68 pending | Host variance. | | R3 | AC1-AC5 | T006 and T008 pending | Side-effecting defaults must remain disabled. | | R4 | AC1-AC5 | T007-T008 and T011 pending | Unavailable runner blocks the associated support claim. | @@ -57,7 +57,7 @@ separate explicit approval. | Property | Covered By | Evidence | Residual Risk | |----------|------------|----------|---------------| -| CP-001 | T001-T003, collection partition and workflow runs | pending | Marker drift. | +| CP-001 | T001-T003, collection partition and workflow runs | partial | Local partition proved; provisioned and hosted runs remain. | | CP-002 | T006 version guard and negative test | pending | None expected after automated guard. | | CP-003 | T007 six-combination artifact matrix | pending | Runner availability is a blocking support gap. | | CP-004 | T006, T008-T010, and T013 external-state comparisons | pending | Actual tag behavior remains separately controlled. | @@ -79,7 +79,7 @@ separate explicit approval. | Task ID | Status | Evidence | Notes | |---------|--------|----------|-------| -| T001 | pending | Failing CI root cause captured | Live-versus-mocked classification and collection safety pending. | +| T001 | passed | Exact node partition, focused contract tests, and normal-profile run passed | Four live nodes are `minio`; mocked/configuration tests remain normal. | | T002 | pending | | Provisioned profile pending. | | T003 | pending | | CI checkpoint pending. | | T004 | pending | GitHub issue #68 created and assigned | Spec owns implementation; issue tracks state and evidence. | @@ -106,6 +106,9 @@ separate explicit approval. | 2026-07-18 | Downstream task and verification review | passed | Tasks and verification were rechecked after the final requirements and design reconciliation, including the changelog-derived communications model. | | 2026-07-18 | Spec Lifecycle Manager package checks | passed | Package lint has zero diagnostics; stage readiness is implementation-ready with zero gaps; sampled T001, T004, T006, T007, T009, and T013 lookups and T001 readiness resolve without gaps. | | 2026-07-18 | Documentation and patch checks | passed with advisory warnings | No structural Markdown findings, broken links, or whitespace errors; 135 table-readability warnings and 25 pre-existing canonical-link style suggestions remain non-blocking. | +| 2026-07-18 | T001 focused MinIO profile tests | passed | Nine normal-profile contract/configuration tests passed and four live nodes were deselected without using repository MinIO configuration. | +| 2026-07-18 | T001 collection partition | passed | All 2,812 nodes accounted for: 2,755 normal, 53 performance/stress, and four live MinIO. | +| 2026-07-18 | T001 exact normal profile | passed | 2,754 passed, one skipped, 57 deselected, 19 warnings, and 52.13% coverage in 783.96 seconds. | ## Manual Or External Verification @@ -116,9 +119,10 @@ release artifacts must be linked here before release readiness can be approved. ## Residual Risks -- Normal CI is currently red and blocks every downstream release claim. -- MinIO marker or collection changes can reduce coverage unless the complete - node partition and mocked-contract placement are proved. +- The corrected normal profile passes locally, but hosted CI and the provisioned + MinIO profile remain unproved until T002-T003. +- Future marker drift could change profile ownership; the T001 contract test + guards the intended four live nodes and mocked-test placement. - Stress thresholds can remain host-sensitive until T004 evidence is accepted. - Version tooling commits and tags by default; every preparation run must use both disabling flags and prove external state is unchanged. @@ -142,7 +146,7 @@ release artifacts must be linked here before release readiness can be approved. ### Spec Cleanup Decision - **Cleanup action:** keep active -- **Reason:** Implementation and release preparation have not started. +- **Reason:** Implementation is active; T001 is complete and T002-T013 remain. - **Final spec commit:** pending - **Closure log path:** `docs/history/spec-closure-log.md` - **Closure log entry updated:** no @@ -163,7 +167,8 @@ release artifacts must be linked here before release readiness can be approved. ### Risk Rationale -Normal CI currently fails, the tag-triggered release workflow has no repository +The corrected normal profile passes locally, but provisioned and hosted profile +evidence is incomplete, the tag-triggered release workflow has no repository release history, and artifact or clean-install evidence for `0.9.1` does not exist. No release should proceed until the required gates are complete. diff --git a/pyproject.toml b/pyproject.toml index 5df977f..1920646 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,6 +139,7 @@ markers = [ "critical: Critical path tests that must pass", "slow: Tests that take a long time to run", "network: Tests that require network access", + "minio: Tests that contact a live, explicitly provisioned MinIO service", "filesystem: Tests that interact with the filesystem", "backup: Tests related to backup operations", "restore: Tests related to restore operations", diff --git a/tests/TimeLocker/integration/test_minio_connection.py b/tests/TimeLocker/integration/test_minio_connection.py index d35f169..29bb566 100644 --- a/tests/TimeLocker/integration/test_minio_connection.py +++ b/tests/TimeLocker/integration/test_minio_connection.py @@ -20,7 +20,6 @@ from __future__ import annotations import os -from pathlib import Path from typing import Dict, Tuple import pytest @@ -30,29 +29,26 @@ from TimeLocker.restic.Repositories.s3 import S3ResticRepository from TimeLocker.security.credential_manager import CredentialManager -from .minio_test_utils import load_minio_settings, ensure_minio_reachable +from . import minio_test_utils DEFAULT_BUCKET = "timelocker-test" DEFAULT_REGION = "us-east-1" -def _sanitize_endpoint(endpoint: str) -> str: - parsed = urlparse(endpoint) - return f"{parsed.scheme}://{parsed.netloc}{parsed.path}" if parsed.scheme else endpoint - - def _get_minio_settings() -> Tuple[str, str, str, str, str, bool]: - settings, missing = load_minio_settings(require_credentials=True) + settings, missing = minio_test_utils.load_minio_settings(require_credentials=True) if missing: pytest.fail( - "MinIO connectivity tests missing configuration for " + ", ".join(missing) + "MinIO connectivity tests missing configuration for " + ", ".join(missing) ) endpoint = settings["MINIO_ENDPOINT_URL"] access_key = settings["MINIO_ACCESS_KEY"] secret_key = settings["MINIO_SECRET_KEY"] bucket = settings.get("MINIO_BUCKET", DEFAULT_BUCKET) - region = settings.get("MINIO_REGION", os.getenv("AWS_DEFAULT_REGION", DEFAULT_REGION)) + region = settings.get( + "MINIO_REGION", os.getenv("AWS_DEFAULT_REGION", DEFAULT_REGION) + ) verify_value = str(settings.get("MINIO_VERIFY_SSL", "true")).lower() verify_ssl = verify_value not in {"0", "false", "no"} @@ -62,12 +58,12 @@ def _get_minio_settings() -> Tuple[str, str, str, str, str, bool]: @pytest.fixture(scope="session") def minio_settings() -> Tuple[str, str, str, str, str, bool]: sample = { - "MINIO_ENDPOINT_URL": "https://mock-minio.local:9000", - "MINIO_ACCESS_KEY": "mock-access", - "MINIO_SECRET_KEY": "mock-secret", - "MINIO_BUCKET": DEFAULT_BUCKET, - "MINIO_REGION": DEFAULT_REGION, - "MINIO_VERIFY_SSL": "true" + "MINIO_ENDPOINT_URL": "https://mock-minio.local:9000", + "MINIO_ACCESS_KEY": "mock-access", + "MINIO_SECRET_KEY": "mock-secret", + "MINIO_BUCKET": DEFAULT_BUCKET, + "MINIO_REGION": DEFAULT_REGION, + "MINIO_VERIFY_SSL": "true", } mp = pytest.MonkeyPatch() @@ -76,8 +72,8 @@ def _fake_loader(require_credentials: bool = True): return sample, [] mp.setattr( - 'tests.TimeLocker.integration.minio_test_utils.load_minio_settings', - _fake_loader + "tests.TimeLocker.integration.minio_test_utils.load_minio_settings", + _fake_loader, ) try: return _get_minio_settings() @@ -103,11 +99,15 @@ def unlock(self, _password: str) -> bool: self._locked = False return True - def store_repository_backend_credentials(self, repo: str, backend: str, payload: Dict[str, str]) -> bool: + def store_repository_backend_credentials( + self, repo: str, backend: str, payload: Dict[str, str] + ) -> bool: self._store[(repo, backend)] = payload return True - def get_repository_backend_credentials(self, repo: str, backend: str) -> Dict[str, str] | None: + def get_repository_backend_credentials( + self, repo: str, backend: str + ) -> Dict[str, str] | None: return self._store.get((repo, backend)) @@ -117,16 +117,16 @@ def temp_credential_manager() -> CredentialManager: @pytest.fixture() -def repository(minio_settings) -> S3ResticRepository: +def repository(minio_settings, monkeypatch: pytest.MonkeyPatch) -> S3ResticRepository: endpoint, access_key, secret_key, bucket, _, _ = minio_settings host = urlparse(endpoint).netloc or endpoint location = f"s3:{host}/{bucket}" - os.environ["AWS_S3_ENDPOINT"] = endpoint + monkeypatch.setenv("AWS_S3_ENDPOINT", endpoint) return S3ResticRepository( - location=location, - password="test-password-123", - aws_access_key_id=access_key, - aws_secret_access_key=secret_key, + location=location, + password="test-password-123", + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, ) @@ -139,15 +139,21 @@ def _fake_ensure(*_args, **_kwargs): return client monkeypatch.setattr( - 'tests.TimeLocker.integration.minio_test_utils.ensure_minio_reachable', - _fake_ensure + "tests.TimeLocker.integration.minio_test_utils.ensure_minio_reachable", + _fake_ensure, ) return client def test_boto3_lists_buckets(minio_settings, mock_minio_client): endpoint, access_key, secret_key, _, region, verify_ssl = minio_settings - client = ensure_minio_reachable(endpoint, access_key, secret_key, region, verify_ssl) + client = minio_test_utils.ensure_minio_reachable( + endpoint, + access_key, + secret_key, + region, + verify_ssl, + ) response = client.list_buckets() assert isinstance(response.get("Buckets", []), list) @@ -156,11 +162,15 @@ def test_credential_manager_roundtrip(minio_settings, temp_credential_manager): _, access_key, secret_key, _, _, _ = minio_settings repo_name = "minio-test" payload: Dict[str, str] = { - "access_key_id": access_key, - "secret_access_key": secret_key, + "access_key_id": access_key, + "secret_access_key": secret_key, } - temp_credential_manager.store_repository_backend_credentials(repo_name, "s3", payload) - retrieved = temp_credential_manager.get_repository_backend_credentials(repo_name, "s3") + temp_credential_manager.store_repository_backend_credentials( + repo_name, "s3", payload + ) + retrieved = temp_credential_manager.get_repository_backend_credentials( + repo_name, "s3" + ) assert retrieved == payload diff --git a/tests/TimeLocker/integration/test_minio_profile_contract.py b/tests/TimeLocker/integration/test_minio_profile_contract.py new file mode 100644 index 0000000..ed3e99f --- /dev/null +++ b/tests/TimeLocker/integration/test_minio_profile_contract.py @@ -0,0 +1,51 @@ +"""Regression tests for normal-versus-live MinIO test ownership.""" + +from pathlib import Path + +from tests.TimeLocker.integration import test_minio_connection, test_s3_minio + + +LIVE_TESTS = { + "test_s3_repository_init_and_check", + "test_s3_backup_and_restore", + "test_s3_multiple_backups", + "test_s3_repository_stats", +} + + +def _marker_names(test_function) -> set[str]: + return {marker.name for marker in getattr(test_function, "pytestmark", [])} + + +def test_only_live_service_tests_use_minio_marker(): + collected_tests = { + name: value + for name, value in vars(test_s3_minio).items() + if name.startswith("test_") and callable(value) + } + + assert LIVE_TESTS <= collected_tests.keys() + for name, test_function in collected_tests.items(): + marker_names = _marker_names(test_function) + assert ("minio" in marker_names) is (name in LIVE_TESTS) + assert ("network" in marker_names) is (name in LIVE_TESTS) + + +def test_mocked_minio_contracts_remain_in_normal_profile(): + mocked_tests = [ + value + for name, value in vars(test_minio_connection).items() + if name.startswith("test_") and callable(value) + ] + + assert mocked_tests + assert all("minio" not in _marker_names(test) for test in mocked_tests) + + +def test_normal_ci_explicitly_excludes_live_minio_profile(): + repo_root = Path(__file__).resolve().parents[3] + workflow = (repo_root / ".github/workflows/test-suite.yml").read_text() + + assert ( + 'python -m pytest -m "not performance and not stress and not minio"' in workflow + ) diff --git a/tests/TimeLocker/integration/test_s3_minio.py b/tests/TimeLocker/integration/test_s3_minio.py index 14be78f..86a55ff 100644 --- a/tests/TimeLocker/integration/test_s3_minio.py +++ b/tests/TimeLocker/integration/test_s3_minio.py @@ -15,7 +15,6 @@ along with this program. If not, see . """ -import os import shutil import tempfile from pathlib import Path @@ -27,47 +26,64 @@ from TimeLocker.file_selections import FileSelection, SelectionType from TimeLocker.restic.Repositories.s3 import S3ResticRepository from TimeLocker.restic.restic_repository import RepositoryError -from .minio_test_utils import load_minio_settings, ensure_minio_reachable +from .minio_test_utils import ensure_minio_reachable, load_minio_settings -_MINIO_SETTINGS, _MISSING_KEYS = load_minio_settings(require_credentials=True) +pytestmark = pytest.mark.integration -if _MISSING_KEYS: - missing_list = ", ".join(_MISSING_KEYS) - raise RuntimeError( - f"MinIO integration tests cannot run: missing configuration for {missing_list}. " - f"Set environment variables or update your test-config.json." - ) +SYNTHETIC_ENDPOINT_URL = "https://minio.invalid:9000" +SYNTHETIC_ACCESS_KEY = "test-access" +SYNTHETIC_SECRET_KEY = "test-secret" +SYNTHETIC_BUCKET = "timelocker-test" +SYNTHETIC_REGION = "us-east-1" + + +def _verify_ssl(settings: dict[str, str]) -> bool: + value = str(settings.get("MINIO_VERIFY_SSL", "true")).lower() + return value not in {"0", "false", "no"} -MINIO_ENDPOINT_HOST = _MINIO_SETTINGS["MINIO_ENDPOINT_HOST"] -MINIO_ENDPOINT_URL = _MINIO_SETTINGS["MINIO_ENDPOINT_URL"] -MINIO_ACCESS_KEY = _MINIO_SETTINGS["MINIO_ACCESS_KEY"] -MINIO_SECRET_KEY = _MINIO_SETTINGS["MINIO_SECRET_KEY"] -MINIO_BUCKET = _MINIO_SETTINGS["MINIO_BUCKET"] -MINIO_REGION = _MINIO_SETTINGS["MINIO_REGION"] -MINIO_URI_PREFIX = _MINIO_SETTINGS["MINIO_URI_PREFIX"] -MINIO_VERIFY_SSL_VALUE = str(_MINIO_SETTINGS.get("MINIO_VERIFY_SSL", "true")).lower() -MINIO_VERIFY_SSL = MINIO_VERIFY_SSL_VALUE not in {"0", "false", "no"} + +@pytest.fixture(scope="session") +def minio_settings() -> dict[str, str]: + """Load and validate live MinIO settings when a live test starts.""" + settings, missing = load_minio_settings(require_credentials=True) + if missing: + pytest.fail( + "MinIO profile dependency error: missing configuration for " + + ", ".join(missing) + + ". Set environment variables or provide a test configuration." + ) + return settings @pytest.fixture(scope="session") -def minio_available() -> bool: +def minio_available(minio_settings: dict[str, str]) -> bool: """ Check if MinIO is available for testing. This is a session-scoped fixture to avoid repeated connection attempts. - Returns True if MinIO is available, otherwise skips all tests that depend on it. + Returns True if MinIO is available; otherwise fails with a dependency error. """ try: - ensure_minio_reachable(MINIO_ENDPOINT_URL, MINIO_ACCESS_KEY, MINIO_SECRET_KEY, MINIO_REGION, MINIO_VERIFY_SSL) + ensure_minio_reachable( + minio_settings["MINIO_ENDPOINT_URL"], + minio_settings["MINIO_ACCESS_KEY"], + minio_settings["MINIO_SECRET_KEY"], + minio_settings["MINIO_REGION"], + _verify_ssl(minio_settings), + ) return True except Exception as e: - raise RuntimeError(f"MinIO not available: {e}") + pytest.fail(f"MinIO profile dependency error: service is unavailable: {e}") @pytest.fixture -def test_repo_path() -> Generator[str, None, None]: +def test_repo_path( + minio_settings: dict[str, str], + minio_available: bool, +) -> Generator[str, None, None]: """Create a unique test repository path in MinIO bucket.""" import uuid + test_id = str(uuid.uuid4())[:8] repo_path = f"test-repo-{test_id}" yield repo_path @@ -75,25 +91,27 @@ def test_repo_path() -> Generator[str, None, None]: # Cleanup: Remove test repository from MinIO try: import boto3 - verify = MINIO_VERIFY_SSL + s3_client = boto3.client( - 's3', - endpoint_url=MINIO_ENDPOINT_URL, - aws_access_key_id=MINIO_ACCESS_KEY, - aws_secret_access_key=MINIO_SECRET_KEY, - region_name=MINIO_REGION, - verify=verify, + "s3", + endpoint_url=minio_settings["MINIO_ENDPOINT_URL"], + aws_access_key_id=minio_settings["MINIO_ACCESS_KEY"], + aws_secret_access_key=minio_settings["MINIO_SECRET_KEY"], + region_name=minio_settings["MINIO_REGION"], + verify=_verify_ssl(minio_settings), ) # List and delete all objects in the test path - paginator = s3_client.get_paginator('list_objects_v2') - for page in paginator.paginate(Bucket=MINIO_BUCKET, Prefix=repo_path): - if 'Contents' in page: - objects = [{'Key': obj['Key']} for obj in page['Contents']] + paginator = s3_client.get_paginator("list_objects_v2") + for page in paginator.paginate( + Bucket=minio_settings["MINIO_BUCKET"], Prefix=repo_path + ): + if "Contents" in page: + objects = [{"Key": obj["Key"]} for obj in page["Contents"]] if objects: s3_client.delete_objects( - Bucket=MINIO_BUCKET, - Delete={'Objects': objects} + Bucket=minio_settings["MINIO_BUCKET"], + Delete={"Objects": objects}, ) except Exception as e: print(f"Warning: Failed to cleanup test repository: {e}") @@ -119,21 +137,41 @@ def temp_backup_source() -> Generator[Path, None, None]: @pytest.fixture -def s3_repository(test_repo_path: str, minio_available: bool) -> S3ResticRepository: - """Create an S3 repository instance configured for MinIO.""" - # MinIO location format - location = f"{MINIO_URI_PREFIX}/{MINIO_BUCKET}/{test_repo_path}" +def s3_repository(monkeypatch: pytest.MonkeyPatch) -> S3ResticRepository: + """Create a repository for configuration-only tests without live I/O.""" + location = f"s3:minio.invalid:9000/{SYNTHETIC_BUCKET}/configuration-test" + monkeypatch.setenv("AWS_S3_ENDPOINT", SYNTHETIC_ENDPOINT_URL) repo = S3ResticRepository( - location=location, - password="test-password-123", - aws_access_key_id=MINIO_ACCESS_KEY, - aws_secret_access_key=MINIO_SECRET_KEY, - aws_default_region=MINIO_REGION + location=location, + password="test-password-123", + aws_access_key_id=SYNTHETIC_ACCESS_KEY, + aws_secret_access_key=SYNTHETIC_SECRET_KEY, + aws_default_region=SYNTHETIC_REGION, + ) + return repo + + +@pytest.fixture +def live_s3_repository( + test_repo_path: str, + minio_settings: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> S3ResticRepository: + """Create an S3 repository backed by the provisioned MinIO service.""" + location = ( + f"{minio_settings['MINIO_URI_PREFIX']}/" + f"{minio_settings['MINIO_BUCKET']}/{test_repo_path}" ) + monkeypatch.setenv("AWS_S3_ENDPOINT", minio_settings["MINIO_ENDPOINT_URL"]) - # Set MinIO endpoint in environment for restic - os.environ['AWS_S3_ENDPOINT'] = MINIO_ENDPOINT_URL + repo = S3ResticRepository( + location=location, + password="test-password-123", + aws_access_key_id=minio_settings["MINIO_ACCESS_KEY"], + aws_secret_access_key=minio_settings["MINIO_SECRET_KEY"], + aws_default_region=minio_settings["MINIO_REGION"], + ) return repo @@ -144,62 +182,59 @@ def _make_backup_target(path: Path, *tags: str) -> BackupTarget: return BackupTarget(selection=selection, tags=list(tags)) -@pytest.mark.integration -@pytest.mark.network def test_s3_repository_initialization(s3_repository: S3ResticRepository): """Test S3 repository initialization with MinIO.""" assert s3_repository is not None - assert s3_repository.aws_access_key_id == MINIO_ACCESS_KEY - assert s3_repository.aws_secret_access_key == MINIO_SECRET_KEY - assert s3_repository.aws_default_region == MINIO_REGION + assert s3_repository.aws_access_key_id == SYNTHETIC_ACCESS_KEY + assert s3_repository.aws_secret_access_key == SYNTHETIC_SECRET_KEY + assert s3_repository.aws_default_region == SYNTHETIC_REGION -@pytest.mark.integration -@pytest.mark.network def test_s3_backend_env(s3_repository: S3ResticRepository): """Test that backend environment variables are correctly set.""" env = s3_repository.backend_env() assert "AWS_ACCESS_KEY_ID" in env - assert env["AWS_ACCESS_KEY_ID"] == MINIO_ACCESS_KEY + assert env["AWS_ACCESS_KEY_ID"] == SYNTHETIC_ACCESS_KEY assert "AWS_SECRET_ACCESS_KEY" in env - assert env["AWS_SECRET_ACCESS_KEY"] == MINIO_SECRET_KEY + assert env["AWS_SECRET_ACCESS_KEY"] == SYNTHETIC_SECRET_KEY assert "AWS_DEFAULT_REGION" in env - assert env["AWS_DEFAULT_REGION"] == MINIO_REGION + assert env["AWS_DEFAULT_REGION"] == SYNTHETIC_REGION -@pytest.mark.integration @pytest.mark.network -def test_s3_repository_init_and_check(s3_repository: S3ResticRepository): +@pytest.mark.minio +def test_s3_repository_init_and_check(live_s3_repository: S3ResticRepository): """Test initializing a repository in MinIO and checking it.""" - assert s3_repository.initialize() is True - assert s3_repository.is_repository_initialized() - assert s3_repository.check() is True + assert live_s3_repository.initialize() is True + assert live_s3_repository.is_repository_initialized() + assert live_s3_repository.check() is True -@pytest.mark.integration @pytest.mark.network +@pytest.mark.minio def test_s3_backup_and_restore( - s3_repository: S3ResticRepository, - temp_backup_source: Path + live_s3_repository: S3ResticRepository, temp_backup_source: Path ): """Test complete backup and restore workflow with MinIO.""" - s3_repository.initialize() + live_s3_repository.initialize() target = _make_backup_target(temp_backup_source, "test", "integration") - backup_result = s3_repository.backup_target([target]) + backup_result = live_s3_repository.backup_target([target]) assert backup_result is not None - snapshots = s3_repository.snapshots() + snapshots = live_s3_repository.snapshots() assert snapshots, "Expected at least one snapshot after backup" latest_snapshot = snapshots[0] restore_dir = Path(tempfile.mkdtemp(prefix="timelocker_restore_")) try: - s3_repository.restore(latest_snapshot.id, restore_dir) + live_s3_repository.restore(latest_snapshot.id, restore_dir) def _find_file(name: str) -> Path: match = next((candidate for candidate in restore_dir.rglob(name)), None) - assert match is not None, f"Expected restored file '{name}' not found under {restore_dir}" + assert ( + match is not None + ), f"Expected restored file '{name}' not found under {restore_dir}" return match restored_file1 = _find_file("file1.txt") @@ -214,51 +249,61 @@ def _find_file(name: str) -> Path: shutil.rmtree(restore_dir, ignore_errors=True) -@pytest.mark.integration @pytest.mark.network +@pytest.mark.minio def test_s3_multiple_backups( - s3_repository: S3ResticRepository, - temp_backup_source: Path + live_s3_repository: S3ResticRepository, temp_backup_source: Path ): """Test multiple backups to track incremental changes.""" - s3_repository.initialize() - s3_repository.backup_target([_make_backup_target(temp_backup_source, "backup1")]) + live_s3_repository.initialize() + live_s3_repository.backup_target( + [_make_backup_target(temp_backup_source, "backup1")] + ) (temp_backup_source / "file1.txt").write_text("Modified content 1") (temp_backup_source / "new_file.txt").write_text("New file content") - s3_repository.backup_target([_make_backup_target(temp_backup_source, "backup2")]) + live_s3_repository.backup_target( + [_make_backup_target(temp_backup_source, "backup2")] + ) - snapshots = s3_repository.snapshots() + snapshots = live_s3_repository.snapshots() assert len(snapshots) >= 2 - tags_found = {tag for snapshot in snapshots for tag in getattr(snapshot, "tags", []) or []} + tags_found = { + tag for snapshot in snapshots for tag in getattr(snapshot, "tags", []) or [] + } assert {"backup1", "backup2"} & tags_found -@pytest.mark.integration @pytest.mark.network -def test_s3_repository_stats(s3_repository: S3ResticRepository, temp_backup_source: Path): +@pytest.mark.minio +def test_s3_repository_stats( + live_s3_repository: S3ResticRepository, temp_backup_source: Path +): """Test retrieving repository statistics.""" - s3_repository.initialize() - s3_repository.backup_target([_make_backup_target(temp_backup_source)]) - stats = s3_repository.stats() + live_s3_repository.initialize() + live_s3_repository.backup_target([_make_backup_target(temp_backup_source)]) + stats = live_s3_repository.stats() assert isinstance(stats, dict) and stats -@pytest.mark.integration -@pytest.mark.network def test_s3_missing_credentials_error(monkeypatch): """Test that missing credentials raise appropriate errors.""" - for key in ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "MINIO_ACCESS_KEY", "MINIO_SECRET_KEY"): + for key in ( + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "MINIO_ACCESS_KEY", + "MINIO_SECRET_KEY", + ): monkeypatch.delenv(key, raising=False) - location = f"{MINIO_URI_PREFIX}/{MINIO_BUCKET}/test" + location = f"s3:minio.invalid:9000/{SYNTHETIC_BUCKET}/test" repo = S3ResticRepository( - location=location, - password="test-password" - # No credentials provided + location=location, + password="test-password", + # No credentials provided ) with pytest.raises(RepositoryError) as exc_info: From 5ec840935e8f88da68f181636dd5f7071efa63da Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:20:13 +0100 Subject: [PATCH 04/72] test(ci): provision MinIO integration profile Run live S3 tests against a pinned disposable MinIO service and make dependency failures actionable. Preserve explicit CI environment values, document profile ownership, and record the local Phase 1 evidence. --- .github/workflows/test-suite.yml | 97 ++++- docs/4-testing/README.md | 75 ++-- docs/4-testing/guide-minio-testing.md | 402 ++++-------------- .../change-impact.md | 2 +- .../tasks.md | 37 +- .../verification.md | 22 +- .../integration/minio_test_utils.py | 84 ++-- .../test_minio_profile_contract.py | 46 ++ tests/TimeLocker/integration/test_s3_minio.py | 5 +- .../project/test_pytest_environment.py | 20 + tests/conftest.py | 19 +- 11 files changed, 402 insertions(+), 407 deletions(-) create mode 100644 tests/TimeLocker/project/test_pytest_environment.py diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index ca146e4..df45603 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -89,6 +89,95 @@ jobs: .coverage coverage.xml + minio-test: + runs-on: ubuntu-latest + permissions: + contents: read + env: + PYTHONPATH: src + MINIO_ENDPOINT_URL: http://127.0.0.1:9000 + AWS_S3_ENDPOINT: http://127.0.0.1:9000 + MINIO_ACCESS_KEY: timelocker-ci + MINIO_SECRET_KEY: timelocker-ci-secret + MINIO_BUCKET: timelocker-test + MINIO_REGION: us-east-1 + MINIO_VERIFY_SSL: "false" + AWS_DEFAULT_REGION: us-east-1 + TIMELOCKER_LOG_LEVEL: INFO + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Restic + run: | + RESTIC_VERSION="0.18.0" + wget https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2 + bunzip2 restic_${RESTIC_VERSION}_linux_amd64.bz2 + sudo mv restic_${RESTIC_VERSION}_linux_amd64 /usr/local/bin/restic + sudo chmod +x /usr/local/bin/restic + restic version + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[dev] + + - name: Start ephemeral MinIO + run: | + docker run --detach --rm \ + --name timelocker-minio \ + --publish 127.0.0.1:9000:9000 \ + --env MINIO_ROOT_USER="$MINIO_ACCESS_KEY" \ + --env MINIO_ROOT_PASSWORD="$MINIO_SECRET_KEY" \ + quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z \ + server /data --address :9000 + + - name: Wait for MinIO and create test bucket + run: | + for attempt in {1..30}; do + if curl --fail --silent "$MINIO_ENDPOINT_URL/minio/health/live" >/dev/null; then + break + fi + if [ "$attempt" -eq 30 ]; then + echo "::error::MinIO profile dependency error: service did not become ready within 30 seconds" + docker logs timelocker-minio + exit 1 + fi + sleep 1 + done + python - <<'PY' + import os + + import boto3 + from botocore.config import Config + + client = boto3.client( + "s3", + endpoint_url=os.environ["MINIO_ENDPOINT_URL"], + aws_access_key_id=os.environ["MINIO_ACCESS_KEY"], + aws_secret_access_key=os.environ["MINIO_SECRET_KEY"], + region_name=os.environ["MINIO_REGION"], + config=Config(s3={"addressing_style": "path"}), + ) + bucket = os.environ["MINIO_BUCKET"] + existing = {item["Name"] for item in client.list_buckets()["Buckets"]} + if bucket not in existing: + client.create_bucket(Bucket=bucket) + print(f"MinIO dependency ready; bucket '{bucket}' is available") + PY + + - name: Run live MinIO profile + run: python -m pytest -m minio --no-cov + + - name: Stop ephemeral MinIO + if: always() + run: docker rm --force timelocker-minio || true + extended-test: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest @@ -115,7 +204,7 @@ jobs: quality-gate: runs-on: ubuntu-latest - needs: [test] + needs: [test, minio-test] permissions: contents: read pull-requests: write @@ -221,18 +310,18 @@ jobs: notify: runs-on: ubuntu-latest - needs: [test, quality-gate] + needs: [test, minio-test, quality-gate] if: always() permissions: contents: read steps: - name: Notify on success - if: needs.test.result == 'success' && needs.quality-gate.result == 'success' + if: needs.test.result == 'success' && needs.minio-test.result == 'success' && needs.quality-gate.result == 'success' run: | echo "✅ All tests passed and quality gates met!" - name: Notify on failure - if: needs.test.result == 'failure' || needs.quality-gate.result == 'failure' + if: needs.test.result != 'success' || needs.minio-test.result != 'success' || needs.quality-gate.result != 'success' run: | echo "❌ Tests failed or quality gates not met!" exit 1 diff --git a/docs/4-testing/README.md b/docs/4-testing/README.md index d5f7bf6..2367f58 100644 --- a/docs/4-testing/README.md +++ b/docs/4-testing/README.md @@ -1,5 +1,5 @@ --- -title: "Testing Documentation" +title: Testing documentation doc_type: reference id: "RM-006" type: [ readme ] @@ -13,44 +13,61 @@ links: # Testing Documentation -- **Owner**: Auriora Team -- **Status**: Approved -- **Created Date**: 27-10-2023 -- **Last Updated**: 2025-11-07 +This area contains current test commands, dependency profiles, and quality-gate +guidance. Point-in-time results belong in CI artifacts, commits, issues, or an +active specification package. -## 1. Purpose +## Test Profiles -**When to use this template**: This folder centralizes test strategies, coverage goals, QA playbooks, and release validation checklists. -**Location**: `docs/4-testing/` +### Normal correctness and coverage -## 2. What Belongs Here? +```bash +python -m pytest -m "not performance and not stress and not minio" +``` -- Test strategy documents and matrices. -- Manual/automated QA procedures. -- Stable test strategy and quality-gate guidance. +This is the default CI profile. It owns the configured 50 percent coverage +gate, includes mocked S3/MinIO contract tests, and does not contact a live +MinIO service. -## 3. What Does NOT Belong Here? +### Live MinIO integration -- Individual test logs (keep near CI artifacts). -- Implementation details (see `../3-implementation/`). -- Operational runbooks (see `../guides/`). +```bash +python -m pytest -m minio --no-cov +``` -## 4. Available Documents +The `minio` marker identifies only tests that contact a live MinIO service. +Before running this profile, provide these non-production inputs: -### Quick Start -- **[quickstart-testing.md](./quickstart-testing.md)** - Fast path for verifying environments and running tests +- `MINIO_ENDPOINT_URL`, such as `http://127.0.0.1:9000`; +- `MINIO_ACCESS_KEY` and `MINIO_SECRET_KEY`; +- `MINIO_BUCKET` and `MINIO_REGION`; +- `MINIO_VERIFY_SSL`, set to `false` only for a trusted local HTTP service. -### MinIO Testing -- **[guide-minio-testing.md](./guide-minio-testing.md)** - Complete MinIO testing guide -- **[checklist-minio-testing.md](./checklist-minio-testing.md)** - MinIO testing checklist -- **[summary-minio-setup.md](./summary-minio-setup.md)** - MinIO setup summary +The GitHub Actions MinIO job starts a pinned ephemeral container, waits up to +30 seconds for `/minio/health/live`, creates the test bucket, runs the profile, +and removes the container. Missing configuration or an unavailable service is +a dependency failure; it is never treated as a skip. Coverage is disabled for +this four-test profile because the normal profile owns the repository gate. -## 5. Available Templates +### Performance and stress -- Use the central [test-plan template](../templates/test-plan.md) for durable - test strategy, environments, gates, and residual risks. +```bash +python -m pytest -m "performance or stress" --no-cov +``` -## 6. References +This opt-in profile is intended for representative performance environments; +it does not own the correctness coverage gate. -- [Testing Quick Start](./quickstart-testing.md) - Start here for testing -- CI artifacts and Git history preserve point-in-time test results. +## Local MinIO + +Use a disposable MinIO instance or an explicitly approved shared test service. +Never use production credentials or a production bucket. The detailed +[MinIO testing guide](./guide-minio-testing.md) describes environment-based +configuration and manual troubleshooting. + +## Other Testing Documents + +- [Testing quick start](./quickstart-testing.md) +- [MinIO testing checklist](./checklist-minio-testing.md) +- [MinIO setup summary](./summary-minio-setup.md) +- [Test-plan template](../templates/test-plan.md) diff --git a/docs/4-testing/guide-minio-testing.md b/docs/4-testing/guide-minio-testing.md index fd3cb63..5996957 100644 --- a/docs/4-testing/guide-minio-testing.md +++ b/docs/4-testing/guide-minio-testing.md @@ -1,358 +1,120 @@ -# MinIO Testing Setup for TimeLocker +--- +title: MinIO integration testing +doc_type: guide +status: active +owner: Auriora Team +last_reviewed: 2026-07-18 +--- -This guide explains how to use the existing MinIO deployment at `minio.lan` for S3 integration testing with TimeLocker. +# MinIO Integration Testing -## Overview +Use this guide for the four live S3 integration tests marked `minio`. Mocked +credential, backend-environment, and protocol-contract tests belong to the +normal CI profile and do not require a service. -MinIO is an S3-compatible object storage server that allows you to test S3 functionality without needing AWS credentials or incurring cloud costs. +## Requirements -**This project uses an existing MinIO deployment (proxied behind Traefik) at:** +- Python 3.12 with `pip install -e .[dev]`; +- Restic 0.18.0 or later; +- an isolated MinIO service and disposable bucket; +- non-production credentials supplied through environment variables. -- **API**: `minio.lan` (port 80, proxied via Traefik) -- **Console**: `minio-console.local` (port 80, proxied via Traefik) - -## Prerequisites - -- Access to MinIO deployment at `minio.lan` -- Python 3.11+ with TimeLocker development dependencies -- boto3 installed (`pip install boto3`) -- `/etc/hosts` configured with MinIO hostnames - -## Quick Start - -### 1. Verify MinIO Access - -```bash -# Run the setup script to verify access -./scripts/setup_minio_test.sh -``` - -This will: - -- ✅ Check `/etc/hosts` for `minio.lan` entry -- ✅ Verify MinIO API is accessible -- ✅ Create `.env.test` configuration file -- ✅ Install boto3 if needed - -### 2. Verify /etc/hosts Configuration - -Ensure your `/etc/hosts` file contains entries for MinIO: - -```bash -# Check current entries -grep minio /etc/hosts -``` - -You should see something like: - -``` - minio.lan minio-console.local -``` - -If not present, contact your system administrator or add them if you have access. - -### 3. Access MinIO Console - -Open your browser and navigate to: - -- **Console URL**: http://minio-console.local -- **Username**: minioadmin (or your configured credentials) -- **Password**: minioadmin (or your configured credentials) - -Note: The console is proxied through Traefik on port 80, so no port number is needed. - -### 4. Create Test Bucket (if needed) - -If the `timelocker-test` bucket doesn't exist, create it via the console or CLI. - -### 5. Run Integration Tests - -```bash -# Activate virtual environment -source .venv/bin/activate - -# Run S3/MinIO integration tests -pytest tests/TimeLocker/integration/test_s3_minio.py -v -m "integration and network" - -# Run all integration tests -pytest tests/TimeLocker/integration/ -v -m integration -``` - -## Configuration - -### Environment Variables - -You can customize MinIO settings using environment variables: +Required variables: ```bash -export MINIO_ENDPOINT="minio.lan" -export MINIO_ACCESS_KEY="minioadmin" -export MINIO_SECRET_KEY="minioadmin" -export MINIO_BUCKET="timelocker-test" -export MINIO_REGION="us-east-1" +export MINIO_ENDPOINT_URL=http://127.0.0.1:9000 +export AWS_S3_ENDPOINT="$MINIO_ENDPOINT_URL" +export MINIO_ACCESS_KEY=timelocker-local +export MINIO_SECRET_KEY=timelocker-local-secret +export MINIO_BUCKET=timelocker-test +export MINIO_REGION=us-east-1 +export MINIO_VERIFY_SSL=false ``` -Note: No port number needed - MinIO is proxied through Traefik on port 80. - -Or use the `.env.test` file: +Use `MINIO_VERIFY_SSL=false` only for a trusted local HTTP service. Never use +production credentials or a production bucket. -```bash -# Copy and customize -cp .env.test.example .env.test +## Run the Profile -# Load environment -source .env.test -``` - -### Test Configuration File - -A sample test configuration is provided in `test-config.json`: +Start a disposable service: ```bash -# Use test configuration -export TIMELOCKER_CONFIG_FILE="./test-config.json" - -# Run TimeLocker CLI with test config -tl repos list +docker run --detach --rm \ + --name timelocker-minio \ + --publish 127.0.0.1:9000:9000 \ + --env MINIO_ROOT_USER="$MINIO_ACCESS_KEY" \ + --env MINIO_ROOT_PASSWORD="$MINIO_SECRET_KEY" \ + quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z \ + server /data --address :9000 ``` -## Testing Workflow - -### 1. Initialize Test Repository +Wait for the service and create the bucket before pytest: ```bash -# Load environment variables -source .env.test +curl --fail "$MINIO_ENDPOINT_URL/minio/health/live" +python - <<'PY' +import os -# Using TimeLocker CLI -tl repos add minio-test "s3:https://minio.lan/timelocker-test/my-repo" \ - --description "MinIO test repository" +import boto3 +from botocore.config import Config -# Initialize repository -tl repos init minio-test --password "test-password-123" +client = boto3.client( + "s3", + endpoint_url=os.environ["MINIO_ENDPOINT_URL"], + aws_access_key_id=os.environ["MINIO_ACCESS_KEY"], + aws_secret_access_key=os.environ["MINIO_SECRET_KEY"], + region_name=os.environ["MINIO_REGION"], + config=Config(s3={"addressing_style": "path"}), +) +bucket = os.environ["MINIO_BUCKET"] +existing = {item["Name"] for item in client.list_buckets()["Buckets"]} +if bucket not in existing: + client.create_bucket(Bucket=bucket) +PY +python -m pytest -m minio --no-cov ``` -### 2. Create Test Backup +Remove the disposable service: ```bash -# Create test data -mkdir -p /tmp/test-backup-source -echo "Test file 1" > /tmp/test-backup-source/file1.txt -echo "Test file 2" > /tmp/test-backup-source/file2.txt - -# Create data selection -tl selections create test-backup \ - --include '/tmp/test-backup-source/**' \ - --description "Test backup selection" - -# Run backup -tl backup create --selection test-backup --repository minio-test +docker rm --force timelocker-minio ``` -### 3. List Snapshots +The profile uses `--no-cov` because the normal correctness profile owns the +repository's 50 percent coverage gate. -```bash -# List all snapshots -tl snapshots list --repository minio-test +## Failure Contract -# Get snapshot details -tl snapshot show -``` - -### 4. Restore from Backup - -```bash -# Restore to directory -mkdir -p /tmp/test-restore -tl snapshot restore /tmp/test-restore - -# Verify restored files -ls -la /tmp/test-restore -``` - -## Integration Test Details +Collection does not load MinIO configuration or contact the network. The live +profile loads its settings at runtime and fails with +`MinIO profile dependency error` when configuration is missing or the service +is unavailable. A missing service is not a skip. -The integration tests in `tests/TimeLocker/integration/test_s3_minio.py` cover: +If readiness fails: -1. **Repository Initialization**: Creating and initializing S3 repositories -2. **Backup Operations**: Creating backups with real data -3. **Restore Operations**: Restoring files from snapshots -4. **Snapshot Management**: Listing and managing snapshots -5. **Incremental Backups**: Testing deduplication and incremental changes -6. **Error Handling**: Testing credential errors and edge cases +1. confirm `curl --fail "$MINIO_ENDPOINT_URL/minio/health/live"` succeeds; +2. confirm all required variables are exported in the pytest process; +3. confirm the bucket exists and credentials can list it; +4. inspect `docker logs timelocker-minio` for service startup errors. -### Test Markers +Do not print credential values while troubleshooting. -Tests are marked with: +## GitHub Actions Ownership -- `@pytest.mark.integration` - Integration tests -- `@pytest.mark.network` - Tests requiring network access +The `minio-test` job in `.github/workflows/test-suite.yml` owns provisioning, +readiness, bucket creation, the live pytest selector, and container cleanup. The +normal job excludes `minio`, `performance`, and `stress`; the extended job +owns the latter two profiles. -### Running Specific Tests +## Related Commands ```bash -# Run only S3/MinIO tests -pytest tests/TimeLocker/integration/test_s3_minio.py -v +# Normal correctness and coverage +python -m pytest -m "not performance and not stress and not minio" -# Run specific test -pytest tests/TimeLocker/integration/test_s3_minio.py::test_s3_backup_and_restore -v +# Live service only +python -m pytest -m minio --no-cov -# Skip integration tests -pytest -m "not integration" +# Performance and stress +python -m pytest -m "performance or stress" --no-cov ``` - -## Optional: Local MinIO Deployment - -If you need to run your own local MinIO instance instead of using the shared deployment: - -```bash -# Start local MinIO using Docker Compose -docker-compose -f docker-compose.local.yml up -d - -# This will start MinIO on localhost:9000 -# Update your .env.test to use localhost instead of minio.lan -export MINIO_ENDPOINT="localhost:9000" -export AWS_S3_ENDPOINT="http://localhost:9000" -``` - -## Troubleshooting - -### Cannot Access minio.lan - -```bash -# Check /etc/hosts -grep minio /etc/hosts - -# Test DNS resolution -ping minio.lan - -# Test connection -curl https://minio.lan/minio/health/live -``` - -If you get "Could not resolve host", add to `/etc/hosts`: - -```bash - minio.lan minio-console.local -``` - -### Connection Refused - -Ensure MinIO is running and accessible: - -```bash -# Test connection (Traefik proxy on port 80) -curl https://minio.lan/minio/health/live - -# Check if Traefik is accessible -curl -I https://minio.lan - -# Check firewall -sudo ufw status -``` - -### Bucket Not Found - -The bucket should be created automatically. If not: - -```bash -# Use MinIO client to create bucket -docker run --rm --network timelocker-test \ - minio/mc alias set myminio http://minio:9000 minioadmin minioadmin - -docker run --rm --network timelocker-test \ - minio/mc mb myminio/timelocker-test -``` - -### Tests Skipped - -If tests are skipped with "MinIO not available": - -1. Verify MinIO is running: `docker-compose ps` -2. Check boto3 is installed: `pip install boto3` -3. Verify network connectivity: `curl http://localhost:9000` - -## Cleanup - -### Remove Test Data - -```bash -# Stop and remove containers -docker-compose down - -# Remove volumes (deletes all data) -docker-compose down -v - -# Remove test directories -rm -rf /tmp/test-backup-source /tmp/test-restore -``` - -### Reset MinIO - -```bash -# Complete reset -docker-compose down -v -docker volume rm timelocker_minio-data -docker-compose up -d -``` - -## Advanced Usage - -### Custom MinIO Configuration - -Edit `docker-compose.yml` to customize: - -```yaml -environment: - MINIO_ROOT_USER: custom-user - MINIO_ROOT_PASSWORD: custom-password - MINIO_REGION: eu-west-1 -``` - -### Multiple Buckets - -```bash -# Create additional buckets -docker run --rm --network timelocker-test \ - minio/mc mb myminio/another-bucket -``` - -### TLS/HTTPS Setup - -For testing with HTTPS, you'll need to: - -1. Generate certificates -2. Mount certificates in docker-compose.yml -3. Update MinIO command to use certificates -4. Update test configuration to use https:// - -See MinIO documentation for detailed TLS setup. - -## CI/CD Integration - -For GitHub Actions or other CI systems: - -```yaml -# .github/workflows/test.yml -services: - minio: - image: minio/minio - ports: - - 9000:9000 - env: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - options: >- - --health-cmd "curl -f http://localhost:9000/minio/health/live" - --health-interval 10s - --health-timeout 5s - --health-retries 5 -``` - -## Resources - -- [MinIO Documentation](https://min.io/docs/minio/linux/index.html) -- [MinIO Docker Hub](https://hub.docker.com/r/minio/minio) -- [Restic S3 Backend](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#amazon-s3) -- [boto3 Documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) - diff --git a/docs/specs/007-release-readiness-stabilization/change-impact.md b/docs/specs/007-release-readiness-stabilization/change-impact.md index c2d95cc..5006f99 100644 --- a/docs/specs/007-release-readiness-stabilization/change-impact.md +++ b/docs/specs/007-release-readiness-stabilization/change-impact.md @@ -50,7 +50,7 @@ bounded `v0.9.1` stabilization release. | Spec content | Durable destination | Promotion status | Notes | |--------------|---------------------|------------------|-------| -| Test profile contract and commands | `docs/4-testing/README.md` | pending | Include MinIO prerequisites and extended profile. | +| Test profile contract and commands | `docs/4-testing/README.md` | partial | T001-T002 promoted normal and MinIO ownership, prerequisites, and commands; T004 will add the extended-profile disposition. | | Verified install matrix and prerequisites | `docs/guides/user/installation.md` | pending | Do not claim untested platforms. | | Release procedure and rollback boundary | `docs/processes/version-management.md` | pending | Correct in place and link from `docs/processes/README.md`. | | Release contents and limitations | `CHANGELOG.md` | pending | Canonical checked-in source; derive the GitHub release body from the `v0.9.1` section. | diff --git a/docs/specs/007-release-readiness-stabilization/tasks.md b/docs/specs/007-release-readiness-stabilization/tasks.md index f31f0b8..72174ff 100644 --- a/docs/specs/007-release-readiness-stabilization/tasks.md +++ b/docs/specs/007-release-readiness-stabilization/tasks.md @@ -56,7 +56,7 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 the focused profile produced nine successful tests, and the full profile produced 2,754 successful tests at 52.13% coverage. -- [ ] T002 Add and validate the provisioned MinIO profile. +- [x] T002 Add and validate the provisioned MinIO profile. - Depends on: T001 - Requirement: Requirement 1 - Acceptance Criteria: Requirement 1 AC2, AC3, AC6 @@ -67,13 +67,31 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 `pytest -m minio`, passes its tests, and reports an actionable dependency error when unavailable. - Validation: Provisioned profile plus a negative preflight test. - - Evidence: Pending. - - [ ] T002.1 Define ephemeral endpoint and credential inputs. - - [ ] T002.2 Provision MinIO and wait for readiness before pytest. - - [ ] T002.3 Add clear dependency-preflight failure behavior. - - [ ] T002.4 Run and record the explicit profile. + - Evidence: The workflow provisions pinned MinIO image + `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`, waits on its live + health endpoint, creates the isolated bucket, and runs the four live nodes. + The final local provisioned profile passed all four nodes in 20.39 seconds; the + negative fixture contract reports the endpoint and required recovery action. + - Status: Complete on 2026-07-18; T003 owns hosted validation. + - Evidence mode: implementation + - [x] T002.1 Define ephemeral endpoint and credential inputs. + - Evidence: `.github/workflows/test-suite.yml` defines the loopback endpoint, + disposable `timelocker-ci` credentials, bucket, region, TLS-verification, + and log-level values in the `minio-test` job. + - [x] T002.2 Provision MinIO and wait for readiness before pytest. + - Evidence: The job starts the pinned container, polls + `/minio/health/live`, creates the bucket with `boto3`, and always removes + the container. + - [x] T002.3 Add clear dependency-preflight failure behavior. + - Evidence: `test_live_minio_preflight_failure_is_actionable` and + `test_workflow_provisions_and_runs_live_minio_profile` passed, proving + unavailable MinIO reports its endpoint and recovery action instead of + skipping. + - [x] T002.4 Run and record the explicit profile. + - Evidence: A disposable local container served all four `minio` nodes; + pytest reported four passed and 2,812 deselected. -- [ ] T003 Checkpoint - CI profile validation. +- [~] T003 Checkpoint - CI profile validation. - Depends on: T002 - Requirement: Requirement 1 - Acceptance Criteria: Requirement 1 AC1, AC2, AC3, AC4, AC5, AC6 @@ -81,7 +99,10 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 partitioned or intentionally shared, mocked contracts remain normal, coverage remains at least 50 percent, and no unrelated test is excluded. - Validation: GitHub Actions evidence, pytest collection partition, coverage report. - - Evidence: Pending. + - Evidence: Local partition, normal-profile, and provisioned-MinIO evidence + pass; hosted GitHub Actions evidence remains before checkpoint completion. + - Status: Awaiting the hosted workflow run. + - Evidence mode: validation ## Phase 2: Stabilize the Extended Signal diff --git a/docs/specs/007-release-readiness-stabilization/verification.md b/docs/specs/007-release-readiness-stabilization/verification.md index 1fd2a04..b1b67f5 100644 --- a/docs/specs/007-release-readiness-stabilization/verification.md +++ b/docs/specs/007-release-readiness-stabilization/verification.md @@ -22,7 +22,7 @@ separate explicit approval. | Acceptance traceability complete | yes | passed | Lifecycle stage readiness reports zero acceptance, property, context, downstream-review, or blocking gaps after reconciliation. | | Substantive requirements and design review | yes | passed | Review on 2026-07-18 produced TLR-001 through TLR-006; all six findings were reconciled into the package before implementation. | | Task evidence complete | yes | pending | T001 passed; T002-T013 pending. | -| Normal and dependency-owning test profiles pass | yes | partial | The corrected normal profile passes locally; provisioned MinIO execution remains T002. | +| Normal and dependency-owning test profiles pass | yes | partial | Both profiles pass locally; T003 still requires hosted GitHub Actions evidence. | | Stress implementation and disposition recorded | yes | pending | T004 and GitHub issue #68. | | Artifacts and six-combination clean installs validate | yes | pending | T006-T008. | | Release interface and rehearsal prove no publication side effect | yes | pending | T009-T010. | @@ -33,9 +33,9 @@ separate explicit approval. | Command Or Method | Purpose | Result | Evidence | |-------------------|---------|--------|----------| -| `python -m pytest -m "not performance and not stress and not minio"` | Normal correctness and coverage profile | passed | 2,754 passed, one skipped, 57 deselected; 52.13% coverage. | -| complete collection compared with normal, `minio`, performance, and stress selections | Prove collection safety and node ownership | passed | 2,812 total nodes partition into 2,755 normal, 53 performance/stress, and four live MinIO nodes. | -| `python -m pytest -m minio` with provisioned service | Validate live S3 integration and dependency preflight | pending | T002. | +| `python -m pytest -m "not performance and not stress and not minio"` | Normal correctness and coverage profile | passed | 2,758 passed, one skipped, 57 deselected; 52.13% coverage. | +| complete collection compared with normal, `minio`, performance, and stress selections | Prove collection safety and node ownership | passed | 2,816 total nodes partition into 2,759 normal, 53 performance/stress, and four live MinIO nodes. | +| `python -m pytest -m minio` with provisioned service | Validate live S3 integration and dependency preflight | passed locally | Four passed and 2,812 deselected in 20.39 seconds; hosted execution remains T003. | | `python -m pytest -m "performance or stress" --no-cov` | Extended performance and stress profile | pending | T004 and issue #68; final evidence must explain the coverage exception for this opt-in profile. | | `python scripts/bump_version.py bump patch --no-commit --no-tag` plus pre/post commit, tag, tag-triggered release-workflow run, and release identity | Prepare `0.9.1` without publication side effects | pending | T006. | | `python -m build`, version guard, metadata inspection, and SHA-256 generation | Build and prove artifact identity | pending | T006. | @@ -47,7 +47,7 @@ separate explicit approval. | Requirement | Acceptance Criteria Covered | Evidence | Residual Risk | |-------------|------------------------------|----------|---------------| -| R1 | AC1-AC6 | T001 passed; T002-T003 pending | Provisioned MinIO and hosted workflow evidence remain. | +| R1 | AC1-AC6 | T001-T002 passed; T003 in progress | Hosted workflow evidence remains. | | R2 | AC1-AC4 | Spec-owned T004-T005 and issue #68 pending | Host variance. | | R3 | AC1-AC5 | T006 and T008 pending | Side-effecting defaults must remain disabled. | | R4 | AC1-AC5 | T007-T008 and T011 pending | Unavailable runner blocks the associated support claim. | @@ -57,7 +57,7 @@ separate explicit approval. | Property | Covered By | Evidence | Residual Risk | |----------|------------|----------|---------------| -| CP-001 | T001-T003, collection partition and workflow runs | partial | Local partition proved; provisioned and hosted runs remain. | +| CP-001 | T001-T003, collection partition and workflow runs | partial | Local partition and both profiles pass; hosted runs remain. | | CP-002 | T006 version guard and negative test | pending | None expected after automated guard. | | CP-003 | T007 six-combination artifact matrix | pending | Runner availability is a blocking support gap. | | CP-004 | T006, T008-T010, and T013 external-state comparisons | pending | Actual tag behavior remains separately controlled. | @@ -80,8 +80,8 @@ separate explicit approval. | Task ID | Status | Evidence | Notes | |---------|--------|----------|-------| | T001 | passed | Exact node partition, focused contract tests, and normal-profile run passed | Four live nodes are `minio`; mocked/configuration tests remain normal. | -| T002 | pending | | Provisioned profile pending. | -| T003 | pending | | CI checkpoint pending. | +| T002 | passed | Pinned disposable MinIO, readiness preflight, negative dependency contract, and four live nodes passed | Hosted execution belongs to T003. | +| T003 | in progress | Local normal and MinIO profiles and exact collection partition pass | Hosted Actions evidence pending. | | T004 | pending | GitHub issue #68 created and assigned | Spec owns implementation; issue tracks state and evidence. | | T005 | pending | | Prerequisite checkpoint pending. | | T006 | pending | Side-effecting helper defaults identified | Safe bump, artifact, and external-state evidence pending. | @@ -109,6 +109,9 @@ separate explicit approval. | 2026-07-18 | T001 focused MinIO profile tests | passed | Nine normal-profile contract/configuration tests passed and four live nodes were deselected without using repository MinIO configuration. | | 2026-07-18 | T001 collection partition | passed | All 2,812 nodes accounted for: 2,755 normal, 53 performance/stress, and four live MinIO. | | 2026-07-18 | T001 exact normal profile | passed | 2,754 passed, one skipped, 57 deselected, 19 warnings, and 52.13% coverage in 783.96 seconds. | +| 2026-07-18 | T002 workflow and environment contracts | passed | Action syntax, pinned-service provisioning, actionable preflight failure, URI-scheme preservation, and process-environment precedence passed focused tests. | +| 2026-07-18 | T002 provisioned MinIO profile | passed | Disposable loopback MinIO served all four live nodes; 2,812 nodes were deselected and cleanup succeeded. | +| 2026-07-18 | Phase 1 exact normal profile | passed | 2,758 passed, one skipped, 57 deselected, 19 warnings, and 52.13% coverage in 571.62 seconds. | ## Manual Or External Verification @@ -119,8 +122,7 @@ release artifacts must be linked here before release readiness can be approved. ## Residual Risks -- The corrected normal profile passes locally, but hosted CI and the provisioned - MinIO profile remain unproved until T002-T003. +- Both profiles pass locally, but hosted CI remains unproved until T003. - Future marker drift could change profile ownership; the T001 contract test guards the intended four live nodes and mocked-test placement. - Stress thresholds can remain host-sensitive until T004 evidence is accepted. diff --git a/tests/TimeLocker/integration/minio_test_utils.py b/tests/TimeLocker/integration/minio_test_utils.py index f928ae2..db20be6 100644 --- a/tests/TimeLocker/integration/minio_test_utils.py +++ b/tests/TimeLocker/integration/minio_test_utils.py @@ -1,4 +1,5 @@ """Shared helpers for MinIO integration tests.""" + from __future__ import annotations import json @@ -43,23 +44,31 @@ def _extract_host_bucket_from_s3_uri(uri: str) -> Tuple[str | None, str | None]: return host, bucket -def load_minio_settings(require_credentials: bool = True) -> Tuple[Dict[str, str], Tuple[str, ...]]: +def load_minio_settings( + require_credentials: bool = True, +) -> Tuple[Dict[str, str], Tuple[str, ...]]: """Gather MinIO connection settings from environment or config files.""" settings: Dict[str, str] = {} endpoint_value = ( - os.getenv("MINIO_ENDPOINT_URL") - or os.getenv("AWS_S3_ENDPOINT") - or os.getenv("MINIO_ENDPOINT") + os.getenv("MINIO_ENDPOINT_URL") + or os.getenv("AWS_S3_ENDPOINT") + or os.getenv("MINIO_ENDPOINT") ) if endpoint_value: url, host = _normalize_endpoint(endpoint_value) settings["MINIO_ENDPOINT_URL"] = url settings["MINIO_ENDPOINT_HOST"] = host settings.setdefault("AWS_S3_ENDPOINT", url) - settings.setdefault("MINIO_URI_PREFIX", f"s3:{host}") - - for env_key in ("MINIO_ACCESS_KEY", "MINIO_SECRET_KEY", "MINIO_BUCKET", "MINIO_REGION", "MINIO_VERIFY_SSL"): + settings.setdefault("MINIO_URI_PREFIX", f"s3:{url}") + + for env_key in ( + "MINIO_ACCESS_KEY", + "MINIO_SECRET_KEY", + "MINIO_BUCKET", + "MINIO_REGION", + "MINIO_VERIFY_SSL", + ): value = os.getenv(env_key) if value: settings[env_key] = value @@ -88,30 +97,45 @@ def load_minio_settings(require_credentials: bool = True) -> Tuple[Dict[str, str if host and "MINIO_ENDPOINT_HOST" not in settings: _, normalized_host = _normalize_endpoint(host) settings["MINIO_ENDPOINT_HOST"] = normalized_host - settings["MINIO_ENDPOINT_URL"] = settings.get("MINIO_ENDPOINT_URL", f"http://{normalized_host}") + settings["MINIO_ENDPOINT_URL"] = settings.get( + "MINIO_ENDPOINT_URL", f"http://{normalized_host}" + ) settings.setdefault("AWS_S3_ENDPOINT", settings["MINIO_ENDPOINT_URL"]) - settings.setdefault("MINIO_URI_PREFIX", f"s3:{normalized_host}") + settings.setdefault( + "MINIO_URI_PREFIX", f"s3:{settings['MINIO_ENDPOINT_URL']}" + ) if bucket and "MINIO_BUCKET" not in settings: settings["MINIO_BUCKET"] = bucket creds = repo.get("credentials", {}) if isinstance(creds, dict): - settings.setdefault("MINIO_ACCESS_KEY", creds.get("aws_access_key_id", "")) - settings.setdefault("MINIO_SECRET_KEY", creds.get("aws_secret_access_key", "")) + settings.setdefault( + "MINIO_ACCESS_KEY", creds.get("aws_access_key_id", "") + ) + settings.setdefault( + "MINIO_SECRET_KEY", creds.get("aws_secret_access_key", "") + ) settings.setdefault("MINIO_REGION", creds.get("aws_default_region", "")) break - missing_core = [key for key in ("MINIO_ENDPOINT_HOST", "MINIO_BUCKET") if not settings.get(key)] + missing_core = [ + key + for key in ("MINIO_ENDPOINT_HOST", "MINIO_BUCKET") + if not settings.get(key) + ] if not missing_core: break settings.setdefault("MINIO_BUCKET", DEFAULT_BUCKET) settings.setdefault("MINIO_REGION", os.getenv("AWS_DEFAULT_REGION", DEFAULT_REGION)) - settings.setdefault("MINIO_VERIFY_SSL", os.getenv("MINIO_VERIFY_SSL", settings.get("MINIO_VERIFY_SSL", "true"))) + settings.setdefault( + "MINIO_VERIFY_SSL", + os.getenv("MINIO_VERIFY_SSL", settings.get("MINIO_VERIFY_SSL", "true")), + ) if "MINIO_ENDPOINT_URL" not in settings and "MINIO_ENDPOINT_HOST" in settings: settings["MINIO_ENDPOINT_URL"] = f"http://{settings['MINIO_ENDPOINT_HOST']}" if "MINIO_URI_PREFIX" not in settings and "MINIO_ENDPOINT_HOST" in settings: - settings["MINIO_URI_PREFIX"] = f"s3:{settings['MINIO_ENDPOINT_HOST']}" + settings["MINIO_URI_PREFIX"] = f"s3:{settings['MINIO_ENDPOINT_URL']}" required = ["MINIO_ENDPOINT_URL", "MINIO_BUCKET", "MINIO_REGION"] if require_credentials: @@ -121,7 +145,13 @@ def load_minio_settings(require_credentials: bool = True) -> Tuple[Dict[str, str return settings, missing -def ensure_minio_reachable(endpoint: str, access_key: str, secret_key: str, region: str, verify_ssl: bool = True): +def ensure_minio_reachable( + endpoint: str, + access_key: str, + secret_key: str, + region: str, + verify_ssl: bool = True, +): """Return a boto3 client if the MinIO endpoint is reachable.""" import boto3 from botocore.config import Config @@ -136,7 +166,7 @@ def ensure_minio_reachable(endpoint: str, access_key: str, secret_key: str, regi tried_endpoints = set() candidates = [endpoint] if endpoint.startswith("http://"): - candidates.append("https://" + endpoint[len("http://"):]) + candidates.append("https://" + endpoint[len("http://") :]) last_error: Exception | None = None for candidate in candidates: @@ -145,13 +175,13 @@ def ensure_minio_reachable(endpoint: str, access_key: str, secret_key: str, regi tried_endpoints.add(candidate) client = boto3.client( - "s3", - endpoint_url=candidate, - aws_access_key_id=access_key, - aws_secret_access_key=secret_key, - region_name=region, - config=Config(s3={"addressing_style": "path"}), - verify=verify_ssl, + "s3", + endpoint_url=candidate, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + region_name=region, + config=Config(s3={"addressing_style": "path"}), + verify=verify_ssl, ) try: @@ -170,7 +200,9 @@ def ensure_minio_reachable(endpoint: str, access_key: str, secret_key: str, regi try: with urllib.request.urlopen(endpoint, timeout=5) as response: if response.status >= 500: - raise RuntimeError(f"MinIO endpoint responded with status {response.status}") + raise RuntimeError( + f"MinIO endpoint responded with status {response.status}" + ) except HTTPError as exc: if exc.code >= 500: raise RuntimeError(f"MinIO endpoint responded with status {exc.code}") @@ -178,5 +210,7 @@ def ensure_minio_reachable(endpoint: str, access_key: str, secret_key: str, regi raise RuntimeError(f"MinIO not reachable: {exc}") if last_error: - raise RuntimeError(f"Unable to reach MinIO endpoint '{endpoint}': {last_error}") from last_error + raise RuntimeError( + f"Unable to reach MinIO endpoint '{endpoint}': {last_error}" + ) from last_error raise RuntimeError(f"Unable to reach MinIO endpoint '{endpoint}'") diff --git a/tests/TimeLocker/integration/test_minio_profile_contract.py b/tests/TimeLocker/integration/test_minio_profile_contract.py index ed3e99f..24d810b 100644 --- a/tests/TimeLocker/integration/test_minio_profile_contract.py +++ b/tests/TimeLocker/integration/test_minio_profile_contract.py @@ -2,6 +2,8 @@ from pathlib import Path +import pytest + from tests.TimeLocker.integration import test_minio_connection, test_s3_minio @@ -49,3 +51,47 @@ def test_normal_ci_explicitly_excludes_live_minio_profile(): assert ( 'python -m pytest -m "not performance and not stress and not minio"' in workflow ) + + +def test_workflow_provisions_and_runs_live_minio_profile(): + repo_root = Path(__file__).resolve().parents[3] + workflow = (repo_root / ".github/workflows/test-suite.yml").read_text() + + assert "minio-test:" in workflow + assert "quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z" in workflow + assert "MINIO_ACCESS_KEY: timelocker-ci" in workflow + assert "MINIO_SECRET_KEY: timelocker-ci-secret" in workflow + assert "$MINIO_ENDPOINT_URL/minio/health/live" in workflow + assert "MinIO profile dependency error: service did not become ready" in workflow + assert "python -m pytest -m minio --no-cov" in workflow + + +def test_live_minio_preflight_failure_is_actionable(monkeypatch): + def _raise_unavailable(*_args, **_kwargs): + raise RuntimeError("connection refused") + + monkeypatch.setattr(test_s3_minio, "ensure_minio_reachable", _raise_unavailable) + settings = { + "MINIO_ENDPOINT_URL": "http://127.0.0.1:9000", + "MINIO_ACCESS_KEY": "test-access", + "MINIO_SECRET_KEY": "test-secret", + "MINIO_REGION": "us-east-1", + "MINIO_VERIFY_SSL": "false", + } + + with pytest.raises( + pytest.fail.Exception, + match="MinIO profile dependency error: service is unavailable", + ): + test_s3_minio.minio_available.__wrapped__(settings) + + +def test_minio_repository_uri_preserves_explicit_endpoint_scheme(monkeypatch): + monkeypatch.setenv("MINIO_ENDPOINT_URL", "http://127.0.0.1:19000") + monkeypatch.setenv("MINIO_ACCESS_KEY", "test-access") + monkeypatch.setenv("MINIO_SECRET_KEY", "test-secret") + + settings, missing = test_s3_minio.load_minio_settings(require_credentials=True) + + assert not missing + assert settings["MINIO_URI_PREFIX"] == "s3:http://127.0.0.1:19000" diff --git a/tests/TimeLocker/integration/test_s3_minio.py b/tests/TimeLocker/integration/test_s3_minio.py index 86a55ff..0ff44c2 100644 --- a/tests/TimeLocker/integration/test_s3_minio.py +++ b/tests/TimeLocker/integration/test_s3_minio.py @@ -156,21 +156,20 @@ def s3_repository(monkeypatch: pytest.MonkeyPatch) -> S3ResticRepository: def live_s3_repository( test_repo_path: str, minio_settings: dict[str, str], - monkeypatch: pytest.MonkeyPatch, ) -> S3ResticRepository: """Create an S3 repository backed by the provisioned MinIO service.""" location = ( f"{minio_settings['MINIO_URI_PREFIX']}/" f"{minio_settings['MINIO_BUCKET']}/{test_repo_path}" ) - monkeypatch.setenv("AWS_S3_ENDPOINT", minio_settings["MINIO_ENDPOINT_URL"]) - repo = S3ResticRepository( location=location, password="test-password-123", aws_access_key_id=minio_settings["MINIO_ACCESS_KEY"], aws_secret_access_key=minio_settings["MINIO_SECRET_KEY"], aws_default_region=minio_settings["MINIO_REGION"], + aws_s3_endpoint=minio_settings["MINIO_ENDPOINT_URL"], + insecure_tls=not _verify_ssl(minio_settings), ) return repo diff --git a/tests/TimeLocker/project/test_pytest_environment.py b/tests/TimeLocker/project/test_pytest_environment.py new file mode 100644 index 0000000..bb9f6d0 --- /dev/null +++ b/tests/TimeLocker/project/test_pytest_environment.py @@ -0,0 +1,20 @@ +"""Tests for repository-wide pytest environment precedence.""" + +import os + +from tests.conftest import _load_project_env + + +def test_explicit_environment_wins_over_test_env_file(tmp_path, monkeypatch): + env_file = tmp_path / ".env.test" + env_file.write_text( + "MINIO_ENDPOINT_URL=https://tracked-config.invalid\n" + "MINIO_ACCESS_KEY=tracked-access\n" + ) + monkeypatch.setenv("MINIO_ENDPOINT_URL", "http://127.0.0.1:19000") + monkeypatch.setenv("MINIO_ACCESS_KEY", "ci-access") + + _load_project_env(tmp_path) + + assert os.environ["MINIO_ENDPOINT_URL"] == "http://127.0.0.1:19000" + assert os.environ["MINIO_ACCESS_KEY"] == "ci-access" diff --git a/tests/conftest.py b/tests/conftest.py index e0da983..8b741ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ uses locally. Values already present in ``os.environ`` take precedence, allowing CI pipelines to inject secrets without modifying the files. """ + from __future__ import annotations import os @@ -50,12 +51,16 @@ def _load_env_file(path: Path, *, override: bool = False) -> None: os.environ[key] = _parse_env_value(value_part) +def _load_project_env(project_root: Path) -> None: + """Load project test files while preserving explicit process values.""" + env_files: Iterable[Path] = ( + project_root / ".env", + project_root / ".env.test", + ) + for env_path in env_files: + _load_env_file(env_path, override=False) + + def pytest_configure() -> None: # noqa: D401 - hook invoked by pytest """Load environment configuration before tests start.""" - project_root = Path(__file__).resolve().parents[1] - env_files: Iterable[tuple[Path, bool]] = ( - (project_root / ".env", False), - (project_root / ".env.test", True), - ) - for env_path, override in env_files: - _load_env_file(env_path, override=override) + _load_project_env(Path(__file__).resolve().parents[1]) From 3137a7cd8120dc14c88e33ec5a287fa508bcca54 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:34:51 +0100 Subject: [PATCH 05/72] test(telemetry): enable backend test explicitly Make the PostHog backend test independent of CI auto-disable semantics and normalize the touched test module formatting. --- tests/TimeLocker/monitoring/test_telemetry.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/TimeLocker/monitoring/test_telemetry.py b/tests/TimeLocker/monitoring/test_telemetry.py index c925bdc..d27382e 100644 --- a/tests/TimeLocker/monitoring/test_telemetry.py +++ b/tests/TimeLocker/monitoring/test_telemetry.py @@ -1,5 +1,3 @@ -import os - import pytest from opentelemetry.sdk.metrics.export import MetricExportResult, MetricExporter from opentelemetry.sdk.trace.export import SpanExportResult, SpanExporter @@ -67,15 +65,15 @@ def test_setup_telemetry_uses_custom_exporters(monkeypatch: pytest.MonkeyPatch) metric_exporter = DummyMetricExporter() config = TelemetryConfig( - enabled=True, - api_key="test-key", - endpoint="https://eu.i.posthog.com", + enabled=True, + api_key="test-key", + endpoint="https://eu.i.posthog.com", ) handle = setup_telemetry( - config, - span_exporter_factory=lambda _: span_exporter, - metric_exporter_factory=lambda _: metric_exporter, + config, + span_exporter_factory=lambda _: span_exporter, + metric_exporter_factory=lambda _: metric_exporter, ) assert isinstance(handle, TelemetryHandle) @@ -107,7 +105,8 @@ def flush(self): def close(self): calls["close"] = True - import types, sys + import sys + import types fake_module = types.SimpleNamespace(Posthog=FakePosthog) sys.modules["posthog"] = fake_module # type: ignore[assignment] @@ -115,6 +114,7 @@ def close(self): import TimeLocker.monitoring.telemetry as telemetry monkeypatch.setenv("TIMELOCKER_TELEMETRY_BACKEND", "posthog") + monkeypatch.setenv("TIMELOCKER_TELEMETRY_ENABLED", "true") monkeypatch.setenv("POSTHOG_API_KEY", "k") monkeypatch.setenv("POSTHOG_HOST", "https://eu.i.posthog.com") @@ -127,7 +127,9 @@ def close(self): assert "capture" in calls -def test_auto_mode_disables_in_ci_even_with_key(monkeypatch: pytest.MonkeyPatch) -> None: +def test_auto_mode_disables_in_ci_even_with_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setenv("CI", "true") monkeypatch.setenv("POSTHOG_API_KEY", "secret-key") monkeypatch.delenv("TIMELOCKER_TELEMETRY_ENABLED", raising=False) From 8a7e1c167c8f2c2789dc996c2be46b743e94ae6f Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:37:34 +0100 Subject: [PATCH 06/72] fix(ci): retain coverage data artifact Include the hidden coverage database in the archived normal-profile results so the dependent quality gate can enforce the configured threshold. --- .github/workflows/test-suite.yml | 1 + .../TimeLocker/integration/test_minio_profile_contract.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index df45603..800f299 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -84,6 +84,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.os }}-${{ matrix.python-version }} + include-hidden-files: true path: | htmlcov/ .coverage diff --git a/tests/TimeLocker/integration/test_minio_profile_contract.py b/tests/TimeLocker/integration/test_minio_profile_contract.py index 24d810b..ae46722 100644 --- a/tests/TimeLocker/integration/test_minio_profile_contract.py +++ b/tests/TimeLocker/integration/test_minio_profile_contract.py @@ -53,6 +53,14 @@ def test_normal_ci_explicitly_excludes_live_minio_profile(): ) +def test_coverage_artifact_includes_hidden_data_file(): + repo_root = Path(__file__).resolve().parents[3] + workflow = (repo_root / ".github/workflows/test-suite.yml").read_text() + + assert "include-hidden-files: true" in workflow + assert "test -f .coverage" in workflow + + def test_workflow_provisions_and_runs_live_minio_profile(): repo_root = Path(__file__).resolve().parents[3] workflow = (repo_root / ".github/workflows/test-suite.yml").read_text() From 9183aba0025af794cd403018d613a64e5f02895f Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:54:35 +0100 Subject: [PATCH 07/72] docs(spec): complete release readiness phase 1 Record the successful hosted normal, MinIO, coverage, and notification jobs from Actions run 29676747955 and advance Spec 007 to T004. --- .../tasks.md | 12 +++++++---- .../verification.md | 20 ++++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/specs/007-release-readiness-stabilization/tasks.md b/docs/specs/007-release-readiness-stabilization/tasks.md index 72174ff..8c8a971 100644 --- a/docs/specs/007-release-readiness-stabilization/tasks.md +++ b/docs/specs/007-release-readiness-stabilization/tasks.md @@ -91,7 +91,7 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 - Evidence: A disposable local container served all four `minio` nodes; pytest reported four passed and 2,812 deselected. -- [~] T003 Checkpoint - CI profile validation. +- [x] T003 Checkpoint - CI profile validation. - Depends on: T002 - Requirement: Requirement 1 - Acceptance Criteria: Requirement 1 AC1, AC2, AC3, AC4, AC5, AC6 @@ -99,9 +99,13 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 partitioned or intentionally shared, mocked contracts remain normal, coverage remains at least 50 percent, and no unrelated test is excluded. - Validation: GitHub Actions evidence, pytest collection partition, coverage report. - - Evidence: Local partition, normal-profile, and provisioned-MinIO evidence - pass; hosted GitHub Actions evidence remains before checkpoint completion. - - Status: Awaiting the hosted workflow run. + - Evidence: GitHub Actions run `29676747955` passed at commit `8a7e1c1`: + the normal job completed 2,760 selected nodes with 2,759 successes and + 52.15% coverage; 57 nodes were outside its selector. The provisioned MinIO + job passed all four live nodes, and the coverage quality gate and final + notification also passed. Full collection contains 2,817 nodes: 2,760 + normal, 53 performance/stress, and four MinIO. + - Status: Complete on 2026-07-19; Phase 1 is complete and T004 is next. - Evidence mode: validation ## Phase 2: Stabilize the Extended Signal diff --git a/docs/specs/007-release-readiness-stabilization/verification.md b/docs/specs/007-release-readiness-stabilization/verification.md index b1b67f5..4259628 100644 --- a/docs/specs/007-release-readiness-stabilization/verification.md +++ b/docs/specs/007-release-readiness-stabilization/verification.md @@ -21,8 +21,8 @@ separate explicit approval. |------|-----------|--------|----------| | Acceptance traceability complete | yes | passed | Lifecycle stage readiness reports zero acceptance, property, context, downstream-review, or blocking gaps after reconciliation. | | Substantive requirements and design review | yes | passed | Review on 2026-07-18 produced TLR-001 through TLR-006; all six findings were reconciled into the package before implementation. | -| Task evidence complete | yes | pending | T001 passed; T002-T013 pending. | -| Normal and dependency-owning test profiles pass | yes | partial | Both profiles pass locally; T003 still requires hosted GitHub Actions evidence. | +| Task evidence complete | yes | pending | T001-T003 passed; T004-T013 pending. | +| Normal and dependency-owning test profiles pass | yes | passed | GitHub Actions run `29676747955` passed normal, MinIO, coverage quality-gate, and notification jobs. | | Stress implementation and disposition recorded | yes | pending | T004 and GitHub issue #68. | | Artifacts and six-combination clean installs validate | yes | pending | T006-T008. | | Release interface and rehearsal prove no publication side effect | yes | pending | T009-T010. | @@ -33,9 +33,9 @@ separate explicit approval. | Command Or Method | Purpose | Result | Evidence | |-------------------|---------|--------|----------| -| `python -m pytest -m "not performance and not stress and not minio"` | Normal correctness and coverage profile | passed | 2,758 passed, one skipped, 57 deselected; 52.13% coverage. | -| complete collection compared with normal, `minio`, performance, and stress selections | Prove collection safety and node ownership | passed | 2,816 total nodes partition into 2,759 normal, 53 performance/stress, and four live MinIO nodes. | -| `python -m pytest -m minio` with provisioned service | Validate live S3 integration and dependency preflight | passed locally | Four passed and 2,812 deselected in 20.39 seconds; hosted execution remains T003. | +| `python -m pytest -m "not performance and not stress and not minio"` | Normal correctness and coverage profile | passed | Hosted run `29676747955`: 2,759 passed, one skipped, 57 deselected; 52.15% coverage. | +| complete collection compared with normal, `minio`, performance, and stress selections | Prove collection safety and node ownership | passed | 2,817 total nodes partition into 2,760 normal, 53 performance/stress, and four live MinIO nodes. | +| `python -m pytest -m minio` with provisioned service | Validate live S3 integration and dependency preflight | passed | Four local live nodes passed in 20.39 seconds; the provisioned job also passed in run `29676747955`. | | `python -m pytest -m "performance or stress" --no-cov` | Extended performance and stress profile | pending | T004 and issue #68; final evidence must explain the coverage exception for this opt-in profile. | | `python scripts/bump_version.py bump patch --no-commit --no-tag` plus pre/post commit, tag, tag-triggered release-workflow run, and release identity | Prepare `0.9.1` without publication side effects | pending | T006. | | `python -m build`, version guard, metadata inspection, and SHA-256 generation | Build and prove artifact identity | pending | T006. | @@ -47,7 +47,7 @@ separate explicit approval. | Requirement | Acceptance Criteria Covered | Evidence | Residual Risk | |-------------|------------------------------|----------|---------------| -| R1 | AC1-AC6 | T001-T002 passed; T003 in progress | Hosted workflow evidence remains. | +| R1 | AC1-AC6 | T001-T003 passed; GitHub Actions run `29676747955` | Marker and workflow contract tests guard future profile drift. | | R2 | AC1-AC4 | Spec-owned T004-T005 and issue #68 pending | Host variance. | | R3 | AC1-AC5 | T006 and T008 pending | Side-effecting defaults must remain disabled. | | R4 | AC1-AC5 | T007-T008 and T011 pending | Unavailable runner blocks the associated support claim. | @@ -57,7 +57,7 @@ separate explicit approval. | Property | Covered By | Evidence | Residual Risk | |----------|------------|----------|---------------| -| CP-001 | T001-T003, collection partition and workflow runs | partial | Local partition and both profiles pass; hosted runs remain. | +| CP-001 | T001-T003, collection partition and workflow run `29676747955` | passed | Contract tests guard marker, selector, service, and artifact-transfer drift. | | CP-002 | T006 version guard and negative test | pending | None expected after automated guard. | | CP-003 | T007 six-combination artifact matrix | pending | Runner availability is a blocking support gap. | | CP-004 | T006, T008-T010, and T013 external-state comparisons | pending | Actual tag behavior remains separately controlled. | @@ -81,7 +81,7 @@ separate explicit approval. |---------|--------|----------|-------| | T001 | passed | Exact node partition, focused contract tests, and normal-profile run passed | Four live nodes are `minio`; mocked/configuration tests remain normal. | | T002 | passed | Pinned disposable MinIO, readiness preflight, negative dependency contract, and four live nodes passed | Hosted execution belongs to T003. | -| T003 | in progress | Local normal and MinIO profiles and exact collection partition pass | Hosted Actions evidence pending. | +| T003 | passed | Actions run `29676747955`: normal, MinIO, quality-gate, and notification jobs passed | Phase 1 checkpoint complete. | | T004 | pending | GitHub issue #68 created and assigned | Spec owns implementation; issue tracks state and evidence. | | T005 | pending | | Prerequisite checkpoint pending. | | T006 | pending | Side-effecting helper defaults identified | Safe bump, artifact, and external-state evidence pending. | @@ -112,6 +112,7 @@ separate explicit approval. | 2026-07-18 | T002 workflow and environment contracts | passed | Action syntax, pinned-service provisioning, actionable preflight failure, URI-scheme preservation, and process-environment precedence passed focused tests. | | 2026-07-18 | T002 provisioned MinIO profile | passed | Disposable loopback MinIO served all four live nodes; 2,812 nodes were deselected and cleanup succeeded. | | 2026-07-18 | Phase 1 exact normal profile | passed | 2,758 passed, one skipped, 57 deselected, 19 warnings, and 52.13% coverage in 571.62 seconds. | +| 2026-07-19 | Hosted Phase 1 checkpoint, run `29676747955` | passed | Commit `8a7e1c1`; 2,759 normal tests passed, one skipped, 57 deselected, 52.15% coverage, four live MinIO tests passed, and the quality gate and notification completed successfully. | ## Manual Or External Verification @@ -122,7 +123,8 @@ release artifacts must be linked here before release readiness can be approved. ## Residual Risks -- Both profiles pass locally, but hosted CI remains unproved until T003. +- GitHub Actions currently emits a non-blocking Node.js 20 deprecation warning + for upstream action versions that the runner forces onto Node.js 24. - Future marker drift could change profile ownership; the T001 contract test guards the intended four live nodes and mocked-test placement. - Stress thresholds can remain host-sensitive until T004 evidence is accepted. From 6572f655f6471b7f1381f6956ab55377dd6ec052 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:45:46 +0100 Subject: [PATCH 08/72] perf(selection): stabilize stress threshold Separate deterministic selection correctness from the opt-in timing signal. Replace the fixed-duration iteration gate with a calibrated median baseline and tolerance, document reproduction, and record Spec 007 Phase 2 evidence. Refs #68 --- docs/4-testing/README.md | 31 +++++++- .../tasks.md | 54 ++++++++++--- .../traceability.md | 4 +- .../verification.md | 18 +++-- src/TimeLocker/selection_testing_harness.py | 36 ++++++++- .../integration/test_stress_testing.py | 77 ++++++++++++++----- .../selection/test_performance_stress.py | 28 +++++++ 7 files changed, 202 insertions(+), 46 deletions(-) diff --git a/docs/4-testing/README.md b/docs/4-testing/README.md index 2367f58..3cae36c 100644 --- a/docs/4-testing/README.md +++ b/docs/4-testing/README.md @@ -5,7 +5,7 @@ id: "RM-006" type: [ readme ] status: active owner: "Auriora Team" -last_reviewed: 2026-07-18 +last_reviewed: 2026-07-19 tags: [ readme, testing ] links: tooling: [ ] @@ -56,7 +56,34 @@ python -m pytest -m "performance or stress" --no-cov ``` This opt-in profile is intended for representative performance environments; -it does not own the correctness coverage gate. +it does not own the correctness coverage gate. Run it without coverage because +instrumentation changes timing enough to invalidate performance comparisons. + +Selection stress testing separates two signals: + +- deterministic repeated-operation correctness runs in the normal profile; +- sustained timing runs in the opt-in profile against a 1.0-second-per-operation + representative baseline and a 2.0x regression tolerance. + +The timing test warms the selection caches, measures 12 fixed operations with a +monotonic clock, and gates on the median. This avoids the previous contract, +which required an absolute iteration count inside a fixed one-minute window and +therefore changed outcome with host load. The baseline represents the slower +supported development observations captured in issue 68; the tolerance absorbs +normal shared-runner variance while still failing a material sustained slowdown. +The observed median, baseline, and tolerance are emitted as test properties. + +To reproduce only this contract, run: + +```bash +python -m pytest \ + tests/TimeLocker/integration/test_stress_testing.py::TestStressTesting::test_sustained_selection_performance \ + --no-cov -q -s +``` + +Repeat the command at least three times on a representative host when changing +selection traversal, size estimation, pattern handling, or the threshold. Record +the environment and results in the owning issue rather than this durable guide. ## Local MinIO diff --git a/docs/specs/007-release-readiness-stabilization/tasks.md b/docs/specs/007-release-readiness-stabilization/tasks.md index 8c8a971..789b020 100644 --- a/docs/specs/007-release-readiness-stabilization/tasks.md +++ b/docs/specs/007-release-readiness-stabilization/tasks.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: tasks status: active owner: Auriora Team -last_reviewed: 2026-07-18 +last_reviewed: 2026-07-19 --- # Tasks @@ -110,7 +110,7 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 ## Phase 2: Stabilize the Extended Signal -- [ ] T004 Implement and validate the selection stress-threshold contract. +- [x] T004 Implement and validate the selection stress-threshold contract. - Depends on: T003 - Requirement: Requirement 2 - Acceptance Criteria: Requirement 2 AC1, AC2, AC3, AC4 @@ -122,21 +122,51 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 tolerance are implemented; the repeatable extended profile passes or a release-blocking disposition is recorded. - Evidence mode: implementation - - Destination: - - Evidence: Pending. - - [ ] T004.1 Capture representative host timings and environment context in issue #68. - - [ ] T004.2 Separate deterministic correctness assertions from environment-sensitive timing assertions. - - [ ] T004.3 Implement the evidence-backed baseline and tolerance strategy. - - [ ] T004.4 Run a repeatable extended profile and link results from issue #68. - -- [ ] T005 Checkpoint - Release validation prerequisites. + - Destination: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293 + - Evidence: Implemented `PerformanceBaseline`, split deterministic correctness from opt-in timing, replaced the 60-second iteration-count gate with a warmed 12-operation median check using a 1.0s baseline and 2.0x tolerance, and documented reproduction. Three targeted runs passed at 0.160s/0.176s/0.173s; the extended profile passed 53 tests in 45.60s; the normal profile passed 2,765 tests with one skip and 52.14% coverage. Evidence: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293. + - Status: Complete on 2026-07-19; immutable post-change hosted evidence follows the explicitly requested commit. + - [x] T004.1 Capture representative host timings and environment context in issue #68. + - Evidence: Issue #68 records Linux/Python/CPU/load context, the + 209-iteration legacy result, historical 57/70-iteration observations, + and the calibrated strategy. + - Status: Complete on 2026-07-19. + - Evidence mode: validation + - Destination: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293 + - [x] T004.2 Separate deterministic correctness assertions from environment-sensitive timing assertions. + - Evidence: `test_repeated_operations_preserve_selection_correctness` owns + deterministic stability assertions; `test_sustained_selection_performance` + owns only the opt-in timing signal. + - Status: Complete on 2026-07-19. + - Evidence mode: validation + - Destination: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293 + - [x] T004.3 Implement the evidence-backed baseline and tolerance strategy. + - Evidence: `PerformanceBaseline` validates a named 1.0-second reference + with a 2.0x tolerance; the stress test warms caches, measures 12 fixed + operations with a monotonic clock, and evaluates the median. + - Status: Complete on 2026-07-19. + - Evidence mode: validation + - Destination: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293 + - [x] T004.4 Run a repeatable extended profile and link results from issue #68. + - Evidence: Three targeted runs passed at 0.160s, 0.176s, and 0.173s + median; the complete extended profile passed 53 tests in 45.60s. + - Status: Complete on 2026-07-19. + - Evidence mode: validation + - Destination: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293 +- [x] T005 Checkpoint - Release validation prerequisites. - Depends on: T004 - - Requirements: Requirements 1 and 2 + - Requirements: Requirement 1, Requirement 2 - Acceptance: Normal CI is green, explicit external-service coverage is green, Spec 007 stress acceptance is met, and issue #68 contains linked evidence or an explicit release-blocking disposition. - Validation: Review T003 and T004 evidence and the linked issue history. - - Evidence: Pending. + - Evidence: Phase 2 prerequisites are met: hosted run 29676747955 passed + normal CI, provisioned MinIO, the coverage quality gate, and notification; + the post-change local normal profile passed 2,765 tests with one skip and + 52.14% coverage; the extended profile passed 53 tests; and issue #68 + contains environment, calibration, and repeat evidence. + - Status: Complete on 2026-07-19; Phase 2 checkpoint passed and T006 is next. + - Evidence mode: validation + - Destination: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293 ## Phase 3: Build and Install v0.9.1 diff --git a/docs/specs/007-release-readiness-stabilization/traceability.md b/docs/specs/007-release-readiness-stabilization/traceability.md index 19d1be9..def40af 100644 --- a/docs/specs/007-release-readiness-stabilization/traceability.md +++ b/docs/specs/007-release-readiness-stabilization/traceability.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: traceability status: active owner: Auriora Team -last_reviewed: 2026-07-18 +last_reviewed: 2026-07-19 --- # Traceability Matrix @@ -17,7 +17,7 @@ last_reviewed: 2026-07-18 | T002 | Requirement 1 | AC2, AC3, AC6 | CI Profile Logic; Error Handling; Security | Provisioned MinIO profile | MinIO profile and negative preflight | workflow, testing guide | none | | T003 | Requirement 1 | AC1, AC2, AC3, AC4, AC5, AC6 | Validation Strategy | CI profile readiness | CI quality gate, coverage, partition proof | testing guide | none | | T004 | Requirement 2 | AC1, AC2, AC3, AC4 | Components; Validation Strategy | Spec-owned stress bug fix | representative timings, tests, issue #68, extended profile | tests, testing guide | none | -| T005 | Requirements 1 and 2 | all | Downstream Task Guidance | CI and stress readiness | prerequisite checkpoint | none | none | +| T005 | Requirement 1, Requirement 2 | all | Downstream Task Guidance | CI and stress readiness | prerequisite checkpoint | none | none | | T006 | Requirement 3 | AC1, AC2, AC3, AC4, AC5 | Version and Artifact Guard; Security | Side-effect-safe version and artifact changes | Git/release-state comparison, build, metadata, version guard | metadata, version process, changelog | none | | T007 | Requirement 4 | AC1, AC2, AC3, AC4, AC5 | Clean-Install Matrix | Exact support matrix and install validation | six-combination wheel and sdist smoke matrix | metadata, installation guide | none | | T008 | Requirements 3 and 4 | all | Validation Strategy | Artifact and install readiness | artifact checkpoint and side-effect proof | installation guide | none | diff --git a/docs/specs/007-release-readiness-stabilization/verification.md b/docs/specs/007-release-readiness-stabilization/verification.md index 4259628..f05a95b 100644 --- a/docs/specs/007-release-readiness-stabilization/verification.md +++ b/docs/specs/007-release-readiness-stabilization/verification.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: verification status: active owner: Auriora Team -last_reviewed: 2026-07-18 +last_reviewed: 2026-07-19 --- # Verification @@ -21,9 +21,9 @@ separate explicit approval. |------|-----------|--------|----------| | Acceptance traceability complete | yes | passed | Lifecycle stage readiness reports zero acceptance, property, context, downstream-review, or blocking gaps after reconciliation. | | Substantive requirements and design review | yes | passed | Review on 2026-07-18 produced TLR-001 through TLR-006; all six findings were reconciled into the package before implementation. | -| Task evidence complete | yes | pending | T001-T003 passed; T004-T013 pending. | +| Task evidence complete | yes | pending | T001-T005 passed; T006-T013 pending. | | Normal and dependency-owning test profiles pass | yes | passed | GitHub Actions run `29676747955` passed normal, MinIO, coverage quality-gate, and notification jobs. | -| Stress implementation and disposition recorded | yes | pending | T004 and GitHub issue #68. | +| Stress implementation and disposition recorded | yes | passed | T004 implementation and repeat evidence are recorded in GitHub issue #68. | | Artifacts and six-combination clean installs validate | yes | pending | T006-T008. | | Release interface and rehearsal prove no publication side effect | yes | pending | T009-T010. | | Durable documentation and communications promoted | yes | pending | T011-T012 and promotion table below. | @@ -36,7 +36,7 @@ separate explicit approval. | `python -m pytest -m "not performance and not stress and not minio"` | Normal correctness and coverage profile | passed | Hosted run `29676747955`: 2,759 passed, one skipped, 57 deselected; 52.15% coverage. | | complete collection compared with normal, `minio`, performance, and stress selections | Prove collection safety and node ownership | passed | 2,817 total nodes partition into 2,760 normal, 53 performance/stress, and four live MinIO nodes. | | `python -m pytest -m minio` with provisioned service | Validate live S3 integration and dependency preflight | passed | Four local live nodes passed in 20.39 seconds; the provisioned job also passed in run `29676747955`. | -| `python -m pytest -m "performance or stress" --no-cov` | Extended performance and stress profile | pending | T004 and issue #68; final evidence must explain the coverage exception for this opt-in profile. | +| `python -m pytest -m "performance or stress" --no-cov` | Extended performance and stress profile | passed | 53 passed, 2,770 deselected in 45.60 seconds; issue #68 records three repeated targeted medians and the no-coverage rationale. | | `python scripts/bump_version.py bump patch --no-commit --no-tag` plus pre/post commit, tag, tag-triggered release-workflow run, and release identity | Prepare `0.9.1` without publication side effects | pending | T006. | | `python -m build`, version guard, metadata inspection, and SHA-256 generation | Build and prove artifact identity | pending | T006. | | wheel and sdist smoke installs on Linux, macOS, and Windows for Python 3.12 and 3.13 | Prove CP-003 and all declared support claims | pending | T007 six-combination matrix. | @@ -48,7 +48,7 @@ separate explicit approval. | Requirement | Acceptance Criteria Covered | Evidence | Residual Risk | |-------------|------------------------------|----------|---------------| | R1 | AC1-AC6 | T001-T003 passed; GitHub Actions run `29676747955` | Marker and workflow contract tests guard future profile drift. | -| R2 | AC1-AC4 | Spec-owned T004-T005 and issue #68 pending | Host variance. | +| R2 | AC1-AC4 | T004-T005 passed; issue #68 records environment, baseline, tolerance, and repeat evidence | Post-change hosted evidence follows the explicitly requested commit. | | R3 | AC1-AC5 | T006 and T008 pending | Side-effecting defaults must remain disabled. | | R4 | AC1-AC5 | T007-T008 and T011 pending | Unavailable runner blocks the associated support claim. | | R5 | AC1-AC6 | T009-T013 pending | Human operator error at first actual tag. | @@ -82,8 +82,8 @@ separate explicit approval. | T001 | passed | Exact node partition, focused contract tests, and normal-profile run passed | Four live nodes are `minio`; mocked/configuration tests remain normal. | | T002 | passed | Pinned disposable MinIO, readiness preflight, negative dependency contract, and four live nodes passed | Hosted execution belongs to T003. | | T003 | passed | Actions run `29676747955`: normal, MinIO, quality-gate, and notification jobs passed | Phase 1 checkpoint complete. | -| T004 | pending | GitHub issue #68 created and assigned | Spec owns implementation; issue tracks state and evidence. | -| T005 | pending | | Prerequisite checkpoint pending. | +| T004 | passed | Correctness/timing split, 1.0-second baseline, 2.0x tolerance, three repeat runs, and 53-test extended profile | Issue #68 contains the environment and chronological evidence. | +| T005 | passed | T003 hosted run plus T004 local normal/extended profiles and issue evidence | Phase 2 checkpoint complete. | | T006 | pending | Side-effecting helper defaults identified | Safe bump, artifact, and external-state evidence pending. | | T007 | pending | Six-combination contract defined | Artifact matrix pending. | | T008 | pending | | Artifact checkpoint pending. | @@ -113,6 +113,10 @@ separate explicit approval. | 2026-07-18 | T002 provisioned MinIO profile | passed | Disposable loopback MinIO served all four live nodes; 2,812 nodes were deselected and cleanup succeeded. | | 2026-07-18 | Phase 1 exact normal profile | passed | 2,758 passed, one skipped, 57 deselected, 19 warnings, and 52.13% coverage in 571.62 seconds. | | 2026-07-19 | Hosted Phase 1 checkpoint, run `29676747955` | passed | Commit `8a7e1c1`; 2,759 normal tests passed, one skipped, 57 deselected, 52.15% coverage, four live MinIO tests passed, and the quality gate and notification completed successfully. | +| 2026-07-19 | Legacy selection stress baseline | passed but unstable contract | The fixed 60-second gate completed 209 iterations on Linux/Python 3.12.6; historical observations of 57 and 70 demonstrated host sensitivity. | +| 2026-07-19 | Repeated calibrated selection stress contract | passed | Three `--no-cov` runs reported 0.160, 0.176, and 0.173 second medians against a 1.0-second baseline and 2.0x tolerance. | +| 2026-07-19 | Phase 2 extended profile | passed | 53 passed and 2,770 deselected in 45.60 seconds without coverage instrumentation. | +| 2026-07-19 | Phase 2 normal profile | passed | 2,765 passed, one skipped, 57 deselected, and 52.14% coverage in 726.60 seconds. | ## Manual Or External Verification diff --git a/src/TimeLocker/selection_testing_harness.py b/src/TimeLocker/selection_testing_harness.py index 3a310a4..ac2b0cc 100644 --- a/src/TimeLocker/selection_testing_harness.py +++ b/src/TimeLocker/selection_testing_harness.py @@ -16,6 +16,7 @@ """ import logging +import math import time from dataclasses import dataclass, field from pathlib import Path @@ -36,6 +37,37 @@ logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class PerformanceBaseline: + """A named performance baseline with an explicit regression tolerance.""" + + name: str + seconds_per_operation: float + tolerance_multiplier: float + + def __post_init__(self) -> None: + """Reject baselines that cannot define a meaningful threshold.""" + if not self.name.strip(): + raise ValueError("Performance baseline name must not be empty") + if not math.isfinite(self.seconds_per_operation) or self.seconds_per_operation <= 0: + raise ValueError("Baseline seconds per operation must be positive and finite") + if not math.isfinite(self.tolerance_multiplier) or self.tolerance_multiplier < 1: + raise ValueError("Performance tolerance multiplier must be finite and at least 1") + + @property + def maximum_seconds_per_operation(self) -> float: + """Return the slowest observation accepted by this baseline.""" + return self.seconds_per_operation * self.tolerance_multiplier + + def accepts(self, observed_seconds_per_operation: float) -> bool: + """Return whether an observed duration is valid and within tolerance.""" + return ( + math.isfinite(observed_seconds_per_operation) + and observed_seconds_per_operation >= 0 + and observed_seconds_per_operation <= self.maximum_seconds_per_operation + ) + + @dataclass class TestScenario: """ @@ -291,7 +323,7 @@ def benchmark_performance( total_times = [] for i in range(iterations): - start_time = time.time() + start_time = time.perf_counter() for test_path in scenario.test_paths: self.debugger.test_path_selection( @@ -299,7 +331,7 @@ def benchmark_performance( scenario.selection_config ) - iteration_time_ms = (time.time() - start_time) * 1000 + iteration_time_ms = (time.perf_counter() - start_time) * 1000 total_times.append(iteration_time_ms) # Calculate statistics diff --git a/tests/TimeLocker/integration/test_stress_testing.py b/tests/TimeLocker/integration/test_stress_testing.py index e5f2874..ae048c9 100644 --- a/tests/TimeLocker/integration/test_stress_testing.py +++ b/tests/TimeLocker/integration/test_stress_testing.py @@ -12,6 +12,7 @@ import time import psutil import gc +import statistics from pathlib import Path from unittest.mock import Mock, patch from concurrent.futures import ThreadPoolExecutor, as_completed @@ -20,6 +21,14 @@ from TimeLocker.backup_manager import BackupManager from TimeLocker.backup_target import BackupTarget from TimeLocker.security import CredentialManager, SecurityService +from TimeLocker.selection_testing_harness import PerformanceBaseline + + +SELECTION_OPERATION_BASELINE = PerformanceBaseline( + name="selection traversal and size estimation", + seconds_per_operation=1.0, + tolerance_multiplier=2.0, +) class TestStressTesting: @@ -349,38 +358,64 @@ def test_pattern_complexity_stress(self): assert len(effective_paths['included']) > 0 print(f"Complex pattern matching: {len(effective_paths['included'])} files matched") - @pytest.mark.stress - def test_long_running_operations(self): - """Test stability during long-running operations""" - # Create dataset for long-running test - self._create_stress_dataset(num_files=1500, num_dirs=150) + def test_repeated_operations_preserve_selection_correctness(self): + """Verify repeated selection operations independently of elapsed time.""" + self._create_stress_dataset(num_files=300, num_dirs=30) file_selection = FileSelection() file_selection.add_path(self.stress_data_dir, SelectionType.INCLUDE) file_selection.add_pattern("*.dat", SelectionType.INCLUDE) - # Perform operations repeatedly for extended period - start_time = time.time() - max_duration = 60 # 1 minute of continuous operations - iteration_count = 0 - - while (time.time() - start_time) < max_duration: - # Perform file operations + observations = [] + for _ in range(3): effective_paths = file_selection.get_effective_paths() size_stats = file_selection.estimate_backup_size() + observations.append( + (len(effective_paths['included']), size_stats['file_count']) + ) + + assert observations[0][0] > 0 + assert observations[0][1] > 0 + assert observations == [observations[0]] * 3 - # Validate each iteration - assert len(effective_paths['included']) > 0 - assert size_stats['file_count'] > 0 + @pytest.mark.stress + def test_sustained_selection_performance(self, record_property): + """Detect sustained-operation regressions against a calibrated baseline.""" + self._create_stress_dataset(num_files=1500, num_dirs=150) - iteration_count += 1 + file_selection = FileSelection() + file_selection.add_path(self.stress_data_dir, SelectionType.INCLUDE) + file_selection.add_pattern("*.dat", SelectionType.INCLUDE) - # Brief pause to prevent overwhelming the system - time.sleep(0.1) + # Warm caches before measuring a fixed amount of work. Correctness is + # covered separately so this test has one environment-sensitive signal. + file_selection.get_effective_paths() + file_selection.estimate_backup_size() - # Validate long-running stability - assert iteration_count > 100, f"Only {iteration_count} iterations completed" - print(f"Long-running test: {iteration_count} iterations in {max_duration}s") + durations = [] + for _ in range(12): + start_time = time.perf_counter() + file_selection.get_effective_paths() + file_selection.estimate_backup_size() + durations.append(time.perf_counter() - start_time) + + observed_seconds = statistics.median(durations) + maximum_seconds = SELECTION_OPERATION_BASELINE.maximum_seconds_per_operation + record_property("selection_baseline_seconds", 1.0) + record_property("selection_tolerance_multiplier", 2.0) + record_property("selection_observed_median_seconds", observed_seconds) + + assert SELECTION_OPERATION_BASELINE.accepts(observed_seconds), ( + f"Median selection operation took {observed_seconds:.3f}s; " + f"maximum is {maximum_seconds:.3f}s " + f"({SELECTION_OPERATION_BASELINE.seconds_per_operation:.3f}s baseline x " + f"{SELECTION_OPERATION_BASELINE.tolerance_multiplier:.1f} tolerance)" + ) + print( + f"Selection performance: median={observed_seconds:.3f}s, " + f"baseline={SELECTION_OPERATION_BASELINE.seconds_per_operation:.3f}s, " + f"maximum={maximum_seconds:.3f}s, iterations={len(durations)}" + ) @pytest.mark.stress def test_resource_cleanup_stress(self): diff --git a/tests/TimeLocker/selection/test_performance_stress.py b/tests/TimeLocker/selection/test_performance_stress.py index 2b04649..c63d662 100644 --- a/tests/TimeLocker/selection/test_performance_stress.py +++ b/tests/TimeLocker/selection/test_performance_stress.py @@ -29,6 +29,7 @@ from TimeLocker.pattern_engine import PatternEngine from TimeLocker.selection_validation_service import SelectionValidationService from TimeLocker.selection_performance_optimizer import SelectionPerformanceOptimizer +from TimeLocker.selection_testing_harness import PerformanceBaseline from TimeLocker.selection_models import ( SelectionConfig, PatternRule, @@ -38,6 +39,33 @@ ) +class TestPerformanceBaselineContract: + """Deterministic tests for the shared timing-threshold contract.""" + + def test_accepts_observations_at_or_below_tolerance(self): + baseline = PerformanceBaseline("selection", 1.0, 2.0) + + assert baseline.accepts(0.5) + assert baseline.accepts(2.0) + assert not baseline.accepts(2.01) + + @pytest.mark.parametrize( + ("seconds_per_operation", "tolerance_multiplier"), + [(0.0, 2.0), (float("inf"), 2.0), (1.0, 0.99), (1.0, float("nan"))], + ) + def test_rejects_invalid_thresholds( + self, + seconds_per_operation, + tolerance_multiplier, + ): + with pytest.raises(ValueError): + PerformanceBaseline( + "selection", + seconds_per_operation, + tolerance_multiplier, + ) + + @pytest.fixture def temp_storage_dir(): """Create a temporary directory for template storage.""" From 9348c58413af3422167faf0a052ef5e80571d647 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:47:49 +0100 Subject: [PATCH 09/72] docs(spec-007): start artifact preparation Record T006 as the active implementation slice after reviewing the governing release authorities and resolving the advisory canonical-context warning. --- docs/specs/007-release-readiness-stabilization/tasks.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/specs/007-release-readiness-stabilization/tasks.md b/docs/specs/007-release-readiness-stabilization/tasks.md index 789b020..78fcf6b 100644 --- a/docs/specs/007-release-readiness-stabilization/tasks.md +++ b/docs/specs/007-release-readiness-stabilization/tasks.md @@ -170,7 +170,7 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 ## Phase 3: Build and Install v0.9.1 -- [ ] T006 Prepare version `0.9.1` safely and build reproducible artifacts. +- [~] T006 Prepare version `0.9.1` safely and build reproducible artifacts. - Depends on: T005 - Requirement: Requirement 3 - Acceptance Criteria: Requirement 3 AC1, AC2, AC3, AC4, AC5 @@ -184,7 +184,9 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 - Validation: Pre/post Git and release-state comparison, `python scripts/bump_version.py bump patch --no-commit --no-tag`, version guard, `python -m build`, artifact inspection. - - Evidence: Pending. + - Evidence: Implementation started from clean commit `6572f65`; charter, metadata, version helper/configuration, release workflow, installation guide, and version process were reviewed directly. The missing canonical-context artifact is an advisory with no concrete authority ambiguity, so no duplicate context file is needed. + - Status: Capturing a clean pre-change external-state snapshot before the non-publishing version bump. + - Evidence mode: implementation - [ ] T006.1 Record pre-change commit, tag, tag-triggered release-workflow run, and GitHub-release identity. - [ ] T006.2 Run the version helper with both commit and tag side effects disabled. - [ ] T006.3 Update `requires-python` to `>=3.12,<3.14`, remove `OS Independent`, and reconcile Python and OS classifiers. From e8549051e32b81a08dcfa232b02faa05a1ed2751 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:53:50 +0100 Subject: [PATCH 10/72] build(release): validate 0.9.1 artifacts --- .bumpversion.cfg | 3 +- .github/workflows/artifact-smoke.yml | 70 +++++++++ pyproject.toml | 12 +- scripts/smoke_release_artifact.py | 49 ++++++ scripts/validate_release_artifacts.py | 142 ++++++++++++++++++ src/TimeLocker/__init__.py | 2 +- .../project/test_release_artifacts.py | 81 ++++++++++ 7 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/artifact-smoke.yml create mode 100755 scripts/smoke_release_artifact.py create mode 100755 scripts/validate_release_artifacts.py create mode 100644 tests/TimeLocker/project/test_release_artifacts.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg index bf362b4..07eb83c 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.0 +current_version = 0.9.1 commit = True tag = True tag_name = v{new_version} @@ -14,4 +14,3 @@ replace = version = "{new_version}" [bumpversion:file:src/TimeLocker/__init__.py] search = __version__ = "{current_version}" replace = __version__ = "{new_version}" - diff --git a/.github/workflows/artifact-smoke.yml b/.github/workflows/artifact-smoke.yml new file mode 100644 index 0000000..238e76d --- /dev/null +++ b/.github/workflows/artifact-smoke.yml @@ -0,0 +1,70 @@ +name: Release Artifact Smoke + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Build once and inspect artifacts + run: | + python -m pip install --upgrade pip build + python -m build + python scripts/validate_release_artifacts.py --expected-version 0.9.1 + - name: Upload immutable artifact set + uses: actions/upload-artifact@v4 + with: + name: timelocker-0.9.1-distributions + path: dist/ + if-no-files-found: error + + smoke: + needs: build + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.12", "3.13"] + artifact: [wheel, sdist] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout smoke tooling + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: timelocker-0.9.1-distributions + path: dist + - name: Select artifact + id: artifact + shell: python + run: | + from pathlib import Path + import os + + suffix = ".whl" if "${{ matrix.artifact }}" == "wheel" else ".tar.gz" + matches = list(Path("dist").glob(f"*{suffix}")) + if len(matches) != 1: + raise SystemExit(f"Expected one {suffix} artifact, found {matches}") + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"path={matches[0]}\n") + - name: Install and smoke ${{ matrix.artifact }} + run: >- + python scripts/smoke_release_artifact.py + "${{ steps.artifact.outputs.path }}" + --expected-version 0.9.1 diff --git a/pyproject.toml b/pyproject.toml index 1920646..5b17df5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "timelocker" -version = "0.9.0" +version = "0.9.1" description = "High-level Python interface for backup operations using Restic" readme = "README.md" license = "GPL-3.0-or-later" @@ -32,7 +32,6 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Utilities", # Operating System - "Operating System :: OS Independent", "Operating System :: POSIX :: Linux", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", @@ -47,7 +46,7 @@ classifiers = [ # Natural Language "Natural Language :: English", ] -requires-python = ">=3.12" +requires-python = ">=3.12,<3.14" dependencies = [ "packaging~=25.0", "b2sdk~=2.10.1", @@ -106,7 +105,12 @@ where = ["src"] TimeLocker = [ "restic/*.json", "config/*.json", - "*.md", + "cli_modules/*.md", + "cli_modules/helpers/*.md", + "cli_modules/testing/*.md", + "cli_modules/validation/*.md", + "policy/*.md", + "services/plugins/*.md", ] [tool.pytest.ini_options] diff --git a/scripts/smoke_release_artifact.py b/scripts/smoke_release_artifact.py new file mode 100755 index 0000000..162ae80 --- /dev/null +++ b/scripts/smoke_release_artifact.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Install one built artifact in a fresh environment and smoke both CLIs.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import tempfile +import venv +from pathlib import Path + + +def executable(environment: Path, name: str) -> Path: + scripts = environment / ("Scripts" if os.name == "nt" else "bin") + suffix = ".exe" if os.name == "nt" else "" + return scripts / f"{name}{suffix}" + + +def run(command: list[str], *, expected: str | None = None) -> None: + result = subprocess.run(command, check=True, capture_output=True, text=True) + if expected is not None and result.stdout.strip() != expected: + raise RuntimeError(f"{' '.join(command)} returned {result.stdout.strip()!r}, expected {expected!r}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("artifact", type=Path) + parser.add_argument("--expected-version", required=True) + args = parser.parse_args() + artifact = args.artifact.resolve() + if not artifact.is_file(): + raise SystemExit(f"Artifact does not exist: {artifact}") + + with tempfile.TemporaryDirectory(prefix="timelocker-artifact-") as temporary: + environment = Path(temporary) / "venv" + venv.EnvBuilder(with_pip=True).create(environment) + python = executable(environment, "python") + run([str(python), "-m", "pip", "install", "--disable-pip-version-check", str(artifact)]) + for command_name in ("timelocker", "tl"): + command = executable(environment, command_name) + run([str(command), "version", "--short"], expected=args.expected_version) + run([str(command), "--help"]) + print(f"Smoke contract passed for {artifact.name} on Python {sys.version.split()[0]}") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_release_artifacts.py b/scripts/validate_release_artifacts.py new file mode 100755 index 0000000..b289b07 --- /dev/null +++ b/scripts/validate_release_artifacts.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Validate TimeLocker release identity, artifact metadata, data, and hashes.""" + +from __future__ import annotations + +import argparse +import ast +import configparser +import hashlib +import tarfile +import tomllib +import zipfile +from email.parser import BytesParser +from pathlib import Path, PurePosixPath + +EXPECTED_REQUIRES_PYTHON = ">=3.12,<3.14" +EXPECTED_ENTRY_POINTS = { + "timelocker": "TimeLocker.cli:main", + "tl": "TimeLocker.cli:main", +} + + +def project_metadata(root: Path) -> dict[str, object]: + with (root / "pyproject.toml").open("rb") as stream: + return tomllib.load(stream) + + +def package_version(root: Path) -> str: + module = ast.parse((root / "src/TimeLocker/__init__.py").read_text()) + for statement in module.body: + if ( + isinstance(statement, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "__version__" for target in statement.targets) + ): + return str(ast.literal_eval(statement.value)) + raise AssertionError("src/TimeLocker/__init__.py does not define __version__") + + +def expected_package_data(root: Path, metadata: dict[str, object]) -> set[str]: + patterns = metadata["tool"]["setuptools"]["package-data"]["TimeLocker"] # type: ignore[index] + package_root = root / "src/TimeLocker" + return { + path.relative_to(root / "src").as_posix() + for pattern in patterns + for path in package_root.glob(pattern) + if path.is_file() + } + + +def parse_metadata(raw: bytes) -> tuple[str, str]: + parsed = BytesParser().parsebytes(raw) + return str(parsed["Version"]), str(parsed["Requires-Python"]) + + +def parse_entry_points(raw: bytes) -> dict[str, str]: + config = configparser.ConfigParser() + config.read_string(raw.decode()) + return dict(config["console_scripts"]) + + +def assert_requires_python(actual: str, artifact: str) -> None: + actual_parts = {part.strip() for part in actual.split(",")} + expected_parts = {part.strip() for part in EXPECTED_REQUIRES_PYTHON.split(",")} + assert actual_parts == expected_parts, ( + f"{artifact} Requires-Python is {actual}, expected {EXPECTED_REQUIRES_PYTHON}" + ) + + +def inspect_wheel(path: Path, expected_version: str, package_data: set[str]) -> None: + with zipfile.ZipFile(path) as archive: + names = set(archive.namelist()) + metadata_name = next(name for name in names if name.endswith(".dist-info/METADATA")) + entry_points_name = next(name for name in names if name.endswith(".dist-info/entry_points.txt")) + version, requires_python = parse_metadata(archive.read(metadata_name)) + entry_points = parse_entry_points(archive.read(entry_points_name)) + assert version == expected_version, f"wheel version is {version}, expected {expected_version}" + assert_requires_python(requires_python, "wheel") + assert entry_points == EXPECTED_ENTRY_POINTS, f"wheel entry points differ: {entry_points}" + missing = package_data - names + assert not missing, f"wheel is missing package data: {sorted(missing)}" + + +def inspect_sdist(path: Path, expected_version: str, package_data: set[str]) -> None: + with tarfile.open(path, "r:gz") as archive: + names = {PurePosixPath(name) for name in archive.getnames()} + pkg_info = next(name for name in names if len(name.parts) == 2 and name.name == "PKG-INFO") + extracted = archive.extractfile(str(pkg_info)) + assert extracted is not None + version, requires_python = parse_metadata(extracted.read()) + assert version == expected_version, f"sdist version is {version}, expected {expected_version}" + assert_requires_python(requires_python, "sdist") + prefix = pkg_info.parent + expected_names = {prefix / "src" / PurePosixPath(name) for name in package_data} + missing = expected_names - names + assert not missing, f"sdist is missing package data: {sorted(map(str, missing))}" + + +def write_and_verify_hashes(artifacts: list[Path], destination: Path) -> None: + lines = [f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}" for path in artifacts] + destination.write_text("\n".join(lines) + "\n") + for line, path in zip(destination.read_text().splitlines(), artifacts, strict=True): + digest, filename = line.split(" ", maxsplit=1) + assert filename == path.name + assert digest == hashlib.sha256(path.read_bytes()).hexdigest() + + +def validate(root: Path, dist: Path, expected_version: str) -> None: + metadata = project_metadata(root) + project = metadata["project"] # type: ignore[index] + versions = {str(project["version"]), package_version(root)} # type: ignore[index] + assert versions == {expected_version}, ( + f"version guard failed: expected={expected_version}, sources={sorted(versions)}" + ) + assert project["requires-python"] == EXPECTED_REQUIRES_PYTHON # type: ignore[index] + + wheels = sorted(dist.glob("*.whl")) + sdists = sorted(dist.glob("*.tar.gz")) + assert len(wheels) == 1, f"expected one wheel, found {len(wheels)}" + assert len(sdists) == 1, f"expected one sdist, found {len(sdists)}" + package_data = expected_package_data(root, metadata) + assert package_data, "package-data declaration did not resolve any files" + inspect_wheel(wheels[0], expected_version, package_data) + inspect_sdist(sdists[0], expected_version, package_data) + write_and_verify_hashes([*wheels, *sdists], dist / "SHA256SUMS") + print( + f"Validated {wheels[0].name}, {sdists[0].name}, " + f"{len(package_data)} package-data files, and SHA-256 hashes" + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--expected-version", required=True) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--dist", type=Path) + args = parser.parse_args() + root = args.root.resolve() + validate(root, (args.dist or root / "dist").resolve(), args.expected_version) + + +if __name__ == "__main__": + main() diff --git a/src/TimeLocker/__init__.py b/src/TimeLocker/__init__.py index c932260..ee3ccb9 100644 --- a/src/TimeLocker/__init__.py +++ b/src/TimeLocker/__init__.py @@ -37,7 +37,7 @@ # Integration components from .integration import IntegrationService -__version__ = "0.9.0" +__version__ = "0.9.1" __all__ = [ # Core components diff --git a/tests/TimeLocker/project/test_release_artifacts.py b/tests/TimeLocker/project/test_release_artifacts.py new file mode 100644 index 0000000..68594cd --- /dev/null +++ b/tests/TimeLocker/project/test_release_artifacts.py @@ -0,0 +1,81 @@ +"""Contracts for non-publishing release artifact validation.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + +ROOT = Path(__file__).parents[3] + + +def load_validator(): + path = ROOT / "scripts/validate_release_artifacts.py" + spec = importlib.util.spec_from_file_location("validate_release_artifacts", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.config +@pytest.mark.unit +def test_supported_python_and_os_metadata_are_explicit(): + with (ROOT / "pyproject.toml").open("rb") as stream: + project = tomllib.load(stream)["project"] + assert project["requires-python"] == ">=3.12,<3.14" + assert "Operating System :: OS Independent" not in project["classifiers"] + for classifier in ( + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + ): + assert classifier in project["classifiers"] + + +@pytest.mark.config +@pytest.mark.unit +def test_version_guard_rejects_a_mismatch(tmp_path): + validator = load_validator() + with pytest.raises(AssertionError, match="version guard failed"): + validator.validate(ROOT, tmp_path, "0.9.0") + + +@pytest.mark.config +@pytest.mark.unit +def test_smoke_workflow_is_manual_read_only_and_covers_support_matrix(): + workflow = (ROOT / ".github/workflows/artifact-smoke.yml").read_text() + assert "workflow_dispatch:" in workflow + assert "push:" not in workflow + assert "contents: read" in workflow + assert "ubuntu-latest, macos-latest, windows-latest" in workflow + assert 'python-version: ["3.12", "3.13"]' in workflow + assert "artifact: [wheel, sdist]" in workflow + for forbidden in ("gh release", "git tag", "twine upload"): + assert forbidden not in workflow + + +@pytest.mark.config +@pytest.mark.unit +def test_validator_cli_rejects_a_version_mismatch_before_artifact_checks(tmp_path): + result = subprocess.run( + [ + sys.executable, + str(ROOT / "scripts/validate_release_artifacts.py"), + "--expected-version", + "0.9.0", + "--dist", + str(tmp_path), + ], + cwd=ROOT, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "version guard failed" in result.stderr From 0c597e6b8e2088f82433c3310f11b0cf7dac8ca6 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:54:51 +0100 Subject: [PATCH 11/72] ci(release): run artifact smoke on pull requests --- .github/workflows/artifact-smoke.yml | 1 + tests/TimeLocker/project/test_release_artifacts.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/artifact-smoke.yml b/.github/workflows/artifact-smoke.yml index 238e76d..32aeb64 100644 --- a/.github/workflows/artifact-smoke.yml +++ b/.github/workflows/artifact-smoke.yml @@ -1,6 +1,7 @@ name: Release Artifact Smoke on: + pull_request: workflow_dispatch: permissions: diff --git a/tests/TimeLocker/project/test_release_artifacts.py b/tests/TimeLocker/project/test_release_artifacts.py index 68594cd..d704045 100644 --- a/tests/TimeLocker/project/test_release_artifacts.py +++ b/tests/TimeLocker/project/test_release_artifacts.py @@ -49,8 +49,9 @@ def test_version_guard_rejects_a_mismatch(tmp_path): @pytest.mark.config @pytest.mark.unit -def test_smoke_workflow_is_manual_read_only_and_covers_support_matrix(): +def test_smoke_workflow_is_non_publishing_read_only_and_covers_support_matrix(): workflow = (ROOT / ".github/workflows/artifact-smoke.yml").read_text() + assert "pull_request:" in workflow assert "workflow_dispatch:" in workflow assert "push:" not in workflow assert "contents: read" in workflow From 58ccec971e824b5c86d4540f8a24135bbae44a4f Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:57:21 +0100 Subject: [PATCH 12/72] test(release): report artifact smoke failures --- scripts/smoke_release_artifact.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/smoke_release_artifact.py b/scripts/smoke_release_artifact.py index 162ae80..f1f4315 100755 --- a/scripts/smoke_release_artifact.py +++ b/scripts/smoke_release_artifact.py @@ -19,7 +19,12 @@ def executable(environment: Path, name: str) -> Path: def run(command: list[str], *, expected: str | None = None) -> None: - result = subprocess.run(command, check=True, capture_output=True, text=True) + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError( + f"{' '.join(command)} exited {result.returncode}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) if expected is not None and result.stdout.strip() != expected: raise RuntimeError(f"{' '.join(command)} returned {result.stdout.strip()!r}, expected {expected!r}") From 4a2d99894006e4248e1edfe93171093544b254a5 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:00:54 +0100 Subject: [PATCH 13/72] fix(cli): keep help portable on Windows --- src/TimeLocker/cli.py | 24 +++++++++---------- src/TimeLocker/cli_modules/commands/backup.py | 2 +- src/TimeLocker/cli_modules/commands/base.py | 2 +- .../cli_modules/commands/restore.py | 2 +- .../project/test_release_artifacts.py | 11 +++++++++ 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/TimeLocker/cli.py b/src/TimeLocker/cli.py index ddf9772..e427757 100644 --- a/src/TimeLocker/cli.py +++ b/src/TimeLocker/cli.py @@ -400,12 +400,12 @@ def complete_operation(self, **_kwargs: object) -> None: # pragma: no cover - n " tl snapshots restore /restore/path --repository \n\n" "Note: Local repository paths must use the file:// prefix (e.g., file:///path/to/repo).\n" ), - epilog="Made with ❤️ by Bruce Cherrington", + epilog="Made by Bruce Cherrington", rich_markup_mode=None, no_args_is_help=True, context_settings=CLI_CONTEXT_SETTINGS, ) -app.info.options_metavar = "⟨OPTIONS⟩" +app.info.options_metavar = "[OPTIONS]" def _merge_typer_app(target_app: typer.Typer, source_app: typer.Typer) -> None: @@ -416,20 +416,20 @@ def _merge_typer_app(target_app: typer.Typer, source_app: typer.Typer) -> None: # Create sub-apps for new hierarchy backup_app = typer.Typer(help="Backup operations", no_args_is_help=True, context_settings=CLI_CONTEXT_SETTINGS) -backup_app.info.options_metavar = "⟨OPTIONS⟩" +backup_app.info.options_metavar = "[OPTIONS]" snapshots_app = typer.Typer(help="Snapshot operations", context_settings=CLI_CONTEXT_SETTINGS) -snapshots_app.info.options_metavar = "⟨OPTIONS⟩" +snapshots_app.info.options_metavar = "[OPTIONS]" repos_app = typer.Typer(help="Repository operations", context_settings=CLI_CONTEXT_SETTINGS) -repos_app.info.options_metavar = "⟨OPTIONS⟩" +repos_app.info.options_metavar = "[OPTIONS]" config_app = typer.Typer(help="Configuration management commands", context_settings=CLI_CONTEXT_SETTINGS) -config_app.info.options_metavar = "⟨OPTIONS⟩" +config_app.info.options_metavar = "[OPTIONS]" credentials_app = typer.Typer(help="Credential management commands", context_settings=CLI_CONTEXT_SETTINGS) -credentials_app.info.options_metavar = "⟨OPTIONS⟩" +credentials_app.info.options_metavar = "[OPTIONS]" # Create security sub-app security_app = typer.Typer(help="Security management commands", context_settings=CLI_CONTEXT_SETTINGS) -security_app.info.options_metavar = "⟨OPTIONS⟩" +security_app.info.options_metavar = "[OPTIONS]" # Add sub-apps to main app app.add_typer(backup_app, name="backup") @@ -443,14 +443,14 @@ def _merge_typer_app(target_app: typer.Typer, source_app: typer.Typer) -> None: # Create config sub-apps config_import_app = typer.Typer(help="Import configuration commands", context_settings=CLI_CONTEXT_SETTINGS) -config_import_app.info.options_metavar = "⟨OPTIONS⟩" +config_import_app.info.options_metavar = "[OPTIONS]" config_export_app = typer.Typer(help="Export configuration commands", context_settings=CLI_CONTEXT_SETTINGS) -config_export_app.info.options_metavar = "⟨OPTIONS⟩" +config_export_app.info.options_metavar = "[OPTIONS]" # Create migrate app for configuration migration and validation migrate_app = typer.Typer(help="Configuration migration and validation commands", context_settings=CLI_CONTEXT_SETTINGS) -migrate_app.info.options_metavar = "⟨OPTIONS⟩" +migrate_app.info.options_metavar = "[OPTIONS]" # Add config sub-apps config_app.add_typer(config_import_app, name="import") @@ -461,7 +461,7 @@ def _merge_typer_app(target_app: typer.Typer, source_app: typer.Typer) -> None: # Create repos sub-apps repos_credentials_app = typer.Typer(help="Repository credential management", context_settings=CLI_CONTEXT_SETTINGS) -repos_credentials_app.info.options_metavar = "⟨OPTIONS⟩" +repos_credentials_app.info.options_metavar = "[OPTIONS]" # Add repos sub-apps repos_app.add_typer(repos_credentials_app, name="credentials") diff --git a/src/TimeLocker/cli_modules/commands/backup.py b/src/TimeLocker/cli_modules/commands/backup.py index 43f46c1..4b48fb7 100644 --- a/src/TimeLocker/cli_modules/commands/backup.py +++ b/src/TimeLocker/cli_modules/commands/backup.py @@ -51,7 +51,7 @@ no_args_is_help=True, context_settings=CLI_CONTEXT_SETTINGS ) -backup_app.info.options_metavar = "⟨OPTIONS⟩" +backup_app.info.options_metavar = "[OPTIONS]" BackupDisplayValue: TypeAlias = str | int | float | bool | list[object] | dict[str, object] | tuple[object, ...] diff --git a/src/TimeLocker/cli_modules/commands/base.py b/src/TimeLocker/cli_modules/commands/base.py index 430806b..3c15c27 100644 --- a/src/TimeLocker/cli_modules/commands/base.py +++ b/src/TimeLocker/cli_modules/commands/base.py @@ -339,7 +339,7 @@ def create_typer_app( no_args_is_help=no_args_is_help, context_settings=CLI_CONTEXT_SETTINGS ) - app.info.options_metavar = "⟨OPTIONS⟩" + app.info.options_metavar = "[OPTIONS]" return app diff --git a/src/TimeLocker/cli_modules/commands/restore.py b/src/TimeLocker/cli_modules/commands/restore.py index 9ea156e..e1f361c 100644 --- a/src/TimeLocker/cli_modules/commands/restore.py +++ b/src/TimeLocker/cli_modules/commands/restore.py @@ -54,7 +54,7 @@ no_args_is_help=True, context_settings=CLI_CONTEXT_SETTINGS ) -restore_app.info.options_metavar = "⟨OPTIONS⟩" +restore_app.info.options_metavar = "[OPTIONS]" def _get_repository(repository_input: str, config_dir: Optional[Path] = None): diff --git a/tests/TimeLocker/project/test_release_artifacts.py b/tests/TimeLocker/project/test_release_artifacts.py index d704045..d08c7dd 100644 --- a/tests/TimeLocker/project/test_release_artifacts.py +++ b/tests/TimeLocker/project/test_release_artifacts.py @@ -9,6 +9,9 @@ from pathlib import Path import pytest +from typer.testing import CliRunner + +from TimeLocker.cli import app ROOT = Path(__file__).parents[3] @@ -62,6 +65,14 @@ def test_smoke_workflow_is_non_publishing_read_only_and_covers_support_matrix(): assert forbidden not in workflow +@pytest.mark.platform +@pytest.mark.unit +def test_root_help_is_compatible_with_windows_default_encoding(): + result = CliRunner().invoke(app, ["--help"]) + assert result.exit_code == 0 + result.output.encode("cp1252") + + @pytest.mark.config @pytest.mark.unit def test_validator_cli_rejects_a_version_mismatch_before_artifact_checks(tmp_path): From 1dcf91090c755c476afe1851b2c4e02cdd9a949f Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:08:26 +0100 Subject: [PATCH 14/72] fix(release): complete phase 3 validation --- docs/guides/user/installation.md | 41 +++++++++++---- .../tasks.md | 52 +++++++++++++------ .../verification.md | 48 +++++++++-------- src/TimeLocker/cli.py | 22 ++++---- src/TimeLocker/cli_modules/commands/backup.py | 2 +- src/TimeLocker/cli_modules/commands/base.py | 2 +- .../cli_modules/commands/restore.py | 2 +- 7 files changed, 107 insertions(+), 62 deletions(-) diff --git a/docs/guides/user/installation.md b/docs/guides/user/installation.md index eefdebc..2df9ecd 100644 --- a/docs/guides/user/installation.md +++ b/docs/guides/user/installation.md @@ -4,7 +4,7 @@ id: "user-guide-installation" type: [ guide ] status: [ approved ] owner: "Documentation Team" -last_reviewed: "18-07-2026" +last_reviewed: "19-07-2026" tags: [guide, user, installation] links: tooling: [] @@ -15,7 +15,7 @@ links: - **Owner**: Documentation Team - **Status**: Approved - **Created Date**: 19-12-2024 -- **Last Updated**: 18-07-2026 +- **Last Updated**: 19-07-2026 - **Audience**: End Users, Administrators ## 1. Purpose @@ -28,7 +28,9 @@ After completing this guide you will have TimeLocker installed, dependencies con ## 3. Prerequisites -- Supported operating system (Linux, macOS, or Windows). +- Supported operating system: Linux, macOS, or Windows. +- Python 3.12 or 3.13. TimeLocker declares `>=3.12,<3.14`. +- Restic 0.18.0 or later available on `PATH`. - Internet access to install Python and Restic. - Git if cloning from source. - Optional: AWS/B2 credentials for cloud backends. @@ -37,7 +39,7 @@ After completing this guide you will have TimeLocker installed, dependencies con ### 4.1 Review Release Status -- **Current status**: Beta, version 0.9.0. +- **Current status**: Beta, version 0.9.1 is prepared but not published. - **Distribution**: Source checkout only; TimeLocker is not currently published to PyPI. - **Quality gate**: The configured test suite enforces at least 50% coverage. @@ -53,20 +55,20 @@ multi-backend support. ```bash sudo apt update -sudo apt install python3.12 python3-pip git # Ubuntu/Debian -# sudo dnf install python3.12 python3-pip git # Fedora +sudo apt install python3.12 python3-pip git # Ubuntu/Debian; Python 3.13 is also supported +# sudo dnf install python3.12 python3-pip git # Fedora; use python3.13 if preferred # sudo pacman -S python python-pip git # Arch ``` #### macOS ```bash -brew install python@3.12 git +brew install python@3.12 git # python@3.13 is also supported ``` #### Windows -1. Download Python 3.12 from [python.org](https://www.python.org/downloads/). +1. Download Python 3.12 or 3.13 from [python.org](https://www.python.org/downloads/). 2. Run the installer and select "Add Python to PATH". 3. Install Git from [git-scm.com](https://git-scm.com/download/win). @@ -119,14 +121,31 @@ python -m pytest -m "not performance and not stress" Expected results: both CLI commands display help. For contributor installs, the configured suite passes and enforces coverage of at least 50%. -### 4.7 Understand Modern Packaging Features +### 4.7 Validated Platform Matrix + +The `0.9.1` wheel and source distribution are clean-install tested on every +combination below. Each test runs `version --short` and root help through both +the `timelocker` and `tl` entry points. + +| Operating system | Python 3.12 | Python 3.13 | +|------------------|-------------|-------------| +| Linux | wheel and sdist | wheel and sdist | +| macOS | wheel and sdist | wheel and sdist | +| Windows | wheel and sdist | wheel and sdist | + +This validates installation and safe CLI startup. Backup and restore operations +still require a compatible Restic executable and any backend-specific +credentials. No PyPI distribution is currently published; use the source path +above until an authorized release provides downloadable artifacts. + +### 4.8 Understand Modern Packaging Features - `pyproject.toml` for modern builds (PEP 517/518). - Optional dependency groups (`dev`, `gui`). S3 and B2 runtime dependencies are included in the base installation. - Entry points install both `timelocker` and `tl` commands. -### 4.8 Configure Environment +### 4.9 Configure Environment Basic configuration focuses on setting up repositories and targets. For cloud backends, export credentials: @@ -141,7 +160,7 @@ export B2_ACCOUNT_ID=your_account_id export B2_ACCOUNT_KEY=your_account_key ``` -### 4.9 Optional: Manual Vacuum / Additional Sections +### 4.10 Optional: Manual Vacuum / Additional Sections (If applicable, include other configuration tasks; original document contains extended instructions you may retain here.) diff --git a/docs/specs/007-release-readiness-stabilization/tasks.md b/docs/specs/007-release-readiness-stabilization/tasks.md index 78fcf6b..d49a66d 100644 --- a/docs/specs/007-release-readiness-stabilization/tasks.md +++ b/docs/specs/007-release-readiness-stabilization/tasks.md @@ -170,7 +170,7 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 ## Phase 3: Build and Install v0.9.1 -- [~] T006 Prepare version `0.9.1` safely and build reproducible artifacts. +- [x] T006 Prepare version `0.9.1` safely and build reproducible artifacts. - Depends on: T005 - Requirement: Requirement 3 - Acceptance Criteria: Requirement 3 AC1, AC2, AC3, AC4, AC5 @@ -184,16 +184,26 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 - Validation: Pre/post Git and release-state comparison, `python scripts/bump_version.py bump patch --no-commit --no-tag`, version guard, `python -m build`, artifact inspection. - - Evidence: Implementation started from clean commit `6572f65`; charter, metadata, version helper/configuration, release workflow, installation guide, and version process were reviewed directly. The missing canonical-context artifact is an advisory with no concrete authority ambiguity, so no duplicate context file is needed. - - Status: Capturing a clean pre-change external-state snapshot before the non-publishing version bump. + - Evidence: From clean commit `9348c58413af3422167faf0a052ef5e80571d647`, the exact non-publishing helper changed only the three version sources. Final run `29679083454` built one shared wheel/sdist set, validated version `0.9.1`, `Requires-Python`, both entry points, nine package-data files, and hashes. The deliberate `0.9.0` guard failed before artifact checks. Tags remained empty, GitHub releases remained empty, and the release workflow retained its 11 historical runs with the newest dated 2025-09-27. + - Status: Complete on 2026-07-19; no tag, GitHub release, or publication was created. - Evidence mode: implementation - - [ ] T006.1 Record pre-change commit, tag, tag-triggered release-workflow run, and GitHub-release identity. - - [ ] T006.2 Run the version helper with both commit and tag side effects disabled. - - [ ] T006.3 Update `requires-python` to `>=3.12,<3.14`, remove `OS Independent`, and reconcile Python and OS classifiers. - - [ ] T006.4 Build sdist and wheel once; inspect metadata, contents, entry points, and hashes. - - [ ] T006.5 Prove a version mismatch blocks the guard and prove commit, tag, tag-triggered release-workflow run, and release identity did not change. + - [x] T006.1 Record pre-change commit, tag, tag-triggered release-workflow run, and GitHub-release identity. + - Evidence: Baseline was commit `9348c58413af3422167faf0a052ef5e80571d647`, zero tags, 11 historical release-workflow runs (newest 2025-09-27), and zero GitHub releases. + - Status: Complete on 2026-07-19. + - [x] T006.2 Run the version helper with both commit and tag side effects disabled. + - Evidence: `python scripts/bump_version.py bump patch --no-commit --no-tag` advanced `0.9.0` to `0.9.1` and modified only the three configured version files. + - Status: Complete on 2026-07-19. + - [x] T006.3 Update `requires-python` to `>=3.12,<3.14`, remove `OS Independent`, and reconcile Python and OS classifiers. + - Evidence: Final metadata declares only Python 3.12/3.13 and the explicitly validated Linux, macOS, and Windows classifiers. + - Status: Complete on 2026-07-19. + - [x] T006.4 Build sdist and wheel once; inspect metadata, contents, entry points, and hashes. + - Evidence: Run `29679083454` built one shared artifact set and validated version, Python range, two entry points, nine data files, and SHA-256 hashes before matrix fan-out. + - Status: Complete on 2026-07-19. + - [x] T006.5 Prove a version mismatch blocks the guard and prove commit, tag, tag-triggered release-workflow run, and release identity did not change. + - Evidence: Expected version `0.9.0` exited nonzero before artifact checks; the helper itself left HEAD and all external release identities at their baseline values. + - Status: Complete on 2026-07-19. -- [ ] T007 Validate wheel and sdist across the declared support matrix. +- [x] T007 Validate wheel and sdist across the declared support matrix. - Depends on: T006 - Requirement: Requirement 4 - Acceptance Criteria: Requirement 4 AC1, AC2, AC3, AC4, AC5 @@ -204,20 +214,30 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 macOS, and Windows for Python 3.12 and 3.13; an unvalidated combination blocks readiness until its support claim is corrected and reviewed. - Validation: Six OS/Python combinations, both artifact types, both console entry points. - - Evidence: Pending. - - [ ] T007.1 Add or reconcile the six-combination Linux/macOS/Windows and Python 3.12/3.13 smoke matrix. - - [ ] T007.2 Install the wheel and run version, root help, and safe quick-start smoke checks in every combination. - - [ ] T007.3 Install the sdist and run the identical smoke contract in every combination. - - [ ] T007.4 Record platform prerequisites and correct any support claim that cannot be validated. + - Evidence: Read-only pull-request run `29679083454` passed a single shared build plus 12 install jobs: wheel and sdist on Linux, macOS, and Windows with Python 3.12 and 3.13. Both console entry points passed version and root-help checks. The first matrix exposed Windows `cp1252`-unsafe help glyphs; commit `4a2d998` replaced them and the full rerun passed. The installation guide records the verified matrix, Python range, Restic prerequisite, and publication boundary. + - Status: Complete on 2026-07-19. + - [x] T007.1 Add or reconcile the six-combination Linux/macOS/Windows and Python 3.12/3.13 smoke matrix. + - Evidence: `.github/workflows/artifact-smoke.yml` defines the full three-OS by two-Python matrix and reuses one uploaded artifact set. + - Status: Complete on 2026-07-19. + - [x] T007.2 Install the wheel and run version, root help, and safe quick-start smoke checks in every combination. + - Evidence: All six wheel jobs passed both `timelocker` and `tl` version and root-help checks in run `29679083454`. + - Status: Complete on 2026-07-19. + - [x] T007.3 Install the sdist and run the identical smoke contract in every combination. + - Evidence: All six sdist jobs passed the identical two-entry-point contract in run `29679083454`. + - Status: Complete on 2026-07-19. + - [x] T007.4 Record platform prerequisites and correct any support claim that cannot be validated. + - Evidence: The installation guide now records Python `>=3.12,<3.14`, Restic 0.18.0 or later, the verified matrix, and the no-PyPI-publication boundary; Windows help was corrected and revalidated rather than dropping support. + - Status: Complete on 2026-07-19. -- [ ] T008 Checkpoint - Artifact and installation readiness. +- [x] T008 Checkpoint - Artifact and installation readiness. - Depends on: T007 - Requirements: Requirements 3 and 4 - Acceptance: Side-effect safety, artifact identity, hashes, six-combination installation results, platform coverage, and residual risk are recorded before release rehearsal. - Validation: Review artifact and clean-install evidence against CP-002, CP-003, and CP-004. - - Evidence: Pending. + - Evidence: CP-002 passed through source/artifact identity checks and the negative mismatch guard. CP-003 passed all 12 artifact install jobs in run `29679083454`. CP-004 side-effect evidence shows zero tags, zero GitHub releases, and no new release-workflow run. Final artifact hashes are `a3d5eb9f423cbb38a829387f286c261c93e6bedd2a9cc1413069981d6a268bc5` (wheel) and `75c5fc42a3a2909094d9d1ed52466ecdd05266160f36ae1eb04cb23e9236b843` (sdist). The only observed advisory is upstream Actions Node.js 20 deprecation; it did not affect validation and remains a workflow-maintenance risk. + - Status: Complete on 2026-07-19; Phase 3 passed and T009 is next. ## Phase 4: Rehearse, Promote, and Review diff --git a/docs/specs/007-release-readiness-stabilization/verification.md b/docs/specs/007-release-readiness-stabilization/verification.md index f05a95b..8220ee8 100644 --- a/docs/specs/007-release-readiness-stabilization/verification.md +++ b/docs/specs/007-release-readiness-stabilization/verification.md @@ -21,10 +21,10 @@ separate explicit approval. |------|-----------|--------|----------| | Acceptance traceability complete | yes | passed | Lifecycle stage readiness reports zero acceptance, property, context, downstream-review, or blocking gaps after reconciliation. | | Substantive requirements and design review | yes | passed | Review on 2026-07-18 produced TLR-001 through TLR-006; all six findings were reconciled into the package before implementation. | -| Task evidence complete | yes | pending | T001-T005 passed; T006-T013 pending. | +| Task evidence complete | yes | pending | T001-T008 passed; T009-T013 pending. | | Normal and dependency-owning test profiles pass | yes | passed | GitHub Actions run `29676747955` passed normal, MinIO, coverage quality-gate, and notification jobs. | | Stress implementation and disposition recorded | yes | passed | T004 implementation and repeat evidence are recorded in GitHub issue #68. | -| Artifacts and six-combination clean installs validate | yes | pending | T006-T008. | +| Artifacts and six-combination clean installs validate | yes | passed | Run `29679083454` passed one build and all 12 artifact/OS/Python jobs. | | Release interface and rehearsal prove no publication side effect | yes | pending | T009-T010. | | Durable documentation and communications promoted | yes | pending | T011-T012 and promotion table below. | | Final lifecycle checks and expert review pass | yes | pending | T013; package-creation review does not replace final implementation review. | @@ -37,9 +37,9 @@ separate explicit approval. | complete collection compared with normal, `minio`, performance, and stress selections | Prove collection safety and node ownership | passed | 2,817 total nodes partition into 2,760 normal, 53 performance/stress, and four live MinIO nodes. | | `python -m pytest -m minio` with provisioned service | Validate live S3 integration and dependency preflight | passed | Four local live nodes passed in 20.39 seconds; the provisioned job also passed in run `29676747955`. | | `python -m pytest -m "performance or stress" --no-cov` | Extended performance and stress profile | passed | 53 passed, 2,770 deselected in 45.60 seconds; issue #68 records three repeated targeted medians and the no-coverage rationale. | -| `python scripts/bump_version.py bump patch --no-commit --no-tag` plus pre/post commit, tag, tag-triggered release-workflow run, and release identity | Prepare `0.9.1` without publication side effects | pending | T006. | -| `python -m build`, version guard, metadata inspection, and SHA-256 generation | Build and prove artifact identity | pending | T006. | -| wheel and sdist smoke installs on Linux, macOS, and Windows for Python 3.12 and 3.13 | Prove CP-003 and all declared support claims | pending | T007 six-combination matrix. | +| `python scripts/bump_version.py bump patch --no-commit --no-tag` plus pre/post commit, tag, tag-triggered release-workflow run, and release identity | Prepare `0.9.1` without publication side effects | passed | Helper changed only `.bumpversion.cfg`, `pyproject.toml`, and `src/TimeLocker/__init__.py`; zero tags/releases and 11 historical release runs remained. | +| `python -m build`, version guard, metadata inspection, and SHA-256 generation | Build and prove artifact identity | passed | Final run `29679083454` validated one wheel, one sdist, metadata, entry points, nine data files, and hashes; wrong-version guard failed as intended. | +| wheel and sdist smoke installs on Linux, macOS, and Windows for Python 3.12 and 3.13 | Prove CP-003 and all declared support claims | passed | Run `29679083454`: all 12 wheel/sdist jobs passed both CLI entry points. | | safe pre-tag interface tests and non-publishing rehearsal | Prove CP-004, including failure paths and unchanged external state | pending | T009-T010. | | repository Markdown/link checks and `git diff --check` | Validate specification and durable-doc hygiene | pending | Package reconciliation and T011-T013. | @@ -49,8 +49,8 @@ separate explicit approval. |-------------|------------------------------|----------|---------------| | R1 | AC1-AC6 | T001-T003 passed; GitHub Actions run `29676747955` | Marker and workflow contract tests guard future profile drift. | | R2 | AC1-AC4 | T004-T005 passed; issue #68 records environment, baseline, tolerance, and repeat evidence | Post-change hosted evidence follows the explicitly requested commit. | -| R3 | AC1-AC5 | T006 and T008 pending | Side-effecting defaults must remain disabled. | -| R4 | AC1-AC5 | T007-T008 and T011 pending | Unavailable runner blocks the associated support claim. | +| R3 | AC1-AC5 | T006 and T008 passed; run `29679083454` | Preparation must continue to use both disabling flags. | +| R4 | AC1-AC5 | T007-T008 passed; installation guide updated | T011 will reconcile the broader durable release procedure. | | R5 | AC1-AC6 | T009-T013 pending | Human operator error at first actual tag. | ## Correctness Property Coverage @@ -58,9 +58,9 @@ separate explicit approval. | Property | Covered By | Evidence | Residual Risk | |----------|------------|----------|---------------| | CP-001 | T001-T003, collection partition and workflow run `29676747955` | passed | Contract tests guard marker, selector, service, and artifact-transfer drift. | -| CP-002 | T006 version guard and negative test | pending | None expected after automated guard. | -| CP-003 | T007 six-combination artifact matrix | pending | Runner availability is a blocking support gap. | -| CP-004 | T006, T008-T010, and T013 external-state comparisons | pending | Actual tag behavior remains separately controlled. | +| CP-002 | T006 version guard and negative test | passed | Automated guard covers source and artifact identity. | +| CP-003 | T007 six-combination artifact matrix | passed | Final shared-artifact run passed all 12 jobs. | +| CP-004 | T006, T008-T010, and T013 external-state comparisons | partial | T006/T008 passed; rehearsal and final review remain. | | CP-005 | T012-T013 changelog and derived release-body review | pending | Review quality. | ## Agent Readiness Evidence @@ -84,9 +84,9 @@ separate explicit approval. | T003 | passed | Actions run `29676747955`: normal, MinIO, quality-gate, and notification jobs passed | Phase 1 checkpoint complete. | | T004 | passed | Correctness/timing split, 1.0-second baseline, 2.0x tolerance, three repeat runs, and 53-test extended profile | Issue #68 contains the environment and chronological evidence. | | T005 | passed | T003 hosted run plus T004 local normal/extended profiles and issue evidence | Phase 2 checkpoint complete. | -| T006 | pending | Side-effecting helper defaults identified | Safe bump, artifact, and external-state evidence pending. | -| T007 | pending | Six-combination contract defined | Artifact matrix pending. | -| T008 | pending | | Artifact checkpoint pending. | +| T006 | passed | Safe helper invocation, identity guard, one shared build, metadata/data/hash inspection, and unchanged external release state | No tag or release created. | +| T007 | passed | Run `29679083454` passed wheel and sdist on all six OS/Python combinations | Windows encoding defect found in the first run and fixed by `4a2d998`. | +| T008 | passed | CP-002, CP-003, and Phase 3 CP-004 evidence reviewed | Phase 3 checkpoint complete. | | T009 | pending | | Safe pre-tag interface pending. | | T010 | pending | | Non-publishing rehearsal pending. | | T011 | pending | Existing version process selected as promotion target | Durable updates pending. | @@ -117,6 +117,11 @@ separate explicit approval. | 2026-07-19 | Repeated calibrated selection stress contract | passed | Three `--no-cov` runs reported 0.160, 0.176, and 0.173 second medians against a 1.0-second baseline and 2.0x tolerance. | | 2026-07-19 | Phase 2 extended profile | passed | 53 passed and 2,770 deselected in 45.60 seconds without coverage instrumentation. | | 2026-07-19 | Phase 2 normal profile | passed | 2,765 passed, one skipped, 57 deselected, and 52.14% coverage in 726.60 seconds. | +| 2026-07-19 | Non-publishing version preparation | passed | From `9348c584`, the helper changed only the three configured version files; tag, release-workflow, and GitHub-release identity did not change. | +| 2026-07-19 | Local artifact inspection and negative guard | passed | Version, Python range, both entry points, nine package-data files, and hashes passed; expected version `0.9.0` failed before artifact checks. | +| 2026-07-19 | Hosted artifact run `29678906850` | failed as designed gate | Linux/macOS passed; all Windows jobs exposed `cp1252`-unsafe help glyphs. | +| 2026-07-19 | Windows help portability fix `4a2d998` | passed | ASCII metavar/epilog plus a `cp1252` regression contract removed the installation blocker. | +| 2026-07-19 | Hosted artifact run `29679083454` | passed | One shared build and all 12 wheel/sdist matrix jobs passed across Linux, macOS, Windows, Python 3.12, and Python 3.13. | ## Manual Or External Verification @@ -134,8 +139,8 @@ release artifacts must be linked here before release readiness can be approved. - Stress thresholds can remain host-sensitive until T004 evidence is accepted. - Version tooling commits and tags by default; every preparation run must use both disabling flags and prove external state is unchanged. -- All six declared OS/Python combinations are release blockers until validated - or their support claims are corrected. +- The verified matrix depends on hosted runner availability; future unavailable + combinations block readiness until rerun or the support claim is reviewed. - The first actual tag exercises external publication behavior that rehearsal cannot reproduce fully; it remains a human-controlled release risk. @@ -144,7 +149,7 @@ release artifacts must be linked here before release readiness can be approved. | Spec Content | Durable Destination Or Deferral | Status | Evidence | |--------------|---------------------------------|--------|----------| | Test profile contract | `docs/4-testing/README.md` | pending | T002-T003. | -| Verified installation matrix | `docs/guides/user/installation.md` | pending | T007 and T011. | +| Verified installation matrix | `docs/guides/user/installation.md` | partial | T007 matrix and prerequisites promoted; T011 owns final procedure reconciliation. | | Version preparation, release procedure, and rollback | `docs/processes/version-management.md`, linked from `docs/processes/README.md` | pending | T011; no duplicate process document. | | Version contents and release communications | `CHANGELOG.md`; GitHub release body derived from its `v0.9.1` section | pending | T012. | | Front-door support and version claims | `README.md` if current text requires correction | pending | T011. | @@ -154,7 +159,7 @@ release artifacts must be linked here before release readiness can be approved. ### Spec Cleanup Decision - **Cleanup action:** keep active -- **Reason:** Implementation is active; T001 is complete and T002-T013 remain. +- **Reason:** Implementation is active; T001-T008 are complete and T009-T013 remain. - **Final spec commit:** pending - **Closure log path:** `docs/history/spec-closure-log.md` - **Closure log entry updated:** no @@ -175,10 +180,11 @@ release artifacts must be linked here before release readiness can be approved. ### Risk Rationale -The corrected normal profile passes locally, but provisioned and hosted profile -evidence is incomplete, the tag-triggered release workflow has no repository -release history, and artifact or clean-install evidence for `0.9.1` does not -exist. No release should proceed until the required gates are complete. +Normal, provisioned MinIO, extended, artifact, and cross-platform install gates +now pass. The tag-triggered release workflow still has no successful repository +release history, and Phase 4 rehearsal, durable procedure reconciliation, +communications, and final expert review remain. No release should proceed until +those gates are complete. ## Readiness Decision diff --git a/src/TimeLocker/cli.py b/src/TimeLocker/cli.py index e427757..957664e 100644 --- a/src/TimeLocker/cli.py +++ b/src/TimeLocker/cli.py @@ -405,7 +405,7 @@ def complete_operation(self, **_kwargs: object) -> None: # pragma: no cover - n no_args_is_help=True, context_settings=CLI_CONTEXT_SETTINGS, ) -app.info.options_metavar = "[OPTIONS]" +app.info.options_metavar = "" def _merge_typer_app(target_app: typer.Typer, source_app: typer.Typer) -> None: @@ -416,20 +416,20 @@ def _merge_typer_app(target_app: typer.Typer, source_app: typer.Typer) -> None: # Create sub-apps for new hierarchy backup_app = typer.Typer(help="Backup operations", no_args_is_help=True, context_settings=CLI_CONTEXT_SETTINGS) -backup_app.info.options_metavar = "[OPTIONS]" +backup_app.info.options_metavar = "" snapshots_app = typer.Typer(help="Snapshot operations", context_settings=CLI_CONTEXT_SETTINGS) -snapshots_app.info.options_metavar = "[OPTIONS]" +snapshots_app.info.options_metavar = "" repos_app = typer.Typer(help="Repository operations", context_settings=CLI_CONTEXT_SETTINGS) -repos_app.info.options_metavar = "[OPTIONS]" +repos_app.info.options_metavar = "" config_app = typer.Typer(help="Configuration management commands", context_settings=CLI_CONTEXT_SETTINGS) -config_app.info.options_metavar = "[OPTIONS]" +config_app.info.options_metavar = "" credentials_app = typer.Typer(help="Credential management commands", context_settings=CLI_CONTEXT_SETTINGS) -credentials_app.info.options_metavar = "[OPTIONS]" +credentials_app.info.options_metavar = "" # Create security sub-app security_app = typer.Typer(help="Security management commands", context_settings=CLI_CONTEXT_SETTINGS) -security_app.info.options_metavar = "[OPTIONS]" +security_app.info.options_metavar = "" # Add sub-apps to main app app.add_typer(backup_app, name="backup") @@ -443,14 +443,14 @@ def _merge_typer_app(target_app: typer.Typer, source_app: typer.Typer) -> None: # Create config sub-apps config_import_app = typer.Typer(help="Import configuration commands", context_settings=CLI_CONTEXT_SETTINGS) -config_import_app.info.options_metavar = "[OPTIONS]" +config_import_app.info.options_metavar = "" config_export_app = typer.Typer(help="Export configuration commands", context_settings=CLI_CONTEXT_SETTINGS) -config_export_app.info.options_metavar = "[OPTIONS]" +config_export_app.info.options_metavar = "" # Create migrate app for configuration migration and validation migrate_app = typer.Typer(help="Configuration migration and validation commands", context_settings=CLI_CONTEXT_SETTINGS) -migrate_app.info.options_metavar = "[OPTIONS]" +migrate_app.info.options_metavar = "" # Add config sub-apps config_app.add_typer(config_import_app, name="import") @@ -461,7 +461,7 @@ def _merge_typer_app(target_app: typer.Typer, source_app: typer.Typer) -> None: # Create repos sub-apps repos_credentials_app = typer.Typer(help="Repository credential management", context_settings=CLI_CONTEXT_SETTINGS) -repos_credentials_app.info.options_metavar = "[OPTIONS]" +repos_credentials_app.info.options_metavar = "" # Add repos sub-apps repos_app.add_typer(repos_credentials_app, name="credentials") diff --git a/src/TimeLocker/cli_modules/commands/backup.py b/src/TimeLocker/cli_modules/commands/backup.py index 4b48fb7..a7a6611 100644 --- a/src/TimeLocker/cli_modules/commands/backup.py +++ b/src/TimeLocker/cli_modules/commands/backup.py @@ -51,7 +51,7 @@ no_args_is_help=True, context_settings=CLI_CONTEXT_SETTINGS ) -backup_app.info.options_metavar = "[OPTIONS]" +backup_app.info.options_metavar = "" BackupDisplayValue: TypeAlias = str | int | float | bool | list[object] | dict[str, object] | tuple[object, ...] diff --git a/src/TimeLocker/cli_modules/commands/base.py b/src/TimeLocker/cli_modules/commands/base.py index 3c15c27..620cfae 100644 --- a/src/TimeLocker/cli_modules/commands/base.py +++ b/src/TimeLocker/cli_modules/commands/base.py @@ -339,7 +339,7 @@ def create_typer_app( no_args_is_help=no_args_is_help, context_settings=CLI_CONTEXT_SETTINGS ) - app.info.options_metavar = "[OPTIONS]" + app.info.options_metavar = "" return app diff --git a/src/TimeLocker/cli_modules/commands/restore.py b/src/TimeLocker/cli_modules/commands/restore.py index e1f361c..a641eed 100644 --- a/src/TimeLocker/cli_modules/commands/restore.py +++ b/src/TimeLocker/cli_modules/commands/restore.py @@ -54,7 +54,7 @@ no_args_is_help=True, context_settings=CLI_CONTEXT_SETTINGS ) -restore_app.info.options_metavar = "[OPTIONS]" +restore_app.info.options_metavar = "" def _get_repository(repository_input: str, config_dir: Optional[Path] = None): From a7544627e57652c80952145e7f037a3b8308462c Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:58:10 +0100 Subject: [PATCH 15/72] feat(release): add safe release rehearsal Separate read-only release validation from publishing, enforce tag and artifact intent, and document the reproducible rehearsal workflow. Complete Spec 007 Phase 4 evidence without creating a tag, release, or package publication. --- .github/workflows/release-validation.yml | 97 ++++++ .github/workflows/release.yml | 100 ++---- CHANGELOG.md | 74 +++-- README.md | 8 +- docs/guides/user/installation.md | 6 +- docs/processes/README.md | 58 +--- docs/processes/version-management.md | 291 +++++++----------- .../change-impact.md | 21 +- .../tasks.md | 91 ++++-- .../verification.md | 90 +++--- scripts/extract_release_notes.py | 42 +++ scripts/validate_release_intent.py | 63 ++++ scripts/validate_release_workflows.py | 64 ++++ .../project/test_release_artifacts.py | 63 ++++ .../TimeLocker/services/test_tool_manager.py | 14 +- 15 files changed, 658 insertions(+), 424 deletions(-) create mode 100644 .github/workflows/release-validation.yml create mode 100644 scripts/extract_release_notes.py create mode 100644 scripts/validate_release_intent.py create mode 100644 scripts/validate_release_workflows.py diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml new file mode 100644 index 0000000..2da9758 --- /dev/null +++ b/.github/workflows/release-validation.yml @@ -0,0 +1,97 @@ +name: TimeLocker Release Validation + +on: + workflow_call: + inputs: + version_ref: + description: Semantic release reference to validate + required: true + type: string + workflow_dispatch: + inputs: + version_ref: + description: Semantic release reference to rehearse without publishing + required: true + default: v0.9.1 + type: string + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + env: + VERSION_REF: ${{ inputs.version_ref }} + + steps: + - name: Checkout source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install Restic 0.18.0 + shell: bash + run: | + set -euo pipefail + restic_version="0.18.0" + wget --quiet "https://github.com/restic/restic/releases/download/v${restic_version}/restic_${restic_version}_linux_amd64.bz2" + wget --quiet "https://github.com/restic/restic/releases/download/v${restic_version}/SHA256SUMS" + grep " restic_${restic_version}_linux_amd64.bz2$" SHA256SUMS | sha256sum --check --strict + bunzip2 "restic_${restic_version}_linux_amd64.bz2" + sudo install -m 0755 "restic_${restic_version}_linux_amd64" /usr/local/bin/restic + restic version + + - name: Install build and test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install build + python -m pip install -e '.[dev]' + + - name: Verify release intent and workflow boundary + run: | + python scripts/validate_release_intent.py --version-ref "$VERSION_REF" + python scripts/validate_release_workflows.py + + - name: Run required test suite + env: + PYTHONPATH: src + run: python -m pytest -m "not performance and not stress and not minio" + + - name: Build and validate distributions + run: | + python -m build + python scripts/validate_release_artifacts.py --expected-version "${VERSION_REF#v}" + + - name: Smoke wheel and source distribution + shell: bash + run: | + set -euo pipefail + python scripts/smoke_release_artifact.py dist/*.whl --expected-version "${VERSION_REF#v}" + python scripts/smoke_release_artifact.py dist/*.tar.gz --expected-version "${VERSION_REF#v}" + + - name: Derive release notes from changelog + run: >- + python scripts/extract_release_notes.py + --version "${VERSION_REF#v}" + --output release-notes.md + + - name: Upload validated distributions + uses: actions/upload-artifact@v4 + with: + name: timelocker-distributions-${{ inputs.version_ref }} + path: dist/* + if-no-files-found: error + + - name: Upload release-note preview + uses: actions/upload-artifact@v4 + with: + name: timelocker-release-notes-${{ inputs.version_ref }} + path: release-notes.md + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d229e20..72ffa93 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,95 +6,31 @@ on: - "v*.*.*" permissions: - contents: write + contents: read jobs: - release: + validate: + uses: ./.github/workflows/release-validation.yml + with: + version_ref: ${{ github.ref_name }} + + publish: + needs: validate runs-on: ubuntu-latest + permissions: + contents: write steps: - - name: Checkout tagged source - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - - - name: Set up Python 3.12 - uses: actions/setup-python@v5 + - name: Download validated distributions + uses: actions/download-artifact@v4 with: - python-version: "3.12" - cache: pip - - - name: Install Restic 0.18.0 - shell: bash - run: | - set -euo pipefail - restic_version="0.18.0" - wget --quiet "https://github.com/restic/restic/releases/download/v${restic_version}/restic_${restic_version}_linux_amd64.bz2" - wget --quiet "https://github.com/restic/restic/releases/download/v${restic_version}/SHA256SUMS" - grep " restic_${restic_version}_linux_amd64.bz2$" SHA256SUMS | sha256sum --check --strict - bunzip2 "restic_${restic_version}_linux_amd64.bz2" - sudo install -m 0755 "restic_${restic_version}_linux_amd64" /usr/local/bin/restic - restic version - - - name: Install build and test dependencies - run: | - python -m pip install --upgrade pip - python -m pip install build - python -m pip install -e '.[dev]' - - - name: Verify tag and package versions - shell: bash - run: | - set -euo pipefail - python - <<'PY' - import os - import tomllib - - from TimeLocker import __version__ - - tag = os.environ["GITHUB_REF_NAME"] - if not tag.startswith("v"): - raise SystemExit(f"Release tag must start with v: {tag}") - - tag_version = tag[1:] - with open("pyproject.toml", "rb") as pyproject_file: - package_version = tomllib.load(pyproject_file)["project"]["version"] - - if tag_version != package_version or package_version != __version__: - raise SystemExit( - "Version mismatch: " - f"tag={tag_version}, pyproject={package_version}, package={__version__}" - ) - - print(f"Version verified: {package_version}") - PY - - - name: Run required test suite - env: - PYTHONPATH: src - run: python -m pytest -m "not performance and not stress" - - - name: Build source and wheel distributions - run: | - python -m build - sha256sum dist/* > dist/SHA256SUMS - - - name: Smoke test the built wheel - shell: bash - run: | - set -euo pipefail - python -m venv /tmp/timelocker-release-smoke - /tmp/timelocker-release-smoke/bin/python -m pip install --upgrade pip - /tmp/timelocker-release-smoke/bin/python -m pip install dist/*.whl - installed_version=$(/tmp/timelocker-release-smoke/bin/tl version --short) - test "$installed_version" = "${GITHUB_REF_NAME#v}" + name: timelocker-distributions-${{ github.ref_name }} + path: dist - - name: Upload distribution artifacts - uses: actions/upload-artifact@v4 + - name: Download derived release notes + uses: actions/download-artifact@v4 with: - name: timelocker-${{ github.ref_name }} - path: dist/* - if-no-files-found: error + name: timelocker-release-notes-${{ github.ref_name }} - name: Create GitHub release env: @@ -102,5 +38,5 @@ jobs: run: | gh release create "$GITHUB_REF_NAME" dist/* \ --verify-tag \ - --generate-notes \ + --notes-file release-notes.md \ --title "TimeLocker $GITHUB_REF_NAME" diff --git a/CHANGELOG.md b/CHANGELOG.md index c63ec9f..8355e4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,53 +4,61 @@ All notable changes to TimeLocker are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -TimeLocker currently declares package version `0.9.0` and Beta status. The -repository has no release tags, so this file does not present an untagged -version as a published release. Git history retains implementation detail; -[`docs/history/spec-closure-log.md`](docs/history/spec-closure-log.md) provides -compact discovery for closed specification packages. +A version section records release contents, not proof that publication occurred. +The `Prepared` qualifier on `0.9.1` marks it as a Beta release candidate until +the release maintainer approves and finalizes the production tag. ## [Unreleased] +No changes have been assigned beyond the `0.9.1` release candidate. + +## [0.9.1] - Prepared 2026-07-19 + ### Added -- Active specification lifecycle governance, deterministic readiness checks, - durable-document promotion, and compact closure history. -- Automatic test workflow triggers for pushes and pull requests to `main` and - `staging`. +- Deterministic normal and provisioned-MinIO CI profiles, with explicit + dependency ownership and failure preflight. +- Reproducible wheel and source-distribution validation for package metadata, + both CLI entry points, packaged data, and SHA-256 hashes. +- Clean-install smoke coverage for wheel and source distributions on Linux, + macOS, and Windows with Python 3.12 and 3.13. +- A reusable, read-only release rehearsal that derives its release-body preview + from this changelog section. ### Changed -- Consolidated pytest configuration in `pyproject.toml` as the source of truth, - including the 50 percent coverage gate. -- Reorganized documentation around current requirements, architecture, - implementation, testing, guides, references, and temporary active specs. -- Centralized repository agent instructions under `docs/guides/ai-agent/`. +- Bounded supported Python versions to `>=3.12,<3.14` and documented Restic + 0.18.0 or later as the runtime prerequisite. +- Replaced the host-sensitive fixed-iteration selection stress gate with a + calibrated correctness and timing contract. +- Isolated GitHub release creation behind successful validation and a single + job-scoped `contents: write` permission. ### Fixed -- Removed stale navigation to deleted plans, update diaries, archives, and - superseded requirement/design packages. -- Aligned package and version-bump configuration with the declared `0.9.0` - project version. -- Stopped representing historical `v1.0.0` design inventory as a released - implementation. +- Prevented normal CI from contacting an unprovisioned MinIO service. +- Made root CLI help safe for the Windows default `cp1252` encoding. +- Aligned package, source, and version-bump metadata at `0.9.1`. + +### Known Limitations -## Current Beta Baseline +- This is a Beta release candidate. A production tag and GitHub release still + require separate maintainer approval. +- TimeLocker is not published to PyPI; install from source until an authorized + GitHub release provides downloadable artifacts. +- The first production tag will exercise GitHub release creation in the live + repository for the first time. The non-publishing rehearsal cannot reproduce + that final external write. +- GitHub Actions currently reports a non-blocking upstream Node.js runtime + deprecation advisory for pinned actions. -The current `0.9.0` codebase includes CLI support for repository management, -backup and recovery, file selection, scheduling, credentials, monitoring, and -service integration. Policy management is partially implemented. The REST API, -desktop GUI, and database-backed storage remain design ideas rather than -released features. +## Current Beta Feature Boundary + +TimeLocker includes CLI support for repository management, backup and recovery, +file selection, scheduling, credentials, monitoring, and service integration. +Policy management is partially implemented. The REST API, desktop GUI, and +database-backed storage remain design ideas rather than released features. See the [documentation status](docs/DOCUMENTATION-STATUS.md) for the current feature boundary and the [active specification index](docs/specs/README.md) for approved work in progress. - -## Historical Design Material - -The former `v1.0.0` changelog entry was an initial design specification, not -evidence of a tagged or published release. Current design belongs under -`docs/2-architecture/`; superseded plans, reports, and implementation diaries -remain recoverable through Git history. diff --git a/README.md b/README.md index a73f10a..fdb3b80 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg?logo=gnu)](https://www.gnu.org/licenses/gpl-3.0) -[![Python 3.12+](https://img.shields.io/badge/Python-3.12+-blue.svg?logo=python&logoColor=white)](https://www.python.org/downloads/) +[![Python 3.12–3.13](https://img.shields.io/badge/Python-3.12%E2%80%933.13-blue.svg?logo=python&logoColor=white)](https://www.python.org/downloads/) [![Status: Beta](https://img.shields.io/badge/Status-Beta-yellow.svg?logo=git)](https://github.com/Auriora/TimeLocker) [![GitHub Actions CI](https://img.shields.io/github/actions/workflow/status/Auriora/TimeLocker/test-suite.yml?branch=main&label=CI&logo=github)](https://github.com/Auriora/TimeLocker/actions/workflows/test-suite.yml) [![Quality Gate](https://img.shields.io/badge/Quality%20Gate-50%25%20Coverage-brightgreen?logo=sonarqube)](https://github.com/Auriora/TimeLocker/actions) @@ -108,7 +108,7 @@ This project is intended for: ### Project dependencies -- Python 3.12 or higher +- Python 3.12 or 3.13 - Restic backup tool installed and accessible in PATH - For cloud storage: - S3: boto3 package (`pip install boto3`) @@ -385,7 +385,7 @@ This is particularly suitable for libraries and applications that you want to re ## Document Information -- Version: 0.9.0 -- Last Updated: 2026-07-18 +- Version: 0.9.1 (prepared, not published) +- Last Updated: 2026-07-19 - Author: Bruce Cherrington - Copyright © Bruce Cherrington diff --git a/docs/guides/user/installation.md b/docs/guides/user/installation.md index 2df9ecd..f5da482 100644 --- a/docs/guides/user/installation.md +++ b/docs/guides/user/installation.md @@ -115,11 +115,13 @@ python -m pip install -e '.[dev]' timelocker --help tl --help python -c "from TimeLocker.backup_manager import BackupManager; print('TimeLocker installed successfully')" -python -m pytest -m "not performance and not stress" +python -m pytest -m "not performance and not stress and not minio" ``` Expected results: both CLI commands display help. For contributor installs, the -configured suite passes and enforces coverage of at least 50%. +normal suite passes and enforces coverage of at least 50%. Live MinIO tests are +owned by the separately provisioned profile documented in +[`docs/4-testing/README.md`](../../4-testing/README.md). ### 4.7 Validated Platform Matrix diff --git a/docs/processes/README.md b/docs/processes/README.md index 5ca1d02..5a42593 100644 --- a/docs/processes/README.md +++ b/docs/processes/README.md @@ -1,55 +1,23 @@ --- -title: "Processes Documentation" +title: Processes doc_type: reference -id: "RM-011" -type: [ readme ] +id: RM-011 status: active -owner: "Auriora Team" -last_reviewed: 2026-07-18 +owner: Auriora Team +last_reviewed: 2026-07-19 tags: [readme, processes] -links: - tooling: [] --- -# Processes Documentation +# Processes -- **Owner**: Auriora Team -- **Status**: Approved -- **Created Date**: 27-10-2023 -- **Last Updated**: 27-10-2023 +Current operating procedures belong here. Delivery plans and point-in-time +evidence belong in active specs, issues, pull requests, or CI runs. -## 1. Purpose +## Current Procedures -**When to use this template**: This folder contains documents that define standard operating procedures for the project or team. A good process document ensures -consistency, reduces errors, and helps new team members get up to speed quickly. -**Location**: `docs/processes/` +- [Version management and GitHub releases](version-management.md) — prepare, + rehearse, authorize, publish, verify, and recover a TimeLocker release. -## 2. What Belongs Here? - -- Definitions of workflows (e.g., code review process, incident management). -- Checklists for recurring operational tasks. -- Guidelines for team collaboration and communication. - -## 3. What Does NOT Belong Here? - -- Project delivery plans; use active specs or the issue tracker. -- Technical implementation details (see `../3-implementation/`). -- Point-in-time reports or metrics; retain them in CI or issue/PR artifacts. - -## 4. Usage Notes - -- **Checklist for Authors**: - - [ ] Fill in all placeholder values (e.g., `[Name or Team]`). - - [ ] Delete this `Usage Notes` section before publishing. - - [ ] Ensure the document is linked from the relevant `README.md` file. - -- **Naming Convention**: N/A for this file. - -## 5. Available Templates - -- Use the central [durable-document template](../templates/durable-document.md) - and tailor its current-behavior section into roles, procedure, and metrics. - -# References - -- Link to additional resources, specs, or tickets +Use the central [durable-document template](../templates/durable-document.md) +when a new recurring procedure is genuinely needed. Update an existing +procedure in place when it already owns the behavior. diff --git a/docs/processes/version-management.md b/docs/processes/version-management.md index df4a162..5f2152c 100644 --- a/docs/processes/version-management.md +++ b/docs/processes/version-management.md @@ -3,213 +3,134 @@ title: Version management and GitHub releases doc_type: process status: active owner: Auriora Team -last_reviewed: 2026-07-18 +last_reviewed: 2026-07-19 --- # Version Management And GitHub Releases -This document describes how to manage versions in the TimeLocker project using the automated version bumping system. +This procedure separates safe release preparation from publication. Running a +rehearsal does not authorize or create a commit, tag, GitHub release, or PyPI +distribution. The release maintainer must approve publication separately. -## Overview +## Release Contract -TimeLocker uses [semantic versioning](https://semver.org/) with the format `MAJOR.MINOR.PATCH`: +- Versions follow `MAJOR.MINOR.PATCH` semantic versioning. +- `pyproject.toml`, `src/TimeLocker/__init__.py`, and `.bumpversion.cfg` must + agree. +- A tag has the exact form `vMAJOR.MINOR.PATCH`. +- The matching `CHANGELOG.md` section is the canonical release-note source. +- `.github/workflows/release-validation.yml` owns read-only validation. +- `.github/workflows/release.yml` owns the isolated GitHub release action. +- TimeLocker is not published to PyPI. Adding registry publication requires a + separate scope decision, credentials, process changes, and approval. -- **MAJOR**: Incompatible API changes -- **MINOR**: New functionality in a backwards compatible manner -- **PATCH**: Backwards compatible bug fixes +## Roles And Approval Boundary -## Quick Start +Contributors may prepare versions and run non-publishing validation. Only the +release maintainer may approve a release commit and production tag. Pushing the +tag is the publication boundary: it starts the workflow whose final job creates +the GitHub release. Never push a release tag as part of preparation or +rehearsal. -### Show Current Version +## Prepare A Version Safely -```bash -# Activate virtual environment first -source .venv/bin/activate - -# Show current version and check consistency -python scripts/bump_version.py show -``` - -### Bump Version (Most Common) - -```bash -# Activate virtual environment first -source .venv/bin/activate - -# Patch version (1.0.0 -> 1.0.1) - for bug fixes -python scripts/bump_version.py bump patch - -# Minor version (1.0.0 -> 1.1.0) - for new features -python scripts/bump_version.py bump minor - -# Major version (1.0.0 -> 2.0.0) - for breaking changes -python scripts/bump_version.py bump major -``` - -### Preview Changes (Dry Run) +Start from the intended release branch and inspect the tree. Do not hide +unrelated changes. ```bash -# See what would happen without making changes -source .venv/bin/activate +git status --short --branch +python scripts/bump_version.py show python scripts/bump_version.py bump patch --dry-run -python scripts/bump_version.py bump minor --dry-run -python scripts/bump_version.py bump major --dry-run ``` -## Detailed Usage - -### Using the Python Script Directly - -The version management is handled by `scripts/bump_version.py`: +When a version change is required, disable both automatic side effects: ```bash -# Activate virtual environment first -source .venv/bin/activate - -# Show current version and check consistency -python scripts/bump_version.py show - -# Bump versions with various options -python scripts/bump_version.py bump patch # Standard patch bump -python scripts/bump_version.py bump minor --dry-run # Preview minor bump -python scripts/bump_version.py bump major --no-tag # Bump without git tag -python scripts/bump_version.py bump patch --no-commit # Bump without git commit -``` - -### Command Options - -- `--dry-run`: Preview changes without modifying files -- `--no-commit`: Don't create a git commit -- `--no-tag`: Don't create a git tag - -## What Happens During Version Bump - -When you bump a version, the system automatically: - -1. **Updates version numbers** in: - - `pyproject.toml` - - `src/TimeLocker/__init__.py` - -2. **Creates a git commit** with message: `Bump version: 1.0.0 → 1.0.1` - -3. **Creates a git tag** with format: `v1.0.1` - -4. **Validates consistency** across all version files - -## Configuration - -Version management is configured in `.bumpversion.cfg`: - -```ini -[bumpversion] -current_version = 1.0.0 -commit = True -tag = True -tag_name = v{new_version} -message = Bump version: {current_version} → {new_version} - -[bumpversion:file:pyproject.toml] -search = version = "{current_version}" -replace = version = "{new_version}" - -[bumpversion:file:src/TimeLocker/__init__.py] -search = __version__ = "{current_version}" -replace = __version__ = "{new_version}" +python scripts/bump_version.py bump patch --no-commit --no-tag ``` -## Best Practices +Review the three expected version files, update the matching changelog section, +and request a separate commit instruction. A dirty tree or disagreement about +the target version stops preparation. -### Before Bumping Version +## Run The Non-Publishing Rehearsal -1. **Ensure clean git state**: Commit or stash all changes -2. **Run tests**: Make sure all tests pass -3. **Update CHANGELOG.md**: Document what changed -4. **Review changes**: Use dry-run to preview - -### Version Bump Guidelines - -- **Patch (1.0.0 → 1.0.1)**: Bug fixes, documentation updates, minor improvements -- **Minor (1.0.0 → 1.1.0)**: New features, new CLI commands, backwards-compatible changes -- **Major (1.0.0 → 2.0.0)**: Breaking API changes, removed features, incompatible changes - -### After Version Bump - -1. **Push the version commit and tag**: `git push && git push --tags` -2. **Observe the release workflow**: The tag-triggered workflow verifies the - tag against both Python version sources, runs the configured test suite, - builds the source and wheel distributions, installs and smokes the wheel, - uploads artifacts, and creates the GitHub release. -3. **Verify release artifacts**: Download the wheel/source distribution from - the GitHub release and confirm its version and checksums before announcing it. -4. **Update documentation**: If needed for new features. -5. **Notify users**: For major changes. - -The workflow does not publish to PyPI. Adding a package-registry publishing -step requires separate approval, credentials, and release-process updates. - -## Troubleshooting - -### Git Repository Not Clean - -If you see "Git working directory is not clean": +For `v0.9.1`, run: ```bash -# Check what files are modified -git status - -# Either commit the changes -git add . -git commit -m "Prepare for version bump" - -# Or use --allow-dirty (not recommended for actual releases) -python scripts/bump_version.py bump patch --dry-run # This allows dirty for dry-run +python scripts/validate_release_intent.py --version-ref v0.9.1 +python scripts/validate_release_workflows.py +python -m pytest -m "not performance and not stress and not minio" +python -m build +python scripts/validate_release_artifacts.py --expected-version 0.9.1 +python scripts/smoke_release_artifact.py dist/*.whl --expected-version 0.9.1 +python scripts/smoke_release_artifact.py dist/*.tar.gz --expected-version 0.9.1 +python scripts/extract_release_notes.py --version 0.9.1 --output release-notes.md ``` -### Version Inconsistency - -If versions don't match across files: - -```bash -# Check current state -python scripts/bump_version.py show - -# Fix by doing a version bump (even if just patch) -python scripts/bump_version.py bump patch -``` - -### Permission Issues - -Ensure you have write permissions to: - -- Project files (`pyproject.toml`, `src/TimeLocker/__init__.py`) -- Git repository (for commits and tags) - -## Integration With CI/CD - -`.github/workflows/release.yml` is the durable executable release contract. It -runs only for semantic version tags matching `v*.*.*` and uses the repository's -Python metadata and test configuration. It does not move tags or mutate source. - -## Manual Version Management - -If you need to manually set a version: - -1. Edit `.bumpversion.cfg` to set `current_version` -2. Edit `pyproject.toml` to set `version` -3. Edit `src/TimeLocker/__init__.py` to set `__version__` -4. Verify with `python scripts/bump_version.py show` - -## Dependencies - -The version management system requires: - -- `bump2version` package (installed automatically) -- Git repository -- Python 3.12+ - -Install dependencies: - -```bash -source .venv/bin/activate -pip install bump2version -``` +The reusable workflow may also be run manually with `version_ref=v0.9.1` after +the preparation changes are committed. It has `contents: read` permission and +uploads only validation artifacts and a release-note preview. Confirm the +commit, local tags, GitHub releases, and tag-triggered release-run inventory are +unchanged after either rehearsal. + +The normal suite deliberately excludes performance, stress, and live MinIO +profiles. Those profiles and their prerequisites are defined in the +[testing guide](../4-testing/README.md) and must already have current passing +evidence for the release candidate. + +## Authorize And Publish + +Publication requires all of the following: + +1. The release candidate is committed on the intended protected branch and CI + is green. +2. The active release-readiness spec is ready for closure and its residual + risks have an owner. +3. The version guard, artifacts, supported OS/Python matrix, changelog preview, + and non-publishing rehearsal pass. +4. The release maintainer explicitly approves the exact commit and version. +5. The approved release commit replaces the changelog's `Prepared` qualifier + with the actual release date without changing its evidence-backed body. + +The maintainer then creates and pushes the approved signed tag according to the +repository Git policy. The tag-triggered workflow reruns the read-only contract, +downloads only those validated artifacts, and grants `contents: write` solely +to the dependent job that creates the GitHub release. The release body is the +derived changelog section; it is not generated independently. + +## Verify Publication + +After the workflow completes: + +1. Confirm the release tag and GitHub release point to the approved commit. +2. Download both distributions and `SHA256SUMS` from the release. +3. Compare hashes and smoke a clean install through `timelocker` and `tl`. +4. Confirm the published body matches the corresponding changelog section. +5. Announce the release only after these checks pass. + +## Failure And Recovery + +- **Version mismatch:** stop before building; reconcile the three version + sources and the approved tag intent. Do not move an existing published tag. +- **Missing Restic, build tool, or artifact:** install the documented + prerequisite or correct the build. Do not bypass the failing check. +- **Permission failure:** keep rehearsal permissions read-only. Check repository + Actions policy and the publish job's narrowly scoped `contents: write` + permission; do not grant write permission to validation. +- **Test, artifact, or smoke failure:** retain the failed run as evidence, fix + through the normal review path, and rerun the whole validation contract. +- **Failure after a tag is pushed:** stop announcements and record an issue. + Do not overwrite or silently move a public tag. The release maintainer decides + whether to remove an unpublished erroneous tag/release or issue a new patch. + +The practical rollback before publication is to discard the uncommitted +preparation changes or revert the reviewed preparation commit. After +publication, prefer a new corrective patch release so consumers retain an +immutable history. + +## Current Deferrals + +Version `0.9.1` remains a Beta GitHub release candidate until separately +approved. PyPI distribution and the `1.0.0` milestone remain deferred and are +not implied by completing this procedure. diff --git a/docs/specs/007-release-readiness-stabilization/change-impact.md b/docs/specs/007-release-readiness-stabilization/change-impact.md index 5006f99..1b7b7b6 100644 --- a/docs/specs/007-release-readiness-stabilization/change-impact.md +++ b/docs/specs/007-release-readiness-stabilization/change-impact.md @@ -19,8 +19,9 @@ bounded `v0.9.1` stabilization release. | Source | Current behavior relied on | Confidence | Notes | |--------|----------------------------|------------|-------| | `.github/workflows/test-suite.yml` | Normal CI runs all non-performance, non-stress tests but provisions no MinIO. | high | Current failure source. | -| `.github/workflows/release.yml` | A version tag triggers tests, build, wheel smoke, artifact upload, and GitHub release creation. | high | Must be rehearsed without publication. | -| `pyproject.toml` | Version `0.9.0`, Python range, package metadata, scripts, markers, and coverage threshold. | high | Will move to `0.9.1`. | +| `.github/workflows/release-validation.yml` | A reusable read-only workflow validates intent, tests, artifacts, both smoke installs, and release-note derivation. | high | Added by T009 and exercised locally by T010. | +| `.github/workflows/release.yml` | A version tag calls the validation workflow before a separately permissioned GitHub release job. | high | Publication remains human-authorized. | +| `pyproject.toml` | Version `0.9.1`, bounded Python range, package metadata, scripts, markers, and coverage threshold. | high | Prepared but not published. | | `scripts/bump_version.py` and `.bumpversion.cfg` | Version bumping commits and tags by default unless both are disabled. | high | Preparation must use `--no-commit --no-tag`. | | `docs/guides/user/installation.md` | Current source install and test guidance. | high | Must reflect only verified artifact and platform behavior. | | `docs/processes/version-management.md` | Existing version and release procedure. | high | Correct in place rather than creating a duplicate process. | @@ -50,11 +51,11 @@ bounded `v0.9.1` stabilization release. | Spec content | Durable destination | Promotion status | Notes | |--------------|---------------------|------------------|-------| -| Test profile contract and commands | `docs/4-testing/README.md` | partial | T001-T002 promoted normal and MinIO ownership, prerequisites, and commands; T004 will add the extended-profile disposition. | -| Verified install matrix and prerequisites | `docs/guides/user/installation.md` | pending | Do not claim untested platforms. | -| Release procedure and rollback boundary | `docs/processes/version-management.md` | pending | Correct in place and link from `docs/processes/README.md`. | -| Release contents and limitations | `CHANGELOG.md` | pending | Canonical checked-in source; derive the GitHub release body from the `v0.9.1` section. | -| Current version and release path | `README.md` where needed | pending | Keep front door concise. | +| Test profile contract and commands | `docs/4-testing/README.md` | complete | T001-T004 promoted normal, MinIO, and extended-profile ownership and commands. | +| Verified install matrix and prerequisites | `docs/guides/user/installation.md` | complete | T007 and T011 limit claims to the validated six-combination matrix. | +| Release procedure and rollback boundary | `docs/processes/version-management.md` | complete | Corrected in place and linked from `docs/processes/README.md` by T011. | +| Release contents and limitations | `CHANGELOG.md` | complete | T012 made the `v0.9.1` section canonical and previewed its derived release body. | +| Current version and release path | `README.md` | complete | T011 records Python 3.12-3.13 and `0.9.1` prepared, not published. | ## Unchanged Durable Areas @@ -82,9 +83,9 @@ bounded `v0.9.1` stabilization release. ## Open Questions -None block implementation. The declared validation contract is Python 3.12 and -3.13 on Linux, macOS, and Windows. T007 must validate all six combinations or -correct the affected claim before downstream work continues. +None block implementation. The declared Python 3.12 and 3.13 contract passed on +Linux, macOS, and Windows. Publication and lifecycle closure remain separate +human decisions after T013. ## Related Artifacts diff --git a/docs/specs/007-release-readiness-stabilization/tasks.md b/docs/specs/007-release-readiness-stabilization/tasks.md index d49a66d..46808f3 100644 --- a/docs/specs/007-release-readiness-stabilization/tasks.md +++ b/docs/specs/007-release-readiness-stabilization/tasks.md @@ -241,7 +241,7 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 ## Phase 4: Rehearse, Promote, and Review -- [ ] T009 Implement a safe pre-tag validation interface. +- [x] T009 Implement a safe pre-tag validation interface. - Depends on: T008 - Requirement: Requirement 5 - Acceptance Criteria: Requirement 5 AC1, AC5 @@ -251,12 +251,22 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 contains no commit, tag, release, or package-index publication action. - Evidence mode: implementation - Validation: Workflow syntax, focused script tests, publication-boundary review. - - Evidence: Pending. - - [ ] T009.1 Identify and isolate every pre-publication release step. - - [ ] T009.2 Implement a manual or local validation entry point with read-only permissions. - - [ ] T009.3 Add regression coverage for the publication boundary and failure propagation. + - Evidence: Added reusable `.github/workflows/release-validation.yml`, extracted release-intent, release-note, and workflow-boundary validators, and refactored `.github/workflows/release.yml` so validation is read-only and only the dependent publish job has `contents: write`. Focused release-contract tests: 9 passed. `actionlint` passed both workflows. Boundary validator passed and negative permission/mismatch/missing-artifact paths propagate failure. + - Status: Complete on 2026-07-19; no publication action executed. + - [x] T009.1 Identify and isolate every pre-publication release step. + - Evidence: Separated checkout, prerequisites, intent, tests, build, artifact inspection, both smoke installs, notes derivation, and uploads into the reusable validation workflow; GitHub release creation remains outside it. + - Status: Complete on 2026-07-19. + - Evidence mode: validation + - [x] T009.2 Implement a manual or local validation entry point with read-only permissions. + - Evidence: Added manual `workflow_dispatch` and reusable `workflow_call` entry points under `contents: read`. + - Status: Complete on 2026-07-19. + - Evidence mode: validation + - [x] T009.3 Add regression coverage for the publication boundary and failure propagation. -- [ ] T010 Execute and record a non-publishing release rehearsal. + - Evidence: Added focused positive and negative tests for intent, derivation, missing artifacts, rehearsal permission, and the isolated publish job. + - Status: Complete on 2026-07-19. + - Evidence mode: validation +- [x] T010 Execute and record a non-publishing release rehearsal. - Depends on: T009 - Requirement: Requirement 5 - Acceptance Criteria: Requirement 5 AC1, AC4, AC5 @@ -267,13 +277,26 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 unchanged; no external publication occurs. - Evidence mode: validation - Validation: Non-publishing rehearsal and external-state comparison. - - Evidence: Pending. - - [ ] T010.1 Capture pre-rehearsal commit, tag, release, and permission state. - - [ ] T010.2 Exercise successful build, smoke, artifact, and release-note inputs. - - [ ] T010.3 Exercise version mismatch, missing prerequisite, and permission failure paths. - - [ ] T010.4 Capture unchanged post-rehearsal external state and link all logs. + - Evidence: Local rehearsal passed release-intent and permission-boundary validation, built and inspected one wheel and one sdist, wrote SHA256SUMS, and clean-installed/smoked both artifacts through `timelocker` and `tl`. Negative version `v0.9.0`, missing-artifact, and unsafe-permission cases failed as intended. Pre/post HEAD remained `1dcf91090c755c476afe1851b2c4e02cdd9a949f`; tags remained zero, GitHub releases remained zero, and historical tag-triggered release runs remained 11. + - Status: Complete on 2026-07-19; no external publication occurred. + - [x] T010.1 Capture pre-rehearsal commit, tag, release, and permission state. + - Evidence: Captured HEAD `1dcf910`, zero tags, zero GitHub releases, 11 historical release runs, read-only rehearsal permission, and one job-scoped publish permission. + - Status: Complete on 2026-07-19. + - Evidence mode: validation + - [x] T010.2 Exercise successful build, smoke, artifact, and release-note inputs. + - Evidence: Built and validated both distributions, hashes, both clean-install smokes, upload configuration, and the changelog-derived release-body input. + - Status: Complete on 2026-07-19. + - Evidence mode: validation + - [x] T010.3 Exercise version mismatch, missing prerequisite, and permission failure paths. + - Evidence: Confirmed `v0.9.0` mismatch, missing artifact, and unsafe rehearsal permission all fail. + - Status: Complete on 2026-07-19. + - Evidence mode: validation + - [x] T010.4 Capture unchanged post-rehearsal external state and link all logs. -- [ ] T011 Update existing durable release and installation procedures. + - Evidence: Post-state remained HEAD `1dcf910`, zero tags, zero GitHub releases, and 11 historical release runs. + - Status: Complete on 2026-07-19. + - Evidence mode: validation +- [x] T011 Update existing durable release and installation procedures. - Depends on: T010 - Requirements: Requirements 4 and 5 - Acceptance Criteria: Requirement 4 AC3, AC4, AC5; Requirement 5 AC2, AC5 @@ -285,12 +308,22 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 the validated support matrix and prerequisites. - Evidence mode: implementation - Validation: Procedure review, Markdown and internal-link checks, command review. - - Evidence: Pending. - - [ ] T011.1 Correct `version-management.md` in place; do not create a duplicate release procedure. - - [ ] T011.2 Link the procedure from `docs/processes/README.md`. - - [ ] T011.3 Update installation and front-door claims from T007 evidence. + - Evidence: Corrected `docs/processes/version-management.md` in place with preparation, rehearsal, approval, publication, verification, failure, rollback, and PyPI/1.0 deferral boundaries; indexed it from `docs/processes/README.md`; aligned README and installation claims to Python 3.12-3.13, version 0.9.1 prepared/not published, and the normal test selector. Agent Workbench checked all five durable documents with zero Markdown or link findings. + - Status: Complete on 2026-07-19; durable procedure and front-door claims are current. + - [x] T011.1 Correct `version-management.md` in place; do not create a duplicate release procedure. + - Evidence: Rewrote the existing version-management process in place with preparation, authorization, validation, recovery, and deferral boundaries. + - Status: Complete on 2026-07-19. + - Evidence mode: validation + - [x] T011.2 Link the procedure from `docs/processes/README.md`. + - Evidence: Linked the corrected release procedure from the current processes index. + - Status: Complete on 2026-07-19. + - Evidence mode: validation + - [x] T011.3 Update installation and front-door claims from T007 evidence. -- [ ] T012 Prepare evidence-backed `v0.9.1` communications. + - Evidence: Aligned README and installation claims to version 0.9.1 prepared/not published, Python 3.12-3.13, and the normal selector. + - Status: Complete on 2026-07-19. + - Evidence mode: validation +- [x] T012 Prepare evidence-backed `v0.9.1` communications. - Depends on: T011 - Requirement: Requirement 5 - Acceptance Criteria: Requirement 5 AC3, AC5, AC6 @@ -301,12 +334,22 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 limitation; the eventual GitHub release body is derived from that section. - Evidence mode: implementation - Validation: Claim-to-evidence review and release-body derivation preview. - - Evidence: Pending. - - [ ] T012.1 Draft the changelog section from verified changes and limitations. - - [ ] T012.2 Map each public claim to verification, commits, specs, or issues. - - [ ] T012.3 Preview the GitHub release body without creating a release. + - Evidence: Added canonical `CHANGELOG.md` section `[0.9.1] - Prepared 2026-07-19` using verified CI, stress, artifact, cross-platform, encoding, version, and publication-boundary evidence plus four explicit limitations. `scripts/extract_release_notes.py` derived the complete GitHub release-body preview from that exact section; focused extraction tests passed. + - Status: Complete on 2026-07-19; communications are prepared but unpublished. + - [x] T012.1 Draft the changelog section from verified changes and limitations. + - Evidence: Drafted the canonical 0.9.1 changelog section from verified changes and explicit limitations. + - Status: Complete on 2026-07-19. + - Evidence mode: validation + - [x] T012.2 Map each public claim to verification, commits, specs, or issues. + - Evidence: Mapped public claims to hosted CI, stress issue evidence, artifact matrix, rehearsal, or explicit limitation in verification.md. + - Status: Complete on 2026-07-19. + - Evidence mode: validation + - [x] T012.3 Preview the GitHub release body without creating a release. -- [ ] T013 Checkpoint - Human release decision and spec closure readiness. + - Evidence: Derived and inspected the complete GitHub release-body preview without creating a release. + - Status: Complete on 2026-07-19. + - Evidence mode: validation +- [x] T013 Checkpoint - Human release decision and spec closure readiness. - Depends on: T012 - Requirements: Requirement 1, Requirement 2, Requirement 3, Requirement 4, Requirement 5 @@ -318,8 +361,10 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 - Validation: Lifecycle lint, readiness, traceability and evidence checks, required test profiles, Markdown and internal-link checks, `git diff --check`, security and release-readiness expert review. - - Evidence: Pending. + - Evidence: Final normal profile passed: 2,774 passed, one skipped, 57 deselected, 19 warnings, 52.14% coverage in 1,439.49 seconds. The initial run exposed one live-host-load test dependency; explicit low-load test resources corrected it and all 22 tool-manager tests passed. Nine release-contract tests, `actionlint`, release intent/boundary validators, derived-notes preview, Agent Workbench Markdown/link checks, and `git diff --check` passed. TimeLocker expert-panel review found no remaining actionable Phase 4 findings. Lifecycle lint has zero errors, zero acceptance gaps, and only the reviewed non-blocking canonical-context advisory. Final HEAD remains `1dcf910`; tags and GitHub releases remain zero; release-run inventory remains 11; no PyPI action occurred. + - Status: Complete on 2026-07-19; ready for separate release-maintainer approval and lifecycle closure, with no commit or publication created. + - Evidence mode: validation ## Execution Rules - Read the linked row in `traceability.md` and the relevant requirements, diff --git a/docs/specs/007-release-readiness-stabilization/verification.md b/docs/specs/007-release-readiness-stabilization/verification.md index 8220ee8..2078206 100644 --- a/docs/specs/007-release-readiness-stabilization/verification.md +++ b/docs/specs/007-release-readiness-stabilization/verification.md @@ -21,13 +21,13 @@ separate explicit approval. |------|-----------|--------|----------| | Acceptance traceability complete | yes | passed | Lifecycle stage readiness reports zero acceptance, property, context, downstream-review, or blocking gaps after reconciliation. | | Substantive requirements and design review | yes | passed | Review on 2026-07-18 produced TLR-001 through TLR-006; all six findings were reconciled into the package before implementation. | -| Task evidence complete | yes | pending | T001-T008 passed; T009-T013 pending. | +| Task evidence complete | yes | passed | T001-T013 contain concrete implementation or validation evidence. | | Normal and dependency-owning test profiles pass | yes | passed | GitHub Actions run `29676747955` passed normal, MinIO, coverage quality-gate, and notification jobs. | | Stress implementation and disposition recorded | yes | passed | T004 implementation and repeat evidence are recorded in GitHub issue #68. | | Artifacts and six-combination clean installs validate | yes | passed | Run `29679083454` passed one build and all 12 artifact/OS/Python jobs. | -| Release interface and rehearsal prove no publication side effect | yes | pending | T009-T010. | -| Durable documentation and communications promoted | yes | pending | T011-T012 and promotion table below. | -| Final lifecycle checks and expert review pass | yes | pending | T013; package-creation review does not replace final implementation review. | +| Release interface and rehearsal prove no publication side effect | yes | passed | T009-T010: reusable read-only validation, local rehearsal, three negative paths, and unchanged external state. | +| Durable documentation and communications promoted | yes | passed | T011-T012; Markdown/link check found zero issues in the five durable targets. | +| Final lifecycle checks and expert review pass | yes | passed | T013 lifecycle checks have zero blocking gaps; bounded expert review has no remaining actionable findings. | ## Validation Commands And Methods @@ -40,18 +40,19 @@ separate explicit approval. | `python scripts/bump_version.py bump patch --no-commit --no-tag` plus pre/post commit, tag, tag-triggered release-workflow run, and release identity | Prepare `0.9.1` without publication side effects | passed | Helper changed only `.bumpversion.cfg`, `pyproject.toml`, and `src/TimeLocker/__init__.py`; zero tags/releases and 11 historical release runs remained. | | `python -m build`, version guard, metadata inspection, and SHA-256 generation | Build and prove artifact identity | passed | Final run `29679083454` validated one wheel, one sdist, metadata, entry points, nine data files, and hashes; wrong-version guard failed as intended. | | wheel and sdist smoke installs on Linux, macOS, and Windows for Python 3.12 and 3.13 | Prove CP-003 and all declared support claims | passed | Run `29679083454`: all 12 wheel/sdist jobs passed both CLI entry points. | -| safe pre-tag interface tests and non-publishing rehearsal | Prove CP-004, including failure paths and unchanged external state | pending | T009-T010. | -| repository Markdown/link checks and `git diff --check` | Validate specification and durable-doc hygiene | pending | Package reconciliation and T011-T013. | +| `actionlint`, nine focused release-contract tests, build/inspect, two clean-install smokes, and negative mismatch/missing/permission paths | Prove the reusable pre-tag interface and CP-004 rehearsal | passed | T009-T010; HEAD `1dcf910`, zero tags/releases, and 11 historical release runs were unchanged. | +| `python scripts/extract_release_notes.py --version 0.9.1` | Derive the eventual GitHub body from the canonical changelog section | passed | T012 preview contains the complete evidence-backed section and limitations. | +| Agent Workbench Markdown/link set check and `git diff --check` | Validate durable-doc hygiene | passed | Five durable documents had zero findings; whitespace check passed before final review. | ## Requirement Coverage | Requirement | Acceptance Criteria Covered | Evidence | Residual Risk | |-------------|------------------------------|----------|---------------| -| R1 | AC1-AC6 | T001-T003 passed; GitHub Actions run `29676747955` | Marker and workflow contract tests guard future profile drift. | -| R2 | AC1-AC4 | T004-T005 passed; issue #68 records environment, baseline, tolerance, and repeat evidence | Post-change hosted evidence follows the explicitly requested commit. | -| R3 | AC1-AC5 | T006 and T008 passed; run `29679083454` | Preparation must continue to use both disabling flags. | -| R4 | AC1-AC5 | T007-T008 passed; installation guide updated | T011 will reconcile the broader durable release procedure. | -| R5 | AC1-AC6 | T009-T013 pending | Human operator error at first actual tag. | +| Requirement 1 | AC1-AC6 | T001-T003 passed; GitHub Actions run `29676747955` | Marker and workflow contract tests guard future profile drift. | +| Requirement 2 | AC1-AC4 | T004-T005 passed; issue #68 records environment, baseline, tolerance, and repeat evidence | Post-change hosted evidence follows the explicitly requested commit. | +| Requirement 3 | AC1-AC5 | T006 and T008 passed; run `29679083454` | Preparation must continue to use both disabling flags. | +| Requirement 4 | AC1-AC5 | T007-T008 passed; installation guide and release procedure updated by T011 | Future support changes require the same matrix. | +| Requirement 5 | AC1-AC6 | T009-T013 passed | Human operator error at first actual tag remains explicitly owned. | ## Correctness Property Coverage @@ -60,8 +61,8 @@ separate explicit approval. | CP-001 | T001-T003, collection partition and workflow run `29676747955` | passed | Contract tests guard marker, selector, service, and artifact-transfer drift. | | CP-002 | T006 version guard and negative test | passed | Automated guard covers source and artifact identity. | | CP-003 | T007 six-combination artifact matrix | passed | Final shared-artifact run passed all 12 jobs. | -| CP-004 | T006, T008-T010, and T013 external-state comparisons | partial | T006/T008 passed; rehearsal and final review remain. | -| CP-005 | T012-T013 changelog and derived release-body review | pending | Review quality. | +| CP-004 | T006, T008-T010, and T013 external-state comparisons | partial | Preparation and rehearsal passed; final comparison remains in T013. | +| CP-005 | T012-T013 changelog and derived release-body review | partial | Derivation passed; final expert review remains. | ## Agent Readiness Evidence @@ -87,11 +88,11 @@ separate explicit approval. | T006 | passed | Safe helper invocation, identity guard, one shared build, metadata/data/hash inspection, and unchanged external release state | No tag or release created. | | T007 | passed | Run `29679083454` passed wheel and sdist on all six OS/Python combinations | Windows encoding defect found in the first run and fixed by `4a2d998`. | | T008 | passed | CP-002, CP-003, and Phase 3 CP-004 evidence reviewed | Phase 3 checkpoint complete. | -| T009 | pending | | Safe pre-tag interface pending. | -| T010 | pending | | Non-publishing rehearsal pending. | -| T011 | pending | Existing version process selected as promotion target | Durable updates pending. | -| T012 | pending | `CHANGELOG.md` selected as canonical source | Communications pending. | -| T013 | pending | | Final review and human decision pending. | +| T009 | passed | Reusable read-only workflow, isolated publish job, workflow syntax, and nine focused tests | Only the dependent publish job has write permission. | +| T010 | passed | Local build/inspect, wheel/sdist smokes, three negative paths, and unchanged commit/tag/release/run state | No external publication occurred. | +| T011 | passed | Existing process corrected and indexed; README and installation claims aligned; Markdown/link set clean | PyPI and 1.0 remain deferred. | +| T012 | passed | Canonical changelog section and successful derived release-body preview | Four limitations are explicit. | +| T013 | passed | Final normal profile, lifecycle/hygiene checks, external-state comparison, and bounded TimeLocker expert-panel review | Human release approval and lifecycle closure remain separate. | ## Evidence Log @@ -122,6 +123,16 @@ separate explicit approval. | 2026-07-19 | Hosted artifact run `29678906850` | failed as designed gate | Linux/macOS passed; all Windows jobs exposed `cp1252`-unsafe help glyphs. | | 2026-07-19 | Windows help portability fix `4a2d998` | passed | ASCII metavar/epilog plus a `cp1252` regression contract removed the installation blocker. | | 2026-07-19 | Hosted artifact run `29679083454` | passed | One shared build and all 12 wheel/sdist matrix jobs passed across Linux, macOS, Windows, Python 3.12, and Python 3.13. | +| 2026-07-19 | T009 release interface contracts | passed | `actionlint`, the workflow-boundary validator, and nine focused tests proved read-only rehearsal, isolated publication, and negative failure propagation. | +| 2026-07-19 | T010 local non-publishing rehearsal | passed | Intent, build, metadata/data/hashes, wheel and sdist clean-install smokes, and release inputs passed; version mismatch, missing artifact, and unsafe permission failed. | +| 2026-07-19 | T010 external-state comparison | unchanged | HEAD remained `1dcf910`; zero tags, zero GitHub releases, and 11 historical release runs remained. | +| 2026-07-19 | T011 durable documentation check | passed | Agent Workbench found zero Markdown or link issues across README, installation, process index, version process, and changelog. | +| 2026-07-19 | T012 release-body derivation | passed | The preview was extracted from the exact `0.9.1` changelog section; no second durable release-note file was created. | +| 2026-07-19 | Initial T013 normal-profile run | corrective finding | 2,773 passed, one skipped, 57 deselected, and 52.14% coverage; one test sampled live 100% CPU while asserting unconstrained parallelism. | +| 2026-07-19 | Resource-dependent test isolation | passed | The high-priority tool-manager test now supplies explicit low-load resources; all 22 tool-manager tests passed. | +| 2026-07-19 | Final T013 normal-profile run | passed | 2,774 passed, one skipped, 57 deselected, 19 warnings, and 52.14% coverage in 1,439.49 seconds. | +| 2026-07-19 | T013 TimeLocker expert-panel review | passed | Bounded Phase 4 diff review applied stewardship, Python CLI, security, reliability, operations, and documentation/lifecycle lenses; Restic behavior was unchanged. No actionable findings remained after test isolation. | +| 2026-07-19 | T013 lifecycle and hygiene checks | passed with advisory | Lifecycle lint had no errors and only the reviewed optional canonical-context advisory; traceability had zero acceptance gaps; `actionlint`, Markdown/link checks, workflow boundary validation, and `git diff --check` passed. | ## Manual Or External Verification @@ -136,7 +147,8 @@ release artifacts must be linked here before release readiness can be approved. for upstream action versions that the runner forces onto Node.js 24. - Future marker drift could change profile ownership; the T001 contract test guards the intended four live nodes and mocked-test placement. -- Stress thresholds can remain host-sensitive until T004 evidence is accepted. +- Stress thresholds remain host-sensitive by nature; T004's calibrated contract + and issue #68 own the accepted tolerance evidence. - Version tooling commits and tags by default; every preparation run must use both disabling flags and prove external state is unchanged. - The verified matrix depends on hosted runner availability; future unavailable @@ -148,49 +160,49 @@ release artifacts must be linked here before release readiness can be approved. | Spec Content | Durable Destination Or Deferral | Status | Evidence | |--------------|---------------------------------|--------|----------| -| Test profile contract | `docs/4-testing/README.md` | pending | T002-T003. | -| Verified installation matrix | `docs/guides/user/installation.md` | partial | T007 matrix and prerequisites promoted; T011 owns final procedure reconciliation. | -| Version preparation, release procedure, and rollback | `docs/processes/version-management.md`, linked from `docs/processes/README.md` | pending | T011; no duplicate process document. | -| Version contents and release communications | `CHANGELOG.md`; GitHub release body derived from its `v0.9.1` section | pending | T012. | -| Front-door support and version claims | `README.md` if current text requires correction | pending | T011. | -| PyPI and `1.0.0` deferral | GitHub issue #22, milestone description, version process | partial | GitHub scope updated; durable process pending. | -| Follow-up work | GitHub issues outside milestone or an approved successor spec | pending | T013. | +| Test profile contract | `docs/4-testing/README.md` | complete | T002-T004 promoted profile ownership, prerequisites, commands, and stress disposition. | +| Verified installation matrix | `docs/guides/user/installation.md` | complete | T007 matrix and prerequisites reconciled by T011. | +| Version preparation, release procedure, and rollback | `docs/processes/version-management.md`, linked from `docs/processes/README.md` | complete | T011 corrected the existing procedure; no duplicate was created. | +| Version contents and release communications | `CHANGELOG.md`; GitHub release body derived from its `v0.9.1` section | complete | T012 preview passed. | +| Front-door support and version claims | `README.md` | complete | T011 aligned version and Python support. | +| PyPI and `1.0.0` deferral | GitHub issue #22, milestone description, version process | complete | External and durable boundaries agree. | +| Follow-up work | GitHub issues outside milestone or an approved successor spec | complete | Existing issue #68 retains stress history; no new Phase 4 finding requires routing. | ### Spec Cleanup Decision - **Cleanup action:** keep active -- **Reason:** Implementation is active; T001-T008 are complete and T009-T013 remain. +- **Reason:** All implementation tasks are complete; the package remains active + only for the separately authorized lifecycle closure and its final commits. - **Final spec commit:** pending - **Closure log path:** `docs/history/spec-closure-log.md` - **Closure log entry updated:** no - **Closure cleanup commit:** pending - **Active indexes updated:** yes for package creation -- **Durable docs linked back to evidence where useful:** no -- **Residual spec-only content:** all intended content remains active +- **Durable docs linked back to evidence where useful:** yes +- **Residual spec-only content:** task-level implementation and validation evidence only ## Ship Or Closure Risk - **Risk level:** high - **Breaking change:** no -- **Blast radius checked:** partial -- **Rollback path:** existing version process to be corrected and validated in T011 +- **Blast radius checked:** complete for the Phase 4 workflow, scripts, tests, and docs diff +- **Rollback path:** corrected and validated in `docs/processes/version-management.md` - **Requires human review:** yes - **Release notes needed:** yes, in `CHANGELOG.md` - **Follow-up issue or spec needed:** issue #68 already tracks stress evidence ### Risk Rationale -Normal, provisioned MinIO, extended, artifact, and cross-platform install gates -now pass. The tag-triggered release workflow still has no successful repository -release history, and Phase 4 rehearsal, durable procedure reconciliation, -communications, and final expert review remain. No release should proceed until -those gates are complete. +Normal, provisioned MinIO, extended, artifact, cross-platform install, rehearsal, +documentation, and expert-review gates pass. The tag-triggered release workflow +still has no successful repository release history, so the first tag remains a +high, human-controlled publication risk rather than an implementation blocker. ## Readiness Decision -- **Ready for promotion:** no -- **Ready for release:** no -- **Ready for closure:** no +- **Ready for promotion:** yes +- **Ready for release:** yes, for a separate release-maintainer decision +- **Ready for closure:** yes, through the separate lifecycle closure workflow ## Related Artifacts diff --git a/scripts/extract_release_notes.py b/scripts/extract_release_notes.py new file mode 100644 index 0000000..b381b6b --- /dev/null +++ b/scripts/extract_release_notes.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Derive one GitHub release body from the canonical changelog section.""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + + +def extract(changelog: str, version: str) -> str: + heading = re.compile(rf"^## \[{re.escape(version)}\](?:\s+-\s+[^\n]+)?\s*$", re.MULTILINE) + match = heading.search(changelog) + if match is None: + raise AssertionError(f"CHANGELOG.md has no section for {version}") + next_heading = re.search(r"^## ", changelog[match.end() :], re.MULTILINE) + end = match.end() + next_heading.start() if next_heading else len(changelog) + body = changelog[match.end() : end].strip() + if not body: + raise AssertionError(f"CHANGELOG.md section for {version} is empty") + return body + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True) + parser.add_argument("--changelog", type=Path, default=Path("CHANGELOG.md")) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + try: + body = extract(args.changelog.read_text(), args.version) + except AssertionError as error: + raise SystemExit(str(error)) from error + if args.output: + args.output.write_text(body) + print(f"Wrote release notes for {args.version} to {args.output}") + else: + print(body, end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_release_intent.py b/scripts/validate_release_intent.py new file mode 100644 index 0000000..9174393 --- /dev/null +++ b/scripts/validate_release_intent.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Validate a proposed release tag against TimeLocker's version sources.""" + +from __future__ import annotations + +import argparse +import ast +import re +import tomllib +from pathlib import Path + +TAG_PATTERN = re.compile( + r"^v(?P(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$" +) + + +def package_version(root: Path) -> str: + module = ast.parse((root / "src/TimeLocker/__init__.py").read_text()) + for statement in module.body: + if ( + isinstance(statement, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "__version__" + for target in statement.targets + ) + ): + return str(ast.literal_eval(statement.value)) + raise AssertionError("src/TimeLocker/__init__.py does not define __version__") + + +def validate(root: Path, version_ref: str) -> str: + match = TAG_PATTERN.fullmatch(version_ref) + if match is None: + raise AssertionError( + f"release reference must be a semantic version tag such as v0.9.1: {version_ref}" + ) + + expected = match.group("version") + with (root / "pyproject.toml").open("rb") as stream: + project_version = str(tomllib.load(stream)["project"]["version"]) + source_version = package_version(root) + versions = {project_version, source_version} + if versions != {expected}: + raise AssertionError( + f"version guard failed: tag={expected}, sources={sorted(versions)}" + ) + return expected + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--version-ref", required=True) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + args = parser.parse_args() + try: + version = validate(args.root.resolve(), args.version_ref) + except AssertionError as error: + raise SystemExit(str(error)) from error + print(f"Release intent verified for v{version}") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_release_workflows.py b/scripts/validate_release_workflows.py new file mode 100644 index 0000000..06a5909 --- /dev/null +++ b/scripts/validate_release_workflows.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Enforce TimeLocker's read-only rehearsal and publication permission boundary.""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +FORBIDDEN_REHEARSAL_ACTIONS = ( + "gh release create", + "git commit", + "git push", + "git tag", + "twine upload", + "pypa/gh-action-pypi-publish", +) + + +def validate(rehearsal_path: Path, release_path: Path) -> None: + rehearsal = rehearsal_path.read_text() + release = release_path.read_text() + + assert "workflow_call:" in rehearsal, "rehearsal must be reusable" + assert "workflow_dispatch:" in rehearsal, "rehearsal must support manual execution" + assert re.search(r"^permissions:\n contents: read$", rehearsal, re.MULTILINE), ( + "rehearsal must declare read-only contents permission" + ) + for action in FORBIDDEN_REHEARSAL_ACTIONS: + assert action not in rehearsal, f"rehearsal contains publication action: {action}" + + assert ' - "v*.*.*"' in release, "release workflow must remain tag-only" + assert re.search(r"^permissions:\n contents: read$", release, re.MULTILINE), ( + "release workflow must default to read-only contents permission" + ) + assert "uses: ./.github/workflows/release-validation.yml" in release + assert "needs: validate" in release, "publication must wait for validation" + assert re.search( + r"^ publish:\n(?:.*\n)*? permissions:\n contents: write$", + release, + re.MULTILINE, + ), "only the publication job may request contents: write" + assert release.count("contents: write") == 1 + assert release.count("gh release create") == 1 + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--rehearsal", + type=Path, + default=Path(".github/workflows/release-validation.yml"), + ) + parser.add_argument("--release", type=Path, default=Path(".github/workflows/release.yml")) + args = parser.parse_args() + try: + validate(args.rehearsal, args.release) + except AssertionError as error: + raise SystemExit(str(error)) from error + print("Release workflow permission and publication boundaries verified") + + +if __name__ == "__main__": + main() diff --git a/tests/TimeLocker/project/test_release_artifacts.py b/tests/TimeLocker/project/test_release_artifacts.py index d08c7dd..16dd10f 100644 --- a/tests/TimeLocker/project/test_release_artifacts.py +++ b/tests/TimeLocker/project/test_release_artifacts.py @@ -25,6 +25,15 @@ def load_validator(): return module +def load_script(name: str): + path = ROOT / f"scripts/{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + @pytest.mark.config @pytest.mark.unit def test_supported_python_and_os_metadata_are_explicit(): @@ -91,3 +100,57 @@ def test_validator_cli_rejects_a_version_mismatch_before_artifact_checks(tmp_pat ) assert result.returncode != 0 assert "version guard failed" in result.stderr + + +@pytest.mark.config +@pytest.mark.unit +def test_release_intent_accepts_current_version_and_rejects_mismatch(): + validator = load_script("validate_release_intent") + assert validator.validate(ROOT, "v0.9.1") == "0.9.1" + with pytest.raises(AssertionError, match="version guard failed"): + validator.validate(ROOT, "v0.9.0") + with pytest.raises(AssertionError, match="semantic version tag"): + validator.validate(ROOT, "0.9.1") + + +@pytest.mark.config +@pytest.mark.unit +def test_release_notes_are_derived_from_exact_changelog_section(): + extractor = load_script("extract_release_notes") + changelog = "# Changelog\n\n## [0.9.1] - Prepared\n\nCurrent notes.\n\n## [0.9.0]\n\nOld notes.\n" + assert extractor.extract(changelog, "0.9.1") == "Current notes.\n" + with pytest.raises(AssertionError, match="no section"): + extractor.extract(changelog, "1.0.0") + + +@pytest.mark.config +@pytest.mark.unit +def test_release_workflows_enforce_read_only_rehearsal_and_isolated_publication(tmp_path): + validator = load_script("validate_release_workflows") + rehearsal = ROOT / ".github/workflows/release-validation.yml" + release = ROOT / ".github/workflows/release.yml" + validator.validate(rehearsal, release) + + unsafe_rehearsal = tmp_path / "release-validation.yml" + unsafe_rehearsal.write_text(rehearsal.read_text().replace("contents: read", "contents: write")) + with pytest.raises(AssertionError, match="read-only"): + validator.validate(unsafe_rehearsal, release) + + +@pytest.mark.config +@pytest.mark.unit +def test_release_rehearsal_propagates_missing_prerequisite_failure(tmp_path): + missing = subprocess.run( + [ + sys.executable, + str(ROOT / "scripts/smoke_release_artifact.py"), + str(tmp_path / "missing.whl"), + "--expected-version", + "0.9.1", + ], + cwd=ROOT, + capture_output=True, + text=True, + ) + assert missing.returncode != 0 + assert "Artifact does not exist" in missing.stderr diff --git a/tests/TimeLocker/services/test_tool_manager.py b/tests/TimeLocker/services/test_tool_manager.py index 24001de..882d4e5 100644 --- a/tests/TimeLocker/services/test_tool_manager.py +++ b/tests/TimeLocker/services/test_tool_manager.py @@ -34,6 +34,7 @@ ToolConfiguration, ExecutionContext ) +from TimeLocker.services.parallel_execution_optimizer import SystemResources class TestToolManager: @@ -133,6 +134,15 @@ def test_configure_tool_for_job(self): def test_configure_tool_high_priority_job(self): """Test configuration for high priority job""" manager = ToolManager() + manager._parallel_optimizer.get_system_resources = Mock( + return_value=SystemResources( + cpu_count=8, + cpu_usage_percent=20.0, + memory_total_gb=16.0, + memory_available_gb=12.0, + memory_usage_percent=25.0, + ) + ) job_config = BackupJobConfig( job_id="high-priority-job", @@ -149,7 +159,9 @@ def test_configure_tool_high_priority_job(self): config = manager.configure_tool_for_job('restic', job) - # High priority should get more parallel operations + # With explicitly unconstrained resources, high priority gets more + # parallel operations. Do not make this unit contract depend on live + # host load observed while the suite is running. assert config.parallel_operations > 1 # High priority should get lower compression assert config.compression_level is not None From 433c0aa5752e977312fbf67b709f67fc96287653 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:10:06 +0100 Subject: [PATCH 16/72] feat(backup): complete machine acceptance hardening --- docs/guides/developer/scheduling-guide.md | 242 +++---- docs/guides/user/installation.md | 14 + docs/guides/user/recovery-operations-guide.md | 594 ++++-------------- .../change-impact.md | 28 +- .../design.md | 97 ++- .../requirements.md | 125 +++- .../tasks.md | 159 ++++- .../traceability.md | 25 +- .../verification.md | 91 ++- src/TimeLocker/backup_manager.py | 11 +- src/TimeLocker/backup_snapshot.py | 22 +- src/TimeLocker/cli_modules/commands/backup.py | 11 +- .../cli_modules/commands/repositories.py | 24 +- .../cli_modules/commands/restore.py | 12 +- .../cli_modules/commands/schedule.py | 261 +++++--- .../services/repository_resolver.py | 8 +- src/TimeLocker/file_selections.py | 15 +- src/TimeLocker/interfaces/recovery_models.py | 2 + .../monitoring/system_tray_integration.py | 57 +- src/TimeLocker/recovery_orchestrator.py | 22 +- src/TimeLocker/restic/restic_repository.py | 4 +- .../services/backup_orchestrator.py | 34 +- src/TimeLocker/snapshot_manager.py | 6 + src/TimeLocker/utils/progress_service.py | 32 +- .../backup/test_enhanced_backup_operations.py | 20 +- .../TimeLocker/backup/test_file_selections.py | 14 +- tests/TimeLocker/backup/test_snapshot.py | 19 +- tests/TimeLocker/backup/test_target.py | 17 +- ...cli_end_to_end_snapshots_schedule_flows.py | 8 +- .../cli/test_cli_end_to_end_user_flows.py | 1 + tests/TimeLocker/cli/test_repos_commands.py | 40 ++ .../TimeLocker/cli/test_schedule_commands.py | 52 +- .../test_system_tray_integration.py | 58 ++ .../recovery/mock_recovery_repository.py | 1 + .../recovery/test_recovery_orchestrator.py | 3 + .../recovery/test_snapshot_manager.py | 8 + .../regression/test_regression_suite.py | 4 +- .../test_backup_orchestrator_job_execution.py | 52 ++ .../TimeLocker/utils/test_progress_service.py | 19 + 39 files changed, 1373 insertions(+), 839 deletions(-) diff --git a/docs/guides/developer/scheduling-guide.md b/docs/guides/developer/scheduling-guide.md index 9baef91..230b2c6 100644 --- a/docs/guides/developer/scheduling-guide.md +++ b/docs/guides/developer/scheduling-guide.md @@ -1,127 +1,139 @@ --- -title: "Developer Guide: Scheduling Backups" +title: "Operator Guide: Scheduling Backups" id: "dev-guide-scheduling" type: [ guide ] status: [ approved ] owner: "Operations Team" -last_reviewed: "01-11-2025" -tags: [guide, developer, scheduling] +last_reviewed: "19-07-2026" +tags: [guide, developer, operator, scheduling] links: tooling: [] --- -# Developer Guide: Scheduling Backups +# Operator Guide: Scheduling Backups - **Owner**: Operations Team - **Status**: Approved -- **Created Date**: 19-12-2024 -- **Last Updated**: 01-11-2025 -- **Audience**: Developers, Operators - -## 1. Purpose - -Provide actionable steps for scheduling recurring TimeLocker backups using systemd timers or cron, including validation, customization, and troubleshooting guidance. - -## 2. Steps - -### 2.1 Prepare Secrets -1. Edit the reusable environment file: - ```bash - nano ~/.config/timelocker/env - TIMELOCKER_PASSWORD="your-actual-repository-password" - ``` -2. Validate the configuration: - ```bash - ~/.local/bin/timelocker-test.sh - ``` - -### 2.2 Option A – systemd Timer (Recommended on Linux) -1. Install service and timer units: - ```bash - sudo cp ~/.config/timelocker/timelocker-backup.service /etc/systemd/system/ - sudo cp ~/.config/timelocker/timelocker-backup.timer /etc/systemd/system/ - ``` -2. Enable and start: - ```bash - sudo systemctl daemon-reload - sudo systemctl enable --now timelocker-backup.timer - ``` -3. Monitor runtime: - ```bash - journalctl -u timelocker-backup.service -f - journalctl -u timelocker-backup.timer -f - ``` -4. Modify schedule by editing the timer and reloading: - ```bash - sudo nano /etc/systemd/system/timelocker-backup.timer - sudo systemctl daemon-reload - sudo systemctl restart timelocker-backup.timer - ``` - -### 2.3 Option B – Cron Job -1. Edit crontab (`crontab -e`) and choose a schedule: - ```bash - # Daily at 2 AM - 0 2 * * * /home/bcherrington/.local/bin/timelocker-backup.sh - # Every 6 hours - 0 */6 * * * /home/bcherrington/.local/bin/timelocker-backup.sh - # Weekly on Sunday at 3 AM - 0 3 * * 0 /home/bcherrington/.local/bin/timelocker-backup.sh - ``` -2. Monitor logs: - ```bash - tail -f ~/.local/share/timelocker/backup.log - grep CRON /var/log/syslog | tail - ``` - -### 2.4 Customize Backup Script -1. Adjust target: - ```bash - python3 -m src.TimeLocker.cli backup run your-backup-target-name - ``` -2. Process multiple targets: - ```bash - for target in target1 target2 target3; do - python3 -m src.TimeLocker.cli backup run "$target" - done - ``` -3. Add health checks: - ```bash - if python3 -m src.TimeLocker.cli backup run my-target; then - curl -fsS https://hc-ping.com/your-uuid - else - curl -fsS https://hc-ping.com/your-uuid/fail - exit 1 - fi - ``` -4. Extend environment variables within `~/.config/timelocker/env` (repository overrides, cache location, bandwidth limits, AWS credentials, etc.). - -## 3. Troubleshooting - -- **Permission issues**: - ```bash - chmod +x ~/.local/bin/timelocker-backup.sh - chmod 600 ~/.config/timelocker/env - ``` -- **Python import errors**: - ```bash - export PYTHONPATH="/home/bcherrington/Projects/Auriora/TimeLocker:$PYTHONPATH" - ``` -- **Repository not found**: - ```bash - python3 -m src.TimeLocker.cli config repositories show local-test - ``` -- **Verify manually**: - ```bash - ~/.local/bin/timelocker-backup.sh - ``` -- **Log locations**: - - Backup logs: `~/.local/share/timelocker/backup.log` - - systemd logs: `journalctl -u timelocker-backup.service` - - Cron logs: `/var/log/syslog` or `/var/log/cron` - -# References - -- `docs/guides/developer/automation-examples.md` -- `docs/guides/user/per-repo-credentials.md` -- `docs/guides/user/installation.md` +- **Audience**: Developers and operators + +## Purpose and boundaries + +Use TimeLocker to define a recurring backup, generate reviewable cron or +systemd assets, and stage a migration from another scheduler. Schedule +generation does not install or enable anything. Installing a system unit, +choosing repository credentials, and disabling an existing backup job are +separate operator decisions. + +Each executable schedule must explicitly bind: + +- one repository name or URI; +- either one configured selection or one or more source paths; +- its configuration directory when it is not the default; and +- an optional protected environment-file path, never copied secret values. + +## Create a disabled schedule + +Use a selection template: + +```bash +tl schedule create nightly-documents \ + --repository primary \ + --selection documents \ + --environment-file ~/.config/timelocker/backup.env \ + --frequency daily \ + --disabled \ + --config-dir ~/.config/timelocker +``` + +Or supply repeatable direct sources: + +```bash +tl schedule create nightly-config \ + --repository primary \ + --source /etc \ + --source /srv/application/config \ + --environment-file ~/.config/timelocker/backup.env \ + --system \ + --cron '30 1 * * *' \ + --disabled \ + --config-dir ~/.config/timelocker +``` + +`--system` preserves the privilege boundary for sources that require root +access. It does not grant privileges or install a unit. + +Protect the referenced environment file and keep it outside generated assets: + +```bash +chmod 600 ~/.config/timelocker/backup.env +``` + +See [Per-Repository Credentials](../user/per-repo-credentials.md) for the +credential choices. Do not copy a masked credential from another backup tool. + +## Generate and review assets + +Generate both candidate formats without installing either: + +```bash +mkdir -p ~/.local/share/timelocker/staged-schedules +tl schedule generate-scripts nightly-config \ + --platform systemd \ + --output ~/.local/share/timelocker/staged-schedules \ + --config-dir ~/.config/timelocker +tl schedule generate-scripts nightly-config \ + --platform cron \ + --output ~/.local/share/timelocker/staged-schedules \ + --config-dir ~/.config/timelocker +``` + +Before installation: + +1. Confirm the generated backup command contains `backup create`, the intended + repository, all sources or the selection, and the intended `--config-dir`. +2. Confirm it contains no password or other credential value. +3. Run the generated wrapper manually in the intended user or root context. +4. Complete a backup and a digest-verified TimeLocker restore. +5. Review the displayed install commands; generation has not run them. + +For systemd assets, `EnvironmentFile=` references the protected file. The cron +wrapper sources the same file with fail-fast shell settings. A missing environment file causes the +backup to fail instead of silently switching credentials. + +## Staged NPBackup replacement + +Keep the NPBackup job enabled while TimeLocker is staged: + +1. Discover and record the actual NPBackup scheduling mechanism and protected + source list using its supported, masked interface. +2. Create a disabled TimeLocker schedule with matching sources and an + independently chosen TimeLocker credential source. +3. Generate and review the TimeLocker assets. +4. With explicit approval, install the system-level timer or root cron entry. +5. Observe successful scheduled TimeLocker backups and perform a restore test. +6. Only then make a separate cutover decision to disable NPBackup. + +Do not extract masked NPBackup secrets, install a privileged timer, or disable +NPBackup as part of schedule generation. + +## Validation and troubleshooting + +```bash +tl schedule list --json --config-dir ~/.config/timelocker +tl schedule show nightly-config --config-dir ~/.config/timelocker +bash -n ~/.local/share/timelocker/staged-schedules/nightly-config_cron.sh +systemd-analyze verify \ + ~/.local/share/timelocker/staged-schedules/timelocker-nightly-config.service \ + ~/.local/share/timelocker/staged-schedules/timelocker-nightly-config.timer +``` + +If the command reports a missing repository, selection, or source, recreate or +edit the schedule so the execution target is explicit. If access fails only in +the scheduler, compare its user, environment-file permissions, executable +path, and configuration directory with the successful manual run. + +## References + +- [Installation](../user/installation.md) +- [Per-Repository Credentials](../user/per-repo-credentials.md) +- [Scheduling Architecture](../../2-architecture/scheduling-system.md) diff --git a/docs/guides/user/installation.md b/docs/guides/user/installation.md index f5da482..df307a3 100644 --- a/docs/guides/user/installation.md +++ b/docs/guides/user/installation.md @@ -60,6 +60,20 @@ sudo apt install python3.12 python3-pip git # Ubuntu/Debian; Python 3.13 is als # sudo pacman -S python python-pip git # Arch ``` +The system tray is optional and does not affect backup, restore, or other CLI +commands. On Linux Mint/Ubuntu, install GTK 3, PyGObject, and the Ayatana +AppIndicator typelib when you want tray support: + +```bash +sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-ayatanaappindicator3-0.1 +``` + +TimeLocker prefers `AyatanaAppIndicator3` and retains the legacy +`AppIndicator3` fallback. A pyenv-managed Python does not normally see +distribution `python3-gi`; use the supported system Python in a virtual +environment created with `--system-site-packages`, or install the build +prerequisites needed for `python -m pip install '.[gui]'`. + #### macOS ```bash diff --git a/docs/guides/user/recovery-operations-guide.md b/docs/guides/user/recovery-operations-guide.md index 9b4e707..fca9032 100644 --- a/docs/guides/user/recovery-operations-guide.md +++ b/docs/guides/user/recovery-operations-guide.md @@ -1,539 +1,169 @@ -# Recovery Operations User Guide +--- +title: "Backup and Recovery Operations" +id: "user-guide-recovery-operations" +type: [ guide ] +status: [ approved ] +owner: "Operations Team" +last_reviewed: "19-07-2026" +tags: [guide, user, backup, recovery] +links: + tooling: [] +--- -**Audience**: End Users -**Level**: Beginner to Advanced -**Last Updated**: 2025-11-10 +# Backup and Recovery Operations -## Overview +This guide covers the current TimeLocker-owned path from repository access to a +digest-verified restore. Use `tl --help` for the complete option set. -This guide explains how to use TimeLocker's recovery operations to restore data from backup snapshots. Whether you need to recover a single file or restore an entire backup, this guide will walk you through the process. +## Prerequisites -## Table of Contents +- Restic is installed and available on `PATH`. +- The named repository is present in TimeLocker configuration. +- Its credential is available from the configured credential store, an + environment variable, a protected environment file loaded by the caller, or + an explicit interactive command option. +- Backup sources exist and the executing user can read them. +- Restore targets have enough space and are writable. -- [Getting Started](#getting-started) -- [Browsing Snapshots](#browsing-snapshots) -- [Full Recovery](#full-recovery) -- [Selective Recovery](#selective-recovery) -- [Monitoring Recovery Progress](#monitoring-recovery-progress) -- [Verifying Restored Data](#verifying-restored-data) -- [Common Scenarios](#common-scenarios) -- [Troubleshooting](#troubleshooting) +Repository passwords are runtime inputs. TimeLocker does not persist them in a +backup result or generated schedule. See +[Per-Repository Credentials](./per-repo-credentials.md). -## Getting Started +## Initialize and verify a repository -### Prerequisites - -Before performing recovery operations, ensure you have: - -1. Access to a repository containing backup snapshots -2. Sufficient disk space at the target location -3. Appropriate permissions to write to the target location -4. The snapshot ID or criteria to identify the snapshot to restore - -### Basic Concepts - -- **Snapshot**: A point-in-time backup containing files and metadata -- **Full Recovery**: Restoring all files from a snapshot -- **Selective Recovery**: Restoring only specific files or directories -- **Target Path**: The location where restored files will be placed -- **Verification**: Checking that restored files match the original backup data - -## Browsing Snapshots - -Before restoring data, you can browse snapshot contents to identify what you need to recover. - -### Listing Available Snapshots - -```bash -# List all snapshots in a repository -timelocker snapshot list --repository my-backup - -# List snapshots with specific tags -timelocker snapshot list --repository my-backup --tag full - -# List recent snapshots -timelocker snapshot list --repository my-backup --last 7d -``` - -### Exploring Snapshot Contents - -```bash -# Browse snapshot root directory -timelocker snapshot browse abc123 - -# Browse specific path in snapshot -timelocker snapshot browse abc123 --path /home/user/documents - -# Search for files in snapshot -timelocker snapshot search abc123 --pattern "*.pdf" - -# Search with size filter -timelocker snapshot search abc123 --pattern "*.jpg" --min-size 1M --max-size 10M -``` - -### Comparing Snapshots - -```bash -# Compare two snapshots -timelocker snapshot compare abc123 def456 - -# Compare specific path across snapshots -timelocker snapshot compare abc123 def456 --path /home/user/documents -``` - -## Full Recovery - -Full recovery restores all files from a snapshot to a target location. - -### Basic Full Recovery - -```bash -# Restore entire snapshot to target directory -timelocker restore full abc123 --target /restore/backup - -# Restore with verification -timelocker restore full abc123 --target /restore/backup --verify - -# Restore preserving permissions and timestamps -timelocker restore full abc123 --target /restore/backup --preserve-all -``` - -### Full Recovery Options - -```bash -# Overwrite existing files -timelocker restore full abc123 --target /restore/backup --overwrite - -# Skip existing files -timelocker restore full abc123 --target /restore/backup --skip-existing - -# Rename conflicting files -timelocker restore full abc123 --target /restore/backup --rename-conflicts - -# Continue on errors -timelocker restore full abc123 --target /restore/backup --continue-on-error - -# Set maximum retries -timelocker restore full abc123 --target /restore/backup --max-retries 5 -``` - -### Restoring to Original Location +For a configured repository named `primary`, load the intended environment and +initialize without placing the password on the command line: ```bash -# Restore to original paths (use with caution!) -timelocker restore full abc123 --target / --original-paths - -# Dry run to preview what would be restored -timelocker restore full abc123 --target / --original-paths --dry-run +set -a +. ~/.config/timelocker/backup.env +set +a +tl repos init primary --yes --config-dir ~/.config/timelocker ``` -## Selective Recovery - -Selective recovery allows you to restore only specific files or directories. - -### Pattern-Based Selection - -```bash -# Restore only PDF files -timelocker restore selective abc123 --target /restore/docs \ - --include "*.pdf" - -# Restore multiple file types -timelocker restore selective abc123 --target /restore/docs \ - --include "*.pdf" --include "*.docx" --include "*.xlsx" +If the repository already exists, TimeLocker reports that state without +reinitializing it. An explicit `--password` remains available for interactive +use, but shell history makes the protected environment or credential store +preferable. -# Restore with exclusions -timelocker restore selective abc123 --target /restore/data \ - --include "**/*" --exclude "*/temp/*" --exclude "*.tmp" +## Preview a backup -# Restore specific directory tree -timelocker restore selective abc123 --target /restore/projects \ - --include "/home/user/projects/**" --exclude "**/node_modules/**" -``` - -### Size and Date Filtering +Direct files and directories are valid sources. A dry run validates the source +and reports the planned file and byte totals without creating a snapshot: ```bash -# Restore files within size range -timelocker restore selective abc123 --target /restore/files \ - --min-size 1K --max-size 10M - -# Restore recently modified files -timelocker restore selective abc123 --target /restore/recent \ - --modified-after "30 days ago" +tl backup create ~/Documents/report.odt \ + --repository primary \ + --dry-run \ + --config-dir ~/.config/timelocker -# Restore files from date range -timelocker restore selective abc123 --target /restore/range \ - --modified-after "2025-01-01" --modified-before "2025-03-31" - -# Combined filters -timelocker restore selective abc123 --target /restore/filtered \ - --include "*.jpg" --min-size 1M --modified-after "7 days ago" +tl backup create ~/Documents \ + --repository primary \ + --dry-run \ + --config-dir ~/.config/timelocker ``` -### Using Selection Templates +Missing sources and invalid targets fail before retry. Alternatively, use one +configured selection: ```bash -# List available templates -timelocker selection template list - -# Restore using template -timelocker restore selective abc123 --target /restore/docs \ - --template documents - -# Restore using template with additional patterns -timelocker restore selective abc123 --target /restore/docs \ - --template documents --include "*.odt" +tl backup create \ + --selection documents \ + --repository primary \ + --dry-run \ + --config-dir ~/.config/timelocker ``` -## Monitoring Recovery Progress +## Create a snapshot -### Real-Time Progress Display +Remove `--dry-run` only after reviewing the preview: ```bash -# Restore with progress display -timelocker restore full abc123 --target /restore/backup --progress - -# Restore with detailed progress -timelocker restore full abc123 --target /restore/backup --progress --verbose +tl backup create ~/Documents/report.odt \ + --repository primary \ + --tags manual-check \ + --config-dir ~/.config/timelocker ``` -### Checking Recovery Status - -```bash -# List active recovery operations -timelocker restore status - -# Check specific operation status -timelocker restore status recovery-001 +Record the full snapshot ID from the result or JSON listing. Reported file and +byte counts come from Restic's summary. -# Monitor operation until completion -timelocker restore status recovery-001 --follow -``` +## List and inspect snapshots -### Cancelling Recovery Operations +The restore listing exposes the full ID, canonical timestamp, host, user, tags, +and source paths: ```bash -# Cancel a running recovery operation -timelocker restore cancel recovery-001 - -# Cancel with cleanup -timelocker restore cancel recovery-001 --cleanup +tl restore list primary --config-dir ~/.config/timelocker +tl restore list primary --format json --config-dir ~/.config/timelocker +tl restore browse primary latest --config-dir ~/.config/timelocker +tl restore find primary '*.odt' --config-dir ~/.config/timelocker ``` -## Verifying Restored Data - -### Automatic Verification +`latest` resolves to the newest snapshot. An exact full ID or unambiguous ID +prefix is also accepted. -```bash -# Restore with automatic verification -timelocker restore full abc123 --target /restore/backup --verify - -# Verify during restoration -timelocker restore full abc123 --target /restore/backup --verify-during -``` +## Restore safely -### Manual Verification +Restore into a fresh directory first: ```bash -# Verify completed recovery operation -timelocker restore verify recovery-001 - -# Verify specific files -timelocker restore verify recovery-001 --path /restore/backup/file.txt - -# Generate verification report -timelocker restore verify recovery-001 --report verification-report.json +mkdir -p ~/timelocker-restore-check +tl restore full primary latest ~/timelocker-restore-check \ + --config-dir ~/.config/timelocker ``` -### Handling Verification Failures +Restore an exact snapshot when reproducing a particular recovery point: ```bash -# Retry failed files -timelocker restore retry recovery-001 --failed-only - -# Re-verify after retry -timelocker restore verify recovery-001 +tl restore full primary 0123456789abcdef ~/timelocker-exact-check \ + --config-dir ~/.config/timelocker ``` -## Common Scenarios +The default restore path requests verification. Use `--no-verify` only when an +independent validation step is deliberately taking its place. `--overwrite` +must be explicit when existing target files may be replaced. -### Scenario 1: Recovering Deleted Files +For selected paths: ```bash -# 1. Find the snapshot before deletion -timelocker snapshot list --before "2025-11-09" - -# 2. Browse snapshot to locate files -timelocker snapshot browse abc123 --path /home/user/documents - -# 3. Restore deleted files -timelocker restore selective abc123 --target /restore/recovered \ - --include "/home/user/documents/deleted-file.txt" +tl restore files primary latest /home/user/Documents/report.odt \ + --target ~/timelocker-file-check \ + --config-dir ~/.config/timelocker ``` -### Scenario 2: Disaster Recovery - -```bash -# 1. Identify latest good snapshot -timelocker snapshot list --last 1 - -# 2. Verify snapshot integrity -timelocker snapshot verify abc123 - -# 3. Perform full recovery with verification -timelocker restore full abc123 --target /restore/system \ - --verify --preserve-all --continue-on-error - -# 4. Monitor progress -timelocker restore status --follow - -# 5. Verify restoration -timelocker restore verify recovery-001 --report disaster-recovery-report.json -``` +## Verify recovered content -### Scenario 3: Recovering Specific File Versions +Compare a known reference after the restore: ```bash -# 1. Compare snapshots to find version -timelocker snapshot compare abc123 def456 --path /home/user/document.txt - -# 2. Browse older snapshot -timelocker snapshot browse abc123 --path /home/user - -# 3. Restore specific version -timelocker restore selective abc123 --target /restore/versions \ - --include "/home/user/document.txt" +sha256sum ~/Documents/report.odt +sha256sum ~/timelocker-restore-check/home/user/Documents/report.odt ``` -### Scenario 4: Recovering Large Datasets - -```bash -# 1. Check available space -df -h /restore - -# 2. Estimate recovery size -timelocker snapshot info abc123 --size +The digests must match. Keep the existing backup scheduler active until a +TimeLocker-created snapshot has been listed and restored through TimeLocker and +scheduled TimeLocker runs have been observed successfully. -# 3. Perform recovery with progress monitoring -timelocker restore full abc123 --target /restore/large-dataset \ - --progress --verify --max-retries 5 +## Common failures -# 4. Monitor in separate terminal -watch -n 5 'timelocker restore status recovery-001' -``` - -### Scenario 5: Selective Document Recovery - -```bash -# 1. Search for documents in snapshot -timelocker snapshot search abc123 --pattern "*.pdf" --pattern "*.docx" - -# 2. Preview what will be restored -timelocker restore selective abc123 --target /restore/docs \ - --include "*.pdf" --include "*.docx" --dry-run - -# 3. Perform selective recovery -timelocker restore selective abc123 --target /restore/docs \ - --include "*.pdf" --include "*.docx" --verify -``` - -## Troubleshooting - -### Issue: Snapshot Not Found - -**Symptoms**: Error message "Snapshot not found" - -**Solutions**: -```bash -# Verify snapshot exists -timelocker snapshot list --repository my-backup - -# Check snapshot ID -timelocker snapshot info abc123 - -# Verify repository access -timelocker repository check my-backup -``` - -### Issue: Permission Denied - -**Symptoms**: Error message "Permission denied" when restoring - -**Solutions**: -```bash -# Check target directory permissions -ls -ld /restore/backup - -# Create target directory with proper permissions -mkdir -p /restore/backup -chmod 755 /restore/backup - -# Run with appropriate user permissions -sudo timelocker restore full abc123 --target /restore/backup -``` - -### Issue: Insufficient Disk Space - -**Symptoms**: Recovery fails with "No space left on device" - -**Solutions**: -```bash -# Check available space -df -h /restore - -# Estimate required space -timelocker snapshot info abc123 --size - -# Use selective recovery to restore less data -timelocker restore selective abc123 --target /restore/partial \ - --include "/critical/data/**" - -# Clean up space and retry -rm -rf /restore/old-data -timelocker restore retry recovery-001 -``` - -### Issue: Slow Recovery Performance - -**Symptoms**: Recovery is taking longer than expected - -**Solutions**: -```bash -# Check transfer rate -timelocker restore status recovery-001 --verbose - -# Use selective recovery for specific files -timelocker restore selective abc123 --target /restore/specific \ - --include "/specific/path/**" - -# Check network connectivity (for remote repositories) -ping repository-host - -# Monitor system resources -top -iostat -x 5 -``` - -### Issue: Verification Failures - -**Symptoms**: Files fail integrity verification - -**Solutions**: -```bash -# Check verification report -timelocker restore verify recovery-001 --report report.json -cat report.json - -# Retry failed files -timelocker restore retry recovery-001 --failed-only - -# Verify snapshot integrity -timelocker snapshot verify abc123 - -# Try different snapshot -timelocker snapshot list --before "2025-11-09" -timelocker restore full def456 --target /restore/backup --verify -``` - -### Issue: Recovery Operation Stuck - -**Symptoms**: Recovery operation appears to hang - -**Solutions**: -```bash -# Check operation status -timelocker restore status recovery-001 --verbose - -# Check system resources -top -df -h - -# Cancel and retry -timelocker restore cancel recovery-001 -timelocker restore full abc123 --target /restore/backup --max-retries 5 - -# Check logs -tail -f /var/log/timelocker/recovery.log -``` - -## Best Practices - -### Before Recovery - -1. **Verify snapshot integrity** before starting recovery -2. **Check available disk space** at target location -3. **Test with dry run** for large recoveries -4. **Browse snapshot contents** to verify what will be restored -5. **Plan target location** to avoid conflicts - -### During Recovery - -1. **Monitor progress** regularly -2. **Check system resources** (disk space, memory, network) -3. **Keep recovery logs** for troubleshooting -4. **Avoid interrupting** recovery operations -5. **Use continue-on-error** for large recoveries - -### After Recovery - -1. **Verify restored data** integrity -2. **Check file permissions** and ownership -3. **Review verification report** for any issues -4. **Test restored files** before deleting originals -5. **Document recovery** for future reference - -## Advanced Topics - -### Scripting Recovery Operations - -```bash -#!/bin/bash -# Automated recovery script - -SNAPSHOT_ID="abc123" -TARGET="/restore/backup" -LOG_FILE="/var/log/recovery-$(date +%Y%m%d-%H%M%S).log" - -# Pre-recovery checks -echo "Starting recovery at $(date)" | tee -a "$LOG_FILE" -timelocker snapshot verify "$SNAPSHOT_ID" | tee -a "$LOG_FILE" - -# Perform recovery -timelocker restore full "$SNAPSHOT_ID" --target "$TARGET" \ - --verify --preserve-all --continue-on-error \ - 2>&1 | tee -a "$LOG_FILE" - -# Post-recovery verification -timelocker restore verify recovery-001 --report report.json \ - 2>&1 | tee -a "$LOG_FILE" - -echo "Recovery completed at $(date)" | tee -a "$LOG_FILE" -``` - -### Parallel Recovery - -For large datasets, consider splitting recovery into parallel operations: - -```bash -# Recover different directories in parallel -timelocker restore selective abc123 --target /restore/dir1 \ - --include "/data/dir1/**" & - -timelocker restore selective abc123 --target /restore/dir2 \ - --include "/data/dir2/**" & - -timelocker restore selective abc123 --target /restore/dir3 \ - --include "/data/dir3/**" & - -# Wait for all to complete -wait -``` +- **Repository not found:** pass the intended `--config-dir` and confirm `tl + repos list` shows the name. +- **Wrong password or repository unavailable:** load the intended credential + source and run `tl repos check ` before retrying. +- **Source missing:** correct the direct path or selection; TimeLocker will not + retry a deterministic path-validation failure. +- **Tray unavailable:** this does not block backup or recovery. Install the + optional Linux GUI prerequisites described in + [Installation](./installation.md) if tray status is wanted. +- **Scheduled command differs from a successful manual run:** compare its user, + environment-file reference, repository, source or selection, executable, and + `--config-dir` with the manual command. See the + [Scheduling Guide](../developer/scheduling-guide.md). -## See Also +## References -- [Recovery Operations API Reference](../../reference/recovery-operations-api.md) -- [Recovery Operations Models Reference](../../reference/recovery-operations-models-reference.md) -- [Backup Operations Troubleshooting](./backup-operations-troubleshooting.md) -- [Repository Management Guide](repository-management-guide.md) -- [Recovery Operations Troubleshooting](recovery-operations-troubleshooting.md) +- [Repository Management](./repository-management-guide.md) +- [Per-Repository Credentials](./per-repo-credentials.md) +- [Installation](./installation.md) +- [Scheduling Backups](../developer/scheduling-guide.md) diff --git a/docs/specs/007-release-readiness-stabilization/change-impact.md b/docs/specs/007-release-readiness-stabilization/change-impact.md index 1b7b7b6..4d7690c 100644 --- a/docs/specs/007-release-readiness-stabilization/change-impact.md +++ b/docs/specs/007-release-readiness-stabilization/change-impact.md @@ -4,15 +4,15 @@ doc_type: spec artifact_type: change-impact status: active owner: Auriora Team -last_reviewed: 2026-07-18 +last_reviewed: 2026-07-19 --- # Change Impact ## Purpose -Record the durable behavior and documentation changed while preparing the -bounded `v0.9.1` stabilization release. +Record the durable behavior and documentation changed while preparing and +machine-validating the bounded `v0.9.1` stabilization release. ## Durable Source Mapping @@ -27,13 +27,17 @@ bounded `v0.9.1` stabilization release. | `docs/processes/version-management.md` | Existing version and release procedure. | high | Correct in place rather than creating a duplicate process. | | `docs/processes/README.md` | Existing process index. | high | Must link the corrected procedure. | | `CHARTER.md` | PyPI distribution is outside current project state. | high | Remains unchanged. | +| Repository, backup, snapshot, and restore command paths | A Linux Mint pilot created a valid Restic snapshot but exposed TimeLocker credential, dry-run, listing, restore, and reporting defects. | high | Raw Restic recovery proved the data while TimeLocker recovery remained blocked. | +| Linux tray integration | Mint provides the Ayatana namespace while the implementation expects only the legacy namespace. | high | Optional GUI behavior must not affect CLI availability. | +| Schedule generation | Generated commands currently reference unsupported policy and non-interactive options. | high | Assets are not safe to install until parser validation passes. | ## Change Type - **Primary type:** operational - **Breaking change:** no - **Durable docs required:** yes -- **External behavior affected:** yes, CI and release artifacts +- **External behavior affected:** yes, CI, release artifacts, backup/recovery, + optional tray behavior, and generated schedules ## Proposed Changes @@ -46,6 +50,9 @@ bounded `v0.9.1` stabilization release. | Correct the release operator procedure | modify | Spec 007 design and rehearsal evidence | `docs/processes/version-management.md` and process index | yes | | Publish accurate `v0.9.1` communications | add | Git history and verification evidence | `CHANGELOG.md`; GitHub release body derived from its version section | yes | | Defer PyPI and `1.0.0` | clarify | `CHARTER.md`, milestone decision | version process and changelog | yes | +| Repair local repository initialization, dry-run, backup result, snapshot listing, and restore | bug_fix | runtime command and Restic adapter behavior | user backup/recovery guidance | yes | +| Support Mint's Ayatana indicator with legacy fallback | bug_fix | tray integration | installation and troubleshooting guidance | yes | +| Generate executable schedules with explicit configuration and privilege boundaries | modify | schedule model and renderers | scheduling/operator guidance | yes | ## Promotion Targets @@ -56,14 +63,16 @@ bounded `v0.9.1` stabilization release. | Release procedure and rollback boundary | `docs/processes/version-management.md` | complete | Corrected in place and linked from `docs/processes/README.md` by T011. | | Release contents and limitations | `CHANGELOG.md` | complete | T012 made the `v0.9.1` section canonical and previewed its derived release body. | | Current version and release path | `README.md` | complete | T011 records Python 3.12-3.13 and `0.9.1` prepared, not published. | +| Backup/recovery credential and source contract | `docs/guides/user/recovery-operations-guide.md` | complete | T015-T016 machine acceptance and T019 review passed. | +| Linux tray prerequisites and fallback | `docs/guides/user/installation.md` | complete | T017 Mint and headless validation passed. | +| Schedule configuration, environment, privilege, and cutover boundary | `docs/guides/developer/scheduling-guide.md` | complete | T018 staging and T019 handoff review passed. | ## Unchanged Durable Areas | Durable area | Reviewed source | Reason unchanged | |--------------|-----------------|------------------| | Product scope | `CHARTER.md` | Stabilization does not expand the product or publication boundary. | -| Application architecture | `docs/2-architecture/` | No runtime component boundary changes are intended. | -| Credential handling | durable security and user guidance | CI uses only ephemeral MinIO values; repository credential behavior is out of scope. | +| Product mandate | `CHARTER.md` | Runtime stabilization remains within the existing backup and recovery mandate. | | CLI feature backlog | GitHub issues #5, #7, #9, #11, #28-#30, #33-#34, #54-#56 | These are reconciled but not pulled into the patch release spec. | ## Bug Fix Details @@ -83,9 +92,10 @@ bounded `v0.9.1` stabilization release. ## Open Questions -None block implementation. The declared Python 3.12 and 3.13 contract passed on -Linux, macOS, and Windows. Publication and lifecycle closure remain separate -human decisions after T013. +Implementation is unblocked in the isolated pilot. Privileged schedule +installation, repository credential selection, identification of the actual +NPBackup scheduler, and final cutover remain explicit operator decisions after +T019; publication and lifecycle closure remain separate human decisions. ## Related Artifacts diff --git a/docs/specs/007-release-readiness-stabilization/design.md b/docs/specs/007-release-readiness-stabilization/design.md index 5f41df0..1fec166 100644 --- a/docs/specs/007-release-readiness-stabilization/design.md +++ b/docs/specs/007-release-readiness-stabilization/design.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: design status: active owner: Auriora Team -last_reviewed: 2026-07-18 +last_reviewed: 2026-07-19 --- # Technical Design @@ -17,6 +17,10 @@ under this spec with issue #68 retaining assignment and evidence history; versioned artifacts are then built once and installed into clean environments; finally, the existing release workflow is rehearsed and the evidence is promoted into durable guidance and release communications. +Phase 5 adds a machine-acceptance gate after a Linux Mint pilot exposed runtime +defects that artifact smoke tests could not detect. Release readiness now also +requires a TimeLocker-owned backup, listing, restore, tray, and scheduling path +to work without changing the existing NPBackup job prematurely. ## Requirement Coverage @@ -27,6 +31,10 @@ the evidence is promoted into durable guidance and release communications. | R3 | AC1-AC5 | Side-effect-safe version preparation, one version guard, and one artifact set reused by smoke validation | Git-state comparison, build, metadata inspection, hashes, CLI version | | R4 | AC1-AC5 | Explicit six-combination support contract | Wheel and sdist installs, CLI smoke matrix | | R5 | AC1-AC6 | Non-publishing rehearsal followed by in-place process updates and changelog-derived communications | Workflow lint/review, rehearsal, docs review | +| R6 | AC1-AC4 | Consistent credential resolution, side-effect-free dry-run, source validation, and truthful backup results | Focused CLI/service tests and local pilot | +| R7 | AC1-AC4 | Canonical snapshot mapping plus robust latest/exact restore and error propagation | Focused snapshot/restore tests and digest-verified restore | +| R8 | AC1-AC3 | Ayatana-first Linux indicator discovery with legacy fallback and non-fatal headless behavior | Import-path tests and Linux Mint tray smoke | +| R9 | AC1-AC4 | Schedule records bind executable repository/source inputs and render only supported CLI options | Parser round-trip tests and staged systemd asset inspection | ## Correctness Property Coverage @@ -37,6 +45,10 @@ the evidence is promoted into durable guidance and release communications. | CP-003 | The same smoke contract is run against wheel and sdist installs | Clean virtual environments and supported platform jobs | System prerequisites remain explicit. | | CP-004 | Rehearsal stops before tag creation and uses workflow validation or a non-publishing harness | Command review and absence of new tag/release | Any external write needs separate release approval. | | CP-005 | Release-note items link to commits, specs, issues, tests, or known limitations | Documentation and release review | Generated notes may be input, not sole evidence. | +| CP-006 | Credential sources converge on one repository password boundary and generated assets contain references, never values | Unit tests and redacted asset review | Existing NPBackup secrets are not inspected. | +| CP-007 | TimeLocker creates, lists, restores, and digest-verifies the same snapshot | Focused tests plus local round trip | Raw Restic proof alone is insufficient. | +| CP-008 | Rendered schedule commands are parsed by the installed CLI before installation | Parser contract tests | Privileged execution still requires operator approval. | +| CP-009 | Tray imports and initialization are optional and isolated from core CLI execution | Namespace/fallback tests and headless CLI smoke | Desktop packaging varies by distribution. | ## High-Level Design @@ -50,6 +62,7 @@ CI profile repair -> clean-install matrix -> release workflow rehearsal -> durable docs and changelog-derived release communications + -> Linux Mint machine acceptance and staged schedule validation -> human release decision ``` @@ -73,10 +86,20 @@ CI profile repair - `CHANGELOG.md`, installation guide, and the existing version-management process receive accepted current-state guidance before spec closure. The GitHub release body is derived from the `v0.9.1` changelog section. +- Repository, backup, snapshot, and restore commands: reconcile credential + precedence, source handling, snapshot mapping, progress cleanup, and reported + results around the existing Restic adapter. +- Linux tray integration: prefer `AyatanaAppIndicator3` on current Mint while + retaining the legacy `AppIndicator3` fallback and non-fatal headless mode. +- Schedule generation: persist an executable repository/source target, render + current CLI commands, and make configuration and environment-file boundaries + explicit without embedding secrets. ### Data Models -No application data model changes are required. Release evidence uses files and +The schedule record gains the repository and source/selection inputs required +to execute a backup; compatibility handling is required for existing records. +Other release evidence uses files and external records: workflow runs, `dist/` artifacts, `SHA256SUMS`, clean-install logs, issue #68, changelog text, and release review notes. Generated `dist/` content remains untracked unless repository policy explicitly says otherwise. @@ -91,6 +114,20 @@ operator and user guidance is promoted to durable docs, while the spec remains the temporary coordination surface until closure. +### Phase 5 Machine Acceptance Flow + +```text +resolve repository credential -> initialize isolated repository + -> validate dry-run without mutation -> create TimeLocker backup + -> list snapshot through TimeLocker -> restore latest and exact snapshot + -> verify reference-file digest -> validate Mint tray namespace + -> render and parse staged schedule assets -> operator cutover decision +``` + +The pilot uses isolated TimeLocker configuration and data directories. It does +not read masked NPBackup secret values, install privileged units, or disable an +existing schedule. The raw Restic CLI remains a diagnostic control only. + ## Low-Level Design ### CI Profile Logic @@ -153,6 +190,34 @@ It records pre/post commit, tag, and GitHub-release identity. The publishing boundary is a hard stop before any commit, `git tag`, tag push, `gh release create`, or package-index upload. +### Repository and Credential Boundary + +Repository initialization and later operations use the same credential +resolver. Explicit command input takes precedence over the documented +environment chain; interactive prompting occurs only when allowed and no +non-interactive source is available. Credentials are passed to Restic without +being stored in schedule commands, normal logs, or verification evidence. + +Dry-run validates the same repository and sources as execution but must not +create a snapshot. Deterministic source or credential validation failures are +returned directly and are not retried. + +### Snapshot and Restore Boundary + +Snapshot adapters map Restic's canonical timestamp into the domain model once. +Listing and restore share exact/latest resolution. Progress and status cleanup +must preserve the primary exception even if cleanup itself encounters stale or +partially initialized state. + +### Schedule Rendering Boundary + +A schedule is executable only when it identifies a repository and explicit +sources or a saved selection. Renderers build argv from commands accepted by +the current parser and validate that argv before writing cron or systemd +assets. Non-default config and credential environment files are references in +the asset; secret values are never serialized. Privileged sources require a +system-level unit and remain an operator/sudo gate. + ### Error Handling - Missing MinIO fails at dependency preflight in the MinIO profile. @@ -161,6 +226,11 @@ boundary is a hard stop before any commit, `git tag`, tag push, - Unsupported platform results are recorded as blocking support-claim gaps, not silently ignored. - Rehearsal or workflow uncertainty remains a release blocker until reviewed. +- Repository and source validation errors remain primary and are not retried. +- Progress/status cleanup logs secondary failures without replacing the + original backup or restore error. +- An incomplete schedule target blocks generation before any asset is written. +- Missing tray libraries disable only the optional tray integration. ### Security, Trust, and Access @@ -172,9 +242,11 @@ required nor accessed. ### Migration and Compatibility -This is a patch release. No application data migration or intended breaking CLI -change is included. Any discovered breaking change is removed from the release -or escalated for a new requirement and explicit versioning decision. +This is a patch release with no intended breaking CLI change. Existing schedule +records without an executable target remain readable but cannot generate new +assets until repository and source/selection fields are supplied. Any other +discovered breaking change is removed from the release or escalated for a new +requirement and explicit versioning decision. ## Validation Strategy @@ -186,6 +258,10 @@ or escalated for a new requirement and explicit versioning decision. | Build, metadata, hashes, wheel and sdist installs | R3, R4, CP-002, CP-003 | `verification.md`, artifacts | OS coverage limits | | Non-publishing workflow rehearsal | R5, CP-004 | `verification.md`, review record | Tag-only behavior not executed until release approval | | Changelog-derived communications, install, and process review | R4, R5, CP-005 | durable docs and review | Human wording error | +| Repository init, dry-run, backup, and result checks | R6, CP-006 | focused tests and `verification.md` | Host credential differences | +| TimeLocker snapshot list/restore and digest round trip | R7, CP-007 | focused tests and isolated Mint pilot | Filesystem metadata variance | +| Ayatana, legacy, and headless tray paths | R8, CP-009 | focused tests and Mint tray smoke | Desktop session variance | +| Schedule render/parser round trip and staged asset review | R9, CP-006, CP-008 | focused tests and `verification.md` | Privileged installation remains manual | ## Downstream Task Guidance @@ -197,6 +273,10 @@ or escalated for a new requirement and explicit versioning decision. does not authorize tagging or publishing. - Reconcile requirements, design, tasks, verification, and traceability after any support-matrix or workflow-scope change. +- Do not restore release-ready status until the TimeLocker-owned machine round + trip succeeds and generated schedule commands parse against the current CLI. +- Do not disable NPBackup or install a privileged timer within implementation; + prepare redacted assets and leave those actions as explicit operator gates. ## Operational Considerations @@ -208,9 +288,10 @@ durable release procedure. ## Open Questions -None block implementation. The support contract is Python 3.12 and 3.13 on -Linux, macOS, and Windows; T007 must validate all six combinations or correct -the associated claim before release preparation can continue. +Implementation can proceed on the isolated pilot. Final cutover still requires +the operator to provide a supported TimeLocker repository credential, approve +sudo installation for protected sources, identify the actual NPBackup scheduler, +and observe successful TimeLocker scheduled runs before disabling it. ## Related Artifacts diff --git a/docs/specs/007-release-readiness-stabilization/requirements.md b/docs/specs/007-release-readiness-stabilization/requirements.md index 9c43696..27e6a24 100644 --- a/docs/specs/007-release-readiness-stabilization/requirements.md +++ b/docs/specs/007-release-readiness-stabilization/requirements.md @@ -11,12 +11,14 @@ last_reviewed: 2026-07-18 ## Introduction -TimeLocker is versioned as `0.9.0`, has no published release tags, and its -normal GitHub Actions test profile currently fails because MinIO integration -tests run without a reachable MinIO service. The next milestone is a bounded -`v0.9.1` stabilization release that restores trustworthy CI, validates built -artifacts in clean environments, rehearses the tag-triggered release path, and -publishes evidence-backed release notes. +TimeLocker is prepared as `0.9.1` but has no published release tags. Phases 1-4 +restored CI, artifact, cross-platform smoke, and non-publishing release +evidence. A subsequent Linux Mint machine pilot proved that a valid Restic +backup can be created but exposed release-blocking defects in repository +initialization, dry-run, snapshot discovery, restore, system-tray integration, +and generated scheduling commands. Phase 5 extends the stabilization boundary +until a real local backup can be discovered and restored through TimeLocker and +an executable staged-migration schedule can be prepared safely. ## Goals @@ -27,6 +29,8 @@ publishes evidence-backed release notes. - Prove the supported installation and CLI smoke paths in clean environments. - Rehearse the release workflow without creating a production tag. - Produce accurate changelog-derived release communications and operator documentation. +- Prove repository setup, backup, snapshot discovery, restore, Linux Mint tray + compatibility, and schedule generation on a real operator machine. ## Non-Goals @@ -35,6 +39,8 @@ publishes evidence-backed release notes. - Implementing unrelated feature, CLI, configuration, or performance backlog. - Creating a release tag or GitHub release during implementation rehearsal. - Weakening tests, coverage, or supported-platform claims to obtain a pass. +- Extracting masked NPBackup secrets, disabling NPBackup before TimeLocker + restore proof, or installing a privileged timer without explicit sudo access. ## Glossary @@ -65,20 +71,22 @@ publishes evidence-backed release notes. ## Durable Impact See `change-impact.md`. This spec modifies test workflow behavior, package -version metadata, installation guidance, the release process, and release -communications. It does not change product architecture or the supported -credential model. +version metadata, installation guidance, the release process, release +communications, CLI recovery behavior, optional Linux tray integration, and +schedule generation. It preserves the supported credential model while making +its precedence and non-interactive use consistent. ## Staged Readiness -- **Current stage:** implementation-ready -- **Next stage:** implementation +- **Current stage:** implementation +- **Next stage:** validation - **Ready to implement when:** package lint, traceability, task dependency, and agent-readiness checks pass. - **Design-first exception:** no - **Optional artifacts included:** `change-impact.md`, `verification.md`, `traceability.md` -- **Downstream review needed:** verification and release readiness +- **Downstream review needed:** recovery, security, operations, documentation, + and release readiness ## Requirements @@ -193,6 +201,83 @@ from failures. release communications; the eventual GitHub release body SHALL be derived from that version section rather than a second durable release-note file. +### Requirement 6: Operator-ready repository and backup workflow + +**User Story:** As an operator, I want repository initialization and backup +commands to honor the documented credential and source contracts, so that I can +run TimeLocker non-interactively without hidden CLI exceptions. + +#### Acceptance Criteria + +1. GIVEN a repository password from the explicit option or supported + environment chain, WHEN a local repository is initialized, THEN TimeLocker + SHALL initialize it without requiring an unrelated interactive prompt. +2. GIVEN a file or directory accepted by `backup create`, WHEN a dry-run is + requested, THEN it SHALL complete without repository mutation or an + undefined-variable exception. +3. GIVEN a valid initialized repository and source, WHEN a backup completes, + THEN the result SHALL identify the created snapshot and SHALL NOT report a + false zero-file count when files were stored. +4. IF source validation fails, THEN TimeLocker SHALL report the actionable + validation error without retrying a deterministic input failure. + +### Requirement 7: Recoverable snapshot workflow + +**User Story:** As an operator, I want TimeLocker to list and restore its +snapshots, so that a successful backup represents recoverable data rather than +an opaque Restic artifact. + +#### Acceptance Criteria + +1. GIVEN a valid Restic snapshot, WHEN table or JSON listing is requested, + THEN TimeLocker SHALL map the canonical snapshot timestamp and return the + snapshot without an attribute error. +2. GIVEN `latest` or an exact snapshot ID, WHEN a full restore is requested, + THEN TimeLocker SHALL resolve the snapshot and restore its files. +3. GIVEN a restored reference file, WHEN its digest is compared with the + source, THEN the digests SHALL match. +4. IF discovery or restore fails, THEN the original failure SHALL remain + visible and SHALL NOT be replaced by progress-context or persisted-status + secondary errors. + +### Requirement 8: Linux Mint system-tray compatibility + +**User Story:** As a Linux Mint operator, I want optional tray integration to +use the desktop toolkit actually installed, so that TimeLocker does not claim +the tray is unavailable on a supported Cinnamon session. + +#### Acceptance Criteria + +1. GIVEN PyGObject and `AyatanaAppIndicator3`, WHEN TimeLocker initializes the + Linux tray, THEN it SHALL create an indicator through that namespace. +2. WHERE legacy `AppIndicator3` is available, THE SYSTEM SHALL retain that + supported compatibility path. +3. IF no tray toolkit is importable or a command runs headlessly, THEN CLI + backup and recovery behavior SHALL remain usable and the diagnostic SHALL + identify the missing optional dependency rather than deny platform support. + +### Requirement 9: Executable staged-migration schedules + +**User Story:** As an operator replacing a privileged NPBackup job, I want +generated automation to invoke a real TimeLocker command with explicit +configuration and credential boundaries, so that scheduling cannot silently +run an unsupported CLI shape or omit protected sources. + +#### Acceptance Criteria + +1. GIVEN a schedule bound to a repository and either explicit sources or a + selection, WHEN cron or systemd assets are generated, THEN every emitted + TimeLocker option SHALL be accepted by the current CLI. +2. WHERE a non-default configuration directory or protected environment file + is required, THE GENERATED ASSET SHALL reference it explicitly without + embedding secret values. +3. GIVEN sources such as `/etc`, `/var`, or `/root`, WHEN system scheduling is + prepared, THEN the guidance SHALL preserve the required privileged execution + boundary and SHALL NOT imply that a user timer provides equivalent coverage. +4. UNTIL TimeLocker backup, listing, and restore validation pass and the new + timer has observed successful runs, NPBackup SHALL remain enabled or its + external scheduling state SHALL remain unchanged. + ## Correctness Properties - **CP-001:** Every test in normal CI either has all external dependencies @@ -206,6 +291,14 @@ from failures. publication, commit, or tag as a side effect. - **CP-005:** Each public release claim maps to a recorded validation result or an explicit known limitation. +- **CP-006:** Every accepted credential source produces the same Restic + repository password without exposing it in generated assets or logs. +- **CP-007:** A snapshot created through TimeLocker can be listed and restored + through TimeLocker with byte-identical file content. +- **CP-008:** Every generated schedule command parses successfully against the + installed TimeLocker CLI. +- **CP-009:** Optional tray initialization cannot make core CLI backup or + recovery operations fail. ## Technical Context @@ -214,6 +307,8 @@ from failures. - **Primary Dependencies:** pytest, coverage, build, GitHub Actions, Restic, MinIO for S3 integration tests. - **Target Platform:** Linux, macOS, and Windows, each on Python 3.12 and 3.13. +- **Machine Acceptance Platform:** Linux Mint/Cinnamon on X11, with the system + `python3-gi` and Ayatana AppIndicator typelib available. - **Constraints:** No secrets in logs or artifacts; no production tag during rehearsal; coverage threshold remains 50 percent; PyPI is deferred. - **Performance Goals:** Stress thresholds must distinguish regression from @@ -231,6 +326,12 @@ from failures. - **SC-005:** Release rehearsal completes without external publication. - **SC-006:** Durable operator guidance, installation guidance, changelog, and release notes are ready for human release approval. +- **SC-007:** A fresh local pilot repository completes init, backup, TimeLocker + snapshot listing, TimeLocker restore, and digest verification. +- **SC-008:** Linux Mint tray initialization recognizes Ayatana when the GUI + extra and system typelib are present. +- **SC-009:** Generated systemd assets use only supported commands and preserve + the separate credential, sudo, and NPBackup cutover gates. ## Related Artifacts diff --git a/docs/specs/007-release-readiness-stabilization/tasks.md b/docs/specs/007-release-readiness-stabilization/tasks.md index 46808f3..d8d0311 100644 --- a/docs/specs/007-release-readiness-stabilization/tasks.md +++ b/docs/specs/007-release-readiness-stabilization/tasks.md @@ -15,7 +15,8 @@ last_reviewed: 2026-07-19 ```text T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 - -> T009 -> T010 -> T011 -> T012 -> T013 + -> T009 -> T010 -> T011 -> T012 -> T013 -> T014 -> T015 -> T016 + -> T017 -> T018 -> T019 ``` ## Phase 1: Restore Trustworthy Validation @@ -361,10 +362,164 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 - Validation: Lifecycle lint, readiness, traceability and evidence checks, required test profiles, Markdown and internal-link checks, `git diff --check`, security and release-readiness expert review. - - Evidence: Final normal profile passed: 2,774 passed, one skipped, 57 deselected, 19 warnings, 52.14% coverage in 1,439.49 seconds. The initial run exposed one live-host-load test dependency; explicit low-load test resources corrected it and all 22 tool-manager tests passed. Nine release-contract tests, `actionlint`, release intent/boundary validators, derived-notes preview, Agent Workbench Markdown/link checks, and `git diff --check` passed. TimeLocker expert-panel review found no remaining actionable Phase 4 findings. Lifecycle lint has zero errors, zero acceptance gaps, and only the reviewed non-blocking canonical-context advisory. Final HEAD remains `1dcf910`; tags and GitHub releases remain zero; release-run inventory remains 11; no PyPI action occurred. + - Evidence: The final normal profile passed 2,774 tests with 52.14% + coverage. All 22 tool-manager tests, nine release-contract tests, + `actionlint`, release validators, derived-notes preview, documentation + checks, lifecycle checks, and expert-panel assessment passed. External-state + identities matched the recorded baseline and the publication boundary was + preserved. - Status: Complete on 2026-07-19; ready for separate release-maintainer approval and lifecycle closure, with no commit or publication created. - Evidence mode: validation + +## Phase 5: Machine Acceptance and Migration Preparation + +- [x] T014 Reconcile the Linux Mint pilot findings into the active package. + - Depends on: T013 + - Requirements: Requirements 6, 7, 8, and 9 + - Acceptance Criteria: all + - Properties: CP-006, CP-007, CP-008, CP-009 + - Files: all Spec 007 artifacts + - Acceptance: The package records the failed TimeLocker-owned pilot, removes + stale release/closure readiness claims, maps every new criterion, and + preserves credential, privilege, and NPBackup cutover boundaries. + - Validation: Lifecycle lint, readiness, traceability, and `git diff --check`. + - Evidence: The isolated Linux Mint/Cinnamon pilot created Restic snapshot + `876b20bc7916` but exposed repository-init credential inconsistency, an + undefined dry-run variable, false backup counts, snapshot timestamp and + restore-state failures, Ayatana namespace mismatch, and unparseable + generated scheduling commands. Raw Restic listing and digest-verified + restore passed as a diagnostic control; no secret was extracted, no timer + was installed, and NPBackup state was not changed. + - Status: Complete on 2026-07-19; lifecycle lint and stage readiness passed + with zero gaps and the release/closure decision is explicitly withdrawn. + - Evidence mode: reconciliation + +- [x] T015 Repair repository initialization and backup execution. + - Depends on: T014 + - Requirement: Requirement 6 + - Acceptance Criteria: AC1-AC4 + - Properties: CP-006 + - Files: repository and backup CLI/service paths plus focused tests + - Acceptance: Explicit and environment credentials initialize consistently; + file/directory dry-runs do not mutate or raise; successful results identify + the snapshot and truthful counts; deterministic validation is not retried. + - Validation: Focused repository/backup tests and isolated pilot init, + dry-run, and backup. + - Evidence: Credential resolution now accepts explicit, stored, or environment + input without re-resolving a known URI; direct files are valid selections; + missing CLI sources and invalid targets fail before retry; both dry-run + paths avoid job-only state; Restic summary fields produce truthful counts; + runtime passwords are absent from result metadata. Focused validation passed + 124 tests. On the isolated Mint pilot, environment-only init recognized the + repository, file and directory dry-runs reported 1 and 11 files, and an + actual file backup created snapshot `731d9784` with one file and 15,839 + bytes. Snapshot count moved from one to two only after the actual backup. + - Status: Complete on 2026-07-19. + - Evidence mode: implementation + +- [x] T016 Repair snapshot discovery and restore. + - Depends on: T015 + - Requirement: Requirement 7 + - Acceptance Criteria: AC1-AC4 + - Properties: CP-007 + - Files: snapshot model/adapter, restore manager/CLI, progress/status handling, + and focused tests + - Acceptance: Table and JSON listing work; latest and exact restore work; + a reference digest matches; cleanup never replaces the primary failure. + - Validation: Focused snapshot/restore tests and isolated TimeLocker-owned + list/restore/digest round trip. + - Evidence: The snapshot adapter now maps Restic `time`, `paths`, host, user, + and full IDs; table and JSON listing serialize canonical fields; `latest` + resolves to the newest snapshot; recovery operations initialize progress; + and progress cleanup preserves a primary body exception. The focused + snapshot/recovery/CLI/progress suite passed 120 tests, followed by 19 + snapshot-manager and 14 orchestrator regression tests. On the isolated + Mint pilot, both listing formats exposed two snapshots, `latest` and the + exact 64-character ID restored through TimeLocker, and both restored + `README.md` files matched the source SHA-256 digest. + - Status: Complete on 2026-07-19. + - Evidence mode: implementation + +- [x] T017 Add Linux Mint tray compatibility without coupling core CLI behavior. + - Depends on: T016 + - Requirement: Requirement 8 + - Acceptance Criteria: AC1-AC3 + - Properties: CP-009 + - Files: tray integration, optional dependency metadata/guidance, focused tests + - Acceptance: Ayatana and legacy namespaces are supported; headless or + missing-dependency state is accurate and non-fatal to backup/recovery. + - Validation: Focused import/fallback tests, headless CLI smoke, and Mint tray smoke. + - Evidence: Linux tray discovery now prefers `AyatanaAppIndicator3`, falls + back to legacy `AppIndicator3`, retains the selected namespace for + shutdown, and leaves the facade unavailable without affecting the CLI when + PyGObject is absent. Six focused tests passed. On this Mint Cinnamon/X11 + host, the project interpreter remained correctly headless, `tl version` + passed, and `/usr/bin/python3` initialized and shut down an Ayatana + indicator using the installed GTK/PyGObject typelibs. Installation guidance + now explains the optional packages and pyenv boundary. + - Status: Complete on 2026-07-19. + - Evidence mode: implementation + +- [x] T018 Generate executable staged-migration schedules. + - Depends on: T017 + - Requirement: Requirement 9 + - Acceptance Criteria: AC1-AC3 + - Properties: CP-006, CP-008 + - Files: schedule model/CLI/renderers, migrations or compatibility handling, + operator guidance, and focused tests + - Acceptance: Schedules bind a repository and sources/selection; rendered + commands parse against the current CLI; assets reference non-default config + and protected environment files without secret values; protected sources + retain a system-level privilege boundary. + - Validation: Focused schedule tests, parser round trip, and redacted staged + cron/systemd asset inspection without installation. + - Evidence: Schedule creation now requires an explicit repository and exactly + one selection or one-or-more sources; it records non-default config, + environment-file reference, and user/system boundary. Cron, systemd, and + Windows renderers use the current `backup create` contract and never emit + credential values. The schedule test validates the command and referenced + paths. Twenty focused CLI and end-to-end tests passed. The isolated Mint + pilot created a disabled system schedule for repository + `timelocker-pilot`, protected source `/etc`, the mode-0600 pilot environment + reference, and the explicit pilot config directory. Cron and systemd assets + passed shell/parser and current-CLI checks; no unit or cron entry was + installed or enabled, and NPBackup state was unchanged. + - Status: Complete on 2026-07-19. + - Evidence mode: implementation + +- [x] T019 Checkpoint - Machine acceptance and operator cutover handoff. + - Depends on: T018 + - Requirements: Requirements 6, 7, 8, and 9 + - Acceptance Criteria: all + - Properties: CP-006, CP-007, CP-008, CP-009 + - Acceptance: The isolated TimeLocker round trip and tray/schedule checks pass; + durable guidance is promoted; privileged installation, repository + credential selection, actual NPBackup scheduler discovery, observation, and + final cutover are documented as separate operator gates. + - Decision owner: operator and release maintainer + - Validation: Focused and normal test profiles, machine pilot, lifecycle and + traceability checks, durable-doc review, and `git diff --check`. + - Evidence: T015-T018 passed focused implementation suites and the isolated + Linux Mint/Cinnamon pilot: environment-only init, non-mutating dry-runs, a + real TimeLocker snapshot, table/JSON listing, latest and exact-ID restores + with matching SHA-256 digests, Ayatana initialization, headless CLI use, + and redacted cron/systemd staging. The final normal profile passed 2,787 + tests with one skipped, 57 deselected, and 52.38% coverage in 801.81 + seconds. Durable installation, recovery, and scheduling guidance was + promoted. Task-state audit, closure readiness, acceptance traceability, + guide-specific Markdown, local-link, compile, and whitespace checks passed; + lifecycle lint retained only its optional canonical-context advisory and + the package retains historical evidence/table-readability advisories. No + privileged unit was installed, NPBackup was not changed, and `/etc` remains + a representative protected pilot source rather than a confirmed NPBackup + source. + - Status: Complete on 2026-07-19; ready for separate operator credential and + source reconciliation, scheduler discovery, privileged-install approval, + observed scheduled runs, and final cutover approval. Release approval and + lifecycle closure remain separate human decisions. + - Evidence mode: validation + ## Execution Rules - Read the linked row in `traceability.md` and the relevant requirements, diff --git a/docs/specs/007-release-readiness-stabilization/traceability.md b/docs/specs/007-release-readiness-stabilization/traceability.md index def40af..98f4725 100644 --- a/docs/specs/007-release-readiness-stabilization/traceability.md +++ b/docs/specs/007-release-readiness-stabilization/traceability.md @@ -26,6 +26,12 @@ last_reviewed: 2026-07-19 | T011 | Requirements 4 and 5 | R4 AC3, AC4, AC5; R5 AC2, AC5 | Operational Considerations; Clean-Install Matrix | Existing process and install guidance | command, Markdown, and link review | version process, process index, install guide, README if needed | none | | T012 | Requirement 5 | AC3, AC5, AC6 | Validation Strategy | Canonical release communications | claim-to-evidence review and release-body preview | changelog | none | | T013 | Requirement 1, Requirement 2, Requirement 3, Requirement 4, Requirement 5 | all | Validation Strategy; Downstream Task Guidance | all promotion targets | lifecycle, evidence, security, and expert review | all listed targets | none | +| T014 | Requirement 6, Requirement 7, Requirement 8, Requirement 9 | all | Phase 5 Machine Acceptance Flow; Migration and Compatibility | Linux Mint pilot reconciliation | lifecycle, traceability, and package review | spec package | operator credential, sudo, and cutover remain downstream gates | +| T015 | Requirement 6 | Requirement 6 AC1, Requirement 6 AC2, Requirement 6 AC3, Requirement 6 AC4 | Repository and Credential Boundary; Error Handling | Backup/recovery runtime repair | focused tests and isolated init/dry-run/backup | backup and recovery guidance | credential choice remains operator-owned | +| T016 | Requirement 7 | Requirement 7 AC1, Requirement 7 AC2, Requirement 7 AC3, Requirement 7 AC4 | Snapshot and Restore Boundary; Error Handling | Recoverable snapshot workflow | focused tests and digest-verified TimeLocker restore | backup and recovery guidance | none | +| T017 | Requirement 8 | Requirement 8 AC1, Requirement 8 AC2, Requirement 8 AC3 | Components and Changes; Error Handling | Mint tray compatibility | namespace/fallback tests and Mint smoke | installation/troubleshooting guidance | desktop packaging variance | +| T018 | Requirement 9 | Requirement 9 AC1, Requirement 9 AC2, Requirement 9 AC3, Requirement 9 AC4 | Schedule Rendering Boundary; Migration and Compatibility | Executable staged schedules | parser round trip and staged asset review | scheduling/operator guidance | privileged install remains operator-owned | +| T019 | Requirement 6, Requirement 7, Requirement 8, Requirement 9 | all | Validation Strategy; Downstream Task Guidance | all Phase 5 promotion targets | machine acceptance, lifecycle, tests, and docs review | all Phase 5 targets | NPBackup cutover requires observed runs | ## Requirement To Delivery Matrix @@ -36,6 +42,10 @@ last_reviewed: 2026-07-19 | Requirement 3 | AC1-AC5 | Version and Artifact Guard | T006, T008 | side-effect proof, build, metadata, hashes, version guard | metadata, version process, changelog | | Requirement 4 | AC1-AC5 | Clean-Install Matrix | T007-T008, T011 | six-combination artifact install matrix and support-claim review | metadata, installation guide | | Requirement 5 | AC1-AC6 | Release Rehearsal; Operational Considerations | T009-T013 | interface tests, rehearsal, docs, communications, expert review | version process, process index, changelog, README if needed | +| Requirement 6 | AC1-AC4 | Repository and Credential Boundary; Error Handling | T014-T015, T019 | focused tests and isolated init/dry-run/backup | backup and recovery guidance | +| Requirement 7 | AC1-AC4 | Snapshot and Restore Boundary; Error Handling | T014, T016, T019 | list/latest/exact restore and digest proof | backup and recovery guidance | +| Requirement 8 | AC1-AC3 | Components and Changes; Error Handling | T014, T017, T019 | Ayatana/legacy/headless tests and Mint smoke | installation/troubleshooting guidance | +| Requirement 9 | AC1-AC4 | Schedule Rendering Boundary; Migration and Compatibility | T014, T018-T019 | parser round trip, redacted asset review, cutover gate review | scheduling/operator guidance | ## Correctness Property Coverage @@ -46,6 +56,10 @@ last_reviewed: 2026-07-19 | CP-003 | Requirement 4 | Clean-Install Matrix | T007-T008 | wheel and sdist smoke across six combinations | runner availability blocks support claim | | CP-004 | Requirements 3 and 5 | Version and Artifact Guard; Release Rehearsal | T006, T008-T010, T013 | pre/post commit, tag, and release-state identity | tag-only external behavior | | CP-005 | Requirement 5 | Validation Strategy | T012-T013 | changelog claim evidence and derived release-body review | human review quality | +| CP-006 | Requirements 6 and 9 | Repository and Credential Boundary; Schedule Rendering Boundary | T014-T015, T018-T019 | credential precedence tests and redacted asset review | operator-managed environment file permissions | +| CP-007 | Requirement 7 | Snapshot and Restore Boundary | T014, T016, T019 | TimeLocker create/list/restore/digest round trip | filesystem metadata variance | +| CP-008 | Requirement 9 | Schedule Rendering Boundary | T014, T018-T019 | generated argv parser contract | CLI evolution requires contract maintenance | +| CP-009 | Requirement 8 | Components and Changes; Error Handling | T014, T017, T019 | namespace/fallback and headless CLI tests | desktop session variance | ## Design To Implementation Matrix @@ -57,12 +71,17 @@ last_reviewed: 2026-07-19 | Release Rehearsal | Requirement 5 | T009-T010, T013 | release workflow, rehearsal evidence | non-publishing interface, rehearsal, external-state identity | | Operational Considerations | Requirements 4 and 5 | T011-T013 | existing version process, process index, installation guide, changelog | docs, command, link, communications, and expert review | | Security, Trust, and Access | Requirements 1, 3, and 5 | T002, T006, T009-T010, T013 | workflow permissions, ephemeral MinIO values, version helper | secrets, permissions, and side-effect review | +| Repository and Credential Boundary | Requirement 6 | T014-T015, T019 | repository/backup CLI and credential resolver | focused tests and isolated pilot | +| Snapshot and Restore Boundary | Requirement 7 | T014, T016, T019 | snapshot adapter, restore manager/CLI, progress/status handling | list/restore/digest round trip | +| Schedule Rendering Boundary | Requirement 9 | T014, T018-T019 | schedule model, CLI, cron/systemd renderers | parser round trip and staged asset review | +| Phase 5 Machine Acceptance Flow | Requirements 6-9 | T014-T019 | runtime paths, optional tray, schedule tooling, durable guidance | isolated Linux Mint pilot and final checkpoint | ## Open Decision Impact -There are no unresolved decisions blocking implementation. Any newly discovered -support or publication decision must be recorded and reconciled across this -package before downstream tasks continue. +There are no unresolved decisions blocking isolated implementation. Repository +credential selection, sudo installation for protected sources, discovery of the +actual NPBackup scheduler, observed scheduled runs, and cutover are explicit +operator gates that block migration, not T015-T018 implementation. ## Maintenance Notes diff --git a/docs/specs/007-release-readiness-stabilization/verification.md b/docs/specs/007-release-readiness-stabilization/verification.md index 2078206..71564f4 100644 --- a/docs/specs/007-release-readiness-stabilization/verification.md +++ b/docs/specs/007-release-readiness-stabilization/verification.md @@ -11,23 +11,27 @@ last_reviewed: 2026-07-19 ## Scope -This record covers Spec 007 requirements R1-R5 and tasks T001-T013. It records -release-preparation evidence only; creating a production tag or release requires -separate explicit approval. +This record covers Spec 007 requirements R1-R9 and tasks T001-T019. It records +release-preparation and machine-acceptance evidence; creating a production tag, +installing a privileged schedule, disabling NPBackup, or publishing a release +requires separate explicit approval. ## Quality Gates | Gate | Required? | Status | Evidence | |------|-----------|--------|----------| -| Acceptance traceability complete | yes | passed | Lifecycle stage readiness reports zero acceptance, property, context, downstream-review, or blocking gaps after reconciliation. | +| Acceptance traceability complete | yes | passed | Phase 5 requirement, criterion, property, design, task, and verification mappings were reconciled by T014. | | Substantive requirements and design review | yes | passed | Review on 2026-07-18 produced TLR-001 through TLR-006; all six findings were reconciled into the package before implementation. | -| Task evidence complete | yes | passed | T001-T013 contain concrete implementation or validation evidence. | +| Task evidence complete | yes | passed | T001-T019 have implementation and validation evidence. | | Normal and dependency-owning test profiles pass | yes | passed | GitHub Actions run `29676747955` passed normal, MinIO, coverage quality-gate, and notification jobs. | | Stress implementation and disposition recorded | yes | passed | T004 implementation and repeat evidence are recorded in GitHub issue #68. | | Artifacts and six-combination clean installs validate | yes | passed | Run `29679083454` passed one build and all 12 artifact/OS/Python jobs. | | Release interface and rehearsal prove no publication side effect | yes | passed | T009-T010: reusable read-only validation, local rehearsal, three negative paths, and unchanged external state. | -| Durable documentation and communications promoted | yes | passed | T011-T012; Markdown/link check found zero issues in the five durable targets. | -| Final lifecycle checks and expert review pass | yes | passed | T013 lifecycle checks have zero blocking gaps; bounded expert review has no remaining actionable findings. | +| Durable documentation and communications promoted | yes | passed | Phase 4 targets and Phase 5 installation, recovery, and scheduling guidance are complete. | +| TimeLocker backup/list/restore machine round trip passes | yes | passed | Two snapshots list correctly; latest and exact-ID restores match the source digest. | +| Linux Mint tray path passes | yes | passed | Ayatana initialization/shutdown, legacy fallback tests, and headless CLI smoke passed. | +| Generated schedule parses and preserves migration boundaries | yes | passed | Disabled cron/systemd assets parse against the current CLI and contain references, not credential values. | +| Final lifecycle checks and expert review pass | yes | passed | T013 expert review passed; T019 task audit, closure readiness, and traceability have zero blockers. Optional canonical-context and historical evidence-quality advisories remain recorded. | ## Validation Commands And Methods @@ -43,6 +47,10 @@ separate explicit approval. | `actionlint`, nine focused release-contract tests, build/inspect, two clean-install smokes, and negative mismatch/missing/permission paths | Prove the reusable pre-tag interface and CP-004 rehearsal | passed | T009-T010; HEAD `1dcf910`, zero tags/releases, and 11 historical release runs were unchanged. | | `python scripts/extract_release_notes.py --version 0.9.1` | Derive the eventual GitHub body from the canonical changelog section | passed | T012 preview contains the complete evidence-backed section and limitations. | | Agent Workbench Markdown/link set check and `git diff --check` | Validate durable-doc hygiene | passed | Five durable documents had zero findings; whitespace check passed before final review. | +| isolated Linux Mint repository init, dry-run, backup, list, restore, and digest comparison | Prove TimeLocker-owned recoverability | passed | Environment-only init, file/directory dry-runs, actual backup, table/JSON listing, latest and exact-ID restore, and SHA-256 comparison passed. | +| Mint tray namespace and headless smoke | Prove optional tray compatibility | passed | System Python initialized Ayatana on Cinnamon/X11; fallback tests and the project-interpreter headless CLI smoke passed. | +| generated schedule argv parsed by current CLI | Prove schedule executability before asset installation | passed | Disabled cron/systemd assets use the current `backup create` contract, explicit config and environment references, and no credential values. | +| `python -m pytest -m "not performance and not stress and not minio"` | Revalidate the complete normal profile after Phase 5 | passed | 2,787 passed, one skipped, 57 deselected, 19 warnings, and 52.38% coverage in 801.81 seconds. | ## Requirement Coverage @@ -53,6 +61,10 @@ separate explicit approval. | Requirement 3 | AC1-AC5 | T006 and T008 passed; run `29679083454` | Preparation must continue to use both disabling flags. | | Requirement 4 | AC1-AC5 | T007-T008 passed; installation guide and release procedure updated by T011 | Future support changes require the same matrix. | | Requirement 5 | AC1-AC6 | T009-T013 passed | Human operator error at first actual tag remains explicitly owned. | +| Requirement 6 | AC1-AC4 | T015 focused tests and isolated Mint init/dry-run/backup passed | Operator credential and real-source selection remain deployment decisions. | +| Requirement 7 | AC1-AC4 | T016 focused tests plus TimeLocker-owned list/latest/exact/digest round trip passed | Filesystem metadata may vary across target filesystems. | +| Requirement 8 | AC1-AC3 | T017 namespace/fallback tests, headless CLI smoke, and Mint Ayatana initialization passed | Desktop packaging variance remains documented. | +| Requirement 9 | AC1-AC4 | T018 focused tests and disabled Mint cron/systemd staging passed | Privileged install, observed runs, and cutover remain operator gates. | ## Correctness Property Coverage @@ -61,19 +73,23 @@ separate explicit approval. | CP-001 | T001-T003, collection partition and workflow run `29676747955` | passed | Contract tests guard marker, selector, service, and artifact-transfer drift. | | CP-002 | T006 version guard and negative test | passed | Automated guard covers source and artifact identity. | | CP-003 | T007 six-combination artifact matrix | passed | Final shared-artifact run passed all 12 jobs. | -| CP-004 | T006, T008-T010, and T013 external-state comparisons | partial | Preparation and rehearsal passed; final comparison remains in T013. | -| CP-005 | T012-T013 changelog and derived release-body review | partial | Derivation passed; final expert review remains. | +| CP-004 | T006, T008-T010, and T013 external-state comparisons | passed | Preparation and rehearsal did not create a tag, release, or publication. | +| CP-005 | T012-T013 changelog and derived release-body review | passed | Canonical changelog derivation and expert review passed. | +| CP-006 | T014-T015 and T018-T019 | passed | Runtime credentials converge on the environment boundary; generated assets contain the protected file reference, not values. | +| CP-007 | T014, T016, and T019 | passed | Two TimeLocker-created snapshots listed; latest and exact restores matched the source digest. | +| CP-008 | T014 and T018-T019 | passed | Generated backup argv parsed current CLI with explicit repository, source, and config directory. | +| CP-009 | T014, T017, and T019 | passed | Ayatana, legacy fallback, missing-dependency, headless CLI, and Mint initialization paths passed. | ## Agent Readiness Evidence | Field | Evidence | Residual Risk | |-------|----------|---------------| -| Scope and out-of-scope files | Requirements goals, non-goals, change impact, and task file lists | Newly discovered release blockers require reconciliation. | +| Scope and out-of-scope files | Requirements goals, non-goals, change impact, and task file lists | Actual host migration remains outside implementation authority. | | Must-read and optional context | Full Spec 007 package, `CHARTER.md`, workflows, metadata, version helper/config, install and process docs, issue #68 | GitHub evidence can change. | | Permissions and approval points | Branch work approved; task commits require explicit commit instruction; tag, GitHub release, and PyPI publication require separate release approval | Do not infer publication authority. | | Validation commands and expected signals | Validation table plus task-specific commands | Hosted services and runners remain external. | -| Review needs | CI, packaging, security, operations, and documentation review at T013 | Human release decision remains. | -| Durable-doc or closure impact | Promotion table and `change-impact.md` | Package cannot close before promotion. | +| Review needs | Recovery, security, operations, documentation, and release review completed across T013 and T019 | Human release and cutover decisions remain. | +| Durable-doc or closure impact | Promotion table and `change-impact.md` | Promotion is complete; closure still requires the lifecycle decision. | | Optional repo-evidence provider caveats | Agent Workbench routing is advisory and has stale deleted-path candidates; direct repository and lifecycle evidence are authoritative | Recheck provider before relying on suggestions. | ## Task Evidence @@ -93,6 +109,12 @@ separate explicit approval. | T011 | passed | Existing process corrected and indexed; README and installation claims aligned; Markdown/link set clean | PyPI and 1.0 remain deferred. | | T012 | passed | Canonical changelog section and successful derived release-body preview | Four limitations are explicit. | | T013 | passed | Final normal profile, lifecycle/hygiene checks, external-state comparison, and bounded TimeLocker expert-panel review | Human release approval and lifecycle closure remain separate. | +| T014 | passed | Linux Mint pilot blockers reconciled into requirements, design, tasks, traceability, and verification; lifecycle lint and stage readiness have zero gaps | No external schedule state or secrets changed. | +| T015 | passed | 124 focused tests; Mint environment-only init; 1-file and 11-file dry-runs; actual snapshot `731d9784` with one file and 15,839 bytes | Pilot snapshot count changed from one to two only for the actual backup. | +| T016 | passed | 120 focused tests, 19 snapshot-manager tests, 14 orchestrator tests; Mint table/JSON listing; latest and exact restores; matching SHA-256 digests | Final combined checkpoint remains T019. | +| T017 | passed | Six focused tests; project-interpreter headless smoke; `tl version`; system-Python Ayatana initialization/shutdown | Desktop package availability remains operator-owned. | +| T018 | passed | 20 focused tests; disabled Mint schedule; cron shell parse; systemd/CLI parser review; redacted assets | Nothing installed or enabled; NPBackup unchanged. | +| T019 | passed | Machine round trip, Mint tray, staged schedules, promoted docs, full normal profile, and lifecycle/hygiene checks passed | No timer installed or enabled; NPBackup unchanged; operator migration gates remain. | ## Evidence Log @@ -133,6 +155,17 @@ separate explicit approval. | 2026-07-19 | Final T013 normal-profile run | passed | 2,774 passed, one skipped, 57 deselected, 19 warnings, and 52.14% coverage in 1,439.49 seconds. | | 2026-07-19 | T013 TimeLocker expert-panel review | passed | Bounded Phase 4 diff review applied stewardship, Python CLI, security, reliability, operations, and documentation/lifecycle lenses; Restic behavior was unchanged. No actionable findings remained after test isolation. | | 2026-07-19 | T013 lifecycle and hygiene checks | passed with advisory | Lifecycle lint had no errors and only the reviewed optional canonical-context advisory; traceability had zero acceptance gaps; `actionlint`, Markdown/link checks, workflow boundary validation, and `git diff --check` passed. | +| 2026-07-19 | Isolated Linux Mint/Cinnamon machine pilot | failed | Explicit-password init and a directory backup created snapshot `876b20bc7916`; environment-only init, dry-run, truthful result reporting, TimeLocker listing, TimeLocker restore, Ayatana tray discovery, and generated schedule parsing failed. | +| 2026-07-19 | Raw Restic diagnostic control | passed | Restic listed one snapshot with 11 files and restored the reference file with a matching digest; this does not satisfy TimeLocker-owned recovery acceptance. | +| 2026-07-19 | NPBackup migration boundary review | unchanged | Existing protected configuration was inspected only through its masked interface; no credential was extracted, scheduler changed, timer installed, or job disabled. | +| 2026-07-19 | T015 focused validation | passed | 124 repository, CLI, resolver, orchestrator, backup, and regression tests passed without coverage instrumentation. | +| 2026-07-19 | T015 isolated Mint pilot | passed | Environment-only init recognized the repository; file and directory dry-runs reported 1 and 11 files; actual file backup created snapshot `731d9784` with one file and 15,839 bytes. Exactly two snapshots exist after the one real T015 backup. | +| 2026-07-19 | T016 focused recovery validation | passed | 120 snapshot, recovery, restore CLI, and progress tests passed; the latest-alias and initialized-progress regressions then passed 19 and 14 focused tests. | +| 2026-07-19 | T016 isolated Mint recovery pilot | passed | Table and JSON listed two snapshots with canonical metadata. TimeLocker restored `latest` and exact full ID `731d9784...`; both restored `README.md` files matched the source SHA-256 digest. | +| 2026-07-19 | T017 Mint tray validation | passed | Six namespace/fallback tests passed; the pyenv CLI remained functional without `gi`; system Python initialized and shut down `AyatanaAppIndicator3` on Cinnamon/X11. | +| 2026-07-19 | T018 schedule validation | passed | Twenty focused tests passed. A disabled system-level pilot schedule generated redacted cron/systemd assets whose command parsed the current CLI with explicit repository, `/etc` source, environment-file reference, and config directory. No scheduler state changed. | +| 2026-07-19 | T019 final normal profile | passed | 2,787 passed, one skipped, 57 deselected, 19 warnings, and 52.38% coverage in 801.81 seconds. | +| 2026-07-19 | T019 machine and handoff checkpoint | passed with advisories | TimeLocker-owned backup/list/latest/exact/digest, Mint Ayatana/headless, staged-schedule, promoted-guide, task-audit, closure-readiness, traceability, link, compile, and whitespace gates passed. Lifecycle lint retained one optional canonical-context advisory; historical evidence-quality and spec-table-readability advisories remain non-blocking. No privileged schedule or NPBackup state changed. | ## Manual Or External Verification @@ -155,6 +188,14 @@ release artifacts must be linked here before release readiness can be approved. combinations block readiness until rerun or the support claim is reviewed. - The first actual tag exercises external publication behavior that rehearsal cannot reproduce fully; it remains a human-controlled release risk. +- Tray availability still depends on the desktop toolkit being installed for + the interpreter that runs the tray integration; core CLI behavior is + deliberately independent. +- Repository credentials, actual NPBackup source/scheduler discovery, + privileged schedule installation, observed scheduled TimeLocker runs, and + final NPBackup cutover remain unapproved operator actions. +- The staged `/etc` source proves the protected-source boundary but is not a + claim about the sources configured in the existing NPBackup job. ## Durable Promotion And Cleanup @@ -167,25 +208,28 @@ release artifacts must be linked here before release readiness can be approved. | Front-door support and version claims | `README.md` | complete | T011 aligned version and Python support. | | PyPI and `1.0.0` deferral | GitHub issue #22, milestone description, version process | complete | External and durable boundaries agree. | | Follow-up work | GitHub issues outside milestone or an approved successor spec | complete | Existing issue #68 retains stress history; no new Phase 4 finding requires routing. | +| Backup/recovery runtime contract | `docs/guides/user/recovery-operations-guide.md` | complete | T015-T016 machine acceptance and T019 review passed. | +| Linux tray prerequisites | `docs/guides/user/installation.md` | complete | T017 Mint and headless validation passed. | +| Schedule and staged NPBackup cutover boundary | `docs/guides/developer/scheduling-guide.md` | complete | T018 staging and T019 handoff review passed. | ### Spec Cleanup Decision - **Cleanup action:** keep active -- **Reason:** All implementation tasks are complete; the package remains active - only for the separately authorized lifecycle closure and its final commits. +- **Reason:** Phase 5 is complete, but release approval, operator migration, and + lifecycle closure are separately human-controlled decisions. - **Final spec commit:** pending - **Closure log path:** `docs/history/spec-closure-log.md` - **Closure log entry updated:** no - **Closure cleanup commit:** pending - **Active indexes updated:** yes for package creation - **Durable docs linked back to evidence where useful:** yes -- **Residual spec-only content:** task-level implementation and validation evidence only +- **Residual spec-only content:** release decision and machine-pilot evidence ## Ship Or Closure Risk -- **Risk level:** high +- **Risk level:** medium - **Breaking change:** no -- **Blast radius checked:** complete for the Phase 4 workflow, scripts, tests, and docs diff +- **Blast radius checked:** complete for the approved Phase 5 implementation boundary - **Rollback path:** corrected and validated in `docs/processes/version-management.md` - **Requires human review:** yes - **Release notes needed:** yes, in `CHANGELOG.md` @@ -194,15 +238,16 @@ release artifacts must be linked here before release readiness can be approved. ### Risk Rationale Normal, provisioned MinIO, extended, artifact, cross-platform install, rehearsal, -documentation, and expert-review gates pass. The tag-triggered release workflow -still has no successful repository release history, so the first tag remains a -high, human-controlled publication risk rather than an implementation blocker. +documentation, expert-review, and Linux Mint machine-acceptance gates pass. +Remaining risk is operational: the actual NPBackup job has not been reconciled, +the privileged TimeLocker schedule has not been installed or observed, and no +publication or cutover authority has been granted. ## Readiness Decision -- **Ready for promotion:** yes -- **Ready for release:** yes, for a separate release-maintainer decision -- **Ready for closure:** yes, through the separate lifecycle closure workflow +- **Ready for promotion:** no, Phase 5 durable guidance is pending +- **Ready for release:** no +- **Ready for closure:** no ## Related Artifacts diff --git a/src/TimeLocker/backup_manager.py b/src/TimeLocker/backup_manager.py index c046eee..12168dd 100644 --- a/src/TimeLocker/backup_manager.py +++ b/src/TimeLocker/backup_manager.py @@ -197,13 +197,14 @@ def execute_backup_with_retry(self, metadata={"max_retries": max_retries, "targets_count": len(targets)}) try: + # Invalid target configuration is deterministic and must not enter + # the transient retry loop. + for target in targets: + target.validate() + # Use the centralized retry mechanism @with_retry(max_retries=max_retries, delay=retry_delay, backoff_multiplier=backoff_multiplier) def _execute_single_backup(): - # Validate targets before attempting backup - for target in targets: - target.validate() - # Execute backup result = repository.backup_target(targets, tags) @@ -221,6 +222,8 @@ def _execute_single_backup(): # Execute with retries; if all attempts fail, the last exception is re-raised return _execute_single_backup() + except ValueError as e: + raise BackupManagerError(f"Invalid backup target: {e}") from e except Exception as e: # Wrap in domain-specific error with attempts count, preserve context raise BackupManagerError(f"Backup failed after {max_retries + 1} attempts: {e}") from e diff --git a/src/TimeLocker/backup_snapshot.py b/src/TimeLocker/backup_snapshot.py index 4831aad..342ff15 100644 --- a/src/TimeLocker/backup_snapshot.py +++ b/src/TimeLocker/backup_snapshot.py @@ -42,6 +42,13 @@ def __init__(self, repo: 'BackupRepository', snapshot_id: str, timestamp: dateti self.paths = paths self.tags = [] self.size = 0 + self.hostname = "" + self.username = "" + + @property + def time(self) -> datetime: + """Backward-compatible alias for the canonical snapshot timestamp.""" + return self.timestamp def restore( self, @@ -89,10 +96,19 @@ def delete(self, prune: bool = False) -> bool: @classmethod def from_dict(cls, repository: 'BackupRepository', data: Mapping[str, object]) -> Self: """Create a snapshot instance from dictionary data""" - raw_path = Path(str(data['path'])) + if 'paths' not in data and 'path' not in data: + raise KeyError('path') + raw_paths = data.get('paths', data.get('path')) + if isinstance(raw_paths, (str, Path)): + paths = [Path(str(raw_paths))] + else: + paths = [Path(str(path)) for path in raw_paths] + timestamp_value = data.get('timestamp', data.get('time')) + if timestamp_value is None: + raise ValueError("Snapshot timestamp is required") return cls( repo=repository, snapshot_id=str(data['id']), - timestamp=datetime.fromisoformat(str(data['timestamp'])), - paths=raw_path + timestamp=datetime.fromisoformat(str(timestamp_value).replace('Z', '+00:00')), + paths=paths ) diff --git a/src/TimeLocker/cli_modules/commands/backup.py b/src/TimeLocker/cli_modules/commands/backup.py index a7a6611..1ee1d6d 100644 --- a/src/TimeLocker/cli_modules/commands/backup.py +++ b/src/TimeLocker/cli_modules/commands/backup.py @@ -281,7 +281,7 @@ def backup_create( if result.status.value in ['completed', 'success']: details = { "Snapshot ID": result.snapshot_id or "Unknown", - "Files processed": f"{result.files_processed:,}" if result.files_processed else "Unknown", + "Files processed": f"{result.files_processed:,}", "Data processed": f"{result.bytes_transferred:,} bytes" if result.bytes_transferred else "Unknown", "Duration": f"{result.duration.total_seconds():.1f}s" if result.duration else "Unknown" } @@ -323,6 +323,15 @@ def backup_create( console.print("💡 Either provide source paths or use --selection to specify a data selection template") raise typer.Exit(1) + invalid_sources = [str(source) for source in sources if not source.exists()] + if invalid_sources: + show_error_panel( + "Invalid Sources", + "The following backup source paths do not exist:", + invalid_sources, + ) + raise typer.Exit(1) + repository_uri = repository actual_repository_name = repository resolved_password = password or "" diff --git a/src/TimeLocker/cli_modules/commands/repositories.py b/src/TimeLocker/cli_modules/commands/repositories.py index e9611dd..1c5bf24 100644 --- a/src/TimeLocker/cli_modules/commands/repositories.py +++ b/src/TimeLocker/cli_modules/commands/repositories.py @@ -33,6 +33,7 @@ _call_service_method, _get_service_manager_for_command, _create_config_service, + _create_repository_resolver, VerboseOption, JsonOption, YesOption, @@ -1699,16 +1700,19 @@ def repos_init( ) raise typer.Exit(1) - # Prompt for password if not provided and in interactive mode - if not password and interactive: - from rich.prompt import Prompt - password = Prompt.ask("Enter password for repository", password=True) - password_confirm = Prompt.ask("Confirm password", password=True) - if password != password_confirm: - show_error_panel("Password Mismatch", "Passwords do not match.") - raise typer.Exit(1) - elif not password and not interactive: - show_error_panel("Password Required", "Password must be provided with --password in non-interactive mode.") + resolver = _create_repository_resolver(config_dir) + password = resolver.resolve_credentials( + repository_name=name, + explicit_password=password, + allow_prompt=interactive, + repository_uri=repo_uri, + ) + if not password: + show_error_panel( + "Password Required", + "Repository password is required; provide --password, configure " + "stored credentials, or set RESTIC_PASSWORD/TIMELOCKER_PASSWORD." + ) raise typer.Exit(1) init_method = _get_service_method(manager, "initialize_repository") diff --git a/src/TimeLocker/cli_modules/commands/restore.py b/src/TimeLocker/cli_modules/commands/restore.py index a641eed..c66cac3 100644 --- a/src/TimeLocker/cli_modules/commands/restore.py +++ b/src/TimeLocker/cli_modules/commands/restore.py @@ -126,11 +126,11 @@ def restore_list( import json console.print_json(data=[{ 'id': s.id, - 'time': s.time.isoformat() if s.time else None, - 'hostname': s.hostname, - 'username': s.username, + 'time': s.timestamp.isoformat() if s.timestamp else None, + 'hostname': getattr(s, 'hostname', ''), + 'username': getattr(s, 'username', ''), 'tags': s.tags, - 'paths': s.paths + 'paths': [str(path) for path in s.paths] } for s in snapshots]) else: table = Table(title=f"Snapshots in {repository}") @@ -142,8 +142,8 @@ def restore_list( for snapshot in snapshots: table.add_row( snapshot.id[:12], - snapshot.time.strftime("%Y-%m-%d %H:%M:%S") if snapshot.time else "N/A", - snapshot.hostname or "N/A", + snapshot.timestamp.strftime("%Y-%m-%d %H:%M:%S") if snapshot.timestamp else "N/A", + getattr(snapshot, 'hostname', '') or "N/A", ", ".join(snapshot.tags) if snapshot.tags else "" ) diff --git a/src/TimeLocker/cli_modules/commands/schedule.py b/src/TimeLocker/cli_modules/commands/schedule.py index baf87ce..79eab81 100644 --- a/src/TimeLocker/cli_modules/commands/schedule.py +++ b/src/TimeLocker/cli_modules/commands/schedule.py @@ -9,6 +9,7 @@ import logging import json import platform +import shlex from typing import Optional, List, Annotated, Dict, Any from pathlib import Path from datetime import datetime, time @@ -82,11 +83,46 @@ def _save_schedules(schedules: Dict[str, Dict[str, Any]], config_dir: Optional[P json.dump(schedules, f, indent=2) +def _build_backup_command(schedule: Dict[str, Any], config_dir: Optional[Path] = None) -> str: + """Build an executable backup command without embedding credentials.""" + repository = schedule.get('repository') + selection = schedule.get('selection') + sources = schedule.get('sources') or [] + if not repository: + raise ValueError("Schedule is missing a repository") + if bool(selection) == bool(sources): + raise ValueError("Schedule must define exactly one selection or one or more sources") + + import shutil + executable = shutil.which('tl') or shutil.which('timelocker') or 'tl' + argv = [executable, 'backup', 'create'] + if selection: + argv.extend(['--selection', str(selection)]) + else: + argv.extend(str(source) for source in sources) + argv.extend(['--repository', str(repository)]) + + effective_config_dir = schedule.get('config_dir') or config_dir + if effective_config_dir: + argv.extend(['--config-dir', str(Path(effective_config_dir).resolve())]) + return shlex.join(argv) + + +def _environment_file(schedule: Dict[str, Any]) -> Optional[Path]: + """Return a validated environment-file reference, never its contents.""" + value = schedule.get('environment_file') + if not value: + return None + if '\n' in str(value) or '\r' in str(value): + raise ValueError("Environment file path contains a newline") + return Path(value).expanduser().resolve() + + def _format_schedule_table(schedules: Dict[str, Dict[str, Any]]) -> Table: """Format schedules as a Rich table.""" table = Table(title="Backup Schedules") table.add_column("Name", style="cyan") - table.add_column("Policy", style="green") + table.add_column("Repository", style="green") table.add_column("Frequency", style="yellow") table.add_column("Next Run", style="white") table.add_column("Enabled", style="magenta") @@ -95,9 +131,9 @@ def _format_schedule_table(schedules: Dict[str, Dict[str, Any]]) -> Table: enabled = "✓" if schedule.get('enabled', False) else "✗" next_run = schedule.get('next_run', 'N/A') frequency = schedule.get('frequency', 'N/A') - policy = schedule.get('policy', 'N/A') + repository = schedule.get('repository', 'N/A') - table.add_row(name, policy, frequency, next_run, enabled) + table.add_row(name, repository, frequency, next_run, enabled) return table @@ -106,38 +142,15 @@ def _interactive_schedule_configuration(config_dir: Optional[Path] = None) -> Di """Interactively configure a schedule.""" console.print("\n[bold]Schedule Configuration[/bold]\n") - # Select or create policy - console.print("[bold]1. Select Backup Policy[/bold]") - - # Try to list existing policies - try: - from TimeLocker.cli_modules.commands.policy import _get_policy_manager - policy_manager = _get_policy_manager(config_dir) - policies = policy_manager.list_backup_policies() - - if policies: - console.print("\nExisting policies:") - for i, policy in enumerate(policies, 1): - console.print(f" {i}. {policy.name} (ID: {policy.id[:8]})") - - choice = Prompt.ask( - "\nSelect policy number or enter 'new' to create one", - default="1" - ) - - if choice.lower() == 'new': - policy_name = Prompt.ask("New policy name") - console.print(f"[yellow]Note: Create policy '{policy_name}' using 'timelocker policy backup create' first[/yellow]") - else: - try: - idx = int(choice) - 1 - policy_name = policies[idx].name - except (ValueError, IndexError): - policy_name = choice - else: - policy_name = Prompt.ask("Policy name") - except Exception: - policy_name = Prompt.ask("Policy name") + console.print("[bold]1. Select Backup Inputs[/bold]") + repository = Prompt.ask("Repository name or URI") + source_mode = Prompt.ask("Use a selection or direct source?", choices=["selection", "source"]) + selection = None + sources = [] + if source_mode == "selection": + selection = Prompt.ask("Selection template") + else: + sources = [str(Path(Prompt.ask("Source path")).expanduser().resolve())] # Configure frequency console.print("\n[bold]2. Configure Frequency[/bold]") @@ -185,7 +198,13 @@ def _interactive_schedule_configuration(config_dir: Optional[Path] = None) -> Di enabled = Confirm.ask("Enable schedule immediately?", default=True) return { - "policy": policy_name, + "policy": None, + "repository": repository, + "selection": selection, + "sources": sources, + "environment_file": None, + "system": False, + "config_dir": str(config_dir.expanduser().resolve()) if config_dir else None, "frequency": frequency, "cron_expression": cron_expression, "enabled": enabled, @@ -196,33 +215,40 @@ def _interactive_schedule_configuration(config_dir: Optional[Path] = None) -> Di def _generate_cron_script(schedule_name: str, schedule: Dict[str, Any], config_dir: Optional[Path] = None) -> str: """Generate cron script for Linux/macOS.""" - policy = schedule.get('policy', '') cron_expr = schedule.get('cron_expression', '0 2 * * *') - - # Get timelocker executable path - import shutil - timelocker_path = shutil.which('timelocker') or 'timelocker' + command = _build_backup_command(schedule, config_dir) + environment_file = _environment_file(schedule) + environment_setup = "" + if environment_file: + environment_setup = ( + "set -a\n" + f". {shlex.quote(str(environment_file))}\n" + "set +a\n" + ) + cron_owner = "root" if schedule.get('system') else "the current user" script = f"""#!/bin/bash +set -euo pipefail # TimeLocker Backup Schedule: {schedule_name} # Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} -# Policy: {policy} +# Repository: {schedule.get('repository')} # Frequency: {schedule.get('frequency', 'custom')} +# Install for: {cron_owner} # Cron expression: {cron_expr} -# Add this line to your crontab (crontab -e): -# {cron_expr} {timelocker_path} backup create --policy {policy} --non-interactive >> /var/log/timelocker/{schedule_name}.log 2>&1 +# Schedule this generated wrapper; it contains no credential values. # Or run this script directly: -{timelocker_path} backup create --policy {policy} --non-interactive +{environment_setup}{command} """ return script def _generate_systemd_script(schedule_name: str, schedule: Dict[str, Any], config_dir: Optional[Path] = None) -> tuple[str, str]: """Generate systemd service and timer files for Linux.""" - policy = schedule.get('policy', '') cron_expr = schedule.get('cron_expression', '0 2 * * *') + command = _build_backup_command(schedule, config_dir) + environment_file = _environment_file(schedule) # Convert cron to systemd OnCalendar # This is a simplified conversion @@ -243,9 +269,8 @@ def _generate_systemd_script(schedule_name: str, schedule: Dict[str, Any], confi else: oncalendar = "daily" - # Get timelocker executable path - import shutil - timelocker_path = shutil.which('timelocker') or '/usr/local/bin/timelocker' + environment_line = f"EnvironmentFile={environment_file}\n" if environment_file else "" + user_line = "User=root\n" if schedule.get('system') else "" service = f"""[Unit] Description=TimeLocker Backup - {schedule_name} @@ -254,13 +279,10 @@ def _generate_systemd_script(schedule_name: str, schedule: Dict[str, Any], confi [Service] Type=oneshot -ExecStart={timelocker_path} backup create --policy {policy} --non-interactive +{user_line}{environment_line}ExecStart={command} StandardOutput=journal StandardError=journal SyslogIdentifier=timelocker-{schedule_name} - -[Install] -WantedBy=multi-user.target """ timer = f"""[Unit] @@ -280,8 +302,10 @@ def _generate_systemd_script(schedule_name: str, schedule: Dict[str, Any], confi def _generate_windows_script(schedule_name: str, schedule: Dict[str, Any], config_dir: Optional[Path] = None) -> str: """Generate Windows Task Scheduler script.""" - policy = schedule.get('policy', '') cron_expr = schedule.get('cron_expression', '0 2 * * *') + command = _build_backup_command(schedule, config_dir) + if _environment_file(schedule): + raise ValueError("Environment-file schedules are not supported by the Windows renderer") # Parse cron for Windows schedule parts = cron_expr.split() @@ -302,10 +326,10 @@ def _generate_windows_script(schedule_name: str, schedule: Dict[str, Any], confi script = f"""@echo off REM TimeLocker Backup Schedule: {schedule_name} REM Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} -REM Policy: {policy} +REM Repository: {schedule.get('repository')} REM Create scheduled task -schtasks /CREATE /TN "TimeLocker\\{schedule_name}" {trigger} /TR "timelocker backup create --policy {policy} --non-interactive" /F +schtasks /CREATE /TN "TimeLocker\\{schedule_name}" {trigger} /TR "{command}" /F echo Scheduled task created: TimeLocker\\{schedule_name} echo Run 'schtasks /Query /TN "TimeLocker\\{schedule_name}"' to verify @@ -320,7 +344,12 @@ def _generate_windows_script(schedule_name: str, schedule: Dict[str, Any], confi @with_logging def schedule_create( name: Annotated[str, typer.Argument(help="Schedule name")], - policy: Annotated[Optional[str], typer.Argument(help="Policy name", autocompletion=policy_name_completer)] = None, + policy: Annotated[Optional[str], typer.Argument(help="Legacy policy label", autocompletion=policy_name_completer)] = None, + repository: Annotated[Optional[str], typer.Option("--repository", "-r", help="Repository name or URI")] = None, + selection: Annotated[Optional[str], typer.Option("--selection", "-s", help="Configured selection template")] = None, + sources: Annotated[Optional[List[Path]], typer.Option("--source", help="Source path (repeatable)")] = None, + environment_file: Annotated[Optional[Path], typer.Option("--environment-file", help="Protected environment file to reference, not copy")] = None, + system: Annotated[bool, typer.Option("--system/--user", help="Generate a system-level or user-level schedule")] = False, frequency: Annotated[Optional[str], typer.Option("--frequency", "-f", help="Frequency (hourly, daily, weekly, monthly)")] = None, cron: Annotated[Optional[str], typer.Option("--cron", help="Custom cron expression")] = None, enabled: Annotated[bool, typer.Option("--enabled/--disabled", help="Enable schedule immediately")] = True, @@ -342,8 +371,15 @@ def schedule_create( schedule_config = _interactive_schedule_configuration(config_dir) else: # Command-line configuration - if not policy: - show_error_panel("Missing Policy", "Policy name is required. Use --interactive or provide policy name.") + if not repository: + show_error_panel("Missing Repository", "--repository is required for an executable schedule.") + raise typer.Exit(1) + + if bool(selection) == bool(sources): + show_error_panel( + "Missing or Ambiguous Sources", + "Provide exactly one --selection or one or more --source options." + ) raise typer.Exit(1) if not frequency and not cron: @@ -367,6 +403,12 @@ def schedule_create( schedule_config = { "policy": policy, + "repository": repository, + "selection": selection, + "sources": [str(source.expanduser().resolve()) for source in (sources or [])], + "environment_file": str(environment_file.expanduser().resolve()) if environment_file else None, + "system": system, + "config_dir": str(config_dir.expanduser().resolve()) if config_dir else None, "frequency": frequency or "custom", "cron_expression": cron_expression, "enabled": enabled, @@ -383,7 +425,8 @@ def schedule_create( f"Created backup schedule '{name}'", details={ "Name": name, - "Policy": schedule_config['policy'], + "Repository": schedule_config.get('repository', 'N/A'), + "Selection/Sources": schedule_config.get('selection') or ", ".join(schedule_config.get('sources', [])), "Frequency": schedule_config['frequency'], "Cron": schedule_config['cron_expression'], "Enabled": "Yes" if schedule_config['enabled'] else "No", @@ -444,7 +487,9 @@ def schedule_show( console.print(Panel( f"[bold]Name:[/bold] {name}\n" - f"[bold]Policy:[/bold] {schedule.get('policy', 'N/A')}\n" + f"[bold]Repository:[/bold] {schedule.get('repository', 'N/A')}\n" + f"[bold]Selection:[/bold] {schedule.get('selection', 'N/A')}\n" + f"[bold]Sources:[/bold] {', '.join(schedule.get('sources', [])) or 'N/A'}\n" f"[bold]Frequency:[/bold] {schedule.get('frequency', 'N/A')}\n" f"[bold]Cron Expression:[/bold] {schedule.get('cron_expression', 'N/A')}\n" f"[bold]Status:[/bold] {enabled_status}\n" @@ -463,6 +508,11 @@ def schedule_show( def schedule_edit( name: Annotated[str, typer.Argument(help="Schedule name", autocompletion=schedule_name_completer)], policy: Annotated[Optional[str], typer.Option("--policy", "-p", help="New policy name", autocompletion=policy_name_completer)] = None, + repository: Annotated[Optional[str], typer.Option("--repository", "-r", help="New repository name or URI")] = None, + selection: Annotated[Optional[str], typer.Option("--selection", "-s", help="Replace sources with a selection template")] = None, + sources: Annotated[Optional[List[Path]], typer.Option("--source", help="Replace selection with source path(s)")] = None, + environment_file: Annotated[Optional[Path], typer.Option("--environment-file", help="New protected environment-file reference")] = None, + system: Annotated[Optional[bool], typer.Option("--system/--user", help="Generate a system-level or user-level schedule")] = None, frequency: Annotated[Optional[str], typer.Option("--frequency", "-f", help="New frequency")] = None, cron: Annotated[Optional[str], typer.Option("--cron", help="New cron expression")] = None, enabled: Annotated[Optional[bool], typer.Option("--enabled/--disabled", help="Enable/disable schedule")] = None, @@ -482,6 +532,18 @@ def schedule_edit( # Update fields if policy is not None: schedule['policy'] = policy + if repository is not None: + schedule['repository'] = repository + if selection is not None: + schedule['selection'] = selection + schedule['sources'] = [] + if sources: + schedule['sources'] = [str(source.expanduser().resolve()) for source in sources] + schedule['selection'] = None + if environment_file is not None: + schedule['environment_file'] = str(environment_file.expanduser().resolve()) + if system is not None: + schedule['system'] = system if frequency is not None: schedule['frequency'] = frequency if cron is not None: @@ -634,7 +696,12 @@ def schedule_generate_scripts( "Platform": "Linux/macOS (cron)", } ) - console.print(f"\n[cyan]To install:[/cyan] Add the cron line from {script_file} to your crontab") + cron_command = f"{schedule.get('cron_expression', '0 2 * * *')} {script_file}" + if schedule.get('system'): + console.print(f"\n[cyan]Privileged install gate:[/cyan] Review, then add to root's crontab with 'sudo crontab -e':") + else: + console.print(f"\n[cyan]To install after review:[/cyan] Add to your crontab with 'crontab -e':") + console.print(f" {cron_command}") elif platform_type == "systemd": service, timer = _generate_systemd_script(name, schedule, config_dir) @@ -655,10 +722,17 @@ def schedule_generate_scripts( "Platform": "Linux (systemd)", } ) - console.print(f"\n[cyan]To install:[/cyan]") - console.print(f" sudo cp {service_file} {timer_file} /etc/systemd/system/") - console.print(f" sudo systemctl daemon-reload") - console.print(f" sudo systemctl enable --now timelocker-{name}.timer") + if schedule.get('system'): + console.print(f"\n[cyan]Privileged install gate (not performed):[/cyan]") + console.print(f" sudo cp {service_file} {timer_file} /etc/systemd/system/") + console.print(" sudo systemctl daemon-reload") + console.print(f" sudo systemctl enable --now timelocker-{name}.timer") + else: + console.print(f"\n[cyan]User install gate (not performed):[/cyan]") + console.print(" mkdir -p ~/.config/systemd/user") + console.print(f" cp {service_file} {timer_file} ~/.config/systemd/user/") + console.print(" systemctl --user daemon-reload") + console.print(f" systemctl --user enable --now timelocker-{name}.timer") elif platform_type == "windows": script = _generate_windows_script(name, schedule, config_dir) @@ -702,46 +776,61 @@ def schedule_test( raise typer.Exit(1) schedule = schedules[name] - policy_name = schedule.get('policy') - console.print(f"\n[bold]Testing Schedule: {name}[/bold]\n") - - # Test 1: Check policy exists - console.print("[cyan]1. Checking policy...[/cyan]") + errors = [] + + # Test 1: Validate the executable command contract + console.print("[cyan]1. Validating backup command...[/cyan]") try: - from TimeLocker.cli_modules.commands.policy import _get_policy_manager - policy_manager = _get_policy_manager(config_dir) - policies = policy_manager.list_backup_policies() - policy_exists = any(p.name == policy_name for p in policies) - - if policy_exists: - console.print(f" [green]✓[/green] Policy '{policy_name}' exists") - else: - console.print(f" [red]✗[/red] Policy '{policy_name}' not found") + command = _build_backup_command(schedule, config_dir) + console.print(f" [green]✓[/green] Executable command: {command}") except Exception as e: - console.print(f" [yellow]⚠[/yellow] Could not verify policy: {e}") + errors.append(str(e)) + console.print(f" [red]✗[/red] Invalid backup command: {e}") + + # Test 2: Validate referenced paths without reading credential contents + console.print("\n[cyan]2. Validating referenced paths...[/cyan]") + for source in schedule.get('sources') or []: + if Path(source).exists(): + console.print(f" [green]✓[/green] Source exists: {source}") + else: + errors.append(f"Source does not exist: {source}") + console.print(f" [red]✗[/red] Source does not exist: {source}") + environment_file = _environment_file(schedule) + if environment_file: + if environment_file.is_file(): + console.print(f" [green]✓[/green] Environment file exists: {environment_file}") + else: + errors.append(f"Environment file does not exist: {environment_file}") + console.print(f" [red]✗[/red] Environment file does not exist: {environment_file}") - # Test 2: Validate cron expression - console.print("\n[cyan]2. Validating cron expression...[/cyan]") + # Test 3: Validate cron expression + console.print("\n[cyan]3. Validating cron expression...[/cyan]") cron_expr = schedule.get('cron_expression') if cron_expr: parts = cron_expr.split() if len(parts) == 5: console.print(f" [green]✓[/green] Valid cron expression: {cron_expr}") else: + errors.append(f"Invalid cron expression: {cron_expr}") console.print(f" [red]✗[/red] Invalid cron expression: {cron_expr}") else: + errors.append("No cron expression defined") console.print(f" [red]✗[/red] No cron expression defined") - # Test 3: Check schedule status - console.print("\n[cyan]3. Checking schedule status...[/cyan]") + # Test 4: Check schedule status + console.print("\n[cyan]4. Checking schedule status...[/cyan]") enabled = schedule.get('enabled', False) if enabled: console.print(f" [green]✓[/green] Schedule is enabled") else: console.print(f" [yellow]⚠[/yellow] Schedule is disabled") - console.print(f"\n[bold green]Schedule test complete[/bold green]") + if errors: + show_error_panel("Schedule Test Failed", "; ".join(errors)) + raise typer.Exit(1) + + console.print("\n[bold green]Schedule test complete[/bold green]") except Exception as e: CommandBase.handle_error(e, verbose, "Schedule Test Error") diff --git a/src/TimeLocker/cli_modules/services/repository_resolver.py b/src/TimeLocker/cli_modules/services/repository_resolver.py index 367fb87..82e35ea 100644 --- a/src/TimeLocker/cli_modules/services/repository_resolver.py +++ b/src/TimeLocker/cli_modules/services/repository_resolver.py @@ -288,7 +288,8 @@ def resolve_credentials( self, repository_name: str, explicit_password: Optional[str] = None, - allow_prompt: bool = False + allow_prompt: bool = False, + repository_uri: Optional[str] = None, ) -> Optional[str]: """ Public method to resolve credentials for a repository. @@ -297,15 +298,16 @@ def resolve_credentials( repository_name: Repository name explicit_password: Optional explicit password allow_prompt: Whether to prompt for password if not found + repository_uri: Already-resolved repository URI, when available Returns: Optional[str]: Resolved password or None """ try: - repository_uri = self.resolve_repository_uri(repository_name) + resolved_uri = repository_uri or self.resolve_repository_uri(repository_name) return self._resolve_credentials( repository_name=repository_name, - repository_uri=repository_uri, + repository_uri=resolved_uri, explicit_password=explicit_password, allow_prompt=allow_prompt ) diff --git a/src/TimeLocker/file_selections.py b/src/TimeLocker/file_selections.py index 7580152..b40d817 100644 --- a/src/TimeLocker/file_selections.py +++ b/src/TimeLocker/file_selections.py @@ -266,20 +266,11 @@ def validate(self) -> bool: bool: True if valid, False otherwise Raises: - ValueError: If no folders are included in the backup selection + ValueError: If no paths are included in the backup selection """ - # Check if path exists and is a directory, or if it looks like a directory path - def is_directory_path(path: Path) -> bool: - # If path exists, check if it's a directory - if path.exists(): - return path.is_dir() - # Otherwise check if it looks like a directory path (no file extension) - return path.suffix == '' or path.name.endswith('/') - - has_folder = any(is_directory_path(path) for path in self._includes) - if not has_folder: - raise ValueError("At least one folder must be included in the backup selection") + if not self._includes: + raise ValueError("At least one path must be included in the backup selection") return True @property diff --git a/src/TimeLocker/interfaces/recovery_models.py b/src/TimeLocker/interfaces/recovery_models.py index a9abb00..8699326 100644 --- a/src/TimeLocker/interfaces/recovery_models.py +++ b/src/TimeLocker/interfaces/recovery_models.py @@ -38,6 +38,7 @@ class RecoveryType(Enum): class OperationStatus(Enum): """Status of recovery operation""" PENDING = "pending" + VALIDATING = "validating" RUNNING = "running" COMPLETED = "completed" FAILED = "failed" @@ -486,6 +487,7 @@ def is_active(self) -> bool: """Check if operation is currently active""" return self.status in ( OperationStatus.PENDING, + OperationStatus.VALIDATING, OperationStatus.RUNNING ) diff --git a/src/TimeLocker/monitoring/system_tray_integration.py b/src/TimeLocker/monitoring/system_tray_integration.py index 8cdbdd0..cf7795a 100644 --- a/src/TimeLocker/monitoring/system_tray_integration.py +++ b/src/TimeLocker/monitoring/system_tray_integration.py @@ -16,6 +16,7 @@ """ import logging +import importlib import sys import threading from datetime import datetime @@ -27,6 +28,35 @@ logger = logging.getLogger(__name__) +def _load_linux_tray_modules(): + """Load GTK and the first supported AppIndicator namespace.""" + try: + import gi + except ImportError as exc: + raise SystemTrayError("PyGObject is not installed") from exc + + try: + gi.require_version('Gtk', '3.0') + gtk = importlib.import_module('gi.repository.Gtk') + except (ImportError, ValueError) as exc: + raise SystemTrayError("GTK 3 is not available") from exc + + errors = [] + for namespace in ('AyatanaAppIndicator3', 'AppIndicator3'): + try: + gi.require_version(namespace, '0.1') + indicator = importlib.import_module(f'gi.repository.{namespace}') + return gtk, indicator, namespace + except (ImportError, ValueError) as exc: + errors.append(f"{namespace}: {exc}") + + raise SystemTrayError( + "Neither AyatanaAppIndicator3 nor AppIndicator3 is available (" + + "; ".join(errors) + + ")" + ) + + class SystemTrayError(Exception): """Base exception for system tray errors""" pass @@ -255,25 +285,22 @@ def __init__(self, app_name: str): def _initialize_tray(self): """Initialize tray with available toolkit""" - # Try GTK first try: - import gi - gi.require_version('Gtk', '3.0') - gi.require_version('AppIndicator3', '0.1') - from gi.repository import Gtk, AppIndicator3 - + self._gtk, self._indicator_module, self._indicator_namespace = ( + _load_linux_tray_modules() + ) self._use_gtk = True - self._indicator = AppIndicator3.Indicator.new( + self._indicator = self._indicator_module.Indicator.new( self.app_name, "dialog-information", - AppIndicator3.IndicatorCategory.APPLICATION_STATUS + self._indicator_module.IndicatorCategory.APPLICATION_STATUS ) - self._indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) + self._indicator.set_status(self._indicator_module.IndicatorStatus.ACTIVE) self._create_gtk_menu() - logger.info("Using GTK for Linux system tray") + logger.info("Using GTK with %s for Linux system tray", self._indicator_namespace) return - except (ImportError, ValueError) as e: - logger.debug(f"GTK not available: {e}") + except SystemTrayError as e: + logger.debug(f"GTK AppIndicator not available: {e}") # Fallback: log that tray is not available logger.warning("No suitable system tray toolkit found for Linux") @@ -282,8 +309,7 @@ def _initialize_tray(self): def _create_gtk_menu(self): """Create GTK context menu""" try: - from gi.repository import Gtk - + Gtk = self._gtk self._menu = Gtk.Menu() # Open item @@ -370,8 +396,7 @@ def shutdown(self): """Shutdown tray""" if hasattr(self, '_indicator'): try: - from gi.repository import AppIndicator3 - self._indicator.set_status(AppIndicator3.IndicatorStatus.PASSIVE) + self._indicator.set_status(self._indicator_module.IndicatorStatus.PASSIVE) except Exception as e: logger.error(f"Failed to shutdown GTK tray: {e}") diff --git a/src/TimeLocker/recovery_orchestrator.py b/src/TimeLocker/recovery_orchestrator.py index 0dc2b2e..91a7495 100644 --- a/src/TimeLocker/recovery_orchestrator.py +++ b/src/TimeLocker/recovery_orchestrator.py @@ -191,7 +191,13 @@ def _validate_repository_authentication(self) -> None: """ try: # Check if repository has valid credentials - if not self.repository._password: + password_getter = getattr(self.repository, "password", None) + password = password_getter() if callable(password_getter) else getattr( + self.repository, + "_explicit_password", + getattr(self.repository, "_password", None), + ) + if not password: raise RepositoryAccessError( "Repository password not available. Cannot access encrypted repository." ) @@ -280,7 +286,8 @@ def initiate_full_recovery( recovery_type=RecoveryType.FULL, target_path=str(target), status=OperationStatus.PENDING, - start_time=datetime.now() + start_time=datetime.now(), + progress=ProgressStatus(0, 0, 0, 0) ) # Store options separately (not part of the data model) @@ -377,7 +384,8 @@ def initiate_selective_recovery( recovery_type=RecoveryType.SELECTIVE, target_path=str(target), status=OperationStatus.PENDING, - start_time=datetime.now() + start_time=datetime.now(), + progress=ProgressStatus(0, 0, 0, 0) ) # Store options separately (not part of the data model) @@ -551,7 +559,13 @@ def _validate_encryption_keys(self, snapshot_id: str) -> None: if encryption_status.is_encrypted: # Check if we have valid credentials - if not self.repository._password: + password_getter = getattr(self.repository, "password", None) + password = password_getter() if callable(password_getter) else getattr( + self.repository, + "_explicit_password", + getattr(self.repository, "_password", None), + ) + if not password: raise EncryptionKeyError( f"Snapshot {snapshot_id} is encrypted but no decryption key is available" ) diff --git a/src/TimeLocker/restic/restic_repository.py b/src/TimeLocker/restic/restic_repository.py index d3c2181..1a30966 100644 --- a/src/TimeLocker/restic/restic_repository.py +++ b/src/TimeLocker/restic/restic_repository.py @@ -610,7 +610,7 @@ def snapshots(self, tags: Optional[List[str]] = None) -> List[BackupSnapshot]: snapshot = BackupSnapshot( repo=self, - snapshot_id=s["short_id"], + snapshot_id=s["id"] if "id" in s else s["short_id"], timestamp=timestamp, paths=paths ) @@ -618,6 +618,8 @@ def snapshots(self, tags: Optional[List[str]] = None) -> List[BackupSnapshot]: # Add additional attributes from restic data if "hostname" in s: snapshot.hostname = s["hostname"] + if "username" in s: + snapshot.username = s["username"] if "tags" in s: snapshot.tags = s["tags"] else: diff --git a/src/TimeLocker/services/backup_orchestrator.py b/src/TimeLocker/services/backup_orchestrator.py index a8b3cac..96028ae 100644 --- a/src/TimeLocker/services/backup_orchestrator.py +++ b/src/TimeLocker/services/backup_orchestrator.py @@ -918,7 +918,7 @@ def execute_backup(self, repository_name=repository_name, target_names=target_names.copy(), start_time=time.time(), - metadata={'operation_id': operation_id, 'dry_run': dry_run, 'tags': tags or [], 'password': password} + metadata={'operation_id': operation_id, 'dry_run': dry_run, 'tags': tags or []} ) # Track the operation @@ -948,7 +948,7 @@ def execute_backup(self, if dry_run: backup_result = self._execute_dry_run(backup_result) else: - backup_result = self._execute_actual_backup(backup_result) + backup_result = self._execute_actual_backup(backup_result, password=password) backup_result.end_time = time.time() @@ -998,7 +998,12 @@ def _execute_dry_run(self, backup_result: BackupResult) -> BackupResult: for target in targets: # Estimate files and size (simplified) - for path in target.paths: + paths = ( + target.selection.get_backup_paths() + if target.selection is not None + else [] + ) + for path in paths: try: from pathlib import Path path_obj = Path(path) @@ -1025,10 +1030,13 @@ def _execute_dry_run(self, backup_result: BackupResult) -> BackupResult: backup_result.errors.append(f"Dry run failed: {e}") backup_result.status = BackupStatus.FAILED - self._attach_selection_warnings(backup_job, backup_result) return backup_result - def _execute_actual_backup(self, backup_result: BackupResult) -> BackupResult: + def _execute_actual_backup( + self, + backup_result: BackupResult, + password: Optional[str] = None, + ) -> BackupResult: """Execute an actual backup""" logger.info(f"Executing backup for repository: {backup_result.repository_name}") @@ -1046,7 +1054,6 @@ def _execute_actual_backup(self, backup_result: BackupResult) -> BackupResult: return backup_result # Create repository instance - password = backup_result.metadata.get('password') logger.debug(f"Password retrieved from metadata: {'***' if password else 'None'}") logger.debug(f"Repository URI: {repo_config['uri']}") repository = self._repository_factory.create_repository( @@ -1058,6 +1065,10 @@ def _execute_actual_backup(self, backup_result: BackupResult) -> BackupResult: # Get backup targets targets = self._get_backup_targets(backup_result.target_names) + # Deterministic target validation must fail before retry handling. + for target in targets: + target.validate() + # Execute backup with retry @with_retry(max_retries=3, delay=1.0, backoff_multiplier=2.0) def _perform_backup(): @@ -1067,8 +1078,15 @@ def _perform_backup(): if result and 'snapshot_id' in result: backup_result.snapshot_id = result['snapshot_id'] - backup_result.files_processed = result.get('files_processed', 0) - backup_result.bytes_processed = result.get('bytes_processed', 0) + backup_result.files_processed = result.get( + 'files_processed', + result.get('files_new', 0) + + result.get('files_changed', 0) + + result.get('files_unmodified', 0), + ) + backup_result.bytes_processed = result.get( + 'bytes_processed', result.get('data_added', 0) + ) backup_result.status = BackupStatus.COMPLETED logger.info(f"Backup completed successfully: {backup_result.snapshot_id}") diff --git a/src/TimeLocker/snapshot_manager.py b/src/TimeLocker/snapshot_manager.py index 35bca38..554abff 100644 --- a/src/TimeLocker/snapshot_manager.py +++ b/src/TimeLocker/snapshot_manager.py @@ -155,6 +155,12 @@ def get_snapshot_by_id(self, snapshot_id: str) -> BackupSnapshot: Raises: SnapshotNotFoundError: If snapshot is not found """ + if snapshot_id == "latest": + snapshot = self.get_latest_snapshot() + if snapshot is None: + raise SnapshotNotFoundError("No snapshots are available") + return snapshot + snapshots = self.list_snapshots() for snapshot in snapshots: diff --git a/src/TimeLocker/utils/progress_service.py b/src/TimeLocker/utils/progress_service.py index 152b099..d500966 100644 --- a/src/TimeLocker/utils/progress_service.py +++ b/src/TimeLocker/utils/progress_service.py @@ -174,6 +174,7 @@ def spinner( yield self._create_noop_context(description) return + body_entered = False try: # Build progress columns for spinner columns = [ @@ -195,13 +196,26 @@ def spinner( self._active_contexts.append(context) try: + body_entered = True yield context - finally: + except BaseException: + try: + context.complete() + except Exception as cleanup_error: + logger.warning( + "Progress cleanup failed while preserving the operation error: %s", + cleanup_error, + ) + raise + else: context.complete() + finally: if context in self._active_contexts: self._active_contexts.remove(context) except Exception as e: + if body_entered: + raise logger.error(f"Failed to create spinner progress: {e}") # Graceful degradation - continue without progress yield self._create_noop_context(description) @@ -242,6 +256,7 @@ def bar( yield self._create_noop_context(description, total) return + body_entered = False try: # Build progress columns for bar columns = [ @@ -268,13 +283,26 @@ def bar( self._active_contexts.append(context) try: + body_entered = True yield context - finally: + except BaseException: + try: + context.complete() + except Exception as cleanup_error: + logger.warning( + "Progress cleanup failed while preserving the operation error: %s", + cleanup_error, + ) + raise + else: context.complete() + finally: if context in self._active_contexts: self._active_contexts.remove(context) except Exception as e: + if body_entered: + raise logger.error(f"Failed to create bar progress: {e}") # Graceful degradation - continue without progress yield self._create_noop_context(description, total) diff --git a/tests/TimeLocker/backup/test_enhanced_backup_operations.py b/tests/TimeLocker/backup/test_enhanced_backup_operations.py index b2cb4a1..7799c95 100644 --- a/tests/TimeLocker/backup/test_enhanced_backup_operations.py +++ b/tests/TimeLocker/backup/test_enhanced_backup_operations.py @@ -234,6 +234,20 @@ def test_backup_with_retry_all_attempts_fail(self, mock_subprocess, mock_verify) assert mock_subprocess.call_count == 3 # Initial + 2 retries + @pytest.mark.backup + @pytest.mark.unit + def test_invalid_target_does_not_enter_retry_loop(self): + """Deterministic target validation failures are reported immediately.""" + repository = Mock() + target = Mock() + target.validate.side_effect = ValueError("invalid source") + + with pytest.raises(BackupManagerError, match="Invalid backup target"): + self.manager.execute_backup_with_retry(repository, [target]) + + target.validate.assert_called_once_with() + repository.backup_target.assert_not_called() + @patch('TimeLocker.restic.restic_repository.ResticRepository._verify_restic_executable') @patch('subprocess.run') @pytest.mark.backup @@ -494,14 +508,14 @@ def test_file_selection_validation_enhanced(self): selection = FileSelection() # Should raise error with no included paths - with pytest.raises(ValueError, match="At least one folder must be included"): + with pytest.raises(ValueError, match="At least one path must be included"): selection.validate() # Should pass with directory path selection.add_path(self.source_path, SelectionType.INCLUDE) assert selection.validate() is True - # Should pass with file that looks like directory (no extension) + # Files are valid direct backup sources. selection2 = FileSelection() - selection2.add_path(Path("/some/directory"), SelectionType.INCLUDE) + selection2.add_path(self.source_path / "file1.txt", SelectionType.INCLUDE) assert selection2.validate() is True diff --git a/tests/TimeLocker/backup/test_file_selections.py b/tests/TimeLocker/backup/test_file_selections.py index 7afa0e7..3925fd8 100644 --- a/tests/TimeLocker/backup/test_file_selections.py +++ b/tests/TimeLocker/backup/test_file_selections.py @@ -107,13 +107,11 @@ def test_remove_pattern_group(selection): @pytest.mark.backup @pytest.mark.filesystem @pytest.mark.unit -def test_validate_requires_folder(selection, test_dir, test_file): - """Test that validation requires at least one folder""" - # Should raise error when no folders are included +def test_validate_accepts_file_and_requires_path(selection, test_file): + """Validation accepts direct files but rejects an empty selection.""" selection.add_path(test_file) - with pytest.raises(ValueError): - selection.validate() + assert selection.validate() - # Should pass when a folder is included - selection.add_path(test_dir) - assert selection.validate() \ No newline at end of file + empty_selection = type(selection)() + with pytest.raises(ValueError, match="At least one path"): + empty_selection.validate() diff --git a/tests/TimeLocker/backup/test_snapshot.py b/tests/TimeLocker/backup/test_snapshot.py index 92f8751..b2ad984 100644 --- a/tests/TimeLocker/backup/test_snapshot.py +++ b/tests/TimeLocker/backup/test_snapshot.py @@ -41,8 +41,25 @@ def test___init___initializes_attributes_correctly(): assert snapshot.repo == repo assert snapshot.id == snapshot_id assert snapshot.timestamp == timestamp + assert snapshot.time == timestamp assert snapshot.paths == paths + +@pytest.mark.backup +@pytest.mark.unit +def test_from_restic_dict_uses_canonical_time_and_paths(): + """Restic's canonical JSON fields map directly to the snapshot model.""" + repo = MockBackupRepository() + snapshot = BackupSnapshot.from_dict(repo, { + 'id': 'full-snapshot-id', + 'time': '2026-07-19T10:30:00Z', + 'paths': ['/home/user/file.txt', '/etc'], + }) + + assert snapshot.id == 'full-snapshot-id' + assert snapshot.timestamp.isoformat() == '2026-07-19T10:30:00+00:00' + assert snapshot.paths == [Path('/home/user/file.txt'), Path('/etc')] + @pytest.mark.backup @pytest.mark.filesystem @pytest.mark.unit @@ -121,7 +138,7 @@ def test_from_dict_1(): assert snapshot.repo == mock_repo assert snapshot.id == 'test_snapshot_id' assert snapshot.timestamp == datetime(2023, 5, 20, 12, 34, 56) - assert snapshot.paths == Path('/test/backup/path') + assert snapshot.paths == [Path('/test/backup/path')] @pytest.mark.backup @pytest.mark.filesystem diff --git a/tests/TimeLocker/backup/test_target.py b/tests/TimeLocker/backup/test_target.py index 9338c0c..4c68373 100644 --- a/tests/TimeLocker/backup/test_target.py +++ b/tests/TimeLocker/backup/test_target.py @@ -72,21 +72,17 @@ def test_init_with_selection_and_tags(): @pytest.mark.backup @pytest.mark.filesystem @pytest.mark.unit -def test_validate_requires_folder(selection, test_dir, test_file): - """Test that validation requires at least one folder""" +def test_validate_accepts_file_and_requires_path(selection, test_file): + """Backup targets accept direct files but reject an empty selection.""" target = BackupTarget(selection) - # Should raise error when no folders are included selection.add_path(test_file) - with pytest.raises(ValueError): - target.validate() - - # Should pass when a folder is included - selection = FileSelection() # Reset selection - selection.add_path(test_dir) - target = BackupTarget(selection) assert target.validate() + empty_target = BackupTarget(FileSelection()) + with pytest.raises(ValueError, match="At least one path"): + empty_target.validate() + @pytest.mark.backup @pytest.mark.filesystem @pytest.mark.unit @@ -134,4 +130,3 @@ def test_backup_target_with_pattern_group(selection, test_dir): assert target.validate() assert "*.doc" in target.selection.include_patterns assert "*.pdf" in target.selection.include_patterns - diff --git a/tests/TimeLocker/cli/test_cli_end_to_end_snapshots_schedule_flows.py b/tests/TimeLocker/cli/test_cli_end_to_end_snapshots_schedule_flows.py index 9d96dcc..e36e802 100644 --- a/tests/TimeLocker/cli/test_cli_end_to_end_snapshots_schedule_flows.py +++ b/tests/TimeLocker/cli/test_cli_end_to_end_snapshots_schedule_flows.py @@ -170,7 +170,7 @@ class TestCLIScheduleEndToEndFlows: def test_schedule_create_list_and_toggle_flow(self, isolated_cli_environment): schedule_name = "nightly-docs" - policy_name = "docs-backup-policy" + repository_name = "docs-repository" scripts_dir = Path(isolated_cli_environment["config_dir"]) / "scripts-out" _invoke( @@ -178,7 +178,8 @@ def test_schedule_create_list_and_toggle_flow(self, isolated_cli_environment): [ "schedule", "create", schedule_name, - policy_name, + "--repository", repository_name, + "--source", str(Path(isolated_cli_environment["config_dir"])), "--frequency", "daily", "--enabled", "--config-dir", str(isolated_cli_environment["config_dir"]), @@ -196,7 +197,8 @@ def test_schedule_create_list_and_toggle_flow(self, isolated_cli_environment): label="tl schedule list --json", ) assert schedule_name in schedules - assert schedules[schedule_name]["policy"] == policy_name + assert schedules[schedule_name]["repository"] == repository_name + assert schedules[schedule_name]["sources"] == [str(Path(isolated_cli_environment["config_dir"]).resolve())] _invoke( isolated_cli_environment, diff --git a/tests/TimeLocker/cli/test_cli_end_to_end_user_flows.py b/tests/TimeLocker/cli/test_cli_end_to_end_user_flows.py index 3a0789a..81f0c34 100644 --- a/tests/TimeLocker/cli/test_cli_end_to_end_user_flows.py +++ b/tests/TimeLocker/cli/test_cli_end_to_end_user_flows.py @@ -184,6 +184,7 @@ def configured_restore_patches(source_dir: Path) -> Iterator[dict]: snapshot_entry = SimpleNamespace( id=SNAPSHOT_ID, time=now, + timestamp=now, hostname="timelocker-e2e", username="cli-user", tags=["e2e", "documents"], diff --git a/tests/TimeLocker/cli/test_repos_commands.py b/tests/TimeLocker/cli/test_repos_commands.py index 920acd2..01166f3 100644 --- a/tests/TimeLocker/cli/test_repos_commands.py +++ b/tests/TimeLocker/cli/test_repos_commands.py @@ -325,6 +325,46 @@ def test_repos_init_command(self, mock_service_manager, mock_config_manager_clas # Mocked service manager returns success, should exit 0 assert_success(result) + @pytest.mark.unit + @patch('TimeLocker.cli_modules.commands.repositories._create_repository_resolver') + @patch('TimeLocker.cli_modules.commands.repositories.ConfigurationManager') + @patch('TimeLocker.cli_modules.commands.repositories._get_service_manager_for_command') + def test_repos_init_uses_resolved_environment_password( + self, + mock_service_manager, + mock_config_manager_class, + mock_create_resolver, + tmp_path, + ): + """Non-interactive init accepts the shared credential resolution chain.""" + repo_dir = tmp_path / "environment-repo" + repo_dir.mkdir() + repo_uri = f"file://{repo_dir}" + + manager = Mock() + manager.initialize_repository.return_value = {"success": True} + mock_service_manager.return_value = manager + mock_config_manager_class.return_value.get_repository.return_value = { + "name": "environment-repo", + "uri": repo_uri, + } + resolver = mock_create_resolver.return_value + resolver.resolve_credentials.return_value = "environment-password" + + result = runner.invoke(app, [ + "repos", "init", "environment-repo", "--yes", + "--repository", repo_uri, + ]) + + assert_success(result) + resolver.resolve_credentials.assert_called_once_with( + repository_name="environment-repo", + explicit_password=None, + allow_prompt=False, + repository_uri=repo_uri, + ) + assert manager.initialize_repository.call_args.kwargs["password"] == "environment-password" + @pytest.mark.unit @patch('TimeLocker.cli_modules.commands.repositories.ConfigurationManager') @patch('TimeLocker.cli_modules.commands.repositories._get_service_manager_for_command') diff --git a/tests/TimeLocker/cli/test_schedule_commands.py b/tests/TimeLocker/cli/test_schedule_commands.py index eed61be..a2554d3 100644 --- a/tests/TimeLocker/cli/test_schedule_commands.py +++ b/tests/TimeLocker/cli/test_schedule_commands.py @@ -5,9 +5,16 @@ """ import pytest +import shlex +from pathlib import Path from unittest.mock import Mock, patch from TimeLocker.cli import app +from TimeLocker.cli_modules.commands.schedule import ( + _build_backup_command, + _generate_cron_script, + _generate_systemd_script, +) from tests.TimeLocker.cli.test_utils import ( get_cli_runner, combined_output, assert_success, assert_exit_code, assert_help_quality ) @@ -81,12 +88,55 @@ def test_schedule_list_command(self): def test_schedule_create_with_parameters(self): """Test schedule create command with parameters.""" result = runner.invoke(app, [ - "schedule", "create", "test-schedule", "test-policy", + "schedule", "create", "test-schedule", + "--repository", "test-repo", "--source", ".", "--frequency", "daily" ]) # Should succeed or fail gracefully (policy might not exist) assert result.exit_code in [0, 1, 2] + @pytest.mark.unit + def test_generated_backup_command_parses_current_cli(self, tmp_path): + schedule = { + "repository": "pilot-repo", + "sources": [str(tmp_path / "source")], + "selection": None, + "config_dir": str(tmp_path / "config"), + } + + argv = shlex.split(_build_backup_command(schedule)) + result = runner.invoke(app, argv[1:] + ["--help"]) + + assert_success(result) + assert "--policy" not in argv + assert "--non-interactive" not in argv + assert argv[-2:] == ["--config-dir", str((tmp_path / "config").resolve())] + + @pytest.mark.unit + def test_linux_renderers_reference_environment_without_secret_values(self, tmp_path): + env_file = tmp_path / "pilot.env" + schedule = { + "repository": "pilot-repo", + "selection": "protected-files", + "sources": [], + "environment_file": str(env_file), + "config_dir": str(tmp_path / "config"), + "system": True, + "cron_expression": "0 2 * * *", + "frequency": "daily", + } + + cron = _generate_cron_script("pilot", schedule) + service, timer = _generate_systemd_script("pilot", schedule) + + assert str(env_file) in cron + assert f"EnvironmentFile={env_file}" in service + assert "set -euo pipefail" in cron + assert "User=root" in service + assert "backup create --selection protected-files" in cron + assert "--repository pilot-repo" in service + assert "RESTIC_PASSWORD=" not in cron + service + timer + @pytest.mark.unit def test_schedule_edit_command(self): """Test schedule edit command execution with non-existent schedule.""" diff --git a/tests/TimeLocker/monitoring/test_system_tray_integration.py b/tests/TimeLocker/monitoring/test_system_tray_integration.py index 5ea8ab4..8a0a75f 100644 --- a/tests/TimeLocker/monitoring/test_system_tray_integration.py +++ b/tests/TimeLocker/monitoring/test_system_tray_integration.py @@ -25,6 +25,10 @@ TrayStatusInfo, SystemTrayError ) +from TimeLocker.monitoring.system_tray_integration import ( + LinuxSystemTray, + _load_linux_tray_modules, +) class TestTrayStatusInfo: @@ -71,3 +75,57 @@ def test_tray_status_enum(self): assert TrayStatus.SUCCESS.value == "success" assert TrayStatus.WARNING.value == "warning" assert TrayStatus.ERROR.value == "error" + + +class TestLinuxSystemTray: + """Linux namespace selection and non-fatal availability behavior.""" + + @pytest.mark.monitoring + @pytest.mark.unit + def test_prefers_ayatana_appindicator(self): + gi = Mock() + gtk = Mock() + ayatana = Mock() + + with patch.dict('sys.modules', {'gi': gi}): + with patch( + 'TimeLocker.monitoring.system_tray_integration.importlib.import_module', + side_effect=[gtk, ayatana], + ): + modules = _load_linux_tray_modules() + + assert modules == (gtk, ayatana, 'AyatanaAppIndicator3') + gi.require_version.assert_any_call('AyatanaAppIndicator3', '0.1') + + @pytest.mark.monitoring + @pytest.mark.unit + def test_falls_back_to_legacy_appindicator(self): + gi = Mock() + gtk = Mock() + legacy = Mock() + + def require_version(namespace, version): + if namespace == 'AyatanaAppIndicator3': + raise ValueError('namespace unavailable') + + gi.require_version.side_effect = require_version + with patch.dict('sys.modules', {'gi': gi}): + with patch( + 'TimeLocker.monitoring.system_tray_integration.importlib.import_module', + side_effect=[gtk, legacy], + ): + modules = _load_linux_tray_modules() + + assert modules == (gtk, legacy, 'AppIndicator3') + gi.require_version.assert_any_call('AppIndicator3', '0.1') + + @pytest.mark.monitoring + @pytest.mark.unit + def test_missing_indicator_namespaces_is_non_fatal_to_facade(self): + with patch( + 'TimeLocker.monitoring.system_tray_integration._load_linux_tray_modules', + side_effect=SystemTrayError('no indicator'), + ): + tray = SystemTrayIntegration('TestApp') + + assert tray.is_available() is False diff --git a/tests/TimeLocker/recovery/mock_recovery_repository.py b/tests/TimeLocker/recovery/mock_recovery_repository.py index e57c6cc..42bb877 100644 --- a/tests/TimeLocker/recovery/mock_recovery_repository.py +++ b/tests/TimeLocker/recovery/mock_recovery_repository.py @@ -23,6 +23,7 @@ def from_uri(cls, uri: str, password: Optional[str] = None) -> "MockRecoveryRepo def __init__(self): self._initialized = True # Initialize by default for testing + self._password = "test_password" self._snapshots = {} self._location = "/mock/recovery/repository" self._restore_results = {} diff --git a/tests/TimeLocker/recovery/test_recovery_orchestrator.py b/tests/TimeLocker/recovery/test_recovery_orchestrator.py index b7fb4ba..d0f59e8 100644 --- a/tests/TimeLocker/recovery/test_recovery_orchestrator.py +++ b/tests/TimeLocker/recovery/test_recovery_orchestrator.py @@ -15,6 +15,7 @@ RecoveryOptions, RecoveryType, OperationStatus, + ProgressStatus, SelectionCriteria ) from TimeLocker.recovery_errors import ( @@ -66,6 +67,7 @@ def test_initiate_full_recovery_success(self): assert operation is not None assert operation.snapshot_id == "abc123" assert operation.recovery_type == RecoveryType.FULL + assert operation.progress == ProgressStatus(0, 0, 0, 0) assert operation.target_path == target_path # Operation should be initiated (any status is acceptable for this test) assert operation.status in [ @@ -127,6 +129,7 @@ def test_initiate_selective_recovery_success(self): assert operation is not None assert operation.snapshot_id == "abc123" assert operation.recovery_type == RecoveryType.SELECTIVE + assert operation.progress == ProgressStatus(0, 0, 0, 0) assert operation.target_path == target_path @pytest.mark.recovery diff --git a/tests/TimeLocker/recovery/test_snapshot_manager.py b/tests/TimeLocker/recovery/test_snapshot_manager.py index 2cfdf75..789f14b 100644 --- a/tests/TimeLocker/recovery/test_snapshot_manager.py +++ b/tests/TimeLocker/recovery/test_snapshot_manager.py @@ -89,6 +89,14 @@ def test_get_snapshot_by_id_not_found(self): with pytest.raises(SnapshotNotFoundError): self.manager.get_snapshot_by_id("nonexistent") + @pytest.mark.restore + @pytest.mark.unit + def test_get_snapshot_by_id_latest_alias(self): + """Test that the CLI's latest alias resolves to the newest snapshot.""" + snapshot = self.manager.get_snapshot_by_id("latest") + + assert snapshot.id == "abc123" + @pytest.mark.restore @pytest.mark.unit def test_get_latest_snapshot(self): diff --git a/tests/TimeLocker/regression/test_regression_suite.py b/tests/TimeLocker/regression/test_regression_suite.py index a73d27d..8f69197 100644 --- a/tests/TimeLocker/regression/test_regression_suite.py +++ b/tests/TimeLocker/regression/test_regression_suite.py @@ -418,8 +418,8 @@ def test_backup_target_validation_regression(self): tags=["empty_test"] ) - # Should fail validation because no folders are included - with pytest.raises(ValueError, match="At least one folder must be included"): + # Should fail validation because no paths are included + with pytest.raises(ValueError, match="At least one path must be included"): empty_target.validate() @pytest.mark.regression diff --git a/tests/TimeLocker/services/test_backup_orchestrator_job_execution.py b/tests/TimeLocker/services/test_backup_orchestrator_job_execution.py index 4c97d3e..a674659 100644 --- a/tests/TimeLocker/services/test_backup_orchestrator_job_execution.py +++ b/tests/TimeLocker/services/test_backup_orchestrator_job_execution.py @@ -196,6 +196,58 @@ def test_execute_job_dry_run(self, mock_path, orchestrator, sample_job_config): assert result.snapshot_id is not None assert 'dry-run' in result.snapshot_id assert result.metadata['dry_run'] is True + + def test_legacy_dry_run_completes_without_undefined_job( + self, orchestrator, tmp_path + ): + """Direct-path CLI dry-run must not reference job-only state.""" + source_file = tmp_path / "source.txt" + source_file.write_text("content") + orchestrator._configuration_provider.get_backup_targets.return_value = [{ + "name": "test-target", + "paths": [str(source_file)], + "exclude_patterns": [], + "include_patterns": [], + }] + result = BackupResult( + status=BackupStatus.PENDING, + repository_name="test-repo", + target_names=["test-target"], + ) + + completed = orchestrator._execute_dry_run(result) + + assert completed.status == BackupStatus.COMPLETED + assert completed.files_processed == 1 + + def test_actual_backup_maps_restic_summary_and_does_not_persist_password( + self, orchestrator, mock_repository_factory + ): + """Restic summary fields drive truthful CLI counts without secret metadata.""" + repository = mock_repository_factory.create_repository.return_value + repository.backup_target.return_value = { + "snapshot_id": "snapshot-summary", + "files_new": 2, + "files_changed": 3, + "files_unmodified": 5, + "data_added": 4096, + } + result = BackupResult( + status=BackupStatus.PENDING, + repository_name="test-repo", + target_names=["test-target"], + metadata={"tags": []}, + ) + + completed = orchestrator._execute_actual_backup( + result, password="runtime-only-password" + ) + + assert completed.status == BackupStatus.COMPLETED + assert completed.snapshot_id == "snapshot-summary" + assert completed.files_processed == 10 + assert completed.bytes_processed == 4096 + assert "password" not in completed.metadata def test_execute_backup_job_success(self, orchestrator, sample_job_config): """Test successful backup job execution""" diff --git a/tests/TimeLocker/utils/test_progress_service.py b/tests/TimeLocker/utils/test_progress_service.py index 7dce4a7..95bc9a2 100644 --- a/tests/TimeLocker/utils/test_progress_service.py +++ b/tests/TimeLocker/utils/test_progress_service.py @@ -7,6 +7,7 @@ import pytest from io import StringIO from rich.console import Console +from unittest.mock import Mock from TimeLocker.utils.progress_service import ( ProgressService, @@ -72,6 +73,24 @@ def test_bar_context(self): assert progress.completed == 10 assert not service.has_active_progress() + + @pytest.mark.parametrize("context_name", ["spinner", "bar"]) + def test_body_exception_remains_primary(self, context_name): + """Progress cleanup must not yield twice or replace the body failure.""" + output = StringIO() + service = ProgressService(console=Console(file=output, width=80)) + context = ( + service.spinner("Failing operation") + if context_name == "spinner" + else service.bar("Failing operation", total=1) + ) + + with pytest.raises(ValueError, match="primary failure"): + with context as progress: + progress.complete = Mock(side_effect=RuntimeError("cleanup failure")) + raise ValueError("primary failure") + + assert not service.has_active_progress() def test_simple_context(self): """Test simple progress context.""" From 2c93709bcd0b58d39e7af91bd4d1c0b2e1357162 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:00:04 +0100 Subject: [PATCH 17/72] feat(backup): add NPBackup migration parity Carry compression and filesystem-boundary behavior through direct and scheduled Restic backups. Establish Spec 008, promote the durable operator guidance, and record the protected credential-source gate before root-owned installation. --- docs/guides/developer/scheduling-guide.md | 17 ++- docs/guides/user/recovery-operations-guide.md | 20 +++ .../canonical-context.md | 79 +++++++++++ .../change-impact.md | 40 ++++++ .../008-npbackup-migration-parity/design.md | 119 ++++++++++++++++ .../requirements.md | 129 ++++++++++++++++++ .../008-npbackup-migration-parity/tasks.md | 104 ++++++++++++++ .../traceability.md | 52 +++++++ .../verification.md | 75 ++++++++++ docs/specs/README.md | 14 +- src/TimeLocker/backup_target.py | 7 +- src/TimeLocker/cli_modules/commands/backup.py | 15 +- .../cli_modules/commands/schedule.py | 68 ++++++++- src/TimeLocker/cli_services.py | 10 +- src/TimeLocker/config/configuration_schema.py | 2 + src/TimeLocker/restic/restic_repository.py | 23 ++++ .../services/backup_orchestrator.py | 9 +- .../backup/test_backup_operations.py | 58 ++++++++ tests/TimeLocker/backup/test_target.py | 15 ++ tests/TimeLocker/cli/test_backup_commands.py | 45 ++++++ .../test_backup_data_selection_integration.py | 6 +- .../TimeLocker/cli/test_schedule_commands.py | 102 +++++++++++++- .../test_backup_orchestrator_job_execution.py | 17 +++ 23 files changed, 1010 insertions(+), 16 deletions(-) create mode 100644 docs/specs/008-npbackup-migration-parity/canonical-context.md create mode 100644 docs/specs/008-npbackup-migration-parity/change-impact.md create mode 100644 docs/specs/008-npbackup-migration-parity/design.md create mode 100644 docs/specs/008-npbackup-migration-parity/requirements.md create mode 100644 docs/specs/008-npbackup-migration-parity/tasks.md create mode 100644 docs/specs/008-npbackup-migration-parity/traceability.md create mode 100644 docs/specs/008-npbackup-migration-parity/verification.md diff --git a/docs/guides/developer/scheduling-guide.md b/docs/guides/developer/scheduling-guide.md index 230b2c6..d44e677 100644 --- a/docs/guides/developer/scheduling-guide.md +++ b/docs/guides/developer/scheduling-guide.md @@ -31,6 +31,11 @@ Each executable schedule must explicitly bind: - its configuration directory when it is not the default; and - an optional protected environment-file path, never copied secret values. +Schedules may also persist repeatable `--tags` and `--exclude` values, +`--compression auto|off|max`, and the +`--one-file-system/--cross-filesystems` traversal choice. Missing fields retain +the legacy defaults and emit no additional backup arguments. + ## Create a disabled schedule Use a selection template: @@ -54,6 +59,10 @@ tl schedule create nightly-config \ --source /srv/application/config \ --environment-file ~/.config/timelocker/backup.env \ --system \ + --tags Bruce-5560 \ + --exclude 'cache/*' \ + --compression max \ + --one-file-system \ --cron '30 1 * * *' \ --disabled \ --config-dir ~/.config/timelocker @@ -90,7 +99,8 @@ tl schedule generate-scripts nightly-config \ Before installation: 1. Confirm the generated backup command contains `backup create`, the intended - repository, all sources or the selection, and the intended `--config-dir`. + repository, all sources or the selection, the intended `--config-dir`, and + every reviewed tag, exclusion, compression, and traversal option. 2. Confirm it contains no password or other credential value. 3. Run the generated wrapper manually in the intended user or root context. 4. Complete a backup and a digest-verified TimeLocker restore. @@ -127,6 +137,11 @@ systemd-analyze verify \ ~/.local/share/timelocker/staged-schedules/timelocker-nightly-config.timer ``` +`schedule list`, `schedule show`, and `schedule test` expose or validate the +stored execution options without reading the referenced environment file. +Cron, systemd, and Windows assets are rendered from the same argument-safe +command builder, so spaces and shell metacharacters remain single arguments. + If the command reports a missing repository, selection, or source, recreate or edit the schedule so the execution target is explicit. If access fails only in the scheduler, compare its user, environment-file permissions, executable diff --git a/docs/guides/user/recovery-operations-guide.md b/docs/guides/user/recovery-operations-guide.md index fca9032..28a1750 100644 --- a/docs/guides/user/recovery-operations-guide.md +++ b/docs/guides/user/recovery-operations-guide.md @@ -85,6 +85,26 @@ tl backup create ~/Documents/report.odt \ --config-dir ~/.config/timelocker ``` +When migrating an existing Restic job, preserve its reviewed execution +semantics explicitly. Compression accepts `auto`, `off`, or `max`; +`--one-file-system` prevents traversal into other mounted filesystems and +subvolumes. Both options work with direct paths and selection templates: + +```bash +tl backup create /home /etc /var /srv /root /nix/var \ + --repository primary \ + --tags Bruce-5560 \ + --exclude 'cache/*' \ + --compression max \ + --one-file-system \ + --dry-run \ + --config-dir ~/.config/timelocker +``` + +Omitting these options preserves the existing defaults: TimeLocker does not +add a Restic compression argument and permits cross-filesystem traversal. +Unsupported compression values fail CLI validation before repository access. + Record the full snapshot ID from the result or JSON listing. Reported file and byte counts come from Restic's summary. diff --git a/docs/specs/008-npbackup-migration-parity/canonical-context.md b/docs/specs/008-npbackup-migration-parity/canonical-context.md new file mode 100644 index 0000000..9cccb35 --- /dev/null +++ b/docs/specs/008-npbackup-migration-parity/canonical-context.md @@ -0,0 +1,79 @@ +--- +title: NPBackup migration parity canonical context +doc_type: spec +artifact_type: canonical-context +status: active +owner: Auriora Team +last_reviewed: 2026-07-19 +--- + +# Canonical Context + +## Purpose + +Prevent historical plans, masked sensitive data, or an uncommitted checkout +from being mistaken for authority during the staged NPBackup migration. + +## Authority Hierarchy + +- `CHARTER.md` owns safety, recovery, credentials, and operator boundaries. +- Spec 007 commit `433c0aa` owns the machine-accepted backup, restore, tray, and + executable-schedule baseline. +- This package owns migration parity and sequencing only. +- Current source, tests, and generated argv own implemented behavior. +- The live root crontab and NPBackup masked interface own existing-job evidence. + +## Always-Canonical External Sources + +| Source | Authority reason | Handling | +|--------|------------------|----------| +| `AGENTS.md` and `CHARTER.md` | Repository governance and safety boundary | Stop for a scope decision on conflict. | +| `docs/guides/ai-agent/` | Operational agent rules | Apply by documented priority. | +| source, tests, generated argv, and live masked host evidence | Implemented and operator truth | Reconcile conflicts into the package. | + +## Spec-Canonical Working Sources + +| Source | Role | Scope | Notes | +|--------|------|-------|-------| +| `requirements.md` | accepted intent | Spec 008 | Phase 2 actions still require named approvals. | +| `design.md` | implementation approach | Spec 008 | Does not authorize host mutation. | +| `tasks.md` | execution index | Spec 008 | Read with traceability and verification. | + +## Imported Sources + +| Spec path | Source path | Source revision or date | Status | Canonical scope | Promotion target | +|-----------|-------------|-------------------------|--------|-----------------|------------------| +| `canonical-context.md` | Spec 007 verification | `433c0aa` | summarized | machine-acceptance dependency | current user/operator guides | +| `canonical-context.md` | live masked NPBackup evidence | 2026-07-19 | summarized | existing job semantics only | installation and scheduling guides | + +## Non-Canonical Background Sources + +| Source | Reason non-canonical | Handling | +|--------|----------------------|----------| +| NPBackup ciphertext and unexpanded implementation internals | Not a usable credential or reviewed TimeLocker contract | Never copy, print, or infer plaintext values. | +| deleted or archived historical plans | No current-state authority | Use only for provenance when explicitly needed. | + +## Promotion Map + +| Spec-local content | Durable destination or route | Required before closure | +|--------------------|------------------------------|-------------------------| +| accepted backup execution options | `docs/guides/user/recovery-operations-guide.md` | yes | +| accepted schedule fields and staging workflow | `docs/guides/developer/scheduling-guide.md` | yes | +| unresolved credential, observation, and cutover work | T005-T008 or explicit follow-up spec | yes | + +## Sensitive Context Boundary + +The NPBackup repository URI, repository password, and AWS-compatible values are +intentionally absent from this package and session evidence. A byte-identical +copy exists at the root-owned, mode-0600 +`/etc/timelocker/npbackup-migration.env`; only its path, metadata, expected +variable names, and successful non-empty load check are recorded. Other safe +metadata includes the six source paths, option/retention shape, +exclusion-source categories, schedule, execution identity, and recent snapshot +identity. + +## Sequencing Decision + +Spec 008 may be active while Spec 007 awaits release/closure decisions because +it depends on the committed Spec 007 implementation and cannot publish a +release or close Spec 007. Phase 2 host mutations require their own approvals. diff --git a/docs/specs/008-npbackup-migration-parity/change-impact.md b/docs/specs/008-npbackup-migration-parity/change-impact.md new file mode 100644 index 0000000..3c049c0 --- /dev/null +++ b/docs/specs/008-npbackup-migration-parity/change-impact.md @@ -0,0 +1,40 @@ +--- +title: NPBackup migration parity change impact +doc_type: spec +artifact_type: change-impact +status: active +owner: Auriora Team +last_reviewed: 2026-07-19 +--- + +# Change Impact + +## Durable Source Mapping + +| Source | Current behavior relied on | Confidence | Notes | +|--------|----------------------------|------------|-------| +| `docs/guides/user/recovery-operations-guide.md` | Current backup execution workflow | high | Needs explicit compression and traversal options. | +| `docs/guides/developer/scheduling-guide.md` | Current schedule and renderer workflow | high | Needs persisted fields and staging boundary. | + +## Proposed Changes + +| Change | Type | Source of truth | New durable destination | Promotion required | +|--------|------|-----------------|-------------------------|-------------------| +| Backup compression | add | backup CLI/request/target and Restic adapter | user backup guidance | yes | +| Filesystem traversal | add | backup CLI/request/target and Restic adapter | user backup guidance | yes | +| Schedule parity | modify | schedule commands and renderers | scheduling guide | yes | +| Host installation | migration | approved Phase 2 only | installation and scheduling guides | yes, after acceptance | + +## Promotion Targets + +| Spec content | Durable destination | Promotion status | Notes | +|--------------|---------------------|------------------|-------| +| Backup execution parity | `docs/guides/user/recovery-operations-guide.md` | complete | Promoted after focused tests passed. | +| Stored schedule parity and safe staging | `docs/guides/developer/scheduling-guide.md` | complete | Credential and cutover gates remain explicit. | + +## Unchanged Boundaries + +- No repository or credential format changes. +- No automatic service installation or crontab mutation. +- No retention enforcement or prune behavior in Phase 1. +- Spec 007 retains release approval and lifecycle closure authority. diff --git a/docs/specs/008-npbackup-migration-parity/design.md b/docs/specs/008-npbackup-migration-parity/design.md new file mode 100644 index 0000000..66a5584 --- /dev/null +++ b/docs/specs/008-npbackup-migration-parity/design.md @@ -0,0 +1,119 @@ +--- +title: NPBackup migration parity design +doc_type: spec +artifact_type: design +status: active +owner: Auriora Team +last_reviewed: 2026-07-19 +--- + +# Technical Design + +## Overview + +Add typed backup execution options at the existing CLI-to-target boundary. +`CLIBackupRequest` and selection-job metadata carry `compression` and +`one_file_system`; `BackupTarget` exposes them to `ResticRepository`, which +validates one invocation-wide value and adds the corresponding Restic flags. +This avoids widening the abstract repository interface or changing unrelated +backend signatures. + +Schedules persist `tags`, `exclude_patterns`, `compression`, and +`one_file_system`. `_build_backup_command` remains the single renderer +source for cron, systemd, and Windows and emits current CLI options using +argument-safe platform quoting. + +## High-Level Design + +### Components And Changes + +- The backup CLI and `CLIBackupRequest` accept typed execution options. +- Direct and selection-based orchestrators carry them into `BackupTarget`. +- `ResticRepository` converts consistent target options to Restic argv. +- Schedule storage and `_build_backup_command` preserve the same options + across cron, systemd, and Windows renderers. + +### Data Flow + +```text +CLI or stored schedule -> CLIBackupRequest/job metadata -> BackupTarget + -> ResticRepository -> argument-safe restic backup argv +``` + +## Low-Level Design + +### Contracts And Interfaces + +Add optional `compression` and default-false `one_file_system` fields to +`CLIBackupRequest` and `BackupTarget`. Add `tags`, `exclude_patterns`, +`compression`, and `one_file_system` to schedule records. The abstract +repository method remains unchanged; the Restic adapter reads invocation +options from the concrete targets it already receives. + +### Error Handling + +Click validates the public compression choice. The Restic adapter independently +rejects unsupported or inconsistent target values before invoking Restic so +programmatic callers cannot bypass the guardrail. + +## Compatibility + +- `compression=None` emits no argument and preserves Restic's current default. +- `one_file_system=False` emits no argument. +- Missing schedule fields load as empty/false/none. +- Existing repository adapters may ignore target execution options; Restic is + the only backend in this migration acceptance path. + +## Validation And Failure Handling + +- Validate compression at the CLI and Restic adapter boundary. +- Reject conflicting target-level invocation options rather than choosing one. +- Test direct and selection-based backup propagation. +- Test stored schedule creation, editing, display, parser round trip, and all + renderers. +- Run focused tests, CLI help checks, compile, and whitespace checks before the + Phase 1 checkpoint. + +## Operator Staging Design + +After Phase 1 is committed, build a wheel and install it into a root-owned +virtual environment such as `/opt/timelocker/venv`. Store configuration under +`/etc/timelocker` and reference a mode-0600 root-owned environment file. Attach +to the existing repository read-only, list and restore snapshot `8958659e`, +then stage a disabled system timer at a non-overlapping time. Retention remains +simulation-only during overlap. + +## Security And Rollback + +No NPBackup ciphertext is copied as a usable credential. Credential transfer +must use operator-supplied values or an explicitly approved secure export that +never prints values. For this host, the operator approved a byte-identical copy +of the existing Restic service-account environment into the root-owned, +mode-0600 `/etc/timelocker/npbackup-migration.env`; its values remain outside +repository and session evidence. Phase 1 rollback is a code revert. Later host +rollback is disabling/removing the TimeLocker timer while leaving root's +NPBackup cron untouched until final cutover approval. + +## Durable Promotion + +Promote accepted CLI options to user backup guidance and schedule fields to the +operator scheduling guide. Production installation and cutover evidence remain +in verification until accepted, then only current operating instructions are +promoted. + +## Operational Considerations + +Phase 1 changes code, tests, and durable guidance only. Root installation, +credential provisioning, repository attachment, timer installation, retention, +and NPBackup cutover remain explicit Phase 2 operator gates. + +## Resolved Decisions + +- D001 is resolved: `/etc/timelocker/npbackup-migration.env`, copied without + value output from the existing Restic service-account environment, supplies + the production repository and backend credentials. + +## Open Questions + +- Does the effective NPBackup built-in exclusion expansion require a normalized + TimeLocker selection template before T007? diff --git a/docs/specs/008-npbackup-migration-parity/requirements.md b/docs/specs/008-npbackup-migration-parity/requirements.md new file mode 100644 index 0000000..047d9bc --- /dev/null +++ b/docs/specs/008-npbackup-migration-parity/requirements.md @@ -0,0 +1,129 @@ +--- +title: NPBackup migration parity requirements +doc_type: spec +artifact_type: requirements +status: active +owner: Auriora Team +last_reviewed: 2026-07-19 +--- + +# Requirements + +## Introduction + +Preserve the observable backup semantics of the existing root-owned NPBackup +job before TimeLocker is installed or scheduled against its repository. This +package follows the machine-acceptance implementation committed by Spec 007 at +`433c0aa`; it does not authorize credential extraction, privileged +installation, release publication, or NPBackup cutover. + +## Known Operator Baseline + +- Root cron runs daily at 17:30. +- Active sources are `/home`, `/etc`, `/var`, `/srv`, `/root`, and `/nix/var`. +- The job requests maximum Restic compression, single-filesystem traversal, + tag `Bruce-5560`, three configured patterns, and NPBackup built-in excludes. +- The repository URI, password, and AWS-compatible credentials are encrypted. +- Snapshot `8958659e` dated 2026-07-18 is the latest verified recent snapshot. + +## Goals + +- Carry compression and filesystem-boundary intent from CLI and schedules to + the Restic invocation. +- Carry backup tags and exclusions through generated schedules. +- Preserve default behavior for existing callers and stored schedules. +- Establish a root-owned, credential-safe, observable migration sequence. + +## Non-Goals + +- Decrypting or copying NPBackup credentials without a separate secure choice. +- Installing or enabling a system service during Phase 1. +- Disabling or editing either NPBackup crontab during Phase 1. +- Applying destructive retention or prune operations during overlap. +- Publishing TimeLocker or closing Spec 007. + +## Durable Source Baseline + +| Source | Current behavior relied on | Confidence | Notes | +|--------|----------------------------|------------|-------| +| `docs/guides/user/recovery-operations-guide.md` | Current backup CLI and repository workflow | high | Add accepted execution options during promotion. | +| `docs/guides/developer/scheduling-guide.md` | Current schedule creation and rendering workflow | high | Add persisted parity fields during promotion. | +| Spec 007 verification at `433c0aa` | Machine-acceptance and executable-schedule baseline | high | Committed implementation dependency. | +| live masked NPBackup configuration and root cron, inspected 2026-07-19 | Current operator-job semantics | high | Sensitive values were not captured. | + +## Requirements + +### Requirement 1: Backup execution parity + +**User story:** As the operator, I want explicit Restic execution options to +reach the backup engine so that a migrated job does not silently change its +filesystem or compression boundary. + +**Priority:** must-have + +### Acceptance Criteria + +1. GIVEN compression `auto`, `off`, or `max`, WHEN `backup create` runs, THEN + the selected value SHALL reach Restic as `--compression`. +2. IF an unsupported compression value is supplied, THEN TimeLocker SHALL fail + before repository mutation with an actionable validation error. +3. GIVEN `--one-file-system`, WHEN a backup runs, THEN Restic SHALL receive + `--one-file-system`; existing callers without the option SHALL retain + cross-filesystem behavior. +4. GIVEN tags and exclude patterns, WHEN the backup runs, THEN all values SHALL + reach the existing tag and exclusion command path without credential output. + +### Requirement 2: Executable schedule parity + +**User story:** As the operator, I want a stored schedule to retain execution +options so generated assets represent the reviewed migration contract. + +**Priority:** must-have + +### Acceptance Criteria + +1. Schedule create/edit SHALL persist repeatable tags and exclusions, + compression, and the one-filesystem flag. +2. Cron, systemd, and Windows renderers SHALL emit only current `backup create` + options and preserve argument boundaries for spaces and metacharacters. +3. Existing schedules without new fields SHALL render with existing defaults. +4. Schedule show/list/test SHALL expose or validate the parity fields without + displaying credential values. + +### Requirement 3: Staged migration safety + +**User story:** As the operator, I want migration actions separated by risk so +NPBackup remains a recoverable fallback until TimeLocker is observed. + +**Priority:** must-have + +### Acceptance Criteria + +1. Phase 1 SHALL NOT install a service, change a crontab, or write plaintext + repository credentials. +2. The production install SHALL use a committed artifact in a root-owned + location rather than a mutable user pyenv checkout. +3. TimeLocker SHALL attach read-only and restore an existing snapshot before + its first production-source backup. +4. NPBackup SHALL remain active until non-overlapping scheduled TimeLocker runs + and a subsequent restore pass; cutover requires separate approval. + +## Correctness Properties + +- **CP-001:** Generated argv parses as the current CLI and preserves every + reviewed parity value exactly once per supplied value. +- **CP-002:** Default callers produce no new Restic compression or + one-filesystem arguments. +- **CP-003:** Invalid compression cannot reach repository execution. +- **CP-004:** Generated assets contain credential references only, never values. +- **CP-005:** Phase 1 leaves systemd, cron, NPBackup, and production credentials + unchanged. + +## Success Criteria + +- **SC-001:** Focused backup tests prove valid compression and filesystem + boundary options reach Restic while defaults emit no new arguments. +- **SC-002:** Schedule create, edit, show, test, and platform render tests prove + all parity fields survive storage and argument-safe rendering. +- **SC-003:** Phase 1 validation records no live scheduler, NPBackup, repository, + or credential mutation. diff --git a/docs/specs/008-npbackup-migration-parity/tasks.md b/docs/specs/008-npbackup-migration-parity/tasks.md new file mode 100644 index 0000000..508309d --- /dev/null +++ b/docs/specs/008-npbackup-migration-parity/tasks.md @@ -0,0 +1,104 @@ +--- +title: NPBackup migration parity tasks +doc_type: spec +artifact_type: tasks +status: active +owner: Auriora Team +last_reviewed: 2026-07-19 +--- + +# Tasks + +## Phase 1: Implement Migration Parity + +- [x] T001 Create and reconcile the migration-parity package. + - Depends on: Spec 007 implementation commit `433c0aa` + - Requirements: Requirement 1, Requirement 2, Requirement 3 + - Acceptance Criteria: all + - Properties: CP-001-CP-005 + - Acceptance: Safe host evidence, sequencing, implementation boundary, and + later operator gates are explicit and contain no secret values. + - Evidence: Spec Lifecycle Manager allocated `008`; the approved read-only + host inspection identified the active root cron, six sources, option and + retention shape, exclusion sources, and recent snapshot without changing + host state or revealing plaintext credentials. + - Evidence mode: artifact + +- [x] T002 Carry compression and filesystem-boundary options to Restic. + - Depends on: T001 + - Requirement: Requirement 1 + - Acceptance Criteria: AC1-AC4 + - Properties: CP-002, CP-003 + - Files: backup CLI/request/target/orchestrator paths, Restic adapter, tests + - Acceptance: Direct and selection backups propagate valid options; invalid + or conflicting options fail before execution; defaults remain compatible. + - Validation: Focused CLI, service, target, and Restic command tests. + - Evidence mode: implementation + + - Evidence: Implemented compression and one-filesystem propagation through direct CLI requests, selection-job metadata, BackupTarget, configuration, both orchestrator paths, and Restic argv. Adapter validation rejects invalid or conflicting values before subprocess execution; defaults emit no new arguments. Focused parity suite contribution passed within 99 tests on 2026-07-19. +- [x] T003 Persist and render schedule parity fields. + - Depends on: T002 + - Requirement: Requirement 2 + - Acceptance Criteria: AC1-AC4 + - Properties: CP-001, CP-004 + - Files: schedule CLI/renderers, tests, operator guide + - Acceptance: Create/edit/show/test and all renderers preserve tags, + exclusions, compression, and one-filesystem intent with safe quoting. + - Validation: Focused schedule tests and parser round trip. + - Evidence mode: implementation + + - Evidence: Schedule create/edit persist tags, exclusions, compression, and one-filesystem fields; list/show expose them; test validates the generated command; cron, systemd, and Windows render from the shared argument-safe builder. Durable backup and scheduling guides were promoted. Focused parity suite passed 99 tests on 2026-07-19. +- [x] T004 Checkpoint - Phase 1 parity ready for host staging. + - Depends on: T002, T003 + - Requirements: Requirement 1, Requirement 2, Requirement 3 AC1 + - Properties: CP-001-CP-005 + - Acceptance: Focused tests, CLI help, lifecycle checks, compile, docs, and + whitespace checks pass; host scheduler and credentials remain unchanged. + - Decision owner: project maintainer + - Evidence mode: validation + + - Evidence: Phase 1 checkpoint passed on 2026-07-19: 99 focused tests and the full normal profile (2,796 passed, one skipped, 57 deselected, 52.52% coverage) passed; compileall, git diff --check, zero-diagnostic Spec 008 lint, and zero-finding durable-guide Markdown checks passed. Read-only root-cron comparison still shows the 17:30 NPBackup job; no TimeLocker unit or /opt/timelocker installation exists, and no credential values were read or written. + +## Phase 2: Operator-Controlled Installation And Observation + +- [x] T005 Resolve the secure production repository and credential source. + - Depends on: T004 + - Requirement: Requirement 3 + - Acceptance: Exact URI and required environment values are supplied through + a root-only path without being printed or copied from masked ciphertext. + - Decision owner: operator + - Evidence mode: manual + + - Evidence: Operator-approved T005 completed on 2026-07-19: `/etc/timelocker/npbackup-migration.env` is root:root mode 0600 and byte-identical to the existing Restic service-account environment. It contains exactly the five expected non-empty Restic/AWS assignments and loads successfully as root; no values were emitted. Root's 17:30 NPBackup cron is unchanged, and no TimeLocker unit or `/opt/timelocker` installation exists. + - Status: D001 resolved; protected credential source ready. T006 privileged artifact installation remains separately gated. +- [~] T006 Install a committed root-owned TimeLocker artifact and attach read-only. + - Depends on: T005 and explicit privileged-install approval + - Requirement: Requirement 3 + - Acceptance: Root-owned versioned installation lists and restores existing + snapshot `8958659e`; NPBackup remains unchanged. + - Evidence mode: validation + + - Evidence: Operator authorized T006 on 2026-07-19. The validated Phase 1/T005 tree will be committed before building; only that commit may be installed. Repository access is limited to snapshot listing and a bounded restore, with NPBackup and scheduling unchanged. + - Status: Preparing an auditable commit and versioned root-owned installation; production backup and timer operations remain prohibited. +- [ ] T007 Stage, install, and observe a non-overlapping TimeLocker timer. + - Depends on: T006 and explicit timer-install approval + - Requirement: Requirement 3 + - Acceptance: Production-equivalent sources/options run successfully on the + scheduler and a subsequent restore passes; no retention deletion runs. + - Evidence mode: validation + +- [ ] T008 Checkpoint - Separate NPBackup cutover decision. + - Depends on: T007 + - Requirement: Requirement 3 + - Acceptance: Evidence supports a deliberate decision to retain, disable, or + roll back TimeLocker; changing root's NPBackup cron requires explicit approval. + - Decision owner: operator + - Evidence mode: manual + +## Rules Consulted + +Coding Standards (100), General Preferences (50), Operational Best Practices +(40), Planning Protocol (30), Testing Conventions (25), Documentation +Conventions (20), and Git Conventions (15). User approval on 2026-07-19 covers +Phase 1 implementation and the separate T005 credential copy. T006-T008 +privileged installation, scheduling, and cutover gates remain separate. diff --git a/docs/specs/008-npbackup-migration-parity/traceability.md b/docs/specs/008-npbackup-migration-parity/traceability.md new file mode 100644 index 0000000..9b65323 --- /dev/null +++ b/docs/specs/008-npbackup-migration-parity/traceability.md @@ -0,0 +1,52 @@ +--- +title: NPBackup migration parity traceability +doc_type: spec +artifact_type: traceability +status: active +owner: Auriora Team +last_reviewed: 2026-07-19 +--- + +# Traceability Matrix + +## Task To Context Matrix + +| Task ID | Requirements | Acceptance Criteria | Design Sections | Verification | Durable Targets | Open Decisions | +|---------|--------------|---------------------|-----------------|--------------|-----------------|----------------| +| T001 | Requirement 1, Requirement 2, Requirement 3 | all | Overview; Operational Considerations | host and lifecycle discovery | spec package | none | +| T002 | Requirement 1 | AC1-AC4 | Overview; Low-Level Design; Compatibility | CLI, service, target, and Restic tests | backup operations guide | none | +| T003 | Requirement 2 | AC1-AC4 | High-Level Design; Compatibility | schedule and renderer tests | scheduling guide | none | +| T004 | Requirement 1, Requirement 2, Requirement 3 | R1-R2 all; R3 AC1 | Validation And Failure Handling | Phase 1 checkpoint | both guides | none | +| T005 | Requirement 3 | AC2 | Security And Rollback | credential-path review | installation guidance | D001 resolved | +| T006 | Requirement 3 | AC2-AC3 | Operator Staging Design | version, list, and restore | installation and recovery guides | none | +| T007 | Requirement 3 | AC3-AC4 | Operator Staging Design | scheduled runs and restore | scheduling guide | D002 | +| T008 | Requirement 3 | AC4 | Security And Rollback | operator decision | scheduling guide | D002 | + +## Requirement To Delivery Matrix + +| Requirement | Priority | Acceptance Criteria | Design Sections | Tasks | Verification | Durable Targets | Coverage State | Residual Destination | +|-------------|----------|---------------------|-----------------|-------|--------------|-----------------|----------------|----------------------| +| Requirement 1 | must-have | AC1-AC4 | Overview; Low-Level Design | T002, T004 | focused and normal-profile backup tests | recovery operations guide | complete | none | +| Requirement 2 | must-have | AC1-AC4 | High-Level Design; Compatibility | T003, T004 | schedule and renderer tests | scheduling guide | complete | none | +| Requirement 3 | must-have | AC1-AC4 | Operational Considerations; Security And Rollback | T004-T008 | host comparison, restore, observation, decision | installation and scheduling guides | partial-blocking | T005-T008 | + +## Design To Implementation Matrix + +| Design Section | Requirements | Tasks | Interfaces Or Files | Verification | Coverage State | Residual Destination | +|----------------|--------------|-------|---------------------|--------------|----------------|----------------------| +| Overview and Low-Level Design | Requirement 1 | T002 | backup CLI, request, target, orchestrators, Restic adapter | focused and normal-profile backup tests | complete | none | +| High-Level Design and Compatibility | Requirement 2 | T003 | schedule commands, records, renderers | schedule tests and parser round trip | complete | none | +| Operational Considerations | Requirement 3 | T004-T008 | docs, root-owned installation, timer | host comparison, list, restore, observed runs | partial-blocking | T005-T008 | + +## Open Decision Impact + +| Decision ID | Blocks | Affected Requirements | Affected Tasks | Resolution Needed | +|-------------|--------|-----------------------|----------------|-------------------| +| D001 (resolved 2026-07-19) | none | Requirement 3 | T005-T006 | Operator approved root-owned mode-0600 `/etc/timelocker/npbackup-migration.env`; no values enter repository evidence. | +| D002 | production schedule and cutover | Requirement 3 | T007-T008 | Operator separately approves timer installation and NPBackup cutover. | + +## Open Gate + +Phase 1 and T005 are complete. T006-T008 still require privileged installation, +observation, and cutover approvals and cannot be inferred from the T005 +credential-copy approval. diff --git a/docs/specs/008-npbackup-migration-parity/verification.md b/docs/specs/008-npbackup-migration-parity/verification.md new file mode 100644 index 0000000..04a6a88 --- /dev/null +++ b/docs/specs/008-npbackup-migration-parity/verification.md @@ -0,0 +1,75 @@ +--- +title: NPBackup migration parity verification +doc_type: spec +artifact_type: verification +status: active +owner: Auriora Team +last_reviewed: 2026-07-19 +--- + +# Verification + +## Quality Gates + +| Gate | Status | Evidence | +|------|--------|----------| +| Package and traceability ready | passed | Zero Spec 008 lifecycle lint diagnostics; readiness selects T005 after T004. | +| Direct backup parity | passed | T002 focused CLI, target, and Restic tests. | +| Selection backup parity | passed | T002 handler metadata and orchestrator target tests. | +| Stored and rendered schedule parity | passed | T003 create/edit/show/list and cron/systemd/Windows tests. | +| Phase 1 host state unchanged | passed | Root cron still contains the 17:30 NPBackup job; no TimeLocker unit installed. | +| Durable guidance promoted | passed | Both current guides pass bounded Markdown checks with zero findings. | +| Production attachment and restore | blocked | T005 credential decision and T006 approval required. | +| Scheduled observation and cutover | blocked | T007-T008 explicit operator gates required. | + +## Baseline Evidence + +- Spec 007 implementation is committed at `433c0aa` with 2,787 normal-profile + tests passing and 52.38% coverage. +- Root cron runs NPBackup at 17:30 over six protected sources. The most recent + verified snapshot is `8958659e` from 2026-07-18. +- NPBackup configuration is valid and credentials remain encrypted; no service, + crontab, repository, or credential state changed during discovery. + +## Requirement Coverage + +| Requirement | Acceptance criteria covered | Evidence | Residual risk | +|-------------|-----------------------------|----------|---------------| +| Requirement 1 | AC1-AC4 | T002 focused tests and 2,796-test normal profile | none for Phase 1 | +| Requirement 2 | AC1-AC4 | T003 stored schedule and renderer tests | none for Phase 1 | +| Requirement 3 | AC1; T005 prerequisite | Phase 1 host comparison and protected credential-source validation | AC2-AC4 remain gated in T006-T008. | + +## Evidence Log + +| Date | Evidence | Result | Notes | +|------|----------|--------|-------| +| 2026-07-19 | Spec 007 commit and full normal-profile test evidence | pass | Dependency commit `433c0aa`; 2,787 passed. | +| 2026-07-19 | Read-only masked NPBackup configuration, root cron, journal, and Restic snapshot inspection | pass | Root job remains `30 17 * * *`; snapshot `8958659e`; no plaintext secret captured. | +| 2026-07-19 | Focused Phase 1 parity suite | pass | 99 tests passed in 14.29 seconds with coverage disabled for the focused run. | +| 2026-07-19 | Full normal profile | pass | `python -m pytest -m "not performance and not stress and not minio"` exited 0: 2,796 passed; 52.52% coverage in 953.40 seconds. | +| 2026-07-19 | Compile and workspace hygiene | pass | `compileall` and `git diff --check` completed successfully. | +| 2026-07-19 | Current durable-guide Markdown checks | pass | `check_markdown_set`: two documents checked, zero findings. | +| 2026-07-19 | Spec 008 lifecycle checks | pass | `lint_spec_package`: error=0, warn=0; `task_state_audit`: error=0, warn=0. | +| 2026-07-19 | Post-implementation host comparison | pass | Root crontab is unchanged with NPBackup at 17:30; no installed TimeLocker unit or `/opt/timelocker` path. | +| 2026-07-19 | T005 protected credential-source installation | pass | Root-only `/etc/timelocker/npbackup-migration.env` is a mode-0600, root-owned, byte-identical copy containing exactly the five expected non-empty assignments; values were not emitted. | +| 2026-07-19 | T005 post-install host comparison | pass | Root loaded all required variables; NPBackup cron remained unchanged, with no TimeLocker unit or `/opt/timelocker` installation. | + +## Residual Risks + +- NPBackup and TimeLocker pattern semantics may differ; the effective expanded + exclusion set needs bounded comparison before production backup. +- Credential values now exist in a second protected location and must be + rotated when the source Restic service-account environment changes. +- Same-repository overlap can lock or duplicate work; timers must not overlap. +- Retention enforcement can delete snapshots and remains simulation-only until + separately reviewed. +- Root installation and cutover remain external mutations requiring approval. + +## Readiness Decision + +- **Phase 1 ready for host staging:** yes +- **Credential source ready for production attachment:** yes; T005 passed. +- **Ready for production repository attachment:** no; T006 still requires a + committed root-owned artifact and explicit privileged-install approval. +- **Ready for timer installation or NPBackup cutover:** no; T006-T008 and + their explicit approvals remain pending. diff --git a/docs/specs/README.md b/docs/specs/README.md index 0780efb..cb8ea69 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -19,14 +19,18 @@ accepted content has been promoted and the package is closed. — active package for restoring trustworthy CI, stabilizing release signals, validating `v0.9.1` artifacts, rehearsing release operations, and promoting durable release guidance. +- [`008-npbackup-migration-parity`](./008-npbackup-migration-parity/requirements.md) + — active package for preserving the existing root-owned NPBackup job's + backup semantics before a staged TimeLocker installation and cutover. ## Active-Package Sequencing -Spec 007 is the only active package. GitHub milestone `v0.9.1` and issue #68 -provide external scheduling and stress-test evidence; the spec remains -authoritative for implementation sequencing, acceptance, validation, and -promotion. Closed package identity and recovery commits remain recorded in -`docs/history/` rather than kept in this active documentation path. +Spec 007 owns release readiness and remains at the separate human release and +closure gate. Spec 008 depends on Spec 007 implementation commit `433c0aa` and +owns only NPBackup migration parity and operator staging. The packages may run +concurrently because Spec 008 does not approve a release, publication, service +installation, or NPBackup cutover. Closed package identity and recovery commits +remain recorded in `docs/history/` rather than kept in this active path. ## When a Spec Is Needed diff --git a/src/TimeLocker/backup_target.py b/src/TimeLocker/backup_target.py index db1c9f2..9e9f65c 100644 --- a/src/TimeLocker/backup_target.py +++ b/src/TimeLocker/backup_target.py @@ -35,6 +35,8 @@ def __init__(self, name: str = None, template_id: Optional[str] = None, template_overrides: Optional[Dict[str, Any]] = None, + compression: Optional[str] = None, + one_file_system: bool = False, **kwargs): """ Initialize a backup target @@ -45,6 +47,8 @@ def __init__(self, name: Optional name for the backup target (for backward compatibility) template_id: Optional selection template ID to use template_overrides: Optional overrides for template configuration + compression: Optional Restic compression mode (auto, off, or max) + one_file_system: Whether backup traversal must stay on one filesystem **kwargs: Additional parameters for backward compatibility """ # Handle backward compatibility for old API @@ -84,6 +88,8 @@ def __init__(self, self.selection = selection self.tags = tags or [] self.name = name + self.compression = compression + self.one_file_system = one_file_system # New selection management integration self.template_id = template_id @@ -167,4 +173,3 @@ def get_selection_info(self) -> Dict[str, Any]: info['exclude_pattern_count'] = len(getattr(self.selection, 'exclude_patterns', [])) return info - diff --git a/src/TimeLocker/cli_modules/commands/backup.py b/src/TimeLocker/cli_modules/commands/backup.py index 1ee1d6d..e22f17d 100644 --- a/src/TimeLocker/cli_modules/commands/backup.py +++ b/src/TimeLocker/cli_modules/commands/backup.py @@ -105,6 +105,15 @@ def backup_create( exclude: Annotated[Optional[List[str]], typer.Option("--exclude", "-e", help="Exclude pattern")] = None, include: Annotated[Optional[List[str]], typer.Option("--include", "-i", help="Include pattern")] = None, tags: Annotated[Optional[List[str]], typer.Option("--tags", help="Backup tags")] = None, + compression: Annotated[Optional[str], typer.Option( + "--compression", + help="Restic compression mode: auto, off, or max", + click_type=click.Choice(["auto", "off", "max"], case_sensitive=False), + )] = None, + one_file_system: Annotated[bool, typer.Option( + "--one-file-system/--cross-filesystems", + help="Do not cross filesystem boundaries while backing up", + )] = False, dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would be backed up without actually performing backup")] = False, config_dir: Annotated[Optional[Path], typer.Option("--config-dir", help="Configuration directory")] = None, verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, @@ -236,7 +245,9 @@ def backup_create( 'notify_on_success': True, 'notify_on_failure': True, 'notifications_enabled': True, - 'priority': 0 + 'priority': 0, + 'compression': compression, + 'one_file_system': one_file_system, } try: @@ -410,6 +421,8 @@ def backup_create( tags=tags or [], include_patterns=include or [], exclude_patterns=exclude or [], + compression=compression, + one_file_system=one_file_system, dry_run=dry_run ) logger.debug("CLIBackupRequest created successfully") diff --git a/src/TimeLocker/cli_modules/commands/schedule.py b/src/TimeLocker/cli_modules/commands/schedule.py index 79eab81..33681d0 100644 --- a/src/TimeLocker/cli_modules/commands/schedule.py +++ b/src/TimeLocker/cli_modules/commands/schedule.py @@ -102,6 +102,18 @@ def _build_backup_command(schedule: Dict[str, Any], config_dir: Optional[Path] = argv.extend(str(source) for source in sources) argv.extend(['--repository', str(repository)]) + for tag in schedule.get('tags') or []: + argv.extend(['--tags', str(tag)]) + for pattern in schedule.get('exclude_patterns') or []: + argv.extend(['--exclude', str(pattern)]) + compression = schedule.get('compression') + if compression: + if compression not in {'auto', 'off', 'max'}: + raise ValueError(f"Unsupported Restic compression mode: {compression}") + argv.extend(['--compression', str(compression)]) + if schedule.get('one_file_system', False): + argv.append('--one-file-system') + effective_config_dir = schedule.get('config_dir') or config_dir if effective_config_dir: argv.extend(['--config-dir', str(Path(effective_config_dir).resolve())]) @@ -126,14 +138,24 @@ def _format_schedule_table(schedules: Dict[str, Dict[str, Any]]) -> Table: table.add_column("Frequency", style="yellow") table.add_column("Next Run", style="white") table.add_column("Enabled", style="magenta") + table.add_column("Backup Options", style="blue") for name, schedule in schedules.items(): enabled = "✓" if schedule.get('enabled', False) else "✗" next_run = schedule.get('next_run', 'N/A') frequency = schedule.get('frequency', 'N/A') repository = schedule.get('repository', 'N/A') - - table.add_row(name, repository, frequency, next_run, enabled) + options = [] + if schedule.get('compression'): + options.append(f"compression={schedule['compression']}") + if schedule.get('one_file_system', False): + options.append("one-filesystem") + if schedule.get('tags'): + options.append(f"tags={len(schedule['tags'])}") + if schedule.get('exclude_patterns'): + options.append(f"excludes={len(schedule['exclude_patterns'])}") + + table.add_row(name, repository, frequency, next_run, enabled, ", ".join(options) or "default") return table @@ -204,6 +226,10 @@ def _interactive_schedule_configuration(config_dir: Optional[Path] = None) -> Di "sources": sources, "environment_file": None, "system": False, + "tags": [], + "exclude_patterns": [], + "compression": None, + "one_file_system": False, "config_dir": str(config_dir.expanduser().resolve()) if config_dir else None, "frequency": frequency, "cron_expression": cron_expression, @@ -350,6 +376,17 @@ def schedule_create( sources: Annotated[Optional[List[Path]], typer.Option("--source", help="Source path (repeatable)")] = None, environment_file: Annotated[Optional[Path], typer.Option("--environment-file", help="Protected environment file to reference, not copy")] = None, system: Annotated[bool, typer.Option("--system/--user", help="Generate a system-level or user-level schedule")] = False, + tags: Annotated[Optional[List[str]], typer.Option("--tags", help="Backup tag (repeatable)")] = None, + exclude_patterns: Annotated[Optional[List[str]], typer.Option("--exclude", help="Backup exclusion pattern (repeatable)")] = None, + compression: Annotated[Optional[str], typer.Option( + "--compression", + help="Restic compression mode: auto, off, or max", + click_type=click.Choice(["auto", "off", "max"], case_sensitive=False), + )] = None, + one_file_system: Annotated[bool, typer.Option( + "--one-file-system/--cross-filesystems", + help="Do not cross filesystem boundaries while backing up", + )] = False, frequency: Annotated[Optional[str], typer.Option("--frequency", "-f", help="Frequency (hourly, daily, weekly, monthly)")] = None, cron: Annotated[Optional[str], typer.Option("--cron", help="Custom cron expression")] = None, enabled: Annotated[bool, typer.Option("--enabled/--disabled", help="Enable schedule immediately")] = True, @@ -408,6 +445,10 @@ def schedule_create( "sources": [str(source.expanduser().resolve()) for source in (sources or [])], "environment_file": str(environment_file.expanduser().resolve()) if environment_file else None, "system": system, + "tags": tags or [], + "exclude_patterns": exclude_patterns or [], + "compression": compression, + "one_file_system": one_file_system, "config_dir": str(config_dir.expanduser().resolve()) if config_dir else None, "frequency": frequency or "custom", "cron_expression": cron_expression, @@ -490,6 +531,10 @@ def schedule_show( f"[bold]Repository:[/bold] {schedule.get('repository', 'N/A')}\n" f"[bold]Selection:[/bold] {schedule.get('selection', 'N/A')}\n" f"[bold]Sources:[/bold] {', '.join(schedule.get('sources', [])) or 'N/A'}\n" + f"[bold]Tags:[/bold] {', '.join(schedule.get('tags', [])) or 'N/A'}\n" + f"[bold]Exclusions:[/bold] {', '.join(schedule.get('exclude_patterns', [])) or 'N/A'}\n" + f"[bold]Compression:[/bold] {schedule.get('compression') or 'default'}\n" + f"[bold]One Filesystem:[/bold] {'Yes' if schedule.get('one_file_system', False) else 'No'}\n" f"[bold]Frequency:[/bold] {schedule.get('frequency', 'N/A')}\n" f"[bold]Cron Expression:[/bold] {schedule.get('cron_expression', 'N/A')}\n" f"[bold]Status:[/bold] {enabled_status}\n" @@ -513,6 +558,17 @@ def schedule_edit( sources: Annotated[Optional[List[Path]], typer.Option("--source", help="Replace selection with source path(s)")] = None, environment_file: Annotated[Optional[Path], typer.Option("--environment-file", help="New protected environment-file reference")] = None, system: Annotated[Optional[bool], typer.Option("--system/--user", help="Generate a system-level or user-level schedule")] = None, + tags: Annotated[Optional[List[str]], typer.Option("--tags", help="Replace backup tags (repeatable)")] = None, + exclude_patterns: Annotated[Optional[List[str]], typer.Option("--exclude", help="Replace exclusion patterns (repeatable)")] = None, + compression: Annotated[Optional[str], typer.Option( + "--compression", + help="Replace Restic compression mode", + click_type=click.Choice(["auto", "off", "max"], case_sensitive=False), + )] = None, + one_file_system: Annotated[Optional[bool], typer.Option( + "--one-file-system/--cross-filesystems", + help="Set filesystem traversal behavior", + )] = None, frequency: Annotated[Optional[str], typer.Option("--frequency", "-f", help="New frequency")] = None, cron: Annotated[Optional[str], typer.Option("--cron", help="New cron expression")] = None, enabled: Annotated[Optional[bool], typer.Option("--enabled/--disabled", help="Enable/disable schedule")] = None, @@ -544,6 +600,14 @@ def schedule_edit( schedule['environment_file'] = str(environment_file.expanduser().resolve()) if system is not None: schedule['system'] = system + if tags is not None: + schedule['tags'] = tags + if exclude_patterns is not None: + schedule['exclude_patterns'] = exclude_patterns + if compression is not None: + schedule['compression'] = compression + if one_file_system is not None: + schedule['one_file_system'] = one_file_system if frequency is not None: schedule['frequency'] = frequency if cron is not None: diff --git a/src/TimeLocker/cli_services.py b/src/TimeLocker/cli_services.py index 9ae54aa..355d817 100644 --- a/src/TimeLocker/cli_services.py +++ b/src/TimeLocker/cli_services.py @@ -185,6 +185,8 @@ class CLIBackupRequest: tags: List[str] = None include_patterns: List[str] = None exclude_patterns: List[str] = None + compression: Optional[str] = None + one_file_system: bool = False dry_run: bool = False def __post_init__(self): @@ -1126,7 +1128,9 @@ def execute_backup_from_cli(self, request: CLIBackupRequest) -> BackupResult: 'name': request.target_name, 'paths': [str(p) for p in request.sources], 'include_patterns': request.include_patterns, - 'exclude_patterns': request.exclude_patterns + 'exclude_patterns': request.exclude_patterns, + 'compression': request.compression, + 'one_file_system': request.one_file_system, } self._config_service.add_backup_target(target_config) else: @@ -1169,7 +1173,9 @@ def _execute_adhoc_backup(self, request: CLIBackupRequest, repository_uri: str, 'name': target_name, 'paths': [str(p) for p in request.sources], 'include_patterns': request.include_patterns, - 'exclude_patterns': request.exclude_patterns + 'exclude_patterns': request.exclude_patterns, + 'compression': request.compression, + 'one_file_system': request.one_file_system, } # Add to configuration temporarily diff --git a/src/TimeLocker/config/configuration_schema.py b/src/TimeLocker/config/configuration_schema.py index 166dc63..2b00d93 100644 --- a/src/TimeLocker/config/configuration_schema.py +++ b/src/TimeLocker/config/configuration_schema.py @@ -222,6 +222,8 @@ class BackupTargetConfig: include_patterns: List[str] = field(default_factory=list) exclude_files: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list) + compression: Optional[str] = None + one_file_system: bool = False schedule: Optional[str] = None # cron expression retention_policy: Optional[Dict[str, int]] = None pre_backup_script: Optional[str] = None diff --git a/src/TimeLocker/restic/restic_repository.py b/src/TimeLocker/restic/restic_repository.py index 1a30966..625d1d3 100644 --- a/src/TimeLocker/restic/restic_repository.py +++ b/src/TimeLocker/restic/restic_repository.py @@ -321,6 +321,25 @@ def backup_target(self, targets: List[BackupTarget], tags: Optional[List[str]] = for target in targets: target.validate() + compression_values = { + target.compression for target in targets if target.compression is not None + } + if not compression_values.issubset({'auto', 'off', 'max'}): + invalid = sorted(compression_values - {'auto', 'off', 'max'}) + raise RepositoryError( + f"Unsupported Restic compression mode: {', '.join(invalid)}" + ) + if len(compression_values) > 1: + raise RepositoryError( + "Backup targets specify conflicting Restic compression modes" + ) + + filesystem_values = {target.one_file_system for target in targets} + if len(filesystem_values) > 1: + raise RepositoryError( + "Backup targets specify conflicting filesystem traversal modes" + ) + # Collect all paths to backup and build command arguments all_paths = [] all_tags = set(tags or []) @@ -336,6 +355,10 @@ def backup_target(self, targets: List[BackupTarget], tags: Optional[List[str]] = # Build backup command using the existing command builder pattern backup_command = self._command.command("backup") + if compression_values: + backup_command.param("compression", next(iter(compression_values))) + if filesystem_values == {True}: + backup_command.param("one-file-system") # Add exclude patterns from all targets for target in targets: diff --git a/src/TimeLocker/services/backup_orchestrator.py b/src/TimeLocker/services/backup_orchestrator.py index 96028ae..370e47c 100644 --- a/src/TimeLocker/services/backup_orchestrator.py +++ b/src/TimeLocker/services/backup_orchestrator.py @@ -872,10 +872,13 @@ def _create_backup_targets_from_job(self, backup_job: BackupJob) -> List[BackupT for pattern in backup_job.include_patterns: selection.add_pattern(pattern, SelectionType.INCLUDE) + cli_options = backup_job.config.metadata.get('cli_options', {}) target = BackupTarget( selection=selection, name=f"job-{backup_job.config.job_id}", - tags=backup_job.config.tags + tags=backup_job.config.tags, + compression=cli_options.get('compression'), + one_file_system=bool(cli_options.get('one_file_system', False)), ) targets.append(target) @@ -1146,7 +1149,9 @@ def _get_backup_targets(self, target_names: List[str]) -> List[BackupTarget]: target = BackupTarget( selection=selection, name=target_config['name'], - tags=target_config.get('tags', []) + tags=target_config.get('tags', []), + compression=target_config.get('compression'), + one_file_system=bool(target_config.get('one_file_system', False)), ) logger.debug(f"BackupTarget created successfully for '{target_name}'") diff --git a/tests/TimeLocker/backup/test_backup_operations.py b/tests/TimeLocker/backup/test_backup_operations.py index 48c64de..4e9efdf 100644 --- a/tests/TimeLocker/backup/test_backup_operations.py +++ b/tests/TimeLocker/backup/test_backup_operations.py @@ -123,6 +123,64 @@ def test_backup_target_success(self, mock_subprocess, mock_verify): assert "--tag" in command_list assert str(self.source_path) in command_list + @patch('TimeLocker.restic.restic_repository.ResticRepository._verify_restic_executable') + @patch('subprocess.run') + @pytest.mark.backup + @pytest.mark.filesystem + @pytest.mark.unit + def test_backup_target_emits_migration_parity_options(self, mock_subprocess, mock_verify): + """Restic receives explicit compression and traversal options.""" + mock_verify.return_value = "0.18.0" + mock_subprocess.return_value = Mock( + stdout=json.dumps({"message_type": "summary", "snapshot_id": "parity123"}), + returncode=0, + ) + repository = LocalResticRepository( + location=str(self.repo_path), + password="test_password", + ) + selection = FileSelection() + selection.add_path(self.source_path, SelectionType.INCLUDE) + + repository.backup_target([BackupTarget( + selection=selection, + compression="max", + one_file_system=True, + )]) + + command = mock_subprocess.call_args.args[0] + assert command[command.index("--compression") + 1] == "max" + assert command.count("--one-file-system") == 1 + + @patch('TimeLocker.restic.restic_repository.ResticRepository._verify_restic_executable') + @pytest.mark.backup + @pytest.mark.filesystem + @pytest.mark.unit + def test_backup_target_rejects_invalid_or_conflicting_options(self, mock_verify): + """Programmatic callers cannot bypass invocation-wide option checks.""" + mock_verify.return_value = "0.18.0" + repository = LocalResticRepository( + location=str(self.repo_path), + password="test_password", + ) + selection = FileSelection() + selection.add_path(self.source_path, SelectionType.INCLUDE) + + with pytest.raises(RepositoryError, match="Unsupported Restic compression"): + repository.backup_target([BackupTarget(selection, compression="gzip")]) + + with pytest.raises(RepositoryError, match="conflicting Restic compression"): + repository.backup_target([ + BackupTarget(selection, compression="auto"), + BackupTarget(selection, compression="max"), + ]) + + with pytest.raises(RepositoryError, match="conflicting filesystem"): + repository.backup_target([ + BackupTarget(selection, one_file_system=True), + BackupTarget(selection, one_file_system=False), + ]) + @patch('TimeLocker.restic.restic_repository.ResticRepository._verify_restic_executable') @patch('subprocess.run') @pytest.mark.backup diff --git a/tests/TimeLocker/backup/test_target.py b/tests/TimeLocker/backup/test_target.py index 4c68373..565d17f 100644 --- a/tests/TimeLocker/backup/test_target.py +++ b/tests/TimeLocker/backup/test_target.py @@ -41,6 +41,21 @@ def test_create_backup_target_with_tags(selection): target = BackupTarget(selection, tags=tags) assert target.tags == tags + +@pytest.mark.backup +@pytest.mark.filesystem +@pytest.mark.unit +def test_backup_target_carries_restic_execution_options(selection): + """Execution options remain typed metadata until the Restic boundary.""" + target = BackupTarget( + selection, + compression="max", + one_file_system=True, + ) + + assert target.compression == "max" + assert target.one_file_system is True + @pytest.mark.backup @pytest.mark.filesystem @pytest.mark.unit diff --git a/tests/TimeLocker/cli/test_backup_commands.py b/tests/TimeLocker/cli/test_backup_commands.py index ab7ac8c..e8cd129 100644 --- a/tests/TimeLocker/cli/test_backup_commands.py +++ b/tests/TimeLocker/cli/test_backup_commands.py @@ -237,6 +237,51 @@ def test_backup_create_with_sources(self, mock_service_manager: Mock) -> None: # TODO: Fix mock setup to properly simulate successful backup execution assert result.exit_code in [0, 1] + @pytest.mark.unit + @patch('TimeLocker.cli_modules.commands.backup._get_service_manager_for_command') + def test_backup_create_propagates_execution_options( + self, mock_get_manager: Mock + ) -> None: + """Direct backup requests retain reviewed Restic execution options.""" + manager = Mock() + manager.execute_backup.return_value = Mock( + is_successful=True, + files_processed=1, + bytes_processed=1, + duration=0.1, + snapshot_id="snapshot123", + warnings=[], + ) + mock_get_manager.return_value = manager + + with tempfile.TemporaryDirectory() as temp_dir: + result = runner.invoke(app, [ + "backup", "create", temp_dir, + "--compression", "max", + "--one-file-system", + "--dry-run", + ]) + + assert result.exit_code == 0, (combined_output(result), result.exception) + request = manager.execute_backup.call_args.args[0] + assert request.compression == "max" + assert request.one_file_system is True + + @pytest.mark.unit + @patch('TimeLocker.cli_modules.commands.backup._get_service_manager_for_command') + def test_backup_create_rejects_invalid_compression_before_services( + self, mock_get_manager: Mock + ) -> None: + """CLI validation prevents invalid compression reaching repositories.""" + result = runner.invoke(app, [ + "backup", "create", ".", + "--compression", "gzip", + "--dry-run", + ]) + + assert result.exit_code == 2 + mock_get_manager.assert_not_called() + @pytest.mark.unit def test_backup_create_parameter_validation(self) -> None: """Test backup create parameter validation.""" diff --git a/tests/TimeLocker/cli/test_backup_data_selection_integration.py b/tests/TimeLocker/cli/test_backup_data_selection_integration.py index efb743f..d72e727 100644 --- a/tests/TimeLocker/cli/test_backup_data_selection_integration.py +++ b/tests/TimeLocker/cli/test_backup_data_selection_integration.py @@ -206,13 +206,17 @@ def test_backup_create_passes_cli_options( result = runner.invoke(app, [ "backup", "create", "--selection", "docs", - "--repository", "test-repo" + "--repository", "test-repo", + "--compression", "max", + "--one-file-system", ]) assert_exit_code(result, 0) cli_options = service_manager.run_selection_backup.call_args[1]["cli_options"] assert cli_options["tool_type"] == "restic" assert cli_options["max_retries"] == 3 + assert cli_options["compression"] == "max" + assert cli_options["one_file_system"] is True class TestBackupSelectionErrors: """Test error handling paths for selection-driven backups.""" diff --git a/tests/TimeLocker/cli/test_schedule_commands.py b/tests/TimeLocker/cli/test_schedule_commands.py index a2554d3..11a770f 100644 --- a/tests/TimeLocker/cli/test_schedule_commands.py +++ b/tests/TimeLocker/cli/test_schedule_commands.py @@ -4,16 +4,19 @@ Tests schedule command parsing, parameter validation, help output, and error handling. """ -import pytest +import json import shlex from pathlib import Path from unittest.mock import Mock, patch +import pytest + from TimeLocker.cli import app from TimeLocker.cli_modules.commands.schedule import ( _build_backup_command, _generate_cron_script, _generate_systemd_script, + _generate_windows_script, ) from tests.TimeLocker.cli.test_utils import ( get_cli_runner, combined_output, assert_success, assert_exit_code, assert_help_quality @@ -112,6 +115,103 @@ def test_generated_backup_command_parses_current_cli(self, tmp_path): assert "--non-interactive" not in argv assert argv[-2:] == ["--config-dir", str((tmp_path / "config").resolve())] + @pytest.mark.unit + def test_generated_backup_command_preserves_migration_parity_fields(self, tmp_path): + schedule = { + "repository": "pilot repo", + "sources": [str(tmp_path / "source with spaces")], + "selection": None, + "tags": ["Bruce-5560", "tag with spaces"], + "exclude_patterns": ["cache/*", "name;still-an-argument"], + "compression": "max", + "one_file_system": True, + } + + command = _build_backup_command(schedule) + argv = shlex.split(command) + + assert argv.count("--tags") == 2 + assert argv.count("--exclude") == 2 + assert argv[argv.index("--compression") + 1] == "max" + assert argv.count("--one-file-system") == 1 + assert "tag with spaces" in argv + assert "name;still-an-argument" in argv + + cron = _generate_cron_script("pilot", schedule) + service, _ = _generate_systemd_script("pilot", { + **schedule, + "cron_expression": "0 2 * * *", + }) + windows = _generate_windows_script("pilot", { + **schedule, + "cron_expression": "0 2 * * *", + }) + for rendered in (cron, service, windows): + assert "--compression max" in rendered + assert "--one-file-system" in rendered + + @pytest.mark.unit + def test_generated_backup_command_preserves_legacy_defaults(self, tmp_path): + argv = shlex.split(_build_backup_command({ + "repository": "pilot-repo", + "sources": [str(tmp_path)], + "selection": None, + })) + + assert "--compression" not in argv + assert "--one-file-system" not in argv + assert "--tags" not in argv + assert "--exclude" not in argv + + @pytest.mark.unit + @patch('TimeLocker.cli_modules.commands.schedule._get_schedule_storage_dir') + def test_schedule_create_edit_show_and_list_parity_fields( + self, mock_storage_dir: Mock, tmp_path + ): + """Stored schedule commands expose and update all migration fields.""" + mock_storage_dir.return_value = tmp_path + source = tmp_path / "source" + source.mkdir() + + create = runner.invoke(app, [ + "schedule", "create", "migration", + "--repository", "pilot-repo", + "--source", str(source), + "--frequency", "daily", + "--tags", "Bruce-5560", + "--exclude", "cache/*", + "--compression", "max", + "--one-file-system", + ]) + assert_success(create) + + stored = json.loads((tmp_path / "schedules.json").read_text())['migration'] + assert stored['tags'] == ['Bruce-5560'] + assert stored['exclude_patterns'] == ['cache/*'] + assert stored['compression'] == 'max' + assert stored['one_file_system'] is True + + edit = runner.invoke(app, [ + "schedule", "edit", "migration", + "--tags", "replacement", + "--exclude", "*.tmp", + "--compression", "off", + "--cross-filesystems", + ]) + assert_success(edit) + stored = json.loads((tmp_path / "schedules.json").read_text())['migration'] + assert stored['tags'] == ['replacement'] + assert stored['exclude_patterns'] == ['*.tmp'] + assert stored['compression'] == 'off' + assert stored['one_file_system'] is False + + shown = runner.invoke(app, ["schedule", "show", "migration"]) + listed = runner.invoke(app, ["schedule", "list"]) + assert_success(shown) + assert_success(listed) + assert "Compression:" in combined_output(shown) + assert "compression=off" in combined_output(listed) + @pytest.mark.unit def test_linux_renderers_reference_environment_without_secret_values(self, tmp_path): env_file = tmp_path / "pilot.env" diff --git a/tests/TimeLocker/services/test_backup_orchestrator_job_execution.py b/tests/TimeLocker/services/test_backup_orchestrator_job_execution.py index a674659..f9debaa 100644 --- a/tests/TimeLocker/services/test_backup_orchestrator_job_execution.py +++ b/tests/TimeLocker/services/test_backup_orchestrator_job_execution.py @@ -139,6 +139,23 @@ def test_prepare_backup_job_with_targets(self, orchestrator, sample_job_config): assert '/tmp/test-data' in backup_job.source_paths assert '*.tmp' in backup_job.exclude_patterns + + def test_job_target_carries_selection_cli_execution_options( + self, orchestrator, sample_job_config + ): + """Selection-job metadata reaches the concrete backup target.""" + sample_job_config.metadata = { + 'cli_options': { + 'compression': 'max', + 'one_file_system': True, + } + } + backup_job = orchestrator.prepare_backup_job(sample_job_config) + + target = orchestrator._create_backup_targets_from_job(backup_job)[0] + + assert target.compression == 'max' + assert target.one_file_system is True def test_queue_backup_job(self, orchestrator, sample_job_config): """Test queueing a backup job""" From 6896c8dc8692857ff4e6a07731fa9ce962ed3289 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:15:35 +0100 Subject: [PATCH 18/72] fix(restore): preserve selective recovery paths --- .../008-npbackup-migration-parity/tasks.md | 10 +++++++++- .../verification.md | 14 +++++++++---- src/TimeLocker/backup_repository.py | 4 ++++ src/TimeLocker/backup_snapshot.py | 10 +++++++++- src/TimeLocker/recovery_orchestrator.py | 1 - src/TimeLocker/restic/restic_repository.py | 6 ++++++ src/TimeLocker/restore_manager.py | 2 ++ tests/TimeLocker/backup/mock_repository.py | 2 ++ tests/TimeLocker/backup/test_repository.py | 10 +++++++++- .../recovery/mock_recovery_repository.py | 4 ++++ .../recovery/test_recovery_orchestrator.py | 5 +++++ .../recovery/test_restore_manager.py | 2 ++ .../restic/test_restic_repository.py | 20 +++++++++++++++++++ 13 files changed, 82 insertions(+), 8 deletions(-) diff --git a/docs/specs/008-npbackup-migration-parity/tasks.md b/docs/specs/008-npbackup-migration-parity/tasks.md index 508309d..7d601cb 100644 --- a/docs/specs/008-npbackup-migration-parity/tasks.md +++ b/docs/specs/008-npbackup-migration-parity/tasks.md @@ -79,7 +79,15 @@ last_reviewed: 2026-07-19 - Evidence mode: validation - Evidence: Operator authorized T006 on 2026-07-19. The validated Phase 1/T005 tree will be committed before building; only that commit may be installed. Repository access is limited to snapshot listing and a bounded restore, with NPBackup and scheduling unchanged. - - Status: Preparing an auditable commit and versioned root-owned installation; production backup and timer operations remain prohibited. + - Evidence: Phase 1 was committed as `2c93709`; its root-owned release listed + the protected repository and found snapshot `8958659e`. The first bounded + restore exposed two recovery defects before Restic ran: selective validation + supplied an unsupported selection name, and include/exclude paths were not + propagated to the backend. The repair removes the invalid field and carries + bounded paths through the restore interfaces to repeated Restic arguments; + 64 focused recovery and adapter tests pass. + - Status: Preparing a replacement committed artifact for the live bounded + restore; production backup and timer operations remain prohibited. - [ ] T007 Stage, install, and observe a non-overlapping TimeLocker timer. - Depends on: T006 and explicit timer-install approval - Requirement: Requirement 3 diff --git a/docs/specs/008-npbackup-migration-parity/verification.md b/docs/specs/008-npbackup-migration-parity/verification.md index 04a6a88..e6a1034 100644 --- a/docs/specs/008-npbackup-migration-parity/verification.md +++ b/docs/specs/008-npbackup-migration-parity/verification.md @@ -19,7 +19,7 @@ last_reviewed: 2026-07-19 | Stored and rendered schedule parity | passed | T003 create/edit/show/list and cron/systemd/Windows tests. | | Phase 1 host state unchanged | passed | Root cron still contains the 17:30 NPBackup job; no TimeLocker unit installed. | | Durable guidance promoted | passed | Both current guides pass bounded Markdown checks with zero findings. | -| Production attachment and restore | blocked | T005 credential decision and T006 approval required. | +| Production attachment and restore | in progress | Approved T006 release listed snapshot `8958659e`; bounded restore exposed and now has a focused-tested selective-restore repair awaiting replacement install. | | Scheduled observation and cutover | blocked | T007-T008 explicit operator gates required. | ## Baseline Evidence @@ -53,6 +53,9 @@ last_reviewed: 2026-07-19 | 2026-07-19 | Post-implementation host comparison | pass | Root crontab is unchanged with NPBackup at 17:30; no installed TimeLocker unit or `/opt/timelocker` path. | | 2026-07-19 | T005 protected credential-source installation | pass | Root-only `/etc/timelocker/npbackup-migration.env` is a mode-0600, root-owned, byte-identical copy containing exactly the five expected non-empty assignments; values were not emitted. | | 2026-07-19 | T005 post-install host comparison | pass | Root loaded all required variables; NPBackup cron remained unchanged, with no TimeLocker unit or `/opt/timelocker` installation. | +| 2026-07-19 | First committed T006 artifact and repository attachment | partial | Root-owned release from `2c93709` reported version 0.9.1 and listed snapshot `8958659e` through the protected named repository without changing NPBackup. | +| 2026-07-19 | First T006 bounded restore | fail-safe | Recovery stopped before invoking Restic because selective validation supplied an unsupported constructor field; review also found bounded include/exclude paths were dropped before the backend. No full restore or backup ran. | +| 2026-07-19 | Selective-restore repair focused suite | pass | 64 adapter, restore-manager, orchestrator, and repository tests passed with coverage disabled; tests verify include/exclude propagation and completed selective orchestration. | ## Residual Risks @@ -63,13 +66,16 @@ last_reviewed: 2026-07-19 - Same-repository overlap can lock or duplicate work; timers must not overlap. - Retention enforcement can delete snapshots and remains simulation-only until separately reviewed. -- Root installation and cutover remain external mutations requiring approval. +- The first installed T006 artifact is retained for rollback but is not accepted; + a replacement committed artifact and successful bounded restore are required. +- Timer installation and cutover remain external mutations requiring approval. ## Readiness Decision - **Phase 1 ready for host staging:** yes - **Credential source ready for production attachment:** yes; T005 passed. -- **Ready for production repository attachment:** no; T006 still requires a - committed root-owned artifact and explicit privileged-install approval. +- **Ready for production repository attachment:** in progress; approval was + granted and listing passed, but T006 still requires a successful bounded + restore from the repaired replacement artifact. - **Ready for timer installation or NPBackup cutover:** no; T006-T008 and their explicit approvals remain pending. diff --git a/src/TimeLocker/backup_repository.py b/src/TimeLocker/backup_repository.py index 9818bd9..0cf916f 100644 --- a/src/TimeLocker/backup_repository.py +++ b/src/TimeLocker/backup_repository.py @@ -116,6 +116,8 @@ def restore( target_path: Optional[Path] = None, *, overwrite: str = "never", + include_paths: Optional[List[Path]] = None, + exclude_paths: Optional[List[Path]] = None, ) -> str: """ Restores a specific snapshot to the given target path. @@ -130,6 +132,8 @@ def restore( should be restored to. :param overwrite: Backend overwrite policy. Supported values are ``never`` (the safe default) and ``always``. + :param include_paths: Optional repository-relative paths to restore. + :param exclude_paths: Optional repository-relative paths to omit. :return: A string message indicating the result of the restore operation. """ diff --git a/src/TimeLocker/backup_snapshot.py b/src/TimeLocker/backup_snapshot.py index 342ff15..1cf233e 100644 --- a/src/TimeLocker/backup_snapshot.py +++ b/src/TimeLocker/backup_snapshot.py @@ -55,9 +55,17 @@ def restore( target_path: Optional[Path] = None, *, overwrite: str = "never", + include_paths: Optional[list[Path]] = None, + exclude_paths: Optional[list[Path]] = None, ) -> str: """Restore this snapshot""" - return self.repo.restore(self.id, target_path, overwrite=overwrite) + return self.repo.restore( + self.id, + target_path, + overwrite=overwrite, + include_paths=include_paths, + exclude_paths=exclude_paths, + ) def restore_file(self, target_path: Optional[Path] = None) -> bool: """Restore a single file from this snapshot""" diff --git a/src/TimeLocker/recovery_orchestrator.py b/src/TimeLocker/recovery_orchestrator.py index 91a7495..3ea2c78 100644 --- a/src/TimeLocker/recovery_orchestrator.py +++ b/src/TimeLocker/recovery_orchestrator.py @@ -866,7 +866,6 @@ async def _validate_selection_criteria( ] config = SelectionConfig( - name=f"recovery_validation_{snapshot_id}", include_patterns=include_rules, exclude_patterns=exclude_rules, precedence_config=PrecedenceConfig() diff --git a/src/TimeLocker/restic/restic_repository.py b/src/TimeLocker/restic/restic_repository.py index 625d1d3..8de68da 100644 --- a/src/TimeLocker/restic/restic_repository.py +++ b/src/TimeLocker/restic/restic_repository.py @@ -658,6 +658,8 @@ def restore( target_path: Optional[Path] = None, *, overwrite: str = "never", + include_paths: Optional[List[Path]] = None, + exclude_paths: Optional[List[Path]] = None, ) -> str: if overwrite not in {"never", "always"}: raise ValueError("overwrite must be 'never' or 'always'") @@ -667,6 +669,10 @@ def restore( .param("target", target_path) .param("overwrite", overwrite) ) + for path in include_paths or []: + restore_command.param("include", path) + for path in exclude_paths or []: + restore_command.param("exclude", path) return restore_command.run(self.to_env(), synopsis_values={"snapshotID": snapshot_id}) def stats(self) -> dict: diff --git a/src/TimeLocker/restore_manager.py b/src/TimeLocker/restore_manager.py index 4d7a97d..b4d1187 100644 --- a/src/TimeLocker/restore_manager.py +++ b/src/TimeLocker/restore_manager.py @@ -352,6 +352,8 @@ def _execute_restore(self, snapshot: BackupSnapshot, options: RestoreOptions, re restore_output = snapshot.restore( options.target_path, overwrite=overwrite, + include_paths=options.include_paths, + exclude_paths=options.exclude_paths, ) # Parse restore output for statistics (implementation depends on repository type) diff --git a/tests/TimeLocker/backup/mock_repository.py b/tests/TimeLocker/backup/mock_repository.py index 7d762c7..b498588 100644 --- a/tests/TimeLocker/backup/mock_repository.py +++ b/tests/TimeLocker/backup/mock_repository.py @@ -104,6 +104,8 @@ def restore( target_path: Optional[Path] = None, *, overwrite: str = "never", + include_paths: Optional[List[Path]] = None, + exclude_paths: Optional[List[Path]] = None, ) -> str: self.last_restore_overwrite = overwrite if snapshot_id not in self._snapshots: diff --git a/tests/TimeLocker/backup/test_repository.py b/tests/TimeLocker/backup/test_repository.py index 851e83b..c08dfa0 100644 --- a/tests/TimeLocker/backup/test_repository.py +++ b/tests/TimeLocker/backup/test_repository.py @@ -79,7 +79,15 @@ def backup_target(self, targets: List[BackupTarget], tags: Optional[List[str]] = self._snapshots[snapshot_id] = snapshot return {"status": "success", "snapshot_id": snapshot_id} - def restore(self, snapshot_id: str, target_path: Optional[Path] = None) -> str: + def restore( + self, + snapshot_id: str, + target_path: Optional[Path] = None, + *, + overwrite: str = "never", + include_paths: Optional[List[Path]] = None, + exclude_paths: Optional[List[Path]] = None, + ) -> str: if snapshot_id in self._snapshots: return f"Snapshot {snapshot_id} restored to {target_path}" return "Snapshot not found" diff --git a/tests/TimeLocker/recovery/mock_recovery_repository.py b/tests/TimeLocker/recovery/mock_recovery_repository.py index 42bb877..65ffaac 100644 --- a/tests/TimeLocker/recovery/mock_recovery_repository.py +++ b/tests/TimeLocker/recovery/mock_recovery_repository.py @@ -111,6 +111,8 @@ def restore( target_path: Optional[Path] = None, *, overwrite: str = "never", + include_paths: Optional[List[Path]] = None, + exclude_paths: Optional[List[Path]] = None, ) -> str: """Mock restore operation""" if self._should_fail_restore: @@ -124,6 +126,8 @@ def restore( self._restore_results[snapshot_id] = { "target_path": str(target_path) if target_path else None, "overwrite": overwrite, + "include_paths": list(include_paths or []), + "exclude_paths": list(exclude_paths or []), "timestamp": datetime.now(), "success": True } diff --git a/tests/TimeLocker/recovery/test_recovery_orchestrator.py b/tests/TimeLocker/recovery/test_recovery_orchestrator.py index d0f59e8..9ddde06 100644 --- a/tests/TimeLocker/recovery/test_recovery_orchestrator.py +++ b/tests/TimeLocker/recovery/test_recovery_orchestrator.py @@ -131,6 +131,11 @@ def test_initiate_selective_recovery_success(self): assert operation.recovery_type == RecoveryType.SELECTIVE assert operation.progress == ProgressStatus(0, 0, 0, 0) assert operation.target_path == target_path + assert operation.status == OperationStatus.COMPLETED + assert self.repository._restore_results["abc123"]["include_paths"] == [ + Path("*.txt"), + Path("*.pdf"), + ] @pytest.mark.recovery @pytest.mark.unit diff --git a/tests/TimeLocker/recovery/test_restore_manager.py b/tests/TimeLocker/recovery/test_restore_manager.py index a6c91ec..b7c1af0 100644 --- a/tests/TimeLocker/recovery/test_restore_manager.py +++ b/tests/TimeLocker/recovery/test_restore_manager.py @@ -149,6 +149,7 @@ def test_restore_with_include_paths(self): assert result.success is True assert options.include_paths == include_paths + assert self.repository._restore_results["abc123"]["include_paths"] == include_paths @pytest.mark.restore @pytest.mark.unit @@ -163,6 +164,7 @@ def test_restore_with_exclude_paths(self): result = self.restore_manager.restore_snapshot("abc123", options) assert result.success is True + assert self.repository._restore_results["abc123"]["exclude_paths"] == exclude_paths assert options.exclude_paths == exclude_paths @pytest.mark.restore diff --git a/tests/TimeLocker/restic/test_restic_repository.py b/tests/TimeLocker/restic/test_restic_repository.py index 9630a01..c47ef73 100644 --- a/tests/TimeLocker/restic/test_restic_repository.py +++ b/tests/TimeLocker/restic/test_restic_repository.py @@ -16,6 +16,7 @@ """ import json +from pathlib import Path from unittest.mock import MagicMock, Mock, patch import pytest @@ -69,6 +70,25 @@ def test_restore_rejects_unknown_overwrite_policy(): repo.restore("snapshot-1", overwrite="if-newer") +@pytest.mark.unit +def test_restore_passes_include_and_exclude_paths(): + repo = ConcreteResticRepository("test_location") + command = MagicMock() + command.param.return_value = command + repo._command = MagicMock() + repo._command.command.return_value = command + command.run.return_value = "restore complete" + + repo.restore( + "snapshot-1", + include_paths=[Path("/etc/hostname")], + exclude_paths=[Path("/etc/shadow")], + ) + + command.param.assert_any_call("include", Path("/etc/hostname")) + command.param.assert_any_call("exclude", Path("/etc/shadow")) + + @pytest.mark.unit def test___init___1(): location = "/path/to/backup" From 3a4572c6c10d6f38ae9bf4a10a76eefd34a79391 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:14:09 +0100 Subject: [PATCH 19/72] feat(backup): preserve NPBackup exclusion parity --- docs/guides/developer/scheduling-guide.md | 10 +++++- docs/guides/user/recovery-operations-guide.md | 7 ++++ .../008-npbackup-migration-parity/design.md | 27 ++++++++++++--- .../requirements.md | 6 ++++ .../008-npbackup-migration-parity/tasks.md | 19 +++++++---- .../traceability.md | 4 +-- .../verification.md | 24 +++++++++----- src/TimeLocker/backup_target.py | 6 ++++ src/TimeLocker/cli_modules/commands/backup.py | 9 +++++ .../cli_modules/commands/schedule.py | 33 +++++++++++++++++++ src/TimeLocker/cli_services.py | 13 ++++++++ src/TimeLocker/restic/restic_repository.py | 26 +++++++++++++++ .../services/backup_orchestrator.py | 6 ++++ .../backup/test_backup_operations.py | 14 ++++++++ tests/TimeLocker/cli/test_backup_commands.py | 9 +++++ .../TimeLocker/cli/test_schedule_commands.py | 22 +++++++++++++ 16 files changed, 212 insertions(+), 23 deletions(-) diff --git a/docs/guides/developer/scheduling-guide.md b/docs/guides/developer/scheduling-guide.md index d44e677..1e07919 100644 --- a/docs/guides/developer/scheduling-guide.md +++ b/docs/guides/developer/scheduling-guide.md @@ -35,6 +35,10 @@ Schedules may also persist repeatable `--tags` and `--exclude` values, `--compression auto|off|max`, and the `--one-file-system/--cross-filesystems` traversal choice. Missing fields retain the legacy defaults and emit no additional backup arguments. +Migration schedules may additionally reference repeatable root-readable +`--exclude-file` paths, enable `--exclude-caches`, and carry an allowlisted +`--backend-option`. The initial backend allowlist supports only Restic +`s3.storage-class` with its documented non-archive storage classes. ## Create a disabled schedule @@ -61,6 +65,9 @@ tl schedule create nightly-config \ --system \ --tags Bruce-5560 \ --exclude 'cache/*' \ + --exclude-file /etc/timelocker/excludes \ + --exclude-caches \ + --backend-option s3.storage-class=INTELLIGENT_TIERING \ --compression max \ --one-file-system \ --cron '30 1 * * *' \ @@ -100,7 +107,8 @@ Before installation: 1. Confirm the generated backup command contains `backup create`, the intended repository, all sources or the selection, the intended `--config-dir`, and - every reviewed tag, exclusion, compression, and traversal option. + every reviewed tag, exclusion, exclusion-file, cache, backend, compression, + and traversal option. 2. Confirm it contains no password or other credential value. 3. Run the generated wrapper manually in the intended user or root context. 4. Complete a backup and a digest-verified TimeLocker restore. diff --git a/docs/guides/user/recovery-operations-guide.md b/docs/guides/user/recovery-operations-guide.md index 28a1750..ca58ab6 100644 --- a/docs/guides/user/recovery-operations-guide.md +++ b/docs/guides/user/recovery-operations-guide.md @@ -95,6 +95,9 @@ tl backup create /home /etc /var /srv /root /nix/var \ --repository primary \ --tags Bruce-5560 \ --exclude 'cache/*' \ + --exclude-file /etc/timelocker/excludes \ + --exclude-caches \ + --backend-option s3.storage-class=INTELLIGENT_TIERING \ --compression max \ --one-file-system \ --dry-run \ @@ -104,6 +107,10 @@ tl backup create /home /etc /var /srv /root /nix/var \ Omitting these options preserves the existing defaults: TimeLocker does not add a Restic compression argument and permits cross-filesystem traversal. Unsupported compression values fail CLI validation before repository access. +Exclusion files remain external inputs and must be readable by the backup +service account. `--exclude-caches` preserves Restic's CACHEDIR.TAG semantics. +Backend options are validated before execution; the initial migration +allowlist accepts only documented `s3.storage-class` values. Record the full snapshot ID from the result or JSON listing. Reported file and byte counts come from Restic's summary. diff --git a/docs/specs/008-npbackup-migration-parity/design.md b/docs/specs/008-npbackup-migration-parity/design.md index 66a5584..f0c5d86 100644 --- a/docs/specs/008-npbackup-migration-parity/design.md +++ b/docs/specs/008-npbackup-migration-parity/design.md @@ -44,9 +44,10 @@ CLI or stored schedule -> CLIBackupRequest/job metadata -> BackupTarget ### Contracts And Interfaces -Add optional `compression` and default-false `one_file_system` fields to -`CLIBackupRequest` and `BackupTarget`. Add `tags`, `exclude_patterns`, -`compression`, and `one_file_system` to schedule records. The abstract +Add optional `compression`, default-false `one_file_system` and +`exclude_caches`, repeatable `exclude_files`, and repeatable allowlisted +`backend_options` fields to `CLIBackupRequest` and `BackupTarget`. Add the same +execution fields to schedule records. The abstract repository method remains unchanged; the Restic adapter reads invocation options from the concrete targets it already receives. @@ -68,6 +69,8 @@ programmatic callers cannot bypass the guardrail. - Validate compression at the CLI and Restic adapter boundary. - Reject conflicting target-level invocation options rather than choosing one. +- Accept only `s3.storage-class` as the initial migrated backend option and + validate its Restic-supported value before invoking the repository. - Test direct and selection-based backup propagation. - Test stored schedule creation, editing, display, parser round trip, and all renderers. @@ -83,6 +86,13 @@ to the existing repository read-only, list and restore snapshot `8958659e`, then stage a disabled system timer at a non-overlapping time. Retention remains simulation-only during overlap. +The T007 host reconciliation found 252 unique patterns across three NPBackup +exclude files, cache-directory exclusion enabled, and +`s3.storage-class=INTELLIGENT_TIERING`. Preserve the files by reference rather +than expanding their contents into generated unit arguments; preserve cache +semantics with Restic `--exclude-caches` and the storage class through the +allowlisted global backend option. + ## Security And Rollback No NPBackup ciphertext is copied as a usable credential. Credential transfer @@ -113,7 +123,14 @@ and NPBackup cutover remain explicit Phase 2 operator gates. value output from the existing Restic service-account environment, supplies the production repository and backend credentials. +## Resolved T007 Reconciliation + +- The effective NPBackup exclusion set requires explicit migration support: + three reviewed exclusion files remain referenced, and cache-directory + exclusion is carried separately. Expanding 252 patterns into generated unit + arguments is rejected because it duplicates another tool's maintained files. + ## Open Questions -- Does the effective NPBackup built-in exclusion expansion require a normalized - TimeLocker selection template before T007? +- No implementation question remains for T007. D002 remains the separate + operator decision about production schedule retention and NPBackup cutover. diff --git a/docs/specs/008-npbackup-migration-parity/requirements.md b/docs/specs/008-npbackup-migration-parity/requirements.md index 047d9bc..9d41483 100644 --- a/docs/specs/008-npbackup-migration-parity/requirements.md +++ b/docs/specs/008-npbackup-migration-parity/requirements.md @@ -72,6 +72,10 @@ filesystem or compression boundary. cross-filesystem behavior. 4. GIVEN tags and exclude patterns, WHEN the backup runs, THEN all values SHALL reach the existing tag and exclusion command path without credential output. +5. GIVEN reviewed exclusion files, cache-directory exclusion, and allowlisted + Restic backend options, WHEN the migrated production backup runs, THEN those + values SHALL reach Restic exactly and unsupported backend options SHALL fail + before repository mutation. ### Requirement 2: Executable schedule parity @@ -118,6 +122,8 @@ NPBackup remains a recoverable fallback until TimeLocker is observed. - **CP-004:** Generated assets contain credential references only, never values. - **CP-005:** Phase 1 leaves systemd, cron, NPBackup, and production credentials unchanged. +- **CP-006:** Production migration does not silently drop NPBackup exclusion + files, cache-directory exclusion, or reviewed S3 storage-class intent. ## Success Criteria diff --git a/docs/specs/008-npbackup-migration-parity/tasks.md b/docs/specs/008-npbackup-migration-parity/tasks.md index 7d601cb..927de94 100644 --- a/docs/specs/008-npbackup-migration-parity/tasks.md +++ b/docs/specs/008-npbackup-migration-parity/tasks.md @@ -71,14 +71,14 @@ last_reviewed: 2026-07-19 - Evidence: Operator-approved T005 completed on 2026-07-19: `/etc/timelocker/npbackup-migration.env` is root:root mode 0600 and byte-identical to the existing Restic service-account environment. It contains exactly the five expected non-empty Restic/AWS assignments and loads successfully as root; no values were emitted. Root's 17:30 NPBackup cron is unchanged, and no TimeLocker unit or `/opt/timelocker` installation exists. - Status: D001 resolved; protected credential source ready. T006 privileged artifact installation remains separately gated. -- [~] T006 Install a committed root-owned TimeLocker artifact and attach read-only. +- [x] T006 Install a committed root-owned TimeLocker artifact and attach read-only. - Depends on: T005 and explicit privileged-install approval - Requirement: Requirement 3 - Acceptance: Root-owned versioned installation lists and restores existing snapshot `8958659e`; NPBackup remains unchanged. - Evidence mode: validation - - Evidence: Operator authorized T006 on 2026-07-19. The validated Phase 1/T005 tree will be committed before building; only that commit may be installed. Repository access is limited to snapshot listing and a bounded restore, with NPBackup and scheduling unchanged. + - Evidence: Committed selective-restore repair `6896c8d` passed 64 focused tests and the full normal profile (2,797 passed, one skipped, 57 deselected, 52.53% coverage). Wheel SHA-256 `876246c4783d63f4d9f1fae80c5a4180afe95fbcb5161df01278e5b60de8da3c` was installed root-owned at `/opt/timelocker/releases/6896c8d6d90cb4c8320ec1fa66b966d9eb2dabcd`; both entry points report 0.9.1. The protected named repository listed snapshot `8958659e`; a bounded `/etc/hostname` restore produced a nonempty root-only result matching the live file byte-for-byte. Root's 17:30 NPBackup cron remains present; no TimeLocker cron entry, systemd unit, or timer exists. No backup, retention, schedule, or cutover action ran. - Evidence: Phase 1 was committed as `2c93709`; its root-owned release listed the protected repository and found snapshot `8958659e`. The first bounded restore exposed two recovery defects before Restic ran: selective validation @@ -86,15 +86,22 @@ last_reviewed: 2026-07-19 propagated to the backend. The repair removes the invalid field and carries bounded paths through the restore interfaces to repeated Restic arguments; 64 focused recovery and adapter tests pass. - - Status: Preparing a replacement committed artifact for the live bounded - restore; production backup and timer operations remain prohibited. -- [ ] T007 Stage, install, and observe a non-overlapping TimeLocker timer. + - Status: T006 complete. T007 remains separately gated by explicit timer-install approval. +- [~] T007 Stage, install, and observe a non-overlapping TimeLocker timer. - Depends on: T006 and explicit timer-install approval - - Requirement: Requirement 3 + - Requirements: Requirement 1, Requirement 3 + - Acceptance Criteria: Requirement 1 AC5; Requirement 3 AC3-AC4 + - Properties: CP-006 - Acceptance: Production-equivalent sources/options run successfully on the scheduler and a subsequent restore passes; no retention deletion runs. - Evidence mode: validation + - Evidence: Masked NPBackup reconciliation found three exclusion files containing 252 unique patterns, cache-directory exclusion enabled, and `s3.storage-class=INTELLIGENT_TIERING`. TimeLocker now carries repeatable exclusion-file references, CACHEDIR.TAG exclusion, and an allowlisted S3 storage-class option through direct and selection CLI requests, stored schedules, generated assets, targets, orchestrators, and Restic argv. Invalid options and missing exclusion files fail before repository mutation. The focused parity profile passed 77 tests; the full normal profile passed 2,797 tests with one skipped, 57 deselected, and 52.56% coverage. + - Evidence: Masked NPBackup reconciliation found three exclusion files with + 252 unique patterns, cache-directory exclusion enabled, and reviewed + `s3.storage-class=INTELLIGENT_TIERING` intent. These must be carried by the + committed TimeLocker artifact before the timer may run. + - Status: Parity implementation validated; preparing the committed root-owned artifact and non-overlapping timer. - [ ] T008 Checkpoint - Separate NPBackup cutover decision. - Depends on: T007 - Requirement: Requirement 3 diff --git a/docs/specs/008-npbackup-migration-parity/traceability.md b/docs/specs/008-npbackup-migration-parity/traceability.md index 9b65323..b36db03 100644 --- a/docs/specs/008-npbackup-migration-parity/traceability.md +++ b/docs/specs/008-npbackup-migration-parity/traceability.md @@ -19,14 +19,14 @@ last_reviewed: 2026-07-19 | T004 | Requirement 1, Requirement 2, Requirement 3 | R1-R2 all; R3 AC1 | Validation And Failure Handling | Phase 1 checkpoint | both guides | none | | T005 | Requirement 3 | AC2 | Security And Rollback | credential-path review | installation guidance | D001 resolved | | T006 | Requirement 3 | AC2-AC3 | Operator Staging Design | version, list, and restore | installation and recovery guides | none | -| T007 | Requirement 3 | AC3-AC4 | Operator Staging Design | scheduled runs and restore | scheduling guide | D002 | +| T007 | Requirement 1, Requirement 3 | R1 AC5; R3 AC3-AC4 | Low-Level Design; Operator Staging Design | focused parity tests, scheduled runs, and restore | backup and scheduling guides | D002 | | T008 | Requirement 3 | AC4 | Security And Rollback | operator decision | scheduling guide | D002 | ## Requirement To Delivery Matrix | Requirement | Priority | Acceptance Criteria | Design Sections | Tasks | Verification | Durable Targets | Coverage State | Residual Destination | |-------------|----------|---------------------|-----------------|-------|--------------|-----------------|----------------|----------------------| -| Requirement 1 | must-have | AC1-AC4 | Overview; Low-Level Design | T002, T004 | focused and normal-profile backup tests | recovery operations guide | complete | none | +| Requirement 1 | must-have | AC1-AC5 | Overview; Low-Level Design | T002, T004, T007 | focused and normal-profile backup tests; live migrated command | recovery operations guide | partial-blocking | T007 | | Requirement 2 | must-have | AC1-AC4 | High-Level Design; Compatibility | T003, T004 | schedule and renderer tests | scheduling guide | complete | none | | Requirement 3 | must-have | AC1-AC4 | Operational Considerations; Security And Rollback | T004-T008 | host comparison, restore, observation, decision | installation and scheduling guides | partial-blocking | T005-T008 | diff --git a/docs/specs/008-npbackup-migration-parity/verification.md b/docs/specs/008-npbackup-migration-parity/verification.md index e6a1034..5900d1d 100644 --- a/docs/specs/008-npbackup-migration-parity/verification.md +++ b/docs/specs/008-npbackup-migration-parity/verification.md @@ -19,7 +19,7 @@ last_reviewed: 2026-07-19 | Stored and rendered schedule parity | passed | T003 create/edit/show/list and cron/systemd/Windows tests. | | Phase 1 host state unchanged | passed | Root cron still contains the 17:30 NPBackup job; no TimeLocker unit installed. | | Durable guidance promoted | passed | Both current guides pass bounded Markdown checks with zero findings. | -| Production attachment and restore | in progress | Approved T006 release listed snapshot `8958659e`; bounded restore exposed and now has a focused-tested selective-restore repair awaiting replacement install. | +| Production attachment and restore | passed | Root-owned release `6896c8d` listed snapshot `8958659e` and restored `/etc/hostname` selectively with a byte-for-byte match. | | Scheduled observation and cutover | blocked | T007-T008 explicit operator gates required. | ## Baseline Evidence @@ -37,7 +37,7 @@ last_reviewed: 2026-07-19 |-------------|-----------------------------|----------|---------------| | Requirement 1 | AC1-AC4 | T002 focused tests and 2,796-test normal profile | none for Phase 1 | | Requirement 2 | AC1-AC4 | T003 stored schedule and renderer tests | none for Phase 1 | -| Requirement 3 | AC1; T005 prerequisite | Phase 1 host comparison and protected credential-source validation | AC2-AC4 remain gated in T006-T008. | +| Requirement 3 | AC1-AC3 | Phase 1 host comparison, protected credential source, committed root-owned release, repository listing, and bounded restore | AC4 remains gated in T007-T008. | ## Evidence Log @@ -54,8 +54,15 @@ last_reviewed: 2026-07-19 | 2026-07-19 | T005 protected credential-source installation | pass | Root-only `/etc/timelocker/npbackup-migration.env` is a mode-0600, root-owned, byte-identical copy containing exactly the five expected non-empty assignments; values were not emitted. | | 2026-07-19 | T005 post-install host comparison | pass | Root loaded all required variables; NPBackup cron remained unchanged, with no TimeLocker unit or `/opt/timelocker` installation. | | 2026-07-19 | First committed T006 artifact and repository attachment | partial | Root-owned release from `2c93709` reported version 0.9.1 and listed snapshot `8958659e` through the protected named repository without changing NPBackup. | -| 2026-07-19 | First T006 bounded restore | fail-safe | Recovery stopped before invoking Restic because selective validation supplied an unsupported constructor field; review also found bounded include/exclude paths were dropped before the backend. No full restore or backup ran. | +| 2026-07-19 | First T006 bounded restore | fail-safe | Root-only log `restore-8958659e.log` records `SelectionConfig.__init__()` rejecting keyword `name` before Restic invocation; code review found include/exclude paths were also dropped. No full restore or backup ran. | | 2026-07-19 | Selective-restore repair focused suite | pass | 64 adapter, restore-manager, orchestrator, and repository tests passed with coverage disabled; tests verify include/exclude propagation and completed selective orchestration. | +| 2026-07-19 | Replacement T006 committed artifact | pass | Wheel SHA-256 `876246c4783d63f4d9f1fae80c5a4180afe95fbcb5161df01278e5b60de8da3c` was built from `6896c8d`, installed root-owned under `/opt/timelocker/releases/`, and both entry points reported 0.9.1. | +| 2026-07-19 | T006 protected repository listing and bounded restore | pass | Snapshot `8958659e` was present; selective `/etc/hostname` restore produced a nonempty file matching the live file byte-for-byte. Output remains in root-only verification logs. | +| 2026-07-19 | T006 scheduler safety comparison | pass | Root `crontab -l`, `systemctl list-unit-files`, and `systemctl list-timers --all` checks found the 17:30 NPBackup job and zero TimeLocker scheduler entries. No backup, retention, schedule, or cutover operation ran. | +| 2026-07-19 | Repaired-artifact full normal profile | pass | `python -m pytest -m "not performance and not stress and not minio"` exited 0: 2,797 passed, one skipped, 57 deselected, and 52.53% coverage in 803.23 seconds. | +| 2026-07-19 | T007 masked execution-parity reconciliation | pass | Three exclusion files contain 252 unique patterns; cache exclusion and `s3.storage-class=INTELLIGENT_TIERING` are enabled. No credential value was emitted. | +| 2026-07-19 | T007 focused parity profile | pass | 77 backup, CLI, schedule, target, and selection tests passed with coverage disabled. | +| 2026-07-19 | T007 full normal profile | pass | `python -m pytest -m "not performance and not stress and not minio"` exited 0: 2,797 passed, one skipped, 57 deselected, and 52.56% coverage in 797.47 seconds. | ## Residual Risks @@ -66,16 +73,15 @@ last_reviewed: 2026-07-19 - Same-repository overlap can lock or duplicate work; timers must not overlap. - Retention enforcement can delete snapshots and remains simulation-only until separately reviewed. -- The first installed T006 artifact is retained for rollback but is not accepted; - a replacement committed artifact and successful bounded restore are required. +- The first installed T006 artifact is retained for rollback; the accepted + `current` release is the repaired `6896c8d` artifact. - Timer installation and cutover remain external mutations requiring approval. ## Readiness Decision - **Phase 1 ready for host staging:** yes - **Credential source ready for production attachment:** yes; T005 passed. -- **Ready for production repository attachment:** in progress; approval was - granted and listing passed, but T006 still requires a successful bounded - restore from the repaired replacement artifact. -- **Ready for timer installation or NPBackup cutover:** no; T006-T008 and +- **Ready for production repository attachment:** yes; T006 passed listing and + bounded restore from the committed root-owned artifact. +- **Ready for timer installation or NPBackup cutover:** no; T007-T008 and their explicit approvals remain pending. diff --git a/src/TimeLocker/backup_target.py b/src/TimeLocker/backup_target.py index 9e9f65c..3f15cc9 100644 --- a/src/TimeLocker/backup_target.py +++ b/src/TimeLocker/backup_target.py @@ -37,6 +37,9 @@ def __init__(self, template_overrides: Optional[Dict[str, Any]] = None, compression: Optional[str] = None, one_file_system: bool = False, + exclude_files: Optional[List[str]] = None, + exclude_caches: bool = False, + backend_options: Optional[List[str]] = None, **kwargs): """ Initialize a backup target @@ -90,6 +93,9 @@ def __init__(self, self.name = name self.compression = compression self.one_file_system = one_file_system + self.exclude_files = list(exclude_files or []) + self.exclude_caches = exclude_caches + self.backend_options = list(backend_options or []) # New selection management integration self.template_id = template_id diff --git a/src/TimeLocker/cli_modules/commands/backup.py b/src/TimeLocker/cli_modules/commands/backup.py index e22f17d..58ce31b 100644 --- a/src/TimeLocker/cli_modules/commands/backup.py +++ b/src/TimeLocker/cli_modules/commands/backup.py @@ -114,6 +114,9 @@ def backup_create( "--one-file-system/--cross-filesystems", help="Do not cross filesystem boundaries while backing up", )] = False, + exclude_file: Annotated[Optional[List[Path]], typer.Option("--exclude-file", help="Restic exclusion file (repeatable)")] = None, + exclude_caches: Annotated[bool, typer.Option("--exclude-caches", help="Exclude directories marked with CACHEDIR.TAG")] = False, + backend_option: Annotated[Optional[List[str]], typer.Option("--backend-option", help="Allowlisted Restic backend option (repeatable)")] = None, dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would be backed up without actually performing backup")] = False, config_dir: Annotated[Optional[Path], typer.Option("--config-dir", help="Configuration directory")] = None, verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, @@ -248,6 +251,9 @@ def backup_create( 'priority': 0, 'compression': compression, 'one_file_system': one_file_system, + 'exclude_files': exclude_file or [], + 'exclude_caches': exclude_caches, + 'backend_options': backend_option or [], } try: @@ -423,6 +429,9 @@ def backup_create( exclude_patterns=exclude or [], compression=compression, one_file_system=one_file_system, + exclude_files=exclude_file or [], + exclude_caches=exclude_caches, + backend_options=backend_option or [], dry_run=dry_run ) logger.debug("CLIBackupRequest created successfully") diff --git a/src/TimeLocker/cli_modules/commands/schedule.py b/src/TimeLocker/cli_modules/commands/schedule.py index 33681d0..ed1e6c7 100644 --- a/src/TimeLocker/cli_modules/commands/schedule.py +++ b/src/TimeLocker/cli_modules/commands/schedule.py @@ -106,6 +106,12 @@ def _build_backup_command(schedule: Dict[str, Any], config_dir: Optional[Path] = argv.extend(['--tags', str(tag)]) for pattern in schedule.get('exclude_patterns') or []: argv.extend(['--exclude', str(pattern)]) + for exclude_file in schedule.get('exclude_files') or []: + argv.extend(['--exclude-file', str(exclude_file)]) + if schedule.get('exclude_caches', False): + argv.append('--exclude-caches') + for backend_option in schedule.get('backend_options') or []: + argv.extend(['--backend-option', str(backend_option)]) compression = schedule.get('compression') if compression: if compression not in {'auto', 'off', 'max'}: @@ -154,6 +160,12 @@ def _format_schedule_table(schedules: Dict[str, Dict[str, Any]]) -> Table: options.append(f"tags={len(schedule['tags'])}") if schedule.get('exclude_patterns'): options.append(f"excludes={len(schedule['exclude_patterns'])}") + if schedule.get('exclude_files'): + options.append(f"exclude-files={len(schedule['exclude_files'])}") + if schedule.get('exclude_caches', False): + options.append("exclude-caches") + if schedule.get('backend_options'): + options.append(f"backend-options={len(schedule['backend_options'])}") table.add_row(name, repository, frequency, next_run, enabled, ", ".join(options) or "default") @@ -228,6 +240,9 @@ def _interactive_schedule_configuration(config_dir: Optional[Path] = None) -> Di "system": False, "tags": [], "exclude_patterns": [], + "exclude_files": [], + "exclude_caches": False, + "backend_options": [], "compression": None, "one_file_system": False, "config_dir": str(config_dir.expanduser().resolve()) if config_dir else None, @@ -378,6 +393,9 @@ def schedule_create( system: Annotated[bool, typer.Option("--system/--user", help="Generate a system-level or user-level schedule")] = False, tags: Annotated[Optional[List[str]], typer.Option("--tags", help="Backup tag (repeatable)")] = None, exclude_patterns: Annotated[Optional[List[str]], typer.Option("--exclude", help="Backup exclusion pattern (repeatable)")] = None, + exclude_files: Annotated[Optional[List[Path]], typer.Option("--exclude-file", help="Restic exclusion file (repeatable)")] = None, + exclude_caches: Annotated[bool, typer.Option("--exclude-caches", help="Exclude directories marked with CACHEDIR.TAG")] = False, + backend_options: Annotated[Optional[List[str]], typer.Option("--backend-option", help="Allowlisted Restic backend option (repeatable)")] = None, compression: Annotated[Optional[str], typer.Option( "--compression", help="Restic compression mode: auto, off, or max", @@ -447,6 +465,9 @@ def schedule_create( "system": system, "tags": tags or [], "exclude_patterns": exclude_patterns or [], + "exclude_files": [str(path.expanduser().resolve()) for path in (exclude_files or [])], + "exclude_caches": exclude_caches, + "backend_options": backend_options or [], "compression": compression, "one_file_system": one_file_system, "config_dir": str(config_dir.expanduser().resolve()) if config_dir else None, @@ -533,6 +554,9 @@ def schedule_show( f"[bold]Sources:[/bold] {', '.join(schedule.get('sources', [])) or 'N/A'}\n" f"[bold]Tags:[/bold] {', '.join(schedule.get('tags', [])) or 'N/A'}\n" f"[bold]Exclusions:[/bold] {', '.join(schedule.get('exclude_patterns', [])) or 'N/A'}\n" + f"[bold]Exclusion Files:[/bold] {', '.join(schedule.get('exclude_files', [])) or 'N/A'}\n" + f"[bold]Exclude Caches:[/bold] {'Yes' if schedule.get('exclude_caches', False) else 'No'}\n" + f"[bold]Backend Options:[/bold] {', '.join(schedule.get('backend_options', [])) or 'N/A'}\n" f"[bold]Compression:[/bold] {schedule.get('compression') or 'default'}\n" f"[bold]One Filesystem:[/bold] {'Yes' if schedule.get('one_file_system', False) else 'No'}\n" f"[bold]Frequency:[/bold] {schedule.get('frequency', 'N/A')}\n" @@ -560,6 +584,9 @@ def schedule_edit( system: Annotated[Optional[bool], typer.Option("--system/--user", help="Generate a system-level or user-level schedule")] = None, tags: Annotated[Optional[List[str]], typer.Option("--tags", help="Replace backup tags (repeatable)")] = None, exclude_patterns: Annotated[Optional[List[str]], typer.Option("--exclude", help="Replace exclusion patterns (repeatable)")] = None, + exclude_files: Annotated[Optional[List[Path]], typer.Option("--exclude-file", help="Replace exclusion files (repeatable)")] = None, + exclude_caches: Annotated[Optional[bool], typer.Option("--exclude-caches/--include-caches", help="Replace cache-directory exclusion mode")] = None, + backend_options: Annotated[Optional[List[str]], typer.Option("--backend-option", help="Replace allowlisted Restic backend options (repeatable)")] = None, compression: Annotated[Optional[str], typer.Option( "--compression", help="Replace Restic compression mode", @@ -604,6 +631,12 @@ def schedule_edit( schedule['tags'] = tags if exclude_patterns is not None: schedule['exclude_patterns'] = exclude_patterns + if exclude_files is not None: + schedule['exclude_files'] = [str(path.expanduser().resolve()) for path in exclude_files] + if exclude_caches is not None: + schedule['exclude_caches'] = exclude_caches + if backend_options is not None: + schedule['backend_options'] = backend_options if compression is not None: schedule['compression'] = compression if one_file_system is not None: diff --git a/src/TimeLocker/cli_services.py b/src/TimeLocker/cli_services.py index 355d817..a12004e 100644 --- a/src/TimeLocker/cli_services.py +++ b/src/TimeLocker/cli_services.py @@ -187,6 +187,9 @@ class CLIBackupRequest: exclude_patterns: List[str] = None compression: Optional[str] = None one_file_system: bool = False + exclude_files: List[Path] = None + exclude_caches: bool = False + backend_options: List[str] = None dry_run: bool = False def __post_init__(self): @@ -196,6 +199,10 @@ def __post_init__(self): self.include_patterns = [] if self.exclude_patterns is None: self.exclude_patterns = [] + if self.exclude_files is None: + self.exclude_files = [] + if self.backend_options is None: + self.backend_options = [] class CLIServiceManager: @@ -1131,6 +1138,9 @@ def execute_backup_from_cli(self, request: CLIBackupRequest) -> BackupResult: 'exclude_patterns': request.exclude_patterns, 'compression': request.compression, 'one_file_system': request.one_file_system, + 'exclude_files': [str(path) for path in request.exclude_files], + 'exclude_caches': request.exclude_caches, + 'backend_options': request.backend_options, } self._config_service.add_backup_target(target_config) else: @@ -1176,6 +1186,9 @@ def _execute_adhoc_backup(self, request: CLIBackupRequest, repository_uri: str, 'exclude_patterns': request.exclude_patterns, 'compression': request.compression, 'one_file_system': request.one_file_system, + 'exclude_files': [str(path) for path in request.exclude_files], + 'exclude_caches': request.exclude_caches, + 'backend_options': request.backend_options, } # Add to configuration temporarily diff --git a/src/TimeLocker/restic/restic_repository.py b/src/TimeLocker/restic/restic_repository.py index 8de68da..660bd14 100644 --- a/src/TimeLocker/restic/restic_repository.py +++ b/src/TimeLocker/restic/restic_repository.py @@ -340,6 +340,23 @@ def backup_target(self, targets: List[BackupTarget], tags: Optional[List[str]] = "Backup targets specify conflicting filesystem traversal modes" ) + cache_values = {target.exclude_caches for target in targets} + if len(cache_values) > 1: + raise RepositoryError("Backup targets specify conflicting cache exclusion modes") + + backend_option_values = {tuple(target.backend_options) for target in targets} + if len(backend_option_values) > 1: + raise RepositoryError("Backup targets specify conflicting Restic backend options") + backend_options = list(next(iter(backend_option_values), ())) + allowed_storage_classes = { + "STANDARD", "STANDARD_IA", "ONEZONE_IA", + "INTELLIGENT_TIERING", "REDUCED_REDUNDANCY", + } + for option in backend_options: + key, separator, value = option.partition("=") + if key != "s3.storage-class" or not separator or value not in allowed_storage_classes: + raise RepositoryError(f"Unsupported Restic backend option: {option}") + # Collect all paths to backup and build command arguments all_paths = [] all_tags = set(tags or []) @@ -355,13 +372,22 @@ def backup_target(self, targets: List[BackupTarget], tags: Optional[List[str]] = # Build backup command using the existing command builder pattern backup_command = self._command.command("backup") + for option in backend_options: + backup_command.param("option", option) if compression_values: backup_command.param("compression", next(iter(compression_values))) if filesystem_values == {True}: backup_command.param("one-file-system") + if cache_values == {True}: + backup_command.param("exclude-caches") # Add exclude patterns from all targets for target in targets: + for exclude_file in target.exclude_files: + exclude_path = Path(exclude_file) + if not exclude_path.is_file(): + raise RepositoryError(f"Restic exclusion file does not exist: {exclude_path}") + backup_command.param("exclude-file", str(exclude_path)) for pattern in target.selection.exclude_patterns: backup_command.param("exclude", pattern) for path in target.selection.excludes: diff --git a/src/TimeLocker/services/backup_orchestrator.py b/src/TimeLocker/services/backup_orchestrator.py index 370e47c..5103bad 100644 --- a/src/TimeLocker/services/backup_orchestrator.py +++ b/src/TimeLocker/services/backup_orchestrator.py @@ -879,6 +879,9 @@ def _create_backup_targets_from_job(self, backup_job: BackupJob) -> List[BackupT tags=backup_job.config.tags, compression=cli_options.get('compression'), one_file_system=bool(cli_options.get('one_file_system', False)), + exclude_files=cli_options.get('exclude_files', []), + exclude_caches=bool(cli_options.get('exclude_caches', False)), + backend_options=cli_options.get('backend_options', []), ) targets.append(target) @@ -1152,6 +1155,9 @@ def _get_backup_targets(self, target_names: List[str]) -> List[BackupTarget]: tags=target_config.get('tags', []), compression=target_config.get('compression'), one_file_system=bool(target_config.get('one_file_system', False)), + exclude_files=target_config.get('exclude_files', []), + exclude_caches=bool(target_config.get('exclude_caches', False)), + backend_options=target_config.get('backend_options', []), ) logger.debug(f"BackupTarget created successfully for '{target_name}'") diff --git a/tests/TimeLocker/backup/test_backup_operations.py b/tests/TimeLocker/backup/test_backup_operations.py index 4e9efdf..ae672f9 100644 --- a/tests/TimeLocker/backup/test_backup_operations.py +++ b/tests/TimeLocker/backup/test_backup_operations.py @@ -141,16 +141,24 @@ def test_backup_target_emits_migration_parity_options(self, mock_subprocess, moc ) selection = FileSelection() selection.add_path(self.source_path, SelectionType.INCLUDE) + exclude_file = self.source_path / "excludes.txt" + exclude_file.write_text("*.cache\n") repository.backup_target([BackupTarget( selection=selection, compression="max", one_file_system=True, + exclude_files=[str(exclude_file)], + exclude_caches=True, + backend_options=["s3.storage-class=INTELLIGENT_TIERING"], )]) command = mock_subprocess.call_args.args[0] assert command[command.index("--compression") + 1] == "max" assert command.count("--one-file-system") == 1 + assert command[command.index("--exclude-file") + 1] == str(exclude_file) + assert command.count("--exclude-caches") == 1 + assert command[command.index("--option") + 1] == "s3.storage-class=INTELLIGENT_TIERING" @patch('TimeLocker.restic.restic_repository.ResticRepository._verify_restic_executable') @pytest.mark.backup @@ -181,6 +189,12 @@ def test_backup_target_rejects_invalid_or_conflicting_options(self, mock_verify) BackupTarget(selection, one_file_system=False), ]) + with pytest.raises(RepositoryError, match="Unsupported Restic backend option"): + repository.backup_target([BackupTarget( + selection, + backend_options=["s3.storage-class=GLACIER"], + )]) + @patch('TimeLocker.restic.restic_repository.ResticRepository._verify_restic_executable') @patch('subprocess.run') @pytest.mark.backup diff --git a/tests/TimeLocker/cli/test_backup_commands.py b/tests/TimeLocker/cli/test_backup_commands.py index e8cd129..efdb44e 100644 --- a/tests/TimeLocker/cli/test_backup_commands.py +++ b/tests/TimeLocker/cli/test_backup_commands.py @@ -6,6 +6,7 @@ import pytest import tempfile +from pathlib import Path from unittest.mock import AsyncMock, Mock, patch from TimeLocker.cli import app @@ -255,10 +256,15 @@ def test_backup_create_propagates_execution_options( mock_get_manager.return_value = manager with tempfile.TemporaryDirectory() as temp_dir: + exclude_file = Path(temp_dir) / "excludes.txt" + exclude_file.write_text("*.cache\n") result = runner.invoke(app, [ "backup", "create", temp_dir, "--compression", "max", "--one-file-system", + "--exclude-file", str(exclude_file), + "--exclude-caches", + "--backend-option", "s3.storage-class=INTELLIGENT_TIERING", "--dry-run", ]) @@ -266,6 +272,9 @@ def test_backup_create_propagates_execution_options( request = manager.execute_backup.call_args.args[0] assert request.compression == "max" assert request.one_file_system is True + assert request.exclude_files == [exclude_file] + assert request.exclude_caches is True + assert request.backend_options == ["s3.storage-class=INTELLIGENT_TIERING"] @pytest.mark.unit @patch('TimeLocker.cli_modules.commands.backup._get_service_manager_for_command') diff --git a/tests/TimeLocker/cli/test_schedule_commands.py b/tests/TimeLocker/cli/test_schedule_commands.py index 11a770f..864c87c 100644 --- a/tests/TimeLocker/cli/test_schedule_commands.py +++ b/tests/TimeLocker/cli/test_schedule_commands.py @@ -125,6 +125,9 @@ def test_generated_backup_command_preserves_migration_parity_fields(self, tmp_pa "exclude_patterns": ["cache/*", "name;still-an-argument"], "compression": "max", "one_file_system": True, + "exclude_files": [str(tmp_path / "excludes with spaces")], + "exclude_caches": True, + "backend_options": ["s3.storage-class=INTELLIGENT_TIERING"], } command = _build_backup_command(schedule) @@ -134,6 +137,9 @@ def test_generated_backup_command_preserves_migration_parity_fields(self, tmp_pa assert argv.count("--exclude") == 2 assert argv[argv.index("--compression") + 1] == "max" assert argv.count("--one-file-system") == 1 + assert argv.count("--exclude-file") == 1 + assert argv.count("--exclude-caches") == 1 + assert argv[argv.index("--backend-option") + 1] == "s3.storage-class=INTELLIGENT_TIERING" assert "tag with spaces" in argv assert "name;still-an-argument" in argv @@ -149,6 +155,8 @@ def test_generated_backup_command_preserves_migration_parity_fields(self, tmp_pa for rendered in (cron, service, windows): assert "--compression max" in rendered assert "--one-file-system" in rendered + assert "--exclude-caches" in rendered + assert "--backend-option s3.storage-class=INTELLIGENT_TIERING" in rendered @pytest.mark.unit def test_generated_backup_command_preserves_legacy_defaults(self, tmp_path): @@ -162,6 +170,9 @@ def test_generated_backup_command_preserves_legacy_defaults(self, tmp_path): assert "--one-file-system" not in argv assert "--tags" not in argv assert "--exclude" not in argv + assert "--exclude-file" not in argv + assert "--exclude-caches" not in argv + assert "--backend-option" not in argv @pytest.mark.unit @patch('TimeLocker.cli_modules.commands.schedule._get_schedule_storage_dir') @@ -172,6 +183,8 @@ def test_schedule_create_edit_show_and_list_parity_fields( mock_storage_dir.return_value = tmp_path source = tmp_path / "source" source.mkdir() + exclude_file = tmp_path / "excludes.txt" + exclude_file.write_text("*.cache\n") create = runner.invoke(app, [ "schedule", "create", "migration", @@ -182,6 +195,9 @@ def test_schedule_create_edit_show_and_list_parity_fields( "--exclude", "cache/*", "--compression", "max", "--one-file-system", + "--exclude-file", str(exclude_file), + "--exclude-caches", + "--backend-option", "s3.storage-class=INTELLIGENT_TIERING", ]) assert_success(create) @@ -190,6 +206,9 @@ def test_schedule_create_edit_show_and_list_parity_fields( assert stored['exclude_patterns'] == ['cache/*'] assert stored['compression'] == 'max' assert stored['one_file_system'] is True + assert stored['exclude_files'] == [str(exclude_file.resolve())] + assert stored['exclude_caches'] is True + assert stored['backend_options'] == ['s3.storage-class=INTELLIGENT_TIERING'] edit = runner.invoke(app, [ "schedule", "edit", "migration", @@ -197,6 +216,7 @@ def test_schedule_create_edit_show_and_list_parity_fields( "--exclude", "*.tmp", "--compression", "off", "--cross-filesystems", + "--include-caches", ]) assert_success(edit) stored = json.loads((tmp_path / "schedules.json").read_text())['migration'] @@ -204,6 +224,7 @@ def test_schedule_create_edit_show_and_list_parity_fields( assert stored['exclude_patterns'] == ['*.tmp'] assert stored['compression'] == 'off' assert stored['one_file_system'] is False + assert stored['exclude_caches'] is False shown = runner.invoke(app, ["schedule", "show", "migration"]) listed = runner.invoke(app, ["schedule", "list"]) @@ -211,6 +232,7 @@ def test_schedule_create_edit_show_and_list_parity_fields( assert_success(listed) assert "Compression:" in combined_output(shown) assert "compression=off" in combined_output(listed) + assert "Exclusion Files:" in combined_output(shown) @pytest.mark.unit def test_linux_renderers_reference_environment_without_secret_values(self, tmp_path): From 2eb9928d6984d814d8e0b2766278155f742cdcd5 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:51:17 +0100 Subject: [PATCH 20/72] fix(monitoring): skip tray initialization when headless --- .../monitoring/system_tray_integration.py | 11 ++++++++++ .../test_system_tray_integration.py | 21 +++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/TimeLocker/monitoring/system_tray_integration.py b/src/TimeLocker/monitoring/system_tray_integration.py index cf7795a..58d4d69 100644 --- a/src/TimeLocker/monitoring/system_tray_integration.py +++ b/src/TimeLocker/monitoring/system_tray_integration.py @@ -17,6 +17,7 @@ import logging import importlib +import os import sys import threading from datetime import datetime @@ -28,6 +29,11 @@ logger = logging.getLogger(__name__) +def _linux_graphical_session_available() -> bool: + """Return whether a Linux process has a desktop display connection.""" + return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) + + def _load_linux_tray_modules(): """Load GTK and the first supported AppIndicator namespace.""" try: @@ -124,6 +130,11 @@ def _initialize_platform_tray(self): """Initialize platform-specific system tray implementation""" try: if sys.platform == "linux": + if not _linux_graphical_session_available(): + logger.info( + "System tray disabled because no Linux graphical session is available" + ) + return self._tray_impl = LinuxSystemTray(self.app_name) elif sys.platform == "darwin": self._tray_impl = MacOSSystemTray(self.app_name) diff --git a/tests/TimeLocker/monitoring/test_system_tray_integration.py b/tests/TimeLocker/monitoring/test_system_tray_integration.py index 8a0a75f..0d47871 100644 --- a/tests/TimeLocker/monitoring/test_system_tray_integration.py +++ b/tests/TimeLocker/monitoring/test_system_tray_integration.py @@ -58,13 +58,30 @@ class TestSystemTrayIntegration: @pytest.mark.monitoring @pytest.mark.unit @patch('TimeLocker.monitoring.system_tray_integration.sys.platform', 'linux') - def test_initialization(self): + def test_initialization(self, monkeypatch): """Test SystemTrayIntegration initialization""" - with patch('TimeLocker.monitoring.system_tray_integration.LinuxSystemTray'): + monkeypatch.setenv('DISPLAY', ':0') + with patch('TimeLocker.monitoring.system_tray_integration.LinuxSystemTray') as linux_tray: tray = SystemTrayIntegration(app_name="TestApp") assert tray.app_name == "TestApp" assert tray.current_status == TrayStatus.IDLE + assert tray.is_available() is True + linux_tray.assert_called_once_with("TestApp") + + @pytest.mark.monitoring + @pytest.mark.unit + @patch('TimeLocker.monitoring.system_tray_integration.sys.platform', 'linux') + def test_headless_linux_skips_native_tray_initialization(self, monkeypatch): + """A service without a display must not enter GTK/AppIndicator code.""" + monkeypatch.delenv('DISPLAY', raising=False) + monkeypatch.delenv('WAYLAND_DISPLAY', raising=False) + + with patch('TimeLocker.monitoring.system_tray_integration.LinuxSystemTray') as linux_tray: + tray = SystemTrayIntegration(app_name="HeadlessService") + + assert tray.is_available() is False + linux_tray.assert_not_called() @pytest.mark.monitoring @pytest.mark.unit From 310548dc69633bcff1bfe526a07925f47e1aa321 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:01:48 +0100 Subject: [PATCH 21/72] docs(spec): record failed timer observation --- docs/specs/008-npbackup-migration-parity/tasks.md | 13 +++++++++++-- .../008-npbackup-migration-parity/verification.md | 3 +++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/specs/008-npbackup-migration-parity/tasks.md b/docs/specs/008-npbackup-migration-parity/tasks.md index 927de94..77c1919 100644 --- a/docs/specs/008-npbackup-migration-parity/tasks.md +++ b/docs/specs/008-npbackup-migration-parity/tasks.md @@ -96,12 +96,21 @@ last_reviewed: 2026-07-19 scheduler and a subsequent restore passes; no retention deletion runs. - Evidence mode: validation - - Evidence: Masked NPBackup reconciliation found three exclusion files containing 252 unique patterns, cache-directory exclusion enabled, and `s3.storage-class=INTELLIGENT_TIERING`. TimeLocker now carries repeatable exclusion-file references, CACHEDIR.TAG exclusion, and an allowlisted S3 storage-class option through direct and selection CLI requests, stored schedules, generated assets, targets, orchestrators, and Restic argv. Invalid options and missing exclusion files fail before repository mutation. The focused parity profile passed 77 tests; the full normal profile passed 2,797 tests with one skipped, 57 deselected, and 52.56% coverage. + - Evidence: Committed parity artifact `3a4572c` (wheel SHA-256 `90c45af99b3e6c913757fcc9539afe91ae5c6c735556ee252a015637c9dbbbf8`) is installed root-owned and reports 0.9.1. The stored disabled schedule and generated systemd assets match six sources, three direct patterns, three exclusion files, cache exclusion, tag `Bruce-5560`, maximum compression, single-filesystem traversal, and allowlisted `s3.storage-class=INTELLIGENT_TIERING`; systemd verification passed and no credentials are embedded. The timer was enabled for daily 03:30 with a one-time 2026-07-19 19:30 observation trigger, after the 17:30 NPBackup job and its recent maximum 2,867-second event span. A service condition skips TimeLocker if NPBackup remains active. Retention and cutover remain prohibited. - Evidence: Masked NPBackup reconciliation found three exclusion files with 252 unique patterns, cache-directory exclusion enabled, and reviewed `s3.storage-class=INTELLIGENT_TIERING` intent. These must be carried by the committed TimeLocker artifact before the timer may run. - - Status: Parity implementation validated; preparing the committed root-owned artifact and non-overlapping timer. + - Evidence: The 19:30 timer triggered and its NPBackup exclusion condition + passed, but the service exited by `SIGTRAP` before Restic started because + GTK tray initialization ran without a display. No backup or retention + operation completed. Commit `2eb9928` now skips native Linux tray startup + when neither `DISPLAY` nor `WAYLAND_DISPLAY` is present. Seven focused + tests and the full normal profile passed: 2,798 passed, one skipped, 57 + deselected, and 52.55% coverage. The replacement wheel SHA-256 is + `c6998f9af68068185d80ad6261086fdc0dd8092cc38d97565a529bce1ab421e5`. + - Status: Awaiting privileged installation of `2eb9928`, a controlled + scheduler retry, and a subsequent bounded restore; T007 is not complete. - [ ] T008 Checkpoint - Separate NPBackup cutover decision. - Depends on: T007 - Requirement: Requirement 3 diff --git a/docs/specs/008-npbackup-migration-parity/verification.md b/docs/specs/008-npbackup-migration-parity/verification.md index 5900d1d..fadf493 100644 --- a/docs/specs/008-npbackup-migration-parity/verification.md +++ b/docs/specs/008-npbackup-migration-parity/verification.md @@ -63,6 +63,9 @@ last_reviewed: 2026-07-19 | 2026-07-19 | T007 masked execution-parity reconciliation | pass | Three exclusion files contain 252 unique patterns; cache exclusion and `s3.storage-class=INTELLIGENT_TIERING` are enabled. No credential value was emitted. | | 2026-07-19 | T007 focused parity profile | pass | 77 backup, CLI, schedule, target, and selection tests passed with coverage disabled. | | 2026-07-19 | T007 full normal profile | pass | `python -m pytest -m "not performance and not stress and not minio"` exited 0: 2,797 passed, one skipped, 57 deselected, and 52.56% coverage in 797.47 seconds. | +| 2026-07-19 | T007 committed artifact and timer staging | pass | Commit `3a4572c`, wheel SHA-256 `90c45af99b3e6c913757fcc9539afe91ae5c6c735556ee252a015637c9dbbbf8`, generated-unit parity, installation, and active-timer checks passed. The first observation trigger was set for 19:30, safely after NPBackup. | +| 2026-07-19 | T007 first scheduler observation | fail-safe | The 19:30 timer and non-overlap condition ran, but the service exited by `SIGTRAP` during GTK tray initialization before Restic started. No backup, retention, or cutover operation completed. | +| 2026-07-19 | T007 headless-service repair | pass | Commit `2eb9928` skips native Linux tray startup without `DISPLAY` or `WAYLAND_DISPLAY`. Seven focused tests passed; the full normal profile passed 2,798 tests with one skipped, 57 deselected, and 52.55% coverage. Replacement wheel SHA-256: `c6998f9af68068185d80ad6261086fdc0dd8092cc38d97565a529bce1ab421e5`. Privileged installation and live retry remain pending. | ## Residual Risks From daaad538f7adb02e27e86b744af43ead79f07408 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:34:56 +0100 Subject: [PATCH 22/72] fix(cli): preserve native restic repository URIs --- src/TimeLocker/cli_services.py | 12 ++++++++--- tests/TimeLocker/cli/test_backup_commands.py | 21 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/TimeLocker/cli_services.py b/src/TimeLocker/cli_services.py index a12004e..363d386 100644 --- a/src/TimeLocker/cli_services.py +++ b/src/TimeLocker/cli_services.py @@ -781,8 +781,11 @@ def resolve_repository_uri(self, repository_input: str) -> str: Raises: ConfigurationError: If repository cannot be resolved """ - # Check if it's already a URI (contains scheme) - if "://" in repository_input or repository_input.startswith("/"): + # Restic supports both conventional URIs (``s3://...``) and native + # backend syntax (``s3:host/bucket``). Preserve either form here: this + # method is also called after the CLI has already resolved a configured + # repository name to its stored location. + if self._looks_like_uri(repository_input): return repository_input # Try to resolve as repository name from configuration @@ -846,7 +849,10 @@ def _looks_like_uri(candidate: str) -> bool: return False if "://" in candidate: return True - prefixes = ("s3:", "b2:", "gs:", "azure:", "rest:", "rclone:", "local:", "minio:", "swift:", "/") + prefixes = ( + "s3:", "b2:", "sftp:", "gs:", "azure:", "rest:", + "rclone:", "local:", "minio:", "swift:", "/", + ) return candidate.startswith(prefixes) def _create_repository_instance(self, diff --git a/tests/TimeLocker/cli/test_backup_commands.py b/tests/TimeLocker/cli/test_backup_commands.py index efdb44e..c9ac0a2 100644 --- a/tests/TimeLocker/cli/test_backup_commands.py +++ b/tests/TimeLocker/cli/test_backup_commands.py @@ -24,6 +24,27 @@ class TestBackupCommands: """Test suite for backup command group.""" + @pytest.mark.unit + @pytest.mark.parametrize( + "repository_uri", + [ + "s3:s3.af-south-1.amazonaws.com/example-restic", + "b2:example-bucket:path", + "sftp:user@example.test:/srv/restic", + ], + ) + def test_cli_service_manager_preserves_restic_native_repository_uri( + self, repository_uri: str + ) -> None: + """A second resolution pass must not reinterpret Restic URIs as paths.""" + manager = CLIServiceManager.__new__(CLIServiceManager) + manager._config_service = Mock() + manager._config_module = Mock() + + assert manager.resolve_repository_uri(repository_uri) == repository_uri + manager._config_service.get_repositories.assert_not_called() + manager._config_module.get_repository.assert_not_called() + @pytest.mark.unit def test_selection_handler_prefers_focused_service(self) -> None: """Real managers expose the narrow handler instead of facade internals.""" From aacc00c33902017cae8f1391cdc20530540c675b Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:39:06 +0100 Subject: [PATCH 23/72] fix(schedule): avoid immediate systemd service start --- docs/guides/developer/scheduling-guide.md | 9 +++++- .../008-npbackup-migration-parity/tasks.md | 16 +++++----- .../traceability.md | 18 ++++++------ .../verification.md | 29 ++++++++++++------- .../cli_modules/commands/schedule.py | 1 - .../TimeLocker/cli/test_schedule_commands.py | 1 + 6 files changed, 45 insertions(+), 29 deletions(-) diff --git a/docs/guides/developer/scheduling-guide.md b/docs/guides/developer/scheduling-guide.md index 1e07919..8e884fd 100644 --- a/docs/guides/developer/scheduling-guide.md +++ b/docs/guides/developer/scheduling-guide.md @@ -4,7 +4,7 @@ id: "dev-guide-scheduling" type: [ guide ] status: [ approved ] owner: "Operations Team" -last_reviewed: "19-07-2026" +last_reviewed: "20-07-2026" tags: [guide, developer, operator, scheduling] links: tooling: [] @@ -118,6 +118,13 @@ For systemd assets, `EnvironmentFile=` references the protected file. The cron wrapper sources the same file with fail-fast shell settings. A missing environment file causes the backup to fail instead of silently switching credentials. +The generated timer does not declare a `Requires=` dependency on its service. +Systemd starts the same-named service when the timer elapses; coupling the +service to timer activation would also start a backup whenever the timer unit is +started or restarted. Because generated timers use `Persistent=true`, starting +one after a missed calendar event can legitimately trigger one catch-up run. +Check the service state after installing or changing a timer. + ## Staged NPBackup replacement Keep the NPBackup job enabled while TimeLocker is staged: diff --git a/docs/specs/008-npbackup-migration-parity/tasks.md b/docs/specs/008-npbackup-migration-parity/tasks.md index 77c1919..79ae686 100644 --- a/docs/specs/008-npbackup-migration-parity/tasks.md +++ b/docs/specs/008-npbackup-migration-parity/tasks.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: tasks status: active owner: Auriora Team -last_reviewed: 2026-07-19 +last_reviewed: 2026-07-20 --- # Tasks @@ -87,7 +87,7 @@ last_reviewed: 2026-07-19 bounded paths through the restore interfaces to repeated Restic arguments; 64 focused recovery and adapter tests pass. - Status: T006 complete. T007 remains separately gated by explicit timer-install approval. -- [~] T007 Stage, install, and observe a non-overlapping TimeLocker timer. +- [x] T007 Stage, install, and observe a non-overlapping TimeLocker timer. - Depends on: T006 and explicit timer-install approval - Requirements: Requirement 1, Requirement 3 - Acceptance Criteria: Requirement 1 AC5; Requirement 3 AC3-AC4 @@ -96,7 +96,7 @@ last_reviewed: 2026-07-19 scheduler and a subsequent restore passes; no retention deletion runs. - Evidence mode: validation - - Evidence: Committed parity artifact `3a4572c` (wheel SHA-256 `90c45af99b3e6c913757fcc9539afe91ae5c6c735556ee252a015637c9dbbbf8`) is installed root-owned and reports 0.9.1. The stored disabled schedule and generated systemd assets match six sources, three direct patterns, three exclusion files, cache exclusion, tag `Bruce-5560`, maximum compression, single-filesystem traversal, and allowlisted `s3.storage-class=INTELLIGENT_TIERING`; systemd verification passed and no credentials are embedded. The timer was enabled for daily 03:30 with a one-time 2026-07-19 19:30 observation trigger, after the 17:30 NPBackup job and its recent maximum 2,867-second event span. A service condition skips TimeLocker if NPBackup remains active. Retention and cutover remain prohibited. + - Evidence: Root-owned release from commit `daaad53` (wheel SHA-256 `5c3106e573d3805b3e9962007c20d5e47cd88faccb1ac8d20c0c1f315f212867`) was installed under `/opt/timelocker/releases/daaad538f7adb02e27e86b744af43ead79f07408`. The NPBackup overlap condition passed and the controlled systemd run used the native repository URI `s3:s3.af-south-1.amazonaws.com/5560-restic` with six production sources, tag `Bruce-5560`, three direct exclusions, three exclusion files, cache exclusion, `s3.storage-class=INTELLIGENT_TIERING`, compression `max`, and one-filesystem traversal. It completed successfully on 2026-07-19 as snapshot `f7417b35ab2e497052e33894d5b084a16260bc71e5c76780c7405cbf4454551f` (659,639 files; 455,495,193 bytes). The normal 2026-07-20 03:30 timer run also completed successfully as snapshot `ffafd15e6948ba278101463f85ac192176e83f8e423d109b5c06254859197de9`. A bounded `tl restore files` of `/etc/hostname` from `f7417b35...` to `/var/lib/timelocker/verification/restore-f7417b35` completed and matched `/etc/hostname` byte-for-byte. The timer's invalid service dependency was removed on-host and from the generator; 43 focused schedule/integration tests passed. After one harmless persistent catch-up snapshot (`a57f037d...`), the service is inactive and the enabled timer is waiting for 2026-07-21 03:30. NPBackup remains active; no retention, prune, or cutover action ran. - Evidence: Masked NPBackup reconciliation found three exclusion files with 252 unique patterns, cache-directory exclusion enabled, and reviewed `s3.storage-class=INTELLIGENT_TIERING` intent. These must be carried by the @@ -109,8 +109,7 @@ last_reviewed: 2026-07-19 tests and the full normal profile passed: 2,798 passed, one skipped, 57 deselected, and 52.55% coverage. The replacement wheel SHA-256 is `c6998f9af68068185d80ad6261086fdc0dd8092cc38d97565a529bce1ab421e5`. - - Status: Awaiting privileged installation of `2eb9928`, a controlled - scheduler retry, and a subsequent bounded restore; T007 is not complete. + - Status: Complete: production-equivalent scheduled backup and subsequent bounded restore passed; NPBackup remains the active fallback pending separate cutover approval. - [ ] T008 Checkpoint - Separate NPBackup cutover decision. - Depends on: T007 - Requirement: Requirement 3 @@ -123,6 +122,7 @@ last_reviewed: 2026-07-19 Coding Standards (100), General Preferences (50), Operational Best Practices (40), Planning Protocol (30), Testing Conventions (25), Documentation -Conventions (20), and Git Conventions (15). User approval on 2026-07-19 covers -Phase 1 implementation and the separate T005 credential copy. T006-T008 -privileged installation, scheduling, and cutover gates remain separate. +Conventions (20), and Git Conventions (15). User approvals covered Phase 1, +the T005 credential copy, T006 installation, and T007 timer observation. T008 +remains a separate explicit operator decision; NPBackup and retention are +unchanged. diff --git a/docs/specs/008-npbackup-migration-parity/traceability.md b/docs/specs/008-npbackup-migration-parity/traceability.md index b36db03..5727925 100644 --- a/docs/specs/008-npbackup-migration-parity/traceability.md +++ b/docs/specs/008-npbackup-migration-parity/traceability.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: traceability status: active owner: Auriora Team -last_reviewed: 2026-07-19 +last_reviewed: 2026-07-20 --- # Traceability Matrix @@ -19,16 +19,16 @@ last_reviewed: 2026-07-19 | T004 | Requirement 1, Requirement 2, Requirement 3 | R1-R2 all; R3 AC1 | Validation And Failure Handling | Phase 1 checkpoint | both guides | none | | T005 | Requirement 3 | AC2 | Security And Rollback | credential-path review | installation guidance | D001 resolved | | T006 | Requirement 3 | AC2-AC3 | Operator Staging Design | version, list, and restore | installation and recovery guides | none | -| T007 | Requirement 1, Requirement 3 | R1 AC5; R3 AC3-AC4 | Low-Level Design; Operator Staging Design | focused parity tests, scheduled runs, and restore | backup and scheduling guides | D002 | +| T007 | Requirement 1, Requirement 3 | R1 AC5; R3 AC3-AC4 | Low-Level Design; Operator Staging Design | focused parity tests, scheduled runs, and restore | backup and scheduling guides | none | | T008 | Requirement 3 | AC4 | Security And Rollback | operator decision | scheduling guide | D002 | ## Requirement To Delivery Matrix | Requirement | Priority | Acceptance Criteria | Design Sections | Tasks | Verification | Durable Targets | Coverage State | Residual Destination | |-------------|----------|---------------------|-----------------|-------|--------------|-----------------|----------------|----------------------| -| Requirement 1 | must-have | AC1-AC5 | Overview; Low-Level Design | T002, T004, T007 | focused and normal-profile backup tests; live migrated command | recovery operations guide | partial-blocking | T007 | +| Requirement 1 | must-have | AC1-AC5 | Overview; Low-Level Design | T002, T004, T007 | focused and normal-profile backup tests; live migrated command | recovery operations guide | complete | none | | Requirement 2 | must-have | AC1-AC4 | High-Level Design; Compatibility | T003, T004 | schedule and renderer tests | scheduling guide | complete | none | -| Requirement 3 | must-have | AC1-AC4 | Operational Considerations; Security And Rollback | T004-T008 | host comparison, restore, observation, decision | installation and scheduling guides | partial-blocking | T005-T008 | +| Requirement 3 | must-have | AC1-AC4 | Operational Considerations; Security And Rollback | T004-T008 | host comparison, restore, observation, decision | installation and scheduling guides | partial-blocking | T008 | ## Design To Implementation Matrix @@ -36,17 +36,17 @@ last_reviewed: 2026-07-19 |----------------|--------------|-------|---------------------|--------------|----------------|----------------------| | Overview and Low-Level Design | Requirement 1 | T002 | backup CLI, request, target, orchestrators, Restic adapter | focused and normal-profile backup tests | complete | none | | High-Level Design and Compatibility | Requirement 2 | T003 | schedule commands, records, renderers | schedule tests and parser round trip | complete | none | -| Operational Considerations | Requirement 3 | T004-T008 | docs, root-owned installation, timer | host comparison, list, restore, observed runs | partial-blocking | T005-T008 | +| Operational Considerations | Requirement 3 | T004-T008 | docs, root-owned installation, timer | host comparison, list, restore, observed runs | partial-blocking | T008 | ## Open Decision Impact | Decision ID | Blocks | Affected Requirements | Affected Tasks | Resolution Needed | |-------------|--------|-----------------------|----------------|-------------------| | D001 (resolved 2026-07-19) | none | Requirement 3 | T005-T006 | Operator approved root-owned mode-0600 `/etc/timelocker/npbackup-migration.env`; no values enter repository evidence. | -| D002 | production schedule and cutover | Requirement 3 | T007-T008 | Operator separately approves timer installation and NPBackup cutover. | +| D002 | NPBackup cutover disposition | Requirement 3 | T008 | Timer installation and observation are approved and complete; the operator must separately decide whether to retain, disable, or roll back TimeLocker and whether to change NPBackup. | ## Open Gate -Phase 1 and T005 are complete. T006-T008 still require privileged installation, -observation, and cutover approvals and cannot be inferred from the T005 -credential-copy approval. +T001-T007 are complete. T008 remains the explicit operator decision about +TimeLocker retention and NPBackup cutover; no cron, retention, prune, or +cutover change may be inferred from the completed observation work. diff --git a/docs/specs/008-npbackup-migration-parity/verification.md b/docs/specs/008-npbackup-migration-parity/verification.md index fadf493..5cda0c4 100644 --- a/docs/specs/008-npbackup-migration-parity/verification.md +++ b/docs/specs/008-npbackup-migration-parity/verification.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: verification status: active owner: Auriora Team -last_reviewed: 2026-07-19 +last_reviewed: 2026-07-20 --- # Verification @@ -20,7 +20,8 @@ last_reviewed: 2026-07-19 | Phase 1 host state unchanged | passed | Root cron still contains the 17:30 NPBackup job; no TimeLocker unit installed. | | Durable guidance promoted | passed | Both current guides pass bounded Markdown checks with zero findings. | | Production attachment and restore | passed | Root-owned release `6896c8d` listed snapshot `8958659e` and restored `/etc/hostname` selectively with a byte-for-byte match. | -| Scheduled observation and cutover | blocked | T007-T008 explicit operator gates required. | +| Scheduled production observation | passed | Controlled snapshot `f7417b35`, the normal 03:30 snapshot `ffafd15e`, and a subsequent byte-matched bounded restore passed. | +| NPBackup cutover | blocked | T008 remains a separate explicit operator decision; NPBackup is unchanged. | ## Baseline Evidence @@ -35,9 +36,9 @@ last_reviewed: 2026-07-19 | Requirement | Acceptance criteria covered | Evidence | Residual risk | |-------------|-----------------------------|----------|---------------| -| Requirement 1 | AC1-AC4 | T002 focused tests and 2,796-test normal profile | none for Phase 1 | +| Requirement 1 | AC1-AC5 | T002 focused tests, normal-profile coverage, and live production-equivalent Restic command evidence | none | | Requirement 2 | AC1-AC4 | T003 stored schedule and renderer tests | none for Phase 1 | -| Requirement 3 | AC1-AC3 | Phase 1 host comparison, protected credential source, committed root-owned release, repository listing, and bounded restore | AC4 remains gated in T007-T008. | +| Requirement 3 | AC1-AC4 | Protected credential source, committed root-owned release, repository listing, non-overlapping scheduled runs, bounded restore, and unchanged NPBackup fallback | Cutover remains a separate T008 decision. | ## Evidence Log @@ -66,19 +67,25 @@ last_reviewed: 2026-07-19 | 2026-07-19 | T007 committed artifact and timer staging | pass | Commit `3a4572c`, wheel SHA-256 `90c45af99b3e6c913757fcc9539afe91ae5c6c735556ee252a015637c9dbbbf8`, generated-unit parity, installation, and active-timer checks passed. The first observation trigger was set for 19:30, safely after NPBackup. | | 2026-07-19 | T007 first scheduler observation | fail-safe | The 19:30 timer and non-overlap condition ran, but the service exited by `SIGTRAP` during GTK tray initialization before Restic started. No backup, retention, or cutover operation completed. | | 2026-07-19 | T007 headless-service repair | pass | Commit `2eb9928` skips native Linux tray startup without `DISPLAY` or `WAYLAND_DISPLAY`. Seven focused tests passed; the full normal profile passed 2,798 tests with one skipped, 57 deselected, and 52.55% coverage. Replacement wheel SHA-256: `c6998f9af68068185d80ad6261086fdc0dd8092cc38d97565a529bce1ab421e5`. Privileged installation and live retry remain pending. | +| 2026-07-19 | T007 native S3 URI repair | pass | Commit `daaad53` preserves Restic-native repository URIs during the CLI service manager's second resolution pass. The exact root-owned wheel SHA-256 is `5c3106e573d3805b3e9962007c20d5e47cd88faccb1ac8d20c0c1f315f212867`; 84 focused/adjacent tests passed. The full configured suite reached 52.89% coverage with 2,857 passed, one skipped, and one unrelated timing-threshold miss that passed three immediate reruns. | +| 2026-07-19 | T007 controlled production backup | pass | The NPBackup overlap condition passed. Restic received the native S3 URI and all production parity options. Snapshot `f7417b35ab2e497052e33894d5b084a16260bc71e5c76780c7405cbf4454551f` completed with 659,639 files and 455,495,193 bytes; no retention or cutover ran. | +| 2026-07-20 | T007 normal scheduled backup | pass | The enabled 03:30 timer completed snapshot `ffafd15e6948ba278101463f85ac192176e83f8e423d109b5c06254859197de9` with 659,639 files and 16,771,834 bytes. | +| 2026-07-20 | T007 post-backup bounded restore | pass | `tl restore files` restored `/etc/hostname` from `f7417b35...` into root-only verification storage; the restored file matched the live file byte-for-byte. | +| 2026-07-20 | T007 timer dependency repair | pass | The invalid generated `Requires=...service` dependency was removed on-host and from the renderer. One persistent catch-up run completed safely as `a57f037d...`; 43 focused schedule/integration tests passed. The service is inactive and the enabled timer is waiting for 2026-07-21 03:30. | ## Residual Risks -- NPBackup and TimeLocker pattern semantics may differ; the effective expanded - exclusion set needs bounded comparison before production backup. - Credential values now exist in a second protected location and must be rotated when the source Restic service-account environment changes. - Same-repository overlap can lock or duplicate work; timers must not overlap. +- A manual restart of a persistent timer after a missed calendar event can + legitimately trigger one catch-up run; operators must observe service state + when changing installed timer configuration. - Retention enforcement can delete snapshots and remains simulation-only until separately reviewed. - The first installed T006 artifact is retained for rollback; the accepted - `current` release is the repaired `6896c8d` artifact. -- Timer installation and cutover remain external mutations requiring approval. + `current` release is the repaired `daaad53` artifact. +- NPBackup cutover remains an external mutation requiring separate approval. ## Readiness Decision @@ -86,5 +93,7 @@ last_reviewed: 2026-07-19 - **Credential source ready for production attachment:** yes; T005 passed. - **Ready for production repository attachment:** yes; T006 passed listing and bounded restore from the committed root-owned artifact. -- **Ready for timer installation or NPBackup cutover:** no; T007-T008 and - their explicit approvals remain pending. +- **Ready for scheduled TimeLocker backups:** yes; T007 passed controlled and + normal timer runs plus a subsequent bounded restore. +- **Ready for NPBackup cutover:** no; T008 and explicit operator approval remain + pending. diff --git a/src/TimeLocker/cli_modules/commands/schedule.py b/src/TimeLocker/cli_modules/commands/schedule.py index ed1e6c7..955610b 100644 --- a/src/TimeLocker/cli_modules/commands/schedule.py +++ b/src/TimeLocker/cli_modules/commands/schedule.py @@ -328,7 +328,6 @@ def _generate_systemd_script(schedule_name: str, schedule: Dict[str, Any], confi timer = f"""[Unit] Description=TimeLocker Backup Timer - {schedule_name} -Requires=timelocker-{schedule_name}.service [Timer] OnCalendar={oncalendar} diff --git a/tests/TimeLocker/cli/test_schedule_commands.py b/tests/TimeLocker/cli/test_schedule_commands.py index 864c87c..0bfa65a 100644 --- a/tests/TimeLocker/cli/test_schedule_commands.py +++ b/tests/TimeLocker/cli/test_schedule_commands.py @@ -257,6 +257,7 @@ def test_linux_renderers_reference_environment_without_secret_values(self, tmp_p assert "User=root" in service assert "backup create --selection protected-files" in cron assert "--repository pilot-repo" in service + assert "Requires=timelocker-pilot.service" not in timer assert "RESTIC_PASSWORD=" not in cron + service + timer @pytest.mark.unit From d394a30551d1dbf451b1ad0e6a4cb965fe858141 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:47:32 +0100 Subject: [PATCH 24/72] docs(spec): record NPBackup cutover --- docs/guides/developer/scheduling-guide.md | 7 +++++++ .../008-npbackup-migration-parity/tasks.md | 14 ++++++++----- .../traceability.md | 14 ++++++------- .../verification.md | 20 ++++++++++++------- 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/docs/guides/developer/scheduling-guide.md b/docs/guides/developer/scheduling-guide.md index 8e884fd..f0ef30c 100644 --- a/docs/guides/developer/scheduling-guide.md +++ b/docs/guides/developer/scheduling-guide.md @@ -141,6 +141,13 @@ Keep the NPBackup job enabled while TimeLocker is staged: Do not extract masked NPBackup secrets, install a privileged timer, or disable NPBackup as part of schedule generation. +TimeLocker backup schedules currently run `tl backup create` only. They do not +automatically run `tl repos forget` or `tl repos prune`. After a cutover, +continue the reviewed manual retention procedure until a separate maintenance +schedule has been designed, dry-run, and explicitly approved. Do not append +retention or prune to the backup service without failure isolation and rollback +handling; backup success must not imply approval for snapshot deletion. + ## Validation and troubleshooting ```bash diff --git a/docs/specs/008-npbackup-migration-parity/tasks.md b/docs/specs/008-npbackup-migration-parity/tasks.md index 79ae686..1892b14 100644 --- a/docs/specs/008-npbackup-migration-parity/tasks.md +++ b/docs/specs/008-npbackup-migration-parity/tasks.md @@ -109,8 +109,10 @@ last_reviewed: 2026-07-20 tests and the full normal profile passed: 2,798 passed, one skipped, 57 deselected, and 52.55% coverage. The replacement wheel SHA-256 is `c6998f9af68068185d80ad6261086fdc0dd8092cc38d97565a529bce1ab421e5`. - - Status: Complete: production-equivalent scheduled backup and subsequent bounded restore passed; NPBackup remains the active fallback pending separate cutover approval. -- [ ] T008 Checkpoint - Separate NPBackup cutover decision. + - Status: Complete: production-equivalent scheduled backup and subsequent + bounded restore passed. T008 subsequently completed the separately approved + option-2 cutover. +- [x] T008 Checkpoint - Separate NPBackup cutover decision. - Depends on: T007 - Requirement: Requirement 3 - Acceptance: Evidence supports a deliberate decision to retain, disable, or @@ -118,11 +120,13 @@ last_reviewed: 2026-07-20 - Decision owner: operator - Evidence mode: manual + - Evidence: On 2026-07-20 the operator selected cutover option 2. The guarded root cutover verified the TimeLocker timer enabled and active, refused overlap with a running service, saved root's prior crontab at /var/lib/timelocker/migration-backup/root-crontab-before-cutover-20260720T054308Z, and disabled the single active NPBackup cron entry. The TimeLocker timer remained active with next run Tue 2026-07-21 03:30:00 IST. Retention automation is not configured; the existing manual forget process remains required. + - Status: Complete: TimeLocker retained as the scheduled backup and the NPBackup cron entry disabled with an explicit rollback artifact; retention remains manual. ## Rules Consulted Coding Standards (100), General Preferences (50), Operational Best Practices (40), Planning Protocol (30), Testing Conventions (25), Documentation Conventions (20), and Git Conventions (15). User approvals covered Phase 1, -the T005 credential copy, T006 installation, and T007 timer observation. T008 -remains a separate explicit operator decision; NPBackup and retention are -unchanged. +the T005 credential copy, T006 installation, T007 timer observation, and the +T008 option-2 cutover. The NPBackup cron entry is disabled with a root-only +rollback artifact. Retention is unchanged and remains manual. diff --git a/docs/specs/008-npbackup-migration-parity/traceability.md b/docs/specs/008-npbackup-migration-parity/traceability.md index 5727925..764db87 100644 --- a/docs/specs/008-npbackup-migration-parity/traceability.md +++ b/docs/specs/008-npbackup-migration-parity/traceability.md @@ -20,7 +20,7 @@ last_reviewed: 2026-07-20 | T005 | Requirement 3 | AC2 | Security And Rollback | credential-path review | installation guidance | D001 resolved | | T006 | Requirement 3 | AC2-AC3 | Operator Staging Design | version, list, and restore | installation and recovery guides | none | | T007 | Requirement 1, Requirement 3 | R1 AC5; R3 AC3-AC4 | Low-Level Design; Operator Staging Design | focused parity tests, scheduled runs, and restore | backup and scheduling guides | none | -| T008 | Requirement 3 | AC4 | Security And Rollback | operator decision | scheduling guide | D002 | +| T008 | Requirement 3 | AC4 | Security And Rollback | operator decision and guarded cutover | scheduling guide | D002 resolved | ## Requirement To Delivery Matrix @@ -28,7 +28,7 @@ last_reviewed: 2026-07-20 |-------------|----------|---------------------|-----------------|-------|--------------|-----------------|----------------|----------------------| | Requirement 1 | must-have | AC1-AC5 | Overview; Low-Level Design | T002, T004, T007 | focused and normal-profile backup tests; live migrated command | recovery operations guide | complete | none | | Requirement 2 | must-have | AC1-AC4 | High-Level Design; Compatibility | T003, T004 | schedule and renderer tests | scheduling guide | complete | none | -| Requirement 3 | must-have | AC1-AC4 | Operational Considerations; Security And Rollback | T004-T008 | host comparison, restore, observation, decision | installation and scheduling guides | partial-blocking | T008 | +| Requirement 3 | must-have | AC1-AC4 | Operational Considerations; Security And Rollback | T004-T008 | host comparison, restore, observation, and guarded cutover | installation and scheduling guides | complete | none | ## Design To Implementation Matrix @@ -36,17 +36,17 @@ last_reviewed: 2026-07-20 |----------------|--------------|-------|---------------------|--------------|----------------|----------------------| | Overview and Low-Level Design | Requirement 1 | T002 | backup CLI, request, target, orchestrators, Restic adapter | focused and normal-profile backup tests | complete | none | | High-Level Design and Compatibility | Requirement 2 | T003 | schedule commands, records, renderers | schedule tests and parser round trip | complete | none | -| Operational Considerations | Requirement 3 | T004-T008 | docs, root-owned installation, timer | host comparison, list, restore, observed runs | partial-blocking | T008 | +| Operational Considerations | Requirement 3 | T004-T008 | docs, root-owned installation, timer | host comparison, list, restore, observed runs, and guarded cutover | complete | none | ## Open Decision Impact | Decision ID | Blocks | Affected Requirements | Affected Tasks | Resolution Needed | |-------------|--------|-----------------------|----------------|-------------------| | D001 (resolved 2026-07-19) | none | Requirement 3 | T005-T006 | Operator approved root-owned mode-0600 `/etc/timelocker/npbackup-migration.env`; no values enter repository evidence. | -| D002 | NPBackup cutover disposition | Requirement 3 | T008 | Timer installation and observation are approved and complete; the operator must separately decide whether to retain, disable, or roll back TimeLocker and whether to change NPBackup. | +| D002 (resolved 2026-07-20) | none | Requirement 3 | T008 | Operator selected option 2: retain the active TimeLocker timer and disable the single NPBackup cron entry, preserving a root-only crontab rollback artifact. Retention remains manual and outside this cutover decision. | ## Open Gate -T001-T007 are complete. T008 remains the explicit operator decision about -TimeLocker retention and NPBackup cutover; no cron, retention, prune, or -cutover change may be inferred from the completed observation work. +T001-T008 are complete. The TimeLocker backup timer is active and the NPBackup +cron entry is disabled with a recoverable crontab backup. Automatic retention +is not configured and must be handled as separate follow-on work. diff --git a/docs/specs/008-npbackup-migration-parity/verification.md b/docs/specs/008-npbackup-migration-parity/verification.md index 5cda0c4..9724c52 100644 --- a/docs/specs/008-npbackup-migration-parity/verification.md +++ b/docs/specs/008-npbackup-migration-parity/verification.md @@ -21,7 +21,7 @@ last_reviewed: 2026-07-20 | Durable guidance promoted | passed | Both current guides pass bounded Markdown checks with zero findings. | | Production attachment and restore | passed | Root-owned release `6896c8d` listed snapshot `8958659e` and restored `/etc/hostname` selectively with a byte-for-byte match. | | Scheduled production observation | passed | Controlled snapshot `f7417b35`, the normal 03:30 snapshot `ffafd15e`, and a subsequent byte-matched bounded restore passed. | -| NPBackup cutover | blocked | T008 remains a separate explicit operator decision; NPBackup is unchanged. | +| NPBackup cutover | passed | T008 option 2 disabled the single NPBackup cron entry after saving a root-only rollback copy; the TimeLocker timer remains active. | ## Baseline Evidence @@ -38,7 +38,7 @@ last_reviewed: 2026-07-20 |-------------|-----------------------------|----------|---------------| | Requirement 1 | AC1-AC5 | T002 focused tests, normal-profile coverage, and live production-equivalent Restic command evidence | none | | Requirement 2 | AC1-AC4 | T003 stored schedule and renderer tests | none for Phase 1 | -| Requirement 3 | AC1-AC4 | Protected credential source, committed root-owned release, repository listing, non-overlapping scheduled runs, bounded restore, and unchanged NPBackup fallback | Cutover remains a separate T008 decision. | +| Requirement 3 | AC1-AC4 | Protected credential source, committed root-owned release, repository listing, non-overlapping scheduled runs, bounded restore, and guarded option-2 cutover | Automatic retention is separate follow-on work, not a migration acceptance criterion. | ## Evidence Log @@ -72,6 +72,7 @@ last_reviewed: 2026-07-20 | 2026-07-20 | T007 normal scheduled backup | pass | The enabled 03:30 timer completed snapshot `ffafd15e6948ba278101463f85ac192176e83f8e423d109b5c06254859197de9` with 659,639 files and 16,771,834 bytes. | | 2026-07-20 | T007 post-backup bounded restore | pass | `tl restore files` restored `/etc/hostname` from `f7417b35...` into root-only verification storage; the restored file matched the live file byte-for-byte. | | 2026-07-20 | T007 timer dependency repair | pass | The invalid generated `Requires=...service` dependency was removed on-host and from the renderer. One persistent catch-up run completed safely as `a57f037d...`; 43 focused schedule/integration tests passed. The service is inactive and the enabled timer is waiting for 2026-07-21 03:30. | +| 2026-07-20 | T008 option-2 cutover | pass | The guarded root cutover verified the TimeLocker timer active, backed up root's crontab to `/var/lib/timelocker/migration-backup/root-crontab-before-cutover-20260720T054308Z`, and disabled the single active NPBackup cron entry. The next TimeLocker run remains 2026-07-21 03:30. No retention or prune operation ran. | ## Residual Risks @@ -81,11 +82,14 @@ last_reviewed: 2026-07-20 - A manual restart of a persistent timer after a missed calendar event can legitimately trigger one catch-up run; operators must observe service state when changing installed timer configuration. -- Retention enforcement can delete snapshots and remains simulation-only until - separately reviewed. +- Retention is not part of the backup timer and remains a manual operation until + a separate destructive-operation schedule is designed and reviewed. The + operator's current policy is keep 5 daily, 4 weekly, 12 monthly, and 3 yearly + snapshots without prune; TimeLocker must preserve those explicit values. - The first installed T006 artifact is retained for rollback; the accepted `current` release is the repaired `daaad53` artifact. -- NPBackup cutover remains an external mutation requiring separate approval. +- Rollback requires restoring the saved root crontab if the TimeLocker schedule + is withdrawn. ## Readiness Decision @@ -95,5 +99,7 @@ last_reviewed: 2026-07-20 bounded restore from the committed root-owned artifact. - **Ready for scheduled TimeLocker backups:** yes; T007 passed controlled and normal timer runs plus a subsequent bounded restore. -- **Ready for NPBackup cutover:** no; T008 and explicit operator approval remain - pending. +- **NPBackup cutover complete:** yes; T008 option 2 was explicitly approved and + executed with a root-only rollback artifact. +- **Automatic retention ready:** no; the backup timer does not run forget or + prune, so the existing manual cleanup remains required. From 5830194fbd87596aa1783692dcfac9267eb884ca Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:05:54 +0100 Subject: [PATCH 25/72] docs(spec): strengthen cutover evidence --- docs/specs/008-npbackup-migration-parity/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/008-npbackup-migration-parity/tasks.md b/docs/specs/008-npbackup-migration-parity/tasks.md index 1892b14..c72f0c2 100644 --- a/docs/specs/008-npbackup-migration-parity/tasks.md +++ b/docs/specs/008-npbackup-migration-parity/tasks.md @@ -120,7 +120,7 @@ last_reviewed: 2026-07-20 - Decision owner: operator - Evidence mode: manual - - Evidence: On 2026-07-20 the operator selected cutover option 2. The guarded root cutover verified the TimeLocker timer enabled and active, refused overlap with a running service, saved root's prior crontab at /var/lib/timelocker/migration-backup/root-crontab-before-cutover-20260720T054308Z, and disabled the single active NPBackup cron entry. The TimeLocker timer remained active with next run Tue 2026-07-21 03:30:00 IST. Retention automation is not configured; the existing manual forget process remains required. + - Evidence: On 2026-07-20 the operator selected option 2 and ran `sudo bash /tmp/timelocker-cutover-option-2.sh`; it exited 0. The command reported `NPBackup cron entry disabled`, saved `/var/lib/timelocker/migration-backup/root-crontab-before-cutover-20260720T054308Z`, reported the TimeLocker timer `active`, and reported its next run as `Tue 2026-07-21 03:30:00 IST`. The guarded script had first verified the timer enabled/active, the backup service inactive with a successful prior result, and exactly one active NPBackup cron entry. Retention automation is not configured; the existing manual forget process remains required. - Status: Complete: TimeLocker retained as the scheduled backup and the NPBackup cron entry disabled with an explicit rollback artifact; retention remains manual. ## Rules Consulted From 1bfea0833ee45c5dc468f979c2d4e5a1b2c8dee3 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:07:11 +0100 Subject: [PATCH 26/72] docs(spec): close NPBackup migration --- docs/history/spec-archive-index.md | 1 + docs/history/spec-closure-log.md | 22 +++ .../canonical-context.md | 79 ---------- .../change-impact.md | 40 ------ .../008-npbackup-migration-parity/design.md | 136 ------------------ .../requirements.md | 135 ----------------- .../008-npbackup-migration-parity/tasks.md | 132 ----------------- .../traceability.md | 52 ------- .../verification.md | 105 -------------- 9 files changed, 23 insertions(+), 679 deletions(-) delete mode 100644 docs/specs/008-npbackup-migration-parity/canonical-context.md delete mode 100644 docs/specs/008-npbackup-migration-parity/change-impact.md delete mode 100644 docs/specs/008-npbackup-migration-parity/design.md delete mode 100644 docs/specs/008-npbackup-migration-parity/requirements.md delete mode 100644 docs/specs/008-npbackup-migration-parity/tasks.md delete mode 100644 docs/specs/008-npbackup-migration-parity/traceability.md delete mode 100644 docs/specs/008-npbackup-migration-parity/verification.md diff --git a/docs/history/spec-archive-index.md b/docs/history/spec-archive-index.md index e238a69..5b9bb49 100644 --- a/docs/history/spec-archive-index.md +++ b/docs/history/spec-archive-index.md @@ -16,6 +16,7 @@ contracts. | Spec ID | Title | Package path | Status | Final spec commit | Cleanup commit | Closure action | Durable destinations | Verification | |---------|-------|--------------|--------|-------------------|----------------|----------------|----------------------|--------------| +| 008-npbackup-migration-parity | NPBackup migration parity requirements | `docs/specs/008-npbackup-migration-parity/` | removed | 5830194 | pending-cleanup-commit | removed | `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md`; `spec package`; `backup operations guide`; `scheduling guide`; `both guides`; `installation guidance`; `installation and recovery guides`; `backup and scheduling guides`; `T005-T008 or explicit follow-up spec` | `docs/history/spec-closure-log.md` | | 001-cli-consolidation-stabilization | CLI Consolidation Stabilization | removed; recover from Git | removed | `a1bb654` | `b8df9e9` | removed | `docs/3-implementation/service-layer-integration.md`; `docs/reference/repo-orientation-and-change-map.md`; `docs/specs/README.md`; `docs/history/` | `docs/history/spec-closure-log.md` | | 002-repository-safety-release-readiness | Repository Safety and Release Readiness | removed; recover from Git | removed | `4aff166` | `c6ed9ee` | removed | `README.md`; `docs/2-architecture/`; `docs/guides/user/installation.md`; `docs/guides/user/per-repo-credentials.md`; `docs/processes/version-management.md`; `docs/DOCUMENTATION-STATUS.md`; `docs/specs/README.md` | `docs/history/spec-closure-log.md` | | 006-repository-review-skill | Repository Review Skill | removed; recover from Git | removed | `62dac67` | `82f0247` | removed | `.agents/skills/review-timelocker/`; `AGENTS.md` | `docs/history/spec-closure-log.md` | diff --git a/docs/history/spec-closure-log.md b/docs/history/spec-closure-log.md index 3943d7e..2e4444e 100644 --- a/docs/history/spec-closure-log.md +++ b/docs/history/spec-closure-log.md @@ -15,6 +15,28 @@ final spec commit preserves the complete package. ## Entries +### 2026-07-20 - 008-npbackup-migration-parity + +- **Spec:** `docs/specs/008-npbackup-migration-parity/` +- **Title:** NPBackup migration parity requirements +- **Final spec commit:** `5830194` +- **Closure cleanup commit:** `pending-cleanup-commit` +- **Closure action:** removed +- **Durable docs updated:** + - `docs/guides/user/recovery-operations-guide.md` + - `docs/guides/developer/scheduling-guide.md` + - `spec package` + - `backup operations guide` + - `scheduling guide` + - `both guides` + - `installation guidance` + - `installation and recovery guides` + - `backup and scheduling guides` + - `T005-T008 or explicit follow-up spec` +- **Verification summary:** Closure validation not yet executed. +- **Residual risks:** + - none +- **Follow-up:** none ### 2026-07-18 - 001-cli-consolidation-stabilization - **Spec:** removed; recover from Git diff --git a/docs/specs/008-npbackup-migration-parity/canonical-context.md b/docs/specs/008-npbackup-migration-parity/canonical-context.md deleted file mode 100644 index 9cccb35..0000000 --- a/docs/specs/008-npbackup-migration-parity/canonical-context.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -title: NPBackup migration parity canonical context -doc_type: spec -artifact_type: canonical-context -status: active -owner: Auriora Team -last_reviewed: 2026-07-19 ---- - -# Canonical Context - -## Purpose - -Prevent historical plans, masked sensitive data, or an uncommitted checkout -from being mistaken for authority during the staged NPBackup migration. - -## Authority Hierarchy - -- `CHARTER.md` owns safety, recovery, credentials, and operator boundaries. -- Spec 007 commit `433c0aa` owns the machine-accepted backup, restore, tray, and - executable-schedule baseline. -- This package owns migration parity and sequencing only. -- Current source, tests, and generated argv own implemented behavior. -- The live root crontab and NPBackup masked interface own existing-job evidence. - -## Always-Canonical External Sources - -| Source | Authority reason | Handling | -|--------|------------------|----------| -| `AGENTS.md` and `CHARTER.md` | Repository governance and safety boundary | Stop for a scope decision on conflict. | -| `docs/guides/ai-agent/` | Operational agent rules | Apply by documented priority. | -| source, tests, generated argv, and live masked host evidence | Implemented and operator truth | Reconcile conflicts into the package. | - -## Spec-Canonical Working Sources - -| Source | Role | Scope | Notes | -|--------|------|-------|-------| -| `requirements.md` | accepted intent | Spec 008 | Phase 2 actions still require named approvals. | -| `design.md` | implementation approach | Spec 008 | Does not authorize host mutation. | -| `tasks.md` | execution index | Spec 008 | Read with traceability and verification. | - -## Imported Sources - -| Spec path | Source path | Source revision or date | Status | Canonical scope | Promotion target | -|-----------|-------------|-------------------------|--------|-----------------|------------------| -| `canonical-context.md` | Spec 007 verification | `433c0aa` | summarized | machine-acceptance dependency | current user/operator guides | -| `canonical-context.md` | live masked NPBackup evidence | 2026-07-19 | summarized | existing job semantics only | installation and scheduling guides | - -## Non-Canonical Background Sources - -| Source | Reason non-canonical | Handling | -|--------|----------------------|----------| -| NPBackup ciphertext and unexpanded implementation internals | Not a usable credential or reviewed TimeLocker contract | Never copy, print, or infer plaintext values. | -| deleted or archived historical plans | No current-state authority | Use only for provenance when explicitly needed. | - -## Promotion Map - -| Spec-local content | Durable destination or route | Required before closure | -|--------------------|------------------------------|-------------------------| -| accepted backup execution options | `docs/guides/user/recovery-operations-guide.md` | yes | -| accepted schedule fields and staging workflow | `docs/guides/developer/scheduling-guide.md` | yes | -| unresolved credential, observation, and cutover work | T005-T008 or explicit follow-up spec | yes | - -## Sensitive Context Boundary - -The NPBackup repository URI, repository password, and AWS-compatible values are -intentionally absent from this package and session evidence. A byte-identical -copy exists at the root-owned, mode-0600 -`/etc/timelocker/npbackup-migration.env`; only its path, metadata, expected -variable names, and successful non-empty load check are recorded. Other safe -metadata includes the six source paths, option/retention shape, -exclusion-source categories, schedule, execution identity, and recent snapshot -identity. - -## Sequencing Decision - -Spec 008 may be active while Spec 007 awaits release/closure decisions because -it depends on the committed Spec 007 implementation and cannot publish a -release or close Spec 007. Phase 2 host mutations require their own approvals. diff --git a/docs/specs/008-npbackup-migration-parity/change-impact.md b/docs/specs/008-npbackup-migration-parity/change-impact.md deleted file mode 100644 index 3c049c0..0000000 --- a/docs/specs/008-npbackup-migration-parity/change-impact.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: NPBackup migration parity change impact -doc_type: spec -artifact_type: change-impact -status: active -owner: Auriora Team -last_reviewed: 2026-07-19 ---- - -# Change Impact - -## Durable Source Mapping - -| Source | Current behavior relied on | Confidence | Notes | -|--------|----------------------------|------------|-------| -| `docs/guides/user/recovery-operations-guide.md` | Current backup execution workflow | high | Needs explicit compression and traversal options. | -| `docs/guides/developer/scheduling-guide.md` | Current schedule and renderer workflow | high | Needs persisted fields and staging boundary. | - -## Proposed Changes - -| Change | Type | Source of truth | New durable destination | Promotion required | -|--------|------|-----------------|-------------------------|-------------------| -| Backup compression | add | backup CLI/request/target and Restic adapter | user backup guidance | yes | -| Filesystem traversal | add | backup CLI/request/target and Restic adapter | user backup guidance | yes | -| Schedule parity | modify | schedule commands and renderers | scheduling guide | yes | -| Host installation | migration | approved Phase 2 only | installation and scheduling guides | yes, after acceptance | - -## Promotion Targets - -| Spec content | Durable destination | Promotion status | Notes | -|--------------|---------------------|------------------|-------| -| Backup execution parity | `docs/guides/user/recovery-operations-guide.md` | complete | Promoted after focused tests passed. | -| Stored schedule parity and safe staging | `docs/guides/developer/scheduling-guide.md` | complete | Credential and cutover gates remain explicit. | - -## Unchanged Boundaries - -- No repository or credential format changes. -- No automatic service installation or crontab mutation. -- No retention enforcement or prune behavior in Phase 1. -- Spec 007 retains release approval and lifecycle closure authority. diff --git a/docs/specs/008-npbackup-migration-parity/design.md b/docs/specs/008-npbackup-migration-parity/design.md deleted file mode 100644 index f0c5d86..0000000 --- a/docs/specs/008-npbackup-migration-parity/design.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -title: NPBackup migration parity design -doc_type: spec -artifact_type: design -status: active -owner: Auriora Team -last_reviewed: 2026-07-19 ---- - -# Technical Design - -## Overview - -Add typed backup execution options at the existing CLI-to-target boundary. -`CLIBackupRequest` and selection-job metadata carry `compression` and -`one_file_system`; `BackupTarget` exposes them to `ResticRepository`, which -validates one invocation-wide value and adds the corresponding Restic flags. -This avoids widening the abstract repository interface or changing unrelated -backend signatures. - -Schedules persist `tags`, `exclude_patterns`, `compression`, and -`one_file_system`. `_build_backup_command` remains the single renderer -source for cron, systemd, and Windows and emits current CLI options using -argument-safe platform quoting. - -## High-Level Design - -### Components And Changes - -- The backup CLI and `CLIBackupRequest` accept typed execution options. -- Direct and selection-based orchestrators carry them into `BackupTarget`. -- `ResticRepository` converts consistent target options to Restic argv. -- Schedule storage and `_build_backup_command` preserve the same options - across cron, systemd, and Windows renderers. - -### Data Flow - -```text -CLI or stored schedule -> CLIBackupRequest/job metadata -> BackupTarget - -> ResticRepository -> argument-safe restic backup argv -``` - -## Low-Level Design - -### Contracts And Interfaces - -Add optional `compression`, default-false `one_file_system` and -`exclude_caches`, repeatable `exclude_files`, and repeatable allowlisted -`backend_options` fields to `CLIBackupRequest` and `BackupTarget`. Add the same -execution fields to schedule records. The abstract -repository method remains unchanged; the Restic adapter reads invocation -options from the concrete targets it already receives. - -### Error Handling - -Click validates the public compression choice. The Restic adapter independently -rejects unsupported or inconsistent target values before invoking Restic so -programmatic callers cannot bypass the guardrail. - -## Compatibility - -- `compression=None` emits no argument and preserves Restic's current default. -- `one_file_system=False` emits no argument. -- Missing schedule fields load as empty/false/none. -- Existing repository adapters may ignore target execution options; Restic is - the only backend in this migration acceptance path. - -## Validation And Failure Handling - -- Validate compression at the CLI and Restic adapter boundary. -- Reject conflicting target-level invocation options rather than choosing one. -- Accept only `s3.storage-class` as the initial migrated backend option and - validate its Restic-supported value before invoking the repository. -- Test direct and selection-based backup propagation. -- Test stored schedule creation, editing, display, parser round trip, and all - renderers. -- Run focused tests, CLI help checks, compile, and whitespace checks before the - Phase 1 checkpoint. - -## Operator Staging Design - -After Phase 1 is committed, build a wheel and install it into a root-owned -virtual environment such as `/opt/timelocker/venv`. Store configuration under -`/etc/timelocker` and reference a mode-0600 root-owned environment file. Attach -to the existing repository read-only, list and restore snapshot `8958659e`, -then stage a disabled system timer at a non-overlapping time. Retention remains -simulation-only during overlap. - -The T007 host reconciliation found 252 unique patterns across three NPBackup -exclude files, cache-directory exclusion enabled, and -`s3.storage-class=INTELLIGENT_TIERING`. Preserve the files by reference rather -than expanding their contents into generated unit arguments; preserve cache -semantics with Restic `--exclude-caches` and the storage class through the -allowlisted global backend option. - -## Security And Rollback - -No NPBackup ciphertext is copied as a usable credential. Credential transfer -must use operator-supplied values or an explicitly approved secure export that -never prints values. For this host, the operator approved a byte-identical copy -of the existing Restic service-account environment into the root-owned, -mode-0600 `/etc/timelocker/npbackup-migration.env`; its values remain outside -repository and session evidence. Phase 1 rollback is a code revert. Later host -rollback is disabling/removing the TimeLocker timer while leaving root's -NPBackup cron untouched until final cutover approval. - -## Durable Promotion - -Promote accepted CLI options to user backup guidance and schedule fields to the -operator scheduling guide. Production installation and cutover evidence remain -in verification until accepted, then only current operating instructions are -promoted. - -## Operational Considerations - -Phase 1 changes code, tests, and durable guidance only. Root installation, -credential provisioning, repository attachment, timer installation, retention, -and NPBackup cutover remain explicit Phase 2 operator gates. - -## Resolved Decisions - -- D001 is resolved: `/etc/timelocker/npbackup-migration.env`, copied without - value output from the existing Restic service-account environment, supplies - the production repository and backend credentials. - -## Resolved T007 Reconciliation - -- The effective NPBackup exclusion set requires explicit migration support: - three reviewed exclusion files remain referenced, and cache-directory - exclusion is carried separately. Expanding 252 patterns into generated unit - arguments is rejected because it duplicates another tool's maintained files. - -## Open Questions - -- No implementation question remains for T007. D002 remains the separate - operator decision about production schedule retention and NPBackup cutover. diff --git a/docs/specs/008-npbackup-migration-parity/requirements.md b/docs/specs/008-npbackup-migration-parity/requirements.md deleted file mode 100644 index 9d41483..0000000 --- a/docs/specs/008-npbackup-migration-parity/requirements.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -title: NPBackup migration parity requirements -doc_type: spec -artifact_type: requirements -status: active -owner: Auriora Team -last_reviewed: 2026-07-19 ---- - -# Requirements - -## Introduction - -Preserve the observable backup semantics of the existing root-owned NPBackup -job before TimeLocker is installed or scheduled against its repository. This -package follows the machine-acceptance implementation committed by Spec 007 at -`433c0aa`; it does not authorize credential extraction, privileged -installation, release publication, or NPBackup cutover. - -## Known Operator Baseline - -- Root cron runs daily at 17:30. -- Active sources are `/home`, `/etc`, `/var`, `/srv`, `/root`, and `/nix/var`. -- The job requests maximum Restic compression, single-filesystem traversal, - tag `Bruce-5560`, three configured patterns, and NPBackup built-in excludes. -- The repository URI, password, and AWS-compatible credentials are encrypted. -- Snapshot `8958659e` dated 2026-07-18 is the latest verified recent snapshot. - -## Goals - -- Carry compression and filesystem-boundary intent from CLI and schedules to - the Restic invocation. -- Carry backup tags and exclusions through generated schedules. -- Preserve default behavior for existing callers and stored schedules. -- Establish a root-owned, credential-safe, observable migration sequence. - -## Non-Goals - -- Decrypting or copying NPBackup credentials without a separate secure choice. -- Installing or enabling a system service during Phase 1. -- Disabling or editing either NPBackup crontab during Phase 1. -- Applying destructive retention or prune operations during overlap. -- Publishing TimeLocker or closing Spec 007. - -## Durable Source Baseline - -| Source | Current behavior relied on | Confidence | Notes | -|--------|----------------------------|------------|-------| -| `docs/guides/user/recovery-operations-guide.md` | Current backup CLI and repository workflow | high | Add accepted execution options during promotion. | -| `docs/guides/developer/scheduling-guide.md` | Current schedule creation and rendering workflow | high | Add persisted parity fields during promotion. | -| Spec 007 verification at `433c0aa` | Machine-acceptance and executable-schedule baseline | high | Committed implementation dependency. | -| live masked NPBackup configuration and root cron, inspected 2026-07-19 | Current operator-job semantics | high | Sensitive values were not captured. | - -## Requirements - -### Requirement 1: Backup execution parity - -**User story:** As the operator, I want explicit Restic execution options to -reach the backup engine so that a migrated job does not silently change its -filesystem or compression boundary. - -**Priority:** must-have - -### Acceptance Criteria - -1. GIVEN compression `auto`, `off`, or `max`, WHEN `backup create` runs, THEN - the selected value SHALL reach Restic as `--compression`. -2. IF an unsupported compression value is supplied, THEN TimeLocker SHALL fail - before repository mutation with an actionable validation error. -3. GIVEN `--one-file-system`, WHEN a backup runs, THEN Restic SHALL receive - `--one-file-system`; existing callers without the option SHALL retain - cross-filesystem behavior. -4. GIVEN tags and exclude patterns, WHEN the backup runs, THEN all values SHALL - reach the existing tag and exclusion command path without credential output. -5. GIVEN reviewed exclusion files, cache-directory exclusion, and allowlisted - Restic backend options, WHEN the migrated production backup runs, THEN those - values SHALL reach Restic exactly and unsupported backend options SHALL fail - before repository mutation. - -### Requirement 2: Executable schedule parity - -**User story:** As the operator, I want a stored schedule to retain execution -options so generated assets represent the reviewed migration contract. - -**Priority:** must-have - -### Acceptance Criteria - -1. Schedule create/edit SHALL persist repeatable tags and exclusions, - compression, and the one-filesystem flag. -2. Cron, systemd, and Windows renderers SHALL emit only current `backup create` - options and preserve argument boundaries for spaces and metacharacters. -3. Existing schedules without new fields SHALL render with existing defaults. -4. Schedule show/list/test SHALL expose or validate the parity fields without - displaying credential values. - -### Requirement 3: Staged migration safety - -**User story:** As the operator, I want migration actions separated by risk so -NPBackup remains a recoverable fallback until TimeLocker is observed. - -**Priority:** must-have - -### Acceptance Criteria - -1. Phase 1 SHALL NOT install a service, change a crontab, or write plaintext - repository credentials. -2. The production install SHALL use a committed artifact in a root-owned - location rather than a mutable user pyenv checkout. -3. TimeLocker SHALL attach read-only and restore an existing snapshot before - its first production-source backup. -4. NPBackup SHALL remain active until non-overlapping scheduled TimeLocker runs - and a subsequent restore pass; cutover requires separate approval. - -## Correctness Properties - -- **CP-001:** Generated argv parses as the current CLI and preserves every - reviewed parity value exactly once per supplied value. -- **CP-002:** Default callers produce no new Restic compression or - one-filesystem arguments. -- **CP-003:** Invalid compression cannot reach repository execution. -- **CP-004:** Generated assets contain credential references only, never values. -- **CP-005:** Phase 1 leaves systemd, cron, NPBackup, and production credentials - unchanged. -- **CP-006:** Production migration does not silently drop NPBackup exclusion - files, cache-directory exclusion, or reviewed S3 storage-class intent. - -## Success Criteria - -- **SC-001:** Focused backup tests prove valid compression and filesystem - boundary options reach Restic while defaults emit no new arguments. -- **SC-002:** Schedule create, edit, show, test, and platform render tests prove - all parity fields survive storage and argument-safe rendering. -- **SC-003:** Phase 1 validation records no live scheduler, NPBackup, repository, - or credential mutation. diff --git a/docs/specs/008-npbackup-migration-parity/tasks.md b/docs/specs/008-npbackup-migration-parity/tasks.md deleted file mode 100644 index c72f0c2..0000000 --- a/docs/specs/008-npbackup-migration-parity/tasks.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -title: NPBackup migration parity tasks -doc_type: spec -artifact_type: tasks -status: active -owner: Auriora Team -last_reviewed: 2026-07-20 ---- - -# Tasks - -## Phase 1: Implement Migration Parity - -- [x] T001 Create and reconcile the migration-parity package. - - Depends on: Spec 007 implementation commit `433c0aa` - - Requirements: Requirement 1, Requirement 2, Requirement 3 - - Acceptance Criteria: all - - Properties: CP-001-CP-005 - - Acceptance: Safe host evidence, sequencing, implementation boundary, and - later operator gates are explicit and contain no secret values. - - Evidence: Spec Lifecycle Manager allocated `008`; the approved read-only - host inspection identified the active root cron, six sources, option and - retention shape, exclusion sources, and recent snapshot without changing - host state or revealing plaintext credentials. - - Evidence mode: artifact - -- [x] T002 Carry compression and filesystem-boundary options to Restic. - - Depends on: T001 - - Requirement: Requirement 1 - - Acceptance Criteria: AC1-AC4 - - Properties: CP-002, CP-003 - - Files: backup CLI/request/target/orchestrator paths, Restic adapter, tests - - Acceptance: Direct and selection backups propagate valid options; invalid - or conflicting options fail before execution; defaults remain compatible. - - Validation: Focused CLI, service, target, and Restic command tests. - - Evidence mode: implementation - - - Evidence: Implemented compression and one-filesystem propagation through direct CLI requests, selection-job metadata, BackupTarget, configuration, both orchestrator paths, and Restic argv. Adapter validation rejects invalid or conflicting values before subprocess execution; defaults emit no new arguments. Focused parity suite contribution passed within 99 tests on 2026-07-19. -- [x] T003 Persist and render schedule parity fields. - - Depends on: T002 - - Requirement: Requirement 2 - - Acceptance Criteria: AC1-AC4 - - Properties: CP-001, CP-004 - - Files: schedule CLI/renderers, tests, operator guide - - Acceptance: Create/edit/show/test and all renderers preserve tags, - exclusions, compression, and one-filesystem intent with safe quoting. - - Validation: Focused schedule tests and parser round trip. - - Evidence mode: implementation - - - Evidence: Schedule create/edit persist tags, exclusions, compression, and one-filesystem fields; list/show expose them; test validates the generated command; cron, systemd, and Windows render from the shared argument-safe builder. Durable backup and scheduling guides were promoted. Focused parity suite passed 99 tests on 2026-07-19. -- [x] T004 Checkpoint - Phase 1 parity ready for host staging. - - Depends on: T002, T003 - - Requirements: Requirement 1, Requirement 2, Requirement 3 AC1 - - Properties: CP-001-CP-005 - - Acceptance: Focused tests, CLI help, lifecycle checks, compile, docs, and - whitespace checks pass; host scheduler and credentials remain unchanged. - - Decision owner: project maintainer - - Evidence mode: validation - - - Evidence: Phase 1 checkpoint passed on 2026-07-19: 99 focused tests and the full normal profile (2,796 passed, one skipped, 57 deselected, 52.52% coverage) passed; compileall, git diff --check, zero-diagnostic Spec 008 lint, and zero-finding durable-guide Markdown checks passed. Read-only root-cron comparison still shows the 17:30 NPBackup job; no TimeLocker unit or /opt/timelocker installation exists, and no credential values were read or written. - -## Phase 2: Operator-Controlled Installation And Observation - -- [x] T005 Resolve the secure production repository and credential source. - - Depends on: T004 - - Requirement: Requirement 3 - - Acceptance: Exact URI and required environment values are supplied through - a root-only path without being printed or copied from masked ciphertext. - - Decision owner: operator - - Evidence mode: manual - - - Evidence: Operator-approved T005 completed on 2026-07-19: `/etc/timelocker/npbackup-migration.env` is root:root mode 0600 and byte-identical to the existing Restic service-account environment. It contains exactly the five expected non-empty Restic/AWS assignments and loads successfully as root; no values were emitted. Root's 17:30 NPBackup cron is unchanged, and no TimeLocker unit or `/opt/timelocker` installation exists. - - Status: D001 resolved; protected credential source ready. T006 privileged artifact installation remains separately gated. -- [x] T006 Install a committed root-owned TimeLocker artifact and attach read-only. - - Depends on: T005 and explicit privileged-install approval - - Requirement: Requirement 3 - - Acceptance: Root-owned versioned installation lists and restores existing - snapshot `8958659e`; NPBackup remains unchanged. - - Evidence mode: validation - - - Evidence: Committed selective-restore repair `6896c8d` passed 64 focused tests and the full normal profile (2,797 passed, one skipped, 57 deselected, 52.53% coverage). Wheel SHA-256 `876246c4783d63f4d9f1fae80c5a4180afe95fbcb5161df01278e5b60de8da3c` was installed root-owned at `/opt/timelocker/releases/6896c8d6d90cb4c8320ec1fa66b966d9eb2dabcd`; both entry points report 0.9.1. The protected named repository listed snapshot `8958659e`; a bounded `/etc/hostname` restore produced a nonempty root-only result matching the live file byte-for-byte. Root's 17:30 NPBackup cron remains present; no TimeLocker cron entry, systemd unit, or timer exists. No backup, retention, schedule, or cutover action ran. - - Evidence: Phase 1 was committed as `2c93709`; its root-owned release listed - the protected repository and found snapshot `8958659e`. The first bounded - restore exposed two recovery defects before Restic ran: selective validation - supplied an unsupported selection name, and include/exclude paths were not - propagated to the backend. The repair removes the invalid field and carries - bounded paths through the restore interfaces to repeated Restic arguments; - 64 focused recovery and adapter tests pass. - - Status: T006 complete. T007 remains separately gated by explicit timer-install approval. -- [x] T007 Stage, install, and observe a non-overlapping TimeLocker timer. - - Depends on: T006 and explicit timer-install approval - - Requirements: Requirement 1, Requirement 3 - - Acceptance Criteria: Requirement 1 AC5; Requirement 3 AC3-AC4 - - Properties: CP-006 - - Acceptance: Production-equivalent sources/options run successfully on the - scheduler and a subsequent restore passes; no retention deletion runs. - - Evidence mode: validation - - - Evidence: Root-owned release from commit `daaad53` (wheel SHA-256 `5c3106e573d3805b3e9962007c20d5e47cd88faccb1ac8d20c0c1f315f212867`) was installed under `/opt/timelocker/releases/daaad538f7adb02e27e86b744af43ead79f07408`. The NPBackup overlap condition passed and the controlled systemd run used the native repository URI `s3:s3.af-south-1.amazonaws.com/5560-restic` with six production sources, tag `Bruce-5560`, three direct exclusions, three exclusion files, cache exclusion, `s3.storage-class=INTELLIGENT_TIERING`, compression `max`, and one-filesystem traversal. It completed successfully on 2026-07-19 as snapshot `f7417b35ab2e497052e33894d5b084a16260bc71e5c76780c7405cbf4454551f` (659,639 files; 455,495,193 bytes). The normal 2026-07-20 03:30 timer run also completed successfully as snapshot `ffafd15e6948ba278101463f85ac192176e83f8e423d109b5c06254859197de9`. A bounded `tl restore files` of `/etc/hostname` from `f7417b35...` to `/var/lib/timelocker/verification/restore-f7417b35` completed and matched `/etc/hostname` byte-for-byte. The timer's invalid service dependency was removed on-host and from the generator; 43 focused schedule/integration tests passed. After one harmless persistent catch-up snapshot (`a57f037d...`), the service is inactive and the enabled timer is waiting for 2026-07-21 03:30. NPBackup remains active; no retention, prune, or cutover action ran. - - Evidence: Masked NPBackup reconciliation found three exclusion files with - 252 unique patterns, cache-directory exclusion enabled, and reviewed - `s3.storage-class=INTELLIGENT_TIERING` intent. These must be carried by the - committed TimeLocker artifact before the timer may run. - - Evidence: The 19:30 timer triggered and its NPBackup exclusion condition - passed, but the service exited by `SIGTRAP` before Restic started because - GTK tray initialization ran without a display. No backup or retention - operation completed. Commit `2eb9928` now skips native Linux tray startup - when neither `DISPLAY` nor `WAYLAND_DISPLAY` is present. Seven focused - tests and the full normal profile passed: 2,798 passed, one skipped, 57 - deselected, and 52.55% coverage. The replacement wheel SHA-256 is - `c6998f9af68068185d80ad6261086fdc0dd8092cc38d97565a529bce1ab421e5`. - - Status: Complete: production-equivalent scheduled backup and subsequent - bounded restore passed. T008 subsequently completed the separately approved - option-2 cutover. -- [x] T008 Checkpoint - Separate NPBackup cutover decision. - - Depends on: T007 - - Requirement: Requirement 3 - - Acceptance: Evidence supports a deliberate decision to retain, disable, or - roll back TimeLocker; changing root's NPBackup cron requires explicit approval. - - Decision owner: operator - - Evidence mode: manual - - - Evidence: On 2026-07-20 the operator selected option 2 and ran `sudo bash /tmp/timelocker-cutover-option-2.sh`; it exited 0. The command reported `NPBackup cron entry disabled`, saved `/var/lib/timelocker/migration-backup/root-crontab-before-cutover-20260720T054308Z`, reported the TimeLocker timer `active`, and reported its next run as `Tue 2026-07-21 03:30:00 IST`. The guarded script had first verified the timer enabled/active, the backup service inactive with a successful prior result, and exactly one active NPBackup cron entry. Retention automation is not configured; the existing manual forget process remains required. - - Status: Complete: TimeLocker retained as the scheduled backup and the NPBackup cron entry disabled with an explicit rollback artifact; retention remains manual. -## Rules Consulted - -Coding Standards (100), General Preferences (50), Operational Best Practices -(40), Planning Protocol (30), Testing Conventions (25), Documentation -Conventions (20), and Git Conventions (15). User approvals covered Phase 1, -the T005 credential copy, T006 installation, T007 timer observation, and the -T008 option-2 cutover. The NPBackup cron entry is disabled with a root-only -rollback artifact. Retention is unchanged and remains manual. diff --git a/docs/specs/008-npbackup-migration-parity/traceability.md b/docs/specs/008-npbackup-migration-parity/traceability.md deleted file mode 100644 index 764db87..0000000 --- a/docs/specs/008-npbackup-migration-parity/traceability.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -title: NPBackup migration parity traceability -doc_type: spec -artifact_type: traceability -status: active -owner: Auriora Team -last_reviewed: 2026-07-20 ---- - -# Traceability Matrix - -## Task To Context Matrix - -| Task ID | Requirements | Acceptance Criteria | Design Sections | Verification | Durable Targets | Open Decisions | -|---------|--------------|---------------------|-----------------|--------------|-----------------|----------------| -| T001 | Requirement 1, Requirement 2, Requirement 3 | all | Overview; Operational Considerations | host and lifecycle discovery | spec package | none | -| T002 | Requirement 1 | AC1-AC4 | Overview; Low-Level Design; Compatibility | CLI, service, target, and Restic tests | backup operations guide | none | -| T003 | Requirement 2 | AC1-AC4 | High-Level Design; Compatibility | schedule and renderer tests | scheduling guide | none | -| T004 | Requirement 1, Requirement 2, Requirement 3 | R1-R2 all; R3 AC1 | Validation And Failure Handling | Phase 1 checkpoint | both guides | none | -| T005 | Requirement 3 | AC2 | Security And Rollback | credential-path review | installation guidance | D001 resolved | -| T006 | Requirement 3 | AC2-AC3 | Operator Staging Design | version, list, and restore | installation and recovery guides | none | -| T007 | Requirement 1, Requirement 3 | R1 AC5; R3 AC3-AC4 | Low-Level Design; Operator Staging Design | focused parity tests, scheduled runs, and restore | backup and scheduling guides | none | -| T008 | Requirement 3 | AC4 | Security And Rollback | operator decision and guarded cutover | scheduling guide | D002 resolved | - -## Requirement To Delivery Matrix - -| Requirement | Priority | Acceptance Criteria | Design Sections | Tasks | Verification | Durable Targets | Coverage State | Residual Destination | -|-------------|----------|---------------------|-----------------|-------|--------------|-----------------|----------------|----------------------| -| Requirement 1 | must-have | AC1-AC5 | Overview; Low-Level Design | T002, T004, T007 | focused and normal-profile backup tests; live migrated command | recovery operations guide | complete | none | -| Requirement 2 | must-have | AC1-AC4 | High-Level Design; Compatibility | T003, T004 | schedule and renderer tests | scheduling guide | complete | none | -| Requirement 3 | must-have | AC1-AC4 | Operational Considerations; Security And Rollback | T004-T008 | host comparison, restore, observation, and guarded cutover | installation and scheduling guides | complete | none | - -## Design To Implementation Matrix - -| Design Section | Requirements | Tasks | Interfaces Or Files | Verification | Coverage State | Residual Destination | -|----------------|--------------|-------|---------------------|--------------|----------------|----------------------| -| Overview and Low-Level Design | Requirement 1 | T002 | backup CLI, request, target, orchestrators, Restic adapter | focused and normal-profile backup tests | complete | none | -| High-Level Design and Compatibility | Requirement 2 | T003 | schedule commands, records, renderers | schedule tests and parser round trip | complete | none | -| Operational Considerations | Requirement 3 | T004-T008 | docs, root-owned installation, timer | host comparison, list, restore, observed runs, and guarded cutover | complete | none | - -## Open Decision Impact - -| Decision ID | Blocks | Affected Requirements | Affected Tasks | Resolution Needed | -|-------------|--------|-----------------------|----------------|-------------------| -| D001 (resolved 2026-07-19) | none | Requirement 3 | T005-T006 | Operator approved root-owned mode-0600 `/etc/timelocker/npbackup-migration.env`; no values enter repository evidence. | -| D002 (resolved 2026-07-20) | none | Requirement 3 | T008 | Operator selected option 2: retain the active TimeLocker timer and disable the single NPBackup cron entry, preserving a root-only crontab rollback artifact. Retention remains manual and outside this cutover decision. | - -## Open Gate - -T001-T008 are complete. The TimeLocker backup timer is active and the NPBackup -cron entry is disabled with a recoverable crontab backup. Automatic retention -is not configured and must be handled as separate follow-on work. diff --git a/docs/specs/008-npbackup-migration-parity/verification.md b/docs/specs/008-npbackup-migration-parity/verification.md deleted file mode 100644 index 9724c52..0000000 --- a/docs/specs/008-npbackup-migration-parity/verification.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: NPBackup migration parity verification -doc_type: spec -artifact_type: verification -status: active -owner: Auriora Team -last_reviewed: 2026-07-20 ---- - -# Verification - -## Quality Gates - -| Gate | Status | Evidence | -|------|--------|----------| -| Package and traceability ready | passed | Zero Spec 008 lifecycle lint diagnostics; readiness selects T005 after T004. | -| Direct backup parity | passed | T002 focused CLI, target, and Restic tests. | -| Selection backup parity | passed | T002 handler metadata and orchestrator target tests. | -| Stored and rendered schedule parity | passed | T003 create/edit/show/list and cron/systemd/Windows tests. | -| Phase 1 host state unchanged | passed | Root cron still contains the 17:30 NPBackup job; no TimeLocker unit installed. | -| Durable guidance promoted | passed | Both current guides pass bounded Markdown checks with zero findings. | -| Production attachment and restore | passed | Root-owned release `6896c8d` listed snapshot `8958659e` and restored `/etc/hostname` selectively with a byte-for-byte match. | -| Scheduled production observation | passed | Controlled snapshot `f7417b35`, the normal 03:30 snapshot `ffafd15e`, and a subsequent byte-matched bounded restore passed. | -| NPBackup cutover | passed | T008 option 2 disabled the single NPBackup cron entry after saving a root-only rollback copy; the TimeLocker timer remains active. | - -## Baseline Evidence - -- Spec 007 implementation is committed at `433c0aa` with 2,787 normal-profile - tests passing and 52.38% coverage. -- Root cron runs NPBackup at 17:30 over six protected sources. The most recent - verified snapshot is `8958659e` from 2026-07-18. -- NPBackup configuration is valid and credentials remain encrypted; no service, - crontab, repository, or credential state changed during discovery. - -## Requirement Coverage - -| Requirement | Acceptance criteria covered | Evidence | Residual risk | -|-------------|-----------------------------|----------|---------------| -| Requirement 1 | AC1-AC5 | T002 focused tests, normal-profile coverage, and live production-equivalent Restic command evidence | none | -| Requirement 2 | AC1-AC4 | T003 stored schedule and renderer tests | none for Phase 1 | -| Requirement 3 | AC1-AC4 | Protected credential source, committed root-owned release, repository listing, non-overlapping scheduled runs, bounded restore, and guarded option-2 cutover | Automatic retention is separate follow-on work, not a migration acceptance criterion. | - -## Evidence Log - -| Date | Evidence | Result | Notes | -|------|----------|--------|-------| -| 2026-07-19 | Spec 007 commit and full normal-profile test evidence | pass | Dependency commit `433c0aa`; 2,787 passed. | -| 2026-07-19 | Read-only masked NPBackup configuration, root cron, journal, and Restic snapshot inspection | pass | Root job remains `30 17 * * *`; snapshot `8958659e`; no plaintext secret captured. | -| 2026-07-19 | Focused Phase 1 parity suite | pass | 99 tests passed in 14.29 seconds with coverage disabled for the focused run. | -| 2026-07-19 | Full normal profile | pass | `python -m pytest -m "not performance and not stress and not minio"` exited 0: 2,796 passed; 52.52% coverage in 953.40 seconds. | -| 2026-07-19 | Compile and workspace hygiene | pass | `compileall` and `git diff --check` completed successfully. | -| 2026-07-19 | Current durable-guide Markdown checks | pass | `check_markdown_set`: two documents checked, zero findings. | -| 2026-07-19 | Spec 008 lifecycle checks | pass | `lint_spec_package`: error=0, warn=0; `task_state_audit`: error=0, warn=0. | -| 2026-07-19 | Post-implementation host comparison | pass | Root crontab is unchanged with NPBackup at 17:30; no installed TimeLocker unit or `/opt/timelocker` path. | -| 2026-07-19 | T005 protected credential-source installation | pass | Root-only `/etc/timelocker/npbackup-migration.env` is a mode-0600, root-owned, byte-identical copy containing exactly the five expected non-empty assignments; values were not emitted. | -| 2026-07-19 | T005 post-install host comparison | pass | Root loaded all required variables; NPBackup cron remained unchanged, with no TimeLocker unit or `/opt/timelocker` installation. | -| 2026-07-19 | First committed T006 artifact and repository attachment | partial | Root-owned release from `2c93709` reported version 0.9.1 and listed snapshot `8958659e` through the protected named repository without changing NPBackup. | -| 2026-07-19 | First T006 bounded restore | fail-safe | Root-only log `restore-8958659e.log` records `SelectionConfig.__init__()` rejecting keyword `name` before Restic invocation; code review found include/exclude paths were also dropped. No full restore or backup ran. | -| 2026-07-19 | Selective-restore repair focused suite | pass | 64 adapter, restore-manager, orchestrator, and repository tests passed with coverage disabled; tests verify include/exclude propagation and completed selective orchestration. | -| 2026-07-19 | Replacement T006 committed artifact | pass | Wheel SHA-256 `876246c4783d63f4d9f1fae80c5a4180afe95fbcb5161df01278e5b60de8da3c` was built from `6896c8d`, installed root-owned under `/opt/timelocker/releases/`, and both entry points reported 0.9.1. | -| 2026-07-19 | T006 protected repository listing and bounded restore | pass | Snapshot `8958659e` was present; selective `/etc/hostname` restore produced a nonempty file matching the live file byte-for-byte. Output remains in root-only verification logs. | -| 2026-07-19 | T006 scheduler safety comparison | pass | Root `crontab -l`, `systemctl list-unit-files`, and `systemctl list-timers --all` checks found the 17:30 NPBackup job and zero TimeLocker scheduler entries. No backup, retention, schedule, or cutover operation ran. | -| 2026-07-19 | Repaired-artifact full normal profile | pass | `python -m pytest -m "not performance and not stress and not minio"` exited 0: 2,797 passed, one skipped, 57 deselected, and 52.53% coverage in 803.23 seconds. | -| 2026-07-19 | T007 masked execution-parity reconciliation | pass | Three exclusion files contain 252 unique patterns; cache exclusion and `s3.storage-class=INTELLIGENT_TIERING` are enabled. No credential value was emitted. | -| 2026-07-19 | T007 focused parity profile | pass | 77 backup, CLI, schedule, target, and selection tests passed with coverage disabled. | -| 2026-07-19 | T007 full normal profile | pass | `python -m pytest -m "not performance and not stress and not minio"` exited 0: 2,797 passed, one skipped, 57 deselected, and 52.56% coverage in 797.47 seconds. | -| 2026-07-19 | T007 committed artifact and timer staging | pass | Commit `3a4572c`, wheel SHA-256 `90c45af99b3e6c913757fcc9539afe91ae5c6c735556ee252a015637c9dbbbf8`, generated-unit parity, installation, and active-timer checks passed. The first observation trigger was set for 19:30, safely after NPBackup. | -| 2026-07-19 | T007 first scheduler observation | fail-safe | The 19:30 timer and non-overlap condition ran, but the service exited by `SIGTRAP` during GTK tray initialization before Restic started. No backup, retention, or cutover operation completed. | -| 2026-07-19 | T007 headless-service repair | pass | Commit `2eb9928` skips native Linux tray startup without `DISPLAY` or `WAYLAND_DISPLAY`. Seven focused tests passed; the full normal profile passed 2,798 tests with one skipped, 57 deselected, and 52.55% coverage. Replacement wheel SHA-256: `c6998f9af68068185d80ad6261086fdc0dd8092cc38d97565a529bce1ab421e5`. Privileged installation and live retry remain pending. | -| 2026-07-19 | T007 native S3 URI repair | pass | Commit `daaad53` preserves Restic-native repository URIs during the CLI service manager's second resolution pass. The exact root-owned wheel SHA-256 is `5c3106e573d3805b3e9962007c20d5e47cd88faccb1ac8d20c0c1f315f212867`; 84 focused/adjacent tests passed. The full configured suite reached 52.89% coverage with 2,857 passed, one skipped, and one unrelated timing-threshold miss that passed three immediate reruns. | -| 2026-07-19 | T007 controlled production backup | pass | The NPBackup overlap condition passed. Restic received the native S3 URI and all production parity options. Snapshot `f7417b35ab2e497052e33894d5b084a16260bc71e5c76780c7405cbf4454551f` completed with 659,639 files and 455,495,193 bytes; no retention or cutover ran. | -| 2026-07-20 | T007 normal scheduled backup | pass | The enabled 03:30 timer completed snapshot `ffafd15e6948ba278101463f85ac192176e83f8e423d109b5c06254859197de9` with 659,639 files and 16,771,834 bytes. | -| 2026-07-20 | T007 post-backup bounded restore | pass | `tl restore files` restored `/etc/hostname` from `f7417b35...` into root-only verification storage; the restored file matched the live file byte-for-byte. | -| 2026-07-20 | T007 timer dependency repair | pass | The invalid generated `Requires=...service` dependency was removed on-host and from the renderer. One persistent catch-up run completed safely as `a57f037d...`; 43 focused schedule/integration tests passed. The service is inactive and the enabled timer is waiting for 2026-07-21 03:30. | -| 2026-07-20 | T008 option-2 cutover | pass | The guarded root cutover verified the TimeLocker timer active, backed up root's crontab to `/var/lib/timelocker/migration-backup/root-crontab-before-cutover-20260720T054308Z`, and disabled the single active NPBackup cron entry. The next TimeLocker run remains 2026-07-21 03:30. No retention or prune operation ran. | - -## Residual Risks - -- Credential values now exist in a second protected location and must be - rotated when the source Restic service-account environment changes. -- Same-repository overlap can lock or duplicate work; timers must not overlap. -- A manual restart of a persistent timer after a missed calendar event can - legitimately trigger one catch-up run; operators must observe service state - when changing installed timer configuration. -- Retention is not part of the backup timer and remains a manual operation until - a separate destructive-operation schedule is designed and reviewed. The - operator's current policy is keep 5 daily, 4 weekly, 12 monthly, and 3 yearly - snapshots without prune; TimeLocker must preserve those explicit values. -- The first installed T006 artifact is retained for rollback; the accepted - `current` release is the repaired `daaad53` artifact. -- Rollback requires restoring the saved root crontab if the TimeLocker schedule - is withdrawn. - -## Readiness Decision - -- **Phase 1 ready for host staging:** yes -- **Credential source ready for production attachment:** yes; T005 passed. -- **Ready for production repository attachment:** yes; T006 passed listing and - bounded restore from the committed root-owned artifact. -- **Ready for scheduled TimeLocker backups:** yes; T007 passed controlled and - normal timer runs plus a subsequent bounded restore. -- **NPBackup cutover complete:** yes; T008 option 2 was explicitly approved and - executed with a root-only rollback artifact. -- **Automatic retention ready:** no; the backup timer does not run forget or - prune, so the existing manual cleanup remains required. From 2487a3538c958d3502e1eacb4c1d45678397be89 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:08:37 +0100 Subject: [PATCH 27/72] docs(spec): resolve NPBackup closure record --- docs/history/spec-archive-index.md | 4 ++-- docs/history/spec-closure-log.md | 25 +++++++++++-------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/docs/history/spec-archive-index.md b/docs/history/spec-archive-index.md index 5b9bb49..010d3ca 100644 --- a/docs/history/spec-archive-index.md +++ b/docs/history/spec-archive-index.md @@ -3,7 +3,7 @@ title: Spec archive index doc_type: history status: active owner: Auriora Team -last_reviewed: 2026-07-18 +last_reviewed: 2026-07-20 --- # Spec Archive Index @@ -16,7 +16,7 @@ contracts. | Spec ID | Title | Package path | Status | Final spec commit | Cleanup commit | Closure action | Durable destinations | Verification | |---------|-------|--------------|--------|-------------------|----------------|----------------|----------------------|--------------| -| 008-npbackup-migration-parity | NPBackup migration parity requirements | `docs/specs/008-npbackup-migration-parity/` | removed | 5830194 | pending-cleanup-commit | removed | `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md`; `spec package`; `backup operations guide`; `scheduling guide`; `both guides`; `installation guidance`; `installation and recovery guides`; `backup and scheduling guides`; `T005-T008 or explicit follow-up spec` | `docs/history/spec-closure-log.md` | +| 008-npbackup-migration-parity | NPBackup migration parity requirements | removed; recover from Git | removed | `5830194` | `1bfea08` | removed | `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md` | `docs/history/spec-closure-log.md` | | 001-cli-consolidation-stabilization | CLI Consolidation Stabilization | removed; recover from Git | removed | `a1bb654` | `b8df9e9` | removed | `docs/3-implementation/service-layer-integration.md`; `docs/reference/repo-orientation-and-change-map.md`; `docs/specs/README.md`; `docs/history/` | `docs/history/spec-closure-log.md` | | 002-repository-safety-release-readiness | Repository Safety and Release Readiness | removed; recover from Git | removed | `4aff166` | `c6ed9ee` | removed | `README.md`; `docs/2-architecture/`; `docs/guides/user/installation.md`; `docs/guides/user/per-repo-credentials.md`; `docs/processes/version-management.md`; `docs/DOCUMENTATION-STATUS.md`; `docs/specs/README.md` | `docs/history/spec-closure-log.md` | | 006-repository-review-skill | Repository Review Skill | removed; recover from Git | removed | `62dac67` | `82f0247` | removed | `.agents/skills/review-timelocker/`; `AGENTS.md` | `docs/history/spec-closure-log.md` | diff --git a/docs/history/spec-closure-log.md b/docs/history/spec-closure-log.md index 2e4444e..dd2e06e 100644 --- a/docs/history/spec-closure-log.md +++ b/docs/history/spec-closure-log.md @@ -3,7 +3,7 @@ title: Spec closure log doc_type: history status: active owner: Auriora Team -last_reviewed: 2026-07-18 +last_reviewed: 2026-07-20 --- # Spec Closure Log @@ -17,26 +17,23 @@ final spec commit preserves the complete package. ### 2026-07-20 - 008-npbackup-migration-parity -- **Spec:** `docs/specs/008-npbackup-migration-parity/` +- **Spec:** removed; recover from Git - **Title:** NPBackup migration parity requirements - **Final spec commit:** `5830194` -- **Closure cleanup commit:** `pending-cleanup-commit` +- **Closure cleanup commit:** `1bfea08` - **Closure action:** removed - **Durable docs updated:** - `docs/guides/user/recovery-operations-guide.md` - `docs/guides/developer/scheduling-guide.md` - - `spec package` - - `backup operations guide` - - `scheduling guide` - - `both guides` - - `installation guidance` - - `installation and recovery guides` - - `backup and scheduling guides` - - `T005-T008 or explicit follow-up spec` -- **Verification summary:** Closure validation not yet executed. +- **Verification summary:** Spec lint reported zero diagnostics; closure check + was ready; closure risk was low with all 37 evidence records concrete; the + cutover left the TimeLocker timer active and preserved a root-only NPBackup + crontab rollback artifact. - **Residual risks:** - - none -- **Follow-up:** none + - Automatic retention is not configured; keep 5 daily, 4 weekly, 12 monthly, + and 3 yearly snapshots without prune remains a manual operation. +- **Follow-up:** Define system CLI elevation, independent tray/backend control, + run visibility, and automatic retention in a separate active specification. ### 2026-07-18 - 001-cli-consolidation-stabilization - **Spec:** removed; recover from Git From cacb227d9eb3b85b9e146da1339ab5224fe1ceba Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:15:21 +0100 Subject: [PATCH 28/72] docs(spec): define system operations UX requirements --- .../requirements.md | 337 ++++++++++++++++++ docs/specs/README.md | 20 +- 2 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 docs/specs/009-system-cli-tray-retention/requirements.md diff --git a/docs/specs/009-system-cli-tray-retention/requirements.md b/docs/specs/009-system-cli-tray-retention/requirements.md new file mode 100644 index 0000000..d74b27e --- /dev/null +++ b/docs/specs/009-system-cli-tray-retention/requirements.md @@ -0,0 +1,337 @@ +--- +title: System CLI, independent tray, and retention requirements +doc_type: spec +artifact_type: requirements +status: active +owner: Auriora Team +last_reviewed: 2026-07-20 +--- + +# Requirements + +## Introduction + +TimeLocker now runs the machine's production backup through a root-owned, +systemd-managed installation, but operators must invoke a virtual-environment +path, retention remains manual, and ordinary CLI service construction can try +to initialize desktop tray components. The tray is currently an in-process +notification integration rather than an independent desktop client, and +operation history is not yet a single durable cross-process contract. + +This package defines a coherent system-operations experience: a stable +system-path command that requests elevation only when required, an independent +per-user tray process, a local authenticated control/status boundary, and +separately scheduled retention with visible outcomes. + +## Goals + +- Install a stable `timelocker` command on the system path and retain `tl` as a + compatible alias. +- Let unprivileged commands remain unprivileged while privileged system actions + request elevation through an explicit, reviewable boundary. +- Remove all tray initialization from normal CLI, scheduler, and backend + execution paths. +- Run the tray as an independent process in the signed-in user's graphical + session. +- Let the tray observe current work, last backup, last retention run, and next + scheduled runs, and safely request an on-demand backup. +- Automate the accepted production retention policy independently of backup: + keep 5 daily, 4 weekly, 12 monthly, and 3 yearly snapshots without prune. +- Preserve safe rollback, headless operation, secret isolation, and failure + independence between backup, retention, CLI, and tray processes. + +## Non-Goals + +- Building the future full desktop UI or presenting an unimplemented UI as + available. +- Implementing a network-accessible REST API, hosted control plane, or remote + administration service. +- Running the tray as root or granting the desktop process direct access to + protected repository credentials. +- Automatically elevating every TimeLocker command or bypassing an operator's + authorization policy. +- Enabling Restic prune as part of the initial automated retention policy. +- Allowing retention failure to make an otherwise successful backup appear to + have failed, or vice versa. +- Implementing user-scoped management of the user's accessible subset of the + system backup. That capability belongs in the product backlog and must later + receive its own access-control and restore-boundary specification. + +## Glossary + +| Term | Definition | +|------|------------| +| System command | The stable `timelocker` executable discoverable through the normal system `PATH`. | +| System backend | The privileged, headless execution boundary that owns machine-level configuration and scheduled operations. It does not imply a network service. | +| Tray client | An unprivileged process running in a user's graphical session and communicating through the approved local control/status boundary. | +| Elevation broker | The narrow operating-system authorization path used to request a privileged operation without making the whole desktop or CLI session privileged. | +| Run record | Durable, secret-free status for one backup or retention attempt, including type, target, timestamps, state, result, and safe error summary. | + +## Durable Source Baseline + +| Source | Current behavior relied on | Confidence | Notes | +|--------|----------------------------|------------|-------| +| `CHARTER.md` | TimeLocker is CLI-first and may provide optional tray integration, automation, monitoring, and schedules. | high | The proposed work is within the current mandate and remains short of a full GUI or hosted service. | +| `pyproject.toml` | Both `timelocker` and `tl` resolve to `TimeLocker.cli:main` after package installation. | high | Package entry points do not by themselves provide the machine deployment's stable system-path launcher. | +| `src/TimeLocker/monitoring/notification_service.py` | Notification service construction currently initializes `SystemTrayIntegration` in-process. | high | This causes CLI/headless coupling and produced warnings during a retention dry run. | +| `src/TimeLocker/monitoring/system_tray_integration.py` | Platform tray rendering and callbacks exist as a library component. | high | It is not an independently managed process or a backend client. | +| `src/TimeLocker/cli_modules/commands/schedule.py` | Generated schedules currently execute `tl backup create` only. | high | Retention is not chained or separately scheduled. | +| `src/TimeLocker/cli_modules/commands/repositories.py` | `tl repos forget` supports explicit daily, weekly, monthly, yearly, dry-run, and optional prune values. | high | Production dry run passed with 5/4/12/3 and no prune. | +| `docs/guides/developer/scheduling-guide.md` | Backup schedules and the current manual-retention boundary are documented. | high | Promotion target for accepted installation and maintenance behavior. | +| `docs/SYSTEM-TRAY-SETUP.md` | Current tray documentation describes optional in-process monitoring and notifications. | high | Must be replaced or rewritten when independent tray behavior is implemented. | + +## Durable Impact + +| Durable area | Action | Target | Notes | +|--------------|--------|--------|-------| +| requirements | add | `docs/1-requirements/system-operations.md` | Promote privilege, status, retention, and process-boundary invariants. | +| architecture | modify | `docs/2-architecture/system-architecture.md` | Document CLI, backend, tray, local control/status, and durable run-state boundaries after implementation. | +| architecture | modify | `docs/2-architecture/scheduling-system.md` | Document independent backup and retention scheduling and overlap control. | +| implementation | modify | `docs/3-implementation/service-layer-integration.md` | Identify the owning services and prohibit UI initialization in headless execution. | +| runbook | modify | `docs/guides/developer/scheduling-guide.md` | Document installation, retention staging, rollback, and validation. | +| user guide | modify | `docs/guides/user/installation.md` | Document system-path command and supported elevation behavior. | +| user guide | supersede | `docs/SYSTEM-TRAY-SETUP.md` | Replace current in-process assumptions with the independent tray lifecycle. | + +## Staged Readiness + +- **Current stage:** requirements +- **Next stage:** design +- **Ready to design when:** elevation and tray trust boundaries, operation + status semantics, retention policy, failure isolation, compatibility, and + roadmap exclusions are accepted. +- **Design-first exception:** no +- **Optional artifacts recommended:** `research.md`, `change-impact.md`, and + `open-decisions.md` +- **Downstream review needed:** design, security, operations, desktop + integration, testing, traceability, and verification +- **Concurrent package sequencing:** Spec 007 has no incomplete task but still + needs evidence-quality reconciliation and closure. Spec 009 may author + requirements and design concurrently; implementation must not reuse Spec + 007 release evidence as proof and must preserve its release-readiness gates. + +## Requirements + +### Requirement 1: Stable system-path command + +**User Story:** As an operator, I want to invoke TimeLocker by name from a +normal shell, so that machine operations do not depend on knowing an internal +release or virtual-environment path. + +**Priority:** must-have + +#### Acceptance Criteria + +1. GIVEN a supported system installation, WHEN the operator resolves + `timelocker`, THEN it SHALL execute the current immutable TimeLocker release + through a root-owned system-path launcher. +2. THE `tl` alias SHALL remain available and behaviorally compatible. +3. GIVEN a release switch or rollback, WHEN either command is invoked, THEN it + SHALL resolve the same selected release without rewriting user shell files. +4. IF the selected release is missing or invalid, THEN the launcher SHALL fail + without falling back to a mutable checkout, user environment, or legacy + root configuration overlay. + +### Requirement 2: Contextual privilege elevation + +**User Story:** As an operator, I want TimeLocker to request elevation only for +operations that require system authority, so that routine inspection remains +convenient without widening privilege unnecessarily. + +**Priority:** must-have + +#### Acceptance Criteria + +1. GIVEN a read-only operation whose data is accessible to the caller, WHEN it + runs, THEN TimeLocker SHALL remain in the caller's security context. +2. GIVEN an allowlisted machine-level operation that requires elevated access, + WHEN an interactive caller invokes it, THEN TimeLocker SHALL request + authorization through the supported operating-system elevation mechanism + and preserve the intended command arguments. +3. IF no interactive authorization agent or terminal is available, THEN the + command SHALL fail promptly with the exact manual or automation-safe next + action; it SHALL NOT wait indefinitely. +4. ELEVATION SHALL NOT forward repository passwords, unrestricted environment + variables, display/session credentials, or arbitrary executable paths. +5. THE SYSTEM SHALL prevent recursive elevation and SHALL record a secret-free + audit event identifying the requested operation, caller, decision, and + result. +6. A denied or failed elevation SHALL leave configuration, schedules, + repositories, and run state unchanged. + +### Requirement 3: Independent tray process + +**User Story:** As a desktop user, I want the TimeLocker tray to run separately +from backup commands, so that desktop integration neither destabilizes nor +pollutes headless operations. + +**Priority:** must-have + +#### Acceptance Criteria + +1. CLI, scheduler, retention, and backend processes SHALL NOT import, + initialize, or shut down a platform tray implementation during ordinary + command execution. +2. THE tray SHALL run as a separately installable and independently restartable + process in the signed-in user's graphical session, never as root. +3. IF the tray is absent, crashes, or cannot connect, THEN scheduled backup and + retention SHALL continue unaffected. +4. IF the backend is unavailable, THEN the tray SHALL display a disconnected + or unavailable state without presenting stale success as current. +5. Starting more than one tray instance for the same user SHALL be prevented or + resolved deterministically. +6. The tray lifecycle SHALL support Linux Mint's GNOME-based session first and + retain explicit portability boundaries for other supported platforms. + +### Requirement 4: Local control and status contract + +**User Story:** As a desktop user, I want the tray to show what TimeLocker is +doing and request a backup safely, so that I can understand and operate the +machine backup without handling protected credentials. + +**Priority:** must-have + +#### Acceptance Criteria + +1. THE backend SHALL expose an authenticated, local-only, versioned contract + for current operation state, last backup run, last retention run, next + scheduled runs, and safe error summaries. +2. Run state SHALL persist across CLI and scheduler processes and remain + inspectable after process exit and system restart. +3. Backup and retention run records SHALL be distinguishable and SHALL include + start time, completion time, state, result, target identity, and a + secret-free diagnostic summary. +4. THE tray MAY request an on-demand backup only through an allowlisted backend + action that performs normal authorization, validation, locking, and audit. +5. IF a conflicting backup or retention operation is active, THEN a new request + SHALL be rejected or queued according to one documented policy; it SHALL NOT + start an unsafe concurrent Restic mutation. +6. Status and control messages SHALL NOT contain repository passwords, cloud + credentials, unrestricted environment data, or unredacted Restic output. +7. The contract SHALL reserve a future UI-launch action without claiming that + a UI exists; until implemented, the tray action SHALL be hidden or clearly + unavailable. + +### Requirement 5: Automatic retention as an independent operation + +**User Story:** As an operator, I want TimeLocker to apply my retention policy +automatically after backups, so that snapshot cleanup is consistent without +coupling deletion to backup success. + +**Priority:** must-have + +#### Acceptance Criteria + +1. THE production policy SHALL explicitly keep 5 daily, 4 weekly, 12 monthly, + and 3 yearly snapshots and SHALL leave prune disabled. +2. BEFORE first enablement or any policy change, THE SYSTEM SHALL support a dry + run using the same repository, credentials, grouping semantics, and policy + values as the eventual mutation. +3. Retention SHALL use a separately identifiable service and schedule from the + backup service and schedule. +4. Retention SHALL NOT run while a backup or another repository mutation is + active, and a skipped conflict SHALL be visible as a run result rather than + silently lost. +5. A retention failure SHALL NOT rewrite the preceding backup result, and a + backup failure SHALL NOT implicitly authorize retention. +6. Each retention attempt SHALL produce a durable run record visible through + the CLI and tray, including whether it was a dry run and how many snapshots + were selected or removed. +7. Disabling automatic retention SHALL be reversible without disabling + backups, and rollback guidance SHALL preserve the manual forget command. + +### Requirement 6: Installation, upgrade, and recovery safety + +**User Story:** As an operator, I want the launcher, backend, schedules, and +tray to upgrade and roll back coherently, so that a partial deployment cannot +silently select the wrong code or privilege boundary. + +**Priority:** must-have + +#### Acceptance Criteria + +1. System launchers, privileged units, local contract definitions, and tray + startup assets SHALL be installed from one committed release artifact or a + compatibility-checked set of artifacts. +2. Upgrade SHALL validate launcher resolution, backend health, contract + compatibility, timer state, and tray reconnection before retiring the prior + release. +3. Rollback SHALL restore the prior selected release and compatible system + assets without deleting run records or changing retention policy. +4. Headless installations SHALL remain supported without GUI dependencies or + tray warnings. + +## Correctness Properties + +- **CP-001:** An operation executes with elevated authority if and only if its + centrally classified action requires that authority and authorization was + granted. +- **CP-002:** Removing, stopping, or crashing every tray process cannot stop, + start, or alter a scheduled backup or retention run by itself. +- **CP-003:** At most one mutating Restic operation for the protected repository + is active at any time. +- **CP-004:** Every completed or failed backup and retention attempt yields one + durable terminal run record without secret material. +- **CP-005:** The enabled production retention invocation always carries the + explicit tuple `(5, 4, 12, 3, prune=false)`; no CLI default may change it. +- **CP-006:** A denied elevation or incompatible client/backend contract causes + no privileged mutation. + +## Technical Context + +- **Language/Version:** Python 3.12-3.13 +- **Primary Dependencies:** Typer, systemd on Linux, Restic, existing + monitoring and scheduling services, optional PyGObject tray support +- **Target Platform:** Linux Mint GNOME first; preserve documented macOS and + Windows compatibility boundaries +- **Constraints:** local-first, least privilege, root-owned production + configuration, no secret-bearing IPC, immutable release selection, no + unsafe backup/retention overlap +- **Performance Goals:** status reads should feel interactive and must not + initialize the repository or block on Restic; tray disconnection must not + delay backend work + +## Success Criteria + +- **SC-001:** `command -v timelocker` and `command -v tl` resolve the selected + system release without a project checkout or virtual-environment path in the + caller's command. +- **SC-002:** A representative read-only command runs without elevation, while + a representative privileged command requests authorization once and records + its result without exposing secrets. +- **SC-003:** CLI and systemd retention runs produce no tray initialization + attempt or tray warning. +- **SC-004:** Restarting or terminating the tray leaves scheduled operations + unaffected and the tray recovers current and last-run state after reconnect. +- **SC-005:** An approved on-demand tray backup follows the same lock, + credentials, configuration, and run-record paths as a scheduled backup. +- **SC-006:** A dry run and one controlled automatic retention run prove the + explicit 5/4/12/3, no-prune policy and appear in both CLI and tray status. + +## Open Questions For Design + +- Which Linux elevation split best serves terminal and graphical callers: + `sudo`, polkit/`pkexec`, a narrow privileged helper, or a combination? +- Which local IPC mechanism provides the smallest authenticated interface and + cleanest systemd integration without creating a general application server? +- Should the tray read durable run state directly through a read-only library + or exclusively through the backend contract? +- What exact timer offset and missed-run policy should automatic retention use + relative to the 03:30 backup? The initial recommendation is daily at 04:30. +- Which existing history implementation should become authoritative, and what + migration is required for old or in-memory records? + +## Routed Future Work + +- [GitHub issue #70](https://github.com/Auriora/TimeLocker/issues/70) tracks + user-scoped backup and restore management: a signed-in user may manage only + files they can access within the overall system backup. That future work must + define selection ownership, snapshot visibility, restore destinations, + symlink and ACL behavior, privilege boundaries, and defenses against using + the system service to read or write inaccessible paths. + +## Related Artifacts + +- Change Impact: pending +- Design: pending +- Tasks: pending +- Verification: pending diff --git a/docs/specs/README.md b/docs/specs/README.md index cb8ea69..0349754 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -3,7 +3,7 @@ title: "Active Specification Packages" doc_type: reference status: active owner: "Auriora Team" -last_reviewed: 2026-07-18 +last_reviewed: 2026-07-20 --- # Active Specification Packages @@ -19,17 +19,19 @@ accepted content has been promoted and the package is closed. — active package for restoring trustworthy CI, stabilizing release signals, validating `v0.9.1` artifacts, rehearsing release operations, and promoting durable release guidance. -- [`008-npbackup-migration-parity`](./008-npbackup-migration-parity/requirements.md) - — active package for preserving the existing root-owned NPBackup job's - backup semantics before a staged TimeLocker installation and cutover. +- [`009-system-cli-tray-retention`](./009-system-cli-tray-retention/requirements.md) + — requirements-stage package for a stable system command, contextual + elevation, an independent tray/backend boundary, durable run visibility, and + automatic retention. ## Active-Package Sequencing -Spec 007 owns release readiness and remains at the separate human release and -closure gate. Spec 008 depends on Spec 007 implementation commit `433c0aa` and -owns only NPBackup migration parity and operator staging. The packages may run -concurrently because Spec 008 does not approve a release, publication, service -installation, or NPBackup cutover. Closed package identity and recovery commits +Spec 007 owns release readiness. All of its implementation tasks are complete; +its remaining work is evidence-quality reconciliation and lifecycle closure. +Spec 009 may author requirements and design concurrently because it does not +alter Spec 007 evidence or authorize a release. Spec 009 implementation must +preserve Spec 007's release gates and receive separate approval after its +design and tasks are complete. Closed package identity and recovery commits remain recorded in `docs/history/` rather than kept in this active path. ## When a Spec Is Needed From 7fd11f9aa1cbc670d5e8b429aede4a7c01e185a4 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:38:07 +0100 Subject: [PATCH 29/72] docs(spec): prepare release readiness closure --- .../tasks.md | 38 ++++++++----- .../traceability.md | 33 ++++++------ .../verification.md | 53 +++++++++++++------ 3 files changed, 77 insertions(+), 47 deletions(-) diff --git a/docs/specs/007-release-readiness-stabilization/tasks.md b/docs/specs/007-release-readiness-stabilization/tasks.md index d8d0311..acb0bd2 100644 --- a/docs/specs/007-release-readiness-stabilization/tasks.md +++ b/docs/specs/007-release-readiness-stabilization/tasks.md @@ -123,36 +123,32 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 tolerance are implemented; the repeatable extended profile passes or a release-blocking disposition is recorded. - Evidence mode: implementation - - Destination: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293 - Evidence: Implemented `PerformanceBaseline`, split deterministic correctness from opt-in timing, replaced the 60-second iteration-count gate with a warmed 12-operation median check using a 1.0s baseline and 2.0x tolerance, and documented reproduction. Three targeted runs passed at 0.160s/0.176s/0.173s; the extended profile passed 53 tests in 45.60s; the normal profile passed 2,765 tests with one skip and 52.14% coverage. Evidence: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293. - - Status: Complete on 2026-07-19; immutable post-change hosted evidence follows the explicitly requested commit. + - Status: Complete on 2026-07-19; issue #68 was closed with final evidence + on 2026-07-20. - [x] T004.1 Capture representative host timings and environment context in issue #68. - Evidence: Issue #68 records Linux/Python/CPU/load context, the 209-iteration legacy result, historical 57/70-iteration observations, and the calibrated strategy. - Status: Complete on 2026-07-19. - Evidence mode: validation - - Destination: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293 - [x] T004.2 Separate deterministic correctness assertions from environment-sensitive timing assertions. - Evidence: `test_repeated_operations_preserve_selection_correctness` owns deterministic stability assertions; `test_sustained_selection_performance` owns only the opt-in timing signal. - Status: Complete on 2026-07-19. - Evidence mode: validation - - Destination: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293 - [x] T004.3 Implement the evidence-backed baseline and tolerance strategy. - Evidence: `PerformanceBaseline` validates a named 1.0-second reference with a 2.0x tolerance; the stress test warms caches, measures 12 fixed operations with a monotonic clock, and evaluates the median. - Status: Complete on 2026-07-19. - Evidence mode: validation - - Destination: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293 - [x] T004.4 Run a repeatable extended profile and link results from issue #68. - Evidence: Three targeted runs passed at 0.160s, 0.176s, and 0.173s median; the complete extended profile passed 53 tests in 45.60s. - Status: Complete on 2026-07-19. - Evidence mode: validation - - Destination: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293 - [x] T005 Checkpoint - Release validation prerequisites. - Depends on: T004 - Requirements: Requirement 1, Requirement 2 @@ -167,7 +163,6 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 contains environment, calibration, and repeat evidence. - Status: Complete on 2026-07-19; Phase 2 checkpoint passed and T006 is next. - Evidence mode: validation - - Destination: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293 ## Phase 3: Build and Install v0.9.1 @@ -255,7 +250,10 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 - Evidence: Added reusable `.github/workflows/release-validation.yml`, extracted release-intent, release-note, and workflow-boundary validators, and refactored `.github/workflows/release.yml` so validation is read-only and only the dependent publish job has `contents: write`. Focused release-contract tests: 9 passed. `actionlint` passed both workflows. Boundary validator passed and negative permission/mismatch/missing-artifact paths propagate failure. - Status: Complete on 2026-07-19; no publication action executed. - [x] T009.1 Identify and isolate every pre-publication release step. - - Evidence: Separated checkout, prerequisites, intent, tests, build, artifact inspection, both smoke installs, notes derivation, and uploads into the reusable validation workflow; GitHub release creation remains outside it. + - Evidence: `.github/workflows/release-validation.yml` owns checkout, + prerequisites, intent, tests, build, artifact inspection, both smoke + installs, notes derivation, and uploads; `.github/workflows/release.yml` + retains GitHub release creation in its dependent publish job. - Status: Complete on 2026-07-19. - Evidence mode: validation - [x] T009.2 Implement a manual or local validation entry point with read-only permissions. @@ -264,7 +262,9 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 - Evidence mode: validation - [x] T009.3 Add regression coverage for the publication boundary and failure propagation. - - Evidence: Added focused positive and negative tests for intent, derivation, missing artifacts, rehearsal permission, and the isolated publish job. + - Evidence: Nine focused release-contract tests passed for intent, + derivation, missing artifacts, rehearsal permission, and the isolated + publish job; `actionlint` also passed both release workflows. - Status: Complete on 2026-07-19. - Evidence mode: validation - [x] T010 Execute and record a non-publishing release rehearsal. @@ -285,7 +285,11 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 - Status: Complete on 2026-07-19. - Evidence mode: validation - [x] T010.2 Exercise successful build, smoke, artifact, and release-note inputs. - - Evidence: Built and validated both distributions, hashes, both clean-install smokes, upload configuration, and the changelog-derived release-body input. + - Evidence: Built and validated wheel SHA-256 + `a3d5eb9f423cbb38a829387f286c261c93e6bedd2a9cc1413069981d6a268bc5` + and sdist SHA-256 + `75c5fc42a3a2909094d9d1ed52466ecdd05266160f36ae1eb04cb23e9236b843`; + both clean-install smokes passed through `timelocker` and `tl`. - Status: Complete on 2026-07-19. - Evidence mode: validation - [x] T010.3 Exercise version mismatch, missing prerequisite, and permission failure paths. @@ -312,11 +316,14 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 - Evidence: Corrected `docs/processes/version-management.md` in place with preparation, rehearsal, approval, publication, verification, failure, rollback, and PyPI/1.0 deferral boundaries; indexed it from `docs/processes/README.md`; aligned README and installation claims to Python 3.12-3.13, version 0.9.1 prepared/not published, and the normal test selector. Agent Workbench checked all five durable documents with zero Markdown or link findings. - Status: Complete on 2026-07-19; durable procedure and front-door claims are current. - [x] T011.1 Correct `version-management.md` in place; do not create a duplicate release procedure. - - Evidence: Rewrote the existing version-management process in place with preparation, authorization, validation, recovery, and deferral boundaries. + - Evidence: `docs/processes/version-management.md` now contains preparation, + authorization, validation, recovery, and PyPI/1.0 deferral boundaries. - Status: Complete on 2026-07-19. - Evidence mode: validation - [x] T011.2 Link the procedure from `docs/processes/README.md`. - - Evidence: Linked the corrected release procedure from the current processes index. + - Evidence: `docs/processes/README.md` links + `./version-management.md`; the bounded Markdown/link check reported zero + findings. - Status: Complete on 2026-07-19. - Evidence mode: validation - [x] T011.3 Update installation and front-door claims from T007 evidence. @@ -338,7 +345,8 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 - Evidence: Added canonical `CHANGELOG.md` section `[0.9.1] - Prepared 2026-07-19` using verified CI, stress, artifact, cross-platform, encoding, version, and publication-boundary evidence plus four explicit limitations. `scripts/extract_release_notes.py` derived the complete GitHub release-body preview from that exact section; focused extraction tests passed. - Status: Complete on 2026-07-19; communications are prepared but unpublished. - [x] T012.1 Draft the changelog section from verified changes and limitations. - - Evidence: Drafted the canonical 0.9.1 changelog section from verified changes and explicit limitations. + - Evidence: `CHANGELOG.md` contains `[0.9.1] - Prepared 2026-07-19` with + verified changes and four explicit limitations. - Status: Complete on 2026-07-19. - Evidence mode: validation - [x] T012.2 Map each public claim to verification, commits, specs, or issues. @@ -347,7 +355,9 @@ T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 - Evidence mode: validation - [x] T012.3 Preview the GitHub release body without creating a release. - - Evidence: Derived and inspected the complete GitHub release-body preview without creating a release. + - Evidence: `scripts/extract_release_notes.py` derived the complete 0.9.1 + GitHub release-body preview; focused extraction tests passed and GitHub + releases remained zero. - Status: Complete on 2026-07-19. - Evidence mode: validation - [x] T013 Checkpoint - Human release decision and spec closure readiness. diff --git a/docs/specs/007-release-readiness-stabilization/traceability.md b/docs/specs/007-release-readiness-stabilization/traceability.md index 98f4725..855f4ad 100644 --- a/docs/specs/007-release-readiness-stabilization/traceability.md +++ b/docs/specs/007-release-readiness-stabilization/traceability.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: traceability status: active owner: Auriora Team -last_reviewed: 2026-07-19 +last_reviewed: 2026-07-20 --- # Traceability Matrix @@ -35,17 +35,17 @@ last_reviewed: 2026-07-19 ## Requirement To Delivery Matrix -| Requirement | Acceptance Criteria | Design Sections | Tasks | Verification | Durable Targets | -|-------------|---------------------|-----------------|-------|--------------|-----------------| -| Requirement 1 | AC1-AC6 | CI Profile Logic; Error Handling | T001-T003 | normal and MinIO profiles, coverage, complete collection partition | workflow, `docs/4-testing/README.md` | -| Requirement 2 | AC1-AC4 | Components; Validation Strategy | T004-T005 | stress tests, issue #68 evidence, extended profile | tests and testing guide | -| Requirement 3 | AC1-AC5 | Version and Artifact Guard | T006, T008 | side-effect proof, build, metadata, hashes, version guard | metadata, version process, changelog | -| Requirement 4 | AC1-AC5 | Clean-Install Matrix | T007-T008, T011 | six-combination artifact install matrix and support-claim review | metadata, installation guide | -| Requirement 5 | AC1-AC6 | Release Rehearsal; Operational Considerations | T009-T013 | interface tests, rehearsal, docs, communications, expert review | version process, process index, changelog, README if needed | -| Requirement 6 | AC1-AC4 | Repository and Credential Boundary; Error Handling | T014-T015, T019 | focused tests and isolated init/dry-run/backup | backup and recovery guidance | -| Requirement 7 | AC1-AC4 | Snapshot and Restore Boundary; Error Handling | T014, T016, T019 | list/latest/exact restore and digest proof | backup and recovery guidance | -| Requirement 8 | AC1-AC3 | Components and Changes; Error Handling | T014, T017, T019 | Ayatana/legacy/headless tests and Mint smoke | installation/troubleshooting guidance | -| Requirement 9 | AC1-AC4 | Schedule Rendering Boundary; Migration and Compatibility | T014, T018-T019 | parser round trip, redacted asset review, cutover gate review | scheduling/operator guidance | +| Requirement | Acceptance Criteria | Design Sections | Tasks | Verification | Durable Targets | Coverage State | Residual Destination | +|-------------|---------------------|-----------------|-------|--------------|-----------------|----------------|----------------------| +| Requirement 1 | AC1-AC6 | CI Profile Logic; Error Handling | T001-T003 | normal and MinIO profiles, coverage, complete collection partition | workflow, `docs/4-testing/README.md` | complete | none | +| Requirement 2 | AC1-AC4 | Components; Validation Strategy | T004-T005 | stress tests, issue #68 evidence, extended profile | tests and testing guide | complete | none | +| Requirement 3 | AC1-AC5 | Version and Artifact Guard | T006, T008 | side-effect proof, build, metadata, hashes, version guard | metadata, version process, changelog | complete | none | +| Requirement 4 | AC1-AC5 | Clean-Install Matrix | T007-T008, T011 | six-combination artifact install matrix and support-claim review | metadata, installation guide | complete | none | +| Requirement 5 | AC1-AC6 | Release Rehearsal; Operational Considerations | T009-T013 | interface tests, rehearsal, docs, communications, expert review | version process, process index, changelog, README if needed | complete | none | +| Requirement 6 | AC1-AC4 | Repository and Credential Boundary; Error Handling | T014-T015, T019 | focused tests and isolated init/dry-run/backup | backup and recovery guidance | complete | none | +| Requirement 7 | AC1-AC4 | Snapshot and Restore Boundary; Error Handling | T014, T016, T019 | list/latest/exact restore and digest proof | backup and recovery guidance | complete | none | +| Requirement 8 | AC1-AC3 | Components and Changes; Error Handling | T014, T017, T019 | Ayatana/legacy/headless tests and Mint smoke | installation/troubleshooting guidance | complete | none | +| Requirement 9 | AC1-AC4 | Schedule Rendering Boundary; Migration and Compatibility | T014, T018-T019 | parser round trip, redacted asset review, cutover gate review | scheduling/operator guidance | complete | none | ## Correctness Property Coverage @@ -80,8 +80,9 @@ last_reviewed: 2026-07-19 There are no unresolved decisions blocking isolated implementation. Repository credential selection, sudo installation for protected sources, discovery of the -actual NPBackup scheduler, observed scheduled runs, and cutover are explicit -operator gates that block migration, not T015-T018 implementation. + actual NPBackup scheduler, observed scheduled runs, and cutover were explicit + operator gates outside T015-T018 implementation and were completed later by + closed Spec 008. ## Maintenance Notes @@ -90,5 +91,5 @@ operator gates that block migration, not T015-T018 implementation. - Requirements and design, including the changelog-derived communications decision, were re-reviewed against this matrix after the TLR-001 through TLR-006 remediation; all acceptance mappings are explicit. -- Spec 007 owns stress implementation and acceptance; issue #68 is the linked - assignment, state, and chronological-evidence record. +- Spec 007 owns stress implementation and acceptance; issue #68 preserves the + chronological evidence and was closed on 2026-07-20. diff --git a/docs/specs/007-release-readiness-stabilization/verification.md b/docs/specs/007-release-readiness-stabilization/verification.md index 71564f4..f0809c7 100644 --- a/docs/specs/007-release-readiness-stabilization/verification.md +++ b/docs/specs/007-release-readiness-stabilization/verification.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: verification status: active owner: Auriora Team -last_reviewed: 2026-07-19 +last_reviewed: 2026-07-20 --- # Verification @@ -24,7 +24,7 @@ requires separate explicit approval. | Substantive requirements and design review | yes | passed | Review on 2026-07-18 produced TLR-001 through TLR-006; all six findings were reconciled into the package before implementation. | | Task evidence complete | yes | passed | T001-T019 have implementation and validation evidence. | | Normal and dependency-owning test profiles pass | yes | passed | GitHub Actions run `29676747955` passed normal, MinIO, coverage quality-gate, and notification jobs. | -| Stress implementation and disposition recorded | yes | passed | T004 implementation and repeat evidence are recorded in GitHub issue #68. | +| Stress implementation and disposition recorded | yes | passed | T004 implementation and repeat evidence are recorded in closed GitHub issue #68. | | Artifacts and six-combination clean installs validate | yes | passed | Run `29679083454` passed one build and all 12 artifact/OS/Python jobs. | | Release interface and rehearsal prove no publication side effect | yes | passed | T009-T010: reusable read-only validation, local rehearsal, three negative paths, and unchanged external state. | | Durable documentation and communications promoted | yes | passed | Phase 4 targets and Phase 5 installation, recovery, and scheduling guidance are complete. | @@ -207,47 +207,66 @@ release artifacts must be linked here before release readiness can be approved. | Version contents and release communications | `CHANGELOG.md`; GitHub release body derived from its `v0.9.1` section | complete | T012 preview passed. | | Front-door support and version claims | `README.md` | complete | T011 aligned version and Python support. | | PyPI and `1.0.0` deferral | GitHub issue #22, milestone description, version process | complete | External and durable boundaries agree. | -| Follow-up work | GitHub issues outside milestone or an approved successor spec | complete | Existing issue #68 retains stress history; no new Phase 4 finding requires routing. | +| Follow-up work | GitHub issues outside milestone or an approved successor spec | complete | Closed issue #68 retains stress history; Spec 009 owns the newly approved system-operations UX requirements. | | Backup/recovery runtime contract | `docs/guides/user/recovery-operations-guide.md` | complete | T015-T016 machine acceptance and T019 review passed. | | Linux tray prerequisites | `docs/guides/user/installation.md` | complete | T017 Mint and headless validation passed. | | Schedule and staged NPBackup cutover boundary | `docs/guides/developer/scheduling-guide.md` | complete | T018 staging and T019 handoff review passed. | ### Spec Cleanup Decision -- **Cleanup action:** keep active -- **Reason:** Phase 5 is complete, but release approval, operator migration, and - lifecycle closure are separately human-controlled decisions. +- **Cleanup action:** remove after the final spec commit +- **Reason:** All 53 task records are complete, durable behavior is promoted, + issue #68 is closed, and the operator has now approved lifecycle closure. - **Final spec commit:** pending - **Closure log path:** `docs/history/spec-closure-log.md` - **Closure log entry updated:** no - **Closure cleanup commit:** pending -- **Active indexes updated:** yes for package creation +- **Active indexes updated:** pending closure cleanup - **Durable docs linked back to evidence where useful:** yes -- **Residual spec-only content:** release decision and machine-pilot evidence +- **Residual spec-only content:** none; release publication remains a separate + human action governed by the durable version-management process. ## Ship Or Closure Risk -- **Risk level:** medium +- **Risk level:** low for closure; release publication remains separately gated - **Breaking change:** no - **Blast radius checked:** complete for the approved Phase 5 implementation boundary - **Rollback path:** corrected and validated in `docs/processes/version-management.md` -- **Requires human review:** yes +- **Requires human review:** satisfied by the 2026-07-20 closure request - **Release notes needed:** yes, in `CHANGELOG.md` - **Follow-up issue or spec needed:** issue #68 already tracks stress evidence ### Risk Rationale -Normal, provisioned MinIO, extended, artifact, cross-platform install, rehearsal, -documentation, expert-review, and Linux Mint machine-acceptance gates pass. -Remaining risk is operational: the actual NPBackup job has not been reconciled, -the privileged TimeLocker schedule has not been installed or observed, and no -publication or cutover authority has been granted. +Normal, provisioned MinIO, extended, artifact, cross-platform install, +rehearsal, documentation, expert-review, and Linux Mint machine-acceptance +gates pass. Closed Spec 008 subsequently reconciled and cut over NPBackup. +TimeLocker remains unpublished; closing this implementation package does not +grant tag, GitHub release, PyPI, or other publication authority. + +### Accepted Evidence-Quality Residual + +The lifecycle evidence classifier reports 99 records: 73 concrete and 26 +advisory weak, vague, or `not_run` classifications. These advisories are +accepted for closure because they describe intermediate negative controls, +no-mutation observations, or subordinate task summaries whose terminal parent +tasks and quality gates contain concrete commits, workflow-run IDs, snapshot +IDs, hashes, test counts, or coverage. The five `not_run` classifications do +not represent missing final validation; for example, the final normal profile +record itself reports 2,787 passed, one skipped, 57 deselected, and 52.38% +coverage. Chronological failed and unchanged-state rows are retained rather +than rewritten as successes. + +The optional canonical-context advisory is also accepted: requirements and +promotion already cite the durable charter, front door, installation guide, +release process, changelog, and history authorities directly, and no ambiguity +remains that would justify adding another copied context artifact at closure. ## Readiness Decision -- **Ready for promotion:** no, Phase 5 durable guidance is pending +- **Ready for promotion:** yes; all named durable targets are current - **Ready for release:** no -- **Ready for closure:** no +- **Ready for closure:** yes; release remains a separate human decision ## Related Artifacts From 6334af0690b5b9e8b6575042269e5b73914a9295 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:39:41 +0100 Subject: [PATCH 30/72] docs(spec): close release readiness stabilization --- docs/history/spec-archive-index.md | 1 + docs/history/spec-closure-log.md | 33 + .../change-impact.md | 106 ---- .../design.md | 302 ---------- .../requirements.md | 342 ----------- .../tasks.md | 565 ------------------ .../traceability.md | 95 --- .../verification.md | 277 --------- docs/specs/README.md | 14 +- 9 files changed, 38 insertions(+), 1697 deletions(-) delete mode 100644 docs/specs/007-release-readiness-stabilization/change-impact.md delete mode 100644 docs/specs/007-release-readiness-stabilization/design.md delete mode 100644 docs/specs/007-release-readiness-stabilization/requirements.md delete mode 100644 docs/specs/007-release-readiness-stabilization/tasks.md delete mode 100644 docs/specs/007-release-readiness-stabilization/traceability.md delete mode 100644 docs/specs/007-release-readiness-stabilization/verification.md diff --git a/docs/history/spec-archive-index.md b/docs/history/spec-archive-index.md index 010d3ca..8b1e973 100644 --- a/docs/history/spec-archive-index.md +++ b/docs/history/spec-archive-index.md @@ -16,6 +16,7 @@ contracts. | Spec ID | Title | Package path | Status | Final spec commit | Cleanup commit | Closure action | Durable destinations | Verification | |---------|-------|--------------|--------|-------------------|----------------|----------------|----------------------|--------------| +| 007-release-readiness-stabilization | Release readiness stabilization requirements | removed; recover from Git | removed | `7fd11f9aa1cbc670d5e8b429aede4a7c01e185a4` | `pending-cleanup-commit` | removed | `README.md`; `CHANGELOG.md`; `.github/workflows/test-suite.yml`; `.github/workflows/artifact-smoke.yml`; `.github/workflows/release-validation.yml`; `.github/workflows/release.yml`; `docs/4-testing/README.md`; `docs/guides/user/installation.md`; `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md`; `docs/processes/version-management.md`; `docs/processes/README.md` | `docs/history/spec-closure-log.md` | | 008-npbackup-migration-parity | NPBackup migration parity requirements | removed; recover from Git | removed | `5830194` | `1bfea08` | removed | `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md` | `docs/history/spec-closure-log.md` | | 001-cli-consolidation-stabilization | CLI Consolidation Stabilization | removed; recover from Git | removed | `a1bb654` | `b8df9e9` | removed | `docs/3-implementation/service-layer-integration.md`; `docs/reference/repo-orientation-and-change-map.md`; `docs/specs/README.md`; `docs/history/` | `docs/history/spec-closure-log.md` | | 002-repository-safety-release-readiness | Repository Safety and Release Readiness | removed; recover from Git | removed | `4aff166` | `c6ed9ee` | removed | `README.md`; `docs/2-architecture/`; `docs/guides/user/installation.md`; `docs/guides/user/per-repo-credentials.md`; `docs/processes/version-management.md`; `docs/DOCUMENTATION-STATUS.md`; `docs/specs/README.md` | `docs/history/spec-closure-log.md` | diff --git a/docs/history/spec-closure-log.md b/docs/history/spec-closure-log.md index dd2e06e..dc55bff 100644 --- a/docs/history/spec-closure-log.md +++ b/docs/history/spec-closure-log.md @@ -15,6 +15,39 @@ final spec commit preserves the complete package. ## Entries +### 2026-07-20 - 007-release-readiness-stabilization + +- **Spec:** removed; recover from Git +- **Title:** Release readiness stabilization requirements +- **Final spec commit:** `7fd11f9aa1cbc670d5e8b429aede4a7c01e185a4` +- **Closure cleanup commit:** `pending-cleanup-commit` +- **Closure action:** removed +- **Durable docs updated:** + - `README.md` + - `CHANGELOG.md` + - `.github/workflows/test-suite.yml` + - `.github/workflows/artifact-smoke.yml` + - `.github/workflows/release-validation.yml` + - `.github/workflows/release.yml` + - `docs/4-testing/README.md` + - `docs/guides/user/installation.md` + - `docs/guides/user/recovery-operations-guide.md` + - `docs/guides/developer/scheduling-guide.md` + - `docs/processes/version-management.md` + - `docs/processes/README.md` +- **Verification summary:** All nine requirements and 53 task records are + complete. Lifecycle closure reported no blockers or open decisions; the + final normal profile passed 2,787 tests with one skip and 52.38% coverage, + and Spec 008 later completed the NPBackup cutover gates. +- **Residual risks:** + - Release publication remains a separate human decision. Intermediate + negative-control evidence retains accepted classifier advisories, while + terminal validation records contain concrete run IDs, hashes, counts, and + coverage. +- **Follow-up:** Spec 009 owns system-path elevation, independent tray/backend + control, durable run visibility, and automatic retention. It remains at the + requirements stage pending approval. + ### 2026-07-20 - 008-npbackup-migration-parity - **Spec:** removed; recover from Git diff --git a/docs/specs/007-release-readiness-stabilization/change-impact.md b/docs/specs/007-release-readiness-stabilization/change-impact.md deleted file mode 100644 index 4d7690c..0000000 --- a/docs/specs/007-release-readiness-stabilization/change-impact.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: Release readiness stabilization change impact -doc_type: spec -artifact_type: change-impact -status: active -owner: Auriora Team -last_reviewed: 2026-07-19 ---- - -# Change Impact - -## Purpose - -Record the durable behavior and documentation changed while preparing and -machine-validating the bounded `v0.9.1` stabilization release. - -## Durable Source Mapping - -| Source | Current behavior relied on | Confidence | Notes | -|--------|----------------------------|------------|-------| -| `.github/workflows/test-suite.yml` | Normal CI runs all non-performance, non-stress tests but provisions no MinIO. | high | Current failure source. | -| `.github/workflows/release-validation.yml` | A reusable read-only workflow validates intent, tests, artifacts, both smoke installs, and release-note derivation. | high | Added by T009 and exercised locally by T010. | -| `.github/workflows/release.yml` | A version tag calls the validation workflow before a separately permissioned GitHub release job. | high | Publication remains human-authorized. | -| `pyproject.toml` | Version `0.9.1`, bounded Python range, package metadata, scripts, markers, and coverage threshold. | high | Prepared but not published. | -| `scripts/bump_version.py` and `.bumpversion.cfg` | Version bumping commits and tags by default unless both are disabled. | high | Preparation must use `--no-commit --no-tag`. | -| `docs/guides/user/installation.md` | Current source install and test guidance. | high | Must reflect only verified artifact and platform behavior. | -| `docs/processes/version-management.md` | Existing version and release procedure. | high | Correct in place rather than creating a duplicate process. | -| `docs/processes/README.md` | Existing process index. | high | Must link the corrected procedure. | -| `CHARTER.md` | PyPI distribution is outside current project state. | high | Remains unchanged. | -| Repository, backup, snapshot, and restore command paths | A Linux Mint pilot created a valid Restic snapshot but exposed TimeLocker credential, dry-run, listing, restore, and reporting defects. | high | Raw Restic recovery proved the data while TimeLocker recovery remained blocked. | -| Linux tray integration | Mint provides the Ayatana namespace while the implementation expects only the legacy namespace. | high | Optional GUI behavior must not affect CLI availability. | -| Schedule generation | Generated commands currently reference unsupported policy and non-interactive options. | high | Assets are not safe to install until parser validation passes. | - -## Change Type - -- **Primary type:** operational -- **Breaking change:** no -- **Durable docs required:** yes -- **External behavior affected:** yes, CI, release artifacts, backup/recovery, - optional tray behavior, and generated schedules - -## Proposed Changes - -| Change | Type | Source of truth | New durable destination | Promotion required | -|--------|------|-----------------|-------------------------|-------------------| -| Separate normal and live MinIO test ownership with collection safety | modify | `pyproject.toml`, tests, `.github/workflows/test-suite.yml` | `docs/4-testing/README.md` and workflow | yes | -| Stabilize the selection stress signal under spec authority | bug_fix | Spec 007; issue #68 tracks assignment and evidence | test code and durable testing guidance | yes | -| Prepare version `0.9.1` without commit, tag, or release side effects | modify | `scripts/bump_version.py`, `.bumpversion.cfg`, package version sources | same files and corrected version process | yes | -| Bound Python support and validate six OS/Python combinations | modify | `pyproject.toml` and release evidence | `docs/guides/user/installation.md` | yes | -| Correct the release operator procedure | modify | Spec 007 design and rehearsal evidence | `docs/processes/version-management.md` and process index | yes | -| Publish accurate `v0.9.1` communications | add | Git history and verification evidence | `CHANGELOG.md`; GitHub release body derived from its version section | yes | -| Defer PyPI and `1.0.0` | clarify | `CHARTER.md`, milestone decision | version process and changelog | yes | -| Repair local repository initialization, dry-run, backup result, snapshot listing, and restore | bug_fix | runtime command and Restic adapter behavior | user backup/recovery guidance | yes | -| Support Mint's Ayatana indicator with legacy fallback | bug_fix | tray integration | installation and troubleshooting guidance | yes | -| Generate executable schedules with explicit configuration and privilege boundaries | modify | schedule model and renderers | scheduling/operator guidance | yes | - -## Promotion Targets - -| Spec content | Durable destination | Promotion status | Notes | -|--------------|---------------------|------------------|-------| -| Test profile contract and commands | `docs/4-testing/README.md` | complete | T001-T004 promoted normal, MinIO, and extended-profile ownership and commands. | -| Verified install matrix and prerequisites | `docs/guides/user/installation.md` | complete | T007 and T011 limit claims to the validated six-combination matrix. | -| Release procedure and rollback boundary | `docs/processes/version-management.md` | complete | Corrected in place and linked from `docs/processes/README.md` by T011. | -| Release contents and limitations | `CHANGELOG.md` | complete | T012 made the `v0.9.1` section canonical and previewed its derived release body. | -| Current version and release path | `README.md` | complete | T011 records Python 3.12-3.13 and `0.9.1` prepared, not published. | -| Backup/recovery credential and source contract | `docs/guides/user/recovery-operations-guide.md` | complete | T015-T016 machine acceptance and T019 review passed. | -| Linux tray prerequisites and fallback | `docs/guides/user/installation.md` | complete | T017 Mint and headless validation passed. | -| Schedule configuration, environment, privilege, and cutover boundary | `docs/guides/developer/scheduling-guide.md` | complete | T018 staging and T019 handoff review passed. | - -## Unchanged Durable Areas - -| Durable area | Reviewed source | Reason unchanged | -|--------------|-----------------|------------------| -| Product scope | `CHARTER.md` | Stabilization does not expand the product or publication boundary. | -| Product mandate | `CHARTER.md` | Runtime stabilization remains within the existing backup and recovery mandate. | -| CLI feature backlog | GitHub issues #5, #7, #9, #11, #28-#30, #33-#34, #54-#56 | These are reconciled but not pulled into the patch release spec. | - -## Bug Fix Details - -- **Observed behavior:** GitHub Actions run 29653160911 failed with one failure - and four setup errors because MinIO tests attempted to resolve an unavailable - endpoint; 1310 tests passed before the maximum-failure stop. -- **Expected behavior:** Each CI profile provisions every external dependency - needed by its selected tests and fails dependency preflight clearly. -- **Root cause evidence:** `.github/workflows/test-suite.yml` runs - `pytest -m "not performance and not stress"` and contains no MinIO service or - dedicated marker; the live suite also performs configuration work during - import/collection while mocked MinIO contracts are not a live-service class. -- **Regression risk:** An over-broad marker expression could hide integration - coverage; collection comparison and the explicit profile mitigate it. -- **Durable doc update needed:** Yes, testing profile and prerequisite guidance. - -## Open Questions - -Implementation is unblocked in the isolated pilot. Privileged schedule -installation, repository credential selection, identification of the actual -NPBackup scheduler, and final cutover remain explicit operator decisions after -T019; publication and lifecycle closure remain separate human decisions. - -## Related Artifacts - -- Requirements: `requirements.md` -- Design: `design.md` -- Tasks: `tasks.md` -- Verification: `verification.md` -- Traceability: `traceability.md` diff --git a/docs/specs/007-release-readiness-stabilization/design.md b/docs/specs/007-release-readiness-stabilization/design.md deleted file mode 100644 index 1fec166..0000000 --- a/docs/specs/007-release-readiness-stabilization/design.md +++ /dev/null @@ -1,302 +0,0 @@ ---- -title: Release readiness stabilization design -doc_type: spec -artifact_type: design -status: active -owner: Auriora Team -last_reviewed: 2026-07-19 ---- - -# Technical Design - -## Overview - -The release is prepared as a sequence of independently verifiable gates. CI -profiles first become dependency-correct; the known stress signal is resolved -under this spec with issue #68 retaining assignment and evidence history; -versioned artifacts are then built once and installed into -clean environments; finally, the existing release workflow is rehearsed and -the evidence is promoted into durable guidance and release communications. -Phase 5 adds a machine-acceptance gate after a Linux Mint pilot exposed runtime -defects that artifact smoke tests could not detect. Release readiness now also -requires a TimeLocker-owned backup, listing, restore, tray, and scheduling path -to work without changing the existing NPBackup job prematurely. - -## Requirement Coverage - -| Requirement | Acceptance Criteria | Design Coverage | Validation Approach | -|-------------|---------------------|-----------------|---------------------| -| R1 | AC1-AC6 | Dedicated live-service marker, collection-safe fixtures, and explicit MinIO dependency gate | Workflow review, collection partition, normal CI, MinIO profile | -| R2 | AC1-AC4 | Spec-owned stress implementation and validation; issue #68 tracks assignment and chronological evidence | Stress tests, issue evidence, extended profile | -| R3 | AC1-AC5 | Side-effect-safe version preparation, one version guard, and one artifact set reused by smoke validation | Git-state comparison, build, metadata inspection, hashes, CLI version | -| R4 | AC1-AC5 | Explicit six-combination support contract | Wheel and sdist installs, CLI smoke matrix | -| R5 | AC1-AC6 | Non-publishing rehearsal followed by in-place process updates and changelog-derived communications | Workflow lint/review, rehearsal, docs review | -| R6 | AC1-AC4 | Consistent credential resolution, side-effect-free dry-run, source validation, and truthful backup results | Focused CLI/service tests and local pilot | -| R7 | AC1-AC4 | Canonical snapshot mapping plus robust latest/exact restore and error propagation | Focused snapshot/restore tests and digest-verified restore | -| R8 | AC1-AC3 | Ayatana-first Linux indicator discovery with legacy fallback and non-fatal headless behavior | Import-path tests and Linux Mint tray smoke | -| R9 | AC1-AC4 | Schedule records bind executable repository/source inputs and render only supported CLI options | Parser round-trip tests and staged systemd asset inspection | - -## Correctness Property Coverage - -| Property | Design Behavior | Validation Direction | Notes | -|----------|-----------------|----------------------|-------| -| CP-001 | Only live-service tests carry `minio`; collection is side-effect-free; normal CI excludes that marker while retaining mocked S3/MinIO contract tests | Collection partition checks plus both workflow profiles | Marker selection must not hide unrelated integration tests. | -| CP-002 | A shared version verification command compares intended tag, `pyproject.toml`, import version, and installed CLI | Negative and positive version checks | Production tag is never needed for rehearsal. | -| CP-003 | The same smoke contract is run against wheel and sdist installs | Clean virtual environments and supported platform jobs | System prerequisites remain explicit. | -| CP-004 | Rehearsal stops before tag creation and uses workflow validation or a non-publishing harness | Command review and absence of new tag/release | Any external write needs separate release approval. | -| CP-005 | Release-note items link to commits, specs, issues, tests, or known limitations | Documentation and release review | Generated notes may be input, not sole evidence. | -| CP-006 | Credential sources converge on one repository password boundary and generated assets contain references, never values | Unit tests and redacted asset review | Existing NPBackup secrets are not inspected. | -| CP-007 | TimeLocker creates, lists, restores, and digest-verifies the same snapshot | Focused tests plus local round trip | Raw Restic proof alone is insufficient. | -| CP-008 | Rendered schedule commands are parsed by the installed CLI before installation | Parser contract tests | Privileged execution still requires operator approval. | -| CP-009 | Tray imports and initialization are optional and isolated from core CLI execution | Namespace/fallback tests and headless CLI smoke | Desktop packaging varies by distribution. | - -## High-Level Design - -### Release Gate Flow - -```text -CI profile repair - -> MinIO profile proof - -> Spec 007 stress implementation and issue #68 evidence - -> version bump and artifact build - -> clean-install matrix - -> release workflow rehearsal - -> durable docs and changelog-derived release communications - -> Linux Mint machine acceptance and staged schedule validation - -> human release decision -``` - -### Components and Changes - -- `.github/workflows/test-suite.yml`: make normal and external-service test - ownership explicit; add or invoke a MinIO-capable profile. -- Test markers and MinIO fixtures: register `minio`, mark only live-service - tests, keep collection free of configuration failures and network calls, and - provide actionable runtime dependency behavior. -- Selection stress tests and tooling: implement the calibrated baseline and - deterministic/timing separation under Spec 007; GitHub issue #68 tracks - assignment, state, representative timings, and chronological evidence. -- `pyproject.toml` and `src/TimeLocker/__init__.py`: move together to `0.9.1` - using the version helper with commit and tag side effects disabled; bound - Python support to `>=3.12,<3.14` and align classifiers. -- Build and smoke tooling: build once, inspect both artifacts, and install each - in isolated environments. -- `.github/workflows/release.yml`: preserve tag-triggered publication while - extracting or documenting a safe pre-tag rehearsal path where practical. -- `CHANGELOG.md`, installation guide, and the existing version-management - process receive accepted current-state guidance before spec closure. The - GitHub release body is derived from the `v0.9.1` changelog section. -- Repository, backup, snapshot, and restore commands: reconcile credential - precedence, source handling, snapshot mapping, progress cleanup, and reported - results around the existing Restic adapter. -- Linux tray integration: prefer `AyatanaAppIndicator3` on current Mint while - retaining the legacy `AppIndicator3` fallback and non-fatal headless mode. -- Schedule generation: persist an executable repository/source target, render - current CLI commands, and make configuration and environment-file boundaries - explicit without embedding secrets. - -### Data Models - -The schedule record gains the repository and source/selection inputs required -to execute a backup; compatibility handling is required for existing records. -Other release evidence uses files and -external records: workflow runs, `dist/` artifacts, `SHA256SUMS`, clean-install -logs, issue #68, changelog text, and release review notes. Generated `dist/` -content remains untracked unless repository policy explicitly says otherwise. - -### Data Flow - -Source metadata determines the build version. A clean checkout produces sdist -and wheel artifacts plus hashes. Each artifact is installed into a fresh -environment and queried through both console entry points. CI, stress-test -results, and linked issue evidence feed the verification record. Accepted -operator and user guidance -is promoted to durable docs, while the spec remains the temporary coordination -surface until closure. - -### Phase 5 Machine Acceptance Flow - -```text -resolve repository credential -> initialize isolated repository - -> validate dry-run without mutation -> create TimeLocker backup - -> list snapshot through TimeLocker -> restore latest and exact snapshot - -> verify reference-file digest -> validate Mint tray namespace - -> render and parse staged schedule assets -> operator cutover decision -``` - -The pilot uses isolated TimeLocker configuration and data directories. It does -not read masked NPBackup secret values, install privileged units, or disable an -existing schedule. The raw Restic CLI remains a diagnostic control only. - -## Low-Level Design - -### CI Profile Logic - -1. Register a dedicated `minio` pytest marker in `pyproject.toml`. -2. Apply it only to tests that contact a live MinIO service. Keep mocked - credential, backend, and protocol-contract tests unmarked in normal CI. -3. Move configuration validation and client/network access out of module import - and collection into fixtures or an explicit runtime preflight. -4. Run normal CI with - `pytest -m "not performance and not stress and not minio"`. -5. Add a job that provisions MinIO, waits for readiness, exports ephemeral - endpoint and credential inputs, and runs `pytest -m minio`. -6. Ensure missing dependency state produces a clear preflight failure. -7. Compare the complete collection with the normal, MinIO, performance, and - stress selections so every intended node is accounted for and no mocked - contract test moves out of normal CI. - -### Version and Artifact Guard - -```text -expected = "0.9.1" -before_commit = git_head -before_tags = git_tags -before_release_runs = tag_triggered_release_workflow_runs -before_releases = github_releases -run "python scripts/bump_version.py bump patch --no-commit --no-tag" -assert git_head == before_commit -assert git_tags == before_tags -assert tag_triggered_release_workflow_runs == before_release_runs -assert github_releases == before_releases -assert pyproject_version == expected -assert imported_version == expected -build sdist and wheel once -for artifact in [wheel, sdist]: - install artifact in a fresh environment - assert timelocker version --short == expected - assert tl version --short == expected -record metadata and SHA-256 -``` - -### Clean-Install Matrix - -The release contract is exactly Python 3.12 and 3.13 on Linux, macOS, and -Windows. `requires-python` becomes `>=3.12,<3.14`; Python classifiers list 3.12 -and 3.13; OS classifiers name the three supported systems and remove -`Operating System :: OS Independent`. -Artifact smoke validation covers all six OS/Python combinations. The normal -correctness suite runs on Ubuntu for both Python versions; artifact smoke -coverage on every declared OS is mandatory. If a runner cannot validate a -combination, the support claim must be corrected before release or readiness -remains blocked. - -### Release Rehearsal - -The rehearsal validates checkout depth, Restic acquisition and checksum, -version guard, normal tests, artifact build, smoke install, artifact upload -configuration, release-note inputs, permissions, and rollback instructions. -It records pre/post commit, tag, and GitHub-release identity. The publishing -boundary is a hard stop before any commit, `git tag`, tag push, -`gh release create`, or package-index upload. - -### Repository and Credential Boundary - -Repository initialization and later operations use the same credential -resolver. Explicit command input takes precedence over the documented -environment chain; interactive prompting occurs only when allowed and no -non-interactive source is available. Credentials are passed to Restic without -being stored in schedule commands, normal logs, or verification evidence. - -Dry-run validates the same repository and sources as execution but must not -create a snapshot. Deterministic source or credential validation failures are -returned directly and are not retried. - -### Snapshot and Restore Boundary - -Snapshot adapters map Restic's canonical timestamp into the domain model once. -Listing and restore share exact/latest resolution. Progress and status cleanup -must preserve the primary exception even if cleanup itself encounters stale or -partially initialized state. - -### Schedule Rendering Boundary - -A schedule is executable only when it identifies a repository and explicit -sources or a saved selection. Renderers build argv from commands accepted by -the current parser and validate that argv before writing cron or systemd -assets. Non-default config and credential environment files are references in -the asset; secret values are never serialized. Privileged sources require a -system-level unit and remain an operator/sudo gate. - -### Error Handling - -- Missing MinIO fails at dependency preflight in the MinIO profile. -- Test collection drift blocks the CI-profile task. -- Version mismatch or artifact-install failure blocks downstream release tasks. -- Unsupported platform results are recorded as blocking support-claim gaps, not - silently ignored. -- Rehearsal or workflow uncertainty remains a release blocker until reviewed. -- Repository and source validation errors remain primary and are not retried. -- Progress/status cleanup logs secondary failures without replacing the - original backup or restore error. -- An incomplete schedule target blocks generation before any asset is written. -- Missing tray libraries disable only the optional tray integration. - -### Security, Trust, and Access - -MinIO CI credentials must be ephemeral non-production values. Logs and -artifacts must not contain repository passwords, tokens, or callback material. -The rehearsal requires read access only; tag push and GitHub release creation -remain separately authorized release actions. PyPI credentials are neither -required nor accessed. - -### Migration and Compatibility - -This is a patch release with no intended breaking CLI change. Existing schedule -records without an executable target remain readable but cannot generate new -assets until repository and source/selection fields are supplied. Any other -discovered breaking change is removed from the release or escalated for a new -requirement and explicit versioning decision. - -## Validation Strategy - -| Validation | Covers | Evidence Location | Residual Risk | -|------------|--------|-------------------|---------------| -| Normal CI and collection comparison | R1, CP-001 | `verification.md`, Actions run | Hosted-runner variance | -| Provisioned MinIO profile and dependency preflight | R1, CP-001 | `verification.md`, Actions run | Service image drift | -| Spec-owned stress implementation, issue #68 evidence, and extended profile | R2 | tests, GitHub issue #68, `verification.md` | Hardware variance | -| Build, metadata, hashes, wheel and sdist installs | R3, R4, CP-002, CP-003 | `verification.md`, artifacts | OS coverage limits | -| Non-publishing workflow rehearsal | R5, CP-004 | `verification.md`, review record | Tag-only behavior not executed until release approval | -| Changelog-derived communications, install, and process review | R4, R5, CP-005 | durable docs and review | Human wording error | -| Repository init, dry-run, backup, and result checks | R6, CP-006 | focused tests and `verification.md` | Host credential differences | -| TimeLocker snapshot list/restore and digest round trip | R7, CP-007 | focused tests and isolated Mint pilot | Filesystem metadata variance | -| Ayatana, legacy, and headless tray paths | R8, CP-009 | focused tests and Mint tray smoke | Desktop session variance | -| Schedule render/parser round trip and staged asset review | R9, CP-006, CP-008 | focused tests and `verification.md` | Privileged installation remains manual | - -## Downstream Task Guidance - -- Repair CI before treating any release validation as authoritative. -- Do not start artifact release validation until Spec 007 stress acceptance is - met and issue #68 contains the linked evidence or blocking disposition. -- Build once and reuse artifacts across clean-install checks. -- Stop for human release approval after rehearsal and documentation; this spec - does not authorize tagging or publishing. -- Reconcile requirements, design, tasks, verification, and traceability after - any support-matrix or workflow-scope change. -- Do not restore release-ready status until the TimeLocker-owned machine round - trip succeeds and generated schedule commands parse against the current CLI. -- Do not disable NPBackup or install a privileged timer within implementation; - prepare redacted assets and leave those actions as explicit operator gates. - -## Operational Considerations - -The first real tag remains a controlled external change. A failed release must -leave the existing code and documentation recoverable by correcting the source, -incrementing version if necessary, and creating a new tag; published tags or -releases must not be silently overwritten. Exact policy is promoted to the -durable release procedure. - -## Open Questions - -Implementation can proceed on the isolated pilot. Final cutover still requires -the operator to provide a supported TimeLocker repository credential, approve -sudo installation for protected sources, identify the actual NPBackup scheduler, -and observe successful TimeLocker scheduled runs before disabling it. - -## Related Artifacts - -- Requirements: `requirements.md` -- Change Impact: `change-impact.md` -- Tasks: `tasks.md` -- Verification: `verification.md` -- Traceability: `traceability.md` diff --git a/docs/specs/007-release-readiness-stabilization/requirements.md b/docs/specs/007-release-readiness-stabilization/requirements.md deleted file mode 100644 index 27e6a24..0000000 --- a/docs/specs/007-release-readiness-stabilization/requirements.md +++ /dev/null @@ -1,342 +0,0 @@ ---- -title: Release readiness stabilization requirements -doc_type: spec -artifact_type: requirements -status: active -owner: Auriora Team -last_reviewed: 2026-07-18 ---- - -# Requirements - -## Introduction - -TimeLocker is prepared as `0.9.1` but has no published release tags. Phases 1-4 -restored CI, artifact, cross-platform smoke, and non-publishing release -evidence. A subsequent Linux Mint machine pilot proved that a valid Restic -backup can be created but exposed release-blocking defects in repository -initialization, dry-run, snapshot discovery, restore, system-tray integration, -and generated scheduling commands. Phase 5 extends the stabilization boundary -until a real local backup can be discovered and restored through TimeLocker and -an executable staged-migration schedule can be prepared safely. - -## Goals - -- Restore a green, deterministic normal CI profile without silently discarding - MinIO integration coverage. -- Stabilize the separate selection stress signal tracked by GitHub issue #68. -- Build and validate source and wheel artifacts for version `0.9.1`. -- Prove the supported installation and CLI smoke paths in clean environments. -- Rehearse the release workflow without creating a production tag. -- Produce accurate changelog-derived release communications and operator documentation. -- Prove repository setup, backup, snapshot discovery, restore, Linux Mint tray - compatibility, and schedule generation on a real operator machine. - -## Non-Goals - -- Publishing to PyPI or configuring PyPI credentials or trusted publishing. -- Declaring TimeLocker `1.0.0` or promising a stable public Python API. -- Implementing unrelated feature, CLI, configuration, or performance backlog. -- Creating a release tag or GitHub release during implementation rehearsal. -- Weakening tests, coverage, or supported-platform claims to obtain a pass. -- Extracting masked NPBackup secrets, disabling NPBackup before TimeLocker - restore proof, or installing a privileged timer without explicit sudo access. - -## Glossary - -| Term | Definition | -|------|------------| -| Normal CI | The test profile run for pushes and pull requests: tests excluding the `performance`, `stress`, and `minio` markers. | -| MinIO profile | Tests marked `minio` that contact an explicitly provisioned S3-compatible MinIO endpoint. Mocked S3/MinIO contract tests remain in normal CI. | -| Release rehearsal | Non-publishing validation of the release workflow, commands, inputs, artifacts, and permissions. | -| Release evidence | CI runs, commands, artifact metadata, hashes, install results, and review records supporting a release decision. | -| Release notes | The eventual GitHub release body derived from the canonical `v0.9.1` section in `CHANGELOG.md`, not a separate durable document. | - -## Durable Source Baseline - -| Source | Current behavior relied on | Confidence | Notes | -|--------|----------------------------|------------|-------| -| `CHARTER.md` | TimeLocker is a local-first CLI and is not currently distributed through PyPI. | high | Product and distribution boundary. | -| `README.md` | Version, installation, test, and project maturity front door. | high | Must remain aligned with verified behavior. | -| `pyproject.toml` | Package version, Python support, dependencies, console scripts, test markers, and coverage configuration. | high | Authoritative build metadata. | -| `.github/workflows/test-suite.yml` | Normal and manually dispatched extended test profiles. | high | Normal CI currently lacks MinIO provisioning or isolation. | -| `.github/workflows/release.yml` | Tag-triggered version check, tests, build, smoke install, artifact upload, and GitHub release. | high | Exists but has not been exercised by a repository release. | -| `scripts/bump_version.py` and `.bumpversion.cfg` | The version helper commits and tags by default unless both side effects are disabled. | high | Release preparation must use `--no-commit --no-tag`. | -| `docs/guides/user/installation.md` | Current installation and validation guidance. | high | Promotion target for verified clean-install behavior. | -| `docs/processes/version-management.md` | Current version-bump and release procedure. | high | Must be corrected in place and linked from the process index. | -| `docs/processes/README.md` | Durable process index. | high | Must link the corrected version-management procedure. | -| `CHANGELOG.md` | Durable project change history. | high | Target for the `v0.9.1` entry. | -| `docs/history/spec-closure-log.md` | Records the waived selection stress threshold from Spec 001. | high | Follow-up is GitHub issue #68. | - -## Durable Impact - -See `change-impact.md`. This spec modifies test workflow behavior, package -version metadata, installation guidance, the release process, release -communications, CLI recovery behavior, optional Linux tray integration, and -schedule generation. It preserves the supported credential model while making -its precedence and non-interactive use consistent. - -## Staged Readiness - -- **Current stage:** implementation -- **Next stage:** validation -- **Ready to implement when:** package lint, traceability, task dependency, and - agent-readiness checks pass. -- **Design-first exception:** no -- **Optional artifacts included:** `change-impact.md`, `verification.md`, - `traceability.md` -- **Downstream review needed:** recovery, security, operations, documentation, - and release readiness - -## Requirements - -### Requirement 1: Deterministic CI profiles - -**User Story:** As a maintainer, I want normal CI to exercise only tests whose -dependencies it provisions, so that a green result is a trustworthy release -signal and integration coverage remains explicit. - -#### Acceptance Criteria - -1. GIVEN a push or pull request, WHEN normal CI runs, THEN it SHALL complete - without attempting to contact an unprovisioned MinIO endpoint. -2. WHERE MinIO integration tests are retained, THE SYSTEM SHALL provide an - explicit profile that provisions or validates MinIO before those tests run. -3. IF the MinIO service is unavailable in its explicit profile, THEN the job - SHALL fail with a clear dependency error rather than an ambiguous test - failure or silent skip. -4. THE SYSTEM SHALL retain the configured coverage threshold and SHALL NOT - exclude unrelated correctness tests to make CI pass. -5. THE `minio` marker SHALL identify only tests that contact a live MinIO - service; mocked credential, backend, and protocol-contract tests SHALL - remain in normal CI. -6. GIVEN a checkout without MinIO configuration, WHEN pytest collects the - suite, THEN collection SHALL complete without a module-import exception or - network access and the normal, MinIO, performance, and stress selections - SHALL form an auditable ownership map for the intended suite. - -### Requirement 2: Stable performance and stress signal - -**User Story:** As a maintainer, I want the known host-sensitive selection -stress threshold resolved, so that the extended profile detects regressions -without producing routine false failures. - -#### Acceptance Criteria - -1. GIVEN representative supported hosts, WHEN the selection stress scenario is - measured under Spec 007, THEN issue #68 SHALL record timings and the chosen - tolerance or baseline strategy as chronological evidence. -2. WHERE correctness and throughput assertions are combined, THE TEST SUITE - SHALL separate deterministic correctness from environment-sensitive timing. -3. WHILE stress tests remain opt-in, THE RELEASE EVIDENCE SHALL record their - result or an explicit, owner-approved residual risk. -4. THE active spec SHALL own the approved stress-test implementation scope, - acceptance criteria, sequencing, and validation; issue #68 SHALL track - assignment, state, and linked evidence without overriding this contract. - -### Requirement 3: Reproducible release artifacts - -**User Story:** As a release operator, I want version-consistent source and -wheel artifacts, so that the GitHub release contains installable outputs built -from the tagged source. - -#### Acceptance Criteria - -1. GIVEN a clean checkout prepared for `v0.9.1`, WHEN the package is built, - THEN both sdist and wheel SHALL be produced successfully. -2. THE package version, importable `__version__`, intended tag version, and - installed CLI version SHALL all equal `0.9.1`. -3. THE artifacts SHALL contain the declared package data, both `timelocker` and - `tl` entry points, valid metadata, and recorded SHA-256 hashes. -4. IF artifact validation fails, THEN no release tag SHALL be created. -5. GIVEN the repository's side-effecting version helper, WHEN version sources - are prepared for `0.9.1`, THEN the operator SHALL use - `python scripts/bump_version.py bump patch --no-commit --no-tag` (or an - equivalently proven non-publishing operation) and SHALL record unchanged - pre/post commit, tag, tag-triggered release-workflow run, and GitHub-release - state. - -### Requirement 4: Clean installation validation - -**User Story:** As a user, I want verified installation instructions and -artifacts, so that I can install TimeLocker on a supported environment without -undeclared dependencies. - -#### Acceptance Criteria - -1. GIVEN Python 3.12 and 3.13, WHEN the wheel and sdist are installed into fresh - environments, THEN installation SHALL complete without undeclared Python - dependencies. -2. GIVEN each of Linux, macOS, and Windows on Python 3.12 and 3.13, WHEN the - supported smoke path runs, THEN `timelocker`, `tl`, version output, and root - help SHALL work in all six combinations. -3. WHERE a platform requires Restic or another system prerequisite, THE - INSTALLATION GUIDE SHALL state the verified prerequisite and limitation. -4. IF a declared support claim cannot be validated, THEN the claim SHALL be - corrected before release or the release SHALL remain blocked. -5. THE package metadata SHALL express the bounded Python support range - `>=3.12,<3.14`, and its Python and operating-system classifiers SHALL agree - with the six-combination validation contract without retaining the broader - `Operating System :: OS Independent` classifier. - -### Requirement 5: Safe release rehearsal and communications - -**User Story:** As a release operator, I want a rehearsed process and accurate -release notes, so that `v0.9.1` can be published deliberately and recovered -from failures. - -#### Acceptance Criteria - -1. GIVEN the tag-triggered workflow, WHEN it is rehearsed, THEN every step - before tag publication SHALL be validated without creating a production tag - or GitHub release. -2. THE durable release procedure SHALL identify prerequisites, authorized - operator, commands, checks, failure handling, and rollback boundaries. -3. THE `CHANGELOG.md` entry and release notes SHALL describe only changes and - limitations supported by repository evidence. -4. BEFORE release approval, THE VERIFICATION RECORD SHALL link required CI, - artifact, clean-install, stress, documentation, and review evidence. -5. PyPI publication and `1.0.0` SHALL remain explicitly deferred. -6. `CHANGELOG.md` SHALL be the checked-in canonical source for `v0.9.1` - release communications; the eventual GitHub release body SHALL be derived - from that version section rather than a second durable release-note file. - -### Requirement 6: Operator-ready repository and backup workflow - -**User Story:** As an operator, I want repository initialization and backup -commands to honor the documented credential and source contracts, so that I can -run TimeLocker non-interactively without hidden CLI exceptions. - -#### Acceptance Criteria - -1. GIVEN a repository password from the explicit option or supported - environment chain, WHEN a local repository is initialized, THEN TimeLocker - SHALL initialize it without requiring an unrelated interactive prompt. -2. GIVEN a file or directory accepted by `backup create`, WHEN a dry-run is - requested, THEN it SHALL complete without repository mutation or an - undefined-variable exception. -3. GIVEN a valid initialized repository and source, WHEN a backup completes, - THEN the result SHALL identify the created snapshot and SHALL NOT report a - false zero-file count when files were stored. -4. IF source validation fails, THEN TimeLocker SHALL report the actionable - validation error without retrying a deterministic input failure. - -### Requirement 7: Recoverable snapshot workflow - -**User Story:** As an operator, I want TimeLocker to list and restore its -snapshots, so that a successful backup represents recoverable data rather than -an opaque Restic artifact. - -#### Acceptance Criteria - -1. GIVEN a valid Restic snapshot, WHEN table or JSON listing is requested, - THEN TimeLocker SHALL map the canonical snapshot timestamp and return the - snapshot without an attribute error. -2. GIVEN `latest` or an exact snapshot ID, WHEN a full restore is requested, - THEN TimeLocker SHALL resolve the snapshot and restore its files. -3. GIVEN a restored reference file, WHEN its digest is compared with the - source, THEN the digests SHALL match. -4. IF discovery or restore fails, THEN the original failure SHALL remain - visible and SHALL NOT be replaced by progress-context or persisted-status - secondary errors. - -### Requirement 8: Linux Mint system-tray compatibility - -**User Story:** As a Linux Mint operator, I want optional tray integration to -use the desktop toolkit actually installed, so that TimeLocker does not claim -the tray is unavailable on a supported Cinnamon session. - -#### Acceptance Criteria - -1. GIVEN PyGObject and `AyatanaAppIndicator3`, WHEN TimeLocker initializes the - Linux tray, THEN it SHALL create an indicator through that namespace. -2. WHERE legacy `AppIndicator3` is available, THE SYSTEM SHALL retain that - supported compatibility path. -3. IF no tray toolkit is importable or a command runs headlessly, THEN CLI - backup and recovery behavior SHALL remain usable and the diagnostic SHALL - identify the missing optional dependency rather than deny platform support. - -### Requirement 9: Executable staged-migration schedules - -**User Story:** As an operator replacing a privileged NPBackup job, I want -generated automation to invoke a real TimeLocker command with explicit -configuration and credential boundaries, so that scheduling cannot silently -run an unsupported CLI shape or omit protected sources. - -#### Acceptance Criteria - -1. GIVEN a schedule bound to a repository and either explicit sources or a - selection, WHEN cron or systemd assets are generated, THEN every emitted - TimeLocker option SHALL be accepted by the current CLI. -2. WHERE a non-default configuration directory or protected environment file - is required, THE GENERATED ASSET SHALL reference it explicitly without - embedding secret values. -3. GIVEN sources such as `/etc`, `/var`, or `/root`, WHEN system scheduling is - prepared, THEN the guidance SHALL preserve the required privileged execution - boundary and SHALL NOT imply that a user timer provides equivalent coverage. -4. UNTIL TimeLocker backup, listing, and restore validation pass and the new - timer has observed successful runs, NPBackup SHALL remain enabled or its - external scheduling state SHALL remain unchanged. - -## Correctness Properties - -- **CP-001:** Every test in normal CI either has all external dependencies - provisioned by the job or is assigned to an explicit dependency-owning - profile. -- **CP-002:** A version mismatch among tag intent, package metadata, - `TimeLocker.__version__`, or installed CLI output always blocks release. -- **CP-003:** Installing either release artifact in a clean supported - environment yields the same version and console entry-point behavior. -- **CP-004:** Rehearsal cannot create a production tag, GitHub release, or PyPI - publication, commit, or tag as a side effect. -- **CP-005:** Each public release claim maps to a recorded validation result or - an explicit known limitation. -- **CP-006:** Every accepted credential source produces the same Restic - repository password without exposing it in generated assets or logs. -- **CP-007:** A snapshot created through TimeLocker can be listed and restored - through TimeLocker with byte-identical file content. -- **CP-008:** Every generated schedule command parses successfully against the - installed TimeLocker CLI. -- **CP-009:** Optional tray initialization cannot make core CLI backup or - recovery operations fail. - -## Technical Context - -- **Language/Version:** Python 3.12 and 3.13 only, expressed as - `requires-python = ">=3.12,<3.14"` and matching classifiers. -- **Primary Dependencies:** pytest, coverage, build, GitHub Actions, Restic, - MinIO for S3 integration tests. -- **Target Platform:** Linux, macOS, and Windows, each on Python 3.12 and 3.13. -- **Machine Acceptance Platform:** Linux Mint/Cinnamon on X11, with the system - `python3-gi` and Ayatana AppIndicator typelib available. -- **Constraints:** No secrets in logs or artifacts; no production tag during - rehearsal; coverage threshold remains 50 percent; PyPI is deferred. -- **Performance Goals:** Stress thresholds must distinguish regression from - normal host variance; no new absolute target is invented by this spec. - -## Success Criteria - -- **SC-001:** Normal GitHub Actions CI passes from a clean checkout. -- **SC-002:** The explicit MinIO profile passes with a provisioned endpoint and - fails clearly when its dependency is unavailable. -- **SC-003:** Issue #68 has closure-quality threshold evidence or an explicit - release-blocking disposition. -- **SC-004:** Both `0.9.1` artifacts pass metadata, hash, and clean-install - validation. -- **SC-005:** Release rehearsal completes without external publication. -- **SC-006:** Durable operator guidance, installation guidance, changelog, and - release notes are ready for human release approval. -- **SC-007:** A fresh local pilot repository completes init, backup, TimeLocker - snapshot listing, TimeLocker restore, and digest verification. -- **SC-008:** Linux Mint tray initialization recognizes Ayatana when the GUI - extra and system typelib are present. -- **SC-009:** Generated systemd assets use only supported commands and preserve - the separate credential, sudo, and NPBackup cutover gates. - -## Related Artifacts - -- Change Impact: `change-impact.md` -- Design: `design.md` -- Tasks: `tasks.md` -- Verification: `verification.md` -- Traceability: `traceability.md` diff --git a/docs/specs/007-release-readiness-stabilization/tasks.md b/docs/specs/007-release-readiness-stabilization/tasks.md deleted file mode 100644 index acb0bd2..0000000 --- a/docs/specs/007-release-readiness-stabilization/tasks.md +++ /dev/null @@ -1,565 +0,0 @@ ---- -title: Release readiness stabilization tasks -doc_type: spec -artifact_type: tasks -status: active -owner: Auriora Team -last_reviewed: 2026-07-19 ---- - -# Tasks - -**Input:** `docs/specs/007-release-readiness-stabilization/` - -## Task Dependency Graph - -```text -T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 - -> T009 -> T010 -> T011 -> T012 -> T013 -> T014 -> T015 -> T016 - -> T017 -> T018 -> T019 -``` - -## Phase 1: Restore Trustworthy Validation - -- [x] T001 Classify live MinIO tests and repair normal CI ownership. - - Depends on: none - - Requirement: Requirement 1 - - Acceptance Criteria: Requirement 1 AC1, AC4, AC5, AC6 - - Properties: CP-001 - - Files: `pyproject.toml`, `.github/workflows/test-suite.yml`, - `tests/TimeLocker/integration/test_s3_minio.py`, - `tests/TimeLocker/integration/test_minio_connection.py`, MinIO fixtures - - Acceptance: `minio` marks only live-service tests; collection performs no - configuration failure or network access; mocked S3/MinIO contract tests - remain in normal CI; every intended node is accounted for. - - Validation: Complete and partitioned collection, focused mocked tests, - `pytest -m "not performance and not stress and not minio"`. - - Evidence: `.github/workflows/test-suite.yml:69` owns the corrected CI - selector. Its local execution produced 2,754 successful tests and 52.13% - coverage. Collection found 2,812 nodes: 2,755 in the CI profile, 53 in the - performance/stress profile, and four in the live MinIO profile. - - Status: Complete on 2026-07-18; provisioned live-service execution remains T002. - - Evidence mode: implementation - - [x] T001.1 Capture complete, normal, MinIO, performance, and stress collections and failing-run evidence. - - Evidence: Full collection found 2,812 nodes; selector counts were 2,755, - 53, and four respectively. GitHub Actions run 29653160911 recorded the - original one failure and four setup errors. - - [x] T001.2 Register `minio` and mark only tests that contact the live service. - - Evidence: `pyproject.toml` registers `minio`; contract test - `test_only_live_service_tests_use_minio_marker` passed for the four named - live-service nodes. - - [x] T001.3 Move configuration and network access from import/collection into fixtures or runtime preflight. - - Evidence: Clean-environment collection reported `4/2812`; runtime fixtures - at `tests/TimeLocker/integration/test_s3_minio.py:45` and line 58 load - settings and perform reachability checks. - - [x] T001.4 Prove mocked MinIO contract tests remain in normal CI and collection nodes are not lost. - - Evidence: `test_mocked_minio_contracts_remain_in_normal_profile` passed; - the focused profile produced nine successful tests, and the full profile - produced 2,754 successful tests at 52.13% coverage. - -- [x] T002 Add and validate the provisioned MinIO profile. - - Depends on: T001 - - Requirement: Requirement 1 - - Acceptance Criteria: Requirement 1 AC2, AC3, AC6 - - Properties: CP-001 - - Files: `.github/workflows/test-suite.yml`, MinIO fixtures or preflight tests, - `docs/4-testing/README.md` - - Acceptance: The explicit profile provisions or validates MinIO, runs - `pytest -m minio`, passes its tests, and reports an actionable dependency - error when unavailable. - - Validation: Provisioned profile plus a negative preflight test. - - Evidence: The workflow provisions pinned MinIO image - `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`, waits on its live - health endpoint, creates the isolated bucket, and runs the four live nodes. - The final local provisioned profile passed all four nodes in 20.39 seconds; the - negative fixture contract reports the endpoint and required recovery action. - - Status: Complete on 2026-07-18; T003 owns hosted validation. - - Evidence mode: implementation - - [x] T002.1 Define ephemeral endpoint and credential inputs. - - Evidence: `.github/workflows/test-suite.yml` defines the loopback endpoint, - disposable `timelocker-ci` credentials, bucket, region, TLS-verification, - and log-level values in the `minio-test` job. - - [x] T002.2 Provision MinIO and wait for readiness before pytest. - - Evidence: The job starts the pinned container, polls - `/minio/health/live`, creates the bucket with `boto3`, and always removes - the container. - - [x] T002.3 Add clear dependency-preflight failure behavior. - - Evidence: `test_live_minio_preflight_failure_is_actionable` and - `test_workflow_provisions_and_runs_live_minio_profile` passed, proving - unavailable MinIO reports its endpoint and recovery action instead of - skipping. - - [x] T002.4 Run and record the explicit profile. - - Evidence: A disposable local container served all four `minio` nodes; - pytest reported four passed and 2,812 deselected. - -- [x] T003 Checkpoint - CI profile validation. - - Depends on: T002 - - Requirement: Requirement 1 - - Acceptance Criteria: Requirement 1 AC1, AC2, AC3, AC4, AC5, AC6 - - Acceptance: Normal and MinIO profiles pass, all intended test nodes are - partitioned or intentionally shared, mocked contracts remain normal, - coverage remains at least 50 percent, and no unrelated test is excluded. - - Validation: GitHub Actions evidence, pytest collection partition, coverage report. - - Evidence: GitHub Actions run `29676747955` passed at commit `8a7e1c1`: - the normal job completed 2,760 selected nodes with 2,759 successes and - 52.15% coverage; 57 nodes were outside its selector. The provisioned MinIO - job passed all four live nodes, and the coverage quality gate and final - notification also passed. Full collection contains 2,817 nodes: 2,760 - normal, 53 performance/stress, and four MinIO. - - Status: Complete on 2026-07-19; Phase 1 is complete and T004 is next. - - Evidence mode: validation - -## Phase 2: Stabilize the Extended Signal - -- [x] T004 Implement and validate the selection stress-threshold contract. - - Depends on: T003 - - Requirement: Requirement 2 - - Acceptance Criteria: Requirement 2 AC1, AC2, AC3, AC4 - - Files: `tests/TimeLocker/selection/test_performance_stress.py`, - `src/TimeLocker/selection_testing_harness.py`, related test tooling, - `docs/4-testing/README.md` - - Acceptance: Spec 007 owns the implementation and validation; deterministic - correctness is separated from timing; a representative baseline and - tolerance are implemented; the repeatable extended profile passes or a - release-blocking disposition is recorded. - - Evidence mode: implementation - - Evidence: Implemented `PerformanceBaseline`, split deterministic correctness from opt-in timing, replaced the 60-second iteration-count gate with a warmed 12-operation median check using a 1.0s baseline and 2.0x tolerance, and documented reproduction. Three targeted runs passed at 0.160s/0.176s/0.173s; the extended profile passed 53 tests in 45.60s; the normal profile passed 2,765 tests with one skip and 52.14% coverage. Evidence: https://github.com/Auriora/TimeLocker/issues/68#issuecomment-5014886293. - - Status: Complete on 2026-07-19; issue #68 was closed with final evidence - on 2026-07-20. - - [x] T004.1 Capture representative host timings and environment context in issue #68. - - Evidence: Issue #68 records Linux/Python/CPU/load context, the - 209-iteration legacy result, historical 57/70-iteration observations, - and the calibrated strategy. - - Status: Complete on 2026-07-19. - - Evidence mode: validation - - [x] T004.2 Separate deterministic correctness assertions from environment-sensitive timing assertions. - - Evidence: `test_repeated_operations_preserve_selection_correctness` owns - deterministic stability assertions; `test_sustained_selection_performance` - owns only the opt-in timing signal. - - Status: Complete on 2026-07-19. - - Evidence mode: validation - - [x] T004.3 Implement the evidence-backed baseline and tolerance strategy. - - Evidence: `PerformanceBaseline` validates a named 1.0-second reference - with a 2.0x tolerance; the stress test warms caches, measures 12 fixed - operations with a monotonic clock, and evaluates the median. - - Status: Complete on 2026-07-19. - - Evidence mode: validation - - [x] T004.4 Run a repeatable extended profile and link results from issue #68. - - Evidence: Three targeted runs passed at 0.160s, 0.176s, and 0.173s - median; the complete extended profile passed 53 tests in 45.60s. - - Status: Complete on 2026-07-19. - - Evidence mode: validation -- [x] T005 Checkpoint - Release validation prerequisites. - - Depends on: T004 - - Requirements: Requirement 1, Requirement 2 - - Acceptance: Normal CI is green, explicit external-service coverage is - green, Spec 007 stress acceptance is met, and issue #68 contains linked - evidence or an explicit release-blocking disposition. - - Validation: Review T003 and T004 evidence and the linked issue history. - - Evidence: Phase 2 prerequisites are met: hosted run 29676747955 passed - normal CI, provisioned MinIO, the coverage quality gate, and notification; - the post-change local normal profile passed 2,765 tests with one skip and - 52.14% coverage; the extended profile passed 53 tests; and issue #68 - contains environment, calibration, and repeat evidence. - - Status: Complete on 2026-07-19; Phase 2 checkpoint passed and T006 is next. - - Evidence mode: validation - -## Phase 3: Build and Install v0.9.1 - -- [x] T006 Prepare version `0.9.1` safely and build reproducible artifacts. - - Depends on: T005 - - Requirement: Requirement 3 - - Acceptance Criteria: Requirement 3 AC1, AC2, AC3, AC4, AC5 - - Properties: CP-002, CP-004 - - Files: `pyproject.toml`, `src/TimeLocker/__init__.py`, - `scripts/bump_version.py`, `.bumpversion.cfg`, build and release tooling - - Acceptance: The non-publishing version command changes only versioned - working-tree files; commit, tag, and GitHub-release identity remain - unchanged; version sources, sdist, wheel, metadata, entry points, package - data, and SHA-256 hashes validate from a clean source baseline. - - Validation: Pre/post Git and release-state comparison, - `python scripts/bump_version.py bump patch --no-commit --no-tag`, version - guard, `python -m build`, artifact inspection. - - Evidence: From clean commit `9348c58413af3422167faf0a052ef5e80571d647`, the exact non-publishing helper changed only the three version sources. Final run `29679083454` built one shared wheel/sdist set, validated version `0.9.1`, `Requires-Python`, both entry points, nine package-data files, and hashes. The deliberate `0.9.0` guard failed before artifact checks. Tags remained empty, GitHub releases remained empty, and the release workflow retained its 11 historical runs with the newest dated 2025-09-27. - - Status: Complete on 2026-07-19; no tag, GitHub release, or publication was created. - - Evidence mode: implementation - - [x] T006.1 Record pre-change commit, tag, tag-triggered release-workflow run, and GitHub-release identity. - - Evidence: Baseline was commit `9348c58413af3422167faf0a052ef5e80571d647`, zero tags, 11 historical release-workflow runs (newest 2025-09-27), and zero GitHub releases. - - Status: Complete on 2026-07-19. - - [x] T006.2 Run the version helper with both commit and tag side effects disabled. - - Evidence: `python scripts/bump_version.py bump patch --no-commit --no-tag` advanced `0.9.0` to `0.9.1` and modified only the three configured version files. - - Status: Complete on 2026-07-19. - - [x] T006.3 Update `requires-python` to `>=3.12,<3.14`, remove `OS Independent`, and reconcile Python and OS classifiers. - - Evidence: Final metadata declares only Python 3.12/3.13 and the explicitly validated Linux, macOS, and Windows classifiers. - - Status: Complete on 2026-07-19. - - [x] T006.4 Build sdist and wheel once; inspect metadata, contents, entry points, and hashes. - - Evidence: Run `29679083454` built one shared artifact set and validated version, Python range, two entry points, nine data files, and SHA-256 hashes before matrix fan-out. - - Status: Complete on 2026-07-19. - - [x] T006.5 Prove a version mismatch blocks the guard and prove commit, tag, tag-triggered release-workflow run, and release identity did not change. - - Evidence: Expected version `0.9.0` exited nonzero before artifact checks; the helper itself left HEAD and all external release identities at their baseline values. - - Status: Complete on 2026-07-19. - -- [x] T007 Validate wheel and sdist across the declared support matrix. - - Depends on: T006 - - Requirement: Requirement 4 - - Acceptance Criteria: Requirement 4 AC1, AC2, AC3, AC4, AC5 - - Properties: CP-003 - - Files: `.github/workflows/`, smoke tooling, - `docs/guides/user/installation.md`, `pyproject.toml` - - Acceptance: Wheel and sdist pass the shared CLI smoke contract on Linux, - macOS, and Windows for Python 3.12 and 3.13; an unvalidated combination - blocks readiness until its support claim is corrected and reviewed. - - Validation: Six OS/Python combinations, both artifact types, both console entry points. - - Evidence: Read-only pull-request run `29679083454` passed a single shared build plus 12 install jobs: wheel and sdist on Linux, macOS, and Windows with Python 3.12 and 3.13. Both console entry points passed version and root-help checks. The first matrix exposed Windows `cp1252`-unsafe help glyphs; commit `4a2d998` replaced them and the full rerun passed. The installation guide records the verified matrix, Python range, Restic prerequisite, and publication boundary. - - Status: Complete on 2026-07-19. - - [x] T007.1 Add or reconcile the six-combination Linux/macOS/Windows and Python 3.12/3.13 smoke matrix. - - Evidence: `.github/workflows/artifact-smoke.yml` defines the full three-OS by two-Python matrix and reuses one uploaded artifact set. - - Status: Complete on 2026-07-19. - - [x] T007.2 Install the wheel and run version, root help, and safe quick-start smoke checks in every combination. - - Evidence: All six wheel jobs passed both `timelocker` and `tl` version and root-help checks in run `29679083454`. - - Status: Complete on 2026-07-19. - - [x] T007.3 Install the sdist and run the identical smoke contract in every combination. - - Evidence: All six sdist jobs passed the identical two-entry-point contract in run `29679083454`. - - Status: Complete on 2026-07-19. - - [x] T007.4 Record platform prerequisites and correct any support claim that cannot be validated. - - Evidence: The installation guide now records Python `>=3.12,<3.14`, Restic 0.18.0 or later, the verified matrix, and the no-PyPI-publication boundary; Windows help was corrected and revalidated rather than dropping support. - - Status: Complete on 2026-07-19. - -- [x] T008 Checkpoint - Artifact and installation readiness. - - Depends on: T007 - - Requirements: Requirements 3 and 4 - - Acceptance: Side-effect safety, artifact identity, hashes, six-combination - installation results, platform coverage, and residual risk are recorded - before release rehearsal. - - Validation: Review artifact and clean-install evidence against CP-002, CP-003, and CP-004. - - Evidence: CP-002 passed through source/artifact identity checks and the negative mismatch guard. CP-003 passed all 12 artifact install jobs in run `29679083454`. CP-004 side-effect evidence shows zero tags, zero GitHub releases, and no new release-workflow run. Final artifact hashes are `a3d5eb9f423cbb38a829387f286c261c93e6bedd2a9cc1413069981d6a268bc5` (wheel) and `75c5fc42a3a2909094d9d1ed52466ecdd05266160f36ae1eb04cb23e9236b843` (sdist). The only observed advisory is upstream Actions Node.js 20 deprecation; it did not affect validation and remains a workflow-maintenance risk. - - Status: Complete on 2026-07-19; Phase 3 passed and T009 is next. - -## Phase 4: Rehearse, Promote, and Review - -- [x] T009 Implement a safe pre-tag validation interface. - - Depends on: T008 - - Requirement: Requirement 5 - - Acceptance Criteria: Requirement 5 AC1, AC5 - - Properties: CP-004 - - Files: `.github/workflows/release.yml`, release validation scripts or tests - - Acceptance: A reusable pre-tag path validates release inputs and steps but - contains no commit, tag, release, or package-index publication action. - - Evidence mode: implementation - - Validation: Workflow syntax, focused script tests, publication-boundary review. - - Evidence: Added reusable `.github/workflows/release-validation.yml`, extracted release-intent, release-note, and workflow-boundary validators, and refactored `.github/workflows/release.yml` so validation is read-only and only the dependent publish job has `contents: write`. Focused release-contract tests: 9 passed. `actionlint` passed both workflows. Boundary validator passed and negative permission/mismatch/missing-artifact paths propagate failure. - - Status: Complete on 2026-07-19; no publication action executed. - - [x] T009.1 Identify and isolate every pre-publication release step. - - Evidence: `.github/workflows/release-validation.yml` owns checkout, - prerequisites, intent, tests, build, artifact inspection, both smoke - installs, notes derivation, and uploads; `.github/workflows/release.yml` - retains GitHub release creation in its dependent publish job. - - Status: Complete on 2026-07-19. - - Evidence mode: validation - - [x] T009.2 Implement a manual or local validation entry point with read-only permissions. - - Evidence: Added manual `workflow_dispatch` and reusable `workflow_call` entry points under `contents: read`. - - Status: Complete on 2026-07-19. - - Evidence mode: validation - - [x] T009.3 Add regression coverage for the publication boundary and failure propagation. - - - Evidence: Nine focused release-contract tests passed for intent, - derivation, missing artifacts, rehearsal permission, and the isolated - publish job; `actionlint` also passed both release workflows. - - Status: Complete on 2026-07-19. - - Evidence mode: validation -- [x] T010 Execute and record a non-publishing release rehearsal. - - Depends on: T009 - - Requirement: Requirement 5 - - Acceptance Criteria: Requirement 5 AC1, AC4, AC5 - - Properties: CP-004 - - Files: `verification.md`, workflow-run or local rehearsal evidence - - Acceptance: Build, smoke, artifact configuration, permissions, and failure - paths are exercised; pre/post commit, tag, and GitHub-release identity are - unchanged; no external publication occurs. - - Evidence mode: validation - - Validation: Non-publishing rehearsal and external-state comparison. - - Evidence: Local rehearsal passed release-intent and permission-boundary validation, built and inspected one wheel and one sdist, wrote SHA256SUMS, and clean-installed/smoked both artifacts through `timelocker` and `tl`. Negative version `v0.9.0`, missing-artifact, and unsafe-permission cases failed as intended. Pre/post HEAD remained `1dcf91090c755c476afe1851b2c4e02cdd9a949f`; tags remained zero, GitHub releases remained zero, and historical tag-triggered release runs remained 11. - - Status: Complete on 2026-07-19; no external publication occurred. - - [x] T010.1 Capture pre-rehearsal commit, tag, release, and permission state. - - Evidence: Captured HEAD `1dcf910`, zero tags, zero GitHub releases, 11 historical release runs, read-only rehearsal permission, and one job-scoped publish permission. - - Status: Complete on 2026-07-19. - - Evidence mode: validation - - [x] T010.2 Exercise successful build, smoke, artifact, and release-note inputs. - - Evidence: Built and validated wheel SHA-256 - `a3d5eb9f423cbb38a829387f286c261c93e6bedd2a9cc1413069981d6a268bc5` - and sdist SHA-256 - `75c5fc42a3a2909094d9d1ed52466ecdd05266160f36ae1eb04cb23e9236b843`; - both clean-install smokes passed through `timelocker` and `tl`. - - Status: Complete on 2026-07-19. - - Evidence mode: validation - - [x] T010.3 Exercise version mismatch, missing prerequisite, and permission failure paths. - - Evidence: Confirmed `v0.9.0` mismatch, missing artifact, and unsafe rehearsal permission all fail. - - Status: Complete on 2026-07-19. - - Evidence mode: validation - - [x] T010.4 Capture unchanged post-rehearsal external state and link all logs. - - - Evidence: Post-state remained HEAD `1dcf910`, zero tags, zero GitHub releases, and 11 historical release runs. - - Status: Complete on 2026-07-19. - - Evidence mode: validation -- [x] T011 Update existing durable release and installation procedures. - - Depends on: T010 - - Requirements: Requirements 4 and 5 - - Acceptance Criteria: Requirement 4 AC3, AC4, AC5; Requirement 5 AC2, AC5 - - Files: `docs/processes/version-management.md`, `docs/processes/README.md`, - `docs/guides/user/installation.md`, `README.md` if required - - Acceptance: The existing version-management procedure documents the safe - preparation command, authorized publication boundary, checks, failure and - rollback handling, and is indexed; installation guidance reflects only - the validated support matrix and prerequisites. - - Evidence mode: implementation - - Validation: Procedure review, Markdown and internal-link checks, command review. - - Evidence: Corrected `docs/processes/version-management.md` in place with preparation, rehearsal, approval, publication, verification, failure, rollback, and PyPI/1.0 deferral boundaries; indexed it from `docs/processes/README.md`; aligned README and installation claims to Python 3.12-3.13, version 0.9.1 prepared/not published, and the normal test selector. Agent Workbench checked all five durable documents with zero Markdown or link findings. - - Status: Complete on 2026-07-19; durable procedure and front-door claims are current. - - [x] T011.1 Correct `version-management.md` in place; do not create a duplicate release procedure. - - Evidence: `docs/processes/version-management.md` now contains preparation, - authorization, validation, recovery, and PyPI/1.0 deferral boundaries. - - Status: Complete on 2026-07-19. - - Evidence mode: validation - - [x] T011.2 Link the procedure from `docs/processes/README.md`. - - Evidence: `docs/processes/README.md` links - `./version-management.md`; the bounded Markdown/link check reported zero - findings. - - Status: Complete on 2026-07-19. - - Evidence mode: validation - - [x] T011.3 Update installation and front-door claims from T007 evidence. - - - Evidence: Aligned README and installation claims to version 0.9.1 prepared/not published, Python 3.12-3.13, and the normal selector. - - Status: Complete on 2026-07-19. - - Evidence mode: validation -- [x] T012 Prepare evidence-backed `v0.9.1` communications. - - Depends on: T011 - - Requirement: Requirement 5 - - Acceptance Criteria: Requirement 5 AC3, AC5, AC6 - - Properties: CP-005 - - Files: `CHANGELOG.md`, GitHub release-body input or derivation tooling - - Acceptance: The `v0.9.1` changelog section is the single checked-in - canonical release-note source; every claim maps to evidence or a known - limitation; the eventual GitHub release body is derived from that section. - - Evidence mode: implementation - - Validation: Claim-to-evidence review and release-body derivation preview. - - Evidence: Added canonical `CHANGELOG.md` section `[0.9.1] - Prepared 2026-07-19` using verified CI, stress, artifact, cross-platform, encoding, version, and publication-boundary evidence plus four explicit limitations. `scripts/extract_release_notes.py` derived the complete GitHub release-body preview from that exact section; focused extraction tests passed. - - Status: Complete on 2026-07-19; communications are prepared but unpublished. - - [x] T012.1 Draft the changelog section from verified changes and limitations. - - Evidence: `CHANGELOG.md` contains `[0.9.1] - Prepared 2026-07-19` with - verified changes and four explicit limitations. - - Status: Complete on 2026-07-19. - - Evidence mode: validation - - [x] T012.2 Map each public claim to verification, commits, specs, or issues. - - Evidence: Mapped public claims to hosted CI, stress issue evidence, artifact matrix, rehearsal, or explicit limitation in verification.md. - - Status: Complete on 2026-07-19. - - Evidence mode: validation - - [x] T012.3 Preview the GitHub release body without creating a release. - - - Evidence: `scripts/extract_release_notes.py` derived the complete 0.9.1 - GitHub release-body preview; focused extraction tests passed and GitHub - releases remained zero. - - Status: Complete on 2026-07-19. - - Evidence mode: validation -- [x] T013 Checkpoint - Human release decision and spec closure readiness. - - Depends on: T012 - - Requirements: Requirement 1, Requirement 2, Requirement 3, Requirement 4, - Requirement 5 - - Acceptance: All required evidence is linked; durable content is promoted; - residual risks and owners are explicit; no commit, tag, GitHub release, or - PyPI publication was created by preparation or rehearsal; and the package - is ready for separate human release approval and lifecycle closure. - - Decision owner: release maintainer - - Validation: Lifecycle lint, readiness, traceability and evidence checks, - required test profiles, Markdown and internal-link checks, - `git diff --check`, security and release-readiness expert review. - - Evidence: The final normal profile passed 2,774 tests with 52.14% - coverage. All 22 tool-manager tests, nine release-contract tests, - `actionlint`, release validators, derived-notes preview, documentation - checks, lifecycle checks, and expert-panel assessment passed. External-state - identities matched the recorded baseline and the publication boundary was - preserved. - - - Status: Complete on 2026-07-19; ready for separate release-maintainer approval and lifecycle closure, with no commit or publication created. - - Evidence mode: validation - -## Phase 5: Machine Acceptance and Migration Preparation - -- [x] T014 Reconcile the Linux Mint pilot findings into the active package. - - Depends on: T013 - - Requirements: Requirements 6, 7, 8, and 9 - - Acceptance Criteria: all - - Properties: CP-006, CP-007, CP-008, CP-009 - - Files: all Spec 007 artifacts - - Acceptance: The package records the failed TimeLocker-owned pilot, removes - stale release/closure readiness claims, maps every new criterion, and - preserves credential, privilege, and NPBackup cutover boundaries. - - Validation: Lifecycle lint, readiness, traceability, and `git diff --check`. - - Evidence: The isolated Linux Mint/Cinnamon pilot created Restic snapshot - `876b20bc7916` but exposed repository-init credential inconsistency, an - undefined dry-run variable, false backup counts, snapshot timestamp and - restore-state failures, Ayatana namespace mismatch, and unparseable - generated scheduling commands. Raw Restic listing and digest-verified - restore passed as a diagnostic control; no secret was extracted, no timer - was installed, and NPBackup state was not changed. - - Status: Complete on 2026-07-19; lifecycle lint and stage readiness passed - with zero gaps and the release/closure decision is explicitly withdrawn. - - Evidence mode: reconciliation - -- [x] T015 Repair repository initialization and backup execution. - - Depends on: T014 - - Requirement: Requirement 6 - - Acceptance Criteria: AC1-AC4 - - Properties: CP-006 - - Files: repository and backup CLI/service paths plus focused tests - - Acceptance: Explicit and environment credentials initialize consistently; - file/directory dry-runs do not mutate or raise; successful results identify - the snapshot and truthful counts; deterministic validation is not retried. - - Validation: Focused repository/backup tests and isolated pilot init, - dry-run, and backup. - - Evidence: Credential resolution now accepts explicit, stored, or environment - input without re-resolving a known URI; direct files are valid selections; - missing CLI sources and invalid targets fail before retry; both dry-run - paths avoid job-only state; Restic summary fields produce truthful counts; - runtime passwords are absent from result metadata. Focused validation passed - 124 tests. On the isolated Mint pilot, environment-only init recognized the - repository, file and directory dry-runs reported 1 and 11 files, and an - actual file backup created snapshot `731d9784` with one file and 15,839 - bytes. Snapshot count moved from one to two only after the actual backup. - - Status: Complete on 2026-07-19. - - Evidence mode: implementation - -- [x] T016 Repair snapshot discovery and restore. - - Depends on: T015 - - Requirement: Requirement 7 - - Acceptance Criteria: AC1-AC4 - - Properties: CP-007 - - Files: snapshot model/adapter, restore manager/CLI, progress/status handling, - and focused tests - - Acceptance: Table and JSON listing work; latest and exact restore work; - a reference digest matches; cleanup never replaces the primary failure. - - Validation: Focused snapshot/restore tests and isolated TimeLocker-owned - list/restore/digest round trip. - - Evidence: The snapshot adapter now maps Restic `time`, `paths`, host, user, - and full IDs; table and JSON listing serialize canonical fields; `latest` - resolves to the newest snapshot; recovery operations initialize progress; - and progress cleanup preserves a primary body exception. The focused - snapshot/recovery/CLI/progress suite passed 120 tests, followed by 19 - snapshot-manager and 14 orchestrator regression tests. On the isolated - Mint pilot, both listing formats exposed two snapshots, `latest` and the - exact 64-character ID restored through TimeLocker, and both restored - `README.md` files matched the source SHA-256 digest. - - Status: Complete on 2026-07-19. - - Evidence mode: implementation - -- [x] T017 Add Linux Mint tray compatibility without coupling core CLI behavior. - - Depends on: T016 - - Requirement: Requirement 8 - - Acceptance Criteria: AC1-AC3 - - Properties: CP-009 - - Files: tray integration, optional dependency metadata/guidance, focused tests - - Acceptance: Ayatana and legacy namespaces are supported; headless or - missing-dependency state is accurate and non-fatal to backup/recovery. - - Validation: Focused import/fallback tests, headless CLI smoke, and Mint tray smoke. - - Evidence: Linux tray discovery now prefers `AyatanaAppIndicator3`, falls - back to legacy `AppIndicator3`, retains the selected namespace for - shutdown, and leaves the facade unavailable without affecting the CLI when - PyGObject is absent. Six focused tests passed. On this Mint Cinnamon/X11 - host, the project interpreter remained correctly headless, `tl version` - passed, and `/usr/bin/python3` initialized and shut down an Ayatana - indicator using the installed GTK/PyGObject typelibs. Installation guidance - now explains the optional packages and pyenv boundary. - - Status: Complete on 2026-07-19. - - Evidence mode: implementation - -- [x] T018 Generate executable staged-migration schedules. - - Depends on: T017 - - Requirement: Requirement 9 - - Acceptance Criteria: AC1-AC3 - - Properties: CP-006, CP-008 - - Files: schedule model/CLI/renderers, migrations or compatibility handling, - operator guidance, and focused tests - - Acceptance: Schedules bind a repository and sources/selection; rendered - commands parse against the current CLI; assets reference non-default config - and protected environment files without secret values; protected sources - retain a system-level privilege boundary. - - Validation: Focused schedule tests, parser round trip, and redacted staged - cron/systemd asset inspection without installation. - - Evidence: Schedule creation now requires an explicit repository and exactly - one selection or one-or-more sources; it records non-default config, - environment-file reference, and user/system boundary. Cron, systemd, and - Windows renderers use the current `backup create` contract and never emit - credential values. The schedule test validates the command and referenced - paths. Twenty focused CLI and end-to-end tests passed. The isolated Mint - pilot created a disabled system schedule for repository - `timelocker-pilot`, protected source `/etc`, the mode-0600 pilot environment - reference, and the explicit pilot config directory. Cron and systemd assets - passed shell/parser and current-CLI checks; no unit or cron entry was - installed or enabled, and NPBackup state was unchanged. - - Status: Complete on 2026-07-19. - - Evidence mode: implementation - -- [x] T019 Checkpoint - Machine acceptance and operator cutover handoff. - - Depends on: T018 - - Requirements: Requirements 6, 7, 8, and 9 - - Acceptance Criteria: all - - Properties: CP-006, CP-007, CP-008, CP-009 - - Acceptance: The isolated TimeLocker round trip and tray/schedule checks pass; - durable guidance is promoted; privileged installation, repository - credential selection, actual NPBackup scheduler discovery, observation, and - final cutover are documented as separate operator gates. - - Decision owner: operator and release maintainer - - Validation: Focused and normal test profiles, machine pilot, lifecycle and - traceability checks, durable-doc review, and `git diff --check`. - - Evidence: T015-T018 passed focused implementation suites and the isolated - Linux Mint/Cinnamon pilot: environment-only init, non-mutating dry-runs, a - real TimeLocker snapshot, table/JSON listing, latest and exact-ID restores - with matching SHA-256 digests, Ayatana initialization, headless CLI use, - and redacted cron/systemd staging. The final normal profile passed 2,787 - tests with one skipped, 57 deselected, and 52.38% coverage in 801.81 - seconds. Durable installation, recovery, and scheduling guidance was - promoted. Task-state audit, closure readiness, acceptance traceability, - guide-specific Markdown, local-link, compile, and whitespace checks passed; - lifecycle lint retained only its optional canonical-context advisory and - the package retains historical evidence/table-readability advisories. No - privileged unit was installed, NPBackup was not changed, and `/etc` remains - a representative protected pilot source rather than a confirmed NPBackup - source. - - Status: Complete on 2026-07-19; ready for separate operator credential and - source reconciliation, scheduler discovery, privileged-install approval, - observed scheduled runs, and final cutover approval. Release approval and - lifecycle closure remain separate human decisions. - - Evidence mode: validation - -## Execution Rules - -- Read the linked row in `traceability.md` and the relevant requirements, - design, change-impact, and verification sections before starting a task. -- Mark a selected task `[~]` before implementation and record evidence before - marking it `[x]`. -- Do not create a commit, production tag, GitHub release, or PyPI publication - as a side effect of version preparation or rehearsal. A normal task commit - may occur only after validation and separate explicit commit instruction; - tagging and publication always require separate release approval. -- Spec 007 owns stress-threshold scope, implementation, sequencing, - acceptance, and validation. GitHub issue #68 tracks assignment, state, and - chronological evidence. -- A failed prerequisite blocks downstream release tasks; it is not waived by - reducing test or support scope without an approved spec reconciliation. - -## Rules Consulted - -Rules consulted and applied: General Preferences (priority 50), Operational -Best Practices (priority 40), Planning Protocol (priority 30), Testing -Conventions (priority 25), and Documentation Conventions (priority 20). -Override: the user already approved remediation by requesting that the review -findings be addressed, so no repeated approval gate was required. -Final downstream review confirmed these tasks implement the reconciled -requirements and design, including the changelog-derived communications model. - -## Related Artifacts - -- Requirements: `requirements.md` -- Change Impact: `change-impact.md` -- Design: `design.md` -- Verification: `verification.md` -- Traceability: `traceability.md` diff --git a/docs/specs/007-release-readiness-stabilization/traceability.md b/docs/specs/007-release-readiness-stabilization/traceability.md deleted file mode 100644 index 855f4ad..0000000 --- a/docs/specs/007-release-readiness-stabilization/traceability.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: Release readiness stabilization traceability -doc_type: spec -artifact_type: traceability -status: active -owner: Auriora Team -last_reviewed: 2026-07-20 ---- - -# Traceability Matrix - -## Task To Context Matrix - -| Task ID | Requirements | Acceptance Criteria | Design Sections | Change Impact | Verification | Durable Targets | Open Decisions | -|---------|--------------|---------------------|-----------------|---------------|--------------|-----------------|----------------| -| T001 | Requirement 1 | AC1, AC4, AC5, AC6 | CI Profile Logic | Live MinIO classification and collection safety | normal profile and collection partition | workflow, testing guide | none | -| T002 | Requirement 1 | AC2, AC3, AC6 | CI Profile Logic; Error Handling; Security | Provisioned MinIO profile | MinIO profile and negative preflight | workflow, testing guide | none | -| T003 | Requirement 1 | AC1, AC2, AC3, AC4, AC5, AC6 | Validation Strategy | CI profile readiness | CI quality gate, coverage, partition proof | testing guide | none | -| T004 | Requirement 2 | AC1, AC2, AC3, AC4 | Components; Validation Strategy | Spec-owned stress bug fix | representative timings, tests, issue #68, extended profile | tests, testing guide | none | -| T005 | Requirement 1, Requirement 2 | all | Downstream Task Guidance | CI and stress readiness | prerequisite checkpoint | none | none | -| T006 | Requirement 3 | AC1, AC2, AC3, AC4, AC5 | Version and Artifact Guard; Security | Side-effect-safe version and artifact changes | Git/release-state comparison, build, metadata, version guard | metadata, version process, changelog | none | -| T007 | Requirement 4 | AC1, AC2, AC3, AC4, AC5 | Clean-Install Matrix | Exact support matrix and install validation | six-combination wheel and sdist smoke matrix | metadata, installation guide | none | -| T008 | Requirements 3 and 4 | all | Validation Strategy | Artifact and install readiness | artifact checkpoint and side-effect proof | installation guide | none | -| T009 | Requirement 5 | AC1, AC5 | Release Rehearsal; Security | Safe pre-tag interface | syntax, tests, publication-boundary review | release workflow | none | -| T010 | Requirement 5 | AC1, AC4, AC5 | Release Rehearsal; Error Handling | Non-publishing rehearsal | rehearsal, failure paths, external-state comparison | verification record | none | -| T011 | Requirements 4 and 5 | R4 AC3, AC4, AC5; R5 AC2, AC5 | Operational Considerations; Clean-Install Matrix | Existing process and install guidance | command, Markdown, and link review | version process, process index, install guide, README if needed | none | -| T012 | Requirement 5 | AC3, AC5, AC6 | Validation Strategy | Canonical release communications | claim-to-evidence review and release-body preview | changelog | none | -| T013 | Requirement 1, Requirement 2, Requirement 3, Requirement 4, Requirement 5 | all | Validation Strategy; Downstream Task Guidance | all promotion targets | lifecycle, evidence, security, and expert review | all listed targets | none | -| T014 | Requirement 6, Requirement 7, Requirement 8, Requirement 9 | all | Phase 5 Machine Acceptance Flow; Migration and Compatibility | Linux Mint pilot reconciliation | lifecycle, traceability, and package review | spec package | operator credential, sudo, and cutover remain downstream gates | -| T015 | Requirement 6 | Requirement 6 AC1, Requirement 6 AC2, Requirement 6 AC3, Requirement 6 AC4 | Repository and Credential Boundary; Error Handling | Backup/recovery runtime repair | focused tests and isolated init/dry-run/backup | backup and recovery guidance | credential choice remains operator-owned | -| T016 | Requirement 7 | Requirement 7 AC1, Requirement 7 AC2, Requirement 7 AC3, Requirement 7 AC4 | Snapshot and Restore Boundary; Error Handling | Recoverable snapshot workflow | focused tests and digest-verified TimeLocker restore | backup and recovery guidance | none | -| T017 | Requirement 8 | Requirement 8 AC1, Requirement 8 AC2, Requirement 8 AC3 | Components and Changes; Error Handling | Mint tray compatibility | namespace/fallback tests and Mint smoke | installation/troubleshooting guidance | desktop packaging variance | -| T018 | Requirement 9 | Requirement 9 AC1, Requirement 9 AC2, Requirement 9 AC3, Requirement 9 AC4 | Schedule Rendering Boundary; Migration and Compatibility | Executable staged schedules | parser round trip and staged asset review | scheduling/operator guidance | privileged install remains operator-owned | -| T019 | Requirement 6, Requirement 7, Requirement 8, Requirement 9 | all | Validation Strategy; Downstream Task Guidance | all Phase 5 promotion targets | machine acceptance, lifecycle, tests, and docs review | all Phase 5 targets | NPBackup cutover requires observed runs | - -## Requirement To Delivery Matrix - -| Requirement | Acceptance Criteria | Design Sections | Tasks | Verification | Durable Targets | Coverage State | Residual Destination | -|-------------|---------------------|-----------------|-------|--------------|-----------------|----------------|----------------------| -| Requirement 1 | AC1-AC6 | CI Profile Logic; Error Handling | T001-T003 | normal and MinIO profiles, coverage, complete collection partition | workflow, `docs/4-testing/README.md` | complete | none | -| Requirement 2 | AC1-AC4 | Components; Validation Strategy | T004-T005 | stress tests, issue #68 evidence, extended profile | tests and testing guide | complete | none | -| Requirement 3 | AC1-AC5 | Version and Artifact Guard | T006, T008 | side-effect proof, build, metadata, hashes, version guard | metadata, version process, changelog | complete | none | -| Requirement 4 | AC1-AC5 | Clean-Install Matrix | T007-T008, T011 | six-combination artifact install matrix and support-claim review | metadata, installation guide | complete | none | -| Requirement 5 | AC1-AC6 | Release Rehearsal; Operational Considerations | T009-T013 | interface tests, rehearsal, docs, communications, expert review | version process, process index, changelog, README if needed | complete | none | -| Requirement 6 | AC1-AC4 | Repository and Credential Boundary; Error Handling | T014-T015, T019 | focused tests and isolated init/dry-run/backup | backup and recovery guidance | complete | none | -| Requirement 7 | AC1-AC4 | Snapshot and Restore Boundary; Error Handling | T014, T016, T019 | list/latest/exact restore and digest proof | backup and recovery guidance | complete | none | -| Requirement 8 | AC1-AC3 | Components and Changes; Error Handling | T014, T017, T019 | Ayatana/legacy/headless tests and Mint smoke | installation/troubleshooting guidance | complete | none | -| Requirement 9 | AC1-AC4 | Schedule Rendering Boundary; Migration and Compatibility | T014, T018-T019 | parser round trip, redacted asset review, cutover gate review | scheduling/operator guidance | complete | none | - -## Correctness Property Coverage - -| Property | Requirements | Design Sections | Tasks | Tests Or Verification | Residual Risk | -|----------|--------------|-----------------|-------|-----------------------|---------------| -| CP-001 | Requirement 1 | CI Profile Logic | T001-T003 | collection partition and both CI profiles | marker drift | -| CP-002 | Requirement 3 | Version and Artifact Guard | T006, T008 | positive and negative version guard | none expected | -| CP-003 | Requirement 4 | Clean-Install Matrix | T007-T008 | wheel and sdist smoke across six combinations | runner availability blocks support claim | -| CP-004 | Requirements 3 and 5 | Version and Artifact Guard; Release Rehearsal | T006, T008-T010, T013 | pre/post commit, tag, and release-state identity | tag-only external behavior | -| CP-005 | Requirement 5 | Validation Strategy | T012-T013 | changelog claim evidence and derived release-body review | human review quality | -| CP-006 | Requirements 6 and 9 | Repository and Credential Boundary; Schedule Rendering Boundary | T014-T015, T018-T019 | credential precedence tests and redacted asset review | operator-managed environment file permissions | -| CP-007 | Requirement 7 | Snapshot and Restore Boundary | T014, T016, T019 | TimeLocker create/list/restore/digest round trip | filesystem metadata variance | -| CP-008 | Requirement 9 | Schedule Rendering Boundary | T014, T018-T019 | generated argv parser contract | CLI evolution requires contract maintenance | -| CP-009 | Requirement 8 | Components and Changes; Error Handling | T014, T017, T019 | namespace/fallback and headless CLI tests | desktop session variance | - -## Design To Implementation Matrix - -| Design Section | Requirements | Tasks | Interfaces Or Files | Verification | -|----------------|--------------|-------|---------------------|--------------| -| CI Profile Logic | Requirement 1 | T001-T003 | workflow, marker registry, fixtures, live and mocked integration tests | collection partition, normal CI, MinIO CI | -| Version and Artifact Guard | Requirement 3 | T006, T008 | helper, bump config, metadata, package version, build output | external-state identity, guard, build, metadata, hashes | -| Clean-Install Matrix | Requirement 4 | T007-T008, T011 | metadata, workflows, smoke tooling, installation guide | isolated artifact installs on six combinations | -| Release Rehearsal | Requirement 5 | T009-T010, T013 | release workflow, rehearsal evidence | non-publishing interface, rehearsal, external-state identity | -| Operational Considerations | Requirements 4 and 5 | T011-T013 | existing version process, process index, installation guide, changelog | docs, command, link, communications, and expert review | -| Security, Trust, and Access | Requirements 1, 3, and 5 | T002, T006, T009-T010, T013 | workflow permissions, ephemeral MinIO values, version helper | secrets, permissions, and side-effect review | -| Repository and Credential Boundary | Requirement 6 | T014-T015, T019 | repository/backup CLI and credential resolver | focused tests and isolated pilot | -| Snapshot and Restore Boundary | Requirement 7 | T014, T016, T019 | snapshot adapter, restore manager/CLI, progress/status handling | list/restore/digest round trip | -| Schedule Rendering Boundary | Requirement 9 | T014, T018-T019 | schedule model, CLI, cron/systemd renderers | parser round trip and staged asset review | -| Phase 5 Machine Acceptance Flow | Requirements 6-9 | T014-T019 | runtime paths, optional tray, schedule tooling, durable guidance | isolated Linux Mint pilot and final checkpoint | - -## Open Decision Impact - -There are no unresolved decisions blocking isolated implementation. Repository -credential selection, sudo installation for protected sources, discovery of the - actual NPBackup scheduler, observed scheduled runs, and cutover were explicit - operator gates outside T015-T018 implementation and were completed later by - closed Spec 008. - -## Maintenance Notes - -- Update this matrix whenever acceptance criteria, task IDs, support claims, - validation profiles, or durable destinations change. -- Requirements and design, including the changelog-derived communications - decision, were re-reviewed against this matrix after the TLR-001 through - TLR-006 remediation; all acceptance mappings are explicit. -- Spec 007 owns stress implementation and acceptance; issue #68 preserves the - chronological evidence and was closed on 2026-07-20. diff --git a/docs/specs/007-release-readiness-stabilization/verification.md b/docs/specs/007-release-readiness-stabilization/verification.md deleted file mode 100644 index f0809c7..0000000 --- a/docs/specs/007-release-readiness-stabilization/verification.md +++ /dev/null @@ -1,277 +0,0 @@ ---- -title: Release readiness stabilization verification -doc_type: spec -artifact_type: verification -status: active -owner: Auriora Team -last_reviewed: 2026-07-20 ---- - -# Verification - -## Scope - -This record covers Spec 007 requirements R1-R9 and tasks T001-T019. It records -release-preparation and machine-acceptance evidence; creating a production tag, -installing a privileged schedule, disabling NPBackup, or publishing a release -requires separate explicit approval. - -## Quality Gates - -| Gate | Required? | Status | Evidence | -|------|-----------|--------|----------| -| Acceptance traceability complete | yes | passed | Phase 5 requirement, criterion, property, design, task, and verification mappings were reconciled by T014. | -| Substantive requirements and design review | yes | passed | Review on 2026-07-18 produced TLR-001 through TLR-006; all six findings were reconciled into the package before implementation. | -| Task evidence complete | yes | passed | T001-T019 have implementation and validation evidence. | -| Normal and dependency-owning test profiles pass | yes | passed | GitHub Actions run `29676747955` passed normal, MinIO, coverage quality-gate, and notification jobs. | -| Stress implementation and disposition recorded | yes | passed | T004 implementation and repeat evidence are recorded in closed GitHub issue #68. | -| Artifacts and six-combination clean installs validate | yes | passed | Run `29679083454` passed one build and all 12 artifact/OS/Python jobs. | -| Release interface and rehearsal prove no publication side effect | yes | passed | T009-T010: reusable read-only validation, local rehearsal, three negative paths, and unchanged external state. | -| Durable documentation and communications promoted | yes | passed | Phase 4 targets and Phase 5 installation, recovery, and scheduling guidance are complete. | -| TimeLocker backup/list/restore machine round trip passes | yes | passed | Two snapshots list correctly; latest and exact-ID restores match the source digest. | -| Linux Mint tray path passes | yes | passed | Ayatana initialization/shutdown, legacy fallback tests, and headless CLI smoke passed. | -| Generated schedule parses and preserves migration boundaries | yes | passed | Disabled cron/systemd assets parse against the current CLI and contain references, not credential values. | -| Final lifecycle checks and expert review pass | yes | passed | T013 expert review passed; T019 task audit, closure readiness, and traceability have zero blockers. Optional canonical-context and historical evidence-quality advisories remain recorded. | - -## Validation Commands And Methods - -| Command Or Method | Purpose | Result | Evidence | -|-------------------|---------|--------|----------| -| `python -m pytest -m "not performance and not stress and not minio"` | Normal correctness and coverage profile | passed | Hosted run `29676747955`: 2,759 passed, one skipped, 57 deselected; 52.15% coverage. | -| complete collection compared with normal, `minio`, performance, and stress selections | Prove collection safety and node ownership | passed | 2,817 total nodes partition into 2,760 normal, 53 performance/stress, and four live MinIO nodes. | -| `python -m pytest -m minio` with provisioned service | Validate live S3 integration and dependency preflight | passed | Four local live nodes passed in 20.39 seconds; the provisioned job also passed in run `29676747955`. | -| `python -m pytest -m "performance or stress" --no-cov` | Extended performance and stress profile | passed | 53 passed, 2,770 deselected in 45.60 seconds; issue #68 records three repeated targeted medians and the no-coverage rationale. | -| `python scripts/bump_version.py bump patch --no-commit --no-tag` plus pre/post commit, tag, tag-triggered release-workflow run, and release identity | Prepare `0.9.1` without publication side effects | passed | Helper changed only `.bumpversion.cfg`, `pyproject.toml`, and `src/TimeLocker/__init__.py`; zero tags/releases and 11 historical release runs remained. | -| `python -m build`, version guard, metadata inspection, and SHA-256 generation | Build and prove artifact identity | passed | Final run `29679083454` validated one wheel, one sdist, metadata, entry points, nine data files, and hashes; wrong-version guard failed as intended. | -| wheel and sdist smoke installs on Linux, macOS, and Windows for Python 3.12 and 3.13 | Prove CP-003 and all declared support claims | passed | Run `29679083454`: all 12 wheel/sdist jobs passed both CLI entry points. | -| `actionlint`, nine focused release-contract tests, build/inspect, two clean-install smokes, and negative mismatch/missing/permission paths | Prove the reusable pre-tag interface and CP-004 rehearsal | passed | T009-T010; HEAD `1dcf910`, zero tags/releases, and 11 historical release runs were unchanged. | -| `python scripts/extract_release_notes.py --version 0.9.1` | Derive the eventual GitHub body from the canonical changelog section | passed | T012 preview contains the complete evidence-backed section and limitations. | -| Agent Workbench Markdown/link set check and `git diff --check` | Validate durable-doc hygiene | passed | Five durable documents had zero findings; whitespace check passed before final review. | -| isolated Linux Mint repository init, dry-run, backup, list, restore, and digest comparison | Prove TimeLocker-owned recoverability | passed | Environment-only init, file/directory dry-runs, actual backup, table/JSON listing, latest and exact-ID restore, and SHA-256 comparison passed. | -| Mint tray namespace and headless smoke | Prove optional tray compatibility | passed | System Python initialized Ayatana on Cinnamon/X11; fallback tests and the project-interpreter headless CLI smoke passed. | -| generated schedule argv parsed by current CLI | Prove schedule executability before asset installation | passed | Disabled cron/systemd assets use the current `backup create` contract, explicit config and environment references, and no credential values. | -| `python -m pytest -m "not performance and not stress and not minio"` | Revalidate the complete normal profile after Phase 5 | passed | 2,787 passed, one skipped, 57 deselected, 19 warnings, and 52.38% coverage in 801.81 seconds. | - -## Requirement Coverage - -| Requirement | Acceptance Criteria Covered | Evidence | Residual Risk | -|-------------|------------------------------|----------|---------------| -| Requirement 1 | AC1-AC6 | T001-T003 passed; GitHub Actions run `29676747955` | Marker and workflow contract tests guard future profile drift. | -| Requirement 2 | AC1-AC4 | T004-T005 passed; issue #68 records environment, baseline, tolerance, and repeat evidence | Post-change hosted evidence follows the explicitly requested commit. | -| Requirement 3 | AC1-AC5 | T006 and T008 passed; run `29679083454` | Preparation must continue to use both disabling flags. | -| Requirement 4 | AC1-AC5 | T007-T008 passed; installation guide and release procedure updated by T011 | Future support changes require the same matrix. | -| Requirement 5 | AC1-AC6 | T009-T013 passed | Human operator error at first actual tag remains explicitly owned. | -| Requirement 6 | AC1-AC4 | T015 focused tests and isolated Mint init/dry-run/backup passed | Operator credential and real-source selection remain deployment decisions. | -| Requirement 7 | AC1-AC4 | T016 focused tests plus TimeLocker-owned list/latest/exact/digest round trip passed | Filesystem metadata may vary across target filesystems. | -| Requirement 8 | AC1-AC3 | T017 namespace/fallback tests, headless CLI smoke, and Mint Ayatana initialization passed | Desktop packaging variance remains documented. | -| Requirement 9 | AC1-AC4 | T018 focused tests and disabled Mint cron/systemd staging passed | Privileged install, observed runs, and cutover remain operator gates. | - -## Correctness Property Coverage - -| Property | Covered By | Evidence | Residual Risk | -|----------|------------|----------|---------------| -| CP-001 | T001-T003, collection partition and workflow run `29676747955` | passed | Contract tests guard marker, selector, service, and artifact-transfer drift. | -| CP-002 | T006 version guard and negative test | passed | Automated guard covers source and artifact identity. | -| CP-003 | T007 six-combination artifact matrix | passed | Final shared-artifact run passed all 12 jobs. | -| CP-004 | T006, T008-T010, and T013 external-state comparisons | passed | Preparation and rehearsal did not create a tag, release, or publication. | -| CP-005 | T012-T013 changelog and derived release-body review | passed | Canonical changelog derivation and expert review passed. | -| CP-006 | T014-T015 and T018-T019 | passed | Runtime credentials converge on the environment boundary; generated assets contain the protected file reference, not values. | -| CP-007 | T014, T016, and T019 | passed | Two TimeLocker-created snapshots listed; latest and exact restores matched the source digest. | -| CP-008 | T014 and T018-T019 | passed | Generated backup argv parsed current CLI with explicit repository, source, and config directory. | -| CP-009 | T014, T017, and T019 | passed | Ayatana, legacy fallback, missing-dependency, headless CLI, and Mint initialization paths passed. | - -## Agent Readiness Evidence - -| Field | Evidence | Residual Risk | -|-------|----------|---------------| -| Scope and out-of-scope files | Requirements goals, non-goals, change impact, and task file lists | Actual host migration remains outside implementation authority. | -| Must-read and optional context | Full Spec 007 package, `CHARTER.md`, workflows, metadata, version helper/config, install and process docs, issue #68 | GitHub evidence can change. | -| Permissions and approval points | Branch work approved; task commits require explicit commit instruction; tag, GitHub release, and PyPI publication require separate release approval | Do not infer publication authority. | -| Validation commands and expected signals | Validation table plus task-specific commands | Hosted services and runners remain external. | -| Review needs | Recovery, security, operations, documentation, and release review completed across T013 and T019 | Human release and cutover decisions remain. | -| Durable-doc or closure impact | Promotion table and `change-impact.md` | Promotion is complete; closure still requires the lifecycle decision. | -| Optional repo-evidence provider caveats | Agent Workbench routing is advisory and has stale deleted-path candidates; direct repository and lifecycle evidence are authoritative | Recheck provider before relying on suggestions. | - -## Task Evidence - -| Task ID | Status | Evidence | Notes | -|---------|--------|----------|-------| -| T001 | passed | Exact node partition, focused contract tests, and normal-profile run passed | Four live nodes are `minio`; mocked/configuration tests remain normal. | -| T002 | passed | Pinned disposable MinIO, readiness preflight, negative dependency contract, and four live nodes passed | Hosted execution belongs to T003. | -| T003 | passed | Actions run `29676747955`: normal, MinIO, quality-gate, and notification jobs passed | Phase 1 checkpoint complete. | -| T004 | passed | Correctness/timing split, 1.0-second baseline, 2.0x tolerance, three repeat runs, and 53-test extended profile | Issue #68 contains the environment and chronological evidence. | -| T005 | passed | T003 hosted run plus T004 local normal/extended profiles and issue evidence | Phase 2 checkpoint complete. | -| T006 | passed | Safe helper invocation, identity guard, one shared build, metadata/data/hash inspection, and unchanged external release state | No tag or release created. | -| T007 | passed | Run `29679083454` passed wheel and sdist on all six OS/Python combinations | Windows encoding defect found in the first run and fixed by `4a2d998`. | -| T008 | passed | CP-002, CP-003, and Phase 3 CP-004 evidence reviewed | Phase 3 checkpoint complete. | -| T009 | passed | Reusable read-only workflow, isolated publish job, workflow syntax, and nine focused tests | Only the dependent publish job has write permission. | -| T010 | passed | Local build/inspect, wheel/sdist smokes, three negative paths, and unchanged commit/tag/release/run state | No external publication occurred. | -| T011 | passed | Existing process corrected and indexed; README and installation claims aligned; Markdown/link set clean | PyPI and 1.0 remain deferred. | -| T012 | passed | Canonical changelog section and successful derived release-body preview | Four limitations are explicit. | -| T013 | passed | Final normal profile, lifecycle/hygiene checks, external-state comparison, and bounded TimeLocker expert-panel review | Human release approval and lifecycle closure remain separate. | -| T014 | passed | Linux Mint pilot blockers reconciled into requirements, design, tasks, traceability, and verification; lifecycle lint and stage readiness have zero gaps | No external schedule state or secrets changed. | -| T015 | passed | 124 focused tests; Mint environment-only init; 1-file and 11-file dry-runs; actual snapshot `731d9784` with one file and 15,839 bytes | Pilot snapshot count changed from one to two only for the actual backup. | -| T016 | passed | 120 focused tests, 19 snapshot-manager tests, 14 orchestrator tests; Mint table/JSON listing; latest and exact restores; matching SHA-256 digests | Final combined checkpoint remains T019. | -| T017 | passed | Six focused tests; project-interpreter headless smoke; `tl version`; system-Python Ayatana initialization/shutdown | Desktop package availability remains operator-owned. | -| T018 | passed | 20 focused tests; disabled Mint schedule; cron shell parse; systemd/CLI parser review; redacted assets | Nothing installed or enabled; NPBackup unchanged. | -| T019 | passed | Machine round trip, Mint tray, staged schedules, promoted docs, full normal profile, and lifecycle/hygiene checks passed | No timer installed or enabled; NPBackup unchanged; operator migration gates remain. | - -## Evidence Log - -| Date | Evidence | Result | Notes | -|------|----------|--------|-------| -| 2026-07-18 | GitHub Actions run 29653160911 | failed | Unprovisioned MinIO caused one failure and four setup errors; normal CI is not release-ready. | -| 2026-07-18 | Focused local mocked MinIO contract test | passed | Controlled environment passed, supporting separation of mocked contracts from live-service tests. | -| 2026-07-18 | Open-issue reconciliation | passed | All 27 inherited open issues reviewed; 9 closed, 18 retained with current scope. | -| 2026-07-18 | GitHub milestone `v0.9.1` | created | PyPI and `1.0.0` explicitly deferred. | -| 2026-07-18 | GitHub issue #68 | created and assigned | Tracks selection stress assignment, state, and chronological evidence; Spec 007 owns delivery authority. | -| 2026-07-18 | Substantive Spec 007 review | findings addressed | TLR-001 through TLR-006 reconciled safe versioning, stress authority, support matrix, MinIO ownership, release-task decomposition, and review evidence. | -| 2026-07-18 | Downstream task and verification review | passed | Tasks and verification were rechecked after the final requirements and design reconciliation, including the changelog-derived communications model. | -| 2026-07-18 | Spec Lifecycle Manager package checks | passed | Package lint has zero diagnostics; stage readiness is implementation-ready with zero gaps; sampled T001, T004, T006, T007, T009, and T013 lookups and T001 readiness resolve without gaps. | -| 2026-07-18 | Documentation and patch checks | passed with advisory warnings | No structural Markdown findings, broken links, or whitespace errors; 135 table-readability warnings and 25 pre-existing canonical-link style suggestions remain non-blocking. | -| 2026-07-18 | T001 focused MinIO profile tests | passed | Nine normal-profile contract/configuration tests passed and four live nodes were deselected without using repository MinIO configuration. | -| 2026-07-18 | T001 collection partition | passed | All 2,812 nodes accounted for: 2,755 normal, 53 performance/stress, and four live MinIO. | -| 2026-07-18 | T001 exact normal profile | passed | 2,754 passed, one skipped, 57 deselected, 19 warnings, and 52.13% coverage in 783.96 seconds. | -| 2026-07-18 | T002 workflow and environment contracts | passed | Action syntax, pinned-service provisioning, actionable preflight failure, URI-scheme preservation, and process-environment precedence passed focused tests. | -| 2026-07-18 | T002 provisioned MinIO profile | passed | Disposable loopback MinIO served all four live nodes; 2,812 nodes were deselected and cleanup succeeded. | -| 2026-07-18 | Phase 1 exact normal profile | passed | 2,758 passed, one skipped, 57 deselected, 19 warnings, and 52.13% coverage in 571.62 seconds. | -| 2026-07-19 | Hosted Phase 1 checkpoint, run `29676747955` | passed | Commit `8a7e1c1`; 2,759 normal tests passed, one skipped, 57 deselected, 52.15% coverage, four live MinIO tests passed, and the quality gate and notification completed successfully. | -| 2026-07-19 | Legacy selection stress baseline | passed but unstable contract | The fixed 60-second gate completed 209 iterations on Linux/Python 3.12.6; historical observations of 57 and 70 demonstrated host sensitivity. | -| 2026-07-19 | Repeated calibrated selection stress contract | passed | Three `--no-cov` runs reported 0.160, 0.176, and 0.173 second medians against a 1.0-second baseline and 2.0x tolerance. | -| 2026-07-19 | Phase 2 extended profile | passed | 53 passed and 2,770 deselected in 45.60 seconds without coverage instrumentation. | -| 2026-07-19 | Phase 2 normal profile | passed | 2,765 passed, one skipped, 57 deselected, and 52.14% coverage in 726.60 seconds. | -| 2026-07-19 | Non-publishing version preparation | passed | From `9348c584`, the helper changed only the three configured version files; tag, release-workflow, and GitHub-release identity did not change. | -| 2026-07-19 | Local artifact inspection and negative guard | passed | Version, Python range, both entry points, nine package-data files, and hashes passed; expected version `0.9.0` failed before artifact checks. | -| 2026-07-19 | Hosted artifact run `29678906850` | failed as designed gate | Linux/macOS passed; all Windows jobs exposed `cp1252`-unsafe help glyphs. | -| 2026-07-19 | Windows help portability fix `4a2d998` | passed | ASCII metavar/epilog plus a `cp1252` regression contract removed the installation blocker. | -| 2026-07-19 | Hosted artifact run `29679083454` | passed | One shared build and all 12 wheel/sdist matrix jobs passed across Linux, macOS, Windows, Python 3.12, and Python 3.13. | -| 2026-07-19 | T009 release interface contracts | passed | `actionlint`, the workflow-boundary validator, and nine focused tests proved read-only rehearsal, isolated publication, and negative failure propagation. | -| 2026-07-19 | T010 local non-publishing rehearsal | passed | Intent, build, metadata/data/hashes, wheel and sdist clean-install smokes, and release inputs passed; version mismatch, missing artifact, and unsafe permission failed. | -| 2026-07-19 | T010 external-state comparison | unchanged | HEAD remained `1dcf910`; zero tags, zero GitHub releases, and 11 historical release runs remained. | -| 2026-07-19 | T011 durable documentation check | passed | Agent Workbench found zero Markdown or link issues across README, installation, process index, version process, and changelog. | -| 2026-07-19 | T012 release-body derivation | passed | The preview was extracted from the exact `0.9.1` changelog section; no second durable release-note file was created. | -| 2026-07-19 | Initial T013 normal-profile run | corrective finding | 2,773 passed, one skipped, 57 deselected, and 52.14% coverage; one test sampled live 100% CPU while asserting unconstrained parallelism. | -| 2026-07-19 | Resource-dependent test isolation | passed | The high-priority tool-manager test now supplies explicit low-load resources; all 22 tool-manager tests passed. | -| 2026-07-19 | Final T013 normal-profile run | passed | 2,774 passed, one skipped, 57 deselected, 19 warnings, and 52.14% coverage in 1,439.49 seconds. | -| 2026-07-19 | T013 TimeLocker expert-panel review | passed | Bounded Phase 4 diff review applied stewardship, Python CLI, security, reliability, operations, and documentation/lifecycle lenses; Restic behavior was unchanged. No actionable findings remained after test isolation. | -| 2026-07-19 | T013 lifecycle and hygiene checks | passed with advisory | Lifecycle lint had no errors and only the reviewed optional canonical-context advisory; traceability had zero acceptance gaps; `actionlint`, Markdown/link checks, workflow boundary validation, and `git diff --check` passed. | -| 2026-07-19 | Isolated Linux Mint/Cinnamon machine pilot | failed | Explicit-password init and a directory backup created snapshot `876b20bc7916`; environment-only init, dry-run, truthful result reporting, TimeLocker listing, TimeLocker restore, Ayatana tray discovery, and generated schedule parsing failed. | -| 2026-07-19 | Raw Restic diagnostic control | passed | Restic listed one snapshot with 11 files and restored the reference file with a matching digest; this does not satisfy TimeLocker-owned recovery acceptance. | -| 2026-07-19 | NPBackup migration boundary review | unchanged | Existing protected configuration was inspected only through its masked interface; no credential was extracted, scheduler changed, timer installed, or job disabled. | -| 2026-07-19 | T015 focused validation | passed | 124 repository, CLI, resolver, orchestrator, backup, and regression tests passed without coverage instrumentation. | -| 2026-07-19 | T015 isolated Mint pilot | passed | Environment-only init recognized the repository; file and directory dry-runs reported 1 and 11 files; actual file backup created snapshot `731d9784` with one file and 15,839 bytes. Exactly two snapshots exist after the one real T015 backup. | -| 2026-07-19 | T016 focused recovery validation | passed | 120 snapshot, recovery, restore CLI, and progress tests passed; the latest-alias and initialized-progress regressions then passed 19 and 14 focused tests. | -| 2026-07-19 | T016 isolated Mint recovery pilot | passed | Table and JSON listed two snapshots with canonical metadata. TimeLocker restored `latest` and exact full ID `731d9784...`; both restored `README.md` files matched the source SHA-256 digest. | -| 2026-07-19 | T017 Mint tray validation | passed | Six namespace/fallback tests passed; the pyenv CLI remained functional without `gi`; system Python initialized and shut down `AyatanaAppIndicator3` on Cinnamon/X11. | -| 2026-07-19 | T018 schedule validation | passed | Twenty focused tests passed. A disabled system-level pilot schedule generated redacted cron/systemd assets whose command parsed the current CLI with explicit repository, `/etc` source, environment-file reference, and config directory. No scheduler state changed. | -| 2026-07-19 | T019 final normal profile | passed | 2,787 passed, one skipped, 57 deselected, 19 warnings, and 52.38% coverage in 801.81 seconds. | -| 2026-07-19 | T019 machine and handoff checkpoint | passed with advisories | TimeLocker-owned backup/list/latest/exact/digest, Mint Ayatana/headless, staged-schedule, promoted-guide, task-audit, closure-readiness, traceability, link, compile, and whitespace gates passed. Lifecycle lint retained one optional canonical-context advisory; historical evidence-quality and spec-table-readability advisories remain non-blocking. No privileged schedule or NPBackup state changed. | - -## Manual Or External Verification - -GitHub issue and milestone state is externally authoritative for assignment and -chronology. The active spec remains authoritative for approved scope, -sequencing, acceptance, and validation. GitHub Actions runs and eventual -release artifacts must be linked here before release readiness can be approved. - -## Residual Risks - -- GitHub Actions currently emits a non-blocking Node.js 20 deprecation warning - for upstream action versions that the runner forces onto Node.js 24. -- Future marker drift could change profile ownership; the T001 contract test - guards the intended four live nodes and mocked-test placement. -- Stress thresholds remain host-sensitive by nature; T004's calibrated contract - and issue #68 own the accepted tolerance evidence. -- Version tooling commits and tags by default; every preparation run must use - both disabling flags and prove external state is unchanged. -- The verified matrix depends on hosted runner availability; future unavailable - combinations block readiness until rerun or the support claim is reviewed. -- The first actual tag exercises external publication behavior that rehearsal - cannot reproduce fully; it remains a human-controlled release risk. -- Tray availability still depends on the desktop toolkit being installed for - the interpreter that runs the tray integration; core CLI behavior is - deliberately independent. -- Repository credentials, actual NPBackup source/scheduler discovery, - privileged schedule installation, observed scheduled TimeLocker runs, and - final NPBackup cutover remain unapproved operator actions. -- The staged `/etc` source proves the protected-source boundary but is not a - claim about the sources configured in the existing NPBackup job. - -## Durable Promotion And Cleanup - -| Spec Content | Durable Destination Or Deferral | Status | Evidence | -|--------------|---------------------------------|--------|----------| -| Test profile contract | `docs/4-testing/README.md` | complete | T002-T004 promoted profile ownership, prerequisites, commands, and stress disposition. | -| Verified installation matrix | `docs/guides/user/installation.md` | complete | T007 matrix and prerequisites reconciled by T011. | -| Version preparation, release procedure, and rollback | `docs/processes/version-management.md`, linked from `docs/processes/README.md` | complete | T011 corrected the existing procedure; no duplicate was created. | -| Version contents and release communications | `CHANGELOG.md`; GitHub release body derived from its `v0.9.1` section | complete | T012 preview passed. | -| Front-door support and version claims | `README.md` | complete | T011 aligned version and Python support. | -| PyPI and `1.0.0` deferral | GitHub issue #22, milestone description, version process | complete | External and durable boundaries agree. | -| Follow-up work | GitHub issues outside milestone or an approved successor spec | complete | Closed issue #68 retains stress history; Spec 009 owns the newly approved system-operations UX requirements. | -| Backup/recovery runtime contract | `docs/guides/user/recovery-operations-guide.md` | complete | T015-T016 machine acceptance and T019 review passed. | -| Linux tray prerequisites | `docs/guides/user/installation.md` | complete | T017 Mint and headless validation passed. | -| Schedule and staged NPBackup cutover boundary | `docs/guides/developer/scheduling-guide.md` | complete | T018 staging and T019 handoff review passed. | - -### Spec Cleanup Decision - -- **Cleanup action:** remove after the final spec commit -- **Reason:** All 53 task records are complete, durable behavior is promoted, - issue #68 is closed, and the operator has now approved lifecycle closure. -- **Final spec commit:** pending -- **Closure log path:** `docs/history/spec-closure-log.md` -- **Closure log entry updated:** no -- **Closure cleanup commit:** pending -- **Active indexes updated:** pending closure cleanup -- **Durable docs linked back to evidence where useful:** yes -- **Residual spec-only content:** none; release publication remains a separate - human action governed by the durable version-management process. - -## Ship Or Closure Risk - -- **Risk level:** low for closure; release publication remains separately gated -- **Breaking change:** no -- **Blast radius checked:** complete for the approved Phase 5 implementation boundary -- **Rollback path:** corrected and validated in `docs/processes/version-management.md` -- **Requires human review:** satisfied by the 2026-07-20 closure request -- **Release notes needed:** yes, in `CHANGELOG.md` -- **Follow-up issue or spec needed:** issue #68 already tracks stress evidence - -### Risk Rationale - -Normal, provisioned MinIO, extended, artifact, cross-platform install, -rehearsal, documentation, expert-review, and Linux Mint machine-acceptance -gates pass. Closed Spec 008 subsequently reconciled and cut over NPBackup. -TimeLocker remains unpublished; closing this implementation package does not -grant tag, GitHub release, PyPI, or other publication authority. - -### Accepted Evidence-Quality Residual - -The lifecycle evidence classifier reports 99 records: 73 concrete and 26 -advisory weak, vague, or `not_run` classifications. These advisories are -accepted for closure because they describe intermediate negative controls, -no-mutation observations, or subordinate task summaries whose terminal parent -tasks and quality gates contain concrete commits, workflow-run IDs, snapshot -IDs, hashes, test counts, or coverage. The five `not_run` classifications do -not represent missing final validation; for example, the final normal profile -record itself reports 2,787 passed, one skipped, 57 deselected, and 52.38% -coverage. Chronological failed and unchanged-state rows are retained rather -than rewritten as successes. - -The optional canonical-context advisory is also accepted: requirements and -promotion already cite the durable charter, front door, installation guide, -release process, changelog, and history authorities directly, and no ambiguity -remains that would justify adding another copied context artifact at closure. - -## Readiness Decision - -- **Ready for promotion:** yes; all named durable targets are current -- **Ready for release:** no -- **Ready for closure:** yes; release remains a separate human decision - -## Related Artifacts - -- Requirements: `requirements.md` -- Change Impact: `change-impact.md` -- Design: `design.md` -- Tasks: `tasks.md` -- Traceability: `traceability.md` diff --git a/docs/specs/README.md b/docs/specs/README.md index 0349754..48480ad 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -15,10 +15,6 @@ accepted content has been promoted and the package is closed. ## Current Packages -- [`007-release-readiness-stabilization`](./007-release-readiness-stabilization/requirements.md) - — active package for restoring trustworthy CI, stabilizing release signals, - validating `v0.9.1` artifacts, rehearsing release operations, and promoting - durable release guidance. - [`009-system-cli-tray-retention`](./009-system-cli-tray-retention/requirements.md) — requirements-stage package for a stable system command, contextual elevation, an independent tray/backend boundary, durable run visibility, and @@ -26,12 +22,10 @@ accepted content has been promoted and the package is closed. ## Active-Package Sequencing -Spec 007 owns release readiness. All of its implementation tasks are complete; -its remaining work is evidence-quality reconciliation and lifecycle closure. -Spec 009 may author requirements and design concurrently because it does not -alter Spec 007 evidence or authorize a release. Spec 009 implementation must -preserve Spec 007's release gates and receive separate approval after its -design and tasks are complete. Closed package identity and recovery commits +Spec 007 is closed; its release-readiness evidence and recovery commits are +recorded in `docs/history/`. Spec 009 is the only active package and remains at +the requirements stage. Its requirements require approval before design and +implementation, and its work does not authorize a release. Closed packages remain recorded in `docs/history/` rather than kept in this active path. ## When a Spec Is Needed From 6bee87ca8ae037bd7eeb47ff0e988473e88771e2 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:39:57 +0100 Subject: [PATCH 31/72] docs(spec): resolve release readiness closure record --- docs/history/spec-archive-index.md | 2 +- docs/history/spec-closure-log.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/history/spec-archive-index.md b/docs/history/spec-archive-index.md index 8b1e973..126d43f 100644 --- a/docs/history/spec-archive-index.md +++ b/docs/history/spec-archive-index.md @@ -16,7 +16,7 @@ contracts. | Spec ID | Title | Package path | Status | Final spec commit | Cleanup commit | Closure action | Durable destinations | Verification | |---------|-------|--------------|--------|-------------------|----------------|----------------|----------------------|--------------| -| 007-release-readiness-stabilization | Release readiness stabilization requirements | removed; recover from Git | removed | `7fd11f9aa1cbc670d5e8b429aede4a7c01e185a4` | `pending-cleanup-commit` | removed | `README.md`; `CHANGELOG.md`; `.github/workflows/test-suite.yml`; `.github/workflows/artifact-smoke.yml`; `.github/workflows/release-validation.yml`; `.github/workflows/release.yml`; `docs/4-testing/README.md`; `docs/guides/user/installation.md`; `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md`; `docs/processes/version-management.md`; `docs/processes/README.md` | `docs/history/spec-closure-log.md` | +| 007-release-readiness-stabilization | Release readiness stabilization requirements | removed; recover from Git | removed | `7fd11f9aa1cbc670d5e8b429aede4a7c01e185a4` | `6334af0690b5b9e8b6575042269e5b73914a9295` | removed | `README.md`; `CHANGELOG.md`; `.github/workflows/test-suite.yml`; `.github/workflows/artifact-smoke.yml`; `.github/workflows/release-validation.yml`; `.github/workflows/release.yml`; `docs/4-testing/README.md`; `docs/guides/user/installation.md`; `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md`; `docs/processes/version-management.md`; `docs/processes/README.md` | `docs/history/spec-closure-log.md` | | 008-npbackup-migration-parity | NPBackup migration parity requirements | removed; recover from Git | removed | `5830194` | `1bfea08` | removed | `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md` | `docs/history/spec-closure-log.md` | | 001-cli-consolidation-stabilization | CLI Consolidation Stabilization | removed; recover from Git | removed | `a1bb654` | `b8df9e9` | removed | `docs/3-implementation/service-layer-integration.md`; `docs/reference/repo-orientation-and-change-map.md`; `docs/specs/README.md`; `docs/history/` | `docs/history/spec-closure-log.md` | | 002-repository-safety-release-readiness | Repository Safety and Release Readiness | removed; recover from Git | removed | `4aff166` | `c6ed9ee` | removed | `README.md`; `docs/2-architecture/`; `docs/guides/user/installation.md`; `docs/guides/user/per-repo-credentials.md`; `docs/processes/version-management.md`; `docs/DOCUMENTATION-STATUS.md`; `docs/specs/README.md` | `docs/history/spec-closure-log.md` | diff --git a/docs/history/spec-closure-log.md b/docs/history/spec-closure-log.md index dc55bff..7063f70 100644 --- a/docs/history/spec-closure-log.md +++ b/docs/history/spec-closure-log.md @@ -20,7 +20,7 @@ final spec commit preserves the complete package. - **Spec:** removed; recover from Git - **Title:** Release readiness stabilization requirements - **Final spec commit:** `7fd11f9aa1cbc670d5e8b429aede4a7c01e185a4` -- **Closure cleanup commit:** `pending-cleanup-commit` +- **Closure cleanup commit:** `6334af0690b5b9e8b6575042269e5b73914a9295` - **Closure action:** removed - **Durable docs updated:** - `README.md` From ab80b89a06f9899248f6156e5f62c7e93bb8754f Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:14:46 +0100 Subject: [PATCH 32/72] docs: reconcile spec 009 requirements --- .../requirements.md | 131 ++++++++++++++---- 1 file changed, 103 insertions(+), 28 deletions(-) diff --git a/docs/specs/009-system-cli-tray-retention/requirements.md b/docs/specs/009-system-cli-tray-retention/requirements.md index d74b27e..0526df5 100644 --- a/docs/specs/009-system-cli-tray-retention/requirements.md +++ b/docs/specs/009-system-cli-tray-retention/requirements.md @@ -35,8 +35,13 @@ separately scheduled retention with visible outcomes. session. - Let the tray observe current work, last backup, last retention run, and next scheduled runs, and safely request an on-demand backup. +- Restrict system-backup status and control to members of a root-controlled + operator group whose identity is verified by the operating system. - Automate the accepted production retention policy independently of backup: - keep 5 daily, 4 weekly, 12 monthly, and 3 yearly snapshots without prune. + keep 5 daily, 4 weekly, 12 monthly, and 3 yearly snapshots, grouped by host + and paths, without prune. +- Keep shared tray, control/status, and run-state contracts platform-neutral, + with replaceable Linux and Windows adapters. - Preserve safe rollback, headless operation, secret isolation, and failure independence between backup, retention, CLI, and tray processes. @@ -56,6 +61,10 @@ separately scheduled retention with visible outcomes. - Implementing user-scoped management of the user's accessible subset of the system backup. That capability belongs in the product backlog and must later receive its own access-control and restore-boundary specification. +- Completing every Linux desktop and Windows adapter in the initial delivery. + The initial implementation may validate one Linux environment first, but it + must not embed Linux, GTK, systemd, Unix-socket, or filesystem-layout + assumptions in shared contracts and domain services. ## Glossary @@ -65,7 +74,9 @@ separately scheduled retention with visible outcomes. | System backend | The privileged, headless execution boundary that owns machine-level configuration and scheduled operations. It does not imply a network service. | | Tray client | An unprivileged process running in a user's graphical session and communicating through the approved local control/status boundary. | | Elevation broker | The narrow operating-system authorization path used to request a privileged operation without making the whole desktop or CLI session privileged. | +| System operator group | A root-controlled operating-system group whose members may inspect system-backup status and request allowlisted system-backup actions. | | Run record | Durable, secret-free status for one backup or retention attempt, including type, target, timestamps, state, result, and safe error summary. | +| Access domain | The files, metadata, snapshots, and restore destinations a user is authorized to inspect or modify; future user partitions may never expand this boundary. | ## Durable Source Baseline @@ -104,10 +115,10 @@ separately scheduled retention with visible outcomes. `open-decisions.md` - **Downstream review needed:** design, security, operations, desktop integration, testing, traceability, and verification -- **Concurrent package sequencing:** Spec 007 has no incomplete task but still - needs evidence-quality reconciliation and closure. Spec 009 may author - requirements and design concurrently; implementation must not reuse Spec - 007 release evidence as proof and must preserve its release-readiness gates. +- **Package sequencing:** Spec 007 is closed and its durable release-readiness + gates remain applicable independently. Spec 009 is the only active package; + it must produce new implementation and validation evidence rather than reuse + Spec 007 evidence as proof. ## Requirements @@ -179,8 +190,17 @@ pollutes headless operations. or unavailable state without presenting stale success as current. 5. Starting more than one tray instance for the same user SHALL be prevented or resolved deterministically. -6. The tray lifecycle SHALL support Linux Mint's GNOME-based session first and - retain explicit portability boundaries for other supported platforms. +6. The tray lifecycle SHALL support the declared Linux reference environment + first and retain explicit capability boundaries for other Linux desktop + environments and Windows. +7. Shared tray lifecycle, status, action, and run-state logic SHALL be + independent of Linux, GTK, systemd, Unix sockets, Windows services, and + Windows notification-area APIs; platform behavior SHALL be supplied through + replaceable adapters. +8. Initial live acceptance SHALL target Linux Mint Cinnamon/X11. The design + SHALL define capability-based adapter contracts for common Linux desktop + environments and supported Windows versions, with unsupported capabilities + reported explicitly instead of inferred from operating-system name alone. ### Requirement 4: Local control and status contract @@ -210,34 +230,54 @@ machine backup without handling protected credentials. 7. The contract SHALL reserve a future UI-launch action without claiming that a UI exists; until implemented, the tray action SHALL be hidden or clearly unavailable. +8. Only members of the configured system operator group SHALL be allowed to + inspect system-backup status or request an on-demand system backup. Group + configuration and membership SHALL be controlled outside the unprivileged + client and SHALL require system authority to change. +9. The local contract SHALL bind authorization to operating-system peer + identity and current group membership. It SHALL reject self-asserted + identities, unauthorized local users, stale authorization, arbitrary + executable paths, and arguments outside the allowlisted action schema + without disclosing protected status or selection metadata. ### Requirement 5: Automatic retention as an independent operation **User Story:** As an operator, I want TimeLocker to apply my retention policy -automatically after backups, so that snapshot cleanup is consistent without -coupling deletion to backup success. +on an independent schedule, so that snapshot cleanup is consistent and does +not depend on the outcome or freshness of a backup run. **Priority:** must-have #### Acceptance Criteria 1. THE production policy SHALL explicitly keep 5 daily, 4 weekly, 12 monthly, - and 3 yearly snapshots and SHALL leave prune disabled. + and 3 yearly snapshots, SHALL explicitly group by `host,paths`, and SHALL + leave prune disabled. 2. BEFORE first enablement or any policy change, THE SYSTEM SHALL support a dry - run using the same repository, credentials, grouping semantics, and policy - values as the eventual mutation. + run using the same repository identity, credential source, snapshot filters, + explicit grouping, policy values, and prune setting as the eventual + mutation, and SHALL record those inputs as one reviewable policy fingerprint. 3. Retention SHALL use a separately identifiable service and schedule from the backup service and schedule. 4. Retention SHALL NOT run while a backup or another repository mutation is active, and a skipped conflict SHALL be visible as a run result rather than silently lost. -5. A retention failure SHALL NOT rewrite the preceding backup result, and a - backup failure SHALL NOT implicitly authorize retention. +5. A retention result SHALL NOT rewrite a backup result, and backup success or + failure SHALL NOT change the eligibility of an independently approved + retention run. 6. Each retention attempt SHALL produce a durable run record visible through the CLI and tray, including whether it was a dry run and how many snapshots - were selected or removed. + were selected or removed, together with the applied policy fingerprint. 7. Disabling automatic retention SHALL be reversible without disabling backups, and rollback guidance SHALL preserve the manual forget command. +8. First enablement and every change to the repository, credential source, + snapshot filters, grouping, retention values, or prune setting SHALL require + explicit operator approval of a successful dry run with the identical policy + fingerprint. A dry run alone SHALL NOT enable mutation. +9. Retention eligibility SHALL be independent of backup success, failure, + absence, age, or freshness. Retention MAY run at any scheduled or explicitly + requested time when its policy is approved and no conflicting repository + mutation is active. ### Requirement 6: Installation, upgrade, and recovery safety @@ -259,6 +299,14 @@ silently select the wrong code or privilege boundary. assets without deleting run records or changing retention policy. 4. Headless installations SHALL remain supported without GUI dependencies or tray warnings. +5. Shared protocol and domain components SHALL support Linux and Windows + adapters without changing their public schema or authorization semantics. + Platform support claims SHALL identify the validated adapter capabilities + and environments rather than assuming all environments behave alike. +6. On startup after a process crash or system restart, THE SYSTEM SHALL + reconcile every non-terminal run and lock against its owning process or + lease, mark abandoned attempts with a durable `interrupted` result, and make + stale locks safely recoverable without creating duplicate terminal records. ## Correctness Properties @@ -272,17 +320,29 @@ silently select the wrong code or privilege boundary. - **CP-004:** Every completed or failed backup and retention attempt yields one durable terminal run record without secret material. - **CP-005:** The enabled production retention invocation always carries the - explicit tuple `(5, 4, 12, 3, prune=false)`; no CLI default may change it. + explicit tuple `(group-by=host,paths, 5, 4, 12, 3, prune=false)` and a matching + approved dry-run fingerprint; no CLI or Restic default may change it. - **CP-006:** A denied elevation or incompatible client/backend contract causes no privileged mutation. +- **CP-007:** A caller can inspect system-backup status or request a system + backup if and only if its operating-system peer identity is currently a + member of the configured system operator group. +- **CP-008:** Every non-terminal run left by a dead process or expired lease is + reconciled exactly once to an interrupted terminal record before its lock can + be reused. +- **CP-009:** Replacing a Linux or Windows platform adapter cannot change the + shared status, action, authorization, locking, or run-record contracts. ## Technical Context - **Language/Version:** Python 3.12-3.13 -- **Primary Dependencies:** Typer, systemd on Linux, Restic, existing - monitoring and scheduling services, optional PyGObject tray support -- **Target Platform:** Linux Mint GNOME first; preserve documented macOS and - Windows compatibility boundaries +- **Primary Dependencies:** Typer, Restic, existing monitoring and scheduling + services, platform adapters such as systemd/desktop integration on Linux and + native service/session integration on Windows, and optional tray dependencies +- **Target Platform:** Linux Mint Cinnamon/X11 for initial live acceptance; + architecture for common Linux desktop environments and supported Windows + versions through capability-based adapters; preserve an explicit macOS + compatibility boundary - **Constraints:** local-first, least privilege, root-owned production configuration, no secret-bearing IPC, immutable release selection, no unsafe backup/retention overlap @@ -305,7 +365,17 @@ silently select the wrong code or privilege boundary. - **SC-005:** An approved on-demand tray backup follows the same lock, credentials, configuration, and run-record paths as a scheduled backup. - **SC-006:** A dry run and one controlled automatic retention run prove the - explicit 5/4/12/3, no-prune policy and appear in both CLI and tray status. + explicit `host,paths`, 5/4/12/3, no-prune policy and matching approval + fingerprint, and appear in both CLI and tray status. +- **SC-007:** An authorized system-operator-group member can inspect status and + request one allowlisted backup, while an otherwise valid local user receives + no protected status, selection metadata, or control capability. +- **SC-008:** Killing a backup or retention process and restarting the backend + produces one interrupted terminal record, releases or recovers its stale lock, + and lets the tray reconnect without showing the attempt as still running. +- **SC-009:** Shared contract tests pass unchanged against the Linux adapter and + a Windows adapter test double, while Linux Mint Cinnamon/X11 live acceptance + proves the first supported desktop environment. ## Open Questions For Design @@ -315,19 +385,24 @@ silently select the wrong code or privilege boundary. cleanest systemd integration without creating a general application server? - Should the tray read durable run state directly through a read-only library or exclusively through the backend contract? -- What exact timer offset and missed-run policy should automatic retention use - relative to the 03:30 backup? The initial recommendation is daily at 04:30. +- What exact cadence and missed-run policy should automatic retention use? The + initial recommendation remains daily at 04:30, but eligibility is explicitly + independent of backup outcome, absence, age, or freshness. - Which existing history implementation should become authoritative, and what migration is required for old or in-memory records? ## Routed Future Work - [GitHub issue #70](https://github.com/Auriora/TimeLocker/issues/70) tracks - user-scoped backup and restore management: a signed-in user may manage only - files they can access within the overall system backup. That future work must - define selection ownership, snapshot visibility, restore destinations, - symlink and ACL behavior, privilege boundaries, and defenses against using - the system service to read or write inaccessible paths. + partitioned user views and user-scoped selection/restore management. A + signed-in user may define selection sets only within their access domain, + inspect only the corresponding partition of snapshot content and metadata, + and restore only to authorized destinations without learning about or + controlling the system selection set. That future work must define selection + ownership, partition identity, snapshot filtering, restore destinations, + symlink, hard-link, ACL, ownership, and special-file behavior, privilege + boundaries, and defenses against using the system service to read or write + inaccessible paths. ## Related Artifacts From a601d0367eef24d17d24c5d11b14f4ee024b10b9 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:42:26 +0100 Subject: [PATCH 33/72] docs: clarify retention trigger semantics --- .../requirements.md | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/docs/specs/009-system-cli-tray-retention/requirements.md b/docs/specs/009-system-cli-tray-retention/requirements.md index 0526df5..95b6db5 100644 --- a/docs/specs/009-system-cli-tray-retention/requirements.md +++ b/docs/specs/009-system-cli-tray-retention/requirements.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: requirements status: active owner: Auriora Team -last_reviewed: 2026-07-20 +last_reviewed: 2026-07-24 --- # Requirements @@ -21,7 +21,8 @@ operation history is not yet a single durable cross-process contract. This package defines a coherent system-operations experience: a stable system-path command that requests elevation only when required, an independent per-user tray process, a local authenticated control/status boundary, and -separately scheduled retention with visible outcomes. +independently runnable retention with backup-success, scheduled, and explicit +triggers plus visible outcomes. ## Goals @@ -37,9 +38,11 @@ separately scheduled retention with visible outcomes. scheduled runs, and safely request an on-demand backup. - Restrict system-backup status and control to members of a root-controlled operator group whose identity is verified by the operating system. -- Automate the accepted production retention policy independently of backup: - keep 5 daily, 4 weekly, 12 monthly, and 3 yearly snapshots, grouped by host - and paths, without prune. +- Automate the accepted production retention policy as an independently + runnable operation that can be triggered immediately after a successful + scheduled backup, by its own schedule, or by an explicit request: keep 5 + daily, 4 weekly, 12 monthly, and 3 yearly snapshots, grouped by host and + paths, without prune. - Keep shared tray, control/status, and run-state contracts platform-neutral, with replaceable Linux and Windows adapters. - Preserve safe rollback, headless operation, secret isolation, and failure @@ -97,7 +100,7 @@ separately scheduled retention with visible outcomes. |--------------|--------|--------|-------| | requirements | add | `docs/1-requirements/system-operations.md` | Promote privilege, status, retention, and process-boundary invariants. | | architecture | modify | `docs/2-architecture/system-architecture.md` | Document CLI, backend, tray, local control/status, and durable run-state boundaries after implementation. | -| architecture | modify | `docs/2-architecture/scheduling-system.md` | Document independent backup and retention scheduling and overlap control. | +| architecture | modify | `docs/2-architecture/scheduling-system.md` | Document backup-success, independent-schedule, and explicit retention triggers plus overlap control. | | implementation | modify | `docs/3-implementation/service-layer-integration.md` | Identify the owning services and prohibit UI initialization in headless execution. | | runbook | modify | `docs/guides/developer/scheduling-guide.md` | Document installation, retention staging, rollback, and validation. | | user guide | modify | `docs/guides/user/installation.md` | Document system-path command and supported elevation behavior. | @@ -243,8 +246,9 @@ machine backup without handling protected credentials. ### Requirement 5: Automatic retention as an independent operation **User Story:** As an operator, I want TimeLocker to apply my retention policy -on an independent schedule, so that snapshot cleanup is consistent and does -not depend on the outcome or freshness of a backup run. +automatically after a successful scheduled backup while remaining independently +runnable, so that snapshot cleanup is consistent without making backup success +a general prerequisite for retention. **Priority:** must-have @@ -257,8 +261,10 @@ not depend on the outcome or freshness of a backup run. run using the same repository identity, credential source, snapshot filters, explicit grouping, policy values, and prune setting as the eventual mutation, and SHALL record those inputs as one reviewable policy fingerprint. -3. Retention SHALL use a separately identifiable service and schedule from the - backup service and schedule. +3. Retention SHALL use a separately identifiable operation and service from + backup. It SHALL support three trigger modes without merging backup and + retention results: successful scheduled-backup completion, an independent + schedule, and an explicit operator request. 4. Retention SHALL NOT run while a backup or another repository mutation is active, and a skipped conflict SHALL be visible as a run result rather than silently lost. @@ -278,6 +284,15 @@ not depend on the outcome or freshness of a backup run. absence, age, or freshness. Retention MAY run at any scheduled or explicitly requested time when its policy is approved and no conflicting repository mutation is active. +10. In the production automation profile, each successful scheduled backup + SHALL trigger at most one retention attempt immediately after the backup has + recorded terminal success and released its repository lock. A failed, + cancelled, skipped, or interrupted backup SHALL NOT emit that success + trigger; this SHALL NOT prevent a later independent or explicit retention + run. +11. A backup-triggered retention attempt SHALL acquire the normal repository + mutation lock and SHALL create its own run record. Its success, failure, or + conflict result SHALL NOT alter the preceding backup's terminal result. ### Requirement 6: Installation, upgrade, and recovery safety @@ -332,6 +347,9 @@ silently select the wrong code or privilege boundary. be reused. - **CP-009:** Replacing a Linux or Windows platform adapter cannot change the shared status, action, authorization, locking, or run-record contracts. +- **CP-010:** One successful scheduled backup emits at most one + backup-success retention trigger after terminal success and lock release; + every resulting retention attempt remains independently locked and recorded. ## Technical Context @@ -376,6 +394,10 @@ silently select the wrong code or privilege boundary. - **SC-009:** Shared contract tests pass unchanged against the Linux adapter and a Windows adapter test double, while Linux Mint Cinnamon/X11 live acceptance proves the first supported desktop environment. +- **SC-010:** One controlled successful scheduled backup produces a distinct + subsequent retention run, while controlled failed and interrupted backups do + not emit the success trigger and a later explicit retention run remains + possible. ## Open Questions For Design @@ -385,9 +407,10 @@ silently select the wrong code or privilege boundary. cleanest systemd integration without creating a general application server? - Should the tray read durable run state directly through a read-only library or exclusively through the backend contract? -- What exact cadence and missed-run policy should automatic retention use? The - initial recommendation remains daily at 04:30, but eligibility is explicitly - independent of backup outcome, absence, age, or freshness. +- Should the production profile also enable an independent retention schedule + as a catch-up path in addition to the required successful-backup trigger? If + so, what cadence and missed-run policy should it use while preventing a + duplicate attempt for the same policy window? - Which existing history implementation should become authoritative, and what migration is required for old or in-memory records? From 1e29d91989203108460a24bf81d458a56d4d1a3a Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:40:47 +0100 Subject: [PATCH 34/72] feat: complete spec 009 phase 1 foundation --- .../canonical-context.md | 90 ++ .../change-impact.md | 103 +++ .../009-system-cli-tray-retention/design.md | 525 ++++++++++++ .../requirements.md | 75 +- .../009-system-cli-tray-retention/tasks.md | 270 ++++++ .../traceability.md | 93 ++ .../verification.md | 250 ++++++ docs/specs/README.md | 21 +- pyproject.toml | 3 + src/TimeLocker/system_control/__init__.py | 95 +++ .../assets/system-control-policy.json | 19 + .../assets/timelocker-control.service | 30 + .../assets/timelocker-control.socket | 13 + src/TimeLocker/system_control/dispatcher.py | 215 +++++ src/TimeLocker/system_control/interfaces.py | 94 ++ .../system_control/linux_adapter.py | 164 ++++ src/TimeLocker/system_control/models.py | 801 ++++++++++++++++++ .../system_control/policy_loader.py | 82 ++ src/TimeLocker/system_control/protocol.py | 482 +++++++++++ src/TimeLocker/system_control/storage.py | 517 +++++++++++ src/TimeLocker/system_control/types.py | 115 +++ src/TimeLocker/system_control/validation.py | 220 +++++ tests/TimeLocker/system_control/__init__.py | 1 + .../system_control/test_dispatcher.py | 214 +++++ .../system_control/test_interfaces.py | 111 +++ .../system_control/test_linux_adapter.py | 231 +++++ .../TimeLocker/system_control/test_models.py | 279 ++++++ .../system_control/test_protocol.py | 407 +++++++++ .../TimeLocker/system_control/test_storage.py | 296 +++++++ .../system_control/test_validation.py | 90 ++ 30 files changed, 5869 insertions(+), 37 deletions(-) create mode 100644 docs/specs/009-system-cli-tray-retention/canonical-context.md create mode 100644 docs/specs/009-system-cli-tray-retention/change-impact.md create mode 100644 docs/specs/009-system-cli-tray-retention/design.md create mode 100644 docs/specs/009-system-cli-tray-retention/tasks.md create mode 100644 docs/specs/009-system-cli-tray-retention/traceability.md create mode 100644 docs/specs/009-system-cli-tray-retention/verification.md create mode 100644 src/TimeLocker/system_control/__init__.py create mode 100644 src/TimeLocker/system_control/assets/system-control-policy.json create mode 100644 src/TimeLocker/system_control/assets/timelocker-control.service create mode 100644 src/TimeLocker/system_control/assets/timelocker-control.socket create mode 100644 src/TimeLocker/system_control/dispatcher.py create mode 100644 src/TimeLocker/system_control/interfaces.py create mode 100644 src/TimeLocker/system_control/linux_adapter.py create mode 100644 src/TimeLocker/system_control/models.py create mode 100644 src/TimeLocker/system_control/policy_loader.py create mode 100644 src/TimeLocker/system_control/protocol.py create mode 100644 src/TimeLocker/system_control/storage.py create mode 100644 src/TimeLocker/system_control/types.py create mode 100644 src/TimeLocker/system_control/validation.py create mode 100644 tests/TimeLocker/system_control/__init__.py create mode 100644 tests/TimeLocker/system_control/test_dispatcher.py create mode 100644 tests/TimeLocker/system_control/test_interfaces.py create mode 100644 tests/TimeLocker/system_control/test_linux_adapter.py create mode 100644 tests/TimeLocker/system_control/test_models.py create mode 100644 tests/TimeLocker/system_control/test_protocol.py create mode 100644 tests/TimeLocker/system_control/test_storage.py create mode 100644 tests/TimeLocker/system_control/test_validation.py diff --git a/docs/specs/009-system-cli-tray-retention/canonical-context.md b/docs/specs/009-system-cli-tray-retention/canonical-context.md new file mode 100644 index 0000000..66e3855 --- /dev/null +++ b/docs/specs/009-system-cli-tray-retention/canonical-context.md @@ -0,0 +1,90 @@ +--- +title: System CLI, tray, retention, and control canonical context +doc_type: spec +artifact_type: canonical-context +status: draft +owner: Auriora Team +last_reviewed: 2026-07-26 +--- + +# Canonical Context + +## Purpose + +Prevent current-state documentation from being mistaken for the accepted +future behavior of Spec 009. The scheduling and tray guides remain authoritative +for the installed application until implementation and promotion; this package +is authoritative only for the active implementation slice. + +## Authority Hierarchy + +System, developer, and user instructions remain highest. `AGENTS.md` routes +project mandate and governance to `CHARTER.md`, agent behavior to +`docs/guides/ai-agent/`, current implementation behavior to source, tests, +configuration, and live evidence, and active change intent to this lifecycle +package. + +Spec-local context does not make planned behavior current. A conflict with +mandate, policy, source contracts, tests, generated contracts, or live system +evidence is a reconciliation input and must not be silently overridden. + +## Always-Canonical External Sources + +| Source | Authority reason | Handling | +|--------|------------------|----------| +| `AGENTS.md` | Repository instruction router | Read before changing governed paths. | +| `CHARTER.md` | Project mandate, boundaries, and governance | Stop for an explicit scope decision if the package conflicts with it. | +| `docs/guides/ai-agent/` | Agent workflow and operational rules | Follow the highest-priority applicable rule. | +| Source, tests, generated contracts, configuration, and live evidence | Current implementation and runtime truth | Reconcile disagreement; do not claim planned behavior is implemented. | + +## Spec-Canonical Working Sources + +| Source | Role | Scope | Notes | +|--------|------|-------|-------| +| `requirements.md` | Accepted intent | Spec 009 behavior and boundaries | User corrections through 2026-07-26 are included. | +| `design.md` | Proposed implementation approach | Spec 009 architecture and security model | Requires owner approval before source implementation. | +| `tasks.md` | Execution and approval index | Spec 009 delivery sequence | Load linked context before each task. | +| `traceability.md` | Delivery coverage contract | Requirement, design, task, verification, and promotion mappings | Coverage means mapped delivery, not completed implementation. | +| `verification.md` | Evidence contract | Validation, live acceptance, promotion, and closure | Pending results are not proof. | + +## Imported Sources + +| Source path | Reviewed | Status | Canonical scope | Promotion target | +|-------------|----------|--------|-----------------|------------------| +| `CHARTER.md` | 2026-07-26 | summarized | Mandate and non-goal boundaries only | `CHARTER.md` remains authoritative | +| `docs/guides/developer/scheduling-guide.md` | 2026-07-26 | background | Current installed scheduling and manual-retention behavior | Update after T010 acceptance | +| `docs/SYSTEM-TRAY-SETUP.md` | 2026-07-26 | background | Current in-process tray behavior and setup | Supersede after T007/T010 acceptance | +| `docs/2-architecture/system-architecture.md` | 2026-07-26 | background | Current service and CLI architecture | Update after accepted implementation | +| `docs/2-architecture/scheduling-system.md` | 2026-07-26 | background | Current schedule adapter architecture | Update after retention implementation | + +No durable document is copied into this package. The listed current-state +documents remain authoritative for users and operators until T011 promotes +verified behavior. + +## Non-Canonical Background Sources + +| Source | Reason non-canonical for this slice | Handling | +|--------|-------------------------------------|----------| +| Closed or archived specs | Historical delivery evidence, not current behavior | Consult only for provenance or regression context. | +| Ad hoc installation scripts under `/tmp` | Ephemeral host-operation aids | Never treat as repository contract or commit them. | +| User-local TimeLocker and pyenv installations | Do not define the root-owned system deployment | Use only as observed compatibility evidence. | +| Raw system journal output | Operational evidence that may contain protected metadata | Do not copy into spec artifacts; record secret-free summaries. | + +## Promotion Map + +| Spec-local content | Durable destination or route | Required before closure | +|--------------------|------------------------------|-------------------------| +| System authorization and record visibility invariants | `docs/1-requirements/system-operations.md` | yes | +| Launcher, backend, transport, run store, and tray architecture | `docs/2-architecture/system-architecture.md` | yes | +| Retention triggers and mutation coordination | `docs/2-architecture/scheduling-system.md` | yes | +| Installed scheduling, retention, tray, and rollback behavior | Current developer and user guides listed in `change-impact.md` | yes | +| Live Windows implementation | Platform roadmap or follow-up package | yes, as an explicit deferral | + +## Related Artifacts + +- Requirements: `requirements.md` +- Design: `design.md` +- Tasks: `tasks.md` +- Change impact: `change-impact.md` +- Traceability: `traceability.md` +- Verification: `verification.md` diff --git a/docs/specs/009-system-cli-tray-retention/change-impact.md b/docs/specs/009-system-cli-tray-retention/change-impact.md new file mode 100644 index 0000000..1a00b13 --- /dev/null +++ b/docs/specs/009-system-cli-tray-retention/change-impact.md @@ -0,0 +1,103 @@ +--- +title: System CLI, tray, retention, and control-plane change impact +doc_type: spec +artifact_type: change-impact +status: draft +owner: Auriora Team +last_reviewed: 2026-07-26 +--- + +# Change Impact + +## Purpose + +Record the durable behavior changed by Spec 009: system command discovery, +contextual machine operations, independent tray ownership, structured system +run visibility, group authorization, and automatic retention. + +## Durable Source Mapping + +| Source | Current behavior relied on | Confidence | Notes | +|--------|----------------------------|------------|-------| +| `CHARTER.md` | CLI-first backup orchestration, safety, stable automation, observable operation | high | Governing mandate | +| `docs/2-architecture/system-architecture.md` | CLI and service ownership; tray is currently optional integration code | high | Must be updated after implementation | +| `docs/2-architecture/scheduling-system.md` | Platform schedule adapters and scheduled backup model | high | Does not yet describe retention or shared run state | +| `docs/3-implementation/service-layer-integration.md` | Focused services should own new behavior instead of expanding the compatibility facade | high | Guides client/service placement | +| `docs/guides/developer/scheduling-guide.md` | Current system scheduling and manual-retention boundary | high | Promotion target for rollout and rollback | +| `docs/guides/user/installation.md` | Package entry points provide `timelocker` and `tl` | high | Does not yet define the machine launcher | +| `docs/SYSTEM-TRAY-SETUP.md` | Tray is an optional in-process integration | high | Must be superseded | +| `src/TimeLocker/cli_modules/commands/monitoring.py` | `logs view` reads the caller's cache log and ignores system run history | high | Bug and UX migration seam | +| `src/TimeLocker/monitoring/notification_service.py` | Notification construction initializes the system tray | high | Headless warning root cause | + +## Change Type + +- **Primary type:** feature +- **Secondary types:** refactor, migration, operational, bug_fix +- **Breaking change:** no +- **Durable docs required:** yes +- **External behavior affected:** yes + +## Proposed Changes + +| Change | Type | Source of truth | New durable destination | Promotion required | +|--------|------|-----------------|-------------------------|-------------------| +| Add root-owned system launchers and immutable release selection | add | Spec 009 | `docs/guides/user/installation.md`, `docs/processes/version-management.md` | yes | +| Route protected reads/actions through a versioned local backend | add | Spec 009 | `docs/2-architecture/system-architecture.md` | yes | +| Restrict system runs/logs to current operator-group members | add | Spec 009 | `docs/1-requirements/system-operations.md`, architecture and runbook docs | yes | +| Keep local logs distinct and add explicit system scope | modify | Current CLI behavior | CLI reference and troubleshooting guide | yes | +| Remove tray ownership from notification and CLI services | refactor | Current source | Architecture and tray setup documentation | yes | +| Add backup-success, independent, and explicit retention triggers | add | Spec 009 | Scheduling architecture and operator guide | yes | +| Add durable run records and interrupted-run reconciliation | add | Spec 009 | Architecture and operations docs | yes | + +## Promotion Targets + +| Spec content | Durable destination | Promotion status | Notes | +|--------------|---------------------|------------------|-------| +| System privilege, group authorization, record redaction, retention invariants | `docs/1-requirements/system-operations.md` | pending | New durable requirements document | +| Launcher, backend, IPC, run store, tray boundaries | `docs/2-architecture/system-architecture.md` | pending | Replace current single-process diagram | +| Backup/retention triggers, shared lock, run recording | `docs/2-architecture/scheduling-system.md` | pending | Preserve platform adapter context | +| Focused client/backend services and removed tray coupling | `docs/3-implementation/service-layer-integration.md` | pending | Do not expand compatibility facade | +| Installation, group management, launcher verification | `docs/guides/user/installation.md` | pending | Include Linux reference and portability limits | +| Production staging, dry-run approval, rollout, rollback | `docs/guides/developer/scheduling-guide.md` | pending | Current manual-retention text changes only after rollout | +| Independent tray installation and lifecycle | `docs/SYSTEM-TRAY-SETUP.md` | pending | Supersede in-process guidance | +| Command names and scopes | `docs/reference/timelocker-cli-command-hierarchy.md` | pending | Add runs and system log scope | +| User-facing diagnosis and permission errors | `docs/guides/user/backup-operations-troubleshooting.md` | pending | Explain local vs system records | + +## Unchanged Durable Areas + +| Durable area | Reviewed source | Reason unchanged | +|--------------|-----------------|------------------| +| Repository engine ownership | `CHARTER.md` | Restic remains the backup engine | +| Supported repository families | `docs/2-architecture/system-architecture.md` | Local, S3, and B2 support is unchanged | +| Restore overwrite policy | `docs/2-architecture/system-architecture.md` | Spec 009 does not change restore behavior | +| User-scoped backup partitions | GitHub issue #70 | Explicitly outside this spec | +| Full desktop UI | `CHARTER.md` and issue tracker | Tray remains a companion client | + +## Bug Fix Details + +- **Observed behavior:** `timelocker logs view` shows only user-cache logs and + CLI construction attempts to initialize the system tray twice. +- **Expected behavior:** local logs are clearly identified; authorized operators + can query structured system runs/diagnostics; headless commands never + initialize tray code. +- **Root cause evidence:** `logs_view` resolves + `ConfigurationPathResolver.get_cache_directory()` directly, while + `NotificationService` constructs `SystemTrayIntegration` and is instantiated + by more than one CLI service path. +- **Regression risk:** monitoring initialization, notification delivery, CLI + compatibility, system installation, and authorization behavior. +- **Durable doc update needed:** yes; see promotion targets. + +## Open Questions + +None blocking. Live Windows support remains a routed platform follow-up after +shared-contract and test-double acceptance. + +## Related Artifacts + +- Requirements: `requirements.md` +- Canonical context: `canonical-context.md` +- Design: `design.md` +- Tasks: `tasks.md` +- Traceability: `traceability.md` +- Verification: `verification.md` diff --git a/docs/specs/009-system-cli-tray-retention/design.md b/docs/specs/009-system-cli-tray-retention/design.md new file mode 100644 index 0000000..6f6f27a --- /dev/null +++ b/docs/specs/009-system-cli-tray-retention/design.md @@ -0,0 +1,525 @@ +--- +title: System CLI, independent tray, retention, and local control design +doc_type: spec +artifact_type: design +status: draft +owner: Auriora Team +last_reviewed: 2026-07-26 +--- + +# Technical Design + +## Overview + +TimeLocker will separate the public CLI, privileged machine operations, desktop +tray, and Restic execution into explicit process boundaries. A root-owned +system backend will expose a small versioned local contract. On Linux, a +systemd-activated Unix-domain socket will authenticate callers from kernel peer +credentials and revalidate membership in the root-controlled +`timelocker-operators` group for every protected request. Windows support will +use the same protocol and domain services behind a named-pipe/service adapter. + +The backend will expose structured run and diagnostic records rather than +granting users direct access to journald, root configuration, repository +credentials, or raw Restic output. The CLI and independent tray will be clients +of this contract. Ordinary user-local commands and logs will remain +unprivileged and separate. + +The Linux reference deployment remains Linux Mint Cinnamon/X11. The initial +delivery will include a Linux implementation and contract-tested Windows +adapter seam; it will not claim live Windows acceptance until that adapter is +implemented and validated. + +## Decisions + +### D001: Dedicated operator group + +The default system operator group is `timelocker-operators`. It is distinct from +the `restic` service account and any broad `systemd-journal` or administrator +group. Installation creates the group but does not add users automatically. +Membership changes remain an explicit system-administrator action. + +### D002: Structured records, not raw journal delegation + +TimeLocker will persist allowlisted `RunRecord` and `DiagnosticRecord` objects +under `/var/lib/timelocker`. Authorized clients may query those records through +the backend. Membership in `timelocker-operators` does not grant direct access +to journald, `/etc/timelocker`, `/var/restic`, environment files, or raw Restic +output. + +### D003: Kernel identity plus current group revalidation + +Linux socket permissions provide a first gate, but the backend also obtains the +peer UID through `SO_PEERCRED`, resolves the account through the operating +system, and checks current NSS group membership on every protected request. A +username, UID, group list, or authorization flag supplied in a request is +ignored. This second check rejects a process whose inherited supplementary +groups became stale after the account was removed from the operator group. + +### D004: Backend-mediated machine actions + +The system launcher does not elevate the entire CLI process. User-scope +commands run locally. Allowlisted machine operations are sent to the privileged +backend, which authenticates, authorizes, validates, locks, audits, and executes +them. Installation, upgrade, rollback, group management, and service-file +changes remain explicit administrator operations through the platform's normal +system authorization mechanism. + +### D005: Explicit local and system log scopes + +`timelocker logs view` remains backward-compatible and reads user-local +application logs by default. `timelocker logs view --scope system` queries +authorized structured diagnostic records. Backup and retention outcomes use +`timelocker runs list` and `timelocker runs show RUN_ID`; they are not inferred +from free-form log text. + +### D006: Independent tray client + +`NotificationService`, CLI services, schedulers, retention workers, and the +backend will not import or construct platform tray implementations. A separate +`timelocker-tray` entry point runs in the graphical user session, reads status +through the local contract, and requests only allowlisted actions. Platform UI +modules are loaded only by that entry point. + +### D007: Retention trigger independence + +Retention is a separate locked operation. The production profile emits one +retention request after a successful scheduled backup has recorded terminal +success and released its repository lock. Manual and independent scheduled +retention remain supported. The initial production profile leaves the +independent catch-up schedule disabled until an operator explicitly enables a +reviewed schedule. + +## Requirement Coverage + +| Requirement | Acceptance Criteria | Design Coverage | Validation Approach | +|-------------|---------------------|-----------------|---------------------| +| Requirement 1 | AC1-AC4 | Root-owned launchers, immutable release selector, fail-closed resolution | Launcher unit and live smoke tests | +| Requirement 2 | AC1-AC6 | Action classifier, backend-mediated machine actions, platform authorization adapter | Privilege-routing and denial tests | +| Requirement 3 | AC1-AC8 | Independent tray entry point, platform adapters, no tray imports in headless paths | Import-boundary, headless, reconnect, and live tray tests | +| Requirement 4 | AC1-AC11 | Versioned local contract, peer authorization, structured records, allowlisted actions | Contract, authorization, redaction, CLI, and tray tests | +| Requirement 5 | AC1-AC11 | Retention policy fingerprint, shared repository lock, three trigger modes | Policy, trigger, conflict, dry-run, and live retention tests | +| Requirement 6 | AC1-AC6 | Release manifest, asset compatibility, record reconciliation, rollback | Packaging, upgrade, interruption, and rollback tests | + +## Correctness Property Coverage + +| Property | Design Behavior | Validation Direction | Notes | +|----------|-----------------|----------------------|-------| +| CP-001 | Central action classifier and backend authorization gate | Table-driven routing tests | No command-local privilege guesses | +| CP-002 | Tray is only an IPC client | Process-kill and import-boundary tests | Headless operations have no GUI dependency | +| CP-003 | One repository mutation lock shared by backup and retention | Concurrency and crash-recovery tests | Lock identity derives from protected repository identity | +| CP-004 | Atomic run-record state machine | Transition and interruption tests | One terminal state per run | +| CP-005 | Approved retention fingerprint is carried into execution | Policy serialization and mutation tests | Restic defaults are not trusted | +| CP-006 | Contract/version/auth failure precedes action dispatch | Negative contract tests | No state change on denial | +| CP-007 | OS peer identity and current operator-group membership | Linux peer-credential integration tests | Socket permissions alone are insufficient | +| CP-008 | Startup reconciliation leases abandoned runs and locks | Kill/restart tests | Reconciliation is idempotent | +| CP-009 | Platform adapters implement one shared contract | Linux adapter and Windows test-double suite | No platform fields in public protocol | +| CP-010 | Backup success emits at most one retention trigger | Idempotency and failure-path tests | Trigger occurs after lock release | +| CP-011 | Protected records require current group membership and schema filtering | Authorized/denied/redaction tests | Raw journal data is never returned | + +## High-Level Design + +### System Architecture + +```text +User shell Graphical user session + | | + v v +/usr/local/bin/timelocker timelocker-tray + | | + +---------- local protocol client -----+ + | + platform transport adapter + | + Linux: /run/timelocker/control.sock + Windows: protected named pipe + | + v + root/system TimeLocker backend + | | | + v v v + Run store Action Repository + and audit policy mutation lock + | | + +--------+---------+ + v + Backup / retention workers + | + v + Restic +``` + +### Components and Changes + +- **System launcher** + - Install root-owned `timelocker` and `tl` launchers on the normal system + path. + - Resolve one immutable release manifest and never fall back to pyenv, a + checkout, or a user virtual environment. + - Keep release code executable but store configuration, credentials, run + state, and environment files outside the release tree with stricter modes. + +- **Action classifier** + - Classify every public operation as `user_local_read`, + `user_local_mutation`, `system_read`, `system_action`, or + `administrator_maintenance`. + - Only the two system categories use the backend contract. + - Unknown actions fail closed. + +- **Local control server** + - Own protocol negotiation, request bounds, peer authentication, + authorization, dispatch, audit, and response redaction. + - Provide only allowlisted operations: health, run list/detail, diagnostic + list, schedule summary, backup request, retention request, and future UI + availability. + - Never accept executable paths, raw Restic arguments, environment maps, + repository credentials, or unrestricted filesystem paths. + +- **Platform security adapters** + - Linux: systemd socket/service, `SO_PEERCRED`, NSS group resolution, file + modes, atomic filesystem storage, and `flock`. + - Windows: service and named-pipe ACL/token adapter implementing the same + domain interfaces. + +- **Run store** + - Persist one JSON document per run using temporary-file, `fsync`, and atomic + replace. + - Keep a bounded append-only diagnostic stream with structured codes and + safe summaries. + - Reconcile non-terminal runs against process/lease ownership at backend + startup. + +- **CLI system client** + - Add focused `SystemControlClient`; do not expand `CLIServiceManager` with + backend implementation details. + - Add `runs list`, `runs show`, and `logs view --scope system`. + - Preserve `logs view --scope local` and make the selected scope visible in + output. + +- **Independent tray** + - Move tray construction and platform callbacks behind the standalone tray + entry point. + - Poll or subscribe through the protocol adapter with bounded reconnect and + stale-state handling. + +- **Backup and retention workers** + - Use the same repository lock and run-record writer. + - Emit structured state transitions and safe diagnostic codes. + - Emit the post-backup retention request only after terminal backup success + and lock release. + +### Data Models + +#### Protocol envelope + +```text +Request { + protocol_version: integer + request_id: UUID + action: enum + parameters: action-specific bounded object +} + +Response { + protocol_version: integer + request_id: UUID + status: ok | denied | conflict | unavailable | invalid | failed + result: action-specific allowlisted object or null + error_code: stable code or null + safe_summary: bounded string or null +} +``` + +#### RunRecord + +```text +RunRecord { + schema_version: integer + run_id: UUID + operation: backup | retention + trigger: scheduled | backup_success | explicit | retry | recovery + target_id: opaque stable identifier + policy_fingerprint: optional digest + started_at: UTC timestamp + completed_at: optional UTC timestamp + state: queued | running | succeeded | failed | skipped | interrupted + result_code: stable code + safe_summary: bounded string + counters: allowlisted numeric map +} +``` + +`RunRecord` excludes repository URIs, credentials, environment values, raw +commands, source paths, selection contents, and raw Restic output. + +#### DiagnosticRecord + +```text +DiagnosticRecord { + schema_version: integer + record_id: UUID + run_id: optional UUID + timestamp: UTC timestamp + level: info | warning | error + component: allowlisted component code + message_code: stable code + safe_summary: bounded string +} +``` + +#### SystemPolicy + +```text +SystemPolicy { + protocol_version: integer + operator_group: string + socket_or_pipe: platform-owned identifier + max_request_bytes: integer + max_response_records: integer + retention_policy: explicit values and approved fingerprint +} +``` + +The policy file is root-owned and validated before the backend starts. + +### Data Flow + +#### Protected read + +1. CLI or tray connects through the platform transport. +2. Transport adapter obtains kernel/OS peer identity. +3. Authorization service resolves current group membership. +4. Contract validates version, action, request size, and parameters. +5. Run store returns bounded structured records. +6. Response serializer projects only the action's allowlisted fields. +7. Audit records the caller UID/account, action, decision, record count, and + result code without protected payload contents. + +#### Backup-triggered retention + +1. Backup worker acquires the repository lock and creates a running record. +2. Backup completes and atomically writes terminal success. +3. Backup releases the lock. +4. Trigger coordinator records an idempotency key derived from backup run ID + and policy fingerprint. +5. Retention worker acquires the repository lock and creates a distinct run. +6. Retention result is recorded independently. + +#### Tray status + +1. Tray starts in the user session and connects as the user. +2. Unauthorized users receive a generic unavailable/denied state with no + protected metadata. +3. Authorized users receive current and recent structured records. +4. Tray reconnects with bounded backoff and never blocks backend work. + +## Low-Level Design + +### Algorithms and Logic + +#### Authorization + +```text +authorize(connection, action): + peer = transport.peer_identity(connection) + if peer is unavailable: + deny GENERIC_ACCESS_DENIED + policy = load_validated_root_policy() + if not group_resolver.is_current_member(peer.uid, policy.operator_group): + audit denied action without protected parameters + deny GENERIC_ACCESS_DENIED + if action not in allowlist_for_operator_group: + deny GENERIC_ACCESS_DENIED + return AuthorizedPrincipal(peer.uid, peer.pid, policy.operator_group) +``` + +The Linux group resolver uses the account database, not only the peer process's +inherited supplementary-group list. It recognizes both the account's primary +group and supplementary memberships. Authorization is recomputed per request +and fails closed when the peer account, configured group, or current membership +cannot be resolved. No positive membership result is cached across requests. + +#### Atomic run transition + +```text +transition(run_id, expected_states, new_state, update): + acquire run-store lock + current = read and validate record + require current.state in expected_states + require current is not terminal + candidate = schema_validate(current + update + new_state) + write temporary file, fsync, atomic replace, fsync directory + release lock +``` + +Terminal-to-terminal transitions fail without modifying the record. + +#### Response projection + +```text +project(action, records): + schema = response_schema_for(action) + bounded = records[:schema.max_records] + return [schema.copy_allowlisted_fields(record) for record in bounded] +``` + +### Function Signatures and Interfaces + +```python +class PeerIdentityProvider(Protocol): + def peer_identity(self, connection: object) -> "PeerIdentity": ... + +class GroupMembershipResolver(Protocol): + def is_current_member(self, uid: int, group_name: str) -> bool: ... + +class LocalControlTransport(Protocol): + def serve(self, handler: "ControlRequestHandler") -> None: ... + +class RunRecordStore(Protocol): + def create(self, record: "RunRecord") -> None: ... + def transition(self, run_id: UUID, transition: "RunTransition") -> "RunRecord": ... + def list(self, query: "RunQuery") -> list["RunRecord"]: ... + def get(self, run_id: UUID) -> "RunRecord | None": ... + def reconcile_interrupted(self, active_leases: set[str]) -> list[UUID]: ... + +class SystemControlClient(Protocol): + def list_runs(self, query: "RunQuery") -> list["RunRecordView"]: ... + def get_run(self, run_id: UUID) -> "RunRecordView": ... + def list_diagnostics(self, query: "DiagnosticQuery") -> list["DiagnosticView"]: ... + def request_backup(self, request: "BackupActionRequest") -> "ActionReceipt": ... + def request_retention(self, request: "RetentionActionRequest") -> "ActionReceipt": ... +``` + +### Error Handling + +- Connection absence returns `SYSTEM_BACKEND_UNAVAILABLE` and the manual + service-health command. +- Authentication and authorization failures return one + `SYSTEM_ACCESS_DENIED` response without confirming resource existence. +- Version mismatch returns `CONTRACT_VERSION_UNSUPPORTED` with supported + version bounds and no protected state. +- Invalid or oversized requests are rejected before dispatch. +- Stale locks are recovered only through lease reconciliation. +- Store corruption moves the invalid record to a root-only quarantine + directory and emits a safe diagnostic; it does not silently discard history. +- Tray failures are local to the tray. CLI and scheduled workers do not import + tray code and cannot emit tray-toolkit warnings. + +### Security, Trust, and Access + +- `/run/timelocker` is root-owned and not writable by clients. +- The Linux socket is `root:timelocker-operators` mode `0660`. +- `/var/lib/timelocker`, its run-store and quarantine directories, and + `/etc/timelocker` remain `root:root`, inaccessible to non-root users, and + non-writable through symlink traversal; clients read none of them directly. +- Run-store writes use root-created files with restrictive modes, validated + UUID-derived names, same-directory temporary files, no-follow semantics, + atomic replacement, and directory `fsync`. +- Group membership is necessary but not sufficient: the server verifies peer + identity and current membership for each request. +- The backend drops requests containing unknown fields, executable paths, + environment maps, raw arguments, or unbounded strings. +- Audit records decisions, not secret-bearing payloads. The audit sink is + root-only and distinct from operator-visible diagnostics; system-log + projections never return peer UIDs, account names, or another caller's audit + trail. +- `safe_summary` values are selected from bounded templates keyed by stable + diagnostic codes. They are never copied from exception strings, subprocess + output, command arguments, environment values, repository URIs, or protected + paths. +- The transport enforces bounded request size, read/idle timeouts, connection + concurrency, and response pagination before allocating unbounded work. +- The operator group does not imply repository credential access, raw journal + access, arbitrary restore, schedule editing, retention-policy editing, or + administrator maintenance. +- Windows named-pipe security must derive the caller token and current group + membership rather than trust payload identity. + +### Migration and Compatibility + +1. Install new backend, socket, group, run-store directory, and launchers in a + disabled/staged state. +2. Preserve the current backup timer and root environment file. +3. Wrap scheduled execution with run recording and shared locking while leaving + the backup command semantics unchanged. +4. Validate authorized and denied reads before enabling tray or actions. +5. Remove tray construction from `NotificationService` only after the + independent tray client is available or explicitly disabled. +6. Enable backup requests, retention triggers, and tray actions separately. +7. Retain the prior release and unit assets for rollback. + +Existing `timelocker` and `tl` package entry points remain. Existing +`logs view` behavior becomes `--scope local` and remains the default. + +### Slice Boundary And Residual Architecture + +| Design target | In this slice | Out of this slice | Follow-up destination | Blocks closure? | +|---------------|---------------|-------------------|-----------------------|-----------------| +| Linux system launcher and backend | Full Linux implementation and live acceptance | Other Linux init systems beyond capability reporting | Backlog after Linux reference acceptance | no | +| Windows portability | Shared contracts and adapter test double | Live Windows service/named-pipe implementation | Roadmap/platform follow-up | no | +| Operator system views | Runs and sanitized diagnostics | Direct/raw journald access | Rejected for least privilege | no | +| Independent tray | Linux Mint Cinnamon/X11 client and headless isolation | Full desktop UI | Existing UI backlog | no | +| Retention | Approved 5/4/12/3 policy, three triggers, no prune | Prune automation | Backlog/spec if requested | no | +| User partitions | No implementation | User-scoped selections and restores | GitHub issue #70 | no | + +## Validation Strategy + +| Validation | Covers | Evidence Location | Residual Risk | +|------------|--------|-------------------|---------------| +| Protocol/model unit and property tests | Requirements 4-6, CP-003-CP-011 | `verification.md`, CI | Platform kernels still need integration evidence | +| Linux socket authorization integration tests | Requirement 4 AC8-AC11, CP-007, CP-011 | `verification.md` | NSS behavior varies by deployment | +| CLI launcher and action-routing tests | Requirements 1-2 | `verification.md` | Live authorization-agent behavior | +| Headless import and tray lifecycle tests | Requirement 3 | `verification.md` | Desktop-environment diversity | +| Backup/retention lock and trigger tests | Requirement 5 | `verification.md` | Restic/storage timing under production load | +| Live Mint systemd acceptance and rollback rehearsal | Success criteria | `verification.md` | One validated Linux environment | +| `review-timelocker` security and operations review | Trust boundary and recovery | review artifact or verification log | Findings must be resolved before rollout | + +## Downstream Task Guidance + +- Required checkpoints before implementation: requirements approval, design + approval, complete traceability, and no unresolved blocking decisions. +- CP-007, CP-008, CP-010, and CP-011 require explicit negative and + interruption-path tests. +- Do not reuse the existing `AccessManager` session model for OS peer + authorization. +- Do not make `timelocker-operators` a member of `systemd-journal` or grant it + access to repository credentials. +- Run security review after the first complete backend/authorization slice and + again before live rollout. + +## Operational Considerations + +- Group membership additions normally require a new login session before + filesystem socket access is available; removals are rejected immediately by + server-side NSS revalidation. +- Backend health, protocol version, run-store corruption, denied requests, + trigger conflicts, and interrupted-run reconciliation need stable diagnostic + codes. +- Rollout must preserve the working 03:30 backup timer until a replacement has + completed backup and restore acceptance. +- A rollback restores prior launchers and units but preserves run records and + the approved retention policy. +- Raw journal inspection remains an administrator troubleshooting operation. + +## Open Questions + +No design-blocking questions remain. The independent retention catch-up schedule +is supported but disabled in the initial production profile; enabling it is a +separate operator decision with duplicate-window validation. + +## Related Artifacts + +- Requirements: `requirements.md` +- Canonical context: `canonical-context.md` +- Change Impact: `change-impact.md` +- Tasks: `tasks.md` +- Traceability: `traceability.md` +- Verification: `verification.md` + +## Reconciliation + +Reviewed against the 2026-07-26 requirements revision. AC10-AC11 system-record +authorization, metadata-free denial, current group membership, and local/system +log separation remain fully represented. The security review additionally +clarified fail-closed NSS resolution, root-only audit data, safe-summary +provenance, storage hardening, and transport resource bounds. diff --git a/docs/specs/009-system-cli-tray-retention/requirements.md b/docs/specs/009-system-cli-tray-retention/requirements.md index 95b6db5..d6213b9 100644 --- a/docs/specs/009-system-cli-tray-retention/requirements.md +++ b/docs/specs/009-system-cli-tray-retention/requirements.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: requirements status: active owner: Auriora Team -last_reviewed: 2026-07-24 +last_reviewed: 2026-07-26 --- # Requirements @@ -108,16 +108,17 @@ triggers plus visible outcomes. ## Staged Readiness -- **Current stage:** requirements -- **Next stage:** design -- **Ready to design when:** elevation and tray trust boundaries, operation - status semantics, retention policy, failure isolation, compatibility, and - roadmap exclusions are accepted. +- **Current stage:** design and task-plan review +- **Next stage:** implementation +- **Ready to implement when:** the design, task plan, traceability, and + verification package pass lifecycle validation, the security and operations + review has no unresolved blocking finding, and the project owner explicitly + approves implementation. - **Design-first exception:** no -- **Optional artifacts recommended:** `research.md`, `change-impact.md`, and - `open-decisions.md` -- **Downstream review needed:** design, security, operations, desktop - integration, testing, traceability, and verification +- **Optional artifacts recommended:** none currently; create + `canonical-context.md` only if a concrete authority conflict is found. +- **Downstream review needed:** implementation-slice security and architecture + review at T004, then full expert review before closure. - **Package sequencing:** Spec 007 is closed and its durable release-readiness gates remain applicable independently. Spec 009 is the only active package; it must produce new implementation and validation evidence rather than reuse @@ -242,6 +243,14 @@ machine backup without handling protected credentials. identities, unauthorized local users, stale authorization, arbitrary executable paths, and arguments outside the allowlisted action schema without disclosing protected status or selection metadata. +10. System-scope run history and diagnostic-log views SHALL require current + membership in the configured system operator group. Responses SHALL contain + only allowlisted, secret-free fields and SHALL NOT disclose raw environment + values, repository credentials, protected source paths, or unrestricted + journal content. +11. User-local application logs SHALL remain distinct from system-scope run and + diagnostic records. An authorization failure SHALL NOT disclose whether a + protected run, repository, selection, schedule, or diagnostic record exists. ### Requirement 5: Automatic retention as an independent operation @@ -350,6 +359,10 @@ silently select the wrong code or privilege boundary. - **CP-010:** One successful scheduled backup emits at most one backup-success retention trigger after terminal success and lock release; every resulting retention attempt remains independently locked and recorded. +- **CP-011:** A system-scope run or diagnostic record is returned if and only + if the server derives the caller's operating-system identity and confirms + current membership in the configured system operator group; returned fields + are a strict subset of the allowlisted response schema. ## Technical Context @@ -398,21 +411,23 @@ silently select the wrong code or privilege boundary. subsequent retention run, while controlled failed and interrupted backups do not emit the success trigger and a later explicit retention run remains possible. - -## Open Questions For Design - -- Which Linux elevation split best serves terminal and graphical callers: - `sudo`, polkit/`pkexec`, a narrow privileged helper, or a combination? -- Which local IPC mechanism provides the smallest authenticated interface and - cleanest systemd integration without creating a general application server? -- Should the tray read durable run state directly through a read-only library - or exclusively through the backend contract? -- Should the production profile also enable an independent retention schedule - as a catch-up path in addition to the required successful-backup trigger? If - so, what cadence and missed-run policy should it use while preventing a - duplicate attempt for the same policy window? -- Which existing history implementation should become authoritative, and what - migration is required for old or in-memory records? +- **SC-011:** An authorized operator can view system backup and retention runs + through the CLI and tray, while an unauthorized local user and a user removed + from the operator group receive the same metadata-free denial and cannot read + protected system log files or raw journal records through TimeLocker. + +## Resolved Design Questions + +- System reads and allowlisted actions use the privileged local backend; + administrator maintenance continues through the platform elevation adapter. +- Linux uses a systemd-managed Unix-domain socket with kernel peer credentials; + shared contracts retain a Windows named-pipe adapter boundary. +- The tray reads structured state exclusively through the backend contract. +- Successful scheduled backups trigger retention after terminal success and + lock release. The independent schedule remains supported but initially + disabled until an operator approves its cadence. +- A new atomic run store under root-owned system state becomes authoritative + for system operations; legacy user-local logs remain a separate local scope. ## Routed Future Work @@ -429,7 +444,9 @@ silently select the wrong code or privilege boundary. ## Related Artifacts -- Change Impact: pending -- Design: pending -- Tasks: pending -- Verification: pending +- Canonical context: `canonical-context.md` +- Change impact: `change-impact.md` +- Design: `design.md` +- Tasks: `tasks.md` +- Traceability: `traceability.md` +- Verification: `verification.md` diff --git a/docs/specs/009-system-cli-tray-retention/tasks.md b/docs/specs/009-system-cli-tray-retention/tasks.md new file mode 100644 index 0000000..0c7f590 --- /dev/null +++ b/docs/specs/009-system-cli-tray-retention/tasks.md @@ -0,0 +1,270 @@ +--- +title: System CLI, independent tray, retention, and control tasks +doc_type: spec +artifact_type: tasks +status: draft +owner: Auriora Team +last_reviewed: 2026-07-26 +--- + +# Tasks + +**Input**: `canonical-context.md`, `requirements.md`, `design.md`, +`change-impact.md`, `traceability.md`, and `verification.md` + +**Prerequisites**: Requirements and design approved; no implementation starts +until the project owner approves the task plan. + +## Task Dependency Graph + +```text +T001 -> T002 -> T003 -> T004 +T004 -> T005 -> T006 +T004 -> T007 +T004 -> T008 +T006 + T007 + T008 -> T009 +T009 -> T010 -> T011 -> T012 +``` + +## Phase 1: Shared contracts and safety foundation + +- [x] T001 Define shared protocol, action, policy, run, diagnostic, and client + models with strict validation. + - Depends on: none + - Requirements: Requirement 2 AC4-AC6; Requirement 4 AC1-AC7, AC9-AC11; + Requirement 5 AC1-AC2, AC5-AC8; Requirement 6 AC5 + - Properties: CP-001, CP-004, CP-005, CP-006, CP-009, CP-011 + - Files: new focused modules under `src/TimeLocker/system_control/`; + matching tests under `tests/TimeLocker/system_control/` + - Acceptance: Versioned bounded schemas reject unknown fields, secret-bearing + inputs, raw arguments, and invalid transitions; response projection returns + only allowlisted fields. + - Evidence: T001 complete: 70 focused tests passed with 92.7% branch-aware coverage; compileall and git diff --check passed; focused review-timelocker implementation review found no remaining actionable findings. No transport, store, CLI, or live-host behavior was changed. + - Evidence mode: validation + - [x] T001.1 Add failing contract and model tests. + - Evidence: Added focused contract, model, transition, response-projection, security-boundary, and portability tests under tests/TimeLocker/system_control; final focused run: 70 passed. + - Evidence mode: validation + - [x] T001.2 Implement schemas, enums, validation, and response projection. + - Evidence: Implemented strict frozen enums, request/response envelopes, run/transition/diagnostic/policy/action models, validation helpers, immutable projections, and code-owned safe summaries under src/TimeLocker/system_control. + - Evidence mode: implementation + - [x] T001.3 Add Linux and Windows adapter protocol test doubles. + + - Evidence: Added platform-neutral peer identity, membership, transport, handler, and client protocols with Linux UID and Windows SID adapter test doubles; platform tests passed. + - Evidence mode: validation +- [x] T002 Implement atomic run/diagnostic storage, repository mutation + locking, and interrupted-run reconciliation. + - Depends on: T001 + - Requirements: Requirement 4 AC2-AC3, AC5-AC6, AC10-AC11; Requirement 5 + AC4-AC6, AC11; Requirement 6 AC6 + - Properties: CP-003, CP-004, CP-008, CP-010, CP-011 + - Files: `src/TimeLocker/system_control/`, focused storage and recovery tests + - Acceptance: Records transition atomically to exactly one terminal state; + concurrent mutations cannot overlap; abandoned runs become interrupted and + stale locks are reusable without duplicate terminal records. + - Evidence: Atomic storage, bounded diagnostics, repository mutation leases, and idempotent abandoned-run reconciliation implemented in src/TimeLocker/system_control/storage.py. Focused T002 validation passed 11 tests, including cross-process lease recovery; final Phase 1 validation remains tracked by T004. + - Status: Complete and dependency-ready for T003. + - Evidence mode: command + - [x] T002.1 Add transition, concurrency, corruption, and kill/restart tests. + - Evidence: Added transition, concurrency, corruption, persistence, bounded-stream, process-exit, and restart-reconciliation tests in tests/TimeLocker/system_control/test_storage.py; focused run passed 11 tests. + - Evidence mode: command + - [x] T002.2 Implement atomic record store and bounded diagnostic stream. + - Evidence: Implemented AtomicRecordStore with strict schema parsing, per-run atomic JSON replacement, fsync of files and directories, process-safe transition locking, bounded immutable diagnostic records, filtering, and mode enforcement. + - Evidence mode: artifact + - [x] T002.3 Implement repository lock leases and startup reconciliation. + + - Evidence: Implemented nonblocking flock repository leases with safe run ownership metadata, conflict behavior, process-exit release, stale metadata clearing, and idempotent startup reconciliation of abandoned queued/running records. + - Evidence mode: artifact +- [x] T003 Implement Linux local transport and current operator-group + authorization. + - Depends on: T002 + - Requirements: Requirement 2 AC2-AC6; Requirement 4 AC1, AC4-AC11 + - Properties: CP-001, CP-006, CP-007, CP-011 + - Files: Linux adapter modules, systemd socket/service assets, security tests + - Acceptance: The server derives peer credentials, revalidates current + `timelocker-operators` membership for every protected request, rejects stale + or self-asserted identity, and leaks no protected metadata on denial. + - Evidence: Linux local transport, kernel peer-credential adapter, fresh NSS operator-group authorization, strict dispatcher/audit/redaction, root-policy loader, and least-privilege staged unit assets implemented. Focused T003 validation passed 15 tests; no units were installed or activated. + - Status: Complete and dependency-ready for T004; live socket/unit acceptance remains T010. + - Evidence mode: command + - [x] T003.1 Add authorized, unauthorized, removed-member, malformed, + oversized, and version-mismatch tests. + - Evidence: Added authorized, unauthorized, membership-removal, handler-failure, self-asserted identity, malformed JSON, oversized request, and version mismatch tests in test_dispatcher.py; Linux adapter suite also covers peer parsing and NSS failures. + - Evidence mode: command + - [x] T003.2 Implement `SO_PEERCRED`, NSS group resolver, dispatcher, audit, + and redaction. + - Evidence: Implemented SO_PEERCRED parsing, per-request primary/supplementary NSS lookup, strict JSON dispatcher, metadata-free denial/error responses, secret-free audit events, and systemd-activated AF_UNIX transport adapter. + - Evidence mode: artifact + - [x] T003.3 Add root-owned policy, runtime directory, socket, and service + templates with least-privilege modes. + + - Evidence: Added packaged policy JSON plus staged socket/service templates with root ownership intent, timelocker-operators 0660 socket access, restrictive umask, AF_UNIX-only address family, filesystem protections, and no GUI/session or credential environment forwarding. + - Evidence mode: artifact +- [x] T004 Checkpoint - Foundation security and agent-readiness review. + - Depends on: T003 + - Files: Spec artifacts and Phase 1 source/tests + - Acceptance: Focused tests pass, Spec Lifecycle Manager reports bounded task + context and traceability, and every `review-timelocker` + security/architecture finding has a recorded disposition before public CLI + or live rollout. + - Evidence mode: command + - Evidence: Phase 1 checkpoint passed: 98 focused tests passed at 88.4% coverage; Ruff check/format, compileall, wheel build and 3/3 asset inventory, git diff --check, spec lint, and T002/T003 task audits passed. The review-timelocker panel identified TLR-001 through TLR-005; all were fixed and their dispositions are recorded in verification.md. Agent Workbench diagnostics had no provider for these Python files, so executed checks and direct review are the proof. No host assets were installed or activated. + + - Status: Phase 1 complete. Real socket activation, installed permissions, live NSS, host restart, and Windows implementation remain assigned to later tasks. +## Phase 2: System CLI and authorized visibility + +- [ ] T005 Implement the root-owned system launcher and centralized action + classification. + - Depends on: T004 + - Requirements: Requirement 1 AC1-AC4; Requirement 2 AC1-AC6; + Requirement 6 AC1-AC3 + - Properties: CP-001, CP-006 + - Files: packaging/install assets, launcher/action-policy modules, tests + - Acceptance: `timelocker` and `tl` resolve one immutable release; user-local + actions remain unprivileged; protected actions use the backend; invalid + release or unknown action fails closed without pyenv/checkout fallback. + - Evidence: Pending. + - [ ] T005.1 Add launcher resolution, rollback, recursion, and routing tests. + - [ ] T005.2 Implement immutable release launcher and action classifier. + - [ ] T005.3 Add staged install/rollback assets without changing the live + selected release. + +- [ ] T006 Add structured system run and diagnostic CLI views. + - Depends on: T005 + - Requirements: Requirement 4 AC1-AC3, AC6, AC8-AC11 + - Properties: CP-004, CP-006, CP-007, CP-011 + - Files: `src/TimeLocker/cli_modules/commands/monitoring.py`, focused system + client modules, CLI and integration tests + - Acceptance: `runs list`, `runs show`, and + `logs view --scope local|system` clearly distinguish local and system data; + only current operator-group members receive protected structured records. + - Evidence: Pending. + - [ ] T006.1 Add CLI contract, compatibility, denial, and redaction tests. + - [ ] T006.2 Implement focused `SystemControlClient` integration. + - [ ] T006.3 Preserve local log behavior and correct `--config-dir`/scope + resolution without reading protected files directly. + +## Phase 3: Independent tray and retention + +- [ ] T007 Remove tray ownership from CLI/headless services and add the + independent tray client. + - Depends on: T004 + - Requirements: Requirement 3 AC1-AC8; Requirement 4 AC1, AC4-AC9; + Requirement 6 AC1-AC5 + - Properties: CP-002, CP-006, CP-007, CP-009 + - Files: notification/monitoring services, tray entry point, platform + adapters, packaging, tray/headless tests + - Acceptance: CLI, backup, retention, scheduler, and backend paths import no + tray platform code and emit no tray warning; the user-session tray connects, + reconnects, displays authorized state, and requests only allowlisted + actions. + - Evidence: Pending. + - [ ] T007.1 Add import-boundary, headless, absence, crash, singleton, and + reconnect tests. + - [ ] T007.2 Refactor notification delivery to publish structured state + without constructing `SystemTrayIntegration`. + - [ ] T007.3 Add standalone tray entry point and Linux Mint Cinnamon/X11 + adapter. + +- [ ] T008 Implement approved retention execution and all three trigger modes. + - Depends on: T004 + - Requirements: Requirement 5 AC1-AC11; Requirement 6 AC1-AC3, AC6 + - Properties: CP-003, CP-004, CP-005, CP-008, CP-010 + - Files: retention policy/executor/trigger modules, scheduling integration, + unit/integration tests + - Acceptance: Dry-run approval fingerprints the complete policy; backup + success, independent schedule, and explicit request create separate locked + retention runs; failure or conflict never changes the backup result. + - Evidence: Pending. + - [ ] T008.1 Add policy fingerprint, approval, conflict, idempotency, and + failure-isolation tests. + - [ ] T008.2 Implement retention executor and protected explicit request. + - [ ] T008.3 Implement post-backup success trigger after terminal record and + lock release. + - [ ] T008.4 Implement independently configurable schedule, disabled in the + initial production profile. + +## Phase 4: Installation, portability, and live acceptance + +- [ ] T009 Integrate release assets, platform adapters, upgrade, and rollback. + - Depends on: T006, T007, T008 + - Requirements: Requirement 1; Requirement 2; Requirement 3 AC6-AC8; + Requirement 6 AC1-AC6 + - Properties: CP-001, CP-006, CP-008, CP-009 + - Files: build/install scripts, systemd assets, platform adapter contracts, + package tests + - Acceptance: One compatibility-checked artifact set installs launchers, + backend, socket, tray, and schedules; upgrade validates them before + retirement; rollback restores the prior release without deleting records or + changing policy. + - Evidence: Pending. + - [ ] T009.1 Add artifact manifest, permission, upgrade, and rollback tests. + - [ ] T009.2 Complete Linux install assets and Windows service/named-pipe test + double. + - [ ] T009.3 Prove headless install requires no GUI dependencies. + +- [ ] T010 Run controlled Linux Mint live acceptance and rollback rehearsal. + - Depends on: T009 + - Requirements: Requirements 1-6; SC-001-SC-011 + - Files: `verification.md`, external system assets only after explicit rollout + approval + - Acceptance: Authorized and denied system views, system launcher, scheduled + backup, restore, post-success retention, independent retention, tray + reconnect, interrupted-run recovery, upgrade, and rollback are evidenced. + - Evidence mode: validation + - Evidence: Pending. + - [ ] T010.1 Stage without changing the working 03:30 backup. + - [ ] T010.2 Obtain explicit approval before group membership, service, + launcher, timer, or live-retention mutations. + - [ ] T010.3 Execute acceptance and record secret-free evidence. + - [ ] T010.4 Rehearse rollback and confirm backup scheduling remains healthy. + +## Phase 5: Promotion, review, and closure + +- [ ] T011 Promote accepted behavior into durable documentation. + - Depends on: T010 + - Files: all promotion targets in `change-impact.md` + - Acceptance: Durable requirements, architecture, CLI reference, + installation, tray, scheduling, troubleshooting, rollout, and rollback docs + match implemented behavior and no future intent is presented as current. + - Evidence: Pending. + +- [ ] T012 Complete expert review, full validation, residual disposition, and + closure preparation. + - Depends on: T011 + - Files: Spec verification/traceability, durable docs, closure records + - Acceptance: Security, Restic/recovery, operations/portability, Python CLI, + tests, and documentation findings have recorded dispositions; all + requirements, ACs, and properties have evidence; closure and archive checks + pass. + - Evidence mode: validation + - Evidence: Pending. + +## Execution Rules + +- Do not implement from this file alone. Load the linked requirement, design, + traceability, change-impact, and verification context first. +- Mark only one implementation task `[~]` at a time unless tasks have no file + or state conflict. +- Do not use `AccessManager` sessions as proof of operating-system identity or + operator-group membership. +- Do not grant `timelocker-operators` direct access to journald, credentials, + `/var/restic`, raw Restic arguments, or protected source paths. +- Live installation, group membership, service enablement, schedule changes, + retention mutation, and rollback require explicit operator approval at T010. +- Record evidence before marking any task complete. + +## Related Artifacts + +- Requirements: `requirements.md` +- Canonical context: `canonical-context.md` +- Change Impact: `change-impact.md` +- Design: `design.md` +- Traceability: `traceability.md` +- Verification: `verification.md` + +## Reconciliation + +Reviewed against the 2026-07-26 requirements and design revisions. T001-T006 +cover the tightened system-record authorization, audit separation, diagnostic +projection, NSS failure, transport-bound, and storage-hardening work. No task +dependency or live-mutation approval boundary changed. diff --git a/docs/specs/009-system-cli-tray-retention/traceability.md b/docs/specs/009-system-cli-tray-retention/traceability.md new file mode 100644 index 0000000..85bd1b3 --- /dev/null +++ b/docs/specs/009-system-cli-tray-retention/traceability.md @@ -0,0 +1,93 @@ +--- +title: System CLI, tray, retention, and control traceability +doc_type: spec +artifact_type: traceability +status: draft +owner: Auriora Team +last_reviewed: 2026-07-26 +--- + +# Traceability Matrix + +## Purpose + +Map Spec 009 requirements, design, tasks, verification, and durable promotion +targets. Reconcile this matrix whenever any linked artifact changes. + +## Task To Context Matrix + +| Task ID | Requirements | Acceptance Criteria | Design Sections | Change Impact | Verification | Durable Targets | Open Decisions | +|---------|--------------|---------------------|-----------------|---------------|--------------|-----------------|----------------| +| T001 | Requirement 2, Requirement 4, Requirement 5, Requirement 6 | Requirement 2 AC4; Requirement 2 AC5; Requirement 2 AC6; Requirement 4 AC1; Requirement 4 AC2; Requirement 4 AC3; Requirement 4 AC4; Requirement 4 AC5; Requirement 4 AC6; Requirement 4 AC7; Requirement 4 AC9; Requirement 4 AC10; Requirement 4 AC11; Requirement 5 AC1; Requirement 5 AC2; Requirement 5 AC5; Requirement 5 AC6; Requirement 5 AC7; Requirement 5 AC8; Requirement 6 AC5 | Decisions D002-D005; Data Models; Interfaces | Protocol, authorization, run visibility | V1, V2 | System requirements, architecture, CLI reference | none | +| T002 | Requirement 4, Requirement 5, Requirement 6 | Requirement 4 AC2; Requirement 4 AC3; Requirement 4 AC5; Requirement 4 AC6; Requirement 4 AC10; Requirement 4 AC11; Requirement 5 AC4; Requirement 5 AC5; Requirement 5 AC6; Requirement 5 AC11; Requirement 6 AC6 | Run store; Atomic transition; Error Handling | Run records and recovery | V1, V3 | System and scheduling architecture | none | +| T003 | Requirement 2, Requirement 4 | Requirement 2 AC2; Requirement 2 AC3; Requirement 2 AC4; Requirement 2 AC5; Requirement 2 AC6; Requirement 4 AC1; Requirement 4 AC4; Requirement 4 AC5; Requirement 4 AC6; Requirement 4 AC7; Requirement 4 AC8; Requirement 4 AC9; Requirement 4 AC10; Requirement 4 AC11 | D001-D004; Authorization; Security | Operator authorization | V2, V4 | Requirements, architecture, installation | none | +| T004 | Requirement 2, Requirement 4, Requirement 5, Requirement 6 | Requirement 2 AC4; Requirement 4 AC8; Requirement 4 AC9; Requirement 4 AC10; Requirement 4 AC11; Requirement 5 AC8; Requirement 6 AC5 | Downstream Task Guidance | All security-sensitive deltas | V1-V4, V11 | none | none | +| T005 | Requirement 1, Requirement 2, Requirement 6 | Requirement 1 AC1; Requirement 1 AC2; Requirement 1 AC3; Requirement 1 AC4; Requirement 2 AC1; Requirement 2 AC2; Requirement 2 AC3; Requirement 2 AC4; Requirement 2 AC5; Requirement 2 AC6; Requirement 6 AC1; Requirement 6 AC2; Requirement 6 AC3 | D004; Launcher; Migration | System launcher/elevation | V5, V9 | Installation, version management | none | +| T006 | Requirement 4 | Requirement 4 AC1; Requirement 4 AC2; Requirement 4 AC3; Requirement 4 AC6; Requirement 4 AC8; Requirement 4 AC9; Requirement 4 AC10; Requirement 4 AC11 | D002, D003, D005; Protected read | Local/system log split | V2, V6 | Requirements, CLI reference, troubleshooting | none | +| T007 | Requirement 3, Requirement 4, Requirement 6 | Requirement 3 AC1; Requirement 3 AC2; Requirement 3 AC3; Requirement 3 AC4; Requirement 3 AC5; Requirement 3 AC6; Requirement 3 AC7; Requirement 3 AC8; Requirement 4 AC1; Requirement 4 AC4; Requirement 4 AC5; Requirement 4 AC6; Requirement 4 AC7; Requirement 4 AC8; Requirement 4 AC9; Requirement 6 AC1; Requirement 6 AC2; Requirement 6 AC3; Requirement 6 AC4; Requirement 6 AC5 | D006; Tray status; Migration | Independent tray | V7, V9 | Architecture, tray setup, installation | none | +| T008 | Requirement 5, Requirement 6 | Requirement 5 AC1; Requirement 5 AC2; Requirement 5 AC3; Requirement 5 AC4; Requirement 5 AC5; Requirement 5 AC6; Requirement 5 AC7; Requirement 5 AC8; Requirement 5 AC9; Requirement 5 AC10; Requirement 5 AC11; Requirement 6 AC1; Requirement 6 AC2; Requirement 6 AC3; Requirement 6 AC6 | D007; Backup-triggered retention | Retention automation | V3, V8 | Scheduling architecture and guide | none | +| T009 | Requirement 1, Requirement 2, Requirement 3, Requirement 6 | Requirement 1 AC1; Requirement 1 AC2; Requirement 1 AC3; Requirement 1 AC4; Requirement 2 AC1; Requirement 2 AC2; Requirement 2 AC3; Requirement 3 AC6; Requirement 3 AC7; Requirement 3 AC8; Requirement 6 AC1; Requirement 6 AC2; Requirement 6 AC3; Requirement 6 AC4; Requirement 6 AC5; Requirement 6 AC6 | Migration; Slice Boundary | Package/install migration | V5, V7, V9 | Installation and version management | none | +| T010 | Requirement 1, Requirement 2, Requirement 3, Requirement 4, Requirement 5, Requirement 6 | SC-001; SC-002; SC-003; SC-004; SC-005; SC-006; SC-007; SC-008; SC-009; SC-010; SC-011 | Operational Considerations | Live operational behavior | V10 | Operator guides and verification | rollout approval | +| T011 | Requirement 1, Requirement 2, Requirement 3, Requirement 4, Requirement 5, Requirement 6 | All accepted criteria promoted after evidence | Related Artifacts | Promotion Targets | V12 | All promotion targets | none | +| T012 | Requirement 1, Requirement 2, Requirement 3, Requirement 4, Requirement 5, Requirement 6 | All accepted criteria reconciled before closure | Validation Strategy | All | V1-V12 | Closure/history records | closure approval | + +## Requirement To Delivery Matrix + +| Requirement | Priority | Acceptance Criteria | Design Sections | Tasks | Verification | Durable Targets | Coverage State | Residual Destination | +|-------------|----------|---------------------|-----------------|-------|--------------|-----------------|----------------|----------------------| +| Requirement 1 | must-have | AC1-AC4 | D004; System launcher; Migration | T005, T009, T010 | V5, V9, V10 | Installation, version management | complete | none | +| Requirement 2 | must-have | AC1-AC6 | D003-D004; Action classifier; Security | T001, T003-T005, T010 | V2, V4, V5, V10 | System requirements/architecture | complete | none | +| Requirement 3 | must-have | AC1-AC8 | D006; Independent tray; Tray status | T007, T009, T010 | V7, V9, V10 | Architecture and tray setup | complete | none | +| Requirement 4 | must-have | AC1-AC11 | D001-D005; Local server; Models; Protected read | T001-T004, T006, T010 | V1-V4, V6, V10 | Requirements, architecture, CLI/troubleshooting | complete | none | +| Requirement 5 | must-have | AC1-AC11 | D007; Run store; Backup-triggered retention | T001-T002, T004, T008, T010 | V1, V3, V8, V10 | Requirements and scheduling docs | complete | none | +| Requirement 6 | must-have | AC1-AC6 | Platform adapters; Migration; Reconciliation | T001-T002, T007-T010 | V1, V3, V7, V9, V10 | Architecture, installation, version management | complete | none | + +## Correctness Property Coverage + +| Property | Requirements | Design Sections | Tasks | Tests Or Verification | Residual Risk | +|----------|--------------|-----------------|-------|-----------------------|---------------| +| CP-001 | R2 | D004; Action classifier | T001, T003, T005 | V2, V5 | Live platform authorization | +| CP-002 | R3 | D006; Independent tray | T007 | V7, V10 | Desktop diversity | +| CP-003 | R4, R5 | Run store and lock | T002, T008 | V3, V8 | Production timing | +| CP-004 | R4, R5 | RunRecord state machine | T001-T002, T006, T008 | V1, V3, V6, V8 | none after evidence | +| CP-005 | R5 | D007; SystemPolicy | T001, T008 | V1, V8 | Operator policy accuracy | +| CP-006 | R2, R4 | Authorization before dispatch | T001, T003, T005-T006 | V2, V4-V6 | none after evidence | +| CP-007 | R4 | D001, D003; Authorization | T003, T006 | V2, V4, V10 | NSS/platform variance | +| CP-008 | R6 | Reconciliation algorithm | T002, T009-T010 | V3, V9-V10 | Crash timing | +| CP-009 | R3, R6 | Platform adapter contracts | T001, T007, T009 | V1, V7, V9 | Windows live follow-up | +| CP-010 | R5 | D007; trigger idempotency | T002, T008 | V3, V8, V10 | none after evidence | +| CP-011 | R4 | D002-D003; response projection | T001, T003, T006 | V1-V2, V4, V6, V10 | Redaction completeness | + +## Design To Implementation Matrix + +| Design Section | Requirements | Tasks | Interfaces Or Files | Verification | Coverage State | Residual Destination | +|----------------|--------------|-------|---------------------|--------------|----------------|----------------------| +| Decisions D001-D005 | R1, R2, R4 | T001, T003, T005-T006 | `system_control`, CLI, install assets | V1-V6 | not-covered | T001 | +| Decision D006 and independent tray | R3, R4 | T007, T009 | monitoring/tray/platform modules | V7, V9-V10 | not-covered | T007 | +| Decision D007 and retention flow | R5 | T002, T008 | retention/scheduling modules | V3, V8, V10 | not-covered | T008 | +| Migration and compatibility | R1-R6 | T005, T007-T010 | install/release/system assets | V9-V10 | not-covered | T009 | +| Durable promotion | R1-R6 | T011-T012 | `docs/` targets | V12 | not-covered | T011 | + +## Open Decision Impact + +| Decision ID | Blocks | Affected Requirements | Affected Tasks | Resolution Needed | +|-------------|--------|-----------------------|----------------|-------------------| +| Live rollout approval | T010 only | R1-R6 | T010 | Explicit approval before host mutation | +| Closure approval | Closure only | R1-R6 | T012 | Review and evidence complete | + +## Maintenance Notes + +- `R1` through `R6` abbreviate Requirement 1 through Requirement 6. +- `V1` through `V12` identify verification gates in `verification.md`. +- `complete` in the requirement-delivery matrix means every accepted criterion + has an explicit design, task, verification, and durable-target mapping. It + does not claim implementation completion. +- Implementation and verification evidence remains pending in `tasks.md` and + `verification.md`; update those states only from executed evidence. + +## Reconciliation + +Reviewed against the 2026-07-26 requirements and design revisions. Every +Requirement 1-6 acceptance criterion has an explicit task mapping, including +Requirement 4 AC10-AC11 and the tightened security constraints. No +implementation-completion claim is made. diff --git a/docs/specs/009-system-cli-tray-retention/verification.md b/docs/specs/009-system-cli-tray-retention/verification.md new file mode 100644 index 0000000..5008b10 --- /dev/null +++ b/docs/specs/009-system-cli-tray-retention/verification.md @@ -0,0 +1,250 @@ +--- +title: System CLI, independent tray, retention, and control verification +doc_type: spec +artifact_type: verification +status: draft +owner: Auriora Team +last_reviewed: 2026-07-26 +--- + +# Verification + +## Scope + +This plan covers all Spec 009 requirements and tasks. It distinguishes local +automated evidence, Linux integration evidence, live host acceptance, expert +review, durable promotion, and closure. + +## Quality Gates + +| Gate | Required? | Status | Evidence | +|------|-----------|--------|----------| +| Requirements acceptance criteria reviewed | yes | pending | Requirements amended through 2026-07-26 | +| Design and traceability approved | yes | passed | Owner approved implementation; lifecycle context reports no Phase 1 gaps | +| Task evidence complete | yes | partial | T001-T004 complete; T005-T012 pending | +| Automated tests pass or alternate verification recorded | yes | partial | Phase 1 focused suite: 98 passed, 88.4% coverage | +| Security and operations expert review complete | yes | partial | T004 checkpoint complete; final T012 review pending | +| Linux Mint live acceptance and rollback rehearsal complete | yes | pending | | +| Durable documentation promoted | yes | pending | | +| Governance or policy conflicts resolved | yes | pending | | +| Spec cleanup decision recorded | yes | pending | | + +## Verification Gates + +| ID | Gate | Covers | Required evidence | +|----|------|--------|-------------------| +| V1 | Protocol/model validation | T001, CP-004-CP-006, CP-009, CP-011 | Focused schema, transition, projection, and property tests | +| V2 | Authorization validation | T001, T003, T006, CP-001, CP-006, CP-007, CP-011 | Authorized/denied/stale-membership/NSS-failure/primary-and-supplementary-group/metadata-leak tests | +| V3 | Run store and lock validation | T002, T008, CP-003, CP-004, CP-008, CP-010 | Concurrency, atomicity, corruption, kill/restart tests | +| V4 | Linux IPC integration | T003-T004 | Real AF_UNIX peer-credential, socket-mode, timeout, request-bound, concurrency-bound, and session-refresh tests | +| V5 | Launcher/elevation validation | T005 | Resolution, routing, denial, recursion, upgrade, rollback tests | +| V6 | CLI visibility validation | T006 | Local/system scope, runs, formatting, compatibility, denial tests | +| V7 | Tray/headless validation | T007 | Import boundary, absence, crash, reconnect, singleton, live session tests | +| V8 | Retention validation | T008 | Fingerprint, approval, three triggers, conflict, no-prune tests | +| V9 | Packaging/portability validation | T009 | Wheel/assets, systemd, permissions, Windows test double, rollback | +| V10 | Live Linux acceptance | T010 | Secret-free command, systemd, backup, restore, retention, tray evidence | +| V11 | Expert review | T004, T012 | `review-timelocker` findings and dispositions | +| V12 | Promotion and closure | T011-T012 | Markdown/link checks, lifecycle gates, closure records | + +## Planned Validation Commands + +Commands are refined through Agent Workbench before execution. + +| Command | Purpose | Result | Evidence | +|---------|---------|--------|----------| +| `python3 -m pytest tests/TimeLocker/system_control -q` | Protocol, auth, storage, IPC, locks | pending | V1-V4 | +| `python3 -m pytest tests/TimeLocker/cli/test_monitoring_commands.py -q` | CLI local/system log and run behavior | pending | V6 | +| `python3 -m pytest tests/TimeLocker/monitoring -q` | Notification/tray/headless regression | pending | V7 | +| `python3 -m pytest tests/TimeLocker/scheduling -q` | Retention and scheduler regression where present | pending | V8 | +| `python3 -m pytest tests/TimeLocker/platform -q` | Platform adapters and portability | pending | V4, V7, V9 | +| `python3 -m pytest -m "not performance and not stress and not minio"` | Full configured non-live regression suite | pending | V1-V9 | +| `systemd-analyze verify ` | Linux unit and socket validation | pending | V4, V9 | +| `python3 scripts/link_checker.py` | Durable/spec link validation | pending | V12 | +| `git diff --check` | Patch integrity | pending | Every implementation slice | + +## Requirement Coverage + +| Requirement | Acceptance criteria covered | Evidence | Residual risk | +|-------------|-----------------------------|----------|---------------| +| Requirement 1 | AC1-AC4 | V5, V9, V10 pending | Live launcher/rollback | +| Requirement 2 | AC1-AC6 | V2, V4-V5, V10 pending | Platform authorization UX | +| Requirement 3 | AC1-AC8 | V7, V9-V10 pending | Desktop diversity | +| Requirement 4 | AC1-AC11 | V1-V4, V6, V10 pending | Redaction and NSS variance | +| Requirement 5 | AC1-AC11 | V1, V3, V8, V10 pending | Live repository timing | +| Requirement 6 | AC1-AC6 | V3, V5, V7, V9-V10 pending | Cross-platform rollout | + +## Correctness Property Coverage + +| Property | Covered by | Evidence | Residual risk | +|----------|------------|----------|---------------| +| CP-001 | V2, V5 | pending | | +| CP-002 | V7, V10 | pending | | +| CP-003 | V3, V8, V10 | pending | | +| CP-004 | V1, V3, V6, V8 | pending | | +| CP-005 | V1, V8, V10 | pending | | +| CP-006 | V1-V2, V4-V6 | pending | | +| CP-007 | V2, V4, V10 | pending | | +| CP-008 | V3, V9-V10 | pending | | +| CP-009 | V1, V7, V9 | pending | Live Windows remains follow-up | +| CP-010 | V3, V8, V10 | pending | | +| CP-011 | V1-V2, V4, V6, V10 | pending | | + +## Scope Reconciliation Before Closure + +| Broad requirement, design target, or review finding | Implemented in this spec | Coverage state | Deferred or rejected work | Destination | Blocks closure? | Evidence | +|-----------------------------------------------------|--------------------------|----------------|---------------------------|-------------|-----------------|----------| +| Linux system command/control plane | none | not-covered | Implementation pending | T001-T006, T009-T010 | yes | pending | +| Group-authorized system records | none | not-covered | Implementation pending | T001-T004, T006 | yes | pending | +| Independent tray | none | not-covered | Implementation pending | T007, T009-T010 | yes | pending | +| Retention automation | none | not-covered | Implementation pending | T008-T010 | yes | pending | +| Windows shared architecture | none | not-covered | Live Windows adapter/acceptance | T001, T009 then roadmap | yes for contracts; no for live Windows | pending | +| Raw journald delegation | rejected | out-of-scope | Rejected because it exposes unrelated/protected records | none | no | Design D002 | +| User-scoped backup partitions | none | out-of-scope | Separate authorization model | GitHub issue #70 | no | Requirements non-goal | + +## Agent Readiness Evidence + +| Field | Evidence | Residual risk | +|-------|----------|---------------| +| Scope and out-of-scope files | Design slice table and change impact | Affected-file list will sharpen per task | +| Must-read and optional context | `canonical-context.md`, full Spec 009 package, and linked durable docs | Refresh current-state evidence before each implementation phase | +| Permissions and approval points | T010 requires explicit host-mutation approval | No live mutation before approval | +| Validation commands and expected signals | V1-V12 and planned commands | Commands must be refreshed after files exist | +| Review needs | Security/architecture at T004; full expert panel at T012 | Findings may change design/tasks | +| Durable-doc or closure impact | `change-impact.md` promotion table | Promotion remains pending | +| Optional repo-evidence provider caveats | Agent Workbench evidence is routing/planning, not executed proof | Direct reads and commands required | + +## Task Evidence + +| Task ID | Status | Evidence | Notes | +|---------|--------|----------|-------| +| T001 | complete | 70 focused tests passed; 92.7% branch-aware coverage; compile and patch checks passed | Shared strict contracts only; no transport, store, CLI, or live-system behavior | +| T002 | complete | Atomic storage, bounded diagnostics, `flock` mutation leases, and startup reconciliation; focused T002 suite passed | No live state directory or production repository used | +| T003 | complete | Linux peer credentials, current NSS membership, strict dispatcher/audit, policy loader, and staged unit assets; focused T003 suite passed | No group, socket, service, or policy installed | +| T004 | complete | Phase 1 suite passed 98 tests at 88.4% coverage; Ruff, compileall, wheel asset, patch, lifecycle, and expert-panel checks passed | Real systemd/AF_UNIX host acceptance remains V4/T010 | +| T005-T012 | pending | No implementation evidence | Later implementation phases | + +## Evidence Log + +| Date | Evidence | Result | Notes | +|------|----------|--------|-------| +| 2026-07-26 | Live CLI/user-log and systemd-journal diagnosis | confirmed gap | User log scope differs from root system backup journal | +| 2026-07-26 | Repository context and direct source reads | confirmed design seams | No OS peer/group auth or local IPC exists; tray is constructed in notification services | +| 2026-07-26 | Spec artifacts created | pending validation | Design/tasks do not constitute implementation | +| 2026-07-26 | Canonical context reconciliation | current/future authority split recorded | Durable docs remain current until verified promotion | +| 2026-07-26 | Focused `review-timelocker` design/security review | blocking design findings addressed | Explicit AC mappings, fail-closed NSS, root-only audit, safe summaries, storage hardening, and transport bounds added | +| 2026-07-26 | `python3 -m pytest tests/TimeLocker/system_control ... --cov-fail-under=90` | 70 passed; 92.7% coverage | T001 strict models, envelopes, projection, transition, portability, and negative security cases | +| 2026-07-26 | `python3 -m compileall -q src/TimeLocker/system_control tests/TimeLocker/system_control` and `git diff --check` | passed | T001 syntax and patch integrity | +| 2026-07-26 | Focused `review-timelocker` T001 implementation review | no actionable findings after remediation | Response summaries were made code-owned; response envelope and transition model omissions were corrected before completion | +| 2026-07-26 | `python3 -m pytest tests/TimeLocker/system_control ... --cov-fail-under=85` | 98 passed; 88.4% coverage | T001-T003 contracts, storage, locking, recovery, authorization, redaction, policy, and Linux adapter tests | +| 2026-07-26 | `PYENV_VERSION=3.12.4 ruff check ...` and `ruff format --check ...` | passed | Phase 1 source and focused tests | +| 2026-07-26 | `python3 -m compileall -q ...` and `git diff --check` | passed | Phase 1 syntax and patch integrity | +| 2026-07-26 | `PYENV_VERSION=3.12.4 python -m build --wheel --no-isolation ...` plus wheel inventory | passed; 3/3 assets present | Policy, socket unit, and service unit are packaged; isolated build could not resolve build dependencies because network access was unavailable | +| 2026-07-26 | Agent Workbench verification planning and diagnostics | planning returned; diagnostics unavailable | No Python diagnostics provider was configured, so direct review and executed checks remain the proof | +| 2026-07-26 | Rules consulted and applied | recorded | Coding Standards (100), General Preferences (50), Operational Best Practices (40), Planning Protocol (30), Testing Conventions (25), Documentation Conventions, and Git Conventions; no overrides | + +## T004 Review Finding Dispositions + +| Finding | Severity / confidence | Roles | Disposition | Validation | +|---------|-----------------------|-------|-------------|------------| +| TLR-001: reconciling an older abandoned run could fail when a newer run held the same repository lock | medium / high | Security and Privacy; Reliability and Testing; Operations and Portability | fixed: the older run is interrupted while the newer live lease is preserved; stale metadata clearing tolerates the live owner | `test_newer_live_lease_does_not_block_old_run_reconciliation` | +| TLR-002: startup reconciliation inspected at most 1,000 runs despite the requirement to reconcile every non-terminal run | medium / high | Project Steward; Reliability and Testing | fixed: internal reconciliation now scans the complete run inventory while public queries remain bounded | focused storage suite and direct source review | +| TLR-003: a client could hold the single-threaded socket server indefinitely with an incomplete request | medium / high | Security and Privacy; Reliability and Testing; Operations and Portability | fixed: each connection receives a bounded timeout and timeout produces an empty invalid frame without dispatch | Linux transport timeout assertion and dispatcher malformed-request tests | +| TLR-004: unconditional POSIX locking imports would break the shared package import on Windows | medium / high | Python CLI Architecture; Operations and Portability | fixed: POSIX locking is capability-checked at use time; the shared contract remains importable and unsupported locking fails explicitly | Ruff/compile checks and platform adapter contract tests | +| TLR-005: the dispatcher allowed an implicit no-op audit sink | medium / high | Security and Privacy; Project Steward | fixed: an audit sink is mandatory and every event carries caller identity, action, decision, response status, and stable result code without parameters | dispatcher authorization, denial, failure, and audit assertions | + +No actionable Phase 1 findings remain after these dispositions. The review was +bounded to Spec 009 Phase 1 source, focused tests, packaged assets, and lifecycle +artifacts. It did not install or execute the staged service, inspect real NSS +membership, or claim live Windows support. + +## Manual Or External Verification + +Live T010 evidence must record the reviewer, timestamp, exact non-secret command, +result, and rollback state. It must not copy environment files, credentials, +repository URIs, protected source paths, or raw journal payloads into this +package. + +## Residual Risks + +- Group/NSS behavior differs across Linux environments; verify current + membership and stale-process removal behavior live. +- Raw diagnostic messages can leak paths or secrets; the backend must emit + allowlisted structured records rather than redact arbitrary text after the + fact. +- Operator-visible `safe_summary` fields require code-keyed templates and + canary tests proving exception strings, subprocess output, peer identity, + repository URIs, and protected paths cannot enter responses. +- Crash timing remains sensitive despite passing atomicity and process-exit + tests; live kill/restart acceptance remains V4/T010. +- Changing launcher and `/opt` permissions can expose protected assets if code + and state are not separated. +- Windows live support is not proven by a test double and must not be claimed. + +## Durable Promotion And Cleanup + +| Spec content | Durable destination or deferral | Status | Evidence | +|--------------|---------------------------------|--------|----------| +| System requirements and authorization invariants | `docs/1-requirements/system-operations.md` | pending | T011 | +| Launcher/backend/tray/run-store architecture | `docs/2-architecture/system-architecture.md` | pending | T011 | +| Scheduling/retention behavior | `docs/2-architecture/scheduling-system.md` | pending | T011 | +| Focused service ownership | `docs/3-implementation/service-layer-integration.md` | pending | T011 | +| Installation/group/launcher guidance | `docs/guides/user/installation.md` | pending | T011 | +| Scheduling rollout/rollback | `docs/guides/developer/scheduling-guide.md` | pending | T011 | +| Independent tray setup | `docs/SYSTEM-TRAY-SETUP.md` | pending | T011 | +| CLI commands and troubleshooting | CLI reference and backup troubleshooting guide | pending | T011 | +| User partitions | GitHub issue #70 | routed | Existing backlog authority | + +### Spec Cleanup Decision + +- **Cleanup action:** keep active until implementation, promotion, and closure +- **Reason:** no implementation evidence exists +- **Final spec commit:** pending +- **Closure log path:** `docs/history/spec-closure-log.md` +- **Closure log entry updated:** no +- **Closure cleanup commit:** pending +- **Active indexes updated:** no +- **Durable docs linked back to evidence where useful:** no +- **Residual spec-only content:** all design and task content remains temporary + +## Ship Or Closure Risk + +- **Risk level:** high +- **Breaking change:** no intended public-command break +- **Blast radius checked:** partially +- **Rollback path:** designed; not yet implemented or rehearsed +- **Requires human review:** yes +- **Release notes needed:** yes +- **Follow-up issue or spec needed:** Windows live adapter/acceptance + +### Risk Rationale + +This change introduces a privileged process boundary, OS identity and group +authorization, machine-level installation assets, repository mutation +coordination, and desktop IPC. Incorrect implementation could disclose +protected metadata, widen privilege, interrupt backups, or delete snapshots. + +## Readiness Decision + +- **Ready for promotion:** no +- **Ready for release:** no +- **Ready for closure:** no +- **Ready for implementation:** yes for the next dependency-ordered task after + the Phase 1 lifecycle audit; later live-host mutations still require T010 + approval + +## Related Artifacts + +- Requirements: `requirements.md` +- Change Impact: `change-impact.md` +- Design: `design.md` +- Tasks: `tasks.md` +- Traceability: `traceability.md` +- Canonical context: `canonical-context.md` + +## Reconciliation + +Reviewed against the 2026-07-26 requirements and design revisions. T001-T004 +now provide executed Phase 1 evidence for V1-V3 and repository-local portions +of V4/V11. Real socket activation, installed ownership/modes, live NSS behavior, +and host restart remain pending under T010; later tasks and durable promotion +remain incomplete. diff --git a/docs/specs/README.md b/docs/specs/README.md index 48480ad..a594d4d 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -3,7 +3,7 @@ title: "Active Specification Packages" doc_type: reference status: active owner: "Auriora Team" -last_reviewed: 2026-07-20 +last_reviewed: 2026-07-26 --- # Active Specification Packages @@ -16,17 +16,20 @@ accepted content has been promoted and the package is closed. ## Current Packages - [`009-system-cli-tray-retention`](./009-system-cli-tray-retention/requirements.md) - — requirements-stage package for a stable system command, contextual - elevation, an independent tray/backend boundary, durable run visibility, and - automatic retention. + — active implementation package; Phase 1 contracts, storage, Linux + authorization boundary, and security checkpoint are complete, with the + system launcher and action classifier selected next. ## Active-Package Sequencing Spec 007 is closed; its release-readiness evidence and recovery commits are -recorded in `docs/history/`. Spec 009 is the only active package and remains at -the requirements stage. Its requirements require approval before design and -implementation, and its work does not authorize a release. Closed packages -remain recorded in `docs/history/` rather than kept in this active path. +recorded in `docs/history/`. Spec 009 is the only active package. Its design, +tasks, traceability, canonical context, and verification plan were approved, +and Phase 1 is complete. Implementation continues in dependency order from +T005. Repository implementation approval does not authorize live-system +mutation, rollout, or release; T010 retains the explicit host-mutation gate. +Closed packages remain recorded in `docs/history/` rather than kept in this +active path. ## When a Spec Is Needed @@ -65,6 +68,8 @@ corrections may proceed directly when their scope and validation are clear. readiness. - `traceability.md` maps requirements, design, tasks, verification, and durable destinations for larger packages. +- `canonical-context.md` distinguishes current durable authority from + spec-local future behavior when both must be consulted during implementation. Tasks use `[ ]` pending, `[~]` in progress, `[/]` partial, `[>]` routed, `[-]` deferred/no-op, `[?]` decision needed, `[!]` attention needed, and `[x]` diff --git a/pyproject.toml b/pyproject.toml index 5b17df5..139f9f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,9 @@ TimeLocker = [ "cli_modules/validation/*.md", "policy/*.md", "services/plugins/*.md", + "system_control/assets/*.json", + "system_control/assets/*.service", + "system_control/assets/*.socket", ] [tool.pytest.ini_options] diff --git a/src/TimeLocker/system_control/__init__.py b/src/TimeLocker/system_control/__init__.py new file mode 100644 index 0000000..231b694 --- /dev/null +++ b/src/TimeLocker/system_control/__init__.py @@ -0,0 +1,95 @@ +"""Platform-neutral contracts for privileged TimeLocker system operations.""" + +from .interfaces import ( + ControlRequestHandler, + GroupMembershipResolver, + LocalControlTransport, + PeerIdentity, + PeerIdentityProvider, + SystemControlClient, +) +from .dispatcher import AuditEvent, AuditSink, LocalControlDispatcher +from .models import ( + ActionReceipt, + BackupActionRequest, + DiagnosticQuery, + DiagnosticRecord, + DiagnosticView, + RetentionActionRequest, + RetentionPolicy, + RunQuery, + RunRecord, + RunRecordView, + RunTransition, + SystemPolicy, +) +from .protocol import RequestEnvelope, ResponseEnvelope, project_response +from .storage import ( + AtomicRecordStore, + InvalidTransitionError, + MutationConflictError, + RecordCorruptionError, + RecordNotFoundError, + RecordStoreError, + RepositoryMutationLease, + RepositoryMutationLock, + reconcile_abandoned_runs, +) +from .types import ( + DiagnosticCode, + DiagnosticComponent, + DiagnosticLevel, + OperationTrigger, + OperationType, + ProtocolErrorCode, + ResponseStatus, + ResultCode, + RunState, + SystemAction, +) + +__all__ = [ + "ActionReceipt", + "AuditEvent", + "AuditSink", + "AtomicRecordStore", + "BackupActionRequest", + "ControlRequestHandler", + "DiagnosticCode", + "DiagnosticComponent", + "DiagnosticLevel", + "DiagnosticQuery", + "DiagnosticRecord", + "DiagnosticView", + "GroupMembershipResolver", + "InvalidTransitionError", + "LocalControlTransport", + "LocalControlDispatcher", + "MutationConflictError", + "OperationTrigger", + "OperationType", + "PeerIdentity", + "PeerIdentityProvider", + "ProtocolErrorCode", + "RecordCorruptionError", + "RecordNotFoundError", + "RecordStoreError", + "RequestEnvelope", + "ResponseEnvelope", + "ResponseStatus", + "ResultCode", + "RetentionActionRequest", + "RetentionPolicy", + "RepositoryMutationLease", + "RepositoryMutationLock", + "RunQuery", + "RunRecord", + "RunRecordView", + "RunTransition", + "RunState", + "SystemAction", + "SystemControlClient", + "SystemPolicy", + "project_response", + "reconcile_abandoned_runs", +] diff --git a/src/TimeLocker/system_control/assets/system-control-policy.json b/src/TimeLocker/system_control/assets/system-control-policy.json new file mode 100644 index 0000000..d728e54 --- /dev/null +++ b/src/TimeLocker/system_control/assets/system-control-policy.json @@ -0,0 +1,19 @@ +{ + "operator_group": "timelocker-operators", + "transport_identifier": "/run/timelocker/control.sock", + "protocol_version": 1, + "max_request_bytes": 65536, + "max_response_records": 100, + "retention": { + "keep_daily": 5, + "keep_weekly": 4, + "keep_monthly": 12, + "keep_yearly": 3, + "group_by": [ + "host", + "paths" + ], + "prune": false, + "approved_fingerprint": null + } +} diff --git a/src/TimeLocker/system_control/assets/timelocker-control.service b/src/TimeLocker/system_control/assets/timelocker-control.service new file mode 100644 index 0000000..b28e75c --- /dev/null +++ b/src/TimeLocker/system_control/assets/timelocker-control.service @@ -0,0 +1,30 @@ +[Unit] +Description=TimeLocker privileged local system-control backend +Requires=timelocker-control.socket +After=local-fs.target + +[Service] +Type=simple +User=root +Group=root +UMask=0077 +RuntimeDirectory=timelocker +RuntimeDirectoryMode=0750 +StateDirectory=timelocker +StateDirectoryMode=0750 +ExecStart=/opt/timelocker/current/venv/bin/timelocker-system-control --systemd-socket +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes +RestrictAddressFamilies=AF_UNIX +ReadWritePaths=/run/timelocker /var/lib/timelocker + +[Install] +WantedBy=multi-user.target diff --git a/src/TimeLocker/system_control/assets/timelocker-control.socket b/src/TimeLocker/system_control/assets/timelocker-control.socket new file mode 100644 index 0000000..4d674a3 --- /dev/null +++ b/src/TimeLocker/system_control/assets/timelocker-control.socket @@ -0,0 +1,13 @@ +[Unit] +Description=TimeLocker local system-control socket + +[Socket] +ListenStream=/run/timelocker/control.sock +SocketUser=root +SocketGroup=timelocker-operators +SocketMode=0660 +RemoveOnStop=yes +Service=timelocker-control.service + +[Install] +WantedBy=sockets.target diff --git a/src/TimeLocker/system_control/dispatcher.py b/src/TimeLocker/system_control/dispatcher.py new file mode 100644 index 0000000..61000c2 --- /dev/null +++ b/src/TimeLocker/system_control/dispatcher.py @@ -0,0 +1,215 @@ +"""Bounded authorization and dispatch for the local system-control protocol.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +import json +from types import MappingProxyType +from typing import Any, Protocol +from uuid import UUID + +from .interfaces import GroupMembershipResolver, PeerIdentity +from .models import SystemPolicy +from .protocol import RequestEnvelope, ResponseEnvelope +from .types import ProtocolErrorCode, ResponseStatus, SystemAction + + +_UNKNOWN_REQUEST_ID = UUID(int=0) +_OPERATOR_ACTIONS = frozenset(SystemAction) + + +@dataclass(frozen=True, slots=True) +class AuditEvent: + """Secret-free decision record retained inside the privileged boundary.""" + + platform_id: str + action: SystemAction | None + decision: str + status: ResponseStatus + result_code: ProtocolErrorCode | None + + +class AuditSink(Protocol): + """Consume secret-free authorization and dispatch decisions.""" + + def record(self, event: AuditEvent) -> None: + """Persist or emit one bounded audit event.""" + + +class LocalControlDispatcher: + """Authenticate every request and dispatch only strict allowlisted actions.""" + + def __init__( + self, + *, + policy: SystemPolicy, + membership_resolver: GroupMembershipResolver, + handlers: Mapping[SystemAction, Callable[[RequestEnvelope], object]], + audit_sink: AuditSink, + ) -> None: + if not isinstance(policy, SystemPolicy): + raise TypeError("policy must be a SystemPolicy") + normalized_handlers: dict[ + SystemAction, Callable[[RequestEnvelope], object] + ] = {} + for action, handler in handlers.items(): + if not isinstance(action, SystemAction): + raise TypeError("handler keys must be SystemAction values") + if not callable(handler): + raise TypeError("handlers must be callable") + normalized_handlers[action] = handler + self.policy = policy + self.membership_resolver = membership_resolver + self.handlers = MappingProxyType(normalized_handlers) + if not hasattr(audit_sink, "record"): + raise TypeError("audit_sink must provide record(event)") + self.audit_sink = audit_sink + + def handle(self, request: bytes, identity: PeerIdentity) -> bytes: + """Return one JSON response without propagating protected details.""" + if not isinstance(identity, PeerIdentity): + raise TypeError("identity must be a PeerIdentity") + request_id = _extract_request_id(request) + if ( + not isinstance(request, bytes) + or len(request) > self.policy.max_request_bytes + ): + return self._encoded_error( + request_id, + identity, + None, + ResponseStatus.INVALID, + ProtocolErrorCode.INVALID_REQUEST, + ) + try: + decoded = json.loads(request.decode("utf-8")) + envelope = RequestEnvelope.from_mapping(decoded) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError): + code = _parse_error_code(request) + return self._encoded_error( + request_id, + identity, + None, + ResponseStatus.INVALID, + code, + ) + if not self._authorized(identity, envelope.action): + return self._encoded_error( + envelope.request_id, + identity, + envelope.action, + ResponseStatus.DENIED, + ProtocolErrorCode.SYSTEM_ACCESS_DENIED, + ) + handler = self.handlers.get(envelope.action) + if handler is None: + return self._encoded_error( + envelope.request_id, + identity, + envelope.action, + ResponseStatus.UNAVAILABLE, + ProtocolErrorCode.SYSTEM_BACKEND_UNAVAILABLE, + ) + try: + response = ResponseEnvelope.success( + envelope.request_id, + envelope.action, + handler(envelope), + ) + except Exception: + response = ResponseEnvelope.error( + envelope.request_id, + ResponseStatus.FAILED, + ProtocolErrorCode.OPERATION_FAILED, + ) + self._audit( + identity, + envelope.action, + "failed", + ResponseStatus.FAILED, + ProtocolErrorCode.OPERATION_FAILED, + ) + else: + self._audit( + identity, + envelope.action, + "allowed", + ResponseStatus.OK, + None, + ) + return _encode_response(response) + + def _authorized(self, identity: PeerIdentity, action: SystemAction) -> bool: + if action not in _OPERATOR_ACTIONS: + return False + try: + return bool( + self.membership_resolver.is_current_member( + identity, + self.policy.operator_group, + ) + ) + except (KeyError, OSError, RuntimeError, ValueError): + return False + + def _encoded_error( + self, + request_id: UUID, + identity: PeerIdentity, + action: SystemAction | None, + status: ResponseStatus, + error_code: ProtocolErrorCode, + ) -> bytes: + self._audit(identity, action, "denied", status, error_code) + return _encode_response(ResponseEnvelope.error(request_id, status, error_code)) + + def _audit( + self, + identity: PeerIdentity, + action: SystemAction | None, + decision: str, + status: ResponseStatus, + result_code: ProtocolErrorCode | None, + ) -> None: + self.audit_sink.record( + AuditEvent( + platform_id=identity.platform_id, + action=action, + decision=decision, + status=status, + result_code=result_code, + ) + ) + + +def _encode_response(response: ResponseEnvelope) -> bytes: + return ( + json.dumps(response.to_wire(), separators=(",", ":"), sort_keys=True) + "\n" + ).encode("utf-8") + + +def _extract_request_id(request: object) -> UUID: + if not isinstance(request, bytes) or len(request) > 1_048_576: + return _UNKNOWN_REQUEST_ID + try: + value = json.loads(request.decode("utf-8")) + if not isinstance(value, Mapping): + return _UNKNOWN_REQUEST_ID + request_id = value.get("request_id") + if not isinstance(request_id, str): + return _UNKNOWN_REQUEST_ID + parsed = UUID(request_id) + return parsed if str(parsed) == request_id else _UNKNOWN_REQUEST_ID + except (UnicodeDecodeError, json.JSONDecodeError, ValueError): + return _UNKNOWN_REQUEST_ID + + +def _parse_error_code(request: bytes) -> ProtocolErrorCode: + try: + value: Any = json.loads(request.decode("utf-8")) + if isinstance(value, Mapping) and value.get("protocol_version") != 1: + return ProtocolErrorCode.CONTRACT_VERSION_UNSUPPORTED + except (UnicodeDecodeError, json.JSONDecodeError): + pass + return ProtocolErrorCode.INVALID_REQUEST diff --git a/src/TimeLocker/system_control/interfaces.py b/src/TimeLocker/system_control/interfaces.py new file mode 100644 index 0000000..7e5512e --- /dev/null +++ b/src/TimeLocker/system_control/interfaces.py @@ -0,0 +1,94 @@ +"""Platform and client interfaces for the TimeLocker system-control boundary.""" + +from dataclasses import dataclass +from typing import Protocol +from uuid import UUID + +from .models import ( + ActionReceipt, + BackupActionRequest, + DiagnosticQuery, + DiagnosticView, + RetentionActionRequest, + RunQuery, + RunRecordView, +) +from .validation import require_int, require_safe_identifier + + +@dataclass(frozen=True, slots=True) +class PeerIdentity: + """Identity derived from the operating-system transport.""" + + platform_id: str + process_id: int | None = None + + def __post_init__(self) -> None: + """Reject payload-like or unbounded identity values.""" + object.__setattr__( + self, + "platform_id", + require_safe_identifier( + self.platform_id, + field="platform_id", + maximum=128, + ), + ) + if self.process_id is not None: + object.__setattr__( + self, + "process_id", + require_int( + self.process_id, + field="process_id", + minimum=1, + maximum=(2**31) - 1, + ), + ) + + +class PeerIdentityProvider(Protocol): + """Derive peer identity from a transport, never from request payload.""" + + def peer_identity(self, connection: object) -> PeerIdentity: + """Return the operating-system identity for a connected peer.""" + + +class GroupMembershipResolver(Protocol): + """Resolve current platform group membership for each protected request.""" + + def is_current_member(self, identity: PeerIdentity, group_name: str) -> bool: + """Return whether the peer is currently a member of the named group.""" + + +class ControlRequestHandler(Protocol): + """Handle one transport-decoded system-control request.""" + + def handle(self, request: bytes, identity: PeerIdentity) -> bytes: + """Return one bounded encoded response.""" + + +class LocalControlTransport(Protocol): + """Platform adapter for a local-only authenticated transport.""" + + def serve(self, handler: ControlRequestHandler) -> None: + """Serve requests until the transport is stopped.""" + + +class SystemControlClient(Protocol): + """Client contract shared by the CLI, tray, and platform adapters.""" + + def list_runs(self, query: RunQuery) -> list[RunRecordView]: + """Return authorized system run summaries.""" + + def get_run(self, run_id: UUID) -> RunRecordView: + """Return one authorized system run.""" + + def list_diagnostics(self, query: DiagnosticQuery) -> list[DiagnosticView]: + """Return authorized structured system diagnostics.""" + + def request_backup(self, request: BackupActionRequest) -> ActionReceipt: + """Request the configured system backup.""" + + def request_retention(self, request: RetentionActionRequest) -> ActionReceipt: + """Request an approved retention operation.""" diff --git a/src/TimeLocker/system_control/linux_adapter.py b/src/TimeLocker/system_control/linux_adapter.py new file mode 100644 index 0000000..d61c64c --- /dev/null +++ b/src/TimeLocker/system_control/linux_adapter.py @@ -0,0 +1,164 @@ +"""Linux peer identity, NSS authorization, and Unix-socket transport adapters.""" + +from __future__ import annotations + +import grp +import pwd +import socket +import struct +from threading import Event + +from .interfaces import ControlRequestHandler, PeerIdentity +from .validation import require_group_name + + +class LinuxPeerIdentityProvider: + """Derive PID and UID from kernel-owned Unix-socket peer credentials.""" + + _CREDENTIAL_FORMAT = "3i" + + def peer_identity(self, connection: object) -> PeerIdentity: + """Read Linux ``SO_PEERCRED``; never consult request content.""" + if not isinstance(connection, socket.socket): + raise TypeError("connection must be a socket") + credentials = connection.getsockopt( + socket.SOL_SOCKET, + socket.SO_PEERCRED, + struct.calcsize(self._CREDENTIAL_FORMAT), + ) + process_id, user_id, _group_id = struct.unpack( + self._CREDENTIAL_FORMAT, + credentials, + ) + if user_id < 0: + raise OSError("peer user identity is unavailable") + return PeerIdentity( + platform_id=f"linux-uid:{user_id}", + process_id=process_id, + ) + + +class LinuxNssGroupMembershipResolver: + """Resolve primary and supplementary membership from current NSS state.""" + + _PREFIX = "linux-uid:" + + def is_current_member(self, identity: PeerIdentity, group_name: str) -> bool: + """Re-read account and group databases for every protected request.""" + if not isinstance(identity, PeerIdentity): + raise TypeError("identity must be a PeerIdentity") + group_name = require_group_name(group_name) + if not identity.platform_id.startswith(self._PREFIX): + return False + raw_user_id = identity.platform_id.removeprefix(self._PREFIX) + if not raw_user_id.isascii() or not raw_user_id.isdecimal(): + return False + try: + account = pwd.getpwuid(int(raw_user_id)) + operator_group = grp.getgrnam(group_name) + except KeyError: + return False + return ( + account.pw_gid == operator_group.gr_gid + or account.pw_name in operator_group.gr_mem + ) + + +class LinuxUnixSocketTransport: + """Serve one bounded request per local Unix-socket connection.""" + + def __init__( + self, + listener: socket.socket, + *, + max_request_bytes: int, + request_timeout_seconds: float = 5.0, + stop_event: Event | None = None, + ) -> None: + if not isinstance(listener, socket.socket): + raise TypeError("listener must be a socket") + if listener.family != socket.AF_UNIX: + raise ValueError("listener must be an AF_UNIX socket") + if ( + type(max_request_bytes) is not int + or not 1_024 <= max_request_bytes <= 1_048_576 + ): + raise ValueError("max_request_bytes is outside the supported bound") + if ( + isinstance(request_timeout_seconds, bool) + or not isinstance(request_timeout_seconds, (int, float)) + or not 0.1 <= request_timeout_seconds <= 60.0 + ): + raise ValueError("request_timeout_seconds is outside the supported bound") + self.listener = listener + self.max_request_bytes = max_request_bytes + self.request_timeout_seconds = float(request_timeout_seconds) + self.stop_event = stop_event or Event() + self.identity_provider = LinuxPeerIdentityProvider() + + @classmethod + def from_systemd( + cls, + *, + max_request_bytes: int, + descriptor: int = 3, + request_timeout_seconds: float = 5.0, + stop_event: Event | None = None, + ) -> "LinuxUnixSocketTransport": + """Adopt a systemd-activated listening socket without rebinding paths.""" + if type(descriptor) is not int or descriptor < 3: + raise ValueError("descriptor must be a systemd-passed descriptor") + listener = socket.fromfd(descriptor, socket.AF_UNIX, socket.SOCK_STREAM) + if listener.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN) != 1: + listener.close() + raise OSError("systemd descriptor is not a listening socket") + return cls( + listener, + max_request_bytes=max_request_bytes, + request_timeout_seconds=request_timeout_seconds, + stop_event=stop_event, + ) + + def serve(self, handler: ControlRequestHandler) -> None: + """Serve until stopped, isolating malformed clients to one connection.""" + while not self.stop_event.is_set(): + connection, _address = self.listener.accept() + with connection: + try: + self.serve_connection(connection, handler) + except OSError: + continue + + def serve_connection( + self, + connection: socket.socket, + handler: ControlRequestHandler, + ) -> None: + """Derive the peer, read one bounded frame, and return one response.""" + identity = self.identity_provider.peer_identity(connection) + if hasattr(connection, "settimeout"): + connection.settimeout(self.request_timeout_seconds) + request = _receive_frame(connection, self.max_request_bytes) + response = handler.handle(request, identity) + connection.sendall(response) + + +def _receive_frame(connection: socket.socket, maximum: int) -> bytes: + chunks: list[bytes] = [] + size = 0 + while size <= maximum: + try: + chunk = connection.recv(min(65_536, maximum + 1 - size)) + except TimeoutError: + return b"" + if not chunk: + break + chunks.append(chunk) + size += len(chunk) + if b"\n" in chunk: + break + payload = b"".join(chunks) + newline = payload.find(b"\n") + if newline >= 0: + payload = payload[:newline] + return payload diff --git a/src/TimeLocker/system_control/models.py b/src/TimeLocker/system_control/models.py new file mode 100644 index 0000000..aa1dc98 --- /dev/null +++ b/src/TimeLocker/system_control/models.py @@ -0,0 +1,801 @@ +"""Strict platform-neutral models for TimeLocker system operations.""" + +from dataclasses import dataclass, field +from datetime import datetime +from types import MappingProxyType +from typing import Any, ClassVar, Mapping +from uuid import UUID + +from .types import ( + DiagnosticCode, + DiagnosticComponent, + DiagnosticLevel, + OperationTrigger, + OperationType, + ResultCode, + RunState, +) +from .validation import ( + MAX_COUNTER_VALUE, + freeze_counters, + require_bool, + require_enum, + require_exact_mapping, + require_fingerprint, + require_group_name, + require_int, + require_optional_utc_datetime, + require_optional_wire_utc_datetime, + require_optional_uuid, + require_safe_identifier, + require_utc_datetime, + require_uuid, + require_wire_utc_datetime, +) + + +PROTOCOL_VERSION = 1 +DEFAULT_MAX_REQUEST_BYTES = 65_536 +DEFAULT_MAX_RESPONSE_RECORDS = 100 + +RESULT_SUMMARIES: Mapping[ResultCode, str] = MappingProxyType( + { + ResultCode.OPERATION_QUEUED: "Operation queued.", + ResultCode.OPERATION_RUNNING: "Operation is running.", + ResultCode.BACKUP_SUCCEEDED: "Backup completed successfully.", + ResultCode.RETENTION_SUCCEEDED: "Retention completed successfully.", + ResultCode.OPERATION_FAILED: "Operation failed.", + ResultCode.OPERATION_CONFLICT: "Operation skipped because another repository operation is active.", + ResultCode.OPERATION_SKIPPED: "Operation was skipped.", + ResultCode.OPERATION_INTERRUPTED: "Operation was interrupted.", + } +) + +DIAGNOSTIC_SUMMARIES: Mapping[DiagnosticCode, str] = MappingProxyType( + { + DiagnosticCode.BACKEND_STARTED: "System backend started.", + DiagnosticCode.BACKEND_UNAVAILABLE: "System backend is unavailable.", + DiagnosticCode.ACCESS_DENIED: "System access denied.", + DiagnosticCode.INVALID_REQUEST: "System request is invalid.", + DiagnosticCode.BACKUP_STARTED: "Backup started.", + DiagnosticCode.BACKUP_SUCCEEDED: "Backup completed successfully.", + DiagnosticCode.RETENTION_STARTED: "Retention started.", + DiagnosticCode.RETENTION_SUCCEEDED: "Retention completed successfully.", + DiagnosticCode.OPERATION_FAILED: "Operation failed.", + DiagnosticCode.OPERATION_CONFLICT: "Another repository operation is active.", + DiagnosticCode.OPERATION_INTERRUPTED: "Operation was interrupted.", + DiagnosticCode.RECORD_CORRUPT: "A system record could not be read safely.", + } +) + +_STATE_RESULT_CODES: Mapping[RunState, frozenset[ResultCode]] = MappingProxyType( + { + RunState.QUEUED: frozenset({ResultCode.OPERATION_QUEUED}), + RunState.RUNNING: frozenset({ResultCode.OPERATION_RUNNING}), + RunState.SUCCEEDED: frozenset( + {ResultCode.BACKUP_SUCCEEDED, ResultCode.RETENTION_SUCCEEDED} + ), + RunState.FAILED: frozenset({ResultCode.OPERATION_FAILED}), + RunState.SKIPPED: frozenset( + {ResultCode.OPERATION_CONFLICT, ResultCode.OPERATION_SKIPPED} + ), + RunState.INTERRUPTED: frozenset({ResultCode.OPERATION_INTERRUPTED}), + } +) + + +@dataclass(frozen=True, slots=True) +class RetentionPolicy: + """Approved retention values shared by all platform adapters.""" + + keep_daily: int = 5 + keep_weekly: int = 4 + keep_monthly: int = 12 + keep_yearly: int = 3 + group_by: tuple[str, ...] = ("host", "paths") + prune: bool = False + approved_fingerprint: str | None = None + + _ALLOWED_GROUP_FIELDS: ClassVar[frozenset[str]] = frozenset({"host", "paths"}) + + def __post_init__(self) -> None: + """Reject unsafe or incomplete retention policies.""" + for name in ("keep_daily", "keep_weekly", "keep_monthly", "keep_yearly"): + object.__setattr__( + self, + name, + require_int(getattr(self, name), field=name, minimum=0, maximum=10_000), + ) + if type(self.group_by) is not tuple: + raise TypeError("group_by must be a tuple") + if not self.group_by or len(set(self.group_by)) != len(self.group_by): + raise ValueError("group_by must contain unique fields") + if not set(self.group_by) <= self._ALLOWED_GROUP_FIELDS: + raise ValueError("group_by contains an unsupported field") + object.__setattr__(self, "prune", require_bool(self.prune, field="prune")) + if self.approved_fingerprint is not None: + object.__setattr__( + self, + "approved_fingerprint", + require_fingerprint( + self.approved_fingerprint, + field="approved_fingerprint", + ), + ) + + @property + def mutation_approved(self) -> bool: + """Return whether an operator approved a matching dry-run fingerprint.""" + return self.approved_fingerprint is not None + + +@dataclass(frozen=True, slots=True) +class SystemPolicy: + """Root-owned policy values consumed by the system-control backend.""" + + operator_group: str = "timelocker-operators" + transport_identifier: str = "/run/timelocker/control.sock" + protocol_version: int = PROTOCOL_VERSION + max_request_bytes: int = DEFAULT_MAX_REQUEST_BYTES + max_response_records: int = DEFAULT_MAX_RESPONSE_RECORDS + retention: RetentionPolicy = field(default_factory=RetentionPolicy) + + def __post_init__(self) -> None: + """Validate bounded platform policy without interpreting its transport.""" + object.__setattr__( + self, + "operator_group", + require_group_name(self.operator_group), + ) + if not isinstance(self.transport_identifier, str): + raise TypeError("transport_identifier must be a string") + if ( + not 1 <= len(self.transport_identifier) <= 260 + or "\x00" in self.transport_identifier + ): + raise ValueError("transport_identifier must be bounded and contain no NUL") + object.__setattr__( + self, + "protocol_version", + require_int( + self.protocol_version, + field="protocol_version", + minimum=1, + maximum=255, + ), + ) + if self.protocol_version != PROTOCOL_VERSION: + raise ValueError("protocol_version is unsupported") + object.__setattr__( + self, + "max_request_bytes", + require_int( + self.max_request_bytes, + field="max_request_bytes", + minimum=1_024, + maximum=1_048_576, + ), + ) + object.__setattr__( + self, + "max_response_records", + require_int( + self.max_response_records, + field="max_response_records", + minimum=1, + maximum=1_000, + ), + ) + if not isinstance(self.retention, RetentionPolicy): + raise TypeError("retention must be a RetentionPolicy") + + +@dataclass(frozen=True, slots=True) +class RunRecord: + """Durable, secret-free state for one backup or retention attempt.""" + + run_id: UUID + operation: OperationType + trigger: OperationTrigger + target_id: str + started_at: datetime + state: RunState + result_code: ResultCode + completed_at: datetime | None = None + policy_fingerprint: str | None = None + counters: Mapping[str, int] = field(default_factory=dict) + schema_version: int = 1 + + def __post_init__(self) -> None: + """Validate state consistency and freeze caller-owned mappings.""" + object.__setattr__(self, "run_id", require_uuid(self.run_id, field="run_id")) + object.__setattr__( + self, + "operation", + require_enum(self.operation, OperationType, field="operation"), + ) + object.__setattr__( + self, + "trigger", + require_enum(self.trigger, OperationTrigger, field="trigger"), + ) + object.__setattr__( + self, + "target_id", + require_safe_identifier(self.target_id, field="target_id"), + ) + object.__setattr__( + self, + "started_at", + require_utc_datetime(self.started_at, field="started_at"), + ) + object.__setattr__( + self, + "completed_at", + require_optional_utc_datetime(self.completed_at, field="completed_at"), + ) + object.__setattr__( + self, + "state", + require_enum(self.state, RunState, field="state"), + ) + object.__setattr__( + self, + "result_code", + require_enum(self.result_code, ResultCode, field="result_code"), + ) + object.__setattr__( + self, + "schema_version", + require_int( + self.schema_version, + field="schema_version", + minimum=1, + maximum=255, + ), + ) + if self.policy_fingerprint is not None: + object.__setattr__( + self, + "policy_fingerprint", + require_fingerprint( + self.policy_fingerprint, field="policy_fingerprint" + ), + ) + object.__setattr__(self, "counters", freeze_counters(self.counters)) + self._validate_state() + + def _validate_state(self) -> None: + if self.result_code not in _STATE_RESULT_CODES[self.state]: + raise ValueError("result_code is inconsistent with state") + terminal = self.state not in {RunState.QUEUED, RunState.RUNNING} + if terminal != (self.completed_at is not None): + raise ValueError("completed_at must be present exactly for terminal states") + if self.completed_at is not None and self.completed_at < self.started_at: + raise ValueError("completed_at must not precede started_at") + if self.state is RunState.SUCCEEDED: + expected = ( + ResultCode.BACKUP_SUCCEEDED + if self.operation is OperationType.BACKUP + else ResultCode.RETENTION_SUCCEEDED + ) + if self.result_code is not expected: + raise ValueError("success result_code does not match operation") + if ( + self.operation is OperationType.BACKUP + and self.trigger is OperationTrigger.BACKUP_SUCCESS + ): + raise ValueError("a backup cannot be triggered by backup success") + if ( + self.operation is OperationType.BACKUP + and self.policy_fingerprint is not None + ): + raise ValueError( + "backup records cannot carry retention policy fingerprints" + ) + + @property + def safe_summary(self) -> str: + """Return the fixed summary owned by the stable result code.""" + return RESULT_SUMMARIES[self.result_code] + + +@dataclass(frozen=True, slots=True) +class RunTransition: + """Validated request to move a queued or running record to a new state.""" + + expected_states: frozenset[RunState] + new_state: RunState + result_code: ResultCode + completed_at: datetime | None = None + counters: Mapping[str, int] = field(default_factory=dict) + + _NON_TERMINAL: ClassVar[frozenset[RunState]] = frozenset( + {RunState.QUEUED, RunState.RUNNING} + ) + + def __post_init__(self) -> None: + """Reject terminal sources, no-op changes, and inconsistent results.""" + if type(self.expected_states) is not frozenset or not self.expected_states: + raise ValueError("expected_states must be a non-empty frozenset") + expected_states = frozenset( + require_enum(state, RunState, field="expected_states") + for state in self.expected_states + ) + if not expected_states <= self._NON_TERMINAL: + raise ValueError("transitions cannot start from a terminal state") + new_state = require_enum(self.new_state, RunState, field="new_state") + if new_state is RunState.QUEUED or new_state in expected_states: + raise ValueError("transition must advance to a different state") + result_code = require_enum( + self.result_code, + ResultCode, + field="result_code", + ) + if result_code not in _STATE_RESULT_CODES[new_state]: + raise ValueError("result_code is inconsistent with new_state") + completed_at = require_optional_utc_datetime( + self.completed_at, + field="completed_at", + ) + terminal = new_state not in self._NON_TERMINAL + if terminal != (completed_at is not None): + raise ValueError("completed_at must be present exactly for terminal states") + object.__setattr__(self, "expected_states", expected_states) + object.__setattr__(self, "new_state", new_state) + object.__setattr__(self, "result_code", result_code) + object.__setattr__(self, "completed_at", completed_at) + object.__setattr__(self, "counters", freeze_counters(self.counters)) + + +@dataclass(frozen=True, slots=True) +class DiagnosticRecord: + """One bounded, operator-visible diagnostic event.""" + + record_id: UUID + timestamp: datetime + level: DiagnosticLevel + component: DiagnosticComponent + message_code: DiagnosticCode + run_id: UUID | None = None + schema_version: int = 1 + + def __post_init__(self) -> None: + """Validate diagnostic identity and enum values.""" + object.__setattr__( + self, + "record_id", + require_uuid(self.record_id, field="record_id"), + ) + object.__setattr__( + self, + "run_id", + require_optional_uuid(self.run_id, field="run_id"), + ) + object.__setattr__( + self, + "timestamp", + require_utc_datetime(self.timestamp, field="timestamp"), + ) + object.__setattr__( + self, + "level", + require_enum(self.level, DiagnosticLevel, field="level"), + ) + object.__setattr__( + self, + "component", + require_enum(self.component, DiagnosticComponent, field="component"), + ) + object.__setattr__( + self, + "message_code", + require_enum(self.message_code, DiagnosticCode, field="message_code"), + ) + object.__setattr__( + self, + "schema_version", + require_int( + self.schema_version, + field="schema_version", + minimum=1, + maximum=255, + ), + ) + + @property + def safe_summary(self) -> str: + """Return the fixed summary owned by the stable diagnostic code.""" + return DIAGNOSTIC_SUMMARIES[self.message_code] + + +@dataclass(frozen=True, slots=True) +class RunQuery: + """Bounded filters for listing system runs.""" + + limit: int = DEFAULT_MAX_RESPONSE_RECORDS + operation: OperationType | None = None + state: RunState | None = None + + def __post_init__(self) -> None: + object.__setattr__( + self, + "limit", + require_int(self.limit, field="limit", minimum=1, maximum=1_000), + ) + if self.operation is not None: + object.__setattr__( + self, + "operation", + require_enum(self.operation, OperationType, field="operation"), + ) + if self.state is not None: + object.__setattr__( + self, + "state", + require_enum(self.state, RunState, field="state"), + ) + + +@dataclass(frozen=True, slots=True) +class DiagnosticQuery: + """Bounded filters for listing system diagnostics.""" + + limit: int = DEFAULT_MAX_RESPONSE_RECORDS + run_id: UUID | None = None + level: DiagnosticLevel | None = None + + def __post_init__(self) -> None: + object.__setattr__( + self, + "limit", + require_int(self.limit, field="limit", minimum=1, maximum=1_000), + ) + object.__setattr__( + self, + "run_id", + require_optional_uuid(self.run_id, field="run_id"), + ) + if self.level is not None: + object.__setattr__( + self, + "level", + require_enum(self.level, DiagnosticLevel, field="level"), + ) + + +@dataclass(frozen=True, slots=True) +class BackupActionRequest: + """Allowlisted request for the configured system backup target.""" + + target_id: str + + def __post_init__(self) -> None: + object.__setattr__( + self, + "target_id", + require_safe_identifier(self.target_id, field="target_id"), + ) + + +@dataclass(frozen=True, slots=True) +class RetentionActionRequest: + """Allowlisted request for an approved retention policy.""" + + policy_fingerprint: str + dry_run: bool = False + + def __post_init__(self) -> None: + object.__setattr__( + self, + "policy_fingerprint", + require_fingerprint(self.policy_fingerprint, field="policy_fingerprint"), + ) + object.__setattr__(self, "dry_run", require_bool(self.dry_run, field="dry_run")) + + +@dataclass(frozen=True, slots=True) +class ActionReceipt: + """Secret-free acknowledgement for an accepted or rejected action.""" + + request_id: UUID + accepted: bool + status: str + run_id: UUID | None = None + + def __post_init__(self) -> None: + object.__setattr__( + self, + "request_id", + require_uuid(self.request_id, field="request_id"), + ) + object.__setattr__( + self, + "run_id", + require_optional_uuid(self.run_id, field="run_id"), + ) + object.__setattr__( + self, "accepted", require_bool(self.accepted, field="accepted") + ) + object.__setattr__( + self, + "status", + require_safe_identifier(self.status, field="status", maximum=64), + ) + if self.accepted != (self.run_id is not None): + raise ValueError( + "accepted receipts must contain a run_id and denied receipts must not" + ) + + def to_wire(self) -> dict[str, Any]: + """Return JSON-compatible allowlisted receipt fields.""" + return { + "request_id": str(self.request_id), + "accepted": self.accepted, + "status": self.status, + "run_id": str(self.run_id) if self.run_id else None, + } + + +@dataclass(frozen=True, slots=True) +class RunRecordView: + """Allowlisted external projection of a run record.""" + + run_id: UUID + operation: OperationType + trigger: OperationTrigger + target_id: str + started_at: datetime + completed_at: datetime | None + state: RunState + result_code: ResultCode + safe_summary: str + policy_fingerprint: str | None + counters: Mapping[str, int] + + def __post_init__(self) -> None: + """Prevent direct construction from bypassing run-record invariants.""" + canonical = RunRecord( + run_id=self.run_id, + operation=self.operation, + trigger=self.trigger, + target_id=self.target_id, + started_at=self.started_at, + completed_at=self.completed_at, + state=self.state, + result_code=self.result_code, + policy_fingerprint=self.policy_fingerprint, + counters=self.counters, + ) + if self.safe_summary != canonical.safe_summary: + raise ValueError("safe_summary must match the stable result code") + object.__setattr__(self, "run_id", canonical.run_id) + object.__setattr__(self, "operation", canonical.operation) + object.__setattr__(self, "trigger", canonical.trigger) + object.__setattr__(self, "target_id", canonical.target_id) + object.__setattr__(self, "started_at", canonical.started_at) + object.__setattr__(self, "completed_at", canonical.completed_at) + object.__setattr__(self, "state", canonical.state) + object.__setattr__(self, "result_code", canonical.result_code) + object.__setattr__(self, "policy_fingerprint", canonical.policy_fingerprint) + object.__setattr__(self, "counters", canonical.counters) + + @classmethod + def from_record(cls, record: RunRecord) -> "RunRecordView": + """Create a response view without accepting caller-supplied fields.""" + return cls( + run_id=record.run_id, + operation=record.operation, + trigger=record.trigger, + target_id=record.target_id, + started_at=record.started_at, + completed_at=record.completed_at, + state=record.state, + result_code=record.result_code, + safe_summary=record.safe_summary, + policy_fingerprint=record.policy_fingerprint, + counters=record.counters, + ) + + @classmethod + def from_mapping(cls, value: object) -> "RunRecordView": + """Parse all required fields from an untrusted projected mapping.""" + record = require_exact_mapping( + value, + field="run", + required=frozenset( + { + "run_id", + "operation", + "trigger", + "target_id", + "started_at", + "completed_at", + "state", + "result_code", + "safe_summary", + "policy_fingerprint", + "counters", + } + ), + ) + if not isinstance(record["safe_summary"], str): + raise TypeError("run.safe_summary must be a string") + result_code = require_enum( + record["result_code"], + ResultCode, + field="run.result_code", + ) + return cls( + run_id=require_uuid(record["run_id"], field="run.run_id"), + operation=require_enum( + record["operation"], + OperationType, + field="run.operation", + ), + trigger=require_enum( + record["trigger"], + OperationTrigger, + field="run.trigger", + ), + target_id=require_safe_identifier( + record["target_id"], field="run.target_id" + ), + started_at=require_wire_utc_datetime( + record["started_at"], + field="run.started_at", + ), + completed_at=require_optional_wire_utc_datetime( + record["completed_at"], + field="run.completed_at", + ), + state=require_enum(record["state"], RunState, field="run.state"), + result_code=result_code, + safe_summary=RESULT_SUMMARIES[result_code], + policy_fingerprint=( + None + if record["policy_fingerprint"] is None + else require_fingerprint( + record["policy_fingerprint"], + field="run.policy_fingerprint", + ) + ), + counters=record["counters"], + ) + + def to_wire(self) -> dict[str, Any]: + """Return JSON-compatible allowlisted fields.""" + return { + "run_id": str(self.run_id), + "operation": self.operation.value, + "trigger": self.trigger.value, + "target_id": self.target_id, + "started_at": self.started_at.isoformat(), + "completed_at": self.completed_at.isoformat() + if self.completed_at + else None, + "state": self.state.value, + "result_code": self.result_code.value, + "safe_summary": self.safe_summary, + "policy_fingerprint": self.policy_fingerprint, + "counters": dict(self.counters), + } + + +@dataclass(frozen=True, slots=True) +class DiagnosticView: + """Allowlisted external projection of a diagnostic record.""" + + record_id: UUID + run_id: UUID | None + timestamp: datetime + level: DiagnosticLevel + component: DiagnosticComponent + message_code: DiagnosticCode + safe_summary: str + + def __post_init__(self) -> None: + """Prevent direct construction from injecting operator-visible text.""" + canonical = DiagnosticRecord( + record_id=self.record_id, + run_id=self.run_id, + timestamp=self.timestamp, + level=self.level, + component=self.component, + message_code=self.message_code, + ) + if self.safe_summary != canonical.safe_summary: + raise ValueError("safe_summary must match the stable diagnostic code") + object.__setattr__(self, "record_id", canonical.record_id) + object.__setattr__(self, "run_id", canonical.run_id) + object.__setattr__(self, "timestamp", canonical.timestamp) + object.__setattr__(self, "level", canonical.level) + object.__setattr__(self, "component", canonical.component) + object.__setattr__(self, "message_code", canonical.message_code) + + @classmethod + def from_record(cls, record: DiagnosticRecord) -> "DiagnosticView": + """Create a response view without caller-controlled summary text.""" + return cls( + record_id=record.record_id, + run_id=record.run_id, + timestamp=record.timestamp, + level=record.level, + component=record.component, + message_code=record.message_code, + safe_summary=record.safe_summary, + ) + + @classmethod + def from_mapping(cls, value: object) -> "DiagnosticView": + """Parse all required fields from an untrusted projected mapping.""" + diagnostic = require_exact_mapping( + value, + field="diagnostic", + required=frozenset( + { + "record_id", + "run_id", + "timestamp", + "level", + "component", + "message_code", + "safe_summary", + } + ), + ) + if not isinstance(diagnostic["safe_summary"], str): + raise TypeError("diagnostic.safe_summary must be a string") + message_code = require_enum( + diagnostic["message_code"], + DiagnosticCode, + field="diagnostic.message_code", + ) + return cls( + record_id=require_uuid( + diagnostic["record_id"], + field="diagnostic.record_id", + ), + run_id=require_optional_uuid( + diagnostic["run_id"], + field="diagnostic.run_id", + ), + timestamp=require_wire_utc_datetime( + diagnostic["timestamp"], + field="diagnostic.timestamp", + ), + level=require_enum( + diagnostic["level"], + DiagnosticLevel, + field="diagnostic.level", + ), + component=require_enum( + diagnostic["component"], + DiagnosticComponent, + field="diagnostic.component", + ), + message_code=message_code, + safe_summary=DIAGNOSTIC_SUMMARIES[message_code], + ) + + def to_wire(self) -> dict[str, Any]: + """Return JSON-compatible allowlisted fields.""" + return { + "record_id": str(self.record_id), + "run_id": str(self.run_id) if self.run_id else None, + "timestamp": self.timestamp.isoformat(), + "level": self.level.value, + "component": self.component.value, + "message_code": self.message_code.value, + "safe_summary": self.safe_summary, + } + + +def validate_counter_value(value: object) -> int: + """Public helper for adapters that build counter maps incrementally.""" + return require_int( + value, + field="counter", + minimum=0, + maximum=MAX_COUNTER_VALUE, + ) diff --git a/src/TimeLocker/system_control/policy_loader.py b/src/TimeLocker/system_control/policy_loader.py new file mode 100644 index 0000000..c7a1468 --- /dev/null +++ b/src/TimeLocker/system_control/policy_loader.py @@ -0,0 +1,82 @@ +"""Strict loading for the root-controlled system-control policy.""" + +from __future__ import annotations + +import json +from pathlib import Path +import stat + +from .models import RetentionPolicy, SystemPolicy +from .validation import require_exact_mapping + + +_POLICY_FIELDS = frozenset( + { + "operator_group", + "transport_identifier", + "protocol_version", + "max_request_bytes", + "max_response_records", + "retention", + } +) +_RETENTION_FIELDS = frozenset( + { + "keep_daily", + "keep_weekly", + "keep_monthly", + "keep_yearly", + "group_by", + "prune", + "approved_fingerprint", + } +) + + +def load_system_policy(path: Path, *, expected_owner: int = 0) -> SystemPolicy: + """Load a regular, owner-controlled policy without accepting extra fields.""" + if not isinstance(path, Path): + raise TypeError("path must be a Path") + if type(expected_owner) is not int or expected_owner < 0: + raise ValueError("expected_owner must be a non-negative UID") + metadata = path.stat() + if not stat.S_ISREG(metadata.st_mode): + raise ValueError("system policy must be a regular file") + if metadata.st_uid != expected_owner: + raise PermissionError("system policy has an unexpected owner") + if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + raise PermissionError("system policy must not be group- or world-writable") + with path.open("r", encoding="utf-8") as source: + payload = json.load(source) + policy = require_exact_mapping( + payload, + field="system policy", + required=_POLICY_FIELDS, + ) + retention_value = require_exact_mapping( + policy["retention"], + field="retention policy", + required=_RETENTION_FIELDS, + ) + group_by = retention_value["group_by"] + if not isinstance(group_by, list) or not all( + isinstance(item, str) for item in group_by + ): + raise TypeError("retention group_by must be a string list") + retention = RetentionPolicy( + keep_daily=retention_value["keep_daily"], + keep_weekly=retention_value["keep_weekly"], + keep_monthly=retention_value["keep_monthly"], + keep_yearly=retention_value["keep_yearly"], + group_by=tuple(group_by), + prune=retention_value["prune"], + approved_fingerprint=retention_value["approved_fingerprint"], + ) + return SystemPolicy( + operator_group=policy["operator_group"], + transport_identifier=policy["transport_identifier"], + protocol_version=policy["protocol_version"], + max_request_bytes=policy["max_request_bytes"], + max_response_records=policy["max_response_records"], + retention=retention, + ) diff --git a/src/TimeLocker/system_control/protocol.py b/src/TimeLocker/system_control/protocol.py new file mode 100644 index 0000000..990cb99 --- /dev/null +++ b/src/TimeLocker/system_control/protocol.py @@ -0,0 +1,482 @@ +"""Strict request envelopes and response projection for local system control.""" + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any +from uuid import UUID + +from .models import ( + ActionReceipt, + DiagnosticView, + PROTOCOL_VERSION, + RunRecordView, +) +from .types import ( + DiagnosticLevel, + OperationType, + ProtocolErrorCode, + ResponseStatus, + RunState, + SystemAction, +) +from .validation import ( + deep_freeze, + deep_thaw, + freeze_mapping, + require_bool, + require_enum, + require_exact_mapping, + require_fingerprint, + require_int, + require_safe_identifier, + require_uuid, + require_wire_utc_datetime, +) + + +_PARAMETER_FIELDS: Mapping[SystemAction, tuple[frozenset[str], frozenset[str]]] = { + SystemAction.HEALTH: (frozenset(), frozenset()), + SystemAction.RUN_LIST: ( + frozenset(), + frozenset({"limit", "operation", "state"}), + ), + SystemAction.RUN_DETAIL: (frozenset({"run_id"}), frozenset()), + SystemAction.DIAGNOSTIC_LIST: ( + frozenset(), + frozenset({"limit", "run_id", "level"}), + ), + SystemAction.SCHEDULE_SUMMARY: (frozenset(), frozenset()), + SystemAction.BACKUP_REQUEST: (frozenset({"target_id"}), frozenset()), + SystemAction.RETENTION_REQUEST: ( + frozenset({"policy_fingerprint"}), + frozenset({"dry_run"}), + ), + SystemAction.UI_AVAILABILITY: (frozenset(), frozenset()), +} + +_RUN_VIEW_FIELDS = frozenset( + { + "run_id", + "operation", + "trigger", + "target_id", + "started_at", + "completed_at", + "state", + "result_code", + "safe_summary", + "policy_fingerprint", + "counters", + } +) +_DIAGNOSTIC_VIEW_FIELDS = frozenset( + { + "record_id", + "run_id", + "timestamp", + "level", + "component", + "message_code", + "safe_summary", + } +) +_ACTION_RECEIPT_FIELDS = frozenset({"request_id", "accepted", "status", "run_id"}) + +PROTOCOL_ERROR_SUMMARIES: Mapping[ProtocolErrorCode, str] = MappingProxyType( + { + ProtocolErrorCode.SYSTEM_ACCESS_DENIED: "System access denied.", + ProtocolErrorCode.SYSTEM_BACKEND_UNAVAILABLE: "System backend is unavailable.", + ProtocolErrorCode.CONTRACT_VERSION_UNSUPPORTED: ( + "System contract version is unsupported." + ), + ProtocolErrorCode.INVALID_REQUEST: "System request is invalid.", + ProtocolErrorCode.OPERATION_CONFLICT: ( + "Another repository operation is active." + ), + ProtocolErrorCode.OPERATION_FAILED: "System operation failed.", + } +) + + +@dataclass(frozen=True, slots=True) +class RequestEnvelope: + """Validated request that contains only action-specific parameters.""" + + request_id: UUID + action: SystemAction + parameters: Mapping[str, Any] + protocol_version: int = PROTOCOL_VERSION + + @classmethod + def from_mapping(cls, payload: object) -> "RequestEnvelope": + """Parse an untrusted mapping and reject unknown or unsafe fields.""" + envelope = require_exact_mapping( + payload, + field="request", + required=frozenset( + {"protocol_version", "request_id", "action", "parameters"} + ), + ) + return cls( + protocol_version=envelope["protocol_version"], + request_id=envelope["request_id"], + action=envelope["action"], + parameters=envelope["parameters"], + ) + + def __post_init__(self) -> None: + """Ensure direct construction cannot bypass strict parsing.""" + protocol_version = require_int( + self.protocol_version, + field="protocol_version", + minimum=1, + maximum=255, + ) + if protocol_version != PROTOCOL_VERSION: + raise ValueError("protocol_version is unsupported") + action = require_enum(self.action, SystemAction, field="action") + required, optional = _PARAMETER_FIELDS[action] + raw_parameters = require_exact_mapping( + self.parameters, + field="parameters", + required=required, + optional=optional, + ) + object.__setattr__( + self, + "request_id", + require_uuid(self.request_id, field="request_id"), + ) + object.__setattr__(self, "action", action) + object.__setattr__( + self, + "parameters", + freeze_mapping(_validate_parameters(action, raw_parameters)), + ) + object.__setattr__(self, "protocol_version", protocol_version) + + def to_wire(self) -> dict[str, Any]: + """Return JSON-compatible protocol fields.""" + return { + "protocol_version": self.protocol_version, + "request_id": str(self.request_id), + "action": self.action.value, + "parameters": deep_thaw(self.parameters), + } + + +@dataclass(frozen=True, slots=True) +class ResponseEnvelope: + """Validated response with a projected result or stable safe error.""" + + request_id: UUID + status: ResponseStatus + result: Mapping[str, Any] | None = None + error_code: ProtocolErrorCode | None = None + safe_summary: str | None = None + protocol_version: int = PROTOCOL_VERSION + + def __post_init__(self) -> None: + """Validate success/error exclusivity and freeze the response result.""" + object.__setattr__( + self, + "request_id", + require_uuid(self.request_id, field="request_id"), + ) + object.__setattr__( + self, + "status", + require_enum(self.status, ResponseStatus, field="status"), + ) + protocol_version = require_int( + self.protocol_version, + field="protocol_version", + minimum=1, + maximum=255, + ) + if protocol_version != PROTOCOL_VERSION: + raise ValueError("protocol_version is unsupported") + object.__setattr__(self, "protocol_version", protocol_version) + if self.status is ResponseStatus.OK: + if not isinstance(self.result, Mapping): + raise TypeError("successful response result must be a mapping") + if self.error_code is not None or self.safe_summary is not None: + raise ValueError("successful responses cannot contain an error") + object.__setattr__(self, "result", deep_freeze(self.result)) + return + if self.result is not None: + raise ValueError("error responses cannot contain a result") + if self.error_code is None: + raise ValueError("error responses require an error_code") + error_code = require_enum( + self.error_code, + ProtocolErrorCode, + field="error_code", + ) + expected_summary = PROTOCOL_ERROR_SUMMARIES[error_code] + if self.safe_summary != expected_summary: + raise ValueError("safe_summary must match the stable error code") + object.__setattr__(self, "error_code", error_code) + + @classmethod + def success( + cls, + request_id: UUID, + action: SystemAction, + payload: object, + ) -> "ResponseEnvelope": + """Build a successful response through the action projection.""" + return cls( + request_id=request_id, + status=ResponseStatus.OK, + result=project_response(action, payload), + ) + + @classmethod + def error( + cls, + request_id: UUID, + status: ResponseStatus, + error_code: ProtocolErrorCode, + ) -> "ResponseEnvelope": + """Build a metadata-free error from a stable code.""" + if status is ResponseStatus.OK: + raise ValueError("error response status cannot be ok") + error_code = require_enum( + error_code, + ProtocolErrorCode, + field="error_code", + ) + return cls( + request_id=request_id, + status=status, + error_code=error_code, + safe_summary=PROTOCOL_ERROR_SUMMARIES[error_code], + ) + + @classmethod + def from_mapping( + cls, + payload: object, + *, + action: SystemAction, + ) -> "ResponseEnvelope": + """Parse an untrusted response and re-project successful results.""" + response = require_exact_mapping( + payload, + field="response", + required=frozenset( + { + "protocol_version", + "request_id", + "status", + "result", + "error_code", + "safe_summary", + } + ), + ) + status = require_enum(response["status"], ResponseStatus, field="status") + result = response["result"] + if status is ResponseStatus.OK: + result = project_response(action, result) + error_code = response["error_code"] + if error_code is not None: + error_code = require_enum( + error_code, + ProtocolErrorCode, + field="error_code", + ) + return cls( + protocol_version=response["protocol_version"], + request_id=response["request_id"], + status=status, + result=result, + error_code=error_code, + safe_summary=response["safe_summary"], + ) + + def to_wire(self) -> dict[str, Any]: + """Return JSON-compatible protocol fields.""" + return { + "protocol_version": self.protocol_version, + "request_id": str(self.request_id), + "status": self.status.value, + "result": deep_thaw(self.result), + "error_code": self.error_code.value if self.error_code else None, + "safe_summary": self.safe_summary, + } + + +def _validate_parameters( + action: SystemAction, + parameters: Mapping[str, Any], +) -> dict[str, Any]: + validated: dict[str, Any] = {} + if "limit" in parameters: + validated["limit"] = require_int( + parameters["limit"], + field="parameters.limit", + minimum=1, + maximum=1_000, + ) + if "run_id" in parameters: + validated["run_id"] = str( + require_uuid(parameters["run_id"], field="parameters.run_id") + ) + enum_fields = { + "operation": OperationType, + "state": RunState, + "level": DiagnosticLevel, + } + for field_name, enum_type in enum_fields.items(): + if field_name in parameters: + validated[field_name] = require_enum( + parameters[field_name], + enum_type, + field=f"parameters.{field_name}", + ).value + if action is SystemAction.BACKUP_REQUEST: + validated["target_id"] = require_safe_identifier( + parameters["target_id"], + field="parameters.target_id", + ) + if action is SystemAction.RETENTION_REQUEST: + validated["policy_fingerprint"] = require_fingerprint( + parameters["policy_fingerprint"], + field="parameters.policy_fingerprint", + ) + if "dry_run" in parameters: + validated["dry_run"] = require_bool( + parameters["dry_run"], + field="parameters.dry_run", + ) + return validated + + +def project_response(action: SystemAction, payload: object) -> dict[str, Any]: + """Project backend data through the action's explicit response schema.""" + if not isinstance(payload, Mapping): + raise TypeError("response payload must be a mapping") + if action is SystemAction.RUN_LIST: + return {"runs": _project_run_sequence(payload.get("runs"))} + if action is SystemAction.RUN_DETAIL: + return {"run": _project_run(payload.get("run"))} + if action is SystemAction.DIAGNOSTIC_LIST: + return {"diagnostics": _project_diagnostic_sequence(payload.get("diagnostics"))} + if action in {SystemAction.BACKUP_REQUEST, SystemAction.RETENTION_REQUEST}: + return _project_receipt(payload) + if action is SystemAction.HEALTH: + return _project_health(payload) + if action is SystemAction.SCHEDULE_SUMMARY: + return _project_schedule(payload) + if action is SystemAction.UI_AVAILABILITY: + projected = _project_mapping(payload, frozenset({"available"}), "ui") + if "available" not in projected: + raise ValueError("ui available is required") + return {"available": require_bool(projected["available"], field="ui.available")} + raise ValueError("unsupported response action") + + +def _project_mapping( + value: object, + allowed: frozenset[str], + field: str, +) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise TypeError(f"{field} must be a mapping") + return {key: value[key] for key in allowed if key in value} + + +def _project_run(value: object) -> dict[str, Any]: + projected = _project_mapping(value, _RUN_VIEW_FIELDS, "run") + return RunRecordView.from_mapping(projected).to_wire() + + +def _project_run_sequence(value: object) -> list[dict[str, Any]]: + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise TypeError("runs must be a sequence") + if len(value) > 1_000: + raise ValueError("runs exceeds the response record bound") + return [_project_run(item) for item in value] + + +def _project_diagnostic(value: object) -> dict[str, Any]: + projected = _project_mapping(value, _DIAGNOSTIC_VIEW_FIELDS, "diagnostic") + return DiagnosticView.from_mapping(projected).to_wire() + + +def _project_diagnostic_sequence(value: object) -> list[dict[str, Any]]: + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise TypeError("diagnostics must be a sequence") + if len(value) > 1_000: + raise ValueError("diagnostics exceeds the response record bound") + return [_project_diagnostic(item) for item in value] + + +def _project_receipt(value: object) -> dict[str, Any]: + projected = _project_mapping(value, _ACTION_RECEIPT_FIELDS, "receipt") + required = _ACTION_RECEIPT_FIELDS - frozenset({"run_id"}) + missing = required - frozenset(projected) + if missing: + raise ValueError(f"receipt is missing required fields: {sorted(missing)}") + receipt = ActionReceipt( + request_id=projected["request_id"], + accepted=projected["accepted"], + status=projected["status"], + run_id=projected.get("run_id"), + ) + return receipt.to_wire() + + +def _project_health(value: object) -> dict[str, Any]: + projected = _project_mapping( + value, + frozenset({"backend_available", "protocol_min", "protocol_max"}), + "health", + ) + missing = frozenset( + {"backend_available", "protocol_min", "protocol_max"} + ) - frozenset(projected) + if missing: + raise ValueError(f"health is missing required fields: {sorted(missing)}") + protocol_min = require_int( + projected["protocol_min"], + field="health.protocol_min", + minimum=1, + maximum=255, + ) + protocol_max = require_int( + projected["protocol_max"], + field="health.protocol_max", + minimum=protocol_min, + maximum=255, + ) + return { + "backend_available": require_bool( + projected["backend_available"], + field="health.backend_available", + ), + "protocol_min": protocol_min, + "protocol_max": protocol_max, + } + + +def _project_schedule(value: object) -> dict[str, Any]: + projected = _project_mapping( + value, + frozenset({"next_backup_at", "next_retention_at"}), + "schedule", + ) + missing = frozenset({"next_backup_at", "next_retention_at"}) - frozenset(projected) + if missing: + raise ValueError(f"schedule is missing required fields: {sorted(missing)}") + for key in ("next_backup_at", "next_retention_at"): + timestamp = projected[key] + if timestamp is not None: + projected[key] = require_wire_utc_datetime( + timestamp, + field=f"schedule.{key}", + ).isoformat() + return projected diff --git a/src/TimeLocker/system_control/storage.py b/src/TimeLocker/system_control/storage.py new file mode 100644 index 0000000..7d24747 --- /dev/null +++ b/src/TimeLocker/system_control/storage.py @@ -0,0 +1,517 @@ +"""Crash-safe storage and repository mutation locking for system operations.""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import replace +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import tempfile +from typing import Any, Iterator, Mapping +from uuid import UUID + +from .models import ( + DiagnosticQuery, + DiagnosticRecord, + RunQuery, + RunRecord, + RunTransition, +) +from .types import ResultCode, RunState +from .validation import require_exact_mapping, require_safe_identifier, require_uuid + +try: + import fcntl +except ImportError: # pragma: no cover - exercised by Windows adapter validation + fcntl = None # type: ignore[assignment] + + +_NON_TERMINAL_STATES = frozenset({RunState.QUEUED, RunState.RUNNING}) +_RUN_FIELDS = frozenset( + { + "schema_version", + "run_id", + "operation", + "trigger", + "target_id", + "started_at", + "completed_at", + "state", + "result_code", + "policy_fingerprint", + "counters", + } +) +_DIAGNOSTIC_FIELDS = frozenset( + { + "schema_version", + "record_id", + "run_id", + "timestamp", + "level", + "component", + "message_code", + } +) + + +class RecordStoreError(RuntimeError): + """Base error for durable system-control state.""" + + +class RecordNotFoundError(RecordStoreError): + """Raised when a requested run does not exist.""" + + +class RecordCorruptionError(RecordStoreError): + """Raised when persisted state does not satisfy the strict schema.""" + + +class InvalidTransitionError(RecordStoreError): + """Raised when the current record cannot accept a requested transition.""" + + +class MutationConflictError(RecordStoreError): + """Raised when another process owns a repository mutation lease.""" + + +def _run_to_wire(record: RunRecord) -> dict[str, Any]: + return { + "schema_version": record.schema_version, + "run_id": str(record.run_id), + "operation": record.operation.value, + "trigger": record.trigger.value, + "target_id": record.target_id, + "started_at": record.started_at.isoformat(), + "completed_at": record.completed_at.isoformat() + if record.completed_at + else None, + "state": record.state.value, + "result_code": record.result_code.value, + "policy_fingerprint": record.policy_fingerprint, + "counters": dict(record.counters), + } + + +def _run_from_wire(value: object) -> RunRecord: + mapping = require_exact_mapping( + value, + field="run record", + required=_RUN_FIELDS, + ) + return RunRecord( + schema_version=mapping["schema_version"], + run_id=mapping["run_id"], + operation=mapping["operation"], + trigger=mapping["trigger"], + target_id=mapping["target_id"], + started_at=datetime.fromisoformat(mapping["started_at"]), + completed_at=( + datetime.fromisoformat(mapping["completed_at"]) + if mapping["completed_at"] is not None + else None + ), + state=mapping["state"], + result_code=mapping["result_code"], + policy_fingerprint=mapping["policy_fingerprint"], + counters=mapping["counters"], + ) + + +def _diagnostic_to_wire(record: DiagnosticRecord) -> dict[str, Any]: + return { + "schema_version": record.schema_version, + "record_id": str(record.record_id), + "run_id": str(record.run_id) if record.run_id else None, + "timestamp": record.timestamp.isoformat(), + "level": record.level.value, + "component": record.component.value, + "message_code": record.message_code.value, + } + + +def _diagnostic_from_wire(value: object) -> DiagnosticRecord: + mapping = require_exact_mapping( + value, + field="diagnostic record", + required=_DIAGNOSTIC_FIELDS, + ) + return DiagnosticRecord( + schema_version=mapping["schema_version"], + record_id=mapping["record_id"], + run_id=mapping["run_id"], + timestamp=datetime.fromisoformat(mapping["timestamp"]), + level=mapping["level"], + component=mapping["component"], + message_code=mapping["message_code"], + ) + + +class AtomicRecordStore: + """Persist strictly validated records with process-safe atomic replacement.""" + + def __init__(self, root: Path, *, max_diagnostics: int = 1_000) -> None: + if not isinstance(root, Path): + raise TypeError("root must be a Path") + if type(max_diagnostics) is not int or not 1 <= max_diagnostics <= 100_000: + raise ValueError("max_diagnostics must be between 1 and 100000") + self.root = root + self.runs_directory = root / "runs" + self.diagnostics_directory = root / "diagnostics" + self._store_lock_path = root / ".record-store.lock" + self.max_diagnostics = max_diagnostics + for directory in (root, self.runs_directory, self.diagnostics_directory): + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + directory.chmod(0o700) + self._store_lock_path.touch(mode=0o600, exist_ok=True) + self._store_lock_path.chmod(0o600) + + @contextmanager + def _locked(self) -> Iterator[None]: + _require_file_locking() + with self._store_lock_path.open("r+b") as lock_file: + assert fcntl is not None + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + def create_run(self, record: RunRecord) -> None: + """Create a run exactly once.""" + if not isinstance(record, RunRecord): + raise TypeError("record must be a RunRecord") + destination = self._run_path(record.run_id) + with self._locked(): + if destination.exists(): + raise InvalidTransitionError("run already exists") + self._atomic_write_json(destination, _run_to_wire(record)) + + def read_run(self, run_id: UUID | str) -> RunRecord: + """Read and validate one durable run.""" + run_id = require_uuid(run_id, field="run_id") + with self._locked(): + return self._read_run_unlocked(run_id) + + def list_runs(self, query: RunQuery | None = None) -> list[RunRecord]: + """Return newest runs first, bounded by the validated query.""" + query = query or RunQuery() + if not isinstance(query, RunQuery): + raise TypeError("query must be a RunQuery") + records = self._list_runs_unbounded() + return [ + record + for record in records + if (query.operation is None or record.operation is query.operation) + and (query.state is None or record.state is query.state) + ][: query.limit] + + def _list_runs_unbounded(self) -> list[RunRecord]: + """Return all runs for internal startup reconciliation.""" + with self._locked(): + records = [ + self._read_run_path(path) for path in self.runs_directory.glob("*.json") + ] + records.sort(key=lambda item: (item.started_at, str(item.run_id)), reverse=True) + return records + + def transition(self, run_id: UUID | str, transition: RunTransition) -> RunRecord: + """Apply one compare-and-swap state transition atomically.""" + run_id = require_uuid(run_id, field="run_id") + if not isinstance(transition, RunTransition): + raise TypeError("transition must be a RunTransition") + with self._locked(): + current = self._read_run_unlocked(run_id) + if current.state not in transition.expected_states: + raise InvalidTransitionError("run state does not match expected_states") + counters = dict(current.counters) + counters.update(transition.counters) + candidate = replace( + current, + state=transition.new_state, + result_code=transition.result_code, + completed_at=transition.completed_at, + counters=counters, + ) + self._atomic_write_json(self._run_path(run_id), _run_to_wire(candidate)) + return candidate + + def append_diagnostic(self, record: DiagnosticRecord) -> None: + """Append one immutable diagnostic and trim only records beyond the bound.""" + if not isinstance(record, DiagnosticRecord): + raise TypeError("record must be a DiagnosticRecord") + destination = self.diagnostics_directory / f"{record.record_id}.json" + with self._locked(): + if destination.exists(): + raise InvalidTransitionError("diagnostic already exists") + self._atomic_write_json(destination, _diagnostic_to_wire(record)) + records = self._diagnostic_paths_unlocked() + for stale in records[: max(0, len(records) - self.max_diagnostics)]: + stale.unlink() + self._fsync_directory(self.diagnostics_directory) + + def list_diagnostics( + self, + query: DiagnosticQuery | None = None, + ) -> list[DiagnosticRecord]: + """Return newest structured diagnostics first.""" + query = query or DiagnosticQuery() + if not isinstance(query, DiagnosticQuery): + raise TypeError("query must be a DiagnosticQuery") + with self._locked(): + records = [ + self._read_diagnostic_path(path) + for path in self._diagnostic_paths_unlocked() + ] + records.sort( + key=lambda item: (item.timestamp, str(item.record_id)), + reverse=True, + ) + return [ + record + for record in records + if (query.run_id is None or record.run_id == query.run_id) + and (query.level is None or record.level is query.level) + ][: query.limit] + + def _run_path(self, run_id: UUID) -> Path: + return self.runs_directory / f"{run_id}.json" + + def _read_run_unlocked(self, run_id: UUID) -> RunRecord: + path = self._run_path(run_id) + if not path.is_file(): + raise RecordNotFoundError("run not found") + return self._read_run_path(path) + + def _read_run_path(self, path: Path) -> RunRecord: + try: + return _run_from_wire(self._read_json(path)) + except (OSError, TypeError, ValueError, json.JSONDecodeError) as error: + raise RecordCorruptionError("run record is corrupt") from error + + def _read_diagnostic_path(self, path: Path) -> DiagnosticRecord: + try: + return _diagnostic_from_wire(self._read_json(path)) + except (OSError, TypeError, ValueError, json.JSONDecodeError) as error: + raise RecordCorruptionError("diagnostic record is corrupt") from error + + def _diagnostic_paths_unlocked(self) -> list[Path]: + paths = list(self.diagnostics_directory.glob("*.json")) + paths.sort(key=lambda path: (path.stat().st_mtime_ns, path.name)) + return paths + + @staticmethod + def _read_json(path: Path) -> object: + with path.open("r", encoding="utf-8") as source: + return json.load(source) + + def _atomic_write_json(self, destination: Path, payload: Mapping[str, Any]) -> None: + descriptor, temporary_name = tempfile.mkstemp( + dir=destination.parent, + prefix=f".{destination.name}.", + suffix=".tmp", + ) + temporary = Path(temporary_name) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + json.dump(payload, output, separators=(",", ":"), sort_keys=True) + output.write("\n") + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, destination) + destination.chmod(0o600) + self._fsync_directory(destination.parent) + except BaseException: + temporary.unlink(missing_ok=True) + raise + + @staticmethod + def _fsync_directory(directory: Path) -> None: + descriptor = os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +class RepositoryMutationLease: + """One held advisory repository lock and its safe ownership metadata.""" + + def __init__(self, file_object: Any, path: Path, run_id: UUID) -> None: + self._file = file_object + self.path = path + self.run_id = run_id + self._released = False + + def release(self) -> None: + """Release this lease once; process exit also releases the kernel lock.""" + if self._released: + return + _require_file_locking() + assert fcntl is not None + self._file.seek(0) + self._file.truncate() + self._file.flush() + os.fsync(self._file.fileno()) + fcntl.flock(self._file.fileno(), fcntl.LOCK_UN) + self._file.close() + self._released = True + + def __enter__(self) -> "RepositoryMutationLease": + return self + + def __exit__(self, *_args: object) -> None: + self.release() + + +class RepositoryMutationLock: + """Coordinate repository mutations across independent system processes.""" + + def __init__(self, root: Path) -> None: + if not isinstance(root, Path): + raise TypeError("root must be a Path") + self.root = root + root.mkdir(mode=0o700, parents=True, exist_ok=True) + root.chmod(0o700) + + def acquire( + self, + target_id: str, + run_id: UUID | str, + *, + blocking: bool = False, + ) -> RepositoryMutationLease: + """Acquire the target mutation lease or raise a stable conflict.""" + target_id = require_safe_identifier(target_id, field="target_id") + run_id = require_uuid(run_id, field="run_id") + _require_file_locking() + assert fcntl is not None + path = self._path(target_id) + file_object = path.open("a+", encoding="utf-8") + path.chmod(0o600) + operation = fcntl.LOCK_EX + if not blocking: + operation |= fcntl.LOCK_NB + try: + fcntl.flock(file_object.fileno(), operation) + except BlockingIOError as error: + file_object.close() + raise MutationConflictError( + "another repository mutation is active" + ) from error + metadata = { + "schema_version": 1, + "run_id": str(run_id), + "process_id": os.getpid(), + } + file_object.seek(0) + file_object.truncate() + json.dump(metadata, file_object, separators=(",", ":"), sort_keys=True) + file_object.write("\n") + file_object.flush() + os.fsync(file_object.fileno()) + return RepositoryMutationLease(file_object, path, run_id) + + def is_active(self, target_id: str, run_id: UUID | str) -> bool: + """Return whether a live process holds this target lease for the run.""" + target_id = require_safe_identifier(target_id, field="target_id") + run_id = require_uuid(run_id, field="run_id") + _require_file_locking() + assert fcntl is not None + path = self._path(target_id) + file_object = path.open("a+", encoding="utf-8") + path.chmod(0o600) + try: + try: + fcntl.flock( + file_object.fileno(), + fcntl.LOCK_EX | fcntl.LOCK_NB, + ) + except BlockingIOError: + file_object.seek(0) + try: + metadata = json.load(file_object) + stored_run_id = require_uuid(metadata["run_id"], field="run_id") + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + return True + return stored_run_id == run_id + else: + fcntl.flock(file_object.fileno(), fcntl.LOCK_UN) + return False + finally: + file_object.close() + + def clear_stale(self, target_id: str) -> None: + """Clear metadata only when no process holds the kernel lock.""" + target_id = require_safe_identifier(target_id, field="target_id") + _require_file_locking() + assert fcntl is not None + path = self._path(target_id) + with path.open("a+", encoding="utf-8") as file_object: + try: + fcntl.flock( + file_object.fileno(), + fcntl.LOCK_EX | fcntl.LOCK_NB, + ) + except BlockingIOError as error: + raise MutationConflictError( + "another repository mutation is active" + ) from error + file_object.seek(0) + file_object.truncate() + file_object.flush() + os.fsync(file_object.fileno()) + fcntl.flock(file_object.fileno(), fcntl.LOCK_UN) + + def _path(self, target_id: str) -> Path: + return self.root / f"{target_id}.lock" + + +def _require_file_locking() -> None: + if fcntl is None: + raise OSError("repository mutation locking is unavailable on this platform") + + +def reconcile_abandoned_runs( + store: AtomicRecordStore, + locks: RepositoryMutationLock, + *, + now: datetime | None = None, +) -> list[RunRecord]: + """Mark non-terminal runs without a live matching lease as interrupted.""" + if not isinstance(store, AtomicRecordStore): + raise TypeError("store must be an AtomicRecordStore") + if not isinstance(locks, RepositoryMutationLock): + raise TypeError("locks must be a RepositoryMutationLock") + completed_at = now or datetime.now(timezone.utc) + if ( + completed_at.tzinfo is None + or completed_at.utcoffset() != timezone.utc.utcoffset(completed_at) + ): + raise ValueError("now must be an aware UTC timestamp") + reconciled: list[RunRecord] = [] + for record in store._list_runs_unbounded(): + if record.state not in _NON_TERMINAL_STATES: + continue + if locks.is_active(record.target_id, record.run_id): + continue + transition = RunTransition( + expected_states=frozenset({record.state}), + new_state=RunState.INTERRUPTED, + result_code=ResultCode.OPERATION_INTERRUPTED, + completed_at=max(completed_at, record.started_at), + ) + try: + reconciled.append(store.transition(record.run_id, transition)) + except InvalidTransitionError: + continue + try: + locks.clear_stale(record.target_id) + except MutationConflictError: + # A newer run may already own the repository lock. Its live lease + # must not prevent the older abandoned record being reconciled. + pass + return reconciled diff --git a/src/TimeLocker/system_control/types.py b/src/TimeLocker/system_control/types.py new file mode 100644 index 0000000..5b81751 --- /dev/null +++ b/src/TimeLocker/system_control/types.py @@ -0,0 +1,115 @@ +"""Shared, platform-neutral system-control enumerations.""" + +from enum import StrEnum + + +class SystemAction(StrEnum): + """Actions exposed by the bounded local system-control contract.""" + + HEALTH = "health" + RUN_LIST = "run.list" + RUN_DETAIL = "run.detail" + DIAGNOSTIC_LIST = "diagnostic.list" + SCHEDULE_SUMMARY = "schedule.summary" + BACKUP_REQUEST = "backup.request" + RETENTION_REQUEST = "retention.request" + UI_AVAILABILITY = "ui.availability" + + +class ResponseStatus(StrEnum): + """Protocol-level outcomes that do not expose backend internals.""" + + OK = "ok" + DENIED = "denied" + CONFLICT = "conflict" + UNAVAILABLE = "unavailable" + INVALID = "invalid" + FAILED = "failed" + + +class ProtocolErrorCode(StrEnum): + """Stable response errors with metadata-free, code-owned summaries.""" + + SYSTEM_ACCESS_DENIED = "system_access_denied" + SYSTEM_BACKEND_UNAVAILABLE = "system_backend_unavailable" + CONTRACT_VERSION_UNSUPPORTED = "contract_version_unsupported" + INVALID_REQUEST = "invalid_request" + OPERATION_CONFLICT = "operation_conflict" + OPERATION_FAILED = "operation_failed" + + +class OperationType(StrEnum): + """Machine operations recorded by the system backend.""" + + BACKUP = "backup" + RETENTION = "retention" + + +class OperationTrigger(StrEnum): + """Origin of a system operation.""" + + SCHEDULED = "scheduled" + BACKUP_SUCCESS = "backup_success" + EXPLICIT = "explicit" + RETRY = "retry" + RECOVERY = "recovery" + + +class RunState(StrEnum): + """Allowed run-record states.""" + + QUEUED = "queued" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + SKIPPED = "skipped" + INTERRUPTED = "interrupted" + + +class ResultCode(StrEnum): + """Stable run result codes with safe, code-owned summaries.""" + + OPERATION_QUEUED = "operation_queued" + OPERATION_RUNNING = "operation_running" + BACKUP_SUCCEEDED = "backup_succeeded" + RETENTION_SUCCEEDED = "retention_succeeded" + OPERATION_FAILED = "operation_failed" + OPERATION_CONFLICT = "operation_conflict" + OPERATION_SKIPPED = "operation_skipped" + OPERATION_INTERRUPTED = "operation_interrupted" + + +class DiagnosticLevel(StrEnum): + """Severity of an operator-visible structured diagnostic.""" + + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class DiagnosticComponent(StrEnum): + """Components allowed to emit operator-visible diagnostics.""" + + BACKEND = "backend" + AUTHORIZATION = "authorization" + BACKUP = "backup" + RETENTION = "retention" + SCHEDULER = "scheduler" + RUN_STORE = "run_store" + + +class DiagnosticCode(StrEnum): + """Stable diagnostic codes whose summaries are owned by TimeLocker.""" + + BACKEND_STARTED = "backend_started" + BACKEND_UNAVAILABLE = "backend_unavailable" + ACCESS_DENIED = "access_denied" + INVALID_REQUEST = "invalid_request" + BACKUP_STARTED = "backup_started" + BACKUP_SUCCEEDED = "backup_succeeded" + RETENTION_STARTED = "retention_started" + RETENTION_SUCCEEDED = "retention_succeeded" + OPERATION_FAILED = "operation_failed" + OPERATION_CONFLICT = "operation_conflict" + OPERATION_INTERRUPTED = "operation_interrupted" + RECORD_CORRUPT = "record_corrupt" diff --git a/src/TimeLocker/system_control/validation.py b/src/TimeLocker/system_control/validation.py new file mode 100644 index 0000000..e5e4cb1 --- /dev/null +++ b/src/TimeLocker/system_control/validation.py @@ -0,0 +1,220 @@ +"""Strict validation helpers for the local system-control contract.""" + +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from enum import Enum +import re +from types import MappingProxyType +from typing import Any, TypeVar +from uuid import UUID + + +MAX_SAFE_IDENTIFIER_LENGTH = 128 +MAX_COUNTERS = 8 +MAX_COUNTER_VALUE = (2**63) - 1 + +_SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") +_GROUP_NAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$") +_COUNTER_NAME = re.compile(r"^[a-z][a-z0-9_]{0,31}$") +_FINGERPRINT = re.compile(r"^[a-f0-9]{64}$") + +_EnumType = TypeVar("_EnumType", bound=Enum) + + +def require_exact_mapping( + value: object, + *, + field: str, + required: frozenset[str], + optional: frozenset[str] = frozenset(), +) -> Mapping[str, Any]: + """Return a mapping only when it contains exactly the allowed string keys.""" + if not isinstance(value, Mapping): + raise TypeError(f"{field} must be a mapping") + if not all(isinstance(key, str) for key in value): + raise TypeError(f"{field} keys must be strings") + keys = frozenset(value) + missing = required - keys + unknown = keys - required - optional + if missing: + raise ValueError(f"{field} is missing required fields: {sorted(missing)}") + if unknown: + raise ValueError(f"{field} contains unknown fields: {sorted(unknown)}") + return value + + +def require_int( + value: object, + *, + field: str, + minimum: int, + maximum: int, +) -> int: + """Validate a bounded integer while rejecting booleans.""" + if type(value) is not int: + raise TypeError(f"{field} must be an integer") + if not minimum <= value <= maximum: + raise ValueError(f"{field} must be between {minimum} and {maximum}") + return value + + +def require_bool(value: object, *, field: str) -> bool: + """Validate a strict boolean.""" + if type(value) is not bool: + raise TypeError(f"{field} must be a boolean") + return value + + +def require_enum( + value: object, + enum_type: type[_EnumType], + *, + field: str, +) -> _EnumType: + """Validate an enum instance or its exact string value.""" + if isinstance(value, enum_type): + return value + if not isinstance(value, str): + raise TypeError(f"{field} must be a string") + try: + return enum_type(value) + except ValueError as error: + raise ValueError(f"{field} has an unsupported value") from error + + +def require_uuid(value: object, *, field: str) -> UUID: + """Validate a UUID instance or canonical UUID string.""" + if isinstance(value, UUID): + return value + if not isinstance(value, str): + raise TypeError(f"{field} must be a UUID") + try: + parsed = UUID(value) + except ValueError as error: + raise ValueError(f"{field} must be a valid UUID") from error + if str(parsed) != value: + raise ValueError(f"{field} must use canonical UUID form") + return parsed + + +def require_optional_uuid(value: object, *, field: str) -> UUID | None: + """Validate an optional UUID.""" + if value is None: + return None + return require_uuid(value, field=field) + + +def require_safe_identifier( + value: object, + *, + field: str, + maximum: int = MAX_SAFE_IDENTIFIER_LENGTH, +) -> str: + """Validate an opaque identifier that cannot encode a path or URI.""" + if not isinstance(value, str): + raise TypeError(f"{field} must be a string") + if not 1 <= len(value) <= maximum or not _SAFE_IDENTIFIER.fullmatch(value): + raise ValueError(f"{field} must be a bounded opaque identifier") + if "://" in value or value.startswith(("/", "\\", ".")): + raise ValueError(f"{field} must not be a path or URI") + return value + + +def require_group_name(value: object, *, field: str = "operator_group") -> str: + """Validate a portable, bounded Unix-style group policy name.""" + if not isinstance(value, str): + raise TypeError(f"{field} must be a string") + if not _GROUP_NAME.fullmatch(value): + raise ValueError(f"{field} must be a valid bounded group name") + return value + + +def require_fingerprint(value: object, *, field: str) -> str: + """Validate a lowercase SHA-256 policy fingerprint.""" + if not isinstance(value, str): + raise TypeError(f"{field} must be a string") + if not _FINGERPRINT.fullmatch(value): + raise ValueError(f"{field} must be a lowercase SHA-256 digest") + return value + + +def require_utc_datetime(value: object, *, field: str) -> datetime: + """Validate an aware UTC timestamp.""" + if not isinstance(value, datetime): + raise TypeError(f"{field} must be a datetime") + if value.tzinfo is None or value.utcoffset() != timezone.utc.utcoffset(value): + raise ValueError(f"{field} must be timezone-aware UTC") + return value + + +def require_optional_utc_datetime(value: object, *, field: str) -> datetime | None: + """Validate an optional UTC timestamp.""" + if value is None: + return None + return require_utc_datetime(value, field=field) + + +def require_wire_utc_datetime(value: object, *, field: str) -> datetime: + """Parse a wire-format ISO timestamp and require a UTC offset.""" + if not isinstance(value, str): + raise TypeError(f"{field} must be an ISO timestamp string") + try: + parsed = datetime.fromisoformat(value) + except ValueError as error: + raise ValueError(f"{field} must be a valid ISO timestamp") from error + return require_utc_datetime(parsed, field=field) + + +def require_optional_wire_utc_datetime( + value: object, + *, + field: str, +) -> datetime | None: + """Parse an optional wire-format UTC timestamp.""" + if value is None: + return None + return require_wire_utc_datetime(value, field=field) + + +def freeze_counters(value: object) -> Mapping[str, int]: + """Return a read-only, bounded map of non-negative numeric counters.""" + if not isinstance(value, Mapping): + raise TypeError("counters must be a mapping") + if len(value) > MAX_COUNTERS: + raise ValueError(f"counters must contain at most {MAX_COUNTERS} entries") + counters: dict[str, int] = {} + for key, counter in value.items(): + if not isinstance(key, str) or not _COUNTER_NAME.fullmatch(key): + raise ValueError("counter names must use bounded snake_case") + counters[key] = require_int( + counter, + field=f"counters.{key}", + minimum=0, + maximum=MAX_COUNTER_VALUE, + ) + return MappingProxyType(counters) + + +def freeze_mapping(value: Mapping[str, Any]) -> Mapping[str, Any]: + """Copy a mapping into a read-only wrapper.""" + return MappingProxyType({key: deep_freeze(item) for key, item in value.items()}) + + +def deep_freeze(value: Any) -> Any: + """Recursively copy protocol containers into immutable equivalents.""" + if isinstance(value, Mapping): + if not all(isinstance(key, str) for key in value): + raise TypeError("protocol mapping keys must be strings") + return MappingProxyType({key: deep_freeze(item) for key, item in value.items()}) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return tuple(deep_freeze(item) for item in value) + return value + + +def deep_thaw(value: Any) -> Any: + """Return JSON-compatible mutable containers from a frozen protocol value.""" + if isinstance(value, Mapping): + return {key: deep_thaw(item) for key, item in value.items()} + if isinstance(value, tuple): + return [deep_thaw(item) for item in value] + return value diff --git a/tests/TimeLocker/system_control/__init__.py b/tests/TimeLocker/system_control/__init__.py new file mode 100644 index 0000000..0bdc73e --- /dev/null +++ b/tests/TimeLocker/system_control/__init__.py @@ -0,0 +1 @@ +"""Tests for the platform-neutral system-control contracts.""" diff --git a/tests/TimeLocker/system_control/test_dispatcher.py b/tests/TimeLocker/system_control/test_dispatcher.py new file mode 100644 index 0000000..0a831ec --- /dev/null +++ b/tests/TimeLocker/system_control/test_dispatcher.py @@ -0,0 +1,214 @@ +"""Authorization, malformed-request, and redaction tests for local dispatch.""" + +from collections.abc import Sequence +import json +from uuid import uuid4 + +import pytest + +from TimeLocker.system_control import ( + AuditEvent, + LocalControlDispatcher, + PeerIdentity, + SystemAction, + SystemPolicy, +) + + +class MembershipSequence: + """Return explicit current-membership results and count every lookup.""" + + def __init__(self, results: Sequence[bool]) -> None: + self.results = iter(results) + self.calls = 0 + + def is_current_member(self, identity: PeerIdentity, group_name: str) -> bool: + self.calls += 1 + assert identity.platform_id == "linux-uid:1000" + assert group_name == "timelocker-operators" + return next(self.results) + + +class CollectingAuditSink: + """Capture bounded audit events for assertions.""" + + def __init__(self) -> None: + self.events: list[AuditEvent] = [] + + def record(self, event: AuditEvent) -> None: + self.events.append(event) + + +def request( + action: str = "health", + parameters: dict[str, object] | None = None, + *, + version: int = 1, +) -> bytes: + return json.dumps( + { + "protocol_version": version, + "request_id": str(uuid4()), + "action": action, + "parameters": parameters or {}, + } + ).encode("utf-8") + + +def decode(response: bytes) -> dict[str, object]: + value = json.loads(response) + assert isinstance(value, dict) + return value + + +def health(_request: object) -> dict[str, object]: + return { + "backend_available": True, + "protocol_min": 1, + "protocol_max": 1, + "protected_path": "/var/lib/timelocker", + } + + +@pytest.mark.unit +@pytest.mark.security +class TestLocalControlDispatcher: + """Prove every protected request uses fresh OS-derived authorization.""" + + def test_authorized_request_is_projected_and_audited(self) -> None: + membership = MembershipSequence([True]) + audit = CollectingAuditSink() + dispatcher = LocalControlDispatcher( + policy=SystemPolicy(), + membership_resolver=membership, + handlers={SystemAction.HEALTH: health}, + audit_sink=audit, + ) + + response = decode( + dispatcher.handle(request(), PeerIdentity("linux-uid:1000", 1234)) + ) + + assert response["status"] == "ok" + assert response["result"] == { + "backend_available": True, + "protocol_min": 1, + "protocol_max": 1, + } + assert membership.calls == 1 + assert audit.events[0].decision == "allowed" + assert audit.events[0].status.value == "ok" + + def test_membership_is_revalidated_and_removed_member_is_denied(self) -> None: + membership = MembershipSequence([True, False]) + dispatcher = LocalControlDispatcher( + policy=SystemPolicy(), + membership_resolver=membership, + handlers={SystemAction.HEALTH: health}, + audit_sink=CollectingAuditSink(), + ) + identity = PeerIdentity("linux-uid:1000") + + first = decode(dispatcher.handle(request(), identity)) + second = decode(dispatcher.handle(request(), identity)) + + assert first["status"] == "ok" + assert second["status"] == "denied" + assert second["error_code"] == "system_access_denied" + assert second["result"] is None + assert membership.calls == 2 + + def test_denial_does_not_disclose_handler_or_protected_metadata(self) -> None: + identity = PeerIdentity("linux-uid:1000") + responses = [] + for handlers in ({}, {SystemAction.RUN_DETAIL: lambda _request: {}}): + dispatcher = LocalControlDispatcher( + policy=SystemPolicy(), + membership_resolver=MembershipSequence([False]), + handlers=handlers, + audit_sink=CollectingAuditSink(), + ) + responses.append( + decode( + dispatcher.handle( + request( + "run.detail", + {"run_id": str(uuid4())}, + ), + identity, + ) + ) + ) + + for response in responses: + assert response["status"] == "denied" + assert response["result"] is None + assert response["safe_summary"] == "System access denied." + assert "target_id" not in response + assert "repository" not in response + + @pytest.mark.parametrize( + ("payload", "error_code"), + [ + (b"{", "invalid_request"), + (request(version=2), "contract_version_unsupported"), + ( + request( + "backup.request", + {"target_id": "production", "uid": 0}, + ), + "invalid_request", + ), + ], + ) + def test_malformed_version_and_self_asserted_identity_fail_closed( + self, + payload: bytes, + error_code: str, + ) -> None: + dispatcher = LocalControlDispatcher( + policy=SystemPolicy(), + membership_resolver=MembershipSequence([]), + handlers={}, + audit_sink=CollectingAuditSink(), + ) + + response = decode(dispatcher.handle(payload, PeerIdentity("linux-uid:1000"))) + + assert response["status"] == "invalid" + assert response["error_code"] == error_code + assert response["result"] is None + + def test_oversized_request_is_rejected_before_membership_or_dispatch(self) -> None: + membership = MembershipSequence([]) + dispatcher = LocalControlDispatcher( + policy=SystemPolicy(max_request_bytes=1_024), + membership_resolver=membership, + handlers={SystemAction.HEALTH: lambda _request: pytest.fail("dispatched")}, + audit_sink=CollectingAuditSink(), + ) + + response = decode( + dispatcher.handle(b"x" * 1_025, PeerIdentity("linux-uid:1000")) + ) + + assert response["error_code"] == "invalid_request" + assert membership.calls == 0 + + def test_handler_exception_is_replaced_with_safe_stable_error(self) -> None: + def failing_handler(_request: object) -> object: + raise RuntimeError("password=secret /protected/path") + + dispatcher = LocalControlDispatcher( + policy=SystemPolicy(), + membership_resolver=MembershipSequence([True]), + handlers={SystemAction.HEALTH: failing_handler}, + audit_sink=CollectingAuditSink(), + ) + + encoded = dispatcher.handle(request(), PeerIdentity("linux-uid:1000")) + response = decode(encoded) + + assert response["error_code"] == "operation_failed" + assert b"password" not in encoded + assert b"/protected" not in encoded diff --git a/tests/TimeLocker/system_control/test_interfaces.py b/tests/TimeLocker/system_control/test_interfaces.py new file mode 100644 index 0000000..d19e112 --- /dev/null +++ b/tests/TimeLocker/system_control/test_interfaces.py @@ -0,0 +1,111 @@ +"""Portability tests for shared system-control adapter protocols.""" + +from dataclasses import dataclass +from uuid import UUID, uuid4 + +import pytest + +from TimeLocker.system_control import ( + ActionReceipt, + BackupActionRequest, + DiagnosticQuery, + DiagnosticView, + PeerIdentity, + RetentionActionRequest, + RunQuery, + RunRecordView, +) + + +@dataclass +class FakePeerIdentityProvider: + """Platform test double that derives a configured identity.""" + + identity: PeerIdentity + + def peer_identity(self, connection: object) -> PeerIdentity: + return self.identity + + +@dataclass +class FakeGroupResolver: + """Current-membership test double shared by Linux and Windows cases.""" + + allowed_platform_id: str + + def is_current_member(self, identity: PeerIdentity, group_name: str) -> bool: + return ( + group_name == "timelocker-operators" + and identity.platform_id == self.allowed_platform_id + ) + + +class FakeSystemControlClient: + """Minimal client test double proving the platform-neutral method surface.""" + + def list_runs(self, query: RunQuery) -> list[RunRecordView]: + return [] + + def get_run(self, run_id: UUID) -> RunRecordView: + raise LookupError(run_id) + + def list_diagnostics(self, query: DiagnosticQuery) -> list[DiagnosticView]: + return [] + + def request_backup(self, request: BackupActionRequest) -> ActionReceipt: + return ActionReceipt( + request_id=uuid4(), + accepted=True, + status="accepted", + run_id=uuid4(), + ) + + def request_retention(self, request: RetentionActionRequest) -> ActionReceipt: + return ActionReceipt( + request_id=uuid4(), + accepted=True, + status="accepted", + run_id=uuid4(), + ) + + +@pytest.mark.unit +@pytest.mark.platform +@pytest.mark.parametrize( + "platform_id", + ["uid:1000", "sid:S-1-5-21-1000"], +) +def test_linux_and_windows_identity_adapters_share_authorization_contract( + platform_id: str, +) -> None: + """Linux UID and Windows SID adapters can supply the same shared model.""" + provider = FakePeerIdentityProvider(PeerIdentity(platform_id, process_id=1234)) + resolver = FakeGroupResolver(platform_id) + + identity = provider.peer_identity(object()) + + assert resolver.is_current_member(identity, "timelocker-operators") is True + assert resolver.is_current_member(identity, "administrators") is False + + +@pytest.mark.unit +@pytest.mark.platform +def test_client_double_uses_only_bounded_action_models() -> None: + client = FakeSystemControlClient() + + backup = client.request_backup(BackupActionRequest(target_id="production")) + retention = client.request_retention( + RetentionActionRequest(policy_fingerprint="a" * 64, dry_run=True) + ) + + assert backup.accepted is True + assert retention.accepted is True + assert client.list_runs(RunQuery(limit=10)) == [] + assert client.list_diagnostics(DiagnosticQuery(limit=10)) == [] + + +@pytest.mark.unit +@pytest.mark.security +def test_peer_identity_rejects_path_or_payload_identity() -> None: + with pytest.raises(ValueError): + PeerIdentity("/proc/self") diff --git a/tests/TimeLocker/system_control/test_linux_adapter.py b/tests/TimeLocker/system_control/test_linux_adapter.py new file mode 100644 index 0000000..a73b194 --- /dev/null +++ b/tests/TimeLocker/system_control/test_linux_adapter.py @@ -0,0 +1,231 @@ +"""Linux kernel-identity, NSS, socket, policy, and service-asset tests.""" + +import grp +import json +import os +from pathlib import Path +import pwd +import socket +import struct +from threading import Event +from types import SimpleNamespace + +import pytest + +from TimeLocker.system_control import PeerIdentity +from TimeLocker.system_control.linux_adapter import ( + LinuxNssGroupMembershipResolver, + LinuxPeerIdentityProvider, + LinuxUnixSocketTransport, +) +from TimeLocker.system_control.policy_loader import load_system_policy + + +ASSET_DIRECTORY = ( + Path(__file__).parents[3] / "src" / "TimeLocker" / "system_control" / "assets" +) + + +@pytest.mark.unit +@pytest.mark.security +class TestLinuxPeerIdentity: + """Prove identity is derived from the connected socket and current NSS.""" + + def test_peer_credentials_are_parsed_from_socket_option( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + server, client = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + try: + monkeypatch.setattr( + socket.socket, + "getsockopt", + lambda _socket, level, option, length: ( + struct.pack("3i", 4321, 1000, 1000) + if ( + level == socket.SOL_SOCKET + and option == socket.SO_PEERCRED + and length == struct.calcsize("3i") + ) + else b"" + ), + ) + identity = LinuxPeerIdentityProvider().peer_identity(server) + finally: + server.close() + client.close() + + assert identity.platform_id == "linux-uid:1000" + assert identity.process_id == 4321 + + def test_primary_and_supplementary_membership_are_recognized( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + resolver = LinuxNssGroupMembershipResolver() + identity = PeerIdentity("linux-uid:1000") + monkeypatch.setattr( + pwd, + "getpwuid", + lambda _uid: SimpleNamespace(pw_name="operator", pw_gid=2000), + ) + monkeypatch.setattr( + grp, + "getgrnam", + lambda _name: SimpleNamespace(gr_gid=2000, gr_mem=[]), + ) + assert resolver.is_current_member(identity, "timelocker-operators") + + monkeypatch.setattr( + pwd, + "getpwuid", + lambda _uid: SimpleNamespace(pw_name="operator", pw_gid=3000), + ) + monkeypatch.setattr( + grp, + "getgrnam", + lambda _name: SimpleNamespace(gr_gid=2000, gr_mem=["operator"]), + ) + assert resolver.is_current_member(identity, "timelocker-operators") + + def test_missing_account_group_or_non_linux_identity_fails_closed( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + resolver = LinuxNssGroupMembershipResolver() + monkeypatch.setattr( + pwd, + "getpwuid", + lambda _uid: (_ for _ in ()).throw(KeyError("missing")), + ) + + assert not resolver.is_current_member( + PeerIdentity("linux-uid:1000"), + "timelocker-operators", + ) + assert not resolver.is_current_member( + PeerIdentity("windows-sid:S-1-5-21"), + "timelocker-operators", + ) + + +class EchoHandler: + """Return a fixed response while recording the derived identity.""" + + def __init__(self) -> None: + self.identity: PeerIdentity | None = None + + def handle(self, request: bytes, identity: PeerIdentity) -> bytes: + self.identity = identity + return request + b"\n" + + +class MemoryConnection: + """Minimal connection double for transport framing without sandbox sockets.""" + + def __init__(self, payload: bytes) -> None: + self.payload = payload + self.response = b"" + self.timeout: float | None = None + + def recv(self, _maximum: int) -> bytes: + payload, self.payload = self.payload, b"" + return payload + + def sendall(self, response: bytes) -> None: + self.response += response + + def settimeout(self, timeout: float) -> None: + self.timeout = timeout + + +@pytest.mark.unit +@pytest.mark.platform +class TestLinuxUnixSocketTransport: + """Verify one bounded local request is handled with kernel identity.""" + + def test_connection_uses_kernel_identity_not_payload_identity( + self, + ) -> None: + listener, listener_peer = socket.socketpair( + socket.AF_UNIX, + socket.SOCK_STREAM, + ) + connection = MemoryConnection(b'{"uid":0}\n') + handler = EchoHandler() + transport = LinuxUnixSocketTransport( + listener, + max_request_bytes=1_024, + stop_event=Event(), + ) + transport.identity_provider = SimpleNamespace( + peer_identity=lambda _connection: PeerIdentity( + f"linux-uid:{os.getuid()}", + os.getpid(), + ) + ) + try: + transport.serve_connection(connection, handler) # type: ignore[arg-type] + assert connection.response == b'{"uid":0}\n' + assert connection.timeout == 5.0 + finally: + listener.close() + listener_peer.close() + + assert handler.identity is not None + assert handler.identity.platform_id == f"linux-uid:{os.getuid()}" + + +@pytest.mark.unit +@pytest.mark.security +class TestSystemPolicyAndAssets: + """Verify strict policy ownership and least-privilege staged units.""" + + def test_packaged_policy_loads_with_explicit_retention_defaults(self) -> None: + policy = load_system_policy( + ASSET_DIRECTORY / "system-control-policy.json", + expected_owner=os.getuid(), + ) + + assert policy.operator_group == "timelocker-operators" + assert policy.transport_identifier == "/run/timelocker/control.sock" + assert policy.retention.group_by == ("host", "paths") + assert policy.retention.prune is False + assert policy.retention.approved_fingerprint is None + + def test_policy_rejects_group_writable_or_unknown_fields( + self, + tmp_path: Path, + ) -> None: + source = ASSET_DIRECTORY / "system-control-policy.json" + payload = json.loads(source.read_text()) + payload["repository_password"] = "secret" + policy_path = tmp_path / "policy.json" + policy_path.write_text(json.dumps(payload)) + policy_path.chmod(0o640) + + with pytest.raises(ValueError, match="unknown fields"): + load_system_policy(policy_path, expected_owner=os.getuid()) + + payload.pop("repository_password") + policy_path.write_text(json.dumps(payload)) + policy_path.chmod(0o660) + with pytest.raises(PermissionError, match="writable"): + load_system_policy(policy_path, expected_owner=os.getuid()) + + def test_socket_and_service_templates_enforce_narrow_boundary(self) -> None: + socket_unit = (ASSET_DIRECTORY / "timelocker-control.socket").read_text() + service_unit = (ASSET_DIRECTORY / "timelocker-control.service").read_text() + + assert "ListenStream=/run/timelocker/control.sock" in socket_unit + assert "SocketGroup=timelocker-operators" in socket_unit + assert "SocketMode=0660" in socket_unit + assert "User=root" in service_unit + assert "UMask=0077" in service_unit + assert "NoNewPrivileges=yes" in service_unit + assert "ProtectSystem=strict" in service_unit + assert "ProtectHome=yes" in service_unit + assert "RestrictAddressFamilies=AF_UNIX" in service_unit + assert "EnvironmentFile=" not in service_unit + assert "DISPLAY=" not in service_unit + assert "s3://" not in service_unit diff --git a/tests/TimeLocker/system_control/test_models.py b/tests/TimeLocker/system_control/test_models.py new file mode 100644 index 0000000..fac7400 --- /dev/null +++ b/tests/TimeLocker/system_control/test_models.py @@ -0,0 +1,279 @@ +"""Behavior tests for strict system-control value models.""" + +from dataclasses import FrozenInstanceError +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +import pytest + +from TimeLocker.system_control import ( + ActionReceipt, + DiagnosticCode, + DiagnosticComponent, + DiagnosticLevel, + DiagnosticRecord, + DiagnosticView, + OperationTrigger, + OperationType, + ResultCode, + RetentionPolicy, + RunRecord, + RunRecordView, + RunState, + RunTransition, + SystemPolicy, +) + + +NOW = datetime(2026, 7, 26, 12, 0, tzinfo=timezone.utc) + + +@pytest.mark.unit +class TestRunRecord: + """Validate state transitions and secret-free run projections.""" + + def test_valid_backup_success_projects_only_contract_fields(self) -> None: + record = RunRecord( + run_id=uuid4(), + operation=OperationType.BACKUP, + trigger=OperationTrigger.SCHEDULED, + target_id="npbackup-production", + started_at=NOW, + completed_at=NOW + timedelta(minutes=10), + state=RunState.SUCCEEDED, + result_code=ResultCode.BACKUP_SUCCEEDED, + counters={"files_processed": 42, "bytes_added": 1_024}, + ) + + view = RunRecordView.from_record(record) + wire = view.to_wire() + + assert wire["safe_summary"] == "Backup completed successfully." + assert wire["target_id"] == "npbackup-production" + assert wire["counters"] == {"files_processed": 42, "bytes_added": 1_024} + assert "environment" not in wire + assert "repository_uri" not in wire + assert "source_paths" not in wire + + @pytest.mark.parametrize( + ("state", "result_code", "completed_at"), + [ + (RunState.RUNNING, ResultCode.OPERATION_FAILED, None), + (RunState.FAILED, ResultCode.OPERATION_FAILED, None), + (RunState.SUCCEEDED, ResultCode.RETENTION_SUCCEEDED, NOW), + ], + ) + def test_inconsistent_state_is_rejected( + self, + state: RunState, + result_code: ResultCode, + completed_at: datetime | None, + ) -> None: + with pytest.raises(ValueError): + RunRecord( + run_id=uuid4(), + operation=OperationType.BACKUP, + trigger=OperationTrigger.SCHEDULED, + target_id="production", + started_at=NOW, + completed_at=completed_at, + state=state, + result_code=result_code, + ) + + @pytest.mark.parametrize( + "target_id", + [ + "/etc/timelocker", + "s3://private-bucket/repository", + "../root", + "contains spaces", + ], + ) + def test_target_id_cannot_encode_paths_or_repository_uris( + self, target_id: str + ) -> None: + with pytest.raises(ValueError): + RunRecord( + run_id=uuid4(), + operation=OperationType.BACKUP, + trigger=OperationTrigger.SCHEDULED, + target_id=target_id, + started_at=NOW, + state=RunState.RUNNING, + result_code=ResultCode.OPERATION_RUNNING, + ) + + def test_counters_are_bounded_and_immutable(self) -> None: + counters = {"files_processed": 1} + record = RunRecord( + run_id=uuid4(), + operation=OperationType.BACKUP, + trigger=OperationTrigger.SCHEDULED, + target_id="production", + started_at=NOW, + state=RunState.RUNNING, + result_code=ResultCode.OPERATION_RUNNING, + counters=counters, + ) + counters["files_processed"] = 99 + + assert record.counters["files_processed"] == 1 + with pytest.raises(TypeError): + record.counters["files_processed"] = 2 # type: ignore[index] + + def test_records_are_frozen(self) -> None: + record = RunRecord( + run_id=uuid4(), + operation=OperationType.RETENTION, + trigger=OperationTrigger.EXPLICIT, + target_id="production", + started_at=NOW, + state=RunState.RUNNING, + result_code=ResultCode.OPERATION_RUNNING, + policy_fingerprint="a" * 64, + ) + + with pytest.raises(FrozenInstanceError): + record.state = RunState.FAILED # type: ignore[misc] + + +@pytest.mark.unit +class TestRunTransition: + """Validate storage-independent state-transition commands.""" + + def test_running_to_terminal_transition_is_valid(self) -> None: + transition = RunTransition( + expected_states=frozenset({RunState.RUNNING}), + new_state=RunState.SUCCEEDED, + result_code=ResultCode.BACKUP_SUCCEEDED, + completed_at=NOW, + counters={"files_processed": 10}, + ) + + assert transition.new_state is RunState.SUCCEEDED + assert transition.counters["files_processed"] == 10 + + @pytest.mark.parametrize( + "transition", + [ + { + "expected_states": frozenset({RunState.SUCCEEDED}), + "new_state": RunState.FAILED, + "result_code": ResultCode.OPERATION_FAILED, + "completed_at": NOW, + }, + { + "expected_states": frozenset({RunState.RUNNING}), + "new_state": RunState.RUNNING, + "result_code": ResultCode.OPERATION_RUNNING, + }, + { + "expected_states": frozenset({RunState.RUNNING}), + "new_state": RunState.FAILED, + "result_code": ResultCode.OPERATION_FAILED, + }, + { + "expected_states": frozenset({RunState.QUEUED}), + "new_state": RunState.RUNNING, + "result_code": ResultCode.OPERATION_FAILED, + }, + ], + ) + def test_invalid_transition_is_rejected( + self, + transition: dict[str, object], + ) -> None: + with pytest.raises(ValueError): + RunTransition(**transition) # type: ignore[arg-type] + + +@pytest.mark.unit +@pytest.mark.security +class TestDiagnosticRecord: + """Prove diagnostic summaries cannot contain caller-controlled text.""" + + def test_safe_summary_is_derived_from_code(self) -> None: + record = DiagnosticRecord( + record_id=uuid4(), + run_id=uuid4(), + timestamp=NOW, + level=DiagnosticLevel.ERROR, + component=DiagnosticComponent.BACKUP, + message_code=DiagnosticCode.OPERATION_FAILED, + ) + + view = DiagnosticView.from_record(record).to_wire() + + assert view["safe_summary"] == "Operation failed." + assert set(view) == { + "record_id", + "run_id", + "timestamp", + "level", + "component", + "message_code", + "safe_summary", + } + + def test_raw_summary_is_not_an_input_field(self) -> None: + with pytest.raises(TypeError): + DiagnosticRecord( + record_id=uuid4(), + timestamp=NOW, + level=DiagnosticLevel.ERROR, + component=DiagnosticComponent.BACKUP, + message_code=DiagnosticCode.OPERATION_FAILED, + safe_summary="password=secret /protected/path", # type: ignore[call-arg] + ) + + +@pytest.mark.unit +class TestPolicyModels: + """Validate policy bounds and explicit retention defaults.""" + + def test_production_retention_defaults_are_explicit_and_non_pruning(self) -> None: + policy = SystemPolicy() + + assert policy.operator_group == "timelocker-operators" + assert policy.retention.keep_daily == 5 + assert policy.retention.keep_weekly == 4 + assert policy.retention.keep_monthly == 12 + assert policy.retention.keep_yearly == 3 + assert policy.retention.group_by == ("host", "paths") + assert policy.retention.prune is False + assert policy.retention.mutation_approved is False + + def test_policy_fingerprint_must_be_lowercase_sha256(self) -> None: + with pytest.raises(ValueError): + RetentionPolicy(approved_fingerprint="not-a-fingerprint") + + def test_unknown_grouping_field_is_rejected(self) -> None: + with pytest.raises(ValueError): + RetentionPolicy(group_by=("host", "paths", "tags")) + + def test_system_policy_rejects_unsupported_protocol_version(self) -> None: + with pytest.raises(ValueError, match="unsupported"): + SystemPolicy(protocol_version=2) + + +@pytest.mark.unit +class TestActionReceipt: + """Validate action acknowledgement consistency.""" + + def test_accepted_receipt_requires_run_id(self) -> None: + with pytest.raises(ValueError): + ActionReceipt( + request_id=uuid4(), + accepted=True, + status="accepted", + ) + + def test_denied_receipt_cannot_disclose_run_id(self) -> None: + with pytest.raises(ValueError): + ActionReceipt( + request_id=uuid4(), + accepted=False, + status="denied", + run_id=uuid4(), + ) diff --git a/tests/TimeLocker/system_control/test_protocol.py b/tests/TimeLocker/system_control/test_protocol.py new file mode 100644 index 0000000..c99fd1d --- /dev/null +++ b/tests/TimeLocker/system_control/test_protocol.py @@ -0,0 +1,407 @@ +"""Security and contract tests for system-control request parsing.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest + +from TimeLocker.system_control import ( + ProtocolErrorCode, + RequestEnvelope, + ResponseEnvelope, + ResponseStatus, + SystemAction, + project_response, +) + + +def request_payload( + action: str = "health", + parameters: dict[str, object] | None = None, +) -> dict[str, object]: + """Build one otherwise-valid protocol request.""" + return { + "protocol_version": 1, + "request_id": str(uuid4()), + "action": action, + "parameters": parameters or {}, + } + + +def run_payload(**overrides: object) -> dict[str, object]: + """Build a complete projected run with optional malicious fields.""" + payload: dict[str, object] = { + "run_id": str(uuid4()), + "operation": "backup", + "trigger": "scheduled", + "target_id": "production", + "started_at": datetime(2026, 7, 26, 3, 30, tzinfo=timezone.utc).isoformat(), + "completed_at": datetime(2026, 7, 26, 3, 45, tzinfo=timezone.utc).isoformat(), + "state": "succeeded", + "result_code": "backup_succeeded", + "safe_summary": "untrusted text", + "policy_fingerprint": None, + "counters": {"files_processed": 10}, + } + payload.update(overrides) + return payload + + +def diagnostic_payload(**overrides: object) -> dict[str, object]: + """Build a complete projected diagnostic with optional malicious fields.""" + payload: dict[str, object] = { + "record_id": str(uuid4()), + "run_id": str(uuid4()), + "timestamp": datetime(2026, 7, 26, 3, 45, tzinfo=timezone.utc).isoformat(), + "level": "error", + "component": "backup", + "message_code": "operation_failed", + "safe_summary": "untrusted text", + } + payload.update(overrides) + return payload + + +@pytest.mark.unit +@pytest.mark.security +class TestRequestEnvelope: + """Reject payload identity, secrets, arbitrary paths, and unbounded input.""" + + def test_valid_request_is_frozen_and_normalized(self) -> None: + request = RequestEnvelope.from_mapping( + request_payload( + "run.list", + {"limit": 25, "operation": "backup", "state": "succeeded"}, + ) + ) + + assert request.action is SystemAction.RUN_LIST + assert request.parameters == { + "limit": 25, + "operation": "backup", + "state": "succeeded", + } + assert request.to_wire()["action"] == "run.list" + with pytest.raises(TypeError): + request.parameters["limit"] = 50 # type: ignore[index] + + @pytest.mark.parametrize( + "field", + [ + "password", + "environment", + "executable_path", + "arguments", + "uid", + "groups", + "authorized", + ], + ) + def test_unknown_or_identity_fields_are_rejected(self, field: str) -> None: + payload = request_payload("backup.request", {"target_id": "production"}) + parameters = payload["parameters"] + assert isinstance(parameters, dict) + parameters[field] = "attacker-controlled" + + with pytest.raises(ValueError, match="unknown fields"): + RequestEnvelope.from_mapping(payload) + + def test_unknown_envelope_field_is_rejected(self) -> None: + payload = request_payload() + payload["repository_password"] = "secret" + + with pytest.raises(ValueError, match="unknown fields"): + RequestEnvelope.from_mapping(payload) + + @pytest.mark.parametrize( + "target_id", + [ + "/root", + r"C:\Users\Administrator", + "s3://bucket/private", + "../etc", + ], + ) + def test_backup_request_rejects_paths_and_uris(self, target_id: str) -> None: + with pytest.raises(ValueError): + RequestEnvelope.from_mapping( + request_payload("backup.request", {"target_id": target_id}) + ) + + def test_retention_request_requires_exact_policy_fingerprint(self) -> None: + request = RequestEnvelope.from_mapping( + request_payload( + "retention.request", + {"policy_fingerprint": "a" * 64, "dry_run": True}, + ) + ) + + assert request.parameters["dry_run"] is True + with pytest.raises(ValueError): + RequestEnvelope.from_mapping( + request_payload( + "retention.request", + {"policy_fingerprint": "A" * 64}, + ) + ) + + @pytest.mark.parametrize("version", [0, 2, True, "1"]) + def test_unsupported_or_mistyped_protocol_version_is_rejected( + self, + version: object, + ) -> None: + payload = request_payload() + payload["protocol_version"] = version + + with pytest.raises((TypeError, ValueError)): + RequestEnvelope.from_mapping(payload) + + def test_query_limit_is_bounded_and_bool_is_not_an_integer(self) -> None: + for limit in (0, 1_001, True): + with pytest.raises((TypeError, ValueError)): + RequestEnvelope.from_mapping( + request_payload("run.list", {"limit": limit}) + ) + + +@pytest.mark.unit +@pytest.mark.security +class TestResponseProjection: + """Ensure backend-only and secret-bearing fields never reach clients.""" + + def test_run_projection_copies_only_allowlisted_fields(self) -> None: + projected = project_response( + SystemAction.RUN_LIST, + { + "runs": [ + run_payload( + safe_summary="password=secret /protected/path", + repository_uri="s3://private/repository", + environment={"AWS_SECRET_ACCESS_KEY": "secret"}, + source_paths=["/root"], + raw_output="sensitive output", + peer_uid=1000, + ) + ], + "audit": {"account": "another-user"}, + }, + ) + + run = projected["runs"][0] + assert run["safe_summary"] == "Backup completed successfully." + assert run["target_id"] == "production" + assert "repository_uri" not in run + assert "environment" not in run + assert "source_paths" not in run + assert "raw_output" not in run + assert "peer_uid" not in run + + def test_diagnostic_projection_drops_raw_exception_and_audit_identity(self) -> None: + projected = project_response( + SystemAction.DIAGNOSTIC_LIST, + { + "diagnostics": [ + diagnostic_payload( + safe_summary="password=secret /protected/path", + raw_exception="password=secret", + peer_uid=1000, + account_name="operator", + ) + ] + }, + ) + + diagnostic = projected["diagnostics"][0] + assert diagnostic["safe_summary"] == "Operation failed." + assert "raw_exception" not in diagnostic + assert "peer_uid" not in diagnostic + assert "account_name" not in diagnostic + + def test_response_count_is_bounded(self) -> None: + with pytest.raises(ValueError, match="bound"): + project_response( + SystemAction.RUN_LIST, + {"runs": [{} for _ in range(1_001)]}, + ) + + def test_detail_health_schedule_ui_and_receipt_are_strictly_projected(self) -> None: + detail = project_response( + SystemAction.RUN_DETAIL, + {"run": run_payload(repository_password="secret")}, + ) + health = project_response( + SystemAction.HEALTH, + { + "backend_available": True, + "protocol_min": 1, + "protocol_max": 1, + "internal_path": "/var/lib/timelocker", + }, + ) + schedule = project_response( + SystemAction.SCHEDULE_SUMMARY, + { + "next_backup_at": "2026-07-27T03:30:00+00:00", + "next_retention_at": None, + }, + ) + ui = project_response(SystemAction.UI_AVAILABILITY, {"available": False}) + receipt = project_response( + SystemAction.BACKUP_REQUEST, + { + "request_id": str(uuid4()), + "accepted": True, + "status": "accepted", + "run_id": str(uuid4()), + "raw_arguments": ["--password-file", "/secret"], + }, + ) + + assert detail["run"]["safe_summary"] == "Backup completed successfully." + assert "repository_password" not in detail["run"] + assert health == { + "backend_available": True, + "protocol_min": 1, + "protocol_max": 1, + } + assert schedule["next_retention_at"] is None + assert ui == {"available": False} + assert "raw_arguments" not in receipt + + @pytest.mark.parametrize( + ("action", "payload"), + [ + ( + SystemAction.HEALTH, + {"backend_available": "yes", "protocol_min": 1, "protocol_max": 1}, + ), + ( + SystemAction.SCHEDULE_SUMMARY, + {"next_backup_at": "/secret", "next_retention_at": None}, + ), + (SystemAction.UI_AVAILABILITY, {"available": 1}), + ( + SystemAction.BACKUP_REQUEST, + {"request_id": str(uuid4()), "accepted": True, "status": "accepted"}, + ), + ], + ) + def test_invalid_projected_response_shapes_are_rejected( + self, + action: SystemAction, + payload: dict[str, object], + ) -> None: + with pytest.raises((TypeError, ValueError)): + project_response(action, payload) + + +@pytest.mark.unit +@pytest.mark.security +class TestResponseEnvelope: + """Validate success projection and metadata-free error envelopes.""" + + def test_success_response_is_projected_and_recursively_frozen(self) -> None: + request_id = uuid4() + response = ResponseEnvelope.success( + request_id, + SystemAction.RUN_LIST, + { + "runs": [ + run_payload( + safe_summary="raw untrusted text", + environment={"PASSWORD": "secret"}, + ) + ] + }, + ) + + wire = response.to_wire() + + assert wire["request_id"] == str(request_id) + assert wire["result"]["runs"][0]["safe_summary"] == ( + "Backup completed successfully." + ) + assert "environment" not in wire["result"]["runs"][0] + with pytest.raises(TypeError): + response.result["runs"] = () # type: ignore[index,union-attr] + + def test_error_response_summary_is_owned_by_error_code(self) -> None: + response = ResponseEnvelope.error( + uuid4(), + ResponseStatus.DENIED, + ProtocolErrorCode.SYSTEM_ACCESS_DENIED, + ) + + assert response.safe_summary == "System access denied." + assert response.result is None + + def test_untrusted_error_summary_is_rejected(self) -> None: + payload = { + "protocol_version": 1, + "request_id": str(uuid4()), + "status": "denied", + "result": None, + "error_code": "system_access_denied", + "safe_summary": "Repository production exists at /protected/path", + } + + with pytest.raises(ValueError, match="stable error code"): + ResponseEnvelope.from_mapping(payload, action=SystemAction.RUN_LIST) + + def test_success_response_rejects_error_fields(self) -> None: + with pytest.raises(ValueError): + ResponseEnvelope( + request_id=uuid4(), + status=ResponseStatus.OK, + result={}, + error_code=ProtocolErrorCode.OPERATION_FAILED, + safe_summary="System operation failed.", + ) + + def test_success_response_round_trip_reprojects_untrusted_result(self) -> None: + request_id = uuid4() + payload = { + "protocol_version": 1, + "request_id": str(request_id), + "status": "ok", + "result": {"runs": [run_payload(environment={"PASSWORD": "secret"})]}, + "error_code": None, + "safe_summary": None, + } + + response = ResponseEnvelope.from_mapping( + payload, + action=SystemAction.RUN_LIST, + ) + + assert response.to_wire()["request_id"] == str(request_id) + assert "environment" not in response.to_wire()["result"]["runs"][0] + + @pytest.mark.parametrize( + "kwargs", + [ + {"status": ResponseStatus.OK, "result": None}, + { + "status": ResponseStatus.DENIED, + "result": {}, + "error_code": ProtocolErrorCode.SYSTEM_ACCESS_DENIED, + "safe_summary": "System access denied.", + }, + {"status": ResponseStatus.DENIED, "result": None}, + {"status": ResponseStatus.OK, "result": {}, "protocol_version": 2}, + ], + ) + def test_response_envelope_rejects_inconsistent_shapes( + self, + kwargs: dict[str, object], + ) -> None: + with pytest.raises((TypeError, ValueError)): + ResponseEnvelope(request_id=uuid4(), **kwargs) # type: ignore[arg-type] + + def test_error_builder_rejects_ok_status(self) -> None: + with pytest.raises(ValueError): + ResponseEnvelope.error( + uuid4(), + ResponseStatus.OK, + ProtocolErrorCode.OPERATION_FAILED, + ) diff --git a/tests/TimeLocker/system_control/test_storage.py b/tests/TimeLocker/system_control/test_storage.py new file mode 100644 index 0000000..cea3894 --- /dev/null +++ b/tests/TimeLocker/system_control/test_storage.py @@ -0,0 +1,296 @@ +"""Crash-safety and concurrency tests for system-control durable state.""" + +from datetime import datetime, timedelta, timezone +import multiprocessing +from pathlib import Path +from uuid import UUID, uuid4 + +import pytest + +from TimeLocker.system_control import ( + AtomicRecordStore, + DiagnosticCode, + DiagnosticComponent, + DiagnosticLevel, + DiagnosticQuery, + DiagnosticRecord, + InvalidTransitionError, + MutationConflictError, + OperationTrigger, + OperationType, + RecordCorruptionError, + RepositoryMutationLock, + ResultCode, + RunQuery, + RunRecord, + RunState, + RunTransition, + reconcile_abandoned_runs, +) + + +NOW = datetime(2026, 7, 26, 12, 0, tzinfo=timezone.utc) + + +def running_record( + *, run_id: UUID | None = None, target_id: str = "production" +) -> RunRecord: + """Create one valid running backup record.""" + return RunRecord( + run_id=run_id or uuid4(), + operation=OperationType.BACKUP, + trigger=OperationTrigger.SCHEDULED, + target_id=target_id, + started_at=NOW, + state=RunState.RUNNING, + result_code=ResultCode.OPERATION_RUNNING, + ) + + +def _hold_lease(lock_root: str, target_id: str, run_id: str, ready: object) -> None: + lock = RepositoryMutationLock(Path(lock_root)) + lease = lock.acquire(target_id, run_id) + ready.set() + try: + ready.wait(10) + finally: + lease.release() + + +@pytest.mark.unit +class TestAtomicRecordStore: + """Verify durable state, strict parsing, and terminal-state compare-and-swap.""" + + def test_record_survives_store_recreation(self, tmp_path: Path) -> None: + record = running_record() + AtomicRecordStore(tmp_path).create_run(record) + + restored = AtomicRecordStore(tmp_path).read_run(record.run_id) + + assert restored == record + assert ( + tmp_path / "runs" / f"{record.run_id}.json" + ).stat().st_mode & 0o777 == 0o600 + + def test_exactly_one_terminal_transition_wins(self, tmp_path: Path) -> None: + store = AtomicRecordStore(tmp_path) + record = running_record() + store.create_run(record) + succeeded = store.transition( + record.run_id, + RunTransition( + expected_states=frozenset({RunState.RUNNING}), + new_state=RunState.SUCCEEDED, + result_code=ResultCode.BACKUP_SUCCEEDED, + completed_at=NOW + timedelta(minutes=1), + ), + ) + + with pytest.raises(InvalidTransitionError): + store.transition( + record.run_id, + RunTransition( + expected_states=frozenset({RunState.RUNNING}), + new_state=RunState.FAILED, + result_code=ResultCode.OPERATION_FAILED, + completed_at=NOW + timedelta(minutes=2), + ), + ) + + assert store.read_run(record.run_id) == succeeded + + def test_transition_merges_bounded_counters(self, tmp_path: Path) -> None: + store = AtomicRecordStore(tmp_path) + record = running_record() + store.create_run(record) + + result = store.transition( + record.run_id, + RunTransition( + expected_states=frozenset({RunState.RUNNING}), + new_state=RunState.SUCCEEDED, + result_code=ResultCode.BACKUP_SUCCEEDED, + completed_at=NOW + timedelta(minutes=1), + counters={"files_processed": 42}, + ), + ) + + assert result.counters == {"files_processed": 42} + + def test_corrupt_or_unknown_record_fields_fail_closed(self, tmp_path: Path) -> None: + store = AtomicRecordStore(tmp_path) + record = running_record() + store.create_run(record) + path = tmp_path / "runs" / f"{record.run_id}.json" + path.write_text('{"run_id":"secret","repository_password":"value"}\n') + + with pytest.raises(RecordCorruptionError, match="corrupt"): + store.read_run(record.run_id) + + def test_run_queries_are_filtered_and_bounded(self, tmp_path: Path) -> None: + store = AtomicRecordStore(tmp_path) + backup = running_record(target_id="backup") + retention = RunRecord( + run_id=uuid4(), + operation=OperationType.RETENTION, + trigger=OperationTrigger.EXPLICIT, + target_id="retention", + started_at=NOW + timedelta(minutes=1), + state=RunState.RUNNING, + result_code=ResultCode.OPERATION_RUNNING, + policy_fingerprint="a" * 64, + ) + store.create_run(backup) + store.create_run(retention) + + assert store.list_runs(RunQuery(limit=1))[0] == retention + assert store.list_runs(RunQuery(limit=10, operation=OperationType.BACKUP)) == [ + backup + ] + + def test_diagnostic_stream_is_bounded_and_filtered(self, tmp_path: Path) -> None: + store = AtomicRecordStore(tmp_path, max_diagnostics=2) + run_id = uuid4() + records = [ + DiagnosticRecord( + record_id=uuid4(), + run_id=run_id, + timestamp=NOW + timedelta(seconds=index), + level=DiagnosticLevel.ERROR if index == 2 else DiagnosticLevel.INFO, + component=DiagnosticComponent.RUN_STORE, + message_code=DiagnosticCode.OPERATION_FAILED, + ) + for index in range(3) + ] + for record in records: + store.append_diagnostic(record) + + assert store.list_diagnostics() == [records[2], records[1]] + assert store.list_diagnostics( + DiagnosticQuery(limit=10, level=DiagnosticLevel.ERROR) + ) == [records[2]] + + +@pytest.mark.unit +@pytest.mark.filesystem +class TestRepositoryMutationLock: + """Verify kernel leases reject overlap and recover after process exit.""" + + def test_same_repository_cannot_be_mutated_concurrently( + self, tmp_path: Path + ) -> None: + locks = RepositoryMutationLock(tmp_path) + first = locks.acquire("production", uuid4()) + try: + with pytest.raises(MutationConflictError): + locks.acquire("production", uuid4()) + finally: + first.release() + + def test_different_repositories_have_independent_locks( + self, tmp_path: Path + ) -> None: + locks = RepositoryMutationLock(tmp_path) + with ( + locks.acquire("production-a", uuid4()), + locks.acquire("production-b", uuid4()), + ): + pass + + def test_process_exit_releases_kernel_lease(self, tmp_path: Path) -> None: + context = multiprocessing.get_context("spawn") + ready = context.Event() + run_id = uuid4() + process = context.Process( + target=_hold_lease, + args=(str(tmp_path), "production", str(run_id), ready), + ) + process.start() + assert ready.wait(10) + process.terminate() + process.join(10) + assert process.exitcode is not None + + with RepositoryMutationLock(tmp_path).acquire("production", uuid4()): + pass + + +@pytest.mark.unit +@pytest.mark.filesystem +class TestStartupReconciliation: + """Verify abandoned attempts become interrupted exactly once.""" + + def test_abandoned_run_is_interrupted_and_lock_reusable( + self, tmp_path: Path + ) -> None: + store = AtomicRecordStore(tmp_path / "state") + locks = RepositoryMutationLock(tmp_path / "locks") + record = running_record() + store.create_run(record) + stale = locks.acquire(record.target_id, record.run_id) + stale.release() + + reconciled = reconcile_abandoned_runs( + store, + locks, + now=NOW + timedelta(hours=1), + ) + + assert len(reconciled) == 1 + assert reconciled[0].state is RunState.INTERRUPTED + assert ( + reconcile_abandoned_runs( + store, + locks, + now=NOW + timedelta(hours=2), + ) + == [] + ) + with locks.acquire(record.target_id, uuid4()): + pass + + def test_live_matching_lease_is_not_interrupted(self, tmp_path: Path) -> None: + store = AtomicRecordStore(tmp_path / "state") + locks = RepositoryMutationLock(tmp_path / "locks") + record = running_record() + store.create_run(record) + with locks.acquire(record.target_id, record.run_id): + assert reconcile_abandoned_runs(store, locks, now=NOW) == [] + assert store.read_run(record.run_id).state is RunState.RUNNING + + def test_newer_live_lease_does_not_block_old_run_reconciliation( + self, + tmp_path: Path, + ) -> None: + store = AtomicRecordStore(tmp_path / "state") + locks = RepositoryMutationLock(tmp_path / "locks") + old_record = running_record() + store.create_run(old_record) + newer_run_id = uuid4() + + with locks.acquire(old_record.target_id, newer_run_id): + reconciled = reconcile_abandoned_runs( + store, + locks, + now=NOW + timedelta(minutes=1), + ) + assert reconciled[0].run_id == old_record.run_id + assert reconciled[0].state is RunState.INTERRUPTED + assert locks.is_active(old_record.target_id, newer_run_id) + + def test_clock_rollback_does_not_make_reconciliation_invalid( + self, + tmp_path: Path, + ) -> None: + store = AtomicRecordStore(tmp_path / "state") + locks = RepositoryMutationLock(tmp_path / "locks") + record = running_record() + store.create_run(record) + + reconciled = reconcile_abandoned_runs( + store, + locks, + now=NOW - timedelta(hours=1), + ) + + assert reconciled[0].completed_at == record.started_at diff --git a/tests/TimeLocker/system_control/test_validation.py b/tests/TimeLocker/system_control/test_validation.py new file mode 100644 index 0000000..d78be13 --- /dev/null +++ b/tests/TimeLocker/system_control/test_validation.py @@ -0,0 +1,90 @@ +"""Boundary tests for strict system-control validation helpers.""" + +from datetime import datetime +from uuid import uuid4 + +import pytest + +from TimeLocker.system_control.types import RunState +from TimeLocker.system_control.validation import ( + deep_freeze, + freeze_counters, + require_enum, + require_exact_mapping, + require_group_name, + require_safe_identifier, + require_uuid, + require_wire_utc_datetime, +) + + +@pytest.mark.unit +@pytest.mark.security +class TestStrictValidation: + """Exercise malformed values at the untrusted protocol boundary.""" + + def test_exact_mapping_rejects_non_mapping_missing_and_non_string_keys( + self, + ) -> None: + with pytest.raises(TypeError): + require_exact_mapping( + [], + field="payload", + required=frozenset({"id"}), + ) + with pytest.raises(ValueError, match="missing"): + require_exact_mapping( + {}, + field="payload", + required=frozenset({"id"}), + ) + with pytest.raises(TypeError, match="keys"): + require_exact_mapping( + {1: "value"}, + field="payload", + required=frozenset(), + optional=frozenset({"id"}), + ) + + def test_enum_rejects_wrong_type_and_unknown_value(self) -> None: + with pytest.raises(TypeError): + require_enum(1, RunState, field="state") + with pytest.raises(ValueError, match="unsupported"): + require_enum("secret-state", RunState, field="state") + + def test_uuid_rejects_wrong_type_invalid_and_noncanonical_value(self) -> None: + with pytest.raises(TypeError): + require_uuid(123, field="id") + with pytest.raises(ValueError, match="valid UUID"): + require_uuid("not-a-uuid", field="id") + uppercase = str(uuid4()).upper() + with pytest.raises(ValueError, match="canonical"): + require_uuid(uppercase, field="id") + + def test_safe_identifiers_and_groups_reject_wrong_types(self) -> None: + with pytest.raises(TypeError): + require_safe_identifier(123, field="target") + with pytest.raises(TypeError): + require_group_name(123) + with pytest.raises(ValueError): + require_group_name("Invalid Group") + + def test_wire_timestamp_requires_valid_aware_utc_iso_value(self) -> None: + with pytest.raises(TypeError): + require_wire_utc_datetime(datetime.now(), field="timestamp") + with pytest.raises(ValueError, match="valid ISO"): + require_wire_utc_datetime("not-a-timestamp", field="timestamp") + with pytest.raises(ValueError, match="UTC"): + require_wire_utc_datetime("2026-07-26T12:00:00", field="timestamp") + + def test_counters_and_recursive_freeze_reject_unbounded_or_invalid_maps( + self, + ) -> None: + with pytest.raises(TypeError): + freeze_counters([]) + with pytest.raises(ValueError, match="at most"): + freeze_counters({f"count_{index}": index for index in range(9)}) + with pytest.raises(ValueError, match="snake_case"): + freeze_counters({"Invalid Counter": 1}) + with pytest.raises(TypeError, match="keys"): + deep_freeze({1: "value"}) From b268b4cc84af2e1a89b2e1247f7dfb5c3e7b32a6 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:10:52 +0100 Subject: [PATCH 35/72] feat: complete spec 009 phase 2 system cli Add immutable release routing, staged launcher assets, and the bounded system-control client. Expose authorized structured run and diagnostic views while preserving local log behavior and recording Phase 2 verification evidence. --- .../009-system-cli-tray-retention/tasks.md | 42 +- .../verification.md | 60 +- docs/specs/README.md | 14 +- pyproject.toml | 2 + src/TimeLocker/cli.py | 1779 +++++++++++------ .../cli_modules/commands/monitoring.py | 960 +++++---- src/TimeLocker/system_control/__init__.py | 16 + .../system_control/action_policy.py | 214 ++ .../system_control/assets/timelocker-launcher | 7 + .../assets/timelocker-release-select | 5 + .../system_control/assets/tl-launcher | 6 + src/TimeLocker/system_control/client.py | 187 ++ .../system_control/launcher_entry.py | 22 + .../system_control/release_admin.py | 28 + .../system_control/release_launcher.py | 284 +++ .../cli/test_monitoring_commands.py | 211 +- .../system_control/test_action_policy.py | 59 + .../TimeLocker/system_control/test_client.py | 275 +++ .../test_release_entrypoints.py | 68 + .../system_control/test_release_launcher.py | 164 ++ 20 files changed, 3446 insertions(+), 957 deletions(-) create mode 100644 src/TimeLocker/system_control/action_policy.py create mode 100644 src/TimeLocker/system_control/assets/timelocker-launcher create mode 100644 src/TimeLocker/system_control/assets/timelocker-release-select create mode 100644 src/TimeLocker/system_control/assets/tl-launcher create mode 100644 src/TimeLocker/system_control/client.py create mode 100644 src/TimeLocker/system_control/launcher_entry.py create mode 100644 src/TimeLocker/system_control/release_admin.py create mode 100644 src/TimeLocker/system_control/release_launcher.py create mode 100644 tests/TimeLocker/system_control/test_action_policy.py create mode 100644 tests/TimeLocker/system_control/test_client.py create mode 100644 tests/TimeLocker/system_control/test_release_entrypoints.py create mode 100644 tests/TimeLocker/system_control/test_release_launcher.py diff --git a/docs/specs/009-system-cli-tray-retention/tasks.md b/docs/specs/009-system-cli-tray-retention/tasks.md index 0c7f590..f6d5663 100644 --- a/docs/specs/009-system-cli-tray-retention/tasks.md +++ b/docs/specs/009-system-cli-tray-retention/tasks.md @@ -112,7 +112,7 @@ T009 -> T010 -> T011 -> T012 - Status: Phase 1 complete. Real socket activation, installed permissions, live NSS, host restart, and Windows implementation remain assigned to later tasks. ## Phase 2: System CLI and authorized visibility -- [ ] T005 Implement the root-owned system launcher and centralized action +- [x] T005 Implement the root-owned system launcher and centralized action classification. - Depends on: T004 - Requirements: Requirement 1 AC1-AC4; Requirement 2 AC1-AC6; @@ -122,13 +122,24 @@ T009 -> T010 -> T011 -> T012 - Acceptance: `timelocker` and `tl` resolve one immutable release; user-local actions remain unprivileged; protected actions use the backend; invalid release or unknown action fails closed without pyenv/checkout fallback. - - Evidence: Pending. - - [ ] T005.1 Add launcher resolution, rollback, recursion, and routing tests. - - [ ] T005.2 Implement immutable release launcher and action classifier. - - [ ] T005.3 Add staged install/rollback assets without changing the live + - Evidence: The 22-test launcher/action-policy subset passed inside the 177-test Phase 2 run. `release_launcher.py` and `action_policy.py` enforce exact routing, immutable release resolution, and fail-closed unknown actions; the built wheel inventory contains the staged selector plus both command launcher assets. Ruff and `git diff --check` passed; no host state changed. + - Status: Phase 2 launcher/classification contract complete; live artifact integration and authorization-agent acceptance remain T009/T010. + - Evidence mode: validation + - [x] T005.1 Add launcher resolution, rollback, recursion, and routing tests. + - Evidence: 22 focused launcher/action-policy tests passed in the Phase 2 integrated test run; coverage includes resolution, switch/rollback, recursion, invalid ownership/modes/symlinks, alias compatibility, protected routing, and unknown-action denial. + - Status: Verified without changing the live selected release. + - Evidence mode: validation + - [x] T005.2 Implement immutable release launcher and action classifier. + - Evidence: `src/TimeLocker/system_control/release_launcher.py` contains `ImmutableReleaseResolver`, strict selector/manifest validation, recursion protection, and atomic selection/rollback; `src/TimeLocker/system_control/action_policy.py` contains the exact fail-closed registry. The 22-test launcher/action-policy subset and Ruff checks passed. + - Status: Repository implementation verified; live launcher installation remains T009/T010. + - Evidence mode: implementation + - [x] T005.3 Add staged install/rollback assets without changing the live selected release. -- [ ] T006 Add structured system run and diagnostic CLI views. + - Evidence: The no-isolation wheel build passed and its inventory contains `timelocker-launcher`, `tl-launcher`, and `timelocker-release-select` plus their module entry points. `test_release_entrypoints.py` and `test_release_launcher.py` prove the assets do not use pyenv, a checkout, or `/root` overlay fallback. + - Status: Assets are staged only; `/opt`, `/usr/local/bin`, systemd, and the host selector were not modified. + - Evidence mode: validation +- [x] T006 Add structured system run and diagnostic CLI views. - Depends on: T005 - Requirements: Requirement 4 AC1-AC3, AC6, AC8-AC11 - Properties: CP-004, CP-006, CP-007, CP-011 @@ -137,12 +148,23 @@ T009 -> T010 -> T011 -> T012 - Acceptance: `runs list`, `runs show`, and `logs view --scope local|system` clearly distinguish local and system data; only current operator-group members receive protected structured records. - - Evidence: Pending. - - [ ] T006.1 Add CLI contract, compatibility, denial, and redaction tests. - - [ ] T006.2 Implement focused `SystemControlClient` integration. - - [ ] T006.3 Preserve local log behavior and correct `--config-dir`/scope + - Evidence: Completed structured system run and diagnostic CLI views plus the bounded system-control client. The integrated system-control/CLI/help suite passed 177 tests; scoped system-control coverage is 88.2%. Ruff check and format, compileall, wheel build and asset inventory, and git diff --check passed. Review findings TLR-006 through TLR-009 were fixed. No host state changed. + - Status: Phase 2 complete. Live socket, installed launcher, current NSS membership, and authorized/denied host acceptance remain T009/T010. + - Evidence mode: validation + - [x] T006.1 Add CLI contract, compatibility, denial, and redaction tests. + - Evidence: Added CLI contract, compatibility, denial, redaction, bounded-filter, scope-validation, and protected-metadata tests. The integrated Phase 2 suite passed 177 tests; denied requests expose only safe result codes and summaries. + - Status: Verified in the integrated Phase 2 suite; live authorized and denied host acceptance remains T010. + - Evidence mode: validation + - [x] T006.2 Implement focused `SystemControlClient` integration. + - Evidence: `src/TimeLocker/system_control/client.py` provides bounded versioned Unix-socket requests, request-ID correlation, timeouts, strict line framing, safe errors, run list/show, diagnostics, and backup/retention requests. `tests/TimeLocker/system_control/test_client.py` passed within the 177-test Phase 2 run. + - Status: Repository client boundary verified; real AF_UNIX socket activation remains T009/T010. + - Evidence mode: validation + - [x] T006.3 Preserve local log behavior and correct `--config-dir`/scope resolution without reading protected files directly. + - Evidence: `src/TimeLocker/cli_modules/commands/monitoring.py` now provides `runs list`, `runs show`, and `logs view --scope local|system`; local logs resolve from the explicit config directory while system scope requests only backend records. `tests/TimeLocker/cli/test_monitoring_commands.py` passed within the 177-test run, including default/local compatibility, invalid scope/limits, and no direct protected-read cases. + - Status: No protected system file or journal is read directly by the user CLI. + - Evidence mode: validation ## Phase 3: Independent tray and retention - [ ] T007 Remove tray ownership from CLI/headless services and add the diff --git a/docs/specs/009-system-cli-tray-retention/verification.md b/docs/specs/009-system-cli-tray-retention/verification.md index 5008b10..f549664 100644 --- a/docs/specs/009-system-cli-tray-retention/verification.md +++ b/docs/specs/009-system-cli-tray-retention/verification.md @@ -21,9 +21,9 @@ review, durable promotion, and closure. |------|-----------|--------|----------| | Requirements acceptance criteria reviewed | yes | pending | Requirements amended through 2026-07-26 | | Design and traceability approved | yes | passed | Owner approved implementation; lifecycle context reports no Phase 1 gaps | -| Task evidence complete | yes | partial | T001-T004 complete; T005-T012 pending | -| Automated tests pass or alternate verification recorded | yes | partial | Phase 1 focused suite: 98 passed, 88.4% coverage | -| Security and operations expert review complete | yes | partial | T004 checkpoint complete; final T012 review pending | +| Task evidence complete | yes | partial | T001-T006 complete; T007-T012 pending | +| Automated tests pass or alternate verification recorded | yes | partial | Phase 2 focused suite: 177 passed; system-control package 88.2% branch-aware coverage | +| Security and operations expert review complete | yes | partial | T004 and Phase 2 checkpoints complete; final T012 review pending | | Linux Mint live acceptance and rollback rehearsal complete | yes | pending | | | Durable documentation promoted | yes | pending | | | Governance or policy conflicts resolved | yes | pending | | @@ -66,10 +66,10 @@ Commands are refined through Agent Workbench before execution. | Requirement | Acceptance criteria covered | Evidence | Residual risk | |-------------|-----------------------------|----------|---------------| -| Requirement 1 | AC1-AC4 | V5, V9, V10 pending | Live launcher/rollback | +| Requirement 1 | AC1-AC4 | V5 repository validation passed; V9-V10 pending | Live launcher/rollback | | Requirement 2 | AC1-AC6 | V2, V4-V5, V10 pending | Platform authorization UX | | Requirement 3 | AC1-AC8 | V7, V9-V10 pending | Desktop diversity | -| Requirement 4 | AC1-AC11 | V1-V4, V6, V10 pending | Redaction and NSS variance | +| Requirement 4 | AC1-AC11 | V1-V3 and V6 repository validation passed; V4 and V10 live evidence pending | Redaction and NSS variance | | Requirement 5 | AC1-AC11 | V1, V3, V8, V10 pending | Live repository timing | | Requirement 6 | AC1-AC6 | V3, V5, V7, V9-V10 pending | Cross-platform rollout | @@ -77,24 +77,24 @@ Commands are refined through Agent Workbench before execution. | Property | Covered by | Evidence | Residual risk | |----------|------------|----------|---------------| -| CP-001 | V2, V5 | pending | | +| CP-001 | V2, V5 | repository validation passed | Live platform authorization remains V10 | | CP-002 | V7, V10 | pending | | | CP-003 | V3, V8, V10 | pending | | -| CP-004 | V1, V3, V6, V8 | pending | | +| CP-004 | V1, V3, V6, V8 | V1, V3, and V6 repository validation passed | Retention coverage remains V8 | | CP-005 | V1, V8, V10 | pending | | -| CP-006 | V1-V2, V4-V6 | pending | | -| CP-007 | V2, V4, V10 | pending | | +| CP-006 | V1-V2, V4-V6 | V1-V3 and V5-V6 repository validation passed | Live IPC remains V4/V10 | +| CP-007 | V2, V4, V10 | repository authorization and denial validation passed | Live NSS/session behavior remains V4/V10 | | CP-008 | V3, V9-V10 | pending | | | CP-009 | V1, V7, V9 | pending | Live Windows remains follow-up | | CP-010 | V3, V8, V10 | pending | | -| CP-011 | V1-V2, V4, V6, V10 | pending | | +| CP-011 | V1-V2, V4, V6, V10 | repository projection and CLI validation passed | Live metadata-leak acceptance remains V10 | ## Scope Reconciliation Before Closure | Broad requirement, design target, or review finding | Implemented in this spec | Coverage state | Deferred or rejected work | Destination | Blocks closure? | Evidence | |-----------------------------------------------------|--------------------------|----------------|---------------------------|-------------|-----------------|----------| -| Linux system command/control plane | none | not-covered | Implementation pending | T001-T006, T009-T010 | yes | pending | -| Group-authorized system records | none | not-covered | Implementation pending | T001-T004, T006 | yes | pending | +| Linux system command/control plane | Shared contracts, store, dispatcher, staged launcher, and CLI client/views | partial | Live artifact integration and host acceptance | T009-T010 | yes | T001-T006 evidence | +| Group-authorized system records | Current-membership dispatcher and structured CLI projection | partial | Live NSS/socket acceptance | T009-T010 | yes | T003, T004, T006 evidence | | Independent tray | none | not-covered | Implementation pending | T007, T009-T010 | yes | pending | | Retention automation | none | not-covered | Implementation pending | T008-T010 | yes | pending | | Windows shared architecture | none | not-covered | Live Windows adapter/acceptance | T001, T009 then roadmap | yes for contracts; no for live Windows | pending | @@ -121,7 +121,9 @@ Commands are refined through Agent Workbench before execution. | T002 | complete | Atomic storage, bounded diagnostics, `flock` mutation leases, and startup reconciliation; focused T002 suite passed | No live state directory or production repository used | | T003 | complete | Linux peer credentials, current NSS membership, strict dispatcher/audit, policy loader, and staged unit assets; focused T003 suite passed | No group, socket, service, or policy installed | | T004 | complete | Phase 1 suite passed 98 tests at 88.4% coverage; Ruff, compileall, wheel asset, patch, lifecycle, and expert-panel checks passed | Real systemd/AF_UNIX host acceptance remains V4/T010 | -| T005-T012 | pending | No implementation evidence | Later implementation phases | +| T005 | complete | 22 focused launcher/action-policy tests; staged alias, selector, and launcher assets; wheel inventory; Ruff and patch checks passed | No live launcher or selector changed | +| T006 | complete | Integrated system-control/CLI/help suite passed 177 tests; system-control package measured 88.2% branch-aware coverage; Ruff, format, compile, wheel, and patch checks passed | Live socket and operator-group acceptance remain T009/T010 | +| T007-T012 | pending | No implementation evidence | Later implementation phases | ## Evidence Log @@ -141,6 +143,10 @@ Commands are refined through Agent Workbench before execution. | 2026-07-26 | `PYENV_VERSION=3.12.4 python -m build --wheel --no-isolation ...` plus wheel inventory | passed; 3/3 assets present | Policy, socket unit, and service unit are packaged; isolated build could not resolve build dependencies because network access was unavailable | | 2026-07-26 | Agent Workbench verification planning and diagnostics | planning returned; diagnostics unavailable | No Python diagnostics provider was configured, so direct review and executed checks remain the proof | | 2026-07-26 | Rules consulted and applied | recorded | Coding Standards (100), General Preferences (50), Operational Best Practices (40), Planning Protocol (30), Testing Conventions (25), Documentation Conventions, and Git Conventions; no overrides | +| 2026-07-26 | `PYENV_VERSION=3.12.6 python -m pytest tests/TimeLocker/system_control tests/TimeLocker/cli/test_monitoring_commands.py tests/TimeLocker/cli/test_cli_help_system.py -q --no-cov` | 177 passed | Phase 2 launcher, action routing, client, authorization, structured run/log views, compatibility, denial, and redaction | +| 2026-07-26 | `coverage report --include='src/TimeLocker/system_control/*' --skip-empty --fail-under=0` | 88.2% branch-aware coverage | Scoped report for the system-control package; a pytest coverage attempt inherited repository-wide `source=src` and failed the global 50% threshold at 17.3%, so it is not presented as a focused coverage result | +| 2026-07-26 | Ruff check/format, compileall, wheel build/inventory, and `git diff --check` | passed | Wheel contains the Phase 2 modules and all six system-control assets, including distinct `timelocker` and `tl` launcher assets; isolated build dependency resolution was unavailable, and the Python 3.12.4 no-isolation build passed | +| 2026-07-26 | Agent Workbench verification planning | partial routing only | Its index had not incorporated newly created files and proposed unrelated tests; direct source review, the focused suite, and package inventory are the proof | ## T004 Review Finding Dispositions @@ -157,6 +163,20 @@ bounded to Spec 009 Phase 1 source, focused tests, packaged assets, and lifecycl artifacts. It did not install or execute the staged service, inspect real NSS membership, or claim live Windows support. +## Phase 2 Review Finding Dispositions + +| Finding | Severity / confidence | Roles | Disposition | Validation | +|---------|-----------------------|-------|-------------|------------| +| TLR-006: the staged launcher assets did not provide a distinct `tl` compatibility alias | medium / high | Project Steward; Operations and Portability | fixed: packaged `tl-launcher` delegates through the same immutable launcher module as `timelocker-launcher` | wheel inventory and launcher-entrypoint tests | +| TLR-007: rollback selection trusted a selector file without revalidating its parent directory | high / high | Security and Privacy; Operations and Portability | fixed: every selector read validates the root-owned, non-writable selector directory before parsing | release-launcher ownership, mode, symlink, selection, and rollback tests | +| TLR-008: CLI record and diagnostic limits were not bounded at argument parsing | medium / high | Security and Privacy; Python CLI Architecture | fixed: run and log limits are constrained to 1-1,000 before transport requests are built | CLI invalid-limit and request-shape tests | +| TLR-009: client framing, safe errors, entrypoint delegation, and scope rejection lacked focused regression coverage | medium / high | Reliability and Testing; Python CLI Architecture | fixed: added client, release-entrypoint, invalid-scope, request-correlation, timeout, framing, and safe-error tests | integrated 177-test Phase 2 suite | + +No actionable Phase 2 findings remain after these dispositions. The review was +bounded to T005-T006 source, tests, packaged assets, and lifecycle artifacts. +It did not install the launcher, select a live release, activate the socket, +inspect real group membership, or prove platform authorization prompts. + ## Manual Or External Verification Live T010 evidence must record the reviewer, timestamp, exact non-secret command, @@ -228,8 +248,8 @@ protected metadata, widen privilege, interrupt backups, or delete snapshots. - **Ready for promotion:** no - **Ready for release:** no - **Ready for closure:** no -- **Ready for implementation:** yes for the next dependency-ordered task after - the Phase 1 lifecycle audit; later live-host mutations still require T010 +- **Ready for implementation:** yes for Phase 3 tasks T007 and T008; later + live-host mutations still require T010 approval ## Related Artifacts @@ -243,8 +263,8 @@ protected metadata, widen privilege, interrupt backups, or delete snapshots. ## Reconciliation -Reviewed against the 2026-07-26 requirements and design revisions. T001-T004 -now provide executed Phase 1 evidence for V1-V3 and repository-local portions -of V4/V11. Real socket activation, installed ownership/modes, live NSS behavior, -and host restart remain pending under T010; later tasks and durable promotion -remain incomplete. +Reviewed against the 2026-07-26 requirements and design revisions. T001-T006 +now provide executed Phase 1-2 evidence for V1-V3, V5-V6, and repository-local +portions of V4/V11. Real socket activation, installed ownership/modes, live NSS +behavior, authorization prompts, and host restart remain pending under +T009-T010; Phase 3, durable promotion, and closure remain incomplete. diff --git a/docs/specs/README.md b/docs/specs/README.md index a594d4d..3cd087e 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -16,18 +16,20 @@ accepted content has been promoted and the package is closed. ## Current Packages - [`009-system-cli-tray-retention`](./009-system-cli-tray-retention/requirements.md) - — active implementation package; Phase 1 contracts, storage, Linux - authorization boundary, and security checkpoint are complete, with the - system launcher and action classifier selected next. + — active implementation package; Phases 1 and 2 are complete, including + contracts, storage, Linux authorization, immutable launcher/routing, and + structured system run and diagnostic CLI views. The independent tray and + retention work in Phase 3 is next. ## Active-Package Sequencing Spec 007 is closed; its release-readiness evidence and recovery commits are recorded in `docs/history/`. Spec 009 is the only active package. Its design, tasks, traceability, canonical context, and verification plan were approved, -and Phase 1 is complete. Implementation continues in dependency order from -T005. Repository implementation approval does not authorize live-system -mutation, rollout, or release; T010 retains the explicit host-mutation gate. +and Phases 1 and 2 are complete. Implementation continues with the independent +Phase 3 tasks T007 and T008 before integration in T009. Repository +implementation approval does not authorize live-system mutation, rollout, or +release; T010 retains the explicit host-mutation gate. Closed packages remain recorded in `docs/history/` rather than kept in this active path. diff --git a/pyproject.toml b/pyproject.toml index 139f9f9..6c97899 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,6 +114,8 @@ TimeLocker = [ "system_control/assets/*.json", "system_control/assets/*.service", "system_control/assets/*.socket", + "system_control/assets/timelocker-*", + "system_control/assets/tl-launcher", ] [tool.pytest.ini_options] diff --git a/src/TimeLocker/cli.py b/src/TimeLocker/cli.py index 957664e..8f463df 100644 --- a/src/TimeLocker/cli.py +++ b/src/TimeLocker/cli.py @@ -49,6 +49,7 @@ RepositoryConfigStore as _RepositoryConfigStoreLike, store_backend_credentials as store_backend_credentials_helper, ) + # Test-friendly patch: ensure stderr is captured separately in Typer's CliRunner # so tests can safely access result.stderr when using CliRunner. try: @@ -58,16 +59,15 @@ if not getattr(_TyperCliRunner, "_timelocker_mixstderr_patched", False): _orig_invoke = _TyperCliRunner.invoke - def _patched_invoke( - self: _TyperCliRunner, - app: typer.main.Typer, - args: str | Sequence[str] | None = None, - input: bytes | str | IO[bytes] | IO[str] | None = None, - env: Mapping[str, str | None] | None = None, - catch_exceptions: bool = True, - color: bool = False, - **extra: object, + self: _TyperCliRunner, + app: typer.main.Typer, + args: str | Sequence[str] | None = None, + input: bytes | str | IO[bytes] | IO[str] | None = None, + env: Mapping[str, str | None] | None = None, + catch_exceptions: bool = True, + color: bool = False, + **extra: object, ) -> _ClickResult: invoke_kwargs: dict[str, object] = dict(extra) if "mix_stderr" in invoke_kwargs: @@ -78,28 +78,32 @@ def _patched_invoke( capture_buffer = io.StringIO() with contextlib.redirect_stdout(capture_buffer): result = _orig_invoke( - self, - app, - args=args, - input=input, - env=env, - catch_exceptions=catch_exceptions, - color=color, - **invoke_kwargs, + self, + app, + args=args, + input=input, + env=env, + catch_exceptions=catch_exceptions, + color=color, + **invoke_kwargs, ) # Detect older click capturing the TypeError about mix_stderr exception = getattr(result, "exception", None) - if exception and isinstance(exception, TypeError) and "mix_stderr" in str(exception): + if ( + exception + and isinstance(exception, TypeError) + and "mix_stderr" in str(exception) + ): _ = invoke_kwargs.pop("mix_stderr", None) result = _orig_invoke( - self, - app, - args=args, - input=input, - env=env, - catch_exceptions=catch_exceptions, - color=color, - **invoke_kwargs, + self, + app, + args=args, + input=input, + env=env, + catch_exceptions=catch_exceptions, + color=color, + **invoke_kwargs, ) # Ensure result.stderr is safe to access try: @@ -119,7 +123,11 @@ def _patched_invoke( setattr(result, "stdout_bytes", stdout_bytes) else: setattr(result, "stdout", captured_stdout) - setattr(result, "stdout_bytes", captured_stdout.encode(charset, errors="replace")) + setattr( + result, + "stdout_bytes", + captured_stdout.encode(charset, errors="replace"), + ) except Exception: pass try: @@ -128,22 +136,28 @@ def _patched_invoke( output_bytes = getattr(result, "output_bytes", b"") charset = getattr(self, "charset", "utf-8") or "utf-8" if output_bytes: - setattr(result, "output", output_bytes.decode(charset, errors="replace")) + setattr( + result, + "output", + output_bytes.decode(charset, errors="replace"), + ) setattr(result, "output_bytes", output_bytes) else: setattr(result, "output", captured_stdout) - setattr(result, "output_bytes", captured_stdout.encode(charset, errors="replace")) + setattr( + result, + "output_bytes", + captured_stdout.encode(charset, errors="replace"), + ) except Exception: pass return result - _TyperCliRunner.invoke = _patched_invoke setattr(_TyperCliRunner, "_timelocker_mixstderr_patched", True) if not getattr(_ClickBytesIOCopy, "_timelocker_non_closing", False): _orig_bytesio_close = _ClickBytesIOCopy.close - def _non_closing_bytesio_close(self: _ClickBytesIOCopy) -> None: # type: ignore[override] """Keep Click's testing buffers readable after close().""" try: @@ -151,7 +165,6 @@ def _non_closing_bytesio_close(self: _ClickBytesIOCopy) -> None: # type: ignore except Exception: pass - _non_closing_bytesio_close.__doc__ = _orig_bytesio_close.__doc__ _ClickBytesIOCopy.close = _non_closing_bytesio_close # type: ignore[assignment] setattr(_ClickBytesIOCopy, "_timelocker_non_closing", True) @@ -233,9 +246,15 @@ def unlock(self, master_password: str, is_auto_unlock: bool = False) -> bool: .. class _CredentialManagerLike(_UnlockableCredentialManager, Protocol): def ensure_unlocked(self, allow_prompt: bool = True) -> bool: ... - def remove_repository_backend_credentials(self, repository_name: str, backend_type: str) -> bool: ... - def has_repository_backend_credentials(self, repository_name: str, backend_type: str) -> bool: ... - def get_repository_backend_credentials(self, repository_name: str, backend_type: str) -> Mapping[str, str] | None: ... + def remove_repository_backend_credentials( + self, repository_name: str, backend_type: str + ) -> bool: ... + def has_repository_backend_credentials( + self, repository_name: str, backend_type: str + ) -> bool: ... + def get_repository_backend_credentials( + self, repository_name: str, backend_type: str + ) -> Mapping[str, str] | None: ... def _change_bucket_total(bucket: _ValidationChangeBucket) -> int: @@ -280,13 +299,13 @@ def _stream_is_interactive(stream: TextIO | None) -> bool: def _patched_rich_console_input( - self: Console, - prompt: str | Text = "", - *, - markup: bool = True, - emoji: bool = True, - password: bool = False, - stream: TextIO | None = None, + self: Console, + prompt: str | Text = "", + *, + markup: bool = True, + emoji: bool = True, + password: bool = False, + stream: TextIO | None = None, ) -> str: """ Override Rich console input to avoid getpass blocking on non-interactive streams. @@ -305,12 +324,12 @@ def _patched_rich_console_input( return line.rstrip("\r\n") return _original_rich_console_input( - self, - prompt, - markup=markup, - emoji=emoji, - password=password, - stream=stream, + self, + prompt, + markup=markup, + emoji=emoji, + password=password, + stream=stream, ) @@ -323,7 +342,9 @@ def _console_print(*args: object, **kwargs: object) -> None: Console.input = _patched_rich_console_input # type: ignore[attr-defined] sys.modules["TimeLocker.cli"] = sys.modules[__name__] -_ = sys.modules.setdefault("TimeLocker.config.configuration_manager", _timelocker_config_manager_module) +_ = sys.modules.setdefault( + "TimeLocker.config.configuration_manager", _timelocker_config_manager_module +) _ = sys.modules.setdefault("TimeLocker.monitoring", _timelocker_monitoring) @@ -343,7 +364,9 @@ def _combined_output_for_tests(result: object) -> str: setattr(builtins, "_combined_output", _combined_output_for_tests) -def _register_builtin_symbol(symbol_name: str, module_path: str, fallback: object | None = None) -> None: +def _register_builtin_symbol( + symbol_name: str, module_path: str, fallback: object | None = None +) -> None: """Register a symbol in builtins for legacy tests if not already provided.""" if hasattr(builtins, symbol_name): return @@ -363,19 +386,21 @@ def _register_builtin_symbol(symbol_name: str, module_path: str, fallback: objec try: from .monitoring.status_reporter import StatusLevel, StatusReporter except Exception: + class _FallbackStatusLevel(Enum): SUCCESS = "success" FAILURE = "failure" WARNING = "warning" - class _FallbackStatusReporter: """Fallback status reporter for tests when monitoring module is unavailable.""" def update_progress(self, **_kwargs: object) -> None: # pragma: no cover - noop return - def complete_operation(self, **_kwargs: object) -> None: # pragma: no cover - noop + def complete_operation( + self, **_kwargs: object + ) -> None: # pragma: no cover - noop return StatusLevel = _FallbackStatusLevel @@ -383,27 +408,31 @@ def complete_operation(self, **_kwargs: object) -> None: # pragma: no cover - n _register_builtin_symbol("StatusReporter", "TimeLocker.monitoring", StatusReporter) _register_builtin_symbol("StatusLevel", "TimeLocker.monitoring", StatusLevel) -_register_builtin_symbol("ConfigurationManager", "TimeLocker.config.configuration_manager", ConfigurationManager) +_register_builtin_symbol( + "ConfigurationManager", + "TimeLocker.config.configuration_manager", + ConfigurationManager, +) CLI_CONTEXT_SETTINGS = {"max_content_width": 110} app = typer.Typer( - name="timelocker", - help=( - "TimeLocker — Beautiful backup and restore with a clear CLI.\n\n" - "Key groups: repos, selections, snapshots, policy, schedule.\n\n" - "Examples:\n" - " tl repos add file:///path/to/repo\n" - " tl selections create --include '~/Documents/**'\n" - " tl backup create --selection \n" - " tl snapshots list # lists snapshots (see --repository)\n" - " tl snapshots restore /restore/path --repository \n\n" - "Note: Local repository paths must use the file:// prefix (e.g., file:///path/to/repo).\n" - ), - epilog="Made by Bruce Cherrington", - rich_markup_mode=None, - no_args_is_help=True, - context_settings=CLI_CONTEXT_SETTINGS, + name="timelocker", + help=( + "TimeLocker — Beautiful backup and restore with a clear CLI.\n\n" + "Key groups: repos, selections, snapshots, policy, schedule.\n\n" + "Examples:\n" + " tl repos add file:///path/to/repo\n" + " tl selections create --include '~/Documents/**'\n" + " tl backup create --selection \n" + " tl snapshots list # lists snapshots (see --repository)\n" + " tl snapshots restore /restore/path --repository \n\n" + "Note: Local repository paths must use the file:// prefix (e.g., file:///path/to/repo).\n" + ), + epilog="Made by Bruce Cherrington", + rich_markup_mode=None, + no_args_is_help=True, + context_settings=CLI_CONTEXT_SETTINGS, ) app.info.options_metavar = "" @@ -415,20 +444,34 @@ def _merge_typer_app(target_app: typer.Typer, source_app: typer.Typer) -> None: # Create sub-apps for new hierarchy -backup_app = typer.Typer(help="Backup operations", no_args_is_help=True, context_settings=CLI_CONTEXT_SETTINGS) +backup_app = typer.Typer( + help="Backup operations", + no_args_is_help=True, + context_settings=CLI_CONTEXT_SETTINGS, +) backup_app.info.options_metavar = "" -snapshots_app = typer.Typer(help="Snapshot operations", context_settings=CLI_CONTEXT_SETTINGS) +snapshots_app = typer.Typer( + help="Snapshot operations", context_settings=CLI_CONTEXT_SETTINGS +) snapshots_app.info.options_metavar = "" -repos_app = typer.Typer(help="Repository operations", context_settings=CLI_CONTEXT_SETTINGS) +repos_app = typer.Typer( + help="Repository operations", context_settings=CLI_CONTEXT_SETTINGS +) repos_app.info.options_metavar = "" -config_app = typer.Typer(help="Configuration management commands", context_settings=CLI_CONTEXT_SETTINGS) +config_app = typer.Typer( + help="Configuration management commands", context_settings=CLI_CONTEXT_SETTINGS +) config_app.info.options_metavar = "" -credentials_app = typer.Typer(help="Credential management commands", context_settings=CLI_CONTEXT_SETTINGS) +credentials_app = typer.Typer( + help="Credential management commands", context_settings=CLI_CONTEXT_SETTINGS +) credentials_app.info.options_metavar = "" # Create security sub-app -security_app = typer.Typer(help="Security management commands", context_settings=CLI_CONTEXT_SETTINGS) +security_app = typer.Typer( + help="Security management commands", context_settings=CLI_CONTEXT_SETTINGS +) security_app.info.options_metavar = "" # Add sub-apps to main app @@ -442,14 +485,21 @@ def _merge_typer_app(target_app: typer.Typer, source_app: typer.Typer) -> None: app.add_typer(security_app, name="security") # Create config sub-apps -config_import_app = typer.Typer(help="Import configuration commands", context_settings=CLI_CONTEXT_SETTINGS) +config_import_app = typer.Typer( + help="Import configuration commands", context_settings=CLI_CONTEXT_SETTINGS +) config_import_app.info.options_metavar = "" -config_export_app = typer.Typer(help="Export configuration commands", context_settings=CLI_CONTEXT_SETTINGS) +config_export_app = typer.Typer( + help="Export configuration commands", context_settings=CLI_CONTEXT_SETTINGS +) config_export_app.info.options_metavar = "" # Create migrate app for configuration migration and validation -migrate_app = typer.Typer(help="Configuration migration and validation commands", context_settings=CLI_CONTEXT_SETTINGS) +migrate_app = typer.Typer( + help="Configuration migration and validation commands", + context_settings=CLI_CONTEXT_SETTINGS, +) migrate_app.info.options_metavar = "" # Add config sub-apps @@ -460,7 +510,9 @@ def _merge_typer_app(target_app: typer.Typer, source_app: typer.Typer) -> None: app.add_typer(migrate_app, name="migrate") # Create repos sub-apps -repos_credentials_app = typer.Typer(help="Repository credential management", context_settings=CLI_CONTEXT_SETTINGS) +repos_credentials_app = typer.Typer( + help="Repository credential management", context_settings=CLI_CONTEXT_SETTINGS +) repos_credentials_app.info.options_metavar = "" # Add repos sub-apps @@ -469,7 +521,9 @@ def _merge_typer_app(target_app: typer.Typer, source_app: typer.Typer) -> None: @app.command("version") def cli_version( - short: Annotated[bool, typer.Option("--short", help="Only print the version number")] = False, + short: Annotated[ + bool, typer.Option("--short", help="Only print the version number") + ] = False, ) -> None: """Display the TimeLocker CLI version.""" if short: @@ -480,14 +534,19 @@ def cli_version( @app.command("help") def cli_help( - topic: Annotated[str | None, typer.Argument(help="Help topic (repos, backup, restore, policy, schedule, selections)")] = None, + topic: Annotated[ + str | None, + typer.Argument( + help="Help topic (repos, backup, restore, policy, schedule, selections)" + ), + ] = None, ) -> None: """ Show comprehensive help and usage examples for TimeLocker commands. - + This command provides detailed help, usage examples, and common workflows for different TimeLocker operations. - + Examples: timelocker help # Show general help timelocker help repos # Show repository management help @@ -496,25 +555,49 @@ def cli_help( """ if topic is None: # Show general help - console.print("\n[bold cyan]TimeLocker - Backup Management System[/bold cyan]\n") - console.print("TimeLocker provides comprehensive backup and restore capabilities with") + console.print( + "\n[bold cyan]TimeLocker - Backup Management System[/bold cyan]\n" + ) + console.print( + "TimeLocker provides comprehensive backup and restore capabilities with" + ) console.print("policy-based management, scheduling, and data selection.\n") console.print("[bold]Main Command Groups:[/bold]") - console.print(" [cyan]repos[/cyan] - Repository management (create, list, validate)") - console.print(" [cyan]backup[/cyan] - Backup operations (run, status, list)") - console.print(" [cyan]restore[/cyan] - Restore operations (browse, files, full)") - console.print(" [cyan]snapshots[/cyan] - Snapshot management (list, show, delete)") - console.print(" [cyan]policy[/cyan] - Policy management (backup and retention policies)") - console.print(" [cyan]schedule[/cyan] - Scheduling automation (create, manage schedules)") - console.print(" [cyan]selections[/cyan] - Data selection templates (include/exclude patterns)") - console.print(" [cyan]config[/cyan] - Configuration inspection, import, export") + console.print( + " [cyan]repos[/cyan] - Repository management (create, list, validate)" + ) + console.print( + " [cyan]backup[/cyan] - Backup operations (run, status, list)" + ) + console.print( + " [cyan]restore[/cyan] - Restore operations (browse, files, full)" + ) + console.print( + " [cyan]snapshots[/cyan] - Snapshot management (list, show, delete)" + ) + console.print( + " [cyan]policy[/cyan] - Policy management (backup and retention policies)" + ) + console.print( + " [cyan]schedule[/cyan] - Scheduling automation (create, manage schedules)" + ) + console.print( + " [cyan]selections[/cyan] - Data selection templates (include/exclude patterns)" + ) + console.print( + " [cyan]config[/cyan] - Configuration inspection, import, export" + ) console.print(" [cyan]credentials[/cyan] - Secure credential storage") console.print(" [cyan]security[/cyan] - Security and access auditing") - console.print(" [cyan]monitor[/cyan] - System monitoring and health checks") + console.print( + " [cyan]monitor[/cyan] - System monitoring and health checks" + ) console.print(" [cyan]logs[/cyan] - Log viewing and maintenance") console.print(" [cyan]reports[/cyan] - Generate usage and health reports") - console.print(" [cyan]migrate[/cyan] - Validate and migrate configuration files\n") + console.print( + " [cyan]migrate[/cyan] - Validate and migrate configuration files\n" + ) console.print("[bold]Quick Start:[/bold]") console.print(" 1. Add a repository:") @@ -522,7 +605,9 @@ def cli_help( console.print(" 2. Initialize the repository:") console.print(" timelocker repos init myrepo\n") console.print(" 3. Create a data selection:") - console.print(" timelocker selections create documents --include '~/Documents/**'\n") + console.print( + " timelocker selections create documents --include '~/Documents/**'\n" + ) console.print(" 4. Create a backup:") console.print(" timelocker backup create --selection documents\n") console.print(" 5. List snapshots:") @@ -561,18 +646,36 @@ def cli_help( if topic == "repos" or topic == "repository": console.print("\n[bold cyan]Repository Management Help[/bold cyan]\n") - console.print("Repositories store your backup data. TimeLocker supports multiple") + console.print( + "Repositories store your backup data. TimeLocker supports multiple" + ) console.print("repository backends including local, S3, B2, SFTP, and more.\n") console.print("[bold]Common Commands:[/bold]") - console.print(" [cyan]repos add[/cyan] - Add a new repository configuration") - console.print(" [cyan]repos init[/cyan] - Initialize a repository at its location") - console.print(" [cyan]repos list[/cyan] - List all repositories") - console.print(" [cyan]repos show[/cyan] - Show repository details") - console.print(" [cyan]repos validate[/cyan] - Validate repository connectivity") - console.print(" [cyan]repos check[/cyan] - Check repository integrity") - console.print(" [cyan]repos stats[/cyan] - Show repository statistics") - console.print(" [cyan]repos remove[/cyan] - Remove a repository\n") + console.print( + " [cyan]repos add[/cyan] - Add a new repository configuration" + ) + console.print( + " [cyan]repos init[/cyan] - Initialize a repository at its location" + ) + console.print( + " [cyan]repos list[/cyan] - List all repositories" + ) + console.print( + " [cyan]repos show[/cyan] - Show repository details" + ) + console.print( + " [cyan]repos validate[/cyan] - Validate repository connectivity" + ) + console.print( + " [cyan]repos check[/cyan] - Check repository integrity" + ) + console.print( + " [cyan]repos stats[/cyan] - Show repository statistics" + ) + console.print( + " [cyan]repos remove[/cyan] - Remove a repository\n" + ) console.print("[bold]Examples:[/bold]") console.print(" # Add a local repository") @@ -580,7 +683,9 @@ def cli_help( console.print(" # Initialize the repository (required for new repositories)") console.print(" timelocker repos init local-backup\n") console.print(" # Add an S3 repository") - console.print(" timelocker repos add s3-backup s3:s3.amazonaws.com/my-bucket/backup\n") + console.print( + " timelocker repos add s3-backup s3:s3.amazonaws.com/my-bucket/backup\n" + ) console.print(" # List all repositories") console.print(" timelocker repos list\n") console.print(" # Check repository health") @@ -591,23 +696,37 @@ def cli_help( console.print("[bold]Credential Management:[/bold]") console.print(" repos credentials set - Store backend credentials") console.print(" repos credentials show - Show credential status") - console.print(" repos credentials remove - Remove stored credentials\n") + console.print( + " repos credentials remove - Remove stored credentials\n" + ) elif topic == "backup": console.print("\n[bold cyan]Backup Operations Help[/bold cyan]\n") - console.print("Backup operations create snapshots of your data in repositories.\n") + console.print( + "Backup operations create snapshots of your data in repositories.\n" + ) console.print("[bold]Common Commands:[/bold]") - console.print(" [cyan]backup create[/cyan] - Create a backup using a selection template") - console.print(" [cyan]backup status[/cyan] - Show current backup status") + console.print( + " [cyan]backup create[/cyan] - Create a backup using a selection template" + ) + console.print( + " [cyan]backup status[/cyan] - Show current backup status" + ) console.print(" [cyan]backup list[/cyan] - List backup history") - console.print(" [cyan]backup cancel[/cyan] - Cancel a running backup\n") + console.print( + " [cyan]backup cancel[/cyan] - Cancel a running backup\n" + ) console.print("[bold]Examples:[/bold]") console.print(" # Create a backup with a selection template") - console.print(" timelocker backup create --selection documents --repository myrepo\n") + console.print( + " timelocker backup create --selection documents --repository myrepo\n" + ) console.print(" # Create a backup from direct paths") - console.print(" timelocker backup create /path/to/backup --repository myrepo\n") + console.print( + " timelocker backup create /path/to/backup --repository myrepo\n" + ) console.print(" # Check backup status") console.print(" timelocker backup status\n") console.print(" # List recent backups") @@ -615,15 +734,29 @@ def cli_help( elif topic == "snapshots": console.print("\n[bold cyan]Snapshot Management Help[/bold cyan]\n") - console.print("Snapshot commands inspect, compare, search, and clean up stored backups.\n") + console.print( + "Snapshot commands inspect, compare, search, and clean up stored backups.\n" + ) console.print("[bold]Common Commands:[/bold]") - console.print(" [cyan]snapshots list[/cyan] --repository - List snapshots") - console.print(" [cyan]snapshots show[/cyan] --repository - Show snapshot metadata") - console.print(" [cyan]snapshots find[/cyan] - Search for files across snapshots") - console.print(" [cyan]snapshots diff[/cyan] - Compare file changes") - console.print(" [cyan]snapshots forget[/cyan] --keep-daily 7 - Apply retention policies") - console.print(" [cyan]snapshots prune[/cyan] - Remove unreferenced data\n") + console.print( + " [cyan]snapshots list[/cyan] --repository - List snapshots" + ) + console.print( + " [cyan]snapshots show[/cyan] --repository - Show snapshot metadata" + ) + console.print( + " [cyan]snapshots find[/cyan] - Search for files across snapshots" + ) + console.print( + " [cyan]snapshots diff[/cyan] - Compare file changes" + ) + console.print( + " [cyan]snapshots forget[/cyan] --keep-daily 7 - Apply retention policies" + ) + console.print( + " [cyan]snapshots prune[/cyan] - Remove unreferenced data\n" + ) console.print("[bold]Examples:[/bold]") console.print(" # List snapshots for a repository") @@ -638,12 +771,24 @@ def cli_help( console.print("Restore operations recover data from backup snapshots.\n") console.print("[bold]Common Commands:[/bold]") - console.print(" [cyan]restore browse[/cyan] - Browse snapshot contents") - console.print(" [cyan]restore files[/cyan] - Restore specific files") - console.print(" [cyan]restore full[/cyan] - Restore entire snapshot") - console.print(" [cyan]restore list[/cyan] - List available snapshots") - console.print(" [cyan]restore find[/cyan] - Search for files") - console.print(" [cyan]restore diff[/cyan] - Compare snapshots\n") + console.print( + " [cyan]restore browse[/cyan] - Browse snapshot contents" + ) + console.print( + " [cyan]restore files[/cyan] - Restore specific files" + ) + console.print( + " [cyan]restore full[/cyan] - Restore entire snapshot" + ) + console.print( + " [cyan]restore list[/cyan] - List available snapshots" + ) + console.print( + " [cyan]restore find[/cyan] - Search for files" + ) + console.print( + " [cyan]restore diff[/cyan] - Compare snapshots\n" + ) console.print("[bold]Examples:[/bold]") console.print(" # List available snapshots") @@ -651,7 +796,9 @@ def cli_help( console.print(" # Browse latest snapshot") console.print(" timelocker restore browse myrepo latest\n") console.print(" # Restore specific files") - console.print(" timelocker restore files myrepo latest /restore/path --include '*.txt'\n") + console.print( + " timelocker restore files myrepo latest /restore/path --include '*.txt'\n" + ) console.print(" # Restore entire snapshot") console.print(" timelocker restore full myrepo abc123 /restore/path\n") console.print(" # Find files across snapshots") @@ -659,18 +806,34 @@ def cli_help( elif topic == "policy": console.print("\n[bold cyan]Policy Management Help[/bold cyan]\n") - console.print("Policies define backup and retention rules for automated operations.\n") + console.print( + "Policies define backup and retention rules for automated operations.\n" + ) console.print("[bold]Backup Policies:[/bold]") - console.print(" [cyan]policy backup create[/cyan] - Create a backup policy") - console.print(" [cyan]policy backup list[/cyan] - List backup policies") - console.print(" [cyan]policy backup show[/cyan] - Show policy details") - console.print(" [cyan]policy backup delete[/cyan] - Delete a policy\n") + console.print( + " [cyan]policy backup create[/cyan] - Create a backup policy" + ) + console.print( + " [cyan]policy backup list[/cyan] - List backup policies" + ) + console.print( + " [cyan]policy backup show[/cyan] - Show policy details" + ) + console.print( + " [cyan]policy backup delete[/cyan] - Delete a policy\n" + ) console.print("[bold]Retention Policies:[/bold]") - console.print(" [cyan]policy retention create[/cyan] - Create a retention policy") - console.print(" [cyan]policy retention list[/cyan] - List retention policies") - console.print(" [cyan]policy retention show[/cyan] - Show policy details\n") + console.print( + " [cyan]policy retention create[/cyan] - Create a retention policy" + ) + console.print( + " [cyan]policy retention list[/cyan] - List retention policies" + ) + console.print( + " [cyan]policy retention show[/cyan] - Show policy details\n" + ) console.print("[bold]Examples:[/bold]") console.print(" # Create a backup policy") @@ -687,33 +850,57 @@ def cli_help( console.print("Schedules automate backup execution using policies.\n") console.print("[bold]Common Commands:[/bold]") - console.print(" [cyan]schedule create[/cyan] - Create a schedule") - console.print(" [cyan]schedule list[/cyan] - List all schedules") - console.print(" [cyan]schedule show[/cyan] - Show schedule details") - console.print(" [cyan]schedule enable[/cyan] - Enable a schedule") - console.print(" [cyan]schedule disable[/cyan] - Disable a schedule") - console.print(" [cyan]schedule generate-scripts[/cyan] - Generate automation scripts\n") + console.print( + " [cyan]schedule create[/cyan] - Create a schedule" + ) + console.print( + " [cyan]schedule list[/cyan] - List all schedules" + ) + console.print( + " [cyan]schedule show[/cyan] - Show schedule details" + ) + console.print( + " [cyan]schedule enable[/cyan] - Enable a schedule" + ) + console.print( + " [cyan]schedule disable[/cyan] - Disable a schedule" + ) + console.print( + " [cyan]schedule generate-scripts[/cyan] - Generate automation scripts\n" + ) console.print("[bold]Examples:[/bold]") console.print(" # Create a daily schedule") console.print(" timelocker schedule create daily-2am daily-backup \\") console.print(" --frequency daily --cron '0 2 * * *'\n") console.print(" # Generate cron script") - console.print(" timelocker schedule generate-scripts daily-2am --platform cron\n") + console.print( + " timelocker schedule generate-scripts daily-2am --platform cron\n" + ) console.print(" # Enable a schedule") console.print(" timelocker schedule enable daily-2am\n") elif topic == "selections": console.print("\n[bold cyan]Data Selection Help[/bold cyan]\n") - console.print("Selection templates define which files to include or exclude in backups.\n") + console.print( + "Selection templates define which files to include or exclude in backups.\n" + ) console.print("[bold]Common Commands:[/bold]") - console.print(" [cyan]selections create[/cyan] - Create a selection template") - console.print(" [cyan]selections list[/cyan] - List all templates") - console.print(" [cyan]selections show[/cyan] - Show template details") + console.print( + " [cyan]selections create[/cyan] - Create a selection template" + ) + console.print( + " [cyan]selections list[/cyan] - List all templates" + ) + console.print( + " [cyan]selections show[/cyan] - Show template details" + ) console.print(" [cyan]selections test[/cyan] - Test a template") console.print(" [cyan]selections export[/cyan] - Export a template") - console.print(" [cyan]selections import[/cyan] - Import a template\n") + console.print( + " [cyan]selections import[/cyan] - Import a template\n" + ) console.print("[bold]Examples:[/bold]") console.print(" # Create a selection template") @@ -725,15 +912,29 @@ def cli_help( elif topic == "config": console.print("\n[bold cyan]Configuration Management Help[/bold cyan]\n") - console.print("Use config commands to inspect, diff, import, and export TimeLocker settings.\n") + console.print( + "Use config commands to inspect, diff, import, and export TimeLocker settings.\n" + ) console.print("[bold]Common Commands:[/bold]") - console.print(" [cyan]config show[/cyan] - Display the active configuration") - console.print(" [cyan]config diff[/cyan] - Compare a file against active settings") - console.print(" [cyan]config import restic[/cyan] - Import environment variables as configuration") - console.print(" [cyan]config import timeshift[/cyan] - Convert Timeshift profiles into selections") - console.print(" [cyan]config export[/cyan] - Export configuration to JSON") - console.print(" [cyan]config validate[/cyan] - Validate schema and dependencies\n") + console.print( + " [cyan]config show[/cyan] - Display the active configuration" + ) + console.print( + " [cyan]config diff[/cyan] - Compare a file against active settings" + ) + console.print( + " [cyan]config import restic[/cyan] - Import environment variables as configuration" + ) + console.print( + " [cyan]config import timeshift[/cyan] - Convert Timeshift profiles into selections" + ) + console.print( + " [cyan]config export[/cyan] - Export configuration to JSON" + ) + console.print( + " [cyan]config validate[/cyan] - Validate schema and dependencies\n" + ) console.print("[bold]Examples:[/bold]") console.print(" timelocker config show") @@ -742,13 +943,23 @@ def cli_help( elif topic == "credentials": console.print("\n[bold cyan]Credential Management Help[/bold cyan]\n") - console.print("Credential commands securely store repository passwords and access tokens.\n") + console.print( + "Credential commands securely store repository passwords and access tokens.\n" + ) console.print("[bold]Common Commands:[/bold]") - console.print(" [cyan]credentials set[/cyan] - Store credentials for a repository") - console.print(" [cyan]credentials list[/cyan] - Show stored credentials (names only)") - console.print(" [cyan]credentials remove[/cyan] - Delete stored credentials") - console.print(" [cyan]credentials unlock[/cyan] - Unlock credential vault for automation\n") + console.print( + " [cyan]credentials set[/cyan] - Store credentials for a repository" + ) + console.print( + " [cyan]credentials list[/cyan] - Show stored credentials (names only)" + ) + console.print( + " [cyan]credentials remove[/cyan] - Delete stored credentials" + ) + console.print( + " [cyan]credentials unlock[/cyan] - Unlock credential vault for automation\n" + ) console.print("[bold]Examples:[/bold]") console.print(" timelocker credentials set myrepo") @@ -757,13 +968,23 @@ def cli_help( elif topic == "security": console.print("\n[bold cyan]Security Operations Help[/bold cyan]\n") - console.print("Security commands audit access, review compliance, and inspect protection settings.\n") + console.print( + "Security commands audit access, review compliance, and inspect protection settings.\n" + ) console.print("[bold]Common Commands:[/bold]") - console.print(" [cyan]security status[/cyan] - Show encryption, access, and session status") - console.print(" [cyan]security audit[/cyan] --days 30 - View audit trail for recent events") - console.print(" [cyan]security notifications[/cyan] - Configure notification channels") - console.print(" [cyan]security sessions[/cyan] - List or revoke active access sessions\n") + console.print( + " [cyan]security status[/cyan] - Show encryption, access, and session status" + ) + console.print( + " [cyan]security audit[/cyan] --days 30 - View audit trail for recent events" + ) + console.print( + " [cyan]security notifications[/cyan] - Configure notification channels" + ) + console.print( + " [cyan]security sessions[/cyan] - List or revoke active access sessions\n" + ) console.print("[bold]Examples:[/bold]") console.print(" timelocker security status") @@ -771,14 +992,26 @@ def cli_help( elif topic == "monitor": console.print("\n[bold cyan]Monitoring Help[/bold cyan]\n") - console.print("Monitoring commands provide an operational dashboard for repositories and schedules.\n") + console.print( + "Monitoring commands provide an operational dashboard for repositories and schedules.\n" + ) console.print("[bold]Common Commands:[/bold]") - console.print(" [cyan]monitor status[/cyan] - Show system health summary") - console.print(" [cyan]monitor operations[/cyan] - List current or recent operations") - console.print(" [cyan]monitor health[/cyan] - Run repository health checks") - console.print(" [cyan]monitor history[/cyan] - Review historical backup activity") - console.print(" [cyan]monitor stats[/cyan] - Display aggregated statistics\n") + console.print( + " [cyan]monitor status[/cyan] - Show system health summary" + ) + console.print( + " [cyan]monitor operations[/cyan] - List current or recent operations" + ) + console.print( + " [cyan]monitor health[/cyan] - Run repository health checks" + ) + console.print( + " [cyan]monitor history[/cyan] - Review historical backup activity" + ) + console.print( + " [cyan]monitor stats[/cyan] - Display aggregated statistics\n" + ) console.print("[bold]Examples:[/bold]") console.print(" timelocker monitor status") @@ -787,13 +1020,23 @@ def cli_help( elif topic == "logs": console.print("\n[bold cyan]Log Management Help[/bold cyan]\n") - console.print("Log commands inspect, filter, and clear TimeLocker CLI log files.\n") + console.print( + "Log commands inspect, filter, and clear TimeLocker CLI log files.\n" + ) console.print("[bold]Common Commands:[/bold]") - console.print(" [cyan]logs list[/cyan] - List available log files") - console.print(" [cyan]logs tail[/cyan] --lines 200 - Show recent log lines") - console.print(" [cyan]logs export[/cyan] - Export logs for support") - console.print(" [cyan]logs clear[/cyan] - Truncate cached logs\n") + console.print( + " [cyan]logs list[/cyan] - List available log files" + ) + console.print( + " [cyan]logs tail[/cyan] --lines 200 - Show recent log lines" + ) + console.print( + " [cyan]logs export[/cyan] - Export logs for support" + ) + console.print( + " [cyan]logs clear[/cyan] - Truncate cached logs\n" + ) console.print("[bold]Examples:[/bold]") console.print(" timelocker logs tail --level error --since 24h") @@ -801,24 +1044,40 @@ def cli_help( elif topic == "reports": console.print("\n[bold cyan]Reporting Help[/bold cyan]\n") - console.print("Reporting commands generate health, usage, and compliance summaries.\n") + console.print( + "Reporting commands generate health, usage, and compliance summaries.\n" + ) console.print("[bold]Common Commands:[/bold]") - console.print(" [cyan]reports generate[/cyan] backup-history --days 14 - Backup history report") - console.print(" [cyan]reports generate[/cyan] storage-usage - Storage utilization report") - console.print(" [cyan]reports generate[/cyan] performance --format json - Performance report\n") + console.print( + " [cyan]reports generate[/cyan] backup-history --days 14 - Backup history report" + ) + console.print( + " [cyan]reports generate[/cyan] storage-usage - Storage utilization report" + ) + console.print( + " [cyan]reports generate[/cyan] performance --format json - Performance report\n" + ) console.print("[bold]Examples:[/bold]") - console.print(" timelocker reports generate backup-history --days 30 --output report.md") + console.print( + " timelocker reports generate backup-history --days 30 --output report.md" + ) console.print(" timelocker reports generate storage-usage --format json\n") elif topic == "migrate": console.print("\n[bold cyan]Configuration Migration Help[/bold cyan]\n") - console.print("Migrate commands validate exported configurations before import.\n") + console.print( + "Migrate commands validate exported configurations before import.\n" + ) console.print("[bold]Common Commands:[/bold]") - console.print(" [cyan]migrate validate[/cyan] --show-changes - Preview applied changes") - console.print(" [cyan]migrate validate[/cyan] --check-compatibility - Check version compatibility\n") + console.print( + " [cyan]migrate validate[/cyan] --show-changes - Preview applied changes" + ) + console.print( + " [cyan]migrate validate[/cyan] --check-compatibility - Check version compatibility\n" + ) console.print("[bold]Examples:[/bold]") console.print(" timelocker migrate validate backup-config.json") @@ -826,33 +1085,41 @@ def cli_help( else: available_topics = ( - "repos, backup, snapshots, restore, policy, schedule, selections, " - "config, credentials, security, monitor, logs, reports, migrate" + "repos, backup, snapshots, restore, policy, schedule, selections, " + "config, credentials, security, monitor, logs, reports, migrate" ) unknown_topic_message = ( - f"Unknown help topic: {topic}\n\n" - + f"Available topics: {available_topics}" - ) - show_error_panel( - "Unknown Topic", - unknown_topic_message + f"Unknown help topic: {topic}\n\n" + f"Available topics: {available_topics}" ) + show_error_panel("Unknown Topic", unknown_topic_message) raise typer.Exit(1) @app.command("completion") def cli_completion( - shell: Annotated[str | None, typer.Argument(help="Target shell (bash, zsh, fish, powershell)")] = None, - install: Annotated[bool, typer.Option("--install", help="Install completion for the specified shell")] = False, - uninstall: Annotated[bool, typer.Option("--uninstall", help="Uninstall completion for the specified shell")] = False, - verify: Annotated[bool, typer.Option("--verify", help="Verify completion installation")] = False, + shell: Annotated[ + str | None, typer.Argument(help="Target shell (bash, zsh, fish, powershell)") + ] = None, + install: Annotated[ + bool, + typer.Option("--install", help="Install completion for the specified shell"), + ] = False, + uninstall: Annotated[ + bool, + typer.Option( + "--uninstall", help="Uninstall completion for the specified shell" + ), + ] = False, + verify: Annotated[ + bool, typer.Option("--verify", help="Verify completion installation") + ] = False, ) -> None: """ Manage shell completion for TimeLocker commands. - + Shell completion enables tab-completion for TimeLocker commands, options, and dynamic values like repository names, policy names, and schedule names. - + Examples: timelocker completion # Show general completion info timelocker completion bash # Show bash completion instructions @@ -868,14 +1135,11 @@ def cli_completion( if action_requested and shell is None: missing_shell_message = ( - "Please specify a shell when using --install, --uninstall, or --verify.\n\n" - + "Example: timelocker completion --install bash\n" - + f"Supported shells: {', '.join(supported_shells)}" - ) - show_error_panel( - "Missing Shell Argument", - missing_shell_message + "Please specify a shell when using --install, --uninstall, or --verify.\n\n" + + "Example: timelocker completion --install bash\n" + + f"Supported shells: {', '.join(supported_shells)}" ) + show_error_panel("Missing Shell Argument", missing_shell_message) raise typer.Exit(1) if shell is None: @@ -893,8 +1157,12 @@ def cli_completion( console.print(f" • {s}") console.print("\n[bold]Quick Install:[/bold]") - console.print(" timelocker --install-completion # Auto-detect and install") - console.print(" timelocker completion --install bash # Install for specific shell\n") + console.print( + " timelocker --install-completion # Auto-detect and install" + ) + console.print( + " timelocker completion --install bash # Install for specific shell\n" + ) console.print("[bold]Manual Installation:[/bold]") console.print(" timelocker --show-completion > ~/.timelocker-complete.sh") @@ -902,7 +1170,9 @@ def cli_completion( console.print("[bold]Management:[/bold]") console.print(" timelocker completion --verify bash # Check installation") - console.print(" timelocker completion --uninstall bash # Remove completion\n") + console.print( + " timelocker completion --uninstall bash # Remove completion\n" + ) console.print("[bold]Aliases:[/bold]") console.print(" Both 'timelocker' and 'tl' commands support completion\n") @@ -911,8 +1181,8 @@ def cli_completion( shell = shell.lower() if shell not in supported_shells: show_error_panel( - "Unsupported Shell", - f"Shell '{shell}' is not supported. Choose from: {', '.join(supported_shells)}." + "Unsupported Shell", + f"Shell '{shell}' is not supported. Choose from: {', '.join(supported_shells)}.", ) raise typer.Exit(2) @@ -920,7 +1190,7 @@ def cli_completion( home = Path.home() # Use XDG_DATA_HOME for completion files (XDG compliant) - xdg_data_home = os.environ.get('XDG_DATA_HOME') + xdg_data_home = os.environ.get("XDG_DATA_HOME") if xdg_data_home: data_dir = Path(xdg_data_home) else: @@ -930,91 +1200,115 @@ def cli_completion( zsh_completion_dir = data_dir / "zsh" / "site-functions" shell_configs: dict[str, _CompletionConfig] = { - "bash": { - "completion_file": bash_completion_dir / "timelocker", - "rc_file": home / ".bashrc", - "source_line": f"source {bash_completion_dir / 'timelocker'}", - "generate_cmd": f"timelocker --show-completion bash > {bash_completion_dir / 'timelocker'}", - "reload_cmd": "source ~/.bashrc" - }, - "zsh": { - "completion_file": zsh_completion_dir / "_timelocker", - "rc_file": home / ".zshrc", - "source_line": f"fpath=({zsh_completion_dir} $fpath)", - "generate_cmd": f"timelocker --show-completion zsh > {zsh_completion_dir / '_timelocker'}", - "reload_cmd": "source ~/.zshrc" - }, - "fish": { - "completion_file": home / ".config" / "fish" / "completions" / "timelocker.fish", - "rc_file": None, # Fish doesn't need rc file modification - "source_line": None, - "generate_cmd": "timelocker --show-completion fish > ~/.config/fish/completions/timelocker.fish", - "reload_cmd": "fish_update_completions" - }, - "powershell": { - "completion_file": None, # PowerShell uses $PROFILE - "rc_file": None, - "source_line": None, - "generate_cmd": "timelocker --show-completion powershell >> $PROFILE", - "reload_cmd": ". $PROFILE" - } + "bash": { + "completion_file": bash_completion_dir / "timelocker", + "rc_file": home / ".bashrc", + "source_line": f"source {bash_completion_dir / 'timelocker'}", + "generate_cmd": f"timelocker --show-completion bash > {bash_completion_dir / 'timelocker'}", + "reload_cmd": "source ~/.bashrc", + }, + "zsh": { + "completion_file": zsh_completion_dir / "_timelocker", + "rc_file": home / ".zshrc", + "source_line": f"fpath=({zsh_completion_dir} $fpath)", + "generate_cmd": f"timelocker --show-completion zsh > {zsh_completion_dir / '_timelocker'}", + "reload_cmd": "source ~/.zshrc", + }, + "fish": { + "completion_file": home + / ".config" + / "fish" + / "completions" + / "timelocker.fish", + "rc_file": None, # Fish doesn't need rc file modification + "source_line": None, + "generate_cmd": "timelocker --show-completion fish > ~/.config/fish/completions/timelocker.fish", + "reload_cmd": "fish_update_completions", + }, + "powershell": { + "completion_file": None, # PowerShell uses $PROFILE + "rc_file": None, + "source_line": None, + "generate_cmd": "timelocker --show-completion powershell >> $PROFILE", + "reload_cmd": ". $PROFILE", + }, } config = shell_configs[shell] if verify: # Verify completion installation - console.print(f"\n[bold cyan]Verifying {shell.title()} Completion[/bold cyan]\n") + console.print( + f"\n[bold cyan]Verifying {shell.title()} Completion[/bold cyan]\n" + ) is_installed = False issues: list[str] = [] if shell == "powershell": - console.print("[yellow]PowerShell completion verification not yet implemented[/yellow]") + console.print( + "[yellow]PowerShell completion verification not yet implemented[/yellow]" + ) console.print("Please check your $PROFILE manually\n") else: # Check if completion file exists completion_file = config["completion_file"] if completion_file and completion_file.exists(): - console.print(f"[green]✓[/green] Completion file exists: {completion_file}") + console.print( + f"[green]✓[/green] Completion file exists: {completion_file}" + ) is_installed = True else: - console.print(f"[red]✗[/red] Completion file not found: {completion_file}") + console.print( + f"[red]✗[/red] Completion file not found: {completion_file}" + ) issues.append("Completion file not generated") # Check if rc file has source line (for bash/zsh) if config["rc_file"] and config["source_line"]: rc_file = config["rc_file"] if rc_file.exists(): - with open(rc_file, 'r') as f: + with open(rc_file, "r") as f: rc_content = f.read() if config["source_line"] in rc_content: - console.print(f"[green]✓[/green] Shell configuration updated: {rc_file}") + console.print( + f"[green]✓[/green] Shell configuration updated: {rc_file}" + ) else: - console.print(f"[yellow]⚠[/yellow] Shell configuration not updated: {rc_file}") + console.print( + f"[yellow]⚠[/yellow] Shell configuration not updated: {rc_file}" + ) issues.append(f"Add '{config['source_line']}' to {rc_file}") is_installed = False else: - console.print(f"[yellow]⚠[/yellow] Shell configuration file not found: {rc_file}") + console.print( + f"[yellow]⚠[/yellow] Shell configuration file not found: {rc_file}" + ) issues.append(f"Create {rc_file} and add source line") console.print() if is_installed and not issues: console.print("[bold green]Completion is properly installed[/bold green]\n") else: - console.print("[bold yellow]Completion installation incomplete[/bold yellow]\n") + console.print( + "[bold yellow]Completion installation incomplete[/bold yellow]\n" + ) if issues: console.print("[bold]Issues found:[/bold]") for issue in issues: console.print(f" • {issue}") console.print() - console.print(f"To install, run: [cyan]timelocker completion --install {shell}[/cyan]\n") + console.print( + f"To install, run: [cyan]timelocker completion --install {shell}[/cyan]\n" + ) raise typer.Exit(0 if is_installed else 1) if uninstall: # Uninstall completion - console.print(f"\n[bold cyan]Uninstalling {shell.title()} Completion[/bold cyan]\n") + console.print( + f"\n[bold cyan]Uninstalling {shell.title()} Completion[/bold cyan]\n" + ) removed_items: list[str] = [] @@ -1023,7 +1317,9 @@ def cli_completion( if completion_file and completion_file.exists(): try: completion_file.unlink() - console.print(f"[green]✓[/green] Removed completion file: {completion_file}") + console.print( + f"[green]✓[/green] Removed completion file: {completion_file}" + ) removed_items.append("completion file") except Exception as e: console.print(f"[red]✗[/red] Failed to remove completion file: {e}") @@ -1033,7 +1329,7 @@ def cli_completion( rc_file = config["rc_file"] if rc_file.exists(): try: - with open(rc_file, 'r') as f: + with open(rc_file, "r") as f: lines = f.readlines() # Filter out the source line and TimeLocker completion comment @@ -1055,23 +1351,24 @@ def cli_completion( new_lines.append(line) if len(new_lines) < len(lines): - with open(rc_file, 'w') as f: + with open(rc_file, "w") as f: f.writelines(new_lines) - console.print(f"[green]✓[/green] Removed source line from: {rc_file}") + console.print( + f"[green]✓[/green] Removed source line from: {rc_file}" + ) removed_items.append("shell configuration") except Exception as e: - console.print(f"[red]✗[/red] Failed to update shell configuration: {e}") + console.print( + f"[red]✗[/red] Failed to update shell configuration: {e}" + ) console.print() if removed_items: uninstall_message = ( - f"Removed {', '.join(removed_items)} for {shell}\n\n" - + f"Reload your shell with: {config['reload_cmd']}" - ) - show_success_panel( - "Completion Uninstalled", - uninstall_message + f"Removed {', '.join(removed_items)} for {shell}\n\n" + + f"Reload your shell with: {config['reload_cmd']}" ) + show_success_panel("Completion Uninstalled", uninstall_message) else: show_info_panel("Nothing to Uninstall", f"No {shell} completion found") @@ -1079,14 +1376,18 @@ def cli_completion( if install: # Install completion automatically - console.print(f"\n[bold cyan]Installing {shell.title()} Completion[/bold cyan]\n") + console.print( + f"\n[bold cyan]Installing {shell.title()} Completion[/bold cyan]\n" + ) try: # Generate completion script completion_file = config["completion_file"] if shell == "powershell": - console.print("[yellow]PowerShell automatic installation not yet implemented[/yellow]") + console.print( + "[yellow]PowerShell automatic installation not yet implemented[/yellow]" + ) console.print("Please run manually:") console.print(f" {config['generate_cmd']}\n") raise typer.Exit(1) @@ -1099,29 +1400,36 @@ def cli_completion( console.print("[bold]Step 1:[/bold] Generate completion script") try: import subprocess + result = subprocess.run( - ["timelocker", "--show-completion", shell], - capture_output=True, - text=True, - check=True + ["timelocker", "--show-completion", shell], + capture_output=True, + text=True, + check=True, ) if completion_file is None: raise typer.Exit(1) # Write completion script to file - with open(completion_file, 'w') as f: + with open(completion_file, "w") as f: _ = f.write(result.stdout) # Add completion for 'tl' alias if shell == "bash": - _ = f.write("\ncomplete -o default -F _timelocker_completion tl\n") + _ = f.write( + "\ncomplete -o default -F _timelocker_completion tl\n" + ) elif shell == "zsh": _ = f.write("\ncompdef _timelocker_completion tl\n") console.print(f" [green]✓[/green] Generated: {completion_file}\n") except Exception as e: - console.print(f" [red]✗[/red] Failed to generate completion script: {e}") - console.print(f" Run manually: [cyan]{config['generate_cmd']}[/cyan]\n") + console.print( + f" [red]✗[/red] Failed to generate completion script: {e}" + ) + console.print( + f" Run manually: [cyan]{config['generate_cmd']}[/cyan]\n" + ) raise # For bash/zsh, add source line to rc file @@ -1131,40 +1439,48 @@ def cli_completion( # Check if already added if rc_file.exists(): - with open(rc_file, 'r') as f: + with open(rc_file, "r") as f: rc_content = f.read() - if config["source_line"] in rc_content or "# TimeLocker completion" in rc_content: - console.print(f" [green]✓[/green] Already configured in {rc_file}\n") + if ( + config["source_line"] in rc_content + or "# TimeLocker completion" in rc_content + ): + console.print( + f" [green]✓[/green] Already configured in {rc_file}\n" + ) else: # Add source line - with open(rc_file, 'a') as f: - _ = f.write(f"\n# TimeLocker completion\n{config['source_line']}\n") + with open(rc_file, "a") as f: + _ = f.write( + f"\n# TimeLocker completion\n{config['source_line']}\n" + ) console.print(f" [green]✓[/green] Added to {rc_file}\n") else: # Create rc file with source line - with open(rc_file, 'w') as f: - _ = f.write(f"# TimeLocker completion\n{config['source_line']}\n") + with open(rc_file, "w") as f: + _ = f.write( + f"# TimeLocker completion\n{config['source_line']}\n" + ) console.print(f" [green]✓[/green] Created {rc_file}\n") console.print("[bold]Step 3:[/bold] Reload your shell") console.print(f" Run: [cyan]{config['reload_cmd']}[/cyan]\n") installation_message = ( - f"Follow the steps above to complete {shell} completion installation.\n\n" - + "After reloading your shell, tab completion will be available for TimeLocker commands." - ) - show_success_panel( - "Installation Instructions", - installation_message + f"Follow the steps above to complete {shell} completion installation.\n\n" + + "After reloading your shell, tab completion will be available for TimeLocker commands." ) + show_success_panel("Installation Instructions", installation_message) except Exception as e: show_error_panel("Installation Error", f"Failed to install completion: {e}") raise typer.Exit(1) else: # Show instructions without installing - console.print(f"\n[bold cyan]{shell.title()} Completion Instructions[/bold cyan]\n") + console.print( + f"\n[bold cyan]{shell.title()} Completion Instructions[/bold cyan]\n" + ) console.print(f"To view the completion script for {shell}:") console.print(f" timelocker --show-completion {shell}\n") console.print(f"To install completion for {shell}:") @@ -1196,10 +1512,22 @@ def main() -> None: @config_import_app.command("restic") def config_import_restic( - config_dir: Annotated[Path | None, typer.Option("--config-dir", help="Configuration directory")] = None, - config_file: Annotated[Path | None, typer.Option("--config-file", help="Optional configuration file to update")] = None, - dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview changes without modifying configuration")] = False, - verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, + config_dir: Annotated[ + Path | None, typer.Option("--config-dir", help="Configuration directory") + ] = None, + config_file: Annotated[ + Path | None, + typer.Option("--config-file", help="Optional configuration file to update"), + ] = None, + dry_run: Annotated[ + bool, + typer.Option( + "--dry-run", help="Preview changes without modifying configuration" + ), + ] = False, + verbose: Annotated[ + bool, typer.Option("--verbose", "-v", help="Enable verbose output") + ] = False, ) -> None: """Import configuration settings from restic environment variables.""" setup_logging(verbose, config_dir) @@ -1208,17 +1536,20 @@ def config_import_restic( import_method = _get_service_method(manager, "import_restic_config") if not import_method: show_info_panel( - "Restic Import", - "Automatic restic configuration import is not available in this build." + "Restic Import", + "Automatic restic configuration import is not available in this build.", ) return - result = cast(_ServiceMethodResult, _call_service_method( + result = cast( + _ServiceMethodResult, + _call_service_method( import_method, config_dir=config_dir, config_file=str(config_file) if config_file else None, dry_run=dry_run, - )) + ), + ) success_flag = result.success @@ -1228,7 +1559,11 @@ def config_import_restic( message = "Restic configuration import dry-run completed." show_success_panel("Restic Import", message) else: - show_error_panel("Restic Import Failed", "Failed to import restic configuration.", result.errors) + show_error_panel( + "Restic Import Failed", + "Failed to import restic configuration.", + result.errors, + ) raise typer.Exit(1) except KeyboardInterrupt: show_error_panel("Operation Cancelled", "Restic import cancelled by user") @@ -1236,7 +1571,9 @@ def config_import_restic( except click.exceptions.Exit: raise except Exception as exc: - show_error_panel("Restic Import Error", f"Failed to import restic configuration: {exc}") + show_error_panel( + "Restic Import Error", f"Failed to import restic configuration: {exc}" + ) if verbose: console.print_exception() raise typer.Exit(1) @@ -1247,11 +1584,23 @@ def config_import_restic( @config_import_app.command("timeshift") def config_import_timeshift( - config_dir: Annotated[Path | None, typer.Option("--config-dir", help="Configuration directory")] = None, - config_file: Annotated[Path | None, typer.Option("--config-file", help="Path to Timeshift configuration file")] = None, - dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview changes without modifying configuration")] = False, - verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, - yes: YesOption = False, + config_dir: Annotated[ + Path | None, typer.Option("--config-dir", help="Configuration directory") + ] = None, + config_file: Annotated[ + Path | None, + typer.Option("--config-file", help="Path to Timeshift configuration file"), + ] = None, + dry_run: Annotated[ + bool, + typer.Option( + "--dry-run", help="Preview changes without modifying configuration" + ), + ] = False, + verbose: Annotated[ + bool, typer.Option("--verbose", "-v", help="Enable verbose output") + ] = False, + yes: YesOption = False, ) -> None: """Import configuration from Timeshift backup tool.""" setup_logging(verbose, config_dir) @@ -1261,53 +1610,83 @@ def config_import_timeshift( manager = _get_service_manager_for_command(config_dir) import_method = _get_service_method(manager, "import_timeshift_config") if not import_method: - from .importers.timeshift_importer import TimeshiftConfigParser, TimeshiftToTimeLockerMapper + from .importers.timeshift_importer import ( + TimeshiftConfigParser, + TimeshiftToTimeLockerMapper, + ) parser = TimeshiftConfigParser() try: parsed_config = parser.parse_config(config_file) except FileNotFoundError: - show_error_panel("Timeshift Configuration Not Found", "Timeshift configuration file could not be located.") + show_error_panel( + "Timeshift Configuration Not Found", + "Timeshift configuration file could not be located.", + ) raise typer.Exit(1) except PermissionError as exc: - show_error_panel("Timeshift Configuration Error", f"Permission denied reading Timeshift configuration: {exc}") + show_error_panel( + "Timeshift Configuration Error", + f"Permission denied reading Timeshift configuration: {exc}", + ) raise typer.Exit(1) except json.JSONDecodeError as exc: - show_error_panel("Invalid Timeshift Configuration", f"Invalid Timeshift configuration: {exc}") + show_error_panel( + "Invalid Timeshift Configuration", + f"Invalid Timeshift configuration: {exc}", + ) raise typer.Exit(1) except Exception as exc: - show_error_panel("Timeshift Import Error", f"Failed to parse Timeshift configuration: {exc}") + show_error_panel( + "Timeshift Import Error", + f"Failed to parse Timeshift configuration: {exc}", + ) if verbose: console.print_exception() raise typer.Exit(1) mapper = TimeshiftToTimeLockerMapper() - result = cast(_TimeshiftImportResultLike, mapper.import_configuration( + result = cast( + _TimeshiftImportResultLike, + mapper.import_configuration( parsed_config, repository_name=default_repo_name, target_name=default_selection_name, manual_repository_path=None, backup_paths=None, - )) + ), + ) console.rule("Import from Timeshift") summary = parser.get_summary() - config_path_display = summary.get("config_file") or (str(config_file) if config_file else "default locations") - console.print(f"[bold]Timeshift Configuration Found:[/bold] {config_path_display}") + config_path_display = summary.get("config_file") or ( + str(config_file) if config_file else "default locations" + ) + console.print( + f"[bold]Timeshift Configuration Found:[/bold] {config_path_display}" + ) repo_config = result.repository_config or {} selection_config = result.backup_target_config or {} selection_paths = cast(list[str], selection_config.get("paths", ["/"])) - exclude_patterns = cast(list[str], selection_config.get("exclude_patterns", [])) + exclude_patterns = cast( + list[str], selection_config.get("exclude_patterns", []) + ) console.print("\n[bold]Repository Configuration[/bold]") console.print(f"- Name: {repo_config.get('name', default_repo_name)}") console.print(f"- Location: {repo_config.get('location', '/timeshift')}") - console.print(f"- Description: {repo_config.get('description', 'Imported from Timeshift')}") - console.print(f"- Backend: {repo_config.get('backend', 'restic (auto-detected)')}") + console.print( + f"- Description: {repo_config.get('description', 'Imported from Timeshift')}" + ) + console.print( + f"- Backend: {repo_config.get('backend', 'restic (auto-detected)')}" + ) console.print("\n[bold]Selection Template Configuration[/bold]") - console.print(f"- Template: {selection_config.get('name', default_selection_name)}") + console.print( + f"- Template: {selection_config.get('name', default_selection_name)}" + ) console.print(f"- Paths: {', '.join(selection_paths)}") console.print(f"- Excludes: {', '.join(exclude_patterns) or 'None'}") @@ -1316,7 +1695,9 @@ def config_import_timeshift( for warning in result.warnings: console.print(f"- {cast(str, warning)}") if str(parsed_config.get("btrfs_mode", "false")).lower() == "true": - console.print("- BTRFS Mode: Yes (Timeshift configuration indicates BTRFS snapshots were enabled.)") + console.print( + "- BTRFS Mode: Yes (Timeshift configuration indicates BTRFS snapshots were enabled.)" + ) if result.errors: console.print("\n[red]Errors:[/red]") @@ -1324,12 +1705,16 @@ def config_import_timeshift( console.print(f"- {error}") console.print("\n[cyan]Dry run mode - no changes made[/cyan]") - show_success_panel("Timeshift Import", "Timeshift configuration import dry-run completed.") + show_success_panel( + "Timeshift Import", "Timeshift configuration import dry-run completed." + ) return assume_yes_flag = yes or True # legacy behavior: always auto-confirm - result = cast(_ServiceMethodResult, _call_service_method( + result = cast( + _ServiceMethodResult, + _call_service_method( import_method, config_dir=config_dir, config_file=str(config_file) if config_file else None, @@ -1339,7 +1724,8 @@ def config_import_timeshift( backup_paths=None, assume_yes=assume_yes_flag, dry_run=dry_run, - )) + ), + ) success_flag = result.success @@ -1349,7 +1735,11 @@ def config_import_timeshift( message = "Timeshift configuration import dry-run completed." show_success_panel("Timeshift Import", message) else: - show_error_panel("Timeshift Import Failed", "Failed to import Timeshift configuration.", result.errors) + show_error_panel( + "Timeshift Import Failed", + "Failed to import Timeshift configuration.", + result.errors, + ) raise typer.Exit(1) except KeyboardInterrupt: show_error_panel("Operation Cancelled", "Timeshift import cancelled by user") @@ -1357,11 +1747,14 @@ def config_import_timeshift( except click.exceptions.Exit: raise except Exception as exc: - show_error_panel("Timeshift Import Error", f"Failed to import Timeshift configuration: {exc}") + show_error_panel( + "Timeshift Import Error", f"Failed to import Timeshift configuration: {exc}" + ) if verbose: console.print_exception() raise typer.Exit(1) + class UserFacingLogFilter(logging.Filter): """Filter to identify user-facing log messages that should be displayed in CLI.""" @@ -1381,41 +1774,42 @@ def filter(self, record: logging.LogRecord) -> bool: message = record.getMessage().lower() # User-relevant loggers (TimeLocker specific, not third-party libraries) - user_relevant_loggers = [ - 'timelocker', - 'src.timelocker', - '__main__' - ] + user_relevant_loggers = ["timelocker", "src.timelocker", "__main__"] # Check if it's from a user-relevant logger - is_user_logger = any(logger_name.startswith(prefix.lower()) for prefix in user_relevant_loggers) + is_user_logger = any( + logger_name.startswith(prefix.lower()) + for prefix in user_relevant_loggers + ) # User-relevant message patterns user_relevant_patterns = [ - 'configuration', - 'config', - 'repository', - 'backup', - 'restore', - 'snapshot', - 'target', - 'validation', - 'permission denied', - 'not found', - 'failed to', - 'unable to', - 'invalid', - 'missing', - 'authentication', - 'password' + "configuration", + "config", + "repository", + "backup", + "restore", + "snapshot", + "target", + "validation", + "permission denied", + "not found", + "failed to", + "unable to", + "invalid", + "missing", + "authentication", + "password", ] # Check if message contains user-relevant keywords - has_user_keywords = any(pattern in message for pattern in user_relevant_patterns) + has_user_keywords = any( + pattern in message for pattern in user_relevant_patterns + ) # Filter out misleading warnings that aren't helpful during normal operations misleading_warnings = [ - 'no repositories configured', # Don't show during repository add operations + "no repositories configured", # Don't show during repository add operations ] # Skip misleading warnings @@ -1467,10 +1861,10 @@ def emit(self, record: logging.LogRecord) -> None: # Create and display panel for errors/warnings panel = Panel( - f"{icon} {message}", - title=f"[bold {style}]{title}[/bold {style}]", - border_style=style, - padding=(0, 1) + f"{icon} {message}", + title=f"[bold {style}]{title}[/bold {style}]", + border_style=style, + padding=(0, 1), ) self.console.print(panel) @@ -1499,15 +1893,15 @@ def setup_logging(verbose: bool = False, _config_dir: Path | None = None) -> Non file_handler = None try: file_handler = logging.handlers.RotatingFileHandler( - log_file, - maxBytes=10 * 1024 * 1024, # 10MB - backupCount=5, - encoding='utf-8' + log_file, + maxBytes=10 * 1024 * 1024, # 10MB + backupCount=5, + encoding="utf-8", ) file_handler.setLevel(logging.DEBUG) # Log everything to file file_formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", ) file_handler.setFormatter(file_formatter) except (OSError, PermissionError) as exc: @@ -1526,30 +1920,36 @@ def setup_logging(verbose: bool = False, _config_dir: Path | None = None) -> Non # Log the logging setup logger = logging.getLogger(__name__) - logger.debug(f"Logging configured - Level: {logging.getLevelName(level)}, Log file: {log_file}") + logger.debug( + f"Logging configured - Level: {logging.getLevelName(level)}, Log file: {log_file}" + ) # Suppress noisy third-party loggers - logging.getLogger('urllib3').setLevel(logging.WARNING) - logging.getLogger('requests').setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("requests").setLevel(logging.WARNING) def format_file_size(size_bytes: int) -> str: """Format file size in human-readable format.""" size_value = float(size_bytes) - for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + for unit in ["B", "KB", "MB", "GB", "TB"]: if size_value < 1024.0: return f"{size_value:.1f} {unit}" size_value /= 1024.0 return f"{size_value:.1f} PB" -def show_success_panel(title: str, message: str, details: dict[str, object] | None = None) -> None: +def show_success_panel( + title: str, message: str, details: dict[str, object] | None = None +) -> None: """Display a success panel with optional details.""" formatter = get_output_formatter(console=console) formatter.format_success(title, message, details) -def show_error_panel(title: str, message: str, details: list[str] | None = None) -> None: +def show_error_panel( + title: str, message: str, details: list[str] | None = None +) -> None: """Display an error panel with optional details.""" formatter = get_output_formatter(console=console) formatter.format_error(title, message, details) @@ -1561,13 +1961,17 @@ def show_info_panel(title: str, message: str) -> None: formatter.format_info(title, message) -def _get_service_method(manager: object, method_name: str) -> Callable[..., object] | None: +def _get_service_method( + manager: object, method_name: str +) -> Callable[..., object] | None: """Return callable service manager method if available.""" method = getattr(manager, method_name, None) return method if callable(method) else None -def _call_service_method(method: Callable[..., object] | None, **candidates: object) -> object: +def _call_service_method( + method: Callable[..., object] | None, **candidates: object +) -> object: """Call service method with kwargs filtered to supported parameters.""" if method is None: raise AttributeError("Service method is not available") @@ -1577,7 +1981,9 @@ def _call_service_method(method: Callable[..., object] | None, **candidates: obj # Remove potential 'self' parameter confusion filtered: dict[str, object] = {} - accepts_kwargs = any(param.kind == inspect.Parameter.VAR_KEYWORD for param in params.values()) + accepts_kwargs = any( + param.kind == inspect.Parameter.VAR_KEYWORD for param in params.values() + ) if accepts_kwargs: return method(**candidates) @@ -1586,8 +1992,11 @@ def _call_service_method(method: Callable[..., object] | None, **candidates: obj filtered[name] = value missing_required = [ - name for name, param in params.items() - if name != "self" and param.default is inspect.Signature.empty and name not in filtered + name + for name, param in params.items() + if name != "self" + and param.default is inspect.Signature.empty + and name not in filtered ] if missing_required and candidates: @@ -1608,17 +2017,22 @@ def _get_service_manager_for_command(config_dir: Path | None = None): return get_cli_service_manager(config_dir=_resolve_config_dir(config_dir)) -def _create_credential_manager(config_dir: Path | None = None) -> _CredentialManagerLike: +def _create_credential_manager( + config_dir: Path | None = None, +) -> _CredentialManagerLike: """Instantiate credential manager respecting configuration directory.""" from .security.credential_manager import CredentialManager - return cast(_CredentialManagerLike, cast(object, CredentialManager(config_dir=config_dir))) + return cast( + _CredentialManagerLike, cast(object, CredentialManager(config_dir=config_dir)) + ) def _create_configuration_module(config_dir: Path | None = None) -> ConfigurationModule: """Factory for configuration module respecting dynamic patching.""" try: from .config import configuration_module as configuration_module_module + module_class = getattr(configuration_module_module, "ConfigurationModule", None) except (ImportError, AttributeError): module_class = None @@ -1626,7 +2040,9 @@ def _create_configuration_module(config_dir: Path | None = None) -> Configuratio cli_class = globals().get("ConfigurationModule", None) def _is_mock(candidate: object) -> bool: - return getattr(getattr(candidate, "__class__", None), "__module__", "").startswith("unittest.mock") + return getattr( + getattr(candidate, "__class__", None), "__module__", "" + ).startswith("unittest.mock") selected_class: _ConfigurationModuleFactory | None = None @@ -1661,10 +2077,10 @@ def _determine_backend_from_uri(uri: str | None) -> str | None: def _backend_display_name(backend: str) -> str: """Return user-facing backend name.""" mapping = { - "s3": "AWS", - "b2": "Backblaze B2", - "azure": "Azure", - "gcs": "Google Cloud Storage" + "s3": "AWS", + "b2": "Backblaze B2", + "azure": "Azure", + "gcs": "Google Cloud Storage", } return mapping.get(backend, backend.upper()) @@ -1678,13 +2094,17 @@ def _repository_config_to_dict(repository_obj: object, name: str) -> dict[str, o data: dict[str, object] = dict(cast(_SupportsToDict, repository_obj).to_dict()) elif isinstance(repository_obj, Mapping): source_mapping = cast(Mapping[object, object], repository_obj) - data = { - str(key): value - for key, value in source_mapping.items() - } + data = {str(key): value for key, value in source_mapping.items()} else: data = {"name": name} - for attr in ("uri", "location", "description", "tags", "password", "has_backend_credentials"): + for attr in ( + "uri", + "location", + "description", + "tags", + "password", + "has_backend_credentials", + ): if hasattr(repository_obj, attr): value = cast(object, getattr(repository_obj, attr)) if value is not None: @@ -1699,11 +2119,26 @@ def _repository_config_to_dict(repository_obj: object, name: str) -> dict[str, o @repos_credentials_app.command("set") def repos_credentials_set( - name: Annotated[str, typer.Argument(help="Repository name", autocompletion=repository_name_completer)], - master_password: Annotated[ - str | None, typer.Option("--master-password", "-m", help="Master password to unlock the credential manager if locked")] = None, - verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, - config_dir: Annotated[Path | None, typer.Option("--config-dir", help="Configuration directory")] = None, + name: Annotated[ + str, + typer.Argument( + help="Repository name", autocompletion=repository_name_completer + ), + ], + master_password: Annotated[ + str | None, + typer.Option( + "--master-password", + "-m", + help="Master password to unlock the credential manager if locked", + ), + ] = None, + verbose: Annotated[ + bool, typer.Option("--verbose", "-v", help="Enable verbose output") + ] = False, + config_dir: Annotated[ + Path | None, typer.Option("--config-dir", help="Configuration directory") + ] = None, ) -> None: """Store backend credentials for a repository.""" setup_logging(verbose, config_dir) @@ -1711,12 +2146,17 @@ def repos_credentials_set( try: config_module = _create_configuration_module(config_dir) repository_obj = config_module.get_repository(name) - repo_uri = getattr(repository_obj, 'uri', None) or getattr(repository_obj, 'location', None) + repo_uri = getattr(repository_obj, "uri", None) or getattr( + repository_obj, "location", None + ) repository_config = _repository_config_to_dict(repository_obj, name) backend_type = _determine_backend_from_uri(repo_uri) if backend_type != "s3": - show_error_panel("Unsupported Backend", "Backend credentials management is currently supported for S3 repositories only.") + show_error_panel( + "Unsupported Backend", + "Backend credentials management is currently supported for S3 repositories only.", + ) raise typer.Exit(1) service_manager = None @@ -1730,9 +2170,16 @@ def repos_credentials_set( credential_manager = _create_credential_manager(config_dir) if repository_factory is not None: try: - setattr(cast(object, repository_factory), "_credential_manager", credential_manager) + setattr( + cast(object, repository_factory), + "_credential_manager", + credential_manager, + ) except Exception as attach_exc: - logging.getLogger(__name__).debug("Unable to attach credential manager to repository factory: %s", attach_exc) + logging.getLogger(__name__).debug( + "Unable to attach credential manager to repository factory: %s", + attach_exc, + ) if master_password is not None: _ensure_manager_unlocked(credential_manager, master_password, interactive) @@ -1747,16 +2194,22 @@ def repos_credentials_set( prompt_service = PromptService(console=console, force_interactive=True) try: access_key = prompt_service.prompt_text("AWS Access Key ID", required=True) - secret_key = prompt_service.prompt_password("AWS Secret Access Key", required=True) - region = prompt_service.prompt_text("AWS Region", default="", required=False) - insecure_tls = prompt_service.prompt_confirm("Allow insecure TLS (skip certificate verification)?", default=False) + secret_key = prompt_service.prompt_password( + "AWS Secret Access Key", required=True + ) + region = prompt_service.prompt_text( + "AWS Region", default="", required=False + ) + insecure_tls = prompt_service.prompt_confirm( + "Allow insecure TLS (skip certificate verification)?", default=False + ) except PromptError as e: show_error_panel("Missing Parameter", str(e)) raise typer.Exit(2) credentials_payload: dict[str, object] = { - "access_key_id": access_key, - "secret_access_key": secret_key, + "access_key_id": access_key, + "secret_access_key": secret_key, } if region: credentials_payload["region"] = region @@ -1764,22 +2217,25 @@ def repos_credentials_set( credentials_payload["insecure_tls"] = True success = store_backend_credentials_helper( - repository_name=name, - backend_type=backend_type, - backend_name=_backend_display_name(backend_type), - credentials_dict=credentials_payload, - cred_mgr=cast(_CredentialStoreLike, cast(object, credential_manager)), - config_manager=cast(_RepositoryConfigStoreLike, config_module), - repository_config=repository_config, - console=console, - logger=logging.getLogger(__name__), - allow_prompt=interactive, + repository_name=name, + backend_type=backend_type, + backend_name=_backend_display_name(backend_type), + credentials_dict=credentials_payload, + cred_mgr=cast(_CredentialStoreLike, cast(object, credential_manager)), + config_manager=cast(_RepositoryConfigStoreLike, config_module), + repository_config=repository_config, + console=console, + logger=logging.getLogger(__name__), + allow_prompt=interactive, ) if not success: raise typer.Exit(1) - show_success_panel("Credentials Stored", f"{_backend_display_name(backend_type)} credentials stored for '{name}'.") + show_success_panel( + "Credentials Stored", + f"{_backend_display_name(backend_type)} credentials stored for '{name}'.", + ) except RepositoryNotFoundError as e: show_error_panel("Repository Not Found", str(e)) raise typer.Exit(1) @@ -1789,7 +2245,9 @@ def repos_credentials_set( show_error_panel("Operation Cancelled", "Credential storage cancelled by user") raise typer.Exit(130) except Exception as e: - show_error_panel("Credential Error", f"Failed to store repository credentials: {e}") + show_error_panel( + "Credential Error", f"Failed to store repository credentials: {e}" + ) if verbose: console.print_exception() raise typer.Exit(1) @@ -1797,12 +2255,29 @@ def repos_credentials_set( @repos_credentials_app.command("remove") def repos_credentials_remove( - name: Annotated[str, typer.Argument(help="Repository name", autocompletion=repository_name_completer)], - yes: Annotated[bool, typer.Option("--yes", "-y", help="Confirm removal without prompt")] = False, - master_password: Annotated[ - str | None, typer.Option("--master-password", "-m", help="Master password to unlock the credential manager if locked")] = None, - verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, - config_dir: Annotated[Path | None, typer.Option("--config-dir", help="Configuration directory")] = None, + name: Annotated[ + str, + typer.Argument( + help="Repository name", autocompletion=repository_name_completer + ), + ], + yes: Annotated[ + bool, typer.Option("--yes", "-y", help="Confirm removal without prompt") + ] = False, + master_password: Annotated[ + str | None, + typer.Option( + "--master-password", + "-m", + help="Master password to unlock the credential manager if locked", + ), + ] = None, + verbose: Annotated[ + bool, typer.Option("--verbose", "-v", help="Enable verbose output") + ] = False, + config_dir: Annotated[ + Path | None, typer.Option("--config-dir", help="Configuration directory") + ] = None, ) -> None: """Remove stored backend credentials for a repository.""" setup_logging(verbose, config_dir) @@ -1810,21 +2285,31 @@ def repos_credentials_remove( try: config_module = _create_configuration_module(config_dir) repository_obj = config_module.get_repository(name) - repo_uri = getattr(repository_obj, 'uri', None) or getattr(repository_obj, 'location', None) + repo_uri = getattr(repository_obj, "uri", None) or getattr( + repository_obj, "location", None + ) repository_config = _repository_config_to_dict(repository_obj, name) backend_type = _determine_backend_from_uri(repo_uri) if backend_type != "s3": - show_info_panel("Unsupported Backend", "No backend credentials stored for this repository type.") + show_info_panel( + "Unsupported Backend", + "No backend credentials stored for this repository type.", + ) raise typer.Exit(0) confirmed = yes if not confirmed: if interactive: prompt_service = PromptService(console=console) - confirmed = prompt_service.prompt_confirm(f"Remove {_backend_display_name(backend_type)} credentials for '{name}'?", default=False) + confirmed = prompt_service.prompt_confirm( + f"Remove {_backend_display_name(backend_type)} credentials for '{name}'?", + default=False, + ) if not confirmed: - show_info_panel("Operation Cancelled", "Credential removal cancelled.") + show_info_panel( + "Operation Cancelled", "Credential removal cancelled." + ) raise typer.Exit(0) else: confirmed = True @@ -1838,21 +2323,32 @@ def repos_credentials_remove( except Exception: if interactive: raise - logging.getLogger(__name__).debug("Unable to unlock credential manager automatically for remove command.") + logging.getLogger(__name__).debug( + "Unable to unlock credential manager automatically for remove command." + ) removed = False if hasattr(credential_manager, "remove_repository_backend_credentials"): - removed = credential_manager.remove_repository_backend_credentials(name, backend_type) + removed = credential_manager.remove_repository_backend_credentials( + name, backend_type + ) if removed: - repository_config['has_backend_credentials'] = False + repository_config["has_backend_credentials"] = False try: config_module.update_repository(name, repository_config) except Exception as exc: - logging.getLogger(__name__).debug("Failed to update repository after credential removal: %s", exc) - show_success_panel("Credentials Removed", f"Removed {_backend_display_name(backend_type)} credentials for '{name}'.") + logging.getLogger(__name__).debug( + "Failed to update repository after credential removal: %s", exc + ) + show_success_panel( + "Credentials Removed", + f"Removed {_backend_display_name(backend_type)} credentials for '{name}'.", + ) else: - show_info_panel("No Credentials", f"No stored credentials found for '{name}'.") + show_info_panel( + "No Credentials", f"No stored credentials found for '{name}'." + ) except RepositoryNotFoundError as e: show_error_panel("Repository Not Found", str(e)) raise typer.Exit(1) @@ -1862,7 +2358,9 @@ def repos_credentials_remove( show_error_panel("Operation Cancelled", "Credential removal cancelled by user") raise typer.Exit(130) except Exception as e: - show_error_panel("Credential Error", f"Failed to remove repository credentials: {e}") + show_error_panel( + "Credential Error", f"Failed to remove repository credentials: {e}" + ) if verbose: console.print_exception() raise typer.Exit(1) @@ -1870,11 +2368,26 @@ def repos_credentials_remove( @repos_credentials_app.command("show") def repos_credentials_show( - name: Annotated[str, typer.Argument(help="Repository name", autocompletion=repository_name_completer)], - master_password: Annotated[ - str | None, typer.Option("--master-password", "-m", help="Master password to unlock the credential manager if locked")] = None, - verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, - config_dir: Annotated[Path | None, typer.Option("--config-dir", help="Configuration directory")] = None, + name: Annotated[ + str, + typer.Argument( + help="Repository name", autocompletion=repository_name_completer + ), + ], + master_password: Annotated[ + str | None, + typer.Option( + "--master-password", + "-m", + help="Master password to unlock the credential manager if locked", + ), + ] = None, + verbose: Annotated[ + bool, typer.Option("--verbose", "-v", help="Enable verbose output") + ] = False, + config_dir: Annotated[ + Path | None, typer.Option("--config-dir", help="Configuration directory") + ] = None, ) -> None: """Display stored backend credentials for a repository.""" setup_logging(verbose, config_dir) @@ -1882,11 +2395,16 @@ def repos_credentials_show( try: config_module = _create_configuration_module(config_dir) repository_obj = config_module.get_repository(name) - repo_uri = getattr(repository_obj, 'uri', None) or getattr(repository_obj, 'location', None) + repo_uri = getattr(repository_obj, "uri", None) or getattr( + repository_obj, "location", None + ) backend_type = _determine_backend_from_uri(repo_uri) if backend_type != "s3": - show_info_panel("Unsupported Backend", "No backend credentials stored for this repository type.") + show_info_panel( + "Unsupported Backend", + "No backend credentials stored for this repository type.", + ) raise typer.Exit(0) credential_manager = _create_credential_manager(config_dir) @@ -1898,28 +2416,43 @@ def repos_credentials_show( except Exception: if interactive: raise - logging.getLogger(__name__).debug("Unable to unlock credential manager automatically for show command.") + logging.getLogger(__name__).debug( + "Unable to unlock credential manager automatically for show command." + ) has_credentials = False if hasattr(credential_manager, "has_repository_backend_credentials"): - has_credentials = credential_manager.has_repository_backend_credentials(name, backend_type) + has_credentials = credential_manager.has_repository_backend_credentials( + name, backend_type + ) if not has_credentials: - show_info_panel("No Credentials", f"No {_backend_display_name(backend_type)} credentials stored for '{name}'.") + show_info_panel( + "No Credentials", + f"No {_backend_display_name(backend_type)} credentials stored for '{name}'.", + ) return credentials = {} if hasattr(credential_manager, "get_repository_backend_credentials"): - credentials = credential_manager.get_repository_backend_credentials(name, backend_type) or {} + credentials = ( + credential_manager.get_repository_backend_credentials( + name, backend_type + ) + or {} + ) if not credentials: - show_info_panel("No Credentials", f"No {_backend_display_name(backend_type)} credentials stored for '{name}'.") + show_info_panel( + "No Credentials", + f"No {_backend_display_name(backend_type)} credentials stored for '{name}'.", + ) return # Format credentials data for table display table_data: list[dict[str, str]] = [] for key, value in credentials.items(): - display_key = key.replace('_', ' ').title() + display_key = key.replace("_", " ").title() display_value = value if len(value) > 4 and any(token in key for token in ["secret", "key"]): display_value = value[:4] + "•••" + value[-2:] @@ -1929,9 +2462,9 @@ def repos_credentials_show( formatter = get_output_formatter(console=console) formatter.format_table( - data=table_data, - columns=["Field", "Value"], - title=f"{_backend_display_name(backend_type)} Credentials for {name}" + data=table_data, + columns=["Field", "Value"], + title=f"{_backend_display_name(backend_type)} Credentials for {name}", ) except RepositoryNotFoundError as e: show_error_panel("Repository Not Found", str(e)) @@ -1942,16 +2475,18 @@ def repos_credentials_show( show_error_panel("Operation Cancelled", "Credential display cancelled by user") raise typer.Exit(130) except Exception as e: - show_error_panel("Credential Error", f"Failed to display repository credentials: {e}") + show_error_panel( + "Credential Error", f"Failed to display repository credentials: {e}" + ) if verbose: console.print_exception() raise typer.Exit(1) def _ensure_manager_unlocked( - manager: _UnlockableCredentialManager, - master_password: str | None, - interactive: bool, + manager: _UnlockableCredentialManager, + master_password: str | None, + interactive: bool, ) -> None: """Unlock credential manager when required or raise typer.Exit.""" if not manager.is_locked(): @@ -1961,40 +2496,80 @@ def _ensure_manager_unlocked( if interactive: prompt_service = PromptService(console=console) try: - master_password = prompt_service.prompt_password("Master password", required=True) + master_password = prompt_service.prompt_password( + "Master password", required=True + ) except PromptError: pass if master_password is None: - show_error_panel("Credential Manager Locked", "Provide --master-password to unlock before proceeding.") + show_error_panel( + "Credential Manager Locked", + "Provide --master-password to unlock before proceeding.", + ) raise typer.Exit(1) if not manager.unlock(master_password): - show_error_panel("Unlock Failed", "Unable to unlock credential manager with the provided master password.") + show_error_panel( + "Unlock Failed", + "Unable to unlock credential manager with the provided master password.", + ) raise typer.Exit(1) @config_export_app.command("config") def config_export_config( - file: Annotated[Path, typer.Argument(help="Output file path for configuration export")], - include_repositories: Annotated[bool, typer.Option("--repositories/--no-repositories", help="Include repository configurations")] = True, - include_selections: Annotated[bool, typer.Option("--selections/--no-selections", help="Include data selection configurations")] = True, - include_policies: Annotated[bool, typer.Option("--policies/--no-policies", help="Include policy configurations")] = True, - include_schedules: Annotated[bool, typer.Option("--schedules/--no-schedules", help="Include schedule configurations")] = True, - include_credentials: Annotated[bool, typer.Option("--credentials/--no-credentials", help="Include credential references (not actual secrets)")] = False, - overwrite: Annotated[bool, typer.Option("--overwrite", "-f", help="Overwrite existing file")] = False, - verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, - config_dir: Annotated[Path | None, typer.Option("--config-dir", help="Configuration directory")] = None, + file: Annotated[ + Path, typer.Argument(help="Output file path for configuration export") + ], + include_repositories: Annotated[ + bool, + typer.Option( + "--repositories/--no-repositories", help="Include repository configurations" + ), + ] = True, + include_selections: Annotated[ + bool, + typer.Option( + "--selections/--no-selections", help="Include data selection configurations" + ), + ] = True, + include_policies: Annotated[ + bool, + typer.Option("--policies/--no-policies", help="Include policy configurations"), + ] = True, + include_schedules: Annotated[ + bool, + typer.Option( + "--schedules/--no-schedules", help="Include schedule configurations" + ), + ] = True, + include_credentials: Annotated[ + bool, + typer.Option( + "--credentials/--no-credentials", + help="Include credential references (not actual secrets)", + ), + ] = False, + overwrite: Annotated[ + bool, typer.Option("--overwrite", "-f", help="Overwrite existing file") + ] = False, + verbose: Annotated[ + bool, typer.Option("--verbose", "-v", help="Enable verbose output") + ] = False, + config_dir: Annotated[ + Path | None, typer.Option("--config-dir", help="Configuration directory") + ] = None, ) -> None: """ Export TimeLocker configuration to a file. - + This command exports the complete TimeLocker configuration including repositories, data selections, policies, and schedules to a JSON file. The exported configuration can be used for backup, migration, or sharing configurations between systems. - + By default, credential secrets are NOT exported for security reasons. Only credential references are included if --credentials is specified. - + Examples: timelocker config export config backup.json timelocker config export config full-config.json --credentials @@ -2006,8 +2581,8 @@ def config_export_config( # Check if file exists and overwrite not specified if file.exists() and not overwrite: show_error_panel( - "File Exists", - f"Output file '{file}' already exists. Use --overwrite to replace it." + "File Exists", + f"Output file '{file}' already exists. Use --overwrite to replace it.", ) raise typer.Exit(2) @@ -2020,12 +2595,21 @@ def config_export_config( # Build export data based on options export_data: dict[str, object] = { - "metadata": { - "exported_at": datetime.now().isoformat(), - "timelocker_version": __version__, - "export_type": "full" if all([include_repositories, include_selections, include_policies, include_schedules]) else "selective" - }, - "general": _serialize_config_value(config.general), + "metadata": { + "exported_at": datetime.now().isoformat(), + "timelocker_version": __version__, + "export_type": "full" + if all( + [ + include_repositories, + include_selections, + include_policies, + include_schedules, + ] + ) + else "selective", + }, + "general": _serialize_config_value(config.general), } # Add repositories if requested @@ -2035,8 +2619,8 @@ def config_export_config( repo_dict = _serialize_config_value(repo) # Remove sensitive data unless explicitly requested if not include_credentials: - _ = repo_dict.pop('password', None) - _ = repo_dict.pop('has_backend_credentials', None) + _ = repo_dict.pop("password", None) + _ = repo_dict.pop("has_backend_credentials", None) repos_data[name] = repo_dict export_data["repositories"] = repos_data @@ -2076,8 +2660,8 @@ def config_export_config( typed_security = cast(object, security) security_dict = _serialize_config_value(typed_security) if not include_credentials: - _ = security_dict.pop('master_password', None) - _ = security_dict.pop('encryption_key', None) + _ = security_dict.pop("master_password", None) + _ = security_dict.pop("encryption_key", None) export_data["security"] = security_dict monitoring = getattr(config, "monitoring", None) @@ -2086,38 +2670,48 @@ def config_export_config( export_data["monitoring"] = _serialize_config_value(typed_monitoring) # Write export file - with open(file, 'w') as f: + with open(file, "w") as f: json.dump(export_data, f, indent=2) # Show success message with summary summary_parts: list[str] = [] if include_repositories: - repo_count = len(cast(dict[str, object], export_data.get("repositories", {}))) + repo_count = len( + cast(dict[str, object], export_data.get("repositories", {})) + ) summary_parts.append(f"{repo_count} repositories") if include_selections: - selection_count = len(cast(dict[str, object], export_data.get("data_selections", {}))) + selection_count = len( + cast(dict[str, object], export_data.get("data_selections", {})) + ) summary_parts.append(f"{selection_count} selections") if include_policies: policy_count = len(cast(dict[str, object], export_data.get("policies", {}))) summary_parts.append(f"{policy_count} policies") if include_schedules: - schedule_count = len(cast(dict[str, object], export_data.get("schedules", {}))) + schedule_count = len( + cast(dict[str, object], export_data.get("schedules", {})) + ) summary_parts.append(f"{schedule_count} schedules") summary = ", ".join(summary_parts) if summary_parts else "configuration" show_success_panel( - "Configuration Exported", - f"Configuration exported to '{file}'\n\nExported: {summary}", - { - "File": str(file), - "Size": f"{file.stat().st_size} bytes", - "Credentials": "Included (references only)" if include_credentials else "Not included" - } + "Configuration Exported", + f"Configuration exported to '{file}'\n\nExported: {summary}", + { + "File": str(file), + "Size": f"{file.stat().st_size} bytes", + "Credentials": "Included (references only)" + if include_credentials + else "Not included", + }, ) except KeyboardInterrupt: - show_error_panel("Operation Cancelled", "Configuration export cancelled by user") + show_error_panel( + "Operation Cancelled", "Configuration export cancelled by user" + ) raise typer.Exit(130) except Exception as e: show_error_panel("Export Error", f"Failed to export configuration: {e}") @@ -2128,24 +2722,34 @@ def config_export_config( @migrate_app.command("validate") def migrate_validate( - source: Annotated[Path, typer.Argument(help="Source configuration file to validate")], - show_changes: Annotated[bool, typer.Option("--show-changes", help="Show detailed change summary")] = True, - check_compatibility: Annotated[bool, typer.Option("--check-compatibility", help="Check version compatibility")] = True, - verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, - config_dir: Annotated[Path | None, typer.Option("--config-dir", help="Configuration directory")] = None, + source: Annotated[ + Path, typer.Argument(help="Source configuration file to validate") + ], + show_changes: Annotated[ + bool, typer.Option("--show-changes", help="Show detailed change summary") + ] = True, + check_compatibility: Annotated[ + bool, typer.Option("--check-compatibility", help="Check version compatibility") + ] = True, + verbose: Annotated[ + bool, typer.Option("--verbose", "-v", help="Enable verbose output") + ] = False, + config_dir: Annotated[ + Path | None, typer.Option("--config-dir", help="Configuration directory") + ] = None, ) -> None: """ Validate configuration file for import without making changes. - + This command performs a dry-run validation of a configuration file to check: - File format and structure validity - Version compatibility with current TimeLocker installation - Potential conflicts with existing configuration - Required dependencies and prerequisites - + Use this command before importing configuration to preview changes and identify potential issues. - + Examples: timelocker migrate validate backup.json timelocker migrate validate config.json --show-changes @@ -2157,20 +2761,17 @@ def migrate_validate( # Check if source file exists if not source.exists(): show_error_panel( - "File Not Found", - f"Source configuration file '{source}' does not exist." + "File Not Found", + f"Source configuration file '{source}' does not exist.", ) raise typer.Exit(2) # Load and parse source configuration try: - with open(source, 'r') as f: + with open(source, "r") as f: import_data = cast(_ConfigObjectMap, json.load(f)) except json.JSONDecodeError as e: - show_error_panel( - "Invalid JSON", - f"Source file contains invalid JSON: {e}" - ) + show_error_panel("Invalid JSON", f"Source file contains invalid JSON: {e}") raise typer.Exit(2) # Get current configuration @@ -2179,15 +2780,15 @@ def migrate_validate( # Validation results validation_results: _ValidationResults = { - "valid": True, - "errors": [], - "warnings": [], - "changes": { - "repositories": {"add": [], "update": [], "remove": []}, - "targets": {"add": [], "update": [], "remove": []}, - "policies": {"add": [], "update": [], "remove": []}, - "schedules": {"add": [], "update": [], "remove": []}, - }, + "valid": True, + "errors": [], + "warnings": [], + "changes": { + "repositories": {"add": [], "update": [], "remove": []}, + "targets": {"add": [], "update": [], "remove": []}, + "policies": {"add": [], "update": [], "remove": []}, + "schedules": {"add": [], "update": [], "remove": []}, + }, } # Check metadata and version compatibility @@ -2198,32 +2799,38 @@ def migrate_validate( # Simple version check - in production, this would be more sophisticated if import_version != "unknown" and import_version != __version__: version_warning = ( - f"Configuration was exported from version {import_version}, " - f"current version is {__version__}. Some features may not be compatible." + f"Configuration was exported from version {import_version}, " + f"current version is {__version__}. Some features may not be compatible." ) validation_results["warnings"].append(version_warning) # Validate repositories import_repos = cast(_ConfigSectionMap, import_data.get("repositories", {})) - current_repos = {name: repo for name, repo in current_config.repositories.items()} + current_repos = { + name: repo for name, repo in current_config.repositories.items() + } for repo_name, repo_data in import_repos.items(): # Check required fields if not repo_data.get("uri") and not repo_data.get("location"): validation_results["errors"].append( - f"Repository '{repo_name}' missing required 'uri' or 'location' field" + f"Repository '{repo_name}' missing required 'uri' or 'location' field" ) validation_results["valid"] = False # Check for conflicts if repo_name in current_repos: - current_uri = getattr(current_repos[repo_name], 'uri', None) or getattr(current_repos[repo_name], 'location', None) + current_uri = getattr(current_repos[repo_name], "uri", None) or getattr( + current_repos[repo_name], "location", None + ) import_uri = repo_data.get("uri") or repo_data.get("location") if current_uri != import_uri: - validation_results["changes"]["repositories"]["update"].append(repo_name) + validation_results["changes"]["repositories"]["update"].append( + repo_name + ) repository_warning = ( - f"Repository '{repo_name}' exists with different URI. " - f"Import will update: {current_uri} -> {import_uri}" + f"Repository '{repo_name}' exists with different URI. " + f"Import will update: {current_uri} -> {import_uri}" ) validation_results["warnings"].append(repository_warning) else: @@ -2231,21 +2838,27 @@ def migrate_validate( # Validate backup targets import_targets = cast(_ConfigSectionMap, import_data.get("backup_targets", {})) - current_targets = {name: target for name, target in current_config.backup_targets.items()} + current_targets = { + name: target for name, target in current_config.backup_targets.items() + } for target_name, target_data in import_targets.items(): # Check required fields if not target_data.get("paths"): validation_results["errors"].append( - f"Backup target '{target_name}' missing required 'paths' field" + f"Backup target '{target_name}' missing required 'paths' field" ) validation_results["valid"] = False # Check repository reference target_repo = target_data.get("repository") - if target_repo and target_repo not in import_repos and target_repo not in current_repos: + if ( + target_repo + and target_repo not in import_repos + and target_repo not in current_repos + ): validation_results["warnings"].append( - f"Backup target '{target_name}' references unknown repository '{target_repo}'" + f"Backup target '{target_name}' references unknown repository '{target_repo}'" ) # Check for conflicts @@ -2260,9 +2873,13 @@ def migrate_validate( for policy_name, policy_data in import_policies.items(): # Check repository references policy_repo = policy_data.get("repository") - if policy_repo and policy_repo not in import_repos and policy_repo not in current_repos: + if ( + policy_repo + and policy_repo not in import_repos + and policy_repo not in current_repos + ): validation_results["warnings"].append( - f"Policy '{policy_name}' references unknown repository '{policy_repo}'" + f"Policy '{policy_name}' references unknown repository '{policy_repo}'" ) validation_results["changes"]["policies"]["add"].append(policy_name) @@ -2275,7 +2892,7 @@ def migrate_validate( schedule_policy = schedule_data.get("policy") if schedule_policy and schedule_policy not in import_policies: validation_results["warnings"].append( - f"Schedule '{schedule_name}' references unknown policy '{schedule_policy}'" + f"Schedule '{schedule_name}' references unknown policy '{schedule_policy}'" ) validation_results["changes"]["schedules"]["add"].append(schedule_name) @@ -2287,15 +2904,21 @@ def migrate_validate( console.print(f"[bold]Source File:[/bold] {source}") console.print(f"[bold]File Size:[/bold] {source.stat().st_size} bytes") if metadata: - console.print(f"[bold]Exported:[/bold] {metadata.get('exported_at', 'unknown')}") + console.print( + f"[bold]Exported:[/bold] {metadata.get('exported_at', 'unknown')}" + ) console.print(f"[bold]Source Version:[/bold] {import_version}") console.print(f"[bold]Current Version:[/bold] {__version__}\n") # Show validation status if validation_results["valid"]: - console.print("[bold green]✓ Configuration is valid and can be imported[/bold green]\n") + console.print( + "[bold green]✓ Configuration is valid and can be imported[/bold green]\n" + ) else: - console.print("[bold red]✗ Configuration has errors and cannot be imported[/bold red]\n") + console.print( + "[bold red]✗ Configuration has errors and cannot be imported[/bold red]\n" + ) # Show errors if validation_results["errors"]: @@ -2317,12 +2940,14 @@ def migrate_validate( changes = validation_results["changes"] change_sections: list[tuple[str, _ValidationChangeBucket]] = [ - ("repositories", changes["repositories"]), - ("targets", changes["targets"]), - ("policies", changes["policies"]), - ("schedules", changes["schedules"]), + ("repositories", changes["repositories"]), + ("targets", changes["targets"]), + ("policies", changes["policies"]), + ("schedules", changes["schedules"]), ] - total_changes = sum(_change_bucket_total(bucket) for _, bucket in change_sections) + total_changes = sum( + _change_bucket_total(bucket) for _, bucket in change_sections + ) if total_changes == 0: console.print(" No changes detected\n") @@ -2332,11 +2957,17 @@ def migrate_validate( if category_changes > 0: console.print(f"\n [bold]{category.title()}:[/bold]") if actions["add"]: - console.print(f" [green]+ Add:[/green] {', '.join(actions['add'])}") + console.print( + f" [green]+ Add:[/green] {', '.join(actions['add'])}" + ) if actions["update"]: - console.print(f" [yellow]~ Update:[/yellow] {', '.join(actions['update'])}") + console.print( + f" [yellow]~ Update:[/yellow] {', '.join(actions['update'])}" + ) if actions["remove"]: - console.print(f" [red]- Remove:[/red] {', '.join(actions['remove'])}") + console.print( + f" [red]- Remove:[/red] {', '.join(actions['remove'])}" + ) console.print() # Show next steps @@ -2370,23 +3001,38 @@ def migrate_validate( @config_import_app.command("config") def config_import_config( - file: Annotated[Path, typer.Argument(help="Configuration file to import")], - merge: Annotated[bool, typer.Option("--merge", help="Merge with existing configuration instead of replacing")] = True, - overwrite: Annotated[bool, typer.Option("--overwrite", help="Overwrite conflicting items")] = False, - dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview changes without applying them")] = False, - yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts")] = False, - verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, - config_dir: Annotated[Path | None, typer.Option("--config-dir", help="Configuration directory")] = None, + file: Annotated[Path, typer.Argument(help="Configuration file to import")], + merge: Annotated[ + bool, + typer.Option( + "--merge", help="Merge with existing configuration instead of replacing" + ), + ] = True, + overwrite: Annotated[ + bool, typer.Option("--overwrite", help="Overwrite conflicting items") + ] = False, + dry_run: Annotated[ + bool, typer.Option("--dry-run", help="Preview changes without applying them") + ] = False, + yes: Annotated[ + bool, typer.Option("--yes", "-y", help="Skip confirmation prompts") + ] = False, + verbose: Annotated[ + bool, typer.Option("--verbose", "-v", help="Enable verbose output") + ] = False, + config_dir: Annotated[ + Path | None, typer.Option("--config-dir", help="Configuration directory") + ] = None, ) -> None: """ Import TimeLocker configuration from a file. - + This command imports configuration from a previously exported file. By default, it merges the imported configuration with existing configuration. Use --overwrite to replace conflicting items. - + It's recommended to run 'timelocker migrate validate' first to preview changes. - + Examples: timelocker config import config backup.json --dry-run timelocker config import config backup.json --merge @@ -2399,19 +3045,17 @@ def config_import_config( # Check if source file exists if not file.exists(): show_error_panel( - "File Not Found", - f"Configuration file '{file}' does not exist." + "File Not Found", f"Configuration file '{file}' does not exist." ) raise typer.Exit(2) # Load import data try: - with open(file, 'r') as f: + with open(file, "r") as f: import_data = cast(_ConfigObjectMap, json.load(f)) except json.JSONDecodeError as e: show_error_panel( - "Invalid JSON", - f"Configuration file contains invalid JSON: {e}" + "Invalid JSON", f"Configuration file contains invalid JSON: {e}" ) raise typer.Exit(2) @@ -2424,8 +3068,12 @@ def config_import_config( metadata = cast(_ConfigObjectMap, import_data.get("metadata", {})) if metadata: - console.print(f"[bold]Exported:[/bold] {metadata.get('exported_at', 'unknown')}") - console.print(f"[bold]Version:[/bold] {metadata.get('timelocker_version', 'unknown')}") + console.print( + f"[bold]Exported:[/bold] {metadata.get('exported_at', 'unknown')}" + ) + console.print( + f"[bold]Version:[/bold] {metadata.get('timelocker_version', 'unknown')}" + ) console.print(f"[bold]Mode:[/bold] {'Merge' if merge else 'Replace'}") console.print(f"[bold]Overwrite:[/bold] {'Yes' if overwrite else 'No'}\n") @@ -2450,36 +3098,37 @@ def config_import_config( if not dry_run and not yes and interactive: prompt_service = PromptService(console=console) if not prompt_service.prompt_confirm("Proceed with import?", default=False): - show_info_panel("Import Cancelled", "Configuration import cancelled by user") + show_info_panel( + "Import Cancelled", "Configuration import cancelled by user" + ) raise typer.Exit(0) if dry_run: dry_run_message = ( - "Configuration validated successfully. No changes were made.\n\n" - "Run without --dry-run to apply changes." - ) - show_info_panel( - "Dry Run Complete", - dry_run_message + "Configuration validated successfully. No changes were made.\n\n" + "Run without --dry-run to apply changes." ) + show_info_panel("Dry Run Complete", dry_run_message) raise typer.Exit(0) # Perform import using configuration module config_module.import_configuration(file) show_success_panel( - "Configuration Imported", - f"Configuration imported successfully from '{file}'", - { - "Repositories": str(repo_count), - "Targets": str(target_count), - "Policies": str(policy_count), - "Schedules": str(schedule_count) - } + "Configuration Imported", + f"Configuration imported successfully from '{file}'", + { + "Repositories": str(repo_count), + "Targets": str(target_count), + "Policies": str(policy_count), + "Schedules": str(schedule_count), + }, ) except KeyboardInterrupt: - show_error_panel("Operation Cancelled", "Configuration import cancelled by user") + show_error_panel( + "Operation Cancelled", "Configuration import cancelled by user" + ) raise typer.Exit(130) except typer.Exit: raise @@ -2523,7 +3172,9 @@ def config_import_config( logging.getLogger(__name__).debug(f"Could not import policy commands: {e}") try: - from .cli_modules.commands.selections import selections_app as _selections_commands_app + from .cli_modules.commands.selections import ( + selections_app as _selections_commands_app, + ) # Add selections app to main app app.add_typer(_selections_commands_app, name="selections") @@ -2542,11 +3193,13 @@ def config_import_config( from .cli_modules.commands.monitoring import monitor_app as _monitor_commands_app from .cli_modules.commands.monitoring import logs_app as _logs_commands_app from .cli_modules.commands.monitoring import reports_app as _reports_commands_app + from .cli_modules.commands.monitoring import runs_app as _runs_commands_app # Add monitoring apps to main app app.add_typer(_monitor_commands_app, name="monitor") app.add_typer(_logs_commands_app, name="logs") app.add_typer(_reports_commands_app, name="reports") + app.add_typer(_runs_commands_app, name="runs") except ImportError as e: logging.getLogger(__name__).debug(f"Could not import monitoring commands: {e}") @@ -2559,7 +3212,9 @@ def config_import_config( logging.getLogger(__name__).debug(f"Could not import restore commands: {e}") try: - from .cli_modules.commands.credentials import credentials_app as _credentials_commands_app + from .cli_modules.commands.credentials import ( + credentials_app as _credentials_commands_app, + ) _merge_typer_app(credentials_app, _credentials_commands_app) except ImportError as e: diff --git a/src/TimeLocker/cli_modules/commands/monitoring.py b/src/TimeLocker/cli_modules/commands/monitoring.py index 0e1afe1..e0cae70 100644 --- a/src/TimeLocker/cli_modules/commands/monitoring.py +++ b/src/TimeLocker/cli_modules/commands/monitoring.py @@ -10,6 +10,7 @@ from typing import Optional, Annotated, Dict, Any from pathlib import Path from datetime import datetime, timedelta +from uuid import UUID import typer from rich.table import Table @@ -17,6 +18,14 @@ from rich.prompt import Confirm from rich.progress import Progress, SpinnerColumn, TextColumn from TimeLocker.utils.service_facade import ServiceFacade, create_service_facade +from TimeLocker.system_control.action_policy import classify_public_action +from TimeLocker.system_control.client import UnixSocketSystemControlClient +from TimeLocker.system_control.models import DiagnosticQuery, RunQuery +from TimeLocker.system_control.types import ( + DiagnosticLevel, + OperationType, + RunState, +) # Import from base module from .base import ( @@ -39,27 +48,23 @@ from ..validation import validate_path, ValidationError # Create Typer apps -monitor_app = create_typer_app( - name="monitor", - help_text="System monitoring operations" -) +monitor_app = create_typer_app(name="monitor", help_text="System monitoring operations") -logs_app = create_typer_app( - name="logs", - help_text="Log viewing and management" -) +logs_app = create_typer_app(name="logs", help_text="Log viewing and management") -reports_app = create_typer_app( - name="reports", - help_text="Report generation" +runs_app = create_typer_app( + name="runs", help_text="Authorized system backup and retention run records" ) +reports_app = create_typer_app(name="reports", help_text="Report generation") + # Helper functions + def _format_size(size_bytes: int) -> str: """Format file size in human-readable format.""" - for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + for unit in ["B", "KB", "MB", "GB", "TB"]: if size_bytes < 1024.0: return f"{size_bytes:.1f} {unit}" size_bytes /= 1024.0 @@ -72,10 +77,10 @@ def _get_system_health_data(config_dir: Optional[Path] = None) -> Dict[str, Any] facade = _setup_monitoring_facade(config_dir) service_manager = facade.service_manager config_service = facade.get_configuration_service() - + # Get repositories repositories = list(config_service.get_repositories().values()) - + health_data = { "timestamp": datetime.now().isoformat(), "repositories": { @@ -89,38 +94,34 @@ def _get_system_health_data(config_dir: Optional[Path] = None) -> Dict[str, Any] "failed": 0, "last_24h": 0, }, - "storage": { - "total_size": 0, - "repositories": [] - } + "storage": {"total_size": 0, "repositories": []}, } - + # Check repository health for repo in repositories: - repo_name = repo.get('name', '') + repo_name = repo.get("name", "") try: # Try to get repository stats stats = service_manager.get_repository_stats(repo_name) if stats: health_data["repositories"]["healthy"] += 1 - health_data["storage"]["total_size"] += stats.get('total_size', 0) - health_data["storage"]["repositories"].append({ - "name": repo_name, - "size": stats.get('total_size', 0), - "snapshots": stats.get('snapshots_count', 0) - }) + health_data["storage"]["total_size"] += stats.get("total_size", 0) + health_data["storage"]["repositories"].append( + { + "name": repo_name, + "size": stats.get("total_size", 0), + "snapshots": stats.get("snapshots_count", 0), + } + ) else: health_data["repositories"]["warning"] += 1 except Exception: health_data["repositories"]["error"] += 1 - + return health_data except Exception as e: logging.getLogger(__name__).error(f"Failed to get system health data: {e}") - return { - "timestamp": datetime.now().isoformat(), - "error": str(e) - } + return {"timestamp": datetime.now().isoformat(), "error": str(e)} def _setup_monitoring_facade(config_dir: Optional[Path] = None) -> ServiceFacade: @@ -129,8 +130,38 @@ def _setup_monitoring_facade(config_dir: Optional[Path] = None) -> ServiceFacade return create_service_facade(config_dir=config_dir, service_manager=service_manager) +def _create_system_control_client() -> UnixSocketSystemControlClient: + """Create the focused protected-system client at the CLI boundary.""" + return UnixSocketSystemControlClient() + + +def _local_log_file(config_dir: Optional[Path]) -> Path: + """Resolve the same local log selected by CLI logging setup.""" + if config_dir is not None: + return Path(config_dir) / "cache" / "logs" / "timelocker.log" + from TimeLocker.config.configuration_path_resolver import ConfigurationPathResolver + + return ConfigurationPathResolver.get_cache_directory() / "logs" / "timelocker.log" + + +def _parse_since(value: str) -> datetime: + """Parse a bounded relative or ISO timestamp used by both log scopes.""" + now = datetime.now() + if value.endswith("h"): + return now - timedelta(hours=int(value[:-1])) + if value.endswith("m"): + return now - timedelta(minutes=int(value[:-1])) + if value.endswith("d"): + return now - timedelta(days=int(value[:-1])) + try: + return datetime.fromisoformat(value) + except ValueError as error: + raise ValueError(f"invalid time format: {value}") from error + + # Monitor Commands + @monitor_app.command("status") @with_error_handling("Status Error") @with_logging @@ -141,82 +172,88 @@ def monitor_status( ) -> None: """ Show current system monitoring status. - + Displays overall system health, current operations, and recent activity summary. - + Requirements: 8.1, 8.3, 8.5 """ try: facade = _setup_monitoring_facade(config_dir) service_manager = facade.service_manager - + # Get system status status = service_manager.get_system_monitoring_status() - + if json_output: console.print(json.dumps(status, indent=2)) return - - if 'error' in status: - show_error_panel("Status Error", status['error']) + + if "error" in status: + show_error_panel("Status Error", status["error"]) raise typer.Exit(1) - + # Display health status - health = status.get('health_status', 'unknown') + health = status.get("health_status", "unknown") health_colors = { - 'healthy': 'green', - 'warning': 'yellow', - 'error': 'red', - 'unknown': 'dim' + "healthy": "green", + "warning": "yellow", + "error": "red", + "unknown": "dim", } - health_color = health_colors.get(health, 'dim') - - console.print(Panel( - f"[{health_color}]System Health: {health.upper()}[/{health_color}]", - title="TimeLocker Monitoring Status", - border_style=health_color - )) - + health_color = health_colors.get(health, "dim") + + console.print( + Panel( + f"[{health_color}]System Health: {health.upper()}[/{health_color}]", + title="TimeLocker Monitoring Status", + border_style=health_color, + ) + ) + # Display current operations - current_ops = status.get('current_operations', 0) + current_ops = status.get("current_operations", 0) if current_ops > 0: - console.print(f"\n[yellow]⚡ {current_ops} operation(s) currently running[/yellow]") + console.print( + f"\n[yellow]⚡ {current_ops} operation(s) currently running[/yellow]" + ) else: console.print("\n[dim]No operations currently running[/dim]") - + # Display recent activity - recent_ops = status.get('recent_operations_24h', 0) + recent_ops = status.get("recent_operations_24h", 0) console.print(f"\n📊 Recent Activity (24 hours): {recent_ops} operations") - + # Display status counts if verbose: - status_counts = status.get('status_counts', {}) + status_counts = status.get("status_counts", {}) if status_counts: console.print("\n[bold]Operation Status Breakdown:[/bold]") - + table = Table(show_header=True, header_style="bold") table.add_column("Status", style="cyan") table.add_column("Count", justify="right") - + status_display = { - 'success': ('✅ Success', 'green'), - 'warning': ('⚠️ Warning', 'yellow'), - 'error': ('❌ Error', 'red'), - 'critical': ('🚨 Critical', 'red bold') + "success": ("✅ Success", "green"), + "warning": ("⚠️ Warning", "yellow"), + "error": ("❌ Error", "red"), + "critical": ("🚨 Critical", "red bold"), } - + for status_key, (label, style) in status_display.items(): count = status_counts.get(status_key, 0) table.add_row(f"[{style}]{label}[/{style}]", str(count)) - + console.print(table) - + # Display timestamp - timestamp = status.get('timestamp', '') + timestamp = status.get("timestamp", "") if timestamp: try: dt = datetime.fromisoformat(timestamp) - console.print(f"\n[dim]Last updated: {dt.strftime('%Y-%m-%d %H:%M:%S')}[/dim]") + console.print( + f"\n[dim]Last updated: {dt.strftime('%Y-%m-%d %H:%M:%S')}[/dim]" + ) except Exception: console.print(f"\n[dim]Last updated: {timestamp}[/dim]") except Exception as e: @@ -227,105 +264,119 @@ def monitor_status( @with_error_handling("Operations Error") @with_logging def monitor_operations( - operation_id: Annotated[Optional[str], typer.Argument(help="Specific operation ID to query")] = None, + operation_id: Annotated[ + Optional[str], typer.Argument(help="Specific operation ID to query") + ] = None, verbose: VerboseOption = False, json_output: JsonOption = False, config_dir: ConfigDirOption = None, ) -> None: """ Show currently running operations or details of a specific operation. - + Requirements: 8.1, 8.3, 8.5 """ try: facade = _setup_monitoring_facade(config_dir) service_manager = facade.service_manager - + if operation_id: # Show specific operation status = service_manager.get_cli_operation_status(operation_id) - + if not status: show_info_panel("Not Found", f"Operation '{operation_id}' not found") raise typer.Exit(1) - + if json_output: console.print(json.dumps(status, indent=2)) return - + # Display operation details - console.print(Panel( - f"[bold]{status['operation_type'].upper()}[/bold]", - title=f"Operation: {operation_id}", - border_style="cyan" - )) - + console.print( + Panel( + f"[bold]{status['operation_type'].upper()}[/bold]", + title=f"Operation: {operation_id}", + border_style="cyan", + ) + ) + console.print(f"\n[bold]Status:[/bold] {status['status']}") console.print(f"[bold]Message:[/bold] {status['message']}") - - if status.get('repository_id'): + + if status.get("repository_id"): console.print(f"[bold]Repository:[/bold] {status['repository_id']}") - - if status.get('progress') is not None: - progress = status['progress'] + + if status.get("progress") is not None: + progress = status["progress"] console.print(f"\n[bold]Progress:[/bold] {progress}%") - + # Show progress bar bar_width = 40 filled = int(bar_width * progress / 100) bar = "█" * filled + "░" * (bar_width - filled) console.print(f"[cyan]{bar}[/cyan]") - - if status.get('files_processed') and status.get('total_files'): - console.print(f"[bold]Files:[/bold] {status['files_processed']}/{status['total_files']}") - - if status.get('estimated_completion'): + + if status.get("files_processed") and status.get("total_files"): + console.print( + f"[bold]Files:[/bold] {status['files_processed']}/{status['total_files']}" + ) + + if status.get("estimated_completion"): try: - dt = datetime.fromisoformat(status['estimated_completion']) - console.print(f"[bold]Estimated Completion:[/bold] {dt.strftime('%Y-%m-%d %H:%M:%S')}") + dt = datetime.fromisoformat(status["estimated_completion"]) + console.print( + f"[bold]Estimated Completion:[/bold] {dt.strftime('%Y-%m-%d %H:%M:%S')}" + ) except Exception: - console.print(f"[bold]Estimated Completion:[/bold] {status['estimated_completion']}") - + console.print( + f"[bold]Estimated Completion:[/bold] {status['estimated_completion']}" + ) + try: - dt = datetime.fromisoformat(status['timestamp']) - console.print(f"\n[dim]Last updated: {dt.strftime('%Y-%m-%d %H:%M:%S')}[/dim]") + dt = datetime.fromisoformat(status["timestamp"]) + console.print( + f"\n[dim]Last updated: {dt.strftime('%Y-%m-%d %H:%M:%S')}[/dim]" + ) except Exception: console.print(f"\n[dim]Last updated: {status['timestamp']}[/dim]") else: # Show all current operations operations = service_manager.get_cli_current_operations() - + if json_output: console.print(json.dumps(operations, indent=2)) return - + if not operations: console.print("[dim]No operations currently running[/dim]") return - + console.print(f"\n[bold]Current Operations ({len(operations)}):[/bold]\n") - + for op in operations: status_colors = { - 'success': 'green', - 'warning': 'yellow', - 'error': 'red', - 'critical': 'red bold', - 'info': 'cyan' + "success": "green", + "warning": "yellow", + "error": "red", + "critical": "red bold", + "info": "cyan", } - status_color = status_colors.get(op['status'], 'dim') - - console.print(f"[{status_color}]●[/{status_color}] {op['operation_id']}") + status_color = status_colors.get(op["status"], "dim") + + console.print( + f"[{status_color}]●[/{status_color}] {op['operation_id']}" + ) console.print(f" Type: {op['operation_type']}") console.print(f" Status: {op['status']}") console.print(f" Message: {op['message']}") - - if op.get('progress') is not None: + + if op.get("progress") is not None: console.print(f" Progress: {op['progress']}%") - - if op.get('repository_id'): + + if op.get("repository_id"): console.print(f" Repository: {op['repository_id']}") - + console.print() except Exception as e: CommandBase.handle_error(e, verbose, "Operations Error") @@ -344,26 +395,26 @@ def monitor_health( with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - console=console + console=console, ) as progress: task = progress.add_task("Checking system health...", total=None) health_data = _get_system_health_data(config_dir) progress.update(task, completed=True) - + if json_output: console.print(json.dumps(health_data, indent=2)) else: if "error" in health_data: show_error_panel("Health Check Failed", health_data["error"]) raise typer.Exit(1) - + # Display health summary repos = health_data.get("repositories", {}) total = repos.get("total", 0) healthy = repos.get("healthy", 0) warning = repos.get("warning", 0) error = repos.get("error", 0) - + # Determine overall status if error > 0: status = "[red]Degraded[/red]" @@ -374,34 +425,34 @@ def monitor_health( else: status = "[green]Healthy[/green]" status_icon = "✓" - - console.print(Panel( - f"{status_icon} [bold]Overall Status:[/bold] {status}\n\n" - f"[bold]Repositories:[/bold]\n" - f" Total: {total}\n" - f" [green]Healthy:[/green] {healthy}\n" - f" [yellow]Warning:[/yellow] {warning}\n" - f" [red]Error:[/red] {error}\n\n" - f"[bold]Storage:[/bold]\n" - f" Total Size: {_format_size(health_data.get('storage', {}).get('total_size', 0))}", - title="[bold cyan]System Health[/bold cyan]", - border_style="cyan" - )) - + + console.print( + Panel( + f"{status_icon} [bold]Overall Status:[/bold] {status}\n\n" + f"[bold]Repositories:[/bold]\n" + f" Total: {total}\n" + f" [green]Healthy:[/green] {healthy}\n" + f" [yellow]Warning:[/yellow] {warning}\n" + f" [red]Error:[/red] {error}\n\n" + f"[bold]Storage:[/bold]\n" + f" Total Size: {_format_size(health_data.get('storage', {}).get('total_size', 0))}", + title="[bold cyan]System Health[/bold cyan]", + border_style="cyan", + ) + ) + if verbose and health_data.get("storage", {}).get("repositories"): console.print("\n[bold]Repository Details:[/bold]") table = Table() table.add_column("Repository", style="cyan") table.add_column("Size", style="green") table.add_column("Snapshots", style="yellow") - + for repo in health_data["storage"]["repositories"]: table.add_row( - repo["name"], - _format_size(repo["size"]), - str(repo["snapshots"]) + repo["name"], _format_size(repo["size"]), str(repo["snapshots"]) ) - + console.print(table) except Exception as e: CommandBase.handle_error(e, verbose, "Health Check Error") @@ -411,41 +462,51 @@ def monitor_health( @with_error_handling("History Error") @with_logging def monitor_history( - days: Annotated[Optional[int], typer.Option("--days", "-d", help="Number of days to look back")] = 7, - repository: Annotated[Optional[str], typer.Option("--repository", "-r", help="Filter by repository")] = None, - status: Annotated[Optional[str], typer.Option("--status", "-s", help="Filter by status (success, failed, partial)")] = None, - limit: Annotated[Optional[int], typer.Option("--limit", "-n", help="Limit number of results")] = 20, + days: Annotated[ + Optional[int], typer.Option("--days", "-d", help="Number of days to look back") + ] = 7, + repository: Annotated[ + Optional[str], typer.Option("--repository", "-r", help="Filter by repository") + ] = None, + status: Annotated[ + Optional[str], + typer.Option( + "--status", "-s", help="Filter by status (success, failed, partial)" + ), + ] = None, + limit: Annotated[ + Optional[int], typer.Option("--limit", "-n", help="Limit number of results") + ] = 20, verbose: VerboseOption = False, json_output: JsonOption = False, config_dir: ConfigDirOption = None, ) -> None: """ View backup operation history. - + Requirements: 8.1 """ try: facade = _setup_monitoring_facade(config_dir) service_manager = facade.service_manager - + # Get backup history history = service_manager.get_cli_backup_history( - days=days, - repository_id=repository, - status=status, - limit=limit + days=days, repository_id=repository, status=status, limit=limit ) - + if json_output: console.print(json.dumps(history, indent=2)) return - + if not history: - console.print("[dim]No backup history found matching the specified filters[/dim]") + console.print( + "[dim]No backup history found matching the specified filters[/dim]" + ) return - + console.print(f"\n[bold]Backup History ({len(history)} operations):[/bold]\n") - + # Create table table = Table(show_header=True, header_style="bold cyan") table.add_column("Time", style="dim") @@ -455,36 +516,36 @@ def monitor_history( table.add_column("Data", justify="right") table.add_column("Duration", justify="right") table.add_column("Throughput", justify="right") - + for record in history: # Format status with color - status_val = record['status'] + status_val = record["status"] status_colors = { - 'success': 'green', - 'partial': 'yellow', - 'failed': 'red', - 'cancelled': 'dim' + "success": "green", + "partial": "yellow", + "failed": "red", + "cancelled": "dim", } - status_color = status_colors.get(status_val, 'dim') + status_color = status_colors.get(status_val, "dim") status_display = f"[{status_color}]{status_val}[/{status_color}]" - + # Format timestamp try: - dt = datetime.fromisoformat(record['start_time']) + dt = datetime.fromisoformat(record["start_time"]) start_time = dt.strftime("%Y-%m-%d %H:%M:%S") except Exception: - start_time = record['start_time'] - + start_time = record["start_time"] + table.add_row( start_time, - record['repository_id'], + record["repository_id"], status_display, - str(record['files_processed']), - record['bytes_transferred_formatted'], - record['duration'], - f"{record['throughput_mbps']} MB/s" + str(record["files_processed"]), + record["bytes_transferred_formatted"], + record["duration"], + f"{record['throughput_mbps']} MB/s", ) - + console.print(table) except Exception as e: CommandBase.handle_error(e, verbose, "History Error") @@ -494,7 +555,9 @@ def monitor_history( @with_error_handling("Statistics Error") @with_logging def monitor_stats( - repository: Annotated[Optional[str], typer.Option("--repository", "-r", help="Filter by repository")] = None, + repository: Annotated[ + Optional[str], typer.Option("--repository", "-r", help="Filter by repository") + ] = None, verbose: VerboseOption = False, json_output: JsonOption = False, config_dir: ConfigDirOption = None, @@ -504,34 +567,38 @@ def monitor_stats( facade = _setup_monitoring_facade(config_dir) service_manager = facade.service_manager config_service = facade.get_configuration_service() - + if repository: # Get stats for specific repository stats = service_manager.get_repository_stats(repository) - + if json_output: console.print(json.dumps(stats, indent=2, default=str)) else: - console.print(Panel( - f"[bold]Repository:[/bold] {repository}\n" - f"[bold]Total Size:[/bold] {_format_size(stats.get('total_size', 0))}\n" - f"[bold]Snapshots:[/bold] {stats.get('snapshots_count', 0)}\n" - f"[bold]Total Files:[/bold] {stats.get('total_files', 0)}\n" - f"[bold]Total Blobs:[/bold] {stats.get('total_blobs', 0)}\n" - f"[bold]Compression Ratio:[/bold] {stats.get('compression_ratio', 0):.2f}", - title=f"[bold green]Repository Statistics: {repository}[/bold green]", - border_style="green" - )) + console.print( + Panel( + f"[bold]Repository:[/bold] {repository}\n" + f"[bold]Total Size:[/bold] {_format_size(stats.get('total_size', 0))}\n" + f"[bold]Snapshots:[/bold] {stats.get('snapshots_count', 0)}\n" + f"[bold]Total Files:[/bold] {stats.get('total_files', 0)}\n" + f"[bold]Total Blobs:[/bold] {stats.get('total_blobs', 0)}\n" + f"[bold]Compression Ratio:[/bold] {stats.get('compression_ratio', 0):.2f}", + title=f"[bold green]Repository Statistics: {repository}[/bold green]", + border_style="green", + ) + ) else: # Get stats for all repositories repositories = list(config_service.get_repositories().values()) - + if json_output: all_stats = {} for repo in repositories: - repo_name = repo.get('name', '') + repo_name = repo.get("name", "") try: - all_stats[repo_name] = service_manager.get_repository_stats(repo_name) + all_stats[repo_name] = service_manager.get_repository_stats( + repo_name + ) except Exception as e: all_stats[repo_name] = {"error": str(e)} console.print(json.dumps(all_stats, indent=2, default=str)) @@ -539,100 +606,197 @@ def monitor_stats( if not repositories: show_info_panel("No Repositories", "No repositories configured.") return - + table = Table(title="Repository Statistics") table.add_column("Repository", style="cyan") table.add_column("Size", style="green") table.add_column("Snapshots", style="yellow") table.add_column("Files", style="white") table.add_column("Status", style="magenta") - + for repo in repositories: - repo_name = repo.get('name', '') + repo_name = repo.get("name", "") try: stats = service_manager.get_repository_stats(repo_name) table.add_row( repo_name, - _format_size(stats.get('total_size', 0)), - str(stats.get('snapshots_count', 0)), - str(stats.get('total_files', 0)), - "[green]✓[/green]" + _format_size(stats.get("total_size", 0)), + str(stats.get("snapshots_count", 0)), + str(stats.get("total_files", 0)), + "[green]✓[/green]", ) except Exception: - table.add_row( - repo_name, - "N/A", - "N/A", - "N/A", - "[red]✗[/red]" - ) - + table.add_row(repo_name, "N/A", "N/A", "N/A", "[red]✗[/red]") + console.print(table) except Exception as e: CommandBase.handle_error(e, verbose, "Statistics Error") +# Protected system run commands + + +@runs_app.command("list") +@with_error_handling("System Runs Error") +@with_logging +def runs_list( + limit: Annotated[ + int, typer.Option("--limit", "-n", min=1, max=1000, help="Maximum runs to show") + ] = 50, + operation: Annotated[ + Optional[str], typer.Option("--operation", help="backup or retention") + ] = None, + state: Annotated[ + Optional[str], typer.Option("--state", help="Run state filter") + ] = None, + json_output: JsonOption = False, + verbose: VerboseOption = False, +) -> None: + """List authorized, structured system backup and retention runs.""" + try: + route = classify_public_action(("runs", "list")) + if not route.uses_system_backend: + raise RuntimeError("system run routing policy is invalid") + query = RunQuery( + limit=limit, + operation=OperationType(operation.lower()) if operation else None, + state=RunState(state.lower()) if state else None, + ) + runs = _create_system_control_client().list_runs(query) + if json_output: + console.print(json.dumps([run.to_wire() for run in runs], indent=2)) + return + console.print(f"[bold]System runs[/bold] (showing {len(runs)})\n") + if not runs: + console.print("[dim]No system runs found.[/dim]") + return + table = Table(show_header=True, header_style="bold") + table.add_column("Run ID", style="cyan") + table.add_column("Operation") + table.add_column("State") + table.add_column("Started") + table.add_column("Result") + for run in runs: + table.add_row( + str(run.run_id), + run.operation.value, + run.state.value, + run.started_at.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z"), + run.safe_summary, + ) + console.print(table) + except Exception as error: + CommandBase.handle_error(error, verbose, "System Runs Error") + + +@runs_app.command("show") +@with_error_handling("System Run Error") +@with_logging +def runs_show( + run_id: Annotated[str, typer.Argument(help="System run UUID")], + json_output: JsonOption = False, + verbose: VerboseOption = False, +) -> None: + """Show one authorized, structured system run.""" + try: + route = classify_public_action(("runs", "show")) + if not route.uses_system_backend: + raise RuntimeError("system run routing policy is invalid") + run = _create_system_control_client().get_run(UUID(run_id)) + if json_output: + console.print(json.dumps(run.to_wire(), indent=2)) + return + table = Table(show_header=False) + table.add_column("Field", style="bold") + table.add_column("Value") + fields = ( + ("Run ID", str(run.run_id)), + ("Operation", run.operation.value), + ("Trigger", run.trigger.value), + ("Target", run.target_id), + ("State", run.state.value), + ("Started", run.started_at.isoformat()), + ("Completed", run.completed_at.isoformat() if run.completed_at else "-"), + ("Result", run.safe_summary), + ) + for label, value in fields: + table.add_row(label, value) + console.print(Panel(table, title="System run")) + except Exception as error: + CommandBase.handle_error(error, verbose, "System Run Error") + + # Logs Commands + @logs_app.command("search") @with_error_handling("Log Search Error") @with_logging def logs_search( query: Annotated[str, typer.Argument(help="Search query string")], - hours: Annotated[Optional[int], typer.Option("--hours", "-h", help="Number of hours to look back")] = None, - days: Annotated[Optional[int], typer.Option("--days", "-d", help="Number of days to look back")] = 7, - repository: Annotated[Optional[str], typer.Option("--repository", "-r", help="Filter by repository")] = None, - limit: Annotated[Optional[int], typer.Option("--limit", "-n", help="Limit number of results")] = 50, + hours: Annotated[ + Optional[int], + typer.Option("--hours", "-h", help="Number of hours to look back"), + ] = None, + days: Annotated[ + Optional[int], typer.Option("--days", "-d", help="Number of days to look back") + ] = 7, + repository: Annotated[ + Optional[str], typer.Option("--repository", "-r", help="Filter by repository") + ] = None, + limit: Annotated[ + Optional[int], typer.Option("--limit", "-n", help="Limit number of results") + ] = 50, verbose: VerboseOption = False, json_output: JsonOption = False, config_dir: ConfigDirOption = None, ) -> None: """ Search monitoring logs for specific text. - + Requirements: 8.2 """ try: facade = _setup_monitoring_facade(config_dir) service_manager = facade.service_manager - + # Search logs logs = service_manager.search_monitoring_logs( - query=query, - hours=hours, - days=days, - repository_id=repository, - limit=limit + query=query, hours=hours, days=days, repository_id=repository, limit=limit ) - + if json_output: console.print(json.dumps(logs, indent=2)) return - + if not logs: console.print(f"[dim]No logs found matching '{query}'[/dim]") return - - console.print(f"\n[bold]Search Results for '{query}' ({len(logs)} matches):[/bold]\n") - + + console.print( + f"\n[bold]Search Results for '{query}' ({len(logs)} matches):[/bold]\n" + ) + # Get monitoring integration for formatting monitoring_integration = service_manager.get_monitoring_integration() - + for log in logs: if monitoring_integration: - formatted = monitoring_integration.format_log_entry_cli(log, verbose=verbose) + formatted = monitoring_integration.format_log_entry_cli( + log, verbose=verbose + ) console.print(formatted) else: # Fallback formatting try: - dt = datetime.fromisoformat(log['timestamp']) + dt = datetime.fromisoformat(log["timestamp"]) timestamp_str = dt.strftime("%Y-%m-%d %H:%M:%S") except Exception: - timestamp_str = log['timestamp'] - - level_str = log['level'].upper() + timestamp_str = log["timestamp"] + + level_str = log["level"].upper() console.print(f"[{level_str}] {timestamp_str} - {log['message']}") - + console.print() except Exception as e: CommandBase.handle_error(e, verbose, "Log Search Error") @@ -642,61 +806,77 @@ def logs_search( @with_error_handling("Recent Logs Error") @with_logging def logs_recent( - hours: Annotated[Optional[int], typer.Option("--hours", "-h", help="Number of hours to look back")] = None, - days: Annotated[Optional[int], typer.Option("--days", "-d", help="Number of days to look back")] = 1, - repository: Annotated[Optional[str], typer.Option("--repository", "-r", help="Filter by repository")] = None, - level: Annotated[Optional[str], typer.Option("--level", "-l", help="Filter by log level (info, warning, error)")] = None, - limit: Annotated[Optional[int], typer.Option("--limit", "-n", help="Limit number of results")] = 50, + hours: Annotated[ + Optional[int], + typer.Option("--hours", "-h", help="Number of hours to look back"), + ] = None, + days: Annotated[ + Optional[int], typer.Option("--days", "-d", help="Number of days to look back") + ] = 1, + repository: Annotated[ + Optional[str], typer.Option("--repository", "-r", help="Filter by repository") + ] = None, + level: Annotated[ + Optional[str], + typer.Option( + "--level", "-l", help="Filter by log level (info, warning, error)" + ), + ] = None, + limit: Annotated[ + Optional[int], typer.Option("--limit", "-n", help="Limit number of results") + ] = 50, verbose: VerboseOption = False, json_output: JsonOption = False, config_dir: ConfigDirOption = None, ) -> None: """ View recent monitoring logs with filtering options. - + Requirements: 8.1, 8.2 """ try: facade = _setup_monitoring_facade(config_dir) service_manager = facade.service_manager - + # Get logs with filters logs = service_manager.get_cli_monitoring_logs( hours=hours, days=days, repository_id=repository, log_level=level, - limit=limit + limit=limit, ) - + if json_output: console.print(json.dumps(logs, indent=2)) return - + if not logs: console.print("[dim]No logs found matching the specified filters[/dim]") return - + console.print(f"\n[bold]Monitoring Logs ({len(logs)} entries):[/bold]\n") - + # Get monitoring integration for formatting monitoring_integration = service_manager.get_monitoring_integration() - + for log in logs: if monitoring_integration: - formatted = monitoring_integration.format_log_entry_cli(log, verbose=verbose) + formatted = monitoring_integration.format_log_entry_cli( + log, verbose=verbose + ) console.print(formatted) else: # Fallback formatting try: - dt = datetime.fromisoformat(log['timestamp']) + dt = datetime.fromisoformat(log["timestamp"]) timestamp_str = dt.strftime("%Y-%m-%d %H:%M:%S") except Exception: - timestamp_str = log['timestamp'] - - level_str = log['level'].upper() + timestamp_str = log["timestamp"] + + level_str = log["level"].upper() console.print(f"[{level_str}] {timestamp_str} - {log['message']}") - + console.print() except Exception as e: CommandBase.handle_error(e, verbose, "Recent Logs Error") @@ -706,96 +886,153 @@ def logs_recent( @with_error_handling("Log View Error") @with_logging def logs_view( - lines: Annotated[int, typer.Option("--lines", "-n", help="Number of lines to show")] = 50, - follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow log output")] = False, - level: Annotated[Optional[str], typer.Option("--level", "-l", help="Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)")] = None, - component: Annotated[Optional[str], typer.Option("--component", "-c", help="Filter by component")] = None, - since: Annotated[Optional[str], typer.Option("--since", help="Show logs since time (e.g., '1h', '30m', '2024-01-01')")] = None, + lines: Annotated[ + int, + typer.Option("--lines", "-n", min=1, max=1000, help="Number of lines to show"), + ] = 50, + follow: Annotated[ + bool, typer.Option("--follow", "-f", help="Follow log output") + ] = False, + scope: Annotated[ + str, typer.Option("--scope", help="Log scope: local or system") + ] = "local", + level: Annotated[ + Optional[str], + typer.Option( + "--level", + "-l", + help="Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", + ), + ] = None, + component: Annotated[ + Optional[str], typer.Option("--component", "-c", help="Filter by component") + ] = None, + since: Annotated[ + Optional[str], + typer.Option( + "--since", help="Show logs since time (e.g., '1h', '30m', '2024-01-01')" + ), + ] = None, verbose: VerboseOption = False, config_dir: ConfigDirOption = None, ) -> None: - """View TimeLocker logs with filtering options.""" + """View user-local logs or authorized structured system diagnostics.""" try: - from TimeLocker.config.configuration_path_resolver import ConfigurationPathResolver - - # Get log file path - log_dir = ConfigurationPathResolver.get_cache_directory() / "logs" - log_file = log_dir / "timelocker.log" - + route = classify_public_action(("logs", "view"), scope=scope) + if route.uses_system_backend: + if follow: + raise ValueError("--follow is not supported for system diagnostics") + diagnostic_level = ( + DiagnosticLevel(level.lower()) if level is not None else None + ) + diagnostics = _create_system_control_client().list_diagnostics( + DiagnosticQuery(limit=lines, level=diagnostic_level) + ) + if component: + diagnostics = [ + record + for record in diagnostics + if record.component.value == component.lower() + ] + if since: + since_time = _parse_since(since) + diagnostics = [ + record + for record in diagnostics + if record.timestamp + >= since_time.astimezone(record.timestamp.tzinfo) + ] + console.print( + f"[bold]TimeLocker system diagnostics[/bold] " + f"(showing {len(diagnostics)} records)\n" + ) + if not diagnostics: + console.print("[dim]No system diagnostics found.[/dim]") + return + for record in diagnostics: + style = { + DiagnosticLevel.ERROR: "red", + DiagnosticLevel.WARNING: "yellow", + DiagnosticLevel.INFO: None, + }[record.level] + line = ( + f"{record.timestamp.astimezone().strftime('%Y-%m-%d %H:%M:%S %Z')} " + f"- {record.component.value} - {record.message_code.value} " + f"- {record.safe_summary}" + ) + console.print(f"[{style}]{line}[/{style}]" if style else line) + return + + log_file = _local_log_file(config_dir) + try: - validate_path(log_file, must_exist=True, must_be_file=True, field_name="log file") + validate_path( + log_file, must_exist=True, must_be_file=True, field_name="log file" + ) except ValidationError: show_info_panel("No Logs", f"Log file not found: {log_file}") return - + # Read log file - with open(log_file, 'r') as f: + with open(log_file, "r") as f: log_lines = f.readlines() - + # Filter by level if level: level = level.upper() log_lines = [line for line in log_lines if level in line] - + # Filter by component if component: log_lines = [line for line in log_lines if component in line] - + # Filter by time if since: - # Parse since parameter - now = datetime.now() - if since.endswith('h'): - hours = int(since[:-1]) - since_time = now - timedelta(hours=hours) - elif since.endswith('m'): - minutes = int(since[:-1]) - since_time = now - timedelta(minutes=minutes) - elif since.endswith('d'): - days = int(since[:-1]) - since_time = now - timedelta(days=days) - else: - try: - since_time = datetime.fromisoformat(since) - except ValueError: - show_error_panel("Invalid Time Format", f"Invalid time format: {since}") - raise typer.Exit(1) - + since_time = _parse_since(since) + if since_time.tzinfo is not None: + since_time = since_time.astimezone().replace(tzinfo=None) + # Filter lines by timestamp filtered_lines = [] for line in log_lines: try: # Extract timestamp from log line (assuming format: YYYY-MM-DD HH:MM:SS) timestamp_str = line[:19] - line_time = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S') + line_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S") if line_time >= since_time: filtered_lines.append(line) except (ValueError, IndexError): # If we can't parse timestamp, include the line filtered_lines.append(line) log_lines = filtered_lines - + # Get last N lines log_lines = log_lines[-lines:] - + # Display logs - console.print(f"[bold]TimeLocker Logs[/bold] (showing last {len(log_lines)} lines)\n") - + console.print( + f"[bold]TimeLocker local logs[/bold] " + f"(showing last {len(log_lines)} lines)\n" + ) + for line in log_lines: # Color code by level - if 'ERROR' in line or 'CRITICAL' in line: + if "ERROR" in line or "CRITICAL" in line: console.print(f"[red]{line.rstrip()}[/red]") - elif 'WARNING' in line: + elif "WARNING" in line: console.print(f"[yellow]{line.rstrip()}[/yellow]") - elif 'DEBUG' in line: + elif "DEBUG" in line: console.print(f"[dim]{line.rstrip()}[/dim]") else: console.print(line.rstrip()) - + if follow: console.print("\n[cyan]Following log output (Ctrl+C to stop)...[/cyan]") # Note: Real follow implementation would require tail -f equivalent - show_info_panel("Follow Mode", "Follow mode not yet implemented. Use 'tail -f' on the log file directly.") + show_info_panel( + "Follow Mode", + "Follow mode not yet implemented. Use 'tail -f' on the log file directly.", + ) except Exception as e: CommandBase.handle_error(e, verbose, "Log View Error") @@ -810,28 +1047,32 @@ def logs_clear( ) -> None: """Clear TimeLocker logs.""" try: - from TimeLocker.config.configuration_path_resolver import ConfigurationPathResolver - + from TimeLocker.config.configuration_path_resolver import ( + ConfigurationPathResolver, + ) + # Get log file path log_dir = ConfigurationPathResolver.get_cache_directory() / "logs" log_file = log_dir / "timelocker.log" - + try: - validate_path(log_file, must_exist=True, must_be_file=True, field_name="log file") + validate_path( + log_file, must_exist=True, must_be_file=True, field_name="log file" + ) except ValidationError: show_info_panel("No Logs", "No log file to clear.") return - + if not yes and CommandBase.is_interactive(): confirmed = Confirm.ask("Clear all TimeLocker logs?", default=False) if not confirmed: show_info_panel("Operation Cancelled", "Log clearing cancelled.") return - + # Clear log file - with open(log_file, 'w') as f: - f.write('') - + with open(log_file, "w") as f: + f.write("") + show_success_panel("Logs Cleared", "TimeLocker logs have been cleared.") except Exception as e: CommandBase.handle_error(e, verbose, "Log Clear Error") @@ -839,15 +1080,27 @@ def logs_clear( # Reports Commands + @reports_app.command("generate") @with_error_handling("Report Generation Error") @with_logging def reports_generate( - report_type: Annotated[str, typer.Argument(help="Report type (backup-history, storage-usage, performance)")], - output: Annotated[Optional[Path], typer.Option("--output", "-o", help="Output file path")] = None, - format: Annotated[str, typer.Option("--format", "-f", help="Output format (json, html, text)")] = "text", - days: Annotated[int, typer.Option("--days", "-d", help="Number of days to include")] = 30, - repository: Annotated[Optional[str], typer.Option("--repository", "-r", help="Filter by repository")] = None, + report_type: Annotated[ + str, + typer.Argument(help="Report type (backup-history, storage-usage, performance)"), + ], + output: Annotated[ + Optional[Path], typer.Option("--output", "-o", help="Output file path") + ] = None, + format: Annotated[ + str, typer.Option("--format", "-f", help="Output format (json, html, text)") + ] = "text", + days: Annotated[ + int, typer.Option("--days", "-d", help="Number of days to include") + ] = 30, + repository: Annotated[ + Optional[str], typer.Option("--repository", "-r", help="Filter by repository") + ] = None, verbose: VerboseOption = False, config_dir: ConfigDirOption = None, ) -> None: @@ -856,15 +1109,17 @@ def reports_generate( facade = _setup_monitoring_facade(config_dir) service_manager = facade.service_manager config_service = facade.get_configuration_service() - + report_type = report_type.lower() - - if report_type not in ['backup-history', 'storage-usage', 'performance']: - show_error_panel("Invalid Report Type", f"Unknown report type: {report_type}") + + if report_type not in ["backup-history", "storage-usage", "performance"]: + show_error_panel( + "Invalid Report Type", f"Unknown report type: {report_type}" + ) raise typer.Exit(1) - + console.print(f"\n[bold]Generating {report_type} report...[/bold]\n") - + # Generate report data report_data = { "report_type": report_type, @@ -872,60 +1127,83 @@ def reports_generate( "period_days": days, "repository_filter": repository, } - + if report_type == "backup-history": # Get backup history - repositories = [repository] if repository else list(config_service.get_repositories().keys()) - + repositories = ( + [repository] + if repository + else list(config_service.get_repositories().keys()) + ) + history = [] for repo_name in repositories: try: - snapshots = service_manager.snapshot_service.list_snapshots(repo_name) + snapshots = service_manager.snapshot_service.list_snapshots( + repo_name + ) # Filter by date cutoff_date = datetime.now() - timedelta(days=days) recent_snapshots = [ - s for s in snapshots - if datetime.fromisoformat(s.get('time', '1970-01-01')) >= cutoff_date + s + for s in snapshots + if datetime.fromisoformat(s.get("time", "1970-01-01")) + >= cutoff_date ] - history.append({ - "repository": repo_name, - "snapshots": len(recent_snapshots), - "total_size": sum(s.get('size', 0) for s in recent_snapshots) - }) + history.append( + { + "repository": repo_name, + "snapshots": len(recent_snapshots), + "total_size": sum( + s.get("size", 0) for s in recent_snapshots + ), + } + ) except Exception as e: - logging.getLogger(__name__).warning(f"Failed to get history for {repo_name}: {e}") - + logging.getLogger(__name__).warning( + f"Failed to get history for {repo_name}: {e}" + ) + report_data["backup_history"] = history - + elif report_type == "storage-usage": # Get storage usage - repositories = [repository] if repository else list(config_service.get_repositories().keys()) - + repositories = ( + [repository] + if repository + else list(config_service.get_repositories().keys()) + ) + usage = [] for repo_name in repositories: try: stats = service_manager.get_repository_stats(repo_name) - usage.append({ - "repository": repo_name, - "total_size": stats.get('total_size', 0), - "snapshots": stats.get('snapshots_count', 0), - "files": stats.get('total_files', 0), - "compression_ratio": stats.get('compression_ratio', 0) - }) + usage.append( + { + "repository": repo_name, + "total_size": stats.get("total_size", 0), + "snapshots": stats.get("snapshots_count", 0), + "files": stats.get("total_files", 0), + "compression_ratio": stats.get("compression_ratio", 0), + } + ) except Exception as e: - logging.getLogger(__name__).warning(f"Failed to get usage for {repo_name}: {e}") - + logging.getLogger(__name__).warning( + f"Failed to get usage for {repo_name}: {e}" + ) + report_data["storage_usage"] = usage - + elif report_type == "performance": # Get performance metrics try: from TimeLocker.performance.metrics import PerformanceMetrics + metrics = PerformanceMetrics() report_data["performance_metrics"] = metrics.get_summary() except Exception as e: report_data["performance_metrics"] = {"error": str(e)} - + # Output report if format == "json": output_text = json.dumps(report_data, indent=2, default=str) @@ -945,7 +1223,7 @@ def reports_generate(

TimeLocker Report: {report_type}

-

Generated: {report_data['generated_at']}

+

Generated: {report_data["generated_at"]}

Period: {days} days

{json.dumps(report_data, indent=2, default=str)}
@@ -956,10 +1234,10 @@ def reports_generate( output_text += f"Generated: {report_data['generated_at']}\n" output_text += f"Period: {days} days\n" output_text += "\n" + json.dumps(report_data, indent=2, default=str) - + # Save or display if output: - with open(output, 'w') as f: + with open(output, "w") as f: f.write(output_text) show_success_panel( "Report Generated", @@ -968,7 +1246,7 @@ def reports_generate( "Type": report_type, "Format": format, "Period": f"{days} days", - } + }, ) else: console.print(output_text) @@ -976,4 +1254,4 @@ def reports_generate( CommandBase.handle_error(e, verbose, "Report Generation Error") -__all__ = ['monitor_app', 'logs_app', 'reports_app'] +__all__ = ["monitor_app", "logs_app", "reports_app", "runs_app"] diff --git a/src/TimeLocker/system_control/__init__.py b/src/TimeLocker/system_control/__init__.py index 231b694..eb03558 100644 --- a/src/TimeLocker/system_control/__init__.py +++ b/src/TimeLocker/system_control/__init__.py @@ -9,6 +9,16 @@ SystemControlClient, ) from .dispatcher import AuditEvent, AuditSink, LocalControlDispatcher +from .action_policy import ( + ActionClass, + ActionRoute, + UnknownPublicActionError, + classify_public_action, +) +from .client import ( + SystemControlClientError, + UnixSocketSystemControlClient, +) from .models import ( ActionReceipt, BackupActionRequest, @@ -50,6 +60,8 @@ __all__ = [ "ActionReceipt", + "ActionClass", + "ActionRoute", "AuditEvent", "AuditSink", "AtomicRecordStore", @@ -89,7 +101,11 @@ "RunState", "SystemAction", "SystemControlClient", + "SystemControlClientError", "SystemPolicy", + "UnixSocketSystemControlClient", + "UnknownPublicActionError", + "classify_public_action", "project_response", "reconcile_abandoned_runs", ] diff --git a/src/TimeLocker/system_control/action_policy.py b/src/TimeLocker/system_control/action_policy.py new file mode 100644 index 0000000..b65398c --- /dev/null +++ b/src/TimeLocker/system_control/action_policy.py @@ -0,0 +1,214 @@ +"""Central classification for public TimeLocker operations.""" + +from dataclasses import dataclass +from enum import StrEnum +from typing import Iterable + + +class ActionClass(StrEnum): + """Privilege boundary used when routing a public command.""" + + USER_LOCAL_READ = "user_local_read" + USER_LOCAL_MUTATION = "user_local_mutation" + SYSTEM_READ = "system_read" + SYSTEM_ACTION = "system_action" + ADMINISTRATOR_MAINTENANCE = "administrator_maintenance" + + +class UnknownPublicActionError(ValueError): + """Raised when an operation has no explicit routing policy.""" + + +@dataclass(frozen=True, slots=True) +class ActionRoute: + """One normalized public action and its required execution boundary.""" + + path: tuple[str, ...] + action_class: ActionClass + + @property + def uses_system_backend(self) -> bool: + """Return whether this action must cross the protected local contract.""" + return self.action_class in { + ActionClass.SYSTEM_READ, + ActionClass.SYSTEM_ACTION, + } + + +_USER_LOCAL_READ = frozenset( + { + ("version",), + ("help",), + ("completion",), + ("config", "show"), + ("config", "performance"), + ("config", "validate"), + ("config", "diff"), + ("snapshots", "list"), + ("snapshots", "show"), + ("snapshots", "find"), + ("snapshots", "diff"), + ("repos", "list"), + ("repos", "show"), + ("repos", "check"), + ("repos", "stats"), + ("repos", "validate"), + ("repos", "validate-all"), + ("restore", "list"), + ("restore", "browse"), + ("restore", "verify"), + ("restore", "find"), + ("restore", "diff"), + ("selections", "list"), + ("selections", "show"), + ("selections", "test"), + ("selections", "export"), + ("schedule", "list"), + ("schedule", "show"), + ("schedule", "test"), + ("monitor", "status"), + ("monitor", "operations"), + ("monitor", "health"), + ("monitor", "history"), + ("monitor", "stats"), + ("logs", "search"), + ("logs", "recent"), + ("logs", "view"), + ("reports", "generate"), + ("policy", "status"), + ("policy", "audit"), + ("policy", "simulate"), + ("policy", "backup", "list"), + ("policy", "backup", "show"), + ("policy", "retention", "list"), + ("policy", "retention", "show"), + ("policy", "assignment", "list"), + ("credentials", "list"), + ("repos", "credentials", "show"), + } +) + +_USER_LOCAL_MUTATION = frozenset( + { + ("backup", "create"), + ("backup", "verify"), + ("config", "setup"), + ("config", "import", "restic"), + ("config", "import", "timeshift"), + ("config", "import", "config"), + ("config", "export", "config"), + ("migrate", "validate"), + ("snapshots", "forget"), + ("snapshots", "prune"), + ("repos", "add"), + ("repos", "remove"), + ("repos", "update"), + ("repos", "edit"), + ("repos", "default"), + ("repos", "lock"), + ("repos", "mode"), + ("repos", "init"), + ("repos", "unlock"), + ("repos", "migrate"), + ("repos", "forget"), + ("repos", "prune"), + ("repos", "credentials", "set"), + ("repos", "credentials", "remove"), + ("restore", "full"), + ("restore", "files"), + ("restore", "mount"), + ("restore", "umount"), + ("selections", "create"), + ("selections", "edit"), + ("selections", "delete"), + ("selections", "import"), + ("schedule", "create"), + ("schedule", "edit"), + ("schedule", "delete"), + ("schedule", "enable"), + ("schedule", "disable"), + ("schedule", "generate-scripts"), + ("logs", "clear"), + ("credentials", "unlock"), + ("credentials", "store"), + ("credentials", "set"), + ("credentials", "remove"), + ("policy", "enforce"), + ("policy", "backup", "create"), + ("policy", "backup", "delete"), + ("policy", "retention", "create"), + ("policy", "retention", "delete"), + ("policy", "assignment", "create"), + ("policy", "assignment", "delete"), + ("security", "audit"), + ("security", "status"), + ("security", "logs"), + ("security", "notifications"), + ("security", "sessions"), + ("security", "cleanup"), + ("security", "config"), + } +) + +_SYSTEM_READ = frozenset( + { + ("runs", "list"), + ("runs", "show"), + ("logs", "view", "system"), + } +) + +_SYSTEM_ACTION = frozenset( + { + ("system", "backup"), + ("system", "retention"), + } +) + +_ADMINISTRATOR_MAINTENANCE = frozenset( + { + ("system", "install"), + ("system", "upgrade"), + ("system", "rollback"), + ("system", "operators"), + ("system", "service"), + } +) + + +def classify_public_action( + path: Iterable[str], + *, + scope: str | None = None, +) -> ActionRoute: + """Classify an exact public action, rejecting every unregistered path.""" + normalized = tuple(_normalize_part(part) for part in path) + if normalized == ("logs", "view") and scope is not None: + normalized_scope = _normalize_part(scope) + if normalized_scope == "system": + normalized = (*normalized, normalized_scope) + elif normalized_scope != "local": + raise UnknownPublicActionError(f"unsupported log scope: {scope!r}") + + tables = ( + (_USER_LOCAL_READ, ActionClass.USER_LOCAL_READ), + (_USER_LOCAL_MUTATION, ActionClass.USER_LOCAL_MUTATION), + (_SYSTEM_READ, ActionClass.SYSTEM_READ), + (_SYSTEM_ACTION, ActionClass.SYSTEM_ACTION), + (_ADMINISTRATOR_MAINTENANCE, ActionClass.ADMINISTRATOR_MAINTENANCE), + ) + for actions, action_class in tables: + if normalized in actions: + return ActionRoute(normalized, action_class) + raise UnknownPublicActionError( + f"public action has no routing policy: {' '.join(normalized)}" + ) + + +def _normalize_part(value: object) -> str: + if not isinstance(value, str): + raise UnknownPublicActionError("action path parts must be strings") + normalized = value.strip().lower() + if not normalized or any(character.isspace() for character in normalized): + raise UnknownPublicActionError("action path parts must be single tokens") + return normalized diff --git a/src/TimeLocker/system_control/assets/timelocker-launcher b/src/TimeLocker/system_control/assets/timelocker-launcher new file mode 100644 index 0000000..f6760ed --- /dev/null +++ b/src/TimeLocker/system_control/assets/timelocker-launcher @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +# Stable, root-owned bootstrap. The bootstrap environment contains only the +# launcher implementation; it resolves the immutable application release. +exec /opt/timelocker/launcher/venv/bin/python \ + -m TimeLocker.system_control.launcher_entry "$@" diff --git a/src/TimeLocker/system_control/assets/timelocker-release-select b/src/TimeLocker/system_control/assets/timelocker-release-select new file mode 100644 index 0000000..3adb0a3 --- /dev/null +++ b/src/TimeLocker/system_control/assets/timelocker-release-select @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +exec /opt/timelocker/launcher/venv/bin/python \ + -m TimeLocker.system_control.release_admin "$@" diff --git a/src/TimeLocker/system_control/assets/tl-launcher b/src/TimeLocker/system_control/assets/tl-launcher new file mode 100644 index 0000000..dfaec62 --- /dev/null +++ b/src/TimeLocker/system_control/assets/tl-launcher @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu + +# Alias bootstrap. It deliberately uses the same resolver as `timelocker`. +exec /opt/timelocker/launcher/venv/bin/python \ + -m TimeLocker.system_control.launcher_entry "$@" diff --git a/src/TimeLocker/system_control/client.py b/src/TimeLocker/system_control/client.py new file mode 100644 index 0000000..d01b748 --- /dev/null +++ b/src/TimeLocker/system_control/client.py @@ -0,0 +1,187 @@ +"""Focused client for the protected local system-control contract.""" + +import json +import socket +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any +from uuid import UUID, uuid4 + +from .models import ( + ActionReceipt, + BackupActionRequest, + DiagnosticQuery, + DiagnosticView, + RetentionActionRequest, + RunQuery, + RunRecordView, +) +from .protocol import RequestEnvelope, ResponseEnvelope +from .types import ProtocolErrorCode, ResponseStatus, SystemAction + + +DEFAULT_SOCKET_PATH = Path("/run/timelocker/control.sock") +DEFAULT_MAX_RESPONSE_BYTES = 1_048_576 + + +class SystemControlClientError(RuntimeError): + """Safe client-visible backend failure.""" + + def __init__( + self, + error_code: ProtocolErrorCode, + safe_summary: str, + *, + status: ResponseStatus, + ) -> None: + super().__init__(safe_summary) + self.error_code = error_code + self.status = status + + +class UnixSocketSystemControlClient: + """Versioned system-control client with a bounded Unix socket transport.""" + + def __init__( + self, + *, + socket_path: Path = DEFAULT_SOCKET_PATH, + timeout_seconds: float = 5.0, + max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES, + exchange: Callable[[bytes], bytes] | None = None, + ) -> None: + if timeout_seconds <= 0 or timeout_seconds > 60: + raise ValueError("timeout_seconds must be between zero and 60") + if max_response_bytes < 1_024 or max_response_bytes > 16_777_216: + raise ValueError("max_response_bytes is outside the supported range") + self.socket_path = socket_path + self.timeout_seconds = timeout_seconds + self.max_response_bytes = max_response_bytes + self._exchange_override = exchange + + def list_runs(self, query: RunQuery) -> list[RunRecordView]: + parameters: dict[str, object] = {"limit": query.limit} + if query.operation is not None: + parameters["operation"] = query.operation.value + if query.state is not None: + parameters["state"] = query.state.value + result = self._request(SystemAction.RUN_LIST, parameters) + return [RunRecordView.from_mapping(item) for item in result["runs"]] + + def get_run(self, run_id: UUID) -> RunRecordView: + result = self._request( + SystemAction.RUN_DETAIL, + {"run_id": str(run_id)}, + ) + return RunRecordView.from_mapping(result["run"]) + + def list_diagnostics(self, query: DiagnosticQuery) -> list[DiagnosticView]: + parameters: dict[str, object] = {"limit": query.limit} + if query.run_id is not None: + parameters["run_id"] = str(query.run_id) + if query.level is not None: + parameters["level"] = query.level.value + result = self._request(SystemAction.DIAGNOSTIC_LIST, parameters) + return [DiagnosticView.from_mapping(item) for item in result["diagnostics"]] + + def request_backup(self, request: BackupActionRequest) -> ActionReceipt: + result = self._request( + SystemAction.BACKUP_REQUEST, + {"target_id": request.target_id}, + ) + return _receipt_from_mapping(result) + + def request_retention(self, request: RetentionActionRequest) -> ActionReceipt: + result = self._request( + SystemAction.RETENTION_REQUEST, + { + "policy_fingerprint": request.policy_fingerprint, + "dry_run": request.dry_run, + }, + ) + return _receipt_from_mapping(result) + + def _request( + self, + action: SystemAction, + parameters: Mapping[str, object], + ) -> Mapping[str, Any]: + request = RequestEnvelope( + request_id=uuid4(), + action=action, + parameters=parameters, + ) + payload = ( + json.dumps(request.to_wire(), sort_keys=True, separators=(",", ":")) + "\n" + ).encode("utf-8") + try: + raw_response = ( + self._exchange_override(payload) + if self._exchange_override is not None + else self._socket_exchange(payload) + ) + if len(raw_response) > self.max_response_bytes: + raise ValueError("response exceeds configured bound") + decoded = json.loads(raw_response.decode("utf-8")) + response = ResponseEnvelope.from_mapping(decoded, action=action) + except SystemControlClientError: + raise + except (OSError, TimeoutError): + raise SystemControlClientError( + ProtocolErrorCode.SYSTEM_BACKEND_UNAVAILABLE, + "System backend is unavailable. " + "Run 'systemctl status timelocker-control.socket'.", + status=ResponseStatus.UNAVAILABLE, + ) from None + except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError): + raise SystemControlClientError( + ProtocolErrorCode.INVALID_REQUEST, + "System backend returned an invalid response.", + status=ResponseStatus.INVALID, + ) from None + if response.request_id != request.request_id: + raise SystemControlClientError( + ProtocolErrorCode.INVALID_REQUEST, + "System backend returned an invalid response.", + status=ResponseStatus.INVALID, + ) + if response.status is not ResponseStatus.OK: + assert response.error_code is not None + assert response.safe_summary is not None + raise SystemControlClientError( + response.error_code, + response.safe_summary, + status=response.status, + ) + assert response.result is not None + return response.result + + def _socket_exchange(self, request: bytes) -> bytes: + if not hasattr(socket, "AF_UNIX"): + raise OSError("Unix sockets are unavailable") + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(self.timeout_seconds) + connection.connect(str(self.socket_path)) + connection.sendall(request) + chunks: list[bytes] = [] + size = 0 + while size <= self.max_response_bytes: + chunk = connection.recv(min(65_536, self.max_response_bytes + 1 - size)) + if not chunk: + break + chunks.append(chunk) + size += len(chunk) + if b"\n" in chunk: + break + response = b"".join(chunks) + newline = response.find(b"\n") + return response[:newline] if newline >= 0 else response + + +def _receipt_from_mapping(value: Mapping[str, Any]) -> ActionReceipt: + return ActionReceipt( + request_id=value["request_id"], + accepted=value["accepted"], + status=value["status"], + run_id=value.get("run_id"), + ) diff --git a/src/TimeLocker/system_control/launcher_entry.py b/src/TimeLocker/system_control/launcher_entry.py new file mode 100644 index 0000000..d408b81 --- /dev/null +++ b/src/TimeLocker/system_control/launcher_entry.py @@ -0,0 +1,22 @@ +"""Stable launcher process entry point.""" + +import sys + +from .release_launcher import ReleaseResolutionError, launch_selected + + +def main() -> None: + """Launch the selected release with a bounded failure message.""" + try: + launch_selected(sys.argv[1:]) + except ReleaseResolutionError: + print( + "TimeLocker system release is unavailable or invalid. " + "Ask an administrator to validate /opt/timelocker/selected-release.json.", + file=sys.stderr, + ) + raise SystemExit(78) from None + + +if __name__ == "__main__": + main() diff --git a/src/TimeLocker/system_control/release_admin.py b/src/TimeLocker/system_control/release_admin.py new file mode 100644 index 0000000..40460af --- /dev/null +++ b/src/TimeLocker/system_control/release_admin.py @@ -0,0 +1,28 @@ +"""Administrator-only immutable release selector command.""" + +import argparse + +from .release_launcher import ImmutableReleaseResolver, ReleaseResolutionError + + +def main() -> None: + """Select or roll back an already staged release.""" + parser = argparse.ArgumentParser(prog="timelocker-release-select") + subcommands = parser.add_subparsers(dest="command", required=True) + select = subcommands.add_parser("select") + select.add_argument("release_id") + subcommands.add_parser("rollback") + arguments = parser.parse_args() + resolver = ImmutableReleaseResolver() + try: + if arguments.command == "select": + state = resolver.select(arguments.release_id) + else: + state = resolver.rollback() + except ReleaseResolutionError as error: + parser.exit(78, f"release selection failed: {error}\n") + print(state.selected) + + +if __name__ == "__main__": + main() diff --git a/src/TimeLocker/system_control/release_launcher.py b/src/TimeLocker/system_control/release_launcher.py new file mode 100644 index 0000000..f1faee8 --- /dev/null +++ b/src/TimeLocker/system_control/release_launcher.py @@ -0,0 +1,284 @@ +"""Fail-closed resolution for root-owned immutable TimeLocker releases.""" + +import json +import os +import stat +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping, NoReturn + +from .models import PROTOCOL_VERSION +from .validation import require_exact_mapping, require_int, require_safe_identifier + + +DEFAULT_RELEASES_ROOT = Path("/opt/timelocker/releases") +DEFAULT_SELECTOR_PATH = Path("/opt/timelocker/selected-release.json") +LAUNCH_GUARD = "TIMELOCKER_SYSTEM_LAUNCH_ACTIVE" + + +class ReleaseResolutionError(RuntimeError): + """Raised when the selected immutable release cannot be trusted.""" + + +@dataclass(frozen=True, slots=True) +class SelectedRelease: + """Validated release selector state written by administrator tooling.""" + + selected: str + previous: str | None = None + schema_version: int = 1 + + @classmethod + def from_mapping(cls, value: object) -> "SelectedRelease": + mapping = require_exact_mapping( + value, + field="release selector", + required=frozenset({"schema_version", "selected", "previous"}), + ) + previous = mapping["previous"] + if previous is not None: + previous = _release_id(previous) + return cls( + schema_version=require_int( + mapping["schema_version"], + field="schema_version", + minimum=1, + maximum=1, + ), + selected=_release_id(mapping["selected"]), + previous=previous, + ) + + def to_wire(self) -> dict[str, object]: + return { + "schema_version": self.schema_version, + "selected": self.selected, + "previous": self.previous, + } + + +@dataclass(frozen=True, slots=True) +class ReleaseManifest: + """Compatibility contract shipped inside one immutable release.""" + + release_id: str + package_version: str + protocol_version: int + entrypoint: str = "venv/bin/timelocker" + schema_version: int = 1 + + @classmethod + def from_mapping(cls, value: object) -> "ReleaseManifest": + mapping = require_exact_mapping( + value, + field="release manifest", + required=frozenset( + { + "schema_version", + "release_id", + "package_version", + "protocol_version", + "entrypoint", + } + ), + ) + entrypoint = mapping["entrypoint"] + if entrypoint != "venv/bin/timelocker": + raise ReleaseResolutionError("release entrypoint is not allowlisted") + return cls( + schema_version=require_int( + mapping["schema_version"], + field="schema_version", + minimum=1, + maximum=1, + ), + release_id=_release_id(mapping["release_id"]), + package_version=require_safe_identifier( + mapping["package_version"], + field="package_version", + maximum=64, + ), + protocol_version=require_int( + mapping["protocol_version"], + field="protocol_version", + minimum=PROTOCOL_VERSION, + maximum=PROTOCOL_VERSION, + ), + entrypoint=entrypoint, + ) + + +class ImmutableReleaseResolver: + """Resolve the selected release without consulting user or shell state.""" + + def __init__( + self, + *, + releases_root: Path = DEFAULT_RELEASES_ROOT, + selector_path: Path = DEFAULT_SELECTOR_PATH, + expected_owner_uid: int = 0, + ) -> None: + self.releases_root = releases_root + self.selector_path = selector_path + self.expected_owner_uid = expected_owner_uid + + def resolve(self, environment: Mapping[str, str] | None = None) -> Path: + """Return the selected executable or fail before any fallback.""" + environment = os.environ if environment is None else environment + if environment.get(LAUNCH_GUARD): + raise ReleaseResolutionError("recursive system launcher invocation") + self._require_trusted_directory(self.selector_path.parent) + self._require_trusted_file(self.selector_path) + selector = SelectedRelease.from_mapping(_read_json(self.selector_path)) + return self._resolve_release(selector.selected) + + def select(self, release_id: str) -> SelectedRelease: + """Atomically select a validated staged release for administrator tooling.""" + release_id = _release_id(release_id) + self._require_trusted_directory(self.selector_path.parent) + self._resolve_release(release_id) + current = self._read_selector_optional() + next_state = SelectedRelease( + selected=release_id, + previous=current.selected + if current and current.selected != release_id + else (current.previous if current else None), + ) + _atomic_write_json(self.selector_path, next_state.to_wire()) + return next_state + + def rollback(self) -> SelectedRelease: + """Atomically swap selected and previous validated releases.""" + current = self._read_selector_optional() + if current is None or current.previous is None: + raise ReleaseResolutionError("no previous release is available") + self._resolve_release(current.previous) + next_state = SelectedRelease( + selected=current.previous, + previous=current.selected, + ) + _atomic_write_json(self.selector_path, next_state.to_wire()) + return next_state + + def _read_selector_optional(self) -> SelectedRelease | None: + self._require_trusted_directory(self.selector_path.parent) + if not self.selector_path.exists(): + return None + self._require_trusted_file(self.selector_path) + return SelectedRelease.from_mapping(_read_json(self.selector_path)) + + def _resolve_release(self, release_id: str) -> Path: + self._require_trusted_directory(self.releases_root) + release_dir = self.releases_root / release_id + self._require_trusted_directory(release_dir) + manifest_path = release_dir / "release.json" + self._require_trusted_file(manifest_path) + manifest = ReleaseManifest.from_mapping(_read_json(manifest_path)) + if manifest.release_id != release_id: + raise ReleaseResolutionError("release manifest identity mismatch") + executable = release_dir / manifest.entrypoint + self._require_trusted_file(executable, executable=True) + if executable.resolve().parent.parent.parent != release_dir.resolve(): + raise ReleaseResolutionError("release entrypoint escapes release directory") + return executable + + def _require_trusted_file(self, path: Path, *, executable: bool = False) -> None: + try: + metadata = path.lstat() + except OSError as error: + raise ReleaseResolutionError( + "required release file is unavailable" + ) from error + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): + raise ReleaseResolutionError("required release file is not regular") + if metadata.st_uid != self.expected_owner_uid: + raise ReleaseResolutionError("required release file has the wrong owner") + if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + raise ReleaseResolutionError( + "required release file is group/world writable" + ) + if executable and not metadata.st_mode & stat.S_IXUSR: + raise ReleaseResolutionError("release entrypoint is not executable") + + def _require_trusted_directory(self, path: Path) -> None: + try: + metadata = path.lstat() + except OSError as error: + raise ReleaseResolutionError( + "required release directory is unavailable" + ) from error + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode): + raise ReleaseResolutionError("required release directory is invalid") + if metadata.st_uid != self.expected_owner_uid: + raise ReleaseResolutionError( + "required release directory has the wrong owner" + ) + if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + raise ReleaseResolutionError( + "required release directory is group/world writable" + ) + + +def launch_selected( + arguments: list[str], + *, + resolver: ImmutableReleaseResolver | None = None, + environment: Mapping[str, str] | None = None, +) -> NoReturn: + """Replace this process with the selected immutable CLI entry point.""" + resolver = resolver or ImmutableReleaseResolver() + source_environment = dict(os.environ if environment is None else environment) + executable = resolver.resolve(source_environment) + source_environment[LAUNCH_GUARD] = "1" + os.execve( + executable, + [str(executable), *arguments], + source_environment, + ) + + +def _release_id(value: object) -> str: + release_id = require_safe_identifier( + value, + field="release_id", + maximum=64, + ) + if len(release_id) < 7 or any( + character not in "0123456789abcdef" for character in release_id + ): + raise ReleaseResolutionError("release_id must be a lowercase commit digest") + return release_id + + +def _read_json(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ReleaseResolutionError("release metadata is invalid") from error + + +def _atomic_write_json(path: Path, value: Mapping[str, object]) -> None: + path.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + try: + descriptor = os.open( + temporary, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o644, + ) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(value, stream, sort_keys=True, separators=(",", ":")) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass diff --git a/tests/TimeLocker/cli/test_monitoring_commands.py b/tests/TimeLocker/cli/test_monitoring_commands.py index 9a2fad9..6954766 100644 --- a/tests/TimeLocker/cli/test_monitoring_commands.py +++ b/tests/TimeLocker/cli/test_monitoring_commands.py @@ -5,7 +5,10 @@ """ import importlib.util +from datetime import UTC, datetime +from pathlib import Path from unittest.mock import Mock, patch +from uuid import UUID, uuid4 import pytest @@ -16,6 +19,24 @@ CLIMonitoringIntegration, ) from TimeLocker.cli_services import CLIServiceManager +from TimeLocker.system_control.client import SystemControlClientError +from TimeLocker.system_control.models import ( + DiagnosticRecord, + DiagnosticView, + RunRecord, + RunRecordView, +) +from TimeLocker.system_control.types import ( + DiagnosticCode, + DiagnosticComponent, + DiagnosticLevel, + OperationTrigger, + OperationType, + ProtocolErrorCode, + ResponseStatus, + ResultCode, + RunState, +) from tests.TimeLocker.cli.test_utils import ( get_cli_runner, combined_output, @@ -36,9 +57,9 @@ def test_monitoring_command_groups_have_one_module_owner(self) -> None: assert monitoring_commands.monitor_app.info.name == "monitor" assert monitoring_commands.logs_app.info.name == "logs" assert monitoring_commands.reports_app.info.name == "reports" - assert importlib.util.find_spec( - "TimeLocker.cli_modules.commands.monitor" - ) is None + assert ( + importlib.util.find_spec("TimeLocker.cli_modules.commands.monitor") is None + ) @pytest.mark.unit def test_service_manager_monitoring_facade_delegates_to_bridge(self) -> None: @@ -126,22 +147,26 @@ def test_monitor_stats_help(self): assert_help_quality(result, "monitor stats") @pytest.mark.unit - @patch('TimeLocker.cli_modules.commands.monitoring._get_service_manager_for_command') + @patch( + "TimeLocker.cli_modules.commands.monitoring._get_service_manager_for_command" + ) def test_monitor_health_command(self, mock_get_service_manager): """Test monitor health command execution.""" mock_manager = create_mock_cli_service_manager() mock_get_service_manager.return_value = mock_manager - + result = runner.invoke(app, ["monitor", "health"]) assert_success(result) @pytest.mark.unit - @patch('TimeLocker.cli_modules.commands.monitoring._get_service_manager_for_command') + @patch( + "TimeLocker.cli_modules.commands.monitoring._get_service_manager_for_command" + ) def test_monitor_stats_command(self, mock_get_service_manager): """Test monitor stats command execution.""" mock_manager = create_mock_cli_service_manager() mock_get_service_manager.return_value = mock_manager - + result = runner.invoke(app, ["monitor", "stats"]) assert_success(result) @@ -177,14 +202,158 @@ def test_logs_view_command(self): @pytest.mark.unit def test_logs_view_with_filters(self): """Test logs view command with filters.""" - result = runner.invoke(app, [ - "logs", "view", - "--level", "ERROR", - "--lines", "50" - ]) + result = runner.invoke( + app, ["logs", "view", "--level", "ERROR", "--lines", "50"] + ) # Should succeed or show "No Logs" if log file doesn't exist assert_success(result) + @pytest.mark.unit + def test_logs_view_rejects_invalid_scope_before_backend_access(self): + result = runner.invoke( + app, + ["logs", "view", "--scope", "protected"], + ) + assert result.exit_code == 1 + output = combined_output(result) + assert "unsupported log scope" in output + + @pytest.mark.unit + def test_logs_view_rejects_unbounded_line_count(self): + result = runner.invoke(app, ["logs", "view", "--lines", "1001"]) + assert result.exit_code == 2 + + @pytest.mark.unit + def test_logs_view_respects_explicit_config_dir(self, tmp_path: Path): + log_file = tmp_path / "cache" / "logs" / "timelocker.log" + log_file.parent.mkdir(parents=True) + log_file.write_text( + "2026-07-26 01:00:00 - local.component - INFO - selected log\n", + encoding="utf-8", + ) + result = runner.invoke( + app, + ["logs", "view", "--config-dir", str(tmp_path)], + ) + assert_success(result) + output = combined_output(result) + assert "TimeLocker local logs" in output + assert "selected log" in output + + @pytest.mark.unit + @patch("TimeLocker.cli_modules.commands.monitoring._create_system_control_client") + def test_logs_view_system_uses_structured_backend( + self, + create_client: Mock, + ): + diagnostic = DiagnosticView.from_record( + DiagnosticRecord( + record_id=uuid4(), + run_id=UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + timestamp=datetime(2026, 7, 26, 2, 45, tzinfo=UTC), + level=DiagnosticLevel.INFO, + component=DiagnosticComponent.BACKUP, + message_code=DiagnosticCode.BACKUP_SUCCEEDED, + ) + ) + client = Mock() + client.list_diagnostics.return_value = [diagnostic] + create_client.return_value = client + + result = runner.invoke( + app, + ["logs", "view", "--scope", "system"], + ) + assert_success(result) + output = combined_output(result) + assert "TimeLocker system diagnostics" in output + assert "backup_succeeded" in output + client.list_diagnostics.assert_called_once() + + @pytest.mark.unit + @patch("TimeLocker.cli_modules.commands.monitoring._create_system_control_client") + def test_system_log_denial_does_not_expose_protected_metadata( + self, + create_client: Mock, + ): + client = Mock() + client.list_diagnostics.side_effect = SystemControlClientError( + ProtocolErrorCode.SYSTEM_ACCESS_DENIED, + "System access denied.", + status=ResponseStatus.DENIED, + ) + create_client.return_value = client + + result = runner.invoke( + app, + ["logs", "view", "--scope", "system"], + ) + assert result.exit_code == 1 + output = combined_output(result) + assert "System access denied." in output + assert "/var/lib/timelocker" not in output + assert "repository-password" not in output + + +class TestRunsCommands: + """Protected structured run views.""" + + @staticmethod + def _run() -> RunRecordView: + return RunRecordView.from_record( + RunRecord( + run_id=UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + operation=OperationType.BACKUP, + trigger=OperationTrigger.SCHEDULED, + target_id="production", + started_at=datetime(2026, 7, 26, 2, 30, tzinfo=UTC), + completed_at=datetime(2026, 7, 26, 2, 45, tzinfo=UTC), + state=RunState.SUCCEEDED, + result_code=ResultCode.BACKUP_SUCCEEDED, + ) + ) + + @pytest.mark.unit + def test_runs_help_is_mounted(self): + result = runner.invoke(app, ["runs", "--help"]) + assert_help_quality(result, "runs") + + @pytest.mark.unit + @patch("TimeLocker.cli_modules.commands.monitoring._create_system_control_client") + def test_runs_list_renders_structured_records( + self, + create_client: Mock, + ): + client = Mock() + client.list_runs.return_value = [self._run()] + create_client.return_value = client + result = runner.invoke(app, ["runs", "list"]) + assert_success(result) + output = combined_output(result) + assert "System runs" in output + assert "backup" in output + assert "Backup completed" in output + assert "successfully." in output + + @pytest.mark.unit + @patch("TimeLocker.cli_modules.commands.monitoring._create_system_control_client") + def test_runs_show_json_is_allowlisted( + self, + create_client: Mock, + ): + client = Mock() + client.get_run.return_value = self._run() + create_client.return_value = client + result = runner.invoke( + app, + ["runs", "show", str(self._run().run_id), "--json"], + ) + assert_success(result) + output = combined_output(result) + assert '"operation": "backup"' in output + assert "repository_uri" not in output + assert "password" not in output + class TestReportsCommands: """Test suite for reports command group.""" @@ -204,31 +373,37 @@ def test_reports_generate_help(self): assert_help_quality(result, "reports generate") @pytest.mark.unit - @patch('TimeLocker.cli_modules.commands.monitoring._get_service_manager_for_command') + @patch( + "TimeLocker.cli_modules.commands.monitoring._get_service_manager_for_command" + ) def test_reports_generate_backup_history(self, mock_get_service_manager): """Test reports generate command for backup history.""" mock_manager = create_mock_cli_service_manager() mock_get_service_manager.return_value = mock_manager - + result = runner.invoke(app, ["reports", "generate", "backup-history"]) assert result.exit_code in [0, 1, 2] @pytest.mark.unit - @patch('TimeLocker.cli_modules.commands.monitoring._get_service_manager_for_command') + @patch( + "TimeLocker.cli_modules.commands.monitoring._get_service_manager_for_command" + ) def test_reports_generate_storage_usage(self, mock_get_service_manager): """Test reports generate command for storage usage.""" mock_manager = create_mock_cli_service_manager() mock_get_service_manager.return_value = mock_manager - + result = runner.invoke(app, ["reports", "generate", "storage-usage"]) assert result.exit_code in [0, 1, 2] @pytest.mark.unit - @patch('TimeLocker.cli_modules.commands.monitoring._get_service_manager_for_command') + @patch( + "TimeLocker.cli_modules.commands.monitoring._get_service_manager_for_command" + ) def test_reports_generate_performance(self, mock_get_service_manager): """Test reports generate command for performance.""" mock_manager = create_mock_cli_service_manager() mock_get_service_manager.return_value = mock_manager - + result = runner.invoke(app, ["reports", "generate", "performance"]) assert result.exit_code in [0, 1, 2] diff --git a/tests/TimeLocker/system_control/test_action_policy.py b/tests/TimeLocker/system_control/test_action_policy.py new file mode 100644 index 0000000..c74189f --- /dev/null +++ b/tests/TimeLocker/system_control/test_action_policy.py @@ -0,0 +1,59 @@ +"""Routing policy tests for public CLI actions.""" + +import pytest + +from TimeLocker.system_control.action_policy import ( + ActionClass, + UnknownPublicActionError, + classify_public_action, +) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("path", "scope", "expected", "uses_backend"), + [ + (("version",), None, ActionClass.USER_LOCAL_READ, False), + (("snapshots", "list"), None, ActionClass.USER_LOCAL_READ, False), + (("selections", "create"), None, ActionClass.USER_LOCAL_MUTATION, False), + (("logs", "view"), None, ActionClass.USER_LOCAL_READ, False), + (("logs", "view"), "local", ActionClass.USER_LOCAL_READ, False), + (("logs", "view"), "system", ActionClass.SYSTEM_READ, True), + (("runs", "list"), None, ActionClass.SYSTEM_READ, True), + (("system", "backup"), None, ActionClass.SYSTEM_ACTION, True), + ( + ("system", "rollback"), + None, + ActionClass.ADMINISTRATOR_MAINTENANCE, + False, + ), + ], +) +def test_classifier_routes_only_explicit_actions( + path: tuple[str, ...], + scope: str | None, + expected: ActionClass, + uses_backend: bool, +) -> None: + route = classify_public_action(path, scope=scope) + assert route.action_class is expected + assert route.uses_system_backend is uses_backend + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("path", "scope"), + [ + (("unknown",), None), + (("logs", "view"), "protected"), + (("runs", "delete"), None), + (("system", "shell"), None), + (("repos prune",), None), + ], +) +def test_classifier_fails_closed_for_unknown_actions( + path: tuple[str, ...], + scope: str | None, +) -> None: + with pytest.raises(UnknownPublicActionError): + classify_public_action(path, scope=scope) diff --git a/tests/TimeLocker/system_control/test_client.py b/tests/TimeLocker/system_control/test_client.py new file mode 100644 index 0000000..6836bbe --- /dev/null +++ b/tests/TimeLocker/system_control/test_client.py @@ -0,0 +1,275 @@ +"""Focused protected system-control client tests.""" + +import json +from datetime import UTC, datetime +from unittest.mock import patch +from uuid import UUID, uuid4 + +import pytest + +from TimeLocker.system_control.client import ( + SystemControlClientError, + UnixSocketSystemControlClient, +) +from TimeLocker.system_control.models import ( + DiagnosticQuery, + DiagnosticRecord, + DiagnosticView, + RunQuery, + RunRecord, + RunRecordView, + BackupActionRequest, + RetentionActionRequest, + ActionReceipt, +) +from TimeLocker.system_control.protocol import ResponseEnvelope +from TimeLocker.system_control.types import ( + DiagnosticCode, + DiagnosticComponent, + DiagnosticLevel, + OperationTrigger, + OperationType, + ProtocolErrorCode, + ResponseStatus, + ResultCode, + RunState, + SystemAction, +) + + +RUN_ID = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + + +def _run() -> RunRecordView: + return RunRecordView.from_record( + RunRecord( + run_id=RUN_ID, + operation=OperationType.BACKUP, + trigger=OperationTrigger.SCHEDULED, + target_id="production", + started_at=datetime(2026, 7, 26, 2, 30, tzinfo=UTC), + completed_at=datetime(2026, 7, 26, 2, 45, tzinfo=UTC), + state=RunState.SUCCEEDED, + result_code=ResultCode.BACKUP_SUCCEEDED, + ) + ) + + +def _success_exchange(action: SystemAction, result: object): + def exchange(request: bytes) -> bytes: + parsed = json.loads(request) + assert parsed["action"] == action.value + response = ResponseEnvelope.success( + UUID(parsed["request_id"]), + action, + result, + ) + return json.dumps(response.to_wire()).encode() + + return exchange + + +@pytest.mark.unit +def test_list_runs_sends_bounded_filters_and_parses_projected_records() -> None: + def exchange(request: bytes) -> bytes: + parsed = json.loads(request) + assert parsed["parameters"] == { + "limit": 5, + "operation": "backup", + "state": "succeeded", + } + return json.dumps( + ResponseEnvelope.success( + UUID(parsed["request_id"]), + SystemAction.RUN_LIST, + {"runs": [_run().to_wire()]}, + ).to_wire() + ).encode() + + client = UnixSocketSystemControlClient(exchange=exchange) + runs = client.list_runs( + RunQuery( + limit=5, + operation=OperationType.BACKUP, + state=RunState.SUCCEEDED, + ) + ) + assert runs == [_run()] + + +@pytest.mark.unit +def test_get_run_and_diagnostics_use_only_structured_contract_fields() -> None: + run_client = UnixSocketSystemControlClient( + exchange=_success_exchange( + SystemAction.RUN_DETAIL, + {"run": _run().to_wire()}, + ) + ) + assert run_client.get_run(RUN_ID) == _run() + + diagnostic = DiagnosticView.from_record( + DiagnosticRecord( + record_id=uuid4(), + run_id=RUN_ID, + timestamp=datetime(2026, 7, 26, 2, 45, tzinfo=UTC), + level=DiagnosticLevel.INFO, + component=DiagnosticComponent.BACKUP, + message_code=DiagnosticCode.BACKUP_SUCCEEDED, + ) + ) + diagnostics_client = UnixSocketSystemControlClient( + exchange=_success_exchange( + SystemAction.DIAGNOSTIC_LIST, + {"diagnostics": [diagnostic.to_wire()]}, + ) + ) + assert diagnostics_client.list_diagnostics(DiagnosticQuery(limit=10)) == [ + diagnostic + ] + + +@pytest.mark.unit +def test_denial_exposes_only_stable_safe_error() -> None: + secret = "s3://secret-bucket repository-password" + + def exchange(request: bytes) -> bytes: + request_id = UUID(json.loads(request)["request_id"]) + response = ResponseEnvelope.error( + request_id, + ResponseStatus.DENIED, + ProtocolErrorCode.SYSTEM_ACCESS_DENIED, + ) + encoded = json.dumps(response.to_wire()) + assert secret not in encoded + return encoded.encode() + + client = UnixSocketSystemControlClient(exchange=exchange) + with pytest.raises(SystemControlClientError) as caught: + client.list_runs(RunQuery()) + assert caught.value.error_code is ProtocolErrorCode.SYSTEM_ACCESS_DENIED + assert str(caught.value) == "System access denied." + assert secret not in str(caught.value) + + +@pytest.mark.unit +def test_unavailable_transport_has_actionable_bounded_message() -> None: + def unavailable(_request: bytes) -> bytes: + raise FileNotFoundError + + client = UnixSocketSystemControlClient(exchange=unavailable) + with pytest.raises(SystemControlClientError) as caught: + client.list_runs(RunQuery()) + assert caught.value.error_code is ProtocolErrorCode.SYSTEM_BACKEND_UNAVAILABLE + assert "systemctl status timelocker-control.socket" in str(caught.value) + + +@pytest.mark.unit +def test_mismatched_response_id_is_rejected() -> None: + def exchange(_request: bytes) -> bytes: + return json.dumps( + ResponseEnvelope.success( + uuid4(), + SystemAction.RUN_LIST, + {"runs": []}, + ).to_wire() + ).encode() + + client = UnixSocketSystemControlClient(exchange=exchange) + with pytest.raises(SystemControlClientError) as caught: + client.list_runs(RunQuery()) + assert caught.value.error_code is ProtocolErrorCode.INVALID_REQUEST + + +@pytest.mark.unit +def test_action_requests_preserve_only_allowlisted_parameters() -> None: + accepted_id = uuid4() + + def exchange(request: bytes) -> bytes: + parsed = json.loads(request) + if parsed["action"] == SystemAction.BACKUP_REQUEST.value: + assert parsed["parameters"] == {"target_id": "production"} + action = SystemAction.BACKUP_REQUEST + else: + assert parsed["parameters"] == { + "policy_fingerprint": "a" * 64, + "dry_run": True, + } + action = SystemAction.RETENTION_REQUEST + response = ResponseEnvelope.success( + UUID(parsed["request_id"]), + action, + ActionReceipt( + request_id=UUID(parsed["request_id"]), + accepted=True, + status="queued", + run_id=accepted_id, + ).to_wire(), + ) + return json.dumps(response.to_wire()).encode() + + client = UnixSocketSystemControlClient(exchange=exchange) + assert ( + client.request_backup(BackupActionRequest("production")).run_id == accepted_id + ) + assert ( + client.request_retention(RetentionActionRequest("a" * 64, dry_run=True)).run_id + == accepted_id + ) + + +@pytest.mark.unit +def test_invalid_or_oversized_response_fails_closed() -> None: + invalid = UnixSocketSystemControlClient(exchange=lambda _request: b"{") + with pytest.raises(SystemControlClientError) as invalid_error: + invalid.list_runs(RunQuery()) + assert invalid_error.value.error_code is ProtocolErrorCode.INVALID_REQUEST + + oversized = UnixSocketSystemControlClient( + max_response_bytes=1024, + exchange=lambda _request: b"x" * 1025, + ) + with pytest.raises(SystemControlClientError) as oversized_error: + oversized.list_runs(RunQuery()) + assert oversized_error.value.error_code is ProtocolErrorCode.INVALID_REQUEST + + +@pytest.mark.unit +def test_unix_socket_exchange_is_bounded_and_line_framed() -> None: + class FakeSocket: + request: bytes + timeout: float + connected: str + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def settimeout(self, timeout: float) -> None: + self.timeout = timeout + + def connect(self, path: str) -> None: + self.connected = path + + def sendall(self, request: bytes) -> None: + self.request = request + + def recv(self, _maximum: int) -> bytes: + parsed = json.loads(self.request) + response = ResponseEnvelope.success( + UUID(parsed["request_id"]), + SystemAction.RUN_LIST, + {"runs": []}, + ) + return json.dumps(response.to_wire()).encode() + b"\nignored" + + connection = FakeSocket() + with patch( + "TimeLocker.system_control.client.socket.socket", + return_value=connection, + ): + client = UnixSocketSystemControlClient(timeout_seconds=1) + assert client.list_runs(RunQuery()) == [] + assert connection.timeout == 1 + assert connection.connected == "/run/timelocker/control.sock" diff --git a/tests/TimeLocker/system_control/test_release_entrypoints.py b/tests/TimeLocker/system_control/test_release_entrypoints.py new file mode 100644 index 0000000..b361904 --- /dev/null +++ b/tests/TimeLocker/system_control/test_release_entrypoints.py @@ -0,0 +1,68 @@ +"""Failure-safe tests for staged release administration entry points.""" + +from unittest.mock import Mock, patch + +import pytest + +from TimeLocker.system_control import launcher_entry, release_admin +from TimeLocker.system_control.release_launcher import ( + ReleaseResolutionError, + SelectedRelease, +) + + +@pytest.mark.unit +def test_launcher_entry_translates_resolution_failure_without_details( + capsys: pytest.CaptureFixture[str], +) -> None: + with patch.object( + launcher_entry, + "launch_selected", + side_effect=ReleaseResolutionError("sensitive path detail"), + ): + with pytest.raises(SystemExit) as caught: + launcher_entry.main() + assert caught.value.code == 78 + output = capsys.readouterr() + assert "selected-release.json" in output.err + assert "sensitive path detail" not in output.err + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("arguments", "method"), + [ + (["timelocker-release-select", "select", "a" * 40], "select"), + (["timelocker-release-select", "rollback"], "rollback"), + ], +) +def test_release_admin_uses_only_bounded_selector_operations( + arguments: list[str], + method: str, + capsys: pytest.CaptureFixture[str], +) -> None: + resolver = Mock() + getattr(resolver, method).return_value = SelectedRelease(selected="a" * 40) + with ( + patch.object(release_admin, "ImmutableReleaseResolver", return_value=resolver), + patch("sys.argv", arguments), + ): + release_admin.main() + getattr(resolver, method).assert_called_once() + assert capsys.readouterr().out.strip() == "a" * 40 + + +@pytest.mark.unit +def test_release_admin_returns_configuration_exit_on_invalid_release( + capsys: pytest.CaptureFixture[str], +) -> None: + resolver = Mock() + resolver.rollback.side_effect = ReleaseResolutionError("no previous release") + with ( + patch.object(release_admin, "ImmutableReleaseResolver", return_value=resolver), + patch("sys.argv", ["timelocker-release-select", "rollback"]), + ): + with pytest.raises(SystemExit) as caught: + release_admin.main() + assert caught.value.code == 78 + assert "release selection failed" in capsys.readouterr().err diff --git a/tests/TimeLocker/system_control/test_release_launcher.py b/tests/TimeLocker/system_control/test_release_launcher.py new file mode 100644 index 0000000..bde5ab3 --- /dev/null +++ b/tests/TimeLocker/system_control/test_release_launcher.py @@ -0,0 +1,164 @@ +"""Immutable release resolution and rollback tests.""" + +import json +import os +from pathlib import Path + +import pytest + +from TimeLocker.system_control.release_launcher import ( + LAUNCH_GUARD, + ImmutableReleaseResolver, + ReleaseResolutionError, +) + + +RELEASE_A = "a" * 40 +RELEASE_B = "b" * 40 + + +def _stage_release(root: Path, release_id: str) -> Path: + release = root / "releases" / release_id + executable = release / "venv" / "bin" / "timelocker" + executable.parent.mkdir(parents=True) + (root / "releases").chmod(0o755) + release.chmod(0o755) + executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + executable.chmod(0o755) + manifest = release / "release.json" + manifest.write_text( + json.dumps( + { + "schema_version": 1, + "release_id": release_id, + "package_version": "0.9.1", + "protocol_version": 1, + "entrypoint": "venv/bin/timelocker", + } + ), + encoding="utf-8", + ) + manifest.chmod(0o644) + return executable + + +def _resolver(root: Path) -> ImmutableReleaseResolver: + return ImmutableReleaseResolver( + releases_root=root / "releases", + selector_path=root / "selected-release.json", + expected_owner_uid=os.getuid(), + ) + + +@pytest.mark.unit +def test_timelocker_and_tl_share_one_selected_release(tmp_path: Path) -> None: + expected = _stage_release(tmp_path, RELEASE_A) + resolver = _resolver(tmp_path) + resolver.select(RELEASE_A) + + assert resolver.resolve({}) == expected + assert resolver.resolve({}) == expected + + +@pytest.mark.unit +def test_release_switch_and_rollback_are_atomic_and_symmetric(tmp_path: Path) -> None: + release_a = _stage_release(tmp_path, RELEASE_A) + release_b = _stage_release(tmp_path, RELEASE_B) + resolver = _resolver(tmp_path) + + resolver.select(RELEASE_A) + resolver.select(RELEASE_B) + assert resolver.resolve({}) == release_b + + state = resolver.rollback() + assert state.selected == RELEASE_A + assert state.previous == RELEASE_B + assert resolver.resolve({}) == release_a + + +@pytest.mark.unit +def test_missing_selected_release_never_falls_back_to_user_environment( + tmp_path: Path, +) -> None: + selector = tmp_path / "selected-release.json" + selector.write_text( + json.dumps( + { + "schema_version": 1, + "selected": RELEASE_A, + "previous": None, + } + ), + encoding="utf-8", + ) + resolver = _resolver(tmp_path) + + with pytest.raises(ReleaseResolutionError): + resolver.resolve( + { + "PATH": str(tmp_path / "checkout"), + "PYENV_VERSION": "3.12.6", + "VIRTUAL_ENV": str(tmp_path / "venv"), + } + ) + + +@pytest.mark.unit +def test_recursive_launch_is_rejected_before_release_resolution( + tmp_path: Path, +) -> None: + resolver = _resolver(tmp_path) + with pytest.raises(ReleaseResolutionError, match="recursive"): + resolver.resolve({LAUNCH_GUARD: "1"}) + + +@pytest.mark.unit +@pytest.mark.parametrize("mode", [0o775, 0o777]) +def test_writable_release_metadata_is_rejected(tmp_path: Path, mode: int) -> None: + _stage_release(tmp_path, RELEASE_A) + resolver = _resolver(tmp_path) + resolver.select(RELEASE_A) + manifest = tmp_path / "releases" / RELEASE_A / "release.json" + manifest.chmod(mode) + + with pytest.raises(ReleaseResolutionError, match="writable"): + resolver.resolve({}) + + +@pytest.mark.unit +def test_symlinked_entrypoint_is_rejected(tmp_path: Path) -> None: + executable = _stage_release(tmp_path, RELEASE_A) + target = tmp_path / "outside" + target.write_text("#!/bin/sh\n", encoding="utf-8") + target.chmod(0o755) + executable.unlink() + executable.symlink_to(target) + resolver = _resolver(tmp_path) + resolver.selector_path.write_text( + json.dumps( + { + "schema_version": 1, + "selected": RELEASE_A, + "previous": None, + } + ), + encoding="utf-8", + ) + + with pytest.raises(ReleaseResolutionError): + resolver.resolve({}) + + +@pytest.mark.unit +def test_staged_launcher_has_no_pyenv_checkout_or_root_overlay_fallback() -> None: + assets = ( + Path(__file__).parents[3] / "src" / "TimeLocker" / "system_control" / "assets" + ) + primary = (assets / "timelocker-launcher").read_text(encoding="utf-8") + alias = (assets / "tl-launcher").read_text(encoding="utf-8") + for content in (primary, alias): + assert "/opt/timelocker/launcher/venv/bin/python" in content + assert "-m TimeLocker.system_control.launcher_entry" in content + assert "pyenv" not in content + assert "/root/.timelocker" not in content + assert "Projects/" not in content From 7b77d62280fef38ba3c0fff021431dbbefde5441 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:49:50 +0100 Subject: [PATCH 36/72] feat: complete spec 009 phase 3 automation Separate tray lifecycle from CLI and headless services, add the standalone tray client entry point, and implement approved retention execution with explicit, scheduled, and post-backup triggers. Record focused validation, lifecycle evidence, and the remaining live installation boundary for Phase 4. --- .../009-system-cli-tray-retention/tasks.md | 40 +- .../traceability.md | 13 +- .../verification.md | 63 ++- docs/specs/README.md | 9 +- pyproject.toml | 1 + src/TimeLocker/cli.py | 4 - src/TimeLocker/monitoring/__init__.py | 152 ++++-- .../monitoring/notification_service.py | 466 ++++++++---------- .../monitoring/system_tray_integration.py | 325 ++++++------ src/TimeLocker/system_control/__init__.py | 18 + src/TimeLocker/system_control/client.py | 5 + src/TimeLocker/system_control/interfaces.py | 4 + src/TimeLocker/system_control/models.py | 45 ++ src/TimeLocker/system_control/retention.py | 394 +++++++++++++++ src/TimeLocker/system_control/tray_client.py | 258 ++++++++++ src/TimeLocker/system_control/tray_entry.py | 311 ++++++++++++ .../test_system_tray_integration.py | 55 ++- .../TimeLocker/system_control/test_client.py | 22 + .../system_control/test_retention.py | 263 ++++++++++ .../system_control/test_tray_client.py | 196 ++++++++ .../test_tray_process_boundary.py | 47 ++ 21 files changed, 2176 insertions(+), 515 deletions(-) create mode 100644 src/TimeLocker/system_control/retention.py create mode 100644 src/TimeLocker/system_control/tray_client.py create mode 100644 src/TimeLocker/system_control/tray_entry.py create mode 100644 tests/TimeLocker/system_control/test_retention.py create mode 100644 tests/TimeLocker/system_control/test_tray_client.py create mode 100644 tests/TimeLocker/system_control/test_tray_process_boundary.py diff --git a/docs/specs/009-system-cli-tray-retention/tasks.md b/docs/specs/009-system-cli-tray-retention/tasks.md index f6d5663..0a36bb2 100644 --- a/docs/specs/009-system-cli-tray-retention/tasks.md +++ b/docs/specs/009-system-cli-tray-retention/tasks.md @@ -167,7 +167,7 @@ T009 -> T010 -> T011 -> T012 - Evidence mode: validation ## Phase 3: Independent tray and retention -- [ ] T007 Remove tray ownership from CLI/headless services and add the +- [x] T007 Remove tray ownership from CLI/headless services and add the independent tray client. - Depends on: T004 - Requirements: Requirement 3 AC1-AC8; Requirement 4 AC1, AC4-AC9; @@ -179,15 +179,23 @@ T009 -> T010 -> T011 -> T012 tray platform code and emit no tray warning; the user-session tray connects, reconnects, displays authorized state, and requests only allowlisted actions. - - Evidence: Pending. - - [ ] T007.1 Add import-boundary, headless, absence, crash, singleton, and + - Evidence: Removed platform tray ownership and exports from NotificationService/headless monitoring; added standalone timelocker-tray entry point, safe tray IPC projection, schedule status, strict action allowlist, backend absence/denial display, bounded reconnect, and singleton locking. Integrated Phase 3 repository slice passed 190 tests including import-boundary, headless, reconnect, authorization projection, singleton, and monitoring compatibility cases; no host state changed. + - Status: Repository behavior verified. Installed desktop-session and live IPC acceptance remains T009/T010. + - Evidence mode: validation + - [x] T007.1 Add import-boundary, headless, absence, crash, singleton, and reconnect tests. - - [ ] T007.2 Refactor notification delivery to publish structured state + - Evidence: Added direct import-boundary, headless availability, backend absence/denial, reconnect, strict allowlist, and singleton-lock tests; all passed in the 190-test integrated slice. + - Evidence mode: validation + - [x] T007.2 Refactor notification delivery to publish structured state without constructing `SystemTrayIntegration`. - - [ ] T007.3 Add standalone tray entry point and Linux Mint Cinnamon/X11 + - Evidence: `tests/TimeLocker/system_control/test_tray_process_boundary.py` verifies that `NotificationService`, CLI imports, and the monitoring package do not import or construct the platform tray; the integrated 190-test slice passed. + - Evidence mode: code + - [x] T007.3 Add standalone tray entry point and Linux Mint Cinnamon/X11 adapter. -- [ ] T008 Implement approved retention execution and all three trigger modes. + - Evidence: `pyproject.toml` packages the `timelocker-tray` entry point; wheel inventory confirmed that entry point plus `tray_entry.py` and `tray_client.py`, and tray/process tests passed. Live desktop installation remains T009/T010. + - Evidence mode: code +- [x] T008 Implement approved retention execution and all three trigger modes. - Depends on: T004 - Requirements: Requirement 5 AC1-AC11; Requirement 6 AC1-AC3, AC6 - Properties: CP-003, CP-004, CP-005, CP-008, CP-010 @@ -196,14 +204,24 @@ T009 -> T010 -> T011 -> T012 - Acceptance: Dry-run approval fingerprints the complete policy; backup success, independent schedule, and explicit request create separate locked retention runs; failure or conflict never changes the backup result. - - Evidence: Pending. - - [ ] T008.1 Add policy fingerprint, approval, conflict, idempotency, and + - Evidence: Implemented approved retention execution and all three trigger modes in the repository slice. Evidence: `src/TimeLocker/system_control/retention.py` adds exact retention-plan fingerprinting, approval-gated mutation, durable trigger claims, explicit protected request handling, and independently gated scheduling. Focused validation passed with `python3 -m pytest tests/TimeLocker/system_control/test_retention.py tests/TimeLocker/system_control/test_tray_client.py tests/TimeLocker/system_control/test_tray_process_boundary.py tests/TimeLocker/monitoring/test_system_tray_integration.py tests/TimeLocker/system_control/test_client.py --cov-reset --cov=src/TimeLocker/system_control --cov-fail-under=50` (32 passed, 58.7% coverage). `git diff --check` passed. No host state changed. + - Status: Repository implementation complete; live asset integration and host acceptance remain T009-T010. + - Evidence mode: validation + - [x] T008.1 Add policy fingerprint, approval, conflict, idempotency, and failure-isolation tests. - - [ ] T008.2 Implement retention executor and protected explicit request. - - [ ] T008.3 Implement post-backup success trigger after terminal record and + - Evidence: Added nine focused tests covering complete fingerprint sensitivity, dry-run non-approval, exact approval, lock conflict, safe failure, durable idempotency, non-success rejection, independent schedule configuration, and protected request projection. The system-control suite passed 149 tests at 83.09% branch-aware coverage. + - Evidence mode: validation + - [x] T008.2 Implement retention executor and protected explicit request. + - Evidence: `src/TimeLocker/system_control/retention.py` implements canonical fingerprinting, separate durable runs, shared repository locking, exact mutation approval, dry-run behavior, safe adapter projection, and the protected request handler; the nine retention tests passed. + - Evidence mode: code + - [x] T008.3 Implement post-backup success trigger after terminal record and lock release. - - [ ] T008.4 Implement independently configurable schedule, disabled in the + - Evidence: `test_success_trigger_is_durable_and_idempotent_across_restart` and `test_success_trigger_rejects_non_successful_backup` passed, proving durable at-most-once claiming and rejection of non-success terminal records. + - Evidence mode: validation + - [x] T008.4 Implement independently configurable schedule, disabled in the initial production profile. + - Evidence: `test_independent_schedule_is_disabled_by_default_and_configurable` passed; the coordinator defaults the independent trigger off and creates a separate scheduled retention run only when enabled. Initial production activation remains T009/T010. + - Evidence mode: validation ## Phase 4: Installation, portability, and live acceptance diff --git a/docs/specs/009-system-cli-tray-retention/traceability.md b/docs/specs/009-system-cli-tray-retention/traceability.md index 85bd1b3..2aa8fce 100644 --- a/docs/specs/009-system-cli-tray-retention/traceability.md +++ b/docs/specs/009-system-cli-tray-retention/traceability.md @@ -63,8 +63,8 @@ targets. Reconcile this matrix whenever any linked artifact changes. | Design Section | Requirements | Tasks | Interfaces Or Files | Verification | Coverage State | Residual Destination | |----------------|--------------|-------|---------------------|--------------|----------------|----------------------| | Decisions D001-D005 | R1, R2, R4 | T001, T003, T005-T006 | `system_control`, CLI, install assets | V1-V6 | not-covered | T001 | -| Decision D006 and independent tray | R3, R4 | T007, T009 | monitoring/tray/platform modules | V7, V9-V10 | not-covered | T007 | -| Decision D007 and retention flow | R5 | T002, T008 | retention/scheduling modules | V3, V8, V10 | not-covered | T008 | +| Decision D006 and independent tray | R3, R4 | T007, T009 | monitoring/tray/platform modules | V7, V9-V10 | repository-validated | T009 | +| Decision D007 and retention flow | R5 | T002, T008 | retention/scheduling modules | V3, V8, V10 | repository-validated | T009 | | Migration and compatibility | R1-R6 | T005, T007-T010 | install/release/system assets | V9-V10 | not-covered | T009 | | Durable promotion | R1-R6 | T011-T012 | `docs/` targets | V12 | not-covered | T011 | @@ -82,12 +82,13 @@ targets. Reconcile this matrix whenever any linked artifact changes. - `complete` in the requirement-delivery matrix means every accepted criterion has an explicit design, task, verification, and durable-target mapping. It does not claim implementation completion. -- Implementation and verification evidence remains pending in `tasks.md` and - `verification.md`; update those states only from executed evidence. +- Phase 3 repository implementation evidence now exists in `tasks.md` and + `verification.md`; live integration and promotion evidence remain pending. ## Reconciliation Reviewed against the 2026-07-26 requirements and design revisions. Every Requirement 1-6 acceptance criterion has an explicit task mapping, including -Requirement 4 AC10-AC11 and the tightened security constraints. No -implementation-completion claim is made. +Requirement 4 AC10-AC11 and the tightened security constraints. Phase 3 +repository implementation evidence now covers Decisions D006-D007; T009-T012 +remain the open live-integration, promotion, and closure path. diff --git a/docs/specs/009-system-cli-tray-retention/verification.md b/docs/specs/009-system-cli-tray-retention/verification.md index f549664..5e836bd 100644 --- a/docs/specs/009-system-cli-tray-retention/verification.md +++ b/docs/specs/009-system-cli-tray-retention/verification.md @@ -21,8 +21,8 @@ review, durable promotion, and closure. |------|-----------|--------|----------| | Requirements acceptance criteria reviewed | yes | pending | Requirements amended through 2026-07-26 | | Design and traceability approved | yes | passed | Owner approved implementation; lifecycle context reports no Phase 1 gaps | -| Task evidence complete | yes | partial | T001-T006 complete; T007-T012 pending | -| Automated tests pass or alternate verification recorded | yes | partial | Phase 2 focused suite: 177 passed; system-control package 88.2% branch-aware coverage | +| Task evidence complete | yes | partial | T001-T008 complete; T009-T012 pending | +| Automated tests pass or alternate verification recorded | yes | partial | Phase 2 focused suite: 177 passed; Phase 3 system-control suite: 149 passed with 83.09% branch-aware coverage | | Security and operations expert review complete | yes | partial | T004 and Phase 2 checkpoints complete; final T012 review pending | | Linux Mint live acceptance and rollback rehearsal complete | yes | pending | | | Durable documentation promoted | yes | pending | | @@ -68,25 +68,25 @@ Commands are refined through Agent Workbench before execution. |-------------|-----------------------------|----------|---------------| | Requirement 1 | AC1-AC4 | V5 repository validation passed; V9-V10 pending | Live launcher/rollback | | Requirement 2 | AC1-AC6 | V2, V4-V5, V10 pending | Platform authorization UX | -| Requirement 3 | AC1-AC8 | V7, V9-V10 pending | Desktop diversity | +| Requirement 3 | AC1-AC8 | V7 repository validation passed; V9-V10 pending | Desktop diversity and live session behavior | | Requirement 4 | AC1-AC11 | V1-V3 and V6 repository validation passed; V4 and V10 live evidence pending | Redaction and NSS variance | -| Requirement 5 | AC1-AC11 | V1, V3, V8, V10 pending | Live repository timing | -| Requirement 6 | AC1-AC6 | V3, V5, V7, V9-V10 pending | Cross-platform rollout | +| Requirement 5 | AC1-AC11 | V1, V3, and V8 repository validation passed; V10 pending | Live repository timing | +| Requirement 6 | AC1-AC6 | V3, V5, and V7 repository validation passed; V9-V10 pending | Cross-platform rollout | ## Correctness Property Coverage | Property | Covered by | Evidence | Residual risk | |----------|------------|----------|---------------| | CP-001 | V2, V5 | repository validation passed | Live platform authorization remains V10 | -| CP-002 | V7, V10 | pending | | -| CP-003 | V3, V8, V10 | pending | | -| CP-004 | V1, V3, V6, V8 | V1, V3, and V6 repository validation passed | Retention coverage remains V8 | -| CP-005 | V1, V8, V10 | pending | | +| CP-002 | V7, V10 | V7 repository validation passed | Live desktop acceptance remains V10 | +| CP-003 | V3, V8, V10 | V3 and V8 repository validation passed | Live repository coordination remains V10 | +| CP-004 | V1, V3, V6, V8 | repository validation passed | Live integration remains V10 | +| CP-005 | V1, V8, V10 | V1 and V8 repository validation passed | Live retention acceptance remains V10 | | CP-006 | V1-V2, V4-V6 | V1-V3 and V5-V6 repository validation passed | Live IPC remains V4/V10 | | CP-007 | V2, V4, V10 | repository authorization and denial validation passed | Live NSS/session behavior remains V4/V10 | -| CP-008 | V3, V9-V10 | pending | | -| CP-009 | V1, V7, V9 | pending | Live Windows remains follow-up | -| CP-010 | V3, V8, V10 | pending | | +| CP-008 | V3, V9-V10 | V3 repository validation passed | Installed coordination and restart remain V9-V10 | +| CP-009 | V1, V7, V9 | V1 and V7 repository validation passed | Live Windows remains follow-up | +| CP-010 | V3, V8, V10 | V3 and V8 repository validation passed | Live retention failure isolation remains V10 | | CP-011 | V1-V2, V4, V6, V10 | repository projection and CLI validation passed | Live metadata-leak acceptance remains V10 | ## Scope Reconciliation Before Closure @@ -95,8 +95,8 @@ Commands are refined through Agent Workbench before execution. |-----------------------------------------------------|--------------------------|----------------|---------------------------|-------------|-----------------|----------| | Linux system command/control plane | Shared contracts, store, dispatcher, staged launcher, and CLI client/views | partial | Live artifact integration and host acceptance | T009-T010 | yes | T001-T006 evidence | | Group-authorized system records | Current-membership dispatcher and structured CLI projection | partial | Live NSS/socket acceptance | T009-T010 | yes | T003, T004, T006 evidence | -| Independent tray | none | not-covered | Implementation pending | T007, T009-T010 | yes | pending | -| Retention automation | none | not-covered | Implementation pending | T008-T010 | yes | pending | +| Independent tray | Standalone tray entry point, bounded tray IPC client, strict menu allowlist, singleton lock, and headless-safe monitoring imports | partial | Installed desktop-session, launcher integration, and live IPC acceptance | T009-T010 | yes | T007 evidence | +| Retention automation | Approved retention executor, exact policy fingerprinting, durable backup-success trigger claiming, explicit request handler, and independently gated schedule | partial | Live backend composition, installed schedule assets, and production-host timing acceptance | T009-T010 | yes | T008 evidence | | Windows shared architecture | none | not-covered | Live Windows adapter/acceptance | T001, T009 then roadmap | yes for contracts; no for live Windows | pending | | Raw journald delegation | rejected | out-of-scope | Rejected because it exposes unrelated/protected records | none | no | Design D002 | | User-scoped backup partitions | none | out-of-scope | Separate authorization model | GitHub issue #70 | no | Requirements non-goal | @@ -123,7 +123,9 @@ Commands are refined through Agent Workbench before execution. | T004 | complete | Phase 1 suite passed 98 tests at 88.4% coverage; Ruff, compileall, wheel asset, patch, lifecycle, and expert-panel checks passed | Real systemd/AF_UNIX host acceptance remains V4/T010 | | T005 | complete | 22 focused launcher/action-policy tests; staged alias, selector, and launcher assets; wheel inventory; Ruff and patch checks passed | No live launcher or selector changed | | T006 | complete | Integrated system-control/CLI/help suite passed 177 tests; system-control package measured 88.2% branch-aware coverage; Ruff, format, compile, wheel, and patch checks passed | Live socket and operator-group acceptance remain T009/T010 | -| T007-T012 | pending | No implementation evidence | Later implementation phases | +| T007 | complete | Independent tray entry point, strict tray allowlist, backend-unavailable/denied projection, and headless-safe monitoring imports; 190-test repository slice passed | Installed desktop-session and live IPC acceptance remain T009-T010 | +| T008 | complete | Approved retention executor, trigger claiming, protected request handler, and independent schedule gate; system-control suite passed 149 tests with 83.09% branch-aware coverage | Live backend composition and host scheduling acceptance remain T009-T010 | +| T009-T012 | pending | No implementation evidence | Later implementation, promotion, and closure phases | ## Evidence Log @@ -147,6 +149,11 @@ Commands are refined through Agent Workbench before execution. | 2026-07-26 | `coverage report --include='src/TimeLocker/system_control/*' --skip-empty --fail-under=0` | 88.2% branch-aware coverage | Scoped report for the system-control package; a pytest coverage attempt inherited repository-wide `source=src` and failed the global 50% threshold at 17.3%, so it is not presented as a focused coverage result | | 2026-07-26 | Ruff check/format, compileall, wheel build/inventory, and `git diff --check` | passed | Wheel contains the Phase 2 modules and all six system-control assets, including distinct `timelocker` and `tl` launcher assets; isolated build dependency resolution was unavailable, and the Python 3.12.4 no-isolation build passed | | 2026-07-26 | Agent Workbench verification planning | partial routing only | Its index had not incorporated newly created files and proposed unrelated tests; direct source review, the focused suite, and package inventory are the proof | +| 2026-07-26 | `python3 -m pytest tests/TimeLocker/system_control/test_retention.py tests/TimeLocker/system_control/test_tray_client.py tests/TimeLocker/system_control/test_tray_process_boundary.py tests/TimeLocker/monitoring/test_system_tray_integration.py tests/TimeLocker/system_control/test_client.py` | 32 passed; repository-wide coverage gate failed at 12.2% | Narrow slice inherited repository-wide `--cov=src/TimeLocker`; tests passed and exposed a coverage-accounting mismatch rather than a functional regression | +| 2026-07-26 | `PYENV_VERSION=3.12.4 PYTHONPATH=src python -m pytest -o addopts='' tests/TimeLocker/system_control --cov-config=/dev/null --cov=TimeLocker.system_control --cov-branch --cov-report=term --cov-fail-under=80 -q` | 149 passed; 83.09% branch-aware coverage | Complete system-control regression and focused Phase 3 coverage without inheriting the repository-wide coverage source | +| 2026-07-26 | System-control, monitoring, and integration regression slice | 190 passed | Tray/process boundaries, retention execution, monitoring compatibility, reconnect, authorization projection, and schedule summaries | +| 2026-07-26 | Ruff check/format, compileall, wheel build/inventory, and `git diff --check` | passed | Wheel contains `timelocker-tray` and all new Phase 3 modules; no host state changed | +| 2026-07-26 | Expanded `system_control`, `monitoring`, and `integration` pytest run | 733 passed, 1 skipped, 2 failed, 4 setup errors | The failures are confined to repository credential integration paths outside this diff: five expect a legacy credential-file location and one cannot register S3 because optional `b2sdk` is absent. They do not invalidate the bounded Phase 3 suites but remain repository test debt. | ## T004 Review Finding Dispositions @@ -177,6 +184,19 @@ bounded to T005-T006 source, tests, packaged assets, and lifecycle artifacts. It did not install the launcher, select a live release, activate the socket, inspect real group membership, or prove platform authorization prompts. +## Phase 3 Review Finding Dispositions + +| Finding | Severity / confidence | Roles | Disposition | Validation | +|---------|-----------------------|-------|-------------|------------| +| TLR-010: the standalone tray used a predictable shared `/tmp` singleton path and did not drain GTK events | high / high | Security and Privacy; Operations and Portability; Reliability and Testing | fixed: the lock now lives in a private XDG runtime/cache directory, rejects symlinks, and the tray loop drains platform UI events | singleton, process-boundary, and tray adapter tests | +| TLR-011: the retention IPC handler returned an `ActionReceipt` object instead of the dispatcher contract's wire mapping | high / high | Python CLI Architecture; Reliability and Testing | fixed: the protected handler projects the receipt through `to_wire()` | protected request projection test | +| TLR-012: backend loss or access denial could leave stale successful state visible in the tray | medium / high | Project Steward; Security and Privacy; Operations and Portability | fixed: bounded retry/reset now replaces stale state with explicit unavailable or denied projections | backend absence, denial, reconnect, and safe-projection tests | + +No actionable Phase 3 findings remain after these dispositions. The review was +bounded to T007-T008 source, tests, packaging, and lifecycle artifacts. It did +not install a desktop-session process, connect to the live system backend, +activate production retention, or mutate host state. + ## Manual Or External Verification Live T010 evidence must record the reviewer, timestamp, exact non-secret command, @@ -217,7 +237,7 @@ package. ### Spec Cleanup Decision - **Cleanup action:** keep active until implementation, promotion, and closure -- **Reason:** no implementation evidence exists +- **Reason:** repository implementation evidence exists through T008, but live integration, durable promotion, and closure evidence remain incomplete - **Final spec commit:** pending - **Closure log path:** `docs/history/spec-closure-log.md` - **Closure log entry updated:** no @@ -248,7 +268,7 @@ protected metadata, widen privilege, interrupt backups, or delete snapshots. - **Ready for promotion:** no - **Ready for release:** no - **Ready for closure:** no -- **Ready for implementation:** yes for Phase 3 tasks T007 and T008; later +- **Ready for implementation:** yes for Phase 4 task T009; later live-host mutations still require T010 approval @@ -263,8 +283,9 @@ protected metadata, widen privilege, interrupt backups, or delete snapshots. ## Reconciliation -Reviewed against the 2026-07-26 requirements and design revisions. T001-T006 -now provide executed Phase 1-2 evidence for V1-V3, V5-V6, and repository-local +Reviewed against the 2026-07-26 requirements and design revisions. T001-T008 +now provide executed Phase 1-3 evidence for V1-V3, V5-V8, and repository-local portions of V4/V11. Real socket activation, installed ownership/modes, live NSS -behavior, authorization prompts, and host restart remain pending under -T009-T010; Phase 3, durable promotion, and closure remain incomplete. +behavior, backend composition on the host, authorization prompts, and host +restart remain pending under T009-T010; durable promotion and closure remain +incomplete. diff --git a/docs/specs/README.md b/docs/specs/README.md index 3cd087e..8b4cf8f 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -18,16 +18,17 @@ accepted content has been promoted and the package is closed. - [`009-system-cli-tray-retention`](./009-system-cli-tray-retention/requirements.md) — active implementation package; Phases 1 and 2 are complete, including contracts, storage, Linux authorization, immutable launcher/routing, and - structured system run and diagnostic CLI views. The independent tray and - retention work in Phase 3 is next. + structured system run and diagnostic CLI views. Phase 3 is complete in the + repository, covering the independent tray and retention domain work; Phase 4 + installation and live acceptance work is next. ## Active-Package Sequencing Spec 007 is closed; its release-readiness evidence and recovery commits are recorded in `docs/history/`. Spec 009 is the only active package. Its design, tasks, traceability, canonical context, and verification plan were approved, -and Phases 1 and 2 are complete. Implementation continues with the independent -Phase 3 tasks T007 and T008 before integration in T009. Repository +and Phases 1 through 3 are complete in the repository. Implementation +continues with Phase 4 task T009 before live-host acceptance in T010. Repository implementation approval does not authorize live-system mutation, rollout, or release; T010 retains the explicit host-mutation gate. Closed packages remain recorded in `docs/history/` rather than kept in this diff --git a/pyproject.toml b/pyproject.toml index 6c97899..109e49c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ gui = [ [project.scripts] timelocker = "TimeLocker.cli:main" tl = "TimeLocker.cli:main" +timelocker-tray = "TimeLocker.system_control.tray_entry:main" [project.urls] Homepage = "https://github.com/Auriora/TimeLocker" diff --git a/src/TimeLocker/cli.py b/src/TimeLocker/cli.py index 8f463df..5e6d6e1 100644 --- a/src/TimeLocker/cli.py +++ b/src/TimeLocker/cli.py @@ -1837,10 +1837,6 @@ def emit(self, record: logging.LogRecord) -> None: # Format the message message = self.format(record) - # Skip system tray warnings - these are expected in CLI-only environments - if record.levelno == logging.WARNING and "system tray" in message.lower(): - return - # Determine panel style based on log level if record.levelno >= logging.CRITICAL: title = "Critical Error" diff --git a/src/TimeLocker/monitoring/__init__.py b/src/TimeLocker/monitoring/__init__.py index f79daff..9130e0b 100644 --- a/src/TimeLocker/monitoring/__init__.py +++ b/src/TimeLocker/monitoring/__init__.py @@ -17,25 +17,19 @@ from .status_reporter import StatusReporter, OperationStatus, StatusLevel from .notification_service import ( - NotificationService, - NotificationError, + NotificationService, + NotificationError, NotificationType, NotificationEventType, NotificationPreferences, - NotificationConfig -) -from .system_tray_integration import ( - SystemTrayIntegration, - SystemTrayError, - TrayStatus, - TrayStatusInfo + NotificationConfig, ) from .progress_monitor import ( ProgressMonitor, ProgressData, ProgressReport, ProgressState, - PerformanceMetrics + PerformanceMetrics, ) from .recovery_progress_notifier import RecoveryProgressNotifier from .monitoring_service import ( @@ -44,7 +38,7 @@ BackupEvent, RecoveryEvent, MonitoringSummary, - MonitoringPreferences + MonitoringPreferences, ) from .activity_logger import ActivityLogger, LogLevel, LogEntry from .backup_history import ( @@ -52,7 +46,7 @@ BackupRecord, BackupStatus, HistoryFilters, - PerformanceTrends + PerformanceTrends, ) from .storage_monitor import ( StorageMonitor, @@ -60,7 +54,7 @@ CapacityWarning, StorageTrends, OptimizationRecommendation, - WarningLevel + WarningLevel, ) from .integrity_checker import ( IntegrityChecker, @@ -69,21 +63,21 @@ IntegrityCheckResult, IntegrityIssue, RemediationGuide, - CheckInterval + CheckInterval, ) from .performance_tracker import ( PerformanceTracker, BackupPerformanceMetrics, PerformanceTrend, PerformanceSummary, - PerformanceLevel + PerformanceLevel, ) from .performance_optimizer import ( PerformanceOptimizer, PerformanceRecommendation, PerformanceIssue, RecommendationType, - RecommendationPriority + RecommendationPriority, ) from .troubleshooting_service import ( TroubleshootingService, @@ -97,11 +91,11 @@ BackupFailure, TroubleshootingReport, EventCorrelator, - IssueDetector + IssueDetector, ) from .configuration_troubleshooter import ( ConfigurationTroubleshooter, - ConfigurationIssue + ConfigurationIssue, ) from .monitoring_dashboard import ( MonitoringDashboard, @@ -110,7 +104,7 @@ BackupHistoryWidget, StorageUsageWidget, PerformanceTrendsWidget, - TroubleshootingWidget + TroubleshootingWidget, ) from .webhook_handler import ( WebhookHandler, @@ -118,7 +112,7 @@ WebhookResult, WebhookError, PayloadFormat, - RetryHandler + RetryHandler, ) from .health_check_integration import ( HealthCheckIntegration, @@ -127,38 +121,94 @@ HealthCheckServiceType, HealthStatus as HealthCheckHealthStatus, PingResult, - HealthCheckError + HealthCheckError, ) __all__ = [ - 'StatusReporter', 'OperationStatus', 'StatusLevel', - 'NotificationService', 'NotificationError', 'NotificationType', - 'NotificationEventType', 'NotificationPreferences', 'NotificationConfig', - 'SystemTrayIntegration', 'SystemTrayError', 'TrayStatus', 'TrayStatusInfo', - 'ProgressMonitor', 'ProgressData', 'ProgressReport', 'ProgressState', 'PerformanceMetrics', - 'RecoveryProgressNotifier', - 'MonitoringService', 'HealthStatus', 'BackupEvent', 'RecoveryEvent', - 'MonitoringSummary', 'MonitoringPreferences', - 'ActivityLogger', 'LogLevel', 'LogEntry', - 'BackupHistory', 'BackupRecord', 'BackupStatus', 'HistoryFilters', 'PerformanceTrends', - 'StorageMonitor', 'StorageUsage', 'CapacityWarning', 'StorageTrends', - 'OptimizationRecommendation', 'WarningLevel', - 'IntegrityChecker', 'IntegrityLevel', 'IntegrityStatus', 'IntegrityCheckResult', - 'IntegrityIssue', 'RemediationGuide', 'CheckInterval', - 'PerformanceTracker', 'BackupPerformanceMetrics', 'PerformanceTrend', - 'PerformanceSummary', 'PerformanceLevel', - 'PerformanceOptimizer', 'PerformanceRecommendation', 'PerformanceIssue', - 'RecommendationType', 'RecommendationPriority', - 'TroubleshootingService', 'IssueType', 'IssueSeverity', 'DetectedIssue', - 'TroubleshootingStep', 'TroubleshootingGuide', 'EventCorrelation', - 'ProactiveRecommendation', 'BackupFailure', 'TroubleshootingReport', - 'EventCorrelator', 'IssueDetector', - 'ConfigurationTroubleshooter', 'ConfigurationIssue', - 'MonitoringDashboard', 'WidgetType', 'HealthOverviewWidget', - 'BackupHistoryWidget', 'StorageUsageWidget', 'PerformanceTrendsWidget', - 'TroubleshootingWidget', - 'WebhookHandler', 'WebhookConfig', 'WebhookResult', 'WebhookError', - 'PayloadFormat', 'RetryHandler', - 'HealthCheckIntegration', 'HealthCheckConfig', 'HealthCheckServiceConfig', - 'HealthCheckServiceType', 'HealthCheckHealthStatus', 'PingResult', 'HealthCheckError' + "StatusReporter", + "OperationStatus", + "StatusLevel", + "NotificationService", + "NotificationError", + "NotificationType", + "NotificationEventType", + "NotificationPreferences", + "NotificationConfig", + "ProgressMonitor", + "ProgressData", + "ProgressReport", + "ProgressState", + "PerformanceMetrics", + "RecoveryProgressNotifier", + "MonitoringService", + "HealthStatus", + "BackupEvent", + "RecoveryEvent", + "MonitoringSummary", + "MonitoringPreferences", + "ActivityLogger", + "LogLevel", + "LogEntry", + "BackupHistory", + "BackupRecord", + "BackupStatus", + "HistoryFilters", + "PerformanceTrends", + "StorageMonitor", + "StorageUsage", + "CapacityWarning", + "StorageTrends", + "OptimizationRecommendation", + "WarningLevel", + "IntegrityChecker", + "IntegrityLevel", + "IntegrityStatus", + "IntegrityCheckResult", + "IntegrityIssue", + "RemediationGuide", + "CheckInterval", + "PerformanceTracker", + "BackupPerformanceMetrics", + "PerformanceTrend", + "PerformanceSummary", + "PerformanceLevel", + "PerformanceOptimizer", + "PerformanceRecommendation", + "PerformanceIssue", + "RecommendationType", + "RecommendationPriority", + "TroubleshootingService", + "IssueType", + "IssueSeverity", + "DetectedIssue", + "TroubleshootingStep", + "TroubleshootingGuide", + "EventCorrelation", + "ProactiveRecommendation", + "BackupFailure", + "TroubleshootingReport", + "EventCorrelator", + "IssueDetector", + "ConfigurationTroubleshooter", + "ConfigurationIssue", + "MonitoringDashboard", + "WidgetType", + "HealthOverviewWidget", + "BackupHistoryWidget", + "StorageUsageWidget", + "PerformanceTrendsWidget", + "TroubleshootingWidget", + "WebhookHandler", + "WebhookConfig", + "WebhookResult", + "WebhookError", + "PayloadFormat", + "RetryHandler", + "HealthCheckIntegration", + "HealthCheckConfig", + "HealthCheckServiceConfig", + "HealthCheckServiceType", + "HealthCheckHealthStatus", + "PingResult", + "HealthCheckError", ] diff --git a/src/TimeLocker/monitoring/notification_service.py b/src/TimeLocker/monitoring/notification_service.py index c8964b4..b2c00c4 100644 --- a/src/TimeLocker/monitoring/notification_service.py +++ b/src/TimeLocker/monitoring/notification_service.py @@ -24,12 +24,11 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from pathlib import Path -from typing import Dict, List, Optional, Any, Callable +from typing import Dict, List, Optional, Callable from dataclasses import dataclass from enum import Enum from .status_reporter import OperationStatus, StatusLevel -from .system_tray_integration import SystemTrayIntegration, TrayStatus, TrayStatusInfo from ..interfaces.service_interface import ServiceInterface from ..interfaces.integration_data_models import ServiceContext @@ -38,11 +37,13 @@ class NotificationError(Exception): """Base exception for notification-related errors""" + pass class NotificationType(Enum): """Types of notifications""" + DESKTOP = "desktop" EMAIL = "email" LOG = "log" @@ -50,6 +51,7 @@ class NotificationType(Enum): class NotificationEventType(Enum): """Types of events that can trigger notifications""" + BACKUP_STARTED = "backup_started" BACKUP_COMPLETED = "backup_completed" BACKUP_FAILED = "backup_failed" @@ -65,6 +67,7 @@ class NotificationEventType(Enum): @dataclass class NotificationPreferences: """User preferences for notifications""" + enabled_event_types: List[str] = None desktop_notification_enabled: bool = True desktop_notification_sound: bool = True @@ -74,7 +77,7 @@ class NotificationPreferences: quiet_hours_enabled: bool = False quiet_hours_start: str = "22:00" quiet_hours_end: str = "08:00" - + def __post_init__(self): if self.enabled_event_types is None: # Default to all event types @@ -84,6 +87,7 @@ def __post_init__(self): @dataclass class NotificationConfig: """Configuration for notifications""" + enabled: bool = True desktop_enabled: bool = True email_enabled: bool = False @@ -97,7 +101,9 @@ class NotificationConfig: notify_on_warning: bool = True notify_on_error: bool = True notify_on_critical: bool = True - min_operation_duration: int = 60 # Only notify for operations longer than this (seconds) + min_operation_duration: int = ( + 60 # Only notify for operations longer than this (seconds) + ) preferences: Optional[NotificationPreferences] = None def __post_init__(self): @@ -116,41 +122,35 @@ class NotificationService(ServiceInterface): def __init__( self, config_dir: Optional[Path] = None, - desktop_notification_sender: Optional[Callable[[str, str, StatusLevel], None]] = None, + desktop_notification_sender: Optional[ + Callable[[str, str, StatusLevel], None] + ] = None, force_desktop_notifications: bool = False, ) -> None: """ Initialize notification service - + Args: config_dir: Directory for notification configuration """ if config_dir is None: # Use centralized path resolver for XDG compliance from ..config.configuration_path_resolver import ConfigurationPathResolver - config_dir = ConfigurationPathResolver.get_config_directory() / "notifications" + + config_dir = ( + ConfigurationPathResolver.get_config_directory() / "notifications" + ) self.config_dir = Path(config_dir) self.config_dir.mkdir(parents=True, exist_ok=True) self.config_file = self.config_dir / "notification_config.json" self.config = self._load_config() - self._desktop_notification_sender = desktop_notification_sender or self._platform_desktop_notification_sender + self._desktop_notification_sender = ( + desktop_notification_sender or self._platform_desktop_notification_sender + ) self._force_desktop_notifications = force_desktop_notifications - - # System tray integration - self.system_tray: Optional[SystemTrayIntegration] = None - try: - self.system_tray = SystemTrayIntegration() - if self.system_tray.is_available(): - logger.info("System tray integration initialized") - else: - logger.info("System tray not available on this platform") - self.system_tray = None - except Exception as e: - logger.warning(f"Failed to initialize system tray: {e}") - self.system_tray = None - + # ServiceInterface implementation self._context: Optional[ServiceContext] = None self._initialized = False @@ -159,10 +159,10 @@ def __init__( def initialize(self, context: ServiceContext) -> bool: """ Initialize the notification service with the provided context. - + Args: context: ServiceContext containing configuration and runtime information - + Returns: bool: True if initialization was successful, False otherwise """ @@ -170,14 +170,14 @@ def initialize(self, context: ServiceContext) -> bool: if not self.validate_context(context): logger.error("Invalid service context provided to NotificationService") return False - + self._context = context - + # Initialize any context-dependent components logger.info("NotificationService initialized successfully") self._initialized = True return True - + except Exception as e: logger.error(f"Failed to initialize NotificationService: {e}") return False @@ -191,27 +191,22 @@ def shutdown(self) -> None: try: self.save_config() except Exception as e: - logger.warning(f"Failed to save notification config during shutdown: {e}") - - # Shutdown system tray - if self.system_tray: - try: - self.system_tray.shutdown() - except Exception as e: - logger.warning(f"Failed to shutdown system tray: {e}") - + logger.warning( + f"Failed to save notification config during shutdown: {e}" + ) + # Clean up resources self._context = None self._initialized = False logger.info("NotificationService shutdown completed") - + except Exception as e: logger.error(f"Error during NotificationService shutdown: {e}") def health_check(self) -> bool: """ Check the health status of the notification service. - + Returns: bool: True if the service is healthy and operational, False otherwise """ @@ -219,17 +214,17 @@ def health_check(self) -> bool: # Check if service is initialized if not self._initialized: return False - + # Check if config directory is accessible if not self.config_dir.exists(): return False - + # Check if configuration is valid if not self.config: return False - + return True - + except Exception as e: logger.error(f"NotificationService health check failed: {e}") return False @@ -237,27 +232,29 @@ def health_check(self) -> bool: def get_capabilities(self) -> List[str]: """ Get the list of capabilities provided by this service. - + Returns: List[str]: List of capability identifiers """ return [ - 'desktop_notifications', - 'email_notifications', - 'log_notifications', - 'notification_testing', - 'notification_config' + "desktop_notifications", + "email_notifications", + "log_notifications", + "notification_testing", + "notification_config", ] def _load_config(self) -> NotificationConfig: """Load notification configuration from file""" try: if self.config_file.exists(): - with open(self.config_file, 'r') as f: + with open(self.config_file, "r") as f: data = json.load(f) # Handle preferences separately - if 'preferences' in data and isinstance(data['preferences'], dict): - data['preferences'] = NotificationPreferences(**data['preferences']) + if "preferences" in data and isinstance(data["preferences"], dict): + data["preferences"] = NotificationPreferences( + **data["preferences"] + ) return NotificationConfig(**data) except Exception as e: logger.warning(f"Failed to load notification config: {e}") @@ -268,34 +265,34 @@ def _load_config(self) -> NotificationConfig: def save_config(self): """Save current configuration to file""" try: - with open(self.config_file, 'w') as f: + with open(self.config_file, "w") as f: # Convert dataclass to dict, handling the email_to list and preferences config_dict = { - 'enabled': self.config.enabled, - 'desktop_enabled': self.config.desktop_enabled, - 'email_enabled': self.config.email_enabled, - 'email_smtp_server': self.config.email_smtp_server, - 'email_smtp_port': self.config.email_smtp_port, - 'email_username': self.config.email_username, - 'email_password': self.config.email_password, - 'email_from': self.config.email_from, - 'email_to': self.config.email_to, - 'notify_on_success': self.config.notify_on_success, - 'notify_on_warning': self.config.notify_on_warning, - 'notify_on_error': self.config.notify_on_error, - 'notify_on_critical': self.config.notify_on_critical, - 'min_operation_duration': self.config.min_operation_duration, - 'preferences': { - 'enabled_event_types': self.config.preferences.enabled_event_types, - 'desktop_notification_enabled': self.config.preferences.desktop_notification_enabled, - 'desktop_notification_sound': self.config.preferences.desktop_notification_sound, - 'desktop_notification_persistence': self.config.preferences.desktop_notification_persistence, - 'email_notification_enabled': self.config.preferences.email_notification_enabled, - 'fallback_to_log': self.config.preferences.fallback_to_log, - 'quiet_hours_enabled': self.config.preferences.quiet_hours_enabled, - 'quiet_hours_start': self.config.preferences.quiet_hours_start, - 'quiet_hours_end': self.config.preferences.quiet_hours_end, - } + "enabled": self.config.enabled, + "desktop_enabled": self.config.desktop_enabled, + "email_enabled": self.config.email_enabled, + "email_smtp_server": self.config.email_smtp_server, + "email_smtp_port": self.config.email_smtp_port, + "email_username": self.config.email_username, + "email_password": self.config.email_password, + "email_from": self.config.email_from, + "email_to": self.config.email_to, + "notify_on_success": self.config.notify_on_success, + "notify_on_warning": self.config.notify_on_warning, + "notify_on_error": self.config.notify_on_error, + "notify_on_critical": self.config.notify_on_critical, + "min_operation_duration": self.config.min_operation_duration, + "preferences": { + "enabled_event_types": self.config.preferences.enabled_event_types, + "desktop_notification_enabled": self.config.preferences.desktop_notification_enabled, + "desktop_notification_sound": self.config.preferences.desktop_notification_sound, + "desktop_notification_persistence": self.config.preferences.desktop_notification_persistence, + "email_notification_enabled": self.config.preferences.email_notification_enabled, + "fallback_to_log": self.config.preferences.fallback_to_log, + "quiet_hours_enabled": self.config.preferences.quiet_hours_enabled, + "quiet_hours_start": self.config.preferences.quiet_hours_start, + "quiet_hours_end": self.config.preferences.quiet_hours_end, + }, } json.dump(config_dict, f, indent=2) except Exception as e: @@ -308,11 +305,11 @@ def update_config(self, **kwargs): if hasattr(self.config, key): setattr(self.config, key, value) self.save_config() - + def update_preferences(self, **kwargs): """ Update notification preferences - + Args: **kwargs: Preference key-value pairs to update """ @@ -320,35 +317,38 @@ def update_preferences(self, **kwargs): if hasattr(self.config.preferences, key): setattr(self.config.preferences, key, value) self.save_config() - + def is_event_type_enabled(self, event_type: str) -> bool: """ Check if a specific event type is enabled for notifications - + Args: event_type: Event type to check - + Returns: bool: True if event type is enabled """ return event_type in self.config.preferences.enabled_event_types - + def is_in_quiet_hours(self) -> bool: """ Check if current time is within quiet hours - + Returns: bool: True if in quiet hours """ if not self.config.preferences.quiet_hours_enabled: return False - + try: - from datetime import time now = datetime.now().time() - start = datetime.strptime(self.config.preferences.quiet_hours_start, "%H:%M").time() - end = datetime.strptime(self.config.preferences.quiet_hours_end, "%H:%M").time() - + start = datetime.strptime( + self.config.preferences.quiet_hours_start, "%H:%M" + ).time() + end = datetime.strptime( + self.config.preferences.quiet_hours_end, "%H:%M" + ).time() + # Handle quiet hours that span midnight if start <= end: return start <= now <= end @@ -361,10 +361,10 @@ def is_in_quiet_hours(self) -> bool: def should_notify(self, status: OperationStatus) -> bool: """ Determine if a notification should be sent for the given status - + Args: status: Operation status to check - + Returns: bool: True if notification should be sent """ @@ -373,20 +373,20 @@ def should_notify(self, status: OperationStatus) -> bool: # Check if we should notify for this status level status_checks = { - StatusLevel.SUCCESS: self.config.notify_on_success, - StatusLevel.WARNING: self.config.notify_on_warning, - StatusLevel.ERROR: self.config.notify_on_error, - StatusLevel.CRITICAL: self.config.notify_on_critical, - StatusLevel.INFO: False # Don't notify for info messages + StatusLevel.SUCCESS: self.config.notify_on_success, + StatusLevel.WARNING: self.config.notify_on_warning, + StatusLevel.ERROR: self.config.notify_on_error, + StatusLevel.CRITICAL: self.config.notify_on_critical, + StatusLevel.INFO: False, # Don't notify for info messages } if not status_checks.get(status.status, False): return False # Check minimum operation duration if we have start time - if status.metadata and 'start_time' in status.metadata: + if status.metadata and "start_time" in status.metadata: try: - start_time = datetime.fromisoformat(status.metadata['start_time']) + start_time = datetime.fromisoformat(status.metadata["start_time"]) duration = (status.timestamp - start_time).total_seconds() if duration < self.config.min_operation_duration: return False @@ -395,17 +395,18 @@ def should_notify(self, status: OperationStatus) -> bool: return True - def send_notification(self, status: OperationStatus, notification_types: Optional[List[NotificationType]] = None): + def send_notification( + self, + status: OperationStatus, + notification_types: Optional[List[NotificationType]] = None, + ): """ Send notification for an operation status - + Args: status: Operation status to notify about notification_types: Types of notifications to send (default: all enabled) """ - # Update system tray status - self._update_system_tray_from_status(status) - if not self.should_notify(status): return @@ -427,89 +428,19 @@ def send_notification(self, status: OperationStatus, notification_types: Optiona elif notification_type == NotificationType.LOG: self._log_notification(title, message, status) except Exception as e: - logger.error(f"Failed to send {notification_type.value} notification: {e}") - - def _update_system_tray_from_status(self, status: OperationStatus): - """ - Update system tray based on operation status - - Args: - status: Operation status - """ - if not self.system_tray or not self.system_tray.is_available(): - return - - # Map status level to tray status - status_map = { - StatusLevel.SUCCESS: TrayStatus.SUCCESS, - StatusLevel.WARNING: TrayStatus.WARNING, - StatusLevel.ERROR: TrayStatus.ERROR, - StatusLevel.CRITICAL: TrayStatus.ERROR, - StatusLevel.INFO: TrayStatus.RUNNING - } - - tray_status = status_map.get(status.status, TrayStatus.IDLE) - - # Create status info - status_info = TrayStatusInfo( - status=tray_status, - tooltip=f"{status.operation_type.title()}: {status.message}", - last_backup_time=status.timestamp, - last_backup_status=status.status.value, - repository_count=1 if status.repository_id else 0, - active_operations=1 if status.progress_percentage and status.progress_percentage < 100 else 0 - ) - - try: - self.system_tray.update_status_info(status_info) - except Exception as e: - logger.error(f"Failed to update system tray: {e}") - - def update_system_tray_status(self, status: TrayStatus, tooltip: Optional[str] = None): - """ - Manually update system tray status - - Args: - status: Tray status - tooltip: Optional tooltip text - """ - if not self.system_tray or not self.system_tray.is_available(): - return - - try: - self.system_tray.update_status(status, tooltip) - except Exception as e: - logger.error(f"Failed to update system tray status: {e}") - - def set_system_tray_callbacks(self, on_click: Optional[Callable] = None, - on_menu_action: Optional[Callable[[str], None]] = None): - """ - Set system tray callbacks - - Args: - on_click: Callback for tray icon click - on_menu_action: Callback for menu actions - """ - if not self.system_tray or not self.system_tray.is_available(): - return - - try: - if on_click: - self.system_tray.set_on_click_callback(on_click) - if on_menu_action: - self.system_tray.set_on_menu_action_callback(on_menu_action) - except Exception as e: - logger.error(f"Failed to set system tray callbacks: {e}") + logger.error( + f"Failed to send {notification_type.value} notification: {e}" + ) def _format_notification(self, status: OperationStatus) -> tuple[str, str]: """Format notification title and message""" # Create title status_emoji = { - StatusLevel.SUCCESS: "✅", - StatusLevel.WARNING: "⚠️", - StatusLevel.ERROR: "❌", - StatusLevel.CRITICAL: "🚨", - StatusLevel.INFO: "ℹ️" + StatusLevel.SUCCESS: "✅", + StatusLevel.WARNING: "⚠️", + StatusLevel.ERROR: "❌", + StatusLevel.CRITICAL: "🚨", + StatusLevel.INFO: "ℹ️", } emoji = status_emoji.get(status.status, "") @@ -525,7 +456,9 @@ def _format_notification(self, status: OperationStatus) -> tuple[str, str]: message_parts.append(f"Progress: {status.progress_percentage}%") if status.files_processed is not None and status.total_files is not None: - message_parts.append(f"Files: {status.files_processed}/{status.total_files}") + message_parts.append( + f"Files: {status.files_processed}/{status.total_files}" + ) if status.bytes_processed is not None: size_mb = status.bytes_processed / (1024 * 1024) @@ -535,30 +468,38 @@ def _format_notification(self, status: OperationStatus) -> tuple[str, str]: return title, "\n".join(message_parts) - def _send_desktop_notification(self, title: str, message: str, status_level: StatusLevel): + def _send_desktop_notification( + self, title: str, message: str, status_level: StatusLevel + ): """ Send desktop notification with fallback mechanisms - + Args: title: Notification title message: Notification message status_level: Status level for urgency """ # Check if desktop notifications are enabled in preferences - if not (self.config.preferences.desktop_notification_enabled or self._force_desktop_notifications): + if not ( + self.config.preferences.desktop_notification_enabled + or self._force_desktop_notifications + ): logger.debug("Desktop notifications disabled in preferences") if self.config.preferences.fallback_to_log: self._fallback_to_log(title, message, status_level) return - + # Check quiet hours (skip when forced for test adapters) - if self.config.preferences.quiet_hours_enabled and not self._force_desktop_notifications: + if ( + self.config.preferences.quiet_hours_enabled + and not self._force_desktop_notifications + ): if self.is_in_quiet_hours(): logger.debug("Skipping notification during quiet hours") if self.config.preferences.fallback_to_log: self._fallback_to_log(title, message, status_level) return - + try: self._desktop_notification_sender(title, message, status_level) except NotificationError as e: @@ -570,7 +511,9 @@ def _send_desktop_notification(self, title: str, message: str, status_level: Sta if self.config.preferences.fallback_to_log: self._fallback_to_log(title, message, status_level) - def _platform_desktop_notification_sender(self, title: str, message: str, status_level: StatusLevel) -> None: + def _platform_desktop_notification_sender( + self, title: str, message: str, status_level: StatusLevel + ) -> None: """Send notifications using platform-specific mechanisms.""" try: if sys.platform == "linux": @@ -580,16 +523,18 @@ def _platform_desktop_notification_sender(self, title: str, message: str, status elif sys.platform == "win32": self._send_windows_notification(title, message) else: - raise NotificationError(f"Desktop notifications not supported on {sys.platform}") + raise NotificationError( + f"Desktop notifications not supported on {sys.platform}" + ) except NotificationError: raise except Exception as exc: raise NotificationError(str(exc)) from exc - + def _fallback_to_log(self, title: str, message: str, status_level: StatusLevel): """ Fallback mechanism when desktop notifications are unavailable - + Args: title: Notification title message: Notification message @@ -600,41 +545,53 @@ def _fallback_to_log(self, title: str, message: str, status_level: StatusLevel): StatusLevel.WARNING: logging.WARNING, StatusLevel.ERROR: logging.ERROR, StatusLevel.CRITICAL: logging.CRITICAL, - StatusLevel.INFO: logging.INFO + StatusLevel.INFO: logging.INFO, } - + log_level = log_level_map.get(status_level, logging.INFO) logger.log(log_level, f"[NOTIFICATION] {title}: {message}") - def _send_linux_notification(self, title: str, message: str, status_level: StatusLevel): + def _send_linux_notification( + self, title: str, message: str, status_level: StatusLevel + ): """ Send notification on Linux using notify-send - + Args: title: Notification title message: Notification message status_level: Status level for urgency """ urgency_map = { - StatusLevel.SUCCESS: "normal", - StatusLevel.WARNING: "normal", - StatusLevel.ERROR: "critical", - StatusLevel.CRITICAL: "critical", - StatusLevel.INFO: "low" + StatusLevel.SUCCESS: "normal", + StatusLevel.WARNING: "normal", + StatusLevel.ERROR: "critical", + StatusLevel.CRITICAL: "critical", + StatusLevel.INFO: "low", } urgency = urgency_map.get(status_level, "normal") - + # Build command with preferences cmd = [ "notify-send", - "--urgency", urgency, - "--app-name", "TimeLocker", - "--expire-time", str(self.config.preferences.desktop_notification_persistence * 1000) # milliseconds + "--urgency", + urgency, + "--app-name", + "TimeLocker", + "--expire-time", + str( + self.config.preferences.desktop_notification_persistence * 1000 + ), # milliseconds ] - + # Try to use TimeLocker logo icon first, fallback to system icons - logo_path = Path(__file__).parent.parent.parent.parent / "resources" / "images" / "TimeLocker-Logo-Icon-Color-White.png" + logo_path = ( + Path(__file__).parent.parent.parent.parent + / "resources" + / "images" + / "TimeLocker-Logo-Icon-Color-White.png" + ) if logo_path.exists(): cmd.extend(["--icon", str(logo_path)]) else: @@ -644,10 +601,10 @@ def _send_linux_notification(self, title: str, message: str, status_level: Statu StatusLevel.WARNING: "dialog-warning", StatusLevel.ERROR: "dialog-error", StatusLevel.CRITICAL: "dialog-error", - StatusLevel.INFO: "dialog-information" + StatusLevel.INFO: "dialog-information", } cmd.extend(["--icon", icon_map.get(status_level, "dialog-information")]) - + cmd.extend([title, message]) subprocess.run(cmd, check=True) @@ -655,7 +612,7 @@ def _send_linux_notification(self, title: str, message: str, status_level: Statu def _send_macos_notification(self, title: str, message: str): """ Send notification on macOS using osascript - + Args: title: Notification title message: Notification message @@ -663,17 +620,17 @@ def _send_macos_notification(self, title: str, message: str): # Escape quotes for AppleScript escaped_title = title.replace('"', '\\"') escaped_message = message.replace('"', '\\"') - + # Note: macOS notifications use the app bundle icon automatically # For terminal-run scripts, we can't easily set a custom icon via osascript # The icon would need to be set at the app bundle level or via a native app - + # Build script with sound preference if self.config.preferences.desktop_notification_sound: script = f'''display notification "{escaped_message}" with title "{escaped_title}" sound name "default"''' else: script = f'''display notification "{escaped_message}" with title "{escaped_title}"''' - + subprocess.run(["osascript", "-e", script], check=True) def _send_windows_notification(self, title: str, message: str): @@ -682,10 +639,15 @@ def _send_windows_notification(self, title: str, message: str): # Escape quotes and special characters for PowerShell escaped_title = title.replace('"', '""').replace("'", "''") escaped_message = message.replace('"', '""').replace("'", "''") - + # Get logo path - logo_path = Path(__file__).parent.parent.parent.parent / "resources" / "images" / "TimeLocker-Logo-Icon-Color-White.png" - escaped_logo_path = str(logo_path).replace('\\', '\\\\').replace('"', '""') + logo_path = ( + Path(__file__).parent.parent.parent.parent + / "resources" + / "images" + / "TimeLocker-Logo-Icon-Color-White.png" + ) + escaped_logo_path = str(logo_path).replace("\\", "\\\\").replace('"', '""') # Use a more robust PowerShell approach with proper error handling and custom icon script = f''' @@ -724,7 +686,7 @@ def _send_windows_notification(self, title: str, message: str): check=False, # Don't raise exception on non-zero exit capture_output=True, text=True, - timeout=10 # 10 second timeout + timeout=10, # 10 second timeout ) if result.returncode != 0: @@ -737,7 +699,9 @@ def _send_windows_notification(self, title: str, message: str): except Exception as e: logger.warning(f"Windows notification failed: {e}") - def _send_email_notification(self, title: str, message: str, status: OperationStatus): + def _send_email_notification( + self, title: str, message: str, status: OperationStatus + ): """Send email notification""" if not self.config.email_enabled or not self.config.email_to: return @@ -745,16 +709,18 @@ def _send_email_notification(self, title: str, message: str, status: OperationSt try: # Create message msg = MIMEMultipart() - msg['From'] = self.config.email_from or self.config.email_username - msg['To'] = ', '.join(self.config.email_to) - msg['Subject'] = title + msg["From"] = self.config.email_from or self.config.email_username + msg["To"] = ", ".join(self.config.email_to) + msg["Subject"] = title # Create HTML body html_body = self._create_email_html(status, message) - msg.attach(MIMEText(html_body, 'html')) + msg.attach(MIMEText(html_body, "html")) # Send email - with smtplib.SMTP(self.config.email_smtp_server, self.config.email_smtp_port) as server: + with smtplib.SMTP( + self.config.email_smtp_server, self.config.email_smtp_port + ) as server: server.starttls() if self.config.email_username and self.config.email_password: server.login(self.config.email_username, self.config.email_password) @@ -769,11 +735,11 @@ def _send_email_notification(self, title: str, message: str, status: OperationSt def _create_email_html(self, status: OperationStatus, message: str) -> str: """Create HTML email body""" status_colors = { - StatusLevel.SUCCESS: "#28a745", - StatusLevel.WARNING: "#ffc107", - StatusLevel.ERROR: "#dc3545", - StatusLevel.CRITICAL: "#dc3545", - StatusLevel.INFO: "#17a2b8" + StatusLevel.SUCCESS: "#28a745", + StatusLevel.WARNING: "#ffc107", + StatusLevel.ERROR: "#dc3545", + StatusLevel.CRITICAL: "#dc3545", + StatusLevel.INFO: "#17a2b8", } color = status_colors.get(status.status, "#6c757d") @@ -786,10 +752,10 @@ def _create_email_html(self, status: OperationStatus, message: str) -> str: TimeLocker {status.operation_type.title()} - {status.status.value.title()}

Message: {status.message}

-

Time: {status.timestamp.strftime('%Y-%m-%d %H:%M:%S')}

- {f'

Repository: {status.repository_id}

' if status.repository_id else ''} - {f'

Progress: {status.progress_percentage}%

' if status.progress_percentage is not None else ''} - {f'

Files Processed: {status.files_processed}/{status.total_files}

' if status.files_processed is not None and status.total_files is not None else ''} +

Time: {status.timestamp.strftime("%Y-%m-%d %H:%M:%S")}

+ {f"

Repository: {status.repository_id}

" if status.repository_id else ""} + {f"

Progress: {status.progress_percentage}%

" if status.progress_percentage is not None else ""} + {f"

Files Processed: {status.files_processed}/{status.total_files}

" if status.files_processed is not None and status.total_files is not None else ""}

@@ -802,34 +768,34 @@ def _create_email_html(self, status: OperationStatus, message: str) -> str: def _log_notification(self, title: str, message: str, status: OperationStatus): """Log notification to file""" log_entry = { - "timestamp": datetime.now().isoformat(), - "title": title, - "message": message, - "status": status.to_dict() + "timestamp": datetime.now().isoformat(), + "title": title, + "message": message, + "status": status.to_dict(), } notification_log = self.config_dir / "notifications.log" try: - with open(notification_log, 'a') as f: - f.write(json.dumps(log_entry) + '\n') + with open(notification_log, "a") as f: + f.write(json.dumps(log_entry) + "\n") except Exception as e: logger.error(f"Failed to log notification: {e}") def test_notifications(self) -> Dict[str, bool]: """ Test notification systems - + Returns: Dict with test results for each notification type """ results = {} test_status = OperationStatus( - operation_id="test", - operation_type="test", - status=StatusLevel.SUCCESS, - message="This is a test notification from TimeLocker", - timestamp=datetime.now() + operation_id="test", + operation_type="test", + status=StatusLevel.SUCCESS, + message="This is a test notification from TimeLocker", + timestamp=datetime.now(), ) # Test desktop notification @@ -837,20 +803,20 @@ def test_notifications(self) -> Dict[str, bool]: try: title, message = self._format_notification(test_status) self._send_desktop_notification(title, message, test_status.status) - results['desktop'] = True + results["desktop"] = True except Exception as e: logger.error(f"Desktop notification test failed: {e}") - results['desktop'] = False + results["desktop"] = False # Test email notification if self.config.email_enabled: try: title, message = self._format_notification(test_status) self._send_email_notification(title, message, test_status) - results['email'] = True + results["email"] = True except Exception as e: logger.error(f"Email notification test failed: {e}") - results['email'] = False + results["email"] = False return results @@ -873,7 +839,7 @@ def notify(self, title: str, message: str, level: str = "info") -> bool: "success": StatusLevel.SUCCESS, "warning": StatusLevel.WARNING, "error": StatusLevel.ERROR, - "critical": StatusLevel.CRITICAL + "critical": StatusLevel.CRITICAL, } status_level = level_map.get(level.lower(), StatusLevel.INFO) @@ -884,7 +850,7 @@ def notify(self, title: str, message: str, level: str = "info") -> bool: operation_type="notification", status=status_level, message=message, - timestamp=datetime.now() + timestamp=datetime.now(), ) self.send_notification(status) diff --git a/src/TimeLocker/monitoring/system_tray_integration.py b/src/TimeLocker/monitoring/system_tray_integration.py index 58d4d69..55742a3 100644 --- a/src/TimeLocker/monitoring/system_tray_integration.py +++ b/src/TimeLocker/monitoring/system_tray_integration.py @@ -22,8 +22,7 @@ import threading from datetime import datetime from enum import Enum -from pathlib import Path -from typing import Optional, Callable, Dict, Any +from typing import Optional, Callable, Any from dataclasses import dataclass logger = logging.getLogger(__name__) @@ -42,16 +41,16 @@ def _load_linux_tray_modules(): raise SystemTrayError("PyGObject is not installed") from exc try: - gi.require_version('Gtk', '3.0') - gtk = importlib.import_module('gi.repository.Gtk') + gi.require_version("Gtk", "3.0") + gtk = importlib.import_module("gi.repository.Gtk") except (ImportError, ValueError) as exc: raise SystemTrayError("GTK 3 is not available") from exc errors = [] - for namespace in ('AyatanaAppIndicator3', 'AppIndicator3'): + for namespace in ("AyatanaAppIndicator3", "AppIndicator3"): try: - gi.require_version(namespace, '0.1') - indicator = importlib.import_module(f'gi.repository.{namespace}') + gi.require_version(namespace, "0.1") + indicator = importlib.import_module(f"gi.repository.{namespace}") return gtk, indicator, namespace except (ImportError, ValueError) as exc: errors.append(f"{namespace}: {exc}") @@ -65,11 +64,13 @@ def _load_linux_tray_modules(): class SystemTrayError(Exception): """Base exception for system tray errors""" + pass class TrayStatus(Enum): """System tray status indicators""" + IDLE = "idle" RUNNING = "running" SUCCESS = "success" @@ -80,6 +81,7 @@ class TrayStatus(Enum): @dataclass class TrayStatusInfo: """Information displayed in system tray""" + status: TrayStatus tooltip: str last_backup_time: Optional[datetime] = None @@ -92,40 +94,39 @@ class SystemTrayIntegration: """ System tray integration for TimeLocker Provides always-visible status information and quick actions - + Features: - Status indicator icons (idle, running, success, error) - Tooltip with last backup status - Context menu with quick actions - Click-to-open main interface """ - + def __init__(self, app_name: str = "TimeLocker"): """ Initialize system tray integration - + Args: app_name: Application name for tray icon """ self.app_name = app_name self.current_status = TrayStatus.IDLE self.status_info = TrayStatusInfo( - status=TrayStatus.IDLE, - tooltip="TimeLocker - No recent activity" + status=TrayStatus.IDLE, tooltip="TimeLocker - No recent activity" ) - + # Platform-specific implementation self._tray_impl: Optional[Any] = None self._initialized = False self._lock = threading.Lock() - + # Callbacks self._on_click_callback: Optional[Callable] = None self._on_menu_action_callback: Optional[Callable[[str], None]] = None - + # Initialize platform-specific tray self._initialize_platform_tray() - + def _initialize_platform_tray(self): """Initialize platform-specific system tray implementation""" try: @@ -143,27 +144,27 @@ def _initialize_platform_tray(self): else: logger.warning(f"System tray not supported on {sys.platform}") return - + self._initialized = True logger.info(f"System tray initialized for {sys.platform}") - + except Exception as e: logger.warning(f"Failed to initialize system tray: {e}") self._initialized = False - + def is_available(self) -> bool: """ Check if system tray is available - + Returns: bool: True if system tray is available """ return self._initialized and self._tray_impl is not None - + def update_status(self, status: TrayStatus, tooltip: Optional[str] = None): """ Update system tray status - + Args: status: New status tooltip: Optional tooltip text @@ -171,99 +172,107 @@ def update_status(self, status: TrayStatus, tooltip: Optional[str] = None): if not self.is_available(): logger.debug("System tray not available, skipping status update") return - + with self._lock: self.current_status = status self.status_info.status = status - + if tooltip: self.status_info.tooltip = tooltip - + try: self._tray_impl.update_icon(status) self._tray_impl.update_tooltip(self.status_info.tooltip) except Exception as e: logger.error(f"Failed to update system tray status: {e}") - + def update_status_info(self, status_info: TrayStatusInfo): """ Update complete status information - + Args: status_info: Complete status information """ if not self.is_available(): return - + with self._lock: self.status_info = status_info self.current_status = status_info.status - + try: self._tray_impl.update_icon(status_info.status) self._tray_impl.update_tooltip(self._format_tooltip(status_info)) except Exception as e: logger.error(f"Failed to update system tray info: {e}") - + def _format_tooltip(self, status_info: TrayStatusInfo) -> str: """ Format tooltip text from status info - + Args: status_info: Status information - + Returns: str: Formatted tooltip text """ lines = [f"{self.app_name} - {status_info.status.value.title()}"] - + if status_info.last_backup_time: time_str = status_info.last_backup_time.strftime("%Y-%m-%d %H:%M") lines.append(f"Last backup: {time_str}") - + if status_info.last_backup_status: lines.append(f"Status: {status_info.last_backup_status}") - + if status_info.repository_count > 0: lines.append(f"Repositories: {status_info.repository_count}") - + if status_info.active_operations > 0: lines.append(f"Active operations: {status_info.active_operations}") - + return "\n".join(lines) - + def set_on_click_callback(self, callback: Callable): """ Set callback for tray icon click - + Args: callback: Function to call when icon is clicked """ self._on_click_callback = callback if self.is_available(): self._tray_impl.set_on_click(callback) - + def set_on_menu_action_callback(self, callback: Callable[[str], None]): """ Set callback for menu actions - + Args: callback: Function to call with action name """ self._on_menu_action_callback = callback if self.is_available(): self._tray_impl.set_on_menu_action(callback) - + def show_context_menu(self): """Show context menu with quick actions""" if not self.is_available(): return - + try: self._tray_impl.show_menu() except Exception as e: logger.error(f"Failed to show context menu: {e}") - + + def process_events(self) -> None: + """Process pending platform UI events without blocking the tray client.""" + if not self.is_available(): + return + process_events = getattr(self._tray_impl, "process_events", None) + if process_events is not None: + process_events() + def shutdown(self): """Shutdown system tray integration""" if self.is_available(): @@ -277,11 +286,11 @@ def shutdown(self): class LinuxSystemTray: """Linux system tray implementation using GTK or Qt""" - + def __init__(self, app_name: str): """ Initialize Linux system tray - + Args: app_name: Application name """ @@ -290,10 +299,10 @@ def __init__(self, app_name: str): self._menu = None self._on_click_callback = None self._on_menu_action_callback = None - + # Try to initialize with available toolkit self._initialize_tray() - + def _initialize_tray(self): """Initialize tray with available toolkit""" try: @@ -304,121 +313,141 @@ def _initialize_tray(self): self._indicator = self._indicator_module.Indicator.new( self.app_name, "dialog-information", - self._indicator_module.IndicatorCategory.APPLICATION_STATUS + self._indicator_module.IndicatorCategory.APPLICATION_STATUS, ) self._indicator.set_status(self._indicator_module.IndicatorStatus.ACTIVE) self._create_gtk_menu() - logger.info("Using GTK with %s for Linux system tray", self._indicator_namespace) + logger.info( + "Using GTK with %s for Linux system tray", self._indicator_namespace + ) return except SystemTrayError as e: logger.debug(f"GTK AppIndicator not available: {e}") - + # Fallback: log that tray is not available logger.warning("No suitable system tray toolkit found for Linux") raise SystemTrayError("System tray not available on this Linux system") - + def _create_gtk_menu(self): """Create GTK context menu""" try: Gtk = self._gtk self._menu = Gtk.Menu() - + # Open item open_item = Gtk.MenuItem(label="Open TimeLocker") open_item.connect("activate", self._on_open_clicked) self._menu.append(open_item) - + # Separator self._menu.append(Gtk.SeparatorMenuItem()) - + # Status item status_item = Gtk.MenuItem(label="View Status") - status_item.connect("activate", lambda x: self._trigger_menu_action("status")) + status_item.connect( + "activate", lambda x: self._trigger_menu_action("status") + ) self._menu.append(status_item) - + # Backup now item backup_item = Gtk.MenuItem(label="Backup Now") - backup_item.connect("activate", lambda x: self._trigger_menu_action("backup_now")) + backup_item.connect( + "activate", lambda x: self._trigger_menu_action("backup_now") + ) self._menu.append(backup_item) - + + # Retention now item + retention_item = Gtk.MenuItem(label="Run Retention") + retention_item.connect( + "activate", lambda x: self._trigger_menu_action("retention_now") + ) + self._menu.append(retention_item) + # Separator self._menu.append(Gtk.SeparatorMenuItem()) - + # Quit item quit_item = Gtk.MenuItem(label="Quit") quit_item.connect("activate", lambda x: self._trigger_menu_action("quit")) self._menu.append(quit_item) - + self._menu.show_all() self._indicator.set_menu(self._menu) - + except Exception as e: logger.error(f"Failed to create GTK menu: {e}") - + def _on_open_clicked(self, widget): """Handle open menu item click""" if self._on_click_callback: self._on_click_callback() - + def _trigger_menu_action(self, action: str): """Trigger menu action callback""" if self._on_menu_action_callback: self._on_menu_action_callback(action) - + def update_icon(self, status: TrayStatus): """Update tray icon based on status""" - if not hasattr(self, '_indicator'): + if not hasattr(self, "_indicator"): return - + icon_map = { TrayStatus.IDLE: "dialog-information", TrayStatus.RUNNING: "system-run", TrayStatus.SUCCESS: "emblem-default", TrayStatus.WARNING: "dialog-warning", - TrayStatus.ERROR: "dialog-error" + TrayStatus.ERROR: "dialog-error", } - + icon_name = icon_map.get(status, "dialog-information") try: self._indicator.set_icon(icon_name) except Exception as e: logger.error(f"Failed to update icon: {e}") - + def update_tooltip(self, tooltip: str): """Update tooltip text""" # GTK AppIndicator doesn't support tooltips directly # Tooltip is shown through the menu pass - + def set_on_click(self, callback: Callable): """Set click callback""" self._on_click_callback = callback - + def set_on_menu_action(self, callback: Callable[[str], None]): """Set menu action callback""" self._on_menu_action_callback = callback - + def show_menu(self): """Show context menu""" # Menu is always visible in GTK AppIndicator pass - + + def process_events(self) -> None: + """Drain pending GTK events while IPC polling remains independent.""" + while self._gtk.events_pending(): + self._gtk.main_iteration_do(False) + def shutdown(self): """Shutdown tray""" - if hasattr(self, '_indicator'): + if hasattr(self, "_indicator"): try: - self._indicator.set_status(self._indicator_module.IndicatorStatus.PASSIVE) + self._indicator.set_status( + self._indicator_module.IndicatorStatus.PASSIVE + ) except Exception as e: logger.error(f"Failed to shutdown GTK tray: {e}") class MacOSSystemTray: """macOS system tray implementation using rumps""" - + def __init__(self, app_name: str): """ Initialize macOS system tray - + Args: app_name: Application name """ @@ -426,89 +455,104 @@ def __init__(self, app_name: str): self._app = None self._on_click_callback = None self._on_menu_action_callback = None - + # Try to initialize with rumps try: import rumps + self._app = rumps.App(app_name, "⏰") self._create_menu() logger.info("Using rumps for macOS system tray") except ImportError: logger.warning("rumps not available for macOS system tray") - raise SystemTrayError("System tray not available on macOS (rumps not installed)") - + raise SystemTrayError( + "System tray not available on macOS (rumps not installed)" + ) + def _create_menu(self): """Create macOS menu""" if not self._app: return - + try: import rumps - + # Create menu items self._app.menu = [ rumps.MenuItem("Open TimeLocker", callback=self._on_open_clicked), None, # Separator - rumps.MenuItem("View Status", callback=lambda _: self._trigger_menu_action("status")), - rumps.MenuItem("Backup Now", callback=lambda _: self._trigger_menu_action("backup_now")), + rumps.MenuItem( + "View Status", + callback=lambda _: self._trigger_menu_action("status"), + ), + rumps.MenuItem( + "Backup Now", + callback=lambda _: self._trigger_menu_action("backup_now"), + ), + rumps.MenuItem( + "Run Retention", + callback=lambda _: self._trigger_menu_action("retention_now"), + ), None, # Separator - rumps.MenuItem("Quit", callback=lambda _: self._trigger_menu_action("quit")) + rumps.MenuItem( + "Quit", callback=lambda _: self._trigger_menu_action("quit") + ), ] except Exception as e: logger.error(f"Failed to create macOS menu: {e}") - + def _on_open_clicked(self, sender): """Handle open menu item click""" if self._on_click_callback: self._on_click_callback() - + def _trigger_menu_action(self, action: str): """Trigger menu action callback""" if self._on_menu_action_callback: self._on_menu_action_callback(action) - + def update_icon(self, status: TrayStatus): """Update tray icon based on status""" if not self._app: return - + icon_map = { TrayStatus.IDLE: "⏰", TrayStatus.RUNNING: "🔄", TrayStatus.SUCCESS: "✅", TrayStatus.WARNING: "⚠️", - TrayStatus.ERROR: "❌" + TrayStatus.ERROR: "❌", } - + icon = icon_map.get(status, "⏰") try: self._app.icon = icon except Exception as e: logger.error(f"Failed to update icon: {e}") - + def update_tooltip(self, tooltip: str): """Update tooltip text""" if not self._app: return - + try: self._app.title = tooltip except Exception as e: logger.error(f"Failed to update tooltip: {e}") - + def set_on_click(self, callback: Callable): """Set click callback""" self._on_click_callback = callback - + def set_on_menu_action(self, callback: Callable[[str], None]): """Set menu action callback""" self._on_menu_action_callback = callback - + def show_menu(self): """Show context menu""" # Menu is always visible in macOS pass - + def shutdown(self): """Shutdown tray""" if self._app: @@ -520,11 +564,11 @@ def shutdown(self): class WindowsSystemTray: """Windows system tray implementation using pystray""" - + def __init__(self, app_name: str): """ Initialize Windows system tray - + Args: app_name: Application name """ @@ -532,78 +576,77 @@ def __init__(self, app_name: str): self._icon = None self._on_click_callback = None self._on_menu_action_callback = None - + # Try to initialize with pystray try: import pystray - from PIL import Image, ImageDraw - + # Create a simple icon image = self._create_icon_image() - + # Create menu menu = self._create_menu() - - self._icon = pystray.Icon( - app_name, - image, - app_name, - menu - ) - + + self._icon = pystray.Icon(app_name, image, app_name, menu) + # Start icon in background thread threading.Thread(target=self._icon.run, daemon=True).start() - + logger.info("Using pystray for Windows system tray") except ImportError: logger.warning("pystray not available for Windows system tray") - raise SystemTrayError("System tray not available on Windows (pystray not installed)") - + raise SystemTrayError( + "System tray not available on Windows (pystray not installed)" + ) + def _create_icon_image(self): """Create icon image""" try: from PIL import Image, ImageDraw - + # Create a simple 64x64 icon - image = Image.new('RGB', (64, 64), color='white') + image = Image.new("RGB", (64, 64), color="white") draw = ImageDraw.Draw(image) - draw.ellipse([16, 16, 48, 48], fill='blue') + draw.ellipse([16, 16, 48, 48], fill="blue") return image except Exception as e: logger.error(f"Failed to create icon image: {e}") return None - + def _create_menu(self): """Create Windows menu""" try: import pystray from pystray import MenuItem as Item - + return pystray.Menu( Item("Open TimeLocker", self._on_open_clicked), Item("View Status", lambda: self._trigger_menu_action("status")), Item("Backup Now", lambda: self._trigger_menu_action("backup_now")), - Item("Quit", lambda: self._trigger_menu_action("quit")) + Item( + "Run Retention", lambda: self._trigger_menu_action("retention_now") + ), + Item("Quit", lambda: self._trigger_menu_action("quit")), ) except Exception as e: logger.error(f"Failed to create Windows menu: {e}") return None - + def _on_open_clicked(self, icon, item): """Handle open menu item click""" if self._on_click_callback: self._on_click_callback() - + def _trigger_menu_action(self, action: str): """Trigger menu action callback""" if self._on_menu_action_callback: self._on_menu_action_callback(action) - + def update_icon(self, status: TrayStatus): """Update tray icon based on status""" if not self._icon: return - + # Update icon image based on status try: image = self._create_status_icon(status) @@ -611,52 +654,52 @@ def update_icon(self, status: TrayStatus): self._icon.icon = image except Exception as e: logger.error(f"Failed to update icon: {e}") - + def _create_status_icon(self, status: TrayStatus): """Create status-specific icon""" try: from PIL import Image, ImageDraw - + color_map = { - TrayStatus.IDLE: 'gray', - TrayStatus.RUNNING: 'blue', - TrayStatus.SUCCESS: 'green', - TrayStatus.WARNING: 'orange', - TrayStatus.ERROR: 'red' + TrayStatus.IDLE: "gray", + TrayStatus.RUNNING: "blue", + TrayStatus.SUCCESS: "green", + TrayStatus.WARNING: "orange", + TrayStatus.ERROR: "red", } - - color = color_map.get(status, 'gray') - image = Image.new('RGB', (64, 64), color='white') + + color = color_map.get(status, "gray") + image = Image.new("RGB", (64, 64), color="white") draw = ImageDraw.Draw(image) draw.ellipse([16, 16, 48, 48], fill=color) return image except Exception as e: logger.error(f"Failed to create status icon: {e}") return None - + def update_tooltip(self, tooltip: str): """Update tooltip text""" if not self._icon: return - + try: self._icon.title = tooltip except Exception as e: logger.error(f"Failed to update tooltip: {e}") - + def set_on_click(self, callback: Callable): """Set click callback""" self._on_click_callback = callback - + def set_on_menu_action(self, callback: Callable[[str], None]): """Set menu action callback""" self._on_menu_action_callback = callback - + def show_menu(self): """Show context menu""" # Menu is shown on right-click in Windows pass - + def shutdown(self): """Shutdown tray""" if self._icon: diff --git a/src/TimeLocker/system_control/__init__.py b/src/TimeLocker/system_control/__init__.py index eb03558..100794d 100644 --- a/src/TimeLocker/system_control/__init__.py +++ b/src/TimeLocker/system_control/__init__.py @@ -27,6 +27,7 @@ DiagnosticView, RetentionActionRequest, RetentionPolicy, + ScheduleSummary, RunQuery, RunRecord, RunRecordView, @@ -34,6 +35,15 @@ SystemPolicy, ) from .protocol import RequestEnvelope, ResponseEnvelope, project_response +from .retention import ( + RetentionAdapter, + RetentionExecutionResult, + RetentionExecutor, + RetentionPlan, + RetentionRequestHandler, + RetentionTriggerCoordinator, + RetentionTriggerStore, +) from .storage import ( AtomicRecordStore, InvalidTransitionError, @@ -91,7 +101,15 @@ "ResponseStatus", "ResultCode", "RetentionActionRequest", + "RetentionAdapter", + "RetentionExecutionResult", + "RetentionExecutor", + "RetentionPlan", "RetentionPolicy", + "RetentionRequestHandler", + "RetentionTriggerCoordinator", + "RetentionTriggerStore", + "ScheduleSummary", "RepositoryMutationLease", "RepositoryMutationLock", "RunQuery", diff --git a/src/TimeLocker/system_control/client.py b/src/TimeLocker/system_control/client.py index d01b748..2e3a197 100644 --- a/src/TimeLocker/system_control/client.py +++ b/src/TimeLocker/system_control/client.py @@ -12,6 +12,7 @@ BackupActionRequest, DiagnosticQuery, DiagnosticView, + ScheduleSummary, RetentionActionRequest, RunQuery, RunRecordView, @@ -101,6 +102,10 @@ def request_retention(self, request: RetentionActionRequest) -> ActionReceipt: ) return _receipt_from_mapping(result) + def get_schedule_summary(self) -> ScheduleSummary: + result = self._request(SystemAction.SCHEDULE_SUMMARY, {}) + return ScheduleSummary.from_mapping(result) + def _request( self, action: SystemAction, diff --git a/src/TimeLocker/system_control/interfaces.py b/src/TimeLocker/system_control/interfaces.py index 7e5512e..793fc4d 100644 --- a/src/TimeLocker/system_control/interfaces.py +++ b/src/TimeLocker/system_control/interfaces.py @@ -9,6 +9,7 @@ BackupActionRequest, DiagnosticQuery, DiagnosticView, + ScheduleSummary, RetentionActionRequest, RunQuery, RunRecordView, @@ -92,3 +93,6 @@ def request_backup(self, request: BackupActionRequest) -> ActionReceipt: def request_retention(self, request: RetentionActionRequest) -> ActionReceipt: """Request an approved retention operation.""" + + def get_schedule_summary(self) -> ScheduleSummary: + """Return next scheduled backup and retention run timestamps.""" diff --git a/src/TimeLocker/system_control/models.py b/src/TimeLocker/system_control/models.py index aa1dc98..a52f877 100644 --- a/src/TimeLocker/system_control/models.py +++ b/src/TimeLocker/system_control/models.py @@ -409,6 +409,51 @@ def safe_summary(self) -> str: return DIAGNOSTIC_SUMMARIES[self.message_code] +@dataclass(frozen=True, slots=True) +class ScheduleSummary: + """Projected schedule timing used by user-facing status views.""" + + next_backup_at: datetime | None + next_retention_at: datetime | None + + def __post_init__(self) -> None: + object.__setattr__( + self, + "next_backup_at", + require_optional_utc_datetime( + self.next_backup_at, + field="next_backup_at", + ), + ) + object.__setattr__( + self, + "next_retention_at", + require_optional_utc_datetime( + self.next_retention_at, + field="next_retention_at", + ), + ) + + @classmethod + def from_mapping(cls, value: object) -> "ScheduleSummary": + """Parse the strict wire projection returned by the protected backend.""" + mapping = require_exact_mapping( + value, + field="schedule_summary", + required=frozenset({"next_backup_at", "next_retention_at"}), + ) + return cls( + next_backup_at=require_optional_wire_utc_datetime( + mapping["next_backup_at"], + field="next_backup_at", + ), + next_retention_at=require_optional_wire_utc_datetime( + mapping["next_retention_at"], + field="next_retention_at", + ), + ) + + @dataclass(frozen=True, slots=True) class RunQuery: """Bounded filters for listing system runs.""" diff --git a/src/TimeLocker/system_control/retention.py b/src/TimeLocker/system_control/retention.py new file mode 100644 index 0000000..4cf73e0 --- /dev/null +++ b/src/TimeLocker/system_control/retention.py @@ -0,0 +1,394 @@ +"""Approved, locked retention execution for protected system repositories.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +from typing import Protocol +from uuid import UUID, uuid4 + +from .models import ( + ActionReceipt, + DiagnosticRecord, + RetentionPolicy, + RunRecord, + RunTransition, +) +from .protocol import RequestEnvelope +from .storage import AtomicRecordStore, MutationConflictError, RepositoryMutationLock +from .types import ( + DiagnosticCode, + DiagnosticComponent, + DiagnosticLevel, + OperationTrigger, + OperationType, + ResultCode, + RunState, +) +from .validation import require_safe_identifier + + +@dataclass(frozen=True, slots=True) +class RetentionPlan: + """Complete, secret-free input whose canonical form is operator-approved.""" + + target_id: str + repository_identity: str + credential_source: str + snapshot_filters: tuple[str, ...] = () + policy: RetentionPolicy = field(default_factory=RetentionPolicy) + + def __post_init__(self) -> None: + object.__setattr__( + self, + "target_id", + require_safe_identifier(self.target_id, field="target_id"), + ) + for field_name in ("repository_identity", "credential_source"): + value = getattr(self, field_name) + if ( + not isinstance(value, str) + or not value + or len(value) > 512 + or "\x00" in value + ): + raise ValueError(f"{field_name} must be a bounded non-empty string") + if type(self.snapshot_filters) is not tuple: + raise TypeError("snapshot_filters must be a tuple") + for value in self.snapshot_filters: + if not isinstance(value, str) or len(value) > 1_024 or "\x00" in value: + raise ValueError("snapshot_filters must contain bounded strings") + if not isinstance(self.policy, RetentionPolicy): + raise TypeError("policy must be a RetentionPolicy") + + @property + def fingerprint(self) -> str: + """Return the exact canonical policy and repository-context fingerprint.""" + payload = { + "credential_source": self.credential_source, + "group_by": list(self.policy.group_by), + "keep_daily": self.policy.keep_daily, + "keep_monthly": self.policy.keep_monthly, + "keep_weekly": self.policy.keep_weekly, + "keep_yearly": self.policy.keep_yearly, + "prune": self.policy.prune, + "repository_identity": self.repository_identity, + "snapshot_filters": list(self.snapshot_filters), + "target_id": self.target_id, + } + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +@dataclass(frozen=True, slots=True) +class RetentionExecutionResult: + """Safe counters returned by a protected retention adapter.""" + + selected_snapshots: int = 0 + removed_snapshots: int = 0 + + def __post_init__(self) -> None: + for value in (self.selected_snapshots, self.removed_snapshots): + if type(value) is not int or value < 0 or value > 2**63 - 1: + raise ValueError( + "retention counters must be bounded non-negative integers" + ) + + def counters(self, *, dry_run: bool) -> Mapping[str, int]: + return { + "dry_run": int(dry_run), + "selected_snapshots": self.selected_snapshots, + "removed_snapshots": self.removed_snapshots, + } + + +class RetentionAdapter(Protocol): + """Resolve protected configuration and execute one exact retention plan.""" + + def execute( + self, + plan: RetentionPlan, + *, + dry_run: bool, + ) -> RetentionExecutionResult: + """Execute without exposing credentials or backend output.""" + + +class RetentionExecutor: + """Create a separate durable run around one repository mutation lease.""" + + def __init__( + self, + *, + store: AtomicRecordStore, + locks: RepositoryMutationLock, + adapter: RetentionAdapter, + clock: Callable[[], datetime] | None = None, + ) -> None: + self.store = store + self.locks = locks + self.adapter = adapter + self._clock = clock or (lambda: datetime.now(timezone.utc)) + + def execute( + self, + plan: RetentionPlan, + *, + trigger: OperationTrigger, + dry_run: bool, + ) -> RunRecord: + """Execute an approved mutation, or an unapproved dry run, fail-closed.""" + if not isinstance(plan, RetentionPlan): + raise TypeError("plan must be a RetentionPlan") + if trigger not in { + OperationTrigger.EXPLICIT, + OperationTrigger.SCHEDULED, + OperationTrigger.BACKUP_SUCCESS, + OperationTrigger.RETRY, + }: + raise ValueError("unsupported retention trigger") + if type(dry_run) is not bool: + raise TypeError("dry_run must be a bool") + fingerprint = plan.fingerprint + if not dry_run and plan.policy.approved_fingerprint != fingerprint: + raise PermissionError("retention policy approval does not match") + + started_at = self._now() + run = RunRecord( + run_id=uuid4(), + operation=OperationType.RETENTION, + trigger=trigger, + target_id=plan.target_id, + started_at=started_at, + state=RunState.QUEUED, + result_code=ResultCode.OPERATION_QUEUED, + policy_fingerprint=fingerprint, + counters={"dry_run": int(dry_run)}, + ) + self.store.create_run(run) + try: + lease = self.locks.acquire(plan.target_id, run.run_id) + except MutationConflictError: + result = self._finish( + run, + RunState.SKIPPED, + ResultCode.OPERATION_CONFLICT, + {"dry_run": int(dry_run)}, + ) + self._diagnostic( + run.run_id, DiagnosticLevel.WARNING, DiagnosticCode.OPERATION_CONFLICT + ) + return result + + with lease: + self.store.transition( + run.run_id, + RunTransition( + expected_states=frozenset({RunState.QUEUED}), + new_state=RunState.RUNNING, + result_code=ResultCode.OPERATION_RUNNING, + counters={"dry_run": int(dry_run)}, + ), + ) + self._diagnostic( + run.run_id, DiagnosticLevel.INFO, DiagnosticCode.RETENTION_STARTED + ) + try: + adapter_result = self.adapter.execute(plan, dry_run=dry_run) + if not isinstance(adapter_result, RetentionExecutionResult): + raise TypeError("retention adapter returned an invalid result") + except Exception: + result = self._finish( + run, + RunState.FAILED, + ResultCode.OPERATION_FAILED, + {"dry_run": int(dry_run)}, + ) + self._diagnostic( + run.run_id, DiagnosticLevel.ERROR, DiagnosticCode.OPERATION_FAILED + ) + return result + result = self._finish( + run, + RunState.SUCCEEDED, + ResultCode.RETENTION_SUCCEEDED, + adapter_result.counters(dry_run=dry_run), + ) + self._diagnostic( + run.run_id, DiagnosticLevel.INFO, DiagnosticCode.RETENTION_SUCCEEDED + ) + return result + + def _finish( + self, + run: RunRecord, + state: RunState, + result_code: ResultCode, + counters: Mapping[str, int], + ) -> RunRecord: + current = self.store.read_run(run.run_id) + return self.store.transition( + run.run_id, + RunTransition( + expected_states=frozenset({current.state}), + new_state=state, + result_code=result_code, + completed_at=max(self._now(), run.started_at), + counters=counters, + ), + ) + + def _diagnostic( + self, + run_id: UUID, + level: DiagnosticLevel, + code: DiagnosticCode, + ) -> None: + self.store.append_diagnostic( + DiagnosticRecord( + record_id=uuid4(), + run_id=run_id, + timestamp=self._now(), + level=level, + component=DiagnosticComponent.RETENTION, + message_code=code, + ) + ) + + def _now(self) -> datetime: + value = self._clock() + if value.tzinfo is None or value.utcoffset() != timezone.utc.utcoffset(value): + raise ValueError("clock must return an aware UTC datetime") + return value + + +class RetentionTriggerStore: + """Durably claim each successful backup trigger at most once.""" + + def __init__(self, root: Path) -> None: + if not isinstance(root, Path): + raise TypeError("root must be a Path") + self.root = root + root.mkdir(mode=0o700, parents=True, exist_ok=True) + root.chmod(0o700) + + def claim(self, backup_run_id: UUID, policy_fingerprint: str) -> bool: + """Atomically claim a backup/fingerprint pair across processes.""" + name = hashlib.sha256( + f"{backup_run_id}:{policy_fingerprint}".encode("ascii") + ).hexdigest() + path = self.root / f"{name}.json" + try: + descriptor = os.open( + path, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + except FileExistsError: + return False + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + json.dump( + { + "backup_run_id": str(backup_run_id), + "policy_fingerprint": policy_fingerprint, + "schema_version": 1, + }, + output, + sort_keys=True, + separators=(",", ":"), + ) + output.write("\n") + output.flush() + os.fsync(output.fileno()) + directory_descriptor = os.open( + self.root, + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0), + ) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + return True + + +class RetentionTriggerCoordinator: + """Expose explicit, independent, and post-backup retention triggers.""" + + def __init__( + self, + *, + executor: RetentionExecutor, + trigger_store: RetentionTriggerStore, + independent_schedule_enabled: bool = False, + ) -> None: + self.executor = executor + self.trigger_store = trigger_store + self.independent_schedule_enabled = independent_schedule_enabled + + def explicit(self, plan: RetentionPlan, *, dry_run: bool = False) -> RunRecord: + return self.executor.execute( + plan, + trigger=OperationTrigger.EXPLICIT, + dry_run=dry_run, + ) + + def scheduled(self, plan: RetentionPlan) -> RunRecord | None: + if not self.independent_schedule_enabled: + return None + return self.executor.execute( + plan, + trigger=OperationTrigger.SCHEDULED, + dry_run=False, + ) + + def after_backup_success( + self, + backup: RunRecord, + plan: RetentionPlan, + ) -> RunRecord | None: + """Trigger once after a terminal successful scheduled backup is released.""" + if ( + backup.operation is not OperationType.BACKUP + or backup.trigger is not OperationTrigger.SCHEDULED + or backup.state is not RunState.SUCCEEDED + or backup.result_code is not ResultCode.BACKUP_SUCCEEDED + ): + return None + if not self.trigger_store.claim(backup.run_id, plan.fingerprint): + return None + return self.executor.execute( + plan, + trigger=OperationTrigger.BACKUP_SUCCESS, + dry_run=False, + ) + + +class RetentionRequestHandler: + """Bind the protected retention domain to the allowlisted IPC dispatcher.""" + + def __init__( + self, + *, + coordinator: RetentionTriggerCoordinator, + plan: RetentionPlan, + ) -> None: + self.coordinator = coordinator + self.plan = plan + + def __call__(self, request: RequestEnvelope) -> Mapping[str, object]: + supplied_fingerprint = request.parameters["policy_fingerprint"] + dry_run = request.parameters["dry_run"] + if supplied_fingerprint != self.plan.fingerprint: + raise PermissionError("retention policy fingerprint does not match") + run = self.coordinator.explicit(self.plan, dry_run=dry_run) + return ActionReceipt( + request_id=request.request_id, + accepted=True, + status=run.state.value, + run_id=run.run_id, + ).to_wire() diff --git a/src/TimeLocker/system_control/tray_client.py b/src/TimeLocker/system_control/tray_client.py new file mode 100644 index 0000000..e3abbdb --- /dev/null +++ b/src/TimeLocker/system_control/tray_client.py @@ -0,0 +1,258 @@ +"""Tray-facing status and action client for TimeLocker system control.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Callable, TypeVar + +from .client import SystemControlClientError, UnixSocketSystemControlClient +from .interfaces import SystemControlClient +from .models import RunQuery, RunRecordView, ScheduleSummary +from .models import RetentionActionRequest, BackupActionRequest +from .types import OperationType, ProtocolErrorCode, ResponseStatus, RunState + + +ALLOWED_TRAY_ACTIONS = frozenset( + {"status", "backup_now", "retention_now", "open_ui", "quit"} +) +_BACKEND_RETRY_DELAY_SECONDS = 2.0 +_BACKEND_RETRY_MAX_SECONDS = 60.0 + + +@dataclass(frozen=True, slots=True) +class TrayDisplayState: + """Minimal projection used by the tray process to render user status.""" + + status: str + tooltip: str + active_operations: int + backend_available: bool + last_backup_started_at: datetime | None + last_backup_status: str | None + last_retention_started_at: datetime | None + last_retention_status: str | None + next_backup_at: datetime | None + next_retention_at: datetime | None + repository_count: int + + +class TrayBackendUnavailable(RuntimeError): + """Raised when the protected local backend is not currently reachable.""" + + +_ClientFactory = Callable[[], SystemControlClient] +_T = TypeVar("_T") + + +class TrayControlClient: + """Small client that powers the standalone tray process. + + The tray process must remain independent from regular CLI execution and + should degrade gracefully whenever the protected backend is missing. + """ + + def __init__( + self, + *, + client_factory: _ClientFactory | None = None, + target_id: str = "production", + retention_policy_fingerprint: str | None = None, + max_history_runs: int = 25, + base_retry_delay: float = _BACKEND_RETRY_DELAY_SECONDS, + max_retry_delay: float = _BACKEND_RETRY_MAX_SECONDS, + ) -> None: + if max_history_runs < 1 or max_history_runs > 1_000: + raise ValueError("max_history_runs must be between 1 and 1000") + if base_retry_delay <= 0 or max_retry_delay < base_retry_delay: + raise ValueError("retry delays are invalid") + self._client_factory = client_factory or UnixSocketSystemControlClient + self._target_id = target_id + self._retention_policy_fingerprint = retention_policy_fingerprint + self._max_history_runs = max_history_runs + self._base_retry_delay = base_retry_delay + self._max_retry_delay = max_retry_delay + self._client: SystemControlClient = self._client_factory() + self._retry_delay: float = base_retry_delay + self._retry_at: float = 0.0 + + @property + def allowed_actions(self) -> frozenset[str]: + return ALLOWED_TRAY_ACTIONS + + def refresh_status(self) -> TrayDisplayState: + """Return a tray-safe status snapshot for the current backend state.""" + + def _build_from_runs( + runs: list[RunRecordView], + summary: ScheduleSummary, + ) -> TrayDisplayState: + active_operations = self._count_active_runs(runs) + backup_runs = [run for run in runs if run.operation is OperationType.BACKUP] + retention_runs = [ + run for run in runs if run.operation is OperationType.RETENTION + ] + latest_backup = self._latest_run(backup_runs) + latest_retention = self._latest_run(retention_runs) + + return TrayDisplayState( + status=self._status_from_runs(runs), + tooltip=self._build_tooltip( + latest_backup, latest_retention, summary, active_operations + ), + active_operations=active_operations, + backend_available=True, + last_backup_started_at=latest_backup.started_at + if latest_backup + else None, + last_backup_status=latest_backup.safe_summary + if latest_backup + else None, + last_retention_started_at=( + latest_retention.started_at if latest_retention else None + ), + last_retention_status=( + latest_retention.safe_summary if latest_retention else None + ), + next_backup_at=summary.next_backup_at, + next_retention_at=summary.next_retention_at, + repository_count=len({run.target_id for run in runs}), + ) + + try: + runs = self._with_backend( + lambda backend: backend.list_runs( + RunQuery(limit=self._max_history_runs) + ) + ) + summary = self._with_backend(lambda backend: backend.get_schedule_summary()) + except TrayBackendUnavailable: + return self._unavailable_state( + "TimeLocker - System backend unavailable", + backend_available=False, + ) + except SystemControlClientError as error: + if error.status is ResponseStatus.DENIED: + return self._unavailable_state( + "TimeLocker - Access denied", + backend_available=True, + ) + raise + return _build_from_runs(runs, summary) + + def perform_action( + self, action: str, *, dry_run_retention: bool = False + ) -> TrayDisplayState | None: + """Execute a supported tray action and refresh status when possible.""" + if action not in ALLOWED_TRAY_ACTIONS: + raise ValueError(f"unsupported tray action: {action}") + if action in {"status", "open_ui", "quit"}: + if action == "quit": + return None + if action == "open_ui": + return None + return self.refresh_status() + if action == "backup_now": + self._with_backend( + lambda backend: backend.request_backup( + BackupActionRequest(target_id=self._target_id) + ) + ) + return self.refresh_status() + + if self._retention_policy_fingerprint is None: + raise ValueError( + "retention policy fingerprint is required to request retention" + ) + self._with_backend( + lambda backend: backend.request_retention( + RetentionActionRequest( + policy_fingerprint=self._retention_policy_fingerprint, + dry_run=dry_run_retention, + ) + ) + ) + return self.refresh_status() + + def _with_backend(self, callback: Callable[[SystemControlClient], _T]) -> _T: + """Protect tray polling from unavailable backend sessions.""" + from time import monotonic + + if monotonic() < self._retry_at: + raise TrayBackendUnavailable("backend temporarily unavailable") + try: + result = callback(self._client) + except SystemControlClientError as error: + if error.error_code is ProtocolErrorCode.SYSTEM_BACKEND_UNAVAILABLE: + self._retry_at = monotonic() + self._retry_delay + self._retry_delay = min(self._retry_delay * 2.0, self._max_retry_delay) + raise TrayBackendUnavailable( + "backend temporarily unavailable" + ) from error + raise + self._retry_at = 0.0 + self._retry_delay = self._base_retry_delay + return result + + @staticmethod + def _unavailable_state( + tooltip: str, + *, + backend_available: bool, + ) -> TrayDisplayState: + return TrayDisplayState( + status="warning", + tooltip=tooltip, + active_operations=0, + backend_available=backend_available, + last_backup_started_at=None, + last_backup_status=None, + last_retention_started_at=None, + last_retention_status=None, + next_backup_at=None, + next_retention_at=None, + repository_count=0, + ) + + def _count_active_runs(self, runs: list[RunRecordView]) -> int: + return sum(1 for run in runs if run.state is RunState.RUNNING) + + def _latest_run(self, runs: list[RunRecordView]) -> RunRecordView | None: + if not runs: + return None + return sorted(runs, key=lambda run: run.started_at, reverse=True)[0] + + def _status_from_runs(self, runs: list[RunRecordView]) -> str: + if any(run.state is RunState.RUNNING for run in runs): + return "running" + if any(run.state is RunState.FAILED for run in runs): + return "error" + if any(run.state is RunState.INTERRUPTED for run in runs): + return "error" + if any(run.state is RunState.SKIPPED for run in runs): + return "warning" + if any(run.state is RunState.SUCCEEDED for run in runs): + return "success" + return "idle" + + def _build_tooltip( + self, + latest_backup: RunRecordView | None, + latest_retention: RunRecordView | None, + summary: ScheduleSummary, + active_operations: int, + ) -> str: + lines: list[str] = ["TimeLocker"] + if active_operations: + lines.append(f"Active: {active_operations}") + if latest_backup: + lines.append(f"Last backup: {latest_backup.started_at.isoformat()}") + lines.append(f"Backup status: {latest_backup.safe_summary}") + if latest_retention: + lines.append(f"Last retention: {latest_retention.started_at.isoformat()}") + lines.append(f"Retention status: {latest_retention.safe_summary}") + if summary.next_backup_at: + lines.append(f"Next backup: {summary.next_backup_at.isoformat()}") + if summary.next_retention_at: + lines.append(f"Next retention: {summary.next_retention_at.isoformat()}") + return "\n".join(lines) if len(lines) > 1 else "TimeLocker - Idle" diff --git a/src/TimeLocker/system_control/tray_entry.py b/src/TimeLocker/system_control/tray_entry.py new file mode 100644 index 0000000..68232b2 --- /dev/null +++ b/src/TimeLocker/system_control/tray_entry.py @@ -0,0 +1,311 @@ +"""Standalone tray entry point for user-session TimeLocker interactions.""" + +from __future__ import annotations + +import argparse +import os +import signal +import sys +import time +from contextlib import contextmanager, suppress +from pathlib import Path +from typing import Any, Callable + +from .tray_client import TrayControlClient, TrayDisplayState +from ..monitoring.system_tray_integration import ( + SystemTrayError, + SystemTrayIntegration, + TrayStatus, +) + +try: + import fcntl +except ImportError: # pragma: no cover - Windows-specific. + fcntl = None + +from .client import ( + ProtocolErrorCode, + SystemControlClientError, + UnixSocketSystemControlClient, +) +from .types import ResponseStatus + +DEFAULT_REFRESH_SECONDS = 15 +DEFAULT_POLL_SECONDS = 30 +TRAY_STATUS_ACTIONS = {"status", "backup_now", "retention_now", "open_ui", "quit"} +_runtime_directory = os.environ.get("XDG_RUNTIME_DIR") +LOCK_PATH = ( + Path(_runtime_directory) / "timelocker" / "tray.lock" + if _runtime_directory and Path(_runtime_directory).is_absolute() + else Path.home() / ".cache" / "timelocker" / "tray.lock" +) + + +_STATUS_MAP = { + "running": TrayStatus.RUNNING, + "error": TrayStatus.ERROR, + "warning": TrayStatus.WARNING, + "success": TrayStatus.SUCCESS, + "idle": TrayStatus.IDLE, +} + + +def _status_to_tray(status: str) -> TrayStatus: + return _STATUS_MAP.get(status, TrayStatus.IDLE) + + +@contextmanager +def _single_instance(lock_path: Path = LOCK_PATH) -> Any: + if fcntl is None: + yield + return + + lock_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + lock_path.parent.chmod(0o700) + flags = os.O_RDWR | os.O_CREAT + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(lock_path, flags, 0o600) + os.fchmod(descriptor, 0o600) + fd = os.fdopen(descriptor, "w", encoding="ascii") + try: + fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + fd.close() + raise RuntimeError("timelocker-tray is already running") from exc + try: + yield + finally: + with suppress(OSError): + fcntl.flock(fd.fileno(), fcntl.LOCK_UN) + with suppress(OSError): + fd.close() + with suppress(OSError): + lock_path.unlink(missing_ok=True) + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(prog="timelocker-tray", description=__doc__) + parser.add_argument( + "action", + nargs="?", + default="serve", + choices=sorted(TRAY_STATUS_ACTIONS | {"serve"}), + help="Action to execute in this invocation.", + ) + parser.add_argument( + "--once", + action="store_true", + help="Perform a single refresh and exit.", + ) + parser.add_argument( + "--refresh-seconds", + type=float, + default=DEFAULT_REFRESH_SECONDS, + dest="refresh_seconds", + ) + parser.add_argument( + "--target-id", + default="production", + ) + parser.add_argument( + "--retention-policy-fingerprint", + default=None, + help="Required for retention_now action.", + ) + parser.add_argument( + "--dry-run-retention", + action="store_true", + help="Request retention in dry-run mode.", + ) + return parser.parse_args(argv) + + +def _render_status(state: TrayDisplayState) -> str: + bits = [ + f"status: {state.status}", + f"active_operations: {state.active_operations}", + f"backend_available: {state.backend_available}", + f"repositories: {state.repository_count}", + ] + if state.next_backup_at: + bits.append(f"next_backup_at: {state.next_backup_at.isoformat()}") + if state.next_retention_at: + bits.append(f"next_retention_at: {state.next_retention_at.isoformat()}") + return "\n".join(bits) + + +def _apply_state( + tray: SystemTrayIntegration, + state: TrayDisplayState, +) -> None: + if not tray.is_available(): + return + tray.update_status(_status_to_tray(state.status), tooltip=state.tooltip) + + +def _build_client( + target_id: str, + retention_policy_fingerprint: str | None, +) -> TrayControlClient: + return TrayControlClient( + client_factory=UnixSocketSystemControlClient, + target_id=target_id, + retention_policy_fingerprint=retention_policy_fingerprint, + ) + + +def _handle_action( + action: str, + client: TrayControlClient, + *, + tray: SystemTrayIntegration | None, + dry_run_retention: bool, +) -> TrayDisplayState | None: + if action == "quit": + raise SystemExit(0) + if action == "open_ui": + print("open-ui not yet implemented") + return None + if action not in TRAY_STATUS_ACTIONS: + raise SystemExit(f"unsupported action: {action}") + return client.perform_action(action, dry_run_retention=dry_run_retention) + + +def _menu_action( + action: str, + client: TrayControlClient, + tray: SystemTrayIntegration | None, + dry_run_retention: bool, +) -> None: + try: + state = _handle_action( + action, + client, + tray=tray, + dry_run_retention=dry_run_retention, + ) + except SystemExit: + raise + if state is not None and tray is not None and tray.is_available(): + _apply_state(tray, state) + + +def _wait_for_next_refresh( + tray: SystemTrayIntegration | None, + seconds: float, + stop_requested: Callable[[], bool], +) -> None: + """Keep the desktop event loop responsive between backend refreshes.""" + deadline = time.monotonic() + seconds + while not stop_requested() and time.monotonic() < deadline: + if tray is not None: + tray.process_events() + time.sleep(min(0.25, max(0.0, deadline - time.monotonic()))) + + +def main() -> None: + arguments = _parse_args() + if ( + arguments.action == "retention_now" + and not arguments.retention_policy_fingerprint + ): + print( + "retention_now requires --retention-policy-fingerprint", + file=sys.stderr, + ) + raise SystemExit(2) + + try: + tray = SystemTrayIntegration(app_name="TimeLocker") + except SystemTrayError: + tray = None + + client = _build_client( + target_id=arguments.target_id, + retention_policy_fingerprint=arguments.retention_policy_fingerprint, + ) + if arguments.action != "serve": + state = _handle_action( + arguments.action, + client, + tray=tray, + dry_run_retention=arguments.dry_run_retention, + ) + if state is not None: + print(_render_status(state)) + return + + poll_interval = max(DEFAULT_POLL_SECONDS, arguments.refresh_seconds) + + stop_requested = False + + def _request_stop(*_args: Any) -> None: + nonlocal stop_requested + stop_requested = True + + signal.signal(signal.SIGTERM, _request_stop) + signal.signal(signal.SIGINT, _request_stop) + + try: + with _single_instance(): + if tray: + if tray.is_available(): + tray.set_on_menu_action_callback( + lambda action_name: _menu_action( + action_name, + client, + tray=tray, + dry_run_retention=arguments.dry_run_retention, + ) + ) + tray.show_context_menu() + + while not stop_requested: + try: + state = client.refresh_status() + except Exception as exc: + if isinstance(exc, SystemControlClientError): + if ( + exc.error_code + is ProtocolErrorCode.SYSTEM_BACKEND_UNAVAILABLE + and exc.status == ResponseStatus.UNAVAILABLE + ): + # Keep tray alive; retry on background interval. + print("backend unavailable, retrying...", file=sys.stderr) + _wait_for_next_refresh( + tray, + poll_interval, + lambda: stop_requested, + ) + continue + print(f"{exc}", file=sys.stderr) + _wait_for_next_refresh( + tray, + poll_interval, + lambda: stop_requested, + ) + continue + + if state: + if tray and tray.is_available(): + _apply_state(tray, state) + print(_render_status(state)) + + if arguments.once: + break + _wait_for_next_refresh( + tray, + poll_interval, + lambda: stop_requested, + ) + except RuntimeError as exc: + print(f"{exc}", file=sys.stderr) + raise SystemExit(1) from None + finally: + if tray is not None and tray.is_available(): + tray.shutdown() + + +if __name__ == "__main__": + main() diff --git a/tests/TimeLocker/monitoring/test_system_tray_integration.py b/tests/TimeLocker/monitoring/test_system_tray_integration.py index 0d47871..7a34a87 100644 --- a/tests/TimeLocker/monitoring/test_system_tray_integration.py +++ b/tests/TimeLocker/monitoring/test_system_tray_integration.py @@ -19,14 +19,11 @@ from datetime import datetime from unittest.mock import Mock, patch -from TimeLocker.monitoring import ( +from TimeLocker.monitoring.system_tray_integration import ( + SystemTrayError, SystemTrayIntegration, TrayStatus, TrayStatusInfo, - SystemTrayError -) -from TimeLocker.monitoring.system_tray_integration import ( - LinuxSystemTray, _load_linux_tray_modules, ) @@ -44,7 +41,7 @@ def test_tray_status_info_creation(self): last_backup_time=datetime.now(), last_backup_status="success", repository_count=3, - active_operations=0 + active_operations=0, ) assert info.status == TrayStatus.SUCCESS @@ -57,13 +54,15 @@ class TestSystemTrayIntegration: @pytest.mark.monitoring @pytest.mark.unit - @patch('TimeLocker.monitoring.system_tray_integration.sys.platform', 'linux') + @patch("TimeLocker.monitoring.system_tray_integration.sys.platform", "linux") def test_initialization(self, monkeypatch): """Test SystemTrayIntegration initialization""" - monkeypatch.setenv('DISPLAY', ':0') - with patch('TimeLocker.monitoring.system_tray_integration.LinuxSystemTray') as linux_tray: + monkeypatch.setenv("DISPLAY", ":0") + with patch( + "TimeLocker.monitoring.system_tray_integration.LinuxSystemTray" + ) as linux_tray: tray = SystemTrayIntegration(app_name="TestApp") - + assert tray.app_name == "TestApp" assert tray.current_status == TrayStatus.IDLE assert tray.is_available() is True @@ -71,13 +70,15 @@ def test_initialization(self, monkeypatch): @pytest.mark.monitoring @pytest.mark.unit - @patch('TimeLocker.monitoring.system_tray_integration.sys.platform', 'linux') + @patch("TimeLocker.monitoring.system_tray_integration.sys.platform", "linux") def test_headless_linux_skips_native_tray_initialization(self, monkeypatch): """A service without a display must not enter GTK/AppIndicator code.""" - monkeypatch.delenv('DISPLAY', raising=False) - monkeypatch.delenv('WAYLAND_DISPLAY', raising=False) + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) - with patch('TimeLocker.monitoring.system_tray_integration.LinuxSystemTray') as linux_tray: + with patch( + "TimeLocker.monitoring.system_tray_integration.LinuxSystemTray" + ) as linux_tray: tray = SystemTrayIntegration(app_name="HeadlessService") assert tray.is_available() is False @@ -104,15 +105,15 @@ def test_prefers_ayatana_appindicator(self): gtk = Mock() ayatana = Mock() - with patch.dict('sys.modules', {'gi': gi}): + with patch.dict("sys.modules", {"gi": gi}): with patch( - 'TimeLocker.monitoring.system_tray_integration.importlib.import_module', + "TimeLocker.monitoring.system_tray_integration.importlib.import_module", side_effect=[gtk, ayatana], ): modules = _load_linux_tray_modules() - assert modules == (gtk, ayatana, 'AyatanaAppIndicator3') - gi.require_version.assert_any_call('AyatanaAppIndicator3', '0.1') + assert modules == (gtk, ayatana, "AyatanaAppIndicator3") + gi.require_version.assert_any_call("AyatanaAppIndicator3", "0.1") @pytest.mark.monitoring @pytest.mark.unit @@ -122,27 +123,27 @@ def test_falls_back_to_legacy_appindicator(self): legacy = Mock() def require_version(namespace, version): - if namespace == 'AyatanaAppIndicator3': - raise ValueError('namespace unavailable') + if namespace == "AyatanaAppIndicator3": + raise ValueError("namespace unavailable") gi.require_version.side_effect = require_version - with patch.dict('sys.modules', {'gi': gi}): + with patch.dict("sys.modules", {"gi": gi}): with patch( - 'TimeLocker.monitoring.system_tray_integration.importlib.import_module', + "TimeLocker.monitoring.system_tray_integration.importlib.import_module", side_effect=[gtk, legacy], ): modules = _load_linux_tray_modules() - assert modules == (gtk, legacy, 'AppIndicator3') - gi.require_version.assert_any_call('AppIndicator3', '0.1') + assert modules == (gtk, legacy, "AppIndicator3") + gi.require_version.assert_any_call("AppIndicator3", "0.1") @pytest.mark.monitoring @pytest.mark.unit def test_missing_indicator_namespaces_is_non_fatal_to_facade(self): with patch( - 'TimeLocker.monitoring.system_tray_integration._load_linux_tray_modules', - side_effect=SystemTrayError('no indicator'), + "TimeLocker.monitoring.system_tray_integration._load_linux_tray_modules", + side_effect=SystemTrayError("no indicator"), ): - tray = SystemTrayIntegration('TestApp') + tray = SystemTrayIntegration("TestApp") assert tray.is_available() is False diff --git a/tests/TimeLocker/system_control/test_client.py b/tests/TimeLocker/system_control/test_client.py index 6836bbe..eaab5e0 100644 --- a/tests/TimeLocker/system_control/test_client.py +++ b/tests/TimeLocker/system_control/test_client.py @@ -217,6 +217,28 @@ def exchange(request: bytes) -> bytes: ) +@pytest.mark.unit +def test_schedule_summary_parses_only_utc_projected_timestamps() -> None: + next_backup = datetime(2026, 7, 27, 2, 30, tzinfo=UTC) + + def exchange(request: bytes) -> bytes: + parsed = json.loads(request) + response = ResponseEnvelope.success( + UUID(parsed["request_id"]), + SystemAction.SCHEDULE_SUMMARY, + { + "next_backup_at": next_backup.isoformat(), + "next_retention_at": None, + }, + ) + return json.dumps(response.to_wire()).encode() + + summary = UnixSocketSystemControlClient(exchange=exchange).get_schedule_summary() + + assert summary.next_backup_at == next_backup + assert summary.next_retention_at is None + + @pytest.mark.unit def test_invalid_or_oversized_response_fails_closed() -> None: invalid = UnixSocketSystemControlClient(exchange=lambda _request: b"{") diff --git a/tests/TimeLocker/system_control/test_retention.py b/tests/TimeLocker/system_control/test_retention.py new file mode 100644 index 0000000..18d5d9b --- /dev/null +++ b/tests/TimeLocker/system_control/test_retention.py @@ -0,0 +1,263 @@ +"""Retention execution, approval, locking, and trigger tests.""" + +from dataclasses import replace +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest + +from TimeLocker.system_control.models import RetentionPolicy, RunRecord +from TimeLocker.system_control.retention import ( + RetentionExecutionResult, + RetentionExecutor, + RetentionPlan, + RetentionRequestHandler, + RetentionTriggerCoordinator, + RetentionTriggerStore, +) +from TimeLocker.system_control.protocol import RequestEnvelope, project_response +from TimeLocker.system_control.storage import AtomicRecordStore, RepositoryMutationLock +from TimeLocker.system_control.types import ( + OperationTrigger, + OperationType, + ResultCode, + RunState, + SystemAction, +) + + +class RecordingAdapter: + def __init__(self, *, fail: bool = False) -> None: + self.fail = fail + self.calls: list[tuple[RetentionPlan, bool]] = [] + + def execute( + self, + plan: RetentionPlan, + *, + dry_run: bool, + ) -> RetentionExecutionResult: + self.calls.append((plan, dry_run)) + if self.fail: + raise RuntimeError("protected backend detail") + return RetentionExecutionResult(selected_snapshots=8, removed_snapshots=3) + + +def _unapproved_plan(**changes: object) -> RetentionPlan: + values = { + "target_id": "npbackup-production", + "repository_identity": "repository-01", + "credential_source": "system-environment-01", + "snapshot_filters": ("host:Bruce-5560",), + "policy": RetentionPolicy(), + } + values.update(changes) + return RetentionPlan(**values) + + +def _approved_plan(**changes: object) -> RetentionPlan: + unapproved = _unapproved_plan(**changes) + policy = replace( + unapproved.policy, + approved_fingerprint=unapproved.fingerprint, + ) + return replace(unapproved, policy=policy) + + +def _executor(tmp_path, adapter: RecordingAdapter | None = None) -> RetentionExecutor: + return RetentionExecutor( + store=AtomicRecordStore(tmp_path / "records"), + locks=RepositoryMutationLock(tmp_path / "locks"), + adapter=adapter or RecordingAdapter(), + ) + + +def _successful_backup() -> RunRecord: + now = datetime.now(timezone.utc) + return RunRecord( + run_id=uuid4(), + operation=OperationType.BACKUP, + trigger=OperationTrigger.SCHEDULED, + target_id="npbackup-production", + started_at=now, + completed_at=now, + state=RunState.SUCCEEDED, + result_code=ResultCode.BACKUP_SUCCEEDED, + ) + + +def test_fingerprint_covers_every_execution_input() -> None: + plan = _unapproved_plan() + variants = ( + _unapproved_plan(repository_identity="repository-02"), + _unapproved_plan(credential_source="system-environment-02"), + _unapproved_plan(snapshot_filters=("host:other",)), + _unapproved_plan(policy=replace(plan.policy, keep_daily=6)), + _unapproved_plan(policy=replace(plan.policy, keep_weekly=5)), + _unapproved_plan(policy=replace(plan.policy, keep_monthly=13)), + _unapproved_plan(policy=replace(plan.policy, keep_yearly=4)), + _unapproved_plan(policy=replace(plan.policy, group_by=("host",))), + _unapproved_plan(policy=replace(plan.policy, prune=True)), + ) + assert all(candidate.fingerprint != plan.fingerprint for candidate in variants) + + +def test_dry_run_does_not_authorize_later_mutation(tmp_path) -> None: + executor = _executor(tmp_path) + plan = _unapproved_plan() + + dry_run = executor.execute( + plan, + trigger=OperationTrigger.EXPLICIT, + dry_run=True, + ) + + assert dry_run.state is RunState.SUCCEEDED + assert dry_run.counters["dry_run"] == 1 + with pytest.raises(PermissionError, match="approval does not match"): + executor.execute( + plan, + trigger=OperationTrigger.EXPLICIT, + dry_run=False, + ) + + +def test_exact_approval_executes_separate_locked_run(tmp_path) -> None: + adapter = RecordingAdapter() + executor = _executor(tmp_path, adapter) + plan = _approved_plan() + + result = executor.execute( + plan, + trigger=OperationTrigger.EXPLICIT, + dry_run=False, + ) + + assert result.operation is OperationType.RETENTION + assert result.state is RunState.SUCCEEDED + assert result.policy_fingerprint == plan.fingerprint + assert result.counters == { + "dry_run": 0, + "removed_snapshots": 3, + "selected_snapshots": 8, + } + assert adapter.calls == [(plan, False)] + + +def test_lock_conflict_is_skipped_without_calling_adapter(tmp_path) -> None: + adapter = RecordingAdapter() + executor = _executor(tmp_path, adapter) + plan = _approved_plan() + + with executor.locks.acquire(plan.target_id, uuid4()): + result = executor.execute( + plan, + trigger=OperationTrigger.BACKUP_SUCCESS, + dry_run=False, + ) + + assert result.state is RunState.SKIPPED + assert result.result_code is ResultCode.OPERATION_CONFLICT + assert adapter.calls == [] + + +def test_adapter_failure_is_safe_and_terminal(tmp_path) -> None: + adapter = RecordingAdapter(fail=True) + executor = _executor(tmp_path, adapter) + + result = executor.execute( + _approved_plan(), + trigger=OperationTrigger.EXPLICIT, + dry_run=False, + ) + + assert result.state is RunState.FAILED + assert result.safe_summary == "Operation failed." + + +def test_post_backup_trigger_is_durable_at_most_once_and_does_not_change_backup( + tmp_path, +) -> None: + executor = _executor(tmp_path) + coordinator = RetentionTriggerCoordinator( + executor=executor, + trigger_store=RetentionTriggerStore(tmp_path / "triggers"), + ) + backup = _successful_backup() + plan = _approved_plan() + + first = coordinator.after_backup_success(backup, plan) + second = coordinator.after_backup_success(backup, plan) + + assert first is not None + assert first.trigger is OperationTrigger.BACKUP_SUCCESS + assert second is None + assert backup.state is RunState.SUCCEEDED + restarted = RetentionTriggerCoordinator( + executor=executor, + trigger_store=RetentionTriggerStore(tmp_path / "triggers"), + ) + assert restarted.after_backup_success(backup, plan) is None + + +def test_post_backup_trigger_rejects_non_success(tmp_path) -> None: + backup = replace( + _successful_backup(), + state=RunState.FAILED, + result_code=ResultCode.OPERATION_FAILED, + ) + coordinator = RetentionTriggerCoordinator( + executor=_executor(tmp_path), + trigger_store=RetentionTriggerStore(tmp_path / "triggers"), + ) + + assert coordinator.after_backup_success(backup, _approved_plan()) is None + + +def test_independent_schedule_is_disabled_by_default_and_can_be_enabled( + tmp_path, +) -> None: + executor = _executor(tmp_path) + plan = _approved_plan() + disabled = RetentionTriggerCoordinator( + executor=executor, + trigger_store=RetentionTriggerStore(tmp_path / "disabled-triggers"), + ) + enabled = RetentionTriggerCoordinator( + executor=executor, + trigger_store=RetentionTriggerStore(tmp_path / "enabled-triggers"), + independent_schedule_enabled=True, + ) + + assert disabled.scheduled(plan) is None + result = enabled.scheduled(plan) + assert result is not None + assert result.trigger is OperationTrigger.SCHEDULED + + +def test_protected_request_handler_requires_exact_fingerprint(tmp_path) -> None: + plan = _approved_plan() + coordinator = RetentionTriggerCoordinator( + executor=_executor(tmp_path), + trigger_store=RetentionTriggerStore(tmp_path / "triggers"), + ) + handler = RetentionRequestHandler(coordinator=coordinator, plan=plan) + request = RequestEnvelope( + request_id=uuid4(), + action=SystemAction.RETENTION_REQUEST, + parameters={"policy_fingerprint": plan.fingerprint, "dry_run": False}, + ) + + receipt = handler(request) + + assert receipt["accepted"] is True + assert receipt["status"] == "succeeded" + assert project_response(SystemAction.RETENTION_REQUEST, receipt) == receipt + with pytest.raises(PermissionError, match="does not match"): + handler( + RequestEnvelope( + request_id=uuid4(), + action=SystemAction.RETENTION_REQUEST, + parameters={"policy_fingerprint": "0" * 64, "dry_run": True}, + ) + ) diff --git a/tests/TimeLocker/system_control/test_tray_client.py b/tests/TimeLocker/system_control/test_tray_client.py new file mode 100644 index 0000000..4e5911e --- /dev/null +++ b/tests/TimeLocker/system_control/test_tray_client.py @@ -0,0 +1,196 @@ +"""Focused tests for the stand-alone tray service client.""" + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +from pytest import mark +import pytest + +from TimeLocker.system_control.client import ( + ProtocolErrorCode, + ResponseStatus, + SystemControlClientError, +) +from TimeLocker.system_control.models import ( + BackupActionRequest, + RunQuery, + RunRecord, + RunRecordView, + RetentionActionRequest, + ScheduleSummary, +) +from TimeLocker.system_control.models import OperationTrigger +from TimeLocker.system_control.types import ( + OperationType as BackendOperationType, + ResultCode, + RunState, +) +from TimeLocker.system_control.tray_client import TrayControlClient + + +class FakeBackend: + def __init__( + self, runs, summary, status_error=None, backup_error=None, retention_error=None + ): + self.runs = runs + self.summary = summary + self.status_error = status_error + self.backup_error = backup_error + self.retention_error = retention_error + self.requests = [] + + def list_runs(self, query: RunQuery): + self.requests.append(("list_runs", query)) + if self.status_error: + raise self.status_error + return self.runs + + def list_diagnostics(self, query): + raise AssertionError("not expected") + + def get_run(self, run_id): + raise AssertionError("not expected") + + def get_schedule_summary(self): + self.requests.append(("get_schedule_summary", None)) + if self.status_error: + raise self.status_error + return self.summary + + def request_backup(self, request: BackupActionRequest): + self.requests.append(("request_backup", request)) + if self.backup_error: + raise self.backup_error + return None + + def request_retention(self, request: RetentionActionRequest): + self.requests.append(("request_retention", request)) + if self.retention_error: + raise self.retention_error + return None + + +@mark.unit +def test_refresh_status_orders_runs_by_newest_and_projects_summary() -> None: + base_time = datetime(2026, 7, 26, 12, 0, tzinfo=UTC) + runs = [ + RunRecordView.from_record( + RunRecord( + run_id=uuid4(), + operation=BackendOperationType.BACKUP, + started_at=base_time - timedelta(minutes=60), + completed_at=base_time - timedelta(minutes=55), + state=RunState.SUCCEEDED, + target_id="prod", + trigger=OperationTrigger.EXPLICIT, + result_code=ResultCode.BACKUP_SUCCEEDED, + policy_fingerprint=None, + counters={}, + schema_version=1, + ) + ), + RunRecordView.from_record( + RunRecord( + run_id=uuid4(), + operation=BackendOperationType.RETENTION, + started_at=base_time - timedelta(minutes=10), + completed_at=base_time - timedelta(minutes=5), + state=RunState.FAILED, + target_id="prod", + trigger=OperationTrigger.EXPLICIT, + result_code=ResultCode.OPERATION_FAILED, + policy_fingerprint="a" * 64, + counters={}, + schema_version=1, + ) + ), + ] + + summary = ScheduleSummary( + next_backup_at=base_time + timedelta(hours=1), + next_retention_at=base_time + timedelta(hours=2), + ) + client = TrayControlClient( + client_factory=lambda: FakeBackend(runs, summary), + ) + + state = client.refresh_status() + + assert state.status == "error" + assert "Next backup" in state.tooltip + assert state.repository_count == 1 + assert state.last_retention_status == "Operation failed." + assert state.last_backup_status == "Backup completed successfully." + assert state.next_backup_at == base_time + timedelta(hours=1) + + +@mark.unit +def test_retention_action_requires_fingerprint() -> None: + client = TrayControlClient( + client_factory=lambda: FakeBackend([], ScheduleSummary(None, None)), + retention_policy_fingerprint=None, + ) + try: + client.perform_action("retention_now") + except ValueError as exc: + assert "retention policy fingerprint is required" in str(exc) + else: + raise AssertionError("expected a ValueError") + + +@mark.unit +def test_unavailable_backend_errors_are_retriable() -> None: + backend_error = SystemControlClientError( + ProtocolErrorCode.SYSTEM_BACKEND_UNAVAILABLE, + "backend unavailable", + status=ResponseStatus.UNAVAILABLE, + ) + client = TrayControlClient( + client_factory=lambda: FakeBackend( + [], ScheduleSummary(None, None), status_error=backend_error + ), + ) + + unavailable = client.refresh_status() + + assert unavailable.backend_available is False + assert unavailable.status == "warning" + assert "backend unavailable" in unavailable.tooltip.lower() + + backend = client._client + backend.status_error = None + client._retry_at = 0.0 + recovered = client.refresh_status() + + assert recovered.backend_available is True + assert recovered.status == "idle" + + +@mark.unit +def test_denied_backend_is_rendered_without_protected_detail() -> None: + denied = SystemControlClientError( + ProtocolErrorCode.SYSTEM_ACCESS_DENIED, + "detail that must not be rendered", + status=ResponseStatus.DENIED, + ) + client = TrayControlClient( + client_factory=lambda: FakeBackend( + [], ScheduleSummary(None, None), status_error=denied + ), + ) + + state = client.refresh_status() + + assert state.backend_available is True + assert state.tooltip == "TimeLocker - Access denied" + assert "detail" not in state.tooltip + + +@mark.unit +def test_tray_rejects_actions_outside_strict_allowlist() -> None: + client = TrayControlClient( + client_factory=lambda: FakeBackend([], ScheduleSummary(None, None)), + ) + + with pytest.raises(ValueError, match="unsupported tray action"): + client.perform_action("shell") diff --git a/tests/TimeLocker/system_control/test_tray_process_boundary.py b/tests/TimeLocker/system_control/test_tray_process_boundary.py new file mode 100644 index 0000000..f3861ca --- /dev/null +++ b/tests/TimeLocker/system_control/test_tray_process_boundary.py @@ -0,0 +1,47 @@ +"""Import and lifecycle boundaries for the independent tray process.""" + +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from TimeLocker.system_control.tray_entry import _single_instance + + +@pytest.mark.unit +def test_cli_import_does_not_load_platform_tray_module() -> None: + environment = dict(os.environ) + environment["PYTHONPATH"] = "src" + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; import TimeLocker.cli; " + "assert 'TimeLocker.monitoring.system_tray_integration' " + "not in sys.modules" + ), + ], + cwd=Path(__file__).parents[3], + env=environment, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.unit +def test_tray_single_instance_lock_rejects_second_owner(tmp_path) -> None: + lock_path = tmp_path / "tray.lock" + + with _single_instance(lock_path): + with pytest.raises(RuntimeError, match="already running"): + with _single_instance(lock_path): + pytest.fail("second tray instance acquired the same lock") + + assert not lock_path.exists() From 1ec3897f377a6a2a519f689d328845bc888da187 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:21:51 +0100 Subject: [PATCH 37/72] feat: complete spec 009 release readiness --- .../009-system-cli-tray-retention/tasks.md | 25 +- .../traceability.md | 13 +- .../verification.md | 53 +- pyproject.toml | 2 + scripts/validate_release_artifacts.py | 41 +- src/TimeLocker/cli.py | 4 +- .../cli_modules/commands/repositories.py | 9 +- .../cli_modules/helpers/service_helpers.py | 6 +- src/TimeLocker/services/repository_factory.py | 209 +++--- .../assets/timelocker-control.service | 2 +- .../assets/timelocker-retention.service | 16 + .../assets/timelocker-retention.timer | 10 + .../assets/timelocker-system-control-launcher | 5 + .../assets/timelocker-tray-launcher | 5 + .../assets/timelocker-tray.desktop | 8 + .../system_control/backend_entry.py | 670 ++++++++++++++++++ .../system_control/backend_launcher_entry.py | 21 + src/TimeLocker/system_control/deployment.py | 258 +++++++ .../system_control/release_launcher.py | 33 +- src/TimeLocker/system_control/tray_entry.py | 12 +- .../system_control/tray_launcher_entry.py | 21 + .../system_control/windows_adapter.py | 133 ++++ .../test_repos_credentials_command_usage.py | 7 +- .../test_repos_credentials_integration.py | 7 +- .../services/test_repository_factory.py | 43 ++ 25 files changed, 1462 insertions(+), 151 deletions(-) create mode 100644 src/TimeLocker/system_control/assets/timelocker-retention.service create mode 100644 src/TimeLocker/system_control/assets/timelocker-retention.timer create mode 100755 src/TimeLocker/system_control/assets/timelocker-system-control-launcher create mode 100755 src/TimeLocker/system_control/assets/timelocker-tray-launcher create mode 100644 src/TimeLocker/system_control/assets/timelocker-tray.desktop create mode 100644 src/TimeLocker/system_control/backend_entry.py create mode 100644 src/TimeLocker/system_control/backend_launcher_entry.py create mode 100644 src/TimeLocker/system_control/deployment.py create mode 100644 src/TimeLocker/system_control/tray_launcher_entry.py create mode 100644 src/TimeLocker/system_control/windows_adapter.py create mode 100644 tests/TimeLocker/services/test_repository_factory.py diff --git a/docs/specs/009-system-cli-tray-retention/tasks.md b/docs/specs/009-system-cli-tray-retention/tasks.md index 0a36bb2..c5cdbcc 100644 --- a/docs/specs/009-system-cli-tray-retention/tasks.md +++ b/docs/specs/009-system-cli-tray-retention/tasks.md @@ -225,7 +225,7 @@ T009 -> T010 -> T011 -> T012 ## Phase 4: Installation, portability, and live acceptance -- [ ] T009 Integrate release assets, platform adapters, upgrade, and rollback. +- [x] T009 Integrate release assets, platform adapters, upgrade, and rollback. - Depends on: T006, T007, T008 - Requirements: Requirement 1; Requirement 2; Requirement 3 AC6-AC8; Requirement 6 AC1-AC6 @@ -236,11 +236,26 @@ T009 -> T010 -> T011 -> T012 backend, socket, tray, and schedules; upgrade validates them before retirement; rollback restores the prior release without deleting records or changing policy. - - Evidence: Pending. - - [ ] T009.1 Add artifact manifest, permission, upgrade, and rollback tests. - - [ ] T009.2 Complete Linux install assets and Windows service/named-pipe test + - Evidence: Compatibility-checked deployment and immutable selected-release activation now cover CLI/backend/tray entrypoints, exact asset hashes and permissions, atomic installation, health-gated upgrade, rollback preserving policy and run records, Linux service/socket/tray/disabled-retention-timer assets, and a fail-closed Windows token/group/named-pipe adapter seam. The focused Phase 4 suite passed 178 tests; the expanded system-control/monitoring/integration suite passed 753 with 1 skipped; wheel/sdist validation covered four console entrypoints and 20 package-data files; an installed headless artifact imported CLI without loading tray code or requiring pystray; staged systemd unit verification passed with recursive dependency errors disabled. + - Status: Repository readiness complete; live Mint installation, production adapter activation, authorized/denied actions, retention execution, and rollback rehearsal remain T010 and require explicit host-mutation approval. + - Evidence mode: validation + - [x] T009.1 Add artifact manifest, permission, upgrade, and rollback tests. + - Evidence: `test_deployment.py` passed manifest hash, installed-mode, + health-gated upgrade, failed-upgrade, rollback, and policy/run-record + preservation cases inside the 178-test Phase 4 suite. + - Evidence mode: validation + - [x] T009.2 Complete Linux install assets and Windows service/named-pipe test double. - - [ ] T009.3 Prove headless install requires no GUI dependencies. + - Evidence: The validated artifact contains backend/socket, stable + CLI/backend/tray launchers, tray autostart, and disabled retention timer + assets; `test_windows_adapter.py` passed four token, membership, request + bound, and close-path tests. + - Evidence mode: validation + - [x] T009.3 Prove headless install requires no GUI dependencies. + - Evidence: A fresh artifact venv installed the wheel without GUI extras, + ran `timelocker-system-control --help`, imported `TimeLocker.cli` without + loading tray integration, and confirmed `pystray` was absent. + - Evidence mode: validation - [ ] T010 Run controlled Linux Mint live acceptance and rollback rehearsal. - Depends on: T009 diff --git a/docs/specs/009-system-cli-tray-retention/traceability.md b/docs/specs/009-system-cli-tray-retention/traceability.md index 2aa8fce..d6be13c 100644 --- a/docs/specs/009-system-cli-tray-retention/traceability.md +++ b/docs/specs/009-system-cli-tray-retention/traceability.md @@ -63,9 +63,9 @@ targets. Reconcile this matrix whenever any linked artifact changes. | Design Section | Requirements | Tasks | Interfaces Or Files | Verification | Coverage State | Residual Destination | |----------------|--------------|-------|---------------------|--------------|----------------|----------------------| | Decisions D001-D005 | R1, R2, R4 | T001, T003, T005-T006 | `system_control`, CLI, install assets | V1-V6 | not-covered | T001 | -| Decision D006 and independent tray | R3, R4 | T007, T009 | monitoring/tray/platform modules | V7, V9-V10 | repository-validated | T009 | -| Decision D007 and retention flow | R5 | T002, T008 | retention/scheduling modules | V3, V8, V10 | repository-validated | T009 | -| Migration and compatibility | R1-R6 | T005, T007-T010 | install/release/system assets | V9-V10 | not-covered | T009 | +| Decision D006 and independent tray | R3, R4 | T007, T009 | monitoring/tray/platform modules | V7, V9-V10 | repository-validated | T010 | +| Decision D007 and retention flow | R5 | T002, T008 | retention/scheduling modules | V3, V8, V10 | repository-validated | T010 | +| Migration and compatibility | R1-R6 | T005, T007-T010 | install/release/system assets | V9-V10 | repository-validated | T010 | | Durable promotion | R1-R6 | T011-T012 | `docs/` targets | V12 | not-covered | T011 | ## Open Decision Impact @@ -82,7 +82,7 @@ targets. Reconcile this matrix whenever any linked artifact changes. - `complete` in the requirement-delivery matrix means every accepted criterion has an explicit design, task, verification, and durable-target mapping. It does not claim implementation completion. -- Phase 3 repository implementation evidence now exists in `tasks.md` and +- Phase 4 repository implementation evidence now exists in `tasks.md` and `verification.md`; live integration and promotion evidence remain pending. ## Reconciliation @@ -90,5 +90,6 @@ targets. Reconcile this matrix whenever any linked artifact changes. Reviewed against the 2026-07-26 requirements and design revisions. Every Requirement 1-6 acceptance criterion has an explicit task mapping, including Requirement 4 AC10-AC11 and the tightened security constraints. Phase 3 -repository implementation evidence now covers Decisions D006-D007; T009-T012 -remain the open live-integration, promotion, and closure path. +repository implementation evidence now covers Decisions D006-D007 and +packaging/portability. T010-T012 remain the open live-integration, promotion, +and closure path. diff --git a/docs/specs/009-system-cli-tray-retention/verification.md b/docs/specs/009-system-cli-tray-retention/verification.md index 5e836bd..256eac0 100644 --- a/docs/specs/009-system-cli-tray-retention/verification.md +++ b/docs/specs/009-system-cli-tray-retention/verification.md @@ -21,8 +21,8 @@ review, durable promotion, and closure. |------|-----------|--------|----------| | Requirements acceptance criteria reviewed | yes | pending | Requirements amended through 2026-07-26 | | Design and traceability approved | yes | passed | Owner approved implementation; lifecycle context reports no Phase 1 gaps | -| Task evidence complete | yes | partial | T001-T008 complete; T009-T012 pending | -| Automated tests pass or alternate verification recorded | yes | partial | Phase 2 focused suite: 177 passed; Phase 3 system-control suite: 149 passed with 83.09% branch-aware coverage | +| Task evidence complete | yes | partial | T001-T009 complete; T010-T012 pending | +| Automated tests pass or alternate verification recorded | yes | partial | Phase 4 focused suite: 178 passed; expanded suite: 753 passed, 1 skipped | | Security and operations expert review complete | yes | partial | T004 and Phase 2 checkpoints complete; final T012 review pending | | Linux Mint live acceptance and rollback rehearsal complete | yes | pending | | | Durable documentation promoted | yes | pending | | @@ -52,26 +52,26 @@ Commands are refined through Agent Workbench before execution. | Command | Purpose | Result | Evidence | |---------|---------|--------|----------| -| `python3 -m pytest tests/TimeLocker/system_control -q` | Protocol, auth, storage, IPC, locks | pending | V1-V4 | +| `python3 -m pytest tests/TimeLocker/system_control -q` | Protocol, auth, storage, IPC, locks | passed in expanded suite | V1-V4, V9 | | `python3 -m pytest tests/TimeLocker/cli/test_monitoring_commands.py -q` | CLI local/system log and run behavior | pending | V6 | -| `python3 -m pytest tests/TimeLocker/monitoring -q` | Notification/tray/headless regression | pending | V7 | +| `python3 -m pytest tests/TimeLocker/monitoring -q` | Notification/tray/headless regression | passed in expanded suite | V7, V9 | | `python3 -m pytest tests/TimeLocker/scheduling -q` | Retention and scheduler regression where present | pending | V8 | | `python3 -m pytest tests/TimeLocker/platform -q` | Platform adapters and portability | pending | V4, V7, V9 | | `python3 -m pytest -m "not performance and not stress and not minio"` | Full configured non-live regression suite | pending | V1-V9 | -| `systemd-analyze verify ` | Linux unit and socket validation | pending | V4, V9 | +| `systemd-analyze verify ` | Linux unit and socket validation | passed in isolated root | V4, V9 | | `python3 scripts/link_checker.py` | Durable/spec link validation | pending | V12 | -| `git diff --check` | Patch integrity | pending | Every implementation slice | +| `git diff --check` | Patch integrity | passed | Every implementation slice | ## Requirement Coverage | Requirement | Acceptance criteria covered | Evidence | Residual risk | |-------------|-----------------------------|----------|---------------| -| Requirement 1 | AC1-AC4 | V5 repository validation passed; V9-V10 pending | Live launcher/rollback | +| Requirement 1 | AC1-AC4 | V5 and V9 repository validation passed; V10 pending | Live launcher/rollback | | Requirement 2 | AC1-AC6 | V2, V4-V5, V10 pending | Platform authorization UX | -| Requirement 3 | AC1-AC8 | V7 repository validation passed; V9-V10 pending | Desktop diversity and live session behavior | +| Requirement 3 | AC1-AC8 | V7 and V9 repository validation passed; V10 pending | Desktop diversity and live session behavior | | Requirement 4 | AC1-AC11 | V1-V3 and V6 repository validation passed; V4 and V10 live evidence pending | Redaction and NSS variance | | Requirement 5 | AC1-AC11 | V1, V3, and V8 repository validation passed; V10 pending | Live repository timing | -| Requirement 6 | AC1-AC6 | V3, V5, and V7 repository validation passed; V9-V10 pending | Cross-platform rollout | +| Requirement 6 | AC1-AC6 | V3, V5, V7, and V9 repository validation passed; V10 pending | Cross-platform rollout | ## Correctness Property Coverage @@ -84,8 +84,8 @@ Commands are refined through Agent Workbench before execution. | CP-005 | V1, V8, V10 | V1 and V8 repository validation passed | Live retention acceptance remains V10 | | CP-006 | V1-V2, V4-V6 | V1-V3 and V5-V6 repository validation passed | Live IPC remains V4/V10 | | CP-007 | V2, V4, V10 | repository authorization and denial validation passed | Live NSS/session behavior remains V4/V10 | -| CP-008 | V3, V9-V10 | V3 repository validation passed | Installed coordination and restart remain V9-V10 | -| CP-009 | V1, V7, V9 | V1 and V7 repository validation passed | Live Windows remains follow-up | +| CP-008 | V3, V9-V10 | V3 and V9 repository validation passed | Installed coordination and restart remain V10 | +| CP-009 | V1, V7, V9 | V1, V7, and V9 repository validation passed | Live Windows remains follow-up | | CP-010 | V3, V8, V10 | V3 and V8 repository validation passed | Live retention failure isolation remains V10 | | CP-011 | V1-V2, V4, V6, V10 | repository projection and CLI validation passed | Live metadata-leak acceptance remains V10 | @@ -93,11 +93,11 @@ Commands are refined through Agent Workbench before execution. | Broad requirement, design target, or review finding | Implemented in this spec | Coverage state | Deferred or rejected work | Destination | Blocks closure? | Evidence | |-----------------------------------------------------|--------------------------|----------------|---------------------------|-------------|-----------------|----------| -| Linux system command/control plane | Shared contracts, store, dispatcher, staged launcher, and CLI client/views | partial | Live artifact integration and host acceptance | T009-T010 | yes | T001-T006 evidence | -| Group-authorized system records | Current-membership dispatcher and structured CLI projection | partial | Live NSS/socket acceptance | T009-T010 | yes | T003, T004, T006 evidence | -| Independent tray | Standalone tray entry point, bounded tray IPC client, strict menu allowlist, singleton lock, and headless-safe monitoring imports | partial | Installed desktop-session, launcher integration, and live IPC acceptance | T009-T010 | yes | T007 evidence | -| Retention automation | Approved retention executor, exact policy fingerprinting, durable backup-success trigger claiming, explicit request handler, and independently gated schedule | partial | Live backend composition, installed schedule assets, and production-host timing acceptance | T009-T010 | yes | T008 evidence | -| Windows shared architecture | none | not-covered | Live Windows adapter/acceptance | T001, T009 then roadmap | yes for contracts; no for live Windows | pending | +| Linux system command/control plane | Shared contracts, store, dispatcher, backend entrypoint, compatible assets, staged launcher, and CLI client/views | partial | Live artifact integration and host acceptance | T010 | yes | T001-T006, T009 evidence | +| Group-authorized system records | Current-membership dispatcher and structured CLI projection | partial | Live NSS/socket acceptance | T010 | yes | T003, T004, T006, T009 evidence | +| Independent tray | Standalone tray entry point, bounded tray IPC client, strict menu allowlist, singleton lock, headless-safe imports, launcher, and autostart asset | partial | Installed desktop-session and live IPC acceptance | T010 | yes | T007, T009 evidence | +| Retention automation | Approved executor, exact fingerprinting, durable triggers, request handler, and disabled independent schedule asset | partial | Production adapter activation and live timing acceptance | T010 | yes | T008-T009 evidence | +| Windows shared architecture | Token-derived identity, current-group resolver, and named-pipe transport seam with Linux-hosted contract tests | repository-validated | Live Windows service/pipe implementation and acceptance | Platform roadmap | no for this Linux reference closure | T009 evidence | | Raw journald delegation | rejected | out-of-scope | Rejected because it exposes unrelated/protected records | none | no | Design D002 | | User-scoped backup partitions | none | out-of-scope | Separate authorization model | GitHub issue #70 | no | Requirements non-goal | @@ -125,7 +125,8 @@ Commands are refined through Agent Workbench before execution. | T006 | complete | Integrated system-control/CLI/help suite passed 177 tests; system-control package measured 88.2% branch-aware coverage; Ruff, format, compile, wheel, and patch checks passed | Live socket and operator-group acceptance remain T009/T010 | | T007 | complete | Independent tray entry point, strict tray allowlist, backend-unavailable/denied projection, and headless-safe monitoring imports; 190-test repository slice passed | Installed desktop-session and live IPC acceptance remain T009-T010 | | T008 | complete | Approved retention executor, trigger claiming, protected request handler, and independent schedule gate; system-control suite passed 149 tests with 83.09% branch-aware coverage | Live backend composition and host scheduling acceptance remain T009-T010 | -| T009-T012 | pending | No implementation evidence | Later implementation, promotion, and closure phases | +| T009 | complete | 178-test focused Phase 4 suite; 753-test expanded regression; validated wheel/sdist, entrypoints, assets, headless import, staged units, upgrade, and rollback | No host state changed; live installation and production adapter activation remain T010 | +| T010-T012 | pending | No completion evidence | Live acceptance, promotion, final review, and closure | ## Evidence Log @@ -154,6 +155,11 @@ Commands are refined through Agent Workbench before execution. | 2026-07-26 | System-control, monitoring, and integration regression slice | 190 passed | Tray/process boundaries, retention execution, monitoring compatibility, reconnect, authorization projection, and schedule summaries | | 2026-07-26 | Ruff check/format, compileall, wheel build/inventory, and `git diff --check` | passed | Wheel contains `timelocker-tray` and all new Phase 3 modules; no host state changed | | 2026-07-26 | Expanded `system_control`, `monitoring`, and `integration` pytest run | 733 passed, 1 skipped, 2 failed, 4 setup errors | The failures are confined to repository credential integration paths outside this diff: five expect a legacy credential-file location and one cannot register S3 because optional `b2sdk` is absent. They do not invalidate the bounded Phase 3 suites but remain repository test debt. | +| 2026-07-26 | Credential-path and backend-registration reconciliation | 6 focused tests passed | `--config-dir` consistently treats the argument as the configuration root and stores credentials under `credentials/credentials.enc`; missing B2 registration no longer prevents S3 registration. No credential contents or live stores were read, copied, or deleted. | +| 2026-07-26 | Phase 4 focused system-control, credential, and artifact suite | 178 passed | Backend/release entrypoints, exact asset manifest, permissions, Windows adapter seam, upgrade, rollback, credential paths, and release metadata passed. | +| 2026-07-26 | Expanded `system_control`, `monitoring`, and `integration` pytest run | 753 passed, 1 skipped | Previous six credential/backend-registration failures are resolved; existing warnings remain non-blocking. | +| 2026-07-26 | Wheel/sdist validation and installed headless smoke | passed | Four console entrypoints and 20 package-data files validated; CLI import did not load tray code and the environment had no `pystray` dependency. | +| 2026-07-26 | Staged `systemd-analyze verify --recursive-errors=no --root=...` | passed | Backend socket/service and disabled retention service/timer parsed successfully against an isolated staged executable. | ## T004 Review Finding Dispositions @@ -268,9 +274,8 @@ protected metadata, widen privilege, interrupt backups, or delete snapshots. - **Ready for promotion:** no - **Ready for release:** no - **Ready for closure:** no -- **Ready for implementation:** yes for Phase 4 task T009; later - live-host mutations still require T010 - approval +- **Ready for implementation:** yes for Phase 4 task T010; live-host + mutations require explicit approval ## Related Artifacts @@ -283,9 +288,9 @@ protected metadata, widen privilege, interrupt backups, or delete snapshots. ## Reconciliation -Reviewed against the 2026-07-26 requirements and design revisions. T001-T008 -now provide executed Phase 1-3 evidence for V1-V3, V5-V8, and repository-local +Reviewed against the 2026-07-26 requirements and design revisions. T001-T009 +now provide executed repository evidence for V1-V9 and repository-local portions of V4/V11. Real socket activation, installed ownership/modes, live NSS -behavior, backend composition on the host, authorization prompts, and host -restart remain pending under T009-T010; durable promotion and closure remain +behavior, production adapter activation, authorization prompts, and host +restart remain pending under T010; durable promotion and closure remain incomplete. diff --git a/pyproject.toml b/pyproject.toml index 109e49c..e51119a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,6 +88,7 @@ gui = [ timelocker = "TimeLocker.cli:main" tl = "TimeLocker.cli:main" timelocker-tray = "TimeLocker.system_control.tray_entry:main" +timelocker-system-control = "TimeLocker.system_control.backend_entry:main" [project.urls] Homepage = "https://github.com/Auriora/TimeLocker" @@ -115,6 +116,7 @@ TimeLocker = [ "system_control/assets/*.json", "system_control/assets/*.service", "system_control/assets/*.socket", + "system_control/assets/*.timer", "system_control/assets/timelocker-*", "system_control/assets/tl-launcher", ] diff --git a/scripts/validate_release_artifacts.py b/scripts/validate_release_artifacts.py index b289b07..721e87e 100755 --- a/scripts/validate_release_artifacts.py +++ b/scripts/validate_release_artifacts.py @@ -16,6 +16,8 @@ EXPECTED_REQUIRES_PYTHON = ">=3.12,<3.14" EXPECTED_ENTRY_POINTS = { "timelocker": "TimeLocker.cli:main", + "timelocker-system-control": "TimeLocker.system_control.backend_entry:main", + "timelocker-tray": "TimeLocker.system_control.tray_entry:main", "tl": "TimeLocker.cli:main", } @@ -28,9 +30,9 @@ def project_metadata(root: Path) -> dict[str, object]: def package_version(root: Path) -> str: module = ast.parse((root / "src/TimeLocker/__init__.py").read_text()) for statement in module.body: - if ( - isinstance(statement, ast.Assign) - and any(isinstance(target, ast.Name) and target.id == "__version__" for target in statement.targets) + if isinstance(statement, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == "__version__" + for target in statement.targets ): return str(ast.literal_eval(statement.value)) raise AssertionError("src/TimeLocker/__init__.py does not define __version__") @@ -69,13 +71,21 @@ def assert_requires_python(actual: str, artifact: str) -> None: def inspect_wheel(path: Path, expected_version: str, package_data: set[str]) -> None: with zipfile.ZipFile(path) as archive: names = set(archive.namelist()) - metadata_name = next(name for name in names if name.endswith(".dist-info/METADATA")) - entry_points_name = next(name for name in names if name.endswith(".dist-info/entry_points.txt")) + metadata_name = next( + name for name in names if name.endswith(".dist-info/METADATA") + ) + entry_points_name = next( + name for name in names if name.endswith(".dist-info/entry_points.txt") + ) version, requires_python = parse_metadata(archive.read(metadata_name)) entry_points = parse_entry_points(archive.read(entry_points_name)) - assert version == expected_version, f"wheel version is {version}, expected {expected_version}" + assert version == expected_version, ( + f"wheel version is {version}, expected {expected_version}" + ) assert_requires_python(requires_python, "wheel") - assert entry_points == EXPECTED_ENTRY_POINTS, f"wheel entry points differ: {entry_points}" + assert entry_points == EXPECTED_ENTRY_POINTS, ( + f"wheel entry points differ: {entry_points}" + ) missing = package_data - names assert not missing, f"wheel is missing package data: {sorted(missing)}" @@ -83,11 +93,15 @@ def inspect_wheel(path: Path, expected_version: str, package_data: set[str]) -> def inspect_sdist(path: Path, expected_version: str, package_data: set[str]) -> None: with tarfile.open(path, "r:gz") as archive: names = {PurePosixPath(name) for name in archive.getnames()} - pkg_info = next(name for name in names if len(name.parts) == 2 and name.name == "PKG-INFO") + pkg_info = next( + name for name in names if len(name.parts) == 2 and name.name == "PKG-INFO" + ) extracted = archive.extractfile(str(pkg_info)) assert extracted is not None version, requires_python = parse_metadata(extracted.read()) - assert version == expected_version, f"sdist version is {version}, expected {expected_version}" + assert version == expected_version, ( + f"sdist version is {version}, expected {expected_version}" + ) assert_requires_python(requires_python, "sdist") prefix = pkg_info.parent expected_names = {prefix / "src" / PurePosixPath(name) for name in package_data} @@ -96,7 +110,10 @@ def inspect_sdist(path: Path, expected_version: str, package_data: set[str]) -> def write_and_verify_hashes(artifacts: list[Path], destination: Path) -> None: - lines = [f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}" for path in artifacts] + lines = [ + f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}" + for path in artifacts + ] destination.write_text("\n".join(lines) + "\n") for line, path in zip(destination.read_text().splitlines(), artifacts, strict=True): digest, filename = line.split(" ", maxsplit=1) @@ -131,7 +148,9 @@ def validate(root: Path, dist: Path, expected_version: str) -> None: def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--expected-version", required=True) - parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument( + "--root", type=Path, default=Path(__file__).resolve().parents[1] + ) parser.add_argument("--dist", type=Path) args = parser.parse_args() root = args.root.resolve() diff --git a/src/TimeLocker/cli.py b/src/TimeLocker/cli.py index 5e6d6e1..3214e0f 100644 --- a/src/TimeLocker/cli.py +++ b/src/TimeLocker/cli.py @@ -2019,8 +2019,10 @@ def _create_credential_manager( """Instantiate credential manager respecting configuration directory.""" from .security.credential_manager import CredentialManager + credential_dir = config_dir / "credentials" if config_dir is not None else None return cast( - _CredentialManagerLike, cast(object, CredentialManager(config_dir=config_dir)) + _CredentialManagerLike, + cast(object, CredentialManager(config_dir=credential_dir)), ) diff --git a/src/TimeLocker/cli_modules/commands/repositories.py b/src/TimeLocker/cli_modules/commands/repositories.py index 1c5bf24..e66a4fe 100644 --- a/src/TimeLocker/cli_modules/commands/repositories.py +++ b/src/TimeLocker/cli_modules/commands/repositories.py @@ -186,16 +186,21 @@ def _normalize_repository_list(raw_repositories: object) -> list[object]: return [] +def _credential_directory(config_dir: Optional[Path]) -> Optional[Path]: + """Resolve the credential-store directory below an explicit config root.""" + return config_dir / "credentials" if config_dir is not None else None + + def _create_credential_manager(config_dir: Optional[Path] = None): """Instantiate credential manager respecting configuration directory.""" - return CredentialManager() + return CredentialManager(config_dir=_credential_directory(config_dir)) def _create_security_manager(config_dir: Optional[Path] = None): """Create security manager with access manager integration.""" from TimeLocker.security import AccessManager - credential_manager = CredentialManager(config_dir=config_dir) + credential_manager = CredentialManager(config_dir=_credential_directory(config_dir)) security_service = SecurityService(credential_manager, config_dir=config_dir) access_manager = AccessManager(config_dir=config_dir) diff --git a/src/TimeLocker/cli_modules/helpers/service_helpers.py b/src/TimeLocker/cli_modules/helpers/service_helpers.py index d9808b9..1fde089 100644 --- a/src/TimeLocker/cli_modules/helpers/service_helpers.py +++ b/src/TimeLocker/cli_modules/helpers/service_helpers.py @@ -57,7 +57,8 @@ def _create_credential_manager(config_dir: Optional[Path] = None): """Instantiate credential manager respecting configuration directory.""" from ...security.credential_manager import CredentialManager - return CredentialManager(config_dir=config_dir) + credential_dir = config_dir / "credentials" if config_dir is not None else None + return CredentialManager(config_dir=credential_dir) def _create_security_manager(config_dir: Optional[Path] = None): @@ -65,7 +66,8 @@ def _create_security_manager(config_dir: Optional[Path] = None): from ...security import CredentialManager, AccessManager from ...security import SecurityService - credential_manager = CredentialManager(config_dir=config_dir) + credential_dir = config_dir / "credentials" if config_dir is not None else None + credential_manager = CredentialManager(config_dir=credential_dir) security_service = SecurityService(credential_manager, config_dir=config_dir) access_manager = AccessManager(config_dir=config_dir) diff --git a/src/TimeLocker/services/repository_factory.py b/src/TimeLocker/services/repository_factory.py index 5681e3e..a6496c8 100644 --- a/src/TimeLocker/services/repository_factory.py +++ b/src/TimeLocker/services/repository_factory.py @@ -19,12 +19,16 @@ from typing import Dict, List, Type, Optional from urllib.parse import urlparse -from ..interfaces import IRepositoryFactory, RepositoryFactoryError, UnsupportedSchemeError +from ..interfaces import ( + IRepositoryFactory, + RepositoryFactoryError, + UnsupportedSchemeError, +) from ..interfaces.backup_engine_plugin import BackupEngine, EngineNotAvailableError from ..backup_repository import BackupRepository from .validation_service import ValidationService from .plugin_registry import get_plugin_registry -from ..utils import with_error_handling, ErrorContext +from ..utils import with_error_handling logger = logging.getLogger(__name__) @@ -32,7 +36,7 @@ class RepositoryFactory(IRepositoryFactory): """ Concrete implementation of repository factory following Abstract Factory pattern. - + This factory supports the Open/Closed Principle by allowing new repository types to be registered without modifying existing code, and follows the Single Responsibility Principle by focusing solely on repository creation. @@ -57,19 +61,24 @@ def _get_credential_manager(self): """Get or create credential manager instance (lazy loading)""" if self._credential_manager is None: from TimeLocker.security.credential_manager import CredentialManager + self._credential_manager = CredentialManager() # Try auto-unlock for non-interactive operations if self._credential_manager.is_locked(): try: if not self._credential_manager.ensure_unlocked(allow_prompt=False): - logger.debug("Credential manager remains locked after non-interactive unlock attempts.") + logger.debug( + "Credential manager remains locked after non-interactive unlock attempts." + ) except Exception as exc: logger.debug("Credential manager unlock attempt failed: %s", exc) else: if self._credential_manager.is_locked(): try: if not self._credential_manager.ensure_unlocked(allow_prompt=False): - logger.debug("Credential manager remains locked after non-interactive unlock attempts.") + logger.debug( + "Credential manager remains locked after non-interactive unlock attempts." + ) except Exception as exc: logger.debug("Credential manager unlock attempt failed: %s", exc) return self._credential_manager @@ -85,50 +94,67 @@ def get_credential_manager(self): def _register_default_types(self) -> None: """Register default repository types""" - try: - # Import and register built-in repository types - from ..restic.Repositories.local import LocalResticRepository - from ..restic.Repositories.s3 import S3ResticRepository - from ..restic.Repositories.b2 import B2ResticRepository - - # Local filesystem repositories - self.register_repository_type('local', LocalResticRepository) - self.register_repository_type('file', LocalResticRepository) - - # Cloud backends - self.register_repository_type('s3', S3ResticRepository) - self.register_repository_type('b2', B2ResticRepository) - logger.debug("Registered default repository types (local, file, s3, b2)") - except ImportError as e: - logger.warning(f"Could not register default repository types: {e}") - + + def _register_type(scheme: str, module_path: str, class_name: str) -> None: + """Load and register one backend repository class.""" + try: + module = __import__(module_path, fromlist=[class_name]) + repository_class = getattr(module, class_name) + self.register_repository_type(scheme, repository_class) + logger.debug( + "Registered repository type '%s' from %s", scheme, module_path + ) + except (ImportError, AttributeError) as exc: + logger.warning( + "Repository backend '%s' unavailable; skipping registration for optional dependency issue", + scheme, + ) + logger.debug("Backend registration failure for '%s': %s", scheme, exc) + + # Local filesystem repositories + _register_type( + "local", "TimeLocker.restic.Repositories.local", "LocalResticRepository" + ) + _register_type( + "file", "TimeLocker.restic.Repositories.local", "LocalResticRepository" + ) + _register_type("s3", "TimeLocker.restic.Repositories.s3", "S3ResticRepository") + _register_type("b2", "TimeLocker.restic.Repositories.b2", "B2ResticRepository") + logger.debug("Registered available default repository types") + def _register_default_plugins(self) -> None: """Register default backup engine plugins""" try: - from .plugins import ResticEnginePlugin, RsyncEnginePlugin, RcloneEnginePlugin - + from .plugins import ( + ResticEnginePlugin, + RsyncEnginePlugin, + RcloneEnginePlugin, + ) + # Register built-in plugins self._plugin_registry.register_plugin(ResticEnginePlugin) self._plugin_registry.register_plugin(RsyncEnginePlugin) self._plugin_registry.register_plugin(RcloneEnginePlugin) - - logger.debug("Registered default backup engine plugins (restic, rsync, rclone)") + + logger.debug( + "Registered default backup engine plugins (restic, rsync, rclone)" + ) except ImportError as e: logger.warning(f"Could not register default plugins: {e}") except Exception as e: logger.warning(f"Error registering default plugins: {e}") @with_error_handling("register_repository_type", "RepositoryFactory") - def register_repository_type(self, - scheme: str, - repository_class: Type[BackupRepository]) -> None: + def register_repository_type( + self, scheme: str, repository_class: Type[BackupRepository] + ) -> None: """ Register a repository implementation for a specific URI scheme. - + Args: scheme: URI scheme (e.g., 'local', 's3', 'b2') repository_class: Repository implementation class - + Raises: RepositoryFactoryError: If registration fails """ @@ -137,7 +163,7 @@ def register_repository_type(self, if not issubclass(repository_class, BackupRepository): raise RepositoryFactoryError( - f"Repository class must inherit from BackupRepository: {repository_class}" + f"Repository class must inherit from BackupRepository: {repository_class}" ) scheme = scheme.lower() @@ -146,12 +172,13 @@ def register_repository_type(self, logger.warning(f"Overriding existing repository type for scheme: {scheme}") self._repository_types[scheme] = repository_class - logger.debug(f"Registered repository type '{repository_class.__name__}' for scheme '{scheme}'") + logger.debug( + f"Registered repository type '{repository_class.__name__}' for scheme '{scheme}'" + ) - def create_repository(self, - uri: str, - password: Optional[str] = None, - **kwargs) -> BackupRepository: + def create_repository( + self, uri: str, password: Optional[str] = None, **kwargs + ) -> BackupRepository: """ Create a repository instance from URI. @@ -172,18 +199,18 @@ def create_repository(self, validation_result = self._validation_service.validate_repository_uri(uri) if not validation_result.is_valid: raise RepositoryFactoryError( - f"Invalid repository URI: {', '.join(validation_result.errors)}" + f"Invalid repository URI: {', '.join(validation_result.errors)}" ) # Parse URI to extract scheme parsed = urlparse(uri) - scheme = parsed.scheme.lower() if parsed.scheme else 'local' + scheme = parsed.scheme.lower() if parsed.scheme else "local" # Check if scheme is supported if not self.is_scheme_supported(scheme): raise UnsupportedSchemeError( - f"Unsupported URI scheme '{scheme}'. " - f"Supported schemes: {', '.join(self.get_supported_schemes())}" + f"Unsupported URI scheme '{scheme}'. " + f"Supported schemes: {', '.join(self.get_supported_schemes())}" ) # Get repository class and create instance @@ -191,22 +218,28 @@ def create_repository(self, try: # Create repository instance with appropriate parameters - logger.debug(f"Repository factory received password: {'***' if password else 'None'}") + logger.debug( + f"Repository factory received password: {'***' if password else 'None'}" + ) if password: - kwargs['password'] = password + kwargs["password"] = password logger.debug("Password added to kwargs") # Provide credential manager to repository - kwargs['credential_manager'] = self._get_credential_manager() + kwargs["credential_manager"] = self._get_credential_manager() logger.debug("Credential manager added to kwargs") # Pass repository_name if provided (for per-repository credential lookup) - if 'repository_name' in kwargs: - logger.debug(f"Repository name provided for credential lookup: {kwargs['repository_name']}") + if "repository_name" in kwargs: + logger.debug( + f"Repository name provided for credential lookup: {kwargs['repository_name']}" + ) # Use from_parsed_uri class method if available, otherwise fall back to constructor - if hasattr(repository_class, 'from_parsed_uri'): - logger.debug(f"Using from_parsed_uri with kwargs: {list(kwargs.keys())}") + if hasattr(repository_class, "from_parsed_uri"): + logger.debug( + f"Using from_parsed_uri with kwargs: {list(kwargs.keys())}" + ) repository = repository_class.from_parsed_uri(parsed, **kwargs) else: logger.debug(f"Using constructor with kwargs: {list(kwargs.keys())}") @@ -221,7 +254,7 @@ def create_repository(self, def get_supported_schemes(self) -> List[str]: """ Get list of supported URI schemes. - + Returns: List of supported URI schemes """ @@ -230,10 +263,10 @@ def get_supported_schemes(self) -> List[str]: def is_scheme_supported(self, scheme: str) -> bool: """ Check if a URI scheme is supported. - + Args: scheme: URI scheme to check - + Returns: True if scheme is supported, False otherwise """ @@ -242,10 +275,10 @@ def is_scheme_supported(self, scheme: str) -> bool: def get_repository_class(self, scheme: str) -> Optional[Type[BackupRepository]]: """ Get repository class for a specific scheme. - + Args: scheme: URI scheme - + Returns: Repository class if found, None otherwise """ @@ -254,10 +287,10 @@ def get_repository_class(self, scheme: str) -> Optional[Type[BackupRepository]]: def unregister_repository_type(self, scheme: str) -> bool: """ Unregister a repository type. - + Args: scheme: URI scheme to unregister - + Returns: True if scheme was unregistered, False if not found """ @@ -271,32 +304,30 @@ def unregister_repository_type(self, scheme: str) -> bool: def get_repository_info(self) -> Dict[str, str]: """ Get information about registered repository types. - + Returns: Dictionary mapping schemes to repository class names """ return { - scheme: repo_class.__name__ - for scheme, repo_class in self._repository_types.items() + scheme: repo_class.__name__ + for scheme, repo_class in self._repository_types.items() } - - def create_repository_with_engine(self, - uri: str, - engine: BackupEngine, - password: Optional[str] = None, - **kwargs) -> BackupRepository: + + def create_repository_with_engine( + self, uri: str, engine: BackupEngine, password: Optional[str] = None, **kwargs + ) -> BackupRepository: """ Create a repository instance using a specific backup engine. - + Args: uri: Repository URI engine: Backup engine to use password: Optional password for repository **kwargs: Additional repository-specific parameters - + Returns: BackupRepository instance - + Raises: EngineNotAvailableError: If engine is not available RepositoryFactoryError: If repository creation fails @@ -304,75 +335,79 @@ def create_repository_with_engine(self, try: # Get plugin for the specified engine plugin = self._plugin_registry.get_plugin(engine) - + # Validate URI for this engine validation = plugin.validate_uri(uri) if not validation.is_valid: raise RepositoryFactoryError( f"Invalid URI for {engine.value}: {', '.join(validation.errors)}" ) - + # Validate engine configuration if provided - if 'engine_config' in kwargs: - config_validation = plugin.validate_configuration(kwargs['engine_config']) + if "engine_config" in kwargs: + config_validation = plugin.validate_configuration( + kwargs["engine_config"] + ) if not config_validation.is_valid: raise RepositoryFactoryError( f"Invalid engine configuration: {', '.join(config_validation.errors)}" ) - + # Provide credential manager - kwargs['credential_manager'] = self._get_credential_manager() - + kwargs["credential_manager"] = self._get_credential_manager() + # Create repository using plugin repository = plugin.create_repository(uri, password, **kwargs) - - logger.info(f"Created repository using {engine.value} engine for URI: {uri}") + + logger.info( + f"Created repository using {engine.value} engine for URI: {uri}" + ) return repository - + except EngineNotAvailableError: raise except Exception as e: raise RepositoryFactoryError( f"Failed to create repository with {engine.value} engine: {e}" ) from e - + def is_engine_available(self, engine: BackupEngine) -> bool: """ Check if a backup engine is available. - + Args: engine: Backup engine to check - + Returns: True if engine is available, False otherwise """ return self._plugin_registry.is_engine_available(engine) - + def get_available_engines(self) -> List[BackupEngine]: """ Get list of available backup engines. - + Returns: List of available BackupEngine types """ return self._plugin_registry.get_available_engines() - + def get_engines_for_storage_type(self, storage_type: str) -> List[BackupEngine]: """ Get list of engines that support a specific storage type. - + Args: storage_type: Storage type (e.g., 's3', 'local') - + Returns: List of BackupEngine types supporting the storage type """ return self._plugin_registry.get_engines_supporting_storage(storage_type) - + def get_plugin_info(self) -> Dict[str, Dict[str, any]]: """ Get information about registered plugins. - + Returns: Dictionary with plugin information """ diff --git a/src/TimeLocker/system_control/assets/timelocker-control.service b/src/TimeLocker/system_control/assets/timelocker-control.service index b28e75c..78e9399 100644 --- a/src/TimeLocker/system_control/assets/timelocker-control.service +++ b/src/TimeLocker/system_control/assets/timelocker-control.service @@ -12,7 +12,7 @@ RuntimeDirectory=timelocker RuntimeDirectoryMode=0750 StateDirectory=timelocker StateDirectoryMode=0750 -ExecStart=/opt/timelocker/current/venv/bin/timelocker-system-control --systemd-socket +ExecStart=/usr/local/libexec/timelocker-system-control --systemd-socket NoNewPrivileges=yes PrivateTmp=yes PrivateDevices=yes diff --git a/src/TimeLocker/system_control/assets/timelocker-retention.service b/src/TimeLocker/system_control/assets/timelocker-retention.service new file mode 100644 index 0000000..46c3344 --- /dev/null +++ b/src/TimeLocker/system_control/assets/timelocker-retention.service @@ -0,0 +1,16 @@ +[Unit] +Description=TimeLocker independent retention catch-up +After=network-online.target timelocker-control.socket +ConditionPathExists=/etc/timelocker/retention-enabled + +[Service] +Type=oneshot +User=root +Group=root +UMask=0077 +ExecStart=/usr/local/libexec/timelocker-system-control --scheduled-retention +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/var/lib/timelocker diff --git a/src/TimeLocker/system_control/assets/timelocker-retention.timer b/src/TimeLocker/system_control/assets/timelocker-retention.timer new file mode 100644 index 0000000..90d70c0 --- /dev/null +++ b/src/TimeLocker/system_control/assets/timelocker-retention.timer @@ -0,0 +1,10 @@ +[Unit] +Description=TimeLocker independent retention catch-up timer + +[Timer] +OnCalendar=daily +Persistent=false +Unit=timelocker-retention.service + +[Install] +WantedBy=timers.target diff --git a/src/TimeLocker/system_control/assets/timelocker-system-control-launcher b/src/TimeLocker/system_control/assets/timelocker-system-control-launcher new file mode 100755 index 0000000..e8bd47a --- /dev/null +++ b/src/TimeLocker/system_control/assets/timelocker-system-control-launcher @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +exec /opt/timelocker/launcher/venv/bin/python \ + -m TimeLocker.system_control.backend_launcher_entry "$@" diff --git a/src/TimeLocker/system_control/assets/timelocker-tray-launcher b/src/TimeLocker/system_control/assets/timelocker-tray-launcher new file mode 100755 index 0000000..59806a4 --- /dev/null +++ b/src/TimeLocker/system_control/assets/timelocker-tray-launcher @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +exec /opt/timelocker/launcher/venv/bin/python \ + -m TimeLocker.system_control.tray_launcher_entry "$@" diff --git a/src/TimeLocker/system_control/assets/timelocker-tray.desktop b/src/TimeLocker/system_control/assets/timelocker-tray.desktop new file mode 100644 index 0000000..9c6c3f1 --- /dev/null +++ b/src/TimeLocker/system_control/assets/timelocker-tray.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=TimeLocker +Comment=Backup status and approved actions +Exec=/usr/local/bin/timelocker-tray +Terminal=false +NoDisplay=true +X-GNOME-Autostart-enabled=true diff --git a/src/TimeLocker/system_control/backend_entry.py b/src/TimeLocker/system_control/backend_entry.py new file mode 100644 index 0000000..726dcc5 --- /dev/null +++ b/src/TimeLocker/system_control/backend_entry.py @@ -0,0 +1,670 @@ +"""Linux backend composition and entrypoint for system-control operations.""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import signal +import socket +import stat +from threading import Event +from types import FrameType +from typing import Protocol +from uuid import UUID, uuid4 + +from .dispatcher import AuditEvent, AuditSink, LocalControlDispatcher +from .interfaces import GroupMembershipResolver +from .linux_adapter import LinuxNssGroupMembershipResolver, LinuxUnixSocketTransport +from .models import ( + ActionReceipt, + BackupActionRequest, + DiagnosticQuery, + DiagnosticRecord, + DiagnosticView, + PROTOCOL_VERSION, + RetentionPolicy, + RunQuery, + RunRecordView, + ScheduleSummary, + SystemPolicy, +) +from .policy_loader import load_system_policy +from .retention import ( + RetentionAdapter, + RetentionExecutionResult, + RetentionExecutor, + RetentionPlan, + RetentionRequestHandler, + RetentionTriggerCoordinator, + RetentionTriggerStore, +) +from .storage import ( + AtomicRecordStore, + RepositoryMutationLock, + reconcile_abandoned_runs, +) +from .types import ( + DiagnosticCode, + DiagnosticComponent, + DiagnosticLevel, + OperationType, + RunState, + SystemAction, +) + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class BackupMutationAdapter(Protocol): + """Execute one allowlisted system backup request.""" + + def request_backup( + self, + request: BackupActionRequest, + *, + request_id: UUID, + ) -> ActionReceipt: + """Return a bounded receipt for the requested backup.""" + + +class RetentionPlanProvider(Protocol): + """Resolve the live retention plan for the protected system repository.""" + + def resolve_retention_plan(self, policy: SystemPolicy) -> RetentionPlan: + """Return the exact plan that matches the installed system policy.""" + + +class ScheduleSummaryProvider(Protocol): + """Project the next scheduled backup and retention times.""" + + def get_schedule_summary(self) -> ScheduleSummary: + """Return a safe schedule projection.""" + + +class FailClosedBackupMutationAdapter: + """Default backup adapter that intentionally exposes no mutation path.""" + + def request_backup( + self, + request: BackupActionRequest, + *, + request_id: UUID, + ) -> ActionReceipt: + raise RuntimeError("system backup mutation is unavailable") + + +class FailClosedRetentionAdapter: + """Default retention adapter that intentionally exposes no mutation path.""" + + def execute( + self, + plan: RetentionPlan, + *, + dry_run: bool, + ) -> RetentionExecutionResult: + raise RuntimeError("system retention mutation is unavailable") + + +class FailClosedRetentionPlanProvider: + """Default retention-plan provider that refuses to invent live config.""" + + def resolve_retention_plan(self, policy: SystemPolicy) -> RetentionPlan: + raise RuntimeError("system retention plan is unavailable") + + +@dataclass(frozen=True, slots=True) +class StaticScheduleSummaryProvider: + """Safe default when no live scheduler projection is available.""" + + summary: ScheduleSummary = field( + default_factory=lambda: ScheduleSummary( + next_backup_at=None, + next_retention_at=None, + ) + ) + + def get_schedule_summary(self) -> ScheduleSummary: + return self.summary + + +@dataclass(frozen=True, slots=True) +class LinuxBackendPaths: + """Filesystem locations owned by the privileged local backend.""" + + policy_path: Path + record_root: Path + lock_root: Path + trigger_root: Path + audit_log_path: Path + expected_owner: int = 0 + + def __post_init__(self) -> None: + for field_name in ( + "policy_path", + "record_root", + "lock_root", + "trigger_root", + "audit_log_path", + ): + value = getattr(self, field_name) + if not isinstance(value, Path): + raise TypeError(f"{field_name} must be a Path") + if type(self.expected_owner) is not int or self.expected_owner < 0: + raise ValueError("expected_owner must be a non-negative UID") + + @classmethod + def from_state_root( + cls, + *, + policy_path: Path, + state_root: Path, + expected_owner: int = 0, + ) -> "LinuxBackendPaths": + if not isinstance(state_root, Path): + raise TypeError("state_root must be a Path") + return cls( + policy_path=policy_path, + record_root=state_root / "records", + lock_root=state_root / "locks", + trigger_root=state_root / "retention-triggers", + audit_log_path=state_root / "audit" / "events.jsonl", + expected_owner=expected_owner, + ) + + +class RootOnlyJsonlAuditSink(AuditSink): + """Persist bounded audit decisions in a root-only JSONL file.""" + + def __init__( + self, + path: Path, + *, + expected_owner: int = 0, + clock: Callable[[], datetime] | None = None, + ) -> None: + if not isinstance(path, Path): + raise TypeError("path must be a Path") + if type(expected_owner) is not int or expected_owner < 0: + raise ValueError("expected_owner must be a non-negative UID") + self.path = path + self.expected_owner = expected_owner + self._clock = clock or _utc_now + self._ensure_private_parent() + + def record(self, event: AuditEvent) -> None: + if not isinstance(event, AuditEvent): + raise TypeError("event must be an AuditEvent") + payload = { + "timestamp": self._timestamp().isoformat(), + "platform_id": event.platform_id, + "action": event.action.value if event.action else None, + "decision": event.decision, + "status": event.status.value, + "result_code": event.result_code.value if event.result_code else None, + } + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND + flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(self.path, flags, 0o600) + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode): + raise ValueError("audit log must be a regular file") + if metadata.st_uid != self.expected_owner: + raise PermissionError("audit log has an unexpected owner") + if stat.S_IMODE(metadata.st_mode) != 0o600: + raise PermissionError( + "audit log must be owner-readable and owner-writable only" + ) + with os.fdopen(descriptor, "a", encoding="utf-8", closefd=False) as output: + json.dump(payload, output, sort_keys=True, separators=(",", ":")) + output.write("\n") + output.flush() + os.fsync(output.fileno()) + os.fchmod(descriptor, 0o600) + finally: + os.close(descriptor) + + def _ensure_private_parent(self) -> None: + self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + self.path.parent.chmod(0o700) + metadata = self.path.parent.stat() + if metadata.st_uid != self.expected_owner: + raise PermissionError("audit directory has an unexpected owner") + if stat.S_IMODE(metadata.st_mode) != 0o700: + raise PermissionError("audit directory must be owner-accessible only") + + def _timestamp(self) -> datetime: + value = self._clock() + if value.tzinfo is None or value.utcoffset() != timezone.utc.utcoffset(value): + raise ValueError("clock must return an aware UTC datetime") + return value + + +class _BackupRequestHandler: + """Bind a system backup mutation adapter to the strict protocol.""" + + def __init__(self, adapter: BackupMutationAdapter) -> None: + self._adapter = adapter + + def __call__(self, request: object) -> Mapping[str, object]: + from .protocol import RequestEnvelope + + if not isinstance(request, RequestEnvelope): + raise TypeError("request must be a RequestEnvelope") + receipt = self._adapter.request_backup( + BackupActionRequest(target_id=request.parameters["target_id"]), + request_id=request.request_id, + ) + if not isinstance(receipt, ActionReceipt): + raise TypeError("backup adapter returned an invalid receipt") + if receipt.request_id != request.request_id: + raise ValueError("backup adapter returned a mismatched request_id") + return receipt.to_wire() + + +@dataclass(slots=True) +class LinuxBackendService: + """Composed Linux backend ready for socket-activated serving.""" + + policy: SystemPolicy + store: AtomicRecordStore + locks: RepositoryMutationLock + dispatcher: LocalControlDispatcher + transport: LinuxUnixSocketTransport + audit_sink: AuditSink + stop_event: Event + reconciled_run_ids: tuple[UUID, ...] = () + + def serve_forever(self, *, install_signal_handlers: bool = True) -> None: + if install_signal_handlers: + self.install_signal_handlers() + try: + self.transport.serve(self.dispatcher) + except OSError: + if not self.stop_event.is_set(): + raise + + def install_signal_handlers(self) -> None: + def _handle_signal(_signum: int, _frame: FrameType | None) -> None: + self.stop() + + signal.signal(signal.SIGINT, _handle_signal) + signal.signal(signal.SIGTERM, _handle_signal) + + def stop(self) -> None: + self.stop_event.set() + listener = getattr(self.transport, "listener", None) + if isinstance(listener, socket.socket): + try: + listener.close() + except OSError: + pass + + +def build_linux_backend( + *, + paths: LinuxBackendPaths, + socket_mode: str = "systemd", + listener: socket.socket | None = None, + systemd_descriptor: int = 3, + request_timeout_seconds: float = 5.0, + membership_resolver: GroupMembershipResolver | None = None, + backup_adapter: BackupMutationAdapter | None = None, + retention_adapter: RetentionAdapter | None = None, + retention_plan_provider: RetentionPlanProvider | None = None, + schedule_summary_provider: ScheduleSummaryProvider | None = None, + max_diagnostics: int = 1_000, + stop_event: Event | None = None, + clock: Callable[[], datetime] | None = None, +) -> LinuxBackendService: + """Compose the Linux backend from strict local components.""" + if type(max_diagnostics) is not int or not 1 <= max_diagnostics <= 100_000: + raise ValueError("max_diagnostics must be between 1 and 100000") + if socket_mode not in {"systemd", "listener"}: + raise ValueError("socket_mode must be 'systemd' or 'listener'") + if socket_mode == "listener": + if listener is None: + raise ValueError("listener socket is required for listener mode") + elif listener is not None: + raise ValueError("listener socket can only be provided in listener mode") + + now = clock or _utc_now + stop_event = stop_event or Event() + policy = load_system_policy(paths.policy_path, expected_owner=paths.expected_owner) + store = AtomicRecordStore(paths.record_root, max_diagnostics=max_diagnostics) + locks = RepositoryMutationLock(paths.lock_root) + audit_sink = RootOnlyJsonlAuditSink( + paths.audit_log_path, + expected_owner=paths.expected_owner, + clock=now, + ) + schedule_summary_provider = ( + schedule_summary_provider or StaticScheduleSummaryProvider() + ) + membership_resolver = membership_resolver or LinuxNssGroupMembershipResolver() + backup_adapter = backup_adapter or FailClosedBackupMutationAdapter() + retention_adapter = retention_adapter or FailClosedRetentionAdapter() + retention_plan_provider = ( + retention_plan_provider or FailClosedRetentionPlanProvider() + ) + + reconciled = reconcile_abandoned_runs(store, locks, now=now()) + _emit_startup_diagnostics(store, reconciled, clock=now) + transport = _build_transport( + policy=policy, + socket_mode=socket_mode, + listener=listener, + systemd_descriptor=systemd_descriptor, + request_timeout_seconds=request_timeout_seconds, + stop_event=stop_event, + ) + dispatcher = LocalControlDispatcher( + policy=policy, + membership_resolver=membership_resolver, + handlers=_build_handlers( + policy=policy, + store=store, + locks=locks, + backup_adapter=backup_adapter, + retention_adapter=retention_adapter, + retention_plan_provider=retention_plan_provider, + schedule_summary_provider=schedule_summary_provider, + trigger_root=paths.trigger_root, + clock=now, + ), + audit_sink=audit_sink, + ) + return LinuxBackendService( + policy=policy, + store=store, + locks=locks, + dispatcher=dispatcher, + transport=transport, + audit_sink=audit_sink, + stop_event=stop_event, + reconciled_run_ids=tuple(record.run_id for record in reconciled), + ) + + +def run_linux_backend(**kwargs: object) -> None: + """Build and serve the Linux backend until the process is stopped.""" + service = build_linux_backend(**kwargs) + service.serve_forever() + + +def run_scheduled_retention() -> None: + """Fail closed until a protected live repository adapter is configured.""" + raise RuntimeError("scheduled retention adapter is not configured") + + +def main(argv: list[str] | None = None) -> None: + """Run one allowlisted privileged system-control process mode.""" + parser = argparse.ArgumentParser(prog="timelocker-system-control") + modes = parser.add_mutually_exclusive_group(required=True) + modes.add_argument( + "--systemd-socket", + action="store_true", + help="Accept the listening socket from systemd descriptor 3.", + ) + modes.add_argument( + "--scheduled-retention", + action="store_true", + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--policy", + type=Path, + default=Path("/etc/timelocker/system-control-policy.json"), + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--state-root", + type=Path, + default=Path("/var/lib/timelocker"), + help=argparse.SUPPRESS, + ) + arguments = parser.parse_args(argv) + try: + if arguments.scheduled_retention: + run_scheduled_retention() + else: + paths = LinuxBackendPaths.from_state_root( + policy_path=arguments.policy, + state_root=arguments.state_root, + ) + run_linux_backend(paths=paths, socket_mode="systemd") + except (OSError, PermissionError, RuntimeError, TypeError, ValueError): + parser.exit(78, "TimeLocker system backend failed to initialize safely.\n") + + +def _build_transport( + *, + policy: SystemPolicy, + socket_mode: str, + listener: socket.socket | None, + systemd_descriptor: int, + request_timeout_seconds: float, + stop_event: Event, +) -> LinuxUnixSocketTransport: + if socket_mode == "listener": + assert listener is not None + return LinuxUnixSocketTransport( + listener, + max_request_bytes=policy.max_request_bytes, + request_timeout_seconds=request_timeout_seconds, + stop_event=stop_event, + ) + return LinuxUnixSocketTransport.from_systemd( + descriptor=systemd_descriptor, + max_request_bytes=policy.max_request_bytes, + request_timeout_seconds=request_timeout_seconds, + stop_event=stop_event, + ) + + +def _build_handlers( + *, + policy: SystemPolicy, + store: AtomicRecordStore, + locks: RepositoryMutationLock, + backup_adapter: BackupMutationAdapter, + retention_adapter: RetentionAdapter, + retention_plan_provider: RetentionPlanProvider, + schedule_summary_provider: ScheduleSummaryProvider, + trigger_root: Path, + clock: Callable[[], datetime], +) -> Mapping[SystemAction, Callable[[object], object]]: + from .protocol import RequestEnvelope + + def health(_request: object) -> Mapping[str, object]: + return { + "backend_available": True, + "protocol_min": PROTOCOL_VERSION, + "protocol_max": PROTOCOL_VERSION, + } + + def run_list(request: object) -> Mapping[str, object]: + if not isinstance(request, RequestEnvelope): + raise TypeError("request must be a RequestEnvelope") + operation = request.parameters.get("operation") + state = request.parameters.get("state") + query = RunQuery( + limit=min( + int(request.parameters.get("limit", policy.max_response_records)), + policy.max_response_records, + ), + operation=OperationType(operation) if operation is not None else None, + state=RunState(state) if state is not None else None, + ) + return { + "runs": [ + RunRecordView.from_record(record).to_wire() + for record in store.list_runs(query) + ] + } + + def run_detail(request: object) -> Mapping[str, object]: + if not isinstance(request, RequestEnvelope): + raise TypeError("request must be a RequestEnvelope") + return { + "run": RunRecordView.from_record( + store.read_run(request.parameters["run_id"]) + ).to_wire() + } + + def diagnostic_list(request: object) -> Mapping[str, object]: + if not isinstance(request, RequestEnvelope): + raise TypeError("request must be a RequestEnvelope") + level = request.parameters.get("level") + query = DiagnosticQuery( + limit=min( + int(request.parameters.get("limit", policy.max_response_records)), + policy.max_response_records, + ), + run_id=request.parameters.get("run_id"), + level=DiagnosticLevel(level) if level is not None else None, + ) + return { + "diagnostics": [ + DiagnosticView.from_record(record).to_wire() + for record in store.list_diagnostics(query) + ] + } + + def schedule_summary(_request: object) -> Mapping[str, object]: + summary = schedule_summary_provider.get_schedule_summary() + if not isinstance(summary, ScheduleSummary): + raise TypeError("schedule_summary_provider returned an invalid summary") + return _schedule_to_wire(summary) + + def ui_availability(_request: object) -> Mapping[str, object]: + return {"available": False} + + handlers: dict[SystemAction, Callable[[object], object]] = { + SystemAction.HEALTH: health, + SystemAction.RUN_LIST: run_list, + SystemAction.RUN_DETAIL: run_detail, + SystemAction.DIAGNOSTIC_LIST: diagnostic_list, + SystemAction.SCHEDULE_SUMMARY: schedule_summary, + SystemAction.UI_AVAILABILITY: ui_availability, + } + if not isinstance(backup_adapter, FailClosedBackupMutationAdapter): + handlers[SystemAction.BACKUP_REQUEST] = _BackupRequestHandler(backup_adapter) + if not isinstance(retention_adapter, FailClosedRetentionAdapter) and not isinstance( + retention_plan_provider, FailClosedRetentionPlanProvider + ): + plan = retention_plan_provider.resolve_retention_plan(policy) + if not isinstance(plan, RetentionPlan): + raise TypeError("retention_plan_provider returned an invalid plan") + plan = _apply_policy_defaults(plan, policy.retention) + coordinator = RetentionTriggerCoordinator( + executor=RetentionExecutor( + store=store, + locks=locks, + adapter=retention_adapter, + clock=clock, + ), + trigger_store=RetentionTriggerStore(trigger_root), + ) + handlers[SystemAction.RETENTION_REQUEST] = RetentionRequestHandler( + coordinator=coordinator, + plan=plan, + ) + return handlers + + +def _apply_policy_defaults( + plan: RetentionPlan, + policy: RetentionPolicy, +) -> RetentionPlan: + approved_fingerprint = ( + policy.approved_fingerprint or plan.policy.approved_fingerprint + ) + return RetentionPlan( + target_id=plan.target_id, + repository_identity=plan.repository_identity, + credential_source=plan.credential_source, + snapshot_filters=plan.snapshot_filters, + policy=RetentionPolicy( + keep_daily=policy.keep_daily, + keep_weekly=policy.keep_weekly, + keep_monthly=policy.keep_monthly, + keep_yearly=policy.keep_yearly, + group_by=policy.group_by, + prune=policy.prune, + approved_fingerprint=approved_fingerprint, + ), + ) + + +def _schedule_to_wire(summary: ScheduleSummary) -> dict[str, object]: + return { + "next_backup_at": ( + summary.next_backup_at.isoformat() if summary.next_backup_at else None + ), + "next_retention_at": ( + summary.next_retention_at.isoformat() if summary.next_retention_at else None + ), + } + + +def _emit_startup_diagnostics( + store: AtomicRecordStore, + reconciled: list[object], + *, + clock: Callable[[], datetime], +) -> None: + timestamp = clock() + for record in reconciled: + run_id = getattr(record, "run_id", None) + if not isinstance(run_id, UUID): + continue + store.append_diagnostic( + DiagnosticRecord( + record_id=uuid4(), + run_id=run_id, + timestamp=timestamp, + level=DiagnosticLevel.WARNING, + component=DiagnosticComponent.RUN_STORE, + message_code=DiagnosticCode.OPERATION_INTERRUPTED, + ) + ) + store.append_diagnostic( + DiagnosticRecord( + record_id=uuid4(), + run_id=None, + timestamp=timestamp, + level=DiagnosticLevel.INFO, + component=DiagnosticComponent.BACKEND, + message_code=DiagnosticCode.BACKEND_STARTED, + ) + ) + + +__all__ = [ + "BackupMutationAdapter", + "FailClosedBackupMutationAdapter", + "FailClosedRetentionAdapter", + "FailClosedRetentionPlanProvider", + "LinuxBackendPaths", + "LinuxBackendService", + "RetentionPlanProvider", + "RootOnlyJsonlAuditSink", + "ScheduleSummaryProvider", + "StaticScheduleSummaryProvider", + "build_linux_backend", + "main", + "run_linux_backend", + "run_scheduled_retention", +] + + +if __name__ == "__main__": + main() diff --git a/src/TimeLocker/system_control/backend_launcher_entry.py b/src/TimeLocker/system_control/backend_launcher_entry.py new file mode 100644 index 0000000..887e66d --- /dev/null +++ b/src/TimeLocker/system_control/backend_launcher_entry.py @@ -0,0 +1,21 @@ +"""Stable launcher entry point for the selected privileged backend.""" + +import sys + +from .release_launcher import ReleaseResolutionError, launch_selected + + +def main() -> None: + """Launch the selected backend without consulting user-managed paths.""" + try: + launch_selected(sys.argv[1:], target="backend") + except ReleaseResolutionError: + print( + "TimeLocker system backend release is unavailable or invalid.", + file=sys.stderr, + ) + raise SystemExit(78) from None + + +if __name__ == "__main__": + main() diff --git a/src/TimeLocker/system_control/deployment.py b/src/TimeLocker/system_control/deployment.py new file mode 100644 index 0000000..de9e3b6 --- /dev/null +++ b/src/TimeLocker/system_control/deployment.py @@ -0,0 +1,258 @@ +"""Compatibility-checked installation and activation of system assets.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +import hashlib +import os +from pathlib import Path +import shutil + +from .models import PROTOCOL_VERSION +from .release_launcher import ImmutableReleaseResolver, SelectedRelease +from .validation import require_int, require_safe_identifier + + +class DeploymentError(RuntimeError): + """Raised before an incomplete or incompatible deployment is activated.""" + + +@dataclass(frozen=True, slots=True) +class AssetTarget: + """One packaged asset and its root-owned installation target.""" + + source_name: str + destination: Path + mode: int + preserve_existing: bool = False + + +@dataclass(frozen=True, slots=True) +class SystemAssetManifest: + """Exact hashes and protocol identity for one packaged system asset set.""" + + release_id: str + package_version: str + hashes: Mapping[str, str] + protocol_version: int = PROTOCOL_VERSION + schema_version: int = 1 + + def __post_init__(self) -> None: + require_safe_identifier(self.release_id, field="release_id", maximum=64) + require_safe_identifier( + self.package_version, + field="package_version", + maximum=64, + ) + require_int( + self.protocol_version, + field="protocol_version", + minimum=PROTOCOL_VERSION, + maximum=PROTOCOL_VERSION, + ) + require_int( + self.schema_version, + field="schema_version", + minimum=1, + maximum=1, + ) + if not self.hashes: + raise ValueError("asset manifest must contain hashes") + for name, digest in self.hashes.items(): + require_safe_identifier(name, field="asset_name", maximum=128) + if len(digest) != 64 or any( + character not in "0123456789abcdef" for character in digest + ): + raise ValueError("asset hash must be a lowercase SHA-256 digest") + + +class SystemReleaseDeployment: + """Install a validated asset set and activate only healthy staged releases.""" + + def __init__( + self, + *, + resolver: ImmutableReleaseResolver, + targets: tuple[AssetTarget, ...], + expected_owner_uid: int = 0, + ) -> None: + if not targets: + raise ValueError("at least one asset target is required") + self.resolver = resolver + self.targets = targets + self.expected_owner_uid = expected_owner_uid + + def install_assets( + self, + asset_root: Path, + manifest: SystemAssetManifest, + ) -> tuple[Path, ...]: + """Validate all sources before atomically replacing install targets.""" + expected_names = {target.source_name for target in self.targets} + if set(manifest.hashes) != expected_names: + raise DeploymentError("asset manifest does not match install target set") + sources: dict[str, Path] = {} + for target in self.targets: + source = asset_root / target.source_name + if not source.is_file() or source.is_symlink(): + raise DeploymentError("required packaged asset is unavailable") + if _sha256(source) != manifest.hashes[target.source_name]: + raise DeploymentError("packaged asset hash mismatch") + sources[target.source_name] = source + + installed: list[Path] = [] + for target in self.targets: + if target.preserve_existing and target.destination.exists(): + continue + _atomic_copy( + sources[target.source_name], + target.destination, + mode=target.mode, + ) + metadata = target.destination.lstat() + if metadata.st_uid != self.expected_owner_uid: + raise DeploymentError("installed asset has the wrong owner") + if metadata.st_mode & 0o777 != target.mode: + raise DeploymentError("installed asset has the wrong mode") + installed.append(target.destination) + return tuple(installed) + + def activate( + self, + release_id: str, + *, + health_probe: Callable[[Path, Path, Path], bool], + ) -> SelectedRelease: + """Select a release only after CLI, backend, and tray probes pass.""" + executables = tuple( + self.resolver._resolve_release(release_id, entrypoint=entrypoint) + for entrypoint in ( + "venv/bin/timelocker", + "venv/bin/timelocker-system-control", + "venv/bin/timelocker-tray", + ) + ) + if health_probe(*executables) is not True: + raise DeploymentError("staged release compatibility probe failed") + return self.resolver.select(release_id) + + def rollback( + self, + *, + health_probe: Callable[[Path, Path, Path], bool], + ) -> SelectedRelease: + """Restore the prior selector only after its artifacts pass probes.""" + current = self.resolver._read_selector_optional() + if current is None or current.previous is None: + raise DeploymentError("no previous release is available") + executables = tuple( + self.resolver._resolve_release(current.previous, entrypoint=entrypoint) + for entrypoint in ( + "venv/bin/timelocker", + "venv/bin/timelocker-system-control", + "venv/bin/timelocker-tray", + ) + ) + if health_probe(*executables) is not True: + raise DeploymentError("rollback release compatibility probe failed") + return self.resolver.rollback() + + +def linux_asset_targets( + *, + bin_root: Path = Path("/usr/local/bin"), + libexec_root: Path = Path("/usr/local/libexec"), + unit_root: Path = Path("/etc/systemd/system"), + config_root: Path = Path("/etc/timelocker"), + autostart_root: Path = Path("/etc/xdg/autostart"), +) -> tuple[AssetTarget, ...]: + """Return the complete Linux launcher, backend, tray, and schedule asset set.""" + return ( + AssetTarget("timelocker-launcher", bin_root / "timelocker", 0o755), + AssetTarget("tl-launcher", bin_root / "tl", 0o755), + AssetTarget( + "timelocker-release-select", + bin_root / "timelocker-release-select", + 0o750, + ), + AssetTarget( + "timelocker-system-control-launcher", + libexec_root / "timelocker-system-control", + 0o750, + ), + AssetTarget( + "timelocker-tray-launcher", + bin_root / "timelocker-tray", + 0o755, + ), + AssetTarget( + "timelocker-control.service", + unit_root / "timelocker-control.service", + 0o644, + ), + AssetTarget( + "timelocker-control.socket", + unit_root / "timelocker-control.socket", + 0o644, + ), + AssetTarget( + "timelocker-retention.service", + unit_root / "timelocker-retention.service", + 0o644, + ), + AssetTarget( + "timelocker-retention.timer", + unit_root / "timelocker-retention.timer", + 0o644, + ), + AssetTarget( + "system-control-policy.json", + config_root / "system-control-policy.json", + 0o640, + preserve_existing=True, + ), + AssetTarget( + "timelocker-tray.desktop", + autostart_root / "timelocker-tray.desktop", + 0o644, + ), + ) + + +def build_asset_manifest( + *, + asset_root: Path, + release_id: str, + package_version: str, + asset_names: tuple[str, ...], +) -> SystemAssetManifest: + """Create an exact manifest from a trusted build workspace.""" + return SystemAssetManifest( + release_id=release_id, + package_version=package_version, + hashes={name: _sha256(asset_root / name) for name in asset_names}, + ) + + +def _sha256(path: Path) -> str: + try: + return hashlib.sha256(path.read_bytes()).hexdigest() + except OSError as error: + raise DeploymentError("required packaged asset is unavailable") from error + + +def _atomic_copy(source: Path, destination: Path, *, mode: int) -> None: + if mode not in {0o600, 0o640, 0o644, 0o700, 0o750, 0o755}: + raise DeploymentError("unsupported installed asset mode") + destination.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp") + try: + with source.open("rb") as input_stream, temporary.open("xb") as output_stream: + shutil.copyfileobj(input_stream, output_stream) + output_stream.flush() + os.fsync(output_stream.fileno()) + temporary.chmod(mode) + os.replace(temporary, destination) + finally: + temporary.unlink(missing_ok=True) diff --git a/src/TimeLocker/system_control/release_launcher.py b/src/TimeLocker/system_control/release_launcher.py index f1faee8..7672bd2 100644 --- a/src/TimeLocker/system_control/release_launcher.py +++ b/src/TimeLocker/system_control/release_launcher.py @@ -14,6 +14,11 @@ DEFAULT_RELEASES_ROOT = Path("/opt/timelocker/releases") DEFAULT_SELECTOR_PATH = Path("/opt/timelocker/selected-release.json") LAUNCH_GUARD = "TIMELOCKER_SYSTEM_LAUNCH_ACTIVE" +_ENTRYPOINTS = { + "cli": "venv/bin/timelocker", + "backend": "venv/bin/timelocker-system-control", + "tray": "venv/bin/timelocker-tray", +} class ReleaseResolutionError(RuntimeError): @@ -124,13 +129,27 @@ def __init__( def resolve(self, environment: Mapping[str, str] | None = None) -> Path: """Return the selected executable or fail before any fallback.""" + return self.resolve_entrypoint("cli", environment) + + def resolve_entrypoint( + self, + target: str, + environment: Mapping[str, str] | None = None, + ) -> Path: + """Return an allowlisted executable from the selected release.""" environment = os.environ if environment is None else environment if environment.get(LAUNCH_GUARD): raise ReleaseResolutionError("recursive system launcher invocation") + try: + entrypoint = _ENTRYPOINTS[target] + except KeyError as error: + raise ReleaseResolutionError( + "release entrypoint is not allowlisted" + ) from error self._require_trusted_directory(self.selector_path.parent) self._require_trusted_file(self.selector_path) selector = SelectedRelease.from_mapping(_read_json(self.selector_path)) - return self._resolve_release(selector.selected) + return self._resolve_release(selector.selected, entrypoint=entrypoint) def select(self, release_id: str) -> SelectedRelease: """Atomically select a validated staged release for administrator tooling.""" @@ -167,7 +186,12 @@ def _read_selector_optional(self) -> SelectedRelease | None: self._require_trusted_file(self.selector_path) return SelectedRelease.from_mapping(_read_json(self.selector_path)) - def _resolve_release(self, release_id: str) -> Path: + def _resolve_release( + self, + release_id: str, + *, + entrypoint: str = _ENTRYPOINTS["cli"], + ) -> Path: self._require_trusted_directory(self.releases_root) release_dir = self.releases_root / release_id self._require_trusted_directory(release_dir) @@ -176,7 +200,7 @@ def _resolve_release(self, release_id: str) -> Path: manifest = ReleaseManifest.from_mapping(_read_json(manifest_path)) if manifest.release_id != release_id: raise ReleaseResolutionError("release manifest identity mismatch") - executable = release_dir / manifest.entrypoint + executable = release_dir / entrypoint self._require_trusted_file(executable, executable=True) if executable.resolve().parent.parent.parent != release_dir.resolve(): raise ReleaseResolutionError("release entrypoint escapes release directory") @@ -222,13 +246,14 @@ def _require_trusted_directory(self, path: Path) -> None: def launch_selected( arguments: list[str], *, + target: str = "cli", resolver: ImmutableReleaseResolver | None = None, environment: Mapping[str, str] | None = None, ) -> NoReturn: """Replace this process with the selected immutable CLI entry point.""" resolver = resolver or ImmutableReleaseResolver() source_environment = dict(os.environ if environment is None else environment) - executable = resolver.resolve(source_environment) + executable = resolver.resolve_entrypoint(target, source_environment) source_environment[LAUNCH_GUARD] = "1" os.execve( executable, diff --git a/src/TimeLocker/system_control/tray_entry.py b/src/TimeLocker/system_control/tray_entry.py index 68232b2..92f7abc 100644 --- a/src/TimeLocker/system_control/tray_entry.py +++ b/src/TimeLocker/system_control/tray_entry.py @@ -216,11 +216,6 @@ def main() -> None: ) raise SystemExit(2) - try: - tray = SystemTrayIntegration(app_name="TimeLocker") - except SystemTrayError: - tray = None - client = _build_client( target_id=arguments.target_id, retention_policy_fingerprint=arguments.retention_policy_fingerprint, @@ -229,13 +224,18 @@ def main() -> None: state = _handle_action( arguments.action, client, - tray=tray, + tray=None, dry_run_retention=arguments.dry_run_retention, ) if state is not None: print(_render_status(state)) return + try: + tray = SystemTrayIntegration(app_name="TimeLocker") + except SystemTrayError: + tray = None + poll_interval = max(DEFAULT_POLL_SECONDS, arguments.refresh_seconds) stop_requested = False diff --git a/src/TimeLocker/system_control/tray_launcher_entry.py b/src/TimeLocker/system_control/tray_launcher_entry.py new file mode 100644 index 0000000..a43a08a --- /dev/null +++ b/src/TimeLocker/system_control/tray_launcher_entry.py @@ -0,0 +1,21 @@ +"""Stable launcher entry point for the selected desktop tray process.""" + +import sys + +from .release_launcher import ReleaseResolutionError, launch_selected + + +def main() -> None: + """Launch the selected tray without consulting user-managed paths.""" + try: + launch_selected(sys.argv[1:], target="tray") + except ReleaseResolutionError: + print( + "TimeLocker tray release is unavailable or invalid.", + file=sys.stderr, + ) + raise SystemExit(78) from None + + +if __name__ == "__main__": + main() diff --git a/src/TimeLocker/system_control/windows_adapter.py b/src/TimeLocker/system_control/windows_adapter.py new file mode 100644 index 0000000..f926d3c --- /dev/null +++ b/src/TimeLocker/system_control/windows_adapter.py @@ -0,0 +1,133 @@ +"""Windows system-control adapter contracts with injectable OS providers. + +The concrete service and named-pipe implementation remain platform follow-up +work. These adapters keep identity and authorization derived from the pipe +token, never from request data, and are testable on non-Windows hosts. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from .interfaces import ControlRequestHandler, PeerIdentity +from .validation import require_group_name, require_safe_identifier + + +@dataclass(frozen=True, slots=True) +class WindowsPeerToken: + """Bounded identity projected from a connected named-pipe client token.""" + + sid: str + process_id: int + + def __post_init__(self) -> None: + object.__setattr__( + self, + "sid", + require_safe_identifier(self.sid, field="sid", maximum=128), + ) + if type(self.process_id) is not int or not 1 <= self.process_id < 2**31: + raise ValueError("process_id is outside the supported bound") + + +class WindowsTokenProvider(Protocol): + """Read the caller token from a connected named pipe.""" + + def peer_token(self, connection: object) -> WindowsPeerToken: + """Return a kernel-derived caller token.""" + + +class WindowsGroupProvider(Protocol): + """Resolve current Windows group membership by SID.""" + + def is_current_member(self, sid: str, group_name: str) -> bool: + """Re-read current local/domain membership for one caller SID.""" + + +class NamedPipeConnection(Protocol): + """Minimal connection seam implemented by a Windows named-pipe binding.""" + + def receive(self, maximum: int) -> bytes: + """Receive at most ``maximum`` bytes.""" + + def send(self, payload: bytes) -> None: + """Send one encoded response.""" + + def close(self) -> None: + """Close the connection.""" + + +class NamedPipeAcceptor(Protocol): + """Accept local clients from a protected named pipe.""" + + def accept(self) -> NamedPipeConnection: + """Return the next local connection.""" + + +class WindowsPeerIdentityProvider: + """Project peer identity exclusively from an injected token provider.""" + + def __init__(self, token_provider: WindowsTokenProvider) -> None: + self._token_provider = token_provider + + def peer_identity(self, connection: object) -> PeerIdentity: + token = self._token_provider.peer_token(connection) + if not isinstance(token, WindowsPeerToken): + raise TypeError("token provider returned an invalid peer token") + return PeerIdentity( + platform_id=f"windows-sid:{token.sid}", + process_id=token.process_id, + ) + + +class WindowsCurrentGroupMembershipResolver: + """Fail closed and re-check the caller's current groups per request.""" + + _PREFIX = "windows-sid:" + + def __init__(self, group_provider: WindowsGroupProvider) -> None: + self._group_provider = group_provider + + def is_current_member(self, identity: PeerIdentity, group_name: str) -> bool: + if not isinstance(identity, PeerIdentity): + raise TypeError("identity must be a PeerIdentity") + group_name = require_group_name(group_name) + if not identity.platform_id.startswith(self._PREFIX): + return False + sid = identity.platform_id.removeprefix(self._PREFIX) + try: + return self._group_provider.is_current_member(sid, group_name) is True + except (OSError, RuntimeError): + return False + + +class WindowsNamedPipeTransport: + """Serve bounded requests over an injected protected named-pipe acceptor.""" + + def __init__( + self, + acceptor: NamedPipeAcceptor, + token_provider: WindowsTokenProvider, + *, + max_request_bytes: int, + ) -> None: + if ( + type(max_request_bytes) is not int + or not 1_024 <= max_request_bytes <= 1_048_576 + ): + raise ValueError("max_request_bytes is outside the supported bound") + self._acceptor = acceptor + self._identity_provider = WindowsPeerIdentityProvider(token_provider) + self.max_request_bytes = max_request_bytes + + def serve_once(self, handler: ControlRequestHandler) -> None: + connection = self._acceptor.accept() + try: + identity = self._identity_provider.peer_identity(connection) + request = connection.receive(self.max_request_bytes + 1) + if len(request) > self.max_request_bytes: + raise OSError("named-pipe request exceeds configured bound") + connection.send(handler.handle(request, identity)) + finally: + connection.close() diff --git a/tests/TimeLocker/integration/test_repos_credentials_command_usage.py b/tests/TimeLocker/integration/test_repos_credentials_command_usage.py index ac1a5df..eec5d70 100644 --- a/tests/TimeLocker/integration/test_repos_credentials_command_usage.py +++ b/tests/TimeLocker/integration/test_repos_credentials_command_usage.py @@ -115,6 +115,11 @@ def _restic_stub_script() -> str: """ +def _credential_store_path(config_dir: Path) -> Path: + """Return encrypted credential store path for the active config directory.""" + return config_dir / "credentials" / "credentials.enc" + + @pytest.fixture() def isolated_cli_environment(tmp_path: Path) -> Dict[str, Any]: """Provision isolated directories + stub restic + base environment. @@ -178,7 +183,7 @@ def prepared_s3_repo(isolated_cli_environment) -> Dict[str, Any]: assert "credential" in combined_out.lower(), f"Did not observe credential confirmation in output: {combined_out}" # Ensure encrypted credential file present - cred_file = Path(env["HOME"]) / ".timelocker" / "credentials" / "credentials.enc" + cred_file = _credential_store_path(config_dir) assert cred_file.exists(), "Encrypted credential store not created" return {"env": env, "config_dir": config_dir} diff --git a/tests/TimeLocker/integration/test_repos_credentials_integration.py b/tests/TimeLocker/integration/test_repos_credentials_integration.py index 15ea81d..0254454 100644 --- a/tests/TimeLocker/integration/test_repos_credentials_integration.py +++ b/tests/TimeLocker/integration/test_repos_credentials_integration.py @@ -46,6 +46,11 @@ def _create_stub_restic_script() -> str: """ +def _credential_store_path(config_dir: Path) -> Path: + """Return encrypted credential store path for the active config directory.""" + return config_dir / "credentials" / "credentials.enc" + + @pytest.mark.integration def test_backend_credentials_store_and_show_s3() -> None: with runner.isolated_filesystem(): @@ -143,5 +148,5 @@ def test_backend_credentials_store_and_show_s3() -> None: assert 'no' in combined_show_removed and 'credential' in combined_show_removed # Sanity check that encrypted credential file was created - cred_file = home_dir / '.timelocker' / 'credentials' / 'credentials.enc' + cred_file = _credential_store_path(config_dir) assert cred_file.exists(), 'Encrypted credential store not created' diff --git a/tests/TimeLocker/services/test_repository_factory.py b/tests/TimeLocker/services/test_repository_factory.py new file mode 100644 index 0000000..5e5a9a0 --- /dev/null +++ b/tests/TimeLocker/services/test_repository_factory.py @@ -0,0 +1,43 @@ +""" +Copyright © Bruce Cherrington + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +# Regression coverage for repository factory backend registration. + +import sys +import types + +from TimeLocker.services.repository_factory import RepositoryFactory + + +def test_registers_local_and_s3_when_b2_import_fails(monkeypatch, caplog): + """Simulate missing optional b2 dependency and verify local/s3 remain registered.""" + missing_b2_module = types.ModuleType("TimeLocker.restic.Repositories.b2") + monkeypatch.setitem( + sys.modules, "TimeLocker.restic.Repositories.b2", missing_b2_module + ) + + with caplog.at_level("WARNING"): + factory = RepositoryFactory() + + supported = set(factory.get_supported_schemes()) + assert "local" in supported + assert "file" in supported + assert "s3" in supported + assert "b2" not in supported + assert "Repository backend 'b2' unavailable" in caplog.text + assert factory.is_scheme_supported("s3") + assert factory.is_scheme_supported("local") From 234f5c72ab9b13c895c9ed1f832921fb462f8e6b Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:22:08 +0100 Subject: [PATCH 38/72] test: verify spec 009 release integration --- .../system_control/test_backend_entry.py | 80 +++++++++ .../system_control/test_deployment.py | 167 ++++++++++++++++++ .../system_control/test_linux_adapter.py | 12 ++ .../system_control/test_release_launcher.py | 31 +++- .../test_tray_process_boundary.py | 30 ++++ .../system_control/test_windows_adapter.py | 117 ++++++++++++ 6 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 tests/TimeLocker/system_control/test_backend_entry.py create mode 100644 tests/TimeLocker/system_control/test_deployment.py create mode 100644 tests/TimeLocker/system_control/test_windows_adapter.py diff --git a/tests/TimeLocker/system_control/test_backend_entry.py b/tests/TimeLocker/system_control/test_backend_entry.py new file mode 100644 index 0000000..277bfa5 --- /dev/null +++ b/tests/TimeLocker/system_control/test_backend_entry.py @@ -0,0 +1,80 @@ +"""Entrypoint checks for the privileged system-control backend.""" + +from pathlib import Path + +import pytest + +from TimeLocker.system_control import backend_entry + + +@pytest.mark.unit +def test_main_requires_systemd_socket_mode() -> None: + with pytest.raises(SystemExit) as caught: + backend_entry.main([]) + + assert caught.value.code == 2 + + +@pytest.mark.unit +def test_scheduled_retention_fails_closed_without_live_adapter( + monkeypatch, + capsys, +) -> None: + monkeypatch.setattr( + backend_entry, + "run_scheduled_retention", + lambda: (_ for _ in ()).throw(RuntimeError("protected URI")), + ) + + with pytest.raises(SystemExit) as caught: + backend_entry.main(["--scheduled-retention"]) + + assert caught.value.code == 78 + assert "protected URI" not in capsys.readouterr().err + + +@pytest.mark.unit +def test_main_composes_only_explicit_system_paths(monkeypatch, tmp_path: Path) -> None: + captured: dict[str, object] = {} + policy = tmp_path / "policy.json" + state = tmp_path / "state" + monkeypatch.setattr( + backend_entry, + "run_linux_backend", + lambda **kwargs: captured.update(kwargs), + ) + + backend_entry.main( + [ + "--systemd-socket", + "--policy", + str(policy), + "--state-root", + str(state), + ] + ) + + paths = captured["paths"] + assert isinstance(paths, backend_entry.LinuxBackendPaths) + assert paths.policy_path == policy + assert paths.record_root == state / "records" + assert captured["socket_mode"] == "systemd" + + +@pytest.mark.unit +def test_main_redacts_initialization_failures(monkeypatch, capsys) -> None: + monkeypatch.setattr( + backend_entry, + "run_linux_backend", + lambda **_kwargs: (_ for _ in ()).throw( + RuntimeError("s3://secret.example/private") + ), + ) + + with pytest.raises(SystemExit) as caught: + backend_entry.main(["--systemd-socket"]) + + assert caught.value.code == 78 + output = capsys.readouterr().err + assert "failed to initialize safely" in output + assert "secret.example" not in output diff --git a/tests/TimeLocker/system_control/test_deployment.py b/tests/TimeLocker/system_control/test_deployment.py new file mode 100644 index 0000000..dce9fde --- /dev/null +++ b/tests/TimeLocker/system_control/test_deployment.py @@ -0,0 +1,167 @@ +"""Manifest, upgrade, rollback, and preservation tests for system deployment.""" + +import json +import os +from pathlib import Path + +import pytest + +from TimeLocker.system_control.deployment import ( + AssetTarget, + DeploymentError, + SystemReleaseDeployment, + build_asset_manifest, + linux_asset_targets, +) +from TimeLocker.system_control.release_launcher import ImmutableReleaseResolver + + +RELEASE_A = "a" * 40 +RELEASE_B = "b" * 40 + + +def _stage_release(root: Path, release_id: str) -> None: + release = root / "releases" / release_id + bin_dir = release / "venv" / "bin" + bin_dir.mkdir(parents=True) + for name in ("timelocker", "timelocker-system-control", "timelocker-tray"): + executable = bin_dir / name + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + (release / "release.json").write_text( + json.dumps( + { + "schema_version": 1, + "release_id": release_id, + "package_version": "0.9.1", + "protocol_version": 1, + "entrypoint": "venv/bin/timelocker", + } + ) + ) + (release / "release.json").chmod(0o644) + release.chmod(0o755) + (root / "releases").chmod(0o755) + + +def _resolver(root: Path) -> ImmutableReleaseResolver: + return ImmutableReleaseResolver( + releases_root=root / "releases", + selector_path=root / "selected-release.json", + expected_owner_uid=os.getuid(), + ) + + +@pytest.mark.unit +def test_install_validates_every_hash_before_replacing_any_asset( + tmp_path: Path, +) -> None: + assets = tmp_path / "assets" + assets.mkdir() + (assets / "launcher").write_text("new launcher") + (assets / "service").write_text("new service") + installed = tmp_path / "installed" + existing = installed / "launcher" + existing.parent.mkdir() + existing.write_text("old launcher") + targets = ( + AssetTarget("launcher", existing, 0o755), + AssetTarget("service", installed / "service", 0o644), + ) + manifest = build_asset_manifest( + asset_root=assets, + release_id=RELEASE_A, + package_version="0.9.1", + asset_names=("launcher", "service"), + ) + (assets / "service").write_text("tampered") + deployment = SystemReleaseDeployment( + resolver=_resolver(tmp_path / "release-state"), + targets=targets, + expected_owner_uid=os.getuid(), + ) + + with pytest.raises(DeploymentError, match="hash mismatch"): + deployment.install_assets(assets, manifest) + + assert existing.read_text() == "old launcher" + assert not (installed / "service").exists() + + +@pytest.mark.unit +def test_upgrade_and_rollback_preserve_policy_and_run_records(tmp_path: Path) -> None: + _stage_release(tmp_path, RELEASE_A) + _stage_release(tmp_path, RELEASE_B) + resolver = _resolver(tmp_path) + deployment = SystemReleaseDeployment( + resolver=resolver, + targets=(AssetTarget("unused", tmp_path / "unused", 0o644),), + expected_owner_uid=os.getuid(), + ) + policy = tmp_path / "policy.json" + record = tmp_path / "records" / "run.json" + record.parent.mkdir() + policy.write_text("approved-policy") + record.write_text("durable-run") + + def probe(*_executables: Path) -> bool: + return True + + deployment.activate(RELEASE_A, health_probe=probe) + deployment.activate(RELEASE_B, health_probe=probe) + assert resolver.resolve({}).parts[-4] == RELEASE_B + deployment.rollback(health_probe=probe) + + assert resolver.resolve({}).parts[-4] == RELEASE_A + assert policy.read_text() == "approved-policy" + assert record.read_text() == "durable-run" + + +@pytest.mark.unit +def test_failed_upgrade_probe_does_not_change_selected_release(tmp_path: Path) -> None: + _stage_release(tmp_path, RELEASE_A) + _stage_release(tmp_path, RELEASE_B) + resolver = _resolver(tmp_path) + deployment = SystemReleaseDeployment( + resolver=resolver, + targets=(AssetTarget("unused", tmp_path / "unused", 0o644),), + expected_owner_uid=os.getuid(), + ) + resolver.select(RELEASE_A) + + with pytest.raises(DeploymentError, match="probe failed"): + deployment.activate(RELEASE_B, health_probe=lambda *_paths: False) + + assert resolver.resolve({}).parts[-4] == RELEASE_A + + +@pytest.mark.unit +def test_linux_asset_set_covers_launchers_backend_tray_and_schedules( + tmp_path: Path, +) -> None: + targets = linux_asset_targets( + bin_root=tmp_path / "bin", + libexec_root=tmp_path / "libexec", + unit_root=tmp_path / "units", + config_root=tmp_path / "etc", + autostart_root=tmp_path / "autostart", + ) + sources = {target.source_name for target in targets} + + assert { + "timelocker-launcher", + "tl-launcher", + "timelocker-system-control-launcher", + "timelocker-tray-launcher", + "timelocker-control.service", + "timelocker-control.socket", + "timelocker-retention.service", + "timelocker-retention.timer", + "timelocker-tray.desktop", + } <= sources + policy = next( + target + for target in targets + if target.source_name == "system-control-policy.json" + ) + assert policy.preserve_existing diff --git a/tests/TimeLocker/system_control/test_linux_adapter.py b/tests/TimeLocker/system_control/test_linux_adapter.py index a73b194..0bf7ef6 100644 --- a/tests/TimeLocker/system_control/test_linux_adapter.py +++ b/tests/TimeLocker/system_control/test_linux_adapter.py @@ -226,6 +226,18 @@ def test_socket_and_service_templates_enforce_narrow_boundary(self) -> None: assert "ProtectSystem=strict" in service_unit assert "ProtectHome=yes" in service_unit assert "RestrictAddressFamilies=AF_UNIX" in service_unit + assert ( + "ExecStart=/usr/local/libexec/timelocker-system-control --systemd-socket" + in service_unit + ) assert "EnvironmentFile=" not in service_unit assert "DISPLAY=" not in service_unit assert "s3://" not in service_unit + + retention_service = ( + ASSET_DIRECTORY / "timelocker-retention.service" + ).read_text() + retention_timer = (ASSET_DIRECTORY / "timelocker-retention.timer").read_text() + assert "ConditionPathExists=/etc/timelocker/retention-enabled" in retention_service + assert "--scheduled-retention" in retention_service + assert "Persistent=false" in retention_timer diff --git a/tests/TimeLocker/system_control/test_release_launcher.py b/tests/TimeLocker/system_control/test_release_launcher.py index bde5ab3..bf30050 100644 --- a/tests/TimeLocker/system_control/test_release_launcher.py +++ b/tests/TimeLocker/system_control/test_release_launcher.py @@ -25,6 +25,10 @@ def _stage_release(root: Path, release_id: str) -> Path: release.chmod(0o755) executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") executable.chmod(0o755) + for sibling in ("timelocker-system-control", "timelocker-tray"): + sibling_executable = executable.with_name(sibling) + sibling_executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + sibling_executable.chmod(0o755) manifest = release / "release.json" manifest.write_text( json.dumps( @@ -58,6 +62,22 @@ def test_timelocker_and_tl_share_one_selected_release(tmp_path: Path) -> None: assert resolver.resolve({}) == expected assert resolver.resolve({}) == expected + assert resolver.resolve_entrypoint("backend", {}) == expected.with_name( + "timelocker-system-control" + ) + assert resolver.resolve_entrypoint("tray", {}) == expected.with_name( + "timelocker-tray" + ) + + +@pytest.mark.unit +def test_non_allowlisted_release_entrypoint_is_rejected(tmp_path: Path) -> None: + _stage_release(tmp_path, RELEASE_A) + resolver = _resolver(tmp_path) + resolver.select(RELEASE_A) + + with pytest.raises(ReleaseResolutionError, match="allowlisted"): + resolver.resolve_entrypoint("../../bin/sh", {}) @pytest.mark.unit @@ -156,9 +176,16 @@ def test_staged_launcher_has_no_pyenv_checkout_or_root_overlay_fallback() -> Non ) primary = (assets / "timelocker-launcher").read_text(encoding="utf-8") alias = (assets / "tl-launcher").read_text(encoding="utf-8") - for content in (primary, alias): + backend = (assets / "timelocker-system-control-launcher").read_text( + encoding="utf-8" + ) + tray = (assets / "timelocker-tray-launcher").read_text(encoding="utf-8") + for content in (primary, alias, backend, tray): assert "/opt/timelocker/launcher/venv/bin/python" in content - assert "-m TimeLocker.system_control.launcher_entry" in content assert "pyenv" not in content assert "/root/.timelocker" not in content assert "Projects/" not in content + assert "-m TimeLocker.system_control.launcher_entry" in primary + assert "-m TimeLocker.system_control.launcher_entry" in alias + assert "-m TimeLocker.system_control.backend_launcher_entry" in backend + assert "-m TimeLocker.system_control.tray_launcher_entry" in tray diff --git a/tests/TimeLocker/system_control/test_tray_process_boundary.py b/tests/TimeLocker/system_control/test_tray_process_boundary.py index f3861ca..b1d91c0 100644 --- a/tests/TimeLocker/system_control/test_tray_process_boundary.py +++ b/tests/TimeLocker/system_control/test_tray_process_boundary.py @@ -7,6 +7,7 @@ import pytest +from TimeLocker.system_control import tray_entry from TimeLocker.system_control.tray_entry import _single_instance @@ -45,3 +46,32 @@ def test_tray_single_instance_lock_rejects_second_owner(tmp_path) -> None: pytest.fail("second tray instance acquired the same lock") assert not lock_path.exists() + + +@pytest.mark.unit +def test_one_shot_action_does_not_construct_desktop_tray(monkeypatch) -> None: + arguments = type( + "Arguments", + (), + { + "action": "status", + "target_id": "production", + "retention_policy_fingerprint": None, + "dry_run_retention": False, + }, + )() + + monkeypatch.setattr(tray_entry, "_parse_args", lambda: arguments) + monkeypatch.setattr( + tray_entry, + "_build_client", + lambda *_args, **_kwargs: object(), + ) + monkeypatch.setattr(tray_entry, "_handle_action", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + tray_entry, + "SystemTrayIntegration", + lambda **_kwargs: pytest.fail("one-shot action constructed a GUI tray"), + ) + + tray_entry.main() diff --git a/tests/TimeLocker/system_control/test_windows_adapter.py b/tests/TimeLocker/system_control/test_windows_adapter.py new file mode 100644 index 0000000..5608ad3 --- /dev/null +++ b/tests/TimeLocker/system_control/test_windows_adapter.py @@ -0,0 +1,117 @@ +"""Contract tests for the platform-neutral Windows adapter seam.""" + +from dataclasses import dataclass, field + +import pytest + +from TimeLocker.system_control.interfaces import PeerIdentity +from TimeLocker.system_control.windows_adapter import ( + WindowsCurrentGroupMembershipResolver, + WindowsNamedPipeTransport, + WindowsPeerIdentityProvider, + WindowsPeerToken, +) + + +class TokenProvider: + def peer_token(self, _connection: object) -> WindowsPeerToken: + return WindowsPeerToken(sid="S-1-5-21-1000", process_id=42) + + +@dataclass +class GroupProvider: + allowed: bool + calls: int = 0 + + def is_current_member(self, sid: str, group_name: str) -> bool: + self.calls += 1 + assert sid == "S-1-5-21-1000" + assert group_name == "timelocker-operators" + return self.allowed + + +@dataclass +class Connection: + request: bytes + sent: list[bytes] = field(default_factory=list) + closed: bool = False + + def receive(self, _maximum: int) -> bytes: + return self.request + + def send(self, payload: bytes) -> None: + self.sent.append(payload) + + def close(self) -> None: + self.closed = True + + +class Acceptor: + def __init__(self, connection: Connection) -> None: + self.connection = connection + + def accept(self) -> Connection: + return self.connection + + +class Handler: + def handle(self, request: bytes, identity: PeerIdentity) -> bytes: + assert request == b"request" + assert identity.platform_id == "windows-sid:S-1-5-21-1000" + return b"response" + + +@pytest.mark.unit +def test_identity_comes_from_pipe_token_provider() -> None: + identity = WindowsPeerIdentityProvider(TokenProvider()).peer_identity(object()) + + assert identity == PeerIdentity( + platform_id="windows-sid:S-1-5-21-1000", + process_id=42, + ) + + +@pytest.mark.unit +def test_group_membership_is_rechecked_and_fails_closed() -> None: + provider = GroupProvider(allowed=True) + resolver = WindowsCurrentGroupMembershipResolver(provider) + identity = PeerIdentity(platform_id="windows-sid:S-1-5-21-1000") + + assert resolver.is_current_member(identity, "timelocker-operators") + provider.allowed = False + assert not resolver.is_current_member(identity, "timelocker-operators") + assert provider.calls == 2 + assert not resolver.is_current_member( + PeerIdentity(platform_id="linux-uid:1000"), + "timelocker-operators", + ) + + +@pytest.mark.unit +def test_named_pipe_transport_bounds_request_and_closes_connection() -> None: + connection = Connection(b"request") + transport = WindowsNamedPipeTransport( + Acceptor(connection), + TokenProvider(), + max_request_bytes=1024, + ) + + transport.serve_once(Handler()) + + assert connection.sent == [b"response"] + assert connection.closed + + +@pytest.mark.unit +def test_named_pipe_transport_rejects_oversized_request() -> None: + connection = Connection(b"x" * 1025) + transport = WindowsNamedPipeTransport( + Acceptor(connection), + TokenProvider(), + max_request_bytes=1024, + ) + + with pytest.raises(OSError, match="exceeds"): + transport.serve_once(Handler()) + + assert connection.closed From 52a9c1d7f63bfca1f2fecfe4f8ea0a9f5d8404f3 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:46:34 +0100 Subject: [PATCH 39/72] fix(system): make installed launcher startup portable Keep immutable selector metadata readable under restrictive administrator\numasks and move default performance metrics out of the caller working\ndirectory. Add regression coverage for both live-rollout failures while\nleaving Spec 009 T010 truthfully in progress. --- .../009-system-cli-tray-retention/tasks.md | 7 ++-- src/TimeLocker/performance/metrics.py | 25 +++++++---- .../system_control/release_launcher.py | 1 + .../performance/test_metrics_paths.py | 42 +++++++++++++++++++ .../system_control/test_release_launcher.py | 16 +++++++ 5 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 tests/TimeLocker/performance/test_metrics_paths.py diff --git a/docs/specs/009-system-cli-tray-retention/tasks.md b/docs/specs/009-system-cli-tray-retention/tasks.md index c5cdbcc..69a297f 100644 --- a/docs/specs/009-system-cli-tray-retention/tasks.md +++ b/docs/specs/009-system-cli-tray-retention/tasks.md @@ -257,7 +257,7 @@ T009 -> T010 -> T011 -> T012 loading tray integration, and confirmed `pystray` was absent. - Evidence mode: validation -- [ ] T010 Run controlled Linux Mint live acceptance and rollback rehearsal. +- [~] T010 Run controlled Linux Mint live acceptance and rollback rehearsal. - Depends on: T009 - Requirements: Requirements 1-6; SC-001-SC-011 - Files: `verification.md`, external system assets only after explicit rollout @@ -265,8 +265,9 @@ T009 -> T010 -> T011 -> T012 - Acceptance: Authorized and denied system views, system launcher, scheduled backup, restore, post-success retention, independent retention, tray reconnect, interrupted-run recovery, upgrade, and rollback are evidenced. - - Evidence mode: validation - - Evidence: Pending. + - Evidence mode: external + - Evidence: Operator approved T010 rollout on 2026-07-26. Scope: create the operator group, install root-owned committed-release launchers/assets, enable the control socket, install tray autostart, stage retention units disabled, and rehearse upgrade/rollback while preserving the existing 03:30 backup timer. Retention mutation remains gated on separate approval of a successful identical dry-run fingerprint. Rules consulted and applied: Coding Standards (100), General Preferences (50), Operational Best Practices (40), Planning Protocol (30), Testing Conventions (25), Documentation Conventions (20), Git Conventions (15). + - Status: Live Linux Mint rollout and acceptance in progress; retention mutation not approved. - [ ] T010.1 Stage without changing the working 03:30 backup. - [ ] T010.2 Obtain explicit approval before group membership, service, launcher, timer, or live-retention mutations. diff --git a/src/TimeLocker/performance/metrics.py b/src/TimeLocker/performance/metrics.py index 23184bb..2c28f38 100644 --- a/src/TimeLocker/performance/metrics.py +++ b/src/TimeLocker/performance/metrics.py @@ -2,12 +2,12 @@ Performance metrics collection and analysis for TimeLocker """ -import time import threading from typing import Dict, List, Optional, Any from dataclasses import dataclass, field from datetime import datetime import json +import os from pathlib import Path @@ -52,7 +52,7 @@ class PerformanceMetrics: """Centralized performance metrics collection""" def __init__(self, metrics_file: Optional[Path] = None): - self.metrics_file = metrics_file or Path("performance_metrics.json") + self.metrics_file = metrics_file or _default_metrics_file() self._operations: Dict[str, OperationMetrics] = {} self._completed_operations: List[OperationMetrics] = [] self._lock = threading.Lock() @@ -166,10 +166,10 @@ def get_performance_summary(self, operation_type: Optional[str] = None) -> Dict[ def _load_metrics(self): """Load metrics from file""" - if not self.metrics_file.exists(): - return - try: + if not self.metrics_file.exists(): + return + with open(self.metrics_file, 'r') as f: data = json.load(f) @@ -187,25 +187,36 @@ def _load_metrics(self): ) self._completed_operations.append(metrics) - except Exception as e: + except Exception: # If we can't load metrics, start fresh pass def _save_metrics(self): """Save metrics to file""" try: + self.metrics_file.parent.mkdir(mode=0o700, parents=True, exist_ok=True) data = { 'completed_operations': [op.to_dict() for op in self._completed_operations] } with open(self.metrics_file, 'w') as f: json.dump(data, f, indent=2) + self.metrics_file.chmod(0o600) - except Exception as e: + except Exception: # Fail silently to avoid disrupting operations pass +def _default_metrics_file() -> Path: + """Resolve metrics outside the caller's working directory.""" + if os.name != "nt" and hasattr(os, "geteuid") and os.geteuid() == 0: + return Path("/var/lib/timelocker/performance_metrics.json") + cache_home = os.environ.get("XDG_CACHE_HOME") + base = Path(cache_home) if cache_home else Path.home() / ".cache" + return base / "timelocker" / "performance_metrics.json" + + # Global metrics instance _global_metrics = PerformanceMetrics() diff --git a/src/TimeLocker/system_control/release_launcher.py b/src/TimeLocker/system_control/release_launcher.py index 7672bd2..d2a140f 100644 --- a/src/TimeLocker/system_control/release_launcher.py +++ b/src/TimeLocker/system_control/release_launcher.py @@ -296,6 +296,7 @@ def _atomic_write_json(path: Path, value: Mapping[str, object]) -> None: stream.write("\n") stream.flush() os.fsync(stream.fileno()) + os.fchmod(stream.fileno(), 0o644) os.replace(temporary, path) directory = os.open(path.parent, os.O_RDONLY) try: diff --git a/tests/TimeLocker/performance/test_metrics_paths.py b/tests/TimeLocker/performance/test_metrics_paths.py new file mode 100644 index 0000000..7658049 --- /dev/null +++ b/tests/TimeLocker/performance/test_metrics_paths.py @@ -0,0 +1,42 @@ +"""Performance metrics path and startup safety tests.""" + +from pathlib import Path + +import pytest + +from TimeLocker.performance.metrics import PerformanceMetrics + + +@pytest.mark.unit +def test_default_metrics_path_uses_xdg_cache_not_working_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + cache_root = tmp_path / "cache" + working_root = tmp_path / "working" + working_root.mkdir() + monkeypatch.setenv("XDG_CACHE_HOME", str(cache_root)) + monkeypatch.chdir(working_root) + + metrics = PerformanceMetrics() + + assert metrics.metrics_file == ( + cache_root / "timelocker" / "performance_metrics.json" + ) + assert metrics.metrics_file.parent != working_root + + +@pytest.mark.unit +def test_unreadable_metrics_parent_does_not_break_initialization( + tmp_path: Path, +) -> None: + blocked = tmp_path / "blocked" + blocked.mkdir(mode=0o700) + metrics_path = blocked / "performance_metrics.json" + blocked.chmod(0o000) + try: + metrics = PerformanceMetrics(metrics_path) + finally: + blocked.chmod(0o700) + + assert metrics.metrics_file == metrics_path diff --git a/tests/TimeLocker/system_control/test_release_launcher.py b/tests/TimeLocker/system_control/test_release_launcher.py index bf30050..836d6e2 100644 --- a/tests/TimeLocker/system_control/test_release_launcher.py +++ b/tests/TimeLocker/system_control/test_release_launcher.py @@ -96,6 +96,22 @@ def test_release_switch_and_rollback_are_atomic_and_symmetric(tmp_path: Path) -> assert resolver.resolve({}) == release_a +@pytest.mark.unit +def test_release_selector_mode_ignores_restrictive_process_umask( + tmp_path: Path, +) -> None: + _stage_release(tmp_path, RELEASE_A) + resolver = _resolver(tmp_path) + previous_umask = os.umask(0o027) + try: + resolver.select(RELEASE_A) + finally: + os.umask(previous_umask) + + assert resolver.selector_path.stat().st_mode & 0o777 == 0o644 + assert resolver.resolve({}).name == "timelocker" + + @pytest.mark.unit def test_missing_selected_release_never_falls_back_to_user_environment( tmp_path: Path, From d4d5e8841e2ce729d17995acda9c2a6656093525 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:50:25 +0100 Subject: [PATCH 40/72] fix(system): allow operator socket traversal Keep the runtime directory root-owned and non-writable while allowing\nclients to traverse to the group-restricted control socket. Preserve the\nroot-only state directory and add unit-asset regression coverage. --- src/TimeLocker/system_control/assets/timelocker-control.service | 2 +- tests/TimeLocker/system_control/test_linux_adapter.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/TimeLocker/system_control/assets/timelocker-control.service b/src/TimeLocker/system_control/assets/timelocker-control.service index 78e9399..63185ea 100644 --- a/src/TimeLocker/system_control/assets/timelocker-control.service +++ b/src/TimeLocker/system_control/assets/timelocker-control.service @@ -9,7 +9,7 @@ User=root Group=root UMask=0077 RuntimeDirectory=timelocker -RuntimeDirectoryMode=0750 +RuntimeDirectoryMode=0755 StateDirectory=timelocker StateDirectoryMode=0750 ExecStart=/usr/local/libexec/timelocker-system-control --systemd-socket diff --git a/tests/TimeLocker/system_control/test_linux_adapter.py b/tests/TimeLocker/system_control/test_linux_adapter.py index 0bf7ef6..015b982 100644 --- a/tests/TimeLocker/system_control/test_linux_adapter.py +++ b/tests/TimeLocker/system_control/test_linux_adapter.py @@ -222,6 +222,8 @@ def test_socket_and_service_templates_enforce_narrow_boundary(self) -> None: assert "SocketMode=0660" in socket_unit assert "User=root" in service_unit assert "UMask=0077" in service_unit + assert "RuntimeDirectoryMode=0755" in service_unit + assert "StateDirectoryMode=0750" in service_unit assert "NoNewPrivileges=yes" in service_unit assert "ProtectSystem=strict" in service_unit assert "ProtectHome=yes" in service_unit From 8630b2425b82157ebc0b0b8955d41d416ce66461 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:52:37 +0100 Subject: [PATCH 41/72] fix(system): preserve control socket across restarts Make the socket unit own runtime-directory creation so restarting the\nbackend cannot unlink an active socket pathname. Keep the directory\nroot-owned and non-writable with group access enforced on the socket. --- .../system_control/assets/timelocker-control.service | 2 -- src/TimeLocker/system_control/assets/timelocker-control.socket | 1 + tests/TimeLocker/system_control/test_linux_adapter.py | 3 ++- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/TimeLocker/system_control/assets/timelocker-control.service b/src/TimeLocker/system_control/assets/timelocker-control.service index 63185ea..5c01b5f 100644 --- a/src/TimeLocker/system_control/assets/timelocker-control.service +++ b/src/TimeLocker/system_control/assets/timelocker-control.service @@ -8,8 +8,6 @@ Type=simple User=root Group=root UMask=0077 -RuntimeDirectory=timelocker -RuntimeDirectoryMode=0755 StateDirectory=timelocker StateDirectoryMode=0750 ExecStart=/usr/local/libexec/timelocker-system-control --systemd-socket diff --git a/src/TimeLocker/system_control/assets/timelocker-control.socket b/src/TimeLocker/system_control/assets/timelocker-control.socket index 4d674a3..5f1bdb8 100644 --- a/src/TimeLocker/system_control/assets/timelocker-control.socket +++ b/src/TimeLocker/system_control/assets/timelocker-control.socket @@ -3,6 +3,7 @@ Description=TimeLocker local system-control socket [Socket] ListenStream=/run/timelocker/control.sock +DirectoryMode=0755 SocketUser=root SocketGroup=timelocker-operators SocketMode=0660 diff --git a/tests/TimeLocker/system_control/test_linux_adapter.py b/tests/TimeLocker/system_control/test_linux_adapter.py index 015b982..b7f52cd 100644 --- a/tests/TimeLocker/system_control/test_linux_adapter.py +++ b/tests/TimeLocker/system_control/test_linux_adapter.py @@ -218,11 +218,12 @@ def test_socket_and_service_templates_enforce_narrow_boundary(self) -> None: service_unit = (ASSET_DIRECTORY / "timelocker-control.service").read_text() assert "ListenStream=/run/timelocker/control.sock" in socket_unit + assert "DirectoryMode=0755" in socket_unit assert "SocketGroup=timelocker-operators" in socket_unit assert "SocketMode=0660" in socket_unit assert "User=root" in service_unit assert "UMask=0077" in service_unit - assert "RuntimeDirectoryMode=0755" in service_unit + assert "RuntimeDirectory=" not in service_unit assert "StateDirectoryMode=0750" in service_unit assert "NoNewPrivileges=yes" in service_unit assert "ProtectSystem=strict" in service_unit From 6fe7848bc03c7188ea05fedbebc27dd721b60430 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:06:14 +0100 Subject: [PATCH 42/72] feat(system): activate protected retention adapter --- .../cli_modules/commands/repositories.py | 2 + src/TimeLocker/cli_services.py | 4 +- .../repository_service_interface.py | 4 +- src/TimeLocker/services/repository_service.py | 24 +- .../assets/timelocker-control.service | 3 +- .../assets/timelocker-retention.service | 1 + .../system_control/backend_entry.py | 74 ++++- .../system_control/production_retention.py | 277 ++++++++++++++++++ .../system_control/test_backend_entry.py | 6 +- .../system_control/test_linux_adapter.py | 5 +- .../test_production_retention.py | 155 ++++++++++ 11 files changed, 536 insertions(+), 19 deletions(-) create mode 100644 src/TimeLocker/system_control/production_retention.py create mode 100644 tests/TimeLocker/system_control/test_production_retention.py diff --git a/src/TimeLocker/cli_modules/commands/repositories.py b/src/TimeLocker/cli_modules/commands/repositories.py index e66a4fe..288b05b 100644 --- a/src/TimeLocker/cli_modules/commands/repositories.py +++ b/src/TimeLocker/cli_modules/commands/repositories.py @@ -1929,6 +1929,7 @@ def repos_forget( keep_weekly: Annotated[int, typer.Option("--keep-weekly", help="Number of weekly snapshots to keep")] = 4, keep_monthly: Annotated[int, typer.Option("--keep-monthly", help="Number of monthly snapshots to keep")] = 12, keep_yearly: Annotated[int, typer.Option("--keep-yearly", help="Number of yearly snapshots to keep")] = 3, + group_by: Annotated[str, typer.Option("--group-by", help="Restic grouping fields (host,paths,tags)")] = "host,paths", dry_run: DryRunOption = False, prune: Annotated[bool, typer.Option("--prune/--no-prune", help="Prune repository after forgetting snapshots", rich_help_panel=None)] = False, repository: Annotated[ @@ -1956,6 +1957,7 @@ def repos_forget( keep_weekly=keep_weekly, keep_monthly=keep_monthly, keep_yearly=keep_yearly, + group_by=group_by, dry_run=dry_run, password=password ) diff --git a/src/TimeLocker/cli_services.py b/src/TimeLocker/cli_services.py index 363d386..33d0db4 100644 --- a/src/TimeLocker/cli_services.py +++ b/src/TimeLocker/cli_services.py @@ -1025,6 +1025,7 @@ def apply_retention_policy(self, keep_monthly: int = 12, keep_yearly: int = 3, dry_run: bool = False, + group_by: str = "host,paths", password: Optional[str] = None, **_) -> Dict[str, Any]: """Apply forget/retention policy to repository.""" @@ -1040,7 +1041,8 @@ def apply_retention_policy(self, keep_weekly=keep_weekly, keep_monthly=keep_monthly, keep_yearly=keep_yearly, - dry_run=dry_run + dry_run=dry_run, + group_by=group_by ) def prune_repository(self, diff --git a/src/TimeLocker/interfaces/repository_service_interface.py b/src/TimeLocker/interfaces/repository_service_interface.py index 4a78b30..ad2a8cf 100644 --- a/src/TimeLocker/interfaces/repository_service_interface.py +++ b/src/TimeLocker/interfaces/repository_service_interface.py @@ -85,7 +85,8 @@ def migrate_repository(self, repository: BackupRepository, migration_name: str = def apply_retention_policy(self, repository: BackupRepository, keep_daily: int = 7, keep_weekly: int = 4, keep_monthly: int = 12, keep_yearly: int = 3, - dry_run: bool = False) -> Dict[str, Any]: + dry_run: bool = False, + group_by: str = "host,paths") -> Dict[str, Any]: """ Apply retention policy to repository @@ -96,6 +97,7 @@ def apply_retention_policy(self, repository: BackupRepository, keep_monthly: Number of monthly snapshots to keep keep_yearly: Number of yearly snapshots to keep dry_run: If True, only show what would be removed + group_by: Explicit Restic snapshot grouping fields Returns: Dictionary with policy application results diff --git a/src/TimeLocker/services/repository_service.py b/src/TimeLocker/services/repository_service.py index 65bcdc0..7eab211 100644 --- a/src/TimeLocker/services/repository_service.py +++ b/src/TimeLocker/services/repository_service.py @@ -431,7 +431,8 @@ def migrate_repository(self, repository: BackupRepository, migration_name: str = def apply_retention_policy(self, repository: BackupRepository, keep_daily: int = 7, keep_weekly: int = 4, keep_monthly: int = 12, keep_yearly: int = 3, - dry_run: bool = False) -> Dict[str, Any]: + dry_run: bool = False, + group_by: str = "host,paths") -> Dict[str, Any]: """ Apply retention policy to repository @@ -442,6 +443,7 @@ def apply_retention_policy(self, repository: BackupRepository, keep_monthly: Number of monthly snapshots to keep keep_yearly: Number of yearly snapshots to keep dry_run: If True, only show what would be removed + group_by: Explicit Restic snapshot grouping fields Returns: Dictionary with policy application results @@ -454,6 +456,14 @@ def apply_retention_policy(self, repository: BackupRepository, cmd.extend(['--keep-weekly', str(keep_weekly)]) cmd.extend(['--keep-monthly', str(keep_monthly)]) cmd.extend(['--keep-yearly', str(keep_yearly)]) + grouping = [item.strip() for item in group_by.split(',')] + if ( + not grouping + or any(item not in {"host", "paths", "tags"} for item in grouping) + or len(grouping) != len(set(grouping)) + ): + raise ValueError("group_by contains unsupported grouping fields") + cmd.extend(['--group-by', ','.join(grouping)]) if dry_run: cmd.append('--dry-run') @@ -484,10 +494,14 @@ def apply_retention_policy(self, repository: BackupRepository, for line in result.stdout.strip().split('\n'): if line.strip(): data = json.loads(line) - if 'remove' in data: - policy_results['removed_snapshots'].extend(data['remove']) - if 'keep' in data: - policy_results['kept_snapshots'].extend(data['keep']) + groups = data if isinstance(data, list) else [data] + for group in groups: + if not isinstance(group, dict): + continue + if 'remove' in group: + policy_results['removed_snapshots'].extend(group['remove']) + if 'keep' in group: + policy_results['kept_snapshots'].extend(group['keep']) except json.JSONDecodeError: logger.warning("Failed to parse retention policy JSON output") policy_results['output'] = result.stdout diff --git a/src/TimeLocker/system_control/assets/timelocker-control.service b/src/TimeLocker/system_control/assets/timelocker-control.service index 5c01b5f..983453a 100644 --- a/src/TimeLocker/system_control/assets/timelocker-control.service +++ b/src/TimeLocker/system_control/assets/timelocker-control.service @@ -8,6 +8,7 @@ Type=simple User=root Group=root UMask=0077 +EnvironmentFile=-/etc/timelocker/retention.env StateDirectory=timelocker StateDirectoryMode=0750 ExecStart=/usr/local/libexec/timelocker-system-control --systemd-socket @@ -21,7 +22,7 @@ ProtectKernelModules=yes ProtectControlGroups=yes RestrictSUIDSGID=yes LockPersonality=yes -RestrictAddressFamilies=AF_UNIX +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 ReadWritePaths=/run/timelocker /var/lib/timelocker [Install] diff --git a/src/TimeLocker/system_control/assets/timelocker-retention.service b/src/TimeLocker/system_control/assets/timelocker-retention.service index 46c3344..3ba89ed 100644 --- a/src/TimeLocker/system_control/assets/timelocker-retention.service +++ b/src/TimeLocker/system_control/assets/timelocker-retention.service @@ -8,6 +8,7 @@ Type=oneshot User=root Group=root UMask=0077 +EnvironmentFile=-/etc/timelocker/retention.env ExecStart=/usr/local/libexec/timelocker-system-control --scheduled-retention NoNewPrivileges=yes PrivateTmp=yes diff --git a/src/TimeLocker/system_control/backend_entry.py b/src/TimeLocker/system_control/backend_entry.py index 726dcc5..02fa3b6 100644 --- a/src/TimeLocker/system_control/backend_entry.py +++ b/src/TimeLocker/system_control/backend_entry.py @@ -34,6 +34,10 @@ SystemPolicy, ) from .policy_loader import load_system_policy +from .production_retention import ( + DEFAULT_PRODUCTION_TARGET_PATH, + load_production_retention_components, +) from .retention import ( RetentionAdapter, RetentionExecutionResult, @@ -319,6 +323,7 @@ def build_linux_backend( backup_adapter: BackupMutationAdapter | None = None, retention_adapter: RetentionAdapter | None = None, retention_plan_provider: RetentionPlanProvider | None = None, + production_target_path: Path | None = None, schedule_summary_provider: ScheduleSummaryProvider | None = None, max_diagnostics: int = 1_000, stop_event: Event | None = None, @@ -350,6 +355,17 @@ def build_linux_backend( ) membership_resolver = membership_resolver or LinuxNssGroupMembershipResolver() backup_adapter = backup_adapter or FailClosedBackupMutationAdapter() + if ( + retention_adapter is None + and retention_plan_provider is None + and production_target_path is not None + ): + retention_adapter, retention_plan_provider = ( + load_production_retention_components( + target_path=production_target_path, + expected_owner=paths.expected_owner, + ) + ) retention_adapter = retention_adapter or FailClosedRetentionAdapter() retention_plan_provider = ( retention_plan_provider or FailClosedRetentionPlanProvider() @@ -399,9 +415,38 @@ def run_linux_backend(**kwargs: object) -> None: service.serve_forever() -def run_scheduled_retention() -> None: - """Fail closed until a protected live repository adapter is configured.""" - raise RuntimeError("scheduled retention adapter is not configured") +def run_scheduled_retention( + *, + paths: LinuxBackendPaths, + production_target_path: Path, +) -> None: + """Run one approved independent retention attempt using protected config.""" + policy = load_system_policy( + paths.policy_path, + expected_owner=paths.expected_owner, + ) + store = AtomicRecordStore(paths.record_root) + locks = RepositoryMutationLock(paths.lock_root) + adapter, provider = load_production_retention_components( + target_path=production_target_path, + expected_owner=paths.expected_owner, + ) + plan = _apply_policy_defaults( + provider.resolve_retention_plan(policy), + policy.retention, + ) + coordinator = RetentionTriggerCoordinator( + executor=RetentionExecutor( + store=store, + locks=locks, + adapter=adapter, + ), + trigger_store=RetentionTriggerStore(paths.trigger_root), + independent_schedule_enabled=True, + ) + run = coordinator.scheduled(plan) + if run is None or run.state is RunState.FAILED: + raise RuntimeError("scheduled retention failed safely") def main(argv: list[str] | None = None) -> None: @@ -430,16 +475,29 @@ def main(argv: list[str] | None = None) -> None: default=Path("/var/lib/timelocker"), help=argparse.SUPPRESS, ) + parser.add_argument( + "--production-target", + type=Path, + default=DEFAULT_PRODUCTION_TARGET_PATH, + help=argparse.SUPPRESS, + ) arguments = parser.parse_args(argv) try: + paths = LinuxBackendPaths.from_state_root( + policy_path=arguments.policy, + state_root=arguments.state_root, + ) if arguments.scheduled_retention: - run_scheduled_retention() + run_scheduled_retention( + paths=paths, + production_target_path=arguments.production_target, + ) else: - paths = LinuxBackendPaths.from_state_root( - policy_path=arguments.policy, - state_root=arguments.state_root, + run_linux_backend( + paths=paths, + socket_mode="systemd", + production_target_path=arguments.production_target, ) - run_linux_backend(paths=paths, socket_mode="systemd") except (OSError, PermissionError, RuntimeError, TypeError, ValueError): parser.exit(78, "TimeLocker system backend failed to initialize safely.\n") diff --git a/src/TimeLocker/system_control/production_retention.py b/src/TimeLocker/system_control/production_retention.py new file mode 100644 index 0000000..8cd651d --- /dev/null +++ b/src/TimeLocker/system_control/production_retention.py @@ -0,0 +1,277 @@ +"""Root-configured Restic retention adapter for the system-control backend.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys + +from .models import RetentionPolicy, SystemPolicy +from .retention import RetentionExecutionResult, RetentionPlan +from .validation import require_exact_mapping, require_safe_identifier + + +DEFAULT_PRODUCTION_TARGET_PATH = Path("/etc/timelocker/production-target.json") +_CONFIG_FIELDS = frozenset( + { + "schema_version", + "target_id", + "repository_name", + "config_directory", + "repository_config", + "credential_source", + "snapshot_filters", + } +) +_REMOVED_PATTERN = re.compile(r"\bRemoved\s+([0-9]+)\s+snapshot") + + +@dataclass(frozen=True, slots=True) +class ProductionRetentionTarget: + """Secret-free references to one root-owned production target.""" + + target_id: str + repository_name: str + config_directory: Path + repository_config: Path + credential_source: Path + snapshot_filters: tuple[str, ...] = () + + def __post_init__(self) -> None: + for field_name in ("target_id", "repository_name"): + object.__setattr__( + self, + field_name, + require_safe_identifier( + getattr(self, field_name), + field=field_name, + maximum=128, + ), + ) + for field_name in ( + "config_directory", + "repository_config", + "credential_source", + ): + value = getattr(self, field_name) + if not isinstance(value, Path) or not value.is_absolute(): + raise ValueError(f"{field_name} must be an absolute Path") + if type(self.snapshot_filters) is not tuple: + raise TypeError("snapshot_filters must be a tuple") + for value in self.snapshot_filters: + if ( + not isinstance(value, str) + or not value + or len(value) > 1_024 + or "\x00" in value + ): + raise ValueError("snapshot_filters must contain bounded strings") + + @classmethod + def load( + cls, + path: Path = DEFAULT_PRODUCTION_TARGET_PATH, + *, + expected_owner: int = 0, + ) -> "ProductionRetentionTarget": + """Load a strict root-owned target without exposing protected values.""" + _require_protected_file(path, expected_owner=expected_owner) + value = json.loads(path.read_text(encoding="utf-8")) + mapping = require_exact_mapping( + value, + field="production retention target", + required=_CONFIG_FIELDS, + ) + if mapping["schema_version"] != 1: + raise ValueError("unsupported production target schema") + filters = mapping["snapshot_filters"] + if not isinstance(filters, list): + raise TypeError("snapshot_filters must be a list") + target = cls( + target_id=mapping["target_id"], + repository_name=mapping["repository_name"], + config_directory=Path(mapping["config_directory"]), + repository_config=Path(mapping["repository_config"]), + credential_source=Path(mapping["credential_source"]), + snapshot_filters=tuple(filters), + ) + _require_protected_file( + target.repository_config, + expected_owner=expected_owner, + ) + _require_protected_file( + target.credential_source, + expected_owner=expected_owner, + ) + return target + + def plan(self, policy: SystemPolicy) -> RetentionPlan: + """Build the exact reviewable plan using hashes, never secret contents.""" + if not isinstance(policy, SystemPolicy): + raise TypeError("policy must be a SystemPolicy") + return RetentionPlan( + target_id=self.target_id, + repository_identity=_file_identity(self.repository_config), + credential_source=_file_identity(self.credential_source), + snapshot_filters=self.snapshot_filters, + policy=RetentionPolicy( + keep_daily=policy.retention.keep_daily, + keep_weekly=policy.retention.keep_weekly, + keep_monthly=policy.retention.keep_monthly, + keep_yearly=policy.retention.keep_yearly, + group_by=policy.retention.group_by, + prune=policy.retention.prune, + approved_fingerprint=policy.retention.approved_fingerprint, + ), + ) + + +class ProductionRetentionPlanProvider: + """Resolve a policy-bound plan from protected root configuration.""" + + def __init__(self, target: ProductionRetentionTarget) -> None: + if not isinstance(target, ProductionRetentionTarget): + raise TypeError("target must be a ProductionRetentionTarget") + self.target = target + + def resolve_retention_plan(self, policy: SystemPolicy) -> RetentionPlan: + return self.target.plan(policy) + + +class TimeLockerCliRetentionAdapter: + """Invoke the existing repository service with a fixed, allowlisted command.""" + + def __init__( + self, + target: ProductionRetentionTarget, + *, + python_executable: Path | None = None, + runner: Callable[..., subprocess.CompletedProcess[str]] | None = None, + environment: Mapping[str, str] | None = None, + ) -> None: + if not isinstance(target, ProductionRetentionTarget): + raise TypeError("target must be a ProductionRetentionTarget") + executable = python_executable or Path(sys.executable) + if not executable.is_absolute(): + raise ValueError("python_executable must be absolute") + self.target = target + self.python_executable = executable + self._runner = runner or subprocess.run + self._environment = dict(environment) if environment is not None else None + + def execute( + self, + plan: RetentionPlan, + *, + dry_run: bool, + ) -> RetentionExecutionResult: + if not isinstance(plan, RetentionPlan): + raise TypeError("plan must be a RetentionPlan") + if type(dry_run) is not bool: + raise TypeError("dry_run must be a bool") + expected = self.target.plan( + SystemPolicy(retention=plan.policy), + ) + if ( + plan.target_id != expected.target_id + or plan.repository_identity != expected.repository_identity + or plan.credential_source != expected.credential_source + or plan.snapshot_filters != expected.snapshot_filters + ): + raise PermissionError("retention plan does not match protected target") + + command = [ + str(self.python_executable), + "-m", + "TimeLocker.cli", + "repos", + "forget", + self.target.repository_name, + "--keep-daily", + str(plan.policy.keep_daily), + "--keep-weekly", + str(plan.policy.keep_weekly), + "--keep-monthly", + str(plan.policy.keep_monthly), + "--keep-yearly", + str(plan.policy.keep_yearly), + "--group-by", + ",".join(plan.policy.group_by), + "--no-prune", + "--config-dir", + str(self.target.config_directory), + ] + if dry_run: + command.append("--dry-run") + result = self._runner( + command, + capture_output=True, + text=True, + check=False, + cwd="/", + env=self._environment or os.environ.copy(), + timeout=4 * 60 * 60, + ) + if result.returncode != 0: + raise RuntimeError("retention command failed") + selected = _removed_count(result.stdout) + return RetentionExecutionResult( + selected_snapshots=selected, + removed_snapshots=0 if dry_run else selected, + ) + + +def load_production_retention_components( + *, + target_path: Path = DEFAULT_PRODUCTION_TARGET_PATH, + expected_owner: int = 0, +) -> tuple[TimeLockerCliRetentionAdapter, ProductionRetentionPlanProvider]: + """Load the production components or fail before any repository access.""" + target = ProductionRetentionTarget.load( + target_path, + expected_owner=expected_owner, + ) + return ( + TimeLockerCliRetentionAdapter(target), + ProductionRetentionPlanProvider(target), + ) + + +def _removed_count(output: str) -> int: + matches = _REMOVED_PATTERN.findall(output) + if not matches: + return 0 + return sum(int(value) for value in matches) + + +def _file_identity(path: Path) -> str: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + return f"sha256:{digest}" + + +def _require_protected_file(path: Path, *, expected_owner: int) -> None: + if not isinstance(path, Path) or not path.is_absolute(): + raise ValueError("protected file path must be absolute") + metadata = path.lstat() + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): + raise ValueError("protected path must be a regular file") + if metadata.st_uid != expected_owner: + raise PermissionError("protected file has an unexpected owner") + if stat.S_IMODE(metadata.st_mode) & 0o022: + raise PermissionError("protected file must not be group/world writable") + + +__all__: Sequence[str] = ( + "DEFAULT_PRODUCTION_TARGET_PATH", + "ProductionRetentionPlanProvider", + "ProductionRetentionTarget", + "TimeLockerCliRetentionAdapter", + "load_production_retention_components", +) diff --git a/tests/TimeLocker/system_control/test_backend_entry.py b/tests/TimeLocker/system_control/test_backend_entry.py index 277bfa5..f4260e7 100644 --- a/tests/TimeLocker/system_control/test_backend_entry.py +++ b/tests/TimeLocker/system_control/test_backend_entry.py @@ -23,7 +23,7 @@ def test_scheduled_retention_fails_closed_without_live_adapter( monkeypatch.setattr( backend_entry, "run_scheduled_retention", - lambda: (_ for _ in ()).throw(RuntimeError("protected URI")), + lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("protected URI")), ) with pytest.raises(SystemExit) as caught: @@ -59,6 +59,10 @@ def test_main_composes_only_explicit_system_paths(monkeypatch, tmp_path: Path) - assert paths.policy_path == policy assert paths.record_root == state / "records" assert captured["socket_mode"] == "systemd" + assert ( + captured["production_target_path"] + == backend_entry.DEFAULT_PRODUCTION_TARGET_PATH + ) @pytest.mark.unit diff --git a/tests/TimeLocker/system_control/test_linux_adapter.py b/tests/TimeLocker/system_control/test_linux_adapter.py index b7f52cd..7f87964 100644 --- a/tests/TimeLocker/system_control/test_linux_adapter.py +++ b/tests/TimeLocker/system_control/test_linux_adapter.py @@ -228,12 +228,12 @@ def test_socket_and_service_templates_enforce_narrow_boundary(self) -> None: assert "NoNewPrivileges=yes" in service_unit assert "ProtectSystem=strict" in service_unit assert "ProtectHome=yes" in service_unit - assert "RestrictAddressFamilies=AF_UNIX" in service_unit + assert "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6" in service_unit assert ( "ExecStart=/usr/local/libexec/timelocker-system-control --systemd-socket" in service_unit ) - assert "EnvironmentFile=" not in service_unit + assert "EnvironmentFile=-/etc/timelocker/retention.env" in service_unit assert "DISPLAY=" not in service_unit assert "s3://" not in service_unit @@ -242,5 +242,6 @@ def test_socket_and_service_templates_enforce_narrow_boundary(self) -> None: ).read_text() retention_timer = (ASSET_DIRECTORY / "timelocker-retention.timer").read_text() assert "ConditionPathExists=/etc/timelocker/retention-enabled" in retention_service + assert "EnvironmentFile=-/etc/timelocker/retention.env" in retention_service assert "--scheduled-retention" in retention_service assert "Persistent=false" in retention_timer diff --git a/tests/TimeLocker/system_control/test_production_retention.py b/tests/TimeLocker/system_control/test_production_retention.py new file mode 100644 index 0000000..ed2a6c9 --- /dev/null +++ b/tests/TimeLocker/system_control/test_production_retention.py @@ -0,0 +1,155 @@ +"""Production retention boundary tests.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess + +import pytest + +from TimeLocker.system_control.models import RetentionPolicy, SystemPolicy +from TimeLocker.system_control.production_retention import ( + ProductionRetentionPlanProvider, + ProductionRetentionTarget, + TimeLockerCliRetentionAdapter, +) + + +def _target(tmp_path: Path) -> ProductionRetentionTarget: + config_directory = tmp_path / "repository-config" + config_directory.mkdir() + repository_config = config_directory / "config.json" + repository_config.write_text('{"repositories":{}}\n', encoding="utf-8") + credential_source = tmp_path / "retention.env" + credential_source.write_text("RESTIC_PASSWORD=protected\n", encoding="utf-8") + repository_config.chmod(0o600) + credential_source.chmod(0o600) + return ProductionRetentionTarget( + target_id="production", + repository_name="production-repository", + config_directory=config_directory, + repository_config=repository_config, + credential_source=credential_source, + ) + + +@pytest.mark.unit +def test_loads_strict_root_config_without_embedding_protected_values( + tmp_path: Path, +) -> None: + target = _target(tmp_path) + path = tmp_path / "production-target.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "target_id": target.target_id, + "repository_name": target.repository_name, + "config_directory": str(target.config_directory), + "repository_config": str(target.repository_config), + "credential_source": str(target.credential_source), + "snapshot_filters": [], + } + ), + encoding="utf-8", + ) + path.chmod(0o600) + + loaded = ProductionRetentionTarget.load(path, expected_owner=os.getuid()) + plan = loaded.plan(SystemPolicy()) + + assert loaded == target + assert plan.repository_identity.startswith("sha256:") + assert plan.credential_source.startswith("sha256:") + assert "protected" not in plan.credential_source + + +@pytest.mark.unit +def test_rejects_writable_production_target(tmp_path: Path) -> None: + path = tmp_path / "production-target.json" + path.write_text("{}\n", encoding="utf-8") + path.chmod(0o666) + + with pytest.raises(PermissionError, match="must not be group/world writable"): + ProductionRetentionTarget.load(path, expected_owner=os.getuid()) + + +@pytest.mark.unit +def test_adapter_runs_only_fixed_retention_command_and_counts_candidates( + tmp_path: Path, +) -> None: + target = _target(tmp_path) + calls: list[list[str]] = [] + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(command) + return subprocess.CompletedProcess( + command, + 0, + stdout="Retention policy applied. Removed 3 snapshots. (dry run)\n", + stderr="", + ) + + policy = SystemPolicy( + retention=RetentionPolicy( + keep_daily=5, + keep_weekly=4, + keep_monthly=12, + keep_yearly=3, + group_by=("host", "paths"), + prune=False, + ) + ) + plan = ProductionRetentionPlanProvider(target).resolve_retention_plan(policy) + adapter = TimeLockerCliRetentionAdapter( + target, + python_executable=Path("/usr/bin/python3"), + runner=runner, + environment={"RESTIC_PASSWORD": "protected"}, + ) + + result = adapter.execute(plan, dry_run=True) + + assert result.selected_snapshots == 3 + assert result.removed_snapshots == 0 + assert calls == [ + [ + "/usr/bin/python3", + "-m", + "TimeLocker.cli", + "repos", + "forget", + "production-repository", + "--keep-daily", + "5", + "--keep-weekly", + "4", + "--keep-monthly", + "12", + "--keep-yearly", + "3", + "--group-by", + "host,paths", + "--no-prune", + "--config-dir", + str(target.config_directory), + "--dry-run", + ] + ] + + +@pytest.mark.unit +def test_adapter_rejects_changed_repository_configuration(tmp_path: Path) -> None: + target = _target(tmp_path) + plan = target.plan(SystemPolicy()) + target.repository_config.write_text('{"repositories":{"changed":{}}}\n') + adapter = TimeLockerCliRetentionAdapter( + target, + python_executable=Path("/usr/bin/python3"), + runner=lambda *_args, **_kwargs: pytest.fail("runner must not be called"), + ) + + with pytest.raises(PermissionError, match="does not match protected target"): + adapter.execute(plan, dry_run=True) From f1243f36b8a399c917be9ae511daebca2755e487 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:14:26 +0100 Subject: [PATCH 43/72] fix(system): gate automatic retention activation --- .../system_control/backend_entry.py | 44 ++++++++++++++----- .../system_control/production_retention.py | 12 +++++ .../system_control/test_backend_entry.py | 30 +++++++++++++ .../test_production_retention.py | 13 ++++++ 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/TimeLocker/system_control/backend_entry.py b/src/TimeLocker/system_control/backend_entry.py index 02fa3b6..1b5ad4a 100644 --- a/src/TimeLocker/system_control/backend_entry.py +++ b/src/TimeLocker/system_control/backend_entry.py @@ -36,7 +36,9 @@ from .policy_loader import load_system_policy from .production_retention import ( DEFAULT_PRODUCTION_TARGET_PATH, + DEFAULT_RETENTION_ENABLE_MARKER, load_production_retention_components, + require_retention_enable_marker, ) from .retention import ( RetentionAdapter, @@ -57,6 +59,7 @@ DiagnosticComponent, DiagnosticLevel, OperationType, + OperationTrigger, RunState, SystemAction, ) @@ -419,8 +422,19 @@ def run_scheduled_retention( *, paths: LinuxBackendPaths, production_target_path: Path, + trigger: OperationTrigger = OperationTrigger.SCHEDULED, + enable_marker: Path = DEFAULT_RETENTION_ENABLE_MARKER, ) -> None: """Run one approved independent retention attempt using protected config.""" + if trigger not in { + OperationTrigger.SCHEDULED, + OperationTrigger.BACKUP_SUCCESS, + }: + raise ValueError("unsupported automatic retention trigger") + require_retention_enable_marker( + enable_marker, + expected_owner=paths.expected_owner, + ) policy = load_system_policy( paths.policy_path, expected_owner=paths.expected_owner, @@ -435,17 +449,16 @@ def run_scheduled_retention( provider.resolve_retention_plan(policy), policy.retention, ) - coordinator = RetentionTriggerCoordinator( - executor=RetentionExecutor( - store=store, - locks=locks, - adapter=adapter, - ), - trigger_store=RetentionTriggerStore(paths.trigger_root), - independent_schedule_enabled=True, + run = RetentionExecutor( + store=store, + locks=locks, + adapter=adapter, + ).execute( + plan, + trigger=trigger, + dry_run=False, ) - run = coordinator.scheduled(plan) - if run is None or run.state is RunState.FAILED: + if run.state is RunState.FAILED: raise RuntimeError("scheduled retention failed safely") @@ -481,6 +494,12 @@ def main(argv: list[str] | None = None) -> None: default=DEFAULT_PRODUCTION_TARGET_PATH, help=argparse.SUPPRESS, ) + parser.add_argument( + "--retention-trigger", + choices=("scheduled", "backup-success"), + default="scheduled", + help=argparse.SUPPRESS, + ) arguments = parser.parse_args(argv) try: paths = LinuxBackendPaths.from_state_root( @@ -491,6 +510,11 @@ def main(argv: list[str] | None = None) -> None: run_scheduled_retention( paths=paths, production_target_path=arguments.production_target, + trigger=( + OperationTrigger.BACKUP_SUCCESS + if arguments.retention_trigger == "backup-success" + else OperationTrigger.SCHEDULED + ), ) else: run_linux_backend( diff --git a/src/TimeLocker/system_control/production_retention.py b/src/TimeLocker/system_control/production_retention.py index 8cd651d..e1e3bba 100644 --- a/src/TimeLocker/system_control/production_retention.py +++ b/src/TimeLocker/system_control/production_retention.py @@ -19,6 +19,7 @@ DEFAULT_PRODUCTION_TARGET_PATH = Path("/etc/timelocker/production-target.json") +DEFAULT_RETENTION_ENABLE_MARKER = Path("/etc/timelocker/retention-enabled") _CONFIG_FIELDS = frozenset( { "schema_version", @@ -244,6 +245,15 @@ def load_production_retention_components( ) +def require_retention_enable_marker( + path: Path = DEFAULT_RETENTION_ENABLE_MARKER, + *, + expected_owner: int = 0, +) -> None: + """Refuse every retention mutation until the protected marker exists.""" + _require_protected_file(path, expected_owner=expected_owner) + + def _removed_count(output: str) -> int: matches = _REMOVED_PATTERN.findall(output) if not matches: @@ -270,8 +280,10 @@ def _require_protected_file(path: Path, *, expected_owner: int) -> None: __all__: Sequence[str] = ( "DEFAULT_PRODUCTION_TARGET_PATH", + "DEFAULT_RETENTION_ENABLE_MARKER", "ProductionRetentionPlanProvider", "ProductionRetentionTarget", "TimeLockerCliRetentionAdapter", "load_production_retention_components", + "require_retention_enable_marker", ) diff --git a/tests/TimeLocker/system_control/test_backend_entry.py b/tests/TimeLocker/system_control/test_backend_entry.py index f4260e7..23c29e0 100644 --- a/tests/TimeLocker/system_control/test_backend_entry.py +++ b/tests/TimeLocker/system_control/test_backend_entry.py @@ -5,6 +5,7 @@ import pytest from TimeLocker.system_control import backend_entry +from TimeLocker.system_control.types import OperationTrigger @pytest.mark.unit @@ -33,6 +34,35 @@ def test_scheduled_retention_fails_closed_without_live_adapter( assert "protected URI" not in capsys.readouterr().err +@pytest.mark.unit +def test_main_maps_backup_success_retention_trigger( + monkeypatch, + tmp_path: Path, +) -> None: + captured: dict[str, object] = {} + monkeypatch.setattr( + backend_entry, + "run_scheduled_retention", + lambda **kwargs: captured.update(kwargs), + ) + + backend_entry.main( + [ + "--scheduled-retention", + "--retention-trigger", + "backup-success", + "--policy", + str(tmp_path / "policy.json"), + "--state-root", + str(tmp_path / "state"), + "--production-target", + str(tmp_path / "target.json"), + ] + ) + + assert captured["trigger"] is OperationTrigger.BACKUP_SUCCESS + + @pytest.mark.unit def test_main_composes_only_explicit_system_paths(monkeypatch, tmp_path: Path) -> None: captured: dict[str, object] = {} diff --git a/tests/TimeLocker/system_control/test_production_retention.py b/tests/TimeLocker/system_control/test_production_retention.py index ed2a6c9..7ad1ea6 100644 --- a/tests/TimeLocker/system_control/test_production_retention.py +++ b/tests/TimeLocker/system_control/test_production_retention.py @@ -14,6 +14,7 @@ ProductionRetentionPlanProvider, ProductionRetentionTarget, TimeLockerCliRetentionAdapter, + require_retention_enable_marker, ) @@ -76,6 +77,18 @@ def test_rejects_writable_production_target(tmp_path: Path) -> None: ProductionRetentionTarget.load(path, expected_owner=os.getuid()) +@pytest.mark.unit +def test_retention_enable_marker_must_be_protected(tmp_path: Path) -> None: + marker = tmp_path / "retention-enabled" + marker.touch(mode=0o600) + + require_retention_enable_marker(marker, expected_owner=os.getuid()) + + marker.chmod(0o666) + with pytest.raises(PermissionError, match="must not be group/world writable"): + require_retention_enable_marker(marker, expected_owner=os.getuid()) + + @pytest.mark.unit def test_adapter_runs_only_fixed_retention_command_and_counts_candidates( tmp_path: Path, From 2388e1d7e45fbda38c94acca1cacc6eccee7b907 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:28:16 +0100 Subject: [PATCH 44/72] feat(system): coordinate protected backup runs --- .../system_control/backend_entry.py | 101 +++++- .../system_control/production_backup.py | 338 ++++++++++++++++++ .../system_control/test_backend_entry.py | 36 ++ .../system_control/test_production_backup.py | 166 +++++++++ 4 files changed, 640 insertions(+), 1 deletion(-) create mode 100644 src/TimeLocker/system_control/production_backup.py create mode 100644 tests/TimeLocker/system_control/test_production_backup.py diff --git a/src/TimeLocker/system_control/backend_entry.py b/src/TimeLocker/system_control/backend_entry.py index 1b5ad4a..df2c4d7 100644 --- a/src/TimeLocker/system_control/backend_entry.py +++ b/src/TimeLocker/system_control/backend_entry.py @@ -34,9 +34,15 @@ SystemPolicy, ) from .policy_loader import load_system_policy +from .production_backup import ( + SystemBackupRunCoordinator, + SystemdBackupMutationAdapter, +) from .production_retention import ( DEFAULT_PRODUCTION_TARGET_PATH, DEFAULT_RETENTION_ENABLE_MARKER, + ProductionRetentionTarget, + TimeLockerCliRetentionAdapter, load_production_retention_components, require_retention_enable_marker, ) @@ -357,7 +363,6 @@ def build_linux_backend( schedule_summary_provider or StaticScheduleSummaryProvider() ) membership_resolver = membership_resolver or LinuxNssGroupMembershipResolver() - backup_adapter = backup_adapter or FailClosedBackupMutationAdapter() if ( retention_adapter is None and retention_plan_provider is None @@ -369,6 +374,16 @@ def build_linux_backend( expected_owner=paths.expected_owner, ) ) + if backup_adapter is None and isinstance( + retention_adapter, + TimeLockerCliRetentionAdapter, + ): + backup_adapter = SystemdBackupMutationAdapter( + store=store, + target_id=retention_adapter.target.target_id, + worker_root=paths.record_root.parent / "backup-worker", + ) + backup_adapter = backup_adapter or FailClosedBackupMutationAdapter() retention_adapter = retention_adapter or FailClosedRetentionAdapter() retention_plan_provider = ( retention_plan_provider or FailClosedRetentionPlanProvider() @@ -462,6 +477,50 @@ def run_scheduled_retention( raise RuntimeError("scheduled retention failed safely") +def run_backup_record_start( + *, + paths: LinuxBackendPaths, + production_target_path: Path, +) -> None: + """Create or claim the run record for one systemd backup invocation.""" + target = ProductionRetentionTarget.load( + production_target_path, + expected_owner=paths.expected_owner, + ) + SystemBackupRunCoordinator( + store=AtomicRecordStore(paths.record_root), + target_id=target.target_id, + worker_root=paths.record_root.parent / "backup-worker", + ).start() + + +def run_backup_record_finish( + *, + paths: LinuxBackendPaths, + production_target_path: Path, + result: str, + exit_status: int | None, +) -> None: + """Finish the active systemd backup record exactly once.""" + target = ProductionRetentionTarget.load( + production_target_path, + expected_owner=paths.expected_owner, + ) + SystemBackupRunCoordinator( + store=AtomicRecordStore(paths.record_root), + target_id=target.target_id, + worker_root=paths.record_root.parent / "backup-worker", + ).finish(result=result, exit_status=exit_status) + + +def _systemd_exit_status(value: str | None) -> int | None: + """Return systemd's numeric process status without trusting free-form input.""" + if value is None or not value.isascii() or not value.isdecimal(): + return None + parsed = int(value) + return parsed if 0 <= parsed <= 255 else None + + def main(argv: list[str] | None = None) -> None: """Run one allowlisted privileged system-control process mode.""" parser = argparse.ArgumentParser(prog="timelocker-system-control") @@ -476,6 +535,16 @@ def main(argv: list[str] | None = None) -> None: action="store_true", help=argparse.SUPPRESS, ) + modes.add_argument( + "--backup-run-start", + action="store_true", + help=argparse.SUPPRESS, + ) + modes.add_argument( + "--backup-run-finish", + action="store_true", + help=argparse.SUPPRESS, + ) parser.add_argument( "--policy", type=Path, @@ -500,6 +569,22 @@ def main(argv: list[str] | None = None) -> None: default="scheduled", help=argparse.SUPPRESS, ) + parser.add_argument( + "--backup-result", + choices=( + "success", + "protocol", + "timeout", + "exit-code", + "signal", + "core-dump", + "watchdog", + "start-limit-hit", + "resources", + ), + default="failure", + help=argparse.SUPPRESS, + ) arguments = parser.parse_args(argv) try: paths = LinuxBackendPaths.from_state_root( @@ -516,6 +601,18 @@ def main(argv: list[str] | None = None) -> None: else OperationTrigger.SCHEDULED ), ) + elif arguments.backup_run_start: + run_backup_record_start( + paths=paths, + production_target_path=arguments.production_target, + ) + elif arguments.backup_run_finish: + run_backup_record_finish( + paths=paths, + production_target_path=arguments.production_target, + result=arguments.backup_result, + exit_status=_systemd_exit_status(os.environ.get("EXIT_STATUS")), + ) else: run_linux_backend( paths=paths, @@ -745,6 +842,8 @@ def _emit_startup_diagnostics( "main", "run_linux_backend", "run_scheduled_retention", + "run_backup_record_finish", + "run_backup_record_start", ] diff --git a/src/TimeLocker/system_control/production_backup.py b/src/TimeLocker/system_control/production_backup.py new file mode 100644 index 0000000..73b7008 --- /dev/null +++ b/src/TimeLocker/system_control/production_backup.py @@ -0,0 +1,338 @@ +"""Durable coordination for the protected systemd backup unit.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import stat +import subprocess +from uuid import UUID, uuid4 + +from .models import ( + ActionReceipt, + BackupActionRequest, + DiagnosticRecord, + RunRecord, + RunTransition, +) +from .storage import AtomicRecordStore +from .types import ( + DiagnosticCode, + DiagnosticComponent, + DiagnosticLevel, + OperationTrigger, + OperationType, + ResultCode, + RunState, +) + + +DEFAULT_BACKUP_UNIT = "timelocker-npbackup-migration.service" + + +class SystemdBackupMutationAdapter: + """Queue the one allowlisted systemd backup and return its durable run ID.""" + + def __init__( + self, + *, + store: AtomicRecordStore, + target_id: str, + worker_root: Path, + unit_name: str = DEFAULT_BACKUP_UNIT, + runner: Callable[..., subprocess.CompletedProcess[str]] | None = None, + ) -> None: + if not isinstance(store, AtomicRecordStore): + raise TypeError("store must be an AtomicRecordStore") + if not isinstance(worker_root, Path) or not worker_root.is_absolute(): + raise ValueError("worker_root must be an absolute Path") + if unit_name != DEFAULT_BACKUP_UNIT: + raise ValueError("backup unit is not allowlisted") + self.store = store + self.target_id = target_id + self.worker_root = worker_root + self.unit_name = unit_name + self._runner = runner or subprocess.run + worker_root.mkdir(mode=0o700, parents=True, exist_ok=True) + worker_root.chmod(0o700) + + @property + def pending_path(self) -> Path: + return self.worker_root / "pending-run.json" + + def request_backup( + self, + request: BackupActionRequest, + *, + request_id: UUID, + ) -> ActionReceipt: + if not isinstance(request, BackupActionRequest): + raise TypeError("request must be a BackupActionRequest") + if request.target_id != self.target_id: + raise PermissionError("backup target is not allowlisted") + if self._unit_active(): + return ActionReceipt( + request_id=request_id, + accepted=False, + status="conflict", + ) + run = RunRecord( + run_id=request_id, + operation=OperationType.BACKUP, + trigger=OperationTrigger.EXPLICIT, + target_id=self.target_id, + started_at=datetime.now(timezone.utc), + state=RunState.QUEUED, + result_code=ResultCode.OPERATION_QUEUED, + ) + try: + _write_exclusive_json( + self.pending_path, + {"schema_version": 1, "run_id": str(run.run_id)}, + ) + except FileExistsError: + return ActionReceipt( + request_id=request_id, + accepted=False, + status="conflict", + ) + try: + self.store.create_run(run) + result = self._runner( + [ + "/usr/bin/systemctl", + "start", + "--no-block", + self.unit_name, + ], + capture_output=True, + text=True, + check=False, + timeout=10, + ) + if result.returncode != 0: + self.pending_path.unlink(missing_ok=True) + self.store.transition( + run.run_id, + RunTransition( + expected_states=frozenset({RunState.QUEUED}), + new_state=RunState.FAILED, + result_code=ResultCode.OPERATION_FAILED, + completed_at=datetime.now(timezone.utc), + ), + ) + return ActionReceipt( + request_id=request_id, + accepted=False, + status="failed", + ) + except Exception: + self.pending_path.unlink(missing_ok=True) + raise + return ActionReceipt( + request_id=request_id, + accepted=True, + status=RunState.QUEUED.value, + run_id=run.run_id, + ) + + def _unit_active(self) -> bool: + result = self._runner( + ["/usr/bin/systemctl", "is-active", "--quiet", self.unit_name], + capture_output=True, + text=True, + check=False, + timeout=5, + ) + return result.returncode == 0 + + +class SystemBackupRunCoordinator: + """Bind systemd pre/post hooks to one crash-recoverable backup record.""" + + def __init__( + self, + *, + store: AtomicRecordStore, + target_id: str, + worker_root: Path, + ) -> None: + if not isinstance(store, AtomicRecordStore): + raise TypeError("store must be an AtomicRecordStore") + if not isinstance(worker_root, Path) or not worker_root.is_absolute(): + raise ValueError("worker_root must be an absolute Path") + self.store = store + self.target_id = target_id + self.worker_root = worker_root + worker_root.mkdir(mode=0o700, parents=True, exist_ok=True) + worker_root.chmod(0o700) + + @property + def pending_path(self) -> Path: + return self.worker_root / "pending-run.json" + + @property + def active_path(self) -> Path: + return self.worker_root / "active-run.json" + + def start(self) -> RunRecord: + self._reconcile_interrupted_active_run() + pending_id = self._consume_pending() + if pending_id is None: + run = RunRecord( + run_id=uuid4(), + operation=OperationType.BACKUP, + trigger=OperationTrigger.SCHEDULED, + target_id=self.target_id, + started_at=datetime.now(timezone.utc), + state=RunState.QUEUED, + result_code=ResultCode.OPERATION_QUEUED, + ) + self.store.create_run(run) + else: + run = self.store.read_run(pending_id) + if ( + run.operation is not OperationType.BACKUP + or run.target_id != self.target_id + or run.state is not RunState.QUEUED + ): + raise ValueError("pending backup run is invalid") + running = self.store.transition( + run.run_id, + RunTransition( + expected_states=frozenset({RunState.QUEUED}), + new_state=RunState.RUNNING, + result_code=ResultCode.OPERATION_RUNNING, + ), + ) + _write_exclusive_json( + self.active_path, + {"schema_version": 1, "run_id": str(run.run_id)}, + ) + self._diagnostic(run.run_id, DiagnosticCode.BACKUP_STARTED) + return running + + def finish(self, *, result: str, exit_status: int | None = None) -> RunRecord | None: + run_id = self._active_run_id() + if run_id is None: + return None + run = self.store.read_run(run_id) + if run.state not in {RunState.QUEUED, RunState.RUNNING}: + self.active_path.unlink(missing_ok=True) + return run + now = datetime.now(timezone.utc) + if result == "success": + state = RunState.SUCCEEDED + result_code = ResultCode.BACKUP_SUCCEEDED + diagnostic = DiagnosticCode.BACKUP_SUCCEEDED + elif exit_status == 75: + state = RunState.SKIPPED + result_code = ResultCode.OPERATION_CONFLICT + diagnostic = DiagnosticCode.OPERATION_CONFLICT + else: + state = RunState.FAILED + result_code = ResultCode.OPERATION_FAILED + diagnostic = DiagnosticCode.OPERATION_FAILED + finished = self.store.transition( + run.run_id, + RunTransition( + expected_states=frozenset({run.state}), + new_state=state, + result_code=result_code, + completed_at=max(now, run.started_at), + ), + ) + self.active_path.unlink(missing_ok=True) + self._diagnostic(run.run_id, diagnostic) + return finished + + def _reconcile_interrupted_active_run(self) -> None: + run_id = self._active_run_id() + if run_id is None: + return + run = self.store.read_run(run_id) + if run.state in {RunState.QUEUED, RunState.RUNNING}: + self.store.transition( + run.run_id, + RunTransition( + expected_states=frozenset({run.state}), + new_state=RunState.INTERRUPTED, + result_code=ResultCode.OPERATION_INTERRUPTED, + completed_at=max(datetime.now(timezone.utc), run.started_at), + ), + ) + self._diagnostic(run.run_id, DiagnosticCode.OPERATION_INTERRUPTED) + self.active_path.unlink(missing_ok=True) + + def _consume_pending(self) -> UUID | None: + try: + descriptor = os.open( + self.pending_path, + os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), + ) + except FileNotFoundError: + return None + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.geteuid(): + raise PermissionError("pending backup file is not trusted") + with os.fdopen(descriptor, encoding="utf-8", closefd=False) as source: + value = json.load(source) + if set(value) != {"schema_version", "run_id"} or value["schema_version"] != 1: + raise ValueError("pending backup file is invalid") + return UUID(value["run_id"]) + finally: + os.close(descriptor) + self.pending_path.unlink(missing_ok=True) + + def _active_run_id(self) -> UUID | None: + try: + value = json.loads(self.active_path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + if set(value) != {"schema_version", "run_id"} or value["schema_version"] != 1: + raise ValueError("active backup file is invalid") + return UUID(value["run_id"]) + + def _diagnostic(self, run_id: UUID, code: DiagnosticCode) -> None: + level = ( + DiagnosticLevel.INFO + if code in {DiagnosticCode.BACKUP_STARTED, DiagnosticCode.BACKUP_SUCCEEDED} + else DiagnosticLevel.WARNING + ) + self.store.append_diagnostic( + DiagnosticRecord( + record_id=uuid4(), + run_id=run_id, + timestamp=datetime.now(timezone.utc), + level=level, + component=DiagnosticComponent.BACKUP, + message_code=code, + ) + ) + + +def _write_exclusive_json(path: Path, value: dict[str, object]) -> None: + descriptor = os.open( + path, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), + 0o600, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as output: + json.dump(value, output, sort_keys=True, separators=(",", ":")) + output.write("\n") + output.flush() + os.fsync(output.fileno()) + finally: + os.close(descriptor) + + +__all__: Sequence[str] = ( + "DEFAULT_BACKUP_UNIT", + "SystemBackupRunCoordinator", + "SystemdBackupMutationAdapter", +) diff --git a/tests/TimeLocker/system_control/test_backend_entry.py b/tests/TimeLocker/system_control/test_backend_entry.py index 23c29e0..f4031d6 100644 --- a/tests/TimeLocker/system_control/test_backend_entry.py +++ b/tests/TimeLocker/system_control/test_backend_entry.py @@ -63,6 +63,42 @@ def test_main_maps_backup_success_retention_trigger( assert captured["trigger"] is OperationTrigger.BACKUP_SUCCESS +@pytest.mark.unit +def test_main_maps_systemd_exit_status_for_backup_finish( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def finish(**kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr(backend_entry, "run_backup_record_finish", finish) + monkeypatch.setenv("EXIT_STATUS", "75") + + backend_entry.main(["--backup-run-finish", "--backup-result", "exit-code"]) + + assert captured["result"] == "exit-code" + assert captured["exit_status"] == 75 + + +@pytest.mark.unit +def test_main_ignores_non_numeric_systemd_exit_status( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def finish(**kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr(backend_entry, "run_backup_record_finish", finish) + monkeypatch.setenv("EXIT_STATUS", "KILL") + + backend_entry.main(["--backup-run-finish", "--backup-result", "signal"]) + + assert captured["result"] == "signal" + assert captured["exit_status"] is None + + @pytest.mark.unit def test_main_composes_only_explicit_system_paths(monkeypatch, tmp_path: Path) -> None: captured: dict[str, object] = {} diff --git a/tests/TimeLocker/system_control/test_production_backup.py b/tests/TimeLocker/system_control/test_production_backup.py new file mode 100644 index 0000000..7694047 --- /dev/null +++ b/tests/TimeLocker/system_control/test_production_backup.py @@ -0,0 +1,166 @@ +"""Production systemd backup coordination tests.""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +from uuid import uuid4 + +import pytest + +from TimeLocker.system_control.models import BackupActionRequest, RunQuery +from TimeLocker.system_control.production_backup import ( + SystemBackupRunCoordinator, + SystemdBackupMutationAdapter, +) +from TimeLocker.system_control.storage import AtomicRecordStore +from TimeLocker.system_control.types import ( + OperationTrigger, + OperationType, + ResultCode, + RunState, +) + + +@pytest.mark.unit +def test_on_demand_request_and_hooks_share_the_receipt_run( + tmp_path: Path, +) -> None: + store = AtomicRecordStore(tmp_path / "records") + worker_root = tmp_path / "worker" + request_id = uuid4() + commands: list[list[str]] = [] + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + commands.append(command) + return subprocess.CompletedProcess(command, 3 if "is-active" in command else 0) + + adapter = SystemdBackupMutationAdapter( + store=store, + target_id="production", + worker_root=worker_root, + runner=runner, + ) + receipt = adapter.request_backup( + BackupActionRequest(target_id="production"), + request_id=request_id, + ) + coordinator = SystemBackupRunCoordinator( + store=store, + target_id="production", + worker_root=worker_root, + ) + + running = coordinator.start() + finished = coordinator.finish(result="success") + + assert receipt.accepted is True + assert receipt.run_id == request_id + assert running.run_id == request_id + assert running.trigger is OperationTrigger.EXPLICIT + assert finished is not None + assert finished.state is RunState.SUCCEEDED + assert finished.result_code is ResultCode.BACKUP_SUCCEEDED + assert commands[-1] == [ + "/usr/bin/systemctl", + "start", + "--no-block", + "timelocker-npbackup-migration.service", + ] + + +@pytest.mark.unit +def test_active_unit_rejects_on_demand_request_without_creating_run( + tmp_path: Path, +) -> None: + store = AtomicRecordStore(tmp_path / "records") + adapter = SystemdBackupMutationAdapter( + store=store, + target_id="production", + worker_root=tmp_path / "worker", + runner=lambda command, **_kwargs: subprocess.CompletedProcess(command, 0), + ) + + receipt = adapter.request_backup( + BackupActionRequest(target_id="production"), + request_id=uuid4(), + ) + + assert receipt.accepted is False + assert receipt.status == "conflict" + assert store.list_runs() == [] + + +@pytest.mark.unit +def test_scheduled_hook_creates_run_and_maps_lock_conflict( + tmp_path: Path, +) -> None: + store = AtomicRecordStore(tmp_path / "records") + coordinator = SystemBackupRunCoordinator( + store=store, + target_id="production", + worker_root=tmp_path / "worker", + ) + + running = coordinator.start() + finished = coordinator.finish(result="failure", exit_status=75) + + assert running.operation is OperationType.BACKUP + assert running.trigger is OperationTrigger.SCHEDULED + assert finished is not None + assert finished.state is RunState.SKIPPED + assert finished.result_code is ResultCode.OPERATION_CONFLICT + assert store.list_runs(RunQuery(operation=OperationType.BACKUP)) == [finished] + + +@pytest.mark.unit +def test_systemd_start_failure_is_terminal_and_secret_free( + tmp_path: Path, +) -> None: + store = AtomicRecordStore(tmp_path / "records") + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + command, + 3 if "is-active" in command else 1, + stdout="", + stderr="protected unit detail", + ) + + adapter = SystemdBackupMutationAdapter( + store=store, + target_id="production", + worker_root=tmp_path / "worker", + runner=runner, + ) + receipt = adapter.request_backup( + BackupActionRequest(target_id="production"), + request_id=uuid4(), + ) + + assert receipt.accepted is False + assert receipt.status == "failed" + [run] = store.list_runs() + assert run.state is RunState.FAILED + assert "protected" not in run.safe_summary + + +@pytest.mark.unit +def test_next_systemd_start_recovers_interrupted_active_run( + tmp_path: Path, +) -> None: + store = AtomicRecordStore(tmp_path / "records") + coordinator = SystemBackupRunCoordinator( + store=store, + target_id="production", + worker_root=tmp_path / "worker", + ) + abandoned = coordinator.start() + + replacement = coordinator.start() + + recovered = store.read_run(abandoned.run_id) + assert recovered.state is RunState.INTERRUPTED + assert recovered.result_code is ResultCode.OPERATION_INTERRUPTED + assert replacement.run_id != abandoned.run_id + assert replacement.state is RunState.RUNNING From 32ab1fefd8fd9334fe37b68b1f2262565f32bebd Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:42:19 +0100 Subject: [PATCH 45/72] fix(tray): prioritize current operation state --- src/TimeLocker/system_control/tray_client.py | 32 +++++-- .../system_control/test_tray_client.py | 83 +++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/TimeLocker/system_control/tray_client.py b/src/TimeLocker/system_control/tray_client.py index e3abbdb..2967274 100644 --- a/src/TimeLocker/system_control/tray_client.py +++ b/src/TimeLocker/system_control/tray_client.py @@ -215,7 +215,11 @@ def _unavailable_state( ) def _count_active_runs(self, runs: list[RunRecordView]) -> int: - return sum(1 for run in runs if run.state is RunState.RUNNING) + return sum( + 1 + for run in runs + if run.state in {RunState.QUEUED, RunState.RUNNING} + ) def _latest_run(self, runs: list[RunRecordView]) -> RunRecordView | None: if not runs: @@ -223,15 +227,29 @@ def _latest_run(self, runs: list[RunRecordView]) -> RunRecordView | None: return sorted(runs, key=lambda run: run.started_at, reverse=True)[0] def _status_from_runs(self, runs: list[RunRecordView]) -> str: - if any(run.state is RunState.RUNNING for run in runs): + if any( + run.state in {RunState.QUEUED, RunState.RUNNING} + for run in runs + ): return "running" - if any(run.state is RunState.FAILED for run in runs): - return "error" - if any(run.state is RunState.INTERRUPTED for run in runs): + latest_runs = [ + latest + for operation in (OperationType.BACKUP, OperationType.RETENTION) + if ( + latest := self._latest_run( + [run for run in runs if run.operation is operation] + ) + ) + is not None + ] + if any( + run.state in {RunState.FAILED, RunState.INTERRUPTED} + for run in latest_runs + ): return "error" - if any(run.state is RunState.SKIPPED for run in runs): + if any(run.state is RunState.SKIPPED for run in latest_runs): return "warning" - if any(run.state is RunState.SUCCEEDED for run in runs): + if any(run.state is RunState.SUCCEEDED for run in latest_runs): return "success" return "idle" diff --git a/tests/TimeLocker/system_control/test_tray_client.py b/tests/TimeLocker/system_control/test_tray_client.py index 4e5911e..912075c 100644 --- a/tests/TimeLocker/system_control/test_tray_client.py +++ b/tests/TimeLocker/system_control/test_tray_client.py @@ -124,6 +124,89 @@ def test_refresh_status_orders_runs_by_newest_and_projects_summary() -> None: assert state.next_backup_at == base_time + timedelta(hours=1) +@mark.unit +def test_queued_backup_is_active_and_overrides_stale_interruption() -> None: + base_time = datetime(2026, 7, 26, 12, 0, tzinfo=UTC) + runs = [ + RunRecordView.from_record( + RunRecord( + run_id=uuid4(), + operation=BackendOperationType.BACKUP, + started_at=base_time, + state=RunState.QUEUED, + target_id="prod", + trigger=OperationTrigger.EXPLICIT, + result_code=ResultCode.OPERATION_QUEUED, + ) + ), + RunRecordView.from_record( + RunRecord( + run_id=uuid4(), + operation=BackendOperationType.BACKUP, + started_at=base_time - timedelta(hours=1), + completed_at=base_time - timedelta(minutes=55), + state=RunState.INTERRUPTED, + target_id="prod", + trigger=OperationTrigger.SCHEDULED, + result_code=ResultCode.OPERATION_INTERRUPTED, + ) + ), + ] + client = TrayControlClient( + client_factory=lambda: FakeBackend( + runs, + ScheduleSummary(None, None), + ), + ) + + state = client.refresh_status() + + assert state.status == "running" + assert state.active_operations == 1 + + +@mark.unit +def test_new_success_supersedes_stale_interruption() -> None: + base_time = datetime(2026, 7, 26, 12, 0, tzinfo=UTC) + runs = [ + RunRecordView.from_record( + RunRecord( + run_id=uuid4(), + operation=BackendOperationType.BACKUP, + started_at=base_time, + completed_at=base_time + timedelta(minutes=5), + state=RunState.SUCCEEDED, + target_id="prod", + trigger=OperationTrigger.EXPLICIT, + result_code=ResultCode.BACKUP_SUCCEEDED, + ) + ), + RunRecordView.from_record( + RunRecord( + run_id=uuid4(), + operation=BackendOperationType.BACKUP, + started_at=base_time - timedelta(hours=1), + completed_at=base_time - timedelta(minutes=55), + state=RunState.INTERRUPTED, + target_id="prod", + trigger=OperationTrigger.SCHEDULED, + result_code=ResultCode.OPERATION_INTERRUPTED, + ) + ), + ] + client = TrayControlClient( + client_factory=lambda: FakeBackend( + runs, + ScheduleSummary(None, None), + ), + ) + + state = client.refresh_status() + + assert state.status == "success" + assert state.last_backup_status == "Backup completed successfully." + + @mark.unit def test_retention_action_requires_fingerprint() -> None: client = TrayControlClient( From 84581d20fa1e3cdddf36d1267f3a62c8ca4aeecd Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:02:38 +0100 Subject: [PATCH 46/72] docs(spec-009): complete live acceptance --- .../009-system-cli-tray-retention/tasks.md | 26 +++++-- .../traceability.md | 14 ++-- .../verification.md | 71 ++++++++++--------- 3 files changed, 64 insertions(+), 47 deletions(-) diff --git a/docs/specs/009-system-cli-tray-retention/tasks.md b/docs/specs/009-system-cli-tray-retention/tasks.md index 69a297f..e8b30ce 100644 --- a/docs/specs/009-system-cli-tray-retention/tasks.md +++ b/docs/specs/009-system-cli-tray-retention/tasks.md @@ -257,7 +257,7 @@ T009 -> T010 -> T011 -> T012 loading tray integration, and confirmed `pystray` was absent. - Evidence mode: validation -- [~] T010 Run controlled Linux Mint live acceptance and rollback rehearsal. +- [x] T010 Run controlled Linux Mint live acceptance and rollback rehearsal. - Depends on: T009 - Requirements: Requirements 1-6; SC-001-SC-011 - Files: `verification.md`, external system assets only after explicit rollout @@ -266,14 +266,26 @@ T009 -> T010 -> T011 -> T012 backup, restore, post-success retention, independent retention, tray reconnect, interrupted-run recovery, upgrade, and rollback are evidenced. - Evidence mode: external - - Evidence: Operator approved T010 rollout on 2026-07-26. Scope: create the operator group, install root-owned committed-release launchers/assets, enable the control socket, install tray autostart, stage retention units disabled, and rehearse upgrade/rollback while preserving the existing 03:30 backup timer. Retention mutation remains gated on separate approval of a successful identical dry-run fingerprint. Rules consulted and applied: Coding Standards (100), General Preferences (50), Operational Best Practices (40), Planning Protocol (30), Testing Conventions (25), Documentation Conventions (20), Git Conventions (15). - - Status: Live Linux Mint rollout and acceptance in progress; retention mutation not approved. - - [ ] T010.1 Stage without changing the working 03:30 backup. - - [ ] T010.2 Obtain explicit approval before group membership, service, + - Evidence: Controlled Linux Mint live acceptance completed on 2026-07-26. Release 32ab1fefd8fd9334fe37b68b1f2262565f32bebd is selected; authorized/denied views, root-owned launcher and backend, tray reconnect/status, interrupted-run recovery, upgrade/rollback, scheduled and explicit backup paths, one-file restore, exact-fingerprint retention approval, post-success retention, and independent retention were evidenced without copying secrets. Backup run 287f480c-283f-45c0-85ed-2eb8b6392596 and post-success retention run b3e5baff-56a7-4437-9295-9611a0c56156 succeeded. Both timers remain enabled and waiting. + - Status: Phase 4 live acceptance complete; T011 durable documentation promotion is next. + - [x] T010.1 Stage without changing the working 03:30 backup. + - Evidence: Staged and installed immutable release assets without changing the existing 03:30 backup cadence. The selected release is 32ab1fefd8fd9334fe37b68b1f2262565f32bebd; the backup timer remains enabled and waiting for 03:30. + - Status: Live staging complete and schedule preserved. + - Evidence mode: external + - [x] T010.2 Obtain explicit approval before group membership, service, launcher, timer, or live-retention mutations. - - [ ] T010.3 Execute acceptance and record secret-free evidence. - - [ ] T010.4 Rehearse rollback and confirm backup scheduling remains healthy. + - Evidence: The operator explicitly approved T010 rollout, credential-free migration, retention dry-run, exact fingerprint e62033fd33259af14b68305e6d1179f840697f4a89f0c0df8cb95a5d69e81d94, and live retention activation before each protected mutation class. + - Status: All required live-mutation approvals recorded. + - Evidence mode: external + - [x] T010.3 Execute acceptance and record secret-free evidence. + - Evidence: Secret-free live acceptance passed on Linux Mint: authorized and denied system views, stable launcher from /, socket activation, standalone tray disconnect/reconnect and status, scheduled and on-demand backup, one-file restore, successful dry-run and approved retention, post-backup retention, independent retention, and interrupted-run recovery. The on-demand backup run 287f480c-283f-45c0-85ed-2eb8b6392596 succeeded, followed by retention run b3e5baff-56a7-4437-9295-9611a0c56156; tray status is success with zero active operations. + - Status: V10 live acceptance passed; protected values remain outside the spec. + - Evidence mode: external + - [x] T010.4 Rehearse rollback and confirm backup scheduling remains healthy. + - Evidence: Upgrade and rollback were rehearsed across immutable releases while preserving root-owned policy and durable run records. The final selected release is 32ab1fefd8fd9334fe37b68b1f2262565f32bebd. The backup timer and independent retention timer are both enabled and waiting; their next runs are 03:30 and 00:00 respectively. + - Status: Rollback rehearsal passed and both production schedules are healthy. + - Evidence mode: external ## Phase 5: Promotion, review, and closure - [ ] T011 Promote accepted behavior into durable documentation. diff --git a/docs/specs/009-system-cli-tray-retention/traceability.md b/docs/specs/009-system-cli-tray-retention/traceability.md index d6be13c..15525cd 100644 --- a/docs/specs/009-system-cli-tray-retention/traceability.md +++ b/docs/specs/009-system-cli-tray-retention/traceability.md @@ -63,9 +63,9 @@ targets. Reconcile this matrix whenever any linked artifact changes. | Design Section | Requirements | Tasks | Interfaces Or Files | Verification | Coverage State | Residual Destination | |----------------|--------------|-------|---------------------|--------------|----------------|----------------------| | Decisions D001-D005 | R1, R2, R4 | T001, T003, T005-T006 | `system_control`, CLI, install assets | V1-V6 | not-covered | T001 | -| Decision D006 and independent tray | R3, R4 | T007, T009 | monitoring/tray/platform modules | V7, V9-V10 | repository-validated | T010 | -| Decision D007 and retention flow | R5 | T002, T008 | retention/scheduling modules | V3, V8, V10 | repository-validated | T010 | -| Migration and compatibility | R1-R6 | T005, T007-T010 | install/release/system assets | V9-V10 | repository-validated | T010 | +| Decision D006 and independent tray | R3, R4 | T007, T009-T010 | monitoring/tray/platform modules | V7, V9-V10 | live-validated on Linux Mint | T011 promotion | +| Decision D007 and retention flow | R5 | T002, T008, T010 | retention/scheduling modules | V3, V8, V10 | live-validated | T011 promotion | +| Migration and compatibility | R1-R6 | T005, T007-T010 | install/release/system assets | V9-V10 | live-validated on Linux Mint | T011 promotion | | Durable promotion | R1-R6 | T011-T012 | `docs/` targets | V12 | not-covered | T011 | ## Open Decision Impact @@ -82,8 +82,8 @@ targets. Reconcile this matrix whenever any linked artifact changes. - `complete` in the requirement-delivery matrix means every accepted criterion has an explicit design, task, verification, and durable-target mapping. It does not claim implementation completion. -- Phase 4 repository implementation evidence now exists in `tasks.md` and - `verification.md`; live integration and promotion evidence remain pending. +- Phase 4 repository and Linux Mint live evidence now exists in `tasks.md` and + `verification.md`; durable promotion and closure evidence remain pending. ## Reconciliation @@ -91,5 +91,5 @@ Reviewed against the 2026-07-26 requirements and design revisions. Every Requirement 1-6 acceptance criterion has an explicit task mapping, including Requirement 4 AC10-AC11 and the tightened security constraints. Phase 3 repository implementation evidence now covers Decisions D006-D007 and -packaging/portability. T010-T012 remain the open live-integration, promotion, -and closure path. +packaging/portability. T010 live integration is complete; T011-T012 remain the +open promotion, final-review, and closure path. diff --git a/docs/specs/009-system-cli-tray-retention/verification.md b/docs/specs/009-system-cli-tray-retention/verification.md index 256eac0..cbdf466 100644 --- a/docs/specs/009-system-cli-tray-retention/verification.md +++ b/docs/specs/009-system-cli-tray-retention/verification.md @@ -21,10 +21,10 @@ review, durable promotion, and closure. |------|-----------|--------|----------| | Requirements acceptance criteria reviewed | yes | pending | Requirements amended through 2026-07-26 | | Design and traceability approved | yes | passed | Owner approved implementation; lifecycle context reports no Phase 1 gaps | -| Task evidence complete | yes | partial | T001-T009 complete; T010-T012 pending | -| Automated tests pass or alternate verification recorded | yes | partial | Phase 4 focused suite: 178 passed; expanded suite: 753 passed, 1 skipped | +| Task evidence complete | yes | partial | T001-T010 complete; T011-T012 pending | +| Automated tests pass or alternate verification recorded | yes | passed for Phase 4 | System-control suite: 176 passed before live rollout; 22 focused backup/backend/tray tests passed after live defect fixes | | Security and operations expert review complete | yes | partial | T004 and Phase 2 checkpoints complete; final T012 review pending | -| Linux Mint live acceptance and rollback rehearsal complete | yes | pending | | +| Linux Mint live acceptance and rollback rehearsal complete | yes | passed | V10 completed on 2026-07-26; selected release `32ab1fefd8fd9334fe37b68b1f2262565f32bebd` | | Durable documentation promoted | yes | pending | | | Governance or policy conflicts resolved | yes | pending | | | Spec cleanup decision recorded | yes | pending | | @@ -66,37 +66,37 @@ Commands are refined through Agent Workbench before execution. | Requirement | Acceptance criteria covered | Evidence | Residual risk | |-------------|-----------------------------|----------|---------------| -| Requirement 1 | AC1-AC4 | V5 and V9 repository validation passed; V10 pending | Live launcher/rollback | -| Requirement 2 | AC1-AC6 | V2, V4-V5, V10 pending | Platform authorization UX | -| Requirement 3 | AC1-AC8 | V7 and V9 repository validation passed; V10 pending | Desktop diversity and live session behavior | -| Requirement 4 | AC1-AC11 | V1-V3 and V6 repository validation passed; V4 and V10 live evidence pending | Redaction and NSS variance | -| Requirement 5 | AC1-AC11 | V1, V3, and V8 repository validation passed; V10 pending | Live repository timing | -| Requirement 6 | AC1-AC6 | V3, V5, V7, and V9 repository validation passed; V10 pending | Cross-platform rollout | +| Requirement 1 | AC1-AC4 | V5, V9, and V10 passed | Durable promotion remains T011 | +| Requirement 2 | AC1-AC6 | V2, V4-V5, and V10 passed on Linux Mint | Other platform authorization remains roadmap work | +| Requirement 3 | AC1-AC8 | V7, V9, and V10 passed for the Linux reference desktop | Desktop diversity remains a portability risk | +| Requirement 4 | AC1-AC11 | V1-V4, V6, and V10 passed | NSS variance remains a residual portability risk | +| Requirement 5 | AC1-AC11 | V1, V3, V8, and V10 passed | Production timing remains observable through durable runs | +| Requirement 6 | AC1-AC6 | V3, V5, V7, V9, and V10 passed for Linux | Live Windows support remains follow-up work | ## Correctness Property Coverage | Property | Covered by | Evidence | Residual risk | |----------|------------|----------|---------------| -| CP-001 | V2, V5 | repository validation passed | Live platform authorization remains V10 | -| CP-002 | V7, V10 | V7 repository validation passed | Live desktop acceptance remains V10 | -| CP-003 | V3, V8, V10 | V3 and V8 repository validation passed | Live repository coordination remains V10 | -| CP-004 | V1, V3, V6, V8 | repository validation passed | Live integration remains V10 | -| CP-005 | V1, V8, V10 | V1 and V8 repository validation passed | Live retention acceptance remains V10 | -| CP-006 | V1-V2, V4-V6 | V1-V3 and V5-V6 repository validation passed | Live IPC remains V4/V10 | -| CP-007 | V2, V4, V10 | repository authorization and denial validation passed | Live NSS/session behavior remains V4/V10 | -| CP-008 | V3, V9-V10 | V3 and V9 repository validation passed | Installed coordination and restart remain V10 | +| CP-001 | V2, V5 | repository and Linux live authorization passed | Other platform authorization remains follow-up | +| CP-002 | V7, V10 | repository and Linux Mint live tray acceptance passed | Desktop diversity | +| CP-003 | V3, V8, V10 | repository locking and live backup/retention coordination passed | Production timing variance | +| CP-004 | V1, V3, V6, V8 | repository and live terminal-state projection passed | none after T010 evidence | +| CP-005 | V1, V8, V10 | exact-fingerprint dry-run and live retention passed | Operator policy accuracy | +| CP-006 | V1-V2, V4-V6 | repository and live local IPC authorization passed | Other platform IPC | +| CP-007 | V2, V4, V10 | authorized, denied, and live NSS behavior passed | NSS variance across Linux distributions | +| CP-008 | V3, V9-V10 | interrupted recovery, installed coordination, upgrade, and rollback passed | Crash timing remains observable | | CP-009 | V1, V7, V9 | V1, V7, and V9 repository validation passed | Live Windows remains follow-up | -| CP-010 | V3, V8, V10 | V3 and V8 repository validation passed | Live retention failure isolation remains V10 | -| CP-011 | V1-V2, V4, V6, V10 | repository projection and CLI validation passed | Live metadata-leak acceptance remains V10 | +| CP-010 | V3, V8, V10 | repository idempotency and both live retention trigger modes passed | none after T010 evidence | +| CP-011 | V1-V2, V4, V6, V10 | repository and live safe projection passed | Continue canary tests during promotion | ## Scope Reconciliation Before Closure | Broad requirement, design target, or review finding | Implemented in this spec | Coverage state | Deferred or rejected work | Destination | Blocks closure? | Evidence | |-----------------------------------------------------|--------------------------|----------------|---------------------------|-------------|-----------------|----------| -| Linux system command/control plane | Shared contracts, store, dispatcher, backend entrypoint, compatible assets, staged launcher, and CLI client/views | partial | Live artifact integration and host acceptance | T010 | yes | T001-T006, T009 evidence | -| Group-authorized system records | Current-membership dispatcher and structured CLI projection | partial | Live NSS/socket acceptance | T010 | yes | T003, T004, T006, T009 evidence | -| Independent tray | Standalone tray entry point, bounded tray IPC client, strict menu allowlist, singleton lock, headless-safe imports, launcher, and autostart asset | partial | Installed desktop-session and live IPC acceptance | T010 | yes | T007, T009 evidence | -| Retention automation | Approved executor, exact fingerprinting, durable triggers, request handler, and disabled independent schedule asset | partial | Production adapter activation and live timing acceptance | T010 | yes | T008-T009 evidence | +| Linux system command/control plane | Shared contracts, store, dispatcher, backend, immutable launcher, CLI views, installed socket, and live acceptance | live-validated | Durable documentation promotion | T011 | yes | T001-T010 evidence | +| Group-authorized system records | Current-membership dispatcher, structured projection, and authorized/denied live acceptance | live-validated | Durable documentation promotion | T011 | yes | T003, T004, T006, T009-T010 evidence | +| Independent tray | Standalone process, bounded IPC client, strict menu allowlist, singleton lock, launcher/autostart, reconnect, and live status | live-validated | Durable documentation promotion | T011 | yes | T007, T009-T010 evidence | +| Retention automation | Exact-fingerprint approval, protected adapter, post-success trigger, independent timer, shared lock, and durable runs | live-validated | Durable documentation promotion | T011 | yes | T008-T010 evidence | | Windows shared architecture | Token-derived identity, current-group resolver, and named-pipe transport seam with Linux-hosted contract tests | repository-validated | Live Windows service/pipe implementation and acceptance | Platform roadmap | no for this Linux reference closure | T009 evidence | | Raw journald delegation | rejected | out-of-scope | Rejected because it exposes unrelated/protected records | none | no | Design D002 | | User-scoped backup partitions | none | out-of-scope | Separate authorization model | GitHub issue #70 | no | Requirements non-goal | @@ -126,7 +126,8 @@ Commands are refined through Agent Workbench before execution. | T007 | complete | Independent tray entry point, strict tray allowlist, backend-unavailable/denied projection, and headless-safe monitoring imports; 190-test repository slice passed | Installed desktop-session and live IPC acceptance remain T009-T010 | | T008 | complete | Approved retention executor, trigger claiming, protected request handler, and independent schedule gate; system-control suite passed 149 tests with 83.09% branch-aware coverage | Live backend composition and host scheduling acceptance remain T009-T010 | | T009 | complete | 178-test focused Phase 4 suite; 753-test expanded regression; validated wheel/sdist, entrypoints, assets, headless import, staged units, upgrade, and rollback | No host state changed; live installation and production adapter activation remain T010 | -| T010-T012 | pending | No completion evidence | Live acceptance, promotion, final review, and closure | +| T010 | complete | Selected immutable release, authorized/denied system views, launcher/socket/tray acceptance, successful backup and restore, approved post-success and independent retention, interrupted recovery, upgrade, and rollback | Linux Mint reference acceptance only; no live Windows claim | +| T011-T012 | pending | No completion evidence | Durable promotion, final review, and closure | ## Evidence Log @@ -160,6 +161,10 @@ Commands are refined through Agent Workbench before execution. | 2026-07-26 | Expanded `system_control`, `monitoring`, and `integration` pytest run | 753 passed, 1 skipped | Previous six credential/backend-registration failures are resolved; existing warnings remain non-blocking. | | 2026-07-26 | Wheel/sdist validation and installed headless smoke | passed | Four console entrypoints and 20 package-data files validated; CLI import did not load tray code and the environment had no `pystray` dependency. | | 2026-07-26 | Staged `systemd-analyze verify --recursive-errors=no --root=...` | passed | Backend socket/service and disabled retention service/timer parsed successfully against an isolated staged executable. | +| 2026-07-26 | Controlled Linux Mint T010 rollout and immutable-release rehearsal | passed | Root-owned launchers/backend, current operator-group authorization and denial, socket activation, tray disconnect/reconnect, interrupted-run recovery, upgrade, and rollback passed; selected release is `32ab1fefd8fd9334fe37b68b1f2262565f32bebd`. | +| 2026-07-26 | Production backup and restore acceptance | passed | Scheduled backup remained healthy; explicit backup run `287f480c-283f-45c0-85ed-2eb8b6392596` succeeded and a one-file restore completed. Evidence contains no credentials, repository URI, or protected source inventory. | +| 2026-07-26 | Exact-fingerprint retention activation | passed | Operator accepted fingerprint `e62033fd33259af14b68305e6d1179f840697f4a89f0c0df8cb95a5d69e81d94`; independent retention and post-backup retention run `b3e5baff-56a7-4437-9295-9611a0c56156` succeeded without pruning. Both timers remain enabled and waiting. | +| 2026-07-26 | Live tray queued/history regression | fixed and passed | Initial accepted request displayed stale `error` while queued; commits `2388e1d` and `32ab1fe` added durable backup coordination and made queued/latest operation state authoritative. Focused regression: 22 passed; live tray reports `success`, zero active operations, and backend available. | ## T004 Review Finding Dispositions @@ -271,11 +276,10 @@ protected metadata, widen privilege, interrupt backups, or delete snapshots. ## Readiness Decision -- **Ready for promotion:** no +- **Ready for promotion:** yes - **Ready for release:** no - **Ready for closure:** no -- **Ready for implementation:** yes for Phase 4 task T010; live-host - mutations require explicit approval +- **Ready for implementation:** yes for Phase 5 task T011 ## Related Artifacts @@ -288,9 +292,10 @@ protected metadata, widen privilege, interrupt backups, or delete snapshots. ## Reconciliation -Reviewed against the 2026-07-26 requirements and design revisions. T001-T009 -now provide executed repository evidence for V1-V9 and repository-local -portions of V4/V11. Real socket activation, installed ownership/modes, live NSS -behavior, production adapter activation, authorization prompts, and host -restart remain pending under T010; durable promotion and closure remain -incomplete. +Reviewed against the 2026-07-26 requirements and design revisions. T001-T010 +now provide repository and Linux Mint live evidence for V1-V10 and +repository-local portions of V11. Real socket activation, installed +ownership/modes, live NSS behavior, protected backup/restore, post-success and +independent retention, tray reconnect/status, interrupted recovery, upgrade, +and rollback passed. Durable promotion, final expert review, and closure remain +T011-T012. From 3f009a8584d853ce72ec6d3bc0ae5b407a8b1f8d Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:41:53 +0100 Subject: [PATCH 47/72] docs: promote spec 009 system operations --- README.md | 15 +- docs/1-requirements/README.md | 8 +- docs/1-requirements/system-operations.md | 85 ++ docs/2-architecture/scheduling-system.md | 412 +++------ docs/2-architecture/system-architecture.md | 98 ++- .../service-layer-integration.md | 28 + docs/DOCUMENTATION-STATUS.md | 10 +- docs/README.md | 20 +- docs/SYSTEM-TRAY-SETUP.md | 105 +-- docs/guides/developer/scheduling-guide.md | 240 +++--- docs/guides/gui-dependencies.md | 175 +--- .../user/backup-operations-troubleshooting.md | 788 +++--------------- docs/guides/user/installation.md | 78 +- docs/processes/version-management.md | 21 +- .../timelocker-cli-command-hierarchy.md | 222 ++--- .../canonical-context.md | 16 +- .../change-impact.md | 28 +- .../009-system-cli-tray-retention/tasks.md | 9 +- .../traceability.md | 19 +- .../verification.md | 54 +- docs/specs/README.md | 17 +- 21 files changed, 853 insertions(+), 1595 deletions(-) create mode 100644 docs/1-requirements/system-operations.md diff --git a/README.md b/README.md index fdb3b80..80c54cf 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ measures of success. Read it when deciding whether proposed work belongs in TimeLocker; use active specs for approved delivery details. > **Note**: TimeLocker is a **CLI-based application**. It does not provide a -> desktop GUI or REST API. Optional desktop integration is limited to system -> tray notifications. +> full desktop GUI or REST API. A protected Linux deployment adds an independent +> optional tray for status and allowlisted backup/retention requests. ## Table of Contents @@ -84,6 +84,7 @@ This project is intended for: │ ├── config/ # Filesystem-backed configuration │ ├── monitoring/ # Telemetry, progress, notifications │ ├── scheduling/ # Scheduling integrations +│ ├── system_control/ # Protected backend, launcher, tray, runs │ ├── security/ # Credentials and privacy controls │ ├── policy/ # Policy models and persistence │ └── restic/ # Restic repositories and commands @@ -139,6 +140,13 @@ python -m pip install -e '.[dev]' For detailed installation instructions, including platform-specific guidance, configuration, and troubleshooting, please refer to our [Installation Guide](docs/guides/user/installation.md). +Administrators deploying host-level backup and retention should also read the +[System Operations Requirements](docs/1-requirements/system-operations.md), +[Scheduling Guide](docs/guides/developer/scheduling-guide.md), and +[Independent Tray Setup](docs/SYSTEM-TRAY-SETUP.md). The protected deployment +uses stable `/usr/local/bin/timelocker` and `/usr/local/bin/tl` launchers and +does not depend on pyenv or a source checkout. + ### Quick Start #### Command Line Interface @@ -337,7 +345,8 @@ For detailed documentation, please refer to: - [**Implementation Guides**](docs/3-implementation/README.md) - Implementation details and patterns - [**API References**](docs/reference/README.md) - API references for backup and recovery operations - [**Testing Documentation**](docs/4-testing/README.md) - Testing guides and strategies -- [**System Tray Setup**](docs/SYSTEM-TRAY-SETUP.md) - Optional system tray integration +- [**System Tray Setup**](docs/SYSTEM-TRAY-SETUP.md) - Independent status and + allowlisted-action tray - [**User Guides**](docs/guides/user/README.md) - End-user documentation - [**Developer Guides**](docs/guides/developer/README.md) - Developer documentation diff --git a/docs/1-requirements/README.md b/docs/1-requirements/README.md index 6db80df..461db19 100644 --- a/docs/1-requirements/README.md +++ b/docs/1-requirements/README.md @@ -5,7 +5,7 @@ id: "RM-003" type: [ readme ] status: active owner: "Auriora Team" -last_reviewed: 2026-07-18 +last_reviewed: 2026-07-26 tags: [readme, requirements] links: tooling: [] @@ -51,6 +51,12 @@ links: for accepted current-state requirements. Use an active spec for proposed implementation work. +## 6. Current Requirements + +- [System Operations Requirements](./system-operations.md) — protected launcher, + authorization, backup/retention, visibility, privacy, tray, and platform + invariants. + # References - Link to additional resources, specs, or tickets diff --git a/docs/1-requirements/system-operations.md b/docs/1-requirements/system-operations.md new file mode 100644 index 0000000..5d4542e --- /dev/null +++ b/docs/1-requirements/system-operations.md @@ -0,0 +1,85 @@ +--- +title: "System Operations Requirements" +doc_type: requirements +status: active +owner: Auriora Team +last_reviewed: 2026-07-26 +--- + +# System Operations Requirements + +## Scope + +These are the accepted current requirements for protected host-level TimeLocker +backup, retention, status, diagnostics, and tray operations. + +## Command And Release Requirements + +- `timelocker` and `tl` must be stable commands on the system path. +- Protected deployments must execute a root-owned selected immutable release, + independent of the caller's shell, pyenv selection, checkout, home directory, + or current working directory. +- Missing, incompatible, or untrusted release metadata must fail closed. +- Activation and rollback must verify compatible CLI, backend, and tray + entrypoints before changing the selected release. + +## Authorization Requirements + +- Protected reads and actions must use a local authenticated backend. +- Only current members of the configured operator group may read system run + records, read structured system diagnostics, or trigger the allowlisted + backup and retention actions. +- Authorization must be evaluated from the operating system's current identity + and group database for each request. +- Administrator maintenance, including installation, operator-group changes, + policy approval, service changes, activation, and rollback, remains root-only. +- Denial and unavailability must not fall back to direct privileged execution. + +## Operation Requirements + +- System backup and retention must create durable, queryable run records. +- Backup and retention must share a repository mutation lock. +- Retention may run after successful backup, on an independent schedule, or by + explicit authorized request. +- Backup-success retention must be a separate run started only after successful + backup completion and lock release. +- Independent retention must not depend on a preceding backup result. +- Interrupted queued or running operations must be reconciled explicitly. +- Retention mutation must require a root-owned enable marker and an exact + approved policy/target fingerprint. Dry-run remains available without + snapshot removal. + +## Visibility And Privacy Requirements + +- Operators must be able to list and inspect structured backup and retention + runs and safe diagnostics. +- User-local logs must remain distinct from protected system records. +- The protected interface must not disclose repository passwords, cloud + credentials, environment-file contents, raw backend output, raw journald + content, or unnecessary protected filesystem paths. +- Run records must use bounded states, result codes, counters, and safe + summaries. + +## Tray Requirements + +- The tray must be an independent user-session process, not part of normal CLI + initialization or the privileged backend. +- It may show backend availability, current activity, latest backup and + retention status, and next known schedules. +- It may request only allowlisted actions through the protected backend. +- Tray failure, exit, or restart must not affect backend services or active + operations. +- A full desktop UI is not part of the current product surface. + +## Platform Requirement + +The architecture must preserve portable contracts for Linux and Windows +adapters. The protected installation and independent tray are currently +live-accepted on Linux Mint. This requirement does not claim an accepted +Windows deployment. + +## References + +- [System Architecture](../2-architecture/system-architecture.md) +- [Scheduling Architecture](../2-architecture/scheduling-system.md) +- [CLI Command Hierarchy](../reference/timelocker-cli-command-hierarchy.md) diff --git a/docs/2-architecture/scheduling-system.md b/docs/2-architecture/scheduling-system.md index 073275f..982f4ca 100644 --- a/docs/2-architecture/scheduling-system.md +++ b/docs/2-architecture/scheduling-system.md @@ -4,343 +4,129 @@ id: "arch-scheduling-system" type: [ architecture ] status: [ approved ] owner: "Architecture Team" -last_reviewed: "18-07-2026" -tags: [architecture, scheduling, automation, platform-integration] +last_reviewed: "2026-07-26" +tags: [architecture, scheduling, backup, retention] links: - tooling: [] + tooling: [] --- # Architecture Document: Scheduling System -- **Owner**: Architecture Team -- **Status**: Approved -- **Created Date**: 13-11-2025 -- **Last Updated**: 13-11-2025 -- **Audience**: Engineering Teams, Platform Integration Developers - -## 1. Context - -The Scheduling System provides comprehensive automated backup scheduling capabilities for TimeLocker through platform-appropriate system schedulers. It enables -unattended backup operations by integrating with native OS scheduling systems (systemd timers, cron, Windows Task Scheduler, launchd) while coordinating with -Policy Management, Data Selection, Repository Management, and Monitoring systems. - -The design emphasizes cross-platform compatibility, secure credential management, and seamless integration with existing TimeLocker architecture. The system -automatically detects the appropriate platform scheduler and generates native configurations while maintaining consistent behavior across all supported -platforms. - -## 2. Architecture - -### 2.1 Component Overview - -The Scheduling System consists of four primary layers: - -1. **Schedule Manager**: Central orchestrator for all scheduling operations -2. **Platform Adapters**: Platform-specific scheduling implementations -3. **Script Generator**: Generates platform-specific wrapper scripts -4. **Automation Engine**: Handles execution of scheduled backups - -### 2.2 Implementation Location - -- **Base Directory**: `/src/TimeLocker/scheduling/` -- **CLI Integration**: `/src/TimeLocker/cli_modules/commands/schedule.py` - -### 2.3 Core Components - -#### Schedule Manager (`schedule_manager.py`) - -Central manager for backup scheduling operations with responsibilities: - -- Schedule creation and management -- Platform adapter coordination -- Integration with TimeLocker systems -- Audit trail maintenance - -**Key Methods**: - -- `create_scheduled_backup()` - Create new scheduled backup from policy -- `update_scheduled_backup()` - Update existing schedule configuration -- `delete_scheduled_backup()` - Remove schedule and cleanup platform scheduler -- `list_scheduled_backups()` - List all scheduled backups with filtering -- `get_schedule_status()` - Get current status and next run time - -#### Platform Adapters - -Platform-specific scheduling implementations with unified interface: - -- **systemd Adapter** (`systemd_adapter.py`) - systemd timer adapter for Linux -- **Cron Adapter** (`cron_adapter.py`) - cron adapter for Unix-like systems -- **Windows Task Scheduler Adapter** (`windows_adapter.py`) - Windows scheduled task adapter -- **launchd Adapter** (`launchd_adapter.py`) - launchd adapter for macOS - -**Common Interface**: - -- `create_schedule()` - Create platform-specific scheduled task -- `update_schedule()` - Update existing scheduled task -- `delete_schedule()` - Remove scheduled task -- `get_schedule_status()` - Get platform-specific status -- `list_schedules()` - List all scheduled tasks - -#### Platform Detection (`platform_detector.py`) - -Detects platform capabilities and selects appropriate scheduler: - -- Automatic detection of best available scheduler -- Capability checking (systemd, cron, Task Scheduler, launchd) -- Fallback mechanism for unsupported platforms - -#### Script Generator (`script_generator.py`) - -Generates platform-specific wrapper scripts with: - -- Environment setup and credential loading -- Error handling and logging integration -- Monitoring integration -- Retry logic and timeout handling - -#### Automation Engine (`automation_engine.py`) - -Handles execution of scheduled backup operations: - -- Backup execution coordination -- Integration with all TimeLocker systems -- Error handling and retry logic -- Monitoring and audit logging - -### 2.4 Integration Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Scheduling System │ -│ │ -│ ┌─────────────────┐ ┌────────────────┐ ┌──────────────┐ │ -│ │ Schedule │ │ Platform │ │ Script │ │ -│ │ Manager │ │ Adapters │ │ Generator │ │ -│ └─────────────────┘ └────────────────┘ └──────────────┘ │ -│ │ -│ ┌─────────────────┐ ┌────────────────┐ ┌──────────────┐ │ -│ │ Automation │ │ Credential │ │ Audit │ │ -│ │ Engine │ │ Integration │ │ Logger │ │ -│ └─────────────────┘ └────────────────┘ └──────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Platform Schedulers │ -│ │ -│ ┌─────────────────┐ ┌────────────────┐ ┌──────────────┐ │ -│ │ systemd Timers │ │ Cron │ │ Task │ │ -│ │ (Linux) │ │ (Unix-like) │ │ Scheduler │ │ -│ └─────────────────┘ └────────────────┘ │ (Windows) │ │ -│ └──────────────┘ │ -│ ┌─────────────────┐ │ -│ │ launchd │ │ -│ │ (macOS) │ │ -│ └─────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ TimeLocker Core Systems │ -│ │ -│ ┌─────────────────┐ ┌────────────────┐ ┌──────────────┐ │ -│ │ Policy │ │ Data │ │ Repository │ │ -│ │ Management │ │ Selection │ │ Management │ │ -│ └─────────────────┘ └────────────────┘ └──────────────┘ │ -│ │ -│ ┌─────────────────┐ ┌────────────────┐ │ -│ │ Backup │ │ Monitoring & │ │ -│ │ Operations │ │ Reporting │ │ -│ └─────────────────┘ └────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ +## Purpose + +Describe the implemented scheduling boundaries for user-managed backup +schedules and protected system backup and retention operations. + +## Scheduling Surfaces + +TimeLocker has two distinct scheduling surfaces: + +1. `tl schedule` stores schedule definitions and generates native scheduler + assets for user-managed backups. Generation is non-mutating; an operator + must review and install the assets. +2. A protected Linux deployment uses root-owned systemd units for the approved + system backup and retention target. These units execute through the selected + immutable TimeLocker release and write structured run records through the + system-control layer. + +The source package contains adapters for systemd, cron, launchd, and Windows +Task Scheduler. The protected system-control deployment has live acceptance +evidence on Linux Mint with systemd. Other protected-host adapters are not +claimed as live-accepted by this document. + +## Protected Operation Flow + +```text +backup timer or authorized request + | + v + allowlisted backup unit + | + v + durable backup RunRecord + repository lock + | + backup completes + / \ + success failure + | | + v v +release lock terminal backup record + | + v +separate retention RunRecord (backup-success trigger) ``` -## 3. Data Models - -### Core Models (`scheduling_models.py`) - -**ScheduleConfig**: Configuration for a scheduled backup - -- schedule_id, name, description -- policy_id reference -- schedule_pattern (cron, interval, calendar) -- enabled flag, timeouts, retry config -- monitoring configuration -- platform-specific settings - -**SchedulePattern**: Defines when backup should execute - -- pattern_type (cron, interval, calendar) -- cron_expression, interval_minutes -- calendar_config for day/time scheduling -- backup_window for time restrictions - -**ExecutionContext**: Context information for backup execution - -- execution_id, schedule_id -- triggered_by (scheduled, manual, retry, test) -- start_time, platform, user_context +Retention may also start from its independent timer or from an authorized +operator request. It does not require a preceding successful backup. When it +follows a successful backup, it starts as a separate run only after the backup +has reached a terminal success state and released the shared repository lock. -**ExecutionResult**: Result of scheduled backup execution +## Retention Contract -- execution_id, schedule_id, status -- backup_result, execution_time -- error_details, retry information +The packaged production policy keeps 5 daily, 4 weekly, 12 monthly, and +3 yearly snapshots, grouped by `host,paths`. Prune is disabled. A production +retention mutation is accepted only when all of the following are true: -## 4. Security Features +- the root-owned enable marker exists; +- the requested policy fingerprint matches the protected approved policy; +- the protected repository and credential references still match the approved + target; and +- the shared repository mutation lock is available. -### Credential Management (`credential_integration.py`) +Dry runs do not remove snapshots. A lock conflict produces a structured skipped +run rather than overlapping a backup or another retention operation. -Integrates with platform-specific credential stores: +## Run State and Recovery -- **Windows**: Windows Credential Manager + DPAPI -- **macOS**: Keychain Services -- **Linux**: Secret Service API (libsecret) -- **Fallback**: Encrypted file-based storage +Backup and retention runs use durable states: queued, running, succeeded, +failed, skipped, or interrupted. Start and finish hooks bind systemd backup +execution to one run identifier. On startup, an unfinished active record is +reconciled to interrupted before new work proceeds. -### Audit Logging (`audit_logger.py`) +System run history is read with: -Comprehensive audit trails for compliance: - -- Schedule creation/modification/deletion events -- Execution start/completion/failure events -- Credential access events -- Platform scheduler interactions - -## 5. Testing and Validation - -### Testing Components - -**Schedule Testing** (`schedule_testing.py`): - -- Schedule validation testing -- Execution simulation -- Platform adapter testing - -**Integration Testing** (`integration_testing.py`): - -- End-to-end scheduling workflow -- Cross-platform compatibility -- TimeLocker system integration -- External integration client testing - -### Validation (`schedule_validator.py`) - -Validates schedule configurations: - -- Schedule pattern validation -- Policy compatibility checks -- Platform capability verification -- Conflict detection - -## 6. Configuration - -### Scheduling Configuration (`scheduling_configuration.py`) - -Master configuration for scheduling system: - -- Platform preferences and defaults -- Retry configuration defaults -- Monitoring configuration defaults -- Audit retention settings -- Execution limits and timeouts - -## 7. Error Handling - -### Exception Hierarchy (`scheduling_exceptions.py`) - -- `SchedulingError` - Base exception -- `PlatformSchedulerError` - Platform operation failed -- `PolicyValidationError` - Policy validation failed -- `DataSelectionValidationError` - Selection validation failed -- `RepositoryValidationError` - Repository access failed -- `CredentialAccessError` - Credential access failed -- `ExecutionTimeoutError` - Execution timeout -- `ScheduleConflictError` - Schedule conflict detected - -### Recovery Strategies - -1. **Platform Scheduler Failures**: Retry with exponential backoff -2. **Credential Access Failures**: Secure retry with user notification -3. **Validation Failures**: Skip execution with detailed logging -4. **Repository Access Failures**: Retry with backoff -5. **Execution Timeouts**: Graceful termination with cleanup -6. **Schedule Conflicts**: Automatic rescheduling - -## 8. Performance Considerations - -### Schedule Storage (`schedule_storage.py`) - -Efficient storage and retrieval: - -- JSON-based configuration storage -- XDG-compliant directory structure -- Indexed schedule lookups -- Efficient list operations - -### Utilities (`schedule_utilities.py`) - -Performance-optimized utilities: - -- Schedule pattern parsing -- Next run time calculation -- Time window validation -- Conflict detection algorithms - -## 9. Monitoring and Compliance - -### Compliance Reporting (`compliance_reporter.py`) - -Generates compliance reports: - -- Execution history tracking -- Success/failure rate analysis -- SLA compliance monitoring -- Audit trail export - -### Integration with Monitoring System - -Deep integration with TimeLocker monitoring: - -- Real-time execution status -- Performance metrics collection -- Alert generation for failures -- Dashboard integration - -## 10. CLI Integration +```bash +timelocker runs list +timelocker runs show RUN_ID +``` -Accessible through `schedule` command namespace: +These commands cross the protected local backend and are available only to +current members of the configured operator group. -```bash -# Create schedule from policy -timelocker schedule create --policy-id --pattern "0 2 * * *" +## Failure Isolation -# List schedules -timelocker schedule list +- Backup failure does not start backup-success retention. +- Independent retention remains eligible regardless of the most recent backup + result. +- Retention failure does not rewrite a successful backup result. +- A repository lock conflict skips the later operation. +- Credentials, repository passwords, raw backend output, protected file paths, + and raw journald content are not placed in run records. -# Get schedule status -timelocker schedule status +## Platform Assets -# Enable/disable schedule -timelocker schedule enable -timelocker schedule disable +The Linux package supplies: -# Delete schedule -timelocker schedule delete +- `timelocker-control.socket` and `timelocker-control.service`; +- `timelocker-retention.service` and `timelocker-retention.timer`; +- stable launchers under `/usr/local`; and +- an independent user-session tray launcher. -# Test schedule execution -timelocker schedule test --dry-run -``` +The generic retention timer uses a daily calendar. Administrators may choose a +specific local time when installing the deployment, provided backup and +retention still share the repository lock. The accepted reference deployment +uses a 03:30 backup and a 00:00 independent retention run; those times are not +portable defaults. -## 11. Design Principles +## Change Rules -- **Platform Native**: Leverage native OS scheduling for reliability -- **Security First**: Secure credential management throughout -- **Integration Focused**: Deep integration with TimeLocker systems -- **Audit Compliant**: Comprehensive audit trails -- **Failure Resilient**: Robust error handling and recovery -- **Performance Aware**: Minimal overhead and efficient operations +Update this document when trigger semantics, locking, durable run states, +supported protected-host adapters, or the retention safety contract changes. +Host-specific credentials, repository URIs, and secrets never belong here. ## References -- [CLI Schedule Commands](../3-implementation/cli-modules.md) -- [Policy Management](../3-implementation/policy-management.md) +- [System Operations Requirements](../1-requirements/system-operations.md) +- [System Architecture](./system-architecture.md) +- [Scheduling Guide](../guides/developer/scheduling-guide.md) +- [Backup Operations Troubleshooting](../guides/user/backup-operations-troubleshooting.md) diff --git a/docs/2-architecture/system-architecture.md b/docs/2-architecture/system-architecture.md index 8429c70..920abac 100644 --- a/docs/2-architecture/system-architecture.md +++ b/docs/2-architecture/system-architecture.md @@ -4,7 +4,7 @@ id: "arch-system-architecture" type: [ architecture ] status: [ approved ] owner: "Architecture Team" -last_reviewed: "18-07-2026" +last_reviewed: "2026-07-26" tags: [architecture, system, layers] links: tooling: [] @@ -19,44 +19,53 @@ This document is a current-state architecture contract, not a roadmap. ## Current State -TimeLocker is a Python 3.12+ CLI application that orchestrates Restic. The -installed `timelocker` and `tl` entry points both invoke `TimeLocker.cli:main`. -The supported repository adapters are local filesystem, S3-compatible storage, -and Backblaze B2. +TimeLocker is a Python 3.12+ CLI application that orchestrates Restic. A normal +source or wheel install exposes `timelocker` and `tl` through +`TimeLocker.cli:main`. A protected system deployment instead places stable +root-owned launchers on the system path; those launchers resolve one validated +immutable release under `/opt/timelocker` before invoking its CLI. The supported +repository adapters are local filesystem, S3-compatible storage, and +Backblaze B2. ```text -Operator / automation - | - v -Typer CLI and modular command groups - | - v -Command-facing services and orchestration - | - +--------------------+ - | | - v v -Configuration / Backup, snapshot, -credential services recovery, policy, - scheduling, monitoring - | | - +----------+---------+ - v - Repository abstractions - | - v - Restic command adapter - | - +--------+--------+ - | | | - Local S3 B2 +user CLI user-session tray + | | + +--------- user-local work | + | | + +----- protected reads/actions -----+ + | + v + authenticated local AF_UNIX protocol + | + v + root-owned system-control backend + | | | + v v v + backup retention run/diagnostic + adapter adapter stores + \ / + shared repository lock + | + v + Restic command adapter ``` ## Component Boundaries - **CLI boundary** — `src/TimeLocker/cli.py` owns the installed entry point; `src/TimeLocker/cli_modules/commands/` owns modular command groups and input/ - output handling. + output handling. User-local commands remain in-process. `runs` and + `logs view --scope system` use the protected client. +- **Release boundary** — root-owned launchers resolve the selected immutable + release from `/opt/timelocker/selected-release.json`. They do not consult + pyenv, a source checkout, the caller's home, or current working directory. +- **System-control boundary** — `src/TimeLocker/system_control/` owns the + versioned local protocol, peer identity, current group authorization, + allowlisted dispatch, protected adapters, repository locking, durable run + records, safe diagnostics, deployment assets, and release activation. +- **Tray boundary** — `timelocker-tray` is an independent unprivileged + user-session process. It polls and requests allowlisted actions through the + same protected backend; CLI startup never initializes it. - **Application boundary** — managers, orchestrators, and focused services coordinate repositories, backups, snapshots, recovery, policies, schedules, validation, and monitoring. CLI modules should delegate domain work here. @@ -69,13 +78,22 @@ credential services recovery, policy, - **Process boundary** — the Restic command definition/builder constructs and executes the external `restic` process. Backend environment variables and repository passwords are passed at this boundary. -- **Platform boundary** — scheduling adapters integrate with systemd/cron, - launchd, and Windows scheduling facilities. Optional system-tray code provides - notifications; it is not an alternative application interface. +- **Platform boundary** — user schedule adapters integrate with systemd/cron, + launchd, and Windows scheduling facilities. Protected system-control adapters + preserve a portable contract, with live acceptance currently established for + Linux Mint/systemd. ## Invariants - The CLI is the public application interface. +- Protected reads and actions fail closed if the authenticated backend, + authorization, selected release, policy approval, or protected target cannot + be validated. +- System backup and retention share a repository mutation lock and have + separate durable run records. +- System output is structured and redacted; it does not expose secrets, raw + backend output, raw journald, or unnecessary protected paths. +- Tray availability is independent of CLI and backend correctness. - Domain behavior belongs behind command-facing services or orchestration, not in presentation-only command code. - Repository secrets must not be written into ordinary configuration files or @@ -88,10 +106,12 @@ credential services recovery, policy, ## Operational Notes -Restic 0.18.0 or later must be available on `PATH`. Configuration locations are -resolved through `ConfigurationPathResolver`; tests and operators should not -hard-code a single home-directory layout. Unattended credential-store access -requires an explicit master-password environment value or protected file. +Restic 0.18.0 or later must be available on `PATH`. User configuration +locations are resolved through `ConfigurationPathResolver`. Protected +deployment configuration is root-owned under `/etc/timelocker`; durable system +state is under `/var/lib/timelocker`; the local socket is +`/run/timelocker/control.sock`. Unattended credentials are referenced from +protected files and are never copied into user-readable configuration. ## Validation @@ -113,3 +133,5 @@ active spec, not in this current-state document. - [Data Flow](./data-flow.md) - [Service-Layer Integration](../3-implementation/service-layer-integration.md) - [CLI Command Hierarchy](../reference/timelocker-cli-command-hierarchy.md) +- [System Operations Requirements](../1-requirements/system-operations.md) +- [Scheduling System](./scheduling-system.md) diff --git a/docs/3-implementation/service-layer-integration.md b/docs/3-implementation/service-layer-integration.md index 063dde3..b1f7421 100644 --- a/docs/3-implementation/service-layer-integration.md +++ b/docs/3-implementation/service-layer-integration.md @@ -36,6 +36,34 @@ command names remain unchanged. ## Components +### Protected System-Control Boundary + +`src/TimeLocker/system_control/` is a separate local service boundary for +host-level backup, retention, status, and diagnostics. It is not part of the +legacy compatibility facade described below. + +- `release_launcher.py` resolves the root-owned selected immutable release for + CLI, backend, and tray entrypoints and fails closed on untrusted state. +- `client.py` exposes the typed local client used by protected CLI reads and the + tray. +- `linux_adapter.py` obtains peer credentials from the AF_UNIX connection and + rechecks current NSS group membership for every request. +- `dispatcher.py` validates the versioned protocol and dispatches only + allowlisted structured actions. +- `production_backup.py` binds the approved systemd backup unit to durable run + start/finish hooks. +- `production_retention.py` resolves root-owned target references and invokes + the fixed retention command without returning backend output. +- `storage.py` owns atomic run/diagnostic records and the shared repository + mutation lock. +- `tray_entry.py` and `tray_client.py` own the independent user-session process; + normal CLI setup must not import or initialize tray integration. + +The protected boundary returns safe summaries, result codes, states, and +counters. It never returns passwords, environment contents, raw journal data, +raw Restic output, or arbitrary protected paths. Public action routing is +centralized in `action_policy.py`; unknown actions fail closed. + ### ConfigurationService **Purpose**: Centralized configuration access with validation and caching diff --git a/docs/DOCUMENTATION-STATUS.md b/docs/DOCUMENTATION-STATUS.md index 12dc026..265b77d 100644 --- a/docs/DOCUMENTATION-STATUS.md +++ b/docs/DOCUMENTATION-STATUS.md @@ -3,7 +3,7 @@ title: Documentation status doc_type: reference status: active owner: Auriora Team -last_reviewed: 2026-07-18 +last_reviewed: 2026-07-26 --- # Documentation Status @@ -20,11 +20,15 @@ Git history. - CLI repository, backup, snapshot, restore, selection, credential, policy, scheduling, monitoring, and integration workflows. - Filesystem/XDG configuration and Restic-backed snapshot storage. -- Optional system-tray integration. +- Protected Linux system-control backend, immutable release launchers, + structured run/diagnostic visibility, backup/retention coordination, and + independent optional tray. - Pytest-based unit, integration, and environment-dependent test suites. The repository does not currently implement a REST API, database application -store, desktop GUI, or mobile client. New future work belongs in GitHub or an +store, full desktop GUI, or mobile client. The portable protected-host +architecture includes a Windows adapter, but live deployment acceptance is +currently Linux Mint/systemd only. New future work belongs in GitHub or an approved active spec. ## Current Work diff --git a/docs/README.md b/docs/README.md index cc5d809..0a97df2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,7 +3,7 @@ title: TimeLocker documentation doc_type: reference status: active owner: Auriora Team -last_reviewed: 2026-07-18 +last_reviewed: 2026-07-26 --- # TimeLocker Documentation @@ -16,6 +16,8 @@ status snapshots—preserves superseded context. - [Project charter](../CHARTER.md) - [Installation](./guides/user/installation.md) +- [System operations requirements](./1-requirements/system-operations.md) +- [Backup operations troubleshooting](./guides/user/backup-operations-troubleshooting.md) - [Repository management](./guides/user/repository-management-guide.md) - [S3-compatible services](./guides/user/s3-compatible-services.md) - [Testing quick start](./4-testing/quickstart-testing.md) @@ -27,9 +29,11 @@ status snapshots—preserves superseded context. TimeLocker is a Beta CLI application that wraps Restic for repository, snapshot, backup, restore, credential, policy, scheduling, monitoring, and -integration workflows. The CLI is the supported user interface. There is no -implemented REST API, database-backed application store, desktop GUI, or -mobile client. +integration workflows. Protected Linux deployments add a root-owned local +backend, immutable release launcher, structured system run/diagnostic views, +scheduled retention, and an independent optional tray. The CLI remains the +supported user interface. There is no implemented REST API, database-backed +application store, full desktop GUI, or mobile client. The CLI consolidation and stabilization package is complete. Its accepted service and ownership boundaries are documented in the @@ -54,10 +58,10 @@ lists any governed delivery work. | Active delivery contracts | `specs/` | | Compact lifecycle evidence | `history/` | -`1-requirements/` currently contains the durable-document contract and template -only; do not treat removed Kiro requirements or historical specs as current -product requirements. When accepted product requirements need durable coverage, -add a current-state document there or promote them from an active spec. +`1-requirements/` contains accepted current-state requirements, including the +protected [system operations contract](./1-requirements/system-operations.md). +Do not treat removed Kiro requirements or historical specs as current product +requirements. ## Authority Boundaries diff --git a/docs/SYSTEM-TRAY-SETUP.md b/docs/SYSTEM-TRAY-SETUP.md index 1df3bbb..0cd95f7 100644 --- a/docs/SYSTEM-TRAY-SETUP.md +++ b/docs/SYSTEM-TRAY-SETUP.md @@ -1,70 +1,81 @@ -# System Tray Integration Setup (Optional) +# Independent System Tray Setup -TimeLocker's system tray integration is **optional**. The CLI works perfectly without it. +The TimeLocker tray is an optional, independent user-session process. Normal +CLI startup never initializes the tray. The tray communicates with the +protected local backend and can disappear or restart without affecting an +active backup or retention run. -> **Note**: This is for system tray integration only, not a full desktop GUI application. TimeLocker is primarily a CLI tool with optional desktop notifications -> and status indicators. +## Current Capability -## Quick Install +The tray can: -### Linux (Ubuntu/Debian) +- show backend availability; +- show active operation count; +- show the latest backup and retention status; +- show the next known backup and retention times; +- request the allowlisted system backup; +- request retention when supplied the exact approved policy fingerprint; and +- degrade to a warning state when the backend is unavailable or access is + denied. -```bash -# Install system dependencies first -sudo apt-get install -y libgirepository1.0-dev libcairo2-dev pkg-config python3-dev gir1.2-gtk-3.0 gir1.2-appindicator3-0.1 +`open_ui` is a reserved no-op. TimeLocker does not currently provide a full +desktop UI. -# Then install TimeLocker with system tray support -pip install -e .[gui] -``` +## Authorization -### macOS +The tray runs as the signed-in desktop user, never as root. The user must be a +current member of `timelocker-operators`; the backend rechecks group membership +for each request. After adding a user to the group, start a new login session +before relying on the tray. -```bash -# No system dependencies needed -pip install -e .[gui] -``` +## Linux Setup -### Windows +The protected installer places: -```bash -# No system dependencies needed -pip install -e .[gui] +```text +/usr/local/bin/timelocker-tray +/etc/xdg/autostart/timelocker-tray.desktop ``` -## Do You Need This? - -**NO** if you're: - -- Using CLI only -- Running on a headless server -- Connecting via SSH +On Linux Mint/Ubuntu with Cinnamon or GNOME-compatible panels, install the GTK +and AppIndicator runtime: -**YES** if you want: +```bash +sudo apt install python3-gi gir1.2-gtk-3.0 \ + gir1.2-ayatanaappindicator3-0.1 +``` -- System tray notifications -- Desktop integration -- Visual status indicators +Log out and back in to load the system autostart entry. For a one-shot +diagnostic that does not create a persistent tray icon: -## Full Documentation +```bash +timelocker-tray status --once +``` -See [guides/gui-dependencies.md](guides/gui-dependencies.md) for complete installation instructions and troubleshooting. +To run the foreground process for troubleshooting: -## What This Provides +```bash +timelocker-tray serve +``` -The system tray integration includes: +## Failure Behavior -- **Status Indicator**: Visual indicator in system tray showing backup status -- **Desktop Notifications**: Alerts for backup completion, errors, and warnings -- **Quick Access**: Right-click menu for common operations -- **Background Monitoring**: Non-intrusive status updates +- `Access denied` means the desktop user is not currently authorized. +- `System backend unavailable` means the local socket/backend is unavailable; + the tray retries with bounded backoff. +- A backup or retention conflict is reported by the backend and does not start + overlapping repository work. +- Quitting the tray does not stop backend services, timers, or operations. -## What This Does NOT Provide +## Platform Status -This is **not** a full desktop GUI application. TimeLocker does not have: +The process boundary is platform-neutral and the source contains a Windows +adapter. The independently installed protected tray/backend deployment has live +acceptance evidence on Linux Mint. This document does not claim a live-accepted +Windows installation. -- Graphical backup configuration interface -- Visual repository management -- Interactive backup wizards -- Dashboard or control panel +## References -For all configuration and operations, use the CLI commands. The system tray is purely for monitoring and notifications. +- [System Architecture](./2-architecture/system-architecture.md) +- [Installation](./guides/user/installation.md) +- [Backup Operations Troubleshooting](./guides/user/backup-operations-troubleshooting.md) diff --git a/docs/guides/developer/scheduling-guide.md b/docs/guides/developer/scheduling-guide.md index f0ef30c..3252730 100644 --- a/docs/guides/developer/scheduling-guide.md +++ b/docs/guides/developer/scheduling-guide.md @@ -1,176 +1,144 @@ --- -title: "Operator Guide: Scheduling Backups" -id: "dev-guide-scheduling" +title: "Operator Guide: Scheduling Backups And Retention" +id: "guide-scheduling" type: [ guide ] status: [ approved ] -owner: "Operations Team" -last_reviewed: "20-07-2026" -tags: [guide, developer, operator, scheduling] +owner: "Auriora Team" +last_reviewed: "2026-07-26" +tags: [guide, developer, operator, scheduling, retention] links: tooling: [] --- -# Operator Guide: Scheduling Backups +# Operator Guide: Scheduling Backups And Retention -- **Owner**: Operations Team -- **Status**: Approved -- **Audience**: Developers and operators +## Purpose -## Purpose and boundaries +Configure reviewable user schedules and operate the protected Linux system +backup and retention schedules without exposing credentials. -Use TimeLocker to define a recurring backup, generate reviewable cron or -systemd assets, and stage a migration from another scheduler. Schedule -generation does not install or enable anything. Installing a system unit, -choosing repository credentials, and disabling an existing backup job are -separate operator decisions. +## User-Managed Schedules -Each executable schedule must explicitly bind: +Create schedules disabled, then generate assets into a staging directory: -- one repository name or URI; -- either one configured selection or one or more source paths; -- its configuration directory when it is not the default; and -- an optional protected environment-file path, never copied secret values. +```bash +tl schedule create nightly-documents \ + --repository my-repository \ + --source "$HOME/Documents" \ + --cron-expression "30 3 * * *" \ + --environment-file "$HOME/.config/timelocker/backup.env" -Schedules may also persist repeatable `--tags` and `--exclude` values, -`--compression auto|off|max`, and the -`--one-file-system/--cross-filesystems` traversal choice. Missing fields retain -the legacy defaults and emit no additional backup arguments. -Migration schedules may additionally reference repeatable root-readable -`--exclude-file` paths, enable `--exclude-caches`, and carry an allowlisted -`--backend-option`. The initial backend allowlist supports only Restic -`s3.storage-class` with its documented non-archive storage classes. +tl schedule generate-scripts nightly-documents \ + --platform systemd \ + --output "$HOME/.local/share/timelocker/staged-schedules" +``` -## Create a disabled schedule +Review generated commands, absolute executable paths, source and exclusion +arguments, the configuration directory, environment-file permissions, and +calendar behavior before installation. Asset generation does not install, +enable, start, or disable a scheduler. -Use a selection template: +Inspect the stored definition with: ```bash -tl schedule create nightly-documents \ - --repository primary \ - --selection documents \ - --environment-file ~/.config/timelocker/backup.env \ - --frequency daily \ - --disabled \ - --config-dir ~/.config/timelocker +tl schedule list +tl schedule show nightly-documents +tl schedule test nightly-documents ``` -Or supply repeatable direct sources: +## Protected System Schedule + +The protected Linux deployment is administrator-installed. It uses root-owned +systemd units, root-owned configuration under `/etc/timelocker`, durable state +under `/var/lib/timelocker`, and the stable launcher selected under +`/opt/timelocker`. + +Inspect it without reading secret files: ```bash -tl schedule create nightly-config \ - --repository primary \ - --source /etc \ - --source /srv/application/config \ - --environment-file ~/.config/timelocker/backup.env \ - --system \ - --tags Bruce-5560 \ - --exclude 'cache/*' \ - --exclude-file /etc/timelocker/excludes \ - --exclude-caches \ - --backend-option s3.storage-class=INTELLIGENT_TIERING \ - --compression max \ - --one-file-system \ - --cron '30 1 * * *' \ - --disabled \ - --config-dir ~/.config/timelocker +systemctl status timelocker-control.socket +systemctl status timelocker-npbackup-migration.timer +systemctl status timelocker-retention.timer +systemctl list-timers 'timelocker-*' ``` -`--system` preserves the privilege boundary for sources that require root -access. It does not grant privileges or install a unit. +The backup unit records each scheduled or authorized invocation. On successful +completion it may request retention as a separate `backup-success` run. +Retention also has an independent timer and may be requested explicitly by an +authorized operator. It therefore does not need a separate *dependency* on a +backup, even when the chosen deployment also runs it after successful backups. -Protect the referenced environment file and keep it outside generated assets: +## Retention Safety -```bash -chmod 600 ~/.config/timelocker/backup.env +The current protected policy is: + +```text +keep daily: 5 +keep weekly: 4 +keep monthly: 12 +keep yearly: 3 +group by: host,paths +prune: disabled ``` -See [Per-Repository Credentials](../user/per-repo-credentials.md) for the -credential choices. Do not copy a masked credential from another backup tool. +Production mutation requires the root-owned enable marker and an exact approved +policy fingerprint. Changing any repository reference, credential reference, +filter, retention count, grouping, or prune setting changes that fingerprint +and requires an administrator to review and approve the new policy. -## Generate and review assets +Backup and retention share a repository mutation lock. A conflict is recorded +as skipped; do not bypass the lock by calling Restic directly while an operation +is active. -Generate both candidate formats without installing either: +## Operator Visibility + +Current members of `timelocker-operators` can inspect protected records: ```bash -mkdir -p ~/.local/share/timelocker/staged-schedules -tl schedule generate-scripts nightly-config \ - --platform systemd \ - --output ~/.local/share/timelocker/staged-schedules \ - --config-dir ~/.config/timelocker -tl schedule generate-scripts nightly-config \ - --platform cron \ - --output ~/.local/share/timelocker/staged-schedules \ - --config-dir ~/.config/timelocker +timelocker runs list --limit 20 +timelocker runs list --operation backup +timelocker runs list --operation retention +timelocker runs show RUN_ID +timelocker logs view --scope system --lines 100 ``` -Before installation: - -1. Confirm the generated backup command contains `backup create`, the intended - repository, all sources or the selection, the intended `--config-dir`, and - every reviewed tag, exclusion, exclusion-file, cache, backend, compression, - and traversal option. -2. Confirm it contains no password or other credential value. -3. Run the generated wrapper manually in the intended user or root context. -4. Complete a backup and a digest-verified TimeLocker restore. -5. Review the displayed install commands; generation has not run them. - -For systemd assets, `EnvironmentFile=` references the protected file. The cron -wrapper sources the same file with fail-fast shell settings. A missing environment file causes the -backup to fail instead of silently switching credentials. - -The generated timer does not declare a `Requires=` dependency on its service. -Systemd starts the same-named service when the timer elapses; coupling the -service to timer activation would also start a backup whenever the timer unit is -started or restarted. Because generated timers use `Persistent=true`, starting -one after a missed calendar event can legitimately trigger one catch-up run. -Check the service state after installing or changing a timer. - -## Staged NPBackup replacement - -Keep the NPBackup job enabled while TimeLocker is staged: - -1. Discover and record the actual NPBackup scheduling mechanism and protected - source list using its supported, masked interface. -2. Create a disabled TimeLocker schedule with matching sources and an - independently chosen TimeLocker credential source. -3. Generate and review the TimeLocker assets. -4. With explicit approval, install the system-level timer or root cron entry. -5. Observe successful scheduled TimeLocker backups and perform a restore test. -6. Only then make a separate cutover decision to disable NPBackup. - -Do not extract masked NPBackup secrets, install a privileged timer, or disable -NPBackup as part of schedule generation. - -TimeLocker backup schedules currently run `tl backup create` only. They do not -automatically run `tl repos forget` or `tl repos prune`. After a cutover, -continue the reviewed manual retention procedure until a separate maintenance -schedule has been designed, dry-run, and explicitly approved. Do not append -retention or prune to the backup service without failure isolation and rollback -handling; backup success must not imply approval for snapshot deletion. - -## Validation and troubleshooting +`timelocker logs view` without `--scope system` reads the invoking user's local +CLI log and does not contain protected scheduled-run records. -```bash -tl schedule list --json --config-dir ~/.config/timelocker -tl schedule show nightly-config --config-dir ~/.config/timelocker -bash -n ~/.local/share/timelocker/staged-schedules/nightly-config_cron.sh -systemd-analyze verify \ - ~/.local/share/timelocker/staged-schedules/timelocker-nightly-config.service \ - ~/.local/share/timelocker/staged-schedules/timelocker-nightly-config.timer -``` +## Cutover From Another Scheduler + +1. Reproduce the existing repository, sources, exclusions, tags, traversal + behavior, environment reference, and calendar in a disabled TimeLocker + schedule. +2. Dry-run and manually exercise the exact protected target. +3. Complete a backup and a restore acceptance test. +4. Approve the retention fingerprint and verify a dry run. +5. Install and enable TimeLocker timers. +6. Confirm `runs list` contains successful backup and retention records. +7. Disable the legacy scheduler only after TimeLocker acceptance succeeds. +8. Preserve the legacy scheduler configuration and crontab as rollback + evidence until the observation window is complete. -`schedule list`, `schedule show`, and `schedule test` expose or validate the -stored execution options without reading the referenced environment file. -Cron, systemd, and Windows assets are rendered from the same argument-safe -command builder, so spaces and shell metacharacters remain single arguments. +## Rollback -If the command reports a missing repository, selection, or source, recreate or -edit the schedule so the execution target is explicit. If access fails only in -the scheduler, compare its user, environment-file permissions, executable -path, and configuration directory with the successful manual run. +If scheduling or the selected release is unhealthy: + +1. disable the affected TimeLocker timer; +2. inspect structured runs and diagnostics; +3. select the previously validated immutable release with the administrator + release tool; +4. restore the preserved legacy scheduler configuration if service continuity + requires it; and +5. retain TimeLocker run and policy records for diagnosis. + +Rollback does not require deleting credentials or state. If automated retention +is disabled during rollback, resume the previously reviewed manual +`restic forget` procedure as the authorized restic account. Do not enable prune +unless it has been separately reviewed. ## References -- [Installation](../user/installation.md) -- [Per-Repository Credentials](../user/per-repo-credentials.md) - [Scheduling Architecture](../../2-architecture/scheduling-system.md) +- [Installation](../user/installation.md) +- [Backup Operations Troubleshooting](../user/backup-operations-troubleshooting.md) +- [Version Management](../../processes/version-management.md) diff --git a/docs/guides/gui-dependencies.md b/docs/guides/gui-dependencies.md index 48ba3b3..e7f2654 100644 --- a/docs/guides/gui-dependencies.md +++ b/docs/guides/gui-dependencies.md @@ -1,171 +1,8 @@ -# System Tray Integration - Installation Guide +# Tray Dependencies -TimeLocker's system tray integration is **optional**. The CLI works perfectly without it. +The current tray installation, dependency, authorization, lifecycle, and +troubleshooting guidance is maintained in +[Independent System Tray Setup](../SYSTEM-TRAY-SETUP.md). -> **Important**: TimeLocker does **not** have a full desktop GUI application. This guide covers the optional system tray integration for desktop notifications -> and status indicators. TimeLocker is primarily a CLI-based backup tool. - -## Do You Need System Tray Integration? - -**You DON'T need system tray dependencies if:** - -- You're using TimeLocker only via CLI commands -- You're running on a headless server -- You're using TimeLocker via SSH -- You don't want system tray notifications - -**You DO need system tray dependencies if:** - -- You want system tray integration with desktop notifications -- You want visual status indicators in your system tray -- You're running TimeLocker on a desktop environment - -## Installation - -### Linux (Ubuntu/Debian) - -First, install system-level dependencies: - -```bash -# Install GObject Introspection development libraries -sudo apt-get update -sudo apt-get install -y \ - libgirepository1.0-dev \ - libcairo2-dev \ - pkg-config \ - python3-dev \ - gir1.2-gtk-3.0 \ - gir1.2-appindicator3-0.1 -``` - -Then install TimeLocker with system tray support: - -```bash -pip install -e .[gui] -# or with dev dependencies -pip install -e .[dev,gui] -``` - -### Linux (Fedora/RHEL/CentOS) - -```bash -# Install system dependencies -sudo dnf install -y \ - gobject-introspection-devel \ - cairo-devel \ - pkg-config \ - python3-devel \ - gtk3 \ - libappindicator-gtk3 - -# Install TimeLocker with system tray support -pip install -e .[gui] -``` - -### Linux (Arch) - -```bash -# Install system dependencies -sudo pacman -S \ - gobject-introspection \ - cairo \ - pkg-config \ - python \ - gtk3 \ - libappindicator-gtk3 - -# Install TimeLocker with system tray support -pip install -e .[gui] -``` - -### macOS - -```bash -# No system dependencies needed - rumps is pure Python -pip install -e .[gui] -``` - -### Windows - -```bash -# No system dependencies needed - pystray uses native Windows APIs -pip install -e .[gui] -``` - -## Troubleshooting - -### PyGObject Build Errors on Linux - -If you get errors like: - -``` -ERROR: Dependency 'girepository-2.0' is required but not found. -``` - -This means you're missing system-level libraries. Install them as shown above for your distribution. - -### System Tray Not Working - -If the system tray doesn't appear after installing GUI dependencies: - -1. **Check your desktop environment**: System tray support varies by desktop environment - - GNOME: May need the "AppIndicator" extension - - KDE: Should work out of the box - - XFCE: Should work out of the box - - i3/Sway: May need additional configuration - -2. **Verify installation**: - ```bash - python -c "import gi; gi.require_version('AppIndicator3', '0.1'); from gi.repository import AppIndicator3; print('OK')" - ``` - -3. **Check logs**: - ```bash - tl monitor logs --level DEBUG | grep -i tray - ``` - -### Running Without System Tray - -If you don't want to install system tray dependencies, TimeLocker works perfectly without them: - -```bash -# Install without system tray support (CLI only) -pip install -e . -# or with dev dependencies -pip install -e .[dev] -``` - -The system tray initialization will fail silently (logged as a warning), but all CLI functionality works normally. - -## Platform-Specific Notes - -### Linux - -- Uses GTK3 and AppIndicator3 for system tray -- Requires GObject Introspection libraries -- Best support on GNOME, KDE, XFCE - -### macOS - -- Uses `rumps` (Ridiculously Uncomplicated macOS Python Statusbar apps) -- Pure Python, no system dependencies needed -- Works on macOS 10.10+ - -### Windows - -- Uses `pystray` for system tray -- Uses Pillow for icon rendering -- Works on Windows 7+ - -## Verifying Installation - -After installing system tray dependencies, verify they work: - -```bash -# Check if system tray is available -python -c "from TimeLocker.monitoring.system_tray_integration import SystemTrayIntegration; tray = SystemTrayIntegration(); print('Available' if tray.is_available() else 'Not available')" -``` - -## See Also - -- [CLI Modules](../3-implementation/cli-modules.md) +Normal TimeLocker CLI and headless operation do not initialize or require a tray +toolkit. diff --git a/docs/guides/user/backup-operations-troubleshooting.md b/docs/guides/user/backup-operations-troubleshooting.md index aa72001..9a5b566 100644 --- a/docs/guides/user/backup-operations-troubleshooting.md +++ b/docs/guides/user/backup-operations-troubleshooting.md @@ -1,721 +1,181 @@ -# Backup Operations Troubleshooting Guide - -**Status**: Active -**Last Updated**: 2025-11-09 -**Audience**: Users and administrators - -## Overview - -This guide helps you diagnose and resolve common issues with TimeLocker's backup operations. It covers error messages, performance problems, and configuration issues. - -## Quick Diagnostic Checklist +--- +title: "Backup Operations Troubleshooting Guide" +doc_type: guide +status: active +owner: Auriora Team +last_reviewed: 2026-07-26 +--- -Before diving into specific issues, run through this checklist: - -- [ ] Is the backup tool (Restic, Borg, etc.) installed and accessible? -- [ ] Is the repository accessible and properly initialized? -- [ ] Are credentials configured correctly? -- [ ] Is there sufficient disk space on source and destination? -- [ ] Are network connections stable (for remote repositories)? -- [ ] Are file permissions correct for source files? -- [ ] Is the backup policy configuration valid? -- [ ] Are data selection rules properly configured? +# Backup Operations Troubleshooting Guide -## Common Error Messages +## Start With The Correct Scope -### Backup Execution Errors +User-local CLI logs and protected system operation records are different data +sources: -#### Error: "Backup tool not found" +```bash +# Invoking user's CLI log +timelocker logs view -``` -ToolNotAvailableError: Backup tool not found at: /usr/bin/restic +# Protected system backup and retention records +timelocker runs list --limit 20 +timelocker logs view --scope system --lines 100 ``` -**Cause**: The backup tool executable is not installed or not at the expected location. +Scheduled system backups do not write into another user's local TimeLocker log. +An empty local log therefore does not mean the scheduled backup did not run. -**Solutions**: +## Access Denied -1. **Verify Installation**: - ```bash - which restic - # or - which borg - ``` +Only current members of the configured operator group can view system runs, +view structured system diagnostics, or trigger protected actions. -2. **Install Missing Tool**: - ```bash - # For Restic - sudo apt install restic - # or download from https://restic.net - - # For Borg - sudo apt install borgbackup - ``` - -3. **Configure Tool Path**: - ```python - # In configuration - tool_manager.set_tool_path("restic", "/custom/path/to/restic") - ``` - -#### Error: "Repository not accessible" - -``` -BackupExecutionError: Repository not accessible: s3:backup-bucket/repo -``` - -**Cause**: Repository cannot be reached or credentials are invalid. - -**Solutions**: - -1. **Check Repository Connectivity**: - ```bash - # For S3 repositories - aws s3 ls s3://backup-bucket/ - - # For local repositories - ls -la /path/to/repository - ``` - -2. **Verify Credentials**: - ```python - # Check credential configuration - credential_manager.list_credentials() - - # Test repository access - repository.validate_repository() - ``` - -3. **Check Network**: - ```bash - # Test network connectivity - ping backup-server.example.com - - # Check firewall rules - sudo iptables -L - ``` - -#### Error: "Insufficient disk space" - -``` -BackupExecutionError: Insufficient disk space on repository +```bash +id +getent group timelocker-operators ``` -**Cause**: Not enough space available on the backup destination. - -**Solutions**: - -1. **Check Available Space**: - ```bash - df -h /path/to/repository - ``` - -2. **Clean Up Old Snapshots**: - ```python - # Apply retention policy - repository.apply_retention_policy( - keep_daily=7, - keep_weekly=4, - keep_monthly=6 - ) - ``` +If an administrator has just added the user, start a new login session so the +desktop and shell obtain the new group membership. Do not solve access denial +by making the control socket world-readable or by copying protected credentials +into a user account. -3. **Prune Repository**: - ```bash - restic -r /path/to/repo prune - ``` - -### Validation Errors - -#### Error: "Invalid job configuration" - -``` -ValidationError: Invalid job configuration: missing required field 'repository_id' -``` +## Backend Unavailable -**Cause**: Backup job configuration is incomplete or invalid. - -**Solutions**: - -1. **Check Required Fields**: - ```python - config = BackupJobConfig( - job_id="backup-001", # Required - policy_id="daily-backup", # Required - repository_id="main-repo", # Required - data_selection_id="docs", # Required - tool_type="restic", # Required - execution_mode=ExecutionMode.ON_DEMAND, - retry_config=RetryConfig(), - notification_config=NotificationConfig() - ) - ``` - -2. **Validate Before Execution**: - ```python - validation_result = orchestrator.validate_job_configuration(config) - if not validation_result.is_valid: - for error in validation_result.errors: - print(f"Validation error: {error}") - ``` - -#### Error: "Data selection rules incompatible with tool" +Check the socket and service: +```bash +systemctl status timelocker-control.socket +systemctl status timelocker-control.service +ls -l /run/timelocker/control.sock ``` -ValidationError: Data selection rules incompatible with backup tool 'borg' -``` - -**Cause**: Some data selection rules cannot be translated to the target backup tool's format. - -**Solutions**: - -1. **Check Tool Capabilities**: - ```python - capabilities = tool_manager.get_tool_capabilities("borg") - print(f"Supported features: {capabilities.native_features}") - ``` - -2. **Simplify Selection Rules**: - ```python - # Use basic patterns supported by all tools - selection = FileSelection() - selection.add_path("/data", SelectionType.INCLUDE) - selection.add_pattern("*.tmp", SelectionType.EXCLUDE) - ``` - -3. **Use Compatible Tool**: - ```python - # Switch to tool with better selection support - config.tool_type = "restic" # Better pattern support - ``` -### Retry and Recovery Errors +The socket should be owned by root and the operator group with group read/write +access. The public CLI returns a bounded backend-unavailable error; it does not +fall back to a checkout, pyenv shim, root home, or legacy configuration. -#### Error: "Maximum retry attempts exceeded" +## Scheduled Backup Did Not Run -``` -BackupExecutionError: Maximum retry attempts exceeded (3 attempts) -Last error: Connection timeout -``` - -**Cause**: Backup failed repeatedly, exhausting retry attempts. - -**Solutions**: - -1. **Check Error Type**: - ```python - # Review error log for root cause - for error in result.errors: - print(f"Attempt {error.attempt}: {error.message}") - ``` - -2. **Increase Retry Limit**: - ```python - config.retry_config = RetryConfig( - max_retries=5, - base_delay=2, - max_delay=60 - ) - ``` - -3. **Fix Underlying Issue**: - - Network connectivity problems - - Repository access issues - - Resource constraints - -4. **Manual Retry**: - ```python - # Retry with manual execution mode - config.execution_mode = ExecutionMode.MANUAL_RETRY - result = orchestrator.execute_backup_job(config) - ``` - -## Performance Issues - -### Slow Backup Speed - -**Symptoms**: Backup takes much longer than expected. - -**Diagnostic Steps**: - -1. **Check Progress Metrics**: - ```python - status = orchestrator.get_execution_status(job_id) - print(f"Throughput: {status.throughput / 1024 / 1024:.2f} MB/s") - print(f"Files processed: {status.files_processed}") - ``` - -2. **Review Performance Metrics**: - ```python - metrics = result.performance_metrics - print(f"Average throughput: {metrics.avg_throughput_mbps:.2f} MB/s") - print(f"CPU utilization: {metrics.avg_cpu_percent:.1f}%") - print(f"Peak memory: {metrics.peak_memory_mb:.2f} MB") - ``` - -**Solutions**: - -1. **Enable Parallel Processing**: - ```python - # Check if tool supports parallelization - if Feature.PARALLEL_PROCESSING in capabilities.native_features: - config.parallel_operations = 4 # Adjust based on system - ``` - -2. **Adjust Compression Level**: - ```python - # Lower compression for faster backups - config.compression_level = 3 # Instead of 9 - ``` - -3. **Optimize Network Settings**: - ```python - # For remote repositories - config.additional_options = { - "connections": 5, - "pack-size": 16 # MB - } - ``` - -4. **Exclude Unnecessary Files**: - ```python - # Add more exclude patterns - selection.add_pattern("*.cache", SelectionType.EXCLUDE) - selection.add_pattern("node_modules/*", SelectionType.EXCLUDE) - selection.add_pattern(".git/*", SelectionType.EXCLUDE) - ``` - -### High Memory Usage - -**Symptoms**: Backup process consumes excessive memory. - -**Diagnostic Steps**: - -1. **Monitor Memory Usage**: - ```python - metrics = result.performance_metrics - print(f"Peak memory: {metrics.peak_memory_mb:.2f} MB") - print(f"Average memory: {metrics.avg_memory_mb:.2f} MB") - ``` - -**Solutions**: - -1. **Limit Parallel Operations**: - ```python - config.parallel_operations = 1 # Reduce parallelism - ``` - -2. **Adjust Tool Settings**: - ```python - config.additional_options = { - "pack-size": 4, # Smaller pack size - "cache-size": 256 # Limit cache size (MB) - } - ``` - -3. **Process in Batches**: - ```python - # Split large backup into smaller jobs - for batch in source_batches: - batch_config = create_batch_config(batch) - result = orchestrator.execute_backup_job(batch_config) - ``` - -### CPU Bottleneck - -**Symptoms**: High CPU usage, slow backup progress. - -**Solutions**: - -1. **Reduce Compression**: - ```python - config.compression_level = 1 # Minimal compression - ``` - -2. **Limit Parallelism**: - ```python - config.parallel_operations = 2 # Reduce from higher value - ``` - -3. **Schedule During Off-Peak**: - ```python - # Run backups when system is less busy - config.execution_mode = ExecutionMode.SCHEDULED - ``` - -## Configuration Issues - -### Policy Configuration Problems - -#### Issue: Policy not found - -**Error**: -``` -PolicyNotFoundError: Policy 'daily-backup' not found +```bash +systemctl status timelocker-npbackup-migration.timer +systemctl status timelocker-npbackup-migration.service +systemctl list-timers 'timelocker-*' +timelocker runs list --operation backup --limit 20 ``` -**Solutions**: - -1. **List Available Policies**: - ```python - policies = policy_service.list_policies() - for policy in policies: - print(f"Policy: {policy.id} - {policy.name}") - ``` - -2. **Create Missing Policy**: - ```python - policy = Policy( - id="daily-backup", - name="Daily Backup", - schedule="0 2 * * *", - retention=RetentionPolicy(keep_daily=7) - ) - policy_service.create_policy(policy) - ``` - -### Data Selection Issues - -#### Issue: No files selected for backup - -**Symptoms**: Backup completes but no files are processed. - -**Diagnostic Steps**: - -1. **Preview Selection**: - ```python - preview = selection_service.preview_selection(selection_id) - print(f"Files to backup: {len(preview.files)}") - for file in preview.files[:10]: - print(f" {file}") - ``` - -2. **Check Selection Rules**: - ```python - selection = selection_service.get_selection(selection_id) - print(f"Include paths: {selection.includes}") - print(f"Exclude patterns: {selection.exclude_patterns}") - ``` - -**Solutions**: - -1. **Verify Include Paths**: - ```python - # Ensure paths exist and are accessible - for path in selection.includes: - if not path.exists(): - print(f"Warning: Path does not exist: {path}") - ``` - -2. **Review Exclude Patterns**: - ```python - # Check if patterns are too broad - selection.exclude_patterns = [ - "*.tmp", # Specific extensions - "*.log", - # Remove overly broad patterns like "*" - ] - ``` - -3. **Test Selection**: - ```python - from TimeLocker.cli_services import CLIServiceManager - - cli = CLIServiceManager() - cli.run_selection_backup( - selection_name="documents", - repository="local-repo", - dry_run=True, - cli_options={"tool_type": "restic"} - ) - ``` - -## Tool-Specific Issues - -### Restic Issues - -#### Issue: Repository locked - -**Error**: -``` -unable to create lock in backend: repository is already locked -``` +Distinguish: -**Solutions**: +- no run record: the timer or pre-start hook did not reach TimeLocker; +- `queued` or `running`: execution is active or awaiting reconciliation; +- `skipped`: the shared repository lock or another protected conflict blocked + the run; +- `failed`: inspect the matching structured diagnostics; +- `interrupted`: a prior process ended without a terminal finish hook; the + coordinator reconciled it on the next start. -1. **Check for Running Processes**: - ```bash - ps aux | grep restic - ``` +## Retention Did Not Run -2. **Remove Stale Lock** (if no process is running): - ```bash - restic -r /path/to/repo unlock - ``` +Retention has three valid triggers: -3. **Wait for Lock Release**: - ```python - # Implement lock wait in configuration - config.additional_options = { - "lock-wait": 300 # Wait up to 5 minutes - } - ``` +- after a successful backup; +- the independent retention timer; or +- an authorized explicit request. -#### Issue: Pack file corruption +Inspect records and the timer: -**Error**: -``` -pack file is corrupted: checksum mismatch +```bash +timelocker runs list --operation retention --limit 20 +systemctl status timelocker-retention.timer ``` -**Solutions**: - -1. **Run Repository Check**: - ```bash - restic -r /path/to/repo check - ``` - -2. **Rebuild Index**: - ```bash - restic -r /path/to/repo rebuild-index - ``` - -3. **Recover from Corruption**: - ```bash - restic -r /path/to/repo check --read-data - restic -r /path/to/repo prune - ``` - -### Borg Issues - -#### Issue: Repository upgrade needed - -**Error**: -``` -repository version is too old, please upgrade -``` +If backup succeeded but no backup-success retention record exists, verify that +the backup unit has the installed TimeLocker finish hook. If independent +retention did not run, verify the timer is enabled and the root-owned enable +marker exists. If the result is skipped, check for overlapping backup or +retention activity. -**Solutions**: +Production retention also fails closed when the policy fingerprint no longer +matches the protected target. Reapprove the exact changed policy; do not edit +the fingerprint to silence the check. -1. **Upgrade Repository**: - ```bash - borg upgrade /path/to/repo - ``` +## Repository Password Or Credentials Requested -2. **Check Borg Version**: - ```bash - borg --version - ``` +Protected scheduled operations obtain repository and backend credentials from +root-readable configured references. Operators should not read or copy their +contents. If a protected run prompts for a password, the service is not using +the expected credential reference or permissions. -#### Issue: Checkpoint handling +Inspect metadata only: -**Error**: -``` -checkpoint detected, resuming backup +```bash +sudo stat /etc/timelocker/production-target.json +sudo systemctl cat timelocker-control.service ``` -**Note**: This is informational, not an error. Borg is resuming an interrupted backup. - -**Actions**: -- Allow backup to continue -- Monitor progress -- Ensure stable connection for completion +Do not paste secrets into command history, logs, issue reports, or ordinary +TimeLocker configuration. -## Monitoring and Debugging +## Repository Lock Conflict -### Enable Debug Logging +Backup and retention serialize mutations through the same lock. A concurrent +request is recorded as skipped. Wait for the active run to finish and retry: -```python -import logging - -# Enable debug logging for backup operations -logging.basicConfig(level=logging.DEBUG) -logger = logging.getLogger('TimeLocker.services.backup_orchestrator') -logger.setLevel(logging.DEBUG) +```bash +timelocker runs list --state running ``` -### Capture Detailed Metrics +Do not run `restic unlock` or delete TimeLocker lock files until you have proved +that no backup or retention process is running and have an administrator's +approval. -```python -# Enable detailed performance monitoring -config.additional_options = { - "verbose": True, - "stats": True, - "progress": True -} +## Tray Problems -result = orchestrator.execute_backup_job(config) - -# Review detailed metrics -print(f"Duration: {result.duration}") -print(f"Files: {result.files_processed}") -print(f"Bytes: {result.bytes_transferred}") -print(f"Errors: {len(result.errors)}") -print(f"Warnings: {len(result.warnings)}") +```bash +timelocker-tray status --once ``` -### Progress Monitoring - -```python -import time -from threading import Thread - -def monitor_backup(job_id): - """Monitor backup progress in real-time.""" - while True: - try: - status = orchestrator.get_execution_status(job_id) - - print(f"\rProgress: {status.progress_percentage:.1f}% | " - f"Files: {status.files_processed} | " - f"Speed: {status.throughput / 1024 / 1024:.2f} MB/s", - end='', flush=True) - - if status.status in [BackupStatus.COMPLETED, BackupStatus.FAILED]: - print() # New line - break - - time.sleep(5) - - except JobNotFoundError: - break - -# Start monitoring -monitor_thread = Thread(target=monitor_backup, args=(job_id,)) -monitor_thread.daemon = True -monitor_thread.start() - -# Execute backup -result = orchestrator.execute_backup_job(config) - -# Wait for monitoring to complete -monitor_thread.join() -``` +- `Access denied`: refresh login group membership. +- `System backend unavailable`: inspect the control socket/service. +- No icon: confirm the desktop has AppIndicator support and the autostart entry + exists. +- Stale status: restart only the user tray process; this does not stop backend + operations. -## Best Practices - -### 1. Test Before Production - -Always test backup configurations in a non-production environment: - -```python -# Create test configuration -test_config = BackupJobConfig( - job_id="test-backup", - policy_id="test-policy", - repository_id="test-repo", - data_selection_id="test-selection", - tool_type="restic", - execution_mode=ExecutionMode.ON_DEMAND, - retry_config=RetryConfig(max_retries=1), - notification_config=NotificationConfig() -) - -# Validate configuration -validation = orchestrator.validate_job_configuration(test_config) -if validation.is_valid: - # Run test backup - result = orchestrator.execute_backup_job(test_config) - print(f"Test backup: {result.status}") -``` +The CLI must not emit tray warnings. If `timelocker --help`, `logs`, or another +normal CLI command initializes a tray toolkit, report it as a regression. -### 2. Monitor Backup Health +## Safe Evidence Collection -Regularly check backup health: +Collect: -```python -# Check recent backups -recent_backups = repository.list_snapshots(limit=10) -for snapshot in recent_backups: - print(f"Snapshot: {snapshot.id}") - print(f" Date: {snapshot.time}") - print(f" Files: {snapshot.files}") - print(f" Size: {snapshot.size}") +```bash +timelocker version --short +timelocker runs list --json --limit 20 +timelocker logs view --scope system --lines 100 +systemctl list-timers 'timelocker-*' ``` -### 3. Implement Notifications - -Configure notifications for backup events: +Share only structured safe summaries. Do not attach raw environment files, +repository configuration, passwords, cloud keys, raw journald output, or +protected path contents. -```python -notification_config = NotificationConfig( - on_success=True, - on_failure=True, - on_warning=True, - min_duration_for_notification=300, # 5 minutes - email_recipients=["admin@example.com"], - slack_webhook="https://hooks.slack.com/..." -) -``` - -### 4. Regular Maintenance +## Rollback -Perform regular repository maintenance: +For a faulty selected release or schedule: -```bash -# Weekly: Check repository integrity -restic -r /path/to/repo check +1. disable the affected timer; +2. retain run records and diagnostics; +3. use the root-only release selector to return to the previous validated + release; +4. restore the preserved legacy scheduler if required; and +5. resume the reviewed manual retention procedure if automated retention is + disabled. -# Monthly: Prune old data -restic -r /path/to/repo forget --keep-daily 7 --keep-weekly 4 --prune +Rollback should preserve `/var/lib/timelocker` run and policy state. -# Quarterly: Full repository check -restic -r /path/to/repo check --read-data -``` +## References -## Getting Help - -### Collect Diagnostic Information - -When reporting issues, collect: - -1. **Error Messages**: - ```python - for error in result.errors: - print(f"Error: {error.message}") - print(f"Type: {error.error_type}") - print(f"Context: {error.context}") - ``` - -2. **Configuration**: - ```python - print(f"Tool: {config.tool_type}") - print(f"Repository: {config.repository_id}") - print(f"Policy: {config.policy_id}") - ``` - -3. **System Information**: - ```bash - # OS and version - uname -a - - # Tool versions - restic version - borg --version - - # Available resources - df -h - free -h - ``` - -4. **Logs**: - ```bash - # TimeLocker logs - tail -n 100 /var/log/timelocker/backup.log - - # System logs - journalctl -u timelocker -n 100 - ``` - -### Support Resources - -- **Documentation**: [TimeLocker Documentation](../../README.md) -- **API Reference**: [Backup Operations API](../../reference/backup-operations-api.md) -- **Issue Tracker**: [GitHub Issues](https://github.com/timelocker/timelocker/issues) -- **Community Forum**: [TimeLocker Forum](https://forum.timelocker.org) - -## See Also - -- [Backup Operations API Reference](../../reference/backup-operations-api.md) -- [Plugin Wrapper Development Guide](../developer/plugin-wrapper-development.md) -- [Repository Management Guide](repository-management-guide.md) +- [Installation](./installation.md) +- [Scheduling Guide](../developer/scheduling-guide.md) +- [Independent System Tray Setup](../../SYSTEM-TRAY-SETUP.md) +- [System Operations Requirements](../../1-requirements/system-operations.md) diff --git a/docs/guides/user/installation.md b/docs/guides/user/installation.md index df307a3..6e5b336 100644 --- a/docs/guides/user/installation.md +++ b/docs/guides/user/installation.md @@ -4,7 +4,7 @@ id: "user-guide-installation" type: [ guide ] status: [ approved ] owner: "Documentation Team" -last_reviewed: "19-07-2026" +last_reviewed: "2026-07-26" tags: [guide, user, installation] links: tooling: [] @@ -15,12 +15,13 @@ links: - **Owner**: Documentation Team - **Status**: Approved - **Created Date**: 19-12-2024 -- **Last Updated**: 19-07-2026 +- **Last Updated**: 2026-07-26 - **Audience**: End Users, Administrators ## 1. Purpose -Provide comprehensive steps for installing TimeLocker, its dependencies, and verifying the setup across Linux, macOS, and Windows. +Provide steps for a user/source installation across supported Python platforms +and explain the separately administered protected Linux deployment. ## 2. Goal @@ -60,8 +61,8 @@ sudo apt install python3.12 python3-pip git # Ubuntu/Debian; Python 3.13 is als # sudo pacman -S python python-pip git # Arch ``` -The system tray is optional and does not affect backup, restore, or other CLI -commands. On Linux Mint/Ubuntu, install GTK 3, PyGObject, and the Ayatana +The independent system tray is optional and does not affect backup, restore, or +other CLI commands. On Linux Mint/Ubuntu, install GTK 3, PyGObject, and the Ayatana AppIndicator typelib when you want tray support: ```bash @@ -137,7 +138,49 @@ normal suite passes and enforces coverage of at least 50%. Live MinIO tests are owned by the separately provisioned profile documented in [`docs/4-testing/README.md`](../../4-testing/README.md). -### 4.7 Validated Platform Matrix +### 4.7 Protected Linux Deployment + +Host-level backup and retention use an administrator-installed deployment that +is distinct from a user/source installation. It provides: + +```text +/usr/local/bin/timelocker +/usr/local/bin/tl +/usr/local/bin/timelocker-tray +/usr/local/libexec/timelocker-system-control +/opt/timelocker/releases/RELEASE_ID/ +/opt/timelocker/selected-release.json +/etc/timelocker/ +/var/lib/timelocker/ +``` + +The stable launchers ignore the caller's pyenv, checkout, home directory, and +current working directory. A staged release is selected only after its CLI, +backend, and tray entrypoints pass compatibility probes. System control is +exposed through `/run/timelocker/control.sock`. + +The repository contains validated deployment primitives and packaged assets; +it does not currently expose a general end-user installer command. An +administrator must stage the release, install the root-owned assets, configure +the protected target and credentials by reference, approve retention, and +enable the required systemd units. + +Verify an installed host without reading secrets: + +```bash +/usr/local/bin/timelocker version --short +/usr/local/bin/timelocker runs list --limit 5 +/usr/local/bin/timelocker logs view --scope system --lines 20 +systemctl status timelocker-control.socket +systemctl status timelocker-retention.timer +/usr/local/bin/timelocker-tray status --once +``` + +Protected reads require current membership in `timelocker-operators`. +Installation, group changes, policy approval, service changes, release +selection, and rollback require root. + +### 4.8 Validated Platform Matrix The `0.9.1` wheel and source distribution are clean-install tested on every combination below. Each test runs `version --short` and root help through both @@ -149,19 +192,24 @@ the `timelocker` and `tl` entry points. | macOS | wheel and sdist | wheel and sdist | | Windows | wheel and sdist | wheel and sdist | -This validates installation and safe CLI startup. Backup and restore operations +This validates wheel/source installation and safe CLI startup. Backup and restore operations still require a compatible Restic executable and any backend-specific credentials. No PyPI distribution is currently published; use the source path above until an authorized release provides downloadable artifacts. -### 4.8 Understand Modern Packaging Features +The protected immutable-release, local-backend, systemd scheduling, and +independent-tray deployment has live acceptance evidence on Linux Mint. The +portable architecture includes a Windows adapter, but a protected Windows +deployment is not yet claimed as live-accepted. + +### 4.9 Understand Modern Packaging Features - `pyproject.toml` for modern builds (PEP 517/518). - Optional dependency groups (`dev`, `gui`). S3 and B2 runtime dependencies are included in the base installation. - Entry points install both `timelocker` and `tl` commands. -### 4.9 Configure Environment +### 4.10 Configure Environment Basic configuration focuses on setting up repositories and targets. For cloud backends, export credentials: @@ -176,10 +224,6 @@ export B2_ACCOUNT_ID=your_account_id export B2_ACCOUNT_KEY=your_account_key ``` -### 4.10 Optional: Manual Vacuum / Additional Sections - -(If applicable, include other configuration tasks; original document contains extended instructions you may retain here.) - ## 5. Troubleshooting - **CLI command not found**: Ensure the Python scripts directory is on `PATH` or reinstall with pip. @@ -187,6 +231,11 @@ export B2_ACCOUNT_KEY=your_account_key contributor dependencies with `python -m pip install -e '.[dev]'`. - **`pip install timelocker` fails**: No PyPI distribution is currently supported; install from a source checkout as shown above. +- **Protected CLI uses the wrong checkout or pyenv**: use + `/usr/local/bin/timelocker`. A protected deployment must fail closed rather + than fall back to user state. +- **System runs are not visible**: confirm the backend socket is active and the + user has current `timelocker-operators` membership. ## 6. Frequently Asked Questions (FAQ) @@ -198,3 +247,6 @@ export B2_ACCOUNT_KEY=your_account_key - Restic installation docs: - Python downloads: - TimeLocker repository: +- [Independent tray setup](../../SYSTEM-TRAY-SETUP.md) +- [Scheduling guide](../developer/scheduling-guide.md) +- [Backup operations troubleshooting](./backup-operations-troubleshooting.md) diff --git a/docs/processes/version-management.md b/docs/processes/version-management.md index 5f2152c..b26553c 100644 --- a/docs/processes/version-management.md +++ b/docs/processes/version-management.md @@ -3,7 +3,7 @@ title: Version management and GitHub releases doc_type: process status: active owner: Auriora Team -last_reviewed: 2026-07-19 +last_reviewed: 2026-07-26 --- # Version Management And GitHub Releases @@ -129,6 +129,25 @@ preparation changes or revert the reviewed preparation commit. After publication, prefer a new corrective patch release so consumers retain an immutable history. +## Protected Host Activation And Rollback + +Publishing a GitHub release and selecting a protected host release are separate +boundaries. A protected host stages an immutable release under +`/opt/timelocker/releases/RELEASE_ID/` with a manifest that binds its release +identity, package version, protocol version, and entrypoint. + +Before activation, the deployment probes the staged CLI, backend, and tray +entrypoints. Only then may the root-only selector atomically update +`/opt/timelocker/selected-release.json`, preserving the prior release identifier +for rollback. Stable launchers resolve that selector and fail closed on missing, +untrusted, incompatible, recursively invoked, or non-allowlisted state. + +Rollback probes the previous release before swapping selected and previous +identifiers. It does not delete protected configuration, credential references, +retention policy, or durable run records. Service/timer rollback is coordinated +separately so operators retain evidence and can restore the previous scheduler +when required. + ## Current Deferrals Version `0.9.1` remains a Beta GitHub release candidate until separately diff --git a/docs/reference/timelocker-cli-command-hierarchy.md b/docs/reference/timelocker-cli-command-hierarchy.md index be96f7c..b927a64 100644 --- a/docs/reference/timelocker-cli-command-hierarchy.md +++ b/docs/reference/timelocker-cli-command-hierarchy.md @@ -4,7 +4,7 @@ id: "ref-cli-hierarchy" type: [ reference ] status: [ approved ] owner: "CLI Team" -last_reviewed: "01-11-2025" +last_reviewed: "2026-07-26" tags: [reference, cli, command-structure] links: tooling: [] @@ -12,143 +12,105 @@ links: # Reference: TimeLocker CLI Command Hierarchy -- **Owner**: CLI Team -- **Status**: Approved -- **Created Date**: 15-12-2024 -- **Last Updated**: 01-11-2025 -- **Audience**: Developers, Technical Writers, Support Engineers +## Entry Points + +`timelocker` and `tl` are equivalent root commands. A user/source installation +invokes the packaged Typer CLI. A protected system deployment installs stable +root-owned launchers at `/usr/local/bin/timelocker` and `/usr/local/bin/tl`; +both resolve the same selected immutable release. + +## Root Command Groups + +```text +timelocker (alias: tl) +├── version +├── help +├── completion +├── backup +├── snapshots +├── repos +├── config +├── credentials +├── security +├── migrate +├── policy +├── selections +├── schedule +├── monitor +├── logs +├── reports +├── runs +└── restore +``` -## 1. Purpose +Run `timelocker GROUP --help` for the current leaf commands and options. -Document the authoritative command hierarchy for the `timelocker` (`tl`) CLI, including namespace organization, aliases, and migration notes for legacy -commands. Use this reference to maintain CLI documentation, implement shell completions, and verify command routing. +## Protected System Reads -## 2. Specification +```text +timelocker runs list + [--limit N] + [--operation backup|retention] + [--state STATE] + [--json] -### 2.1 Design Philosophy +timelocker runs show RUN_ID [--json] -- Repository operations consolidated under `repos` (configuration + actions). -- Data selection operations unified under `selections` (replaces deprecated `targets`). -- Snapshot lifecycle management under `snapshots` (list, forget, prune). -- Recovery operations under `restore` (browse, restore, verify) - separate from snapshot management. -- Configuration, credentials, and version info exposed via dedicated namespaces. +timelocker logs view + [--scope local|system] + [--lines N] + [--level LEVEL] + [--component COMPONENT] + [--since TIME] +``` -### 2.2 Root Command Summary +`runs` and `logs view --scope system` use the authenticated local backend and +require current operator-group membership. System diagnostics are structured +safe records, not raw journald. `--follow` is available for local logs but is +rejected for system diagnostics. -- **Root**: `timelocker` (alias `tl`) -- **Description**: TimeLocker – backup orchestration with Rich terminal output -- **Framework**: Typer + Rich +Without `--scope system`, `logs view` reads only the invoking user's local CLI +log. Scheduled system backups and retention runs are intentionally absent from +that file. -### 2.3 Command Tree +## Independent Tray Command +`timelocker-tray` is a separate executable, not a CLI command group: + +```text +timelocker-tray + status + serve + backup_now + retention_now + open_ui + quit ``` -timelocker/ (alias: tl) -├── backup/ -│ ├── create [paths...] # Create backup (default action) -│ └── verify [--snapshot] # Verify backup integrity (defaults to latest) -├── snapshots/ -│ ├── list|ls # List snapshots from configured repos -│ ├── show # Show snapshot details -│ ├── forget # Remove snapshot -│ ├── prune # Apply retention policies -│ ├── diff # Compare snapshots -│ └── find # Search across repositories -├── restore/ -│ ├── list # List available snapshots for restoration -│ ├── browse # Explore snapshot contents -│ ├── files # Restore specific files -│ ├── full # Restore complete snapshot -│ ├── mount # Mount snapshot as filesystem -│ ├── umount # Unmount snapshot -│ ├── find # Search files for recovery -│ ├── diff # Compare snapshots for recovery -│ └── verify # Verify restored data integrity -├── repos/ -│ ├── list|ls # List repositories -│ ├── add # Add repository configuration -│ ├── remove|rm # Remove repository configuration -│ ├── show # Show repository details -│ ├── default # Set default repository -│ ├── init # Initialize repository -│ ├── check # Check repository integrity -│ ├── stats # Repository statistics -│ ├── unlock # Clear repository locks -│ ├── migrate # Migrate repository format -│ ├── forget # Apply retention policy -│ ├── check-all # Check all repositories -│ └── stats-all # Stats across repositories -├── selections/ -│ ├── list|ls # List data selection templates -│ ├── create # Create selection template -│ ├── show # Show selection details -│ ├── edit # Edit selection template -│ ├── delete # Delete selection template -│ ├── test [path] # Test selection against path -│ ├── export # Export selection template -│ └── import # Import selection template -├── config/ -│ ├── show # Configuration info and validation -│ ├── setup # Interactive setup wizard -│ └── import/ -│ └── restic # Import restic environment -├── credentials/ -│ ├── unlock # Unlock credential manager -│ ├── set # Store repository password -│ └── remove # Remove repository password -└── version # Show CLI version information -``` -### 2.4 Command Aliases - -- Global alias: `tl` → `timelocker` -- Namespace aliases: `repos` ↔ `repositories`, `ls` ↔ `list`, `rm` ↔ `remove`. - -### 2.5 Migration Guide - -| Legacy Command | Current Command | -|-----------------------------------------|-------------------------------------| -| `tl repo myrepo init` | `tl repos init myrepo` | -| `tl repo myrepo check` | `tl repos check myrepo` | -| `tl config repositories add` | `tl repos add` | -| `tl snapshot abc123 show` | `tl snapshots show abc123` | -| `tl snapshot abc123 forget` | `tl snapshots forget abc123` | -| `tl snapshot abc123 restore /path` | `tl restore files myrepo abc123 /path` | -| `tl snapshot abc123 mount /mnt` | `tl restore mount myrepo abc123 /mnt` | -| `tl snapshots find "*.pdf"` | `tl snapshots find "*.pdf"` OR `tl restore find myrepo "*.pdf"` | - -### 2.6 Examples - -- Repository list: `tl repos list` -- Initialize repository: `tl repos init myrepo` -- Create selection: `tl selections create documents --include '~/Documents/**' --exclude '*/temp/*'` -- Backup create: `tl backup create --selection documents --repository myrepo` -- List snapshots: `tl snapshots list` (all repos) or `tl restore list myrepo` (specific repo) -- Browse snapshot: `tl restore browse myrepo abc123` -- Restore files: `tl restore files myrepo abc123 /path/to/file1 /path/to/file2 --target ~/restored` -- Restore full: `tl restore full myrepo abc123 ~/restored` -- Verify restore: `tl restore verify ~/restored --repository myrepo --snapshot abc123` -- Snapshot search: `tl snapshots find "*.pdf"` (management) or `tl restore find myrepo "*.pdf"` (recovery) -- Credential storage: `tl credentials set myrepo` - -## 3. Usage Notes - -- **Snapshot Management** (`snapshots`): Use for lifecycle operations (list, forget, prune, search). -- **Recovery Operations** (`restore`): Use for data restoration (browse, restore, verify, mount). -- All `restore` commands require explicit `` parameter for clarity in multi-repository environments. -- Snapshot commands in `snapshots` namespace default to **all** repositories; use `restore list ` for repository-specific listing. -- Retention (`prune`, `forget`) respects repository-level retention policies; use `tl repos forget` for repo-specific policies. -- Shell completion generators consume this hierarchy; update completion scripts when modifying command namespaces. -- The `targets` command has been deprecated and replaced by `selections` for more flexible data selection patterns. -- When migrating documentation or support scripts, map legacy `targets` commands to `selections` commands. - -## 4. Change Log - -- 11-11-2025: **Major restructure** - Separated `restore` namespace from `snapshots` for proper separation of concerns. Removed `restore`, `mount`, `umount`, `contents`, `find-in` from `snapshots`. Added complete `restore` namespace with 9 commands per CLI Interface Requirements. Updated to align with Recovery Operations architecture. -- 11-11-2025: Removed deprecated `targets` command; replaced with `selections` for data selection management. -- 01-11-2025: Applied reference template; reorganized sections and clarified aliases. -- 15-12-2024: Documented merged `repos`/`targets` namespaces and default behaviors. - -# References - -- TimeLocker user documentation (`docs/guides/user/repository-management-guide.md`) -- Shell completion reference (`docs/guides/user/auto-completion-guide.md`) +`open_ui` is currently a reserved no-op. `retention_now` requires the exact +approved policy fingerprint. The tray communicates with the protected backend +and does not own backup execution. + +## Administrator Release Tool + +`timelocker-release-select` is a root-only deployment tool. It is deliberately +not part of the public CLI hierarchy and is installed with restricted +permissions. Administrators use it to select or roll back compatible immutable +releases; ordinary operators do not gain release-management authority through +group membership. + +## Routing Rules + +- User-local commands operate in the invoking user's configuration boundary. +- Protected reads and allowlisted actions cross the local backend. +- Installation, upgrade, rollback, operator membership, policy approval, and + service changes are administrator maintenance. +- Unknown public actions and unsupported system log scopes fail closed. +- The CLI does not initialize the tray or prompt through a GUI. + +## References + +- [System Operations Requirements](../1-requirements/system-operations.md) +- [System Architecture](../2-architecture/system-architecture.md) +- [Installation](../guides/user/installation.md) +- [Independent System Tray Setup](../SYSTEM-TRAY-SETUP.md) diff --git a/docs/specs/009-system-cli-tray-retention/canonical-context.md b/docs/specs/009-system-cli-tray-retention/canonical-context.md index 66e3855..4307bf5 100644 --- a/docs/specs/009-system-cli-tray-retention/canonical-context.md +++ b/docs/specs/009-system-cli-tray-retention/canonical-context.md @@ -52,14 +52,14 @@ evidence is a reconciliation input and must not be silently overridden. | Source path | Reviewed | Status | Canonical scope | Promotion target | |-------------|----------|--------|-----------------|------------------| | `CHARTER.md` | 2026-07-26 | summarized | Mandate and non-goal boundaries only | `CHARTER.md` remains authoritative | -| `docs/guides/developer/scheduling-guide.md` | 2026-07-26 | background | Current installed scheduling and manual-retention behavior | Update after T010 acceptance | -| `docs/SYSTEM-TRAY-SETUP.md` | 2026-07-26 | background | Current in-process tray behavior and setup | Supersede after T007/T010 acceptance | -| `docs/2-architecture/system-architecture.md` | 2026-07-26 | background | Current service and CLI architecture | Update after accepted implementation | -| `docs/2-architecture/scheduling-system.md` | 2026-07-26 | background | Current schedule adapter architecture | Update after retention implementation | - -No durable document is copied into this package. The listed current-state -documents remain authoritative for users and operators until T011 promotes -verified behavior. +| `docs/guides/developer/scheduling-guide.md` | 2026-07-26 | promoted | Current user/system schedules, retention, cutover, and rollback | Durable authority after T011 | +| `docs/SYSTEM-TRAY-SETUP.md` | 2026-07-26 | promoted | Independent tray behavior and setup | Durable authority after T011 | +| `docs/2-architecture/system-architecture.md` | 2026-07-26 | promoted | Current CLI, launcher, backend, tray, run-store, and repository boundaries | Durable authority after T011 | +| `docs/2-architecture/scheduling-system.md` | 2026-07-26 | promoted | Current user scheduling and protected backup/retention architecture | Durable authority after T011 | + +No durable document is copied into this package. T011 promoted the accepted +behavior into the listed current-state documents, which are now authoritative +for users and operators. ## Non-Canonical Background Sources diff --git a/docs/specs/009-system-cli-tray-retention/change-impact.md b/docs/specs/009-system-cli-tray-retention/change-impact.md index 1a00b13..efdd479 100644 --- a/docs/specs/009-system-cli-tray-retention/change-impact.md +++ b/docs/specs/009-system-cli-tray-retention/change-impact.md @@ -20,12 +20,12 @@ run visibility, group authorization, and automatic retention. | Source | Current behavior relied on | Confidence | Notes | |--------|----------------------------|------------|-------| | `CHARTER.md` | CLI-first backup orchestration, safety, stable automation, observable operation | high | Governing mandate | -| `docs/2-architecture/system-architecture.md` | CLI and service ownership; tray is currently optional integration code | high | Must be updated after implementation | -| `docs/2-architecture/scheduling-system.md` | Platform schedule adapters and scheduled backup model | high | Does not yet describe retention or shared run state | +| `docs/2-architecture/system-architecture.md` | CLI, immutable launcher, protected backend, tray, run-store, and repository boundaries | high | Promoted by T011 | +| `docs/2-architecture/scheduling-system.md` | User schedules plus protected backup/retention triggers, locking, and run state | high | Promoted by T011 | | `docs/3-implementation/service-layer-integration.md` | Focused services should own new behavior instead of expanding the compatibility facade | high | Guides client/service placement | -| `docs/guides/developer/scheduling-guide.md` | Current system scheduling and manual-retention boundary | high | Promotion target for rollout and rollback | -| `docs/guides/user/installation.md` | Package entry points provide `timelocker` and `tl` | high | Does not yet define the machine launcher | -| `docs/SYSTEM-TRAY-SETUP.md` | Tray is an optional in-process integration | high | Must be superseded | +| `docs/guides/developer/scheduling-guide.md` | User/system scheduling, retention safety, cutover, and rollback | high | Promoted by T011 | +| `docs/guides/user/installation.md` | User/source install and protected Linux deployment boundaries | high | Promoted by T011 | +| `docs/SYSTEM-TRAY-SETUP.md` | Independent unprivileged tray installation and behavior | high | Superseded by T011 | | `src/TimeLocker/cli_modules/commands/monitoring.py` | `logs view` reads the caller's cache log and ignores system run history | high | Bug and UX migration seam | | `src/TimeLocker/monitoring/notification_service.py` | Notification construction initializes the system tray | high | Headless warning root cause | @@ -53,15 +53,15 @@ run visibility, group authorization, and automatic retention. | Spec content | Durable destination | Promotion status | Notes | |--------------|---------------------|------------------|-------| -| System privilege, group authorization, record redaction, retention invariants | `docs/1-requirements/system-operations.md` | pending | New durable requirements document | -| Launcher, backend, IPC, run store, tray boundaries | `docs/2-architecture/system-architecture.md` | pending | Replace current single-process diagram | -| Backup/retention triggers, shared lock, run recording | `docs/2-architecture/scheduling-system.md` | pending | Preserve platform adapter context | -| Focused client/backend services and removed tray coupling | `docs/3-implementation/service-layer-integration.md` | pending | Do not expand compatibility facade | -| Installation, group management, launcher verification | `docs/guides/user/installation.md` | pending | Include Linux reference and portability limits | -| Production staging, dry-run approval, rollout, rollback | `docs/guides/developer/scheduling-guide.md` | pending | Current manual-retention text changes only after rollout | -| Independent tray installation and lifecycle | `docs/SYSTEM-TRAY-SETUP.md` | pending | Supersede in-process guidance | -| Command names and scopes | `docs/reference/timelocker-cli-command-hierarchy.md` | pending | Add runs and system log scope | -| User-facing diagnosis and permission errors | `docs/guides/user/backup-operations-troubleshooting.md` | pending | Explain local vs system records | +| System privilege, group authorization, record redaction, retention invariants | `docs/1-requirements/system-operations.md` | complete | Added current protected-system requirements | +| Launcher, backend, IPC, run store, tray boundaries | `docs/2-architecture/system-architecture.md` | complete | Replaced the single-process model | +| Backup/retention triggers, shared lock, run recording | `docs/2-architecture/scheduling-system.md` | complete | Preserves user/platform schedule context | +| Focused client/backend services and removed tray coupling | `docs/3-implementation/service-layer-integration.md` | complete | Added a separate system-control boundary | +| Installation, group management, launcher verification | `docs/guides/user/installation.md` | complete | Linux reference and portability limits recorded | +| Production staging, dry-run approval, rollout, rollback | `docs/guides/developer/scheduling-guide.md` | complete | Accepted retention automation replaces manual-only text | +| Independent tray installation and lifecycle | `docs/SYSTEM-TRAY-SETUP.md` | complete | In-process guidance superseded | +| Command names and scopes | `docs/reference/timelocker-cli-command-hierarchy.md` | complete | Added runs, system log scope, tray, and admin boundary | +| User-facing diagnosis and permission errors | `docs/guides/user/backup-operations-troubleshooting.md` | complete | Local/system records and safe diagnosis documented | ## Unchanged Durable Areas diff --git a/docs/specs/009-system-cli-tray-retention/tasks.md b/docs/specs/009-system-cli-tray-retention/tasks.md index e8b30ce..139dd50 100644 --- a/docs/specs/009-system-cli-tray-retention/tasks.md +++ b/docs/specs/009-system-cli-tray-retention/tasks.md @@ -267,7 +267,8 @@ T009 -> T010 -> T011 -> T012 reconnect, interrupted-run recovery, upgrade, and rollback are evidenced. - Evidence mode: external - Evidence: Controlled Linux Mint live acceptance completed on 2026-07-26. Release 32ab1fefd8fd9334fe37b68b1f2262565f32bebd is selected; authorized/denied views, root-owned launcher and backend, tray reconnect/status, interrupted-run recovery, upgrade/rollback, scheduled and explicit backup paths, one-file restore, exact-fingerprint retention approval, post-success retention, and independent retention were evidenced without copying secrets. Backup run 287f480c-283f-45c0-85ed-2eb8b6392596 and post-success retention run b3e5baff-56a7-4437-9295-9611a0c56156 succeeded. Both timers remain enabled and waiting. - - Status: Phase 4 live acceptance complete; T011 durable documentation promotion is next. + - Status: Phase 4 live acceptance complete; T011 durable documentation + promotion is complete. - [x] T010.1 Stage without changing the working 03:30 backup. - Evidence: Staged and installed immutable release assets without changing the existing 03:30 backup cadence. The selected release is 32ab1fefd8fd9334fe37b68b1f2262565f32bebd; the backup timer remains enabled and waiting for 03:30. - Status: Live staging complete and schedule preserved. @@ -288,14 +289,16 @@ T009 -> T010 -> T011 -> T012 - Evidence mode: external ## Phase 5: Promotion, review, and closure -- [ ] T011 Promote accepted behavior into durable documentation. +- [x] T011 Promote accepted behavior into durable documentation. - Depends on: T010 - Files: all promotion targets in `change-impact.md` - Acceptance: Durable requirements, architecture, CLI reference, installation, tray, scheduling, troubleshooting, rollout, and rollback docs match implemented behavior and no future intent is presented as current. - - Evidence: Pending. + - Evidence: Promoted accepted Linux system-control behavior into durable requirements, architecture, service integration, installation, scheduling, tray, CLI, troubleshooting, rollout, rollback, version-management, and documentation front-door sources. Reconciled duplicate tray guidance and explicitly retained Linux-only live acceptance, Windows follow-up, no full UI, and user-partition issue #70 boundaries. Direct source/installed-help review and a bounded review-timelocker documentation panel found no remaining actionable drift. `python scripts/link_checker.py`, `git diff --check`, and spec-package lint passed; Agent Workbench reported no Markdown diagnostics provider. + - Status: T011 complete. T012 remains the final expert review, residual disposition, and closure task. + - Evidence mode: validation - [ ] T012 Complete expert review, full validation, residual disposition, and closure preparation. - Depends on: T011 diff --git a/docs/specs/009-system-cli-tray-retention/traceability.md b/docs/specs/009-system-cli-tray-retention/traceability.md index 15525cd..197276c 100644 --- a/docs/specs/009-system-cli-tray-retention/traceability.md +++ b/docs/specs/009-system-cli-tray-retention/traceability.md @@ -62,11 +62,11 @@ targets. Reconcile this matrix whenever any linked artifact changes. | Design Section | Requirements | Tasks | Interfaces Or Files | Verification | Coverage State | Residual Destination | |----------------|--------------|-------|---------------------|--------------|----------------|----------------------| -| Decisions D001-D005 | R1, R2, R4 | T001, T003, T005-T006 | `system_control`, CLI, install assets | V1-V6 | not-covered | T001 | -| Decision D006 and independent tray | R3, R4 | T007, T009-T010 | monitoring/tray/platform modules | V7, V9-V10 | live-validated on Linux Mint | T011 promotion | -| Decision D007 and retention flow | R5 | T002, T008, T010 | retention/scheduling modules | V3, V8, V10 | live-validated | T011 promotion | -| Migration and compatibility | R1-R6 | T005, T007-T010 | install/release/system assets | V9-V10 | live-validated on Linux Mint | T011 promotion | -| Durable promotion | R1-R6 | T011-T012 | `docs/` targets | V12 | not-covered | T011 | +| Decisions D001-D005 | R1, R2, R4 | T001, T003, T005-T006 | `system_control`, CLI, install assets | V1-V6 | implemented and promoted | none | +| Decision D006 and independent tray | R3, R4 | T007, T009-T010 | monitoring/tray/platform modules | V7, V9-V10 | live-validated and promoted for Linux Mint | Windows live follow-up | +| Decision D007 and retention flow | R5 | T002, T008, T010 | retention/scheduling modules | V3, V8, V10 | live-validated and promoted | none | +| Migration and compatibility | R1-R6 | T005, T007-T010 | install/release/system assets | V9-V10 | live-validated and promoted for Linux Mint | Windows live follow-up | +| Durable promotion | R1-R6 | T011-T012 | `docs/` targets | V12 | promoted; final closure review pending | T012 | ## Open Decision Impact @@ -82,8 +82,9 @@ targets. Reconcile this matrix whenever any linked artifact changes. - `complete` in the requirement-delivery matrix means every accepted criterion has an explicit design, task, verification, and durable-target mapping. It does not claim implementation completion. -- Phase 4 repository and Linux Mint live evidence now exists in `tasks.md` and - `verification.md`; durable promotion and closure evidence remain pending. +- Phase 4 repository and Linux Mint live evidence exists in `tasks.md` and + `verification.md`; T011 durable promotion is complete and T012 closure review + remains pending. ## Reconciliation @@ -91,5 +92,5 @@ Reviewed against the 2026-07-26 requirements and design revisions. Every Requirement 1-6 acceptance criterion has an explicit task mapping, including Requirement 4 AC10-AC11 and the tightened security constraints. Phase 3 repository implementation evidence now covers Decisions D006-D007 and -packaging/portability. T010 live integration is complete; T011-T012 remain the -open promotion, final-review, and closure path. +packaging/portability. T010 live integration and T011 durable promotion are +complete; T012 remains the final-review and closure path. diff --git a/docs/specs/009-system-cli-tray-retention/verification.md b/docs/specs/009-system-cli-tray-retention/verification.md index cbdf466..5851ed7 100644 --- a/docs/specs/009-system-cli-tray-retention/verification.md +++ b/docs/specs/009-system-cli-tray-retention/verification.md @@ -21,11 +21,11 @@ review, durable promotion, and closure. |------|-----------|--------|----------| | Requirements acceptance criteria reviewed | yes | pending | Requirements amended through 2026-07-26 | | Design and traceability approved | yes | passed | Owner approved implementation; lifecycle context reports no Phase 1 gaps | -| Task evidence complete | yes | partial | T001-T010 complete; T011-T012 pending | +| Task evidence complete | yes | partial | T001-T011 complete; T012 pending | | Automated tests pass or alternate verification recorded | yes | passed for Phase 4 | System-control suite: 176 passed before live rollout; 22 focused backup/backend/tray tests passed after live defect fixes | | Security and operations expert review complete | yes | partial | T004 and Phase 2 checkpoints complete; final T012 review pending | | Linux Mint live acceptance and rollback rehearsal complete | yes | passed | V10 completed on 2026-07-26; selected release `32ab1fefd8fd9334fe37b68b1f2262565f32bebd` | -| Durable documentation promoted | yes | pending | | +| Durable documentation promoted | yes | passed | T011 promotion targets and front doors updated; link and patch checks passed | | Governance or policy conflicts resolved | yes | pending | | | Spec cleanup decision recorded | yes | pending | | @@ -59,14 +59,14 @@ Commands are refined through Agent Workbench before execution. | `python3 -m pytest tests/TimeLocker/platform -q` | Platform adapters and portability | pending | V4, V7, V9 | | `python3 -m pytest -m "not performance and not stress and not minio"` | Full configured non-live regression suite | pending | V1-V9 | | `systemd-analyze verify ` | Linux unit and socket validation | passed in isolated root | V4, V9 | -| `python3 scripts/link_checker.py` | Durable/spec link validation | pending | V12 | +| `python3 scripts/link_checker.py` | Durable/spec link validation | passed | V12; existing style suggestions only, no broken links | | `git diff --check` | Patch integrity | passed | Every implementation slice | ## Requirement Coverage | Requirement | Acceptance criteria covered | Evidence | Residual risk | |-------------|-----------------------------|----------|---------------| -| Requirement 1 | AC1-AC4 | V5, V9, and V10 passed | Durable promotion remains T011 | +| Requirement 1 | AC1-AC4 | V5, V9, V10, and T011 promotion passed | none for Linux reference | | Requirement 2 | AC1-AC6 | V2, V4-V5, and V10 passed on Linux Mint | Other platform authorization remains roadmap work | | Requirement 3 | AC1-AC8 | V7, V9, and V10 passed for the Linux reference desktop | Desktop diversity remains a portability risk | | Requirement 4 | AC1-AC11 | V1-V4, V6, and V10 passed | NSS variance remains a residual portability risk | @@ -93,10 +93,10 @@ Commands are refined through Agent Workbench before execution. | Broad requirement, design target, or review finding | Implemented in this spec | Coverage state | Deferred or rejected work | Destination | Blocks closure? | Evidence | |-----------------------------------------------------|--------------------------|----------------|---------------------------|-------------|-----------------|----------| -| Linux system command/control plane | Shared contracts, store, dispatcher, backend, immutable launcher, CLI views, installed socket, and live acceptance | live-validated | Durable documentation promotion | T011 | yes | T001-T010 evidence | -| Group-authorized system records | Current-membership dispatcher, structured projection, and authorized/denied live acceptance | live-validated | Durable documentation promotion | T011 | yes | T003, T004, T006, T009-T010 evidence | -| Independent tray | Standalone process, bounded IPC client, strict menu allowlist, singleton lock, launcher/autostart, reconnect, and live status | live-validated | Durable documentation promotion | T011 | yes | T007, T009-T010 evidence | -| Retention automation | Exact-fingerprint approval, protected adapter, post-success trigger, independent timer, shared lock, and durable runs | live-validated | Durable documentation promotion | T011 | yes | T008-T010 evidence | +| Linux system command/control plane | Shared contracts, store, dispatcher, backend, immutable launcher, CLI views, installed socket, live acceptance, and durable docs | promoted | none | none | no | T001-T011 evidence | +| Group-authorized system records | Current-membership dispatcher, structured projection, authorized/denied live acceptance, and durable docs | promoted | none | none | no | T003, T004, T006, T009-T011 evidence | +| Independent tray | Standalone process, bounded IPC client, strict menu allowlist, singleton lock, launcher/autostart, reconnect, live status, and durable setup | promoted | none for Linux reference | none | no | T007, T009-T011 evidence | +| Retention automation | Exact-fingerprint approval, protected adapter, post-success trigger, independent timer, shared lock, durable runs, and operator docs | promoted | none | none | no | T008-T011 evidence | | Windows shared architecture | Token-derived identity, current-group resolver, and named-pipe transport seam with Linux-hosted contract tests | repository-validated | Live Windows service/pipe implementation and acceptance | Platform roadmap | no for this Linux reference closure | T009 evidence | | Raw journald delegation | rejected | out-of-scope | Rejected because it exposes unrelated/protected records | none | no | Design D002 | | User-scoped backup partitions | none | out-of-scope | Separate authorization model | GitHub issue #70 | no | Requirements non-goal | @@ -110,7 +110,7 @@ Commands are refined through Agent Workbench before execution. | Permissions and approval points | T010 requires explicit host-mutation approval | No live mutation before approval | | Validation commands and expected signals | V1-V12 and planned commands | Commands must be refreshed after files exist | | Review needs | Security/architecture at T004; full expert panel at T012 | Findings may change design/tasks | -| Durable-doc or closure impact | `change-impact.md` promotion table | Promotion remains pending | +| Durable-doc or closure impact | `change-impact.md` promotion table | Promotion complete; T012 closure records remain | | Optional repo-evidence provider caveats | Agent Workbench evidence is routing/planning, not executed proof | Direct reads and commands required | ## Task Evidence @@ -127,7 +127,8 @@ Commands are refined through Agent Workbench before execution. | T008 | complete | Approved retention executor, trigger claiming, protected request handler, and independent schedule gate; system-control suite passed 149 tests with 83.09% branch-aware coverage | Live backend composition and host scheduling acceptance remain T009-T010 | | T009 | complete | 178-test focused Phase 4 suite; 753-test expanded regression; validated wheel/sdist, entrypoints, assets, headless import, staged units, upgrade, and rollback | No host state changed; live installation and production adapter activation remain T010 | | T010 | complete | Selected immutable release, authorized/denied system views, launcher/socket/tray acceptance, successful backup and restore, approved post-success and independent retention, interrupted recovery, upgrade, and rollback | Linux Mint reference acceptance only; no live Windows claim | -| T011-T012 | pending | No completion evidence | Durable promotion, final review, and closure | +| T011 | complete | Requirements, architecture, implementation, installation, scheduling, tray, CLI, troubleshooting, version, and front-door docs promoted; link and patch checks passed | Bounded review found no remaining actionable documentation drift | +| T012 | pending | No completion evidence | Final expert review and closure | ## Evidence Log @@ -165,6 +166,7 @@ Commands are refined through Agent Workbench before execution. | 2026-07-26 | Production backup and restore acceptance | passed | Scheduled backup remained healthy; explicit backup run `287f480c-283f-45c0-85ed-2eb8b6392596` succeeded and a one-file restore completed. Evidence contains no credentials, repository URI, or protected source inventory. | | 2026-07-26 | Exact-fingerprint retention activation | passed | Operator accepted fingerprint `e62033fd33259af14b68305e6d1179f840697f4a89f0c0df8cb95a5d69e81d94`; independent retention and post-backup retention run `b3e5baff-56a7-4437-9295-9611a0c56156` succeeded without pruning. Both timers remain enabled and waiting. | | 2026-07-26 | Live tray queued/history regression | fixed and passed | Initial accepted request displayed stale `error` while queued; commits `2388e1d` and `32ab1fe` added durable backup coordination and made queued/latest operation state authoritative. Focused regression: 22 passed; live tray reports `success`, zero active operations, and backend available. | +| 2026-07-26 | T011 durable-document promotion and bounded TimeLocker documentation review | passed | All promotion targets plus repository documentation front doors were reconciled with source and T010 evidence. `python scripts/link_checker.py` and `git diff --check` passed; Agent Workbench diagnostics had no Markdown provider. No live host state changed. | ## T004 Review Finding Dispositions @@ -235,34 +237,34 @@ package. | Spec content | Durable destination or deferral | Status | Evidence | |--------------|---------------------------------|--------|----------| -| System requirements and authorization invariants | `docs/1-requirements/system-operations.md` | pending | T011 | -| Launcher/backend/tray/run-store architecture | `docs/2-architecture/system-architecture.md` | pending | T011 | -| Scheduling/retention behavior | `docs/2-architecture/scheduling-system.md` | pending | T011 | -| Focused service ownership | `docs/3-implementation/service-layer-integration.md` | pending | T011 | -| Installation/group/launcher guidance | `docs/guides/user/installation.md` | pending | T011 | -| Scheduling rollout/rollback | `docs/guides/developer/scheduling-guide.md` | pending | T011 | -| Independent tray setup | `docs/SYSTEM-TRAY-SETUP.md` | pending | T011 | -| CLI commands and troubleshooting | CLI reference and backup troubleshooting guide | pending | T011 | +| System requirements and authorization invariants | `docs/1-requirements/system-operations.md` | complete | T011 | +| Launcher/backend/tray/run-store architecture | `docs/2-architecture/system-architecture.md` | complete | T011 | +| Scheduling/retention behavior | `docs/2-architecture/scheduling-system.md` | complete | T011 | +| Focused service ownership | `docs/3-implementation/service-layer-integration.md` | complete | T011 | +| Installation/group/launcher guidance | `docs/guides/user/installation.md` | complete | T011 | +| Scheduling rollout/rollback | `docs/guides/developer/scheduling-guide.md` | complete | T011 | +| Independent tray setup | `docs/SYSTEM-TRAY-SETUP.md` | complete | T011 | +| CLI commands and troubleshooting | CLI reference and backup troubleshooting guide | complete | T011 | | User partitions | GitHub issue #70 | routed | Existing backlog authority | ### Spec Cleanup Decision - **Cleanup action:** keep active until implementation, promotion, and closure -- **Reason:** repository implementation evidence exists through T008, but live integration, durable promotion, and closure evidence remain incomplete +- **Reason:** implementation, live integration, and durable promotion are complete; final expert review and closure evidence remain T012 - **Final spec commit:** pending - **Closure log path:** `docs/history/spec-closure-log.md` - **Closure log entry updated:** no - **Closure cleanup commit:** pending - **Active indexes updated:** no -- **Durable docs linked back to evidence where useful:** no -- **Residual spec-only content:** all design and task content remains temporary +- **Durable docs linked back to evidence where useful:** yes +- **Residual spec-only content:** design, detailed task evidence, and live acceptance remain temporary until T012 closure ## Ship Or Closure Risk - **Risk level:** high - **Breaking change:** no intended public-command break - **Blast radius checked:** partially -- **Rollback path:** designed; not yet implemented or rehearsed +- **Rollback path:** implemented and rehearsed on the Linux reference host - **Requires human review:** yes - **Release notes needed:** yes - **Follow-up issue or spec needed:** Windows live adapter/acceptance @@ -276,10 +278,10 @@ protected metadata, widen privilege, interrupt backups, or delete snapshots. ## Readiness Decision -- **Ready for promotion:** yes +- **Ready for promotion:** complete - **Ready for release:** no - **Ready for closure:** no -- **Ready for implementation:** yes for Phase 5 task T011 +- **Ready for implementation:** T012 only ## Related Artifacts @@ -297,5 +299,5 @@ now provide repository and Linux Mint live evidence for V1-V10 and repository-local portions of V11. Real socket activation, installed ownership/modes, live NSS behavior, protected backup/restore, post-success and independent retention, tray reconnect/status, interrupted recovery, upgrade, -and rollback passed. Durable promotion, final expert review, and closure remain -T011-T012. +and rollback passed. T011 durable promotion passed; final expert review and +closure remain T012. diff --git a/docs/specs/README.md b/docs/specs/README.md index 8b4cf8f..670d055 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -16,21 +16,20 @@ accepted content has been promoted and the package is closed. ## Current Packages - [`009-system-cli-tray-retention`](./009-system-cli-tray-retention/requirements.md) - — active implementation package; Phases 1 and 2 are complete, including - contracts, storage, Linux authorization, immutable launcher/routing, and - structured system run and diagnostic CLI views. Phase 3 is complete in the - repository, covering the independent tray and retention domain work; Phase 4 - installation and live acceptance work is next. + — active implementation package; Phases 1 through 4 and live Linux acceptance + are complete. Durable-document promotion is in progress under T011; expert + review and closure remain in T012. ## Active-Package Sequencing Spec 007 is closed; its release-readiness evidence and recovery commits are recorded in `docs/history/`. Spec 009 is the only active package. Its design, tasks, traceability, canonical context, and verification plan were approved, -and Phases 1 through 3 are complete in the repository. Implementation -continues with Phase 4 task T009 before live-host acceptance in T010. Repository -implementation approval does not authorize live-system mutation, rollout, or -release; T010 retains the explicit host-mutation gate. +and Phases 1 through 4 are complete in the repository. The approved Linux host +mutation and live acceptance gate was completed in T010. Work now proceeds +through durable promotion (T011) and final expert review/closure (T012); +repository implementation approval still does not authorize release +publication. Closed packages remain recorded in `docs/history/` rather than kept in this active path. From d4ce71dd05cb5d7278bf36a9fc43e557d68e1e31 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:26:19 +0100 Subject: [PATCH 48/72] feat: complete spec 009 system operations --- docs/1-requirements/system-operations.md | 2 + docs/2-architecture/system-architecture.md | 3 +- docs/SYSTEM-TRAY-SETUP.md | 5 +- docs/guides/user/installation.md | 6 +- .../timelocker-cli-command-hierarchy.md | 21 +++- .../009-system-cli-tray-retention/design.md | 9 +- .../requirements.md | 56 +++++----- .../009-system-cli-tray-retention/tasks.md | 21 ++-- .../traceability.md | 14 ++- .../verification.md | 82 ++++++++------ docs/specs/README.md | 15 +-- src/TimeLocker/cli.py | 41 ++++++- src/TimeLocker/cli_modules/commands/system.py | 96 ++++++++++++++++ .../monitoring/system_tray_integration.py | 104 +++++++++++++----- .../system_control/production_retention.py | 4 +- src/TimeLocker/system_control/tray_entry.py | 12 +- tests/TimeLocker/cli/test_system_commands.py | 76 +++++++++++++ .../test_system_tray_integration.py | 13 ++- .../test_production_retention.py | 47 +++++++- .../test_tray_process_boundary.py | 8 +- 20 files changed, 508 insertions(+), 127 deletions(-) create mode 100644 src/TimeLocker/cli_modules/commands/system.py create mode 100644 tests/TimeLocker/cli/test_system_commands.py diff --git a/docs/1-requirements/system-operations.md b/docs/1-requirements/system-operations.md index 5d4542e..0233d78 100644 --- a/docs/1-requirements/system-operations.md +++ b/docs/1-requirements/system-operations.md @@ -57,6 +57,8 @@ backup, retention, status, diagnostics, and tray operations. - The protected interface must not disclose repository passwords, cloud credentials, environment-file contents, raw backend output, raw journald content, or unnecessary protected filesystem paths. +- Root-owned target, repository-configuration, credential-source, and retention + enable-marker files accepted by the backend must be owner-only. - Run records must use bounded states, result codes, counters, and safe summaries. diff --git a/docs/2-architecture/system-architecture.md b/docs/2-architecture/system-architecture.md index 920abac..fd2b558 100644 --- a/docs/2-architecture/system-architecture.md +++ b/docs/2-architecture/system-architecture.md @@ -55,7 +55,8 @@ user CLI user-session tray - **CLI boundary** — `src/TimeLocker/cli.py` owns the installed entry point; `src/TimeLocker/cli_modules/commands/` owns modular command groups and input/ output handling. User-local commands remain in-process. `runs` and - `logs view --scope system` use the protected client. + `logs view --scope system` use the protected client, as do the bounded + `system backup` and `system retention` request commands. - **Release boundary** — root-owned launchers resolve the selected immutable release from `/opt/timelocker/selected-release.json`. They do not consult pyenv, a source checkout, the caller's home, or current working directory. diff --git a/docs/SYSTEM-TRAY-SETUP.md b/docs/SYSTEM-TRAY-SETUP.md index 0cd95f7..7ed1b22 100644 --- a/docs/SYSTEM-TRAY-SETUP.md +++ b/docs/SYSTEM-TRAY-SETUP.md @@ -19,7 +19,10 @@ The tray can: denied. `open_ui` is a reserved no-op. TimeLocker does not currently provide a full -desktop UI. +desktop UI. The default system autostart does not contain the approved +retention fingerprint, so it hides `Run Retention`; operators can still request +retention with `timelocker system retention --policy-fingerprint ...`, and a +future managed tray configuration may enable the same action. ## Authorization diff --git a/docs/guides/user/installation.md b/docs/guides/user/installation.md index 6e5b336..0f00f82 100644 --- a/docs/guides/user/installation.md +++ b/docs/guides/user/installation.md @@ -171,12 +171,16 @@ Verify an installed host without reading secrets: /usr/local/bin/timelocker version --short /usr/local/bin/timelocker runs list --limit 5 /usr/local/bin/timelocker logs view --scope system --lines 20 +/usr/local/bin/timelocker system backup --help +/usr/local/bin/timelocker system retention --help systemctl status timelocker-control.socket systemctl status timelocker-retention.timer /usr/local/bin/timelocker-tray status --once ``` -Protected reads require current membership in `timelocker-operators`. +Protected reads and `system backup`/`system retention` requests require current +membership in `timelocker-operators`. These commands use the privileged backend +without elevating the caller process. Installation, group changes, policy approval, service changes, release selection, and rollback require root. diff --git a/docs/reference/timelocker-cli-command-hierarchy.md b/docs/reference/timelocker-cli-command-hierarchy.md index b927a64..9663ed3 100644 --- a/docs/reference/timelocker-cli-command-hierarchy.md +++ b/docs/reference/timelocker-cli-command-hierarchy.md @@ -40,6 +40,7 @@ timelocker (alias: tl) ├── logs ├── reports ├── runs +├── system └── restore ``` @@ -73,6 +74,21 @@ Without `--scope system`, `logs view` reads only the invoking user's local CLI log. Scheduled system backups and retention runs are intentionally absent from that file. +## Protected System Actions + +```text +timelocker system backup [--target TARGET] + +timelocker system retention + --policy-fingerprint FINGERPRINT + [--dry-run] +``` + +These commands keep the caller process unprivileged and send only the bounded +request to the protected backend. The backend derives the caller identity from +the local transport and rechecks current operator-group membership. Denial or +backend unavailability never falls back to direct elevated execution. + ## Independent Tray Command `timelocker-tray` is a separate executable, not a CLI command group: @@ -88,8 +104,9 @@ timelocker-tray ``` `open_ui` is currently a reserved no-op. `retention_now` requires the exact -approved policy fingerprint. The tray communicates with the protected backend -and does not own backup execution. +approved policy fingerprint and is hidden from the graphical menu when the +tray was not configured with one. The tray communicates with the protected +backend and does not own backup execution. ## Administrator Release Tool diff --git a/docs/specs/009-system-cli-tray-retention/design.md b/docs/specs/009-system-cli-tray-retention/design.md index 6f6f27a..1244d10 100644 --- a/docs/specs/009-system-cli-tray-retention/design.md +++ b/docs/specs/009-system-cli-tray-retention/design.md @@ -60,10 +60,11 @@ groups became stale after the account was removed from the operator group. The system launcher does not elevate the entire CLI process. User-scope commands run locally. Allowlisted machine operations are sent to the privileged -backend, which authenticates, authorizes, validates, locks, audits, and executes -them. Installation, upgrade, rollback, group management, and service-file -changes remain explicit administrator operations through the platform's normal -system authorization mechanism. +backend, which authenticates current operating-system identity and group +membership, validates, locks, audits, and executes them. Installation, upgrade, +rollback, group management, and service-file changes remain explicit +administrator operations through the platform's normal system authorization +mechanism. ### D005: Explicit local and system log scopes diff --git a/docs/specs/009-system-cli-tray-retention/requirements.md b/docs/specs/009-system-cli-tray-retention/requirements.md index d6213b9..1287ce0 100644 --- a/docs/specs/009-system-cli-tray-retention/requirements.md +++ b/docs/specs/009-system-cli-tray-retention/requirements.md @@ -19,8 +19,8 @@ notification integration rather than an independent desktop client, and operation history is not yet a single durable cross-process contract. This package defines a coherent system-operations experience: a stable -system-path command that requests elevation only when required, an independent -per-user tray process, a local authenticated control/status boundary, and +system-path command, an independent per-user tray process, a local authenticated +control/status boundary with group-authorized privileged execution, and independently runnable retention with backup-success, scheduled, and explicit triggers plus visible outcomes. @@ -28,8 +28,9 @@ triggers plus visible outcomes. - Install a stable `timelocker` command on the system path and retain `tl` as a compatible alias. -- Let unprivileged commands remain unprivileged while privileged system actions - request elevation through an explicit, reviewable boundary. +- Let user-local commands remain in the caller's context while protected system + actions cross an explicit, reviewable backend boundary authorized by current + operating-system group membership. - Remove all tray initialization from normal CLI, scheduler, and backend execution paths. - Run the tray as an independent process in the signed-in user's graphical @@ -146,11 +147,11 @@ release or virtual-environment path. without falling back to a mutable checkout, user environment, or legacy root configuration overlay. -### Requirement 2: Contextual privilege elevation +### Requirement 2: Contextual privilege and authorization -**User Story:** As an operator, I want TimeLocker to request elevation only for -operations that require system authority, so that routine inspection remains -convenient without widening privilege unnecessarily. +**User Story:** As an operator, I want TimeLocker to use system authority only +through a narrow authorized backend, so routine inspection remains convenient +without widening the caller process's privilege. **Priority:** must-have @@ -158,19 +159,20 @@ convenient without widening privilege unnecessarily. 1. GIVEN a read-only operation whose data is accessible to the caller, WHEN it runs, THEN TimeLocker SHALL remain in the caller's security context. -2. GIVEN an allowlisted machine-level operation that requires elevated access, - WHEN an interactive caller invokes it, THEN TimeLocker SHALL request - authorization through the supported operating-system elevation mechanism - and preserve the intended command arguments. -3. IF no interactive authorization agent or terminal is available, THEN the - command SHALL fail promptly with the exact manual or automation-safe next - action; it SHALL NOT wait indefinitely. -4. ELEVATION SHALL NOT forward repository passwords, unrestricted environment - variables, display/session credentials, or arbitrary executable paths. -5. THE SYSTEM SHALL prevent recursive elevation and SHALL record a secret-free - audit event identifying the requested operation, caller, decision, and - result. -6. A denied or failed elevation SHALL leave configuration, schedules, +2. GIVEN an allowlisted protected operation, WHEN a caller invokes it, THEN + TimeLocker SHALL keep the caller process unprivileged, send the bounded + request to the privileged local backend, and authorize it from current + operating-system identity and operator-group membership. +3. IF the backend is unavailable or the caller is not currently authorized, + THEN the command SHALL fail promptly with an exact safe next action; it + SHALL NOT wait indefinitely or fall back to direct elevated execution. +4. THE PRIVILEGED BOUNDARY SHALL NOT forward repository passwords, unrestricted + environment variables, display/session credentials, or arbitrary executable + paths. +5. THE SYSTEM SHALL prevent recursive launcher execution and SHALL record a + secret-free audit event identifying the requested operation, caller, + decision, and result. +6. A denied or failed authorization SHALL leave configuration, schedules, repositories, and run state unchanged. ### Requirement 3: Independent tray process @@ -386,9 +388,10 @@ silently select the wrong code or privilege boundary. - **SC-001:** `command -v timelocker` and `command -v tl` resolve the selected system release without a project checkout or virtual-environment path in the caller's command. -- **SC-002:** A representative read-only command runs without elevation, while - a representative privileged command requests authorization once and records - its result without exposing secrets. +- **SC-002:** A representative read-only command runs in the caller's context, + while a representative protected command crosses the privileged backend, + checks current operator-group authorization once, and records its result + without exposing secrets. - **SC-003:** CLI and systemd retention runs produce no tray initialization attempt or tray warning. - **SC-004:** Restarting or terminating the tray leaves scheduled operations @@ -418,8 +421,9 @@ silently select the wrong code or privilege boundary. ## Resolved Design Questions -- System reads and allowlisted actions use the privileged local backend; - administrator maintenance continues through the platform elevation adapter. +- System reads and allowlisted actions use the privileged local backend and + current operator-group authorization; administrator maintenance continues + through the platform's normal explicit elevation mechanism. - Linux uses a systemd-managed Unix-domain socket with kernel peer credentials; shared contracts retain a Windows named-pipe adapter boundary. - The tray reads structured state exclusively through the backend contract. diff --git a/docs/specs/009-system-cli-tray-retention/tasks.md b/docs/specs/009-system-cli-tray-retention/tasks.md index 139dd50..44d18a6 100644 --- a/docs/specs/009-system-cli-tray-retention/tasks.md +++ b/docs/specs/009-system-cli-tray-retention/tasks.md @@ -42,14 +42,14 @@ T009 -> T010 -> T011 -> T012 - Evidence: T001 complete: 70 focused tests passed with 92.7% branch-aware coverage; compileall and git diff --check passed; focused review-timelocker implementation review found no remaining actionable findings. No transport, store, CLI, or live-host behavior was changed. - Evidence mode: validation - [x] T001.1 Add failing contract and model tests. - - Evidence: Added focused contract, model, transition, response-projection, security-boundary, and portability tests under tests/TimeLocker/system_control; final focused run: 70 passed. + - Evidence: `python3 -m pytest tests/TimeLocker/system_control ... --cov-fail-under=90` passed 70 tests at 92.7% branch-aware coverage, including contract, transition, response-projection, security-boundary, and portability cases. - Evidence mode: validation - [x] T001.2 Implement schemas, enums, validation, and response projection. - - Evidence: Implemented strict frozen enums, request/response envelopes, run/transition/diagnostic/policy/action models, validation helpers, immutable projections, and code-owned safe summaries under src/TimeLocker/system_control. + - Evidence: `src/TimeLocker/system_control/models.py`, `protocol.py`, and `validation.py` contain the strict frozen models, envelopes, validation helpers, immutable projections, and code-owned safe summaries exercised by the 70-test T001 run. - Evidence mode: implementation - [x] T001.3 Add Linux and Windows adapter protocol test doubles. - - Evidence: Added platform-neutral peer identity, membership, transport, handler, and client protocols with Linux UID and Windows SID adapter test doubles; platform tests passed. + - Evidence: `src/TimeLocker/system_control/interfaces.py` and `tests/TimeLocker/system_control/test_interfaces.py` define and exercise platform-neutral identity, membership, transport, handler, and client protocols; the final T001 command passed all 70 tests. - Evidence mode: validation - [x] T002 Implement atomic run/diagnostic storage, repository mutation locking, and interrupted-run reconciliation. @@ -68,11 +68,11 @@ T009 -> T010 -> T011 -> T012 - Evidence: Added transition, concurrency, corruption, persistence, bounded-stream, process-exit, and restart-reconciliation tests in tests/TimeLocker/system_control/test_storage.py; focused run passed 11 tests. - Evidence mode: command - [x] T002.2 Implement atomic record store and bounded diagnostic stream. - - Evidence: Implemented AtomicRecordStore with strict schema parsing, per-run atomic JSON replacement, fsync of files and directories, process-safe transition locking, bounded immutable diagnostic records, filtering, and mode enforcement. + - Evidence: `src/TimeLocker/system_control/storage.py` implements `AtomicRecordStore`; `python3 -m pytest tests/TimeLocker/system_control/test_storage.py` passed 11 schema, atomic-replacement, fsync, transition, bounded-stream, filtering, and mode cases. - Evidence mode: artifact - [x] T002.3 Implement repository lock leases and startup reconciliation. - - Evidence: Implemented nonblocking flock repository leases with safe run ownership metadata, conflict behavior, process-exit release, stale metadata clearing, and idempotent startup reconciliation of abandoned queued/running records. + - Evidence: `src/TimeLocker/system_control/storage.py` implements nonblocking `flock` leases and startup reconciliation; the 11-test storage run passed conflict, process-exit release, stale metadata, and abandoned-run cases. - Evidence mode: artifact - [x] T003 Implement Linux local transport and current operator-group authorization. @@ -92,12 +92,12 @@ T009 -> T010 -> T011 -> T012 - Evidence mode: command - [x] T003.2 Implement `SO_PEERCRED`, NSS group resolver, dispatcher, audit, and redaction. - - Evidence: Implemented SO_PEERCRED parsing, per-request primary/supplementary NSS lookup, strict JSON dispatcher, metadata-free denial/error responses, secret-free audit events, and systemd-activated AF_UNIX transport adapter. + - Evidence: `src/TimeLocker/system_control/linux_adapter.py` and `dispatcher.py` implement `SO_PEERCRED`, current NSS lookup, strict dispatch, safe denial, and audit projection; the focused T003 command passed 15 tests. - Evidence mode: artifact - [x] T003.3 Add root-owned policy, runtime directory, socket, and service templates with least-privilege modes. - - Evidence: Added packaged policy JSON plus staged socket/service templates with root ownership intent, timelocker-operators 0660 socket access, restrictive umask, AF_UNIX-only address family, filesystem protections, and no GUI/session or credential environment forwarding. + - Evidence: Wheel inventory found all 3 Phase 1 assets: `system-control-policy.json`, `timelocker-control.socket`, and `timelocker-control.service`; focused policy/unit tests verified `0660` group socket access, restrictive umask, AF_UNIX-only networking, and filesystem protections. - Evidence mode: artifact - [x] T004 Checkpoint - Foundation security and agent-readiness review. - Depends on: T003 @@ -275,7 +275,7 @@ T009 -> T010 -> T011 -> T012 - Evidence mode: external - [x] T010.2 Obtain explicit approval before group membership, service, launcher, timer, or live-retention mutations. - - Evidence: The operator explicitly approved T010 rollout, credential-free migration, retention dry-run, exact fingerprint e62033fd33259af14b68305e6d1179f840697f4a89f0c0df8cb95a5d69e81d94, and live retention activation before each protected mutation class. + - Evidence: The operator explicitly approved each T010 mutation class on 2026-07-26 before selecting release `32ab1fefd8fd9334fe37b68b1f2262565f32bebd`; accepted retention fingerprint `e62033fd33259af14b68305e6d1179f840697f4a89f0c0df8cb95a5d69e81d94` produced successful run `b3e5baff-56a7-4437-9295-9611a0c56156`. - Status: All required live-mutation approvals recorded. - Evidence mode: external - [x] T010.3 Execute acceptance and record secret-free evidence. @@ -299,7 +299,7 @@ T009 -> T010 -> T011 -> T012 - Status: T011 complete. T012 remains the final expert review, residual disposition, and closure task. - Evidence mode: validation -- [ ] T012 Complete expert review, full validation, residual disposition, and +- [x] T012 Complete expert review, full validation, residual disposition, and closure preparation. - Depends on: T011 - Files: Spec verification/traceability, durable docs, closure records @@ -308,8 +308,9 @@ T009 -> T010 -> T011 -> T012 requirements, ACs, and properties have evidence; closure and archive checks pass. - Evidence mode: validation - - Evidence: Pending. + - Evidence: Final seven-lens review completed with findings TLR-013 through TLR-018 resolved. Focused system-command, protected-file, and tray validation passed 22 tests. The configured non-performance/non-stress/non-MinIO profile passed 2,998 tests with 1 skip, 57 deselections, and 53.79% coverage against the 50% gate. Ruff lint and format checks, compileall, documentation link validation, and git diff integrity passed. Durable documentation promotion was committed as 3f009a8; Windows live acceptance and publication/deployment of the final repository corrections remain explicit post-spec follow-up work. + - Status: T012 complete; package is ready for lifecycle closure checks and removal after its final spec commit. ## Execution Rules - Do not implement from this file alone. Load the linked requirement, design, diff --git a/docs/specs/009-system-cli-tray-retention/traceability.md b/docs/specs/009-system-cli-tray-retention/traceability.md index 197276c..77b8e49 100644 --- a/docs/specs/009-system-cli-tray-retention/traceability.md +++ b/docs/specs/009-system-cli-tray-retention/traceability.md @@ -22,7 +22,7 @@ targets. Reconcile this matrix whenever any linked artifact changes. | T002 | Requirement 4, Requirement 5, Requirement 6 | Requirement 4 AC2; Requirement 4 AC3; Requirement 4 AC5; Requirement 4 AC6; Requirement 4 AC10; Requirement 4 AC11; Requirement 5 AC4; Requirement 5 AC5; Requirement 5 AC6; Requirement 5 AC11; Requirement 6 AC6 | Run store; Atomic transition; Error Handling | Run records and recovery | V1, V3 | System and scheduling architecture | none | | T003 | Requirement 2, Requirement 4 | Requirement 2 AC2; Requirement 2 AC3; Requirement 2 AC4; Requirement 2 AC5; Requirement 2 AC6; Requirement 4 AC1; Requirement 4 AC4; Requirement 4 AC5; Requirement 4 AC6; Requirement 4 AC7; Requirement 4 AC8; Requirement 4 AC9; Requirement 4 AC10; Requirement 4 AC11 | D001-D004; Authorization; Security | Operator authorization | V2, V4 | Requirements, architecture, installation | none | | T004 | Requirement 2, Requirement 4, Requirement 5, Requirement 6 | Requirement 2 AC4; Requirement 4 AC8; Requirement 4 AC9; Requirement 4 AC10; Requirement 4 AC11; Requirement 5 AC8; Requirement 6 AC5 | Downstream Task Guidance | All security-sensitive deltas | V1-V4, V11 | none | none | -| T005 | Requirement 1, Requirement 2, Requirement 6 | Requirement 1 AC1; Requirement 1 AC2; Requirement 1 AC3; Requirement 1 AC4; Requirement 2 AC1; Requirement 2 AC2; Requirement 2 AC3; Requirement 2 AC4; Requirement 2 AC5; Requirement 2 AC6; Requirement 6 AC1; Requirement 6 AC2; Requirement 6 AC3 | D004; Launcher; Migration | System launcher/elevation | V5, V9 | Installation, version management | none | +| T005 | Requirement 1, Requirement 2, Requirement 6 | Requirement 1 AC1; Requirement 1 AC2; Requirement 1 AC3; Requirement 1 AC4; Requirement 2 AC1; Requirement 2 AC2; Requirement 2 AC3; Requirement 2 AC4; Requirement 2 AC5; Requirement 2 AC6; Requirement 6 AC1; Requirement 6 AC2; Requirement 6 AC3 | D004; Launcher; Migration | System launcher/authorization | V5, V9 | Installation, version management | none | | T006 | Requirement 4 | Requirement 4 AC1; Requirement 4 AC2; Requirement 4 AC3; Requirement 4 AC6; Requirement 4 AC8; Requirement 4 AC9; Requirement 4 AC10; Requirement 4 AC11 | D002, D003, D005; Protected read | Local/system log split | V2, V6 | Requirements, CLI reference, troubleshooting | none | | T007 | Requirement 3, Requirement 4, Requirement 6 | Requirement 3 AC1; Requirement 3 AC2; Requirement 3 AC3; Requirement 3 AC4; Requirement 3 AC5; Requirement 3 AC6; Requirement 3 AC7; Requirement 3 AC8; Requirement 4 AC1; Requirement 4 AC4; Requirement 4 AC5; Requirement 4 AC6; Requirement 4 AC7; Requirement 4 AC8; Requirement 4 AC9; Requirement 6 AC1; Requirement 6 AC2; Requirement 6 AC3; Requirement 6 AC4; Requirement 6 AC5 | D006; Tray status; Migration | Independent tray | V7, V9 | Architecture, tray setup, installation | none | | T008 | Requirement 5, Requirement 6 | Requirement 5 AC1; Requirement 5 AC2; Requirement 5 AC3; Requirement 5 AC4; Requirement 5 AC5; Requirement 5 AC6; Requirement 5 AC7; Requirement 5 AC8; Requirement 5 AC9; Requirement 5 AC10; Requirement 5 AC11; Requirement 6 AC1; Requirement 6 AC2; Requirement 6 AC3; Requirement 6 AC6 | D007; Backup-triggered retention | Retention automation | V3, V8 | Scheduling architecture and guide | none | @@ -46,7 +46,7 @@ targets. Reconcile this matrix whenever any linked artifact changes. | Property | Requirements | Design Sections | Tasks | Tests Or Verification | Residual Risk | |----------|--------------|-----------------|-------|-----------------------|---------------| -| CP-001 | R2 | D004; Action classifier | T001, T003, T005 | V2, V5 | Live platform authorization | +| CP-001 | R2 | D004; Action classifier | T001, T003, T005, T012 | V2, V5, V11 | Other platform authorization | | CP-002 | R3 | D006; Independent tray | T007 | V7, V10 | Desktop diversity | | CP-003 | R4, R5 | Run store and lock | T002, T008 | V3, V8 | Production timing | | CP-004 | R4, R5 | RunRecord state machine | T001-T002, T006, T008 | V1, V3, V6, V8 | none after evidence | @@ -66,7 +66,7 @@ targets. Reconcile this matrix whenever any linked artifact changes. | Decision D006 and independent tray | R3, R4 | T007, T009-T010 | monitoring/tray/platform modules | V7, V9-V10 | live-validated and promoted for Linux Mint | Windows live follow-up | | Decision D007 and retention flow | R5 | T002, T008, T010 | retention/scheduling modules | V3, V8, V10 | live-validated and promoted | none | | Migration and compatibility | R1-R6 | T005, T007-T010 | install/release/system assets | V9-V10 | live-validated and promoted for Linux Mint | Windows live follow-up | -| Durable promotion | R1-R6 | T011-T012 | `docs/` targets | V12 | promoted; final closure review pending | T012 | +| Durable promotion | R1-R6 | T011-T012 | `docs/` targets | V12 | promoted; final review corrections implemented | none | ## Open Decision Impact @@ -83,8 +83,8 @@ targets. Reconcile this matrix whenever any linked artifact changes. has an explicit design, task, verification, and durable-target mapping. It does not claim implementation completion. - Phase 4 repository and Linux Mint live evidence exists in `tasks.md` and - `verification.md`; T011 durable promotion is complete and T012 closure review - remains pending. + `verification.md`; T011 durable promotion is complete and T012 owns the final + review corrections and closure validation. ## Reconciliation @@ -93,4 +93,6 @@ Requirement 1-6 acceptance criterion has an explicit task mapping, including Requirement 4 AC10-AC11 and the tightened security constraints. Phase 3 repository implementation evidence now covers Decisions D006-D007 and packaging/portability. T010 live integration and T011 durable promotion are -complete; T012 remains the final-review and closure path. +complete. T012 reconciled operator-group authorization, added the missing +public protected-action commands, tightened protected-file modes, and hid +unconfigured tray retention; final closure validation remains. diff --git a/docs/specs/009-system-cli-tray-retention/verification.md b/docs/specs/009-system-cli-tray-retention/verification.md index 5851ed7..1c8292e 100644 --- a/docs/specs/009-system-cli-tray-retention/verification.md +++ b/docs/specs/009-system-cli-tray-retention/verification.md @@ -19,15 +19,15 @@ review, durable promotion, and closure. | Gate | Required? | Status | Evidence | |------|-----------|--------|----------| -| Requirements acceptance criteria reviewed | yes | pending | Requirements amended through 2026-07-26 | +| Requirements acceptance criteria reviewed | yes | passed | Final T012 review reconciled later owner-approved operator-group authorization with Requirement 2 | | Design and traceability approved | yes | passed | Owner approved implementation; lifecycle context reports no Phase 1 gaps | -| Task evidence complete | yes | partial | T001-T011 complete; T012 pending | -| Automated tests pass or alternate verification recorded | yes | passed for Phase 4 | System-control suite: 176 passed before live rollout; 22 focused backup/backend/tray tests passed after live defect fixes | -| Security and operations expert review complete | yes | partial | T004 and Phase 2 checkpoints complete; final T012 review pending | +| Task evidence complete | yes | passed | T001-T012 complete with validation or external acceptance evidence | +| Automated tests pass or alternate verification recorded | yes | passed | Final configured profile: 2,998 passed, 1 skipped, 57 deselected, 53.79% coverage against the 50% gate | +| Security and operations expert review complete | yes | passed | Final seven-lens review findings TLR-013 through TLR-018 were corrected | | Linux Mint live acceptance and rollback rehearsal complete | yes | passed | V10 completed on 2026-07-26; selected release `32ab1fefd8fd9334fe37b68b1f2262565f32bebd` | | Durable documentation promoted | yes | passed | T011 promotion targets and front doors updated; link and patch checks passed | -| Governance or policy conflicts resolved | yes | pending | | -| Spec cleanup decision recorded | yes | pending | | +| Governance or policy conflicts resolved | yes | passed | Group authorization is the operational boundary; administrator maintenance remains explicitly elevated | +| Spec cleanup decision recorded | yes | passed | Remove the active package after the final spec commit; preserve recovery metadata in `docs/history/` | ## Verification Gates @@ -53,11 +53,11 @@ Commands are refined through Agent Workbench before execution. | Command | Purpose | Result | Evidence | |---------|---------|--------|----------| | `python3 -m pytest tests/TimeLocker/system_control -q` | Protocol, auth, storage, IPC, locks | passed in expanded suite | V1-V4, V9 | -| `python3 -m pytest tests/TimeLocker/cli/test_monitoring_commands.py -q` | CLI local/system log and run behavior | pending | V6 | +| `python3 -m pytest tests/TimeLocker/cli/test_monitoring_commands.py -q` | CLI local/system log and run behavior | passed in configured profile | V6 | | `python3 -m pytest tests/TimeLocker/monitoring -q` | Notification/tray/headless regression | passed in expanded suite | V7, V9 | -| `python3 -m pytest tests/TimeLocker/scheduling -q` | Retention and scheduler regression where present | pending | V8 | -| `python3 -m pytest tests/TimeLocker/platform -q` | Platform adapters and portability | pending | V4, V7, V9 | -| `python3 -m pytest -m "not performance and not stress and not minio"` | Full configured non-live regression suite | pending | V1-V9 | +| `python3 -m pytest tests/TimeLocker/scheduling -q` | Retention and scheduler regression where present | passed in configured profile | V8 | +| `python3 -m pytest tests/TimeLocker/platform -q` | Platform adapters and portability | passed in configured profile | V4, V7, V9 | +| `python3 -m pytest -m "not performance and not stress and not minio"` | Full configured non-live regression suite | 2,998 passed, 1 skipped, 57 deselected; 53.79% coverage | V1-V9 | | `systemd-analyze verify ` | Linux unit and socket validation | passed in isolated root | V4, V9 | | `python3 scripts/link_checker.py` | Durable/spec link validation | passed | V12; existing style suggestions only, no broken links | | `git diff --check` | Patch integrity | passed | Every implementation slice | @@ -128,16 +128,12 @@ Commands are refined through Agent Workbench before execution. | T009 | complete | 178-test focused Phase 4 suite; 753-test expanded regression; validated wheel/sdist, entrypoints, assets, headless import, staged units, upgrade, and rollback | No host state changed; live installation and production adapter activation remain T010 | | T010 | complete | Selected immutable release, authorized/denied system views, launcher/socket/tray acceptance, successful backup and restore, approved post-success and independent retention, interrupted recovery, upgrade, and rollback | Linux Mint reference acceptance only; no live Windows claim | | T011 | complete | Requirements, architecture, implementation, installation, scheduling, tray, CLI, troubleshooting, version, and front-door docs promoted; link and patch checks passed | Bounded review found no remaining actionable documentation drift | -| T012 | pending | No completion evidence | Final expert review and closure | +| T012 | complete | Seven-lens review findings TLR-013 through TLR-018 resolved; 22 focused tests and the 2,998-test configured profile passed; Ruff, compile, links, patch, and lifecycle checks passed | Windows live acceptance and publication/deployment remain separate follow-up work | ## Evidence Log | Date | Evidence | Result | Notes | |------|----------|--------|-------| -| 2026-07-26 | Live CLI/user-log and systemd-journal diagnosis | confirmed gap | User log scope differs from root system backup journal | -| 2026-07-26 | Repository context and direct source reads | confirmed design seams | No OS peer/group auth or local IPC exists; tray is constructed in notification services | -| 2026-07-26 | Spec artifacts created | pending validation | Design/tasks do not constitute implementation | -| 2026-07-26 | Canonical context reconciliation | current/future authority split recorded | Durable docs remain current until verified promotion | | 2026-07-26 | Focused `review-timelocker` design/security review | blocking design findings addressed | Explicit AC mappings, fail-closed NSS, root-only audit, safe summaries, storage hardening, and transport bounds added | | 2026-07-26 | `python3 -m pytest tests/TimeLocker/system_control ... --cov-fail-under=90` | 70 passed; 92.7% coverage | T001 strict models, envelopes, projection, transition, portability, and negative security cases | | 2026-07-26 | `python3 -m compileall -q src/TimeLocker/system_control tests/TimeLocker/system_control` and `git diff --check` | passed | T001 syntax and patch integrity | @@ -148,17 +144,19 @@ Commands are refined through Agent Workbench before execution. | 2026-07-26 | `PYENV_VERSION=3.12.4 python -m build --wheel --no-isolation ...` plus wheel inventory | passed; 3/3 assets present | Policy, socket unit, and service unit are packaged; isolated build could not resolve build dependencies because network access was unavailable | | 2026-07-26 | Agent Workbench verification planning and diagnostics | planning returned; diagnostics unavailable | No Python diagnostics provider was configured, so direct review and executed checks remain the proof | | 2026-07-26 | Rules consulted and applied | recorded | Coding Standards (100), General Preferences (50), Operational Best Practices (40), Planning Protocol (30), Testing Conventions (25), Documentation Conventions, and Git Conventions; no overrides | +| 2026-07-26 | `PYENV_VERSION=3.12.6 python -m pytest -m "not performance and not stress and not minio"` | 2,998 passed, 1 skipped, 57 deselected; 53.79% coverage | Final configured repository profile; 50% coverage gate passed | +| 2026-07-26 | `PYENV_VERSION=3.12.6 python -m pytest` over `test_system_commands.py`, `test_production_retention.py`, tray integration, and tray process-boundary tests | 22 passed | Public system commands, owner-only protected files, and fingerprint-aware tray actions | +| 2026-07-26 | Ruff lint/format, `compileall`, link checker, and `git diff --check` | passed | Link checker retained 22 pre-existing canonical-style suggestions and reported no broken links | | 2026-07-26 | `PYENV_VERSION=3.12.6 python -m pytest tests/TimeLocker/system_control tests/TimeLocker/cli/test_monitoring_commands.py tests/TimeLocker/cli/test_cli_help_system.py -q --no-cov` | 177 passed | Phase 2 launcher, action routing, client, authorization, structured run/log views, compatibility, denial, and redaction | | 2026-07-26 | `coverage report --include='src/TimeLocker/system_control/*' --skip-empty --fail-under=0` | 88.2% branch-aware coverage | Scoped report for the system-control package; a pytest coverage attempt inherited repository-wide `source=src` and failed the global 50% threshold at 17.3%, so it is not presented as a focused coverage result | | 2026-07-26 | Ruff check/format, compileall, wheel build/inventory, and `git diff --check` | passed | Wheel contains the Phase 2 modules and all six system-control assets, including distinct `timelocker` and `tl` launcher assets; isolated build dependency resolution was unavailable, and the Python 3.12.4 no-isolation build passed | -| 2026-07-26 | Agent Workbench verification planning | partial routing only | Its index had not incorporated newly created files and proposed unrelated tests; direct source review, the focused suite, and package inventory are the proof | | 2026-07-26 | `python3 -m pytest tests/TimeLocker/system_control/test_retention.py tests/TimeLocker/system_control/test_tray_client.py tests/TimeLocker/system_control/test_tray_process_boundary.py tests/TimeLocker/monitoring/test_system_tray_integration.py tests/TimeLocker/system_control/test_client.py` | 32 passed; repository-wide coverage gate failed at 12.2% | Narrow slice inherited repository-wide `--cov=src/TimeLocker`; tests passed and exposed a coverage-accounting mismatch rather than a functional regression | | 2026-07-26 | `PYENV_VERSION=3.12.4 PYTHONPATH=src python -m pytest -o addopts='' tests/TimeLocker/system_control --cov-config=/dev/null --cov=TimeLocker.system_control --cov-branch --cov-report=term --cov-fail-under=80 -q` | 149 passed; 83.09% branch-aware coverage | Complete system-control regression and focused Phase 3 coverage without inheriting the repository-wide coverage source | -| 2026-07-26 | System-control, monitoring, and integration regression slice | 190 passed | Tray/process boundaries, retention execution, monitoring compatibility, reconnect, authorization projection, and schedule summaries | +| 2026-07-26 | `PYENV_VERSION=3.12.6 python -m pytest` over the Phase 3 system-control, monitoring, and integration slice | 190 passed | Tray/process boundaries, retention execution, monitoring compatibility, reconnect, authorization projection, and schedule summaries | | 2026-07-26 | Ruff check/format, compileall, wheel build/inventory, and `git diff --check` | passed | Wheel contains `timelocker-tray` and all new Phase 3 modules; no host state changed | | 2026-07-26 | Expanded `system_control`, `monitoring`, and `integration` pytest run | 733 passed, 1 skipped, 2 failed, 4 setup errors | The failures are confined to repository credential integration paths outside this diff: five expect a legacy credential-file location and one cannot register S3 because optional `b2sdk` is absent. They do not invalidate the bounded Phase 3 suites but remain repository test debt. | | 2026-07-26 | Credential-path and backend-registration reconciliation | 6 focused tests passed | `--config-dir` consistently treats the argument as the configuration root and stores credentials under `credentials/credentials.enc`; missing B2 registration no longer prevents S3 registration. No credential contents or live stores were read, copied, or deleted. | -| 2026-07-26 | Phase 4 focused system-control, credential, and artifact suite | 178 passed | Backend/release entrypoints, exact asset manifest, permissions, Windows adapter seam, upgrade, rollback, credential paths, and release metadata passed. | +| 2026-07-26 | `PYENV_VERSION=3.12.6 python -m pytest` over the Phase 4 system-control, credential, and artifact suite | 178 passed | Backend/release entrypoints, exact asset manifest, permissions, Windows adapter seam, upgrade, rollback, credential paths, and release metadata passed. | | 2026-07-26 | Expanded `system_control`, `monitoring`, and `integration` pytest run | 753 passed, 1 skipped | Previous six credential/backend-registration failures are resolved; existing warnings remain non-blocking. | | 2026-07-26 | Wheel/sdist validation and installed headless smoke | passed | Four console entrypoints and 20 package-data files validated; CLI import did not load tray code and the environment had no `pystray` dependency. | | 2026-07-26 | Staged `systemd-analyze verify --recursive-errors=no --root=...` | passed | Backend socket/service and disabled retention service/timer parsed successfully against an isolated staged executable. | @@ -210,6 +208,21 @@ bounded to T007-T008 source, tests, packaging, and lifecycle artifacts. It did not install a desktop-session process, connect to the live system backend, activate production retention, or mutate host state. +## T012 Final Review Finding Dispositions + +| Finding | Severity / confidence | Roles | Disposition | Validation | +|---------|-----------------------|-------|-------------|------------| +| TLR-013: Requirement 2 retained an older per-invocation elevation prompt after the owner required current operator-group authorization | high / high | Security and Privacy; Project Steward; Documentation and Lifecycle | fixed: requirements, design, traceability, and durable docs now define the privileged backend plus current OS group membership as the operational boundary; administrator maintenance remains explicitly elevated | direct requirements/design reconciliation and authorization tests | +| TLR-014: protected retention files could be group/world readable | high / high | Security and Privacy; Restic and Recovery; Reliability and Testing | fixed: target, repository configuration, credential source, and enable marker must be owner-only | focused `0644` rejection tests | +| TLR-015: the public CLI classified protected actions but exposed no `system backup` or `system retention` commands | high / high | Project Steward; Python CLI; Operations and Portability | fixed: a focused `system` command group sends bounded requests through `UnixSocketSystemControlClient` and never falls back to direct elevation | CLI help, request-shape, routing, and help-tree tests | +| TLR-016: the default tray autostart exposed retention without a configured policy fingerprint | medium / high | Project Steward; Reliability and Testing; Operations and Portability | fixed: tray menus omit retention unless the process has a configured fingerprint | tray menu configuration and process-boundary tests | +| TLR-017: the active-spec front door still described T011 as in progress | medium / high | Documentation and Lifecycle | fixed: `docs/specs/README.md` now identifies T011 as complete and T012 as the only active work | direct documentation review | +| TLR-018: the verification gate understated requirements-review completion | low / medium | Documentation and Lifecycle | fixed: the gate records the final Requirement 2 reconciliation and review disposition | package lint and lifecycle checks | + +The final panel also exposed a pre-existing `timelocker help runs` omission +during the normal profile. The help topic and new `system` topic were added and +the complete help-tree test now passes. No final-review finding remains open. + ## Manual Or External Verification Live T010 evidence must record the reviewer, timestamp, exact non-secret command, @@ -249,21 +262,23 @@ package. ### Spec Cleanup Decision -- **Cleanup action:** keep active until implementation, promotion, and closure -- **Reason:** implementation, live integration, and durable promotion are complete; final expert review and closure evidence remain T012 -- **Final spec commit:** pending +- **Cleanup action:** remove after the final spec commit +- **Reason:** implementation, Linux live acceptance, durable promotion, final + expert review, and repository validation are complete +- **Final spec commit:** pending until this complete package is committed - **Closure log path:** `docs/history/spec-closure-log.md` -- **Closure log entry updated:** no +- **Closure log entry updated:** after the final spec commit - **Closure cleanup commit:** pending -- **Active indexes updated:** no +- **Active indexes updated:** with the closure cleanup commit - **Durable docs linked back to evidence where useful:** yes -- **Residual spec-only content:** design, detailed task evidence, and live acceptance remain temporary until T012 closure +- **Residual spec-only content:** none requires durable promotion; detailed + design, task evidence, and live acceptance remain recoverable from Git ## Ship Or Closure Risk - **Risk level:** high - **Breaking change:** no intended public-command break -- **Blast radius checked:** partially +- **Blast radius checked:** yes - **Rollback path:** implemented and rehearsed on the Linux reference host - **Requires human review:** yes - **Release notes needed:** yes @@ -280,8 +295,8 @@ protected metadata, widen privilege, interrupt backups, or delete snapshots. - **Ready for promotion:** complete - **Ready for release:** no -- **Ready for closure:** no -- **Ready for implementation:** T012 only +- **Ready for closure:** yes +- **Ready for implementation:** complete ## Related Artifacts @@ -295,9 +310,10 @@ protected metadata, widen privilege, interrupt backups, or delete snapshots. ## Reconciliation Reviewed against the 2026-07-26 requirements and design revisions. T001-T010 -now provide repository and Linux Mint live evidence for V1-V10 and -repository-local portions of V11. Real socket activation, installed -ownership/modes, live NSS behavior, protected backup/restore, post-success and -independent retention, tray reconnect/status, interrupted recovery, upgrade, -and rollback passed. T011 durable promotion passed; final expert review and -closure remain T012. +provide repository and Linux Mint live evidence for V1-V10. Real socket +activation, installed ownership/modes, live NSS behavior, protected +backup/restore, post-success and independent retention, tray reconnect/status, +interrupted recovery, upgrade, and rollback passed. T011 durable promotion and +T012 final expert review, correction, full validation, and residual disposition +passed. Windows live acceptance and publication/deployment of the final +repository corrections remain explicit post-spec work. diff --git a/docs/specs/README.md b/docs/specs/README.md index 670d055..57bf515 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -16,9 +16,9 @@ accepted content has been promoted and the package is closed. ## Current Packages - [`009-system-cli-tray-retention`](./009-system-cli-tray-retention/requirements.md) - — active implementation package; Phases 1 through 4 and live Linux acceptance - are complete. Durable-document promotion is in progress under T011; expert - review and closure remain in T012. + — complete package awaiting closure cleanup. Implementation, live Linux + acceptance, durable-document promotion, final expert-review corrections, and + repository validation are complete. ## Active-Package Sequencing @@ -26,10 +26,11 @@ Spec 007 is closed; its release-readiness evidence and recovery commits are recorded in `docs/history/`. Spec 009 is the only active package. Its design, tasks, traceability, canonical context, and verification plan were approved, and Phases 1 through 4 are complete in the repository. The approved Linux host -mutation and live acceptance gate was completed in T010. Work now proceeds -through durable promotion (T011) and final expert review/closure (T012); -repository implementation approval still does not authorize release -publication. +mutation and live acceptance gate was completed in T010, and durable promotion +was completed in T011. Work now proceeds through final expert review, +correction, and closure (T012), which are complete. The package will be removed +after its final spec commit and recorded in `docs/history/`; repository +implementation approval still does not authorize release publication. Closed packages remain recorded in `docs/history/` rather than kept in this active path. diff --git a/src/TimeLocker/cli.py b/src/TimeLocker/cli.py index 3214e0f..5821090 100644 --- a/src/TimeLocker/cli.py +++ b/src/TimeLocker/cli.py @@ -595,6 +595,12 @@ def cli_help( ) console.print(" [cyan]logs[/cyan] - Log viewing and maintenance") console.print(" [cyan]reports[/cyan] - Generate usage and health reports") + console.print( + " [cyan]runs[/cyan] - Authorized system backup and retention records" + ) + console.print( + " [cyan]system[/cyan] - Authorized machine-level backup and retention" + ) console.print( " [cyan]migrate[/cyan] - Validate and migrate configuration files\n" ) @@ -629,6 +635,8 @@ def cli_help( console.print(" timelocker help monitor # Monitoring dashboard help") console.print(" timelocker help logs # Log management help") console.print(" timelocker help reports # Reporting help") + console.print(" timelocker help runs # System run records help") + console.print(" timelocker help system # System action help") console.print(" timelocker help migrate # Configuration migration help\n") console.print("[bold]Command Help:[/bold]") @@ -1065,6 +1073,29 @@ def cli_help( ) console.print(" timelocker reports generate storage-usage --format json\n") + elif topic == "runs": + console.print("\n[bold cyan]System Run Records Help[/bold cyan]\n") + console.print( + "Current operator-group members can inspect structured system backup " + "and retention records.\n" + ) + console.print(" [cyan]runs list[/cyan] - List system runs") + console.print(" [cyan]runs show[/cyan] - Show one system run\n") + + elif topic == "system": + console.print("\n[bold cyan]System Actions Help[/bold cyan]\n") + console.print( + "Current operator-group members can request allowlisted actions " + "through the protected backend.\n" + ) + console.print( + " [cyan]system backup[/cyan] [--target production] - Request a system backup" + ) + console.print( + " [cyan]system retention[/cyan] --policy-fingerprint " + "[--dry-run] - Request retention\n" + ) + elif topic == "migrate": console.print("\n[bold cyan]Configuration Migration Help[/bold cyan]\n") console.print( @@ -1086,7 +1117,8 @@ def cli_help( else: available_topics = ( "repos, backup, snapshots, restore, policy, schedule, selections, " - "config, credentials, security, monitor, logs, reports, migrate" + "config, credentials, security, monitor, logs, reports, runs, system, " + "migrate" ) unknown_topic_message = ( f"Unknown help topic: {topic}\n\n" + f"Available topics: {available_topics}" @@ -3201,6 +3233,13 @@ def config_import_config( except ImportError as e: logging.getLogger(__name__).debug(f"Could not import monitoring commands: {e}") +try: + from .cli_modules.commands.system import system_app as _system_commands_app + + app.add_typer(_system_commands_app, name="system") +except ImportError as e: + logging.getLogger(__name__).debug(f"Could not import system commands: {e}") + try: from .cli_modules.commands.restore import restore_app as _restore_commands_app diff --git a/src/TimeLocker/cli_modules/commands/system.py b/src/TimeLocker/cli_modules/commands/system.py new file mode 100644 index 0000000..077c0fe --- /dev/null +++ b/src/TimeLocker/cli_modules/commands/system.py @@ -0,0 +1,96 @@ +"""Authorized machine-level backup and retention commands.""" + +from typing import Annotated + +import typer + +from TimeLocker.system_control.action_policy import classify_public_action +from TimeLocker.system_control.client import UnixSocketSystemControlClient +from TimeLocker.system_control.models import BackupActionRequest, RetentionActionRequest + +from .base import CommandBase, VerboseOption, create_typer_app, show_success_panel + + +system_app = create_typer_app( + name="system", + help_text="Authorized machine-level backup and retention actions", +) + + +def _create_system_control_client() -> UnixSocketSystemControlClient: + return UnixSocketSystemControlClient() + + +def _require_backend_route(action: str) -> None: + route = classify_public_action(("system", action)) + if not route.uses_system_backend: + raise RuntimeError("system action routing policy is invalid") + + +@system_app.command("backup") +def system_backup( + target_id: Annotated[ + str, + typer.Option( + "--target", + help="Configured system backup target identifier", + ), + ] = "production", + verbose: VerboseOption = False, +) -> None: + """Request an allowlisted system backup through the protected backend.""" + try: + _require_backend_route("backup") + receipt = _create_system_control_client().request_backup( + BackupActionRequest(target_id=target_id) + ) + show_success_panel( + "System Backup Requested", + "The protected backend accepted the backup request.", + { + "Status": receipt.status, + "Run ID": str(receipt.run_id) if receipt.run_id else "pending", + }, + ) + except Exception as error: + CommandBase.handle_error(error, verbose, "System Backup Error") + + +@system_app.command("retention") +def system_retention( + policy_fingerprint: Annotated[ + str, + typer.Option( + "--policy-fingerprint", + help="Exact approved retention policy fingerprint", + ), + ], + dry_run: Annotated[ + bool, + typer.Option("--dry-run", help="Evaluate without removing snapshots"), + ] = False, + verbose: VerboseOption = False, +) -> None: + """Request approved retention through the protected backend.""" + try: + _require_backend_route("retention") + receipt = _create_system_control_client().request_retention( + RetentionActionRequest( + policy_fingerprint=policy_fingerprint, + dry_run=dry_run, + ) + ) + show_success_panel( + "System Retention Requested", + "The protected backend accepted the retention request.", + { + "Status": receipt.status, + "Run ID": str(receipt.run_id) if receipt.run_id else "pending", + "Mode": "dry run" if dry_run else "apply", + }, + ) + except Exception as error: + CommandBase.handle_error(error, verbose, "System Retention Error") + + +__all__ = ["system_app"] diff --git a/src/TimeLocker/monitoring/system_tray_integration.py b/src/TimeLocker/monitoring/system_tray_integration.py index 55742a3..ce5fb58 100644 --- a/src/TimeLocker/monitoring/system_tray_integration.py +++ b/src/TimeLocker/monitoring/system_tray_integration.py @@ -102,7 +102,11 @@ class SystemTrayIntegration: - Click-to-open main interface """ - def __init__(self, app_name: str = "TimeLocker"): + def __init__( + self, + app_name: str = "TimeLocker", + menu_actions: set[str] | frozenset[str] | None = None, + ): """ Initialize system tray integration @@ -110,6 +114,11 @@ def __init__(self, app_name: str = "TimeLocker"): app_name: Application name for tray icon """ self.app_name = app_name + self.menu_actions = frozenset( + menu_actions + if menu_actions is not None + else {"status", "backup_now", "retention_now", "open_ui", "quit"} + ) self.current_status = TrayStatus.IDLE self.status_info = TrayStatusInfo( status=TrayStatus.IDLE, tooltip="TimeLocker - No recent activity" @@ -136,11 +145,11 @@ def _initialize_platform_tray(self): "System tray disabled because no Linux graphical session is available" ) return - self._tray_impl = LinuxSystemTray(self.app_name) + self._tray_impl = LinuxSystemTray(self.app_name, self.menu_actions) elif sys.platform == "darwin": - self._tray_impl = MacOSSystemTray(self.app_name) + self._tray_impl = MacOSSystemTray(self.app_name, self.menu_actions) elif sys.platform == "win32": - self._tray_impl = WindowsSystemTray(self.app_name) + self._tray_impl = WindowsSystemTray(self.app_name, self.menu_actions) else: logger.warning(f"System tray not supported on {sys.platform}") return @@ -287,7 +296,11 @@ def shutdown(self): class LinuxSystemTray: """Linux system tray implementation using GTK or Qt""" - def __init__(self, app_name: str): + def __init__( + self, + app_name: str, + menu_actions: frozenset[str] | None = None, + ): """ Initialize Linux system tray @@ -295,6 +308,11 @@ def __init__(self, app_name: str): app_name: Application name """ self.app_name = app_name + self.menu_actions = ( + menu_actions + if menu_actions is not None + else frozenset({"status", "backup_now", "retention_now", "open_ui", "quit"}) + ) self._icon = None self._menu = None self._on_click_callback = None @@ -357,11 +375,12 @@ def _create_gtk_menu(self): self._menu.append(backup_item) # Retention now item - retention_item = Gtk.MenuItem(label="Run Retention") - retention_item.connect( - "activate", lambda x: self._trigger_menu_action("retention_now") - ) - self._menu.append(retention_item) + if "retention_now" in self.menu_actions: + retention_item = Gtk.MenuItem(label="Run Retention") + retention_item.connect( + "activate", lambda x: self._trigger_menu_action("retention_now") + ) + self._menu.append(retention_item) # Separator self._menu.append(Gtk.SeparatorMenuItem()) @@ -444,7 +463,11 @@ def shutdown(self): class MacOSSystemTray: """macOS system tray implementation using rumps""" - def __init__(self, app_name: str): + def __init__( + self, + app_name: str, + menu_actions: frozenset[str] | None = None, + ): """ Initialize macOS system tray @@ -452,6 +475,11 @@ def __init__(self, app_name: str): app_name: Application name """ self.app_name = app_name + self.menu_actions = ( + menu_actions + if menu_actions is not None + else frozenset({"status", "backup_now", "retention_now", "open_ui", "quit"}) + ) self._app = None self._on_click_callback = None self._on_menu_action_callback = None @@ -478,7 +506,7 @@ def _create_menu(self): import rumps # Create menu items - self._app.menu = [ + menu = [ rumps.MenuItem("Open TimeLocker", callback=self._on_open_clicked), None, # Separator rumps.MenuItem( @@ -489,15 +517,23 @@ def _create_menu(self): "Backup Now", callback=lambda _: self._trigger_menu_action("backup_now"), ), - rumps.MenuItem( - "Run Retention", - callback=lambda _: self._trigger_menu_action("retention_now"), - ), - None, # Separator - rumps.MenuItem( - "Quit", callback=lambda _: self._trigger_menu_action("quit") - ), ] + if "retention_now" in self.menu_actions: + menu.append( + rumps.MenuItem( + "Run Retention", + callback=lambda _: self._trigger_menu_action("retention_now"), + ) + ) + menu.extend( + [ + None, + rumps.MenuItem( + "Quit", callback=lambda _: self._trigger_menu_action("quit") + ), + ] + ) + self._app.menu = menu except Exception as e: logger.error(f"Failed to create macOS menu: {e}") @@ -565,7 +601,11 @@ def shutdown(self): class WindowsSystemTray: """Windows system tray implementation using pystray""" - def __init__(self, app_name: str): + def __init__( + self, + app_name: str, + menu_actions: frozenset[str] | None = None, + ): """ Initialize Windows system tray @@ -573,6 +613,11 @@ def __init__(self, app_name: str): app_name: Application name """ self.app_name = app_name + self.menu_actions = ( + menu_actions + if menu_actions is not None + else frozenset({"status", "backup_now", "retention_now", "open_ui", "quit"}) + ) self._icon = None self._on_click_callback = None self._on_menu_action_callback = None @@ -619,15 +664,20 @@ def _create_menu(self): import pystray from pystray import MenuItem as Item - return pystray.Menu( + items = [ Item("Open TimeLocker", self._on_open_clicked), Item("View Status", lambda: self._trigger_menu_action("status")), Item("Backup Now", lambda: self._trigger_menu_action("backup_now")), - Item( - "Run Retention", lambda: self._trigger_menu_action("retention_now") - ), - Item("Quit", lambda: self._trigger_menu_action("quit")), - ) + ] + if "retention_now" in self.menu_actions: + items.append( + Item( + "Run Retention", + lambda: self._trigger_menu_action("retention_now"), + ) + ) + items.append(Item("Quit", lambda: self._trigger_menu_action("quit"))) + return pystray.Menu(*items) except Exception as e: logger.error(f"Failed to create Windows menu: {e}") return None diff --git a/src/TimeLocker/system_control/production_retention.py b/src/TimeLocker/system_control/production_retention.py index e1e3bba..e1cc38f 100644 --- a/src/TimeLocker/system_control/production_retention.py +++ b/src/TimeLocker/system_control/production_retention.py @@ -274,8 +274,8 @@ def _require_protected_file(path: Path, *, expected_owner: int) -> None: raise ValueError("protected path must be a regular file") if metadata.st_uid != expected_owner: raise PermissionError("protected file has an unexpected owner") - if stat.S_IMODE(metadata.st_mode) & 0o022: - raise PermissionError("protected file must not be group/world writable") + if stat.S_IMODE(metadata.st_mode) & 0o077: + raise PermissionError("protected file must be owner-only") __all__: Sequence[str] = ( diff --git a/src/TimeLocker/system_control/tray_entry.py b/src/TimeLocker/system_control/tray_entry.py index 92f7abc..e76634a 100644 --- a/src/TimeLocker/system_control/tray_entry.py +++ b/src/TimeLocker/system_control/tray_entry.py @@ -155,6 +155,13 @@ def _build_client( ) +def _tray_menu_actions(retention_policy_fingerprint: str | None) -> frozenset[str]: + actions = {"status", "backup_now", "open_ui", "quit"} + if retention_policy_fingerprint: + actions.add("retention_now") + return frozenset(actions) + + def _handle_action( action: str, client: TrayControlClient, @@ -232,7 +239,10 @@ def main() -> None: return try: - tray = SystemTrayIntegration(app_name="TimeLocker") + tray = SystemTrayIntegration( + app_name="TimeLocker", + menu_actions=_tray_menu_actions(arguments.retention_policy_fingerprint), + ) except SystemTrayError: tray = None diff --git a/tests/TimeLocker/cli/test_system_commands.py b/tests/TimeLocker/cli/test_system_commands.py new file mode 100644 index 0000000..124140a --- /dev/null +++ b/tests/TimeLocker/cli/test_system_commands.py @@ -0,0 +1,76 @@ +"""Public protected-system command tests.""" + +from uuid import uuid4 + +import pytest + +from TimeLocker.cli import app +from TimeLocker.cli_modules.commands import system as system_commands +from TimeLocker.system_control.models import ActionReceipt +from tests.TimeLocker.cli.test_utils import combined_output, get_cli_runner + + +runner = get_cli_runner() + + +class _Client: + def __init__(self) -> None: + self.backup_request = None + self.retention_request = None + + def request_backup(self, request): + self.backup_request = request + return ActionReceipt(uuid4(), True, "queued", uuid4()) + + def request_retention(self, request): + self.retention_request = request + return ActionReceipt(uuid4(), True, "queued", uuid4()) + + +@pytest.mark.unit +def test_system_help_exposes_protected_actions() -> None: + result = runner.invoke(app, ["system", "--help"]) + + assert result.exit_code == 0 + assert "backup" in combined_output(result) + assert "retention" in combined_output(result) + + +@pytest.mark.unit +def test_system_backup_uses_protected_backend(monkeypatch) -> None: + client = _Client() + monkeypatch.setattr( + system_commands, + "_create_system_control_client", + lambda: client, + ) + + result = runner.invoke(app, ["system", "backup", "--target", "production"]) + + assert result.exit_code == 0 + assert client.backup_request.target_id == "production" + + +@pytest.mark.unit +def test_system_retention_requires_exact_fingerprint(monkeypatch) -> None: + client = _Client() + monkeypatch.setattr( + system_commands, + "_create_system_control_client", + lambda: client, + ) + + result = runner.invoke( + app, + [ + "system", + "retention", + "--policy-fingerprint", + "a" * 64, + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert client.retention_request.policy_fingerprint == "a" * 64 + assert client.retention_request.dry_run is True diff --git a/tests/TimeLocker/monitoring/test_system_tray_integration.py b/tests/TimeLocker/monitoring/test_system_tray_integration.py index 7a34a87..fe6de4e 100644 --- a/tests/TimeLocker/monitoring/test_system_tray_integration.py +++ b/tests/TimeLocker/monitoring/test_system_tray_integration.py @@ -66,7 +66,18 @@ def test_initialization(self, monkeypatch): assert tray.app_name == "TestApp" assert tray.current_status == TrayStatus.IDLE assert tray.is_available() is True - linux_tray.assert_called_once_with("TestApp") + linux_tray.assert_called_once_with( + "TestApp", + frozenset( + { + "status", + "backup_now", + "retention_now", + "open_ui", + "quit", + } + ), + ) @pytest.mark.monitoring @pytest.mark.unit diff --git a/tests/TimeLocker/system_control/test_production_retention.py b/tests/TimeLocker/system_control/test_production_retention.py index 7ad1ea6..082fd6a 100644 --- a/tests/TimeLocker/system_control/test_production_retention.py +++ b/tests/TimeLocker/system_control/test_production_retention.py @@ -73,7 +73,46 @@ def test_rejects_writable_production_target(tmp_path: Path) -> None: path.write_text("{}\n", encoding="utf-8") path.chmod(0o666) - with pytest.raises(PermissionError, match="must not be group/world writable"): + with pytest.raises(PermissionError, match="must be owner-only"): + ProductionRetentionTarget.load(path, expected_owner=os.getuid()) + + +@pytest.mark.unit +def test_rejects_world_readable_production_target(tmp_path: Path) -> None: + path = tmp_path / "production-target.json" + path.write_text("{}\n", encoding="utf-8") + path.chmod(0o644) + + with pytest.raises(PermissionError, match="must be owner-only"): + ProductionRetentionTarget.load(path, expected_owner=os.getuid()) + + +@pytest.mark.unit +@pytest.mark.parametrize("protected_name", ["repository_config", "credential_source"]) +def test_rejects_world_readable_protected_reference( + tmp_path: Path, + protected_name: str, +) -> None: + target = _target(tmp_path) + path = tmp_path / "production-target.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "target_id": target.target_id, + "repository_name": target.repository_name, + "config_directory": str(target.config_directory), + "repository_config": str(target.repository_config), + "credential_source": str(target.credential_source), + "snapshot_filters": [], + } + ), + encoding="utf-8", + ) + path.chmod(0o600) + getattr(target, protected_name).chmod(0o644) + + with pytest.raises(PermissionError, match="must be owner-only"): ProductionRetentionTarget.load(path, expected_owner=os.getuid()) @@ -85,7 +124,7 @@ def test_retention_enable_marker_must_be_protected(tmp_path: Path) -> None: require_retention_enable_marker(marker, expected_owner=os.getuid()) marker.chmod(0o666) - with pytest.raises(PermissionError, match="must not be group/world writable"): + with pytest.raises(PermissionError, match="must be owner-only"): require_retention_enable_marker(marker, expected_owner=os.getuid()) @@ -96,7 +135,9 @@ def test_adapter_runs_only_fixed_retention_command_and_counts_candidates( target = _target(tmp_path) calls: list[list[str]] = [] - def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: calls.append(command) return subprocess.CompletedProcess( command, diff --git a/tests/TimeLocker/system_control/test_tray_process_boundary.py b/tests/TimeLocker/system_control/test_tray_process_boundary.py index b1d91c0..6806625 100644 --- a/tests/TimeLocker/system_control/test_tray_process_boundary.py +++ b/tests/TimeLocker/system_control/test_tray_process_boundary.py @@ -8,7 +8,7 @@ import pytest from TimeLocker.system_control import tray_entry -from TimeLocker.system_control.tray_entry import _single_instance +from TimeLocker.system_control.tray_entry import _single_instance, _tray_menu_actions @pytest.mark.unit @@ -75,3 +75,9 @@ def test_one_shot_action_does_not_construct_desktop_tray(monkeypatch) -> None: ) tray_entry.main() + + +@pytest.mark.unit +def test_retention_menu_requires_configured_fingerprint() -> None: + assert "retention_now" not in _tray_menu_actions(None) + assert "retention_now" in _tray_menu_actions("a" * 64) From aba95875f453dd6abf39a1fdc6af25fd38c62db4 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:28:24 +0100 Subject: [PATCH 49/72] docs: close spec 009 --- docs/history/spec-archive-index.md | 3 +- docs/history/spec-closure-log.md | 36 +- .../canonical-context.md | 90 --- .../change-impact.md | 103 ---- .../009-system-cli-tray-retention/design.md | 526 ------------------ .../requirements.md | 456 --------------- .../009-system-cli-tray-retention/tasks.md | 342 ------------ .../traceability.md | 98 ---- .../verification.md | 319 ----------- docs/specs/README.md | 21 +- 10 files changed, 43 insertions(+), 1951 deletions(-) delete mode 100644 docs/specs/009-system-cli-tray-retention/canonical-context.md delete mode 100644 docs/specs/009-system-cli-tray-retention/change-impact.md delete mode 100644 docs/specs/009-system-cli-tray-retention/design.md delete mode 100644 docs/specs/009-system-cli-tray-retention/requirements.md delete mode 100644 docs/specs/009-system-cli-tray-retention/tasks.md delete mode 100644 docs/specs/009-system-cli-tray-retention/traceability.md delete mode 100644 docs/specs/009-system-cli-tray-retention/verification.md diff --git a/docs/history/spec-archive-index.md b/docs/history/spec-archive-index.md index 126d43f..f5633cb 100644 --- a/docs/history/spec-archive-index.md +++ b/docs/history/spec-archive-index.md @@ -3,7 +3,7 @@ title: Spec archive index doc_type: history status: active owner: Auriora Team -last_reviewed: 2026-07-20 +last_reviewed: 2026-07-26 --- # Spec Archive Index @@ -16,6 +16,7 @@ contracts. | Spec ID | Title | Package path | Status | Final spec commit | Cleanup commit | Closure action | Durable destinations | Verification | |---------|-------|--------------|--------|-------------------|----------------|----------------|----------------------|--------------| +| 009-system-cli-tray-retention | System CLI, independent tray, retention, and control | removed; recover from Git | removed | `d4ce71dd05cb5d7278bf36a9fc43e557d68e1e31` | pending-cleanup-commit | removed | `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/2-architecture/scheduling-system.md`; `docs/3-implementation/service-layer-integration.md`; `docs/guides/user/installation.md`; `docs/guides/developer/scheduling-guide.md`; `docs/SYSTEM-TRAY-SETUP.md`; `docs/reference/timelocker-cli-command-hierarchy.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/processes/version-management.md`; `docs/README.md`; `docs/DOCUMENTATION-STATUS.md`; `docs/specs/README.md` | `docs/history/spec-closure-log.md` | | 007-release-readiness-stabilization | Release readiness stabilization requirements | removed; recover from Git | removed | `7fd11f9aa1cbc670d5e8b429aede4a7c01e185a4` | `6334af0690b5b9e8b6575042269e5b73914a9295` | removed | `README.md`; `CHANGELOG.md`; `.github/workflows/test-suite.yml`; `.github/workflows/artifact-smoke.yml`; `.github/workflows/release-validation.yml`; `.github/workflows/release.yml`; `docs/4-testing/README.md`; `docs/guides/user/installation.md`; `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md`; `docs/processes/version-management.md`; `docs/processes/README.md` | `docs/history/spec-closure-log.md` | | 008-npbackup-migration-parity | NPBackup migration parity requirements | removed; recover from Git | removed | `5830194` | `1bfea08` | removed | `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md` | `docs/history/spec-closure-log.md` | | 001-cli-consolidation-stabilization | CLI Consolidation Stabilization | removed; recover from Git | removed | `a1bb654` | `b8df9e9` | removed | `docs/3-implementation/service-layer-integration.md`; `docs/reference/repo-orientation-and-change-map.md`; `docs/specs/README.md`; `docs/history/` | `docs/history/spec-closure-log.md` | diff --git a/docs/history/spec-closure-log.md b/docs/history/spec-closure-log.md index 7063f70..85e046f 100644 --- a/docs/history/spec-closure-log.md +++ b/docs/history/spec-closure-log.md @@ -3,7 +3,7 @@ title: Spec closure log doc_type: history status: active owner: Auriora Team -last_reviewed: 2026-07-20 +last_reviewed: 2026-07-26 --- # Spec Closure Log @@ -15,6 +15,40 @@ final spec commit preserves the complete package. ## Entries +### 2026-07-26 - 009-system-cli-tray-retention + +- **Spec:** removed; recover from Git +- **Title:** System CLI, independent tray, retention, and control +- **Final spec commit:** `d4ce71dd05cb5d7278bf36a9fc43e557d68e1e31` +- **Closure cleanup commit:** `pending-cleanup-commit` +- **Closure action:** removed +- **Durable docs updated:** + - `docs/1-requirements/system-operations.md` + - `docs/2-architecture/system-architecture.md` + - `docs/2-architecture/scheduling-system.md` + - `docs/3-implementation/service-layer-integration.md` + - `docs/guides/user/installation.md` + - `docs/guides/developer/scheduling-guide.md` + - `docs/SYSTEM-TRAY-SETUP.md` + - `docs/reference/timelocker-cli-command-hierarchy.md` + - `docs/guides/user/backup-operations-troubleshooting.md` + - `docs/processes/version-management.md` + - `docs/README.md` + - `docs/DOCUMENTATION-STATUS.md` + - `docs/specs/README.md` +- **Verification summary:** All 12 top-level tasks are complete. Final expert + review findings TLR-013 through TLR-018 were corrected; the configured + profile passed 2,998 tests with one skip, 57 deselections, and 53.79% + coverage against the 50% gate. All 72 lifecycle evidence records were + concrete, closure risk was low, and closure readiness had no blockers. +- **Residual risks:** + - Windows has repository adapter coverage but no live deployment acceptance. + - The final T012 corrections are repository-complete but are not yet + published or deployed to replace the selected Linux release. +- **Follow-up:** Treat release publication and deployment as separately + authorized work; retain user-scoped backup partitions and restores in issue + #70, and open a Windows live-acceptance package before claiming support. + ### 2026-07-20 - 007-release-readiness-stabilization - **Spec:** removed; recover from Git diff --git a/docs/specs/009-system-cli-tray-retention/canonical-context.md b/docs/specs/009-system-cli-tray-retention/canonical-context.md deleted file mode 100644 index 4307bf5..0000000 --- a/docs/specs/009-system-cli-tray-retention/canonical-context.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: System CLI, tray, retention, and control canonical context -doc_type: spec -artifact_type: canonical-context -status: draft -owner: Auriora Team -last_reviewed: 2026-07-26 ---- - -# Canonical Context - -## Purpose - -Prevent current-state documentation from being mistaken for the accepted -future behavior of Spec 009. The scheduling and tray guides remain authoritative -for the installed application until implementation and promotion; this package -is authoritative only for the active implementation slice. - -## Authority Hierarchy - -System, developer, and user instructions remain highest. `AGENTS.md` routes -project mandate and governance to `CHARTER.md`, agent behavior to -`docs/guides/ai-agent/`, current implementation behavior to source, tests, -configuration, and live evidence, and active change intent to this lifecycle -package. - -Spec-local context does not make planned behavior current. A conflict with -mandate, policy, source contracts, tests, generated contracts, or live system -evidence is a reconciliation input and must not be silently overridden. - -## Always-Canonical External Sources - -| Source | Authority reason | Handling | -|--------|------------------|----------| -| `AGENTS.md` | Repository instruction router | Read before changing governed paths. | -| `CHARTER.md` | Project mandate, boundaries, and governance | Stop for an explicit scope decision if the package conflicts with it. | -| `docs/guides/ai-agent/` | Agent workflow and operational rules | Follow the highest-priority applicable rule. | -| Source, tests, generated contracts, configuration, and live evidence | Current implementation and runtime truth | Reconcile disagreement; do not claim planned behavior is implemented. | - -## Spec-Canonical Working Sources - -| Source | Role | Scope | Notes | -|--------|------|-------|-------| -| `requirements.md` | Accepted intent | Spec 009 behavior and boundaries | User corrections through 2026-07-26 are included. | -| `design.md` | Proposed implementation approach | Spec 009 architecture and security model | Requires owner approval before source implementation. | -| `tasks.md` | Execution and approval index | Spec 009 delivery sequence | Load linked context before each task. | -| `traceability.md` | Delivery coverage contract | Requirement, design, task, verification, and promotion mappings | Coverage means mapped delivery, not completed implementation. | -| `verification.md` | Evidence contract | Validation, live acceptance, promotion, and closure | Pending results are not proof. | - -## Imported Sources - -| Source path | Reviewed | Status | Canonical scope | Promotion target | -|-------------|----------|--------|-----------------|------------------| -| `CHARTER.md` | 2026-07-26 | summarized | Mandate and non-goal boundaries only | `CHARTER.md` remains authoritative | -| `docs/guides/developer/scheduling-guide.md` | 2026-07-26 | promoted | Current user/system schedules, retention, cutover, and rollback | Durable authority after T011 | -| `docs/SYSTEM-TRAY-SETUP.md` | 2026-07-26 | promoted | Independent tray behavior and setup | Durable authority after T011 | -| `docs/2-architecture/system-architecture.md` | 2026-07-26 | promoted | Current CLI, launcher, backend, tray, run-store, and repository boundaries | Durable authority after T011 | -| `docs/2-architecture/scheduling-system.md` | 2026-07-26 | promoted | Current user scheduling and protected backup/retention architecture | Durable authority after T011 | - -No durable document is copied into this package. T011 promoted the accepted -behavior into the listed current-state documents, which are now authoritative -for users and operators. - -## Non-Canonical Background Sources - -| Source | Reason non-canonical for this slice | Handling | -|--------|-------------------------------------|----------| -| Closed or archived specs | Historical delivery evidence, not current behavior | Consult only for provenance or regression context. | -| Ad hoc installation scripts under `/tmp` | Ephemeral host-operation aids | Never treat as repository contract or commit them. | -| User-local TimeLocker and pyenv installations | Do not define the root-owned system deployment | Use only as observed compatibility evidence. | -| Raw system journal output | Operational evidence that may contain protected metadata | Do not copy into spec artifacts; record secret-free summaries. | - -## Promotion Map - -| Spec-local content | Durable destination or route | Required before closure | -|--------------------|------------------------------|-------------------------| -| System authorization and record visibility invariants | `docs/1-requirements/system-operations.md` | yes | -| Launcher, backend, transport, run store, and tray architecture | `docs/2-architecture/system-architecture.md` | yes | -| Retention triggers and mutation coordination | `docs/2-architecture/scheduling-system.md` | yes | -| Installed scheduling, retention, tray, and rollback behavior | Current developer and user guides listed in `change-impact.md` | yes | -| Live Windows implementation | Platform roadmap or follow-up package | yes, as an explicit deferral | - -## Related Artifacts - -- Requirements: `requirements.md` -- Design: `design.md` -- Tasks: `tasks.md` -- Change impact: `change-impact.md` -- Traceability: `traceability.md` -- Verification: `verification.md` diff --git a/docs/specs/009-system-cli-tray-retention/change-impact.md b/docs/specs/009-system-cli-tray-retention/change-impact.md deleted file mode 100644 index efdd479..0000000 --- a/docs/specs/009-system-cli-tray-retention/change-impact.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -title: System CLI, tray, retention, and control-plane change impact -doc_type: spec -artifact_type: change-impact -status: draft -owner: Auriora Team -last_reviewed: 2026-07-26 ---- - -# Change Impact - -## Purpose - -Record the durable behavior changed by Spec 009: system command discovery, -contextual machine operations, independent tray ownership, structured system -run visibility, group authorization, and automatic retention. - -## Durable Source Mapping - -| Source | Current behavior relied on | Confidence | Notes | -|--------|----------------------------|------------|-------| -| `CHARTER.md` | CLI-first backup orchestration, safety, stable automation, observable operation | high | Governing mandate | -| `docs/2-architecture/system-architecture.md` | CLI, immutable launcher, protected backend, tray, run-store, and repository boundaries | high | Promoted by T011 | -| `docs/2-architecture/scheduling-system.md` | User schedules plus protected backup/retention triggers, locking, and run state | high | Promoted by T011 | -| `docs/3-implementation/service-layer-integration.md` | Focused services should own new behavior instead of expanding the compatibility facade | high | Guides client/service placement | -| `docs/guides/developer/scheduling-guide.md` | User/system scheduling, retention safety, cutover, and rollback | high | Promoted by T011 | -| `docs/guides/user/installation.md` | User/source install and protected Linux deployment boundaries | high | Promoted by T011 | -| `docs/SYSTEM-TRAY-SETUP.md` | Independent unprivileged tray installation and behavior | high | Superseded by T011 | -| `src/TimeLocker/cli_modules/commands/monitoring.py` | `logs view` reads the caller's cache log and ignores system run history | high | Bug and UX migration seam | -| `src/TimeLocker/monitoring/notification_service.py` | Notification construction initializes the system tray | high | Headless warning root cause | - -## Change Type - -- **Primary type:** feature -- **Secondary types:** refactor, migration, operational, bug_fix -- **Breaking change:** no -- **Durable docs required:** yes -- **External behavior affected:** yes - -## Proposed Changes - -| Change | Type | Source of truth | New durable destination | Promotion required | -|--------|------|-----------------|-------------------------|-------------------| -| Add root-owned system launchers and immutable release selection | add | Spec 009 | `docs/guides/user/installation.md`, `docs/processes/version-management.md` | yes | -| Route protected reads/actions through a versioned local backend | add | Spec 009 | `docs/2-architecture/system-architecture.md` | yes | -| Restrict system runs/logs to current operator-group members | add | Spec 009 | `docs/1-requirements/system-operations.md`, architecture and runbook docs | yes | -| Keep local logs distinct and add explicit system scope | modify | Current CLI behavior | CLI reference and troubleshooting guide | yes | -| Remove tray ownership from notification and CLI services | refactor | Current source | Architecture and tray setup documentation | yes | -| Add backup-success, independent, and explicit retention triggers | add | Spec 009 | Scheduling architecture and operator guide | yes | -| Add durable run records and interrupted-run reconciliation | add | Spec 009 | Architecture and operations docs | yes | - -## Promotion Targets - -| Spec content | Durable destination | Promotion status | Notes | -|--------------|---------------------|------------------|-------| -| System privilege, group authorization, record redaction, retention invariants | `docs/1-requirements/system-operations.md` | complete | Added current protected-system requirements | -| Launcher, backend, IPC, run store, tray boundaries | `docs/2-architecture/system-architecture.md` | complete | Replaced the single-process model | -| Backup/retention triggers, shared lock, run recording | `docs/2-architecture/scheduling-system.md` | complete | Preserves user/platform schedule context | -| Focused client/backend services and removed tray coupling | `docs/3-implementation/service-layer-integration.md` | complete | Added a separate system-control boundary | -| Installation, group management, launcher verification | `docs/guides/user/installation.md` | complete | Linux reference and portability limits recorded | -| Production staging, dry-run approval, rollout, rollback | `docs/guides/developer/scheduling-guide.md` | complete | Accepted retention automation replaces manual-only text | -| Independent tray installation and lifecycle | `docs/SYSTEM-TRAY-SETUP.md` | complete | In-process guidance superseded | -| Command names and scopes | `docs/reference/timelocker-cli-command-hierarchy.md` | complete | Added runs, system log scope, tray, and admin boundary | -| User-facing diagnosis and permission errors | `docs/guides/user/backup-operations-troubleshooting.md` | complete | Local/system records and safe diagnosis documented | - -## Unchanged Durable Areas - -| Durable area | Reviewed source | Reason unchanged | -|--------------|-----------------|------------------| -| Repository engine ownership | `CHARTER.md` | Restic remains the backup engine | -| Supported repository families | `docs/2-architecture/system-architecture.md` | Local, S3, and B2 support is unchanged | -| Restore overwrite policy | `docs/2-architecture/system-architecture.md` | Spec 009 does not change restore behavior | -| User-scoped backup partitions | GitHub issue #70 | Explicitly outside this spec | -| Full desktop UI | `CHARTER.md` and issue tracker | Tray remains a companion client | - -## Bug Fix Details - -- **Observed behavior:** `timelocker logs view` shows only user-cache logs and - CLI construction attempts to initialize the system tray twice. -- **Expected behavior:** local logs are clearly identified; authorized operators - can query structured system runs/diagnostics; headless commands never - initialize tray code. -- **Root cause evidence:** `logs_view` resolves - `ConfigurationPathResolver.get_cache_directory()` directly, while - `NotificationService` constructs `SystemTrayIntegration` and is instantiated - by more than one CLI service path. -- **Regression risk:** monitoring initialization, notification delivery, CLI - compatibility, system installation, and authorization behavior. -- **Durable doc update needed:** yes; see promotion targets. - -## Open Questions - -None blocking. Live Windows support remains a routed platform follow-up after -shared-contract and test-double acceptance. - -## Related Artifacts - -- Requirements: `requirements.md` -- Canonical context: `canonical-context.md` -- Design: `design.md` -- Tasks: `tasks.md` -- Traceability: `traceability.md` -- Verification: `verification.md` diff --git a/docs/specs/009-system-cli-tray-retention/design.md b/docs/specs/009-system-cli-tray-retention/design.md deleted file mode 100644 index 1244d10..0000000 --- a/docs/specs/009-system-cli-tray-retention/design.md +++ /dev/null @@ -1,526 +0,0 @@ ---- -title: System CLI, independent tray, retention, and local control design -doc_type: spec -artifact_type: design -status: draft -owner: Auriora Team -last_reviewed: 2026-07-26 ---- - -# Technical Design - -## Overview - -TimeLocker will separate the public CLI, privileged machine operations, desktop -tray, and Restic execution into explicit process boundaries. A root-owned -system backend will expose a small versioned local contract. On Linux, a -systemd-activated Unix-domain socket will authenticate callers from kernel peer -credentials and revalidate membership in the root-controlled -`timelocker-operators` group for every protected request. Windows support will -use the same protocol and domain services behind a named-pipe/service adapter. - -The backend will expose structured run and diagnostic records rather than -granting users direct access to journald, root configuration, repository -credentials, or raw Restic output. The CLI and independent tray will be clients -of this contract. Ordinary user-local commands and logs will remain -unprivileged and separate. - -The Linux reference deployment remains Linux Mint Cinnamon/X11. The initial -delivery will include a Linux implementation and contract-tested Windows -adapter seam; it will not claim live Windows acceptance until that adapter is -implemented and validated. - -## Decisions - -### D001: Dedicated operator group - -The default system operator group is `timelocker-operators`. It is distinct from -the `restic` service account and any broad `systemd-journal` or administrator -group. Installation creates the group but does not add users automatically. -Membership changes remain an explicit system-administrator action. - -### D002: Structured records, not raw journal delegation - -TimeLocker will persist allowlisted `RunRecord` and `DiagnosticRecord` objects -under `/var/lib/timelocker`. Authorized clients may query those records through -the backend. Membership in `timelocker-operators` does not grant direct access -to journald, `/etc/timelocker`, `/var/restic`, environment files, or raw Restic -output. - -### D003: Kernel identity plus current group revalidation - -Linux socket permissions provide a first gate, but the backend also obtains the -peer UID through `SO_PEERCRED`, resolves the account through the operating -system, and checks current NSS group membership on every protected request. A -username, UID, group list, or authorization flag supplied in a request is -ignored. This second check rejects a process whose inherited supplementary -groups became stale after the account was removed from the operator group. - -### D004: Backend-mediated machine actions - -The system launcher does not elevate the entire CLI process. User-scope -commands run locally. Allowlisted machine operations are sent to the privileged -backend, which authenticates current operating-system identity and group -membership, validates, locks, audits, and executes them. Installation, upgrade, -rollback, group management, and service-file changes remain explicit -administrator operations through the platform's normal system authorization -mechanism. - -### D005: Explicit local and system log scopes - -`timelocker logs view` remains backward-compatible and reads user-local -application logs by default. `timelocker logs view --scope system` queries -authorized structured diagnostic records. Backup and retention outcomes use -`timelocker runs list` and `timelocker runs show RUN_ID`; they are not inferred -from free-form log text. - -### D006: Independent tray client - -`NotificationService`, CLI services, schedulers, retention workers, and the -backend will not import or construct platform tray implementations. A separate -`timelocker-tray` entry point runs in the graphical user session, reads status -through the local contract, and requests only allowlisted actions. Platform UI -modules are loaded only by that entry point. - -### D007: Retention trigger independence - -Retention is a separate locked operation. The production profile emits one -retention request after a successful scheduled backup has recorded terminal -success and released its repository lock. Manual and independent scheduled -retention remain supported. The initial production profile leaves the -independent catch-up schedule disabled until an operator explicitly enables a -reviewed schedule. - -## Requirement Coverage - -| Requirement | Acceptance Criteria | Design Coverage | Validation Approach | -|-------------|---------------------|-----------------|---------------------| -| Requirement 1 | AC1-AC4 | Root-owned launchers, immutable release selector, fail-closed resolution | Launcher unit and live smoke tests | -| Requirement 2 | AC1-AC6 | Action classifier, backend-mediated machine actions, platform authorization adapter | Privilege-routing and denial tests | -| Requirement 3 | AC1-AC8 | Independent tray entry point, platform adapters, no tray imports in headless paths | Import-boundary, headless, reconnect, and live tray tests | -| Requirement 4 | AC1-AC11 | Versioned local contract, peer authorization, structured records, allowlisted actions | Contract, authorization, redaction, CLI, and tray tests | -| Requirement 5 | AC1-AC11 | Retention policy fingerprint, shared repository lock, three trigger modes | Policy, trigger, conflict, dry-run, and live retention tests | -| Requirement 6 | AC1-AC6 | Release manifest, asset compatibility, record reconciliation, rollback | Packaging, upgrade, interruption, and rollback tests | - -## Correctness Property Coverage - -| Property | Design Behavior | Validation Direction | Notes | -|----------|-----------------|----------------------|-------| -| CP-001 | Central action classifier and backend authorization gate | Table-driven routing tests | No command-local privilege guesses | -| CP-002 | Tray is only an IPC client | Process-kill and import-boundary tests | Headless operations have no GUI dependency | -| CP-003 | One repository mutation lock shared by backup and retention | Concurrency and crash-recovery tests | Lock identity derives from protected repository identity | -| CP-004 | Atomic run-record state machine | Transition and interruption tests | One terminal state per run | -| CP-005 | Approved retention fingerprint is carried into execution | Policy serialization and mutation tests | Restic defaults are not trusted | -| CP-006 | Contract/version/auth failure precedes action dispatch | Negative contract tests | No state change on denial | -| CP-007 | OS peer identity and current operator-group membership | Linux peer-credential integration tests | Socket permissions alone are insufficient | -| CP-008 | Startup reconciliation leases abandoned runs and locks | Kill/restart tests | Reconciliation is idempotent | -| CP-009 | Platform adapters implement one shared contract | Linux adapter and Windows test-double suite | No platform fields in public protocol | -| CP-010 | Backup success emits at most one retention trigger | Idempotency and failure-path tests | Trigger occurs after lock release | -| CP-011 | Protected records require current group membership and schema filtering | Authorized/denied/redaction tests | Raw journal data is never returned | - -## High-Level Design - -### System Architecture - -```text -User shell Graphical user session - | | - v v -/usr/local/bin/timelocker timelocker-tray - | | - +---------- local protocol client -----+ - | - platform transport adapter - | - Linux: /run/timelocker/control.sock - Windows: protected named pipe - | - v - root/system TimeLocker backend - | | | - v v v - Run store Action Repository - and audit policy mutation lock - | | - +--------+---------+ - v - Backup / retention workers - | - v - Restic -``` - -### Components and Changes - -- **System launcher** - - Install root-owned `timelocker` and `tl` launchers on the normal system - path. - - Resolve one immutable release manifest and never fall back to pyenv, a - checkout, or a user virtual environment. - - Keep release code executable but store configuration, credentials, run - state, and environment files outside the release tree with stricter modes. - -- **Action classifier** - - Classify every public operation as `user_local_read`, - `user_local_mutation`, `system_read`, `system_action`, or - `administrator_maintenance`. - - Only the two system categories use the backend contract. - - Unknown actions fail closed. - -- **Local control server** - - Own protocol negotiation, request bounds, peer authentication, - authorization, dispatch, audit, and response redaction. - - Provide only allowlisted operations: health, run list/detail, diagnostic - list, schedule summary, backup request, retention request, and future UI - availability. - - Never accept executable paths, raw Restic arguments, environment maps, - repository credentials, or unrestricted filesystem paths. - -- **Platform security adapters** - - Linux: systemd socket/service, `SO_PEERCRED`, NSS group resolution, file - modes, atomic filesystem storage, and `flock`. - - Windows: service and named-pipe ACL/token adapter implementing the same - domain interfaces. - -- **Run store** - - Persist one JSON document per run using temporary-file, `fsync`, and atomic - replace. - - Keep a bounded append-only diagnostic stream with structured codes and - safe summaries. - - Reconcile non-terminal runs against process/lease ownership at backend - startup. - -- **CLI system client** - - Add focused `SystemControlClient`; do not expand `CLIServiceManager` with - backend implementation details. - - Add `runs list`, `runs show`, and `logs view --scope system`. - - Preserve `logs view --scope local` and make the selected scope visible in - output. - -- **Independent tray** - - Move tray construction and platform callbacks behind the standalone tray - entry point. - - Poll or subscribe through the protocol adapter with bounded reconnect and - stale-state handling. - -- **Backup and retention workers** - - Use the same repository lock and run-record writer. - - Emit structured state transitions and safe diagnostic codes. - - Emit the post-backup retention request only after terminal backup success - and lock release. - -### Data Models - -#### Protocol envelope - -```text -Request { - protocol_version: integer - request_id: UUID - action: enum - parameters: action-specific bounded object -} - -Response { - protocol_version: integer - request_id: UUID - status: ok | denied | conflict | unavailable | invalid | failed - result: action-specific allowlisted object or null - error_code: stable code or null - safe_summary: bounded string or null -} -``` - -#### RunRecord - -```text -RunRecord { - schema_version: integer - run_id: UUID - operation: backup | retention - trigger: scheduled | backup_success | explicit | retry | recovery - target_id: opaque stable identifier - policy_fingerprint: optional digest - started_at: UTC timestamp - completed_at: optional UTC timestamp - state: queued | running | succeeded | failed | skipped | interrupted - result_code: stable code - safe_summary: bounded string - counters: allowlisted numeric map -} -``` - -`RunRecord` excludes repository URIs, credentials, environment values, raw -commands, source paths, selection contents, and raw Restic output. - -#### DiagnosticRecord - -```text -DiagnosticRecord { - schema_version: integer - record_id: UUID - run_id: optional UUID - timestamp: UTC timestamp - level: info | warning | error - component: allowlisted component code - message_code: stable code - safe_summary: bounded string -} -``` - -#### SystemPolicy - -```text -SystemPolicy { - protocol_version: integer - operator_group: string - socket_or_pipe: platform-owned identifier - max_request_bytes: integer - max_response_records: integer - retention_policy: explicit values and approved fingerprint -} -``` - -The policy file is root-owned and validated before the backend starts. - -### Data Flow - -#### Protected read - -1. CLI or tray connects through the platform transport. -2. Transport adapter obtains kernel/OS peer identity. -3. Authorization service resolves current group membership. -4. Contract validates version, action, request size, and parameters. -5. Run store returns bounded structured records. -6. Response serializer projects only the action's allowlisted fields. -7. Audit records the caller UID/account, action, decision, record count, and - result code without protected payload contents. - -#### Backup-triggered retention - -1. Backup worker acquires the repository lock and creates a running record. -2. Backup completes and atomically writes terminal success. -3. Backup releases the lock. -4. Trigger coordinator records an idempotency key derived from backup run ID - and policy fingerprint. -5. Retention worker acquires the repository lock and creates a distinct run. -6. Retention result is recorded independently. - -#### Tray status - -1. Tray starts in the user session and connects as the user. -2. Unauthorized users receive a generic unavailable/denied state with no - protected metadata. -3. Authorized users receive current and recent structured records. -4. Tray reconnects with bounded backoff and never blocks backend work. - -## Low-Level Design - -### Algorithms and Logic - -#### Authorization - -```text -authorize(connection, action): - peer = transport.peer_identity(connection) - if peer is unavailable: - deny GENERIC_ACCESS_DENIED - policy = load_validated_root_policy() - if not group_resolver.is_current_member(peer.uid, policy.operator_group): - audit denied action without protected parameters - deny GENERIC_ACCESS_DENIED - if action not in allowlist_for_operator_group: - deny GENERIC_ACCESS_DENIED - return AuthorizedPrincipal(peer.uid, peer.pid, policy.operator_group) -``` - -The Linux group resolver uses the account database, not only the peer process's -inherited supplementary-group list. It recognizes both the account's primary -group and supplementary memberships. Authorization is recomputed per request -and fails closed when the peer account, configured group, or current membership -cannot be resolved. No positive membership result is cached across requests. - -#### Atomic run transition - -```text -transition(run_id, expected_states, new_state, update): - acquire run-store lock - current = read and validate record - require current.state in expected_states - require current is not terminal - candidate = schema_validate(current + update + new_state) - write temporary file, fsync, atomic replace, fsync directory - release lock -``` - -Terminal-to-terminal transitions fail without modifying the record. - -#### Response projection - -```text -project(action, records): - schema = response_schema_for(action) - bounded = records[:schema.max_records] - return [schema.copy_allowlisted_fields(record) for record in bounded] -``` - -### Function Signatures and Interfaces - -```python -class PeerIdentityProvider(Protocol): - def peer_identity(self, connection: object) -> "PeerIdentity": ... - -class GroupMembershipResolver(Protocol): - def is_current_member(self, uid: int, group_name: str) -> bool: ... - -class LocalControlTransport(Protocol): - def serve(self, handler: "ControlRequestHandler") -> None: ... - -class RunRecordStore(Protocol): - def create(self, record: "RunRecord") -> None: ... - def transition(self, run_id: UUID, transition: "RunTransition") -> "RunRecord": ... - def list(self, query: "RunQuery") -> list["RunRecord"]: ... - def get(self, run_id: UUID) -> "RunRecord | None": ... - def reconcile_interrupted(self, active_leases: set[str]) -> list[UUID]: ... - -class SystemControlClient(Protocol): - def list_runs(self, query: "RunQuery") -> list["RunRecordView"]: ... - def get_run(self, run_id: UUID) -> "RunRecordView": ... - def list_diagnostics(self, query: "DiagnosticQuery") -> list["DiagnosticView"]: ... - def request_backup(self, request: "BackupActionRequest") -> "ActionReceipt": ... - def request_retention(self, request: "RetentionActionRequest") -> "ActionReceipt": ... -``` - -### Error Handling - -- Connection absence returns `SYSTEM_BACKEND_UNAVAILABLE` and the manual - service-health command. -- Authentication and authorization failures return one - `SYSTEM_ACCESS_DENIED` response without confirming resource existence. -- Version mismatch returns `CONTRACT_VERSION_UNSUPPORTED` with supported - version bounds and no protected state. -- Invalid or oversized requests are rejected before dispatch. -- Stale locks are recovered only through lease reconciliation. -- Store corruption moves the invalid record to a root-only quarantine - directory and emits a safe diagnostic; it does not silently discard history. -- Tray failures are local to the tray. CLI and scheduled workers do not import - tray code and cannot emit tray-toolkit warnings. - -### Security, Trust, and Access - -- `/run/timelocker` is root-owned and not writable by clients. -- The Linux socket is `root:timelocker-operators` mode `0660`. -- `/var/lib/timelocker`, its run-store and quarantine directories, and - `/etc/timelocker` remain `root:root`, inaccessible to non-root users, and - non-writable through symlink traversal; clients read none of them directly. -- Run-store writes use root-created files with restrictive modes, validated - UUID-derived names, same-directory temporary files, no-follow semantics, - atomic replacement, and directory `fsync`. -- Group membership is necessary but not sufficient: the server verifies peer - identity and current membership for each request. -- The backend drops requests containing unknown fields, executable paths, - environment maps, raw arguments, or unbounded strings. -- Audit records decisions, not secret-bearing payloads. The audit sink is - root-only and distinct from operator-visible diagnostics; system-log - projections never return peer UIDs, account names, or another caller's audit - trail. -- `safe_summary` values are selected from bounded templates keyed by stable - diagnostic codes. They are never copied from exception strings, subprocess - output, command arguments, environment values, repository URIs, or protected - paths. -- The transport enforces bounded request size, read/idle timeouts, connection - concurrency, and response pagination before allocating unbounded work. -- The operator group does not imply repository credential access, raw journal - access, arbitrary restore, schedule editing, retention-policy editing, or - administrator maintenance. -- Windows named-pipe security must derive the caller token and current group - membership rather than trust payload identity. - -### Migration and Compatibility - -1. Install new backend, socket, group, run-store directory, and launchers in a - disabled/staged state. -2. Preserve the current backup timer and root environment file. -3. Wrap scheduled execution with run recording and shared locking while leaving - the backup command semantics unchanged. -4. Validate authorized and denied reads before enabling tray or actions. -5. Remove tray construction from `NotificationService` only after the - independent tray client is available or explicitly disabled. -6. Enable backup requests, retention triggers, and tray actions separately. -7. Retain the prior release and unit assets for rollback. - -Existing `timelocker` and `tl` package entry points remain. Existing -`logs view` behavior becomes `--scope local` and remains the default. - -### Slice Boundary And Residual Architecture - -| Design target | In this slice | Out of this slice | Follow-up destination | Blocks closure? | -|---------------|---------------|-------------------|-----------------------|-----------------| -| Linux system launcher and backend | Full Linux implementation and live acceptance | Other Linux init systems beyond capability reporting | Backlog after Linux reference acceptance | no | -| Windows portability | Shared contracts and adapter test double | Live Windows service/named-pipe implementation | Roadmap/platform follow-up | no | -| Operator system views | Runs and sanitized diagnostics | Direct/raw journald access | Rejected for least privilege | no | -| Independent tray | Linux Mint Cinnamon/X11 client and headless isolation | Full desktop UI | Existing UI backlog | no | -| Retention | Approved 5/4/12/3 policy, three triggers, no prune | Prune automation | Backlog/spec if requested | no | -| User partitions | No implementation | User-scoped selections and restores | GitHub issue #70 | no | - -## Validation Strategy - -| Validation | Covers | Evidence Location | Residual Risk | -|------------|--------|-------------------|---------------| -| Protocol/model unit and property tests | Requirements 4-6, CP-003-CP-011 | `verification.md`, CI | Platform kernels still need integration evidence | -| Linux socket authorization integration tests | Requirement 4 AC8-AC11, CP-007, CP-011 | `verification.md` | NSS behavior varies by deployment | -| CLI launcher and action-routing tests | Requirements 1-2 | `verification.md` | Live authorization-agent behavior | -| Headless import and tray lifecycle tests | Requirement 3 | `verification.md` | Desktop-environment diversity | -| Backup/retention lock and trigger tests | Requirement 5 | `verification.md` | Restic/storage timing under production load | -| Live Mint systemd acceptance and rollback rehearsal | Success criteria | `verification.md` | One validated Linux environment | -| `review-timelocker` security and operations review | Trust boundary and recovery | review artifact or verification log | Findings must be resolved before rollout | - -## Downstream Task Guidance - -- Required checkpoints before implementation: requirements approval, design - approval, complete traceability, and no unresolved blocking decisions. -- CP-007, CP-008, CP-010, and CP-011 require explicit negative and - interruption-path tests. -- Do not reuse the existing `AccessManager` session model for OS peer - authorization. -- Do not make `timelocker-operators` a member of `systemd-journal` or grant it - access to repository credentials. -- Run security review after the first complete backend/authorization slice and - again before live rollout. - -## Operational Considerations - -- Group membership additions normally require a new login session before - filesystem socket access is available; removals are rejected immediately by - server-side NSS revalidation. -- Backend health, protocol version, run-store corruption, denied requests, - trigger conflicts, and interrupted-run reconciliation need stable diagnostic - codes. -- Rollout must preserve the working 03:30 backup timer until a replacement has - completed backup and restore acceptance. -- A rollback restores prior launchers and units but preserves run records and - the approved retention policy. -- Raw journal inspection remains an administrator troubleshooting operation. - -## Open Questions - -No design-blocking questions remain. The independent retention catch-up schedule -is supported but disabled in the initial production profile; enabling it is a -separate operator decision with duplicate-window validation. - -## Related Artifacts - -- Requirements: `requirements.md` -- Canonical context: `canonical-context.md` -- Change Impact: `change-impact.md` -- Tasks: `tasks.md` -- Traceability: `traceability.md` -- Verification: `verification.md` - -## Reconciliation - -Reviewed against the 2026-07-26 requirements revision. AC10-AC11 system-record -authorization, metadata-free denial, current group membership, and local/system -log separation remain fully represented. The security review additionally -clarified fail-closed NSS resolution, root-only audit data, safe-summary -provenance, storage hardening, and transport resource bounds. diff --git a/docs/specs/009-system-cli-tray-retention/requirements.md b/docs/specs/009-system-cli-tray-retention/requirements.md deleted file mode 100644 index 1287ce0..0000000 --- a/docs/specs/009-system-cli-tray-retention/requirements.md +++ /dev/null @@ -1,456 +0,0 @@ ---- -title: System CLI, independent tray, and retention requirements -doc_type: spec -artifact_type: requirements -status: active -owner: Auriora Team -last_reviewed: 2026-07-26 ---- - -# Requirements - -## Introduction - -TimeLocker now runs the machine's production backup through a root-owned, -systemd-managed installation, but operators must invoke a virtual-environment -path, retention remains manual, and ordinary CLI service construction can try -to initialize desktop tray components. The tray is currently an in-process -notification integration rather than an independent desktop client, and -operation history is not yet a single durable cross-process contract. - -This package defines a coherent system-operations experience: a stable -system-path command, an independent per-user tray process, a local authenticated -control/status boundary with group-authorized privileged execution, and -independently runnable retention with backup-success, scheduled, and explicit -triggers plus visible outcomes. - -## Goals - -- Install a stable `timelocker` command on the system path and retain `tl` as a - compatible alias. -- Let user-local commands remain in the caller's context while protected system - actions cross an explicit, reviewable backend boundary authorized by current - operating-system group membership. -- Remove all tray initialization from normal CLI, scheduler, and backend - execution paths. -- Run the tray as an independent process in the signed-in user's graphical - session. -- Let the tray observe current work, last backup, last retention run, and next - scheduled runs, and safely request an on-demand backup. -- Restrict system-backup status and control to members of a root-controlled - operator group whose identity is verified by the operating system. -- Automate the accepted production retention policy as an independently - runnable operation that can be triggered immediately after a successful - scheduled backup, by its own schedule, or by an explicit request: keep 5 - daily, 4 weekly, 12 monthly, and 3 yearly snapshots, grouped by host and - paths, without prune. -- Keep shared tray, control/status, and run-state contracts platform-neutral, - with replaceable Linux and Windows adapters. -- Preserve safe rollback, headless operation, secret isolation, and failure - independence between backup, retention, CLI, and tray processes. - -## Non-Goals - -- Building the future full desktop UI or presenting an unimplemented UI as - available. -- Implementing a network-accessible REST API, hosted control plane, or remote - administration service. -- Running the tray as root or granting the desktop process direct access to - protected repository credentials. -- Automatically elevating every TimeLocker command or bypassing an operator's - authorization policy. -- Enabling Restic prune as part of the initial automated retention policy. -- Allowing retention failure to make an otherwise successful backup appear to - have failed, or vice versa. -- Implementing user-scoped management of the user's accessible subset of the - system backup. That capability belongs in the product backlog and must later - receive its own access-control and restore-boundary specification. -- Completing every Linux desktop and Windows adapter in the initial delivery. - The initial implementation may validate one Linux environment first, but it - must not embed Linux, GTK, systemd, Unix-socket, or filesystem-layout - assumptions in shared contracts and domain services. - -## Glossary - -| Term | Definition | -|------|------------| -| System command | The stable `timelocker` executable discoverable through the normal system `PATH`. | -| System backend | The privileged, headless execution boundary that owns machine-level configuration and scheduled operations. It does not imply a network service. | -| Tray client | An unprivileged process running in a user's graphical session and communicating through the approved local control/status boundary. | -| Elevation broker | The narrow operating-system authorization path used to request a privileged operation without making the whole desktop or CLI session privileged. | -| System operator group | A root-controlled operating-system group whose members may inspect system-backup status and request allowlisted system-backup actions. | -| Run record | Durable, secret-free status for one backup or retention attempt, including type, target, timestamps, state, result, and safe error summary. | -| Access domain | The files, metadata, snapshots, and restore destinations a user is authorized to inspect or modify; future user partitions may never expand this boundary. | - -## Durable Source Baseline - -| Source | Current behavior relied on | Confidence | Notes | -|--------|----------------------------|------------|-------| -| `CHARTER.md` | TimeLocker is CLI-first and may provide optional tray integration, automation, monitoring, and schedules. | high | The proposed work is within the current mandate and remains short of a full GUI or hosted service. | -| `pyproject.toml` | Both `timelocker` and `tl` resolve to `TimeLocker.cli:main` after package installation. | high | Package entry points do not by themselves provide the machine deployment's stable system-path launcher. | -| `src/TimeLocker/monitoring/notification_service.py` | Notification service construction currently initializes `SystemTrayIntegration` in-process. | high | This causes CLI/headless coupling and produced warnings during a retention dry run. | -| `src/TimeLocker/monitoring/system_tray_integration.py` | Platform tray rendering and callbacks exist as a library component. | high | It is not an independently managed process or a backend client. | -| `src/TimeLocker/cli_modules/commands/schedule.py` | Generated schedules currently execute `tl backup create` only. | high | Retention is not chained or separately scheduled. | -| `src/TimeLocker/cli_modules/commands/repositories.py` | `tl repos forget` supports explicit daily, weekly, monthly, yearly, dry-run, and optional prune values. | high | Production dry run passed with 5/4/12/3 and no prune. | -| `docs/guides/developer/scheduling-guide.md` | Backup schedules and the current manual-retention boundary are documented. | high | Promotion target for accepted installation and maintenance behavior. | -| `docs/SYSTEM-TRAY-SETUP.md` | Current tray documentation describes optional in-process monitoring and notifications. | high | Must be replaced or rewritten when independent tray behavior is implemented. | - -## Durable Impact - -| Durable area | Action | Target | Notes | -|--------------|--------|--------|-------| -| requirements | add | `docs/1-requirements/system-operations.md` | Promote privilege, status, retention, and process-boundary invariants. | -| architecture | modify | `docs/2-architecture/system-architecture.md` | Document CLI, backend, tray, local control/status, and durable run-state boundaries after implementation. | -| architecture | modify | `docs/2-architecture/scheduling-system.md` | Document backup-success, independent-schedule, and explicit retention triggers plus overlap control. | -| implementation | modify | `docs/3-implementation/service-layer-integration.md` | Identify the owning services and prohibit UI initialization in headless execution. | -| runbook | modify | `docs/guides/developer/scheduling-guide.md` | Document installation, retention staging, rollback, and validation. | -| user guide | modify | `docs/guides/user/installation.md` | Document system-path command and supported elevation behavior. | -| user guide | supersede | `docs/SYSTEM-TRAY-SETUP.md` | Replace current in-process assumptions with the independent tray lifecycle. | - -## Staged Readiness - -- **Current stage:** design and task-plan review -- **Next stage:** implementation -- **Ready to implement when:** the design, task plan, traceability, and - verification package pass lifecycle validation, the security and operations - review has no unresolved blocking finding, and the project owner explicitly - approves implementation. -- **Design-first exception:** no -- **Optional artifacts recommended:** none currently; create - `canonical-context.md` only if a concrete authority conflict is found. -- **Downstream review needed:** implementation-slice security and architecture - review at T004, then full expert review before closure. -- **Package sequencing:** Spec 007 is closed and its durable release-readiness - gates remain applicable independently. Spec 009 is the only active package; - it must produce new implementation and validation evidence rather than reuse - Spec 007 evidence as proof. - -## Requirements - -### Requirement 1: Stable system-path command - -**User Story:** As an operator, I want to invoke TimeLocker by name from a -normal shell, so that machine operations do not depend on knowing an internal -release or virtual-environment path. - -**Priority:** must-have - -#### Acceptance Criteria - -1. GIVEN a supported system installation, WHEN the operator resolves - `timelocker`, THEN it SHALL execute the current immutable TimeLocker release - through a root-owned system-path launcher. -2. THE `tl` alias SHALL remain available and behaviorally compatible. -3. GIVEN a release switch or rollback, WHEN either command is invoked, THEN it - SHALL resolve the same selected release without rewriting user shell files. -4. IF the selected release is missing or invalid, THEN the launcher SHALL fail - without falling back to a mutable checkout, user environment, or legacy - root configuration overlay. - -### Requirement 2: Contextual privilege and authorization - -**User Story:** As an operator, I want TimeLocker to use system authority only -through a narrow authorized backend, so routine inspection remains convenient -without widening the caller process's privilege. - -**Priority:** must-have - -#### Acceptance Criteria - -1. GIVEN a read-only operation whose data is accessible to the caller, WHEN it - runs, THEN TimeLocker SHALL remain in the caller's security context. -2. GIVEN an allowlisted protected operation, WHEN a caller invokes it, THEN - TimeLocker SHALL keep the caller process unprivileged, send the bounded - request to the privileged local backend, and authorize it from current - operating-system identity and operator-group membership. -3. IF the backend is unavailable or the caller is not currently authorized, - THEN the command SHALL fail promptly with an exact safe next action; it - SHALL NOT wait indefinitely or fall back to direct elevated execution. -4. THE PRIVILEGED BOUNDARY SHALL NOT forward repository passwords, unrestricted - environment variables, display/session credentials, or arbitrary executable - paths. -5. THE SYSTEM SHALL prevent recursive launcher execution and SHALL record a - secret-free audit event identifying the requested operation, caller, - decision, and result. -6. A denied or failed authorization SHALL leave configuration, schedules, - repositories, and run state unchanged. - -### Requirement 3: Independent tray process - -**User Story:** As a desktop user, I want the TimeLocker tray to run separately -from backup commands, so that desktop integration neither destabilizes nor -pollutes headless operations. - -**Priority:** must-have - -#### Acceptance Criteria - -1. CLI, scheduler, retention, and backend processes SHALL NOT import, - initialize, or shut down a platform tray implementation during ordinary - command execution. -2. THE tray SHALL run as a separately installable and independently restartable - process in the signed-in user's graphical session, never as root. -3. IF the tray is absent, crashes, or cannot connect, THEN scheduled backup and - retention SHALL continue unaffected. -4. IF the backend is unavailable, THEN the tray SHALL display a disconnected - or unavailable state without presenting stale success as current. -5. Starting more than one tray instance for the same user SHALL be prevented or - resolved deterministically. -6. The tray lifecycle SHALL support the declared Linux reference environment - first and retain explicit capability boundaries for other Linux desktop - environments and Windows. -7. Shared tray lifecycle, status, action, and run-state logic SHALL be - independent of Linux, GTK, systemd, Unix sockets, Windows services, and - Windows notification-area APIs; platform behavior SHALL be supplied through - replaceable adapters. -8. Initial live acceptance SHALL target Linux Mint Cinnamon/X11. The design - SHALL define capability-based adapter contracts for common Linux desktop - environments and supported Windows versions, with unsupported capabilities - reported explicitly instead of inferred from operating-system name alone. - -### Requirement 4: Local control and status contract - -**User Story:** As a desktop user, I want the tray to show what TimeLocker is -doing and request a backup safely, so that I can understand and operate the -machine backup without handling protected credentials. - -**Priority:** must-have - -#### Acceptance Criteria - -1. THE backend SHALL expose an authenticated, local-only, versioned contract - for current operation state, last backup run, last retention run, next - scheduled runs, and safe error summaries. -2. Run state SHALL persist across CLI and scheduler processes and remain - inspectable after process exit and system restart. -3. Backup and retention run records SHALL be distinguishable and SHALL include - start time, completion time, state, result, target identity, and a - secret-free diagnostic summary. -4. THE tray MAY request an on-demand backup only through an allowlisted backend - action that performs normal authorization, validation, locking, and audit. -5. IF a conflicting backup or retention operation is active, THEN a new request - SHALL be rejected or queued according to one documented policy; it SHALL NOT - start an unsafe concurrent Restic mutation. -6. Status and control messages SHALL NOT contain repository passwords, cloud - credentials, unrestricted environment data, or unredacted Restic output. -7. The contract SHALL reserve a future UI-launch action without claiming that - a UI exists; until implemented, the tray action SHALL be hidden or clearly - unavailable. -8. Only members of the configured system operator group SHALL be allowed to - inspect system-backup status or request an on-demand system backup. Group - configuration and membership SHALL be controlled outside the unprivileged - client and SHALL require system authority to change. -9. The local contract SHALL bind authorization to operating-system peer - identity and current group membership. It SHALL reject self-asserted - identities, unauthorized local users, stale authorization, arbitrary - executable paths, and arguments outside the allowlisted action schema - without disclosing protected status or selection metadata. -10. System-scope run history and diagnostic-log views SHALL require current - membership in the configured system operator group. Responses SHALL contain - only allowlisted, secret-free fields and SHALL NOT disclose raw environment - values, repository credentials, protected source paths, or unrestricted - journal content. -11. User-local application logs SHALL remain distinct from system-scope run and - diagnostic records. An authorization failure SHALL NOT disclose whether a - protected run, repository, selection, schedule, or diagnostic record exists. - -### Requirement 5: Automatic retention as an independent operation - -**User Story:** As an operator, I want TimeLocker to apply my retention policy -automatically after a successful scheduled backup while remaining independently -runnable, so that snapshot cleanup is consistent without making backup success -a general prerequisite for retention. - -**Priority:** must-have - -#### Acceptance Criteria - -1. THE production policy SHALL explicitly keep 5 daily, 4 weekly, 12 monthly, - and 3 yearly snapshots, SHALL explicitly group by `host,paths`, and SHALL - leave prune disabled. -2. BEFORE first enablement or any policy change, THE SYSTEM SHALL support a dry - run using the same repository identity, credential source, snapshot filters, - explicit grouping, policy values, and prune setting as the eventual - mutation, and SHALL record those inputs as one reviewable policy fingerprint. -3. Retention SHALL use a separately identifiable operation and service from - backup. It SHALL support three trigger modes without merging backup and - retention results: successful scheduled-backup completion, an independent - schedule, and an explicit operator request. -4. Retention SHALL NOT run while a backup or another repository mutation is - active, and a skipped conflict SHALL be visible as a run result rather than - silently lost. -5. A retention result SHALL NOT rewrite a backup result, and backup success or - failure SHALL NOT change the eligibility of an independently approved - retention run. -6. Each retention attempt SHALL produce a durable run record visible through - the CLI and tray, including whether it was a dry run and how many snapshots - were selected or removed, together with the applied policy fingerprint. -7. Disabling automatic retention SHALL be reversible without disabling - backups, and rollback guidance SHALL preserve the manual forget command. -8. First enablement and every change to the repository, credential source, - snapshot filters, grouping, retention values, or prune setting SHALL require - explicit operator approval of a successful dry run with the identical policy - fingerprint. A dry run alone SHALL NOT enable mutation. -9. Retention eligibility SHALL be independent of backup success, failure, - absence, age, or freshness. Retention MAY run at any scheduled or explicitly - requested time when its policy is approved and no conflicting repository - mutation is active. -10. In the production automation profile, each successful scheduled backup - SHALL trigger at most one retention attempt immediately after the backup has - recorded terminal success and released its repository lock. A failed, - cancelled, skipped, or interrupted backup SHALL NOT emit that success - trigger; this SHALL NOT prevent a later independent or explicit retention - run. -11. A backup-triggered retention attempt SHALL acquire the normal repository - mutation lock and SHALL create its own run record. Its success, failure, or - conflict result SHALL NOT alter the preceding backup's terminal result. - -### Requirement 6: Installation, upgrade, and recovery safety - -**User Story:** As an operator, I want the launcher, backend, schedules, and -tray to upgrade and roll back coherently, so that a partial deployment cannot -silently select the wrong code or privilege boundary. - -**Priority:** must-have - -#### Acceptance Criteria - -1. System launchers, privileged units, local contract definitions, and tray - startup assets SHALL be installed from one committed release artifact or a - compatibility-checked set of artifacts. -2. Upgrade SHALL validate launcher resolution, backend health, contract - compatibility, timer state, and tray reconnection before retiring the prior - release. -3. Rollback SHALL restore the prior selected release and compatible system - assets without deleting run records or changing retention policy. -4. Headless installations SHALL remain supported without GUI dependencies or - tray warnings. -5. Shared protocol and domain components SHALL support Linux and Windows - adapters without changing their public schema or authorization semantics. - Platform support claims SHALL identify the validated adapter capabilities - and environments rather than assuming all environments behave alike. -6. On startup after a process crash or system restart, THE SYSTEM SHALL - reconcile every non-terminal run and lock against its owning process or - lease, mark abandoned attempts with a durable `interrupted` result, and make - stale locks safely recoverable without creating duplicate terminal records. - -## Correctness Properties - -- **CP-001:** An operation executes with elevated authority if and only if its - centrally classified action requires that authority and authorization was - granted. -- **CP-002:** Removing, stopping, or crashing every tray process cannot stop, - start, or alter a scheduled backup or retention run by itself. -- **CP-003:** At most one mutating Restic operation for the protected repository - is active at any time. -- **CP-004:** Every completed or failed backup and retention attempt yields one - durable terminal run record without secret material. -- **CP-005:** The enabled production retention invocation always carries the - explicit tuple `(group-by=host,paths, 5, 4, 12, 3, prune=false)` and a matching - approved dry-run fingerprint; no CLI or Restic default may change it. -- **CP-006:** A denied elevation or incompatible client/backend contract causes - no privileged mutation. -- **CP-007:** A caller can inspect system-backup status or request a system - backup if and only if its operating-system peer identity is currently a - member of the configured system operator group. -- **CP-008:** Every non-terminal run left by a dead process or expired lease is - reconciled exactly once to an interrupted terminal record before its lock can - be reused. -- **CP-009:** Replacing a Linux or Windows platform adapter cannot change the - shared status, action, authorization, locking, or run-record contracts. -- **CP-010:** One successful scheduled backup emits at most one - backup-success retention trigger after terminal success and lock release; - every resulting retention attempt remains independently locked and recorded. -- **CP-011:** A system-scope run or diagnostic record is returned if and only - if the server derives the caller's operating-system identity and confirms - current membership in the configured system operator group; returned fields - are a strict subset of the allowlisted response schema. - -## Technical Context - -- **Language/Version:** Python 3.12-3.13 -- **Primary Dependencies:** Typer, Restic, existing monitoring and scheduling - services, platform adapters such as systemd/desktop integration on Linux and - native service/session integration on Windows, and optional tray dependencies -- **Target Platform:** Linux Mint Cinnamon/X11 for initial live acceptance; - architecture for common Linux desktop environments and supported Windows - versions through capability-based adapters; preserve an explicit macOS - compatibility boundary -- **Constraints:** local-first, least privilege, root-owned production - configuration, no secret-bearing IPC, immutable release selection, no - unsafe backup/retention overlap -- **Performance Goals:** status reads should feel interactive and must not - initialize the repository or block on Restic; tray disconnection must not - delay backend work - -## Success Criteria - -- **SC-001:** `command -v timelocker` and `command -v tl` resolve the selected - system release without a project checkout or virtual-environment path in the - caller's command. -- **SC-002:** A representative read-only command runs in the caller's context, - while a representative protected command crosses the privileged backend, - checks current operator-group authorization once, and records its result - without exposing secrets. -- **SC-003:** CLI and systemd retention runs produce no tray initialization - attempt or tray warning. -- **SC-004:** Restarting or terminating the tray leaves scheduled operations - unaffected and the tray recovers current and last-run state after reconnect. -- **SC-005:** An approved on-demand tray backup follows the same lock, - credentials, configuration, and run-record paths as a scheduled backup. -- **SC-006:** A dry run and one controlled automatic retention run prove the - explicit `host,paths`, 5/4/12/3, no-prune policy and matching approval - fingerprint, and appear in both CLI and tray status. -- **SC-007:** An authorized system-operator-group member can inspect status and - request one allowlisted backup, while an otherwise valid local user receives - no protected status, selection metadata, or control capability. -- **SC-008:** Killing a backup or retention process and restarting the backend - produces one interrupted terminal record, releases or recovers its stale lock, - and lets the tray reconnect without showing the attempt as still running. -- **SC-009:** Shared contract tests pass unchanged against the Linux adapter and - a Windows adapter test double, while Linux Mint Cinnamon/X11 live acceptance - proves the first supported desktop environment. -- **SC-010:** One controlled successful scheduled backup produces a distinct - subsequent retention run, while controlled failed and interrupted backups do - not emit the success trigger and a later explicit retention run remains - possible. -- **SC-011:** An authorized operator can view system backup and retention runs - through the CLI and tray, while an unauthorized local user and a user removed - from the operator group receive the same metadata-free denial and cannot read - protected system log files or raw journal records through TimeLocker. - -## Resolved Design Questions - -- System reads and allowlisted actions use the privileged local backend and - current operator-group authorization; administrator maintenance continues - through the platform's normal explicit elevation mechanism. -- Linux uses a systemd-managed Unix-domain socket with kernel peer credentials; - shared contracts retain a Windows named-pipe adapter boundary. -- The tray reads structured state exclusively through the backend contract. -- Successful scheduled backups trigger retention after terminal success and - lock release. The independent schedule remains supported but initially - disabled until an operator approves its cadence. -- A new atomic run store under root-owned system state becomes authoritative - for system operations; legacy user-local logs remain a separate local scope. - -## Routed Future Work - -- [GitHub issue #70](https://github.com/Auriora/TimeLocker/issues/70) tracks - partitioned user views and user-scoped selection/restore management. A - signed-in user may define selection sets only within their access domain, - inspect only the corresponding partition of snapshot content and metadata, - and restore only to authorized destinations without learning about or - controlling the system selection set. That future work must define selection - ownership, partition identity, snapshot filtering, restore destinations, - symlink, hard-link, ACL, ownership, and special-file behavior, privilege - boundaries, and defenses against using the system service to read or write - inaccessible paths. - -## Related Artifacts - -- Canonical context: `canonical-context.md` -- Change impact: `change-impact.md` -- Design: `design.md` -- Tasks: `tasks.md` -- Traceability: `traceability.md` -- Verification: `verification.md` diff --git a/docs/specs/009-system-cli-tray-retention/tasks.md b/docs/specs/009-system-cli-tray-retention/tasks.md deleted file mode 100644 index 44d18a6..0000000 --- a/docs/specs/009-system-cli-tray-retention/tasks.md +++ /dev/null @@ -1,342 +0,0 @@ ---- -title: System CLI, independent tray, retention, and control tasks -doc_type: spec -artifact_type: tasks -status: draft -owner: Auriora Team -last_reviewed: 2026-07-26 ---- - -# Tasks - -**Input**: `canonical-context.md`, `requirements.md`, `design.md`, -`change-impact.md`, `traceability.md`, and `verification.md` - -**Prerequisites**: Requirements and design approved; no implementation starts -until the project owner approves the task plan. - -## Task Dependency Graph - -```text -T001 -> T002 -> T003 -> T004 -T004 -> T005 -> T006 -T004 -> T007 -T004 -> T008 -T006 + T007 + T008 -> T009 -T009 -> T010 -> T011 -> T012 -``` - -## Phase 1: Shared contracts and safety foundation - -- [x] T001 Define shared protocol, action, policy, run, diagnostic, and client - models with strict validation. - - Depends on: none - - Requirements: Requirement 2 AC4-AC6; Requirement 4 AC1-AC7, AC9-AC11; - Requirement 5 AC1-AC2, AC5-AC8; Requirement 6 AC5 - - Properties: CP-001, CP-004, CP-005, CP-006, CP-009, CP-011 - - Files: new focused modules under `src/TimeLocker/system_control/`; - matching tests under `tests/TimeLocker/system_control/` - - Acceptance: Versioned bounded schemas reject unknown fields, secret-bearing - inputs, raw arguments, and invalid transitions; response projection returns - only allowlisted fields. - - Evidence: T001 complete: 70 focused tests passed with 92.7% branch-aware coverage; compileall and git diff --check passed; focused review-timelocker implementation review found no remaining actionable findings. No transport, store, CLI, or live-host behavior was changed. - - Evidence mode: validation - - [x] T001.1 Add failing contract and model tests. - - Evidence: `python3 -m pytest tests/TimeLocker/system_control ... --cov-fail-under=90` passed 70 tests at 92.7% branch-aware coverage, including contract, transition, response-projection, security-boundary, and portability cases. - - Evidence mode: validation - - [x] T001.2 Implement schemas, enums, validation, and response projection. - - Evidence: `src/TimeLocker/system_control/models.py`, `protocol.py`, and `validation.py` contain the strict frozen models, envelopes, validation helpers, immutable projections, and code-owned safe summaries exercised by the 70-test T001 run. - - Evidence mode: implementation - - [x] T001.3 Add Linux and Windows adapter protocol test doubles. - - - Evidence: `src/TimeLocker/system_control/interfaces.py` and `tests/TimeLocker/system_control/test_interfaces.py` define and exercise platform-neutral identity, membership, transport, handler, and client protocols; the final T001 command passed all 70 tests. - - Evidence mode: validation -- [x] T002 Implement atomic run/diagnostic storage, repository mutation - locking, and interrupted-run reconciliation. - - Depends on: T001 - - Requirements: Requirement 4 AC2-AC3, AC5-AC6, AC10-AC11; Requirement 5 - AC4-AC6, AC11; Requirement 6 AC6 - - Properties: CP-003, CP-004, CP-008, CP-010, CP-011 - - Files: `src/TimeLocker/system_control/`, focused storage and recovery tests - - Acceptance: Records transition atomically to exactly one terminal state; - concurrent mutations cannot overlap; abandoned runs become interrupted and - stale locks are reusable without duplicate terminal records. - - Evidence: Atomic storage, bounded diagnostics, repository mutation leases, and idempotent abandoned-run reconciliation implemented in src/TimeLocker/system_control/storage.py. Focused T002 validation passed 11 tests, including cross-process lease recovery; final Phase 1 validation remains tracked by T004. - - Status: Complete and dependency-ready for T003. - - Evidence mode: command - - [x] T002.1 Add transition, concurrency, corruption, and kill/restart tests. - - Evidence: Added transition, concurrency, corruption, persistence, bounded-stream, process-exit, and restart-reconciliation tests in tests/TimeLocker/system_control/test_storage.py; focused run passed 11 tests. - - Evidence mode: command - - [x] T002.2 Implement atomic record store and bounded diagnostic stream. - - Evidence: `src/TimeLocker/system_control/storage.py` implements `AtomicRecordStore`; `python3 -m pytest tests/TimeLocker/system_control/test_storage.py` passed 11 schema, atomic-replacement, fsync, transition, bounded-stream, filtering, and mode cases. - - Evidence mode: artifact - - [x] T002.3 Implement repository lock leases and startup reconciliation. - - - Evidence: `src/TimeLocker/system_control/storage.py` implements nonblocking `flock` leases and startup reconciliation; the 11-test storage run passed conflict, process-exit release, stale metadata, and abandoned-run cases. - - Evidence mode: artifact -- [x] T003 Implement Linux local transport and current operator-group - authorization. - - Depends on: T002 - - Requirements: Requirement 2 AC2-AC6; Requirement 4 AC1, AC4-AC11 - - Properties: CP-001, CP-006, CP-007, CP-011 - - Files: Linux adapter modules, systemd socket/service assets, security tests - - Acceptance: The server derives peer credentials, revalidates current - `timelocker-operators` membership for every protected request, rejects stale - or self-asserted identity, and leaks no protected metadata on denial. - - Evidence: Linux local transport, kernel peer-credential adapter, fresh NSS operator-group authorization, strict dispatcher/audit/redaction, root-policy loader, and least-privilege staged unit assets implemented. Focused T003 validation passed 15 tests; no units were installed or activated. - - Status: Complete and dependency-ready for T004; live socket/unit acceptance remains T010. - - Evidence mode: command - - [x] T003.1 Add authorized, unauthorized, removed-member, malformed, - oversized, and version-mismatch tests. - - Evidence: Added authorized, unauthorized, membership-removal, handler-failure, self-asserted identity, malformed JSON, oversized request, and version mismatch tests in test_dispatcher.py; Linux adapter suite also covers peer parsing and NSS failures. - - Evidence mode: command - - [x] T003.2 Implement `SO_PEERCRED`, NSS group resolver, dispatcher, audit, - and redaction. - - Evidence: `src/TimeLocker/system_control/linux_adapter.py` and `dispatcher.py` implement `SO_PEERCRED`, current NSS lookup, strict dispatch, safe denial, and audit projection; the focused T003 command passed 15 tests. - - Evidence mode: artifact - - [x] T003.3 Add root-owned policy, runtime directory, socket, and service - templates with least-privilege modes. - - - Evidence: Wheel inventory found all 3 Phase 1 assets: `system-control-policy.json`, `timelocker-control.socket`, and `timelocker-control.service`; focused policy/unit tests verified `0660` group socket access, restrictive umask, AF_UNIX-only networking, and filesystem protections. - - Evidence mode: artifact -- [x] T004 Checkpoint - Foundation security and agent-readiness review. - - Depends on: T003 - - Files: Spec artifacts and Phase 1 source/tests - - Acceptance: Focused tests pass, Spec Lifecycle Manager reports bounded task - context and traceability, and every `review-timelocker` - security/architecture finding has a recorded disposition before public CLI - or live rollout. - - Evidence mode: command - - Evidence: Phase 1 checkpoint passed: 98 focused tests passed at 88.4% coverage; Ruff check/format, compileall, wheel build and 3/3 asset inventory, git diff --check, spec lint, and T002/T003 task audits passed. The review-timelocker panel identified TLR-001 through TLR-005; all were fixed and their dispositions are recorded in verification.md. Agent Workbench diagnostics had no provider for these Python files, so executed checks and direct review are the proof. No host assets were installed or activated. - - - Status: Phase 1 complete. Real socket activation, installed permissions, live NSS, host restart, and Windows implementation remain assigned to later tasks. -## Phase 2: System CLI and authorized visibility - -- [x] T005 Implement the root-owned system launcher and centralized action - classification. - - Depends on: T004 - - Requirements: Requirement 1 AC1-AC4; Requirement 2 AC1-AC6; - Requirement 6 AC1-AC3 - - Properties: CP-001, CP-006 - - Files: packaging/install assets, launcher/action-policy modules, tests - - Acceptance: `timelocker` and `tl` resolve one immutable release; user-local - actions remain unprivileged; protected actions use the backend; invalid - release or unknown action fails closed without pyenv/checkout fallback. - - Evidence: The 22-test launcher/action-policy subset passed inside the 177-test Phase 2 run. `release_launcher.py` and `action_policy.py` enforce exact routing, immutable release resolution, and fail-closed unknown actions; the built wheel inventory contains the staged selector plus both command launcher assets. Ruff and `git diff --check` passed; no host state changed. - - Status: Phase 2 launcher/classification contract complete; live artifact integration and authorization-agent acceptance remain T009/T010. - - Evidence mode: validation - - [x] T005.1 Add launcher resolution, rollback, recursion, and routing tests. - - Evidence: 22 focused launcher/action-policy tests passed in the Phase 2 integrated test run; coverage includes resolution, switch/rollback, recursion, invalid ownership/modes/symlinks, alias compatibility, protected routing, and unknown-action denial. - - Status: Verified without changing the live selected release. - - Evidence mode: validation - - [x] T005.2 Implement immutable release launcher and action classifier. - - Evidence: `src/TimeLocker/system_control/release_launcher.py` contains `ImmutableReleaseResolver`, strict selector/manifest validation, recursion protection, and atomic selection/rollback; `src/TimeLocker/system_control/action_policy.py` contains the exact fail-closed registry. The 22-test launcher/action-policy subset and Ruff checks passed. - - Status: Repository implementation verified; live launcher installation remains T009/T010. - - Evidence mode: implementation - - [x] T005.3 Add staged install/rollback assets without changing the live - selected release. - - - Evidence: The no-isolation wheel build passed and its inventory contains `timelocker-launcher`, `tl-launcher`, and `timelocker-release-select` plus their module entry points. `test_release_entrypoints.py` and `test_release_launcher.py` prove the assets do not use pyenv, a checkout, or `/root` overlay fallback. - - Status: Assets are staged only; `/opt`, `/usr/local/bin`, systemd, and the host selector were not modified. - - Evidence mode: validation -- [x] T006 Add structured system run and diagnostic CLI views. - - Depends on: T005 - - Requirements: Requirement 4 AC1-AC3, AC6, AC8-AC11 - - Properties: CP-004, CP-006, CP-007, CP-011 - - Files: `src/TimeLocker/cli_modules/commands/monitoring.py`, focused system - client modules, CLI and integration tests - - Acceptance: `runs list`, `runs show`, and - `logs view --scope local|system` clearly distinguish local and system data; - only current operator-group members receive protected structured records. - - Evidence: Completed structured system run and diagnostic CLI views plus the bounded system-control client. The integrated system-control/CLI/help suite passed 177 tests; scoped system-control coverage is 88.2%. Ruff check and format, compileall, wheel build and asset inventory, and git diff --check passed. Review findings TLR-006 through TLR-009 were fixed. No host state changed. - - Status: Phase 2 complete. Live socket, installed launcher, current NSS membership, and authorized/denied host acceptance remain T009/T010. - - Evidence mode: validation - - [x] T006.1 Add CLI contract, compatibility, denial, and redaction tests. - - Evidence: Added CLI contract, compatibility, denial, redaction, bounded-filter, scope-validation, and protected-metadata tests. The integrated Phase 2 suite passed 177 tests; denied requests expose only safe result codes and summaries. - - Status: Verified in the integrated Phase 2 suite; live authorized and denied host acceptance remains T010. - - Evidence mode: validation - - [x] T006.2 Implement focused `SystemControlClient` integration. - - Evidence: `src/TimeLocker/system_control/client.py` provides bounded versioned Unix-socket requests, request-ID correlation, timeouts, strict line framing, safe errors, run list/show, diagnostics, and backup/retention requests. `tests/TimeLocker/system_control/test_client.py` passed within the 177-test Phase 2 run. - - Status: Repository client boundary verified; real AF_UNIX socket activation remains T009/T010. - - Evidence mode: validation - - [x] T006.3 Preserve local log behavior and correct `--config-dir`/scope - resolution without reading protected files directly. - - - Evidence: `src/TimeLocker/cli_modules/commands/monitoring.py` now provides `runs list`, `runs show`, and `logs view --scope local|system`; local logs resolve from the explicit config directory while system scope requests only backend records. `tests/TimeLocker/cli/test_monitoring_commands.py` passed within the 177-test run, including default/local compatibility, invalid scope/limits, and no direct protected-read cases. - - Status: No protected system file or journal is read directly by the user CLI. - - Evidence mode: validation -## Phase 3: Independent tray and retention - -- [x] T007 Remove tray ownership from CLI/headless services and add the - independent tray client. - - Depends on: T004 - - Requirements: Requirement 3 AC1-AC8; Requirement 4 AC1, AC4-AC9; - Requirement 6 AC1-AC5 - - Properties: CP-002, CP-006, CP-007, CP-009 - - Files: notification/monitoring services, tray entry point, platform - adapters, packaging, tray/headless tests - - Acceptance: CLI, backup, retention, scheduler, and backend paths import no - tray platform code and emit no tray warning; the user-session tray connects, - reconnects, displays authorized state, and requests only allowlisted - actions. - - Evidence: Removed platform tray ownership and exports from NotificationService/headless monitoring; added standalone timelocker-tray entry point, safe tray IPC projection, schedule status, strict action allowlist, backend absence/denial display, bounded reconnect, and singleton locking. Integrated Phase 3 repository slice passed 190 tests including import-boundary, headless, reconnect, authorization projection, singleton, and monitoring compatibility cases; no host state changed. - - Status: Repository behavior verified. Installed desktop-session and live IPC acceptance remains T009/T010. - - Evidence mode: validation - - [x] T007.1 Add import-boundary, headless, absence, crash, singleton, and - reconnect tests. - - Evidence: Added direct import-boundary, headless availability, backend absence/denial, reconnect, strict allowlist, and singleton-lock tests; all passed in the 190-test integrated slice. - - Evidence mode: validation - - [x] T007.2 Refactor notification delivery to publish structured state - without constructing `SystemTrayIntegration`. - - Evidence: `tests/TimeLocker/system_control/test_tray_process_boundary.py` verifies that `NotificationService`, CLI imports, and the monitoring package do not import or construct the platform tray; the integrated 190-test slice passed. - - Evidence mode: code - - [x] T007.3 Add standalone tray entry point and Linux Mint Cinnamon/X11 - adapter. - - - Evidence: `pyproject.toml` packages the `timelocker-tray` entry point; wheel inventory confirmed that entry point plus `tray_entry.py` and `tray_client.py`, and tray/process tests passed. Live desktop installation remains T009/T010. - - Evidence mode: code -- [x] T008 Implement approved retention execution and all three trigger modes. - - Depends on: T004 - - Requirements: Requirement 5 AC1-AC11; Requirement 6 AC1-AC3, AC6 - - Properties: CP-003, CP-004, CP-005, CP-008, CP-010 - - Files: retention policy/executor/trigger modules, scheduling integration, - unit/integration tests - - Acceptance: Dry-run approval fingerprints the complete policy; backup - success, independent schedule, and explicit request create separate locked - retention runs; failure or conflict never changes the backup result. - - Evidence: Implemented approved retention execution and all three trigger modes in the repository slice. Evidence: `src/TimeLocker/system_control/retention.py` adds exact retention-plan fingerprinting, approval-gated mutation, durable trigger claims, explicit protected request handling, and independently gated scheduling. Focused validation passed with `python3 -m pytest tests/TimeLocker/system_control/test_retention.py tests/TimeLocker/system_control/test_tray_client.py tests/TimeLocker/system_control/test_tray_process_boundary.py tests/TimeLocker/monitoring/test_system_tray_integration.py tests/TimeLocker/system_control/test_client.py --cov-reset --cov=src/TimeLocker/system_control --cov-fail-under=50` (32 passed, 58.7% coverage). `git diff --check` passed. No host state changed. - - Status: Repository implementation complete; live asset integration and host acceptance remain T009-T010. - - Evidence mode: validation - - [x] T008.1 Add policy fingerprint, approval, conflict, idempotency, and - failure-isolation tests. - - Evidence: Added nine focused tests covering complete fingerprint sensitivity, dry-run non-approval, exact approval, lock conflict, safe failure, durable idempotency, non-success rejection, independent schedule configuration, and protected request projection. The system-control suite passed 149 tests at 83.09% branch-aware coverage. - - Evidence mode: validation - - [x] T008.2 Implement retention executor and protected explicit request. - - Evidence: `src/TimeLocker/system_control/retention.py` implements canonical fingerprinting, separate durable runs, shared repository locking, exact mutation approval, dry-run behavior, safe adapter projection, and the protected request handler; the nine retention tests passed. - - Evidence mode: code - - [x] T008.3 Implement post-backup success trigger after terminal record and - lock release. - - Evidence: `test_success_trigger_is_durable_and_idempotent_across_restart` and `test_success_trigger_rejects_non_successful_backup` passed, proving durable at-most-once claiming and rejection of non-success terminal records. - - Evidence mode: validation - - [x] T008.4 Implement independently configurable schedule, disabled in the - initial production profile. - - Evidence: `test_independent_schedule_is_disabled_by_default_and_configurable` passed; the coordinator defaults the independent trigger off and creates a separate scheduled retention run only when enabled. Initial production activation remains T009/T010. - - Evidence mode: validation - -## Phase 4: Installation, portability, and live acceptance - -- [x] T009 Integrate release assets, platform adapters, upgrade, and rollback. - - Depends on: T006, T007, T008 - - Requirements: Requirement 1; Requirement 2; Requirement 3 AC6-AC8; - Requirement 6 AC1-AC6 - - Properties: CP-001, CP-006, CP-008, CP-009 - - Files: build/install scripts, systemd assets, platform adapter contracts, - package tests - - Acceptance: One compatibility-checked artifact set installs launchers, - backend, socket, tray, and schedules; upgrade validates them before - retirement; rollback restores the prior release without deleting records or - changing policy. - - Evidence: Compatibility-checked deployment and immutable selected-release activation now cover CLI/backend/tray entrypoints, exact asset hashes and permissions, atomic installation, health-gated upgrade, rollback preserving policy and run records, Linux service/socket/tray/disabled-retention-timer assets, and a fail-closed Windows token/group/named-pipe adapter seam. The focused Phase 4 suite passed 178 tests; the expanded system-control/monitoring/integration suite passed 753 with 1 skipped; wheel/sdist validation covered four console entrypoints and 20 package-data files; an installed headless artifact imported CLI without loading tray code or requiring pystray; staged systemd unit verification passed with recursive dependency errors disabled. - - Status: Repository readiness complete; live Mint installation, production adapter activation, authorized/denied actions, retention execution, and rollback rehearsal remain T010 and require explicit host-mutation approval. - - Evidence mode: validation - - [x] T009.1 Add artifact manifest, permission, upgrade, and rollback tests. - - Evidence: `test_deployment.py` passed manifest hash, installed-mode, - health-gated upgrade, failed-upgrade, rollback, and policy/run-record - preservation cases inside the 178-test Phase 4 suite. - - Evidence mode: validation - - [x] T009.2 Complete Linux install assets and Windows service/named-pipe test - double. - - Evidence: The validated artifact contains backend/socket, stable - CLI/backend/tray launchers, tray autostart, and disabled retention timer - assets; `test_windows_adapter.py` passed four token, membership, request - bound, and close-path tests. - - Evidence mode: validation - - [x] T009.3 Prove headless install requires no GUI dependencies. - - Evidence: A fresh artifact venv installed the wheel without GUI extras, - ran `timelocker-system-control --help`, imported `TimeLocker.cli` without - loading tray integration, and confirmed `pystray` was absent. - - Evidence mode: validation - -- [x] T010 Run controlled Linux Mint live acceptance and rollback rehearsal. - - Depends on: T009 - - Requirements: Requirements 1-6; SC-001-SC-011 - - Files: `verification.md`, external system assets only after explicit rollout - approval - - Acceptance: Authorized and denied system views, system launcher, scheduled - backup, restore, post-success retention, independent retention, tray - reconnect, interrupted-run recovery, upgrade, and rollback are evidenced. - - Evidence mode: external - - Evidence: Controlled Linux Mint live acceptance completed on 2026-07-26. Release 32ab1fefd8fd9334fe37b68b1f2262565f32bebd is selected; authorized/denied views, root-owned launcher and backend, tray reconnect/status, interrupted-run recovery, upgrade/rollback, scheduled and explicit backup paths, one-file restore, exact-fingerprint retention approval, post-success retention, and independent retention were evidenced without copying secrets. Backup run 287f480c-283f-45c0-85ed-2eb8b6392596 and post-success retention run b3e5baff-56a7-4437-9295-9611a0c56156 succeeded. Both timers remain enabled and waiting. - - Status: Phase 4 live acceptance complete; T011 durable documentation - promotion is complete. - - [x] T010.1 Stage without changing the working 03:30 backup. - - Evidence: Staged and installed immutable release assets without changing the existing 03:30 backup cadence. The selected release is 32ab1fefd8fd9334fe37b68b1f2262565f32bebd; the backup timer remains enabled and waiting for 03:30. - - Status: Live staging complete and schedule preserved. - - Evidence mode: external - - [x] T010.2 Obtain explicit approval before group membership, service, - launcher, timer, or live-retention mutations. - - Evidence: The operator explicitly approved each T010 mutation class on 2026-07-26 before selecting release `32ab1fefd8fd9334fe37b68b1f2262565f32bebd`; accepted retention fingerprint `e62033fd33259af14b68305e6d1179f840697f4a89f0c0df8cb95a5d69e81d94` produced successful run `b3e5baff-56a7-4437-9295-9611a0c56156`. - - Status: All required live-mutation approvals recorded. - - Evidence mode: external - - [x] T010.3 Execute acceptance and record secret-free evidence. - - Evidence: Secret-free live acceptance passed on Linux Mint: authorized and denied system views, stable launcher from /, socket activation, standalone tray disconnect/reconnect and status, scheduled and on-demand backup, one-file restore, successful dry-run and approved retention, post-backup retention, independent retention, and interrupted-run recovery. The on-demand backup run 287f480c-283f-45c0-85ed-2eb8b6392596 succeeded, followed by retention run b3e5baff-56a7-4437-9295-9611a0c56156; tray status is success with zero active operations. - - Status: V10 live acceptance passed; protected values remain outside the spec. - - Evidence mode: external - - [x] T010.4 Rehearse rollback and confirm backup scheduling remains healthy. - - - Evidence: Upgrade and rollback were rehearsed across immutable releases while preserving root-owned policy and durable run records. The final selected release is 32ab1fefd8fd9334fe37b68b1f2262565f32bebd. The backup timer and independent retention timer are both enabled and waiting; their next runs are 03:30 and 00:00 respectively. - - Status: Rollback rehearsal passed and both production schedules are healthy. - - Evidence mode: external -## Phase 5: Promotion, review, and closure - -- [x] T011 Promote accepted behavior into durable documentation. - - Depends on: T010 - - Files: all promotion targets in `change-impact.md` - - Acceptance: Durable requirements, architecture, CLI reference, - installation, tray, scheduling, troubleshooting, rollout, and rollback docs - match implemented behavior and no future intent is presented as current. - - Evidence: Promoted accepted Linux system-control behavior into durable requirements, architecture, service integration, installation, scheduling, tray, CLI, troubleshooting, rollout, rollback, version-management, and documentation front-door sources. Reconciled duplicate tray guidance and explicitly retained Linux-only live acceptance, Windows follow-up, no full UI, and user-partition issue #70 boundaries. Direct source/installed-help review and a bounded review-timelocker documentation panel found no remaining actionable drift. `python scripts/link_checker.py`, `git diff --check`, and spec-package lint passed; Agent Workbench reported no Markdown diagnostics provider. - - - Status: T011 complete. T012 remains the final expert review, residual disposition, and closure task. - - Evidence mode: validation -- [x] T012 Complete expert review, full validation, residual disposition, and - closure preparation. - - Depends on: T011 - - Files: Spec verification/traceability, durable docs, closure records - - Acceptance: Security, Restic/recovery, operations/portability, Python CLI, - tests, and documentation findings have recorded dispositions; all - requirements, ACs, and properties have evidence; closure and archive checks - pass. - - Evidence mode: validation - - Evidence: Final seven-lens review completed with findings TLR-013 through TLR-018 resolved. Focused system-command, protected-file, and tray validation passed 22 tests. The configured non-performance/non-stress/non-MinIO profile passed 2,998 tests with 1 skip, 57 deselections, and 53.79% coverage against the 50% gate. Ruff lint and format checks, compileall, documentation link validation, and git diff integrity passed. Durable documentation promotion was committed as 3f009a8; Windows live acceptance and publication/deployment of the final repository corrections remain explicit post-spec follow-up work. - - - Status: T012 complete; package is ready for lifecycle closure checks and removal after its final spec commit. -## Execution Rules - -- Do not implement from this file alone. Load the linked requirement, design, - traceability, change-impact, and verification context first. -- Mark only one implementation task `[~]` at a time unless tasks have no file - or state conflict. -- Do not use `AccessManager` sessions as proof of operating-system identity or - operator-group membership. -- Do not grant `timelocker-operators` direct access to journald, credentials, - `/var/restic`, raw Restic arguments, or protected source paths. -- Live installation, group membership, service enablement, schedule changes, - retention mutation, and rollback require explicit operator approval at T010. -- Record evidence before marking any task complete. - -## Related Artifacts - -- Requirements: `requirements.md` -- Canonical context: `canonical-context.md` -- Change Impact: `change-impact.md` -- Design: `design.md` -- Traceability: `traceability.md` -- Verification: `verification.md` - -## Reconciliation - -Reviewed against the 2026-07-26 requirements and design revisions. T001-T006 -cover the tightened system-record authorization, audit separation, diagnostic -projection, NSS failure, transport-bound, and storage-hardening work. No task -dependency or live-mutation approval boundary changed. diff --git a/docs/specs/009-system-cli-tray-retention/traceability.md b/docs/specs/009-system-cli-tray-retention/traceability.md deleted file mode 100644 index 77b8e49..0000000 --- a/docs/specs/009-system-cli-tray-retention/traceability.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: System CLI, tray, retention, and control traceability -doc_type: spec -artifact_type: traceability -status: draft -owner: Auriora Team -last_reviewed: 2026-07-26 ---- - -# Traceability Matrix - -## Purpose - -Map Spec 009 requirements, design, tasks, verification, and durable promotion -targets. Reconcile this matrix whenever any linked artifact changes. - -## Task To Context Matrix - -| Task ID | Requirements | Acceptance Criteria | Design Sections | Change Impact | Verification | Durable Targets | Open Decisions | -|---------|--------------|---------------------|-----------------|---------------|--------------|-----------------|----------------| -| T001 | Requirement 2, Requirement 4, Requirement 5, Requirement 6 | Requirement 2 AC4; Requirement 2 AC5; Requirement 2 AC6; Requirement 4 AC1; Requirement 4 AC2; Requirement 4 AC3; Requirement 4 AC4; Requirement 4 AC5; Requirement 4 AC6; Requirement 4 AC7; Requirement 4 AC9; Requirement 4 AC10; Requirement 4 AC11; Requirement 5 AC1; Requirement 5 AC2; Requirement 5 AC5; Requirement 5 AC6; Requirement 5 AC7; Requirement 5 AC8; Requirement 6 AC5 | Decisions D002-D005; Data Models; Interfaces | Protocol, authorization, run visibility | V1, V2 | System requirements, architecture, CLI reference | none | -| T002 | Requirement 4, Requirement 5, Requirement 6 | Requirement 4 AC2; Requirement 4 AC3; Requirement 4 AC5; Requirement 4 AC6; Requirement 4 AC10; Requirement 4 AC11; Requirement 5 AC4; Requirement 5 AC5; Requirement 5 AC6; Requirement 5 AC11; Requirement 6 AC6 | Run store; Atomic transition; Error Handling | Run records and recovery | V1, V3 | System and scheduling architecture | none | -| T003 | Requirement 2, Requirement 4 | Requirement 2 AC2; Requirement 2 AC3; Requirement 2 AC4; Requirement 2 AC5; Requirement 2 AC6; Requirement 4 AC1; Requirement 4 AC4; Requirement 4 AC5; Requirement 4 AC6; Requirement 4 AC7; Requirement 4 AC8; Requirement 4 AC9; Requirement 4 AC10; Requirement 4 AC11 | D001-D004; Authorization; Security | Operator authorization | V2, V4 | Requirements, architecture, installation | none | -| T004 | Requirement 2, Requirement 4, Requirement 5, Requirement 6 | Requirement 2 AC4; Requirement 4 AC8; Requirement 4 AC9; Requirement 4 AC10; Requirement 4 AC11; Requirement 5 AC8; Requirement 6 AC5 | Downstream Task Guidance | All security-sensitive deltas | V1-V4, V11 | none | none | -| T005 | Requirement 1, Requirement 2, Requirement 6 | Requirement 1 AC1; Requirement 1 AC2; Requirement 1 AC3; Requirement 1 AC4; Requirement 2 AC1; Requirement 2 AC2; Requirement 2 AC3; Requirement 2 AC4; Requirement 2 AC5; Requirement 2 AC6; Requirement 6 AC1; Requirement 6 AC2; Requirement 6 AC3 | D004; Launcher; Migration | System launcher/authorization | V5, V9 | Installation, version management | none | -| T006 | Requirement 4 | Requirement 4 AC1; Requirement 4 AC2; Requirement 4 AC3; Requirement 4 AC6; Requirement 4 AC8; Requirement 4 AC9; Requirement 4 AC10; Requirement 4 AC11 | D002, D003, D005; Protected read | Local/system log split | V2, V6 | Requirements, CLI reference, troubleshooting | none | -| T007 | Requirement 3, Requirement 4, Requirement 6 | Requirement 3 AC1; Requirement 3 AC2; Requirement 3 AC3; Requirement 3 AC4; Requirement 3 AC5; Requirement 3 AC6; Requirement 3 AC7; Requirement 3 AC8; Requirement 4 AC1; Requirement 4 AC4; Requirement 4 AC5; Requirement 4 AC6; Requirement 4 AC7; Requirement 4 AC8; Requirement 4 AC9; Requirement 6 AC1; Requirement 6 AC2; Requirement 6 AC3; Requirement 6 AC4; Requirement 6 AC5 | D006; Tray status; Migration | Independent tray | V7, V9 | Architecture, tray setup, installation | none | -| T008 | Requirement 5, Requirement 6 | Requirement 5 AC1; Requirement 5 AC2; Requirement 5 AC3; Requirement 5 AC4; Requirement 5 AC5; Requirement 5 AC6; Requirement 5 AC7; Requirement 5 AC8; Requirement 5 AC9; Requirement 5 AC10; Requirement 5 AC11; Requirement 6 AC1; Requirement 6 AC2; Requirement 6 AC3; Requirement 6 AC6 | D007; Backup-triggered retention | Retention automation | V3, V8 | Scheduling architecture and guide | none | -| T009 | Requirement 1, Requirement 2, Requirement 3, Requirement 6 | Requirement 1 AC1; Requirement 1 AC2; Requirement 1 AC3; Requirement 1 AC4; Requirement 2 AC1; Requirement 2 AC2; Requirement 2 AC3; Requirement 3 AC6; Requirement 3 AC7; Requirement 3 AC8; Requirement 6 AC1; Requirement 6 AC2; Requirement 6 AC3; Requirement 6 AC4; Requirement 6 AC5; Requirement 6 AC6 | Migration; Slice Boundary | Package/install migration | V5, V7, V9 | Installation and version management | none | -| T010 | Requirement 1, Requirement 2, Requirement 3, Requirement 4, Requirement 5, Requirement 6 | SC-001; SC-002; SC-003; SC-004; SC-005; SC-006; SC-007; SC-008; SC-009; SC-010; SC-011 | Operational Considerations | Live operational behavior | V10 | Operator guides and verification | rollout approval | -| T011 | Requirement 1, Requirement 2, Requirement 3, Requirement 4, Requirement 5, Requirement 6 | All accepted criteria promoted after evidence | Related Artifacts | Promotion Targets | V12 | All promotion targets | none | -| T012 | Requirement 1, Requirement 2, Requirement 3, Requirement 4, Requirement 5, Requirement 6 | All accepted criteria reconciled before closure | Validation Strategy | All | V1-V12 | Closure/history records | closure approval | - -## Requirement To Delivery Matrix - -| Requirement | Priority | Acceptance Criteria | Design Sections | Tasks | Verification | Durable Targets | Coverage State | Residual Destination | -|-------------|----------|---------------------|-----------------|-------|--------------|-----------------|----------------|----------------------| -| Requirement 1 | must-have | AC1-AC4 | D004; System launcher; Migration | T005, T009, T010 | V5, V9, V10 | Installation, version management | complete | none | -| Requirement 2 | must-have | AC1-AC6 | D003-D004; Action classifier; Security | T001, T003-T005, T010 | V2, V4, V5, V10 | System requirements/architecture | complete | none | -| Requirement 3 | must-have | AC1-AC8 | D006; Independent tray; Tray status | T007, T009, T010 | V7, V9, V10 | Architecture and tray setup | complete | none | -| Requirement 4 | must-have | AC1-AC11 | D001-D005; Local server; Models; Protected read | T001-T004, T006, T010 | V1-V4, V6, V10 | Requirements, architecture, CLI/troubleshooting | complete | none | -| Requirement 5 | must-have | AC1-AC11 | D007; Run store; Backup-triggered retention | T001-T002, T004, T008, T010 | V1, V3, V8, V10 | Requirements and scheduling docs | complete | none | -| Requirement 6 | must-have | AC1-AC6 | Platform adapters; Migration; Reconciliation | T001-T002, T007-T010 | V1, V3, V7, V9, V10 | Architecture, installation, version management | complete | none | - -## Correctness Property Coverage - -| Property | Requirements | Design Sections | Tasks | Tests Or Verification | Residual Risk | -|----------|--------------|-----------------|-------|-----------------------|---------------| -| CP-001 | R2 | D004; Action classifier | T001, T003, T005, T012 | V2, V5, V11 | Other platform authorization | -| CP-002 | R3 | D006; Independent tray | T007 | V7, V10 | Desktop diversity | -| CP-003 | R4, R5 | Run store and lock | T002, T008 | V3, V8 | Production timing | -| CP-004 | R4, R5 | RunRecord state machine | T001-T002, T006, T008 | V1, V3, V6, V8 | none after evidence | -| CP-005 | R5 | D007; SystemPolicy | T001, T008 | V1, V8 | Operator policy accuracy | -| CP-006 | R2, R4 | Authorization before dispatch | T001, T003, T005-T006 | V2, V4-V6 | none after evidence | -| CP-007 | R4 | D001, D003; Authorization | T003, T006 | V2, V4, V10 | NSS/platform variance | -| CP-008 | R6 | Reconciliation algorithm | T002, T009-T010 | V3, V9-V10 | Crash timing | -| CP-009 | R3, R6 | Platform adapter contracts | T001, T007, T009 | V1, V7, V9 | Windows live follow-up | -| CP-010 | R5 | D007; trigger idempotency | T002, T008 | V3, V8, V10 | none after evidence | -| CP-011 | R4 | D002-D003; response projection | T001, T003, T006 | V1-V2, V4, V6, V10 | Redaction completeness | - -## Design To Implementation Matrix - -| Design Section | Requirements | Tasks | Interfaces Or Files | Verification | Coverage State | Residual Destination | -|----------------|--------------|-------|---------------------|--------------|----------------|----------------------| -| Decisions D001-D005 | R1, R2, R4 | T001, T003, T005-T006 | `system_control`, CLI, install assets | V1-V6 | implemented and promoted | none | -| Decision D006 and independent tray | R3, R4 | T007, T009-T010 | monitoring/tray/platform modules | V7, V9-V10 | live-validated and promoted for Linux Mint | Windows live follow-up | -| Decision D007 and retention flow | R5 | T002, T008, T010 | retention/scheduling modules | V3, V8, V10 | live-validated and promoted | none | -| Migration and compatibility | R1-R6 | T005, T007-T010 | install/release/system assets | V9-V10 | live-validated and promoted for Linux Mint | Windows live follow-up | -| Durable promotion | R1-R6 | T011-T012 | `docs/` targets | V12 | promoted; final review corrections implemented | none | - -## Open Decision Impact - -| Decision ID | Blocks | Affected Requirements | Affected Tasks | Resolution Needed | -|-------------|--------|-----------------------|----------------|-------------------| -| Live rollout approval | T010 only | R1-R6 | T010 | Explicit approval before host mutation | -| Closure approval | Closure only | R1-R6 | T012 | Review and evidence complete | - -## Maintenance Notes - -- `R1` through `R6` abbreviate Requirement 1 through Requirement 6. -- `V1` through `V12` identify verification gates in `verification.md`. -- `complete` in the requirement-delivery matrix means every accepted criterion - has an explicit design, task, verification, and durable-target mapping. It - does not claim implementation completion. -- Phase 4 repository and Linux Mint live evidence exists in `tasks.md` and - `verification.md`; T011 durable promotion is complete and T012 owns the final - review corrections and closure validation. - -## Reconciliation - -Reviewed against the 2026-07-26 requirements and design revisions. Every -Requirement 1-6 acceptance criterion has an explicit task mapping, including -Requirement 4 AC10-AC11 and the tightened security constraints. Phase 3 -repository implementation evidence now covers Decisions D006-D007 and -packaging/portability. T010 live integration and T011 durable promotion are -complete. T012 reconciled operator-group authorization, added the missing -public protected-action commands, tightened protected-file modes, and hid -unconfigured tray retention; final closure validation remains. diff --git a/docs/specs/009-system-cli-tray-retention/verification.md b/docs/specs/009-system-cli-tray-retention/verification.md deleted file mode 100644 index 1c8292e..0000000 --- a/docs/specs/009-system-cli-tray-retention/verification.md +++ /dev/null @@ -1,319 +0,0 @@ ---- -title: System CLI, independent tray, retention, and control verification -doc_type: spec -artifact_type: verification -status: draft -owner: Auriora Team -last_reviewed: 2026-07-26 ---- - -# Verification - -## Scope - -This plan covers all Spec 009 requirements and tasks. It distinguishes local -automated evidence, Linux integration evidence, live host acceptance, expert -review, durable promotion, and closure. - -## Quality Gates - -| Gate | Required? | Status | Evidence | -|------|-----------|--------|----------| -| Requirements acceptance criteria reviewed | yes | passed | Final T012 review reconciled later owner-approved operator-group authorization with Requirement 2 | -| Design and traceability approved | yes | passed | Owner approved implementation; lifecycle context reports no Phase 1 gaps | -| Task evidence complete | yes | passed | T001-T012 complete with validation or external acceptance evidence | -| Automated tests pass or alternate verification recorded | yes | passed | Final configured profile: 2,998 passed, 1 skipped, 57 deselected, 53.79% coverage against the 50% gate | -| Security and operations expert review complete | yes | passed | Final seven-lens review findings TLR-013 through TLR-018 were corrected | -| Linux Mint live acceptance and rollback rehearsal complete | yes | passed | V10 completed on 2026-07-26; selected release `32ab1fefd8fd9334fe37b68b1f2262565f32bebd` | -| Durable documentation promoted | yes | passed | T011 promotion targets and front doors updated; link and patch checks passed | -| Governance or policy conflicts resolved | yes | passed | Group authorization is the operational boundary; administrator maintenance remains explicitly elevated | -| Spec cleanup decision recorded | yes | passed | Remove the active package after the final spec commit; preserve recovery metadata in `docs/history/` | - -## Verification Gates - -| ID | Gate | Covers | Required evidence | -|----|------|--------|-------------------| -| V1 | Protocol/model validation | T001, CP-004-CP-006, CP-009, CP-011 | Focused schema, transition, projection, and property tests | -| V2 | Authorization validation | T001, T003, T006, CP-001, CP-006, CP-007, CP-011 | Authorized/denied/stale-membership/NSS-failure/primary-and-supplementary-group/metadata-leak tests | -| V3 | Run store and lock validation | T002, T008, CP-003, CP-004, CP-008, CP-010 | Concurrency, atomicity, corruption, kill/restart tests | -| V4 | Linux IPC integration | T003-T004 | Real AF_UNIX peer-credential, socket-mode, timeout, request-bound, concurrency-bound, and session-refresh tests | -| V5 | Launcher/elevation validation | T005 | Resolution, routing, denial, recursion, upgrade, rollback tests | -| V6 | CLI visibility validation | T006 | Local/system scope, runs, formatting, compatibility, denial tests | -| V7 | Tray/headless validation | T007 | Import boundary, absence, crash, reconnect, singleton, live session tests | -| V8 | Retention validation | T008 | Fingerprint, approval, three triggers, conflict, no-prune tests | -| V9 | Packaging/portability validation | T009 | Wheel/assets, systemd, permissions, Windows test double, rollback | -| V10 | Live Linux acceptance | T010 | Secret-free command, systemd, backup, restore, retention, tray evidence | -| V11 | Expert review | T004, T012 | `review-timelocker` findings and dispositions | -| V12 | Promotion and closure | T011-T012 | Markdown/link checks, lifecycle gates, closure records | - -## Planned Validation Commands - -Commands are refined through Agent Workbench before execution. - -| Command | Purpose | Result | Evidence | -|---------|---------|--------|----------| -| `python3 -m pytest tests/TimeLocker/system_control -q` | Protocol, auth, storage, IPC, locks | passed in expanded suite | V1-V4, V9 | -| `python3 -m pytest tests/TimeLocker/cli/test_monitoring_commands.py -q` | CLI local/system log and run behavior | passed in configured profile | V6 | -| `python3 -m pytest tests/TimeLocker/monitoring -q` | Notification/tray/headless regression | passed in expanded suite | V7, V9 | -| `python3 -m pytest tests/TimeLocker/scheduling -q` | Retention and scheduler regression where present | passed in configured profile | V8 | -| `python3 -m pytest tests/TimeLocker/platform -q` | Platform adapters and portability | passed in configured profile | V4, V7, V9 | -| `python3 -m pytest -m "not performance and not stress and not minio"` | Full configured non-live regression suite | 2,998 passed, 1 skipped, 57 deselected; 53.79% coverage | V1-V9 | -| `systemd-analyze verify ` | Linux unit and socket validation | passed in isolated root | V4, V9 | -| `python3 scripts/link_checker.py` | Durable/spec link validation | passed | V12; existing style suggestions only, no broken links | -| `git diff --check` | Patch integrity | passed | Every implementation slice | - -## Requirement Coverage - -| Requirement | Acceptance criteria covered | Evidence | Residual risk | -|-------------|-----------------------------|----------|---------------| -| Requirement 1 | AC1-AC4 | V5, V9, V10, and T011 promotion passed | none for Linux reference | -| Requirement 2 | AC1-AC6 | V2, V4-V5, and V10 passed on Linux Mint | Other platform authorization remains roadmap work | -| Requirement 3 | AC1-AC8 | V7, V9, and V10 passed for the Linux reference desktop | Desktop diversity remains a portability risk | -| Requirement 4 | AC1-AC11 | V1-V4, V6, and V10 passed | NSS variance remains a residual portability risk | -| Requirement 5 | AC1-AC11 | V1, V3, V8, and V10 passed | Production timing remains observable through durable runs | -| Requirement 6 | AC1-AC6 | V3, V5, V7, V9, and V10 passed for Linux | Live Windows support remains follow-up work | - -## Correctness Property Coverage - -| Property | Covered by | Evidence | Residual risk | -|----------|------------|----------|---------------| -| CP-001 | V2, V5 | repository and Linux live authorization passed | Other platform authorization remains follow-up | -| CP-002 | V7, V10 | repository and Linux Mint live tray acceptance passed | Desktop diversity | -| CP-003 | V3, V8, V10 | repository locking and live backup/retention coordination passed | Production timing variance | -| CP-004 | V1, V3, V6, V8 | repository and live terminal-state projection passed | none after T010 evidence | -| CP-005 | V1, V8, V10 | exact-fingerprint dry-run and live retention passed | Operator policy accuracy | -| CP-006 | V1-V2, V4-V6 | repository and live local IPC authorization passed | Other platform IPC | -| CP-007 | V2, V4, V10 | authorized, denied, and live NSS behavior passed | NSS variance across Linux distributions | -| CP-008 | V3, V9-V10 | interrupted recovery, installed coordination, upgrade, and rollback passed | Crash timing remains observable | -| CP-009 | V1, V7, V9 | V1, V7, and V9 repository validation passed | Live Windows remains follow-up | -| CP-010 | V3, V8, V10 | repository idempotency and both live retention trigger modes passed | none after T010 evidence | -| CP-011 | V1-V2, V4, V6, V10 | repository and live safe projection passed | Continue canary tests during promotion | - -## Scope Reconciliation Before Closure - -| Broad requirement, design target, or review finding | Implemented in this spec | Coverage state | Deferred or rejected work | Destination | Blocks closure? | Evidence | -|-----------------------------------------------------|--------------------------|----------------|---------------------------|-------------|-----------------|----------| -| Linux system command/control plane | Shared contracts, store, dispatcher, backend, immutable launcher, CLI views, installed socket, live acceptance, and durable docs | promoted | none | none | no | T001-T011 evidence | -| Group-authorized system records | Current-membership dispatcher, structured projection, authorized/denied live acceptance, and durable docs | promoted | none | none | no | T003, T004, T006, T009-T011 evidence | -| Independent tray | Standalone process, bounded IPC client, strict menu allowlist, singleton lock, launcher/autostart, reconnect, live status, and durable setup | promoted | none for Linux reference | none | no | T007, T009-T011 evidence | -| Retention automation | Exact-fingerprint approval, protected adapter, post-success trigger, independent timer, shared lock, durable runs, and operator docs | promoted | none | none | no | T008-T011 evidence | -| Windows shared architecture | Token-derived identity, current-group resolver, and named-pipe transport seam with Linux-hosted contract tests | repository-validated | Live Windows service/pipe implementation and acceptance | Platform roadmap | no for this Linux reference closure | T009 evidence | -| Raw journald delegation | rejected | out-of-scope | Rejected because it exposes unrelated/protected records | none | no | Design D002 | -| User-scoped backup partitions | none | out-of-scope | Separate authorization model | GitHub issue #70 | no | Requirements non-goal | - -## Agent Readiness Evidence - -| Field | Evidence | Residual risk | -|-------|----------|---------------| -| Scope and out-of-scope files | Design slice table and change impact | Affected-file list will sharpen per task | -| Must-read and optional context | `canonical-context.md`, full Spec 009 package, and linked durable docs | Refresh current-state evidence before each implementation phase | -| Permissions and approval points | T010 requires explicit host-mutation approval | No live mutation before approval | -| Validation commands and expected signals | V1-V12 and planned commands | Commands must be refreshed after files exist | -| Review needs | Security/architecture at T004; full expert panel at T012 | Findings may change design/tasks | -| Durable-doc or closure impact | `change-impact.md` promotion table | Promotion complete; T012 closure records remain | -| Optional repo-evidence provider caveats | Agent Workbench evidence is routing/planning, not executed proof | Direct reads and commands required | - -## Task Evidence - -| Task ID | Status | Evidence | Notes | -|---------|--------|----------|-------| -| T001 | complete | 70 focused tests passed; 92.7% branch-aware coverage; compile and patch checks passed | Shared strict contracts only; no transport, store, CLI, or live-system behavior | -| T002 | complete | Atomic storage, bounded diagnostics, `flock` mutation leases, and startup reconciliation; focused T002 suite passed | No live state directory or production repository used | -| T003 | complete | Linux peer credentials, current NSS membership, strict dispatcher/audit, policy loader, and staged unit assets; focused T003 suite passed | No group, socket, service, or policy installed | -| T004 | complete | Phase 1 suite passed 98 tests at 88.4% coverage; Ruff, compileall, wheel asset, patch, lifecycle, and expert-panel checks passed | Real systemd/AF_UNIX host acceptance remains V4/T010 | -| T005 | complete | 22 focused launcher/action-policy tests; staged alias, selector, and launcher assets; wheel inventory; Ruff and patch checks passed | No live launcher or selector changed | -| T006 | complete | Integrated system-control/CLI/help suite passed 177 tests; system-control package measured 88.2% branch-aware coverage; Ruff, format, compile, wheel, and patch checks passed | Live socket and operator-group acceptance remain T009/T010 | -| T007 | complete | Independent tray entry point, strict tray allowlist, backend-unavailable/denied projection, and headless-safe monitoring imports; 190-test repository slice passed | Installed desktop-session and live IPC acceptance remain T009-T010 | -| T008 | complete | Approved retention executor, trigger claiming, protected request handler, and independent schedule gate; system-control suite passed 149 tests with 83.09% branch-aware coverage | Live backend composition and host scheduling acceptance remain T009-T010 | -| T009 | complete | 178-test focused Phase 4 suite; 753-test expanded regression; validated wheel/sdist, entrypoints, assets, headless import, staged units, upgrade, and rollback | No host state changed; live installation and production adapter activation remain T010 | -| T010 | complete | Selected immutable release, authorized/denied system views, launcher/socket/tray acceptance, successful backup and restore, approved post-success and independent retention, interrupted recovery, upgrade, and rollback | Linux Mint reference acceptance only; no live Windows claim | -| T011 | complete | Requirements, architecture, implementation, installation, scheduling, tray, CLI, troubleshooting, version, and front-door docs promoted; link and patch checks passed | Bounded review found no remaining actionable documentation drift | -| T012 | complete | Seven-lens review findings TLR-013 through TLR-018 resolved; 22 focused tests and the 2,998-test configured profile passed; Ruff, compile, links, patch, and lifecycle checks passed | Windows live acceptance and publication/deployment remain separate follow-up work | - -## Evidence Log - -| Date | Evidence | Result | Notes | -|------|----------|--------|-------| -| 2026-07-26 | Focused `review-timelocker` design/security review | blocking design findings addressed | Explicit AC mappings, fail-closed NSS, root-only audit, safe summaries, storage hardening, and transport bounds added | -| 2026-07-26 | `python3 -m pytest tests/TimeLocker/system_control ... --cov-fail-under=90` | 70 passed; 92.7% coverage | T001 strict models, envelopes, projection, transition, portability, and negative security cases | -| 2026-07-26 | `python3 -m compileall -q src/TimeLocker/system_control tests/TimeLocker/system_control` and `git diff --check` | passed | T001 syntax and patch integrity | -| 2026-07-26 | Focused `review-timelocker` T001 implementation review | no actionable findings after remediation | Response summaries were made code-owned; response envelope and transition model omissions were corrected before completion | -| 2026-07-26 | `python3 -m pytest tests/TimeLocker/system_control ... --cov-fail-under=85` | 98 passed; 88.4% coverage | T001-T003 contracts, storage, locking, recovery, authorization, redaction, policy, and Linux adapter tests | -| 2026-07-26 | `PYENV_VERSION=3.12.4 ruff check ...` and `ruff format --check ...` | passed | Phase 1 source and focused tests | -| 2026-07-26 | `python3 -m compileall -q ...` and `git diff --check` | passed | Phase 1 syntax and patch integrity | -| 2026-07-26 | `PYENV_VERSION=3.12.4 python -m build --wheel --no-isolation ...` plus wheel inventory | passed; 3/3 assets present | Policy, socket unit, and service unit are packaged; isolated build could not resolve build dependencies because network access was unavailable | -| 2026-07-26 | Agent Workbench verification planning and diagnostics | planning returned; diagnostics unavailable | No Python diagnostics provider was configured, so direct review and executed checks remain the proof | -| 2026-07-26 | Rules consulted and applied | recorded | Coding Standards (100), General Preferences (50), Operational Best Practices (40), Planning Protocol (30), Testing Conventions (25), Documentation Conventions, and Git Conventions; no overrides | -| 2026-07-26 | `PYENV_VERSION=3.12.6 python -m pytest -m "not performance and not stress and not minio"` | 2,998 passed, 1 skipped, 57 deselected; 53.79% coverage | Final configured repository profile; 50% coverage gate passed | -| 2026-07-26 | `PYENV_VERSION=3.12.6 python -m pytest` over `test_system_commands.py`, `test_production_retention.py`, tray integration, and tray process-boundary tests | 22 passed | Public system commands, owner-only protected files, and fingerprint-aware tray actions | -| 2026-07-26 | Ruff lint/format, `compileall`, link checker, and `git diff --check` | passed | Link checker retained 22 pre-existing canonical-style suggestions and reported no broken links | -| 2026-07-26 | `PYENV_VERSION=3.12.6 python -m pytest tests/TimeLocker/system_control tests/TimeLocker/cli/test_monitoring_commands.py tests/TimeLocker/cli/test_cli_help_system.py -q --no-cov` | 177 passed | Phase 2 launcher, action routing, client, authorization, structured run/log views, compatibility, denial, and redaction | -| 2026-07-26 | `coverage report --include='src/TimeLocker/system_control/*' --skip-empty --fail-under=0` | 88.2% branch-aware coverage | Scoped report for the system-control package; a pytest coverage attempt inherited repository-wide `source=src` and failed the global 50% threshold at 17.3%, so it is not presented as a focused coverage result | -| 2026-07-26 | Ruff check/format, compileall, wheel build/inventory, and `git diff --check` | passed | Wheel contains the Phase 2 modules and all six system-control assets, including distinct `timelocker` and `tl` launcher assets; isolated build dependency resolution was unavailable, and the Python 3.12.4 no-isolation build passed | -| 2026-07-26 | `python3 -m pytest tests/TimeLocker/system_control/test_retention.py tests/TimeLocker/system_control/test_tray_client.py tests/TimeLocker/system_control/test_tray_process_boundary.py tests/TimeLocker/monitoring/test_system_tray_integration.py tests/TimeLocker/system_control/test_client.py` | 32 passed; repository-wide coverage gate failed at 12.2% | Narrow slice inherited repository-wide `--cov=src/TimeLocker`; tests passed and exposed a coverage-accounting mismatch rather than a functional regression | -| 2026-07-26 | `PYENV_VERSION=3.12.4 PYTHONPATH=src python -m pytest -o addopts='' tests/TimeLocker/system_control --cov-config=/dev/null --cov=TimeLocker.system_control --cov-branch --cov-report=term --cov-fail-under=80 -q` | 149 passed; 83.09% branch-aware coverage | Complete system-control regression and focused Phase 3 coverage without inheriting the repository-wide coverage source | -| 2026-07-26 | `PYENV_VERSION=3.12.6 python -m pytest` over the Phase 3 system-control, monitoring, and integration slice | 190 passed | Tray/process boundaries, retention execution, monitoring compatibility, reconnect, authorization projection, and schedule summaries | -| 2026-07-26 | Ruff check/format, compileall, wheel build/inventory, and `git diff --check` | passed | Wheel contains `timelocker-tray` and all new Phase 3 modules; no host state changed | -| 2026-07-26 | Expanded `system_control`, `monitoring`, and `integration` pytest run | 733 passed, 1 skipped, 2 failed, 4 setup errors | The failures are confined to repository credential integration paths outside this diff: five expect a legacy credential-file location and one cannot register S3 because optional `b2sdk` is absent. They do not invalidate the bounded Phase 3 suites but remain repository test debt. | -| 2026-07-26 | Credential-path and backend-registration reconciliation | 6 focused tests passed | `--config-dir` consistently treats the argument as the configuration root and stores credentials under `credentials/credentials.enc`; missing B2 registration no longer prevents S3 registration. No credential contents or live stores were read, copied, or deleted. | -| 2026-07-26 | `PYENV_VERSION=3.12.6 python -m pytest` over the Phase 4 system-control, credential, and artifact suite | 178 passed | Backend/release entrypoints, exact asset manifest, permissions, Windows adapter seam, upgrade, rollback, credential paths, and release metadata passed. | -| 2026-07-26 | Expanded `system_control`, `monitoring`, and `integration` pytest run | 753 passed, 1 skipped | Previous six credential/backend-registration failures are resolved; existing warnings remain non-blocking. | -| 2026-07-26 | Wheel/sdist validation and installed headless smoke | passed | Four console entrypoints and 20 package-data files validated; CLI import did not load tray code and the environment had no `pystray` dependency. | -| 2026-07-26 | Staged `systemd-analyze verify --recursive-errors=no --root=...` | passed | Backend socket/service and disabled retention service/timer parsed successfully against an isolated staged executable. | -| 2026-07-26 | Controlled Linux Mint T010 rollout and immutable-release rehearsal | passed | Root-owned launchers/backend, current operator-group authorization and denial, socket activation, tray disconnect/reconnect, interrupted-run recovery, upgrade, and rollback passed; selected release is `32ab1fefd8fd9334fe37b68b1f2262565f32bebd`. | -| 2026-07-26 | Production backup and restore acceptance | passed | Scheduled backup remained healthy; explicit backup run `287f480c-283f-45c0-85ed-2eb8b6392596` succeeded and a one-file restore completed. Evidence contains no credentials, repository URI, or protected source inventory. | -| 2026-07-26 | Exact-fingerprint retention activation | passed | Operator accepted fingerprint `e62033fd33259af14b68305e6d1179f840697f4a89f0c0df8cb95a5d69e81d94`; independent retention and post-backup retention run `b3e5baff-56a7-4437-9295-9611a0c56156` succeeded without pruning. Both timers remain enabled and waiting. | -| 2026-07-26 | Live tray queued/history regression | fixed and passed | Initial accepted request displayed stale `error` while queued; commits `2388e1d` and `32ab1fe` added durable backup coordination and made queued/latest operation state authoritative. Focused regression: 22 passed; live tray reports `success`, zero active operations, and backend available. | -| 2026-07-26 | T011 durable-document promotion and bounded TimeLocker documentation review | passed | All promotion targets plus repository documentation front doors were reconciled with source and T010 evidence. `python scripts/link_checker.py` and `git diff --check` passed; Agent Workbench diagnostics had no Markdown provider. No live host state changed. | - -## T004 Review Finding Dispositions - -| Finding | Severity / confidence | Roles | Disposition | Validation | -|---------|-----------------------|-------|-------------|------------| -| TLR-001: reconciling an older abandoned run could fail when a newer run held the same repository lock | medium / high | Security and Privacy; Reliability and Testing; Operations and Portability | fixed: the older run is interrupted while the newer live lease is preserved; stale metadata clearing tolerates the live owner | `test_newer_live_lease_does_not_block_old_run_reconciliation` | -| TLR-002: startup reconciliation inspected at most 1,000 runs despite the requirement to reconcile every non-terminal run | medium / high | Project Steward; Reliability and Testing | fixed: internal reconciliation now scans the complete run inventory while public queries remain bounded | focused storage suite and direct source review | -| TLR-003: a client could hold the single-threaded socket server indefinitely with an incomplete request | medium / high | Security and Privacy; Reliability and Testing; Operations and Portability | fixed: each connection receives a bounded timeout and timeout produces an empty invalid frame without dispatch | Linux transport timeout assertion and dispatcher malformed-request tests | -| TLR-004: unconditional POSIX locking imports would break the shared package import on Windows | medium / high | Python CLI Architecture; Operations and Portability | fixed: POSIX locking is capability-checked at use time; the shared contract remains importable and unsupported locking fails explicitly | Ruff/compile checks and platform adapter contract tests | -| TLR-005: the dispatcher allowed an implicit no-op audit sink | medium / high | Security and Privacy; Project Steward | fixed: an audit sink is mandatory and every event carries caller identity, action, decision, response status, and stable result code without parameters | dispatcher authorization, denial, failure, and audit assertions | - -No actionable Phase 1 findings remain after these dispositions. The review was -bounded to Spec 009 Phase 1 source, focused tests, packaged assets, and lifecycle -artifacts. It did not install or execute the staged service, inspect real NSS -membership, or claim live Windows support. - -## Phase 2 Review Finding Dispositions - -| Finding | Severity / confidence | Roles | Disposition | Validation | -|---------|-----------------------|-------|-------------|------------| -| TLR-006: the staged launcher assets did not provide a distinct `tl` compatibility alias | medium / high | Project Steward; Operations and Portability | fixed: packaged `tl-launcher` delegates through the same immutable launcher module as `timelocker-launcher` | wheel inventory and launcher-entrypoint tests | -| TLR-007: rollback selection trusted a selector file without revalidating its parent directory | high / high | Security and Privacy; Operations and Portability | fixed: every selector read validates the root-owned, non-writable selector directory before parsing | release-launcher ownership, mode, symlink, selection, and rollback tests | -| TLR-008: CLI record and diagnostic limits were not bounded at argument parsing | medium / high | Security and Privacy; Python CLI Architecture | fixed: run and log limits are constrained to 1-1,000 before transport requests are built | CLI invalid-limit and request-shape tests | -| TLR-009: client framing, safe errors, entrypoint delegation, and scope rejection lacked focused regression coverage | medium / high | Reliability and Testing; Python CLI Architecture | fixed: added client, release-entrypoint, invalid-scope, request-correlation, timeout, framing, and safe-error tests | integrated 177-test Phase 2 suite | - -No actionable Phase 2 findings remain after these dispositions. The review was -bounded to T005-T006 source, tests, packaged assets, and lifecycle artifacts. -It did not install the launcher, select a live release, activate the socket, -inspect real group membership, or prove platform authorization prompts. - -## Phase 3 Review Finding Dispositions - -| Finding | Severity / confidence | Roles | Disposition | Validation | -|---------|-----------------------|-------|-------------|------------| -| TLR-010: the standalone tray used a predictable shared `/tmp` singleton path and did not drain GTK events | high / high | Security and Privacy; Operations and Portability; Reliability and Testing | fixed: the lock now lives in a private XDG runtime/cache directory, rejects symlinks, and the tray loop drains platform UI events | singleton, process-boundary, and tray adapter tests | -| TLR-011: the retention IPC handler returned an `ActionReceipt` object instead of the dispatcher contract's wire mapping | high / high | Python CLI Architecture; Reliability and Testing | fixed: the protected handler projects the receipt through `to_wire()` | protected request projection test | -| TLR-012: backend loss or access denial could leave stale successful state visible in the tray | medium / high | Project Steward; Security and Privacy; Operations and Portability | fixed: bounded retry/reset now replaces stale state with explicit unavailable or denied projections | backend absence, denial, reconnect, and safe-projection tests | - -No actionable Phase 3 findings remain after these dispositions. The review was -bounded to T007-T008 source, tests, packaging, and lifecycle artifacts. It did -not install a desktop-session process, connect to the live system backend, -activate production retention, or mutate host state. - -## T012 Final Review Finding Dispositions - -| Finding | Severity / confidence | Roles | Disposition | Validation | -|---------|-----------------------|-------|-------------|------------| -| TLR-013: Requirement 2 retained an older per-invocation elevation prompt after the owner required current operator-group authorization | high / high | Security and Privacy; Project Steward; Documentation and Lifecycle | fixed: requirements, design, traceability, and durable docs now define the privileged backend plus current OS group membership as the operational boundary; administrator maintenance remains explicitly elevated | direct requirements/design reconciliation and authorization tests | -| TLR-014: protected retention files could be group/world readable | high / high | Security and Privacy; Restic and Recovery; Reliability and Testing | fixed: target, repository configuration, credential source, and enable marker must be owner-only | focused `0644` rejection tests | -| TLR-015: the public CLI classified protected actions but exposed no `system backup` or `system retention` commands | high / high | Project Steward; Python CLI; Operations and Portability | fixed: a focused `system` command group sends bounded requests through `UnixSocketSystemControlClient` and never falls back to direct elevation | CLI help, request-shape, routing, and help-tree tests | -| TLR-016: the default tray autostart exposed retention without a configured policy fingerprint | medium / high | Project Steward; Reliability and Testing; Operations and Portability | fixed: tray menus omit retention unless the process has a configured fingerprint | tray menu configuration and process-boundary tests | -| TLR-017: the active-spec front door still described T011 as in progress | medium / high | Documentation and Lifecycle | fixed: `docs/specs/README.md` now identifies T011 as complete and T012 as the only active work | direct documentation review | -| TLR-018: the verification gate understated requirements-review completion | low / medium | Documentation and Lifecycle | fixed: the gate records the final Requirement 2 reconciliation and review disposition | package lint and lifecycle checks | - -The final panel also exposed a pre-existing `timelocker help runs` omission -during the normal profile. The help topic and new `system` topic were added and -the complete help-tree test now passes. No final-review finding remains open. - -## Manual Or External Verification - -Live T010 evidence must record the reviewer, timestamp, exact non-secret command, -result, and rollback state. It must not copy environment files, credentials, -repository URIs, protected source paths, or raw journal payloads into this -package. - -## Residual Risks - -- Group/NSS behavior differs across Linux environments; verify current - membership and stale-process removal behavior live. -- Raw diagnostic messages can leak paths or secrets; the backend must emit - allowlisted structured records rather than redact arbitrary text after the - fact. -- Operator-visible `safe_summary` fields require code-keyed templates and - canary tests proving exception strings, subprocess output, peer identity, - repository URIs, and protected paths cannot enter responses. -- Crash timing remains sensitive despite passing atomicity and process-exit - tests; live kill/restart acceptance remains V4/T010. -- Changing launcher and `/opt` permissions can expose protected assets if code - and state are not separated. -- Windows live support is not proven by a test double and must not be claimed. - -## Durable Promotion And Cleanup - -| Spec content | Durable destination or deferral | Status | Evidence | -|--------------|---------------------------------|--------|----------| -| System requirements and authorization invariants | `docs/1-requirements/system-operations.md` | complete | T011 | -| Launcher/backend/tray/run-store architecture | `docs/2-architecture/system-architecture.md` | complete | T011 | -| Scheduling/retention behavior | `docs/2-architecture/scheduling-system.md` | complete | T011 | -| Focused service ownership | `docs/3-implementation/service-layer-integration.md` | complete | T011 | -| Installation/group/launcher guidance | `docs/guides/user/installation.md` | complete | T011 | -| Scheduling rollout/rollback | `docs/guides/developer/scheduling-guide.md` | complete | T011 | -| Independent tray setup | `docs/SYSTEM-TRAY-SETUP.md` | complete | T011 | -| CLI commands and troubleshooting | CLI reference and backup troubleshooting guide | complete | T011 | -| User partitions | GitHub issue #70 | routed | Existing backlog authority | - -### Spec Cleanup Decision - -- **Cleanup action:** remove after the final spec commit -- **Reason:** implementation, Linux live acceptance, durable promotion, final - expert review, and repository validation are complete -- **Final spec commit:** pending until this complete package is committed -- **Closure log path:** `docs/history/spec-closure-log.md` -- **Closure log entry updated:** after the final spec commit -- **Closure cleanup commit:** pending -- **Active indexes updated:** with the closure cleanup commit -- **Durable docs linked back to evidence where useful:** yes -- **Residual spec-only content:** none requires durable promotion; detailed - design, task evidence, and live acceptance remain recoverable from Git - -## Ship Or Closure Risk - -- **Risk level:** high -- **Breaking change:** no intended public-command break -- **Blast radius checked:** yes -- **Rollback path:** implemented and rehearsed on the Linux reference host -- **Requires human review:** yes -- **Release notes needed:** yes -- **Follow-up issue or spec needed:** Windows live adapter/acceptance - -### Risk Rationale - -This change introduces a privileged process boundary, OS identity and group -authorization, machine-level installation assets, repository mutation -coordination, and desktop IPC. Incorrect implementation could disclose -protected metadata, widen privilege, interrupt backups, or delete snapshots. - -## Readiness Decision - -- **Ready for promotion:** complete -- **Ready for release:** no -- **Ready for closure:** yes -- **Ready for implementation:** complete - -## Related Artifacts - -- Requirements: `requirements.md` -- Change Impact: `change-impact.md` -- Design: `design.md` -- Tasks: `tasks.md` -- Traceability: `traceability.md` -- Canonical context: `canonical-context.md` - -## Reconciliation - -Reviewed against the 2026-07-26 requirements and design revisions. T001-T010 -provide repository and Linux Mint live evidence for V1-V10. Real socket -activation, installed ownership/modes, live NSS behavior, protected -backup/restore, post-success and independent retention, tray reconnect/status, -interrupted recovery, upgrade, and rollback passed. T011 durable promotion and -T012 final expert review, correction, full validation, and residual disposition -passed. Windows live acceptance and publication/deployment of the final -repository corrections remain explicit post-spec work. diff --git a/docs/specs/README.md b/docs/specs/README.md index 57bf515..81d989c 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -15,24 +15,15 @@ accepted content has been promoted and the package is closed. ## Current Packages -- [`009-system-cli-tray-retention`](./009-system-cli-tray-retention/requirements.md) - — complete package awaiting closure cleanup. Implementation, live Linux - acceptance, durable-document promotion, final expert-review corrections, and - repository validation are complete. +There are no active specification packages. ## Active-Package Sequencing -Spec 007 is closed; its release-readiness evidence and recovery commits are -recorded in `docs/history/`. Spec 009 is the only active package. Its design, -tasks, traceability, canonical context, and verification plan were approved, -and Phases 1 through 4 are complete in the repository. The approved Linux host -mutation and live acceptance gate was completed in T010, and durable promotion -was completed in T011. Work now proceeds through final expert review, -correction, and closure (T012), which are complete. The package will be removed -after its final spec commit and recorded in `docs/history/`; repository -implementation approval still does not authorize release publication. -Closed packages remain recorded in `docs/history/` rather than kept in this -active path. +Specs 007, 008, and 009 are closed. Their final package commits, cleanup +commits, verification summaries, and residual follow-up are recorded in +`docs/history/`. Closed packages remain recoverable from Git rather than kept +in this active path. Repository implementation approval does not authorize +release publication or deployment. ## When a Spec Is Needed From d5c6b97fed3f932e6d34fe8dfef839bc09690162 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:28:53 +0100 Subject: [PATCH 50/72] docs: record spec 009 cleanup commit --- docs/history/spec-archive-index.md | 2 +- docs/history/spec-closure-log.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/history/spec-archive-index.md b/docs/history/spec-archive-index.md index f5633cb..1ba0163 100644 --- a/docs/history/spec-archive-index.md +++ b/docs/history/spec-archive-index.md @@ -16,7 +16,7 @@ contracts. | Spec ID | Title | Package path | Status | Final spec commit | Cleanup commit | Closure action | Durable destinations | Verification | |---------|-------|--------------|--------|-------------------|----------------|----------------|----------------------|--------------| -| 009-system-cli-tray-retention | System CLI, independent tray, retention, and control | removed; recover from Git | removed | `d4ce71dd05cb5d7278bf36a9fc43e557d68e1e31` | pending-cleanup-commit | removed | `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/2-architecture/scheduling-system.md`; `docs/3-implementation/service-layer-integration.md`; `docs/guides/user/installation.md`; `docs/guides/developer/scheduling-guide.md`; `docs/SYSTEM-TRAY-SETUP.md`; `docs/reference/timelocker-cli-command-hierarchy.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/processes/version-management.md`; `docs/README.md`; `docs/DOCUMENTATION-STATUS.md`; `docs/specs/README.md` | `docs/history/spec-closure-log.md` | +| 009-system-cli-tray-retention | System CLI, independent tray, retention, and control | removed; recover from Git | removed | `d4ce71dd05cb5d7278bf36a9fc43e557d68e1e31` | aba95875f453dd6abf39a1fdc6af25fd38c62db4 | removed | `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/2-architecture/scheduling-system.md`; `docs/3-implementation/service-layer-integration.md`; `docs/guides/user/installation.md`; `docs/guides/developer/scheduling-guide.md`; `docs/SYSTEM-TRAY-SETUP.md`; `docs/reference/timelocker-cli-command-hierarchy.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/processes/version-management.md`; `docs/README.md`; `docs/DOCUMENTATION-STATUS.md`; `docs/specs/README.md` | `docs/history/spec-closure-log.md` | | 007-release-readiness-stabilization | Release readiness stabilization requirements | removed; recover from Git | removed | `7fd11f9aa1cbc670d5e8b429aede4a7c01e185a4` | `6334af0690b5b9e8b6575042269e5b73914a9295` | removed | `README.md`; `CHANGELOG.md`; `.github/workflows/test-suite.yml`; `.github/workflows/artifact-smoke.yml`; `.github/workflows/release-validation.yml`; `.github/workflows/release.yml`; `docs/4-testing/README.md`; `docs/guides/user/installation.md`; `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md`; `docs/processes/version-management.md`; `docs/processes/README.md` | `docs/history/spec-closure-log.md` | | 008-npbackup-migration-parity | NPBackup migration parity requirements | removed; recover from Git | removed | `5830194` | `1bfea08` | removed | `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md` | `docs/history/spec-closure-log.md` | | 001-cli-consolidation-stabilization | CLI Consolidation Stabilization | removed; recover from Git | removed | `a1bb654` | `b8df9e9` | removed | `docs/3-implementation/service-layer-integration.md`; `docs/reference/repo-orientation-and-change-map.md`; `docs/specs/README.md`; `docs/history/` | `docs/history/spec-closure-log.md` | diff --git a/docs/history/spec-closure-log.md b/docs/history/spec-closure-log.md index 85e046f..bd63cb9 100644 --- a/docs/history/spec-closure-log.md +++ b/docs/history/spec-closure-log.md @@ -20,7 +20,7 @@ final spec commit preserves the complete package. - **Spec:** removed; recover from Git - **Title:** System CLI, independent tray, retention, and control - **Final spec commit:** `d4ce71dd05cb5d7278bf36a9fc43e557d68e1e31` -- **Closure cleanup commit:** `pending-cleanup-commit` +- **Closure cleanup commit:** `aba95875f453dd6abf39a1fdc6af25fd38c62db4` - **Closure action:** removed - **Durable docs updated:** - `docs/1-requirements/system-operations.md` From 04a9ef9dd69e8ab9afcd18fec5ce094bc47dffb7 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:23:17 +0100 Subject: [PATCH 51/72] feat(tray): show branded backup status Package and install the TimeLocker logo for the independent tray and desktop entry. Show the latest backup timestamp in the Linux tray menu using the desktop session's local timezone. --- docs/SYSTEM-TRAY-SETUP.md | 7 ++ docs/guides/user/installation.md | 1 + .../monitoring/system_tray_integration.py | 66 ++++++++++++++---- .../system_control/assets/timelocker-icon.png | Bin 0 -> 34503 bytes .../assets/timelocker-tray.desktop | 1 + src/TimeLocker/system_control/deployment.py | 6 ++ src/TimeLocker/system_control/tray_entry.py | 1 + .../test_system_tray_integration.py | 60 +++++++++++++++- .../system_control/test_deployment.py | 7 ++ .../system_control/test_linux_adapter.py | 7 +- .../test_tray_process_boundary.py | 33 ++++++++- 11 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 src/TimeLocker/system_control/assets/timelocker-icon.png diff --git a/docs/SYSTEM-TRAY-SETUP.md b/docs/SYSTEM-TRAY-SETUP.md index 7ed1b22..8291274 100644 --- a/docs/SYSTEM-TRAY-SETUP.md +++ b/docs/SYSTEM-TRAY-SETUP.md @@ -38,8 +38,15 @@ The protected installer places: ```text /usr/local/bin/timelocker-tray /etc/xdg/autostart/timelocker-tray.desktop +/usr/local/share/icons/hicolor/1024x1024/apps/timelocker.png ``` +The packaged TimeLocker logo is the tray and desktop-entry icon. Backup, +retention, and backend state remain available through the tray menu and status +text rather than replacing the application identity with unrelated theme +icons. On Linux, the tray menu includes the latest backup date and time in the +desktop session's local timezone. + On Linux Mint/Ubuntu with Cinnamon or GNOME-compatible panels, install the GTK and AppIndicator runtime: diff --git a/docs/guides/user/installation.md b/docs/guides/user/installation.md index 0f00f82..6aa62f2 100644 --- a/docs/guides/user/installation.md +++ b/docs/guides/user/installation.md @@ -148,6 +148,7 @@ is distinct from a user/source installation. It provides: /usr/local/bin/tl /usr/local/bin/timelocker-tray /usr/local/libexec/timelocker-system-control +/usr/local/share/icons/hicolor/1024x1024/apps/timelocker.png /opt/timelocker/releases/RELEASE_ID/ /opt/timelocker/selected-release.json /etc/timelocker/ diff --git a/src/TimeLocker/monitoring/system_tray_integration.py b/src/TimeLocker/monitoring/system_tray_integration.py index ce5fb58..788361d 100644 --- a/src/TimeLocker/monitoring/system_tray_integration.py +++ b/src/TimeLocker/monitoring/system_tray_integration.py @@ -21,12 +21,20 @@ import sys import threading from datetime import datetime +from pathlib import Path from enum import Enum from typing import Optional, Callable, Any from dataclasses import dataclass logger = logging.getLogger(__name__) +PACKAGED_TRAY_ICON_PATH = ( + Path(__file__).resolve().parents[1] + / "system_control" + / "assets" + / "timelocker-icon.png" +) + def _linux_graphical_session_available() -> bool: """Return whether a Linux process has a desktop display connection.""" @@ -96,7 +104,7 @@ class SystemTrayIntegration: Provides always-visible status information and quick actions Features: - - Status indicator icons (idle, running, success, error) + - Application icon with status details - Tooltip with last backup status - Context menu with quick actions - Click-to-open main interface @@ -253,6 +261,19 @@ def set_on_click_callback(self, callback: Callable): if self.is_available(): self._tray_impl.set_on_click(callback) + def update_last_backup_time(self, backup_time: datetime | None) -> None: + """Update the platform-specific last-backup presentation when supported.""" + if not self.is_available(): + return + + update_last_backup = getattr( + self._tray_impl, + "update_last_backup_time", + None, + ) + if update_last_backup is not None: + update_last_backup(backup_time) + def set_on_menu_action_callback(self, callback: Callable[[str], None]): """ Set callback for menu actions @@ -330,7 +351,11 @@ def _initialize_tray(self): self._use_gtk = True self._indicator = self._indicator_module.Indicator.new( self.app_name, - "dialog-information", + ( + str(PACKAGED_TRAY_ICON_PATH) + if PACKAGED_TRAY_ICON_PATH.is_file() + else "dialog-information" + ), self._indicator_module.IndicatorCategory.APPLICATION_STATUS, ) self._indicator.set_status(self._indicator_module.IndicatorStatus.ACTIVE) @@ -360,6 +385,11 @@ def _create_gtk_menu(self): # Separator self._menu.append(Gtk.SeparatorMenuItem()) + # AppIndicator tooltips are not consistently available on Linux. + self._last_backup_item = Gtk.MenuItem(label="Last backup: Unknown") + self._last_backup_item.set_sensitive(False) + self._menu.append(self._last_backup_item) + # Status item status_item = Gtk.MenuItem(label="View Status") status_item.connect( @@ -411,17 +441,14 @@ def update_icon(self, status: TrayStatus): if not hasattr(self, "_indicator"): return - icon_map = { - TrayStatus.IDLE: "dialog-information", - TrayStatus.RUNNING: "system-run", - TrayStatus.SUCCESS: "emblem-default", - TrayStatus.WARNING: "dialog-warning", - TrayStatus.ERROR: "dialog-error", - } - - icon_name = icon_map.get(status, "dialog-information") try: - self._indicator.set_icon(icon_name) + self._indicator.set_icon( + ( + str(PACKAGED_TRAY_ICON_PATH) + if PACKAGED_TRAY_ICON_PATH.is_file() + else "dialog-information" + ) + ) except Exception as e: logger.error(f"Failed to update icon: {e}") @@ -431,6 +458,21 @@ def update_tooltip(self, tooltip: str): # Tooltip is shown through the menu pass + def update_last_backup_time(self, backup_time: datetime | None) -> None: + """Show the latest backup start time in the Linux tray menu.""" + if not hasattr(self, "_last_backup_item"): + return + + label = "Last backup: Unknown" + if backup_time is not None: + local_time = backup_time.astimezone() if backup_time.tzinfo else backup_time + label = f"Last backup: {local_time.strftime('%Y-%m-%d %H:%M %Z')}".rstrip() + + try: + self._last_backup_item.set_label(label) + except Exception as e: + logger.error(f"Failed to update last backup time: {e}") + def set_on_click(self, callback: Callable): """Set click callback""" self._on_click_callback = callback diff --git a/src/TimeLocker/system_control/assets/timelocker-icon.png b/src/TimeLocker/system_control/assets/timelocker-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..811ae0427b97f4f961859975800c96c38a084c1e GIT binary patch literal 34503 zcmeFZ^noLD2$GwJ{6Ge4uOG4cL~Fy z!wsZE$&K#b_Bq4X_lNKQ@b-4|#)Na7bFMg7+^-9RA84tb`{(jM5Cok=+*i_ppcCNZ z3Fs6p_=187z5!oOzqoJU3PJSWD1WJ1oj+QFZ?YkkpCEOepCdh!LT9v;HBPWG-A zPhVIGJG)q?tjJ!5AT9`@bnlU8>e9GZt%;l0F1fdHXTnCFG2*WyL5KR#CS;)!(CQc(K0 zEy<;4Vv$5-Y=)95dYcAK7m4-4#ioywiPgb8ixn7qAy*G7a7QS>S3Qe{@&RhzvpD_= zWkRffKU{|>EP_A(1RSr00;tW8|NVbU{)*xMi(ittJ=sd!{O&n7qw^3?J_8ntxqJU)%hSP{wm=4M%^3!vpGVoJhmGy?Ph!gt7!Tw z^V*ucj?t9{xPY8{?Y9w4!#Pw5=aLWkq(&V!s1j(q{bG1`6h`}-IPKC0Tk06vWdAxi zneBP!-EFaU)2jrpXf>>f&u{+(KNH)7*`6PM@)6-wS)vq9puLJSdG6trl}$r&9J9|$ zVhDOKt9smDK;SlJ9YY4knP4;$7moeu19Qa;xg*g6bcu#liP-Q%F;s9)JieIr z{;YdQ7e-b>Y-5{da<{b`d#~B^uj73`<4UK^T3mIM-OzY1tj+_&b~dr{M%7kl8iQjk zLR~hDp1OtR@7^aeWIVr`SGm1HtTM6?W%`_PgLdU58hBH30iHE8{n&p8$TqyNn}4A` z%W2|`$;rn`YtGS2`5B-$65zeY9w_A5(vttJoBE1L*J*ch(Z=AU^%`~NC*)aSCzj3~ z%Z5w4c8$|HA`uy9G}3p=?ORpyd=;L{+~d}F@ax-(kR)R^Z!MEz&ule(+4y ztMe^<^VKUntGWK)6P~w7kqk<|Xq*{k*(naCr*eOH98YXiShSX}I25JY=J&)Ae&es) zcOq%V-3akA=PG~ycLg7c)Iyd;1ggyc7T~x|__oY`sAm(LhrQKY{lBf7-=bV!9zIMS zIy!x3wUHZs((~-Gj3TX!_oOOrx%%bG*&1cN^>7Rhr}|5BvmVIBT)nfQo7gxuJvpXP z-`6!)BV z*J3ac&PT4x{;o!2O*TZSY{dt%XDPB#sN@hlHz`F_7jBea@Q2_Klk-`@BVK(f(%+o>yj}UjA^g`WtBizj5Zviu7Cw!6d@qQ0KZ=r=syB| znD0O2FMsTdGnT1*HWHGp_*3@Iv51#Kp80QPbSyI`y~|dtwsLU=E(e}%bQ#~Ill=LU z+j^siY9QC)UmI(D{Mm=3ix|A2nSi{xLN-8l?KqIODrwZ?EMMjPh~`t?m1rARQ!iO~ z;@j(oz1nvB__3KgUtFA$k3$ya`TNgLHD#!_E`@g|Xvk+is(2gCj7YvR-Unem4Q!1N zdsh6e!%qzVIOc3Af6DkMPpqN*c@-+DHj~_gPi$OoQx@M5N$4+nc9x3V_i#c?-TJQR zdDv@*XU8NvO1FebHKu!cRh#_63uH&6hMcVDmwQa3qoX|%gI>|OO1q()4Q1a9wfny| zR4XNL`TXT;JdPYfz7^9H6?fyhtT6*_q zR{ZWPt_}-xt5)71Ys_TWEUuVw!kGSiIYXa3G%?^FkC?v?zh0l$bYvz|e@K!Bv|s)T z0d{H)q7#f!6UhMV^>?Nll>~dexng?rxqRT^J*Awcd*1SC`vUp?rVuuU`*I ztgx6fg6UXzj?il4VQYVC2}2AZ`0I9IphJPlB{eeN4IZh4@!p0v;z(NK~IE?QSVSEk7nlAMt z=-{|pW?Y19tdNbY7Q__`!yMRJ?Mk(8??$J?#(@JYZcpI zAxjuPS_lt3&>p-{qLO#<*Uyb_z0pS%SeVamZP=`kc3%?1S*5_tvO81(?<$H5ovKjjCS+>hFkC&&= zvvSybSvMdbB2QfoVS;Q`x-R-`*K#fS~GDhvgx+yq@5LJvxy)HqTUz zM&SrLCCQ~%WWsglq+b^&r%b((R-Q8q3H#}&{pnx==LqwTzq4`uf`jL9sCF&kGd_I0 zf3oZ)F3NDD0E<5_0hA=_wDjFsTi~&(w%Fij7lf;EPl*%+mFlMFk~#MGmzN!_J5r{m z$fyFzw9J*c+C9$8``^fMTZ2BEsPuY0C)3-@mzY?#$<;kpZi7u9cZrIfdD^i{UHJHM zQAeAOT+Cu5i5q?*?AvLmKdsxq%VyZIE=QN=ah4CTg}&MnIDGa6X5Z~r>#zuT)Q|GV zg!;Z*&A=S`msD;og#XwbV}X^c%jsPn6D0~h_wKw)y1$5vFH6w$93(RIqwS58&Ix6Z zP&`CMWV_RzUu#Q~_4-lJl+Ano$5~#n)+NdM?uTZMCZ2VBbP(jzEOMq{FG1pot(QKN z@sacWXxvN9m+L43Htxe=E-IUzg&Zv*&Q?%3Fe^T~ux1e^z8fCqw;Ek}qux?V*3Yve zv=N^4YVJD}P+vOj%@^08M`}nae&9ML4mL06E#hmpdYa*n4Rsfnms;BN=zWvV+gc@5 zYi)vT6Lyr_A-gTlmXViT8hzKIMiO{BNfkNqo>kvQCaw_<@UbQ@_YYI1$ax>n&_YWU zYJ;DqI}rQ&d?}pcz&j+znata3HS#a?1$L6OqjOdKNX3bj2Ej1-r+*qflxmiABI0NR z$!olbyuri3+xu*aoz_S8ji|AgWoyMkFv+w9+-|n6`=7Upg1KquVXs=up=Q&H;zgNG z6G3@?+oBu-#auJJ>hfMPTx;-sp3-2oKT3`={1hX@T!@i`TP7@c^pdsq=-$O^qXzeg zdu~Yg)iq{&8AP!Id904;iln~$h6?gQqZdCV#5=B8^ZC?xqB((6lloRw9bfg7lXQPO zEjm}(k0jjRwOxHW#0SF;6nn024;3oJY3%;}#NspM4$d8o)1|)g{ni&#{7BZOF06wb<9G z&T5)`zgkI0pdRRR=1r2H%tA$AuIv##LRHC+)RVYX)w8l~7P>M7QzQm9mcC*kM-2B~ z=v?sJyqFfULVClCp61d^Zr>*((|U8Xr_BzMETrsmk$-+#;^>wh`_=vU3NWO5G+64C0Z`prm+b zg}z^Jw@7^dWs19UnMY-(ljJ45`X&XlCrRo%MLj1Q$?PVt4pK@zzd6tBpz-0bM41;@ z#$JjDpF+*_!>0xF1|J<3=jf}}fP18hnJ{hCC2*KT2^36{I-^JUv)M)c)tp%Fe^T*q zP>trPx`|<=ISitLMy)FJU=NCiw}s_p>rVpVB=TR~B{PJ}uH8ncssIl58Ae&Q$k*$s zr}HQoRD;)T&J~-Tfv6DcyFzf9XU29P8u5jSqF*aCowk?~7~lB2%s& zA=0}fmVovO?~d%~_)nUfFO-+qXd&5GX;|GG+Bb=2h>}nzpz{6axEkVnuPPPP%9^rx zZrxTw7-?*L8bk23Q5-U2{CNj<=Fm9->4$7}uOq$0PvyZbr@cA=_A}jOiPoG|=arE> zX29(7)`7f~590g$3Vp)eV%~HWkIY**o{BtZL>4i58TOZ^qqXRu7O@(%0;1kqQN7U% zTg%p27XRM$(AQ{8bJWE!roC!OD$QKwOL7Wc8RCdBWf~6vhXwHDTkR|;i&^cO-hrS< zkz&sM?!3EKwg^eGROZFnwxl8pEX?L+jBu#a&|OrRci}~#wP#R3he^T2(p*OH@o^V6 z59E^&{>U!t)&4ijjsz<#qs`}?zyrM$q&479+YoY$<1kJ%xHA2Hr_l8%o03r9-Ox_EPFCCHgptnc^A?|Z7)aaU}vRLjCGWK1iztJ!8w(kM_3 zpO&M0tX{2x>pwnGp}1{#%-RuR72mySy7%D=yj2CmC19`k^BxTg=RqN0j{&eZB%rT- zbFgEueY@+J>VCu{{>LcRjdqIdZhejHohU1*blbIF!Anu6`j9in z3SCd!h#%4CB|b)iXMLSdyl7i+E({p$&!2w13H==F!!nV%wn8xsD8hskXmC54eO}^*Ff)+p#dAKhq_SgNRLt@8dlbq*8Uqu{PV2}f;P z8Cck;*z~;sudje`h4cjvlII6Dw%u+fLwP>Q`G@50G-vYq#oO_wT(&psE!g|23-25e zu#6uWXaio`$T|J0;7z+Y4y^or7kPBB$B%1evtpALavkq)s$Gr8(_3mEN)sFTOc=+@ zif)w9be#iaYf79Kld7Mxh2%>HBdYRL?21$o zH-Mv$Ny3e0u}7Mug?OW+D}7t4q9<6#uOY-NptjSr0o1I0Lq3`VUGjs%gwf|a*s?_} zA_55tLYupi1lEx7y^2@WE9V-qjBVB5mtyjLKxIW}`_rqDg7w#u$2g^2QT^)L^W4NQ zsC|NCyevTnF)6m0-8#@CXB!)W0{KhLSa`UWXX>I>ySVFT_Tm2O7eofjj;@m)hKM^S zBWb#iibUNNrSsE@92!^MJwodi3#`zboOPtFhAdDBC~S&*xpP1^Q@>C{a>aqX-9bH( zJnGB8qMJaG>-P&2iE3*GIN;uu9fA^C32(qN@1G7(6JjA{l4|>E*6J^m>kL zwa}5Kf!rf(xx^!=C1A{H0e-E3dN${?0xkz1ZnAl=UARE6fMw5PZd-Mb5*rYY;C%H@ zjpF%0Sdv?z(Rr6{`YIRD=(=yzz&mhRfQU8jsPC7~@->Y61ZOtowpw*ob9)$`xEll* z_M8nfn|DeK+CVxaz^@t5#R*xE@wj_fn2g2Bc=lk%&ML*0xh6H>Vv730*+mRHlQ7sT zzNghm^U7K2o@W&3hp`EIGjRTT3zm4-b&z;o=8-|oNsgm=O~ZN=V%8*N_@S{U*Ic+{ z$f8Q)kLYFbj!y7v^%kv5EerVAT_t_M#@DtU&Hf&CsPyordJV+I!V0;x#0zW6{sOh2 z%+!Y4FYJcV(cEB}FAWqh_3|ZI-aRFSHk+NpI?0QZYKMwOp(obK>yL3#I@5R=O^W&) z|9&_BT~-)Kh2C>-FQr1F;#!b-jln_+pqW1lc{M9St}Vu^5l%y|k~=3VXY2b=Fv{xl zyqbM7+3XwN(KPSkIbZe{lbUosg%yRYI@Sk@)El_dLNnAf3gM5Qm!u1AW@pP(cWyT= zZ08jR_W+N7($a~|>#V}a@E=S_cv})v&ene>M9nIt%Z7KSZ+neOhzp};hx%w zOFHMRH$mNeI>7ihbAa(WgA$8F1j3A?!GFa%O9rUup26*667P8SK2Z_Nc+x#K$5dFv{{jfkNs~CtX%c^I=!qVFWxcM*Dh7WF|iZhH(GlD!%nG3%-qo7rr$Jy3~t!_=~m(dgK-mYqO4yFXo zlXJlWG=st|P^9D~S07zFQtpRYagzRA0y0(wlPk{I)>X5D7^aPFA&)5gzo6z4=_=ou zAwT8yub=eX;LK74Jd_!zI8nWrfFiAW6GmCyeJb+%CP~GuvM4(3awG1IEN}L5Vn-EcR0M7}@O*&dPbqkr zo-M;;HFpt8=A=<@dcx;#;nTl+49SVLhEiqLRp!`kgLsUPrGwnQK>^;IhIK7q8>pO{ zn;+tlld&)xF;7^+8CfB`>zlo|57xRZn4{0O!)-2o32bu|gN7PbhGRLYYlt81^)^w*=YmDf>2q7olfCSV1;M-jvg7EV~7moNdl$n zka(^w_H-KvVPk81+|qoeWT5qOYkzWN&ZLU<{b&t3=O)VoDlD6u{*>ol?UVITJ=eyo z@acy~SLPua1uY(@Kk^B|=*4Vyuy=K9PM0iQrjFq3%0-3N{&N_KK}~=j%`b!;?cF=q z2#WZqbED=^+pDz@6|mg=GrAnC=XwlMg?IHS^Hq=HFi}V|q?%mPp{gPc4#%!2k79QM zMo)8$HC_*p5a)+B4)_9kls(8-<1mleye9sI84pw9>rd)Urhnx*>wtS_VJ4qf047$b zratV&6fnD|2{F|lI>jHhX3H#O8mi+^;b(_ju%Q@HLQ>SZHcnuUASs^sUU5L;Axz-I zRcAL&G+V&w7s;gs1WtOt{qHA<9xAjsR#uCnsChQvub@cwp3{IRAg!}QAmE!D0O0XK zFU5Q94^Ebt9q=;|HZ0#5MnUSw6zn(qa(d5%Mm@zO@gWvSI}r} zy;b>$LqOihDT18tv>}98P~0XJMyWYgn8}nFtP)Q4`)i?Ae{QfbsKG_+m2sryqUB9q zKRDddSZLj@RmW%fXY4n#rT8AP8iQzzijMk=B=v*p6tZjU*Cw+QH4cH+_K;`;98R_U zbVau77Q+pbf&(7S5Qoz55o{)|Bih{T5ZCHbXu6J-?yRy)qgpTqXJQh)V{iV|FWM>Q z=(awg+E9pCHhE`P``nSQdB#~G`6JqPYh@4FPZ*2o9PX+a{cP;ja};fT8NhYM@$n}S zE+SMVZZ|u{E)hpJY(Ie)dOQmN8{eH680sCDdy|p2?yJ7t2_#y#_!cBX;=lfD1ZW5Z zi4{|F=z(a<_%7?XM%m6;T}z#xXOigSkXe@e$vScBI%xY}e$Nj+=p05hOzOc3C+6hS zrLO4EExoRrT*)mw3-!I@b3PcQCzRtO-@$iDU$h7+jzoJbC7#@mc9~sAVRQrs?a+B+ z98t9%1JNTfTJ*8eJevG+`>UElN#+|JOeE!+axZV6!>I|Qg-uS&RL_BQQi)I9UM~a1 z+{C_X4B*^mUB{NUg=O?`_Hjd`XjPN7)o{geN$t5!OC7IgUWK@-1D4{gyx?K&?(IG- z;@9NfrA<5Rhky$WCTwsj``lf-e7}e4pNG@q&7_j*R__Xm-G*uxKjCR9slERis5@+Q zmRpNtT?#Erq7MZ-E0+K3k0D?+4?kYp$MVUHn5?=ZW;M}Y03?#3Uf-=*6Bj#iyMAX& zW=vexo-5&#UPI%|uor!`I!gL8c}sT`9fuVPs#(Y7Y1_q_a*VH^m;-T>oAUf65*uI6 zGRBq7JVBIrY+A$(AzcL~jf&mWywN%O$?EwR;;c&!bkXYaal_i`kI4&dVXi0^li;J2 zy}WZ6_GB?X1>Ar5_}1D*BB<4Js{fQpJAGO@SanF$Ok?LKbHS-W!4S#mMbhPlBqUSZ zwy&adb}lFNiq+`Hb*E3auuMkNM@p!!xZ3gheYEQNB*-L~CSN``GpQ1IwKd-J(qclJsn=wXdF5)HbSq$@`A#WXJq8z%A>Vsby2N;cMgE{f zY;fhtRI!(2N!kFeYQa7@kCXj4lg*CQU8zxzM^h;v?B)^ApzG!6c$($&cR5Vn08FvxNzhm5?n=!@GPUrn@hYCl^9Mz@xY;DhC2C zN7m=p=Zy%nH9=kPsvH(&so5^3zyO1SbT-(3bAwNXFwqjspeW^&_v=&^Cta(j36qj= z;EY5nAzQ1vj*@|maRQ_S@_CwahHREX_y^G&?$l9Nq^N+vJU!KX*T{2_ImSCnygK`l z`qCKwl*B-MNW>kT_{I#6iVHLf^px&sDILXMc=dE(geEs$Ps)1#As6CvUwb*6+H+GM z{M0!*XWU(b9ulmo+_{CYMYifDV*KzGft<_$8p!7*So2f?@6j$Y!aP@|E#gcL4wDD{JALSvT$8s`ZZwGDhIziVHElAj^4vMW=6GqwGqIZueTXcnwq!%U~SgA`@yN zBr0Vw7ayKiuJ}%kw2QsO%GqwS&lFwmm7@BUNcIBpo25k@gnQ?t_8S1PbSM`D)k{5{ z+vD3cHG_5}8LboMs&>5OLHxVrie1NpBSzgU!3SpF1fO5wHqdKLdli0qHU+TYPhsKh zbN<5N65p{;_B=+{I6fv@M`WD(*kwSB!n0a-{&&((s(o1%i2hcESbQ;do<1FP+u#Ov z!42_(4TDeZw|CsQv5c(p2E@04aQZc8uE!Smass19XNw>s_cVnp|=(C zL<9I*7rK9nxisi#F}ioq{q&fx*Xbq&_CM&XR@mFO0uA`yx%3VCZ%aNn8HG@8P5x z0`q=lh%tfWZbK$?X33OVb5MHB@j2d&ITl$)?p>(9^%s)~S%RV@3Ts9{Z~RL`&afx` zwVwx!$*kkAySzmBsY>G-)D8TYoAoGtV(Xj1JwJFFV7@VeG!=lf_-?%3vnz>SA@% zL~s}L-f5Uk+^;6PVRr;iFF_09%?t4Ms$+&;fa35YPCv0XZt|=W~Mf%|!WUFUKbrpn7Lqrs}GA6YIyox zt!guy`{$FR52v6=LHDm6gxPq6Jd#iZnp<|E(8<9vGlCdmSJ`9f9dK~(^fgch$op7F zN**qR*q-C7+e@g^t4yoKEr*2 zyfEhR*H><#82G8)7q}v~HlsNJc&-^U(_UpHoV?D0y{cTXA5EZp)PhS%o=rg^#5N{3S9 zRMimpE&?KABXOT)?e9@PufK(qJ37}MQo7qM?k8Ysk zG0gA7m>}tg1j8x=+<$Gfz(#F&Qv9}`S``qLaFzJ@Q^Mmy`(Z8(Wplq4iSN8pE^iN0 zQn&JWo(jvmohB7LnzsGc)`2Enrykt=)@kkAfp*Q7*?Cvz^eIUw`DY_ZA1l=DWVv7Y zO_z64hspFvz5Ww;oBjAx-=9Hk!D@pYRPAtXcHpHY-Zy6DHOhWCYJ4rXwaW1PplQtM zN=(suRo<2g9U>Yu1wJ`^`rOuS-R>AaQ$p#8{9+A-l*MY7+7!E51`u`;PQ&Vt5o7~) zb$N+(Ib!$5^EX|r`%8V#2kfeTJWm$M7pk`w>DN&=6+P~t1#&TiQWQ7`g@=jl6x3fN zNh`#O%{qk#Gd$)bp^mArS|)g?&@Iz1);Cq;d>BmJzv(CkTl1YgWDW~y0k|VSoI32* zXNMMo(?M>I4zlyEXIoB0CFjVlLHDL1Qe)uUP5C1)-u**k?~vq!9b2-ms>fs!_EHc? z2$)gQ0S;KBtbb{v=<`?tQpKy>+T6 z^~JW|8{f$CF=n}QoYhX+vB(;?$-{5>$eWi9Z!Q1OA<(#x?Ip>*+W`rky?9yIaU%BW z@lkQbkKaVYuTf+iTC=hAc++9A#<`mmSjMl;Yhqu{5K%0*U5!s%HwTqkEQbKbU}ZjK zNhHf>A!3y3y`|@mtsg7y50R>AzJd?E^^82o2jECn1OM*@I_P->{8l~MsjFx8NiE3& z%hpB<02p(C=XeO26hye}?_N6Ut%tLV*EsW_XUtRwQjbRQi7KVpB1N$PLll}j$jrfn zuH=g})Or*1=W9dcjW0EWb~{;fgF(rozda!3?!f-foRG>HeK(&s_cuk^4R0@psV%*6 zX_T$c?YWH%4VTkB9(sH2037UT;6S`Us82O59@dV{;GX#=d>E0uq-)@nXqG+9CWpr5 zZlrW?-=jc`y7H+fu19~E?GuT*DYg5!0hy4LFI*JuKSJof>r(JxmZl8{)Q50@{ z#UVC&8eYHJJ=-p}1p6O@`U7r$*2C}oxprApYx=)%yZALF#|v+;Ym*d_8q7mDG=dP% z(j!Swl-`$)pW$RT@)_C3Ua|>5$Dh?sBJn0nFsJEQlO0#|KhBYVfeM(U(xDzve3sH) zYoBz+iD$dEv}eTTR>&~#l5c`}p|8MZfFi&Be$40mI$JEFSCVlU4#G?uZocZQ<}hdgN@E44 z9Tr)+{EDg?c8l?e<2#fYP<^ zqD`68aF8%TPii~1aLerP*i5Bdib#^bLcZO&m{6<)Z@u+c9moCd3DFNe#F8xCND}}| z0!Ju$Enl+y73gp^4wWU;#2N7sO}qCsyCcW6I2uU(t4e`0_YPkQMqE)}@{UR87%$J$ zIB3C-)Hw&QC`>zi-Xg>ps;W50$HvSXELF?!Z1>9&556lC|6~tRa^=)ey`I?0C!iCM z{_gh_XG8G+B#Y=wjq5%LU%raq2g5Qxy2$(KL#c6r@#6y@_tSC9&%M|2ZmuXWD%Zkx zG}e{>Ne=^zB+KaEL}Hg5vBag@+x?&`pcf|lG^R>_q}ry#rfx(>turkc&17ebi~e|9 zTC?znaT;GrWQWm(!I}QTA2VrunlJK|%iD#WCG)3y$;o``$z1ukkK{JC7iK~TGx?YI z$Z232>@G0)kI15ttRnKyGv7eA$-zO~A(IiwV1y+Q%!a7CFDS0uh^_6Ru*v#a> zJWU<2^uj20dYNmQ>hh8HDoAynm=&b$&zv4rk)Ax8Me@>FtHk`6Har>LXXb!`>gLH~RqK zs9Mza$?FmcavFAAF^L;4Fl7hz-lB2;p}M&IJ)8pajEj{Y!hK0AqYPStCaSVf1@SKB&QWslKmAyynNq0la)keX9(A;=7I6(C(xY!5H!R!o}^k=-{km#sIlb!Gitofg<-0Kc6lEZ!#%`t>zS(Tk74J*-u&xm4!AlGwYq zq@wL8>7WO{n%Q{$-b4c!f&5HK$gM$CH*{Cg4jD_HogiOr2bS!=vYVyI6=o{68&W(Y zf%pb;Ov?j(aes{Pr=7Qrf+i#~LM_|qXXsHnj}YcVQ3J{kc{-UgW6Nbc8?_-bMk%<9 z*Sk^7j|bCT!fq0xL4MWQvyVkj>!pL<_MNnGKeMPpnR=S;ALxdzt%c(OTBm!P0GniJ zdIJWcz(xu74ofA1NgfCmgzEQKcdb;S$M_L`j>z&4UDB*iOv zWJ=d=HRR-4fKgzAfhtcxLr-zW|euG(@yaj+rWPr;Bwa1TuZ~{_AJF|X|^c?kUnMh5(ae^&j zDE#E2lu`|s8Aa+D>X2sM#{KQTMV>P4MH~(N zfl*9&=!)kJ&PUOycdF<5S5*=+{Z&d*4$mO;0nE_)G%k47156vLkIB^gh&(p*j@Ky2 zw*SfXT)rUgs5TgkwhF*c?o)6xo9i;!c{8}fY?<`;A!OMK36oDZ)2T)MernZuKLa;H zTdd3Ny4_q#vtO~kKXu2mXOvvJBK#Mj#98fA4OtrcWy_8Z^RESA6GPT*Xd~ku82;PP*C@m z9=t%3K*%m>H%#-Bza*S`y3>*~*l9?(Vs|8XGW|l@D~IH@;zLfn@zCz`y0mM-9$%#} zj0q->Di-==n74(ol$k@i4^Mzu{r;v7CNd@7;<#sPI>2;9IKOz!889D{4UiwRYgh-WYWKG{Z4c{Qbp2U{vI8n!tan^Tb5RuD+#9=zHLQXm2wkaFo{iU>_JZ|@0!=t zc6qDuqTE}*Zd+F#kG-9Lf2_#!=s-c|_Nf|jwBFvx@Eb745=wdAuZ$0c4jaG>#fK6=A76oDlP_p;uX-%jI=dcCGIZy z9g1)kI_3Kc*HfQ+-hmf@pB?4K)daLi6w(JtU;VV6SJTZwb@yNzew;wj z4N*#7^Rc+wadT?kdKAo#*f8lk^#;pkQ&?8z>Kwh54ZtW-A4Mc}vUclN(x}V`y;pw9 zga{aL)&4^480*D989IFbcZ!Y1L0cLQ68KXk5%fdh*o7zC;#kI0(Yc(Dqm5b8)=v~$ zEDfcf_TQ9E|0i5doIFsbMTnsh46o%|A2En-RKDOGZ*x9oCH4?gDY8HoX_u?Fb{;N# zOc$$Gt)ynen}0hwbzUmt;02Op0W{mZ)>_}xo`e)~la>TP(fn z8&o4r0}HSfsEIeb1ba0nNhxhWQJMDZsbgRVF2-trJOA={oKdbRg4_?&$=6RKa4tHeN5~V;>XxSYOn~K| zC>~Vr7-&BskpX@wgD`Ypf0+CsB*Xn9ZbF*P~ zeU691!{?&Y*Z)IhSHBfua}5G2y-n^xHs8Zuf=W%|M(`oVcEKy2(Q(}NS|mqs-s8a@ zq8{3QyC|9yHojrq@&Cw#z_eTBxfKE?X}AL)F2Bh#3f|t- zr1x|S{=$s*;Z&0m&v;g+weaN$3HRs}Vp@*$RI<4$NA$3VtR|+4*Ve2NwQCZgME6T~ z@wC1i|6}hr?erl|_omm(c4Y6kytmawo`9AD_(h_u7OLCWRZ8TE1KSvGl(5?-c=K$ZHIuL~G#nV6IiT~|U|f44<~We+ z#<*#K-&H7BD74y%Q}u~1j6(jVC+806R1f&{pQPBdah(;xvxVOgU7Q| zN_3KH0ArU*Hj2pE!83n12i6ywQvNtRURJy$)or*H=t{215@EPvxb4j6Cs{xHu(wQV zM`PQ$w?Q6$;-n-7^-;I}o)I~k=bYcp+P9gi?3wn5(c#fsY6#t|3%g(6@D)WJN-oD? zCB3|YCeyF?8o0f$^V8L_N1mAyg=W62acT(3wp~)7O$raz8frP z2aL}|XtQ91bAcQj?|B(^5iRSpJ=(RUyxiWa4et#jP9Pf(#82ZXz@U*&(Q0$7MJQgwQbxD)lw>7 z{eCOfg^YD!dUWN=%zzF)?Dp{DMyh;rHNwVq)P8loCVkr^FcvDFU!wCclZcdj*SxsZ#|U46XJUQl@frUC&DHIP|G*jJsZM# zjX+;wd;|B?Dutp=z=#UEQYC>|a|EsQO9DU6@U43lw9-=*Mp5{l^Ob(3g%HpgbJa$& z_+B%snXSfMP2>>3g`vflc0c~IHXD9xfBIFpz0^$yTkgAT0zE=$Z=GCEaY0NqcBBse zP?uCaxT=y;xUV1KtVY6>eGA5Q5p{4bYq*Mba8Pf24;4}uZvUs!U!+k8ZN*yALLCSTbyjVg`Rtht`HqB1YWm% z&dEnfsZapMeeGHcSUXnl66oI?56YwCSpfO~AxL!kfZg2zFSX3C^S$Hlk<5uwHN)A< zXAcCv{sU!lk33}h7T(3CGequv*)Bbvs3Q#H{>ls`8rb$d=C3@YEeW|O(#;6l|Ityo zseUp*ZPuBdS$ny|$T(L+B#*r{?K;#lXD9mRV38$#!hS=B8|MKG!{kVZDpQA`>(!g| z4JVMO6*9TEU_H{uQ7v*!FRm-fkt!f5dalIpzR2YML4Qt$W>iPhw#O;+xKtqVFKJDw z3LZ$SuaVEnkSB8WoBmAQQ}X?Uq&I@ht1I_YP{}&p2;|dt6h{9p4O1SFL6%;GjM&pQ z?Az^8jj~jk7w65Z4Vj*+y^m2>&2;)F;PTnS@k9q21?A-Xvu6S<&j9UY1N=Zw4=UxU z8+>&Mss^1o%RxvF0ZtEmLIol3d+?q82a4yJJqp9IT`Ru?K^mc;d6QWIJ}R)8NcSfM z>p=bHo(UrSKiTRgqfWn3gz{etfo0*;Pyn3wB=orSBy>Gn@aGtf2`ft(q z5)-c!Q=Kq(xd=i19sfYNW_sY{(7=C)|A2?|F-8dL=NhJlnrE&Ko5e%{XXS@-IE*qq+hMzyZ*Zhe&i3B5xocgtBHW{ z4(S85I3)hb?UXqnm1G5b;UoP^1TCU3LH*5O37-n3)-j-`I?>Svo@Mm$-2)+=H!XjE z_~6Y$>-bS>rIilJfZ?kFe`!Qm>pENNfIVz-5XY zbX`tyNxt~2rjK80fAvs}-{Nq$)F?k0aASxXij9&fM@Ik!0K$a7s>!6~9)zf2xHu`= zb|L7|Jt`>n4|BkceKd8mQjQy^U3T66pT_FlnTvC0MX&PbxK_)2z#{ zS?vep+1w}Lk}!NZ$FFc4%T#6HEa~X0{v;?ANryVN?KPb#8AK>miZ>@`(czH3c#*Esra zFQ1+!o>^G+{S=pKNG7j!-=ZTX0AB)si3}n&EuYRY|JH2 zHx4=+IJB2Sg*Q(^&7lV3(Lc)>=%{$E_HI)CKZvw3u^ev6MbK@yZhJdblNHRc@ z@=8A^X04-mUto|ulKi`Yho(Q}mq$q^J^N+u+cd42!9kpz0{(@|VH3Cvv%UKrwxi}_ zN5Nu{dA%f>1`?GnkTNEHclU1{9l?&9bqHXjRs9_Z8qJUFuT81Cb~rovWTQafIMS=% z(%Do^q^hernW;j%cDZZYSNdrSx}wEE$>g9^%xc*Sn6hQ4U)^esF= znFGajjl%%WAS$Etp5;1Kri-bVUVG*_(YHA1!PGIQp(dfcqq!h_LJv(B2RLEtmbt$9 z_7EE+sy=ty31#P8xm$7#SD|iA^*rSQbR*t`<_Y&3$>xi;fQ?5z1QFfuOlsCtK@X@Q z4JI15b1gK>R~`u$jk-^b9fiwlNKe2R?bYU~?mSRie?6h<{~M+TeZ22}&OGca4uXNm zys>IKKN2z1CT{>g)BFY@EoXKb`sqk(6F|S{JhWc;GKc-z)FC+hPIPSFrXyW|+SVBL z1MWAjCtW5JnCxN)oQ+a+{H>L|?-Oo9s-5moz>1hC4e}Ajb~2uuLl;ihT&5;1hd~i_ zNunl|Mf&hFFH*qH?H01AHeeX28VF zyJLM6?F!wDy8z`T8bK5X`pO>qaASGw_cG)98crJGy9X`|`v(f3x0P~2nJOKHP`-&c z4e5TgKB+))!mz0Hs^y0Qd6PHOXw0RTM5xB-p}uPom!L~x&|ve`0MTuo_x;t&t4iPV z(G}3mge&WP#Knw_K-=-a5u5?^Jc=61q&ZluF}uQOozO#7ItDIkH{Z0rfs+p4egVQD z@RaynU_kGSl2DV=u4))P!1sH^y0gwdn=ApBazUf}x{0@~c<3J)?x`HGrJhFk>Pkix zQB|!KN|gL3;2#jqzV_S6Z_tzk1z7`Ol;T1_o0xE`tp z|EjI0B3r5@fnd5WP<|-wjk&!{qW!y-kGLHu_>!&`{TuCT`&yEG%sYB8Se4Xo)9_2KFpIC!HV5gi)r5P>+ViHoJ zFTBrHJrGK~1mt`v{B?BjGzv zI^6u$HM2CG%dzy-H#K|Kw}L#C2v-8v8(lZ`kJ_e+H{}iCr(Oie>Oha~Q$eL)b#hP! z@NbO1H^W**j6bgoqfFqZN|CnX;kpL}8>n{abX|Do8#Yx^e$WF5x+z`~W*-ToKGk>4 zv3}vdAYXUF6^3WdIeX|oBY*m2j}+xL($6@VjMeS)yM~8cn;O7*aom4@lnVSxf!aLu zF^w8BKe^2uIIo-&7~8WBk+P)Tb6!~*J9_uot@9(2)qLaKpwq;F%3*)d-v4XwyWg71 zy0%ZKDhg&SfHVy%AP6WZ1Q1Y>E+UMABE^b`NXyVm9L5n9f^C!?#dWVpF>)<@k`(58p-yiViy2fjA_Bp$(z4j{ix=(PT%kT}h^*ha^QV$W?7c1^; z#l*L*jbirO#g!K^SlPOB1~m%meq;T8v8*C^*t4&4Qi9nz8x*1;KDsy`Xf6{^)07hC z+Lqf38Yz7k4(P3v@s@d(&t|UPB~f%(pHD}IsdmK3P`T1O6&_QZ)q;4Iht>$yb2|K% zx(F)bi`olqun~F?ZCZX=*7JA#J)CyERA|q!pH>V*B)f1SnoBzufoI2t2!A&Ca18C^fF|^j55G|H zz&42+goX^-G;yYfJ+9)aibE*BjKbJRq$!NMJM=TpWA+lxBz-O~1i`tSl;e042e%D} zy740B2cUYxo&TleXCs7cNp4*0J4UXmaw9UAPbMO~EdqNUpwovDh=MjrIqyrpHenEh z@B<6y2K^pk-h@3CMTmj&-?-tdByoL%c$xCcHOLWb1(8!HTiB7oom3SBN144YB;#%yl<0w!aXR`3mYSh3&{VX9Ghns%_iM){Ob0w0IDD!52*>3Ro{L95{pMB_tQM!SAAV`4n*HjC<)Y5aZ#BB{-(y8QJc`b;r!qkZLBn?0qw zRIQ@Gm@fUH9-Tc0yZI7a5}KnL<|+Eu-R>5s&Ufg3A%5$dXp?EJ`&h(4Z&Q}i8i-~NHrtkt^eS`hE=j!d;Z%NG2G<#DBjUBtrfU`%K zP+d(6_D>UI^0jHD*wRwh9AR&k$Tdzo(bNq+WSXkn#*ruz;qXXfU#RS}z(VqZa9U}g z$4u{ssY}O2{zz^;3`&YzTn@T?2-JvRD703sRa+e0bOIwm(=EUH#hQ7B9N@GUQf^qZ zD57%bk>B?xSo$}ztDzyORp;xCa5N?$Cp&RKjevJ#Hw8U;Hav*tzK+k;GjsT%i*V=l z=N>nDipr@U9QXjtsDT71$%||K0Jv6;7&kiAAok|Y0YR)pbW^@zLho3AyIo(=9s}<0 zXFYnjjn9Bq5H!6vO|AA}n`Po}dy>WMXzM)mIQy%SM*pyX9D71}oOv=k6ehPLwx?t^ z0PAc=7nT-JY!~X=ETT%SUi}8=M)TXT;RC2mfTNZk*;IP(O4D3I39Y;0ua~$=n=k8A z1J_izpXS2Z_5u?i`9@^g+d)f>Od##+*lD^jL_MVZ2aqb?+9u^d-i(;0SNnaZht4+| z&?IrAiFym2>M4Ysym!f0zfxSY@p$rTK;U>}lgUS^&5}v*B2EXT5d^GJ z&RJ1A8j@=0|8C_tzRo?O>9Prl;VMQSNvCd$SqvnTXs zTYG5JcmIesT>fdSz@D0)rDsZuC77&FQSmC`bq4x6gI16Uw>4>62UsP?v&Z~Wd561j)_-~s_n8L+OW`K&Yg6=epzjoEEwW=c8bHH8f(QkJhL!BTocZi3NUFAI zTDXDjc+!j3GWRT55jGG4YD|fd5&3L`#(1Ndtn1#F=>iIJvHWb@vtfDqu&pIryKNqf zJmjIU>*1}gs?>aTJtD5T1l~IL_g8lyHujj9D9G_Kq3>2`LT#2kf*Q2PTceS3*#^%B zEzBMwD|}Y|=?XA5NA7Y`0vfUlkAJR!L#qIWVrv2b)}WB?fAn1b)I*H&&Zph`@4+rm zP7fagCuhS+MmtEls=n@ok$aBHU#Dw`Gh!i?SJ!kNQE% z4nbO;5Hf!9KQn(aqFBT>DDN`qV0shubo0Pdq~oKeHM={3&G1PaK$ZWw)!xRRmqO6} zwu4wBSl*s0|KAQ2@fH{-kHcEwC_*&65=81u|7oB?~}?umj<~gPOq(XTiCkb4}Ufr)1)jMIe#7RfW@< zBY97>hIt;+oZ7i~YW&*C?ee?kvv&;y#rYM$9c41s<)3yu`#m3S06%*w*Y+2i2Rpqp z`hUSf6!G*FG9s4+=6v9&|{B68F>mIFO3=)xik7HjMlRpu%j|2luvLz&)H zey(((HlFNCsEw})w3ZY3JrD%XJ0_TX4$h$YP0y7v3tZX&=)TZ=>_YYzrAVW*T8G1Z ziQTZZNyM89%;3N-3l*K4#k?CC{m}WwU3B;+1RU;`i#L^+shDlV*Qx5$B#+UQ#xj~V zwdn%A^%c95T+mZ~Cn8o!tM(RM_(|`{hn*$Arj*#>>}o`AM7gy4JGd=MZm8ZC& zf4(>I*eA5;B$!VGv-7alh(KWo(tyS7;2M> z!2EHc(B=E4FOIqw{k*h%QV9v$*Jck6*}YJeV`NazD&x?Y=97YUFqo&76E8Y%tZMw} zzBBOvzJIr=kFpJEH6F5t5SnGI<1w)G2GLdzjJCUTITo^#Dy%1(<$Mm}{N!+= zmdeyTfmC5Ru^Z)U4&Y=W)R%nw7cpRkleD}04=5UqbG&J{=^UHs%i|w*VR8=q=w`g#%7S)B4U)>gY=0c^?cR+e0lvF@@EFM>_5N}qkGGqMIwr%n$58#J;Pf7;5PP1ZV&QvZgA1Y@qjtpS$AdZ56XLC zMJseJE~_qVpHpFg+K&JlzdP@G4x-pxZX*3k6%u1{8xRIAao2N$Ho*{thgCd%O;{z^lHcMD~K9$x)u+%6xh&yVyo zJ6})5{&!jVoSHi}Skif3%BLkbcyGo!Zwb8e!v%k@BDSRcrJb+-d+Tdy#0BOaJV%LD zAGG;&Wf+Z-4{pHR-@NA3%x6myJp+OIXSM*}eQ5l7xe2ks%{$$JU6)b4U!l$Ajl^6{ zUp$ULb>l?>Bn9^#gYHa^bHrRMC174Y#Js8dc!)^(8;vF2qZ0hiM%hoieg_=(z2CPk z(2pZmk+xeuE{ZvA;|?}MQS%RS4n)B#%lnDC(DFJDGU5FKKQK04!8|MjR>o%YV<*u|@y!2-*@K6;4{4u0iBXPl{u>VUmIpmbranbdA989Q z$%of)eoo=)F&`dH*pTDepz~EvqJ#FK%KWaO^daUYz*)P{^>d)=lJ2Z;7R6YT8X|>h zByMjIcU&h1;MtSVz7-+&DY#FV;*@KY>c2Wp1M{O z&(CCzXv7)`guuZ%-*xYXc)%Ho%%{JRH(kC*D0iAUR=u0Il)gcA~p`Z=yQ@mqeG|2kd6ScRj} z9(P2ObcxSo;r52xnGJz1mT z{gE11rfBug=Z0m@D=55yn}4b=p{&`u?8T}eY)cZNUgJr-V8psa7v7i>>c=}xa!^iQ zhRT5|cYJhf0(5sv2+6hXsg={J)g!z9aQCh9YwQE`15PFx#anDV8fL|@&Sn6YOg6b# zC&dvu{;>-kssoTi&Nr99MtlVz|H~|dnB8bmETf&md)&T|4GFyOy+%t`b!3UZVmzi| ziJJ$S1<)WQGeT{edYb5HU_0Y+1Aq`bJ@ZK`j%t9SBO*1ij#Ym{g&6Izr7MxW_2B4u z8@0oVBqke=kxX+K9=H(Q&(vXDLknf}-4ws8f^a4Q(c!9j+P@nMIgE2o#KH^H9o4-z zxuD7{dH`RxxU7NGiH3sLcQEZ+_|XjBRRGU?O(3zW6;%6eG)F)|sN(dB6&6iLoRixA z*kmD%hR|)kO5v`9X^R>3UI)N>xN}{oo--P!aFy=}r1QOMU4?7IASUkIpWC|X@=u#? zpUO6Q+)x`cmo*046WPkk4WMKp*;QC_v@6_o1klw7X~-MRrsdgx-U+B}hAU}5&p~k> z;Wg_bp(5~~?B;%uL>Gp-cEUQfrrRbYiPs_YxoTWB66PE<+-yKo{x$qnaq2?O531cD z<>dX?ereoL1QpNQu9&sSYB|NbBRdQtk&DZte@KPBYAbX=Ld7+T`c#=A09rLlWDOLZ z#%&w46*trML@Xy>S*y4&FZ)}maOSFYTW#bADi^|PgIi0I zZ{t3zfC~bk!2ITm&N!A5b}o{N7uA#tX`MkUPX^qWqxS9VPiBIJb0p)3F~LHOtiQv- z;fRAXfR`~%kI+6!U7!_Veyjvcy>(>xzy^+Fs@s1rufO<%#5=ti5-1SNWhugkc?&g5 z$>j1q{ld|i>bCWJ0O^f-v~-PU;LJo_LZHVTKoo}@=edh~@<3Ov!Ql_shsfW3)s(=# z8;iBqPG+cnqF2keUv(^DJQML>4T&olWwl%x!=M5C-rSW59(8`Kzo|<7&CJO!a%wpM z`L1U6JFV@}NnbN2tbW(P;Vz(<7bO5K6{2xn6J(GPrhb<6olnDEDo%Wn^MMGLlJ|`@ zJ3v=rt$dsjy5ar(DFA3ON8ps6)0PNfO5&K=1X7K1RRDoPf!A>4Kq2cp z>oU1O_PR@7z|oLx&teIWqSSD2WC;#>85C+@woV*bQip zSfuZgGo(QH8BdqJTo>gkQ&FM8E=o!|_MOzn=E%hlOJ;t;&cyWO4xgb1IoLy;a5}oF zk10}lbKMxyc)E;^xXyac2H!c(GMREH`oJ3h;o30T+!Q{sFZ_Gl{6cqlclC_|zYRbL z*|aiIAb+D~tT}BIMCtFU_@Jz6X?tL9Lx~CaqWd(Px1(old1H0stFVK+FNvI{=k%;i zv0f8)$J|BnBi>uHkA>IrKthFW6(?r;7u%^Pkb993r?PVu_bw&~uld7?*=b}HT7*JP z$Fnw!EmU@Xens$nCIvrB`jZQ=OsZcp-JnTPhYf7;T*W(e0tO^S*pyX*Gd@81^Ud#a z5{BhIHFW_;>-}ZphXu%i9(T(pguJN*MZYoIoZr+<6zs1mS8DQhc|IcmXtHAg`?x9#<`X{nDVi4_E;?bg|uuF-}Cqp<*r?sNG`euC&WKRtektFt0bVqZK zdr;|{a8{@~k;bS9j45+alA?8vOcq0I%k5+cFnUYdCfj$+h;u#I*=xUjj4Cy^9UTsZ8}+}CNj%0~`(|4uL3s(Ue;1>liDwTcPDn!F&R z_qis-L^EwfNLEx)s^3_pK8(Co)6CWL?*u@spcJB<(^=&ci`2ZNN$Q23 zv9U3Jt1c^6#3izTwcv?SJ{C6AmASITg}@VUqu3D2EE#Fseo8P-jZw*Tn3AIf)<-m* z#9Y);6Q_A-t15XI`1{rWh7V@ldH1$2G^dN8Lw9P~ha%|@R!CND+8gu2m%sk~ivH)< z!dN-Ecs6BTDen(&Ua}?akh-AEg**r?esnD^c7VAa_-oJOYny>SMh)~hx0(BnP>pmq z%5zZc^mnY9uPE1l8t@Ah-y_;V^3v$h_OjDbdq+rkW-x3vjE4=i?6B#TV> z{-&i?Mu@tbuYzkbz&F{i?CPgJGVe+-Vaz#A%?1Ep=`!7^P}laM#U8#hHKXK1GACN!3LAKL8DJ&qsD91MxS&9g$ zgkILO>vWnc`r;Y#)SoDemIb~xoz0r>IR2uhu;#_N?FeC@>3fxh-0Uo0izP^%WD35) zCQXTDEch=m1)}D*>8f82)bUf#q8>Fe4 zoPmemG$?zaieOmkoxM$XljjJM>oY$J4UC+5lxQJ=xvD!1I1?Ji0Hjc0v>lO2Ug33I z>vmORup4T?9v_K7C?`E;W}{;LsWlp(6XGDRLW`w1eZtm9V2(0?sA{FGzRt)%F6oATQfxVl~r>DW(oXY*s`hIb<|?4+e{6X_A}U5^q}Z**+K=` z^AdLfK*RB}O$pEHGf-XZn;IG{{`kn^&q!SsQ$mAYk8oF7a84iWMJ< zrk3^$^b38EOVee!D~oeIaBqZDd2A-;iU}2YP&c zyY{4bY%l8&tqYh9P7J8%0a7KDnDQ((y(4gWep>~QCW zS_OSuHj))-)hE0)mOH@0UvwAq2F@Re4idj*QFgy)Uu91>O%W^PT3dh{x=Aj0AGH3E z#CRV#=9_FU*Yy`fsE>)=Ta`b^8u6v6d(5|^?94!q6=wA<6f(y@cvXNKEpQor6abex2RZ*&^&=#hDOycyHbzs6YB8>2Wanmdu`_tQZVRB$;O=a}3^a5E#uen>oGpT% zG((6rXmJv8%6$m9$ks#h?0bP?rvYs-lFb79V8-z1PGgimmk7NWhCxZ|e=edZD?&MZ z?d}cWAi(D{!l}ubzlr~UFQRQv0YEtiA=yZtoCNxnj9!F#DS)Xk@nH04{)yXB{9tRI zHe3*HLYfDkqYVn$KyiZ)Iyv|VHvkVq3VDl`U*V5B!8w5O-v7&05oX&4->{N!4N*1+ zh({Z`G~bfIzZbQZ!}89dm0~0Y^Aq1hh71w{(Bz^nV>=eSAuH~j7Y*(hjok_6r|TFu`2dkV^ITl@8N9u)=gz+r9z zdW*-_c)X)WZiBpkn;k`UDfeHIZ|M@;gl^1F$^SpIVF#ih5I`e-dKS17Ai*TAt!ama z*#`a3wnCF~1WhGYlhseJenwL=RV+>|8ueNXA%R? zZpk4t9#r$F@W%n&0 zg@emhA|81?3=Y?0MK)XSRQXNk`H~r$T2UGr#L}Fq}OMTOTumE#tJg?Mf zfT7d|nNGq6AJpec$GUzcX>wrOXo};OP5>N|+E9B{l-y9>b zS4@EViAU?Mz>@;*>p8Pm=e8bM9i3K7{O$fJw(!W}yTO-*9 zmwn4m`vMUk?M7>fy9oA!;yj^`nazu-mBF6 zu?h%C6p;DZN(EVu54ZsJY*|~K3hO?EHS}B3#Ju1A+G6QbUv+p7`)j@&2fO*9Ew8V>nBm%4H#z#Vi~cp+D=vwzUA!HFxW~ypX&o>dCi87JXOw`s`H1$6hX@F zEqyanm%=$5iyz*d_z_~aDBkz;0Mp$maa$a~%_~7}U)!9mvN4~wb$~2Seoi*iu1~0U zxzos!?o8KklKFDR!LqChz?#Jm!($f#nL;$pfY9OV-7r@{#aCU>7n2ov3{=!NM()|{ zYLt*-A$K*%Iw17_-L0!i2H5IN&Ve~W%Y8+Kgl$pLY)L1jOgft|B|1&j2~Ax^sgNij zlIii*%5qy9$nJ5we}Pd6%c~KFh-rD2Q^Da@W zrS}Q99w)VmOz1li`bJa4k8}KFSG8Qm3;GX{5NRNlAy>P58OzbBfh$^C)r2U=$bnZm zJuVjXy$~d7#+Q}uI(X}%s%Z6*c-M=a%c>A<~W-u-u%DNy^Ogz3?j%Q`PYu4yt(sQLr-a2w#gznORd}*e^L;05-monztZQZB2;PfgV00xB7G~bH4 zT$;xq0O&AQ!my`+#`Lln$l(%ft`gCkM)n?B(Zkd(;p7R5+wr zSA^vJ=OtIH;^4R7aYo1DY2zsw+~u;$#e^MKqu7#ep`qH!m=Tlkd~ua}jdZa}jkcJU z?6xE;4O=t*dhuQ3dS$DThQ{b(LFmwDswc_PZ_K9i2?`cU8(@q}-D;Mo-GSMxP&9 zU7RiZGLY!%u{-YU4x%7%-92t=RlG)3Hz`hdj`2PF%ZzM%z{yusjJaS<|3s%2q&_sT zWY5>qAlMd*<~@st=R(o4_g=agFzw(C0A2j17o@~_1m1?rK$Lqgs8pqZW5Uq@<$J!d zZX@rZL*E@iMq&=Fg&BO13h_UU1Nt;PDJ@lbWagWTEqyQA3YAv%jX&e&y|;S3`q}Jh zGQ)y=-Z+8HhAa5bDY+;1PT(c?_qJ)pMY=mykqNsSOp(t?72d68rVTk#s0TK`YVF|l zr3~!7e?DP%*;$Ny@_u)#)v-Vi->uo?m!v;awjXGx`IkZSd5nAwSNZ8`l?a%Nacu0X zKwHm+=D$InkiR0xmATpG023q$JTOtkW0eMRh}Iv!KtpQWQ-6}=XqKFW87RCZLC!X)tz)6rzmVYWn@Df0P!yzjYaH zx-81Pc9Ij(nM(MDvhOj(_ODGCcNItqXBY*)O3`kpu$!i$ES|5ma@w_}*iguKVJV{_ zW8;->C6kPmNTYKidlBJcK)Eyoh8Gv`qlrO*R&+Ix#k4|Ce58wyicZCc0FA~A;G$EOdPIBd*K-U*{g2{|B;!FQskOE) zwd7<4^{P|WE}u#W0uyA!W{6srVdO~LZ~aTC)d4KhnIiC$5G9}Xv+eZE*NK2#{mxMl~h@>pJ9$!^~)HMNo_&B>+c zhRC7a?GVtZtv5*-5Xls&5O?g$H&@wRpo0OT_5TG19>pbGNBfg0kW1U^F9=E9$kFlW%z|yp}j-eAEy_d&53KA|){#5?ddZRmW6d=Y3UQRx3jHX0pzsIP1@PfzGW7aj& zyJOV*^v>;L5iTUtCB^9gn^a0xye!x1~^>z+JmV+3xQFe57nku+F`61Ry<*rlDcsN$ab?p6o zV`vi;<%nJoA_DIjlBXY?kxG>WU9@@_I$W^zMxXA0wF>*sEc=Ixmc$NXoN9h&Y?I!_ zUUQZ6T3SYP?7)!!Im)pw)*CPP&aG1MIGooPT?~c2;cGD4L0m!%O2CI`s|^Z$l>CX1 z8OG!B4>Pg8FRaT)fxwoOtUa9lkk4*o)3hoLqM3=dC)?6p%Y`U44EHFg*Y{hzRmlK{@P!-mKwOKk4CELg6>1A?3ySbD3h_bbPqRj znim<-HJSK;M@pgCPrUm#7~Rs}hHj>we_HYN%5d*9SHuKm44Cf+iAG&ivE4`(A9{x_ z$CTU5^I1g-^r5GlnRBwwPT zrVx|>;seP0&G%d40gl~Z;l0+LUu5}vuPkJ&U1@PA%S-vwh6_y{nhd6y`Agb_iRBtguhQ@I%wazabNN4llQqTkvyu@ z^yN;&w6ps;&N1}97M)ep4mN(JpLE(`#&_EFMwdp?O6kB~u2s#RmCWCpZ#|;Y`eyL7 zC$AV5lOlRy6W}sZ;Xfyb2qRf!kqxQ;)kL*)8|Z`0b?_gZ`ux_&VRE!W-{%VwD@fZT z=ATH{erO1ncDM3z4J$704?Ub=Qcd+@UodIaL3Ca0*Pgs+><~w~MA5r?uDQE1Hb|@lOj`?uS3U z&F%x$yaUelXt5D}n1?vTiJb1JtByD6^kp2!@nOg9#HoYemC{YE*S}hX^>%sbgargBWmR>1 zaTK)csJ->H#jnin%CbdrMbPdGuV;CeBD3jUEaurs?B~|+7oT1_LjOL{?6MNsUTzVB z*r-CfVE8uT0zW2+;POmpVex^QSLzb!@U=7*E`XiTPCDwdM%#IiIAi*VfTlAR-eJaq z)?KBK)$5Bzmz=O@zd%GD3WDSP6yeG@@bZ;=5h~Pnk_3YPi(1c*q!&iN;i7zoh{N#S z%Ix47y(kdRnzrvyZ$XxhykkbPQ5B-we|5TKF$x49qX9k9GdD^QdGg|XO|W3br8s}} zPyU6_sVWJI02p+nHtMIehIUS(Vd{(9$Pw5o2?-EO{3}^D&8!Q+87h2%Q$w|dbznB%{5xRIC^fTbwDR!D^X!o zFAikkD0KTQKDoP|llZB1iMbijA>PHC<1?GcJoSkM3mv&wIy|&eEQ+C2pbNbHwVns5 zTk}8qtK1E93EvyXN@aNJA<)Odr1c`MmnpNX4a77$xlYab4pwkAN0A`u@Z>t zTHc3c^EP$(Bd`-VO~gnWGWFVqsl*<=`RNYTusgrKTjT_srBOD4Ei=9%AG7sjTvvZO z&0qObI^3aVmZ-e)a1#44x`v-O!O*1*v_nj)*w?EVRCUY%?T*yZC@YH?QtMdhcgx%4 zepE+{SYMyfb$Kw8rBt2q-`n)*Bil9Q5U1dX2-#_jvWkieP_Bc~i6-@Sc%2Pvt1EjU zX7@1hkri9u3k1^@2P*q4A}p+J1wBwNR@sR5$;+-4S|I4#=;pYre?C8>J0As;6c(*= zULwFG(~|S2aY)-<9eCd(27O+5Y_{IQ(WCu8Vm4ZuKVyA~%6RfDtk>jGwa4vA5#%$P z?>&QAv1#i0Fq;cIYO3?P-MYhkC+TYswM;q+ttOg zMq*?^)#lx^xO4lwJhvy6ZQD)U1*>{E-NfyG(Dw#nZdUHiY5^^9_@;*h!cc!gm#2$v zPz7jf{~9WXl*o2SH5c~3JUnSK(CWpGEWiYELu-WHoS7P*%qH769YGm8 zP4#@hx#pp3OCh*0tM}4af>dtl(J``+f03tjCKcy zN%?WBt@=Xtm1{R-Kk*gyv&n~D?{v=*tzP-sp^A*vDi9H&?()gUiOR=bG;hrDIc-MD z^cJ%@8G^a!g5Ov+&Xtr?;&r!(5N4`03H)aU?mTka25r7vhS8{Qdm#rH;eoC0-=#FocgA zN&M!Bl{nx_#BbN5CxDpNsf&OP6zPA_Y2cQv%3k6n;&;Q9n1Wb0t7=eeiajU~BJX&l zv11Q1rV0O#5HVoT;TnqXg1io*|56Z8J_HdUqW>c7Kf9R8oR|=UQxn6E4!HJq?bpgO z)>MbW&wgyOdCl(5>7Z+Pg07)4pFs$7Eb7N-niy;rc#WQCee>d`+<>v_XXi&7y=8Y3DVbPooxVb5q>Q8Nh( zkx0)fMs`lDT<1e#gy8ylZnGwqUf9O<8APCEv__hy(c{HI>4mW*oIFMuj%g!&7SL3y zq~B0)CguUg5%%|f+7d!6wb*P6Pwd(p+UUP(zia_IW%5){z3_TMRgSt>r+ZkUVgP-J==691t?zoK tuple[AssetTarget, ...]: """Return the complete Linux launcher, backend, tray, and schedule asset set.""" return ( @@ -217,6 +218,11 @@ def linux_asset_targets( autostart_root / "timelocker-tray.desktop", 0o644, ), + AssetTarget( + "timelocker-icon.png", + icon_root / "timelocker.png", + 0o644, + ), ) diff --git a/src/TimeLocker/system_control/tray_entry.py b/src/TimeLocker/system_control/tray_entry.py index e76634a..3560ab7 100644 --- a/src/TimeLocker/system_control/tray_entry.py +++ b/src/TimeLocker/system_control/tray_entry.py @@ -142,6 +142,7 @@ def _apply_state( if not tray.is_available(): return tray.update_status(_status_to_tray(state.status), tooltip=state.tooltip) + tray.update_last_backup_time(state.last_backup_started_at) def _build_client( diff --git a/tests/TimeLocker/monitoring/test_system_tray_integration.py b/tests/TimeLocker/monitoring/test_system_tray_integration.py index fe6de4e..63f4d09 100644 --- a/tests/TimeLocker/monitoring/test_system_tray_integration.py +++ b/tests/TimeLocker/monitoring/test_system_tray_integration.py @@ -16,10 +16,12 @@ """ import pytest -from datetime import datetime +from datetime import UTC, datetime from unittest.mock import Mock, patch from TimeLocker.monitoring.system_tray_integration import ( + PACKAGED_TRAY_ICON_PATH, + LinuxSystemTray, SystemTrayError, SystemTrayIntegration, TrayStatus, @@ -148,6 +150,62 @@ def require_version(namespace, version): assert modules == (gtk, legacy, "AppIndicator3") gi.require_version.assert_any_call("AppIndicator3", "0.1") + @pytest.mark.monitoring + @pytest.mark.unit + def test_uses_packaged_timelocker_icon_for_initial_and_updated_status(self): + gtk = Mock() + indicator_module = Mock() + indicator = indicator_module.Indicator.new.return_value + + with patch( + "TimeLocker.monitoring.system_tray_integration._load_linux_tray_modules", + return_value=(gtk, indicator_module, "AyatanaAppIndicator3"), + ): + tray = LinuxSystemTray("TimeLocker") + tray.update_icon(TrayStatus.ERROR) + + indicator_module.Indicator.new.assert_called_once_with( + "TimeLocker", + str(PACKAGED_TRAY_ICON_PATH), + indicator_module.IndicatorCategory.APPLICATION_STATUS, + ) + indicator.set_icon.assert_called_once_with(str(PACKAGED_TRAY_ICON_PATH)) + + @pytest.mark.monitoring + @pytest.mark.unit + def test_linux_menu_shows_last_backup_in_local_time(self): + gtk = Mock() + indicator_module = Mock() + open_item = Mock() + last_backup_item = Mock() + status_item = Mock() + backup_item = Mock() + quit_item = Mock() + gtk.MenuItem.side_effect = [ + open_item, + last_backup_item, + status_item, + backup_item, + quit_item, + ] + + with patch( + "TimeLocker.monitoring.system_tray_integration._load_linux_tray_modules", + return_value=(gtk, indicator_module, "AyatanaAppIndicator3"), + ): + backup_time = datetime(2026, 7, 26, 12, 34, tzinfo=UTC) + tray = LinuxSystemTray( + "TimeLocker", + frozenset({"status", "backup_now", "open_ui", "quit"}), + ) + tray.update_last_backup_time(backup_time) + + last_backup_item.set_sensitive.assert_called_once_with(False) + expected_time = backup_time.astimezone().strftime("%Y-%m-%d %H:%M %Z") + last_backup_item.set_label.assert_called_once_with( + f"Last backup: {expected_time}".rstrip() + ) + @pytest.mark.monitoring @pytest.mark.unit def test_missing_indicator_namespaces_is_non_fatal_to_facade(self): diff --git a/tests/TimeLocker/system_control/test_deployment.py b/tests/TimeLocker/system_control/test_deployment.py index dce9fde..0d94607 100644 --- a/tests/TimeLocker/system_control/test_deployment.py +++ b/tests/TimeLocker/system_control/test_deployment.py @@ -145,6 +145,7 @@ def test_linux_asset_set_covers_launchers_backend_tray_and_schedules( unit_root=tmp_path / "units", config_root=tmp_path / "etc", autostart_root=tmp_path / "autostart", + icon_root=tmp_path / "icons", ) sources = {target.source_name for target in targets} @@ -158,6 +159,7 @@ def test_linux_asset_set_covers_launchers_backend_tray_and_schedules( "timelocker-retention.service", "timelocker-retention.timer", "timelocker-tray.desktop", + "timelocker-icon.png", } <= sources policy = next( target @@ -165,3 +167,8 @@ def test_linux_asset_set_covers_launchers_backend_tray_and_schedules( if target.source_name == "system-control-policy.json" ) assert policy.preserve_existing + icon = next( + target for target in targets if target.source_name == "timelocker-icon.png" + ) + assert icon.destination == tmp_path / "icons" / "timelocker.png" + assert icon.mode == 0o644 diff --git a/tests/TimeLocker/system_control/test_linux_adapter.py b/tests/TimeLocker/system_control/test_linux_adapter.py index 7f87964..076b27f 100644 --- a/tests/TimeLocker/system_control/test_linux_adapter.py +++ b/tests/TimeLocker/system_control/test_linux_adapter.py @@ -241,7 +241,12 @@ def test_socket_and_service_templates_enforce_narrow_boundary(self) -> None: ASSET_DIRECTORY / "timelocker-retention.service" ).read_text() retention_timer = (ASSET_DIRECTORY / "timelocker-retention.timer").read_text() - assert "ConditionPathExists=/etc/timelocker/retention-enabled" in retention_service + tray_desktop = (ASSET_DIRECTORY / "timelocker-tray.desktop").read_text() + assert ( + "ConditionPathExists=/etc/timelocker/retention-enabled" in retention_service + ) assert "EnvironmentFile=-/etc/timelocker/retention.env" in retention_service + assert "Icon=timelocker" in tray_desktop + assert (ASSET_DIRECTORY / "timelocker-icon.png").is_file() assert "--scheduled-retention" in retention_service assert "Persistent=false" in retention_timer diff --git a/tests/TimeLocker/system_control/test_tray_process_boundary.py b/tests/TimeLocker/system_control/test_tray_process_boundary.py index 6806625..1104e33 100644 --- a/tests/TimeLocker/system_control/test_tray_process_boundary.py +++ b/tests/TimeLocker/system_control/test_tray_process_boundary.py @@ -1,14 +1,21 @@ """Import and lifecycle boundaries for the independent tray process.""" import os +from datetime import UTC, datetime from pathlib import Path import subprocess import sys +from unittest.mock import Mock import pytest from TimeLocker.system_control import tray_entry -from TimeLocker.system_control.tray_entry import _single_instance, _tray_menu_actions +from TimeLocker.system_control.tray_client import TrayDisplayState +from TimeLocker.system_control.tray_entry import ( + _apply_state, + _single_instance, + _tray_menu_actions, +) @pytest.mark.unit @@ -81,3 +88,27 @@ def test_one_shot_action_does_not_construct_desktop_tray(monkeypatch) -> None: def test_retention_menu_requires_configured_fingerprint() -> None: assert "retention_now" not in _tray_menu_actions(None) assert "retention_now" in _tray_menu_actions("a" * 64) + + +@pytest.mark.unit +def test_apply_state_projects_last_backup_time_to_tray() -> None: + backup_time = datetime(2026, 7, 26, 12, 34, tzinfo=UTC) + tray = Mock() + tray.is_available.return_value = True + state = TrayDisplayState( + status="success", + tooltip="TimeLocker\nLast backup: 2026-07-26T12:34:00+00:00", + active_operations=0, + backend_available=True, + last_backup_started_at=backup_time, + last_backup_status="Backup completed successfully.", + last_retention_started_at=None, + last_retention_status=None, + next_backup_at=None, + next_retention_at=None, + repository_count=1, + ) + + _apply_state(tray, state) + + tray.update_last_backup_time.assert_called_once_with(backup_time) From 123ab896f86ddfb15ee83ece6f07aa4318dbf384 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:56:16 +0100 Subject: [PATCH 52/72] feat: add event-driven tray status --- docs/processes/version-management.md | 22 +- .../010-event-driven-tray-status/README.md | 48 +++ .../canonical-context.md | 85 +++++ .../change-impact.md | 96 +++++ .../010-event-driven-tray-status/design.md | 282 ++++++++++++++ .../requirements.md | 284 ++++++++++++++ .../010-event-driven-tray-status/tasks.md | 294 ++++++++++++++ .../traceability.md | 89 +++++ .../verification.md | 239 ++++++++++++ docs/specs/README.md | 17 +- pyproject.toml | 1 + scripts/generate_tray_status_icons.py | 122 ++++++ scripts/smoke_release_artifact.py | 51 ++- .../monitoring/system_tray_integration.py | 209 ++++++---- src/TimeLocker/system_control/__init__.py | 46 +++ .../system_control/action_policy.py | 1 + .../assets/timelocker-control.service | 3 +- .../assets/timelocker-control.socket | 1 + .../assets/timelocker-icon-error.png | Bin 0 -> 34066 bytes .../assets/timelocker-icon-idle.png | Bin 0 -> 34525 bytes .../assets/timelocker-icon-running.png | Bin 0 -> 34317 bytes .../assets/timelocker-icon-success.png | Bin 0 -> 33947 bytes .../assets/timelocker-icon-warning.png | Bin 0 -> 33316 bytes .../assets/timelocker-status-events.socket | 15 + .../system_control/backend_entry.py | 164 +++++++- src/TimeLocker/system_control/client.py | 6 + src/TimeLocker/system_control/deployment.py | 189 +++++++-- src/TimeLocker/system_control/event_client.py | 131 +++++++ src/TimeLocker/system_control/interfaces.py | 57 +++ .../system_control/linux_adapter.py | 219 ++++++++++- src/TimeLocker/system_control/models.py | 360 +++++++++++++++++- src/TimeLocker/system_control/protocol.py | 30 ++ .../system_control/release_launcher.py | 83 +++- .../system_control/status_events.py | 224 +++++++++++ src/TimeLocker/system_control/storage.py | 34 +- src/TimeLocker/system_control/tray_client.py | 301 +++++++++------ src/TimeLocker/system_control/tray_entry.py | 147 ++++--- src/TimeLocker/system_control/types.py | 17 + .../system_control/windows_adapter.py | 138 ++++++- .../test_system_tray_integration.py | 63 ++- .../project/test_release_artifacts.py | 16 + .../project/test_tray_icon_assets.py | 54 +++ .../system_control/test_action_policy.py | 1 + .../system_control/test_backend_entry.py | 201 ++++++++++ .../TimeLocker/system_control/test_client.py | 19 + .../system_control/test_deployment.py | 135 ++++++- .../system_control/test_interfaces.py | 4 + .../system_control/test_linux_adapter.py | 21 + .../system_control/test_protocol.py | 28 ++ .../system_control/test_release_launcher.py | 34 ++ .../system_control/test_status_contracts.py | 342 +++++++++++++++++ .../test_status_event_transport.py | 253 ++++++++++++ .../system_control/test_status_events.py | 218 +++++++++++ .../test_status_snapshot_action.py | 175 +++++++++ .../system_control/test_tray_client.py | 119 +++++- .../test_tray_process_boundary.py | 122 +++++- .../test_tray_status_subscription.py | 179 +++++++++ .../system_control/test_windows_adapter.py | 140 +++++++ 58 files changed, 5761 insertions(+), 368 deletions(-) create mode 100644 docs/specs/010-event-driven-tray-status/README.md create mode 100644 docs/specs/010-event-driven-tray-status/canonical-context.md create mode 100644 docs/specs/010-event-driven-tray-status/change-impact.md create mode 100644 docs/specs/010-event-driven-tray-status/design.md create mode 100644 docs/specs/010-event-driven-tray-status/requirements.md create mode 100644 docs/specs/010-event-driven-tray-status/tasks.md create mode 100644 docs/specs/010-event-driven-tray-status/traceability.md create mode 100644 docs/specs/010-event-driven-tray-status/verification.md create mode 100644 scripts/generate_tray_status_icons.py create mode 100644 src/TimeLocker/system_control/assets/timelocker-icon-error.png create mode 100644 src/TimeLocker/system_control/assets/timelocker-icon-idle.png create mode 100644 src/TimeLocker/system_control/assets/timelocker-icon-running.png create mode 100644 src/TimeLocker/system_control/assets/timelocker-icon-success.png create mode 100644 src/TimeLocker/system_control/assets/timelocker-icon-warning.png create mode 100644 src/TimeLocker/system_control/assets/timelocker-status-events.socket create mode 100644 src/TimeLocker/system_control/event_client.py create mode 100644 src/TimeLocker/system_control/status_events.py create mode 100644 tests/TimeLocker/project/test_tray_icon_assets.py create mode 100644 tests/TimeLocker/system_control/test_status_contracts.py create mode 100644 tests/TimeLocker/system_control/test_status_event_transport.py create mode 100644 tests/TimeLocker/system_control/test_status_events.py create mode 100644 tests/TimeLocker/system_control/test_status_snapshot_action.py create mode 100644 tests/TimeLocker/system_control/test_tray_status_subscription.py diff --git a/docs/processes/version-management.md b/docs/processes/version-management.md index b26553c..d63d958 100644 --- a/docs/processes/version-management.md +++ b/docs/processes/version-management.md @@ -105,7 +105,9 @@ After the workflow completes: 1. Confirm the release tag and GitHub release point to the approved commit. 2. Download both distributions and `SHA256SUMS` from the release. -3. Compare hashes and smoke a clean install through `timelocker` and `tl`. +3. Compare hashes and smoke a clean install through `timelocker`, `tl`, + `timelocker-system-control`, and `timelocker-tray`, including the packaged + control/event protocol contract and protected system assets. 4. Confirm the published body matches the corresponding changelog section. 5. Announce the release only after these checks pass. @@ -134,19 +136,21 @@ immutable history. Publishing a GitHub release and selecting a protected host release are separate boundaries. A protected host stages an immutable release under `/opt/timelocker/releases/RELEASE_ID/` with a manifest that binds its release -identity, package version, protocol version, and entrypoint. +identity, package version, control protocol version, event protocol version, +and entrypoint. -Before activation, the deployment probes the staged CLI, backend, and tray -entrypoints. Only then may the root-only selector atomically update +Before activation, the deployment probes the staged CLI, backend, tray, +explicit control status, protected event channel, and active/enabled backup and +retention timers. Only then may the root-only selector atomically update `/opt/timelocker/selected-release.json`, preserving the prior release identifier for rollback. Stable launchers resolve that selector and fail closed on missing, untrusted, incompatible, recursively invoked, or non-allowlisted state. -Rollback probes the previous release before swapping selected and previous -identifiers. It does not delete protected configuration, credential references, -retention policy, or durable run records. Service/timer rollback is coordinated -separately so operators retain evidence and can restore the previous scheduler -when required. +Rollback probes the previous release, explicit control status, and both timer +states before swapping selected and previous identifiers. It does not delete +protected configuration, credential references, retention policy, or durable +run records. A newer event socket asset may remain installed but inert when a +legacy release is selected. ## Current Deferrals diff --git a/docs/specs/010-event-driven-tray-status/README.md b/docs/specs/010-event-driven-tray-status/README.md new file mode 100644 index 0000000..5cbebd8 --- /dev/null +++ b/docs/specs/010-event-driven-tray-status/README.md @@ -0,0 +1,48 @@ +--- +title: Event-driven tray status +doc_type: spec +artifact_type: overview +status: active +owner: Auriora Team +last_reviewed: 2026-07-27 +--- + +# Event-Driven Tray Status + +## Purpose + +Replace the tray's periodic status polling with an authenticated event-driven +status path, and make the status it presents accurate, quiet, and useful. + +This is one package because the backend subscription contract, status snapshot, +tray presentation, authorization, deployment, and recovery behavior form one +end-to-end feature. Splitting them would leave either an unused protocol or a +tray without a reliable source of truth. + +## Current Stage + +- Requirements, design, tasks, traceability, change impact, canonical context, + and verification planning are approved for implementation. +- **Implementation approval:** user approval recorded on 2026-07-27. +- T001 is the first implementation slice. +- There are no active predecessor specs. Spec 009 is closed and its promoted + durable documents are the current-state baseline. +- The working tree already contains the separately requested removal of the + inactive `Open TimeLocker` tray item. Implementation must preserve and + reconcile that change rather than overwrite it. + +## Package + +- [Requirements](./requirements.md) +- [Technical design](./design.md) +- [Tasks](./tasks.md) +- [Change impact](./change-impact.md) +- [Traceability](./traceability.md) +- [Verification](./verification.md) +- [Canonical context](./canonical-context.md) + +## Approval Boundary + +Implementation is approved within this package. Protected host deployment, +operator-group mutation, live backup or retention execution, release +publication, and rollback retain their normal explicit approval gates. diff --git a/docs/specs/010-event-driven-tray-status/canonical-context.md b/docs/specs/010-event-driven-tray-status/canonical-context.md new file mode 100644 index 0000000..5fdd7f6 --- /dev/null +++ b/docs/specs/010-event-driven-tray-status/canonical-context.md @@ -0,0 +1,85 @@ +--- +title: Event-driven tray status canonical context +doc_type: spec +artifact_type: canonical-context +status: active +owner: Auriora Team +last_reviewed: 2026-07-27 +--- + +# Canonical Context + +## Purpose + +This package changes behavior promoted by closed Spec 009 and spans several +durable documents. This map prevents removed specification history or proposed +event behavior from being mistaken for current implementation truth. + +## Authority Hierarchy + +The package is canonical only for the approved implementation slice while +active. It does not override user/platform instructions, `AGENTS.md`, +`CHARTER.md`, security policy, source contracts, tests, generated artifacts, +or live system evidence. + +## Always-Canonical External Sources + +| Source | Authority reason | Handling | +|--------|------------------|----------| +| `AGENTS.md` and `docs/guides/ai-agent/` | Repository behavior and workflow instructions | Read before implementation and validation. | +| `CHARTER.md` | Mandate, boundaries, governance, and approval rights | Stop if scope expands to a full GUI, remote service, or changed security boundary. | +| Current source, tests, package metadata, and live evidence | Implementation and runtime truth | Reconcile conflicts; do not overwrite based on draft prose. | +| `pyproject.toml` and `docs/4-testing/README.md` | Test discovery and final coverage profile | Use focused tests first and the configured profile before closure. | + +## Spec-Canonical Working Sources + +| Source | Role | Scope | Notes | +|--------|------|-------|-------| +| `requirements.md` | Intended observable behavior | Spec 010 | Requires approval before implementation. | +| `design.md` | Snapshot/event architecture | Spec 010 | Reconcile if implementation changes transport or security decisions. | +| `tasks.md` | Dependency-aware execution index | Spec 010 | Never implement from tasks alone. | +| `traceability.md` | Requirement/task/verification routing | Spec 010 | Gaps block readiness. | +| `verification.md` | Required evidence and approval gates | Spec 010 | Live host actions require explicit approval. | + +## Imported Sources + +| Spec path | Source path | Source revision or date | Status | Canonical scope | Promotion target | +|-----------|-------------|-------------------------|--------|-----------------|------------------| +| requirements/design/change impact | `docs/1-requirements/system-operations.md` | reviewed 2026-07-26 | summarized | Current authorization, tray, and portability baseline | same path | +| requirements/design/change impact | `docs/2-architecture/system-architecture.md` | reviewed 2026-07-26 | supersedes | Polling tray boundary for this slice only | same path | +| design/tasks | `docs/3-implementation/service-layer-integration.md` | reviewed 2026-07-18 | adapted | Existing `system_control` ownership | same path | +| requirements/change impact | `docs/SYSTEM-TRAY-SETUP.md` | current checkout | supersedes | Current menu and polling-related operation for this slice | same path | + +## Non-Canonical Background Sources + +| Source | Reason non-canonical | Handling | +|--------|----------------------|----------| +| Removed `docs/specs/009-system-cli-tray-retention/` recovered from Git | Closed delivery scaffolding | Use only for historical rationale; durable promoted docs own current state. | +| `docs/history/spec-closure-log.md` and archive index | Lifecycle history | Use for identity and provenance, not product behavior. | +| Generic integration event-bus documentation | Different in-process integration boundary | Do not reuse as the protected system event contract without explicit reconciliation. | + +## Promotion Map + +| Spec-local content | Durable destination or route | Required before closure | +|--------------------|------------------------------|-------------------------| +| Event-driven tray behavior and authorization | `docs/1-requirements/system-operations.md` | yes | +| Snapshot/event architecture and platform split | `docs/2-architecture/system-architecture.md` | yes | +| Component ownership and interfaces | `docs/3-implementation/service-layer-integration.md` | yes | +| Setup, status rows, failure, reconnect, and rollback | `docs/SYSTEM-TRAY-SETUP.md` and user/developer guides | yes | +| Concrete Windows live service and acceptance | follow-up spec or issue | yes, as routed work | +| Full desktop application | product backlog/roadmap | no implementation; retain exclusion | + +## Worktree Caution + +The working tree contains a user-requested, tested removal of the inactive +`Open TimeLocker` menu item that predates this package. It is implementation +evidence to reconcile under T006, not permission to revert or silently broaden +the current commit. + +## Related Artifacts + +- Requirements: [requirements.md](./requirements.md) +- Design: [design.md](./design.md) +- Tasks: [tasks.md](./tasks.md) +- Change impact: [change-impact.md](./change-impact.md) +- Verification: [verification.md](./verification.md) diff --git a/docs/specs/010-event-driven-tray-status/change-impact.md b/docs/specs/010-event-driven-tray-status/change-impact.md new file mode 100644 index 0000000..8d8ded8 --- /dev/null +++ b/docs/specs/010-event-driven-tray-status/change-impact.md @@ -0,0 +1,96 @@ +--- +title: Event-driven tray status change impact +doc_type: spec +artifact_type: change-impact +status: active +owner: Auriora Team +last_reviewed: 2026-07-27 +--- + +# Change Impact + +## Purpose + +Record the durable behavior changed by event-driven tray status and the +documents that must describe the accepted implementation before closure. + +## Durable Source Mapping + +| Source | Current behavior relied on | Confidence | Notes | +|--------|----------------------------|------------|-------| +| `docs/1-requirements/system-operations.md` | Independent authorized tray and safe system visibility. | high | Modify tray and platform requirements. | +| `docs/2-architecture/system-architecture.md` | Tray polls the protected AF_UNIX backend. | high | Supersede polling with snapshot plus events. | +| `docs/3-implementation/service-layer-integration.md` | `system_control` owns protocol, backend, records, and tray client. | high | Add event ownership without changing layer ownership. | +| `docs/SYSTEM-TRAY-SETUP.md` | Current menu, authorization, Linux setup, and troubleshooting. | high | Update menu and event-channel operation. | +| `docs/reference/timelocker-cli-command-hierarchy.md` | Tray executable and reserved actions. | high | Remove placeholder UI action from visible behavior. | +| `docs/guides/user/backup-operations-troubleshooting.md` | Current stale-status and backend guidance. | high | Add event socket and reconnect diagnostics. | + +## Change Type + +- **Primary type:** feature +- **Secondary types:** bug_fix, refactor, operational, clarification +- **Breaking change:** no for documented CLI actions; event protocol requires a + coherent release +- **Durable docs required:** yes +- **External behavior affected:** yes, optional tray and deployment assets + +## Proposed Changes + +| Change | Type | Source of truth | New durable destination | Promotion required | +|--------|------|-----------------|-------------------------|-------------------| +| Replace tray status polling with authenticated event invalidations and snapshots. | modify | system architecture and code | `docs/2-architecture/system-architecture.md` | yes | +| Define continuous subscription authorization and privacy. | add | system operations requirements | `docs/1-requirements/system-operations.md` | yes | +| Make last backup mean last successful completion. | bug_fix | run model and tray code | requirements and tray setup | yes | +| Replace non-functional status/open menu actions with honest status rows. | bug_fix | tray code | `docs/SYSTEM-TRAY-SETUP.md` | yes | +| Silence healthy background tray output. | bug_fix | tray entrypoint | tray setup and troubleshooting | yes | +| Add event socket, probes, and rollback checks. | operational | deployment code/assets | installation, tray setup, version management | yes | +| Preserve Windows-portable contracts without support claim. | clarify | platform adapters | requirements and architecture | yes | + +## Promotion Targets + +| Spec content | Durable destination | Promotion status | Notes | +|--------------|---------------------|------------------|-------| +| Accepted behavior and security invariants | `docs/1-requirements/system-operations.md` | pending | | +| Snapshot/event architecture and platform boundary | `docs/2-architecture/system-architecture.md` | pending | | +| Component ownership and integration seams | `docs/3-implementation/service-layer-integration.md` | pending | | +| Menu, setup, failure, and restart behavior | `docs/SYSTEM-TRAY-SETUP.md` | pending | | +| Tray executable/action reference | `docs/reference/timelocker-cli-command-hierarchy.md` | pending | | +| Event-channel diagnostics | `docs/guides/user/backup-operations-troubleshooting.md` | pending | | +| Installation and release activation | `docs/guides/user/installation.md`, `docs/processes/version-management.md` | pending | | +| Test profiles or live acceptance guidance | `docs/4-testing/` if reusable guidance changes | pending | Promote only durable procedure. | + +## Unchanged Durable Areas + +| Durable area | Reviewed source | Reason unchanged | +|--------------|-----------------|------------------| +| Project mandate | `CHARTER.md` | Optional local tray status remains within the CLI-first mandate. | +| Restic backup/restore semantics | current backup and recovery docs | Event delivery does not change Restic execution or repository format. | +| Retention policy | `docs/1-requirements/system-operations.md` | Trigger and policy semantics remain unchanged. | +| Full desktop UI scope | `CHARTER.md`, `docs/README.md` | Still excluded. | + +## Bug Fix Details + +- **Observed behavior:** newest backup attempt start time is labeled last backup; + healthy polling prints every cycle; `View Status` refreshes but opens no view; + `Open TimeLocker` has no app to open. +- **Expected behavior:** last successful completion is explicit, healthy service + is quiet, status is visible in menu rows, and placeholder UI actions are + absent. +- **Root cause evidence:** `tray_client.py` selects the latest run by + `started_at`; `tray_entry.py` prints every refresh; platform menus define + actions without a rendered view or registered app callback. +- **Regression risk:** moderate because status, security, IPC, packaging, and + user-session presentation cross process and platform boundaries. +- **Durable doc update needed:** yes, all promotion targets above. + +## Open Questions + +None. Scope expansion to a full UI or live Windows deployment requires a +separate approved intake. + +## Related Artifacts + +- Requirements: [requirements.md](./requirements.md) +- Design: [design.md](./design.md) +- Tasks: [tasks.md](./tasks.md) +- Verification: [verification.md](./verification.md) diff --git a/docs/specs/010-event-driven-tray-status/design.md b/docs/specs/010-event-driven-tray-status/design.md new file mode 100644 index 0000000..ff9780e --- /dev/null +++ b/docs/specs/010-event-driven-tray-status/design.md @@ -0,0 +1,282 @@ +--- +title: Event-driven tray status design +doc_type: spec +artifact_type: design +status: active +owner: Auriora Team +last_reviewed: 2026-07-27 +--- + +# Technical Design + +## Overview + +Add a typed status snapshot to the existing authenticated control protocol and +a separate authenticated event subscription transport. Events are sanitized +revisioned invalidations, not copies of protected records. The tray subscribes, +fetches an initial snapshot, and refreshes only after a newer event or +reconnection. This preserves the request/response control path while removing +steady-state tray polling. + +## Requirement Coverage + +| Requirement | Acceptance Criteria | Design Coverage | Validation Approach | +|-------------|---------------------|-----------------|---------------------| +| Requirement 1 | AC1-AC5 | Subscription handshake, session revisions, invalidation stream | Protocol, broker, integration, idle tests | +| Requirement 2 | AC1-AC5 | Peer identity, per-frame authorization, allowlisted models | Security and negative-control tests | +| Requirement 3 | AC1-AC5 | Backend-derived `StatusSnapshot` and local presentation | Model, store, tray tests | +| Requirement 4 | AC1-AC5 | Separate transport, backoff, heartbeat, bounded clients | Failure, restart, slow-client tests | +| Requirement 5 | AC1-AC6 | Disabled status rows, action-only mutations, and deterministic logo badges | Platform menu/icon tests and Linux acceptance | +| Requirement 6 | AC1-AC4 | Silent serve loop and logging boundary | Captured-stream and logging tests | +| Requirement 7 | AC1-AC5 | Portable interfaces, Linux adapter, Windows contracts, release probes | Platform, package, deployment, rollback tests | + +## Correctness Property Coverage + +| Property | Design Behavior | Validation Direction | Notes | +|----------|-----------------|----------------------|-------| +| CP-001 | Snapshot builder selects maximum successful backup `completed_at`. | Generated histories plus conventional edge tests | No new property dependency required if Hypothesis is unavailable. | +| CP-002 | `(session_id, sequence)` ordering and coalescing guard. | Generated event sequences | Older/duplicate revisions are ignored. | +| CP-003 | Membership resolver runs before every emitted event or heartbeat. | Revocation and denied-subscription tests | Connection is closed on denial. | +| CP-004 | Initial/reconnect flow always fetches `status.snapshot`. | Restart, gap, and reconnect integration tests | Events are invalidations, not state. | +| CP-005 | Event components have read/status dependencies only. | Interface and lock-spy tests | Mutations retain existing action path. | +| CP-006 | Serve path has no successful-state `print`. | Captured 90-second idle test with shortened test clock | One-shot output remains tested separately. | + +## High-Level Design + +### System Architecture + +```text +user-session tray + | request/response | long-lived subscription + v v +control.sock status-events.sock + | | + v v +authenticated dispatcher authenticated event transport + | | + +------- status.snapshot <---- status event broker + ^ + | + record/schedule change sources +``` + +The control socket remains bounded to one request and response per connection. +The event socket is separate so a long-lived subscriber cannot block CLI +requests or mutation actions. + +### Components and Changes + +- **Status models and snapshot builder** + - Add allowlisted `StatusSnapshot`, `StatusRevision`, and `StatusEvent` + models. + - Compute last successful backup by maximum successful `completed_at`. + - Preserve latest attempt state separately. +- **Control protocol** + - Add `status.snapshot` as a read-only authorized action. + - Bump and negotiate protocol compatibility if the wire schema changes. +- **Event broker** + - Own one random backend-session ID and a monotonic sequence. + - Coalesce pending changes; retain no unbounded event history. + - Emit only invalidation, heartbeat, and resynchronization event kinds. +- **Change sources** + - Explicitly notify after TimeLocker-owned run and schedule mutations. + - Monitor protected atomic record/schedule state changes produced by separate + workers through an injectable platform change-watcher boundary. +- **Linux event transport** + - Adopt a systemd-owned AF_UNIX listener. + - Derive `SO_PEERCRED`, enforce current NSS membership, bound connections and + frames, and disconnect slow or unauthorized clients. +- **Windows event contract** + - Define injectable named-pipe acceptor, peer-token, subscription, and send + interfaces with contract/security tests. + - Defer concrete service deployment and live acceptance. +- **Tray subscription client** + - Run blocking event reads independently from the desktop event loop. + - Signal the presentation loop to fetch a fresh snapshot after newer events. + - Reconnect with bounded exponential backoff and coalesce refresh requests. +- **Tray presentation** + - Replace `View Status` with non-actionable status rows. + - Keep `Open TimeLocker` absent. + - Remove periodic successful stdout rendering from `serve`. + +### Data Models + +```text +StatusRevision + session_id: UUID + sequence: non-negative integer + +StatusEvent + schema_version: integer + protocol_version: integer + revision: StatusRevision + kind: snapshot_required | changed | heartbeat | resync_required + +StatusSnapshot + revision: StatusRevision + backend_status: bounded enum + active_operations: non-negative integer + latest_backup: optional safe run summary + last_successful_backup_completed_at: optional UTC datetime + latest_retention: optional safe run summary + next_backup_at: optional UTC datetime + next_retention_at: optional UTC datetime +``` + +The exact snapshot schema must reuse existing stable enums and safe summaries. +It must not include arbitrary strings, raw commands, paths, environment data, +or backend output. + +### Data Flow + +1. Tray connects to the event socket. +2. Backend derives peer identity and authorizes current group membership. +3. Backend sends `snapshot_required` with the current revision. +4. Tray requests `status.snapshot` through the control socket and renders it. +5. A durable run or managed schedule change advances the broker sequence. +6. Backend reauthorizes each subscriber and sends one coalesced `changed` + event. +7. Tray fetches and renders the newest snapshot if its revision is newer. +8. On disconnect, session change, gap, or `resync_required`, the tray reconnects + and repeats the initial snapshot flow. + +## Low-Level Design + +### Algorithms and Logic + +```text +on_subscription_connected(peer): + authorize(peer) + send(snapshot_required, broker.current_revision) + while connected: + event = broker.next_event_or_heartbeat() + authorize(peer) + send(event) + +on_tray_event(event): + if event.session_id != applied.session_id: + request_snapshot() + elif event.sequence > applied.sequence: + coalesce_refresh_request(event.sequence) + ignore duplicate or older revisions + +build_status_snapshot(): + runs = protected_store.list_for_status() + successful = backup runs with state SUCCEEDED and completed_at present + last_success = max(successful, key=completed_at, default=None) + return sanitized snapshot at broker.current_revision +``` + +The snapshot builder and revision read must use a synchronization boundary that +prevents returning a snapshot marked newer than the state it contains. If a +change races with snapshot construction, the resulting newer event causes +another refresh. + +### Function Signatures and Interfaces + +```text +class StatusSnapshotProvider(Protocol): + def snapshot(self) -> StatusSnapshot: ... + +class StatusEventBroker(Protocol): + def current_revision(self) -> StatusRevision: ... + def publish_change(self, kind: StatusChangeKind) -> StatusRevision: ... + def subscribe(self) -> StatusSubscription: ... + +class StatusEventTransport(Protocol): + def serve(self, broker, identity_provider, membership_resolver) -> None: ... + +class StatusEventClient(Protocol): + def events(self, stop_event) -> Iterator[StatusEvent]: ... +``` + +### Error Handling + +- Invalid, oversized, unknown-version, or unauthorized subscription frames fail + closed with a stable safe result and connection close. +- Event channel unavailability changes tray presentation to unavailable but + does not disable explicit control-channel commands. +- Backoff is bounded and resets only after a successful authorized handshake. +- Slow subscribers retain at most the newest pending revision; if they cannot + keep up, the backend disconnects them. +- Watcher overflow or uncertainty emits `resync_required`. +- Logging uses stable codes and redacted summaries with repetition control. + +### Security, Trust, and Access + +- `/run/timelocker/status-events.sock` is root-owned and group-accessible only + to the configured operator group. +- Linux identity comes from `SO_PEERCRED`; Windows identity comes from the + connected named-pipe token. Request content never asserts identity. +- Membership is checked at subscription and before each event or heartbeat, + bounding group-removal latency by the heartbeat interval. +- Event payloads are allowlisted and independently size-bounded. +- The tray never reads `/var/lib/timelocker`, `/etc/timelocker`, environment + files, journal content, or repository credentials. +- The event path cannot invoke backup, retention, release selection, or + arbitrary commands. + +### Migration and Compatibility + +- Existing CLI control actions remain request/response compatible. +- Release metadata records both control and event protocol compatibility. +- Activation installs and probes the event socket/service assets before + selecting the release. +- A new tray paired with an incompatible backend shows a safe unavailable state + rather than reverting to indefinite status polling. +- Linux uses packaged deterministic variants of the TimeLocker logo. A + shape-coded badge distinguishes running, success, warning or never-run, and + failure without requiring a runtime image library or relying on colour alone. +- Rollback selects the prior coherent CLI/backend/tray release. Additional + event assets may remain inert, but must not break the prior control socket, + backup timer, or retention timer. + +### Slice Boundary And Residual Architecture + +| Design target | In this slice | Out of this slice | Follow-up destination | Blocks closure? | +|---------------|---------------|-------------------|-----------------------|-----------------| +| Event-driven local tray status | Snapshot, broker, subscription, Linux transport, tray client | Remote/network subscribers | rejected: outside charter | no | +| Portable desktop contract | Platform-neutral protocol and Windows contract tests | Concrete Windows service, installer, live acceptance | follow-up Windows acceptance spec | no | +| Tray status presentation | Current status rows and mutation actions | Full desktop app or restore UI | product backlog/roadmap | no | +| Reliable change detection | TimeLocker-owned run/schedule changes and resync on uncertainty | Arbitrary external systemd edits without TimeLocker mediation | operator restart/reload guidance | no | + +## Validation Strategy + +| Validation | Covers | Evidence Location | Residual Risk | +|------------|--------|-------------------|---------------| +| Model/protocol/property tests | Requirements 1-3; CP-001-CP-004 | `verification.md`, task evidence | Generated histories may not represent all host races. | +| Transport/security tests | Requirements 2, 4, 7; CP-003, CP-005 | `verification.md`, security review | NSS and named-pipe behavior need live platform evidence. | +| Tray/menu/output tests | Requirements 3, 5, 6; CP-006 | `verification.md`, focused tests | Desktop toolkit variations. | +| Configured regression and package smoke | Compatibility and release integrity | `verification.md`, CI/command evidence | Host timing and optional integrations. | +| Approved Linux Mint acceptance | End-to-end event, restart, authorization, rollback | `verification.md`, root-owned evidence path | Requires explicit deployment and operation approval. | + +## Downstream Task Guidance + +- Complete protocol/security review before transport implementation. +- Give CP-001, CP-002, CP-003, CP-004, and CP-006 explicit test coverage. +- Preserve the existing uncommitted removal of `Open TimeLocker`. +- Run `$review-timelocker` after a runnable implementation and before live + deployment. +- Reconcile design and traceability if concrete Windows delivery enters scope. + +## Operational Considerations + +- Install the event socket with the same operator-group ownership model as the + control socket. +- Expose health without raw subscriber identities or payloads. +- Record bounded connection counts and safe error codes, not user data. +- Activation and rollback must verify both timers remain active and enabled. +- Live acceptance must avoid production mutation unless separately approved. + +## Open Questions + +None currently block implementation review. Any change from a dedicated event +transport, per-event authorization, or invalidation-plus-snapshot model requires +design reconciliation and user approval. + +## Related Artifacts + +- Requirements: [requirements.md](./requirements.md) +- Change Impact: [change-impact.md](./change-impact.md) +- Tasks: [tasks.md](./tasks.md) +- Verification: [verification.md](./verification.md) diff --git a/docs/specs/010-event-driven-tray-status/requirements.md b/docs/specs/010-event-driven-tray-status/requirements.md new file mode 100644 index 0000000..2fa23a0 --- /dev/null +++ b/docs/specs/010-event-driven-tray-status/requirements.md @@ -0,0 +1,284 @@ +--- +title: Event-driven tray status requirements +doc_type: spec +artifact_type: requirements +status: active +owner: Auriora Team +last_reviewed: 2026-07-27 +--- + +# Requirements + +## Introduction + +The independent tray currently polls the protected backend every 30 seconds, +prints each successful refresh to standard output, reports the newest backup +attempt's start time as the last backup, and exposes a `View Status` action +that does not open a view. The tray needs an authenticated event-driven status +path and precise operator-facing semantics without becoming part of the CLI or +privileged backend. + +## Goals + +- Deliver backend status changes to an authorized tray without steady-state + status polling. +- Show the completion time of the most recent successfully completed backup. +- Present useful status directly in the tray menu and keep background operation + quiet. +- Preserve fail-closed authorization, privacy, process independence, immutable + release rollback, and portable Linux/Windows contracts. + +## Non-Goals + +- A full desktop application, settings window, restore browser, or remote API. +- Direct tray access to protected record files, journals, credentials, or + privileged commands. +- Live Windows deployment acceptance in this package. +- Replacing the existing request/response control channel for CLI actions. +- Guaranteeing delivery across process failure without reconnecting and + obtaining a fresh snapshot. + +## Glossary + +| Term | Definition | +|------|------------| +| Status snapshot | A typed, sanitized backend projection of current activity, recent results, and known schedules. | +| Status event | A bounded notification that a newer status snapshot may be available. | +| Subscription revision | A backend-session identifier and monotonic sequence used to order and coalesce status events. | +| Healthy subscription | An authorized event connection that has completed its initial snapshot and has not failed or timed out. | +| Last successful backup | The backup run in `SUCCEEDED` state with the greatest non-null `completed_at` value. | + +## Durable Source Baseline + +| Source | Current behavior relied on | Confidence | Notes | +|--------|----------------------------|------------|-------| +| `CHARTER.md` | The CLI remains primary; optional tray integration must support dependable, observable backup operations. | high | Governing mandate. | +| `docs/1-requirements/system-operations.md` | Protected reads are group-authorized; the tray is independent and may show run and schedule status. | high | Current durable requirements. | +| `docs/2-architecture/system-architecture.md` | The tray currently polls the AF_UNIX backend; protected output is structured and redacted. | high | This spec changes the polling statement. | +| `docs/3-implementation/service-layer-integration.md` | `system_control` owns typed IPC, authorization, records, and the independent tray client. | high | Ownership remains unchanged. | +| `docs/SYSTEM-TRAY-SETUP.md` | Linux tray setup, authorization, failure behavior, and current menu capability. | high | Promotion target. | +| `src/TimeLocker/system_control/` and focused tests | Current protocol is one bounded request/response per control connection. | high | Code-derived contract. | + +## Durable Impact + +See [change-impact.md](./change-impact.md). Accepted behavior must be promoted +before closure. + +## Staged Readiness + +- **Current stage:** implementation +- **Next stage:** T001 contract implementation +- **Ready to implement:** yes - lifecycle lint and readiness pass, acceptance + criteria are traceable, and user approval was recorded on 2026-07-27. +- **Design-first exception:** no +- **Optional artifacts included:** `change-impact.md`, `traceability.md`, + `verification.md`, `canonical-context.md` +- **Downstream review needed:** requirements, design, tasks, traceability, + verification + +## Requirements + +### Requirement 1: Event-Driven Status Delivery + +**User Story:** As an operator, I want the tray to react to backend changes, so +that status is current without repeated polling and terminal output. + +**Priority:** must-have + +#### Acceptance Criteria + +1. GIVEN an authorized tray starts or reconnects, WHEN it establishes a status + subscription, THEN it SHALL obtain an initial status snapshot before + presenting the connection as current. +2. WHILE a subscription is healthy, THE TRAY SHALL NOT issue periodic status + snapshot requests solely because a fixed refresh interval elapsed. +3. WHEN backup, retention, backend-availability, or TimeLocker-managed schedule + status changes, THEN an authorized connected tray SHALL be prompted to + refresh within two seconds under normal local-host load. +4. WHEN multiple changes occur faster than the tray can render them, THEN the + system SHALL coalesce them without applying an older revision after a newer + revision. +5. WHEN the subscription session changes or a revision gap is detected, THEN + the tray SHALL discard incremental assumptions and obtain a fresh snapshot. + +### Requirement 2: Authorization And Privacy + +**User Story:** As an administrator, I want event subscriptions to preserve the +protected control boundary, so that continuous status does not weaken access +control or expose secrets. + +**Priority:** must-have + +#### Acceptance Criteria + +1. WHEN a client subscribes, THEN the backend SHALL derive its identity from + the operating-system transport and verify current operator-group membership. +2. BEFORE sending each status event or heartbeat, THE BACKEND SHALL re-evaluate + current membership, and SHALL disconnect a client whose authorization is no + longer valid. +3. THE STATUS SNAPSHOT AND EVENT CONTRACTS SHALL contain only versioned, + allowlisted fields and SHALL NOT expose credentials, environment contents, + raw backend output, raw journal content, or unnecessary protected paths. +4. IF event authorization or transport validation fails, THEN the tray SHALL + show a safe unavailable or denied state and SHALL NOT fall back to privileged + execution or direct protected-file access. +5. WHEN an unauthorized local client attempts to subscribe, THEN it SHALL + receive no status payload beyond a bounded safe denial. + +### Requirement 3: Accurate Backup And Retention Status + +**User Story:** As an operator, I want the tray's backup time to mean successful +completion, so that a failed or running attempt cannot misrepresent protection. + +**Priority:** must-have + +#### Acceptance Criteria + +1. THE `Last successful backup` value SHALL be selected only from backup runs + in `SUCCEEDED` state and SHALL display that run's `completed_at` time. +2. WHEN a newer backup is queued, running, failed, skipped, or interrupted, + THEN it SHALL NOT replace the last successful backup completion time. +3. WHEN no successful backup exists, THEN the tray SHALL display `Never` or + `Unknown`, not the time of another run state. +4. WHERE a latest backup or retention attempt exists, THE STATUS SNAPSHOT SHALL + preserve its safe state and summary separately from the last successful + backup completion. +5. WHEN a timestamp is displayed, THEN the tray SHALL convert the stored + timezone-aware UTC value to the desktop session's local time and identify + the timezone. + +### Requirement 4: Resilience And Process Independence + +**User Story:** As an operator, I want tray failures and backend restarts to be +recoverable, so that presentation failures never disrupt backup or retention. + +**Priority:** must-have + +#### Acceptance Criteria + +1. WHEN the backend or event channel is unavailable, THEN the tray SHALL remain + responsive and reconnect using bounded exponential backoff. +2. WHEN the backend restarts, THEN a connected or reconnecting tray SHALL + establish a new subscription session and obtain a fresh snapshot. +3. THE BACKEND SHALL bound subscriber count, frame size, queued event state, + heartbeat interval, and slow-client handling. +4. WHEN a tray exits, crashes, or is killed, THEN backend services and active + backup or retention operations SHALL continue unaffected. +5. WHEN the event channel fails while the request/response control channel + remains available, THEN explicit CLI status and action requests SHALL remain + functional. + +### Requirement 5: Useful And Honest Tray Presentation + +**User Story:** As an operator, I want the tray menu to show actionable current +status, so that its labels accurately describe what they do. + +**Priority:** must-have + +#### Acceptance Criteria + +1. THE MENU SHALL show backend availability, current activity, last successful + backup completion, latest retention result, and next known schedules when + those values are available. +2. THE MENU SHALL NOT show `Open TimeLocker` until an implemented desktop + application exists. +3. THE MENU SHALL NOT show an actionable `View Status` item unless activating + it opens a distinct status view; for this slice, status SHALL be represented + by non-actionable menu rows. +4. `Backup Now` and conditionally configured `Run Retention` SHALL remain the + only mutation actions exposed by this slice, in addition to `Quit`. +5. WHEN a status event arrives, THEN the visible menu SHALL update without + restarting the tray process. +6. ON Linux, THE TRAY SHALL preserve the TimeLocker logo while applying a + distinct non-colour-only status badge for running, successful, warning or + never-run, and failed/interrupted states. WHEN no backup attempt exists, the + icon SHALL use the warning or never-run state rather than implying success. + +### Requirement 6: Quiet Background Operation + +**User Story:** As a desktop user, I want the background tray to be silent +during normal operation, so that it does not pollute session output or logs. + +**Priority:** must-have + +#### Acceptance Criteria + +1. WHILE `timelocker-tray serve` is healthy, THE PROCESS SHALL NOT write + periodic successful status snapshots to standard output or standard error. +2. WHEN an operator explicitly invokes a one-shot `status` action, THEN the + command SHALL continue to render a bounded human-readable result. +3. WHEN a recoverable connection failure repeats, THEN diagnostics SHALL use + the configured logging path with bounded repetition rather than unbounded + terminal output. +4. WHEN debug logging is explicitly enabled, THEN connection and event + diagnostics MAY be emitted without including protected or secret values. + +### Requirement 7: Portable Contract And Safe Rollout + +**User Story:** As a maintainer, I want the event contract separated from its +transport, so that Linux is deliverable now without blocking a later Windows +implementation. + +**Priority:** must-have + +#### Acceptance Criteria + +1. THE STATUS SNAPSHOT, EVENT, subscription, reconnect, and authorization + interfaces SHALL be platform-neutral. +2. Linux SHALL provide a protected local event transport with peer-derived + identity and systemd-managed deployment assets. +3. Windows SHALL have injectable named-pipe event-transport contracts and + platform tests, without this package claiming live Windows acceptance. +4. Activation SHALL verify compatible CLI, backend, tray, control protocol, and + event protocol artifacts before selecting a release. +5. Rollback SHALL restore the prior selected release without disabling backup, + retention, or explicit control-channel status commands. + +## Correctness Properties + +- **CP-001:** For any run history, the displayed last successful backup is + either absent or equals the maximum `completed_at` among successful backup + runs. +- **CP-002:** Within one subscription session, applied event revisions are + strictly increasing; duplicate or older events do not regress presentation. +- **CP-003:** No event payload is delivered after the backend observes that the + subscriber is no longer an operator-group member. +- **CP-004:** Reconnect or session change always converges to the same snapshot + that an authorized one-shot status request would return. +- **CP-005:** Tray lifecycle operations cannot acquire the repository mutation + lock or alter an active run except through existing allowlisted requests. +- **CP-006:** A healthy tray over any interval emits zero periodic successful + status records to stdout or stderr. + +## Technical Context + +- **Language/Version:** Python 3.12-3.13 +- **Primary Dependencies:** standard library sockets/threading, existing GTK or + platform tray adapters, systemd on accepted Linux deployments +- **Target Platform:** production acceptance on Linux Mint; portable Windows + contracts and tests +- **Constraints:** local-only IPC, current group authorization, bounded frames, + safe projections, immutable releases, no GUI dependency in CLI/backend +- **Performance Goals:** event-to-menu update within two seconds under normal + local load; no steady-state snapshot polling; bounded idle heartbeat + +## Success Criteria + +- **SC-001:** Integration evidence shows zero fixed-interval status requests + during at least 90 seconds of healthy idle subscription. +- **SC-002:** A successful backup transition updates the tray within two + seconds, while a later failed transition leaves its last-success time intact. +- **SC-003:** Authorization tests prove denial at subscribe time and disconnect + before the next event/heartbeat after membership removal. +- **SC-004:** Restart and revision-gap tests converge to a fresh snapshot + without restarting the desktop session. +- **SC-005:** Focused, platform-contract, security, configured regression, + packaging, and approved Linux acceptance checks pass with no secret-bearing + output. + +## Related Artifacts + +- Change Impact: [change-impact.md](./change-impact.md) +- Design: [design.md](./design.md) +- Tasks: [tasks.md](./tasks.md) +- Verification: [verification.md](./verification.md) diff --git a/docs/specs/010-event-driven-tray-status/tasks.md b/docs/specs/010-event-driven-tray-status/tasks.md new file mode 100644 index 0000000..d87a5d9 --- /dev/null +++ b/docs/specs/010-event-driven-tray-status/tasks.md @@ -0,0 +1,294 @@ +--- +title: Event-driven tray status tasks +doc_type: spec +artifact_type: tasks +status: active +owner: Auriora Team +last_reviewed: 2026-07-27 +--- + +# Tasks + +**Input:** All artifacts in `docs/specs/010-event-driven-tray-status/` + +**Prerequisites:** Approved requirements and design, complete traceability, +implementation approval, and preserved unrelated worktree changes. + +## Task Dependency Graph + +```text +T001 -> T002 -> T003 -> T004 +T004 -> T005 -> T006 -> T007 +T007 -> T008 -> T009 +T009 -> T010 -> T011 -> T012 -> T013 +``` + +## Phase 1: Status And Event Contracts + +- [x] T001 Add typed status snapshot and event contracts. + - Depends on: none + - Requirements: Requirement 1, Requirement 2, Requirement 3, Requirement 7 + - Properties: CP-001, CP-002, CP-004 + - Files: `src/TimeLocker/system_control/models.py`, + `src/TimeLocker/system_control/types.py`, + `src/TimeLocker/system_control/protocol.py`, + `src/TimeLocker/system_control/interfaces.py`, focused tests + - Acceptance: Allowlisted snapshot, revision, and event models validate exact + schemas; last-success selection uses maximum successful `completed_at`; + protocol compatibility and safe failures are tested. + - Evidence: Implemented immutable allowlisted StatusRevision, StatusEvent, and StatusSnapshot contracts; added platform-neutral provider, broker, transport, and client protocols; and added permutation/table-driven CP-001 and CP-002 coverage. Validation on 2026-07-27: `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control/test_status_contracts.py tests/TimeLocker/system_control/test_models.py tests/TimeLocker/system_control/test_protocol.py tests/TimeLocker/system_control/test_interfaces.py` -> 75 passed; `PYENV_VERSION=3.12.4 ruff check src/TimeLocker/system_control/models.py src/TimeLocker/system_control/types.py src/TimeLocker/system_control/interfaces.py src/TimeLocker/system_control/__init__.py tests/TimeLocker/system_control/test_status_contracts.py` -> passed; `git diff --check` -> passed. + - Status: T001 complete; T002 is now dependency-ready. No backend action, deployment, or live host state was changed. + - Evidence mode: implementation + - [x] T001.1 Add failing model and protocol tests. + - Evidence: Added focused model and compatibility tests in `tests/TimeLocker/system_control/test_status_contracts.py`; the final focused run passed as part of the 75-test T001 suite. + - Status: Complete. + - Evidence mode: implementation + - [x] T001.2 Implement exact wire models and version handling. + - Evidence: Implemented exact immutable `StatusRevision`, `StatusEvent`, and `StatusSnapshot` wire models with strict version, enum, UUID, integer, UTC timestamp, nested run, and unknown-field validation. Existing control protocol behavior remained compatible. + - Status: Complete. + - Evidence mode: implementation + - [x] T001.3 Add generated or table-driven CP-001 and CP-002 coverage. + + - Evidence: Added permutation coverage proving last-success selection is order-independent and equals the maximum successful backup `completed_at`, plus strict same-session revision-ordering cases for duplicate, older, newer, and changed-session revisions. + - Status: Complete. + - Evidence mode: implementation +- [x] T002 Add the authorized `status.snapshot` control action. + - Depends on: T001 + - Requirements: Requirement 2, Requirement 3, Requirement 4 + - Properties: CP-001, CP-004, CP-005 + - Files: `src/TimeLocker/system_control/action_policy.py`, + `src/TimeLocker/system_control/backend_entry.py`, + `src/TimeLocker/system_control/client.py`, + `src/TimeLocker/system_control/storage.py`, focused tests + - Acceptance: Authorized clients receive one coherent safe snapshot; + unauthorized clients receive only a safe denial; explicit existing + control actions remain compatible. + - Evidence: Added the read-only `status.snapshot` SystemAction and public system-read classification; strict response projection; `UnixSocketSystemControlClient.get_status_snapshot()`; an internal locked full-history store read; and a backend snapshot handler with one backend-session revision, active-operation count, latest safe attempts, last successful backup completion, and schedule projection. Authorized/denied, redaction, client, protocol, storage, dispatcher, backend, interface, and T001 contract checks passed: `PYENV_VERSION=3.12.6 python -m pytest --no-cov ...` -> 108 passed. Scoped Ruff and `git diff --check` passed. No mutation route, event transport, deployment, or live host state changed. + + - Status: T002 complete; T003 is dependency-ready. + - Evidence mode: implementation +- [x] T003 Implement the bounded status event broker and change sources. + - Depends on: T002 + - Requirements: Requirement 1, Requirement 2, Requirement 4 + - Properties: CP-002, CP-004, CP-005 + - Files: new focused module under `src/TimeLocker/system_control/`, run and + schedule mutation seams, focused tests + - Acceptance: Broker revisions are monotonic per session, changes coalesce, + subscriber state is bounded, and watcher uncertainty forces + resynchronization. + - Evidence: Implemented `status_events.py` with bounded session broker/subscriptions, monotonic revisions, one-event coalescing, subscriber bounds, synchronized snapshot/publication coordinator, schedule and durable-run change seams, and injectable watcher uncertainty-to-resync handling. Integrated the broker/coordinator and post-persistence run callbacks into Linux backend composition while isolating event failures from mutations. Validation: focused T001-T003/status/storage/backend/protocol/client/dispatcher suite -> 96 passed; scoped Ruff -> passed; `git diff --check` -> passed. No transport, deployment, or live host operations were performed. + - Status: T003 complete; Phase 1 checkpoint T004 is dependency-ready. + - Evidence mode: implementation + - [x] T003.1 Implement session revision and coalescing behavior. + - Evidence: Implemented `BoundedStatusEventBroker` and `BoundedStatusSubscription` with random session identity, monotonic bounded sequence, initial snapshot-required event, one-slot per-subscriber coalescing, subscriber limits, and close/unregister behavior. Focused broker and race tests passed. + - Status: Complete. + - Evidence mode: implementation + - [x] T003.2 Publish after TimeLocker-owned durable state changes. + - Evidence: Integrated a shared `StatusChangeCoordinator` into Linux backend composition; durable run create/transition operations publish after atomic persistence, schedule changes have an explicit publication seam, and callback failures are isolated from completed mutations. Snapshot building shares the coordinator boundary. + - Status: Complete; concrete schedule change call sites do not yet exist in the protected backend and later watcher/transport tasks consume the seam. + - Evidence mode: implementation + - [x] T003.3 Add injectable protected-state watcher and overflow handling. + + - Evidence: Added the injectable `ProtectedStateWatcher`/`ProtectedStateChangeMonitor` boundary and sanitized `StatusWatchSignal`; uncertain or unknown observations publish a coalesced `resync_required` event. Focused uncertainty test passed. + - Status: Complete; platform watcher implementation is consumed by the Linux transport/integration slice. + - Evidence mode: implementation +- [x] T004 Checkpoint - contract, security, and broker validation. + - Depends on: T003 + - Requirements: Requirement 1-Requirement 4 + - Acceptance: Focused tests pass, protocol and privacy review has no blocking + findings, and `verification.md` contains concrete phase evidence before + transport work begins. + - Validation: Focused system-control model, protocol, storage, dispatcher, + and broker tests. + - Evidence: Completed the bounded Phase 1 security/protocol checkpoint using `$review-timelocker` across project/operator, Python/IPC architecture, security/privacy, reliability/testing, operations/portability, and documentation lifecycle lenses; Restic data semantics were not materially changed. The review found one actionable watcher-failure resync gap, which was fixed and regression-tested. No blocking findings remain in T001-T003 scope. Validation: `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` -> 208 passed; scoped Ruff, `python -m compileall -q src/TimeLocker/system_control`, and `git diff --check` -> passed. Agent Workbench static diagnostics had no actionable findings but no Python diagnostics provider was available. + + - Status: Phase 1 complete; T005 Linux event transport is dependency-ready. Review excluded T005+ transport, deployment, live operations, and durable promotion. + - Evidence mode: implementation +## Phase 2: Transport And Tray Client + +- [x] T005 Implement authenticated Linux event transport and subscription + client. + - Depends on: T004 + - Requirements: Requirement 1, Requirement 2, Requirement 4, Requirement 7 + - Properties: CP-002, CP-003, CP-004, CP-005 + - Files: `src/TimeLocker/system_control/linux_adapter.py`, + `src/TimeLocker/system_control/backend_entry.py`, + `src/TimeLocker/system_control/tray_client.py`, new event client module, + focused tests + - Acceptance: The dedicated socket does not block control requests; peer + identity and membership are rechecked; slow clients, oversized frames, + disconnect, heartbeat, restart, and revision gaps are bounded and tested. + - Evidence: Implemented the separate authenticated Linux event channel, bounded concurrent subscriber transport, systemd listener adoption, reconnecting bounded-frame event client, and event-driven tray snapshot coordinator. Negative controls cover peer-derived authorization, per-event/heartbeat membership rechecks, revocation, safe denial, slow senders, frame overflow, disconnect/reconnect, backend-session restart, revision gaps/duplicates, stale snapshots, initial snapshot recovery, and event/control independence. Validation on 2026-07-27: focused T005 suite -> 13 passed; `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` -> 224 passed; scoped Ruff, compileall, and `git diff --check` passed. Agent Workbench reported no actionable diagnostics but no Python diagnostics provider was available. No deployment or live host mutation was performed. + - Status: T005 complete; T006 tray presentation is dependency-ready. Deployment assets and live host changes remain gated to later tasks. + - Evidence mode: implementation + - [x] T005.1 Add transport and security negative-control tests. + - Evidence: Added eight negative-control cases in `tests/TimeLocker/system_control/test_status_event_transport.py`; the focused T005 run passed 13 tests, including authorization, revocation, denial, heartbeat, slow sender, systemd adoption, overflow/reconnect, and denial parsing. + - Status: Complete. + - Evidence mode: implementation + - [x] T005.2 Implement systemd listener adoption and bounded subscribers. + - Evidence: Implemented `LinuxStatusEventTransport` in `src/TimeLocker/system_control/linux_adapter.py` and backend isolation in `backend_entry.py`; systemd adoption, bounded sender, revocation, heartbeat, and event/control independence cases passed in the 13-test focused T005 run. + - Status: Complete. + - Evidence mode: implementation + - [x] T005.3 Implement tray reconnect, coalescing, and fresh-snapshot flow. + - Evidence: Added `event_client.py` and `TrayStatusSubscriptionClient` in `tray_client.py`; four cases in `test_tray_status_subscription.py` passed for initial snapshot, gaps/restart, duplicate/older revisions, stale snapshots, denied state, and heartbeat-only recovery while unsynchronized. + - Status: Complete. + - Evidence mode: implementation + - [x] T005.4 Prove CP-003 and CP-004 with revocation/restart tests. + + - Evidence: CP-003/CP-004 controls in `test_status_event_transport.py`, `test_tray_status_subscription.py`, and `test_backend_entry.py` passed in the 13-test focused T005 run: revocation before delivery, safe denial, overflow/disconnect reconnect, new backend session, revision gap, stale snapshot rejection, and event/control independence. + - Status: Complete. + - Evidence mode: implementation +- [x] T006 Correct and simplify tray presentation. + - Depends on: T005 + - Requirements: Requirement 3, Requirement 5, Requirement 6 + - Properties: CP-001, CP-006 + - Files: `src/TimeLocker/system_control/tray_entry.py`, + `src/TimeLocker/system_control/tray_client.py`, + `src/TimeLocker/monitoring/system_tray_integration.py`, focused tests + - Acceptance: Menu rows show current safe status; last backup means + successful completion; `Open TimeLocker` and non-functional `View Status` + are absent; healthy serve mode is silent; explicit one-shot status remains. + - Worktree caution: Reconcile and retain the pre-spec menu-removal changes. + - Evidence: Implemented coherent snapshot-to-tray projection, local-time last-success semantics, seven non-actionable status rows, cross-platform menu removal of `Open TimeLocker`/`View Status`, event-driven UI updates, and silent healthy serve behavior while retaining bounded explicit one-shot output. Validation on 2026-07-27: `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` -> 228 passed; focused monitoring tray suite -> 9 passed; scoped Ruff, compileall, and `git diff --check` passed. No deployment or live host state changed. + - Status: T006 complete; Phase 2 integration checkpoint T007 is dependency-ready. + - Evidence mode: implementation + - [x] T006.1 Reconcile the existing `Open TimeLocker` removal. + - Evidence: Reconciled and retained the pre-spec menu removal in `system_tray_integration.py`; focused menu tests confirm neither `Open TimeLocker` nor `View Status` is created. + - Status: Complete. + - Evidence mode: implementation + - [x] T006.2 Replace `View Status` with non-actionable status rows. + - Evidence: Replaced actionable status entries with seven disabled backend/activity/backup/retention/schedule rows in `system_tray_integration.py`; Linux dynamic-row tests passed and macOS/Windows adapters use the same platform-neutral labels. + - Status: Complete. + - Evidence mode: implementation + - [x] T006.3 Project `last_successful_backup_completed_at` in local time. + - Evidence: `TrayControlClient.project_snapshot()` now projects `last_successful_backup_completed_at`, and platform menu labels convert it with `astimezone()` including the timezone; tests prove a newer failed backup does not replace the successful completion and no success renders `Never`. + - Status: Complete. + - Evidence mode: implementation + - [x] T006.4 Remove periodic stdout/stderr success output and test CP-006. + + - Evidence: `tray_entry.py` now consumes a coalesced event-update queue without periodic `refresh_status()` calls or successful stdout/stderr writes. `test_healthy_serve_is_silent_and_applies_event_snapshot` passed while the explicit one-shot status rendering test remained green. + - Status: Complete. + - Evidence mode: implementation +- [x] T007 Checkpoint - event-driven tray integration. + - Depends on: T006 + - Requirements: Requirement 1-Requirement 6 + - Acceptance: Integration tests show initial snapshot, event update, + coalescing, reconnection, honest last-success semantics, live menu update, + and no steady-state status polling or output. + - Validation: Focused tray, client, transport, monitoring, security, and + integration tests. + - Evidence: Completed the Phase 2 integration checkpoint. The 58-test focused transport/broker/snapshot/subscription/tray/menu suite passed initial snapshot, invalidation update, one-slot coalescing, oversized-frame disconnect/reconnect, backend-session restart, duplicate/older/gap handling, per-delivery revocation, honest last-success/`Never` semantics, dynamic disabled menu rows, silent serve, explicit one-shot output, and an assertion that healthy serve never calls the legacy polling method. Scoped Ruff, compileall, and `git diff --check` passed; the broader system-control suite passed 228 tests during T006. + + - Status: Phase 2 complete; T008 Windows-portable event contracts are dependency-ready. No deployment or live host state changed. + - Evidence mode: validation +## Phase 3: Portability And Deployment + +- [x] T008 Add Windows-portable event transport contracts and tests. + - Depends on: T007 + - Requirements: Requirement 2, Requirement 4, Requirement 7 + - Properties: CP-002-CP-005 + - Files: `src/TimeLocker/system_control/windows_adapter.py`, platform tests, + protocol tests + - Acceptance: Named-pipe interfaces derive peer identity from token + providers, enforce the same bounded event contract, and pass injected + contract/security tests without claiming a live Windows service. + - Evidence: Added injectable `NamedPipeEventConnection`/`NamedPipeEventAcceptor` contracts and `WindowsNamedPipeStatusEventTransport` in `windows_adapter.py`. The adapter derives identity only from the injected token provider, rechecks current group membership before every event/heartbeat, emits only a safe denial, shares the bounded broker, bounds frames/heartbeat/send timeout, closes slow clients, and releases subscription capacity. Four new Windows event tests plus existing platform-neutral contracts passed; `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` -> 232 passed. Scoped Ruff, compileall, and `git diff --check` passed. This is an injected contract tested on Linux and does not claim a live Windows service or deployment. + + - Status: T008 complete; T009 protected deployment/compatibility assets are dependency-ready. + - Evidence mode: implementation +- [x] T009 Add protected deployment, compatibility, and rollback assets. + - Depends on: T008 + - Requirements: Requirement 4, Requirement 7 + - Files: `src/TimeLocker/system_control/assets/`, + `src/TimeLocker/system_control/deployment.py`, + release manifest/launcher code, deployment and package tests + - Acceptance: Event socket ownership/mode, release probes, packaging, atomic + selection, rollback, and existing backup/retention timers are verified. + - Evidence: Added the root-owned/group-accessible `timelocker-status-events.socket` package asset with mode 0660 and named `status-events` descriptor; the control socket is named `control`, and the service explicitly requires both sockets. Backend activation resolves descriptors from validated `LISTEN_PID`/`LISTEN_FDS`/`LISTEN_FDNAMES` rather than positional assumptions. Schema-2 release metadata binds control and event protocol versions while schema 1 remains readable for legacy rollback without claiming event compatibility. Structured activation probes require compatible CLI/backend/tray, explicit control status, event channel, and active+enabled backup/retention timers before atomic selection; rollback requires coherent artifacts, control status, and both timer states while permitting the added event socket to remain inert. Focused deployment/release/backend/Linux asset suite: 52 passed. Full system-control suite: 246 passed. Scoped Ruff and `git diff --check` passed. `systemd-analyze verify` was environment-limited by unrelated host permissions and absent protected installed launcher executability, so clean installed-unit evidence remains T011. No protected host mutation occurred. + + - Status: T009 complete; T010 local package build and installed-artifact smoke are dependency-ready. Live deployment remains gated at T011. + - Evidence mode: validation +- [x] T010 Checkpoint - package and deployment readiness. + - Depends on: T009 + - Requirements: Requirement 5, Requirement 7 + - Acceptance: Linux packages deterministic TimeLocker-logo variants for + running, success, warning or never-run, and failure; snapshot state selects + them honestly; wheel/sdist validation and installed-artifact smoke pass; + live deployment commands and rollback are reviewed; no protected host + mutation has occurred without explicit approval. + - Evidence: Added a deterministic Pillow build script and five packaged + TimeLocker-logo variants with non-colour-only glyphs for idle/connecting, + running, success, warning/never-run, and failed/interrupted. Linux + AppIndicator now selects the projected variant with safe base-logo/theme + fallbacks. Snapshot projection treats no backup attempt as warning even + after successful retention, preserves latest failed/interrupted as error, + and keeps the last-successful timestamp independent. Deployment targets + and installed-artifact smoke cover every new asset. Validation on + 2026-07-27: focused UX/deployment suite -> 53 passed; system-control, + monitoring, icon, and release-artifact regression suite -> 268 passed; + scoped Ruff, compileall, and `git diff --check` -> passed. Isolated wheel + and sdist validation passed with 27 package-data files; both clean-install + smoke contracts passed. SHA-256: wheel + `ceb610a5eafeedc1d0b13f0626d0ac9a74f33a4cb46735778c37fc4712b5bb7b`; + sdist + `ac9371a6e3087dc515dc5cd0c871687dd7cd23e7ce3468cc2d7d3b09c65bb7e0`. + Visual inspection confirmed the base logo remains recognizable and each + status uses a distinct shape/glyph. No protected host mutation occurred. + + - Status: T010 complete; T011 remains separately approval-gated. + - Evidence mode: implementation +## Phase 4: Acceptance, Review, Promotion, And Closure + +- [ ] T011 Perform approved Linux Mint acceptance. + - Depends on: T010 + - Requirements: Requirement 1-Requirement 7 + - Properties: CP-001-CP-006 + - Approval: Explicit protected deployment and any live backup/retention + execution approval required. + - Acceptance: Authorized and denied subscription, event latency, last-success + semantics, 90-second idle silence, backend restart, tray restart, action + independence, timer health, and rollback are evidenced from the installed + artifact. + - Evidence: Pending. + +- [ ] T012 Run the TimeLocker expert review and address findings. + - Depends on: T011 + - Requirements: Requirement 1-Requirement 7 + - Review: Use `$review-timelocker` with project stewardship, Restic, + Python/IPC architecture, security/privacy, reliability/testing, + operations/portability, and documentation lifecycle perspectives. + - Acceptance: Blocking findings are fixed; advisory findings are fixed, + rejected with rationale, or routed to one owned destination. + - Evidence: Pending. + +- [ ] T013 Promote durable documentation, run final validation, and close. + - Depends on: T012 + - Requirements: Requirement 1-Requirement 7 + - Files: promotion targets in `change-impact.md`, `verification.md`, + `docs/specs/README.md`, `docs/history/` + - Acceptance: Configured regression and all required focused/platform/ + security/package checks pass; accepted behavior is promoted; Windows live + work has one follow-up destination; lifecycle evidence, traceability, + closure, final-spec commit, cleanup, and history indexes are complete. + - Evidence: Pending. + +## Execution Rules + +- Do not implement from this file alone. Read the full package and durable + baseline first. +- Mark only one implementation task `[~]` at a time unless non-conflicting work + is explicitly approved. +- Preserve unrelated worktree changes and reconcile the existing tray-menu + correction rather than reverting it. +- Record commands and results under the task and in `verification.md`. +- A focused `--no-cov` run is diagnostic only; the configured final profile + owns the 50 percent coverage gate. +- Protected deployment, group changes, live backup, live retention, rollback, + publication, and release selection retain explicit approval gates. + +## Related Artifacts + +- Requirements: [requirements.md](./requirements.md) +- Change Impact: [change-impact.md](./change-impact.md) +- Design: [design.md](./design.md) +- Verification: [verification.md](./verification.md) diff --git a/docs/specs/010-event-driven-tray-status/traceability.md b/docs/specs/010-event-driven-tray-status/traceability.md new file mode 100644 index 0000000..32838bc --- /dev/null +++ b/docs/specs/010-event-driven-tray-status/traceability.md @@ -0,0 +1,89 @@ +--- +title: Event-driven tray status traceability +doc_type: spec +artifact_type: traceability +status: active +owner: Auriora Team +last_reviewed: 2026-07-27 +--- + +# Traceability Matrix + +## Task To Context Matrix + +| Task | Requirements | Acceptance criteria | Design coverage | Verification | Durable targets | +|------|--------------|---------------------|-----------------|--------------|-----------------| +| T001 | Requirement 1, Requirement 2, Requirement 3, Requirement 7 | Requirement 1 AC4-AC5; Requirement 2 AC3; Requirement 3 AC1-AC5; Requirement 7 AC1 | Data Models, Function Signatures | V1, V2 | requirements, architecture | +| T002 | Requirement 2, Requirement 3, Requirement 4 | Requirement 2 AC1, AC3-AC5; Requirement 3 AC1-AC4; Requirement 4 AC5 | Control Protocol, Snapshot Builder | V1, V3 | requirements, implementation | +| T003 | Requirement 1, Requirement 2, Requirement 4 | Requirement 1 AC3-AC5; Requirement 2 AC2-AC3; Requirement 4 AC2-AC3 | Event Broker, Change Sources | V2, V4 | architecture, implementation | +| T004 | Requirement 1-Requirement 4 | Phase 1 criteria | Validation Strategy | V1-V4 | none | +| T005 | Requirement 1, Requirement 2, Requirement 4, Requirement 7 | Requirement 1 AC1-AC5; Requirement 2 AC1-AC5; Requirement 4 AC1-AC5; Requirement 7 AC1-AC3 | Linux Transport, Tray Client, Security | V2-V5 | architecture, tray setup | +| T006 | Requirement 3, Requirement 5, Requirement 6 | all | Tray Presentation, Error Handling | V1, V5, V6 | tray setup, reference, troubleshooting | +| T007 | Requirement 1-Requirement 6 | Phase 2 criteria | Data Flow, Failure Handling | V1-V6 | none | +| T008 | Requirement 2, Requirement 4, Requirement 7 | Requirement 2 AC1-AC5; Requirement 4 AC3-AC5; Requirement 7 AC1, AC3 | Windows Event Contract | V3, V7 | requirements, architecture | +| T009 | Requirement 4, Requirement 7 | Requirement 4 AC5; Requirement 7 AC2, AC4-AC5 | Migration and Compatibility | V8, V9 | installation, version management | +| T010 | Requirement 5, Requirement 7 | Requirement 5 AC6; Requirement 7 all | Tray Presentation, Operational Considerations | V5, V8, V9 | tray setup | +| T011 | Requirement 1-Requirement 7 | all Linux acceptance criteria | Complete Linux flow | V10 | operational docs | +| T012 | Requirement 1-Requirement 7 | review disposition | Security, Reliability, Portability | V11 | all promotion targets | +| T013 | Requirement 1-Requirement 7 | all | Promotion and Closure | V12-V15 | all promotion targets and history | + +## Requirement To Delivery Matrix + +| Requirement | Priority | Tasks | Verification gates | Durable targets | Coverage state | Residual destination | +|-------------|----------|-------|--------------------|-----------------|----------------|----------------------| +| Requirement 1 | must-have | T001, T003-T005, T007, T011-T013 | V1, V2, V4, V5, V10 | requirements, architecture, tray setup | partial | Contracts through event-driven tray integration complete; deployment and live acceptance remain | +| Requirement 2 | must-have | T001-T005, T007-T008, T011-T013 | V1-V5, V7, V10-V11 | requirements, architecture | partial | Allowlisted models and Linux continuous authorization complete; Windows contract and live acceptance remain | +| Requirement 3 | must-have | T001-T002, T006-T007, T011-T013 | V1, V5-V6, V10 | requirements, tray setup | partial | Last-success contract, backend snapshot, local tray projection, and `Never` fallback complete; live acceptance remains | +| Requirement 4 | must-have | T002-T005, T007-T011, T013 | V2-V5, V7-V10 | architecture, troubleshooting | partial | Linux reconnect, bounds, and event/control independence complete; integration, deployment, and live acceptance remain | +| Requirement 5 | must-have | T006-T007, T010-T013 | V5, V6, V8-V10 | tray setup, command reference | partial | Honest rows, actions, and deterministic non-colour-only Linux logo badges pass local and installed-artifact checks; live acceptance remains | +| Requirement 6 | must-have | T006-T007, T011-T013 | V6, V10 | tray setup, troubleshooting | partial | Healthy serve silence and explicit one-shot output passed; live idle capture remains | +| Requirement 7 | must-have | T001, T005, T008-T013 | V1-V3, V7-V11, V13 | requirements, architecture, installation | partial | T001 platform-neutral interfaces complete; platform transports and rollout remain | + +## Correctness Property Coverage + +| Property | Requirements | Tasks | Tests or verification | Residual risk | +|----------|--------------|-------|-----------------------|---------------| +| CP-001 | Requirement 3 | T001, T002, T006, T011 | V1 permutation coverage passed; backend, tray, and live evidence pending | none after live evidence | +| CP-002 | Requirement 1, Requirement 4 | T001, T003, T005, T008 | V1 strict revision ordering passed; broker/coalescing evidence pending | concurrency scheduling remains host-sensitive | +| CP-003 | Requirement 2 | T005, T008, T011 | V3, V5, V7, V10 | Windows live revocation deferred | +| CP-004 | Requirement 1, Requirement 4 | T001-T005, T008, T011 | V1-V5, V7, V10 | none for accepted Linux slice | +| CP-005 | Requirement 2, Requirement 4 | T002-T005, T008, T011 | V3-V5, V7, V10 | none | +| CP-006 | Requirement 6 | T006-T007, T011 | V6, V10 | desktop session capture variation | + +## Design To Implementation Matrix + +| Design section | Requirements | Tasks | Interfaces or files | Verification | Coverage state | Residual destination | +|----------------|--------------|-------|---------------------|--------------|----------------|----------------------| +| Status models and snapshot | Requirement 2, Requirement 3 | T001-T002 | models, protocol, backend, storage | V1, V3 | partial-pass | T001 contracts passed; T002 backend action remains | +| Event broker and change sources | Requirement 1, Requirement 4 | T003 | new broker/watcher modules | V2, V4 | pass | Bounded broker, mutation seams, snapshot race boundary, and watcher resync validated | +| Linux event transport | Requirement 1, Requirement 2, Requirement 4, Requirement 7 | T005 | Linux adapter, backend, event client | V3-V5 | pass | Authenticated bounded listener, reconnect, revocation, restart, and independence tests passed | +| Tray presentation | Requirement 3, Requirement 5, Requirement 6 | T006 | tray client, entry, platform integration | V5-V6 | pass | Snapshot-driven rows, local last-success, menu actions, and quiet serve validated | +| Windows event contract | Requirement 2, Requirement 4, Requirement 7 | T008 | Windows adapter and platform tests | V7 | not-covered | T008 | +| Deployment and compatibility | Requirement 4, Requirement 7 | T009-T011 | assets, deployment, release probes | V8-V10 | partial | T009 local contract passed; built artifact and live host remain T010-T011 | +| Promotion and closure | Requirement 1-Requirement 7 | T012-T013 | durable docs and lifecycle artifacts | V11-V15 | not-covered | T012-T013 | + +## Open Decision Impact + +There are no open decisions. Changing the dedicated event channel, +invalidation-plus-snapshot approach, continuous authorization, or Linux-now/ +Windows-contract slice requires explicit design reconciliation and approval. + +## Verification Gate Key + +| Gate | Description | +|------|-------------| +| V1 | Model, snapshot, protocol, and CP-001/CP-002 tests | +| V2 | Broker revision, coalescing, and race tests | +| V3 | Authorization and privacy negative controls | +| V4 | Change-source, watcher overflow, and resync tests | +| V5 | Linux transport, reconnect, restart, and independence tests | +| V6 | Tray menu, last-success, local-time, one-shot, and idle-output tests | +| V7 | Windows named-pipe contract and platform tests | +| V8 | Deployment asset and rollback tests | +| V9 | Wheel/sdist validation and installed-artifact smoke | +| V10 | Approved Linux Mint live acceptance | +| V11 | `$review-timelocker` expert review and disposition | +| V12 | Configured normal regression and coverage profile | +| V13 | Ruff, compile, link, Markdown, and Git checks | +| V14 | Lifecycle lint, readiness, task, evidence, promotion, and closure checks | +| V15 | Final spec commit, cleanup, active index, closure log, and archive index | diff --git a/docs/specs/010-event-driven-tray-status/verification.md b/docs/specs/010-event-driven-tray-status/verification.md new file mode 100644 index 0000000..2fd805c --- /dev/null +++ b/docs/specs/010-event-driven-tray-status/verification.md @@ -0,0 +1,239 @@ +--- +title: Event-driven tray status verification +doc_type: spec +artifact_type: verification +status: active +owner: Auriora Team +last_reviewed: 2026-07-27 +--- + +# Verification + +## Scope + +This plan covers Requirements 1-7, CP-001-CP-006, T001-T013, the protected +snapshot/event contracts, Linux implementation, Windows contract tests, tray +presentation, packaging, approved live acceptance, durable promotion, and +closure. + +## Quality Gates + +| Gate | Required? | Status | Evidence | +|------|-----------|--------|----------| +| Requirements and acceptance criteria reviewed | yes | pass | User approval recorded 2026-07-27; lifecycle readiness passed | +| Traceability complete | yes | pass | Lifecycle lint and readiness found no blocking gaps | +| Focused model/protocol/security tests pass | yes | partial-pass | T001-T005 system-control slice: 224 passed; tray presentation T006 pending | +| Tray and event integration tests pass | yes | pass | T007 focused event-driven integration checkpoint: 58 passed | +| Windows platform contract tests pass | yes | pass | T008 injected named-pipe contract; live Windows acceptance remains deferred | +| Package and deployment checks pass | yes | pass | T009 deployment contract and T010 wheel/sdist installed-artifact checks passed | +| Approved Linux Mint acceptance passes | yes | pending | T011 | +| Expert review findings resolved | yes | pending | T012 | +| Configured regression and coverage gate pass | yes | pending | T013 | +| Durable documentation promoted | yes | pending | T013 | +| Closure and cleanup evidence complete | yes | pending | T013 | + +## Validation Commands + +| Command | Purpose | Result | Evidence | +|---------|---------|--------|----------| +| `python -m pytest --no-cov tests/TimeLocker/system_control/ tests/TimeLocker/monitoring/test_system_tray_integration.py` | Fast focused diagnostic during implementation | pending | Not final coverage evidence | +| `python -m pytest -m "unit or security or platform" --no-cov` with selected event files | Focused contract and negative controls | pending | Exact selection recorded per task | +| `python -m pytest -m "not performance and not stress and not minio"` | Normal correctness and 50 percent coverage gate | pending | Final suite evidence | +| `python -m pytest -m "performance or stress" --no-cov` | Timing/stress only if affected or required by review | pending | May be waived with rationale | +| `PYENV_VERSION=3.12.4 ruff check src tests` | Static style and lint | pending | | +| `python -m compileall -q src` | Package syntax/import compilation | pending | | +| `python -m build` and `python scripts/validate_release_artifacts.py --expected-version 0.9.1 --dist dist` | Artifact completeness and hashes | pending | Use current version at execution | +| isolated wheel/sdist CLI, backend, tray, control, and event-protocol smoke | Installed-artifact contract | pending | Exact commands recorded at T010 | +| Agent Workbench Markdown document checks for changed Markdown files | Markdown structure, frontmatter, links, lists, and tables | pending | MCP evidence | +| `python scripts/link_checker.py` | Internal links | pending | | +| `git diff --check` | Patch integrity | pending | | +| lifecycle lint/readiness/traceability/evidence/promotion/closure tools | Spec gates | pending | MCP outputs preferred | + +## Requirement Coverage + +| Requirement | Acceptance criteria covered | Evidence | Residual risk | +|-------------|-----------------------------|----------|---------------| +| Requirement 1 | AC1-AC5 | T001, T003-T005, T007, T011 | pending | +| Requirement 2 | AC1-AC5 | T001-T005, T008, T011-T012 | pending | +| Requirement 3 | AC1-AC5 | T001-T002, T006-T007, T011 | pending | +| Requirement 4 | AC1-AC5 | T002-T005, T007-T011 | pending | +| Requirement 5 | AC1-AC6 | T006-T007, T010-T011 | partial-pass; deterministic Linux badges and honest never-run/failure projection passed, live acceptance pending | +| Requirement 6 | AC1-AC4 | T006-T007, T011 | pending | +| Requirement 7 | AC1-AC5 | T001, T005, T008-T011 | pending | + +## Correctness Property Coverage + +| Property | Covered by | Evidence | Residual risk | +|----------|------------|----------|---------------| +| CP-001 | Generated/table-driven histories, snapshot tests, live result | partial-pass | Model, backend, and tray projection passed; live evidence pending | +| CP-002 | Revision sequence and coalescing tests | partial-pass | Broker, duplicate/older rejection, gap, and stale-snapshot controls passed; live integration remains | +| CP-003 | Subscribe, per-frame revocation, and denied-client tests | partial-pass | Linux per-delivery revocation and denial passed; Windows live evidence deferred | +| CP-004 | Restart, session change, gap, and snapshot convergence tests | partial-pass | Reconnect, new session, gap, and initial-snapshot recovery passed; live integration remains | +| CP-005 | Interface isolation, lock spy, and live operation independence | partial-pass | Separate event/control failure isolation passed; live operation evidence remains | +| CP-006 | Captured idle serve test and live 90-second observation | partial-pass | Healthy serve is silent and one-shot output remains; live 90-second evidence pending | + +## Scope Reconciliation Before Closure + +| Broad target | Implemented in this spec | Coverage state | Deferred or rejected work | Destination | Blocks closure? | Evidence | +|--------------|--------------------------|----------------|---------------------------|-------------|-----------------|----------| +| Event-driven Linux tray status | T001-T007, T009-T011 | partial | none | rollout tasks T009-T011 | yes | T001-T007 implementation and Phase 2 checkpoint passed | +| Continuous authorization/privacy | T001-T005, T008, T011-T012 | partial | Windows live revocation | Windows follow-up spec | yes for Linux; no for Windows live | +| Accurate and quiet tray UX | T001-T002, T006-T007, T011 | partial | Live desktop acceptance remains | T007, T011 | yes | Correct local last-success rows and silent serve passed in T006 | +| Portable Windows architecture | T001, T008 | covered | Concrete Windows service and live acceptance | follow-up spec or issue | no after routing | Injected token-derived named-pipe event contract passed 232-test checkpoint | +| Full desktop application | none | out-of-scope | Product UI | backlog/roadmap | no | charter and requirements | + +## Agent Readiness Evidence + +| Field | Evidence | Residual risk | +|-------|----------|---------------| +| Scope and out-of-scope files | Requirements, design slice table, change impact | Review pending | +| Must-read context | `canonical-context.md` and linked durable sources | Source may change before implementation | +| Permissions and approval points | `README.md`, tasks execution rules, T011 | Live commands require renewed approval | +| Validation commands | This file and testing conventions | Exact new test paths finalized during T001 | +| Review needs | T004 security/protocol checkpoint and T012 expert panel | Review pending | +| Durable-doc and closure impact | `change-impact.md` and promotion table | Promotion pending | +| Repository evidence caveats | Agent Workbench is routing evidence; direct reads and commands establish claims | Re-run after relevant changes | + +## Task Evidence + +| Task | Status | Evidence | Notes | +|------|--------|----------|-------| +| T001 | complete | Immutable status models, platform-neutral interfaces, 75 focused tests, Ruff, patch integrity | Public snapshot action remains T002. | +| T002 | complete | Authorized read-only snapshot action, backend builder, client parsing, safe denial, 108 focused tests | Event publication remains T003. | +| T003 | complete | Bounded broker, coalescing, synchronized revision boundary, mutation seams, watcher resync, 96 focused tests | Platform transport remains T005. | +| T004 | complete | Bounded expert checkpoint; 208 system-control tests, Ruff, compileall, patch integrity | No blocking Phase 1 findings remain. | +| T005 | complete | Authenticated bounded Linux event transport, reconnecting client, fresh-snapshot coordinator, 224 system-control tests | Deployment and live-host evidence remain in T009-T011. | +| T006 | complete | Snapshot-driven status rows, accurate local last-success time, silent serve, 228 system-control and 9 monitoring tests | Phase 2 integration checkpoint remains T007. | +| T007 | complete | 58-test Phase 2 transport, security, reconnect, snapshot, tray, menu, and no-polling checkpoint | Windows, deployment, package, and live acceptance remain. | +| T008 | complete | Token-derived bounded Windows named-pipe event contracts, four new platform tests, 232 system-control tests | No live Windows service or acceptance is claimed. | +| T009 | complete | Named protected event socket asset, named systemd descriptor mapping, dual-protocol release metadata, structured activation/rollback gates, 246 system-control tests | Installed-artifact and live-host evidence remain T010-T011. | +| T010 | complete | Five deterministic accessible logo badges, honest never-run/failure projection, 268-test regression, 27-file package-data validation, SHA-256 verification, and clean-install wheel/sdist smoke | No live selector, unit, socket, timer, backup, retention, or protected path changed. | +| T011-T013 | pending | none | Sequenced by the task dependency graph. | + +## Evidence Log + +| Date | Evidence | Result | Notes | +|------|----------|--------|-------| +| 2026-07-27 | Source and durable-doc inspection for spec authoring | pass | Current polling, stdout, menu, authorization, transport, and deployment boundaries read directly. | +| 2026-07-27 | Existing menu-removal focused tests | 14 passed | Pre-spec partial implementation evidence; final suite not run for this package. | +| 2026-07-27 | Ruff and `git diff --check` for existing menu removal | pass | Does not validate event-driven behavior. | +| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control/test_status_contracts.py tests/TimeLocker/system_control/test_models.py tests/TimeLocker/system_control/test_protocol.py tests/TimeLocker/system_control/test_interfaces.py` | 75 passed | T001 exact models, safe failures, existing protocol compatibility, CP-001, and CP-002. | +| 2026-07-27 | T001 scoped Ruff and `git diff --check` | pass | No static or patch-integrity findings in the contract slice. | +| 2026-07-27 | T002 authorized snapshot action focused suite | 108 passed | Safe projection, denial, client, storage, backend, and compatibility evidence. | +| 2026-07-27 | T003 broker/change-source focused suite | 96 passed | Monotonicity, coalescing, bounds, race boundary, mutation isolation, and watcher resync evidence. | +| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` | 208 passed | Phase 1 system-control regression checkpoint. | +| 2026-07-27 | Scoped Ruff, `compileall`, and `git diff --check` | pass | Phase 1 static, import-syntax, and patch-integrity evidence. | +| 2026-07-27 | `$review-timelocker` bounded Phase 1 security/protocol review | pass after direct fix | One watcher-failure resync gap was found, fixed, and regression-tested; no remaining blocking findings. Scope excluded T005+ transport, deployment, live operations, and durable-doc promotion. | +| 2026-07-27 | T005 transport, security, reconnect, and tray subscription negative controls | 13 passed | Authorized/denied subscription, per-delivery revocation, heartbeat, slow sender, frame overflow, disconnect/restart, revision gap/staleness, initial recovery, and event/control independence. | +| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` | 224 passed | T005 system-control regression evidence. | +| 2026-07-27 | Scoped Ruff, `compileall`, and `git diff --check` | pass | T005 static, import-syntax, and patch-integrity evidence; Agent Workbench had no Python diagnostics provider. | +| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` | 228 passed | T006 snapshot projection, event-driven serve, action, and quiet-output regression evidence. | +| 2026-07-27 | Focused monitoring tray suite | 9 passed | Disabled status rows, local timezone, icon/menu lifecycle, and absence of misleading actions. | +| 2026-07-27 | T007 focused event-driven integration checkpoint | 58 passed | Initial snapshot/update, coalescing, reconnect/restart, revocation, honest last-success, dynamic menu, silence, and no legacy polling. | +| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` after T008 | 232 passed | Windows injected event transport, token identity, per-delivery authorization, bounded heartbeat/frame/send behavior, and no live Windows claim. | +| 2026-07-27 | T009 focused deployment, backend-entry, Linux asset, release, and snapshot suite | 52 passed | Event socket ownership/mode, named descriptor order independence, dual-protocol release metadata, fail-closed activation, timer gates, atomic selection, and rollback. | +| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` after T009 | 246 passed | Phase 3 deployment-contract regression checkpoint. | +| 2026-07-27 | T009 scoped Ruff and `git diff --check` | pass | Ruff used the repository-installed Python 3.12.4 toolchain; Agent Workbench reported no provider-backed Python diagnostics. | +| 2026-07-27 | `systemd-analyze verify` against packaged units | environment-limited | Unit directives parsed without an unknown-directive finding, but host-wide permission errors and absent protected launcher executability prevented a clean verification claim; installed-host evidence remains T011. | +| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m build --outdir /tmp/timelocker-spec010-build.xcoQSY` | pass after network retry | Isolated build produced the `0.9.1` wheel and sdist without overwriting the repository's existing `dist/` artifacts. | +| 2026-07-27 | `validate_release_artifacts.py` against isolated T010 output | pass | Validated versions, Python constraint, four entrypoints, 22 package-data files, and SHA-256 hashes. Wheel: `231cfe2289cdb408ad6d4e8194909cfa867a11aeae8f847b41d32c53ab4dc5c2`; sdist: `9712e7484eca2d5a5f878a7fb9f76d2fd7c93f25511d3eb5b964b682bd734761`. | +| 2026-07-27 | Clean-install smoke for isolated wheel and sdist on Python 3.12.6 | pass | Both artifacts passed `timelocker`, `tl`, backend and tray help, dual-protocol import, and packaged system-asset checks. | +| 2026-07-27 | Release-artifact tests, scoped Ruff, compileall, and patch integrity | pass | 10 artifact tests passed; source/system-control/project-test Ruff, compilation, and `git diff --check` passed. | +| 2026-07-27 | T010 Linux status-badge focused and regression validation | pass | 53 focused tests and 268 system-control/monitoring/icon/release-artifact tests passed; scoped Ruff, compileall, and patch integrity passed. | +| 2026-07-27 | Rebuilt badge-aware wheel/sdist validation and clean-install smoke | pass | Validator found 27 package-data files; both artifacts passed four-entrypoint, dual-protocol, system-asset, and five-icon smoke checks. Wheel SHA-256: `ceb610a5eafeedc1d0b13f0626d0ac9a74f33a4cb46735778c37fc4712b5bb7b`; sdist SHA-256: `ac9371a6e3087dc515dc5cd0c871687dd7cd23e7ce3468cc2d7d3b09c65bb7e0`. | + +## Manual Or External Verification + +T011 requires explicit approval before protected deployment or live operations. +The reviewed sequence is: + +1. Record the current and previous selected release IDs plus active/enabled + backup and retention timer states. +2. Stage the validated artifact as an immutable root-owned release with + schema-2 control/event protocol metadata. +3. Install the exact hashed assets, reload systemd, and enable the protected + control and status-event sockets without changing backup or retention + policy. +4. Run staged CLI/backend/tray/protocol probes and verify both existing timers + before atomically selecting the new release. +5. Run authorized/denied event, status, silence, restart, and independence + acceptance checks. +6. Probe and atomically roll back to the previous release, then verify explicit + control status and both timers before deciding whether to reselect the + candidate. + +Record selected release IDs, artifact hashes, service/socket/timer states, +authorized and denied observations, event latency, idle-output capture, restart +recovery, and rollback without recording credentials or raw protected content. + +## Residual Risks + +- A long-lived subscription expands denial-of-service and revocation concerns; + bound subscribers and reauthorize each event/heartbeat. +- Cross-process record changes may race event publication; watcher uncertainty + and session/snapshot recovery are mandatory. +- Desktop toolkits differ in dynamic menu behavior; keep platform tests and + Linux Mint visual acceptance. +- Concrete Windows service behavior remains unverified and must not be claimed. +- Immutable rollback can leave new inert unit assets; verify prior control and + timers remain healthy. + +## Durable Promotion And Cleanup + +| Spec content | Durable destination or deferral | Status | Evidence | +|--------------|---------------------------------|--------|----------| +| Requirements and security behavior | `docs/1-requirements/system-operations.md` | pending | | +| Architecture and platform contract | `docs/2-architecture/system-architecture.md` | pending | | +| Component/interface ownership | `docs/3-implementation/service-layer-integration.md` | pending | | +| Tray setup, menu, reconnect, rollback | `docs/SYSTEM-TRAY-SETUP.md` | pending | | +| Command/action reference | `docs/reference/timelocker-cli-command-hierarchy.md` | pending | | +| Troubleshooting and installation | user guides and version process | pending | | +| Windows live implementation | follow-up spec or issue | pending routing | | +| Full desktop UI | product backlog/roadmap | excluded | | + +### Spec Cleanup Decision + +- **Cleanup action:** remove after final spec commit and promotion +- **Reason:** Repository policy uses Git plus compact history indexes. +- **Final spec commit:** pending +- **Closure log path:** `docs/history/spec-closure-log.md` +- **Closure log entry updated:** no +- **Closure cleanup commit:** pending +- **Active indexes updated:** no +- **Durable docs linked back to evidence where useful:** no +- **Residual spec-only content:** none expected + +## Ship Or Closure Risk + +- **Risk level:** high until security review and live acceptance; expected + medium after all gates +- **Breaking change:** coherent local protocol/release upgrade required +- **Blast radius checked:** no +- **Rollback path:** designed, not yet verified +- **Requires human review:** yes +- **Release notes needed:** yes if shipped in a release +- **Follow-up issue or spec needed:** yes, Windows live implementation and + acceptance + +### Risk Rationale + +The change crosses a privileged backend, continuous local authorization, +cross-process state observation, desktop presentation, systemd deployment, and +rollback. No repository secrets or Restic mutation semantics need to change, +but implementation and live evidence must prove that the new read path cannot +weaken those boundaries. + +## Readiness Decision + +- **Ready to implement:** yes - lifecycle review passed and user approval was + recorded on 2026-07-27 +- **Ready for promotion:** no +- **Ready for release:** no +- **Ready for closure:** no + +## Related Artifacts + +- Requirements: [requirements.md](./requirements.md) +- Change Impact: [change-impact.md](./change-impact.md) +- Design: [design.md](./design.md) +- Tasks: [tasks.md](./tasks.md) diff --git a/docs/specs/README.md b/docs/specs/README.md index 81d989c..85dae26 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -15,15 +15,20 @@ accepted content has been promoted and the package is closed. ## Current Packages -There are no active specification packages. +- [`010-event-driven-tray-status`](./010-event-driven-tray-status/README.md) - + active implementation package for replacing tray status polling with an + authenticated event subscription and accurate, quiet status presentation. + Implementation was approved on 2026-07-27; T001 is the first slice. ## Active-Package Sequencing -Specs 007, 008, and 009 are closed. Their final package commits, cleanup -commits, verification summaries, and residual follow-up are recorded in -`docs/history/`. Closed packages remain recoverable from Git rather than kept -in this active path. Repository implementation approval does not authorize -release publication or deployment. +Spec 010 is the only active package. Specs 007, 008, and 009 are closed. Their +final package commits, cleanup commits, verification summaries, and residual +follow-up are recorded in `docs/history/`. Closed packages remain recoverable +from Git rather than kept in this active path. Spec 010 may rely on the durable +behavior promoted by Spec 009, but not on its removed package as current +authority. Repository implementation approval does not authorize release +publication or deployment. ## When a Spec Is Needed diff --git a/pyproject.toml b/pyproject.toml index e51119a..9e254bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ dev = [ "pytest-benchmark~=5.2.3", "pytest-timeout>=2.4.0", "pytest-asyncio~=1.3.0", + "Pillow>=12.0.0", "plantuml~=0.3.0", "bump2version>=1.0.1", "safety>=3.0.0", diff --git a/scripts/generate_tray_status_icons.py b/scripts/generate_tray_status_icons.py new file mode 100644 index 0000000..b37fdb2 --- /dev/null +++ b/scripts/generate_tray_status_icons.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Generate deterministic status-badged variants of the TimeLocker tray icon.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from PIL import Image, ImageDraw + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +ASSET_ROOT = PROJECT_ROOT / "src" / "TimeLocker" / "system_control" / "assets" +BASE_ICON = ASSET_ROOT / "timelocker-icon.png" + +BADGE_BOX = (676, 636, 988, 948) +BADGE_OUTLINE_WIDTH = 26 +GLYPH_WIDTH = 38 + + +def _draw_circle_badge( + draw: ImageDraw.ImageDraw, + *, + fill: str, +) -> None: + draw.ellipse( + BADGE_BOX, + fill=fill, + outline="white", + width=BADGE_OUTLINE_WIDTH, + ) + + +def _draw_idle(draw: ImageDraw.ImageDraw) -> None: + _draw_circle_badge(draw, fill="#667085") + draw.arc( + (755, 700, 907, 840), + start=195, + end=520, + fill="white", + width=GLYPH_WIDTH, + ) + draw.line((831, 822, 831, 858), fill="white", width=GLYPH_WIDTH) + draw.ellipse((812, 876, 850, 914), fill="white") + + +def _draw_running(draw: ImageDraw.ImageDraw) -> None: + _draw_circle_badge(draw, fill="#1570EF") + draw.ellipse( + (746, 706, 918, 878), + outline="white", + width=GLYPH_WIDTH, + ) + draw.line((832, 750, 832, 801, 874, 830), fill="white", width=GLYPH_WIDTH) + + +def _draw_success(draw: ImageDraw.ImageDraw) -> None: + _draw_circle_badge(draw, fill="#16803C") + draw.line( + ((754, 810), (810, 866), (912, 746)), + fill="white", + width=GLYPH_WIDTH, + joint="curve", + ) + + +def _draw_warning(draw: ImageDraw.ImageDraw) -> None: + triangle = ((832, 646), (984, 932), (680, 932)) + draw.polygon(triangle, fill="#B54708", outline="white") + draw.line( + (832, 724, 832, 838), + fill="white", + width=GLYPH_WIDTH, + ) + draw.ellipse((812, 866, 852, 906), fill="white") + + +def _draw_error(draw: ImageDraw.ImageDraw) -> None: + _draw_circle_badge(draw, fill="#B42318") + draw.line((764, 744, 900, 880), fill="white", width=GLYPH_WIDTH) + draw.line((900, 744, 764, 880), fill="white", width=GLYPH_WIDTH) + + +DRAWERS = { + "idle": _draw_idle, + "running": _draw_running, + "success": _draw_success, + "warning": _draw_warning, + "error": _draw_error, +} + + +def generate(output_root: Path) -> tuple[Path, ...]: + """Generate all status icon variants under *output_root*.""" + with Image.open(BASE_ICON) as source: + base = source.convert("RGBA") + generated = [] + for status, drawer in DRAWERS.items(): + image = base.copy() + drawer(ImageDraw.Draw(image)) + target = output_root / f"timelocker-icon-{status}.png" + image.save(target, format="PNG", optimize=False, compress_level=9) + generated.append(target) + return tuple(generated) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--output-root", + type=Path, + default=ASSET_ROOT, + help="Directory that receives the generated PNG assets.", + ) + args = parser.parse_args() + args.output_root.mkdir(parents=True, exist_ok=True) + for path in generate(args.output_root): + print(path) + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke_release_artifact.py b/scripts/smoke_release_artifact.py index f1f4315..aad0781 100755 --- a/scripts/smoke_release_artifact.py +++ b/scripts/smoke_release_artifact.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Install one built artifact in a fresh environment and smoke both CLIs.""" +"""Install one built artifact and smoke its public and system entry points.""" from __future__ import annotations @@ -26,7 +26,51 @@ def run(command: list[str], *, expected: str | None = None) -> None: f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" ) if expected is not None and result.stdout.strip() != expected: - raise RuntimeError(f"{' '.join(command)} returned {result.stdout.strip()!r}, expected {expected!r}") + raise RuntimeError( + f"{' '.join(command)} returned {result.stdout.strip()!r}, " + f"expected {expected!r}" + ) + + +def smoke_system_contract(python: Path, expected_version: str) -> None: + """Verify installed protocols and protected deployment assets.""" + contract = """ +import sys +from importlib.resources import files +from TimeLocker.system_control.models import ( + PROTOCOL_VERSION, + STATUS_EVENT_PROTOCOL_VERSION, +) +from TimeLocker.system_control.release_launcher import ReleaseManifest + +assets = files("TimeLocker.system_control").joinpath("assets") +for name in ( + "timelocker-control.service", + "timelocker-control.socket", + "timelocker-status-events.socket", + "timelocker-retention.service", + "timelocker-retention.timer", + "timelocker-icon-idle.png", + "timelocker-icon-running.png", + "timelocker-icon-success.png", + "timelocker-icon-warning.png", + "timelocker-icon-error.png", +): + assert assets.joinpath(name).is_file(), name +manifest = ReleaseManifest.from_mapping( + { + "schema_version": 2, + "release_id": "a" * 40, + "package_version": sys.argv[1], + "control_protocol_version": PROTOCOL_VERSION, + "event_protocol_version": STATUS_EVENT_PROTOCOL_VERSION, + "entrypoint": "venv/bin/timelocker", + } +) +assert manifest.control_protocol_version == PROTOCOL_VERSION +assert manifest.event_protocol_version == STATUS_EVENT_PROTOCOL_VERSION +""" + run([str(python), "-c", contract, expected_version]) def main() -> None: @@ -47,6 +91,9 @@ def main() -> None: command = executable(environment, command_name) run([str(command), "version", "--short"], expected=args.expected_version) run([str(command), "--help"]) + for command_name in ("timelocker-system-control", "timelocker-tray"): + run([str(executable(environment, command_name)), "--help"]) + smoke_system_contract(python, args.expected_version) print(f"Smoke contract passed for {artifact.name} on Python {sys.version.split()[0]}") diff --git a/src/TimeLocker/monitoring/system_tray_integration.py b/src/TimeLocker/monitoring/system_tray_integration.py index 788361d..d20c69a 100644 --- a/src/TimeLocker/monitoring/system_tray_integration.py +++ b/src/TimeLocker/monitoring/system_tray_integration.py @@ -86,18 +86,70 @@ class TrayStatus(Enum): ERROR = "error" +PACKAGED_TRAY_STATUS_ICON_PATHS = { + status: PACKAGED_TRAY_ICON_PATH.with_name( + f"timelocker-icon-{status.value}.png" + ) + for status in TrayStatus +} + + +def _linux_tray_icon_path(status: TrayStatus) -> str: + """Return the packaged status icon, then the base logo, then a theme icon.""" + status_path = PACKAGED_TRAY_STATUS_ICON_PATHS[status] + if status_path.is_file(): + return str(status_path) + if PACKAGED_TRAY_ICON_PATH.is_file(): + return str(PACKAGED_TRAY_ICON_PATH) + return "dialog-information" + + @dataclass class TrayStatusInfo: """Information displayed in system tray""" status: TrayStatus tooltip: str - last_backup_time: Optional[datetime] = None - last_backup_status: Optional[str] = None - repository_count: int = 0 + backend_available: bool = False + last_successful_backup_time: Optional[datetime] = None + latest_backup_status: Optional[str] = None + latest_retention_status: Optional[str] = None + next_backup_time: Optional[datetime] = None + next_retention_time: Optional[datetime] = None active_operations: int = 0 +def _format_local_time(value: datetime | None, *, missing: str) -> str: + if value is None: + return missing + return value.astimezone().strftime("%Y-%m-%d %H:%M %Z").rstrip() + + +def _status_menu_labels(status_info: TrayStatusInfo) -> tuple[str, ...]: + """Return platform-neutral, non-actionable status menu labels.""" + activity = ( + f"{status_info.active_operations} active" + if status_info.active_operations + else "Idle" + ) + return ( + "Backend: " + + ("Available" if status_info.backend_available else "Unavailable"), + f"Activity: {activity}", + "Last successful backup: " + + _format_local_time( + status_info.last_successful_backup_time, + missing="Never", + ), + f"Latest backup: {status_info.latest_backup_status or 'Unknown'}", + f"Latest retention: {status_info.latest_retention_status or 'Unknown'}", + "Next backup: " + + _format_local_time(status_info.next_backup_time, missing="Unknown"), + "Next retention: " + + _format_local_time(status_info.next_retention_time, missing="Unknown"), + ) + + class SystemTrayIntegration: """ System tray integration for TimeLocker @@ -107,7 +159,6 @@ class SystemTrayIntegration: - Application icon with status details - Tooltip with last backup status - Context menu with quick actions - - Click-to-open main interface """ def __init__( @@ -125,7 +176,7 @@ def __init__( self.menu_actions = frozenset( menu_actions if menu_actions is not None - else {"status", "backup_now", "retention_now", "open_ui", "quit"} + else {"backup_now", "retention_now", "quit"} ) self.current_status = TrayStatus.IDLE self.status_info = TrayStatusInfo( @@ -220,6 +271,9 @@ def update_status_info(self, status_info: TrayStatusInfo): try: self._tray_impl.update_icon(status_info.status) self._tray_impl.update_tooltip(self._format_tooltip(status_info)) + update_rows = getattr(self._tray_impl, "update_status_rows", None) + if update_rows is not None: + update_rows(status_info) except Exception as e: logger.error(f"Failed to update system tray info: {e}") @@ -235,15 +289,15 @@ def _format_tooltip(self, status_info: TrayStatusInfo) -> str: """ lines = [f"{self.app_name} - {status_info.status.value.title()}"] - if status_info.last_backup_time: - time_str = status_info.last_backup_time.strftime("%Y-%m-%d %H:%M") - lines.append(f"Last backup: {time_str}") - - if status_info.last_backup_status: - lines.append(f"Status: {status_info.last_backup_status}") + if status_info.last_successful_backup_time: + time_str = _format_local_time( + status_info.last_successful_backup_time, + missing="Never", + ) + lines.append(f"Last successful backup: {time_str}") - if status_info.repository_count > 0: - lines.append(f"Repositories: {status_info.repository_count}") + if status_info.latest_backup_status: + lines.append(f"Latest backup: {status_info.latest_backup_status}") if status_info.active_operations > 0: lines.append(f"Active operations: {status_info.active_operations}") @@ -262,17 +316,14 @@ def set_on_click_callback(self, callback: Callable): self._tray_impl.set_on_click(callback) def update_last_backup_time(self, backup_time: datetime | None) -> None: - """Update the platform-specific last-backup presentation when supported.""" + """Compatibility helper for callers not yet using complete status info.""" if not self.is_available(): return - update_last_backup = getattr( - self._tray_impl, - "update_last_backup_time", - None, - ) - if update_last_backup is not None: - update_last_backup(backup_time) + self.status_info.last_successful_backup_time = backup_time + update_rows = getattr(self._tray_impl, "update_status_rows", None) + if update_rows is not None: + update_rows(self.status_info) def set_on_menu_action_callback(self, callback: Callable[[str], None]): """ @@ -332,7 +383,7 @@ def __init__( self.menu_actions = ( menu_actions if menu_actions is not None - else frozenset({"status", "backup_now", "retention_now", "open_ui", "quit"}) + else frozenset({"backup_now", "retention_now", "quit"}) ) self._icon = None self._menu = None @@ -351,11 +402,7 @@ def _initialize_tray(self): self._use_gtk = True self._indicator = self._indicator_module.Indicator.new( self.app_name, - ( - str(PACKAGED_TRAY_ICON_PATH) - if PACKAGED_TRAY_ICON_PATH.is_file() - else "dialog-information" - ), + _linux_tray_icon_path(TrayStatus.IDLE), self._indicator_module.IndicatorCategory.APPLICATION_STATUS, ) self._indicator.set_status(self._indicator_module.IndicatorStatus.ACTIVE) @@ -377,25 +424,17 @@ def _create_gtk_menu(self): Gtk = self._gtk self._menu = Gtk.Menu() - # Open item - open_item = Gtk.MenuItem(label="Open TimeLocker") - open_item.connect("activate", self._on_open_clicked) - self._menu.append(open_item) - - # Separator - self._menu.append(Gtk.SeparatorMenuItem()) - # AppIndicator tooltips are not consistently available on Linux. - self._last_backup_item = Gtk.MenuItem(label="Last backup: Unknown") - self._last_backup_item.set_sensitive(False) - self._menu.append(self._last_backup_item) - - # Status item - status_item = Gtk.MenuItem(label="View Status") - status_item.connect( - "activate", lambda x: self._trigger_menu_action("status") + self._status_items = [] + initial_status = TrayStatusInfo( + status=TrayStatus.IDLE, + tooltip="TimeLocker - Connecting", ) - self._menu.append(status_item) + for label in _status_menu_labels(initial_status): + item = Gtk.MenuItem(label=label) + item.set_sensitive(False) + self._menu.append(item) + self._status_items.append(item) # Backup now item backup_item = Gtk.MenuItem(label="Backup Now") @@ -442,13 +481,7 @@ def update_icon(self, status: TrayStatus): return try: - self._indicator.set_icon( - ( - str(PACKAGED_TRAY_ICON_PATH) - if PACKAGED_TRAY_ICON_PATH.is_file() - else "dialog-information" - ) - ) + self._indicator.set_icon(_linux_tray_icon_path(status)) except Exception as e: logger.error(f"Failed to update icon: {e}") @@ -458,20 +491,20 @@ def update_tooltip(self, tooltip: str): # Tooltip is shown through the menu pass - def update_last_backup_time(self, backup_time: datetime | None) -> None: - """Show the latest backup start time in the Linux tray menu.""" - if not hasattr(self, "_last_backup_item"): + def update_status_rows(self, status_info: TrayStatusInfo) -> None: + """Refresh non-actionable Linux menu rows from one coherent snapshot.""" + if not hasattr(self, "_status_items"): return - label = "Last backup: Unknown" - if backup_time is not None: - local_time = backup_time.astimezone() if backup_time.tzinfo else backup_time - label = f"Last backup: {local_time.strftime('%Y-%m-%d %H:%M %Z')}".rstrip() - try: - self._last_backup_item.set_label(label) + for item, label in zip( + self._status_items, + _status_menu_labels(status_info), + strict=True, + ): + item.set_label(label) except Exception as e: - logger.error(f"Failed to update last backup time: {e}") + logger.error(f"Failed to update tray status rows: {e}") def set_on_click(self, callback: Callable): """Set click callback""" @@ -520,7 +553,7 @@ def __init__( self.menu_actions = ( menu_actions if menu_actions is not None - else frozenset({"status", "backup_now", "retention_now", "open_ui", "quit"}) + else frozenset({"backup_now", "retention_now", "quit"}) ) self._app = None self._on_click_callback = None @@ -547,14 +580,17 @@ def _create_menu(self): try: import rumps - # Create menu items + self._status_items = [ + rumps.MenuItem(label) + for label in _status_menu_labels( + TrayStatusInfo( + status=TrayStatus.IDLE, + tooltip="TimeLocker - Connecting", + ) + ) + ] menu = [ - rumps.MenuItem("Open TimeLocker", callback=self._on_open_clicked), - None, # Separator - rumps.MenuItem( - "View Status", - callback=lambda _: self._trigger_menu_action("status"), - ), + *self._status_items, rumps.MenuItem( "Backup Now", callback=lambda _: self._trigger_menu_action("backup_now"), @@ -579,6 +615,17 @@ def _create_menu(self): except Exception as e: logger.error(f"Failed to create macOS menu: {e}") + def update_status_rows(self, status_info: TrayStatusInfo) -> None: + """Refresh non-actionable macOS menu rows.""" + if not hasattr(self, "_status_items"): + return + for item, label in zip( + self._status_items, + _status_menu_labels(status_info), + strict=True, + ): + item.title = label + def _on_open_clicked(self, sender): """Handle open menu item click""" if self._on_click_callback: @@ -658,7 +705,7 @@ def __init__( self.menu_actions = ( menu_actions if menu_actions is not None - else frozenset({"status", "backup_now", "retention_now", "open_ui", "quit"}) + else frozenset({"backup_now", "retention_now", "quit"}) ) self._icon = None self._on_click_callback = None @@ -706,11 +753,21 @@ def _create_menu(self): import pystray from pystray import MenuItem as Item + status_info = getattr( + self, + "_status_info", + TrayStatusInfo( + status=TrayStatus.IDLE, + tooltip="TimeLocker - Connecting", + ), + ) items = [ - Item("Open TimeLocker", self._on_open_clicked), - Item("View Status", lambda: self._trigger_menu_action("status")), - Item("Backup Now", lambda: self._trigger_menu_action("backup_now")), + Item(label, None, enabled=False) + for label in _status_menu_labels(status_info) ] + items.append( + Item("Backup Now", lambda: self._trigger_menu_action("backup_now")) + ) if "retention_now" in self.menu_actions: items.append( Item( @@ -724,6 +781,12 @@ def _create_menu(self): logger.error(f"Failed to create Windows menu: {e}") return None + def update_status_rows(self, status_info: TrayStatusInfo) -> None: + """Refresh non-actionable Windows menu rows.""" + self._status_info = status_info + if self._icon is not None: + self._icon.menu = self._create_menu() + def _on_open_clicked(self, icon, item): """Handle open menu item click""" if self._on_click_callback: diff --git a/src/TimeLocker/system_control/__init__.py b/src/TimeLocker/system_control/__init__.py index 100794d..dc9fe9d 100644 --- a/src/TimeLocker/system_control/__init__.py +++ b/src/TimeLocker/system_control/__init__.py @@ -6,6 +6,11 @@ LocalControlTransport, PeerIdentity, PeerIdentityProvider, + StatusEventBroker, + StatusEventClient, + StatusEventTransport, + StatusSnapshotProvider, + StatusSubscription, SystemControlClient, ) from .dispatcher import AuditEvent, AuditSink, LocalControlDispatcher @@ -19,6 +24,10 @@ SystemControlClientError, UnixSocketSystemControlClient, ) +from .event_client import ( + StatusEventAccessDenied, + UnixSocketStatusEventClient, +) from .models import ( ActionReceipt, BackupActionRequest, @@ -31,7 +40,12 @@ RunQuery, RunRecord, RunRecordView, + StatusEvent, + StatusRevision, + StatusSnapshot, RunTransition, + STATUS_EVENT_PROTOCOL_VERSION, + STATUS_EVENT_SCHEMA_VERSION, SystemPolicy, ) from .protocol import RequestEnvelope, ResponseEnvelope, project_response @@ -55,6 +69,15 @@ RepositoryMutationLock, reconcile_abandoned_runs, ) +from .status_events import ( + BoundedStatusEventBroker, + BoundedStatusSubscription, + ProtectedStateChangeMonitor, + ProtectedStateWatcher, + StatusChangeCoordinator, + StatusSubscriptionLimitError, + StatusWatchSignal, +) from .types import ( DiagnosticCode, DiagnosticComponent, @@ -65,6 +88,8 @@ ResponseStatus, ResultCode, RunState, + BackendStatus, + StatusEventKind, SystemAction, ) @@ -74,8 +99,11 @@ "ActionRoute", "AuditEvent", "AuditSink", + "BackendStatus", "AtomicRecordStore", "BackupActionRequest", + "BoundedStatusEventBroker", + "BoundedStatusSubscription", "ControlRequestHandler", "DiagnosticCode", "DiagnosticComponent", @@ -93,6 +121,8 @@ "PeerIdentity", "PeerIdentityProvider", "ProtocolErrorCode", + "ProtectedStateChangeMonitor", + "ProtectedStateWatcher", "RecordCorruptionError", "RecordNotFoundError", "RecordStoreError", @@ -117,11 +147,27 @@ "RunRecordView", "RunTransition", "RunState", + "StatusEvent", + "StatusEventAccessDenied", + "StatusEventBroker", + "StatusEventClient", + "StatusEventKind", + "StatusEventTransport", + "StatusRevision", + "StatusSnapshot", + "StatusSnapshotProvider", + "StatusSubscription", + "StatusSubscriptionLimitError", + "StatusChangeCoordinator", + "StatusWatchSignal", + "STATUS_EVENT_PROTOCOL_VERSION", + "STATUS_EVENT_SCHEMA_VERSION", "SystemAction", "SystemControlClient", "SystemControlClientError", "SystemPolicy", "UnixSocketSystemControlClient", + "UnixSocketStatusEventClient", "UnknownPublicActionError", "classify_public_action", "project_response", diff --git a/src/TimeLocker/system_control/action_policy.py b/src/TimeLocker/system_control/action_policy.py index b65398c..c20bc05 100644 --- a/src/TimeLocker/system_control/action_policy.py +++ b/src/TimeLocker/system_control/action_policy.py @@ -154,6 +154,7 @@ def uses_system_backend(self) -> bool: { ("runs", "list"), ("runs", "show"), + ("system", "status"), ("logs", "view", "system"), } ) diff --git a/src/TimeLocker/system_control/assets/timelocker-control.service b/src/TimeLocker/system_control/assets/timelocker-control.service index 983453a..6b805ad 100644 --- a/src/TimeLocker/system_control/assets/timelocker-control.service +++ b/src/TimeLocker/system_control/assets/timelocker-control.service @@ -1,10 +1,11 @@ [Unit] Description=TimeLocker privileged local system-control backend -Requires=timelocker-control.socket +Requires=timelocker-control.socket timelocker-status-events.socket After=local-fs.target [Service] Type=simple +Sockets=timelocker-control.socket timelocker-status-events.socket User=root Group=root UMask=0077 diff --git a/src/TimeLocker/system_control/assets/timelocker-control.socket b/src/TimeLocker/system_control/assets/timelocker-control.socket index 5f1bdb8..5f7b2bd 100644 --- a/src/TimeLocker/system_control/assets/timelocker-control.socket +++ b/src/TimeLocker/system_control/assets/timelocker-control.socket @@ -7,6 +7,7 @@ DirectoryMode=0755 SocketUser=root SocketGroup=timelocker-operators SocketMode=0660 +FileDescriptorName=control RemoveOnStop=yes Service=timelocker-control.service diff --git a/src/TimeLocker/system_control/assets/timelocker-icon-error.png b/src/TimeLocker/system_control/assets/timelocker-icon-error.png new file mode 100644 index 0000000000000000000000000000000000000000..fae80208c3c2cbdf77ee905c82d7fdc6484360df GIT binary patch literal 34066 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 UHbXzs2?ge?j)C^;Q#N=02hLY1Hvj+t literal 0 HcmV?d00001 diff --git a/src/TimeLocker/system_control/assets/timelocker-icon-idle.png b/src/TimeLocker/system_control/assets/timelocker-icon-idle.png new file mode 100644 index 0000000000000000000000000000000000000000..6bf330368a0fbffebb96fcf323498353b119246f GIT binary patch literal 34525 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 G9{fKn`Cxtk literal 0 HcmV?d00001 diff --git a/src/TimeLocker/system_control/assets/timelocker-icon-running.png b/src/TimeLocker/system_control/assets/timelocker-icon-running.png new file mode 100644 index 0000000000000000000000000000000000000000..e288324442ed5dadbdde91ec48afea02cb9a5ba0 GIT binary patch literal 34317 zcmc$_cT`hbv@g6PQbZ9WAWGFl4@3l1n$+Mi6hVqq=?E%aq$4fySO_H`(xe8ZN=JG} zse*v?4$?aqIw9rlaPEEM8}EMa-SPhT#y5r|WSG6yo@=hTX8HZ*3ewh8VPWQC1^|HN zzA8!w08WBmPXbH~;2$)v&}#sA+kYQ*SI>KNZQ^BxnZCc&pDCHf+DfTcm^1%eh27B_ zzHYx7`eFPg(@Cz}(!9Yk+vqnbgP;8U;k@R7*$<3Oj915vVU>^1sVWxf7nNoGmeafY z0l3ZdDs(@wc<6{R3v=%F_*{N*_u{sk+~&5ye17pD-{5BTkh$!9A{{71R{1*-@DBi? zeO#t}9E%8Gj=!Ee{yahZR6PF5^uK)l@5lYWPw~GQN7K&#@zwuj+-2H`|6b&O#~n-m zU#7quPw`*m{9jl7-;et*Q~aOa_kXq2|C`6Lw{n>I@uN!Oz0qt2!#lO%E+i`xV&yTP3-W^b8r|o=$?|wp^ zo$Y~?n_hPtLSD)>;2~;FH`U@`0{dt*%a}i@>|k{D%pID+T3&WMXn^xa_zbw^sCKJI zBtsEMG1xnX-V5&=2HIxt^I@-%#l)Ih1I8pJ1 zkePfiq^SwTuSsrysjdy!b*ei4{Y-YBjsuymSnGbo)LIZaZz9UoSkAS5mzpm`Q-4E> zQG7hNbK-6h4}m%9)@4`I-nP6U4HLh051Hfff|M`iEYi&u8q~v;{pJtj1H80zp-9lG zHH*|q6#x2S0KLjg+ozO+%*kx*!6Hk7WUbw%)Rb%Nzbf>2VQil_<+hipGsot^u)iJI zpJy(<^-dzkojx+}(`nGQIE_IO_7is>HX~VQevd!*3n0faB25i1gR^1+L@1ziE*+GS z&phGxzZB1eiJcw2XLI};MVNxtvMJ<9mKe(ECAiu_$=v*08Jm@SYjq1;CyE_0{sO(3`H#F zavm=i$^HTwqH-bcE7!3j1l3Y`UCF1p?ei)!??2z_vx;hRNewYD%l%ik`JVsJrB^o) z*$2iWlpON=IUI!(fD@os`Hz*~YDQN7+Tp>4NG19=IIl35#c+VK6v7#{f1o*}{jcdb z-pH;9N_1^&lR98!1uZMG?E zx%sTx#T9iU3>!yxTLc-DeOS}eJT$SCh62X`!Bk!ml3VnYfX)GNl9O2eq?@U7|$VPiM)<*?Pf%@LdO2)jOWJZ$nD^r(MzXuh7;tb|Ct<4cpR z!N3^3@QqdL%XTF8V%`s}cq}$NBZ}(tWO`*l`3`KddTIxEZyosq>Kue6JFr1e#O>vf zd~^Xs85|6lumOn@tNw^yZhPH>{h0z~O|#OLHU&&EziiDFXP>928$no=ei&UFAu}~I zmst2s>3CG(2LIkz`K=zxebj@^S$xqI6=JKc#FQA>V`%b;UitUsViifr*)$%)0@q2% zME1}D?ouXYN0z~}P{JM5kzBImm=N-F4abKJQtvPsrV7T2|?O&Z~A4Q=72fQVPA5+8sZsE87%=n0Fb?wACv!>fn-K8unNm2oCL8#j zpbHe+ZQG{6LHw;%p)V2nSgbrTZ!af%BLOzE%i#9Hh_HXFx^B~t zyIbbBv^1AcD%G52ga_yTXX(e#lqEZ3q&)ceZ^#)58D}iTx7{~>2EE8_RI<>X$h{4> za>Gmb-_bv0hu^8l1yX8W-&V5kmZus?Z*4BLwWed^eCPp&_IF*uhs7CRA?5h(rMSj~ zzw%d$f#0veGn};cXXGv8%B65Q5w4eZJ3ToAh?)E#&)J4J^j_Z>?)UE^Mxgm(ycDG* zal0elZPv>z?e#{bA_Sm&WT@dm_fAUV#zy80QpwMCZf_ujN2YpkX=XLH0O>H8qnj44 zjL*&Qf>xKx9?oG89N>4na{)_x)&_VCGCO*^1J)`8p9%6g31Hhc*K+SrL^D5|Qi{!(2Hmmy^GM*VN?~3^jiCEGr1^X?h)V$8{;Gv0{t|gWWLcs9u zepb=8O}U$bLS{`02fHc>ZDG*9XEUe-Oyc_P{ zGw!L}sd%ZoyXfrStx%2AOz?CB>xK^nTM(fs5Q`Gkp}*bJW2#+@Hy z;j)teM%d6-e>YtBa9>3^qgKS#f}J|GvNzNAsGq-?`c4CRG=x1!T0g`aO2bdv3}QdJFsX$M%RVI0TV>h&HR`L?m4o$NuRVne5$#jz_V+i!GVDad zvC-*L5-I@>7CVR&K>IH~ZhW78NnH{wDNPE%tVfD;#`1Vg=#h7Ed8BykaKZ3oP=yfJ zGWODGG;v}k(ACa9f9jw(T&d|)vcCUAl2(^2 z)kMa4vTQons9-&W?x%q6?@w?l1+Ipu^>|weL-QF5m@_( ztF^l)l(7~*HA3#g-bwjYx17#XkF+LaiwaUUg_BAtKj{{C8o!YX-y*Auhw{R(>{Ez} zxcbuNwGmNw1SqJ#XlbDTrvW~by)d4ESa|E~4iiw==<26n@nYv4;&3ce{;8l!O;LaH zj|htUT$KJIf`ZnNOY+@9HdXbT$Hbr za6dAH_kO_Iu0R3H+i6h>vifM>#$>M=8Q9qyUoBt?68=5A{L6v2#tRpqT4QgUsiJGi zT+2AJYoZo%UI3|%TYy4~s@fP9E{?qzeAWFz={#`rkJP;CJ-1lA&V0)dY-SnM?8gU5 z%BHukp$|$j&nTspUlu zW9dQ|RYmhK6nk+H4m4an8q`~-)ISa{EeQP^Pfrz7bv!PKjOrFJlN@dmG*#4HKx{dp1qJU@?Bi3!xnNJRc7 z#kdMsk5p4%-&_ncUAZ3aIj~mO!*mL`_~vv^ka|MiL_C!X+C9JxG;|4LW{MBT>1)l% zUQ)>lM;IQ$!EN7bXy=_pDZjyK2|ouzqh zuwEA*I$(Gt8$l#lXfDU4@7)jcwz=^mI@MaS>y>g*4v;agyvq!~3Z%3cyR}Tk!OZgn z0E}5m-w_hC8tbK-rB&AwDWrelf5ASxyQOf`ZUAb@~qJT zJR(GW2e-{gD8XGG0z7ydWu>Ix^{q*dw9dl)+|M2r%HGebufIFFn)N%DvFXMB8dlI= ztj#o?lj{-$@VB*{+_F&$v)(xg+d%OBJShmIh|VWc{}u%RT_ftgtEr4fPXn_M{G3x8qnRRgE0>h{B|zvJEhwCwZW^qpgI;3+2um@fb0qaJAS=MVd`zW*o* zEV9%P{G&#w8BX6N>YMB@*j9A6R?)WUYW=O=pO9wn+UY>LT^=LXUo*O!?9YI!1^hD za%tg4(ujSD`Iw4EvZ^%MH?`*=M?)y=&;rf`Jb33}vsteNH)f3PO}_pWz~m+VYOS&= zJMn=Ds0porz%LxhmSzc;@>U%EfJxbGHB z*WvWIsz$bGksHSj6n3<4SOp7O0G1|a$Nw>hC<2PFID`5$9pe(N;&N)=G<;5-v znO{9*02s8+Zc>YP1J#3f=h?X)(_;WkuXz1d$zjCI{kM{v`On%#I}LgKb0SUvp3&E1 zi%(ome?kwKplkFF^R2id=>ZQ*wuUORtFASrzcO}R(_)?KcHV4Wyaoml@bvoE-i(v5 zTPvN;rNa}nskP5I%2`Dzc2iDuA9brb1mR$1Qo1OD=SJ`wFAW@aPDHnwSw(?PTx4Fa zO%fQ`=^@>tyZt3BG%L(6i>gxqEt<|9vFMNntClDA=n&9@?KFQ%=p~xA8v?mN7JUKG zuNGhu!I_T@_cG{0*ApN>hV5;gb|GM>`M!hX#rKHq(5T*4Nk`(yE_Zh#IUl!)wDcqt zA1#fX1YxQA=f_?pn3H3;q`*DCPxJQQ08j^LW67?;<#PtJt*hP1yUQ9j3S0E)k|iq= z?JwyY{=$_0=D@_aRr;9Rx2UK*;)3SL)Gm-}X0V;gOu)ygARV|8q*VPp5S5YS>SGkW z)ghTEabyy)#i)|Nh6k$<)1SKg%0hV|Y7?DT`E;vN{?fVbv`EGEv*BlkVe|LC(4rzN-+fOw zJJ3KkX}!(i0|5k;uOt8&iSz*3V>q#9nJ!!=3zLy6#>E6wP_JdDaRAiEasUwERP`6I zBzjMOz~2|LUdRfDkl<+FJTQI(f?0n=o)ymtT-f{dE>OYytZ)kSX)aL6hyeh;-{1e# zl1AvImYF~i5AP$C_BXh-@ViWOf1??IW5KUJb$lM#%Xx+#lqknIdI(tR08%1yfpb7Z z!8O1LoIh&8rujjg|0#@9vc=f3sRTR~d`z#nq2EarzW^vc-cECl((O1r4~!WXrF%5< z)G-0S&FdmXkqb6e;QH>Xxp+uJ6n#EGACH}lUlRhot~bVHtbgiP{>`(1qMh=)Z7KL? z!=_N-Kn}oh_AVRa(#ey+cs(hZcug7s#(Y4q8#G{46Du=fVXt=eb@5)d8NZY;>lC6Z zVC_Xa;e!tIS(;CadiB$;z4dEE_A2fqp#7Qs;ZFr%-MrW{2TZQyx*!f z>WT$)aP=o|TKB97!4|y+e`mV;;#R0&c`b=oIJ$+Hap^Lrp=X6Gp1$OjG1yGzTk*_6 zixU_9o8rCZmX10LlpI*)v@5S08t5X1f;RtYt+heQwaxaBNpiyR`}uT;C8`esFy*Zz)Dj)(?y zb2)Os6E>6BpT{Tr)pj6Gcj6<<`lnb_Q_1J9Wb>+DO2;Uo|CL>z`I7*mc4KgVt=MGZ z$PdIjoWt8Ju2%%rvJ--yscG?;f&K*U{HQoPbQT^am>uit=QP)^?_jD#v{2^+6!|&D z1w?r5r$&6N=K6^PHMg9f>%DAzLe0}ypENt1p)X(ZAeLS9Z;aOmOLu<<)S8*18=vHD zIaeK2O~GJN-Bm9YEp^fH!bLb*EOX^gRNpl8kREzVeEynKlZ{(D)y92##i4z*!qfnG z-%Fo=St3qq&QYvY#x@*R5Qe48*s-*qv(YehB~dT3V3LggNR2s+)kCF|IdP=%MC%(c z&#AW>6}Mn@7b5CoQV&bb3#x8gC{QKg_v(jV48lc2+5$Oy6aOc0VU@x#U3;WqfgC5T zXK{&YAV3^}&WjMevxVIm>`_9Jje{nNDRjm7`0Rsc4_#1UnHtEa15##piFgq7)vkz; z?l`+4!4;^|9$4g!u6<5-_btZ)Zh9EA-jNm0$EuyL7mME zMuQk#p;&MvyPfS?jlg`Oc_N(m0#r0q+2;`|?4SCE8gy6DWgYW#h|i@aIemO40UBW| zXf6a^?5nntg=7HvwcJ8hh5pv)& z4${p-@elo-teQ_vbxPNSw22I8)H;j1Ngr7*Vq2a4hI40?VKJ9@pCcE0!~)>~)w$Q< zzElg^QQJ3N$$H9ICW~u?%`{8TH-3(Imd^8V^M;g0ICfFF$xSyb>oYX3a<;s*bp{Pf zr`maL3rLwMPy@rxdX`N*ubvL%-b*YMfjwhqJ2B_&zyx5icHFYtuECynHH>A+d-8R* zPr$%*rx+v@-`10obBXB7DU85 zPr3iNh5RM4Ca&(vVbe`9nERakRl#k7@mZ~R%a0tSGls!LWNjNQ-P%l7em@;t$?(Ew zYDphgrx3pCa(au2aVIHZpJdTo%9bd;w+gbH?30jHc4*)tW`Nm ze^xzAFGsxrEZ--^a1|{=w$-X0iu+Lt0;EjLwDFZ0B7k7(A7@+=wRg}k*;7AwXSuCj zkTRmy$wLUN?LVOWsv)>#^16JR^*ALv{eZ0e3Kw5J9ZHw{+>v3ZMcxK~F@n!rJ5nYg zXCYmg28zo)nnurGu~2_#mhxwcAg$I_lw!DBC&Xkaz+--m0f^x4EWell@#uo2N^ey& zR~M68`9cb68-Ftag6G;4o1WOkp!>3#Ks}1C_Fh>_CQ}+>FIIO@*wAs0n;ZcuziicF4Z!5ljx_ya}kLOE`i`Ewe7&w6j3@1fE#HnmS_~`YZ;n~AU z`L|TIo%X1ckf}>t+|;F#mz>_l?Vii}we>;R?3$1V0R!(?<+h;8wwSPH?)x5)2F;o_%1jV-bkIvjlRum%Sy3y`Ak6!z=zf5u5-?^>EBiR5W5C*b zy1yeN^q1u$^d2NorKxDX^u`$3f)?p4F{i0QtcM#Q_voZ-IBeRI193#-bjePrxtaLr zneJnoXVLCVafD4p*)MSld>><|(3rI~=wS#~NVEKm`txl$g^dBW^9TtQ*8qPJl5Z9n^J*`#B<9dJH zc4yI@+=|OE8xU~;Fvboz;iII5=s=R{v_;P4-?DixI}~oOx6OwSUXyzt@ivDlG=Jdt zN@=)JN_<-gr>!BX&6ek-go-P8DPBVr1ycNe@PVMF|0#`#QR|M$2v|}A?Cc=Cv{y~K zHhZW8Spvf__Bkxn3-7G#ER^!gywHN{iE?)*lXJ0p_7%m?-IZ6Fj|K6$KvgmS>g&*T z@D^%!e^|S8&@I^_m&vxzNsbwa)@(0HXNpb zd00cU>K}@y06{ZJJNjXRzA#hDl7Vwybi|Bc?X8=}5!+*9+bvU>LKU026+Skfgnjo~ zy8avzdR}K-N~4rdKQ6Z5fOd_Nnd`$byDk*fu~FstA&Lu<70{rUSBnS<#=0<^`0} zI^b6PwO3C5f{wc>&gsp&h7_~6I@h=is?3x;p-cvD+?h?0H}0&s3X`w-?Jz^dfnj0N z7Tn8}3%x<=fyUb{RD=qp6>GZ+g|%{zTF-9wKvAgB#IHU^D8K6QMhS`~jV85ABNiO+ z+q)YoHx)D2T?}yE2^-)SGX@b4Tl1XLK-r53xwX%=CSD~5Aky?%ssfKJKacSZPDzAM z8hNtK&Vkskxm7N)ag>#}Vo|$zzY^>HY})IG6i6*gSb?iq@;yd|+_JWj5i?3YuNe{9 zHD?u3s0UN%-TFl=|Aon)Ub)Ea2xoEQr`5BeulH=dvNx_*z{d$L1t9{~)- zik9!%;&A+C`Z&=HtjaPA-|dqZxzY7u7hjxq6;0pSm=Op-P$!7R&uV)y(aT+ZHppCL zGD#)cH~Qv%&^eKX(^c1B&N|&0B}+TJf(mu; z+VGU9e&)L+dAd@HWMPiGXHQVrDf($GS4K6Yb?VnG%1YYrP(LcD#5* zBUZV|dFOtl^QIm_1TFxigfG@s0T-7-e!InlmAHDCqvZZ}zWI{TARsjwceBY+Bx{{Cud2mde|EWq0p)M1rhE)Rx71z)Cir8^oJ{9Sd9xtklZ^eN~50 zE!>B%IeRl}(!GzUy8KhYo)A!L;1|%x$nTQ!ZA@Y?V5vbqp|%7@T>ODXHI>3|ZCyzP zMe1Ix2Sw5YBQiP(UjIPr@=43DPQlZ~u$izryBA?2wdUJjcWwAcFH+05L#UpaRX2!h zrN!)rc}0Iz5^%pU7jV)xwc2!G-XIINDo4nF-A*(Bj2;tqCk3Yd zgMTMfV(+AN$5W<2PLXQx2KK^oQKKqFS@%2!A|_r&x`gOORx&7y!t%p-VpZT4lM}W3 zco0}-i}ZYbiF>L%ZuTvhAm9>PT!X)vnJ3P&tQXDWx`JmlJs8gZIyD$9W?Xycniiok z#%U+i?q#3X5MFv!Gz0&2fsMKDNAboQdEc(g1RsLU+%p-=X><0tamKsHmR?c5q_t%; z7<) zN%8`P-@E^At+}&{nu`wV!D=J^SB}o?dm0yImWlv=oi1ep0TqB@t|b~)x)6%~ONYl| z8($>}vxso5l1pE4OE66QL$14sFN*FV+O<3D`A8K=$VNSzlUl^as#w%3Bq7DCYu%lMGJ^z*!A<2ZXRQc6 z-A0Qg4-DihVa+JSO*3<7Zqc+KyRzGNA+K_>P1#XqwP8{JVeK_oSbq0BqSoJidKYc& z4h1XzlYrvXMJ^LcD9<4>lV~jULk5wlB^8gWpP*&07qLODpB76Jd^fkZqGm;5GX#&* zkSd{Y-+Nt;T_pg?#N9S3XT+32B)(WA^B`dviGS&H<8ZI(H}07lcbDAnEZektEKj{z zF~3*i5^B+fkdZJb>+m1%MUUswxTFDfD(|k*-*vZCh>PZF^+Hd@>dh1~z%)&As4PHg z{vbq^kS6mMFgS5Whw0|(Yv>orO?@z)q(^D-npF^s>26^%(_32PpNY01>V;v+28|Zp zz9{RirO)_)&XHh15x5Li*t5zKWv)I#P6t+Vqy&irX)on4R*#&8<-;xOfM@RTUb{?* zc#D^#Bz8iq#HCqm8@kWPg*1lSz_7pf?IJ!3J0QoCzf#qt@!YkfxCp7-FBYmVyFRV& zCOoH*v7|wELPb1ZQn`|TCoe6GXzHB=cFzEkE$rwIV6pvx=y46~VKAdOOK)`$17H^p z=_dMRg)FLmFDmcd-FONTF?`ZwebkibxAl^2(i>bXFO@A-28hX6pmE1Cuz<{r`ajjs zqLPsit+QZX5Bi80n@Ap>-W_O(O;`5uo%4Qx3vVAwjaUeyF{_%|CqWnr0{qe%DgzVQ z`n6g73pW%+yL=@nS=6L(5c_<-qWcosT>Nu^)O5SFLa&)LbwJroF>x>I6N_)YJQB7r zK#HB@WkC7ad-r7X?`q`G*aGhAMl_fW@Ib?}4lho8aN|)RN9j+wTfxB8nhC(v^>CT& zePv&s`i%4-F;cAWpk!Rm$)uNlQJs!-%%1X86YhoA7s7?Y` zPhg0QKmyDPzRetS=ef+m6{?l`4}$HL1%AEubd_HD}0AC%=+Sq|MHg%hkO!5dm&9 zy@x{=o4*xNHd#kSt0t%qpy9kf*(Ckt7gxkU4z#LSo{j>l)S*w+cXVOc?ehSyy+PhG zPOo!GANNUlXD^G(PH_HXGORNT#wy{u_Ap&M3Z|t66?1LUMvcN%_%xxZ^~CySKIknt zY?Q8lRceoGAmq5WE9n&zz#sq&^LSb8fQK7B^>HJBNUevufE{ucyqq2Twk(UM;jP=m z8+4B*K_mBr-+3hI{|CxwpuZgwVZEn_FNaQbLsxa1c6@`ks3W3~AFQpKmDZM@S-#*UJqsXu+s!a&qX zgU)|aS?&rf*`=H6`$ryvnL%&mK3A?$C4>W(H?NriJLI=(ZR+nfN79W5-36R%ocu+V z)hhcAnm!!C$ey&8GclV#WqeDQJ{$;@D^-PV>3Nql-_d$y^Ho>8vp>GGQ z4zv&fqA(wy*SvosmG!#$%}IUuC9n;1m=0K)Iz0^L^RUc-m;2~@D4C36R|@)K<%z#B z>Wk>BDzgXJ;bBIvm3oaIWFKwZ4b##aDwI@g)(*p%h{aHrv1nYGZRakL*5~S<=Wnw* zAl>}R|0ZO6u3mA1qt*Q4ms=O0UhDxRp3%hOwRC027aA5NL^h~hM6zWU6-VP1eg`k<~3*KGtK6g0==kGsNh(iEsKn<^wxl)i0nnBrj^g(`%#p?l04%N zhX8IfcQPZq`Mmr$g9}uo>#a)aUHh~~TF0HT^JU;S<*Nul zeC`UKtxwpUvhN9%A~60>2b+-z)7M(fhB^r(?14{ZB+=bi#>KABmZdC0*P#9ytgDAj zPL)69`k~mtanLR71=PW@gwVG{$Q@N@A z?}USch0(!H?k}&9I@?R@IaE;JC3U|8T3hyA2mA_v!hqc;-IG6;}jd_)sJ4IoyRURX{o!Ks` z?bD~>uh&=1GiCf*zH>YGaDeE&pd{oZaq%CICF%9+TNdoblKJ7vK6Z<}*7de&_RteM zI&jKu=5Zl`MQ>l9u3l&=3f1&y22Xi17c~GP5)QB#P5Ij|>Uta&8jo`_4|=e!vrn%b zQb50lofRoQaORn)oU0JMm`?my;ZBi&y4-!G;fwMuA9TQ-+pl7n?@`L>0-IIes1})` z&tRkKCLI=}k6W6LTatI<7ZWQ=;^33n2R5;MUiu!!Q5DRWn=R6;MTa|?T%1!w3aVY) zr?U?ci)`@ku77-vjLUNlRKO#qF;RSUXdNPS4lb-i8X9VPDgfeMW;&*gu4@yc!)CT` zyf1@|isr^F9?2hcDPFY?MZuoGHi9A8Qztq~?$<{Sfz0bL@wDm|1^G*0D2FXpQaAYg z6xm`TZiJi$%|3b~OVt5_`Dr>eXzj6M)U#}tEyIkAv zzq5qMiD_DWZl0-r4k&b*bcqK;!s$-Xn6NzILiv>TUv^%IeAO@e|mPyNU_-UAZ{VdHxB=$5r)aBm; zUG7coc!z{-_{^z!jox15f>2o90250L*wKj($BJ@#%hfe473L{(<{!2!l>{PXGrb*9 zj)QRBl`+sVR5QZC{AA&4I;pv)+21yNf*@=Dbc!<6}nitoSu2hO>E$)4+ zZZjuzVVEP)$*osEX&Jg{?zQ)dQ_^*F7rHzTo$featr~m~Fh@dz5^u+06OZ=1cogak zi{oNYdW}$MZBUEJ78u?hzgcZ&#>iAWCWn~Pnb>{NqwV|{?Q#u!q^<3U=9IIPO&S?0vJfl`x$@^cGvKj@uA z*EeUV{{)uD6Asy5J`Q%*0sob7r!rIqfMt&5qUQCmnkN+;g#PdG`lVcX^F z{{8*s)EM4j`-~3QOws-!t51Ub6Lvs5tvcYNRe}Ewopze1^NbXjT|DkIE?fR!9boCs zVB*zG3ITDug5b8m3*sE*(94nb?gS4h<)%2O%R-CTGqUING()AP4wzNOdFfRE1{ug-r8a_Zcvx^S1y2X<<%v5q0hO2ObEkGNf8uk&Ximq)%*1y z)aw_=Uq@HZCIAM|kUNP_%&QQ%fv% z$;^|qH8@UiR+d&zP3Y8Id6MAtIwoz$QP~EPjEKN12Xg9Q)MU_(zO2A<&1V6h> z)5?~-4P=FX(-x4%o`qrTj(Sm!HA}T6DVL zk2<%FEfnUe!3GJXM5zc1N4<~C`|R}vL2hU7c#vBL-E`Z#gZ1QvTzw2sYlf-#sw*&& z5FSp0#-!6Ww0*L*KFWn}Ps5yiqEw-nMt{T#r>~xw@ju6c%Wp;kqrwYLQ1hC|u z^Rq?pgUwU5vZ6*hm)QCKXv>31B-mBg#ZKsfarL)}UI4U9=qsh1ZPL6h!lZDCK%ToUqfDI}&twUW5=Ssl#b2C(x7UJYJE<8{~CDbb`gKOprgsp3qoiz)`&?X(sON8nr&ZSY^=y z$7Ir*FubkX`Du*m9DK)ng)QjLPwX!*P2=Ln#!Z~F8|aO?14?7Sk)&jKGDk#m9M?;q zE<3ZX<@_k{?%5WvbZ?cinwNf$$}gAfIU!ERP%J-3PfS!E*hElw zzz^ba9xAJ(sG0D;W~Zd`zMe_}Ft?rfTwPB*)%aT{t>Q?w=h}fmo#Qv-)b9rT^%41q zGz8-V*_nS@xuL&5l`Mpb+A8_U>hPpX4bK#PJ`H?yY_#T-tBSQQI6wLUZefQSZn0ii z!Fn5=a$4T=2?RSLLYe<4_D;|GmL@E}My$VaFc=`bFM3CF>sui)OEJZelzr;R0@%R9 z0YGu%lCTCkJCHRj8wlN;Ym>cJ_|wS(k~Qy=dfML7$|?BDy;JOZuADpc7;_=?4{V`E z>x1W}eQLEj@qOa32LFvBx0o(R$@tCt5t_Z+jzU5J@X=5DtZ>>j2S1f0Cj~!4KDT?Q zah(>F@cn~V7WSR;n{5WEyAx(QBtvjtwl4P4OB5cx2-c7Xy~`Eey|)Gqg30D66CH0V ze%-?Z_4Ae?U2p}S=5|JGYmwbW8+09V)({699cxY$f(;JhixZ!Osa>JhOg1d%33eb+r`3Bh2u#4gdTz5eFOIcb>GAB5dxe{tFO5glDx^bploZFx@c@eg$MmloQK zVl;A?<8IEiDZl2GdE*Mta(Vv2#go9=qvfRSb$Xr_5MR6u4#}!_qz7(F z_|Yo@il3^u6B8As=hdnovnhTQ2WL|vs*nZgru=6l+Y zX)(;PR}-=qSUO4t!I1~JY@uJYyKwkF%)suQuv(gA7~kc?Gnci`O|iVYKPLn<=$Qr7 z^a=oAwH8-#RR~z31{v>h0G7eSeJY?7%91kD5Vm&~S^(IQiv+Wvf8|U}&Ev!%dj365 zyM7Gk1XZhz+9iH411k-nd7K1A-L+v;v<%Kykc6;<5=^N|LeehiN`Mx64tOD2o8f*| zP+4SN3^VcTg1-=mSgic|d>;Oo!&fx{ud zzCkJIW9|;6J-;$^wE y}c2buB;%fa(umO9$BK6`R}*oB)C3pu1ZfwDpXkk^3Bg z6bIm4=yOykTJqVYqcI_GAyW6FSi{vz8Y&_Q98y!nV!wg=yc3YM+sCWEnbJ&7Ocx7` z#~JBAmxHOu!IXZsBlfhQ#i_w$crzsLleaf2CviX8ttLfYG9bFgnlbEC#EGxDbjGzM z*Y`#R%3E8UgR$K3t@y;g_Ff18n<(bpNmA7G}O%3r`iaov7;jd7rf|6@w zDkcl4xa5scH_*L(y1`hUh}jKRb3GR?(jXuAR1|zLja@2(C&!I+{i-Uiq-reTzz%em zy9}J(;Ff}kWR|&@k{%H`P<0?nw zTyzQZEo*9%HpUs0EQX<_bj(K`ODIl#*IG%6u@EpKXMKq%I?cMWu;E~gFptz-yD}LF ziJZ9)GQR~Y!iP^UzBk5?yD#6O=D5-Jq`%?U@RkiKOxLwtG<=T8Ixcjkt)7|ASl`cY z_Gv2>wb+2^INpd~7H&uPy6vR)Cw=d><8Ir8 z{%x@&I=ZZ_ONtQSZE_+Tg6rM+iXk~lo4Tj7mlXA(Z-M{Rd$)kJa^fqeUs&woB73*9 zgs)9$YfC){9F2bA!e3wjcBu4xnD~#9`!%g)EivfAfidrU;H3+m!w4Vx;ct~QZf@;1 zCGoWbc`s-ARWp;GQJCm1m)D%Q45U1`ry(G4ro#Pzp4B-VC%T&6vadTg1QIVk^dFz( zMa}MZg3ST~S7@jBi_W34z?rXmHQV?p`@K3o=(gF=t0ZBinySP3dlVxk+CjB6)cCHO z_NlT%E6CJe1jhGC1p^j#hwv*ve_~)Lc#xRm{PMPjUJ3Ds=f|ErbbzNrD}nNdW*1VgS5G7bu-6nvPXjWl;pN9>DUK9`7(fry;+EFb`8g+}UPYZU}N69HU1OCtx-n_udr)6569UL{LshKs80s)LT#C$jk zR14iwfOgk}tm37)CD-*|4BXaWA5C+2pt_o4&+t|wKLe344gI%Eb__P4$Ppg2*o zfa6bqn|~6()aCKQ0?KR4cJI?P3f_N=I9qMI88yqld-C#0AYvR7HY=x6TCQLU=oF_COQJ!~ ztr+2Wf{AQF%2drWSjEGvv}z&!cTRdgJ!-$F5PkOOZ*^yy=Vs9H?fM!Cn_-fNoKa-@ zQ=Ru|jC1vrAoPdz=JU?o=B{Jac&`4{QSRu}9TtYXPq_)Rv=tAZjAFX@&<__MQn( z+JDowT(EocCRQgnHWR#X)5Auac(kL(&lmNPbmF!BGq>`=qAG}59y@Ix72`m^Pt=rI zOYL)=O9jXM8v2e!_MZj^l_{BkEOv%;P3uEwQFSuFF#br!b>zkbK>wB21B6o@3;C*kSX^k2g z{ec_-5!q>O<Q2@ihph0wLBorD>1fIbMx$R+Yy@i&E?k=m z`)G|u#7rJ|f#eFblVU+F!zo z*EVGM7mb8udWa-ZH~cpKNsjo3xSXF@U&4`>snQgC+Ho?ub0fn#^n8@K9pU{$P>t6G z1a?C7=6hqP&RBNmb%n(Q(NXt956Mw7 zD-8}nQv&b*4e>nZV}ruy$05B5TOf#P^ThI|hXEgT9$Qh9@>i*`bnW*x&?JXv*1rlR zy;gYddP6Wv7`bjPL#+>GK2w&s9mwp^n-j_$<@)Lm%~=9thA;SizdeWA<$m<_2}XBR zrli+5L@jync|Hz)q^}*BEJvfxl^N8s*lTI7PIbfv_~hNGQTsWDDwW+*&&6@sg7Eoy zn7ihEr)(HT&s_krlWHu9p+f^SL2NHZA<)&#GHlc}uM491Is3r!T37fSXC6L-VCBD(}Ua8fN zLtscGaW?Y%O>;U}qHh-s4-^cj7y9l?QDB^6^ZX-xO^BgEx{8@WBy7k!DBPn!6ojRJ zG2HiQ<1j*3jHKHlmei;?LI|5npP338q~Ew`0|@X>0)`54_x$Aa-(S)rcb#}o!5&0` zrh+IaC5VerfoMYXd_bLCyKMo{@;W+5Vrt7{?opJJ0ninD#ZIv{xQAIFlk#1x9>WjcCdPw4&x|zEzI%jz zCy{a8oNha0&!R@sMI-%y^G1Gx4u?$hi$c7fIcqD&-sosV82W(*Pp`X0u&6;7eAk5n zk&e@iW=Cln6yRYtWT5Z)+px3LNL0oID6}NT+8T7bU^+8LmQ)7Ji*FxC5?do%tPu=> zCr-DMiaBxCq!1Xw`ghL~uW3r9#V9eYXDPsh@7dA(QjT99>d67qAI{uK>EmV=;e6&s zIse4|f!|rUG4NeWc_=hCU1^cr>ba8LKlbHKp(Yl)u@1qy(sC7BK1CkN&l}7dTYjuO zceVRxA2QEi_=Eb*qh$HKf*U@{w3Y)5WI0W~E?zF>+%|3H3(s^yY8W;WUby&v_?o}+ z1HE`J2K(KM>C2irJ8a8$cTS@Ta%rkTK^`BAah%@n%1rNTwfn3SLX0ztO(O=PdcmXH zrJ0J51zc!2x9r>XAWuC}-9-cRUIOfZ@Q3*XpKpROO@5}LDav8QKW6J`e7w(IeCEuI z?7s0+sc|MHcWGqFNfPHqhq(NZA&^RDrLyRxZ)Ol#)COE)z=N7n`$7=_98` z>GV}pkV|IRpo^G|G|+@3TRJy#!*t5{u7OevR98B^!%g_zaXZ-Tvxm>@dXY&3c18yGvtHuLe;P)2Qlf^*lte(?u8Qe6!qzt-;iNI6KEH#oOZBYKv9l$|Di zpn-!;oRiygy}H{^PVUahez_nttUgo*sD*3jO=W5{ECbTTg1PeVF(&F%kUQ5)GkZ35 zR80eI9>7;k!F9u-ODCH0(O1j3iybcI5C~VrfpgL{dlCVB(bjLiF(NzA2 z4SQ{i5_(VNEahdL8Lq=FeD@~TE>E}X`JCbLs>#8MokD=AXZ`99(W4RnH8N68+qPKe z--OGrf@OWUlxp!oaaAGYv^|6aj>1WaU_?NFTH#wXaW z$;3T3;6B~>CA<2G{^G<}7n*-i`BQqdKnY+*0#92^k%ND@XG%O#=*wtm8D5qZ_iCDQ zjS)c&V2~bH!rVX+))m=FieFa?F0YY)6WvwnB>>YLl;&!dBb0ujyVMy6+aUVTgJx3qQTh&&5D`qa}kp6Z~ea|95&r|m0^fViR z_Fq7t*i*fkegDOx)79z=ll7t=ID5wLHQKgA{=owZY@=9V9U7a+sOZecRn$A{b1%?KAhD7Z#EVQyCKRp{ z6$A$Zy$6+)o+^K_47&rIN(zb(j@1TL8?QXEI=D={k|u-|<=B34f$=6Yjmf4l19& z_ocYp%UR8cGKsYHc}ou!|A5l*^lqL5{~@W&6_=r2tb|0OGCBW_eYp^}j^VylHeJ^_ zb$0JsZZ(j}o0rU;r{8gJuuyIc8Ep3|TBwqWnGtqxxO|Or7wU=QjGYaoDMeqW42TsA zpn$9$`Bs?-xRs(DB;X}lI2NuK@TCz0_>%i$^>+g{La3Djuk=;_y}%(A$K7z@3-;21 z>V}e!uGS4g%rO4qoDT0P^sFKsmu@YUv15Ni5i%pWkuNA*D>Zqme$@>#uW59c0TEvH zpBbhackNZ*T&iABmfn5rF~VBqA*Q&?qTc;?mK;|${vFDia$oh;N%pjFoo*qBSQ%<; zsP$@&B{(I+>D_%}9eg_su7=h@7ypVUg>jl=WeU=ZOSr);>5Z!vv#Q+k&Jzn3vfoF8 z@O&5T_QT38(_R-0uahVG+WfqSJw28eMhS9*35AkHV)de<{SKEI62=nTW8?%#hz*25 z3g>v1`Xm*9$*-zZX2+5bVskgsH+rLPI9rcwGjY}^EyM|VHPiM)upw^$%w$#w|9-=g zm?bYdYSErqs9W=_VIuM<`Ha4jmC8cDg}+OY94gRCEtwF4x5X&LXyq3Tu6GPqGvEgE zAH3d7f>4o}Yu?)<>X{AG3f-~H1^snZs4Rj!^Fq;ZvX-nY(_^CMPkXb%g6>%N@liG* z-~J0LZxq^Ju~4kNXGMK|kY9cbav&eg)^40ny^#Hcct5zw{aF|}gZ=Z5i zK2(OWg5g}vA{xG>7qt*Xko7S772pa{H4W9vAB$vSpu?iBD;R!+RI?AR9NLP2sfU~# zudeH$%C53{6KK1XrvADgqn2*}(4*v%ea*6`)L}?34iYMl!lNHNt||lmdmJtdw-z0e zI?33|g0y}c#k!(w3AaaQv#J$n4sPO~nto9IedKxfwSE-8NQdNG+LS}v@#SCp?r#}}pem-YEAxp43`gr`z$7KXmMat5 zw?9R99TY0#m`Nlt5>z}%Mrl25&uFB_cy$l^3Ab{0q1ZpZ><~gOO(VNC+5Puo0x$g8 zA;(0wXQCrm4FBC=>WtLoY->?u5+`dq`VavvH*vVoF@#(IL~zON%mn4E=tgS+LeNhO z0G%8|%1Yoe!RYa6`|N2jEPV}7Cqb)X2A27o`$BKLoWR{OhQP2&5SnbEbkMg6C$_y{ zUT+tZnbz|ZLZ==8W_-R$n+uQROr=}^C&2BK|D|1Cm97ZrM`v)c3+T)uWgImWYEe%L zN2>xUsy%cSNpA|xu|>}t#dw*HU&ZJp3D*3eBDtLfkU2IMZbxxq^JOQB=ua?-R~iu8 zgomcAJQ%G7V+3^tsWtFI7Hj{_d-vhFuv)zr>@SiiBdM~t69~b|P{d69d4RH7&n;Fv z+#0%b9%eZJ8W2>@H`VMDCwhZfdn9drfR&A$Fxlh&GdNVmZ1muPJ^JxGD^DvP{gTZ6 z^^4W+cM&>F{p6Xk*8$Pl@)9RGR1SIe%BSdFbWX~b79fhOZIO3DN-Xo8Wcfh><$!H&NrJAFwMUfj+f{)FeiF+ zZFh`tlXFttcgga)?)XHZ z&XHrl2fMj6(vRR;+s!(74iTplAPvv%n_nW1_RNM1ooxCct>g~y3Le3i_2i|vl8k)R z<(Fgg@1%RtiC^TROyyQ~R9)$*54n-unk)VC?_ul7w*2;)+qvA!PvtotaMAZmBxgf^ z@!-R`siw21ZJ^Dp&;~u8XM-Lgk+he&8p4U$Q^d_+M5NB5Ge*+i)UKT6%SeE!+J_nK zWaS29ZlBz0-d&8 zua&ChoW_S2s0L&slM%{I&E~pHS3r0$xfTgi4VU%=m-Voad%~k;*_v>0qWpWOO9l)Yc}gOz4Nv9FIFP#txUUV^(ihXJteb_yW!L^ z@dA@f&vQ}B%gTewi-tmOq5E0CAG?78v?RO5uhS6gY%MZ#QLgM{%3arBOcdA-SB6i2 zO;;Z&_+E{_h{{PMXUDZ$)Vwu}+v0foMKOnltE*M&G&H)Rvbtf!UQIg#yN7FJP*{*W z{Z@NKE@dq_>BX$|70bKHNyJBdrZ+Z zM-t`AOE|&9a$?7`Z*450tr0y@0G^J$TW=V=LzCg`F2DMqH6^nKGAsQxKD%h_t< zMds7Ekz@&qC@NW1lzVl#G793dT!+u+%NPdSx|dkl5ih4{t)8q#-OkbBRJa^XmR14r zLy)IIf=;^CF$9NSS=ADxT4}Y**Lh4-)U-E#e(Wdt%`dO}d)SP7Yx$8Xh_AeJEXTI) zT1tQh6nVN8m1zEGEL2&-CD!ET(7Q(?WW9g5!a&Q2%!cULxKDB}8qR8O>U>P>*H>jZ zkEBP~21h7o*_Dr68YqJDEhXk-^hs3wfJ)@L2o=k{&{A;ONIHOROoVP7c~VxjPoW*7^MjxmwP2mubX^V-y3I z!_gn9Pa?LQReiGuj`Fs1bCD?psiEXwS|z&%*tuC^gDvU|^v@kBzya16*}{u7CZyf* zw{But;-1sJF|@@gGX8V0Y+Q)Ec8yvLu#MfH>if}C@G0r_fpSO6kjL$lhg14%W1dZ5 zB`=-y{H0hy#stac*-0&uC9L#LF&zf}(24yM7AGfHKpr+w zMYjdTQ)WgWug!>0>Pw4Fst1QO+*u@i4WtmK5It7T8k~124~9qrvL_mcC`fyYja+wU zQzgXnvc zx7LfUFO)v9#5IgmM@%QE%l0LpWf9bwUmx;{lfy@^&TFK z$`WnXe8FqUdc7ms9nxH53`=oor*j~?C~FVh0SQlHG-P}Z;9TO;lBsgL^?PLYD^10c z$xM6OjNDk*VYN=LM`N+%RzQ=K`|K+{Hpk+~7A7qv@3+n-!9{F&K1IEKUn3b6FPu%D z#sM-)tKsTHxiepX_AAT~8F-zft}lYKttl9(xLSOru3w z*2#3fImh<5cIp3qK@xeZ2`c^2Aj&Or}lCc*Uq*xquv;=cV)Uvux|GR_Va_0rmL%#esZZ?fJ+~9cB(0Kc3{Z&YQ`>7uiFjYV8uar=f zI7Szp{Ci4H^ZelTq!#yai{`0cvF(}pThG7s*yX){7xPh2yZVxL1nW74<+=3cc!kc_ zKf+j(6j~o$o;mc)Uv8~dj;|-Aov+VTskLb%4(!67S9Ci*Tuo>Iuls#82P1g&M1V*A z#x!RAtE54QatKk`F4J-6gQC+$+q0yvJ1x&+aaFtuovDAg6^y1+h`sLfO5LHvUXSsk zWu~HUB|rY4Y;~L_ap%N@p0TcCFPvHb=@L25vE~2X)*x=IT_kh7ugmM}dpQE=GMSBC zC9XPC_3g3M&cBeI>EQGAHx#a))L%1(>BBKi+ z`10|;$4*9%&-Ot|_}J8eMBT|Cw%*5iOG|7}uL*-F*s`X$>P<0Q-d*&F~MuZwhjQiY4W(p*Tja((V z`zD-)Y3mP)oNFUz=@4=1iU8LbAu?Py$U_A0m-3{j&UR_3;=DsUyI%KF&z`xZf>`E= z;TO8@xUPHtUn7@E&rnq;@Bh9ri>gOpB%f%wf1e41vq+aE#9iAKo7&wz^m7-zp|x!3 z#B(LJ*T3Tk-fzuDoA2`1^Gl- z+Ilc1*7&9EiE^0Ru1pg-S>evD-R4{HqY-N#q}xjVo4;3jaEOBlB_n_v(cg z#7=_-63Lvc{d_W$M^W$EaKni!Ny_L-TODfyzBNItUoS+8#_C-2;kgXB*n#N zLx>UgOiMgQ8%`En^gx(S-N>N;jrrBsZJp~uj5(Pv!=Ho~D&qQ{g4%%B~#g`~y(5M77+s<iOD@xJ`mBY+hDCwt1s z12L^qAEH1YFvl;D(7;P>A$r!$cdmEIRgmlC4vn*o4|Sa_e^))>7v~n|y7-(y2I@e< z8hWZm*_O(9utND&^#MAnEA>Rr`{ws|p9yB!58qQybFG3vX!E>_5lDB1F|=p(N%Qcg zh|J0z?1ZASK{H-I86#qMPvZQ~W#gNI$Prx6`dZ>Thb#(}#%M{D^Wj2>DI*0WS1iuD zmZEWq<=hK56e%h;jC2-i`+m(1fmhT+$7q`}QeX?ze=tx;=Nd3Cj%VPkYyk?{fa-=X zWpSe;M9A1t@}^BKXL1>CI4^Z_I`hnIbFARhL7EMjJciv$Vvs;;BdvTyp| z!|h)gj^YMytZlRW|1wQS)xZJ0&pqU|eux;F8fwxO;v)lG;&*jMT;Mzc4CYuoy`G?2 zE{ApA^B$Z0C{h-~jjHMdSM~bPc((1&Z*asqtA#9Ys3$FKXusrMqBMJ;QsxwJZnDKf zuo2u6qcyf(jC!P$@!8%=v9hs*3#fl0w^#1CzS0?7oi;Y2S#v&7Mm1SfaO9@}Od;O2 zPIlI7g@$!&WxHf_4o*DXfdB4tCidfUe84QZkn{)pG0ohH;3ha2Q|;^}wK&pSSi-owNA5wQd2SlF++ce53KijP zSppjy4X_g!%R8;o`{!Rt0NiZuZxAxfxgR0CwctR76M}CN+u!NE2WsdG3cb9Iw*F3t zWBt!BccVCIruCTjEVYo5%L;NBL~nVCaF)avj0V?02)ptQ8;ye-^HV=+Jo>lr_TgB5 zf{E?;1NnJ>MhGvvx-Po=RhRkhoG}$|dUd1b$_>yGk=CO5;1e_$tP$0IdcYF+y_9JY zf`#Zd8`;5t`EEZO#^`VdT+N_?W`E~%7p5Z;Z6Z!|9G1QbS<5Gvn&n{NvcZIl`zgW| zdL#^$0h_8z_jJ&EO#`kTs5@OzSO7&C+VG!}PTXaBqg0YGcH=lU+3P;zUR4Ngi)Zr4x@JxIN6(apmIagg?iWsw)u(@LB-UT4zc z(2L|JiJ}(5UW=~Z3+ZP$ETswXH^NG$56E{9-8@6b46s_~qooS)fbKm#i&v(_{9PXm zv%CKSGe0u{Q#o5&YH~d4LFz{y{0E$Nf_zy0_WuY4ugXFXM&o?;ZIME7d;@`MjcjIh-U7 z0uP+|iS_;b;G?lvXuxyJ8c|FmqDb_alI0Jp#kVBgpbVHXs-|dRs`wuwLD@7p2l0qF z$tC$9BRO`E^11>Al9*LN4%zU#RPgO4kM4h+n>8UkQ)%-iKhrnxA|vz68h%jJ!kQEz zxdo(6c`13j*R(}(u(Pka+qxHVLgwFFioxeJd>lv(W) zAnDv}>b^NOKax&~ zMgpKqMX3kvf$21p&BOyvsCq|-%jfsLL%kNyA9P242%Ihvq$D+ zO4D(tsebVn|Jhp0=@zSi^W8T>x8$ifAm82OLXFS~48nV59~Zf)jGDaq`6_>-F^%1S z=1bOj%lyHqsVuLoN+QQ??6EP-#$n}8Or#?$zE;$ZBNn7pD;O`o3q{Zvbg+R6oI#@P{junXaubl6C}+AJdqzpw2;8ee~Ib+0vk_ zK?^>)0F)hb46bZur?*+F5P0;a^4cf}oYh^22#}U<{#n>+<;~JQRAvPJC?Xq}7Q9uq zf1CdvaiFM9HcN6D9)XH?(N;p*6?{CyY0v4~;`+Y?di!oj4Ge3_bt{$j=T*J%JBo`F zPYJ7D=&i5a(3RmtNKy3{fZ@n7?B(HkW~O2tnPMdrdQVKvVm2uyE0iFS5P$ZOKLp6` zC>}>c%(x&VZg7Hp@)(}&O{FEFKKJ*Dm+VOE*<)6=8j^PocePjo_58beD4v69>@|Xb zQ^>|Wz?4wGSU60AyafbN6R70EAS+`)`*@zV z1t4-gx`Az0r16UefR3TbP?;f&7Rh0`Wrh?mLOGtZ_Hi8`Wq;2v^9{|N$z*{dr|)_J z&O>rqVB+gPoCm!BK{%GriR~mtrCx?2PC!a;?&z|y`L%9%=Adgjy%LaA_%^yS8R}`m z?kaM#gIGN5{UehcO1y(z3DAj<#UUh}9L<;A?Gv_daS>a@d3Zg zu+D}8b#h1QN(Qj%C@n&n=*JihuLG9q3hz7)gIKok+H6St2*cs~FIG%bJ~i0gK|?N` zv_Q1Sn2%FbD2KCm2tiQscwgI&zN*8?WH}KRH+A_LsVa6rvl*)n1Oab)R|78D$;(BC z;Jd`9M-@wUo}b%2joYmm2c6>k;-7E4n$&mwD*LV|>ARSBmpmVPyxPie52$*FYsp%bS7cmqrLOFR^gOQ!XACTFTIq8eTE z^C0Q>R}I5j!WfYp#TG8JoRvwF&{>~Jo(iR~lV2Ufg*rqrB)ssLD`9PMu)TOhh=U8< z9XOVXJbsdw*_K@sFS2q94aal6Og1zraw&H!DJs;hMWIvA$9VKYS}GL z3&-#!S*8?(LLO&`{^yb_y<#)f_b=2L5Ke-q3!@L$_ivx9YUhWnmXiN z36w;j<7+(e_3b72B{@alxXU8O#;iSsFJ*kV%6j7!c2xu+YP^8PcIeD&DjfI)C(g`u zcc_M#J_$o`ibwUYp@j}2CUR0FLphMU$};3``kn2|?s`wIS9Rl;eVVqwn3(jey7nnq zLr%%m6U^;<`mX72Za#gVeuQ!&1YGAFEe7d+!xHx8eEFm1?pHWo9yz}($Twn|fPe~F z7f``6EfOk^`EM2RO@pvc)bpzN_#SFhu3U6A2=UY|`ZCcCC61Otd67iAmi|r3ofDA7 zbWg!!*DT!EvFx)f#CmhT{0tH;RocV%J{vG|h}aGVL}aEWmJS+1q8`^)_Q=G;sKFj6 zf^`Dmt>=Jt97=_^Skw|JNS0dH?|pl*(tXN7?M}&Nl|8cK$76r^wf9uk8UJEHK(4C( zn)orwq*flwZL9OXb}#PvFqS>_m_=)7!B;+iQtIuwaGS80YVnnK>S7kY<#F;9NQL98 zo#dR`#uwz?Yfs);uU<;Hyz=h7;M+`D5X&sskLI%Tmf#+p%MkawyL0&;9e2K$o83Z7 z9*m3Y?ep9kM|S;15gO9ylZc%=J`UTISm$?r;P*SwyTpX#Jn5pe#NT_}(uc*$PVR%# zmC+=xa8V$sdZ=sDAo&YaMo+v9`nTKnL(4xG@2X8(E+;55@D@_BAmVwlZDZG#o)Oob zpu7?2E6S%lsSwJ4AQLR*T8R?wSP7_YS;$x!Lg0LT#p}DF+(-m;N7!v*`k6^B8rYG5 zmPK!*_3mvq6|~BX2O$Ke$qlrw$P0b{VEEhe6o@hlG_C2eW`wdJt}g{D!XI3xK=jU4 z`0WD`)+zh@3wm?a8g2Tt$Llk>lYi4DmeBzUp#s84g1Uf_4gsScoL?Hsf(V`th9E$d z^Zl6S!w}#K;tD@=jxeOd2A`we3?FDeuG!JQb2kyJj!I^G=<^JApY51v5&$gxy zZeX1+vF5))bqP>_C8-J=c3;Bjl}V+n*G07m;ohbhgQgr<$GQSlGiVyiQ6(U#Lh(cDME#`UPcoPpmf2BNuAG3 zq19<> zj8u0_qADr|$vZ${o@Z-S;mkLwIA$7A({%bMt~3iqOAmal+kZ}oAIupB3NVSmrCyQ) zV8$D{u^WPeWy>(C3VNy?{QT!iwhquR*Qc(Gj~IoAS`=jFKU8s@>@{seNT9^tGCu zeu~T-x->H{{RV4$T0c}r}vp4CR4 zgC43nOeOLnV|5u=uKZCaW4T#kwdu#fjE{07#n-23PG*Khy${}%^Ku=oY|oQijC+>! zg{`DZJFLKdO1|`~fLdR%G`a1zVQ;Bnut{4tw_uWKhi($r?60wto}_*$DVbe(BU!M4 zuZ?>lHg_>(LzZd$NVNw3A;J%=@UpHHEaG$J?QEY+|0q$r+xIQLHV`P2RNHoj`b-U6 z&i-#|H|nEOx9J?exNQ5Y7WWhJekueaGnF2z4+44Pg)B2plhm`4hwYM2g+FEuxG9xb zdUg-IVKx*It-n)^mIYrJC=K!`Eqwao%q^Q)uV7AQ*3T=`xiyorI}t!X->2StUK7w> zSWd8Lx!S$uUfw?Km9sQ*;XDU6-Gdp35}D-1aO+<2a;VAb*{!P+bkQ#UFi)lk1P;|(Gs8M_Gau|cx=~@in-Cl=+ zIHSaZ4B{)Rq)$-P{mMdi^?B&+R*$_o(R+K%RQ=(J znoE`R1a1iho_t>>L5e`r?T#5)lsFJ6pD_LSn|)8k%zAKP-y4bRtiKm9*;38lQ0Y zi@Z6J;;VS>TZnVzf&k1s^FvV*kq!C!hlSuU^6}rn$%DV2eHTTxH72+|h~)DmKtCLJ zCmLOUV;9yB{%&60htGGnihAN@c9Yo{`R6=;a_zQJl)~-<+K&8#)N5W?$N7^-q#%de+19o3Bwsk`1p5~ig7?bvp{5lrIQ)Kl!wLs;_Z#LCe zR2Ty6Qe1M#`;RnEP$jVwcVD+!-|!px2Y{J|24}LM%O(ti8FaN)P!c59-!UH zDo^R4ze@8ACV8=K#wf)BS?>i$=sv;ktK-v68+TNr?ruwUzb?|1!UY;cO*#o6^&W5t zeu63LSPcHyO%~bB7R5hJYnnEiY+WyrbnQPTb})nVHRoV--Nso_ANS5GP+uS$^A8tB zUou7Mv0)DmzI4no3K22NlHiyoItY?PO8(vNEn%|uAa5A1kpa)Cms2mHQAXTHKG!Cj zVb6PzRqSr6gsBeK`IB|>EsvZjn1j8(o4iJp=nnh0&1%6rO=GB~MpAt@B;fU))5JU3 z(T4v-TnLVsyQP|L|He+5Fa&IZ?4%#NK}ot$vl!rhrPO!V5#BQNF#j{nk~HDI7(VR& zAp6)C=+K2B^>VfThyx}5XkFV8DO`{rk{)p1WK$Fq$Vo+qpLFXz2%`9LkVn)4H6a%C zgZ8G&keZtU#qTK%lDoJ_b80|A0m5%D*vj~TdXTm)Hf(>8vXH{^C3_qlMSi}p5r;xH zocR9FXhI591jA^*itfzZEX7_w_1Wd*E#=2)$x=@>2TN#j!h$mR@c~H z)MPV3X1tb01lESh{674SjfFsnTE)IdR_9?Sm8ggS(DboBKoSkb*Jvw8G!{)L3qz#8zP3` zry@DuM+sWOaW*I-gjhmvqmh>Dv*x4zFz(IEcQ^_Hca11gq}SVb#Ay1`*wD8kGF-y$ zYHd7m)~wA@b0e zCWX2`93f9T9O1qZwt9E#;v=h$j3EQHiiE*_FZlv!HJzUJvXOWkZs8*N+S8r1JJQH| z1nCd}_9)t(eTh?370E_?>xen7Q?sW^sh&Dg75uzPO>p+`Zx4b+%*Z-z+`m*wL-^Xw zyZH!n-P-QZK~$x#;75@Uq^Owx-Me!PwzcF@@H+Jpb0oF3rZ;wool+|&YD%fZsed|6 zZAM+Xl)gS^ZE(+gb;bSyy4C^4E7ddvk*rgk)61XGDhq;x3c$~QY#n?p1Ro4C4?TgQ z%`!p{Ag;c>dO4cmdDTE5;V(K6+y{#2W!$AVHd%=sT{fx@4Uv%ZG%nj` zcG%(Cc;fcfpmvPxAH7#KP~6fmn+*}hp&%2|d5ii2_9xi@Dk@PD&`w?hob;@QzJy1@mo;69_+bG@n-lu{xZ8;H64RR&fYd!Y6 zieRBR{vh9bscT1@DQ@KZ{OYy>jYq|;i$KeiW|s|;+k0+l;+ny+nM3K~D|@~;2?F&R zhKmg=cA~WG*er#2?E|L4So9%8VV#E)pFRW1|qRyjv`? z>k+C5VC2xOIMk4|A75o)B_olu86p*EWQb_@jUlmio)EnqMg?)}JPO2;RxBBP?T}IK zS{KptQ6JJdaZDRz4qz5QIz3q8*=)J z6EZC?XMEnxIR37)2!@o%Zuxkx=~CpkC?0r0yWRlAVr4{m`qAb#$2&(f z#f&6l7Zz4_3a11wF^txqac}cgvyJo!n#&Hk67)gIVEbhiWP~`1OL=U(cc~>Gd=X6% zHSiiN>4a5&a#xg&3!94?+r0|*T~@abt={1sE<}t&N$g@IY*5ANn>G#`l28R}Esyq9 zxlgm9sQ!(84sYB^R{FVT--u&7I+f4${F*xEs(L+&=sf_*9 zp(Dcye0$GMd9fvqz9HRYkF2!k47*hh|2^MH*Fj<_6=cy z@5C>pZf7J6(pHpB5zw{|;l;|v+n|}j>x6o`>U!U^og453y&A#CjjZc|ZTov>-Qv&! ztKOxDg8?r!0+kMdAb{78rM>3ln_jcR)1kf-n7pu_q;$h$;_DSEQtMBVfMeOG-3@s{ zCQqTq%>hq!6hzfRUyd=%D)xk!)rUl%dZ`8J zp)nUXJ=m7+={3Q)&0d=@M5jcrW(3&*kR{cdB^dD|eWo3_v;RH*ukw%$h;Mi5U_pv{ zQsC^BEyk?=zpu5xa4QRAg=A-f*uK{YcMl2#-QMA4|PCx`o2Z5mj2M;7s|p~B$Gfz>hxl7f&8UAYcgf0 z(mWlZXsb7sW%IRmqFoaj+#bMA?j1Z@^8od-ft)?{H;*E`94bmeEOw?$ zel(UoD(4kfr`tN$-cN|oLq%}eU(SI@6g}Vn`3ox~==^_v0e(lzy#Wby{4^XpmCEQe zh=>)T$q6lY2oMwc_z}?Ve?LaK1X|N^Y*+3*ro*@%Q@h{z(P{nfH{y3j#4X4F&W@kT zceoC3Il#4x_RCK}SSWk`0}DH~n502RnzuG_g-hP@=6#>532=oWIUSvgoXC#^0dsVW z1?pJ~1eFMu{n$}yz-@u3Gw z$DR3r@NIA4hv6odNS%A#s#G~KZCQZKK?oO&1;4Vdqv6z0V1}d3@7WxnTd)JCgUQl+ zZPA@U_|bom|LG%PuN68kQAlP{l#_bSABwj-E98nj(bn}YuUjPwsh`Ui0Q z?8t*Py>-{a5?_4@p-1`=b9Z+W8lM|JBwP|Gx7-`1_Bq|NXlEyR`nRb^pJy z8uXWue{Fr3pLr6rQr@z;yKa1At1T{B{rgE6N+QNvM`@f-a4?I9+ck0r+v|kgG^P0& zr9knq+;#z>_5lUG!RJZk*E40~1)Z|fD(XfY@|Pt9);>y(51$pto5+rP+?tVCVg_zi zK8Z)V50dQ{3?p)gvJg@QY`Oww*_}i&$i+Njhj=M9kuk$(v4hI9vN}y<9=p5kKBk(B zk>`B^B8T6(n#FM~ws86tl3Z{6l0OpiFK3erGDl8C;UiVM-(mz*luDukf1F12)+_4; zSkAwfG~jyhS2gQne)IF6r?Td%y2HMe!~w^ocEwiLCWgqz!1kX`lAhs z!~5S=RIMIH){KC(v5y)aeGTLTFYYsy$`6(r2rFl@yz>_Dtd;U3^P3>-;hf zx-JV^u9_lmzxsI9^XF+nX7q9t#J1RnOu5wEGS&Y|IKX({W#LT9N9bh60$R~Qw<*gH ztAKDImf?GN=^75AZq(syoVo02_aeQGl~(famjB#y4b@UA*&%)^tS29vctPJ!dwY@s9 ziS!L9FMKkDYEn@#HlAcJtbdMMQ>4FjrT+*19L>(lRIS+dNzc+;H6z_jMHk=Df~R(u zb=h1Z&$A9(>J!PLtoJyI6hFR5_b3Y;7Ftd6e!V1gx93uG)lKDEx7k5So0eXa#WVk= zsLPeCHlQl-%V@h~eeu-)B2z}Nl^Mk)9@~<}@A7Zv4{lK4r%oRlwv)PXGkAisK@+Qv zn!v9|?yGUz$LO2=(X?5Wt+sQU(UK%#38S|3jelU$F=tjd+8Yb|Ix#kb~qru1V_D6~N~x}Fq?S~xujv6~z261bJ@Qnh&I zUy51!6VpFhbTF=!)z-RvYHcp-t1-@YHp0PRndPeSU3Sp6&Kt$XCwQi8i8UklW`f3@ zO{%B1kMMmFqHm;#WTy>vlU_fi$))l{X?`u)EbrUJYuH?*6G6R6MVs3cu_eoluSB-C z?xuYHy9OkVs{>(HO5B|sd9G%^c$zNX)cveUu?BVcssbVXxtdPRCGc-RGqNlafKn}H z?#?F`2>hEdauy)qEH?u|e}xT01POT{i6C#t^Uomas0)SVTz}*GHR{)@>&m{(ZPO0O z^#8T1@3K;pSbCU&dFtQ4G(42OQYN*cu-7NJNtAgdSaeBQ(&Y(pw8MkRxZ`RN02HOi zX3l?^Ao!aQyU)jeN`L;nMSL$HzQDr4kZxwOmZB*X18koGtoch?XhNHy3dK|D7f)(& zWu2j9GQ4)y2I+AN3O*Q=^mJpAR0y4ZnVplFu09x;-gIn|NDbI?Eaw(LHND4tl)pH$ z*_R#P&28%1xudD}FM;5vejp7QpCSDK_q8qC= z1)riBx_>|Y6U}=6v_s&-R(|h_i+^rHi0|SfXZ9Bq94@r{7eq!uhSZpBZWxuc99L;N*iEjo%RUr9Oy@(2wVa?qs%Ql_V%fTQ1Kl|+ z92kSd=WCJ5zE(~wG&9OT+t1t~u91Uh^H#y|`Od;nOeTc5Ve|w*P~EpP2KdD)>8t)Q zqnqxfGUj<8ue|{&@TmAIQAm;%G%3xkJ zde`)b>@Zg~_<}Wax3t4A3c^(QERT)SfdBTd=ApU=#QM{kW;nYi#HBtJmn0losa6U~ zCh5J^FEPXoq*^k83&*2mC+pXfJ3VKXFdqkOG}beD)|Vx#*Hg+C$O+-Im!-GF8o8}H z9H4~yw^WM1MMaOTf@jOyArmK2W&?G)QwKPlk<^MzJjmbgh7hZ<(fYI!NTO0$VC_?h z=|*nNgsp*o)i~uQ=R^GiJ-xk72=Uk}L}ebhb{Rvt|NZQKdA?>-&TIn=7;uwJ=Ppxd`ULyKNf{eR;`&Xtq&kb!G z0vn%unsc?>_wP@=@EwIkzD1c`2-vpw2(7eAn;3T4O*vfAhe>;{Fer)z&qpC836yX1 zwfi_8J6esfHqZbJ>h(dlJu7;kr`Q@V#x?Q{wJurZ< zjPn#o;eeo>jxtBF0r%G#yoWv}_JbBy$gOe$=W!} z60F?XPZ~qI2Vg+ML@v$R{;uNUvO0m$uSR*c zelhy$dhu8B?JO81iwnn)+nfhjsGTXj+p=d1?|Y8e6oO479(y2xXDQLarQh6o#I~}Q zyYZAsoF`|J7-%`e>8!rX4(a(fL$;fB$4k!GyNpt{qGp}Y`_bHjRPUCw0a_1h$oiK) zLY-`uw?j&zlLKgw0g5*cyI!B{{!_#ll;lNI*xagt*PIj4KwKz~7elIe)-nLBCaZb_ zNKFEspYs!u+!pYFg02}0YqWZfK2PI@(65&Jd<$qYF?Aa}j2Ii!v~yE%815)UuLNuCjK%v`QH zSA&F_@1`vpc2Vi6h+_qsOOVgV8+mv+XMRg1&crQ%2&)E_OZ>AYCm%+`D1o3EXii*+-excN$2|Vt4m|LIo;XFE* zp_a9*n$;>!qXBE{;Hf=)Ve$9V5@V|lnpmc^EO{mg0i@p0tLWKpD?QxNB}8V#;7z>w zCrmP1K8tJC&M(znqk=-iTgt{A;=N3H>r19~CtG1UB)@w%J12T1X67KpIon_6+gz5z z6qEr)&QYtGkaDj4^$b;kh0}n=K}Oi-n^qHXQ*?l?P`wm%SGe2@x`si?I1Z+z1QhOn zHi?brmO7-&K%S4!?%h=goZ)n%m5ZENR7OtT8V$2@Z9-p|?9FnPbHg{9zB&qQG_e0E z))S}GuK?9YHSy5ZP?(szIEGoVI_Pdge4uQj-3mj~QChXTp&;=Bgd`fXjL~ErF&t62 z9CqYjb|9fVgHt4e=V_FwCaqOZ6#(`eY*!L(Dtd!mGE~tQ6c(9*m5&-Y&bvZct5NvL z!#>P##qYYsOdhnT!_Os=>);8OR6MnPcxE-^)xo8(K!3kNW8Mpbb;i%c(SBQ>+{_nr zE(1Y+X7;R9%Ut<#N)jTcP${x?2?AJz%vDOH_|3N1k;by*e6Fd~e(g{GiKV*FW(=h0 zQfR@RY6CX#M1{SEK+B}4MBRagwf1LB_(?k$aB&uKD}D# zWUkIesHFz>^e?e;V)$Y5ROn^E;*U!^Hh4Z?`fGbcp-NNV&T&BWlT1YAGb8GTpcY@K zs*6ymvFiY4Pez9CNpifKIWAOYrpck8AZM}`Gi+jgQYaTV3Y_|eE#BI7>ExzGC2rb5 z#PUD_@9nNGjvk_CoR`hm-Y6VKsS>*7p6^uf^6iK_{Pe&_B`TQNZe7SjdejSWUSR-^ zT;r|t*A($J562LeK^r36m!7V*Bh}dO_D7y~5h^$4!tF^*FXevf^Mj=sTLui-&Yu|!^5Rc**fEA?*cN;B#C*P~zN%?b{E#JIo%#XWx ztNQi$R&z!Iq9OkPqQ}|OX5u7`Yb0or(Bxb=m~a^b&4jIylYY8~UhpItFFX&i@|NEf zGoLK5ZSB(m>Mg{clx$S^{RqU{)ZOmak*RR zWK5&wMmWW?GxR+16=Q;wU*l*%iY2IzepNr}<}2N%R-ThU!$W)h=Tvvfgsdid-wI|~ zvFUp`2?Ex-3vC>V>TVz0KIH;jowJf)0_`E@KTJ3io*1jrR0JyVi(2IV&w2F=6kDUK z^@1PvskC6T60)C4%O5UYpJ6b^8=dYNaO4LUmWjG9Y#zzUDZUkYzkwl0MqRM$Q+Yk_ zTmZwxObTmF?c>gyk>L!UKiHBI`#71(SD3u&q+WrQ0&bD7&au#0+>`_2N4K~3xsP4z z<8c=NcbzU9>1!WHKJABKuH8I7>TqaxAUC;i99S5drY*R6+3gsyD}#;=uHhP=Oqw{{ zETznWN0~~K?#bmSt*%G4yG9{Q^m0?Fi(k0F!cnUB*hL`KysUg%feoTHbPi~6R~6Ks zFJafb--@5CB$Bg~npAI3JkZly4~S$5^iVwI)g6vWlX~?_&-XJH0{jjMo-g#)pOsbv zmDi{Lr)hhI$`65b&Xh0Y0N2?KE<0C>=}^?~IjQ|b1u+6-oi38dHX|UK zw1XCDRqlgx=7jpvkaH`G$a)_W_nbYHQda8K)tb3qDst^@Ab+sNkVxbus9BBx5VROg z6Dl-$HX{+M-+UCbkxZESp#Ol_cU(m6^VOyE&pq!sEs=RrfgpvN38`t3a5x}}JNMph z{E!A#`9jcS?fd2#`>97>9_Y?_yC}_Co*6PZ0obQyo?LQyvQm5K*0ergHFCM7XP8o>ZAuxV*C^II5BOIn-tFq{=BT5sJGYL{dzzxtFlae0{_O(PGy6wy!`oi9eujwbY8G`qr zrtvg01i^ituPt18C{R%6yzVoD)nGk9(t=5Gg1NFs7ST?gTpe7Pzu-YMwJjXWp@ zJ20LPLruDl1qu3-qw=F?$}t~}o1PnI5>%S9PKN?PIxg-Z!U+@hrDj7a8p*1NL%;N% z{oGEWD6%>1IB-9B$7Zd>0A_R$*PDF(8xWMA_`8*CSbMWEr;_@hV}bSc|&ovF@5 zOxyaplwa2T$qSQD`oVCgtCVRI+n;D%Lr%W^Sjc8)&c9PoZ#Vxepq;ZD+m$kl!G+c( z&eeKb4iwXDOTh&)1dMcQYWq{m{rNJap0nOoQ=M{m ziLAh>@kDrYl$G5Y*FWWI1?`3m@1igNXnyxk+1~gvt83_CyVK+94B6s?gz)o#=+mcO zV#+ZhK+#QR=i7w<=xPmNO@dhxu52#CWdVrIfr+<+9a~RD-uoW`UI~Qk(MHIPSDJ%e zSt5!U6#+%Nc=Ldzm#W03I|*JY;&XBH9g(#ostkZ`X8T+opeks{&@jj^qF0yxvE>Pa zBJc3gB4_ruJm9HlhvQj%YAh_XR)abLHdXZW4i!c2TWfInIm%hp+sF;03k;j{8`T2` zy%I#`W7b65j{}ujt3E>W%I(Z-z8-<*3KQuAT@6EFRE6?Np6Q!-YEyx-D^toQ6&bi?E5Mtn2SlV3gofC7Kur#sDwd8))T z#)RDgO}Ca4sN+BbJHS%xrAZtdx2HU5{N$!kLfTfTP3;fX$Dt5~0Lc7>T1M@22m=?9 z#glL^Gnl1E){x0N1HSU+;|xF_{DFPOwN%f0j6uDnx|$-nI)NiC;`4#BI@I5`Zqt2W z6%yvNG-;c;^WbaErzR^k_I@R`%vDOgAQ!39|K|fFTpDQD){JiVR(l`7korXHZ6fut zm6vzt7jb^#rViJ@@{Zc*QSE;&fh8eM#@}-KhZRkQ9utvbKb65Cm9x$nKHb@+r?5J| zw^pWpBEn9xH`vnm>J^3{An4`wwTF+w>TITwE# z5}-)(ts;*EUi)OXh8S1h(loI=0~!$nXom7@Mgbl}u!8AjlmZJ>@? z4{rI3E9L% zyO=XtBoKzAK(REqm~pB-VtD7Lw}p&g0~Cj{w^7*eOarW`Rx2s^c*eFy{Tc zsZNH00oW6+&qCkRWJ#eN0ovVuj7C6+jdwz;LMDly5q|vzQ3*v8{j`W=OJwc`r z1_+?tt~f8TYBi!dP=*58eGq!hnm@?1(6B}iNcS_}%LmKYQC?}p;C(_0{w#M&3d|D% zh6ex)jSrQpza^GGj4Cu`7Wsv{K3dNY85&x&$xNjA`m)B(Lo&COHShiF**Xm1f5Ji_ zfu2B{DF-T_|Azl@d4Ig^y`*{4;DrqTN;h_p_UF%4?}4fr*COF>5Y&OME#{0L%e*_E zVz+ASH*~9Us2%i|5(Z<r{8we&wh!lj^N-Cn zl_5WN9ebF6ZFv!0&I~I5eV3{bNIT!~hc~4&5F%K14wlD(24qA6u6LAb>j#tSB)n9#?um?7 z9>9`gbv;zu7vEthFEJePyYHo@w-9qQ@oL*ny*UpPVIe?u(Zt|g{4Z|T@=sR0rL8SP zN=Y2C#-s`;zN15!%%_FU1Ap?HJXpl6+~eQRb~qbwt={z zT;cAx8FTiIuV0UBBldq1$fw2%snSZoHX{q1p5OXLv|Pe&UdkVr zxvVlNuhS$E@Xjs;BYg|X`zb9{Up!4-DlkBeIG5dP;M!%r?z|8vZ(jE-8k?>pNN|}y zpLrBGa2|J>;EzQ4KS@0{)d_d}$oc%yI4XUBIzX$RaM)c{2}k*=Dn-7mZ>ZJkDi+l-;}A?MQ_t<=Gk&fYxdir04z=2e$a5t% z_--4K70$BL>>aic?N)~i_?QaF-0?w z)ih8s>r*m20$Cv`!fQ6ylBhttw~F!C4t3Y=BHAFJM$7TsU{k^X)imT5S_n@klbhOS z>f+qI*|CBH8aNjzH^hMj0n_T_+m>jBg2r<5I?FFVC<%cDX60Rn`O(Zlo*ef~mN0V% zWwH^kVAIWrxmW4f`3#;1>o?$?V2z4ua?_2>PJ`t8%v6-NPNSijG&|3&EGhGEe^esR zc$S+wSFNdVd!>{pK&{!%G0u7$9|tfP&IFk)w@}a98bSoAm#ecQ<(p_`i?MMh zrL=}xI6ZnW)P}gQ>$*PtxtOR0cin*}81U{{P5jWzv9IIAh8kHBXzpr{0z zvcegXbRcc|LsMF}-q0d_-bv00pT(Wlam3)VScOU7W|Jn+wu z?nA(_%~pfGy; zbaF&O?-=NeX_2il-Qu@EZ~K${hUTRkS^}Fs;ro>3WstSM`Pqgu#@jPD1uYF<+;rIG zTI@V#TjYpvsy_n#0ACZ7WCBk&fMeqaZU1TKAY~3cOkR3{ug~2;*P4Bbz4)i*uGI{G zf*8$2OMOHbm$L_lJow;+{aH9kpPr5%KmEISI9uT?M4ZK8P> z>?7t1MDroCbNk{sY~#6w#esH6;nLhBYyNQyzh6eK6ML}MP_Xt+^z|u*aGm1r8sB?| zk*=eWDa)z8L zuzH2N&iuO79T7Z7qqEcSgEpjo$VPn$*ju~*9CcA@dc!2;H1xUi9NPNzl2hXsZg#>g zb6AZtIGQlvxCG9oPMJ-)e67D2*Hy<~TYTsiz5vnk6WFVq#$ zHgfF*IC>j2vg$xr_>f*8_4kLaAK!O&pl(KWN&7dayPw#5`lKhGriKu{S#;cQxWzi~ z10-jW$v$mo4RdWvGAekF#stKOj2ri9X5WOXx2bekA_GxUuFvkdlpNhoi&CkdOTu+D65n$nS~;199t+J3 z7`RAjGXiRH8b%D|1L2JZaybKI{yrJfcU(?|>(5dmcPYfZWtKIBP{E|0ZAUWhtGK_W zzax$F*jJj9f7`f$N>fSLO~G$;xs%_3b0O~1*4X;gp$_v1jKE?9L(ELX9bnDby=U|! zG}Gn0PvsYHVvEOwR_nKZ37x~`UC@v(yYA3foToj+Whc5h1wmc(xBngvJqPW*0x6E0 zJj=Lnd`;$}YmRGGvtg(eeX6s2iYA_uM0U7O-UR}wF|X~K054JfRV)w$4V zLniZ6^+?~b_$cn13_yTH;Q$lB`V1?#PmwpSDAWJKgHD52#vv>I391QAO@vMAd7hhT z0$bsx_Ic#SYS#6N1MQN%HyCg0DX*XK*4}YROVAh>{2D_7)*9xu3210@*!HnK-G@HB zK^V-(s_uAnHw1aT_S5?KGcs)QNol($eNZ&G_D~2$$YM7oXYEffOb@Vgh=FB)GYgm< z|K{RGe#_x9SA#_LbIaKt7nPzi@QVGpFmI-|DBr9_(V^DR2QnWRg!)#qx&ld~yj$Z)81uqEz>VRoIPx#GRb@29zip)9fZx%^3 zgeHH6e~dY6F&VnI7|gJ`DK| zVAaKK1$*KoPT!=Obe9$1{YN9hgMZRx&srouoa(d54|%?$8ty;5Z~|D^A{7b)$MhT~ z_st1yB7NtMGkkPRu-XsDROY*IC0aQb4%K=$E;7sgT3Dq_*H3&Ej#Thly`davOrJNy zq*Ilxx(r4775yA3yO`mpMyy{@frCwNTB%Vzz?ahflzPgZu4W3$?S+X6K13Zv&_m#J z{ID!EIZque=|)u~q%qZflDCjbRv;9@GHHD_omBF?#7r}*;r7f}#Gl!knZtv%5l494 zSqSs4VIR%Uofwag)INGNM~DIKy8RmQ|c+`7Dl|;BDpSsGMH1`7t`_#S!Zxx3hs1PHz^EEC=WAUx@_hl-BNJztAp&&X~Dz6-RqlUJSUHsNfFtKr+@I++QxoVk@?Z4|pN$;xZ>fvQPJ14Ryg-9I?*cuul|z{! z$ShkKDDn?biWdZcW7G3HiBDnqhWRv(A`Mz#vJS-t+*P3Wjn7<9ZWu=x*mNIT9MZ{zcB4YA9H_cO%Cj9hnsFHq z10Hv@&jkKvpAj#&w-%SGyO`;(I$kC7NASVAfrRF2p2-HvX5M1hojg9{8j6m>So71u z%`-6dTq8D}%vO)rI0u-^so^SeRh*=oi0QeWp+YSJYm~6rmM<(6NR?s$n>h^)ChJt0 zv~{VO(Irl!3~3S9K(v?Vx)5pQO#l+16_R*Rq@R1hjO|N+TJm244xJl0qxRF!lVClx z?}Kpeo=0+4#O^xBhv+^!Uc^XgYZbe-K4_6QM9^Or6E{cioJL(>P;|Q5G{X(GdY?ugOU=u8qVi*qiRKe-d{_R1u6SHut&~ z`{OBzTig$Dk6Wh8K(qp;(`IF__IgeQstLPOC70aZ* z5!}s}uNn%>ZGS?Q5GxWy6Keuk2S+XldSxK(I`H~{o5Oev3xLNW?ZfcE-zoccy?lX+cg3j12L~G9BmPb5_2TZ`N+6%X>PJ^_cTodAi3ft~6YH1Gp)i z$J%e1c|`6%S0l~#C_5-1&mQqsvv(YITKkc>IR=~lDoJ@$fs#1GYf338lr@Hc=+&cu zB6>W#MZeQAGr~MHF-8AXiksi;{<8}Elzo%s4`R?Bt{W1$m|i3gTk1tk)Dneu)#b^eiH*uye5lCEl5n8i#jAKf3e3${oz1u z&lQLIDJ5kvWdSU7?{5j*I|?*pm>cM1?AEf;Vqx`p>jIYG#9sp^zNvf?{rJf`!AuHG zx`gdBSKdDSXIwM4JqqtmEJjM^9I-I8cdiZWvVl%}Ign4*vqL)Kar^xnKXYjnh3KE3 zqBtkDoqP@LGrW`{RfhJnYbCktCKuignN1y41}@U`%0qXjg9OZehXVBk95u`8l{@*B zH2r2dd_h0*;PVVvCU5UtH9@qh)Z|{MZLvLr(M^^s1qbT{r6x)|tyi`&2&2U+VsFl{f0wUd!?KR9YDb>0FoO?i4uPJ|8)5_{4eTj5!aaULL5IQfXpC zG=H2-);P)PbidS`AEFMm+*J$@n&tTs!HZN6X(5ti4WtFbWFEKH9%4R2Y2`N#%YWmwu-_KR3*u1T!iW$scu z3cNZ8IKGt?0SAJq^)C0l6b@;4%&-BmyD%z@t)?t^%LhB-6A;Ugw~#02`2xaoBLTU+ zahQ`s%4`$!O6?xQ@XumS3tG;bHj~!8It^@O)UI}V;7o9puRpMFhXe})sh3wjj|g0& zk|J)AzgamiOIh>$gNWTTJmLQ9_b{Sc>gB7aE!+hMhT^U2(COe83NA1{en@xaGMew1 z0SM?zk#mTLi0DvY0LT4%>_7nek}T!NRE?*dK4mMRxrr&nX>n~(UO|PbYF;KgryD-` zhtsT<+=^&@q%@ubA3h;Y8|XCS88QsBR4csPgMTi71*ofGs%<&0=%@`wem=5pA!z9)hxc zxN?)z8-66SB&#V^2PRc%4IXVjF{O{RiZl!&G5lHQyZ@p`>FCtJpXZoTks@ZyNdpD0 z+l@6eZdkLrJV`{Ckd|KGuR5>=!V`L0{nXIWnuunKLr{cp-R@ni<=12jp)eZ1r0?^M zwlqlx`kTPmi+4ai=^o0!sKlC62ryZ8UcXNep`qBY$OGpJY%;%K;KOI)DZRB6!IKe# zK_NhxK25Kk=;iy8`O`Q}RI>LwOrX##7k(Ca=3)ri3cLytIHIlO>o?VD!jEOurR@Fe z-1uQtusH7>sN|nMelI}g3U7U$UFos0wtEhfTo9YZ6Kh%fwA%&o%zs;}zDF2v@Vbt>Uy3YBm=_Hd85P5Sm`xu&EB#65KhyE4Yk z#K;;ni2jb9x1Y~jW^iuo=*#Pd2c!N?7$aU zjskKV2>|1v+jkl8$Yvvnu-xQ^E5UH3ZaqEbw{*&mGx&gEb=K193K!sKm6Fb|+m zOYfiEfgrpp{w{R74D!5&k5d}IBDr;`rq*t?^Dk(+tas9?5P2WM9AijXFU{QWZ?eMTNrQo}DuZhijSJeL854(!Ae zc$!6v`#+o(m=*%y3eCy7595#CJMsBc-R+k{HW->eO z{BxDr{ag^&%Ko=>S%4e`91oc;`_T9oqL&AV9r1$7Z`Y}Xi@(+a+m8d$rKrzNdgS;=+q-;cch-(=I=)f}B=P>+>*t&PH%%`2!fj<_~9HLU5PfHps)3NgXW?SXE z9qRsMeu&Ndhb<|N&)W6K)?I#mGZ?`e2!A zaY}l;64eW%Ga>jQlm!UHxVJeW;L8MD^J8%bQ!}-y+AJCt?p{$!n>>Dcl!5b20b?68 zH&6NK&s=y!Vky}Pn`47&f_tbw82VVKug49b^aC&Lv{+%lf~B^yK}y42c7l~7o_6#RJedNA6EN2RXnN*s| zCxOSC_>Ub+CPm)y(l`U~Qi6cWqG4v4pbGT~^~aAgl#Qr|u!8ebrMlW8>(gby#@*sF zj)jI{fxEb#W`{}9cT)be_GmqiD5@&mL>DDQztBql8U zjO>%1sEY7OAIFpmVyVhnG#75ht;qp8t(YV(=-eeZ33rPdTqJ_+g1y54GQCG0zc`KY z{v}W4$KB++1x`nMd68qA|iUyU6+`-hb)=QbOicevRvQ(rPiAfzs?N zmiW-IuUXY)s5HaI*$LLP%%s1OvH&nv8l*l3cqc8&x zR7=-bJty%EW*C1Kyo+P;&vr7~gTcG6V{-R6*r8C@0;;*1ozLx?zD6=2R+O+6RXMOA zy4XAk&g>#Eg)ObSNV~r3E_FAx!f*`9{^h|=nCjxms+=x-FNY!VT{E%{r-^jl-*8KQ zr0nrLbo<%en?Py`Pmy1!M>WH>vLE(x;yMosmCfXemnwx$oq|AcVNs?%@o;MXH5)tk zl99z)QK&u#uV=i0InL*@rJ>S|G54kMfLwjb?b-bq_w_4(UM(&q&eYmIBy{pj5|Goc zg=8QQafe-4$b7(B>bR{5h)J46-}$}G5(MTU<;7I3D>n-xF&Hmywm=X6`L=u?B3!U} zgh_I_nGyKXPKS=8n*5;C^4#6S>zt5*6N1xoAefk8@#S@B9VdjKBGJW7FI$EVnJZY0 ziD{}4ZZKpU#~wYsZZ8eg4;{|q51P)n8`tS9X%r=teUBC}1phNN$MR|MAPKra}$q-04hCYK?3)s$e)`YO5 zx(E@a0>XJ6R|CiNDN+1MuH-!ohv&PKz=BOeV6tT!szC9Dj$Cf)ASHD#F}Ev1x}|c1 z+qa}9#V+3g2ZO3TDt;fIwEG=GbU_8{gwsc*kmsh9&Av|^I&O^~Zs28^WX=e05Tl~6 zB=@z5JnbhqJ+4Yh#G28+fY5Q6BlfbpnAL9v}#Qe&wlqe;oCc}eB=gD4wR2T8r zb-iviL^3ayuPO=s`5Xjvk3mc&%+-iLqE-Pq!H0F}K;&yZ$nO@Mz`EwCW1YLJjPSM@ zvN_|A`1PvS3bZbssRidc&4SH##rY|-oZrAHGH6Vh3!21VcXny!_>ugrLey*K-h|=9 zzRu6aG?!3m3-YL}SYFJ)ea`RVWA33p)S8z^N zXLigrV<#eJ?ccZA5*9pM@K0^GBOihD5*hc_vep*bEhrVGbrHypzA3JIYVlZV(KMR6 z8DNB+kL~%mZ=~Qi_4>gSgTq>fSwC-l#dJ7`9_>T7wWRk#I`%qJkh?ah;6TrzV}1nu zPWP)#SMp`kyE++z!<$|z85NFu^%?e$7_{$#7fTzueuqEoI-u$KH)D!Eh^H6E_QrUM zkhp-Nq-NUwSFBg+z6zlDeU}H@#W1I#=qgu|ItU)K*OSI%@sSuaNW|CoLqL#I%@~;C zjmWuEVAz?iUNgP3aaG}@8!Ie*NEkZ~R@ z!AYy*{Gb7VvkAM7chTprIsEe;+MI-cEBw7;tNG**CtEprKf+z_lE#Qv%VUB<7Y1Pc z@)zqpEsLE#j{AOuPpBG1^-9TkuBH1TcCUTDiP)S&y>^nbE(piOefS;gT|No#Ri{$(Ng<|Jv(j3RmzC3@4ZQbzwchrvGlCoNrKLEX0YVKK&i1r9!5GKm1}VgJ z-j`3X(HpB<7PCa&YFq7j}jN$DOa$kyG?fgtFb4IS0y zSgP<&XGYTZ6)zoq5ba<)p#TxnQ8K;`K02W`!rUMPi2fPf(*NZEH^OT|bd4!vM~h-J z+C6^TWgQm+!EtLha47eapTsjZsK~J^1b02Ps@JcEO->(sD6>QX^R=!TXmc9RXATw6 z30Sp%I4#^?3H%Uf;V0o%opT)uWd9nl0LoZ-(PkxBBo~%1A1n+&2%H+eGY*%X%RkI_ zu7XNo&X?aE{Rh(6HxjwR25{ub4h`pg>a3+se8o<_gKDhnq7#6@_wv9}YRTl!6EW#A zC+>!xc=g?rebOND_gl>Rg=ZK29vwN7@c~ZpQ(J7h_V_2d=4%Umqaz6q*ls;;!$usF zbw57GD8=|o-{FViz2aO=MexVv@=Q1Ij$w3$#36&?Ca7z0EaEMy4-{E3YnQy%VgU1} zk6#>8|H6Cv?}O5K86*DqEckP#S69V_XB@^10NwXfso%g>4yrEj=KW@nGy=Li&MI1Buyf%^u5Po`2`Bg@TzFUsJZI64VioQkOzSFHty zK$KGU5io@TB?k1TXaFzls>#DgwZZ=dz($U~GrB(j-WLJ|HnAPwi%8|Dis=Td69Ans z@ph0hFd}bQe25lKT_>>sqNX{sm(qbC=xLYdu8d3?9JS(f4%z-*O8@g>C0~VYA7L?-Q6;RBJJ*9txD*21^w-XmV{(z)`AitWND@jLKs;_#S zth(yM6lAmkZl_W4Ab|`L0en;g+v^vBKhC}iM~{N?es1UTNRG; zao1mh7DTlGmeLK%TSY);s%XypLJ&}p~>Z{>)P2?w*?-LY?*BkKj;Uf=*pC@Lw5wp#cTM=uc z|G)^gl14qra4JsK$FQV_T+zwSw{zF@*hPB5_cwJ+s3~;H^mfuLPVnYGc0W`Am63_d zsn|AntY{x#zoo2lSl9*HBcOeR-oS<+G}{I1<=!tNpH+xnq{*DJ)g!*uVCO&3O*(Q! zc#Lj-is;T&R+-$1akuU_f^f~qHs+B;e(xgD)G>?~h$*Un?@IB@vJaFpckS=?ICCns z-3B}-&ilG*X4rD5{c+yfZxk6Xo0!;Cp%?5eiCeJR0anLXC?z>$mUm$8h-|vN6m=k` z@0;epez+*;LLwVLe^arGcf`0a9!dS~ z-Yz&FB?`E92+_S+qTk*1N02&wo&XxerkOqEqH-WOdiiP)bZ<}%G5Nce(t!Sl5mfp| z;SVnEKQ20SBMGE@>ve8Oaf1i%@Q6;6L%ca+BjwHC81%*dpJeFiPUGh~vr8wz0iZnN z{iz*7)N4Q+gkyo$e(lL-!2X+>^7)(QvAPO3p`*S>&oYA6&jE;E_X8R(a>mzF7CR^B zU6e9rwvSx|0nni<;-Jd|U-TTP`$BG-=9-YGUhW`J7Yq%lUuAj`*#}1t{yZ%K2W9>; zbNk*(;R}$|%CmoRwMdw=fc@ozQ>kEp5`@d`R<%|H8<}@v(dI5db1;Cw865{V1}tRD z2>IUk*cL-E-rptxGW6(%4A6hKkDjod7pzE1)b4n@{kg+BD+W9Std~xwx)hk2G1DEi z$Z0fUFvPXVM4n;W^!U5J+_5eEr}%3%E2MiWz@Wk&gEu%*3o zOnc_+AHCHMR*JjKe%MVM1IRpw>gwINl(UeK#S<35oglo!>4|D3EyYEF+`sG_AtaJO z%sAackXhSazKk3kf>eo2Q8e8bPW|TD2O&fg3xN_)&mQiEhBo5x?xl{Dd>BZWz^W`+179`JKlWm)PA)dx`X_c%zDNvq!dg=K}QQX5^yuzJJSqQM$yetMqmNn zIW++mMW#DPMgO$)f)k&GW7pov)8%x(w;A4g2M3&u(EnienVbHnvKSD{E}^1uM+;C= zI%9q$2-*$GbSXRYqxkeiASLoY)PiPOxS<@ zx!5gPzIbz?kn`rX#p#}NppO8<4+MKVSX`z*e9bWve@=XkmoUxLjWsEs z3`7#76`;;So1+}0xy6l>ArTjXz|bWXe{KQ>>-`17wKJ_nl1Hm-zx!>17miRYOIbDc z-@oaXCi8(!jv^oMa%4i(Ej8`ljOM#22H_g@?U@0%|(31(e^h&|}v5pz6toURsiB6{PSAvP^_jz-#ahRJ_*>v_31o2f1Y zJNoL9h^diAp3MAq8>tzBJ*ft?^it*yI4)hAHJYM(0#I)5R_XIlDLlrFS=FVBozE=- z`{H}Qp~s2=bo%|v_%3_woR|eDhY-s(HLmmtx$-bn}qui*n)CyDgojrI+#B8J~;UxSww~P`sdtEk?80wfJy~VB*Q$j&P>uTnv z)9alcf~XSb+^y~h!|V(}jP%4p@$+>7ff)3;2-jp=`n{QrW%VBfb}-kASUy2F>;I>= z?+R;bi`HEUReA)Z7hy{g6%-KZ0cjSxfYJpOMHHkXC9xpcARvm3u5=IqQBYb?0Tra9 z6lu~s(tC2oV()Xm^PPut&waQ)>>ZN1W*KwNQT|buVw>CB#-Gep>e|GGqcpUjdIa~s zn`z$VwoEs4HC|R_lCVtU;g@K^Oc56==cnzdICI7s&ds=sbdAdr)+4zij1)adH(;f9 zfeU8SuM)ZT${v|L^|;OOCI*k-C*qD`Xp4W6c4>I@n_ZuzMTbOulDQ-nwc+DP{n2qq zck|Z3jdE7??$^z|4@w-;u7G?U4TVjpBcK2Ef)nY~%06$;*ppXVfQ1z!ctaWl)D*Z8 zph0B@SE;|=_4w*-P=wZ5M3I8ekAnZtkD#QLEj};>8U)dEAf5B;cLVkk;W1)2gr&Y$ z-Mr0+cz9wF#YCfFxnz_>A|`>~$~oQ(IH3FjSlG9aBeJxR5K^8anMi~9-d9IEj4MRD zC^cGvxuSD)C`t1|s@P7VOOVm+phZ`>e1Q%jLS~VPBKlj@3D!mJfsPWdp=FW zh)6lCfU4%m01UTh@nuTw&VZ-D*MWYJL(KfU8Mds8v3P{|h!e@02>MjcvGeryKF~=0 z^0}_D>_<{g8zzIB#@EDXhg=BaD*K!V>o=t6j6u?q65#kC)8j#(B#4Ml2Lx!EXvzaP zbJ)Ul>td8YRCdW^#a(fliuf25sI&2gu)*%9(l)WJErrXv^VhkYgU9 zTDZMJsi@3sF#TuQ@R>5Z%!H%SWB0HbXOO zFTKHp7XqRn+qOlKiSi}fKjLj7f9}E+wuLWp-M|~Izx~-fT~hMp3H5l%WMUjtb7*92 zH!zY0wHc*cC^u!XG1yC6vMy=E;Zx81?Skv>G3yBg7Md4ZGZ3NyVrknxkr*>w5^ZIi|r|Sx$&*J zoI9CU!+UF&UakZCn+yM(Wr?gPFcy$d9!j3?SVP{6ph3GD-|TMX5zX(?_ASCT8c8`@ zC7e7WtA^s8#*elWJ_U)qKlNE}Xj#w-s!-AGp)5XCPeZeG^IkMk`_`+;k5=y2&T@8na)ImLg1QhdwhPU>mMN(ehpGhk z%$|K`zE$SH^@jgA&~e%=?<+wB7uYe7IUOgSCn&txGZg76Qh-I!Q+PTNV3`h3c_z)z z)FxG2wtZ&*#?2ws=9mKpnGM)Rk1hVWWTq9A%yIlDS1$Wqu8g>Fmn($Ot=9`X#YPBA z{lfvGUxH1x6g=ZoCmng+TazDG_H#*6Bl~eYPuiWQ4M(~|7v8d?Lrw?f)w2hk(kU)G$t%hjJedsw_xlAWqV?G9LWnK0*EE3Dh{AZjah87d{d(26?i{6# zXVgwbhMW(ggT>=f8l=eX*x7027w{1cnnupKmsFU-`MDN%4P1Zx5_l09NeO0C5SD2u z4GEJCn3>zX-G%eqQFkAn#UP?c3j1t_`h*}p`O50inA0GcXyUA8{o?T2cOwlSq>9*n|kTu1o10>0+1_M%H$4)y_ixsRv#nPs}#-)#vNk z{^s=2K3o*ML)e3KDKa24om^bt!X7hY!#(2D7AeR1xV2PVE z0?F?bWohd^NCbWzhuKd*to(2xkZu5@@+G6Vtv(3*J}`Ye zP80_9eO1jQgUnjBUGR#FHrk;LBKJ2fvie0BY12S9khSG~A9vJPmHS3UM2!b*5Zg%N z{mHpQsiu|r#=r0c-nf|wDotNgIo8phG4``!SEw-3dHr*?G~zG%7#~>bvRR6(alZg2 zgmm88IBHdi5Cirihn5f%KV=I`M9qZyR_z+dlDZFKowUuNN!t1?cXy@8GWGPpX@WV~ zj(5fIrGttjo90GWMUx%$qqdv_;kk)qeKu5bkwJt>`Q4}8SM`5Vt9lPH?f55_hL=Lv z@C2^2-^2Q%j#{E_c&7T1$pX zY*wkuGQHk~UYJ0i)^Sb!@c8BG;O?HhR%7qa3Mc;&z8!Bb>`oCG2G^L5QRdX&o8Ff8 zKC#@p+X8~(c(9Ob8>U(e6L#>9|LmeueeLF;@KK+#-qlI^MQwcKEoOD3-9^LaaeA1^&mOI`QO&PKs_R}I=f2z5+9xz(mGRC7TdlPp5 z&{C{&xFvWkQ_B~|e<&|VGAw+_Tq`T9B>m@h67nt$+2LA*3X+4({Z4gcazqSW5%E|( zbvsgP#~$8b5yg|cgFYVCJb|NX7?73Ix2OvjnS<#I%VQ3sx&)L!dN-Xj=q~<}|3PwK z@YqU{u_%l`(x0ey!TSXRBJt}<1W%Br*c0|D<2=tg_lBpkJr~;R6K1Bc16Hq&>_JSX zx4XaXY91?%TpEiRBDijG&V7}Rnz8qumW(X^=g`R&ptUR7W~M0#2J^*PU6~m3>dDxJ zh*F+|$G#ZEqa2`#t${xebmW?mEzl`dc@Fv;yAF@(RnR1 zoDUk)3vdU4N!Tk!*(Wx-WV%=;{pBH==6U2{@L-woQX2bv9L z;fXTJs3^R;0^SIV+q!y#S)&chaaOl(C!-x{LyNG-A__+Z?(y@^KI6o$4x-4Ob^t1H z1B1@EgULjd`{yvW`-;Wp7q+?aK zy*%NOj1x^vM3u`v4ZBbtK?IY9B@&zt42em~dp+_`wlKl6F0#EGQAX}qQ;i9Tobw}Y z#HX46h~&z+m)9=7AF^Nfri~XxhJb z+dVgw_T#hvyBfgxRGT5C{aC2Jph@%dDk`Cs`+yKa*NvC7?c=m#%4k1R&Vo98elPicU2qB|;z z_!BkVVP0pFmN4WSVK?kRD#vnc_QEk zhO3Z3YXg(WzN@}t%ivpZRSE>Ul=Co;rD+VZHEO8GgN|r6oRGOb$Bekji| zMGoE1W*$%r?gXqi#RSUm9EiKeF>t@pt8%$6sAiW?stM;~FmVE{!p}vT9zSS@$Qf`C zJqZ>Yk(!MRUBxL_rTPK9jAV0N;tGV?E!PM?nwM4!KA z=y*jN1e;o!msv13d1U>A8|T?o=bOAe>+(yB)-rv8BtV@F9RQh&uSbcjciq%tED)lu zKsm>j-iWB}w4~QlXI{ebn^nJRvSV$|?LQ2mdmPx)vq=b2>H10F|>SCaJN zw4}2IXN(KD<4O@nlF7zTVEewD`Q1mP9fLyFZ6h-afatp{v!dfBpw* z*0Xy|J!#aMUnZ2kDp>Jng|t1Ar8q@R%vU;1`RQ0a&-EkwO+MLvyjqL)cgnA1)=J#T zSS#%?2qAMn5=)g(jgozuA@r2%EnZ2{&i+O zx|7NxJNL?0lrHP!P1b=I69wGS3Q}_KTlWqH>ZEZpyS57T#$PY}^c7C1f0ZeyElV{Q zRP{|y*~BX+0aoJi&U(I9YGQ&=O&FFj8t3Jcx@)mBF^9~}2Qp%VD`wq~JRv#s_b3~U zRD;9HgReK7Oe5=VJde5c(EqCx=mVQ$#o*%-P_8^<^2okZ!+TvsfR9%#uSZQ;%=cin zVK%Oh$^fFb{iJC=W;nJlKI&|7$@yC!W1_aUW^q?c&o`827kPdxxz84>c%m89qJ)I1 zsMF>3-HdW#POH8p!Eu27v`qRMMUlEL+6fH3Ne~whLWyHZb(#C4O&(X9Q?=3!kB=z5 zrxOBk=?kg%+1edcyt}5q2ABpMtbfXnw#MfH_LQ47s2Nelmsb0H0`2s)`*vR8afg zcL(OjVbO&hhO3`_)n387bHRmwZ&s#dkq;C+w6`wHIdhJ}ezB2x2c9WNg^b{hP27#+ zw+ye2?%y^?an)4X3H!>uBY7H<(b9z z+=CyP-rjh2FW(yz>y@a#~7s1hsw5>01poaalL0_OjNCH>krJ91-0#$i`xO z!`%LxPDw*FSHP{U$F%WgO?KlOR0aLGsFEAC9gd?xX=U%;(MQ&+j%L0-D4a)%-Z3++ zndSjhLh&x9YE2=V}N= z@s-cSgu8e;Dh#L|9eP>#-;%jTupYT@DmK2<3sK_{-#m=g*=@?+R;r(I)wZ!yq0e5g2Ws0DF#B}McVV> zQ{$g4)!rmpQ=f~?^y@cNrvI8M?;qDn<_gzPgy`tbD}ZNh*j5hSH4^_qohpei)+zs> z7&LVebqgql%PC%hT-VM^k7etnSEh=yOAKw}rjb82APb_<_ zHD7L7=5ukgxhaxQw=>`#yZV^)@kPs^mmT-#R+?0)2BnKWM3aowzO?mMc2 zE4&~Z+1Z(B_WgRL`@>|PI|i@ref`6HYJ>X9%|H>2-U!lVy*l?viwdQ8W03?|Gy9)8 zgPie)G*&Y8_7vav84U2o=m^|**&@@q?WehfUIDvWu zO8LER2@O#VP(S}lTn2GHY~$gno#^d5Lpc>_Hzn&?-NV0?pr=zVI_9r+HOosc(5osOgEF$I-aYi+FViKK9Z`;m)(_^U-K;QBI7As;GX>xE(0a0`-)T z41DE4!NKE;R^^goL8Hi4PnUF!h;FsWyPxku`jW_lbzfV0C=(}*Vw_8a+}b>%gk)CC zst>99CtuU6ey!^Ls6U;%<$33C%;ZsDN4B$udPzk?%)P!_7eAcvzaO0c<85}M*Ho;f zulrni&ksSAuUU>ycPr=m1Wq-7z0rHh~i}5{0)(iv4)5i)YN0GA$Z-B+)-2RBKY8R=F$c}<^{+| zXyGyWwb--6Dzy>E0$Ke0`gO0hv;Spevf|LO8cmi@>$5vvMs?+bO}B2bW5kj(m57P9 zTe5SML5X?_YMq?E$|zU1S_b_{+F#M=*sd`oKg~b(B30JWalbBV)9o1OL=HSa+X~4u zeV&W;-2XYHya;N1h{HTP&9VHimZ-MQ0o73Vrl0+p*xCCCrO?HOHJJ%PQCdXe-P)X6 z*Y+yZRGOS*e})s}-AXtlVc`TYKm<-U!m!IvWkaIa|~~7pD@dks_XzC3@HZVh9Vy zb9gp~uD-OSXDN+qVi|X%!X7hw$xEj^U%qUS$(VA>;Y20YME{_j$)iW|Q}hE`w~4BL z3ltBC{fRD-P>B%xVk7S4r%po?XaR-OHCktU`{T(49(feh0yHU+rhd@BexJ0OCay`a z%q<#8BQ4dEI(3_>s@{u>X>?y(?t83B{zKo3>1n$EamV6IidPQhR;ensS83FQxIBLs zm7uOL>)xO7Vl@u||B^ScDbX2b_x<%bv)_k8BMNF(%55SP&*H+lpVB`-^(Yb`#E5O5 z=oF`)9L^wZt@h^lLG1r;m0paSkU%6e>RMT5#cB|yn;?Gvv-6P4C>K>}>}-}*O0)|D zE>}Er)$gZYV;wNpG<|dp9_%)jht`aA1p9?m$CPnD%ESigZqjY&N4?L8X}glxcnzYV z1D7wUW8HXA|BT6WV&BBB)1{4dsj|@|p5ifC&A&W1%1CPmE3mpe6wNC-H{Jc5N(d7h z2_neqN7W=m&4a_ErSPeD@?xILu;7<5Qt~)Xs4| z4M!6WVk_{z!i}qMov&|%sO*YzsAHjD{+HNFtE$|=kZ0l!{4K}w!PbiKjCys7clcL3 z*H$Fvz`YrH2`~vG6M`~0pFQOJ*~JXS{)YUvImN>cX{K~s7Thf@^3dyhG|6sh_cWJM z8W$wq9h!KX^VsiN_pb2GH{Iv8qu|7BbfdX$k)$FhSL6NX%cZyX{DSRu1o@SPcKglp z4Oqx`YCyi(Md{Y`NWG!fe*#2qYm?mx$0yqy#G6qQ_S=;JHj}FK@y6a-*$-361`Nq$ zm!e+fV`dtEi8VlCUi&Ez@=?9CdhwA{kS6M#o?&dN**XHt8#poPdpe%DykKqPf z;q+_5>~?7tq{x=pI}v@v2XnsOIH9nxuebK=Q)j zWV*qP>M=ioS67B>fAeuRv}kR7vyf@3vnc9)8fk1X3pLxNl7o zu0%%~FTShW4=$`4qkZ0(D!ZagLFGY`%`z?VB{`X!8x4X%5U%ZZMV3F$AuT@CKEynH zn4bk9zI5P4hez-&vMil9Q4SzwCNBB(659F`F_G)vCn>S~^wTCB5b6YmO~n~iWej4n z`xd23s&qEBQKXF(yq0Dwk4ZF+pY}Jfg~C1t0S*LGy?!(AF2rx z;J|^G=Y_oMS0>_3+ffLQLzoCPnvbB`hZo5?2Vf331e8Nt=l4e)W*53z1<{BJF?~%K zC~fib^nA248)&9TdpQTq7m;7zce?#^dq#Orp3ba1j6S%Oo8S+Hx(FV&3q-Ul@EA>x zAD(=X2EipQ2H*0)9+}t7685cI^y?;Xj-4W>-6^5?bMGKbNGRbs9VLo#4f8Qrxa>A6 zTjmr_eG3NX&wPHfY-R(EFw0q1QM6Ys%C^n)-1wY8fk~AsGz9ICR72=xrp!#-!R(@a z1%6k#4>`jLSg3<8v$0u1a22L!L>WDj^2cgPUi3WC3QLQeZp@B-*v5! zCS?CWMSh2tCgd&#MgM*^ED~UA$e##~pNMg^82eqoGX81tGfJQJ6O#K+uWMFoMem&3 z$Fx93AXP^bWKl0@3Vh3q-B4!a*jXP8^T!2$8$mHShNlUCp>zt>^kKyAfHvq%xA-22 z;sKc(2uU|sUN3;d^~vFPv}lzJ7TsyIJNe5yBfwwivosvEj2MX%$9dX+7e7j5LBFig z^wH2LUsBt(vyuFJ#F+@i%V#I<{6H+R)1siFeFW$N;i85U=m*~y?sjB2i>Vr(NOyTR zCX0ro5KAX*y{BuKj?wR>l?qZMK1Q%a%oFj)9&2Mq}0p3fbR^7+(p#n0DS;w7B`iNkg?e* z^XDAMulc3TX#5!i~x&-0&s&U9;9$nVui6>D<_E^7-7i$ zMkx}eB9v+Q4|PrHqO8E__+5esVE3!EOlr9FT}0J>A0G}qA5s)8AZ&_px*L!Z<|6n7 z-Cp=Uu4?`O;G8JAI@!|Iwyh5ArR~gqh1$0$#s9js0~zj67Ck|+H9OPP9q`wO1_;^g z6D*ZOn|MXO8Lj8*GXOXnG=?4j_h4YgFj%6>SM)smam?79Airb?R}rE2U-`p_hk6_4pK1yCLVDMKTTzV$CnT0?0*=VR;YneGLV8fP&9!R%HgrM6 zC?pzFx#gSv#v1VGyxX0*0Aoj_($*L@EN71IEc3LOG40xa z>VobFi+>BYQ4+l$ENpWWnnx+cxrlY`lM|lKQRUi75YIV%cVaCy$?{X9a(+nV(kT^f zaBYrowQcXgN5z-rMg`{;f=273_i~M4Z`H7!vFmEiI}m54RAcI`=T5R{t{D)YBK#0C zsoGhg+~WL_h^s4qQ&IN}ohabPXR2FcFZC$ADf;lmxZxKr?6Ga zy6|#~9J6{2P-O9c6m}>0LH@=Gql<4}?uJR5YtWhAC32)(@8E;z4w2IqXs9njgwtFf zgOVb{cjIu4+IHB43((WCD*cRsD!Shw{!RQ)fX;E!e}kRo`+gu*TQH9ifUYR)+8JOL zJI{oNh;`&y+_^7js9MI<7iyM3HTtDJXKP)MM}VQCC~A8$N<*LF3C&}gfC{9=<|EYx z{5FbhzzS7Of6^5U?a=Hzl7AeFNLXF1=zz&}|M8Ad;i3BM!X#v(YbhI2go$JVVI;EG z$Yg5bO^0Y&2gKBs=zZTLChmtQ`tV$wrNZ7fSw zY%p9q&dV$g%2q@IEtah29l|&O^G>No(#z@Pa)o}BMKU1G`D)uP4a}eYPRicapeamrRQ&wt&ZGgw$zf-~}O$Zy}g63bu0m^PmHx zi80gLxUZmpap4>wW5(E>uw12i^a#A4q%eqgR+V-Lo#^E^Yhgu3q>PE73k}y%q}=fx27EbVTHU(- z4y=NlXdaZrQ^5|5B+BnQV1uzki=j&L=?0PtVb)ftLfe@Iy#kb0R?>kASMwCkNI#wG z+SXss#e}()gI_2frLaRJgP)= mQr2zK27vB(ZT*3XYh=!JUx*>%;b(=Wy<3dWEvnb$CY(#F7+2DLCdlpd<07nwAyu>E4lq zW$nm!5d8YU%(^eLA(=#?8pJeBDe#YtFh_r=C1LS1T?hvk)n~l^FT~LrB89DVt6wOt zN%uodVYA0g?s`3)?5uq~)4hB0OeOi+@?DoeU+jG?j$nxB%Tz@7K+z~nRTZzsR)VBu z#bh1nGE}n8=+e<~Yi8|9Zmgp#lcBuO5Ey&x``Yk4c@H1*HoZp_O zN(p&(>A2rE4RJ78`VwaqrsZ0rzl)A-z9d{lDC|e%ujdi;4GdpmgAzTlIYpkgj2s67 z=ExfRoj9IwF#(0&x!RHg1=0*m=RtgM^zGhE=YCT z2t#oIq$+<*Pz3|xj{N$~&KE>&`WmuVtve?o3Z26HgBVf68ALNd@{N-o0!j8W8aSB8 za>g1DQf0Td>ae6fcT+TJF|~m8AaF{RfH}y6SZ2l?vi~yzJ0@!=o%F6Buvs$8WVUv9-%&}NG=zrJp@5<0K# zx}e``K8uT%Do zzrS*n|FDdw>)aYSo4--o@RNH4*Q2Fho+rz6&Zq_%UWV3OsgGJS5`4iselQwJ0G(Ll zNf&(7)-Ua(Aa+l3%t2B48Wx$f+qwgRkZzUZS=1lQauuznW6^q#ebr(+)mom#EU05E z?2B&rH?Du*9pV!8UP8BLVL)hiiLMcQrHb^}!EBsB$*8-P^7V+XHid)Ph5JUzZ%ol2 z%8Q82WuPhtoEonE`A&XeCMLCnyQ0f5^1btvO67;cx_xgHsV$c*dP^#Y4~$d;n+UYwvd)-d|m%kD4=K_iX-|} zzIpIOE#PwB7~2ui>FzZ4epwtJ>m*d5R1*EX*j ziY%wB`IR%nx-}Zk)IA)X8%14e9J8n3RVkOGuVstT-w+`VOC3nf;v43u3Z$}pS~f(N zbnucV9|mvKwLqAquATibP)mCMg7n-e^$k1HW?jYR#F(h| zP(N$Vw}}kjlf}r*&`m=TqHthfh+F`aF=px>jx@7auiG6LBW8oar@Nxdz=1iI^-AW58gAj5G$(x-H;)_vw>uyjj21c4CU9f*Dd)EIE z?l&g7!*c(n#|F8NE9miu>D+{vFD7pSc85GVkbb0(%3e?HV&W{42;s#EL>%*r+Y8=) z?9Vn&lm{XH17Z^TP-fZ_ox0}fM*GeP!Ty6U4yFgGAOAhA)P9)uPoL%;!OI{DI{)_Z z&n3=BFXD30zbb72wjUnM7qMCjph8?iMQqL&HSg5hr^d zzG`md8?IBN<|=tlOE|%{14uIc*YUua`MV_E{VCEm64yUWgz4SQi>cp*_!`TeE6&Hg zXdisnAATi}mk0+ZG0Td3fsPnki6WoQI-UHzEu$8=m{XTe<@UmM=cYBbX`M-dArq*P?4x?br7JOJPJ!1p&^PU6yc|v8Na(2E2436?fid$#bqwJMM_{ zTGVfS0bC7vMFto)f-f|yW7yNm1sin<eS2BF|nrZyXDf{C=cYn*xnoJX8$A?%4v4>rM|!aLtJ?mLa!qf zp;7Z>2(`6*WaEvL({Z6em9L)HttAKq`wQ|c>?2-g8O}r)e8us5@#};yYi)BKfFdD} zT-UA|_i93=+veE6|L*FA>s8$LSof{imsFIC^0Vy)Kw+L8h!Wm5W0~%TtX1q3v-q?! zKK*muUMJRLOW~JIsj-~c9gAa37%u8DOW2>K&{%$5S|s~{Vb8d+U#Pw&B*j`>aKQ57 zwRZ$9GEQ@G+Bzv1LllZ-3SbrOVl-hCrOmpyGmxu^BDeV~+lz9YxGp_n+s%CwCAZHo z%J~G(dJ(unzH?Q?WTR}RY)Ht5a(USQ%&uz_zBU?|uTe(7UmtiWq-=wc8@P+qa=|>f zGJBuH%RLpCFW0fIKTHj|uG5L@wLhF|Ae;nc9X^v7F?W%^O;9e7k*vB-M9%EZRfJJbY4lnIGFb_h&5$g`^@iS%kb`_%*L@27?>jS%xv$e$3?8ja|Np>%_OcBjAOxZ zSk`MBluDc9aXzh;&6%# z5-yDog#!Hau)z-+ufkM<#Ty(djdi9i@a7T{zS$v;I?q`05>9m)4}Ozo9e(Jak74lu zw)AEBBpj>kP%~MPvj*ms$q=b<9$MNYZ6+o|U?Dc{h$`E;qa7r<%X^9Q80c5G&$Mn0 zi(jB$(p>1HRlsRBpg74h|V@!!CY3L_YueE zW2BBoGQ2iN7r6i7$#q)Cy*2#HEnh=v5!fR07s<)|`&A-owh6+(fu-!hzn(|(*M^N* ziL(SxErb6e$`C(iBBnuD^nP_U|2Y^PLHdo2^E9s<&Po=*Nx-|T{&v^Z$tPy0X$W~+ z2I9j$UPZK#;OMDGK=j>9IhUjzkoT>Z<+>I|n3Z(FkA1oBPxEn+6<+Z-i(N#v;MuJ_ z8Ca$2fpl#R3|#!_SI%l?xJ_i~rg}aGg0MP7fvsBCr``$Aq6$1_MY@!~SV}u!Nw@kK zJx)ID`Z*%~^3xd+_J>!Fxo^%c|AlttJ9a8YY@x00eKx#H!iX7peIQM`l9q_I=)Vnf znmFv-7Z81)q|S8tWMhb!^% zri_X5>8XwfD1WXJDnR+O?RkaHiHob?ZqlpvJC&u|+PQW-k}=!{57Rrd-;bb4xMO0lm{ia%ETta{1nH!fLbbGE;u<*mZ;GnCG6WbMI_RDht6RoZSlw5O-Y@t}E8xO({VTjK2 zm9nQ`$l0lvabl+P!mp4o*Wy7H(=dRmd6MPVw!%>ixX9F87jL^kCmfv*~SrFQqG(qJ(4^HU7Z$T70oHISCdP%(QIi)e{U6 z?3?1Y=JWN*U8f>tWhj^o=SzKSLt1ffWs=^BfZ>Wb#(_D_-e9qz9lNM3N7RZ>B100j zTmHj1h0Fa?RyspLz^B^nuexo)D{9xl*9VJ@_bqd+91I!4=tgj)=v}kiGS0Tn7lU?g zcOaU?K2c1AA9t++BUj;RL`d^DHIbokqnf2};|2K<$$P7(Feh&eK;`AlO>D%~cb}~1 zn~HO`eNgoZ-=AYfN!=~EcxR2PXc%iV-!F0iJ(@_>c2lQUxfohA%SrD=-kKGv`ywfQ zzE>OE)Q4^z!Rkid7O0`#;eBRx4=x0w-kNQy#wX-I}f7I41&oG<=KC)T%Wbi=A9&SIO;R|&26gob@@V|Bdo7( z{&&#)C!b&Q{ii|Kk_xB)K&seu?~lWpe#~F%*SgXSI5o5*PkNgV9alZe!iZsFp+PNF zKQ;gz-R@j+P2$EWP3|I2JT<3y+XpO7s&LUg(N6`6z|~_aT7Q{JQBqup!3EsuL!Qn4 z?WST_NE_t>G*N9pOY|BW&A%ay!vVkI^&3(sU&o~ptPUG9a2vwA029%>M-*zS81HP# zBz5Rv=Ay7HS*t(Z8E{|$gxDECzR>d{S*KcciMA@XEDLPube}_e?t)gujXwLy8+@z3 z3~rPrAxxe)+f*wNY?A1Th=sFd&warXOBIMnuwWiL#X z4(c~_()m)}guTuAnJYdrB-Z)|*wj^r_RzUM`FDswac5oz`I@;-i2G_( zUmz+6xtoRm?EsMz4Ns6`#+rcRt>iI^2jfNd@c7z&nMd&p5LX-klWbiIgWzhofhSuy zUGiz=@S$W#tUiYx7)7b0ujujfO?Q0b046{R48xQ0HmvwaW~iaa2H6^rWoH?5mKETp z&1p3m_|vg$sYa*U6>&9#5hZV-519-(RlB0YH&LiniPJR~GyeIL!#tp`0J{)H ziE%s>6s>_HilxO!ZJ{>-R7?&$dCz}-kDUO<9G8B<0>|2@arh!&zKyM=Bo z8s@-=%?kLP|L^kfy!gn7kbiCoE#@}3=-`H<&$E~;p?J#9FuW<_|J+nWiztV0$^V0r z21L$z@S^@RMR0~-=>8oJJY!fI`)QdiI^+-a*Sn_zh?W%T<*q{NlR~T=QAF6wi0)+P zcUA-;Aw!mIH1{h1=|a0ozmJ8MzVi3Zn#B-aYPuk}wvi_y7TU-?&9*p{Rfse9)Nco% zba+?$qyyolu*{>2x-aWN#rmUP`{+MIzeX1!N=y754gZIU@>0-NqO^`{XeaQ+JP30i1hVs??kl^=IPaNrf7S+fyA3}_cf zt@2jB^3L9&f1$<->H|Ul{SP0}5fME4ALRe~@qg&%zmN03?D4-0`Twc&|30z*i55F; h|6gYQzw4YRAbE4+zG-WK=2w)=Cv=T;UTZtv{ts9Unpyw= literal 0 HcmV?d00001 diff --git a/src/TimeLocker/system_control/assets/timelocker-icon-warning.png b/src/TimeLocker/system_control/assets/timelocker-icon-warning.png new file mode 100644 index 0000000000000000000000000000000000000000..42cd0fff83a406fdd0b89a1e1267f1e5ccd9aad2 GIT binary patch literal 33316 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 r{=xr= None: if install_signal_handlers: self.install_signal_handlers() + status_thread = None + if self.status_event_transport is not None: + status_thread = Thread( + target=self._serve_status_events, + name="timelocker-status-events", + daemon=True, + ) + status_thread.start() try: self.transport.serve(self.dispatcher) except OSError: if not self.stop_event.is_set(): raise + finally: + self.stop() + if status_thread is not None: + status_thread.join(timeout=1.0) + + def _serve_status_events(self) -> None: + assert self.status_event_transport is not None + try: + self.status_event_transport.serve( + self.status_event_broker, + self.transport.identity_provider, + self.membership_resolver, + ) + except OSError: + return def install_signal_handlers(self) -> None: def _handle_signal(_signum: int, _frame: FrameType | None) -> None: @@ -319,6 +354,16 @@ def stop(self) -> None: listener.close() except OSError: pass + status_listener = ( + getattr(self.status_event_transport, "listener", None) + if self.status_event_transport is not None + else None + ) + if isinstance(status_listener, socket.socket): + try: + status_listener.close() + except OSError: + pass def build_linux_backend( @@ -326,7 +371,10 @@ def build_linux_backend( paths: LinuxBackendPaths, socket_mode: str = "systemd", listener: socket.socket | None = None, + status_listener: socket.socket | None = None, systemd_descriptor: int = 3, + status_systemd_descriptor: int = 4, + status_socket_mode: str = "systemd", request_timeout_seconds: float = 5.0, membership_resolver: GroupMembershipResolver | None = None, backup_adapter: BackupMutationAdapter | None = None, @@ -343,16 +391,29 @@ def build_linux_backend( raise ValueError("max_diagnostics must be between 1 and 100000") if socket_mode not in {"systemd", "listener"}: raise ValueError("socket_mode must be 'systemd' or 'listener'") + if status_socket_mode not in {"systemd", "listener"}: + raise ValueError("status_socket_mode must be 'systemd' or 'listener'") if socket_mode == "listener": if listener is None: raise ValueError("listener socket is required for listener mode") elif listener is not None: raise ValueError("listener socket can only be provided in listener mode") + if status_socket_mode == "listener": + if status_listener is None: + raise ValueError("status socket listener is required for listener mode") + elif status_listener is not None: + raise ValueError("status socket listener can only be provided in listener mode") now = clock or _utc_now stop_event = stop_event or Event() policy = load_system_policy(paths.policy_path, expected_owner=paths.expected_owner) - store = AtomicRecordStore(paths.record_root, max_diagnostics=max_diagnostics) + status_event_broker = BoundedStatusEventBroker() + status_change_coordinator = StatusChangeCoordinator(status_event_broker) + store = AtomicRecordStore( + paths.record_root, + max_diagnostics=max_diagnostics, + status_change_callback=status_change_coordinator.run_changed, + ) locks = RepositoryMutationLock(paths.lock_root) audit_sink = RootOnlyJsonlAuditSink( paths.audit_log_path, @@ -399,6 +460,13 @@ def build_linux_backend( request_timeout_seconds=request_timeout_seconds, stop_event=stop_event, ) + status_event_transport = _build_status_transport( + policy=policy, + socket_mode=status_socket_mode, + listener=status_listener if status_socket_mode == "listener" else None, + systemd_descriptor=status_systemd_descriptor, + stop_event=stop_event, + ) dispatcher = LocalControlDispatcher( policy=policy, membership_resolver=membership_resolver, @@ -410,6 +478,7 @@ def build_linux_backend( retention_adapter=retention_adapter, retention_plan_provider=retention_plan_provider, schedule_summary_provider=schedule_summary_provider, + status_change_coordinator=status_change_coordinator, trigger_root=paths.trigger_root, clock=now, ), @@ -421,8 +490,12 @@ def build_linux_backend( locks=locks, dispatcher=dispatcher, transport=transport, + status_event_transport=status_event_transport, audit_sink=audit_sink, stop_event=stop_event, + status_event_broker=status_event_broker, + status_change_coordinator=status_change_coordinator, + membership_resolver=membership_resolver, reconciled_run_ids=tuple(record.run_id for record in reconciled), ) @@ -521,6 +594,34 @@ def _systemd_exit_status(value: str | None) -> int | None: return parsed if 0 <= parsed <= 255 else None +def _systemd_socket_descriptors( + environment: Mapping[str, str] | None = None, + *, + process_id: int | None = None, +) -> tuple[int, int]: + """Resolve named control and event descriptors from systemd activation.""" + environment = os.environ if environment is None else environment + process_id = os.getpid() if process_id is None else process_id + if type(process_id) is not int or process_id <= 0: + raise ValueError("process_id must be a positive integer") + listen_pid = environment.get("LISTEN_PID", "") + listen_fds = environment.get("LISTEN_FDS", "") + if ( + not listen_pid.isascii() + or not listen_pid.isdecimal() + or int(listen_pid) != process_id + or not listen_fds.isascii() + or not listen_fds.isdecimal() + or int(listen_fds) != 2 + ): + raise RuntimeError("required systemd socket descriptors are unavailable") + names = environment.get("LISTEN_FDNAMES", "").split(":") + if len(names) != 2 or set(names) != {"control", "status-events"}: + raise RuntimeError("required systemd socket descriptor names are unavailable") + descriptors = {name: 3 + index for index, name in enumerate(names)} + return descriptors["control"], descriptors["status-events"] + + def main(argv: list[str] | None = None) -> None: """Run one allowlisted privileged system-control process mode.""" parser = argparse.ArgumentParser(prog="timelocker-system-control") @@ -614,9 +715,12 @@ def main(argv: list[str] | None = None) -> None: exit_status=_systemd_exit_status(os.environ.get("EXIT_STATUS")), ) else: + control_descriptor, status_descriptor = _systemd_socket_descriptors() run_linux_backend( paths=paths, socket_mode="systemd", + systemd_descriptor=control_descriptor, + status_systemd_descriptor=status_descriptor, production_target_path=arguments.production_target, ) except (OSError, PermissionError, RuntimeError, TypeError, ValueError): @@ -648,6 +752,32 @@ def _build_transport( ) +def _build_status_transport( + *, + policy: SystemPolicy, + socket_mode: str, + listener: socket.socket | None, + systemd_descriptor: int, + stop_event: Event, +) -> LinuxStatusEventTransport: + if socket_mode == "listener": + assert listener is not None + return LinuxStatusEventTransport( + listener, + max_frame_bytes=policy.max_request_bytes, + heartbeat_interval_seconds=5.0, + operator_group=policy.operator_group, + stop_event=stop_event, + ) + return LinuxStatusEventTransport.from_systemd( + descriptor=systemd_descriptor, + heartbeat_interval_seconds=5.0, + max_frame_bytes=policy.max_request_bytes, + operator_group=policy.operator_group, + stop_event=stop_event, + ) + + def _build_handlers( *, policy: SystemPolicy, @@ -657,11 +787,16 @@ def _build_handlers( retention_adapter: RetentionAdapter, retention_plan_provider: RetentionPlanProvider, schedule_summary_provider: ScheduleSummaryProvider, + status_change_coordinator: StatusChangeCoordinator | None = None, trigger_root: Path, clock: Callable[[], datetime], ) -> Mapping[SystemAction, Callable[[object], object]]: from .protocol import RequestEnvelope + status_change_coordinator = status_change_coordinator or StatusChangeCoordinator( + BoundedStatusEventBroker() + ) + def health(_request: object) -> Mapping[str, object]: return { "backend_available": True, @@ -723,6 +858,28 @@ def schedule_summary(_request: object) -> Mapping[str, object]: raise TypeError("schedule_summary_provider returned an invalid summary") return _schedule_to_wire(summary) + def status_snapshot(_request: object) -> Mapping[str, object]: + def build_snapshot(revision: StatusRevision) -> StatusSnapshot: + summary = schedule_summary_provider.get_schedule_summary() + if not isinstance(summary, ScheduleSummary): + raise TypeError( + "schedule_summary_provider returned an invalid summary" + ) + runs = store.list_status_runs() + return StatusSnapshot.from_run_history( + revision=revision, + backend_status=BackendStatus.AVAILABLE, + active_operations=sum( + record.state in {RunState.QUEUED, RunState.RUNNING} + for record in runs + ), + runs=runs, + next_backup_at=summary.next_backup_at, + next_retention_at=summary.next_retention_at, + ) + + return status_change_coordinator.snapshot(build_snapshot).to_wire() + def ui_availability(_request: object) -> Mapping[str, object]: return {"available": False} @@ -732,6 +889,7 @@ def ui_availability(_request: object) -> Mapping[str, object]: SystemAction.RUN_DETAIL: run_detail, SystemAction.DIAGNOSTIC_LIST: diagnostic_list, SystemAction.SCHEDULE_SUMMARY: schedule_summary, + SystemAction.STATUS_SNAPSHOT: status_snapshot, SystemAction.UI_AVAILABILITY: ui_availability, } if not isinstance(backup_adapter, FailClosedBackupMutationAdapter): diff --git a/src/TimeLocker/system_control/client.py b/src/TimeLocker/system_control/client.py index 2e3a197..15945f3 100644 --- a/src/TimeLocker/system_control/client.py +++ b/src/TimeLocker/system_control/client.py @@ -16,6 +16,7 @@ RetentionActionRequest, RunQuery, RunRecordView, + StatusSnapshot, ) from .protocol import RequestEnvelope, ResponseEnvelope from .types import ProtocolErrorCode, ResponseStatus, SystemAction @@ -106,6 +107,11 @@ def get_schedule_summary(self) -> ScheduleSummary: result = self._request(SystemAction.SCHEDULE_SUMMARY, {}) return ScheduleSummary.from_mapping(result) + def get_status_snapshot(self) -> StatusSnapshot: + """Return the backend's coherent allowlisted status projection.""" + result = self._request(SystemAction.STATUS_SNAPSHOT, {}) + return StatusSnapshot.from_mapping(result) + def _request( self, action: SystemAction, diff --git a/src/TimeLocker/system_control/deployment.py b/src/TimeLocker/system_control/deployment.py index 2337f02..42ec045 100644 --- a/src/TimeLocker/system_control/deployment.py +++ b/src/TimeLocker/system_control/deployment.py @@ -9,9 +9,13 @@ from pathlib import Path import shutil -from .models import PROTOCOL_VERSION -from .release_launcher import ImmutableReleaseResolver, SelectedRelease -from .validation import require_int, require_safe_identifier +from .models import PROTOCOL_VERSION, STATUS_EVENT_PROTOCOL_VERSION +from .release_launcher import ( + ImmutableReleaseResolver, + ReleaseManifest, + SelectedRelease, +) +from .validation import require_bool, require_int, require_safe_identifier class DeploymentError(RuntimeError): @@ -67,6 +71,64 @@ def __post_init__(self) -> None: raise ValueError("asset hash must be a lowercase SHA-256 digest") +@dataclass(frozen=True, slots=True) +class ReleaseProbeTargets: + """Trusted artifacts and protocol versions a probe must exercise.""" + + cli: Path + backend: Path + tray: Path + control_protocol_version: int + event_protocol_version: int | None + + +@dataclass(frozen=True, slots=True) +class ReleaseProbeResult: + """Fail-closed activation evidence returned by the deployment probe.""" + + cli_compatible: bool + backend_compatible: bool + tray_compatible: bool + control_status_available: bool + event_channel_available: bool + backup_timer_active: bool + backup_timer_enabled: bool + retention_timer_active: bool + retention_timer_enabled: bool + control_protocol_version: int + event_protocol_version: int | None + + def __post_init__(self) -> None: + for field_name in ( + "cli_compatible", + "backend_compatible", + "tray_compatible", + "control_status_available", + "event_channel_available", + "backup_timer_active", + "backup_timer_enabled", + "retention_timer_active", + "retention_timer_enabled", + ): + require_bool(getattr(self, field_name), field=field_name) + require_int( + self.control_protocol_version, + field="control_protocol_version", + minimum=PROTOCOL_VERSION, + maximum=PROTOCOL_VERSION, + ) + if self.event_protocol_version is not None: + require_int( + self.event_protocol_version, + field="event_protocol_version", + minimum=STATUS_EVENT_PROTOCOL_VERSION, + maximum=STATUS_EVENT_PROTOCOL_VERSION, + ) + + +ReleaseHealthProbe = Callable[[ReleaseProbeTargets], ReleaseProbeResult] + + class SystemReleaseDeployment: """Install a validated asset set and activate only healthy staged releases.""" @@ -122,42 +184,86 @@ def activate( self, release_id: str, *, - health_probe: Callable[[Path, Path, Path], bool], + health_probe: ReleaseHealthProbe, ) -> SelectedRelease: - """Select a release only after CLI, backend, and tray probes pass.""" - executables = tuple( - self.resolver._resolve_release(release_id, entrypoint=entrypoint) - for entrypoint in ( - "venv/bin/timelocker", - "venv/bin/timelocker-system-control", - "venv/bin/timelocker-tray", + """Select a release only after its complete compatibility probe passes.""" + manifest = self.resolver.release_manifest(release_id) + if manifest.event_protocol_version is None: + raise DeploymentError( + "staged release does not declare event protocol compatibility" ) - ) - if health_probe(*executables) is not True: + targets = self._probe_targets(release_id, manifest) + result = health_probe(targets) + if not self._probe_passed(result, targets, require_event=True): raise DeploymentError("staged release compatibility probe failed") return self.resolver.select(release_id) + def _probe_targets( + self, + release_id: str, + manifest: ReleaseManifest, + ) -> ReleaseProbeTargets: + return ReleaseProbeTargets( + cli=self.resolver._resolve_release( + release_id, + entrypoint="venv/bin/timelocker", + ), + backend=self.resolver._resolve_release( + release_id, + entrypoint="venv/bin/timelocker-system-control", + ), + tray=self.resolver._resolve_release( + release_id, + entrypoint="venv/bin/timelocker-tray", + ), + control_protocol_version=manifest.control_protocol_version, + event_protocol_version=manifest.event_protocol_version, + ) + def rollback( self, *, - health_probe: Callable[[Path, Path, Path], bool], + health_probe: ReleaseHealthProbe, ) -> SelectedRelease: - """Restore the prior selector only after its artifacts pass probes.""" + """Restore the prior selector while preserving control and timer health.""" current = self.resolver._read_selector_optional() if current is None or current.previous is None: raise DeploymentError("no previous release is available") - executables = tuple( - self.resolver._resolve_release(current.previous, entrypoint=entrypoint) - for entrypoint in ( - "venv/bin/timelocker", - "venv/bin/timelocker-system-control", - "venv/bin/timelocker-tray", - ) - ) - if health_probe(*executables) is not True: + manifest = self.resolver.release_manifest(current.previous) + targets = self._probe_targets(current.previous, manifest) + result = health_probe(targets) + if not self._probe_passed(result, targets, require_event=False): raise DeploymentError("rollback release compatibility probe failed") return self.resolver.rollback() + @staticmethod + def _probe_passed( + result: object, + targets: ReleaseProbeTargets, + *, + require_event: bool, + ) -> bool: + if not isinstance(result, ReleaseProbeResult): + return False + if ( + result.control_protocol_version != targets.control_protocol_version + or result.event_protocol_version != targets.event_protocol_version + ): + return False + required = ( + result.cli_compatible, + result.backend_compatible, + result.tray_compatible, + result.control_status_available, + result.backup_timer_active, + result.backup_timer_enabled, + result.retention_timer_active, + result.retention_timer_enabled, + ) + return all(required) and ( + result.event_channel_available if require_event else True + ) + def linux_asset_targets( *, @@ -197,6 +303,11 @@ def linux_asset_targets( unit_root / "timelocker-control.socket", 0o644, ), + AssetTarget( + "timelocker-status-events.socket", + unit_root / "timelocker-status-events.socket", + 0o644, + ), AssetTarget( "timelocker-retention.service", unit_root / "timelocker-retention.service", @@ -223,6 +334,14 @@ def linux_asset_targets( icon_root / "timelocker.png", 0o644, ), + *( + AssetTarget( + f"timelocker-icon-{status}.png", + icon_root / f"timelocker-{status}.png", + 0o644, + ) + for status in ("idle", "running", "success", "warning", "error") + ), ) @@ -241,6 +360,28 @@ def build_asset_manifest( ) +def build_release_manifest( + *, + release_id: str, + package_version: str, +) -> dict[str, object]: + """Build schema-2 metadata binding all selected process protocols.""" + mapping: dict[str, object] = { + "schema_version": 2, + "release_id": release_id, + "package_version": require_safe_identifier( + package_version, + field="package_version", + maximum=64, + ), + "control_protocol_version": PROTOCOL_VERSION, + "event_protocol_version": STATUS_EVENT_PROTOCOL_VERSION, + "entrypoint": "venv/bin/timelocker", + } + ReleaseManifest.from_mapping(mapping) + return mapping + + def _sha256(path: Path) -> str: try: return hashlib.sha256(path.read_bytes()).hexdigest() diff --git a/src/TimeLocker/system_control/event_client.py b/src/TimeLocker/system_control/event_client.py new file mode 100644 index 0000000..ff16ab7 --- /dev/null +++ b/src/TimeLocker/system_control/event_client.py @@ -0,0 +1,131 @@ +"""Reconnectable client for the dedicated local status-event socket.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +import json +from pathlib import Path +import socket +from threading import Event + +from .models import StatusEvent + + +DEFAULT_STATUS_EVENT_SOCKET_PATH = Path("/run/timelocker/status-events.sock") +DEFAULT_MAX_EVENT_BYTES = 1_048_576 + + +class StatusEventAccessDenied(RuntimeError): + """Raised when the protected backend denies an event subscription.""" + + +class UnixSocketStatusEventClient: + """Consume allowlisted events with bounded reconnect and frame handling.""" + + def __init__( + self, + *, + socket_path: Path = DEFAULT_STATUS_EVENT_SOCKET_PATH, + heartbeat_timeout_seconds: float = 15.0, + max_event_bytes: int = DEFAULT_MAX_EVENT_BYTES, + base_retry_delay_seconds: float = 0.5, + max_retry_delay_seconds: float = 30.0, + connection_factory: Callable[[], socket.socket] | None = None, + ) -> None: + if ( + isinstance(heartbeat_timeout_seconds, bool) + or not isinstance(heartbeat_timeout_seconds, (int, float)) + or not 0.25 <= heartbeat_timeout_seconds <= 600.0 + ): + raise ValueError("heartbeat_timeout_seconds is outside the supported bound") + if ( + type(max_event_bytes) is not int + or not 1_024 <= max_event_bytes <= 16_777_216 + ): + raise ValueError("max_event_bytes is outside the supported bound") + if ( + isinstance(base_retry_delay_seconds, bool) + or not isinstance(base_retry_delay_seconds, (int, float)) + or base_retry_delay_seconds <= 0 + or max_retry_delay_seconds < base_retry_delay_seconds + or max_retry_delay_seconds > 300.0 + ): + raise ValueError("retry delays are outside the supported bound") + self.socket_path = socket_path + self.heartbeat_timeout_seconds = float(heartbeat_timeout_seconds) + self.max_event_bytes = max_event_bytes + self.base_retry_delay_seconds = float(base_retry_delay_seconds) + self.max_retry_delay_seconds = float(max_retry_delay_seconds) + self._connection_factory = connection_factory + + def events(self, stop_event: Event) -> Iterator[StatusEvent]: + """Yield events, reconnecting until shutdown without steady polling.""" + if not isinstance(stop_event, Event): + raise TypeError("stop_event must be a threading.Event") + retry_delay = self.base_retry_delay_seconds + while not stop_event.is_set(): + connection: socket.socket | None = None + try: + connection = self._connect() + for event in self._connected_events(connection, stop_event): + retry_delay = self.base_retry_delay_seconds + yield event + if stop_event.is_set(): + return + except StatusEventAccessDenied: + raise + except (OSError, TimeoutError, UnicodeDecodeError, ValueError): + pass + finally: + if connection is not None: + try: + connection.close() + except OSError: + pass + if stop_event.wait(retry_delay): + return + retry_delay = min(retry_delay * 2.0, self.max_retry_delay_seconds) + + def _connect(self) -> socket.socket: + if self._connection_factory is not None: + connection = self._connection_factory() + else: + if not hasattr(socket, "AF_UNIX"): + raise OSError("Unix sockets are unavailable") + connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + connection.connect(str(self.socket_path)) + connection.settimeout(self.heartbeat_timeout_seconds) + return connection + + def _connected_events( + self, + connection: socket.socket, + stop_event: Event, + ) -> Iterator[StatusEvent]: + buffer = b"" + while not stop_event.is_set(): + frame, buffer = self._receive_frame(connection, buffer) + decoded = json.loads(frame.decode("utf-8")) + if isinstance(decoded, dict) and decoded.get("status") == "denied": + if set(decoded) != {"status", "safe_summary"}: + raise ValueError("invalid denial frame") + raise StatusEventAccessDenied("System event access denied.") + yield StatusEvent.from_mapping(decoded) + + def _receive_frame( + self, + connection: socket.socket, + buffer: bytes, + ) -> tuple[bytes, bytes]: + while b"\n" not in buffer: + remaining = self.max_event_bytes + 1 - len(buffer) + if remaining <= 0: + raise ValueError("event frame exceeds configured bound") + chunk = connection.recv(min(65_536, remaining)) + if not chunk: + raise OSError("status event connection closed") + buffer += chunk + frame, remainder = buffer.split(b"\n", 1) + if not frame or len(frame) > self.max_event_bytes: + raise ValueError("invalid event frame") + return frame, remainder diff --git a/src/TimeLocker/system_control/interfaces.py b/src/TimeLocker/system_control/interfaces.py index 793fc4d..560deab 100644 --- a/src/TimeLocker/system_control/interfaces.py +++ b/src/TimeLocker/system_control/interfaces.py @@ -1,5 +1,6 @@ """Platform and client interfaces for the TimeLocker system-control boundary.""" +from collections.abc import Iterator from dataclasses import dataclass from typing import Protocol from uuid import UUID @@ -13,7 +14,11 @@ RetentionActionRequest, RunQuery, RunRecordView, + StatusEvent, + StatusRevision, + StatusSnapshot, ) +from .types import StatusEventKind from .validation import require_int, require_safe_identifier @@ -76,6 +81,55 @@ def serve(self, handler: ControlRequestHandler) -> None: """Serve requests until the transport is stopped.""" +class StatusSnapshotProvider(Protocol): + """Provide the current platform-neutral status snapshot.""" + + def snapshot(self) -> StatusSnapshot: + """Return the latest safe status snapshot.""" + + +class StatusEventBroker(Protocol): + """Own monotonic status revisions and publish bounded event updates.""" + + def current_revision(self) -> StatusRevision: + """Return the current backend-session revision.""" + + def publish_change(self, kind: StatusEventKind) -> StatusRevision: + """Advance and return the revision for an emitted status change.""" + + def subscribe(self) -> "StatusSubscription": + """Return one bounded status subscription.""" + + +class StatusSubscription(Protocol): + """Bounded event queue owned by one subscribed client.""" + + def next_event(self, timeout_seconds: float | None = None) -> StatusEvent | None: + """Return the next event, or None after timeout or closure.""" + + def close(self) -> None: + """Close and unregister this subscription.""" + + +class StatusEventTransport(Protocol): + """Serve authenticated event subscriptions without owning platform state.""" + + def serve( + self, + broker: StatusEventBroker, + identity_provider: PeerIdentityProvider, + membership_resolver: GroupMembershipResolver, + ) -> None: + """Serve status events until the transport is stopped.""" + + +class StatusEventClient(Protocol): + """Consume status events from a platform event transport.""" + + def events(self, stop_event: object) -> Iterator[StatusEvent]: + """Yield status events until the caller signals shutdown.""" + + class SystemControlClient(Protocol): """Client contract shared by the CLI, tray, and platform adapters.""" @@ -96,3 +150,6 @@ def request_retention(self, request: RetentionActionRequest) -> ActionReceipt: def get_schedule_summary(self) -> ScheduleSummary: """Return next scheduled backup and retention run timestamps.""" + + def get_status_snapshot(self) -> StatusSnapshot: + """Return one authorized safe backend status snapshot.""" diff --git a/src/TimeLocker/system_control/linux_adapter.py b/src/TimeLocker/system_control/linux_adapter.py index d61c64c..72a98e9 100644 --- a/src/TimeLocker/system_control/linux_adapter.py +++ b/src/TimeLocker/system_control/linux_adapter.py @@ -3,13 +3,23 @@ from __future__ import annotations import grp +import json import pwd import socket import struct -from threading import Event +from threading import BoundedSemaphore, Event, Lock, Thread, current_thread -from .interfaces import ControlRequestHandler, PeerIdentity -from .validation import require_group_name +from .interfaces import ( + ControlRequestHandler, + GroupMembershipResolver, + PeerIdentity, + PeerIdentityProvider, + StatusEventBroker, +) +from .models import StatusEvent +from .status_events import StatusSubscriptionLimitError +from .types import StatusEventKind +from .validation import require_group_name, require_int class LinuxPeerIdentityProvider: @@ -143,6 +153,209 @@ def serve_connection( connection.sendall(response) +class LinuxStatusEventTransport: + """Serve bounded status events on a dedicated local socket.""" + + def __init__( + self, + listener: socket.socket, + *, + heartbeat_interval_seconds: float = 5.0, + send_timeout_seconds: float = 2.0, + max_frame_bytes: int = 1_048_576, + max_connections: int = 32, + operator_group: str = "timelocker-operators", + stop_event: Event | None = None, + ) -> None: + if not isinstance(listener, socket.socket): + raise TypeError("listener must be a socket") + if listener.family != socket.AF_UNIX: + raise ValueError("listener must be an AF_UNIX socket") + if ( + type(max_frame_bytes) is not int + or not 1_024 <= max_frame_bytes <= 16_777_216 + ): + raise ValueError("max_frame_bytes is outside the supported bound") + if ( + isinstance(heartbeat_interval_seconds, bool) + or not isinstance(heartbeat_interval_seconds, (int, float)) + or not 0.25 <= heartbeat_interval_seconds <= 300.0 + ): + raise ValueError("heartbeat_interval_seconds is outside the supported bound") + if ( + isinstance(send_timeout_seconds, bool) + or not isinstance(send_timeout_seconds, (int, float)) + or not 0.1 <= send_timeout_seconds <= 60.0 + ): + raise ValueError("send_timeout_seconds is outside the supported bound") + self.listener = listener + self.heartbeat_interval_seconds = float(heartbeat_interval_seconds) + self.send_timeout_seconds = float(send_timeout_seconds) + self.max_frame_bytes = max_frame_bytes + self.max_connections = require_int( + max_connections, + field="max_connections", + minimum=1, + maximum=256, + ) + self.operator_group = require_group_name(operator_group) + self.stop_event = stop_event or Event() + self._connection_slots = BoundedSemaphore(self.max_connections) + self._threads: set[Thread] = set() + self._threads_lock = Lock() + + @classmethod + def from_systemd( + cls, + *, + descriptor: int, + heartbeat_interval_seconds: float, + send_timeout_seconds: float = 2.0, + max_frame_bytes: int = 1_048_576, + max_connections: int = 32, + operator_group: str = "timelocker-operators", + stop_event: Event | None = None, + ) -> "LinuxStatusEventTransport": + if type(descriptor) is not int or descriptor < 3: + raise ValueError("descriptor must be a systemd-passed descriptor") + listener = socket.fromfd(descriptor, socket.AF_UNIX, socket.SOCK_STREAM) + if listener.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN) != 1: + listener.close() + raise OSError("systemd descriptor is not a listening socket") + return cls( + listener, + heartbeat_interval_seconds=heartbeat_interval_seconds, + send_timeout_seconds=send_timeout_seconds, + max_frame_bytes=max_frame_bytes, + max_connections=max_connections, + operator_group=operator_group, + stop_event=stop_event, + ) + + def serve( + self, + broker: StatusEventBroker, + identity_provider: PeerIdentityProvider, + membership_resolver: GroupMembershipResolver, + ) -> None: + """Accept subscriptions independently so control and event peers stay live.""" + while not self.stop_event.is_set(): + try: + connection, _address = self.listener.accept() + except OSError: + if self.stop_event.is_set(): + break + raise + if not self._connection_slots.acquire(blocking=False): + connection.close() + continue + worker = Thread( + target=self._connection_worker, + kwargs={ + "connection": connection, + "broker": broker, + "identity_provider": identity_provider, + "membership_resolver": membership_resolver, + }, + daemon=True, + name="timelocker-status-subscriber", + ) + with self._threads_lock: + self._threads.add(worker) + worker.start() + + def _connection_worker( + self, + *, + connection: socket.socket, + broker: StatusEventBroker, + identity_provider: PeerIdentityProvider, + membership_resolver: GroupMembershipResolver, + ) -> None: + try: + with connection: + self.serve_connection( + connection, + broker=broker, + identity_provider=identity_provider, + membership_resolver=membership_resolver, + ) + except (OSError, TimeoutError, ValueError): + pass + finally: + with self._threads_lock: + self._threads.discard(current_thread()) + self._connection_slots.release() + + def serve_connection( + self, + connection: socket.socket, + *, + broker: StatusEventBroker, + identity_provider: PeerIdentityProvider, + membership_resolver: GroupMembershipResolver, + ) -> None: + """Authorize and serve one bounded event subscription.""" + try: + identity = identity_provider.peer_identity(connection) + except (OSError, RuntimeError, TypeError, ValueError): + return + subscription = None + try: + if not self._authorized(identity, membership_resolver): + self._send_denial(connection) + return + + connection.settimeout(self.send_timeout_seconds) + try: + subscription = broker.subscribe() + except StatusSubscriptionLimitError: + return + + while not self.stop_event.is_set(): + event = subscription.next_event(self.heartbeat_interval_seconds) + if not self._authorized(identity, membership_resolver): + return + if event is None: + event = StatusEvent( + revision=broker.current_revision(), + kind=StatusEventKind.HEARTBEAT, + ) + self._send_event(connection, event.to_wire()) + finally: + if subscription is not None: + subscription.close() + + def _send_denial(self, connection: socket.socket) -> None: + self._send_event( + connection, + { + "status": "denied", + "safe_summary": "System access denied.", + }, + ) + + def _send_event(self, connection: socket.socket, payload: dict[str, object]) -> None: + frame = (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode( + "utf-8" + ) + if len(frame) > self.max_frame_bytes: + raise ValueError("event frame exceeds transport bounds") + connection.sendall(frame) + + def _authorized( + self, + identity: PeerIdentity, + membership_resolver: GroupMembershipResolver, + ) -> bool: + try: + return bool( + membership_resolver.is_current_member(identity, self.operator_group) + ) + except (KeyError, OSError, RuntimeError, TypeError, ValueError): + return False + + def _receive_frame(connection: socket.socket, maximum: int) -> bytes: chunks: list[bytes] = [] size = 0 diff --git a/src/TimeLocker/system_control/models.py b/src/TimeLocker/system_control/models.py index a52f877..c12de6a 100644 --- a/src/TimeLocker/system_control/models.py +++ b/src/TimeLocker/system_control/models.py @@ -1,12 +1,13 @@ """Strict platform-neutral models for TimeLocker system operations.""" from dataclasses import dataclass, field -from datetime import datetime +from datetime import UTC, datetime from types import MappingProxyType -from typing import Any, ClassVar, Mapping +from typing import Any, ClassVar, Iterable, Mapping from uuid import UUID from .types import ( + BackendStatus, DiagnosticCode, DiagnosticComponent, DiagnosticLevel, @@ -14,6 +15,7 @@ OperationType, ResultCode, RunState, + StatusEventKind, ) from .validation import ( MAX_COUNTER_VALUE, @@ -35,6 +37,8 @@ PROTOCOL_VERSION = 1 +STATUS_EVENT_SCHEMA_VERSION = 1 +STATUS_EVENT_PROTOCOL_VERSION = 1 DEFAULT_MAX_REQUEST_BYTES = 65_536 DEFAULT_MAX_RESPONSE_RECORDS = 100 @@ -727,6 +731,313 @@ def to_wire(self) -> dict[str, Any]: } +@dataclass(frozen=True, slots=True) +class StatusRevision: + """Monotonic per-session revision for event ordering and coalescing.""" + + session_id: UUID + sequence: int + + def __post_init__(self) -> None: + """Reject unsupported revision types and non-monotonic values.""" + object.__setattr__( + self, + "session_id", + require_uuid(self.session_id, field="session_id"), + ) + object.__setattr__( + self, + "sequence", + require_int( + self.sequence, + field="sequence", + minimum=0, + maximum=MAX_COUNTER_VALUE, + ), + ) + + @classmethod + def from_mapping(cls, value: object) -> "StatusRevision": + """Parse an exact revision mapping from an untrusted payload.""" + revision = require_exact_mapping( + value, + field="revision", + required=frozenset({"session_id", "sequence"}), + ) + return cls( + session_id=revision["session_id"], + sequence=revision["sequence"], + ) + + def to_wire(self) -> dict[str, Any]: + """Return the exact wire representation for the status revision.""" + return { + "session_id": str(self.session_id), + "sequence": self.sequence, + } + + def is_strictly_newer_than(self, other: "StatusRevision") -> bool: + """Return True only for newer revisions within the same session.""" + if not isinstance(other, StatusRevision): + raise TypeError("other must be a StatusRevision") + return self.session_id == other.session_id and self.sequence > other.sequence + + +@dataclass(frozen=True, slots=True) +class StatusEvent: + """Allowlisted status-event invalidation contract for tray subscribers.""" + + revision: StatusRevision + kind: StatusEventKind + schema_version: int = STATUS_EVENT_SCHEMA_VERSION + protocol_version: int = STATUS_EVENT_PROTOCOL_VERSION + + def __post_init__(self) -> None: + """Validate supported versions and normalize nested immutable fields.""" + schema_version = require_int( + self.schema_version, + field="schema_version", + minimum=1, + maximum=255, + ) + if schema_version != STATUS_EVENT_SCHEMA_VERSION: + raise ValueError("schema_version is unsupported") + protocol_version = require_int( + self.protocol_version, + field="protocol_version", + minimum=1, + maximum=255, + ) + if protocol_version != STATUS_EVENT_PROTOCOL_VERSION: + raise ValueError("protocol_version is unsupported") + revision = ( + self.revision + if isinstance(self.revision, StatusRevision) + else StatusRevision.from_mapping(self.revision) + ) + object.__setattr__(self, "schema_version", schema_version) + object.__setattr__(self, "protocol_version", protocol_version) + object.__setattr__(self, "revision", revision) + object.__setattr__( + self, + "kind", + require_enum(self.kind, StatusEventKind, field="kind"), + ) + + @classmethod + def from_mapping(cls, value: object) -> "StatusEvent": + """Parse an exact event mapping and reject arbitrary payload fields.""" + event = require_exact_mapping( + value, + field="event", + required=frozenset( + {"schema_version", "protocol_version", "revision", "kind"} + ), + ) + return cls( + schema_version=event["schema_version"], + protocol_version=event["protocol_version"], + revision=event["revision"], + kind=event["kind"], + ) + + def to_wire(self) -> dict[str, Any]: + """Return the exact allowlisted status-event wire shape.""" + return { + "schema_version": self.schema_version, + "protocol_version": self.protocol_version, + "revision": self.revision.to_wire(), + "kind": self.kind.value, + } + + +@dataclass(frozen=True, slots=True) +class StatusSnapshot: + """Safe status projection used by the tray snapshot and event flow.""" + + revision: StatusRevision + backend_status: BackendStatus + active_operations: int + latest_backup: RunRecordView | None = None + last_successful_backup_completed_at: datetime | None = None + latest_retention: RunRecordView | None = None + next_backup_at: datetime | None = None + next_retention_at: datetime | None = None + + def __post_init__(self) -> None: + """Validate exact safe fields and reject mismatched operation payloads.""" + revision = ( + self.revision + if isinstance(self.revision, StatusRevision) + else StatusRevision.from_mapping(self.revision) + ) + latest_backup = _coerce_optional_run_view( + self.latest_backup, + field="latest_backup", + expected_operation=OperationType.BACKUP, + ) + latest_retention = _coerce_optional_run_view( + self.latest_retention, + field="latest_retention", + expected_operation=OperationType.RETENTION, + ) + object.__setattr__(self, "revision", revision) + object.__setattr__( + self, + "backend_status", + require_enum( + self.backend_status, + BackendStatus, + field="backend_status", + ), + ) + object.__setattr__( + self, + "active_operations", + require_int( + self.active_operations, + field="active_operations", + minimum=0, + maximum=MAX_COUNTER_VALUE, + ), + ) + object.__setattr__(self, "latest_backup", latest_backup) + object.__setattr__( + self, + "last_successful_backup_completed_at", + require_optional_utc_datetime( + self.last_successful_backup_completed_at, + field="last_successful_backup_completed_at", + ), + ) + object.__setattr__(self, "latest_retention", latest_retention) + object.__setattr__( + self, + "next_backup_at", + require_optional_utc_datetime( + self.next_backup_at, + field="next_backup_at", + ), + ) + object.__setattr__( + self, + "next_retention_at", + require_optional_utc_datetime( + self.next_retention_at, + field="next_retention_at", + ), + ) + + @classmethod + def from_mapping(cls, value: object) -> "StatusSnapshot": + """Parse the exact allowlisted status-snapshot wire contract.""" + snapshot = require_exact_mapping( + value, + field="status_snapshot", + required=frozenset( + { + "revision", + "backend_status", + "active_operations", + "latest_backup", + "last_successful_backup_completed_at", + "latest_retention", + "next_backup_at", + "next_retention_at", + } + ), + ) + return cls( + revision=snapshot["revision"], + backend_status=snapshot["backend_status"], + active_operations=snapshot["active_operations"], + latest_backup=snapshot["latest_backup"], + last_successful_backup_completed_at=require_optional_wire_utc_datetime( + snapshot["last_successful_backup_completed_at"], + field="status_snapshot.last_successful_backup_completed_at", + ), + latest_retention=snapshot["latest_retention"], + next_backup_at=require_optional_wire_utc_datetime( + snapshot["next_backup_at"], + field="status_snapshot.next_backup_at", + ), + next_retention_at=require_optional_wire_utc_datetime( + snapshot["next_retention_at"], + field="status_snapshot.next_retention_at", + ), + ) + + @classmethod + def from_run_history( + cls, + *, + revision: StatusRevision, + backend_status: BackendStatus, + active_operations: int, + runs: Iterable[RunRecord | RunRecordView], + next_backup_at: datetime | None = None, + next_retention_at: datetime | None = None, + ) -> "StatusSnapshot": + """Build a deterministic snapshot from an unordered safe run history.""" + run_views = tuple(_coerce_run_view(run, field="runs") for run in runs) + backup_runs = tuple( + run for run in run_views if run.operation is OperationType.BACKUP + ) + retention_runs = tuple( + run for run in run_views if run.operation is OperationType.RETENTION + ) + successful_backups = tuple( + run + for run in backup_runs + if run.state is RunState.SUCCEEDED and run.completed_at is not None + ) + return cls( + revision=revision, + backend_status=backend_status, + active_operations=active_operations, + latest_backup=_latest_run_view(backup_runs), + last_successful_backup_completed_at=( + max(run.completed_at for run in successful_backups) + if successful_backups + else None + ), + latest_retention=_latest_run_view(retention_runs), + next_backup_at=next_backup_at, + next_retention_at=next_retention_at, + ) + + def to_wire(self) -> dict[str, Any]: + """Return the exact status-snapshot wire representation.""" + return { + "revision": self.revision.to_wire(), + "backend_status": self.backend_status.value, + "active_operations": self.active_operations, + "latest_backup": ( + self.latest_backup.to_wire() if self.latest_backup is not None else None + ), + "last_successful_backup_completed_at": ( + self.last_successful_backup_completed_at.isoformat() + if self.last_successful_backup_completed_at is not None + else None + ), + "latest_retention": ( + self.latest_retention.to_wire() + if self.latest_retention is not None + else None + ), + "next_backup_at": ( + self.next_backup_at.isoformat() + if self.next_backup_at is not None + else None + ), + "next_retention_at": ( + self.next_retention_at.isoformat() + if self.next_retention_at is not None + else None + ), + } + + @dataclass(frozen=True, slots=True) class DiagnosticView: """Allowlisted external projection of a diagnostic record.""" @@ -844,3 +1155,48 @@ def validate_counter_value(value: object) -> int: minimum=0, maximum=MAX_COUNTER_VALUE, ) + + +def _coerce_run_view( + value: RunRecord | RunRecordView, + *, + field: str, +) -> RunRecordView: + if isinstance(value, RunRecordView): + return value + if isinstance(value, RunRecord): + return RunRecordView.from_record(value) + raise TypeError(f"{field} must contain RunRecord or RunRecordView values") + + +def _coerce_optional_run_view( + value: object, + *, + field: str, + expected_operation: OperationType, +) -> RunRecordView | None: + if value is None: + return None + run_view = ( + value + if isinstance(value, RunRecordView) + else RunRecordView.from_mapping(value) + ) + if run_view.operation is not expected_operation: + raise ValueError(f"{field} must be a {expected_operation.value} run") + return run_view + + +def _latest_run_view(runs: Iterable[RunRecordView]) -> RunRecordView | None: + run_list = tuple(runs) + if not run_list: + return None + minimum_timestamp = datetime.min.replace(tzinfo=UTC) + return max( + run_list, + key=lambda run: ( + run.started_at, + run.completed_at if run.completed_at is not None else minimum_timestamp, + str(run.run_id), + ), + ) diff --git a/src/TimeLocker/system_control/protocol.py b/src/TimeLocker/system_control/protocol.py index 990cb99..550a4d1 100644 --- a/src/TimeLocker/system_control/protocol.py +++ b/src/TimeLocker/system_control/protocol.py @@ -11,6 +11,7 @@ DiagnosticView, PROTOCOL_VERSION, RunRecordView, + StatusSnapshot, ) from .types import ( DiagnosticLevel, @@ -47,6 +48,7 @@ frozenset({"limit", "run_id", "level"}), ), SystemAction.SCHEDULE_SUMMARY: (frozenset(), frozenset()), + SystemAction.STATUS_SNAPSHOT: (frozenset(), frozenset()), SystemAction.BACKUP_REQUEST: (frozenset({"target_id"}), frozenset()), SystemAction.RETENTION_REQUEST: ( frozenset({"policy_fingerprint"}), @@ -82,6 +84,18 @@ } ) _ACTION_RECEIPT_FIELDS = frozenset({"request_id", "accepted", "status", "run_id"}) +_STATUS_SNAPSHOT_FIELDS = frozenset( + { + "revision", + "backend_status", + "active_operations", + "latest_backup", + "last_successful_backup_completed_at", + "latest_retention", + "next_backup_at", + "next_retention_at", + } +) PROTOCOL_ERROR_SUMMARIES: Mapping[ProtocolErrorCode, str] = MappingProxyType( { @@ -371,6 +385,8 @@ def project_response(action: SystemAction, payload: object) -> dict[str, Any]: return _project_health(payload) if action is SystemAction.SCHEDULE_SUMMARY: return _project_schedule(payload) + if action is SystemAction.STATUS_SNAPSHOT: + return _project_status_snapshot(payload) if action is SystemAction.UI_AVAILABILITY: projected = _project_mapping(payload, frozenset({"available"}), "ui") if "available" not in projected: @@ -480,3 +496,17 @@ def _project_schedule(value: object) -> dict[str, Any]: field=f"schedule.{key}", ).isoformat() return projected + + +def _project_status_snapshot(value: object) -> dict[str, Any]: + projected = _project_mapping(value, _STATUS_SNAPSHOT_FIELDS, "status_snapshot") + missing = _STATUS_SNAPSHOT_FIELDS - frozenset(projected) + if missing: + raise ValueError( + f"status_snapshot is missing required fields: {sorted(missing)}" + ) + if projected["latest_backup"] is not None: + projected["latest_backup"] = _project_run(projected["latest_backup"]) + if projected["latest_retention"] is not None: + projected["latest_retention"] = _project_run(projected["latest_retention"]) + return StatusSnapshot.from_mapping(projected).to_wire() diff --git a/src/TimeLocker/system_control/release_launcher.py b/src/TimeLocker/system_control/release_launcher.py index d2a140f..bf25028 100644 --- a/src/TimeLocker/system_control/release_launcher.py +++ b/src/TimeLocker/system_control/release_launcher.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Mapping, NoReturn -from .models import PROTOCOL_VERSION +from .models import PROTOCOL_VERSION, STATUS_EVENT_PROTOCOL_VERSION from .validation import require_exact_mapping, require_int, require_safe_identifier @@ -68,12 +68,23 @@ class ReleaseManifest: release_id: str package_version: str - protocol_version: int + control_protocol_version: int + event_protocol_version: int | None entrypoint: str = "venv/bin/timelocker" - schema_version: int = 1 + schema_version: int = 2 @classmethod def from_mapping(cls, value: object) -> "ReleaseManifest": + if not isinstance(value, Mapping): + raise ReleaseResolutionError("release manifest is invalid") + schema_version = require_int( + value.get("schema_version"), + field="schema_version", + minimum=1, + maximum=2, + ) + if schema_version == 1: + return cls._from_legacy_mapping(value) mapping = require_exact_mapping( value, field="release manifest", @@ -82,7 +93,8 @@ def from_mapping(cls, value: object) -> "ReleaseManifest": "schema_version", "release_id", "package_version", - "protocol_version", + "control_protocol_version", + "event_protocol_version", "entrypoint", } ), @@ -91,24 +103,62 @@ def from_mapping(cls, value: object) -> "ReleaseManifest": if entrypoint != "venv/bin/timelocker": raise ReleaseResolutionError("release entrypoint is not allowlisted") return cls( - schema_version=require_int( - mapping["schema_version"], - field="schema_version", - minimum=1, - maximum=1, + schema_version=schema_version, + release_id=_release_id(mapping["release_id"]), + package_version=require_safe_identifier( + mapping["package_version"], + field="package_version", + maximum=64, + ), + control_protocol_version=require_int( + mapping["control_protocol_version"], + field="control_protocol_version", + minimum=PROTOCOL_VERSION, + maximum=PROTOCOL_VERSION, + ), + event_protocol_version=require_int( + mapping["event_protocol_version"], + field="event_protocol_version", + minimum=STATUS_EVENT_PROTOCOL_VERSION, + maximum=STATUS_EVENT_PROTOCOL_VERSION, + ), + entrypoint=entrypoint, + ) + + @classmethod + def _from_legacy_mapping(cls, value: Mapping[str, object]) -> "ReleaseManifest": + """Read schema 1 for rollback without claiming event compatibility.""" + mapping = require_exact_mapping( + value, + field="release manifest", + required=frozenset( + { + "schema_version", + "release_id", + "package_version", + "protocol_version", + "entrypoint", + } ), + ) + entrypoint = mapping["entrypoint"] + if entrypoint != "venv/bin/timelocker": + raise ReleaseResolutionError("release entrypoint is not allowlisted") + return cls( + schema_version=1, release_id=_release_id(mapping["release_id"]), package_version=require_safe_identifier( mapping["package_version"], field="package_version", maximum=64, ), - protocol_version=require_int( + control_protocol_version=require_int( mapping["protocol_version"], field="protocol_version", minimum=PROTOCOL_VERSION, maximum=PROTOCOL_VERSION, ), + event_protocol_version=None, entrypoint=entrypoint, ) @@ -206,6 +256,19 @@ def _resolve_release( raise ReleaseResolutionError("release entrypoint escapes release directory") return executable + def release_manifest(self, release_id: str) -> ReleaseManifest: + """Return trusted compatibility metadata for one staged release.""" + release_id = _release_id(release_id) + self._require_trusted_directory(self.releases_root) + release_dir = self.releases_root / release_id + self._require_trusted_directory(release_dir) + manifest_path = release_dir / "release.json" + self._require_trusted_file(manifest_path) + manifest = ReleaseManifest.from_mapping(_read_json(manifest_path)) + if manifest.release_id != release_id: + raise ReleaseResolutionError("release manifest identity mismatch") + return manifest + def _require_trusted_file(self, path: Path, *, executable: bool = False) -> None: try: metadata = path.lstat() diff --git a/src/TimeLocker/system_control/status_events.py b/src/TimeLocker/system_control/status_events.py new file mode 100644 index 0000000..9a13ce3 --- /dev/null +++ b/src/TimeLocker/system_control/status_events.py @@ -0,0 +1,224 @@ +"""Bounded in-memory status revisions, subscriptions, and change sources.""" + +from __future__ import annotations + +from collections import deque +from collections.abc import Callable, Iterator +from enum import StrEnum +from threading import Condition, Event, RLock +from typing import Protocol, TypeVar +from uuid import UUID, uuid4 + +from .models import StatusEvent, StatusRevision +from .types import StatusEventKind +from .validation import MAX_COUNTER_VALUE, require_int, require_uuid + + +_Snapshot = TypeVar("_Snapshot") + + +class StatusSubscriptionLimitError(RuntimeError): + """Raised when the configured subscriber bound has been reached.""" + + +class StatusWatchSignal(StrEnum): + """Sanitized watcher observations; uncertainty requires full resync.""" + + CHANGED = "changed" + UNCERTAIN = "uncertain" + + +class ProtectedStateWatcher(Protocol): + """Injectable platform watcher for protected record or schedule changes.""" + + def events(self, stop_event: Event) -> Iterator[StatusWatchSignal]: + """Yield bounded change signals until shutdown.""" + + +class BoundedStatusSubscription: + """One subscriber retaining at most the newest pending event.""" + + def __init__(self, close_callback: Callable[[], None]) -> None: + self._condition = Condition() + self._events: deque[StatusEvent] = deque(maxlen=1) + self._closed = False + self._close_callback = close_callback + + def next_event(self, timeout_seconds: float | None = None) -> StatusEvent | None: + """Return the next event, or None after timeout or closure.""" + if timeout_seconds is not None and ( + isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, (int, float)) + or timeout_seconds <= 0 + or timeout_seconds > 3_600 + ): + raise ValueError("timeout_seconds must be between zero and 3600") + with self._condition: + if not self._events and not self._closed: + self._condition.wait(timeout_seconds) + if self._events: + return self._events.popleft() + return None + + def close(self) -> None: + """Close once and unregister from the owning broker.""" + callback: Callable[[], None] | None = None + with self._condition: + if not self._closed: + self._closed = True + self._events.clear() + self._condition.notify_all() + callback = self._close_callback + if callback is not None: + callback() + + def _offer(self, event: StatusEvent) -> None: + with self._condition: + if self._closed: + return + self._events.append(event) + self._condition.notify() + + +class BoundedStatusEventBroker: + """Thread-safe session revisions with one pending event per subscriber.""" + + def __init__( + self, + *, + session_id: UUID | None = None, + max_subscribers: int = 32, + ) -> None: + self._lock = RLock() + self._session_id = require_uuid( + session_id or uuid4(), + field="session_id", + ) + self._sequence = 0 + self._max_subscribers = require_int( + max_subscribers, + field="max_subscribers", + minimum=1, + maximum=256, + ) + self._subscriptions: dict[UUID, BoundedStatusSubscription] = {} + + def current_revision(self) -> StatusRevision: + """Return the current immutable backend-session revision.""" + with self._lock: + return StatusRevision(self._session_id, self._sequence) + + def publish_change( + self, + kind: StatusEventKind = StatusEventKind.CHANGED, + ) -> StatusRevision: + """Advance once and coalesce the newest invalidation per subscriber.""" + if kind not in { + StatusEventKind.CHANGED, + StatusEventKind.RESYNC_REQUIRED, + }: + raise ValueError("published changes must invalidate or require resync") + with self._lock: + if self._sequence >= MAX_COUNTER_VALUE: + raise RuntimeError("status revision sequence is exhausted") + self._sequence += 1 + revision = StatusRevision(self._session_id, self._sequence) + event = StatusEvent(revision=revision, kind=kind) + for subscription in tuple(self._subscriptions.values()): + subscription._offer(event) + return revision + + def subscribe(self) -> BoundedStatusSubscription: + """Register one bounded subscriber and enqueue its initial refresh.""" + with self._lock: + if len(self._subscriptions) >= self._max_subscribers: + raise StatusSubscriptionLimitError( + "status subscriber limit has been reached" + ) + subscription_id = uuid4() + subscription = BoundedStatusSubscription( + lambda: self._unsubscribe(subscription_id) + ) + self._subscriptions[subscription_id] = subscription + subscription._offer( + StatusEvent( + revision=StatusRevision(self._session_id, self._sequence), + kind=StatusEventKind.SNAPSHOT_REQUIRED, + ) + ) + return subscription + + def _unsubscribe(self, subscription_id: UUID) -> None: + with self._lock: + self._subscriptions.pop(subscription_id, None) + + +class StatusChangeCoordinator: + """Synchronize snapshot revisions with explicit and watched changes.""" + + def __init__(self, broker: BoundedStatusEventBroker) -> None: + if not isinstance(broker, BoundedStatusEventBroker): + raise TypeError("broker must be a BoundedStatusEventBroker") + self.broker = broker + self._boundary = RLock() + + def snapshot(self, builder: Callable[[StatusRevision], _Snapshot]) -> _Snapshot: + """Build state and its revision under one publication boundary.""" + if not callable(builder): + raise TypeError("builder must be callable") + with self._boundary: + return builder(self.broker.current_revision()) + + def run_changed(self) -> StatusRevision: + """Publish after a durable run mutation.""" + return self._publish(StatusEventKind.CHANGED) + + def schedule_changed(self) -> StatusRevision: + """Publish after a TimeLocker-managed schedule mutation.""" + return self._publish(StatusEventKind.CHANGED) + + def watcher_changed(self, *, uncertain: bool = False) -> StatusRevision: + """Publish a watcher observation, forcing resync after uncertainty.""" + return self._publish( + StatusEventKind.RESYNC_REQUIRED + if uncertain + else StatusEventKind.CHANGED + ) + + def _publish(self, kind: StatusEventKind) -> StatusRevision: + with self._boundary: + return self.broker.publish_change(kind) + + +class ProtectedStateChangeMonitor: + """Translate injected watcher signals into safe broker invalidations.""" + + def __init__( + self, + watcher: ProtectedStateWatcher, + coordinator: StatusChangeCoordinator, + ) -> None: + if not hasattr(watcher, "events"): + raise TypeError("watcher must provide events(stop_event)") + if not isinstance(coordinator, StatusChangeCoordinator): + raise TypeError("coordinator must be a StatusChangeCoordinator") + self.watcher = watcher + self.coordinator = coordinator + + def run(self, stop_event: Event) -> None: + """Publish sanitized changes until the watcher or caller stops.""" + if not isinstance(stop_event, Event): + raise TypeError("stop_event must be a threading.Event") + try: + for signal in self.watcher.events(stop_event): + if stop_event.is_set(): + return + if signal is StatusWatchSignal.CHANGED: + self.coordinator.watcher_changed() + elif signal is StatusWatchSignal.UNCERTAIN: + self.coordinator.watcher_changed(uncertain=True) + else: + self.coordinator.watcher_changed(uncertain=True) + except Exception: + if not stop_event.is_set(): + self.coordinator.watcher_changed(uncertain=True) diff --git a/src/TimeLocker/system_control/storage.py b/src/TimeLocker/system_control/storage.py index 7d24747..4c7164c 100644 --- a/src/TimeLocker/system_control/storage.py +++ b/src/TimeLocker/system_control/storage.py @@ -3,13 +3,14 @@ from __future__ import annotations from contextlib import contextmanager +from collections.abc import Callable, Iterator, Mapping from dataclasses import replace from datetime import datetime, timezone import json import os from pathlib import Path import tempfile -from typing import Any, Iterator, Mapping +from typing import Any from uuid import UUID from .models import ( @@ -152,7 +153,13 @@ def _diagnostic_from_wire(value: object) -> DiagnosticRecord: class AtomicRecordStore: """Persist strictly validated records with process-safe atomic replacement.""" - def __init__(self, root: Path, *, max_diagnostics: int = 1_000) -> None: + def __init__( + self, + root: Path, + *, + max_diagnostics: int = 1_000, + status_change_callback: Callable[[], object] | None = None, + ) -> None: if not isinstance(root, Path): raise TypeError("root must be a Path") if type(max_diagnostics) is not int or not 1 <= max_diagnostics <= 100_000: @@ -162,6 +169,11 @@ def __init__(self, root: Path, *, max_diagnostics: int = 1_000) -> None: self.diagnostics_directory = root / "diagnostics" self._store_lock_path = root / ".record-store.lock" self.max_diagnostics = max_diagnostics + if status_change_callback is not None and not callable( + status_change_callback + ): + raise TypeError("status_change_callback must be callable") + self._status_change_callback = status_change_callback for directory in (root, self.runs_directory, self.diagnostics_directory): directory.mkdir(mode=0o700, parents=True, exist_ok=True) directory.chmod(0o700) @@ -188,6 +200,7 @@ def create_run(self, record: RunRecord) -> None: if destination.exists(): raise InvalidTransitionError("run already exists") self._atomic_write_json(destination, _run_to_wire(record)) + self._notify_status_change() def read_run(self, run_id: UUID | str) -> RunRecord: """Read and validate one durable run.""" @@ -208,6 +221,10 @@ def list_runs(self, query: RunQuery | None = None) -> list[RunRecord]: and (query.state is None or record.state is query.state) ][: query.limit] + def list_status_runs(self) -> list[RunRecord]: + """Return one locked run-history snapshot for internal status projection.""" + return self._list_runs_unbounded() + def _list_runs_unbounded(self) -> list[RunRecord]: """Return all runs for internal startup reconciliation.""" with self._locked(): @@ -236,7 +253,8 @@ def transition(self, run_id: UUID | str, transition: RunTransition) -> RunRecord counters=counters, ) self._atomic_write_json(self._run_path(run_id), _run_to_wire(candidate)) - return candidate + self._notify_status_change() + return candidate def append_diagnostic(self, record: DiagnosticRecord) -> None: """Append one immutable diagnostic and trim only records beyond the bound.""" @@ -252,6 +270,16 @@ def append_diagnostic(self, record: DiagnosticRecord) -> None: stale.unlink() self._fsync_directory(self.diagnostics_directory) + def _notify_status_change(self) -> None: + callback = self._status_change_callback + if callback is None: + return + try: + callback() + except Exception: + # Status delivery must never make a completed durable mutation fail. + return + def list_diagnostics( self, query: DiagnosticQuery | None = None, diff --git a/src/TimeLocker/system_control/tray_client.py b/src/TimeLocker/system_control/tray_client.py index 2967274..b4f510f 100644 --- a/src/TimeLocker/system_control/tray_client.py +++ b/src/TimeLocker/system_control/tray_client.py @@ -4,17 +4,24 @@ from dataclasses import dataclass from datetime import datetime +from threading import Event from typing import Callable, TypeVar from .client import SystemControlClientError, UnixSocketSystemControlClient -from .interfaces import SystemControlClient -from .models import RunQuery, RunRecordView, ScheduleSummary -from .models import RetentionActionRequest, BackupActionRequest -from .types import OperationType, ProtocolErrorCode, ResponseStatus, RunState +from .event_client import StatusEventAccessDenied, UnixSocketStatusEventClient +from .interfaces import StatusEventClient, SystemControlClient +from .models import BackupActionRequest, RetentionActionRequest, StatusSnapshot +from .types import ( + BackendStatus, + ProtocolErrorCode, + ResponseStatus, + RunState, + StatusEventKind, +) ALLOWED_TRAY_ACTIONS = frozenset( - {"status", "backup_now", "retention_now", "open_ui", "quit"} + {"status", "backup_now", "retention_now", "quit"} ) _BACKEND_RETRY_DELAY_SECONDS = 2.0 _BACKEND_RETRY_MAX_SECONDS = 60.0 @@ -28,13 +35,13 @@ class TrayDisplayState: tooltip: str active_operations: int backend_available: bool - last_backup_started_at: datetime | None - last_backup_status: str | None - last_retention_started_at: datetime | None - last_retention_status: str | None + last_successful_backup_completed_at: datetime | None + latest_backup_started_at: datetime | None + latest_backup_status: str | None + latest_retention_started_at: datetime | None + latest_retention_status: str | None next_backup_at: datetime | None next_retention_at: datetime | None - repository_count: int class TrayBackendUnavailable(RuntimeError): @@ -45,6 +52,68 @@ class TrayBackendUnavailable(RuntimeError): _T = TypeVar("_T") +class TrayStatusSubscriptionClient: + """Refresh snapshots only when the authenticated event stream invalidates.""" + + def __init__( + self, + *, + control_client: SystemControlClient | None = None, + event_client: StatusEventClient | None = None, + ) -> None: + self._control_client = control_client or UnixSocketSystemControlClient() + self._event_client = event_client or UnixSocketStatusEventClient() + + def serve( + self, + stop_event: Event, + *, + on_snapshot: Callable[[StatusSnapshot], None], + on_unavailable: Callable[[str], None] | None = None, + ) -> None: + """Consume invalidations and publish coherent snapshots to the tray.""" + if not isinstance(stop_event, Event): + raise TypeError("stop_event must be a threading.Event") + applied = None + refresh_pending = True + try: + for event in self._event_client.events(stop_event): + if stop_event.is_set(): + return + if event.kind is StatusEventKind.HEARTBEAT and not refresh_pending: + continue + if event.kind is not StatusEventKind.HEARTBEAT and ( + applied is not None + and event.revision.session_id == applied.session_id + and event.revision.sequence <= applied.sequence + ): + continue + refresh_pending = True + try: + snapshot = self._control_client.get_status_snapshot() + except SystemControlClientError as error: + if on_unavailable is not None: + state = ( + "denied" + if error.status is ResponseStatus.DENIED + else "unavailable" + ) + on_unavailable(state) + continue + if ( + applied is not None + and snapshot.revision.session_id == applied.session_id + and snapshot.revision.sequence < applied.sequence + ): + continue + applied = snapshot.revision + refresh_pending = False + on_snapshot(snapshot) + except StatusEventAccessDenied: + if on_unavailable is not None: + on_unavailable("denied") + + class TrayControlClient: """Small client that powers the standalone tray process. @@ -82,63 +151,106 @@ def allowed_actions(self) -> frozenset[str]: def refresh_status(self) -> TrayDisplayState: """Return a tray-safe status snapshot for the current backend state.""" - - def _build_from_runs( - runs: list[RunRecordView], - summary: ScheduleSummary, - ) -> TrayDisplayState: - active_operations = self._count_active_runs(runs) - backup_runs = [run for run in runs if run.operation is OperationType.BACKUP] - retention_runs = [ - run for run in runs if run.operation is OperationType.RETENTION - ] - latest_backup = self._latest_run(backup_runs) - latest_retention = self._latest_run(retention_runs) - - return TrayDisplayState( - status=self._status_from_runs(runs), - tooltip=self._build_tooltip( - latest_backup, latest_retention, summary, active_operations - ), - active_operations=active_operations, - backend_available=True, - last_backup_started_at=latest_backup.started_at - if latest_backup - else None, - last_backup_status=latest_backup.safe_summary - if latest_backup - else None, - last_retention_started_at=( - latest_retention.started_at if latest_retention else None - ), - last_retention_status=( - latest_retention.safe_summary if latest_retention else None - ), - next_backup_at=summary.next_backup_at, - next_retention_at=summary.next_retention_at, - repository_count=len({run.target_id for run in runs}), - ) - try: - runs = self._with_backend( - lambda backend: backend.list_runs( - RunQuery(limit=self._max_history_runs) - ) + snapshot = self._with_backend( + lambda backend: backend.get_status_snapshot() ) - summary = self._with_backend(lambda backend: backend.get_schedule_summary()) except TrayBackendUnavailable: - return self._unavailable_state( + return self.unavailable_state( "TimeLocker - System backend unavailable", backend_available=False, ) except SystemControlClientError as error: if error.status is ResponseStatus.DENIED: - return self._unavailable_state( + return self.unavailable_state( "TimeLocker - Access denied", backend_available=True, ) raise - return _build_from_runs(runs, summary) + return self.project_snapshot(snapshot) + + @staticmethod + def project_snapshot(snapshot: StatusSnapshot) -> TrayDisplayState: + """Project one coherent backend snapshot into safe desktop fields.""" + if not isinstance(snapshot, StatusSnapshot): + raise TypeError("snapshot must be a StatusSnapshot") + latest_runs = tuple( + run + for run in (snapshot.latest_backup, snapshot.latest_retention) + if run is not None + ) + if snapshot.backend_status is BackendStatus.UNAVAILABLE: + status = "warning" + elif snapshot.active_operations: + status = "running" + elif any( + run.state in {RunState.FAILED, RunState.INTERRUPTED} + for run in latest_runs + ): + status = "error" + elif snapshot.latest_backup is None: + status = "warning" + elif any(run.state is RunState.SKIPPED for run in latest_runs): + status = "warning" + elif any(run.state is RunState.SUCCEEDED for run in latest_runs): + status = "success" + else: + status = "idle" + + tooltip_lines = [ + "TimeLocker", + f"Backend: {snapshot.backend_status.value.title()}", + f"Active operations: {snapshot.active_operations}", + "Last successful backup: " + + _format_local_time(snapshot.last_successful_backup_completed_at), + ] + if snapshot.latest_backup is not None: + tooltip_lines.append( + f"Latest backup: {snapshot.latest_backup.safe_summary}" + ) + if snapshot.latest_retention is not None: + tooltip_lines.append( + f"Latest retention: {snapshot.latest_retention.safe_summary}" + ) + if snapshot.next_backup_at is not None: + tooltip_lines.append( + f"Next backup: {_format_local_time(snapshot.next_backup_at)}" + ) + if snapshot.next_retention_at is not None: + tooltip_lines.append( + f"Next retention: {_format_local_time(snapshot.next_retention_at)}" + ) + return TrayDisplayState( + status=status, + tooltip="\n".join(tooltip_lines), + active_operations=snapshot.active_operations, + backend_available=snapshot.backend_status is BackendStatus.AVAILABLE, + last_successful_backup_completed_at=( + snapshot.last_successful_backup_completed_at + ), + latest_backup_started_at=( + snapshot.latest_backup.started_at + if snapshot.latest_backup is not None + else None + ), + latest_backup_status=( + snapshot.latest_backup.safe_summary + if snapshot.latest_backup is not None + else None + ), + latest_retention_started_at=( + snapshot.latest_retention.started_at + if snapshot.latest_retention is not None + else None + ), + latest_retention_status=( + snapshot.latest_retention.safe_summary + if snapshot.latest_retention is not None + else None + ), + next_backup_at=snapshot.next_backup_at, + next_retention_at=snapshot.next_retention_at, + ) def perform_action( self, action: str, *, dry_run_retention: bool = False @@ -146,11 +258,9 @@ def perform_action( """Execute a supported tray action and refresh status when possible.""" if action not in ALLOWED_TRAY_ACTIONS: raise ValueError(f"unsupported tray action: {action}") - if action in {"status", "open_ui", "quit"}: + if action in {"status", "quit"}: if action == "quit": return None - if action == "open_ui": - return None return self.refresh_status() if action == "backup_now": self._with_backend( @@ -195,7 +305,7 @@ def _with_backend(self, callback: Callable[[SystemControlClient], _T]) -> _T: return result @staticmethod - def _unavailable_state( + def unavailable_state( tooltip: str, *, backend_available: bool, @@ -205,72 +315,17 @@ def _unavailable_state( tooltip=tooltip, active_operations=0, backend_available=backend_available, - last_backup_started_at=None, - last_backup_status=None, - last_retention_started_at=None, - last_retention_status=None, + last_successful_backup_completed_at=None, + latest_backup_started_at=None, + latest_backup_status=None, + latest_retention_started_at=None, + latest_retention_status=None, next_backup_at=None, next_retention_at=None, - repository_count=0, ) - def _count_active_runs(self, runs: list[RunRecordView]) -> int: - return sum( - 1 - for run in runs - if run.state in {RunState.QUEUED, RunState.RUNNING} - ) - - def _latest_run(self, runs: list[RunRecordView]) -> RunRecordView | None: - if not runs: - return None - return sorted(runs, key=lambda run: run.started_at, reverse=True)[0] - def _status_from_runs(self, runs: list[RunRecordView]) -> str: - if any( - run.state in {RunState.QUEUED, RunState.RUNNING} - for run in runs - ): - return "running" - latest_runs = [ - latest - for operation in (OperationType.BACKUP, OperationType.RETENTION) - if ( - latest := self._latest_run( - [run for run in runs if run.operation is operation] - ) - ) - is not None - ] - if any( - run.state in {RunState.FAILED, RunState.INTERRUPTED} - for run in latest_runs - ): - return "error" - if any(run.state is RunState.SKIPPED for run in latest_runs): - return "warning" - if any(run.state is RunState.SUCCEEDED for run in latest_runs): - return "success" - return "idle" - - def _build_tooltip( - self, - latest_backup: RunRecordView | None, - latest_retention: RunRecordView | None, - summary: ScheduleSummary, - active_operations: int, - ) -> str: - lines: list[str] = ["TimeLocker"] - if active_operations: - lines.append(f"Active: {active_operations}") - if latest_backup: - lines.append(f"Last backup: {latest_backup.started_at.isoformat()}") - lines.append(f"Backup status: {latest_backup.safe_summary}") - if latest_retention: - lines.append(f"Last retention: {latest_retention.started_at.isoformat()}") - lines.append(f"Retention status: {latest_retention.safe_summary}") - if summary.next_backup_at: - lines.append(f"Next backup: {summary.next_backup_at.isoformat()}") - if summary.next_retention_at: - lines.append(f"Next retention: {summary.next_retention_at.isoformat()}") - return "\n".join(lines) if len(lines) > 1 else "TimeLocker - Idle" +def _format_local_time(value: datetime | None) -> str: + if value is None: + return "Never" + return value.astimezone().strftime("%Y-%m-%d %H:%M %Z").rstrip() diff --git a/src/TimeLocker/system_control/tray_entry.py b/src/TimeLocker/system_control/tray_entry.py index 3560ab7..cf08450 100644 --- a/src/TimeLocker/system_control/tray_entry.py +++ b/src/TimeLocker/system_control/tray_entry.py @@ -4,18 +4,25 @@ import argparse import os +from queue import Empty, Full, Queue import signal import sys import time from contextlib import contextmanager, suppress from pathlib import Path -from typing import Any, Callable +from threading import Event, Thread +from typing import Any -from .tray_client import TrayControlClient, TrayDisplayState +from .tray_client import ( + TrayControlClient, + TrayDisplayState, + TrayStatusSubscriptionClient, +) from ..monitoring.system_tray_integration import ( SystemTrayError, SystemTrayIntegration, TrayStatus, + TrayStatusInfo, ) try: @@ -23,16 +30,10 @@ except ImportError: # pragma: no cover - Windows-specific. fcntl = None -from .client import ( - ProtocolErrorCode, - SystemControlClientError, - UnixSocketSystemControlClient, -) -from .types import ResponseStatus +from .client import UnixSocketSystemControlClient DEFAULT_REFRESH_SECONDS = 15 -DEFAULT_POLL_SECONDS = 30 -TRAY_STATUS_ACTIONS = {"status", "backup_now", "retention_now", "open_ui", "quit"} +TRAY_STATUS_ACTIONS = {"status", "backup_now", "retention_now", "quit"} _runtime_directory = os.environ.get("XDG_RUNTIME_DIR") LOCK_PATH = ( Path(_runtime_directory) / "timelocker" / "tray.lock" @@ -126,8 +127,12 @@ def _render_status(state: TrayDisplayState) -> str: f"status: {state.status}", f"active_operations: {state.active_operations}", f"backend_available: {state.backend_available}", - f"repositories: {state.repository_count}", ] + if state.last_successful_backup_completed_at: + bits.append( + "last_successful_backup_completed_at: " + f"{state.last_successful_backup_completed_at.isoformat()}" + ) if state.next_backup_at: bits.append(f"next_backup_at: {state.next_backup_at.isoformat()}") if state.next_retention_at: @@ -141,8 +146,21 @@ def _apply_state( ) -> None: if not tray.is_available(): return - tray.update_status(_status_to_tray(state.status), tooltip=state.tooltip) - tray.update_last_backup_time(state.last_backup_started_at) + tray.update_status_info( + TrayStatusInfo( + status=_status_to_tray(state.status), + tooltip=state.tooltip, + backend_available=state.backend_available, + last_successful_backup_time=( + state.last_successful_backup_completed_at + ), + latest_backup_status=state.latest_backup_status, + latest_retention_status=state.latest_retention_status, + next_backup_time=state.next_backup_at, + next_retention_time=state.next_retention_at, + active_operations=state.active_operations, + ) + ) def _build_client( @@ -157,7 +175,7 @@ def _build_client( def _tray_menu_actions(retention_policy_fingerprint: str | None) -> frozenset[str]: - actions = {"status", "backup_now", "open_ui", "quit"} + actions = {"backup_now", "quit"} if retention_policy_fingerprint: actions.add("retention_now") return frozenset(actions) @@ -172,9 +190,6 @@ def _handle_action( ) -> TrayDisplayState | None: if action == "quit": raise SystemExit(0) - if action == "open_ui": - print("open-ui not yet implemented") - return None if action not in TRAY_STATUS_ACTIONS: raise SystemExit(f"unsupported action: {action}") return client.perform_action(action, dry_run_retention=dry_run_retention) @@ -199,17 +214,17 @@ def _menu_action( _apply_state(tray, state) -def _wait_for_next_refresh( - tray: SystemTrayIntegration | None, - seconds: float, - stop_requested: Callable[[], bool], +def _offer_latest( + updates: Queue[TrayDisplayState], + state: TrayDisplayState, ) -> None: - """Keep the desktop event loop responsive between backend refreshes.""" - deadline = time.monotonic() + seconds - while not stop_requested() and time.monotonic() < deadline: - if tray is not None: - tray.process_events() - time.sleep(min(0.25, max(0.0, deadline - time.monotonic()))) + """Coalesce worker-to-desktop updates to the newest safe state.""" + try: + updates.put_nowait(state) + except Full: + with suppress(Empty): + updates.get_nowait() + updates.put_nowait(state) def main() -> None: @@ -247,13 +262,14 @@ def main() -> None: except SystemTrayError: tray = None - poll_interval = max(DEFAULT_POLL_SECONDS, arguments.refresh_seconds) - stop_requested = False + subscription_stop = Event() + subscription_thread: Thread | None = None def _request_stop(*_args: Any) -> None: nonlocal stop_requested stop_requested = True + subscription_stop.set() signal.signal(signal.SIGTERM, _request_stop) signal.signal(signal.SIGINT, _request_stop) @@ -272,48 +288,55 @@ def _request_stop(*_args: Any) -> None: ) tray.show_context_menu() + updates: Queue[TrayDisplayState] = Queue(maxsize=1) + subscription = TrayStatusSubscriptionClient() + + def _subscribe() -> None: + subscription.serve( + subscription_stop, + on_snapshot=lambda snapshot: _offer_latest( + updates, + client.project_snapshot(snapshot), + ), + on_unavailable=lambda reason: _offer_latest( + updates, + client.unavailable_state( + ( + "TimeLocker - Access denied" + if reason == "denied" + else "TimeLocker - System backend unavailable" + ), + backend_available=reason == "denied", + ), + ), + ) + + subscription_thread = Thread( + target=_subscribe, + name="timelocker-tray-status", + daemon=True, + ) + subscription_thread.start() + while not stop_requested: + if tray is not None: + tray.process_events() try: - state = client.refresh_status() - except Exception as exc: - if isinstance(exc, SystemControlClientError): - if ( - exc.error_code - is ProtocolErrorCode.SYSTEM_BACKEND_UNAVAILABLE - and exc.status == ResponseStatus.UNAVAILABLE - ): - # Keep tray alive; retry on background interval. - print("backend unavailable, retrying...", file=sys.stderr) - _wait_for_next_refresh( - tray, - poll_interval, - lambda: stop_requested, - ) - continue - print(f"{exc}", file=sys.stderr) - _wait_for_next_refresh( - tray, - poll_interval, - lambda: stop_requested, - ) + state = updates.get_nowait() + except Empty: + time.sleep(0.05) continue - - if state: - if tray and tray.is_available(): - _apply_state(tray, state) - print(_render_status(state)) - + if tray and tray.is_available(): + _apply_state(tray, state) if arguments.once: break - _wait_for_next_refresh( - tray, - poll_interval, - lambda: stop_requested, - ) except RuntimeError as exc: print(f"{exc}", file=sys.stderr) raise SystemExit(1) from None finally: + subscription_stop.set() + if subscription_thread is not None: + subscription_thread.join(timeout=1.0) if tray is not None and tray.is_available(): tray.shutdown() diff --git a/src/TimeLocker/system_control/types.py b/src/TimeLocker/system_control/types.py index 5b81751..b2489fd 100644 --- a/src/TimeLocker/system_control/types.py +++ b/src/TimeLocker/system_control/types.py @@ -11,6 +11,7 @@ class SystemAction(StrEnum): RUN_DETAIL = "run.detail" DIAGNOSTIC_LIST = "diagnostic.list" SCHEDULE_SUMMARY = "schedule.summary" + STATUS_SNAPSHOT = "status.snapshot" BACKUP_REQUEST = "backup.request" RETENTION_REQUEST = "retention.request" UI_AVAILABILITY = "ui.availability" @@ -27,6 +28,22 @@ class ResponseStatus(StrEnum): FAILED = "failed" +class BackendStatus(StrEnum): + """Bounded backend availability states for status snapshots.""" + + AVAILABLE = "available" + UNAVAILABLE = "unavailable" + + +class StatusEventKind(StrEnum): + """Allowlisted status event kinds for the event-driven tray contract.""" + + SNAPSHOT_REQUIRED = "snapshot_required" + CHANGED = "changed" + HEARTBEAT = "heartbeat" + RESYNC_REQUIRED = "resync_required" + + class ProtocolErrorCode(StrEnum): """Stable response errors with metadata-free, code-owned summaries.""" diff --git a/src/TimeLocker/system_control/windows_adapter.py b/src/TimeLocker/system_control/windows_adapter.py index f926d3c..735ab24 100644 --- a/src/TimeLocker/system_control/windows_adapter.py +++ b/src/TimeLocker/system_control/windows_adapter.py @@ -8,10 +8,20 @@ from __future__ import annotations from dataclasses import dataclass +import json +from threading import Event from typing import Protocol -from .interfaces import ControlRequestHandler, PeerIdentity -from .validation import require_group_name, require_safe_identifier +from .interfaces import ( + ControlRequestHandler, + GroupMembershipResolver, + PeerIdentity, + StatusEventBroker, +) +from .models import StatusEvent +from .status_events import StatusSubscriptionLimitError +from .types import StatusEventKind +from .validation import require_group_name, require_int, require_safe_identifier @dataclass(frozen=True, slots=True) @@ -65,6 +75,23 @@ def accept(self) -> NamedPipeConnection: """Return the next local connection.""" +class NamedPipeEventConnection(Protocol): + """Injectable bounded send seam for a Windows event subscription.""" + + def send_event(self, payload: bytes, timeout_seconds: float) -> None: + """Send one event within the platform binding's timeout.""" + + def close(self) -> None: + """Close the event connection.""" + + +class NamedPipeEventAcceptor(Protocol): + """Accept local event subscribers from a protected named pipe.""" + + def accept(self) -> NamedPipeEventConnection: + """Return the next local event connection.""" + + class WindowsPeerIdentityProvider: """Project peer identity exclusively from an injected token provider.""" @@ -131,3 +158,110 @@ def serve_once(self, handler: ControlRequestHandler) -> None: connection.send(handler.handle(request, identity)) finally: connection.close() + + +class WindowsNamedPipeStatusEventTransport: + """Testable Windows event contract; no live service implementation claim.""" + + def __init__( + self, + acceptor: NamedPipeEventAcceptor, + token_provider: WindowsTokenProvider, + *, + operator_group: str = "timelocker-operators", + heartbeat_interval_seconds: float = 5.0, + send_timeout_seconds: float = 2.0, + max_frame_bytes: int = 1_048_576, + ) -> None: + if ( + isinstance(heartbeat_interval_seconds, bool) + or not isinstance(heartbeat_interval_seconds, (int, float)) + or not 0.25 <= heartbeat_interval_seconds <= 300.0 + ): + raise ValueError("heartbeat_interval_seconds is outside the supported bound") + if ( + isinstance(send_timeout_seconds, bool) + or not isinstance(send_timeout_seconds, (int, float)) + or not 0.1 <= send_timeout_seconds <= 60.0 + ): + raise ValueError("send_timeout_seconds is outside the supported bound") + self._acceptor = acceptor + self._identity_provider = WindowsPeerIdentityProvider(token_provider) + self.operator_group = require_group_name(operator_group) + self.heartbeat_interval_seconds = float(heartbeat_interval_seconds) + self.send_timeout_seconds = float(send_timeout_seconds) + self.max_frame_bytes = require_int( + max_frame_bytes, + field="max_frame_bytes", + minimum=1_024, + maximum=16_777_216, + ) + + def serve_once( + self, + broker: StatusEventBroker, + membership_resolver: GroupMembershipResolver, + *, + stop_event: Event | None = None, + ) -> None: + """Serve one injected connection with per-delivery authorization.""" + stop_event = stop_event or Event() + connection = self._acceptor.accept() + subscription = None + try: + try: + identity = self._identity_provider.peer_identity(connection) + except (OSError, RuntimeError, TypeError, ValueError): + return + if not self._authorized(identity, membership_resolver): + self._send( + connection, + { + "status": "denied", + "safe_summary": "System access denied.", + }, + ) + return + try: + subscription = broker.subscribe() + except StatusSubscriptionLimitError: + return + while not stop_event.is_set(): + event = subscription.next_event(self.heartbeat_interval_seconds) + if not self._authorized(identity, membership_resolver): + return + if event is None: + event = StatusEvent( + revision=broker.current_revision(), + kind=StatusEventKind.HEARTBEAT, + ) + self._send(connection, event.to_wire()) + finally: + if subscription is not None: + subscription.close() + connection.close() + + def _send( + self, + connection: NamedPipeEventConnection, + payload: dict[str, object], + ) -> None: + frame = (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode( + "utf-8" + ) + if len(frame) > self.max_frame_bytes: + raise OSError("named-pipe event exceeds configured bound") + connection.send_event(frame, self.send_timeout_seconds) + + def _authorized( + self, + identity: PeerIdentity, + membership_resolver: GroupMembershipResolver, + ) -> bool: + try: + return membership_resolver.is_current_member( + identity, + self.operator_group, + ) is True + except (KeyError, OSError, RuntimeError, TypeError, ValueError): + return False diff --git a/tests/TimeLocker/monitoring/test_system_tray_integration.py b/tests/TimeLocker/monitoring/test_system_tray_integration.py index 63f4d09..2350c19 100644 --- a/tests/TimeLocker/monitoring/test_system_tray_integration.py +++ b/tests/TimeLocker/monitoring/test_system_tray_integration.py @@ -21,11 +21,13 @@ from TimeLocker.monitoring.system_tray_integration import ( PACKAGED_TRAY_ICON_PATH, + PACKAGED_TRAY_STATUS_ICON_PATHS, LinuxSystemTray, SystemTrayError, SystemTrayIntegration, TrayStatus, TrayStatusInfo, + _linux_tray_icon_path, _load_linux_tray_modules, ) @@ -40,14 +42,14 @@ def test_tray_status_info_creation(self): info = TrayStatusInfo( status=TrayStatus.SUCCESS, tooltip="Last backup: 2 hours ago", - last_backup_time=datetime.now(), - last_backup_status="success", - repository_count=3, + backend_available=True, + last_successful_backup_time=datetime.now(), + latest_backup_status="success", active_operations=0, ) assert info.status == TrayStatus.SUCCESS - assert info.repository_count == 3 + assert info.backend_available is True assert info.active_operations == 0 @@ -72,10 +74,8 @@ def test_initialization(self, monkeypatch): "TestApp", frozenset( { - "status", "backup_now", "retention_now", - "open_ui", "quit", } ), @@ -152,7 +152,7 @@ def require_version(namespace, version): @pytest.mark.monitoring @pytest.mark.unit - def test_uses_packaged_timelocker_icon_for_initial_and_updated_status(self): + def test_uses_packaged_status_icons_for_initial_and_updated_status(self): gtk = Mock() indicator_module = Mock() indicator = indicator_module.Indicator.new.return_value @@ -166,25 +166,35 @@ def test_uses_packaged_timelocker_icon_for_initial_and_updated_status(self): indicator_module.Indicator.new.assert_called_once_with( "TimeLocker", - str(PACKAGED_TRAY_ICON_PATH), + str(PACKAGED_TRAY_STATUS_ICON_PATHS[TrayStatus.IDLE]), indicator_module.IndicatorCategory.APPLICATION_STATUS, ) - indicator.set_icon.assert_called_once_with(str(PACKAGED_TRAY_ICON_PATH)) + indicator.set_icon.assert_called_once_with( + str(PACKAGED_TRAY_STATUS_ICON_PATHS[TrayStatus.ERROR]) + ) + + @pytest.mark.monitoring + @pytest.mark.unit + def test_status_icon_falls_back_to_base_logo(self): + with patch.object( + type(PACKAGED_TRAY_ICON_PATH), + "is_file", + side_effect=(False, True), + ): + icon = _linux_tray_icon_path(TrayStatus.ERROR) + + assert icon == str(PACKAGED_TRAY_ICON_PATH) @pytest.mark.monitoring @pytest.mark.unit def test_linux_menu_shows_last_backup_in_local_time(self): gtk = Mock() indicator_module = Mock() - open_item = Mock() - last_backup_item = Mock() - status_item = Mock() + status_items = [Mock() for _ in range(7)] backup_item = Mock() quit_item = Mock() gtk.MenuItem.side_effect = [ - open_item, - last_backup_item, - status_item, + *status_items, backup_item, quit_item, ] @@ -196,14 +206,27 @@ def test_linux_menu_shows_last_backup_in_local_time(self): backup_time = datetime(2026, 7, 26, 12, 34, tzinfo=UTC) tray = LinuxSystemTray( "TimeLocker", - frozenset({"status", "backup_now", "open_ui", "quit"}), + frozenset({"backup_now", "quit"}), + ) + tray.update_status_rows( + TrayStatusInfo( + status=TrayStatus.SUCCESS, + tooltip="TimeLocker", + backend_available=True, + last_successful_backup_time=backup_time, + latest_backup_status="Backup completed successfully.", + ) ) - tray.update_last_backup_time(backup_time) - last_backup_item.set_sensitive.assert_called_once_with(False) + assert all( + call.kwargs.get("label") not in {"Open TimeLocker", "View Status"} + for call in gtk.MenuItem.call_args_list + ) + for status_item in status_items: + status_item.set_sensitive.assert_called_once_with(False) expected_time = backup_time.astimezone().strftime("%Y-%m-%d %H:%M %Z") - last_backup_item.set_label.assert_called_once_with( - f"Last backup: {expected_time}".rstrip() + status_items[2].set_label.assert_called_once_with( + f"Last successful backup: {expected_time}".rstrip() ) @pytest.mark.monitoring diff --git a/tests/TimeLocker/project/test_release_artifacts.py b/tests/TimeLocker/project/test_release_artifacts.py index 16dd10f..d5a8823 100644 --- a/tests/TimeLocker/project/test_release_artifacts.py +++ b/tests/TimeLocker/project/test_release_artifacts.py @@ -74,6 +74,22 @@ def test_smoke_workflow_is_non_publishing_read_only_and_covers_support_matrix(): assert forbidden not in workflow +@pytest.mark.config +@pytest.mark.unit +def test_artifact_smoke_covers_system_entrypoints_protocols_and_assets(): + smoke = (ROOT / "scripts/smoke_release_artifact.py").read_text() + for expected in ( + "timelocker-system-control", + "timelocker-tray", + "STATUS_EVENT_PROTOCOL_VERSION", + "timelocker-status-events.socket", + "timelocker-retention.timer", + "timelocker-icon-idle.png", + "timelocker-icon-error.png", + ): + assert expected in smoke + + @pytest.mark.platform @pytest.mark.unit def test_root_help_is_compatible_with_windows_default_encoding(): diff --git a/tests/TimeLocker/project/test_tray_icon_assets.py b/tests/TimeLocker/project/test_tray_icon_assets.py new file mode 100644 index 0000000..3d005f5 --- /dev/null +++ b/tests/TimeLocker/project/test_tray_icon_assets.py @@ -0,0 +1,54 @@ +"""Tests for deterministic, accessible TimeLocker tray icon variants.""" + +from __future__ import annotations + +import hashlib +import subprocess +import sys +from pathlib import Path + +from PIL import Image, ImageChops +from pytest import mark + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +ASSET_ROOT = ( + PROJECT_ROOT / "src" / "TimeLocker" / "system_control" / "assets" +) +BASE_ICON = ASSET_ROOT / "timelocker-icon.png" +STATUSES = ("idle", "running", "success", "warning", "error") + + +@mark.unit +def test_status_icons_are_deterministic_logo_variants(tmp_path: Path) -> None: + subprocess.run( + [ + sys.executable, + str(PROJECT_ROOT / "scripts" / "generate_tray_status_icons.py"), + "--output-root", + str(tmp_path), + ], + check=True, + capture_output=True, + text=True, + ) + + with Image.open(BASE_ICON) as source: + base = source.convert("RGBA") + hashes = set() + for status in STATUSES: + packaged = ASSET_ROOT / f"timelocker-icon-{status}.png" + generated = tmp_path / packaged.name + assert packaged.read_bytes() == generated.read_bytes() + with Image.open(packaged) as source: + image = source.convert("RGBA") + assert image.size == (1024, 1024) + assert image.mode == "RGBA" + unchanged = ImageChops.difference( + base.crop((0, 0, 676, 1024)), + image.crop((0, 0, 676, 1024)), + ) + assert unchanged.getbbox() is None + hashes.add(hashlib.sha256(packaged.read_bytes()).hexdigest()) + + assert len(hashes) == len(STATUSES) diff --git a/tests/TimeLocker/system_control/test_action_policy.py b/tests/TimeLocker/system_control/test_action_policy.py index c74189f..d05a0cb 100644 --- a/tests/TimeLocker/system_control/test_action_policy.py +++ b/tests/TimeLocker/system_control/test_action_policy.py @@ -20,6 +20,7 @@ (("logs", "view"), "local", ActionClass.USER_LOCAL_READ, False), (("logs", "view"), "system", ActionClass.SYSTEM_READ, True), (("runs", "list"), None, ActionClass.SYSTEM_READ, True), + (("system", "status"), None, ActionClass.SYSTEM_READ, True), (("system", "backup"), None, ActionClass.SYSTEM_ACTION, True), ( ("system", "rollback"), diff --git a/tests/TimeLocker/system_control/test_backend_entry.py b/tests/TimeLocker/system_control/test_backend_entry.py index f4031d6..0327163 100644 --- a/tests/TimeLocker/system_control/test_backend_entry.py +++ b/tests/TimeLocker/system_control/test_backend_entry.py @@ -1,13 +1,28 @@ """Entrypoint checks for the privileged system-control backend.""" +import os +import socket from pathlib import Path +from threading import Event +from typing import cast import pytest from TimeLocker.system_control import backend_entry +from TimeLocker.system_control.status_events import ( + BoundedStatusEventBroker, + StatusChangeCoordinator, +) from TimeLocker.system_control.types import OperationTrigger +class _StubTransport: + """Minimal transport shim exposing only listener shutdown semantics.""" + + def __init__(self, listener: object) -> None: + self.listener = listener + + @pytest.mark.unit def test_main_requires_systemd_socket_mode() -> None: with pytest.raises(SystemExit) as caught: @@ -109,6 +124,9 @@ def test_main_composes_only_explicit_system_paths(monkeypatch, tmp_path: Path) - "run_linux_backend", lambda **kwargs: captured.update(kwargs), ) + monkeypatch.setenv("LISTEN_PID", str(os.getpid())) + monkeypatch.setenv("LISTEN_FDS", "2") + monkeypatch.setenv("LISTEN_FDNAMES", "control:status-events") backend_entry.main( [ @@ -125,6 +143,8 @@ def test_main_composes_only_explicit_system_paths(monkeypatch, tmp_path: Path) - assert paths.policy_path == policy assert paths.record_root == state / "records" assert captured["socket_mode"] == "systemd" + assert captured["systemd_descriptor"] == 3 + assert captured["status_systemd_descriptor"] == 4 assert ( captured["production_target_path"] == backend_entry.DEFAULT_PRODUCTION_TARGET_PATH @@ -148,3 +168,184 @@ def test_main_redacts_initialization_failures(monkeypatch, capsys) -> None: output = capsys.readouterr().err assert "failed to initialize safely" in output assert "secret.example" not in output + + +@pytest.mark.unit +def test_systemd_descriptor_names_remove_order_dependency() -> None: + control, status = backend_entry._systemd_socket_descriptors( + { + "LISTEN_PID": "123", + "LISTEN_FDS": "2", + "LISTEN_FDNAMES": "status-events:control", + }, + process_id=123, + ) + + assert control == 4 + assert status == 3 + + +@pytest.mark.unit +@pytest.mark.parametrize( + "environment", + [ + {}, + { + "LISTEN_PID": "122", + "LISTEN_FDS": "2", + "LISTEN_FDNAMES": "control:status-events", + }, + { + "LISTEN_PID": "123", + "LISTEN_FDS": "1", + "LISTEN_FDNAMES": "control", + }, + { + "LISTEN_PID": "123", + "LISTEN_FDS": "2", + "LISTEN_FDNAMES": "control:control", + }, + ], +) +def test_systemd_descriptor_contract_fails_closed( + environment: dict[str, str], +) -> None: + with pytest.raises(RuntimeError, match="systemd socket"): + backend_entry._systemd_socket_descriptors(environment, process_id=123) + + +@pytest.mark.unit +def test_build_linux_backend_rejects_status_listener_without_listener_mode( + tmp_path: Path, +) -> None: + paths = backend_entry.LinuxBackendPaths.from_state_root( + policy_path=tmp_path / "policy.json", + state_root=tmp_path / "state-root", + expected_owner=os.getuid(), + ) + + with pytest.raises( + ValueError, + match="status socket listener can only be provided in listener mode", + ): + backend_entry.build_linux_backend( + paths=paths, + status_socket_mode="systemd", + status_listener=socket.socket(socket.AF_UNIX, socket.SOCK_STREAM), + ) + + +@pytest.mark.unit +def test_build_linux_backend_rejects_listener_status_mode_without_status_listener() -> None: + paths = backend_entry.LinuxBackendPaths.from_state_root( + policy_path=Path("/tmp/never-read"), + state_root=Path("/tmp/never-read-state"), + expected_owner=os.getuid(), + ) + + with pytest.raises( + ValueError, + match="status socket listener is required for listener mode", + ): + backend_entry.build_linux_backend(paths=paths, status_socket_mode="listener") + + +@pytest.mark.unit +def test_build_linux_backend_listener_status_mode_uses_supplied_listener( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + captured: dict[str, object] = {} + control_listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + status_listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + paths = backend_entry.LinuxBackendPaths.from_state_root( + policy_path=tmp_path / "policy.json", + state_root=tmp_path / "state-root", + expected_owner=os.getuid(), + ) + + monkeypatch.setattr( + backend_entry, + "load_system_policy", + lambda *_args, **_kwargs: backend_entry.SystemPolicy(), + ) + monkeypatch.setattr( + backend_entry, + "_build_transport", + lambda **_kwargs: _StubTransport(control_listener), + ) + monkeypatch.setattr( + backend_entry, + "_build_status_transport", + lambda **kwargs: ( + captured.__setitem__("status_listener", kwargs["listener"]) + or _StubTransport(status_listener) + ), + ) + monkeypatch.setattr( + backend_entry, + "_build_handlers", + lambda **_kwargs: {}, + ) + monkeypatch.setattr( + backend_entry, + "reconcile_abandoned_runs", + lambda *_args, **_kwargs: [], + ) + monkeypatch.setattr( + backend_entry, + "_emit_startup_diagnostics", + lambda *_, **__: None, + ) + + service = backend_entry.build_linux_backend( + paths=paths, + status_socket_mode="listener", + status_listener=status_listener, + ) + + assert captured["status_listener"] is status_listener + assert service.status_event_transport is not None + service.stop() + assert control_listener.fileno() == -1 + assert status_listener.fileno() == -1 + + +@pytest.mark.unit +def test_event_transport_failure_does_not_block_control_requests() -> None: + control_served = Event() + event_started = Event() + + class _ControlTransport: + listener = None + identity_provider = object() + + def serve(self, _dispatcher: object) -> None: + control_served.set() + + class _FailingEventTransport: + listener = None + + def serve(self, *_args: object) -> None: + event_started.set() + raise OSError("event socket unavailable") + + broker = BoundedStatusEventBroker() + service = backend_entry.LinuxBackendService( + policy=backend_entry.SystemPolicy(), + store=cast(object, None), + locks=cast(object, None), + dispatcher=cast(object, None), + transport=cast(object, _ControlTransport()), + status_event_transport=cast(object, _FailingEventTransport()), + audit_sink=cast(object, None), + stop_event=Event(), + status_event_broker=broker, + status_change_coordinator=StatusChangeCoordinator(broker), + membership_resolver=cast(object, None), + ) + + service.serve_forever(install_signal_handlers=False) + + assert event_started.is_set() + assert control_served.is_set() diff --git a/tests/TimeLocker/system_control/test_client.py b/tests/TimeLocker/system_control/test_client.py index eaab5e0..4719591 100644 --- a/tests/TimeLocker/system_control/test_client.py +++ b/tests/TimeLocker/system_control/test_client.py @@ -18,12 +18,15 @@ RunQuery, RunRecord, RunRecordView, + StatusRevision, + StatusSnapshot, BackupActionRequest, RetentionActionRequest, ActionReceipt, ) from TimeLocker.system_control.protocol import ResponseEnvelope from TimeLocker.system_control.types import ( + BackendStatus, DiagnosticCode, DiagnosticComponent, DiagnosticLevel, @@ -239,6 +242,22 @@ def exchange(request: bytes) -> bytes: assert summary.next_retention_at is None +@pytest.mark.unit +def test_status_snapshot_uses_one_read_only_allowlisted_request() -> None: + expected = StatusSnapshot.from_run_history( + revision=StatusRevision(uuid4(), 0), + backend_status=BackendStatus.AVAILABLE, + active_operations=0, + runs=(_run(),), + ) + + client = UnixSocketSystemControlClient( + exchange=_success_exchange(SystemAction.STATUS_SNAPSHOT, expected.to_wire()) + ) + + assert client.get_status_snapshot() == expected + + @pytest.mark.unit def test_invalid_or_oversized_response_fails_closed() -> None: invalid = UnixSocketSystemControlClient(exchange=lambda _request: b"{") diff --git a/tests/TimeLocker/system_control/test_deployment.py b/tests/TimeLocker/system_control/test_deployment.py index 0d94607..76c7be2 100644 --- a/tests/TimeLocker/system_control/test_deployment.py +++ b/tests/TimeLocker/system_control/test_deployment.py @@ -9,8 +9,11 @@ from TimeLocker.system_control.deployment import ( AssetTarget, DeploymentError, + ReleaseProbeResult, + ReleaseProbeTargets, SystemReleaseDeployment, build_asset_manifest, + build_release_manifest, linux_asset_targets, ) from TimeLocker.system_control.release_launcher import ImmutableReleaseResolver @@ -30,13 +33,10 @@ def _stage_release(root: Path, release_id: str) -> None: executable.chmod(0o755) (release / "release.json").write_text( json.dumps( - { - "schema_version": 1, - "release_id": release_id, - "package_version": "0.9.1", - "protocol_version": 1, - "entrypoint": "venv/bin/timelocker", - } + build_release_manifest( + release_id=release_id, + package_version="0.9.1", + ) ) ) (release / "release.json").chmod(0o644) @@ -52,6 +52,22 @@ def _resolver(root: Path) -> ImmutableReleaseResolver: ) +def _passing_probe(targets: ReleaseProbeTargets) -> ReleaseProbeResult: + return ReleaseProbeResult( + cli_compatible=True, + backend_compatible=True, + tray_compatible=True, + control_status_available=True, + event_channel_available=True, + backup_timer_active=True, + backup_timer_enabled=True, + retention_timer_active=True, + retention_timer_enabled=True, + control_protocol_version=targets.control_protocol_version, + event_protocol_version=targets.event_protocol_version, + ) + + @pytest.mark.unit def test_install_validates_every_hash_before_replacing_any_asset( tmp_path: Path, @@ -104,13 +120,10 @@ def test_upgrade_and_rollback_preserve_policy_and_run_records(tmp_path: Path) -> policy.write_text("approved-policy") record.write_text("durable-run") - def probe(*_executables: Path) -> bool: - return True - - deployment.activate(RELEASE_A, health_probe=probe) - deployment.activate(RELEASE_B, health_probe=probe) + deployment.activate(RELEASE_A, health_probe=_passing_probe) + deployment.activate(RELEASE_B, health_probe=_passing_probe) assert resolver.resolve({}).parts[-4] == RELEASE_B - deployment.rollback(health_probe=probe) + deployment.rollback(health_probe=_passing_probe) assert resolver.resolve({}).parts[-4] == RELEASE_A assert policy.read_text() == "approved-policy" @@ -156,10 +169,16 @@ def test_linux_asset_set_covers_launchers_backend_tray_and_schedules( "timelocker-tray-launcher", "timelocker-control.service", "timelocker-control.socket", + "timelocker-status-events.socket", "timelocker-retention.service", "timelocker-retention.timer", "timelocker-tray.desktop", "timelocker-icon.png", + "timelocker-icon-idle.png", + "timelocker-icon-running.png", + "timelocker-icon-success.png", + "timelocker-icon-warning.png", + "timelocker-icon-error.png", } <= sources policy = next( target @@ -172,3 +191,93 @@ def test_linux_asset_set_covers_launchers_backend_tray_and_schedules( ) assert icon.destination == tmp_path / "icons" / "timelocker.png" assert icon.mode == 0o644 + error_icon = next( + target + for target in targets + if target.source_name == "timelocker-icon-error.png" + ) + assert error_icon.destination == tmp_path / "icons" / "timelocker-error.png" + assert error_icon.mode == 0o644 + + +@pytest.mark.unit +@pytest.mark.parametrize( + "failed_field", + [ + "control_status_available", + "event_channel_available", + "backup_timer_active", + "backup_timer_enabled", + "retention_timer_active", + "retention_timer_enabled", + ], +) +def test_activation_requires_protocol_socket_and_timer_health( + tmp_path: Path, + failed_field: str, +) -> None: + _stage_release(tmp_path, RELEASE_A) + resolver = _resolver(tmp_path) + deployment = SystemReleaseDeployment( + resolver=resolver, + targets=(AssetTarget("unused", tmp_path / "unused", 0o644),), + expected_owner_uid=os.getuid(), + ) + + def probe(targets: ReleaseProbeTargets) -> ReleaseProbeResult: + values = { + "cli_compatible": True, + "backend_compatible": True, + "tray_compatible": True, + "control_status_available": True, + "event_channel_available": True, + "backup_timer_active": True, + "backup_timer_enabled": True, + "retention_timer_active": True, + "retention_timer_enabled": True, + "control_protocol_version": targets.control_protocol_version, + "event_protocol_version": targets.event_protocol_version, + } + values[failed_field] = False + return ReleaseProbeResult(**values) + + with pytest.raises(DeploymentError, match="probe failed"): + deployment.activate(RELEASE_A, health_probe=probe) + + assert not resolver.selector_path.exists() + + +@pytest.mark.unit +def test_rollback_allows_inert_event_socket_but_requires_control_and_timers( + tmp_path: Path, +) -> None: + _stage_release(tmp_path, RELEASE_A) + _stage_release(tmp_path, RELEASE_B) + resolver = _resolver(tmp_path) + resolver.select(RELEASE_A) + resolver.select(RELEASE_B) + deployment = SystemReleaseDeployment( + resolver=resolver, + targets=(AssetTarget("unused", tmp_path / "unused", 0o644),), + expected_owner_uid=os.getuid(), + ) + + def rollback_probe(targets: ReleaseProbeTargets) -> ReleaseProbeResult: + result = _passing_probe(targets) + return ReleaseProbeResult( + cli_compatible=result.cli_compatible, + backend_compatible=result.backend_compatible, + tray_compatible=result.tray_compatible, + control_status_available=result.control_status_available, + event_channel_available=False, + backup_timer_active=result.backup_timer_active, + backup_timer_enabled=result.backup_timer_enabled, + retention_timer_active=result.retention_timer_active, + retention_timer_enabled=result.retention_timer_enabled, + control_protocol_version=result.control_protocol_version, + event_protocol_version=result.event_protocol_version, + ) + + selected = deployment.rollback(health_probe=rollback_probe) + + assert selected.selected == RELEASE_A diff --git a/tests/TimeLocker/system_control/test_interfaces.py b/tests/TimeLocker/system_control/test_interfaces.py index d19e112..ee986fe 100644 --- a/tests/TimeLocker/system_control/test_interfaces.py +++ b/tests/TimeLocker/system_control/test_interfaces.py @@ -14,6 +14,7 @@ RetentionActionRequest, RunQuery, RunRecordView, + StatusSnapshot, ) @@ -68,6 +69,9 @@ def request_retention(self, request: RetentionActionRequest) -> ActionReceipt: run_id=uuid4(), ) + def get_status_snapshot(self) -> StatusSnapshot: + raise LookupError("no snapshot configured") + @pytest.mark.unit @pytest.mark.platform diff --git a/tests/TimeLocker/system_control/test_linux_adapter.py b/tests/TimeLocker/system_control/test_linux_adapter.py index 076b27f..086a02b 100644 --- a/tests/TimeLocker/system_control/test_linux_adapter.py +++ b/tests/TimeLocker/system_control/test_linux_adapter.py @@ -215,13 +215,34 @@ def test_policy_rejects_group_writable_or_unknown_fields( def test_socket_and_service_templates_enforce_narrow_boundary(self) -> None: socket_unit = (ASSET_DIRECTORY / "timelocker-control.socket").read_text() + event_socket_unit = ( + ASSET_DIRECTORY / "timelocker-status-events.socket" + ).read_text() service_unit = (ASSET_DIRECTORY / "timelocker-control.service").read_text() assert "ListenStream=/run/timelocker/control.sock" in socket_unit assert "DirectoryMode=0755" in socket_unit assert "SocketGroup=timelocker-operators" in socket_unit assert "SocketMode=0660" in socket_unit + assert "FileDescriptorName=control" in socket_unit + assert "ListenStream=/run/timelocker/status-events.sock" in event_socket_unit + assert "DirectoryMode=0755" in event_socket_unit + assert "SocketUser=root" in event_socket_unit + assert "SocketGroup=timelocker-operators" in event_socket_unit + assert "SocketMode=0660" in event_socket_unit + assert "FileDescriptorName=status-events" in event_socket_unit + assert ( + "Service=timelocker-control.service" in event_socket_unit + ) assert "User=root" in service_unit + assert ( + "Sockets=timelocker-control.socket timelocker-status-events.socket" + in service_unit + ) + assert ( + "Requires=timelocker-control.socket timelocker-status-events.socket" + in service_unit + ) assert "UMask=0077" in service_unit assert "RuntimeDirectory=" not in service_unit assert "StateDirectoryMode=0750" in service_unit diff --git a/tests/TimeLocker/system_control/test_protocol.py b/tests/TimeLocker/system_control/test_protocol.py index c99fd1d..0f368db 100644 --- a/tests/TimeLocker/system_control/test_protocol.py +++ b/tests/TimeLocker/system_control/test_protocol.py @@ -6,11 +6,14 @@ import pytest from TimeLocker.system_control import ( + BackendStatus, ProtocolErrorCode, RequestEnvelope, ResponseEnvelope, ResponseStatus, SystemAction, + StatusRevision, + StatusSnapshot, project_response, ) @@ -224,6 +227,31 @@ def test_response_count_is_bounded(self) -> None: {"runs": [{} for _ in range(1_001)]}, ) + def test_status_snapshot_projection_drops_non_allowlisted_fields(self) -> None: + snapshot = StatusSnapshot.from_run_history( + revision=StatusRevision(uuid4(), 0), + backend_status=BackendStatus.AVAILABLE, + active_operations=0, + runs=(), + ).to_wire() + snapshot["repository_password"] = "secret" + snapshot["environment"] = {"AWS_SECRET_ACCESS_KEY": "secret"} + + projected = project_response(SystemAction.STATUS_SNAPSHOT, snapshot) + + assert set(projected) == { + "revision", + "backend_status", + "active_operations", + "latest_backup", + "last_successful_backup_completed_at", + "latest_retention", + "next_backup_at", + "next_retention_at", + } + assert "repository_password" not in projected + assert "environment" not in projected + def test_detail_health_schedule_ui_and_receipt_are_strictly_projected(self) -> None: detail = project_response( SystemAction.RUN_DETAIL, diff --git a/tests/TimeLocker/system_control/test_release_launcher.py b/tests/TimeLocker/system_control/test_release_launcher.py index 836d6e2..b230762 100644 --- a/tests/TimeLocker/system_control/test_release_launcher.py +++ b/tests/TimeLocker/system_control/test_release_launcher.py @@ -9,6 +9,7 @@ from TimeLocker.system_control.release_launcher import ( LAUNCH_GUARD, ImmutableReleaseResolver, + ReleaseManifest, ReleaseResolutionError, ) @@ -205,3 +206,36 @@ def test_staged_launcher_has_no_pyenv_checkout_or_root_overlay_fallback() -> Non assert "-m TimeLocker.system_control.launcher_entry" in alias assert "-m TimeLocker.system_control.backend_launcher_entry" in backend assert "-m TimeLocker.system_control.tray_launcher_entry" in tray + + +@pytest.mark.unit +def test_schema_two_manifest_binds_control_and_event_protocols() -> None: + manifest = ReleaseManifest.from_mapping( + { + "schema_version": 2, + "release_id": RELEASE_A, + "package_version": "0.9.1", + "control_protocol_version": 1, + "event_protocol_version": 1, + "entrypoint": "venv/bin/timelocker", + } + ) + + assert manifest.control_protocol_version == 1 + assert manifest.event_protocol_version == 1 + + +@pytest.mark.unit +def test_schema_one_manifest_remains_readable_only_without_event_claim() -> None: + manifest = ReleaseManifest.from_mapping( + { + "schema_version": 1, + "release_id": RELEASE_A, + "package_version": "0.9.1", + "protocol_version": 1, + "entrypoint": "venv/bin/timelocker", + } + ) + + assert manifest.control_protocol_version == 1 + assert manifest.event_protocol_version is None diff --git a/tests/TimeLocker/system_control/test_status_contracts.py b/tests/TimeLocker/system_control/test_status_contracts.py new file mode 100644 index 0000000..db42c13 --- /dev/null +++ b/tests/TimeLocker/system_control/test_status_contracts.py @@ -0,0 +1,342 @@ +"""Focused contract tests for event-driven tray status models.""" + +from dataclasses import FrozenInstanceError +from datetime import UTC, datetime, timedelta +from itertools import permutations +from typing import cast +from uuid import UUID, uuid4 + +import pytest + +from TimeLocker.system_control import ( + BackendStatus, + OperationTrigger, + OperationType, + ResultCode, + RunRecord, + RunRecordView, + RunState, + StatusEvent, + StatusEventBroker, + StatusEventClient, + StatusEventKind, + StatusEventTransport, + StatusRevision, + StatusSnapshot, + StatusSnapshotProvider, +) + + +BASE_TIME = datetime(2026, 7, 27, 10, 0, tzinfo=UTC) +SESSION_ID = UUID("11111111-1111-4111-8111-111111111111") + + +def _run_view( + *, + operation: OperationType, + state: RunState, + started_at: datetime, + completed_at: datetime | None, + result_code: ResultCode, + run_id: UUID | None = None, +) -> RunRecordView: + return RunRecordView.from_record( + RunRecord( + run_id=run_id or uuid4(), + operation=operation, + trigger=OperationTrigger.SCHEDULED, + target_id="production", + started_at=started_at, + completed_at=completed_at, + state=state, + result_code=result_code, + ) + ) + + +@pytest.mark.unit +def test_status_revision_round_trip_and_immutability() -> None: + revision = StatusRevision.from_mapping( + {"session_id": str(SESSION_ID), "sequence": 7} + ) + + assert revision.to_wire() == {"session_id": str(SESSION_ID), "sequence": 7} + with pytest.raises(FrozenInstanceError): + revision.sequence = 8 # type: ignore[misc] + + +@pytest.mark.unit +@pytest.mark.parametrize( + "payload", + [ + {"session_id": str(SESSION_ID)}, + {"session_id": str(SESSION_ID), "sequence": True}, + {"session_id": str(SESSION_ID), "sequence": 1, "extra": "field"}, + ], +) +def test_status_revision_rejects_missing_unknown_and_bool_sequence( + payload: dict[str, object] +) -> None: + with pytest.raises((TypeError, ValueError)): + StatusRevision.from_mapping(payload) + + +@pytest.mark.unit +def test_status_event_round_trips_and_rejects_unsupported_versions() -> None: + event = StatusEvent.from_mapping( + { + "schema_version": 1, + "protocol_version": 1, + "revision": {"session_id": str(SESSION_ID), "sequence": 3}, + "kind": "changed", + } + ) + + assert event.to_wire()["kind"] == "changed" + assert event.revision.is_strictly_newer_than( + StatusRevision(SESSION_ID, 2) + ) is True + + for invalid in ( + { + "schema_version": 2, + "protocol_version": 1, + "revision": {"session_id": str(SESSION_ID), "sequence": 3}, + "kind": "changed", + }, + { + "schema_version": 1, + "protocol_version": 2, + "revision": {"session_id": str(SESSION_ID), "sequence": 3}, + "kind": "changed", + }, + { + "schema_version": 1, + "protocol_version": 1, + "revision": {"session_id": str(SESSION_ID), "sequence": 3}, + "kind": "changed", + "secret": "nope", + }, + ): + with pytest.raises((TypeError, ValueError)): + StatusEvent.from_mapping(invalid) + + +@pytest.mark.unit +def test_status_snapshot_round_trip_and_rejects_mismatched_run_operations() -> None: + latest_backup = _run_view( + operation=OperationType.BACKUP, + state=RunState.FAILED, + started_at=BASE_TIME, + completed_at=BASE_TIME + timedelta(minutes=5), + result_code=ResultCode.OPERATION_FAILED, + run_id=UUID("22222222-2222-4222-8222-222222222222"), + ) + snapshot = StatusSnapshot.from_mapping( + { + "revision": {"session_id": str(SESSION_ID), "sequence": 4}, + "backend_status": "available", + "active_operations": 1, + "latest_backup": latest_backup.to_wire(), + "last_successful_backup_completed_at": None, + "latest_retention": None, + "next_backup_at": (BASE_TIME + timedelta(hours=1)).isoformat(), + "next_retention_at": None, + } + ) + + assert snapshot.to_wire()["backend_status"] == "available" + assert snapshot.latest_backup == latest_backup + + invalid_snapshot = snapshot.to_wire() + invalid_snapshot["latest_backup"] = _run_view( + operation=OperationType.RETENTION, + state=RunState.SUCCEEDED, + started_at=BASE_TIME, + completed_at=BASE_TIME + timedelta(minutes=5), + result_code=ResultCode.RETENTION_SUCCEEDED, + ).to_wire() + with pytest.raises(ValueError, match="latest_backup"): + StatusSnapshot.from_mapping(invalid_snapshot) + + +@pytest.mark.unit +def test_status_snapshot_rejects_unknown_fields_and_bool_as_active_operations() -> None: + payload = { + "revision": {"session_id": str(SESSION_ID), "sequence": 1}, + "backend_status": "available", + "active_operations": True, + "latest_backup": None, + "last_successful_backup_completed_at": None, + "latest_retention": None, + "next_backup_at": None, + "next_retention_at": None, + } + with pytest.raises((TypeError, ValueError)): + StatusSnapshot.from_mapping(payload) + + payload["active_operations"] = 0 + payload["raw_output"] = "secret" + with pytest.raises(ValueError, match="unknown fields"): + StatusSnapshot.from_mapping(payload) + + +@pytest.mark.unit +def test_status_snapshot_builder_selects_latest_attempts_and_max_success_across_permutations() -> None: + successful_backup = _run_view( + operation=OperationType.BACKUP, + state=RunState.SUCCEEDED, + started_at=BASE_TIME - timedelta(hours=3), + completed_at=BASE_TIME - timedelta(hours=2, minutes=55), + result_code=ResultCode.BACKUP_SUCCEEDED, + run_id=UUID("33333333-3333-4333-8333-333333333333"), + ) + newer_failed_backup = _run_view( + operation=OperationType.BACKUP, + state=RunState.FAILED, + started_at=BASE_TIME - timedelta(minutes=20), + completed_at=BASE_TIME - timedelta(minutes=15), + result_code=ResultCode.OPERATION_FAILED, + run_id=UUID("44444444-4444-4444-8444-444444444444"), + ) + newer_successful_backup = _run_view( + operation=OperationType.BACKUP, + state=RunState.SUCCEEDED, + started_at=BASE_TIME - timedelta(hours=1), + completed_at=BASE_TIME - timedelta(minutes=30), + result_code=ResultCode.BACKUP_SUCCEEDED, + run_id=UUID("55555555-5555-4555-8555-555555555555"), + ) + latest_retention = _run_view( + operation=OperationType.RETENTION, + state=RunState.SUCCEEDED, + started_at=BASE_TIME - timedelta(minutes=10), + completed_at=BASE_TIME - timedelta(minutes=5), + result_code=ResultCode.RETENTION_SUCCEEDED, + run_id=UUID("66666666-6666-4666-8666-666666666666"), + ) + + for history in permutations( + ( + successful_backup, + newer_failed_backup, + newer_successful_backup, + latest_retention, + ) + ): + snapshot = StatusSnapshot.from_run_history( + revision=StatusRevision(SESSION_ID, 9), + backend_status=BackendStatus.AVAILABLE, + active_operations=1, + runs=history, + next_backup_at=BASE_TIME + timedelta(hours=2), + next_retention_at=BASE_TIME + timedelta(hours=3), + ) + assert snapshot.latest_backup == newer_failed_backup + assert snapshot.latest_retention == latest_retention + assert ( + snapshot.last_successful_backup_completed_at + == newer_successful_backup.completed_at + ) + + +@pytest.mark.unit +def test_status_snapshot_builder_returns_none_when_no_successful_backup_exists() -> None: + snapshot = StatusSnapshot.from_run_history( + revision=StatusRevision(SESSION_ID, 2), + backend_status=BackendStatus.UNAVAILABLE, + active_operations=0, + runs=( + _run_view( + operation=OperationType.BACKUP, + state=RunState.FAILED, + started_at=BASE_TIME, + completed_at=BASE_TIME + timedelta(minutes=1), + result_code=ResultCode.OPERATION_FAILED, + ), + _run_view( + operation=OperationType.RETENTION, + state=RunState.SUCCEEDED, + started_at=BASE_TIME - timedelta(minutes=5), + completed_at=BASE_TIME - timedelta(minutes=1), + result_code=ResultCode.RETENTION_SUCCEEDED, + ), + ), + ) + + assert snapshot.last_successful_backup_completed_at is None + assert snapshot.latest_backup is not None + + +@pytest.mark.unit +def test_status_revision_ordering_is_strict_within_a_session() -> None: + applied = StatusRevision(SESSION_ID, 5) + duplicate = StatusRevision(SESSION_ID, 5) + newer = StatusRevision(SESSION_ID, 6) + older = StatusRevision(SESSION_ID, 4) + other_session = StatusRevision( + UUID("77777777-7777-4777-8777-777777777777"), + 1, + ) + + assert duplicate.is_strictly_newer_than(applied) is False + assert older.is_strictly_newer_than(applied) is False + assert newer.is_strictly_newer_than(applied) is True + assert other_session.is_strictly_newer_than(applied) is False + + +@pytest.mark.unit +def test_status_protocol_interfaces_remain_platform_neutral_contracts() -> None: + snapshot = StatusSnapshot.from_run_history( + revision=StatusRevision(SESSION_ID, 1), + backend_status=BackendStatus.AVAILABLE, + active_operations=0, + runs=(), + ) + event = StatusEvent( + revision=StatusRevision(SESSION_ID, 1), + kind=StatusEventKind.SNAPSHOT_REQUIRED, + ) + + class FakeProvider: + def snapshot(self) -> StatusSnapshot: + return snapshot + + class FakeBroker: + def current_revision(self) -> StatusRevision: + return snapshot.revision + + def publish_change(self, kind: StatusEventKind) -> StatusRevision: + assert kind is StatusEventKind.CHANGED + return StatusRevision(SESSION_ID, 2) + + def subscribe(self): + return FakeSubscription() + + class FakeSubscription: + def next_event(self, timeout_seconds: float | None = None): + del timeout_seconds + return event + + def close(self) -> None: + return None + + class FakeTransport: + def serve(self, broker, identity_provider, membership_resolver) -> None: + assert broker.current_revision() == snapshot.revision + + class FakeClient: + def events(self, stop_event: object): + del stop_event + yield event + + provider = cast(StatusSnapshotProvider, FakeProvider()) + broker = cast(StatusEventBroker, FakeBroker()) + transport = cast(StatusEventTransport, FakeTransport()) + client = cast(StatusEventClient, FakeClient()) + + assert provider.snapshot() == snapshot + assert broker.publish_change(StatusEventKind.CHANGED).sequence == 2 + assert broker.subscribe().next_event() == event + transport.serve(broker, object(), object()) + assert list(client.events(object())) == [event] diff --git a/tests/TimeLocker/system_control/test_status_event_transport.py b/tests/TimeLocker/system_control/test_status_event_transport.py new file mode 100644 index 0000000..6daa19e --- /dev/null +++ b/tests/TimeLocker/system_control/test_status_event_transport.py @@ -0,0 +1,253 @@ +"""Security and resilience tests for Linux status-event delivery.""" + +from __future__ import annotations + +import json +import socket +from threading import Event +from uuid import UUID + +import pytest + +from TimeLocker.system_control.event_client import ( + StatusEventAccessDenied, + UnixSocketStatusEventClient, +) +from TimeLocker.system_control.interfaces import PeerIdentity +from TimeLocker.system_control.linux_adapter import LinuxStatusEventTransport +from TimeLocker.system_control.models import StatusEvent +from TimeLocker.system_control.status_events import BoundedStatusEventBroker +from TimeLocker.system_control.types import StatusEventKind + + +SESSION_ID = UUID("58d95acd-aa24-4461-96bb-74d3421e8e42") + + +class _IdentityProvider: + def peer_identity(self, _connection: object) -> PeerIdentity: + return PeerIdentity("linux-uid:1000", process_id=123) + + +class _Membership: + def __init__(self, answers: list[bool]) -> None: + self._answers = iter(answers) + self.calls = 0 + + def is_current_member( + self, + _identity: PeerIdentity, + _group_name: str, + ) -> bool: + self.calls += 1 + return next(self._answers, False) + + +def _transport(listener: socket.socket, *, stop_event: Event | None = None): + return LinuxStatusEventTransport( + listener, + heartbeat_interval_seconds=0.25, + send_timeout_seconds=0.25, + max_frame_bytes=1_024, + stop_event=stop_event, + ) + + +class _MemoryConnection: + def __init__(self, incoming: list[bytes] | None = None) -> None: + self.incoming = list(incoming or []) + self.sent: list[bytes] = [] + self.closed = False + + def settimeout(self, _timeout: float) -> None: + pass + + def sendall(self, payload: bytes) -> None: + self.sent.append(payload) + + def recv(self, _maximum: int) -> bytes: + return self.incoming.pop(0) if self.incoming else b"" + + def close(self) -> None: + self.closed = True + + +def test_authorized_subscription_receives_allowlisted_initial_event() -> None: + listener, peer = socket.socketpair() + broker = BoundedStatusEventBroker(session_id=SESSION_ID) + membership = _Membership([True, True, False]) + transport = _transport(listener) + connection = _MemoryConnection() + try: + transport.serve_connection( + connection, # type: ignore[arg-type] + broker=broker, + identity_provider=_IdentityProvider(), + membership_resolver=membership, + ) + event = StatusEvent.from_mapping(json.loads(connection.sent[0])) + assert event.kind is StatusEventKind.SNAPSHOT_REQUIRED + assert event.revision.session_id == SESSION_ID + assert membership.calls >= 2 + finally: + listener.close() + peer.close() + + +def test_denied_subscription_receives_only_safe_bounded_denial() -> None: + listener, peer = socket.socketpair() + transport = _transport(listener) + connection = _MemoryConnection() + try: + transport.serve_connection( + connection, # type: ignore[arg-type] + broker=BoundedStatusEventBroker(), + identity_provider=_IdentityProvider(), + membership_resolver=_Membership([False]), + ) + assert json.loads(connection.sent[0]) == { + "safe_summary": "System access denied.", + "status": "denied", + } + finally: + listener.close() + peer.close() + + +def test_membership_revocation_prevents_the_next_event_payload() -> None: + listener, peer = socket.socketpair() + broker = BoundedStatusEventBroker(session_id=SESSION_ID) + membership = _Membership([True, True, False]) + transport = _transport(listener) + connection = _MemoryConnection() + try: + transport.serve_connection( + connection, # type: ignore[arg-type] + broker=broker, + identity_provider=_IdentityProvider(), + membership_resolver=membership, + ) + initial = StatusEvent.from_mapping(json.loads(connection.sent[0])) + assert initial.kind is StatusEventKind.SNAPSHOT_REQUIRED + assert len(connection.sent) == 1 + assert membership.calls >= 3 + finally: + listener.close() + peer.close() + + +def test_idle_subscription_receives_reauthorized_heartbeat() -> None: + listener, peer = socket.socketpair() + transport = _transport(listener) + connection = _MemoryConnection() + try: + transport.serve_connection( + connection, # type: ignore[arg-type] + broker=BoundedStatusEventBroker(session_id=SESSION_ID), + identity_provider=_IdentityProvider(), + membership_resolver=_Membership([True, True, True, False]), + ) + assert StatusEvent.from_mapping(json.loads(connection.sent[0])).kind is ( + StatusEventKind.SNAPSHOT_REQUIRED + ) + assert StatusEvent.from_mapping(json.loads(connection.sent[1])).kind is ( + StatusEventKind.HEARTBEAT + ) + finally: + listener.close() + peer.close() + + +def test_slow_sender_is_disconnected_and_subscription_capacity_is_released() -> None: + class _SlowConnection: + def settimeout(self, _timeout: float) -> None: + pass + + def sendall(self, _payload: bytes) -> None: + raise TimeoutError + + listener, peer = socket.socketpair() + broker = BoundedStatusEventBroker(max_subscribers=1) + transport = _transport(listener) + try: + with pytest.raises(TimeoutError): + transport.serve_connection( + _SlowConnection(), # type: ignore[arg-type] + broker=broker, + identity_provider=_IdentityProvider(), + membership_resolver=_Membership([True, True]), + ) + replacement = broker.subscribe() + replacement.close() + finally: + listener.close() + peer.close() + + +def test_status_transport_adopts_the_dedicated_systemd_listener( + monkeypatch, +) -> None: + class _ListeningSocket: + family = socket.AF_UNIX + + def getsockopt(self, _level: int, _option: int) -> int: + return 1 + + def close(self) -> None: + pass + + listener = _ListeningSocket() + monkeypatch.setattr(socket, "socket", _ListeningSocket) + monkeypatch.setattr(socket, "fromfd", lambda *_args: listener) + + transport = LinuxStatusEventTransport.from_systemd( + descriptor=4, + heartbeat_interval_seconds=5.0, + ) + + assert transport.listener is listener + assert transport.max_connections == 32 + + +def test_event_client_reconnects_after_oversized_frame_and_disconnect() -> None: + first_valid = StatusEvent( + revision=BoundedStatusEventBroker(session_id=SESSION_ID).current_revision(), + kind=StatusEventKind.SNAPSHOT_REQUIRED, + ) + restarted_session = UUID("963f31ad-ff34-458a-a1df-1782c23c27b7") + second_valid = StatusEvent( + revision=BoundedStatusEventBroker( + session_id=restarted_session + ).current_revision(), + kind=StatusEventKind.SNAPSHOT_REQUIRED, + ) + payloads = [ + (b"x" * 1_025) + b"\n", + (json.dumps(first_valid.to_wire()) + "\n").encode(), + (json.dumps(second_valid.to_wire()) + "\n").encode(), + ] + + def connect() -> socket.socket: + return _MemoryConnection([payloads.pop(0)]) # type: ignore[return-value] + + stop_event = Event() + event_client = UnixSocketStatusEventClient( + max_event_bytes=1_024, + base_retry_delay_seconds=0.001, + max_retry_delay_seconds=0.002, + connection_factory=connect, + ) + events = event_client.events(stop_event) + assert next(events) == first_valid + assert next(events) == second_valid + stop_event.set() + + +def test_event_client_rejects_denial_without_status_disclosure() -> None: + client = _MemoryConnection( + [b'{"safe_summary":"System access denied.","status":"denied"}\n'] + ) + event_client = UnixSocketStatusEventClient( + connection_factory=lambda: client # type: ignore[arg-type,return-value] + ) + with pytest.raises(StatusEventAccessDenied, match="access denied"): + next(event_client.events(Event())) diff --git a/tests/TimeLocker/system_control/test_status_events.py b/tests/TimeLocker/system_control/test_status_events.py new file mode 100644 index 0000000..7453225 --- /dev/null +++ b/tests/TimeLocker/system_control/test_status_events.py @@ -0,0 +1,218 @@ +"""Bounded broker, change-source, and watcher tests for Spec 010 T003.""" + +from datetime import UTC, datetime +from pathlib import Path +from threading import Event, Thread +from uuid import UUID + +import pytest + +from TimeLocker.system_control import ( + BoundedStatusEventBroker, + ProtectedStateChangeMonitor, + StatusChangeCoordinator, + StatusEventKind, + StatusSubscriptionLimitError, + StatusWatchSignal, +) +from TimeLocker.system_control.models import RunRecord, RunTransition +from TimeLocker.system_control.storage import AtomicRecordStore +from TimeLocker.system_control.types import ( + OperationTrigger, + OperationType, + ResultCode, + RunState, +) + + +SESSION_ID = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") +NOW = datetime(2026, 7, 27, 14, 0, tzinfo=UTC) + + +@pytest.mark.unit +def test_broker_revisions_are_monotonic_and_pending_changes_coalesce() -> None: + broker = BoundedStatusEventBroker(session_id=SESSION_ID) + subscription = broker.subscribe() + + initial = subscription.next_event(0.1) + assert initial is not None + assert initial.kind is StatusEventKind.SNAPSHOT_REQUIRED + assert initial.revision.sequence == 0 + + for expected in range(1, 11): + assert broker.publish_change().sequence == expected + + newest = subscription.next_event(0.1) + assert newest is not None + assert newest.kind is StatusEventKind.CHANGED + assert newest.revision.sequence == 10 + assert subscription.next_event(0.01) is None + + +@pytest.mark.unit +def test_broker_bounds_subscribers_and_close_releases_capacity() -> None: + broker = BoundedStatusEventBroker(max_subscribers=1) + first = broker.subscribe() + + with pytest.raises(StatusSubscriptionLimitError): + broker.subscribe() + + first.close() + replacement = broker.subscribe() + assert replacement.next_event(0.1) is not None + + +@pytest.mark.unit +def test_new_broker_session_forces_snapshot_required() -> None: + first = BoundedStatusEventBroker( + session_id=UUID("11111111-1111-4111-8111-111111111111") + ).subscribe() + second = BoundedStatusEventBroker( + session_id=UUID("22222222-2222-4222-8222-222222222222") + ).subscribe() + + first_event = first.next_event(0.1) + second_event = second.next_event(0.1) + assert first_event is not None + assert second_event is not None + assert first_event.revision.session_id != second_event.revision.session_id + assert second_event.kind is StatusEventKind.SNAPSHOT_REQUIRED + + +@pytest.mark.unit +def test_snapshot_boundary_prevents_newer_revision_with_older_state() -> None: + broker = BoundedStatusEventBroker(session_id=SESSION_ID) + coordinator = StatusChangeCoordinator(broker) + builder_entered = Event() + allow_builder_to_finish = Event() + published = Event() + snapshot_revision = [] + + def build(revision): + snapshot_revision.append(revision) + builder_entered.set() + assert allow_builder_to_finish.wait(1) + return revision + + snapshot_thread = Thread(target=lambda: coordinator.snapshot(build)) + publish_thread = Thread( + target=lambda: ( + coordinator.run_changed(), + published.set(), + ) + ) + snapshot_thread.start() + assert builder_entered.wait(1) + publish_thread.start() + + assert published.wait(0.05) is False + allow_builder_to_finish.set() + snapshot_thread.join(1) + publish_thread.join(1) + + assert snapshot_revision[0].sequence == 0 + assert broker.current_revision().sequence == 1 + assert published.is_set() + + +@pytest.mark.unit +def test_watcher_uncertainty_forces_resynchronization() -> None: + class Watcher: + def events(self, _stop_event: Event): + yield StatusWatchSignal.CHANGED + yield StatusWatchSignal.UNCERTAIN + + broker = BoundedStatusEventBroker(session_id=SESSION_ID) + subscription = broker.subscribe() + subscription.next_event(0.1) + monitor = ProtectedStateChangeMonitor( + Watcher(), + StatusChangeCoordinator(broker), + ) + + monitor.run(Event()) + + event = subscription.next_event(0.1) + assert event is not None + assert event.kind is StatusEventKind.RESYNC_REQUIRED + assert event.revision.sequence == 2 + + +@pytest.mark.unit +def test_watcher_failure_forces_resynchronization() -> None: + class FailingWatcher: + def events(self, _stop_event: Event): + yield StatusWatchSignal.CHANGED + raise OSError("watch overflow") + + broker = BoundedStatusEventBroker(session_id=SESSION_ID) + subscription = broker.subscribe() + subscription.next_event(0.1) + + ProtectedStateChangeMonitor( + FailingWatcher(), + StatusChangeCoordinator(broker), + ).run(Event()) + + event = subscription.next_event(0.1) + assert event is not None + assert event.kind is StatusEventKind.RESYNC_REQUIRED + assert event.revision.sequence == 2 + + +@pytest.mark.unit +def test_durable_run_mutations_publish_without_becoming_failure_dependencies( + tmp_path: Path, +) -> None: + broker = BoundedStatusEventBroker(session_id=SESSION_ID) + coordinator = StatusChangeCoordinator(broker) + store = AtomicRecordStore( + tmp_path / "records", + status_change_callback=coordinator.run_changed, + ) + record = RunRecord( + run_id=UUID("33333333-3333-4333-8333-333333333333"), + operation=OperationType.BACKUP, + trigger=OperationTrigger.SCHEDULED, + target_id="production", + started_at=NOW, + state=RunState.RUNNING, + result_code=ResultCode.OPERATION_RUNNING, + ) + + store.create_run(record) + store.transition( + record.run_id, + RunTransition( + expected_states=frozenset({RunState.RUNNING}), + new_state=RunState.SUCCEEDED, + result_code=ResultCode.BACKUP_SUCCEEDED, + completed_at=NOW, + ), + ) + assert broker.current_revision().sequence == 2 + + failing_store = AtomicRecordStore( + tmp_path / "failing-records", + status_change_callback=lambda: (_ for _ in ()).throw(RuntimeError("down")), + ) + failing_store.create_run( + RunRecord( + run_id=UUID("44444444-4444-4444-8444-444444444444"), + operation=OperationType.RETENTION, + trigger=OperationTrigger.SCHEDULED, + target_id="production", + started_at=NOW, + state=RunState.RUNNING, + result_code=ResultCode.OPERATION_RUNNING, + ) + ) + assert len(failing_store.list_status_runs()) == 1 + + +@pytest.mark.unit +def test_schedule_change_seam_advances_the_same_revision() -> None: + broker = BoundedStatusEventBroker(session_id=SESSION_ID) + coordinator = StatusChangeCoordinator(broker) + + assert coordinator.schedule_changed().sequence == 1 diff --git a/tests/TimeLocker/system_control/test_status_snapshot_action.py b/tests/TimeLocker/system_control/test_status_snapshot_action.py new file mode 100644 index 0000000..239c107 --- /dev/null +++ b/tests/TimeLocker/system_control/test_status_snapshot_action.py @@ -0,0 +1,175 @@ +"""Authorized backend snapshot action tests for Spec 010 T002.""" + +import json +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import UUID + +import pytest + +from TimeLocker.system_control import ( + LocalControlDispatcher, + PeerIdentity, + RequestEnvelope, + RunRecord, + ScheduleSummary, + StatusSnapshot, + SystemAction, + SystemPolicy, +) +from TimeLocker.system_control.backend_entry import ( + FailClosedBackupMutationAdapter, + FailClosedRetentionAdapter, + FailClosedRetentionPlanProvider, + StaticScheduleSummaryProvider, + _build_handlers, +) +from TimeLocker.system_control.storage import AtomicRecordStore, RepositoryMutationLock +from TimeLocker.system_control.types import ( + OperationTrigger, + OperationType, + ResultCode, + RunState, +) + + +NOW = datetime(2026, 7, 27, 12, 0, tzinfo=UTC) + + +class Membership: + """Explicit current-membership test double.""" + + def __init__(self, allowed: bool) -> None: + self.allowed = allowed + + def is_current_member( + self, + _identity: PeerIdentity, + _group_name: str, + ) -> bool: + return self.allowed + + +class AuditSink: + """Discard safe audit records in focused dispatcher tests.""" + + def record(self, _event: object) -> None: + return None + + +def _run( + run_id: str, + *, + state: RunState, + started_at: datetime, + completed_at: datetime | None, + result_code: ResultCode, +) -> RunRecord: + return RunRecord( + run_id=UUID(run_id), + operation=OperationType.BACKUP, + trigger=OperationTrigger.SCHEDULED, + target_id="production", + started_at=started_at, + completed_at=completed_at, + state=state, + result_code=result_code, + ) + + +def _handlers(tmp_path: Path): + store = AtomicRecordStore(tmp_path / "records") + store.create_run( + _run( + "11111111-1111-4111-8111-111111111111", + state=RunState.SUCCEEDED, + started_at=NOW - timedelta(hours=2), + completed_at=NOW - timedelta(hours=1, minutes=50), + result_code=ResultCode.BACKUP_SUCCEEDED, + ) + ) + store.create_run( + _run( + "22222222-2222-4222-8222-222222222222", + state=RunState.RUNNING, + started_at=NOW - timedelta(minutes=5), + completed_at=None, + result_code=ResultCode.OPERATION_RUNNING, + ) + ) + handlers = _build_handlers( + policy=SystemPolicy(), + store=store, + locks=RepositoryMutationLock(tmp_path / "locks"), + backup_adapter=FailClosedBackupMutationAdapter(), + retention_adapter=FailClosedRetentionAdapter(), + retention_plan_provider=FailClosedRetentionPlanProvider(), + schedule_summary_provider=StaticScheduleSummaryProvider( + ScheduleSummary( + next_backup_at=NOW + timedelta(hours=1), + next_retention_at=None, + ) + ), + trigger_root=tmp_path / "triggers", + clock=lambda: NOW, + ) + return handlers + + +@pytest.mark.unit +def test_backend_snapshot_is_coherent_safe_and_preserves_last_success( + tmp_path: Path, +) -> None: + handlers = _handlers(tmp_path) + request = RequestEnvelope( + request_id=UUID("33333333-3333-4333-8333-333333333333"), + action=SystemAction.STATUS_SNAPSHOT, + parameters={}, + ) + + first = StatusSnapshot.from_mapping( + handlers[SystemAction.STATUS_SNAPSHOT](request) + ) + second = StatusSnapshot.from_mapping( + handlers[SystemAction.STATUS_SNAPSHOT](request) + ) + + assert first.revision == second.revision + assert first.active_operations == 1 + assert first.latest_backup is not None + assert first.latest_backup.state is RunState.RUNNING + assert first.last_successful_backup_completed_at == NOW - timedelta( + hours=1, + minutes=50, + ) + assert first.next_backup_at == NOW + timedelta(hours=1) + + +@pytest.mark.unit +@pytest.mark.security +def test_unauthorized_snapshot_receives_only_safe_denial(tmp_path: Path) -> None: + dispatcher = LocalControlDispatcher( + policy=SystemPolicy(), + membership_resolver=Membership(False), + handlers=_handlers(tmp_path), + audit_sink=AuditSink(), + ) + request = { + "protocol_version": 1, + "request_id": "44444444-4444-4444-8444-444444444444", + "action": "status.snapshot", + "parameters": {}, + } + + response = json.loads( + dispatcher.handle( + json.dumps(request).encode("utf-8"), + PeerIdentity("linux-uid:1000"), + ) + ) + + assert response["status"] == "denied" + assert response["error_code"] == "system_access_denied" + assert response["result"] is None + assert "revision" not in response + assert "latest_backup" not in response diff --git a/tests/TimeLocker/system_control/test_tray_client.py b/tests/TimeLocker/system_control/test_tray_client.py index 912075c..05fca31 100644 --- a/tests/TimeLocker/system_control/test_tray_client.py +++ b/tests/TimeLocker/system_control/test_tray_client.py @@ -1,7 +1,7 @@ """Focused tests for the stand-alone tray service client.""" from datetime import UTC, datetime, timedelta -from uuid import uuid4 +from uuid import UUID, uuid4 from pytest import mark import pytest @@ -18,9 +18,12 @@ RunRecordView, RetentionActionRequest, ScheduleSummary, + StatusRevision, + StatusSnapshot, ) from TimeLocker.system_control.models import OperationTrigger from TimeLocker.system_control.types import ( + BackendStatus, OperationType as BackendOperationType, ResultCode, RunState, @@ -57,6 +60,25 @@ def get_schedule_summary(self): raise self.status_error return self.summary + def get_status_snapshot(self): + self.requests.append(("get_status_snapshot", None)) + if self.status_error: + raise self.status_error + return StatusSnapshot.from_run_history( + revision=StatusRevision( + UUID("244e6660-95ae-4cb0-b159-704356ab6700"), + 1, + ), + backend_status=BackendStatus.AVAILABLE, + active_operations=sum( + run.state in {RunState.QUEUED, RunState.RUNNING} + for run in self.runs + ), + runs=self.runs, + next_backup_at=self.summary.next_backup_at, + next_retention_at=self.summary.next_retention_at, + ) + def request_backup(self, request: BackupActionRequest): self.requests.append(("request_backup", request)) if self.backup_error: @@ -118,9 +140,11 @@ def test_refresh_status_orders_runs_by_newest_and_projects_summary() -> None: assert state.status == "error" assert "Next backup" in state.tooltip - assert state.repository_count == 1 - assert state.last_retention_status == "Operation failed." - assert state.last_backup_status == "Backup completed successfully." + assert state.latest_retention_status == "Operation failed." + assert state.latest_backup_status == "Backup completed successfully." + assert state.last_successful_backup_completed_at == ( + base_time - timedelta(minutes=55) + ) assert state.next_backup_at == base_time + timedelta(hours=1) @@ -204,7 +228,90 @@ def test_new_success_supersedes_stale_interruption() -> None: state = client.refresh_status() assert state.status == "success" - assert state.last_backup_status == "Backup completed successfully." + assert state.latest_backup_status == "Backup completed successfully." + + +@mark.unit +def test_failed_newer_backup_does_not_replace_last_successful_completion() -> None: + base_time = datetime(2026, 7, 26, 12, 0, tzinfo=UTC) + successful_completion = base_time - timedelta(hours=1) + runs = [ + RunRecordView.from_record( + RunRecord( + run_id=uuid4(), + operation=BackendOperationType.BACKUP, + started_at=base_time - timedelta(hours=2), + completed_at=successful_completion, + state=RunState.SUCCEEDED, + target_id="prod", + trigger=OperationTrigger.SCHEDULED, + result_code=ResultCode.BACKUP_SUCCEEDED, + ) + ), + RunRecordView.from_record( + RunRecord( + run_id=uuid4(), + operation=BackendOperationType.BACKUP, + started_at=base_time, + completed_at=base_time + timedelta(minutes=5), + state=RunState.FAILED, + target_id="prod", + trigger=OperationTrigger.SCHEDULED, + result_code=ResultCode.OPERATION_FAILED, + ) + ), + ] + state = TrayControlClient( + client_factory=lambda: FakeBackend( + runs, + ScheduleSummary(None, None), + ), + ).refresh_status() + + assert state.status == "error" + assert state.latest_backup_status == "Operation failed." + assert state.last_successful_backup_completed_at == successful_completion + expected = successful_completion.astimezone().strftime("%Y-%m-%d %H:%M %Z") + assert f"Last successful backup: {expected}".rstrip() in state.tooltip + + +@mark.unit +def test_no_successful_backup_is_presented_as_never() -> None: + state = TrayControlClient( + client_factory=lambda: FakeBackend([], ScheduleSummary(None, None)), + ).refresh_status() + + assert state.last_successful_backup_completed_at is None + assert state.status == "warning" + assert "Last successful backup: Never" in state.tooltip + + +@mark.unit +def test_successful_retention_does_not_make_never_run_backup_successful() -> None: + base_time = datetime(2026, 7, 26, 12, 0, tzinfo=UTC) + retention = RunRecordView.from_record( + RunRecord( + run_id=uuid4(), + operation=BackendOperationType.RETENTION, + started_at=base_time, + completed_at=base_time + timedelta(minutes=5), + state=RunState.SUCCEEDED, + target_id="prod", + trigger=OperationTrigger.SCHEDULED, + result_code=ResultCode.RETENTION_SUCCEEDED, + policy_fingerprint="a" * 64, + ) + ) + + state = TrayControlClient( + client_factory=lambda: FakeBackend( + [retention], + ScheduleSummary(None, None), + ), + ).refresh_status() + + assert state.status == "warning" + assert state.last_successful_backup_completed_at is None @mark.unit @@ -246,7 +353,7 @@ def test_unavailable_backend_errors_are_retriable() -> None: recovered = client.refresh_status() assert recovered.backend_available is True - assert recovered.status == "idle" + assert recovered.status == "warning" @mark.unit diff --git a/tests/TimeLocker/system_control/test_tray_process_boundary.py b/tests/TimeLocker/system_control/test_tray_process_boundary.py index 1104e33..fd3b341 100644 --- a/tests/TimeLocker/system_control/test_tray_process_boundary.py +++ b/tests/TimeLocker/system_control/test_tray_process_boundary.py @@ -1,6 +1,7 @@ """Import and lifecycle boundaries for the independent tray process.""" import os +from contextlib import nullcontext from datetime import UTC, datetime from pathlib import Path import subprocess @@ -88,6 +89,8 @@ def test_one_shot_action_does_not_construct_desktop_tray(monkeypatch) -> None: def test_retention_menu_requires_configured_fingerprint() -> None: assert "retention_now" not in _tray_menu_actions(None) assert "retention_now" in _tray_menu_actions("a" * 64) + assert "open_ui" not in _tray_menu_actions(None) + assert "status" not in _tray_menu_actions(None) @pytest.mark.unit @@ -100,15 +103,122 @@ def test_apply_state_projects_last_backup_time_to_tray() -> None: tooltip="TimeLocker\nLast backup: 2026-07-26T12:34:00+00:00", active_operations=0, backend_available=True, - last_backup_started_at=backup_time, - last_backup_status="Backup completed successfully.", - last_retention_started_at=None, - last_retention_status=None, + last_successful_backup_completed_at=backup_time, + latest_backup_started_at=backup_time, + latest_backup_status="Backup completed successfully.", + latest_retention_started_at=None, + latest_retention_status=None, next_backup_at=None, next_retention_at=None, - repository_count=1, ) _apply_state(tray, state) - tray.update_last_backup_time.assert_called_once_with(backup_time) + status_info = tray.update_status_info.call_args.args[0] + assert status_info.last_successful_backup_time == backup_time + + +@pytest.mark.unit +def test_healthy_serve_is_silent_and_applies_event_snapshot( + monkeypatch, + capsys, +) -> None: + arguments = type( + "Arguments", + (), + { + "action": "serve", + "once": True, + "refresh_seconds": 15, + "target_id": "production", + "retention_policy_fingerprint": None, + "dry_run_retention": False, + }, + )() + state = TrayDisplayState( + status="success", + tooltip="TimeLocker", + active_operations=0, + backend_available=True, + last_successful_backup_completed_at=datetime( + 2026, + 7, + 26, + 12, + 34, + tzinfo=UTC, + ), + latest_backup_started_at=None, + latest_backup_status="Backup completed successfully.", + latest_retention_started_at=None, + latest_retention_status=None, + next_backup_at=None, + next_retention_at=None, + ) + client = Mock() + client.project_snapshot.return_value = state + tray = Mock() + tray.is_available.return_value = True + + class _Subscription: + def serve(self, _stop_event, *, on_snapshot, on_unavailable) -> None: + on_snapshot(object()) + + monkeypatch.setattr(tray_entry, "_parse_args", lambda: arguments) + monkeypatch.setattr(tray_entry, "_build_client", lambda **_kwargs: client) + monkeypatch.setattr(tray_entry, "SystemTrayIntegration", lambda **_kwargs: tray) + monkeypatch.setattr( + tray_entry, + "TrayStatusSubscriptionClient", + lambda: _Subscription(), + ) + monkeypatch.setattr(tray_entry, "_single_instance", lambda: nullcontext()) + monkeypatch.setattr(tray_entry.signal, "signal", lambda *_args: None) + + tray_entry.main() + + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + assert tray.update_status_info.call_count == 1 + client.refresh_status.assert_not_called() + + +@pytest.mark.unit +def test_explicit_status_action_still_renders_bounded_output( + monkeypatch, + capsys, +) -> None: + arguments = type( + "Arguments", + (), + { + "action": "status", + "target_id": "production", + "retention_policy_fingerprint": None, + "dry_run_retention": False, + }, + )() + state = TrayDisplayState( + status="idle", + tooltip="TimeLocker", + active_operations=0, + backend_available=True, + last_successful_backup_completed_at=None, + latest_backup_started_at=None, + latest_backup_status=None, + latest_retention_started_at=None, + latest_retention_status=None, + next_backup_at=None, + next_retention_at=None, + ) + client = Mock() + client.perform_action.return_value = state + monkeypatch.setattr(tray_entry, "_parse_args", lambda: arguments) + monkeypatch.setattr(tray_entry, "_build_client", lambda **_kwargs: client) + + tray_entry.main() + + output = capsys.readouterr().out + assert "status: idle" in output + assert "backend_available: True" in output diff --git a/tests/TimeLocker/system_control/test_tray_status_subscription.py b/tests/TimeLocker/system_control/test_tray_status_subscription.py new file mode 100644 index 0000000..e4069a6 --- /dev/null +++ b/tests/TimeLocker/system_control/test_tray_status_subscription.py @@ -0,0 +1,179 @@ +"""Event-driven tray snapshot refresh tests.""" + +from __future__ import annotations + +from threading import Event +from uuid import UUID + +from TimeLocker.system_control.client import SystemControlClientError +from TimeLocker.system_control.event_client import StatusEventAccessDenied +from TimeLocker.system_control.models import ( + StatusEvent, + StatusRevision, + StatusSnapshot, +) +from TimeLocker.system_control.tray_client import TrayStatusSubscriptionClient +from TimeLocker.system_control.types import ( + BackendStatus, + ProtocolErrorCode, + ResponseStatus, + StatusEventKind, +) + + +SESSION_ONE = UUID("526719f9-4c46-42ac-b286-2623079bc335") +SESSION_TWO = UUID("eb53eaf9-42c5-45e2-b772-a3c6d7ace818") + + +def _snapshot(session_id: UUID, sequence: int) -> StatusSnapshot: + return StatusSnapshot( + revision=StatusRevision(session_id, sequence), + backend_status=BackendStatus.AVAILABLE, + active_operations=0, + ) + + +class _ControlClient: + def __init__(self, snapshots: list[StatusSnapshot]) -> None: + self.snapshots = iter(snapshots) + self.calls = 0 + + def get_status_snapshot(self) -> StatusSnapshot: + self.calls += 1 + return next(self.snapshots) + + +class _EventClient: + def __init__(self, events: list[StatusEvent]) -> None: + self._events = events + + def events(self, _stop_event: Event): + yield from self._events + + +def test_initial_gap_and_backend_restart_each_fetch_a_fresh_snapshot() -> None: + events = [ + StatusEvent( + StatusRevision(SESSION_ONE, 0), + StatusEventKind.SNAPSHOT_REQUIRED, + ), + StatusEvent(StatusRevision(SESSION_ONE, 0), StatusEventKind.CHANGED), + StatusEvent(StatusRevision(SESSION_ONE, 2), StatusEventKind.CHANGED), + StatusEvent(StatusRevision(SESSION_ONE, 1), StatusEventKind.CHANGED), + StatusEvent(StatusRevision(SESSION_ONE, 2), StatusEventKind.HEARTBEAT), + StatusEvent( + StatusRevision(SESSION_TWO, 0), + StatusEventKind.SNAPSHOT_REQUIRED, + ), + ] + control = _ControlClient( + [ + _snapshot(SESSION_ONE, 0), + _snapshot(SESSION_ONE, 2), + _snapshot(SESSION_TWO, 0), + ] + ) + applied: list[StatusSnapshot] = [] + TrayStatusSubscriptionClient( + control_client=control, + event_client=_EventClient(events), + ).serve(Event(), on_snapshot=applied.append) + + assert control.calls == 3 + assert [snapshot.revision for snapshot in applied] == [ + StatusRevision(SESSION_ONE, 0), + StatusRevision(SESSION_ONE, 2), + StatusRevision(SESSION_TWO, 0), + ] + + +def test_older_snapshot_never_regresses_presentation() -> None: + control = _ControlClient( + [ + _snapshot(SESSION_ONE, 2), + _snapshot(SESSION_ONE, 1), + ] + ) + applied: list[StatusSnapshot] = [] + TrayStatusSubscriptionClient( + control_client=control, + event_client=_EventClient( + [ + StatusEvent( + StatusRevision(SESSION_ONE, 2), + StatusEventKind.SNAPSHOT_REQUIRED, + ), + StatusEvent( + StatusRevision(SESSION_ONE, 3), + StatusEventKind.CHANGED, + ), + ] + ), + ).serve(Event(), on_snapshot=applied.append) + + assert [snapshot.revision.sequence for snapshot in applied] == [2] + + +def test_denied_subscription_projects_only_safe_unavailable_state() -> None: + class _DeniedClient: + def events(self, _stop_event: Event): + raise StatusEventAccessDenied("secret backend detail") + yield + + unavailable: list[str] = [] + TrayStatusSubscriptionClient( + control_client=_ControlClient([]), + event_client=_DeniedClient(), + ).serve( + Event(), + on_snapshot=lambda _snapshot: None, + on_unavailable=unavailable.append, + ) + assert unavailable == ["denied"] + + +def test_heartbeat_retries_initial_snapshot_only_while_not_current() -> None: + class _RecoveringControl: + def __init__(self) -> None: + self.calls = 0 + + def get_status_snapshot(self) -> StatusSnapshot: + self.calls += 1 + if self.calls == 1: + raise SystemControlClientError( + ProtocolErrorCode.SYSTEM_BACKEND_UNAVAILABLE, + "unavailable", + status=ResponseStatus.UNAVAILABLE, + ) + return _snapshot(SESSION_ONE, 0) + + control = _RecoveringControl() + applied: list[StatusSnapshot] = [] + unavailable: list[str] = [] + TrayStatusSubscriptionClient( + control_client=control, + event_client=_EventClient( + [ + StatusEvent( + StatusRevision(SESSION_ONE, 0), + StatusEventKind.SNAPSHOT_REQUIRED, + ), + StatusEvent( + StatusRevision(SESSION_ONE, 0), + StatusEventKind.HEARTBEAT, + ), + StatusEvent( + StatusRevision(SESSION_ONE, 0), + StatusEventKind.HEARTBEAT, + ), + ] + ), + ).serve( + Event(), + on_snapshot=applied.append, + on_unavailable=unavailable.append, + ) + + assert unavailable == ["unavailable"] + assert control.calls == 2 + assert applied == [_snapshot(SESSION_ONE, 0)] diff --git a/tests/TimeLocker/system_control/test_windows_adapter.py b/tests/TimeLocker/system_control/test_windows_adapter.py index 5608ad3..cd824a6 100644 --- a/tests/TimeLocker/system_control/test_windows_adapter.py +++ b/tests/TimeLocker/system_control/test_windows_adapter.py @@ -1,13 +1,18 @@ """Contract tests for the platform-neutral Windows adapter seam.""" from dataclasses import dataclass, field +import json import pytest from TimeLocker.system_control.interfaces import PeerIdentity +from TimeLocker.system_control.models import StatusEvent +from TimeLocker.system_control.status_events import BoundedStatusEventBroker +from TimeLocker.system_control.types import StatusEventKind from TimeLocker.system_control.windows_adapter import ( WindowsCurrentGroupMembershipResolver, WindowsNamedPipeTransport, + WindowsNamedPipeStatusEventTransport, WindowsPeerIdentityProvider, WindowsPeerToken, ) @@ -54,6 +59,38 @@ def accept(self) -> Connection: return self.connection +@dataclass +class EventConnection: + sent: list[tuple[bytes, float]] = field(default_factory=list) + closed: bool = False + + def send_event(self, payload: bytes, timeout_seconds: float) -> None: + self.sent.append((payload, timeout_seconds)) + + def close(self) -> None: + self.closed = True + + +class EventAcceptor: + def __init__(self, connection: EventConnection) -> None: + self.connection = connection + + def accept(self) -> EventConnection: + return self.connection + + +class SequencedGroupProvider: + def __init__(self, answers: list[bool]) -> None: + self._answers = iter(answers) + self.calls = 0 + + def is_current_member(self, sid: str, group_name: str) -> bool: + assert sid == "S-1-5-21-1000" + assert group_name == "timelocker-operators" + self.calls += 1 + return next(self._answers, False) + + class Handler: def handle(self, request: bytes, identity: PeerIdentity) -> bytes: assert request == b"request" @@ -115,3 +152,106 @@ def test_named_pipe_transport_rejects_oversized_request() -> None: transport.serve_once(Handler()) assert connection.closed + + +@pytest.mark.unit +def test_windows_event_subscription_uses_token_and_reauthorizes_delivery() -> None: + connection = EventConnection() + groups = SequencedGroupProvider([True, True, False]) + transport = WindowsNamedPipeStatusEventTransport( + EventAcceptor(connection), + TokenProvider(), + heartbeat_interval_seconds=0.25, + max_frame_bytes=1_024, + ) + + transport.serve_once( + BoundedStatusEventBroker(), + WindowsCurrentGroupMembershipResolver(groups), + ) + + assert connection.closed + assert groups.calls == 3 + assert len(connection.sent) == 1 + event = StatusEvent.from_mapping(json.loads(connection.sent[0][0])) + assert event.kind is StatusEventKind.SNAPSHOT_REQUIRED + assert connection.sent[0][1] == 2.0 + + +@pytest.mark.unit +def test_windows_event_denial_contains_no_status_payload() -> None: + connection = EventConnection() + transport = WindowsNamedPipeStatusEventTransport( + EventAcceptor(connection), + TokenProvider(), + heartbeat_interval_seconds=0.25, + max_frame_bytes=1_024, + ) + + transport.serve_once( + BoundedStatusEventBroker(), + WindowsCurrentGroupMembershipResolver( + SequencedGroupProvider([False]) + ), + ) + + assert json.loads(connection.sent[0][0]) == { + "safe_summary": "System access denied.", + "status": "denied", + } + assert connection.closed + + +@pytest.mark.unit +def test_windows_event_heartbeat_is_bounded_and_reauthorized() -> None: + connection = EventConnection() + transport = WindowsNamedPipeStatusEventTransport( + EventAcceptor(connection), + TokenProvider(), + heartbeat_interval_seconds=0.25, + max_frame_bytes=1_024, + ) + + transport.serve_once( + BoundedStatusEventBroker(), + WindowsCurrentGroupMembershipResolver( + SequencedGroupProvider([True, True, True, False]) + ), + ) + + events = [ + StatusEvent.from_mapping(json.loads(payload)) + for payload, _timeout in connection.sent + ] + assert [event.kind for event in events] == [ + StatusEventKind.SNAPSHOT_REQUIRED, + StatusEventKind.HEARTBEAT, + ] + + +@pytest.mark.unit +def test_windows_slow_event_sender_releases_subscription_capacity() -> None: + class SlowConnection(EventConnection): + def send_event(self, payload: bytes, timeout_seconds: float) -> None: + raise TimeoutError + + connection = SlowConnection() + broker = BoundedStatusEventBroker(max_subscribers=1) + transport = WindowsNamedPipeStatusEventTransport( + EventAcceptor(connection), + TokenProvider(), + heartbeat_interval_seconds=0.25, + max_frame_bytes=1_024, + ) + + with pytest.raises(TimeoutError): + transport.serve_once( + broker, + WindowsCurrentGroupMembershipResolver( + SequencedGroupProvider([True, True]) + ), + ) + + replacement = broker.subscribe() + replacement.close() + assert connection.closed From d540b453864fce9b1c96a85ad9ecf604b98b7f57 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:08:49 +0100 Subject: [PATCH 53/72] fix: reconnect tray promptly after backend restart --- src/TimeLocker/system_control/event_client.py | 5 ++++ .../test_status_event_transport.py | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/TimeLocker/system_control/event_client.py b/src/TimeLocker/system_control/event_client.py index ff16ab7..4d1ca4c 100644 --- a/src/TimeLocker/system_control/event_client.py +++ b/src/TimeLocker/system_control/event_client.py @@ -63,12 +63,14 @@ def events(self, stop_event: Event) -> Iterator[StatusEvent]: if not isinstance(stop_event, Event): raise TypeError("stop_event must be a threading.Event") retry_delay = self.base_retry_delay_seconds + immediate_retry_available = False while not stop_event.is_set(): connection: socket.socket | None = None try: connection = self._connect() for event in self._connected_events(connection, stop_event): retry_delay = self.base_retry_delay_seconds + immediate_retry_available = True yield event if stop_event.is_set(): return @@ -82,6 +84,9 @@ def events(self, stop_event: Event) -> Iterator[StatusEvent]: connection.close() except OSError: pass + if immediate_retry_available: + immediate_retry_available = False + continue if stop_event.wait(retry_delay): return retry_delay = min(retry_delay * 2.0, self.max_retry_delay_seconds) diff --git a/tests/TimeLocker/system_control/test_status_event_transport.py b/tests/TimeLocker/system_control/test_status_event_transport.py index 6daa19e..1c69dbb 100644 --- a/tests/TimeLocker/system_control/test_status_event_transport.py +++ b/tests/TimeLocker/system_control/test_status_event_transport.py @@ -242,6 +242,35 @@ def connect() -> socket.socket: stop_event.set() +def test_event_client_reconnects_immediately_after_a_healthy_stream_drops() -> None: + initial = StatusEvent( + revision=BoundedStatusEventBroker(session_id=SESSION_ID).current_revision(), + kind=StatusEventKind.SNAPSHOT_REQUIRED, + ) + restarted = StatusEvent( + revision=BoundedStatusEventBroker().current_revision(), + kind=StatusEventKind.SNAPSHOT_REQUIRED, + ) + payloads = [ + (json.dumps(initial.to_wire()) + "\n").encode(), + (json.dumps(restarted.to_wire()) + "\n").encode(), + ] + + def connect() -> socket.socket: + return _MemoryConnection([payloads.pop(0)]) # type: ignore[return-value] + + stop_event = Event() + waits: list[float | None] = [] + stop_event.wait = lambda timeout=None: waits.append(timeout) or False # type: ignore[method-assign] + event_client = UnixSocketStatusEventClient(connection_factory=connect) + events = event_client.events(stop_event) + + assert next(events) == initial + assert next(events) == restarted + assert waits == [] + stop_event.set() + + def test_event_client_rejects_denial_without_status_disclosure() -> None: client = _MemoryConnection( [b'{"safe_summary":"System access denied.","status":"denied"}\n'] From 3df80cdc3573e442e39ca772f69de7ab1d05f934 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:33:49 +0100 Subject: [PATCH 54/72] fix: harden tray event acceptance --- docs/1-requirements/system-operations.md | 6 +- docs/SYSTEM-TRAY-SETUP.md | 13 +- docs/guides/user/installation.md | 11 +- .../010-event-driven-tray-status/design.md | 19 +- .../010-event-driven-tray-status/tasks.md | 7 +- .../verification.md | 21 ++- scripts/validate_t011_linux_acceptance.py | 175 ++++++++++++++++++ src/TimeLocker/system_control/__init__.py | 2 + .../assets/timelocker-control.service | 3 +- .../system_control/backend_entry.py | 51 +++-- src/TimeLocker/system_control/event_client.py | 27 ++- src/TimeLocker/system_control/interfaces.py | 13 +- src/TimeLocker/system_control/tray_client.py | 24 ++- src/TimeLocker/system_control/types.py | 8 + .../project/test_t011_linux_acceptance.py | 113 +++++++++++ .../system_control/test_backend_entry.py | 47 ++++- .../system_control/test_linux_adapter.py | 6 +- .../system_control/test_status_contracts.py | 3 +- .../test_status_event_transport.py | 53 +++++- .../test_tray_status_subscription.py | 36 +++- 20 files changed, 592 insertions(+), 46 deletions(-) create mode 100644 scripts/validate_t011_linux_acceptance.py create mode 100644 tests/TimeLocker/project/test_t011_linux_acceptance.py diff --git a/docs/1-requirements/system-operations.md b/docs/1-requirements/system-operations.md index 0233d78..3014057 100644 --- a/docs/1-requirements/system-operations.md +++ b/docs/1-requirements/system-operations.md @@ -76,9 +76,9 @@ backup, retention, status, diagnostics, and tray operations. ## Platform Requirement The architecture must preserve portable contracts for Linux and Windows -adapters. The protected installation and independent tray are currently -live-accepted on Linux Mint. This requirement does not claim an accepted -Windows deployment. +adapters. Linux Mint live acceptance for the protected installation and +independent tray is in progress under Spec 010. This requirement does not yet +claim an accepted Linux or Windows deployment. ## References diff --git a/docs/SYSTEM-TRAY-SETUP.md b/docs/SYSTEM-TRAY-SETUP.md index 8291274..6d900cc 100644 --- a/docs/SYSTEM-TRAY-SETUP.md +++ b/docs/SYSTEM-TRAY-SETUP.md @@ -1,3 +1,11 @@ +--- +title: Independent System Tray Setup +doc_type: guide +status: active +owner: Auriora Team +last_reviewed: 2026-07-27 +--- + # Independent System Tray Setup The TimeLocker tray is an optional, independent user-session process. Normal @@ -80,8 +88,9 @@ timelocker-tray serve ## Platform Status The process boundary is platform-neutral and the source contains a Windows -adapter. The independently installed protected tray/backend deployment has live -acceptance evidence on Linux Mint. This document does not claim a live-accepted +adapter. Linux Mint live acceptance is in progress under Spec 010; package and +installed-artifact checks have passed, but this document does not yet claim a +live-accepted protected deployment. It also does not claim a live-accepted Windows installation. ## References diff --git a/docs/guides/user/installation.md b/docs/guides/user/installation.md index 6aa62f2..4622ab4 100644 --- a/docs/guides/user/installation.md +++ b/docs/guides/user/installation.md @@ -1,10 +1,11 @@ --- title: "User Guide: Installation" id: "user-guide-installation" +doc_type: guide type: [ guide ] status: [ approved ] owner: "Documentation Team" -last_reviewed: "2026-07-26" +last_reviewed: "2026-07-27" tags: [guide, user, installation] links: tooling: [] @@ -203,9 +204,11 @@ credentials. No PyPI distribution is currently published; use the source path above until an authorized release provides downloadable artifacts. The protected immutable-release, local-backend, systemd scheduling, and -independent-tray deployment has live acceptance evidence on Linux Mint. The -portable architecture includes a Windows adapter, but a protected Windows -deployment is not yet claimed as live-accepted. +independent-tray deployment is undergoing live acceptance on Linux Mint under +Spec 010. Package and installed-artifact checks have passed, but protected +deployment acceptance is not yet complete. The portable architecture includes +a Windows adapter, but a protected Windows deployment is not yet claimed as +live-accepted. ### 4.9 Understand Modern Packaging Features diff --git a/docs/specs/010-event-driven-tray-status/design.md b/docs/specs/010-event-driven-tray-status/design.md index ff9780e..d251dd1 100644 --- a/docs/specs/010-event-driven-tray-status/design.md +++ b/docs/specs/010-event-driven-tray-status/design.md @@ -187,13 +187,22 @@ class StatusEventTransport(Protocol): def serve(self, broker, identity_provider, membership_resolver) -> None: ... class StatusEventClient(Protocol): - def events(self, stop_event) -> Iterator[StatusEvent]: ... + def events( + self, + stop_event, + *, + on_connection_state: Callable[[StatusEventConnectionState], None] | None, + ) -> Iterator[StatusEvent]: ... ``` ### Error Handling - Invalid, oversized, unknown-version, or unauthorized subscription frames fail closed with a stable safe result and connection close. +- Platform clients project `connected`, `denied`, and `unavailable` connection + states through the platform-neutral callback. Linux maps an operating-system + socket `PermissionError` to `denied`; other transport failures map to + `unavailable` while bounded reconnect continues. - Event channel unavailability changes tray presentation to unavailable but does not disable explicit control-channel commands. - Backoff is bounded and resets only after a successful authorized handshake. @@ -263,10 +272,18 @@ class StatusEventClient(Protocol): - Install the event socket with the same operator-group ownership model as the control socket. +- Keep the control socket as the service's required activation dependency and + the event socket as a weak dependency. The backend accepts a named control + descriptor without an event descriptor and disables only event delivery in + that mode, so an event-unit failure cannot disable explicit control actions. - Expose health without raw subscriber identities or payloads. - Record bounded connection counts and safe error codes, not user data. - Activation and rollback must verify both timers remain active and enabled. - Live acceptance must avoid production mutation unless separately approved. +- Measure ordinary status-change latency from completed state mutation to tray + presentation. Measure backend restart as separate graceful-shutdown and + new-service-start-to-fresh-presentation intervals; do not count shutdown time + against the ordinary two-second change budget. ## Open Questions diff --git a/docs/specs/010-event-driven-tray-status/tasks.md b/docs/specs/010-event-driven-tray-status/tasks.md index d87a5d9..bcc35c3 100644 --- a/docs/specs/010-event-driven-tray-status/tasks.md +++ b/docs/specs/010-event-driven-tray-status/tasks.md @@ -204,7 +204,7 @@ T009 -> T010 -> T011 -> T012 -> T013 release manifest/launcher code, deployment and package tests - Acceptance: Event socket ownership/mode, release probes, packaging, atomic selection, rollback, and existing backup/retention timers are verified. - - Evidence: Added the root-owned/group-accessible `timelocker-status-events.socket` package asset with mode 0660 and named `status-events` descriptor; the control socket is named `control`, and the service explicitly requires both sockets. Backend activation resolves descriptors from validated `LISTEN_PID`/`LISTEN_FDS`/`LISTEN_FDNAMES` rather than positional assumptions. Schema-2 release metadata binds control and event protocol versions while schema 1 remains readable for legacy rollback without claiming event compatibility. Structured activation probes require compatible CLI/backend/tray, explicit control status, event channel, and active+enabled backup/retention timers before atomic selection; rollback requires coherent artifacts, control status, and both timer states while permitting the added event socket to remain inert. Focused deployment/release/backend/Linux asset suite: 52 passed. Full system-control suite: 246 passed. Scoped Ruff and `git diff --check` passed. `systemd-analyze verify` was environment-limited by unrelated host permissions and absent protected installed launcher executability, so clean installed-unit evidence remains T011. No protected host mutation occurred. + - Evidence: Added the root-owned/group-accessible `timelocker-status-events.socket` package asset with mode 0660 and named `status-events` descriptor; the control socket is named `control`. Backend activation resolves descriptors from validated `LISTEN_PID`/`LISTEN_FDS`/`LISTEN_FDNAMES` rather than positional assumptions. Schema-2 release metadata binds control and event protocol versions while schema 1 remains readable for legacy rollback without claiming event compatibility. Structured activation probes require compatible CLI/backend/tray, explicit control status, event channel, and active+enabled backup/retention timers before atomic selection; rollback requires coherent artifacts, control status, and both timer states while permitting the added event socket to remain inert. Focused deployment/release/backend/Linux asset suite: 52 passed. Full system-control suite: 246 passed. Scoped Ruff and `git diff --check` passed. `systemd-analyze verify` was environment-limited by unrelated host permissions and absent protected installed launcher executability, so clean installed-unit evidence remains T011. The original unit required both sockets; T011 remediation makes the event dependency non-fatal while retaining named descriptor validation. No protected host mutation occurred. - Status: T009 complete; T010 local package build and installed-artifact smoke are dependency-ready. Live deployment remains gated at T011. - Evidence mode: validation @@ -239,7 +239,7 @@ T009 -> T010 -> T011 -> T012 -> T013 - Evidence mode: implementation ## Phase 4: Acceptance, Review, Promotion, And Closure -- [ ] T011 Perform approved Linux Mint acceptance. +- [~] T011 Perform approved Linux Mint acceptance. - Depends on: T010 - Requirements: Requirement 1-Requirement 7 - Properties: CP-001-CP-006 @@ -249,8 +249,9 @@ T009 -> T010 -> T011 -> T012 -> T013 semantics, 90-second idle silence, backend restart, tray restart, action independence, timer health, and rollback are evidenced from the installed artifact. - - Evidence: Pending. + - Evidence: User approved remediation of T011 review findings. Implementation now maps OS socket permission denial to the safe denied state, reports other transport failures as unavailable with bounded reconnect, accepts control-only systemd activation, and makes the event socket a weak service dependency. A repository-owned redacted-evidence validator separates ordinary mutation-to-presentation latency from graceful shutdown and restart convergence. Durable documents now say Linux Mint acceptance is in progress. The focused remediation suite passed 59 tests; the system-control, monitoring-tray, and validator regression passed 270 tests; scoped Ruff, compileall, patch integrity, lifecycle lint, and lifecycle scan passed. Live redeployment and acceptance remain pending. `systemd-analyze verify` parsed the changed units but could not provide clean host evidence because an unrelated unit was unreadable and the protected installed launcher was not executable to the unprivileged verifier. + - Status: Remediation approved after failed live acceptance exposed harness and implementation defects. - [ ] T012 Run the TimeLocker expert review and address findings. - Depends on: T011 - Requirements: Requirement 1-Requirement 7 diff --git a/docs/specs/010-event-driven-tray-status/verification.md b/docs/specs/010-event-driven-tray-status/verification.md index 2fd805c..df2c416 100644 --- a/docs/specs/010-event-driven-tray-status/verification.md +++ b/docs/specs/010-event-driven-tray-status/verification.md @@ -26,7 +26,7 @@ closure. | Tray and event integration tests pass | yes | pass | T007 focused event-driven integration checkpoint: 58 passed | | Windows platform contract tests pass | yes | pass | T008 injected named-pipe contract; live Windows acceptance remains deferred | | Package and deployment checks pass | yes | pass | T009 deployment contract and T010 wheel/sdist installed-artifact checks passed | -| Approved Linux Mint acceptance passes | yes | pending | T011 | +| Approved Linux Mint acceptance passes | yes | in progress | T011 remediation corrects authorization, systemd independence, and evidence timing before another deployment | | Expert review findings resolved | yes | pending | T012 | | Configured regression and coverage gate pass | yes | pending | T013 | | Durable documentation promoted | yes | pending | T013 | @@ -108,7 +108,8 @@ closure. | T008 | complete | Token-derived bounded Windows named-pipe event contracts, four new platform tests, 232 system-control tests | No live Windows service or acceptance is claimed. | | T009 | complete | Named protected event socket asset, named systemd descriptor mapping, dual-protocol release metadata, structured activation/rollback gates, 246 system-control tests | Installed-artifact and live-host evidence remain T010-T011. | | T010 | complete | Five deterministic accessible logo badges, honest never-run/failure projection, 268-test regression, 27-file package-data validation, SHA-256 verification, and clean-install wheel/sdist smoke | No live selector, unit, socket, timer, backup, retention, or protected path changed. | -| T011-T013 | pending | none | Sequenced by the task dependency graph. | +| T011 | in progress | Approved review remediation; repository-owned evidence validator added | Live redeployment and acceptance remain pending. | +| T012-T013 | pending | none | Sequenced by the task dependency graph. | ## Evidence Log @@ -141,6 +142,9 @@ closure. | 2026-07-27 | Release-artifact tests, scoped Ruff, compileall, and patch integrity | pass | 10 artifact tests passed; source/system-control/project-test Ruff, compilation, and `git diff --check` passed. | | 2026-07-27 | T010 Linux status-badge focused and regression validation | pass | 53 focused tests and 268 system-control/monitoring/icon/release-artifact tests passed; scoped Ruff, compileall, and patch integrity passed. | | 2026-07-27 | Rebuilt badge-aware wheel/sdist validation and clean-install smoke | pass | Validator found 27 package-data files; both artifacts passed four-entrypoint, dual-protocol, system-asset, and five-icon smoke checks. Wheel SHA-256: `ceb610a5eafeedc1d0b13f0626d0ac9a74f33a4cb46735778c37fc4712b5bb7b`; sdist SHA-256: `ac9371a6e3087dc515dc5cd0c871687dd7cd23e7ce3468cc2d7d3b09c65bb7e0`. | +| 2026-07-27 | T011 remediation focused tests | 59 passed | OS permission denial, unavailable-state reporting, control-only activation, weak event dependency, and evidence timing boundaries. | +| 2026-07-27 | T011 system-control, monitoring tray, and evidence-validator regression | 270 passed | Scoped Ruff, compileall, `git diff --check`, lifecycle lint, and lifecycle scan also passed. | +| 2026-07-27 | `systemd-analyze verify` for changed control/event units | environment-limited | Changed directives parsed; an unrelated unreadable unit and unprivileged access to the protected installed launcher prevented clean host verification. Live installed-unit proof remains T011. | ## Manual Or External Verification @@ -165,6 +169,19 @@ The reviewed sequence is: Record selected release IDs, artifact hashes, service/socket/timer states, authorized and denied observations, event latency, idle-output capture, restart recovery, and rollback without recording credentials or raw protected content. +Validate the resulting redacted JSON with +`python scripts/validate_t011_linux_acceptance.py EVIDENCE.json`. + +The evidence collector must use these timing boundaries: + +- ordinary change latency: completed state mutation to tray presentation; +- backend restart shutdown: restart request to replacement service start; +- backend restart convergence: replacement service start to a fresh snapshot + from a new backend session. + +Only ordinary change latency carries the Requirement 1 two-second bound. +Backend restart must demonstrate a new session and fresh presentation without +silently folding graceful shutdown time into that latency result. ## Residual Risks diff --git a/scripts/validate_t011_linux_acceptance.py b/scripts/validate_t011_linux_acceptance.py new file mode 100644 index 0000000..856b572 --- /dev/null +++ b/scripts/validate_t011_linux_acceptance.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Validate redacted live evidence for Spec 010 T011. + +The evidence collector records monotonic boundaries at the point where a +status mutation completes, where systemd reports the replacement backend +started, and where the tray presentation callback applies the new snapshot. +This validator deliberately does not count graceful backend shutdown time +against the ordinary two-second status-change budget. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Mapping +import json +from pathlib import Path +import sys +from typing import Any +from uuid import UUID + + +SCHEMA_VERSION = 1 +STATUS_CHANGE_BUDGET_SECONDS = 2.0 +MINIMUM_IDLE_OBSERVATION_SECONDS = 90.0 + + +class AcceptanceEvidenceError(ValueError): + """Raised when live evidence is missing, malformed, or does not pass.""" + + +def _mapping(value: object, field: str) -> Mapping[str, Any]: + if not isinstance(value, dict): + raise AcceptanceEvidenceError(f"{field} must be an object") + return value + + +def _boolean(value: object, field: str) -> bool: + if type(value) is not bool: + raise AcceptanceEvidenceError(f"{field} must be a boolean") + return value + + +def _number(value: object, field: str) -> float: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or value < 0 + ): + raise AcceptanceEvidenceError(f"{field} must be a non-negative number") + return float(value) + + +def _uuid(value: object, field: str) -> UUID: + if not isinstance(value, str): + raise AcceptanceEvidenceError(f"{field} must be a UUID string") + try: + return UUID(value) + except ValueError as error: + raise AcceptanceEvidenceError(f"{field} must be a UUID string") from error + + +def _require_true(section: Mapping[str, Any], field: str) -> None: + if not _boolean(section.get(field), field): + raise AcceptanceEvidenceError(f"{field} did not pass") + + +def validate_evidence(payload: object) -> dict[str, float]: + """Validate one complete, redacted T011 acceptance evidence document.""" + root = _mapping(payload, "evidence") + if root.get("schema_version") != SCHEMA_VERSION: + raise AcceptanceEvidenceError("unsupported schema_version") + + authorization = _mapping(root.get("authorization"), "authorization") + _require_true(authorization, "authorized_initial_snapshot") + if authorization.get("denied_state") != "denied": + raise AcceptanceEvidenceError("denied_state must be 'denied'") + + change = _mapping(root.get("status_change"), "status_change") + mutation_completed = _number( + change.get("mutation_completed_monotonic"), + "mutation_completed_monotonic", + ) + tray_rendered = _number( + change.get("tray_rendered_monotonic"), + "tray_rendered_monotonic", + ) + if tray_rendered < mutation_completed: + raise AcceptanceEvidenceError("tray rendered before mutation completed") + status_change_latency = tray_rendered - mutation_completed + if status_change_latency > STATUS_CHANGE_BUDGET_SECONDS: + raise AcceptanceEvidenceError( + "status change exceeded the two-second acceptance bound" + ) + + restart = _mapping(root.get("backend_restart"), "backend_restart") + restart_requested = _number( + restart.get("restart_requested_monotonic"), + "restart_requested_monotonic", + ) + service_started = _number( + restart.get("service_started_monotonic"), + "service_started_monotonic", + ) + restart_rendered = _number( + restart.get("tray_rendered_monotonic"), + "backend_restart.tray_rendered_monotonic", + ) + if not restart_requested <= service_started <= restart_rendered: + raise AcceptanceEvidenceError("backend restart boundaries are out of order") + previous_session = _uuid( + restart.get("previous_session_id"), + "previous_session_id", + ) + current_session = _uuid( + restart.get("current_session_id"), + "current_session_id", + ) + if previous_session == current_session: + raise AcceptanceEvidenceError("backend restart reused the prior session") + _require_true(restart, "fresh_snapshot_rendered") + + idle = _mapping(root.get("idle_output"), "idle_output") + if ( + _number(idle.get("observation_seconds"), "observation_seconds") + < MINIMUM_IDLE_OBSERVATION_SECONDS + ): + raise AcceptanceEvidenceError("idle observation was shorter than 90 seconds") + if _number(idle.get("stdout_bytes"), "stdout_bytes") != 0: + raise AcceptanceEvidenceError("idle tray wrote to stdout") + if _number(idle.get("stderr_bytes"), "stderr_bytes") != 0: + raise AcceptanceEvidenceError("idle tray wrote to stderr") + + last_success = _mapping(root.get("last_success"), "last_success") + _require_true(last_success, "matches_latest_successful_backup") + _require_true(last_success, "failed_attempt_did_not_replace_success") + + tray_restart = _mapping(root.get("tray_restart"), "tray_restart") + _require_true(tray_restart, "fresh_snapshot_rendered") + + independence = _mapping(root.get("action_independence"), "action_independence") + _require_true(independence, "control_available_without_event_socket") + _require_true(independence, "backup_timer_healthy") + _require_true(independence, "retention_timer_healthy") + + rollback = _mapping(root.get("rollback"), "rollback") + _require_true(rollback, "prior_release_reselected") + _require_true(rollback, "control_available") + _require_true(rollback, "backup_timer_healthy") + _require_true(rollback, "retention_timer_healthy") + + return { + "status_change_latency_seconds": status_change_latency, + "backend_restart_shutdown_seconds": service_started - restart_requested, + "backend_restart_convergence_seconds": restart_rendered - service_started, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Validate redacted Spec 010 T011 Linux acceptance evidence." + ) + parser.add_argument("evidence", type=Path) + arguments = parser.parse_args(argv) + try: + payload = json.loads(arguments.evidence.read_text(encoding="utf-8")) + metrics = validate_evidence(payload) + except (AcceptanceEvidenceError, OSError, json.JSONDecodeError) as error: + print(f"T011 acceptance failed: {error}", file=sys.stderr) + return 1 + print(json.dumps({"status": "pass", "metrics": metrics}, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/TimeLocker/system_control/__init__.py b/src/TimeLocker/system_control/__init__.py index dc9fe9d..fa99464 100644 --- a/src/TimeLocker/system_control/__init__.py +++ b/src/TimeLocker/system_control/__init__.py @@ -89,6 +89,7 @@ ResultCode, RunState, BackendStatus, + StatusEventConnectionState, StatusEventKind, SystemAction, ) @@ -151,6 +152,7 @@ "StatusEventAccessDenied", "StatusEventBroker", "StatusEventClient", + "StatusEventConnectionState", "StatusEventKind", "StatusEventTransport", "StatusRevision", diff --git a/src/TimeLocker/system_control/assets/timelocker-control.service b/src/TimeLocker/system_control/assets/timelocker-control.service index 6b805ad..57fbae8 100644 --- a/src/TimeLocker/system_control/assets/timelocker-control.service +++ b/src/TimeLocker/system_control/assets/timelocker-control.service @@ -1,6 +1,7 @@ [Unit] Description=TimeLocker privileged local system-control backend -Requires=timelocker-control.socket timelocker-status-events.socket +Requires=timelocker-control.socket +Wants=timelocker-status-events.socket After=local-fs.target [Service] diff --git a/src/TimeLocker/system_control/backend_entry.py b/src/TimeLocker/system_control/backend_entry.py index c6bee0e..2456ec9 100644 --- a/src/TimeLocker/system_control/backend_entry.py +++ b/src/TimeLocker/system_control/backend_entry.py @@ -373,7 +373,7 @@ def build_linux_backend( listener: socket.socket | None = None, status_listener: socket.socket | None = None, systemd_descriptor: int = 3, - status_systemd_descriptor: int = 4, + status_systemd_descriptor: int | None = 4, status_socket_mode: str = "systemd", request_timeout_seconds: float = 5.0, membership_resolver: GroupMembershipResolver | None = None, @@ -391,8 +391,10 @@ def build_linux_backend( raise ValueError("max_diagnostics must be between 1 and 100000") if socket_mode not in {"systemd", "listener"}: raise ValueError("socket_mode must be 'systemd' or 'listener'") - if status_socket_mode not in {"systemd", "listener"}: - raise ValueError("status_socket_mode must be 'systemd' or 'listener'") + if status_socket_mode not in {"systemd", "listener", "disabled"}: + raise ValueError( + "status_socket_mode must be 'systemd', 'listener', or 'disabled'" + ) if socket_mode == "listener": if listener is None: raise ValueError("listener socket is required for listener mode") @@ -403,6 +405,8 @@ def build_linux_backend( raise ValueError("status socket listener is required for listener mode") elif status_listener is not None: raise ValueError("status socket listener can only be provided in listener mode") + if status_socket_mode == "systemd" and status_systemd_descriptor is None: + raise ValueError("status systemd descriptor is required for systemd mode") now = clock or _utc_now stop_event = stop_event or Event() @@ -460,12 +464,16 @@ def build_linux_backend( request_timeout_seconds=request_timeout_seconds, stop_event=stop_event, ) - status_event_transport = _build_status_transport( - policy=policy, - socket_mode=status_socket_mode, - listener=status_listener if status_socket_mode == "listener" else None, - systemd_descriptor=status_systemd_descriptor, - stop_event=stop_event, + status_event_transport = ( + None + if status_socket_mode == "disabled" + else _build_status_transport( + policy=policy, + socket_mode=status_socket_mode, + listener=status_listener if status_socket_mode == "listener" else None, + systemd_descriptor=status_systemd_descriptor, + stop_event=stop_event, + ) ) dispatcher = LocalControlDispatcher( policy=policy, @@ -598,8 +606,8 @@ def _systemd_socket_descriptors( environment: Mapping[str, str] | None = None, *, process_id: int | None = None, -) -> tuple[int, int]: - """Resolve named control and event descriptors from systemd activation.""" +) -> tuple[int, int | None]: + """Resolve required control and optional event systemd descriptors.""" environment = os.environ if environment is None else environment process_id = os.getpid() if process_id is None else process_id if type(process_id) is not int or process_id <= 0: @@ -612,14 +620,19 @@ def _systemd_socket_descriptors( or int(listen_pid) != process_id or not listen_fds.isascii() or not listen_fds.isdecimal() - or int(listen_fds) != 2 + or int(listen_fds) not in {1, 2} ): - raise RuntimeError("required systemd socket descriptors are unavailable") + raise RuntimeError("required systemd socket for control is unavailable") names = environment.get("LISTEN_FDNAMES", "").split(":") - if len(names) != 2 or set(names) != {"control", "status-events"}: - raise RuntimeError("required systemd socket descriptor names are unavailable") + expected_names = ( + {"control"} + if int(listen_fds) == 1 + else {"control", "status-events"} + ) + if len(names) != int(listen_fds) or set(names) != expected_names: + raise RuntimeError("required systemd socket name for control is unavailable") descriptors = {name: 3 + index for index, name in enumerate(names)} - return descriptors["control"], descriptors["status-events"] + return descriptors["control"], descriptors.get("status-events") def main(argv: list[str] | None = None) -> None: @@ -721,6 +734,9 @@ def main(argv: list[str] | None = None) -> None: socket_mode="systemd", systemd_descriptor=control_descriptor, status_systemd_descriptor=status_descriptor, + status_socket_mode=( + "systemd" if status_descriptor is not None else "disabled" + ), production_target_path=arguments.production_target, ) except (OSError, PermissionError, RuntimeError, TypeError, ValueError): @@ -757,7 +773,7 @@ def _build_status_transport( policy: SystemPolicy, socket_mode: str, listener: socket.socket | None, - systemd_descriptor: int, + systemd_descriptor: int | None, stop_event: Event, ) -> LinuxStatusEventTransport: if socket_mode == "listener": @@ -769,6 +785,7 @@ def _build_status_transport( operator_group=policy.operator_group, stop_event=stop_event, ) + assert systemd_descriptor is not None return LinuxStatusEventTransport.from_systemd( descriptor=systemd_descriptor, heartbeat_interval_seconds=5.0, diff --git a/src/TimeLocker/system_control/event_client.py b/src/TimeLocker/system_control/event_client.py index 4d1ca4c..066e9de 100644 --- a/src/TimeLocker/system_control/event_client.py +++ b/src/TimeLocker/system_control/event_client.py @@ -9,6 +9,7 @@ from threading import Event from .models import StatusEvent +from .types import StatusEventConnectionState DEFAULT_STATUS_EVENT_SOCKET_PATH = Path("/run/timelocker/status-events.sock") @@ -58,26 +59,48 @@ def __init__( self.max_retry_delay_seconds = float(max_retry_delay_seconds) self._connection_factory = connection_factory - def events(self, stop_event: Event) -> Iterator[StatusEvent]: + def events( + self, + stop_event: Event, + *, + on_connection_state: ( + Callable[[StatusEventConnectionState], None] | None + ) = None, + ) -> Iterator[StatusEvent]: """Yield events, reconnecting until shutdown without steady polling.""" if not isinstance(stop_event, Event): raise TypeError("stop_event must be a threading.Event") retry_delay = self.base_retry_delay_seconds immediate_retry_available = False + reported_state: StatusEventConnectionState | None = None + + def report(state: StatusEventConnectionState) -> None: + nonlocal reported_state + if on_connection_state is not None and state is not reported_state: + on_connection_state(state) + reported_state = state + while not stop_event.is_set(): connection: socket.socket | None = None try: connection = self._connect() for event in self._connected_events(connection, stop_event): + report(StatusEventConnectionState.CONNECTED) retry_delay = self.base_retry_delay_seconds immediate_retry_available = True yield event if stop_event.is_set(): return + except PermissionError as error: + report(StatusEventConnectionState.DENIED) + raise StatusEventAccessDenied( + "System event access denied." + ) from error except StatusEventAccessDenied: + report(StatusEventConnectionState.DENIED) raise except (OSError, TimeoutError, UnicodeDecodeError, ValueError): - pass + report(StatusEventConnectionState.UNAVAILABLE) finally: if connection is not None: try: diff --git a/src/TimeLocker/system_control/interfaces.py b/src/TimeLocker/system_control/interfaces.py index 560deab..82bd14d 100644 --- a/src/TimeLocker/system_control/interfaces.py +++ b/src/TimeLocker/system_control/interfaces.py @@ -1,6 +1,6 @@ """Platform and client interfaces for the TimeLocker system-control boundary.""" -from collections.abc import Iterator +from collections.abc import Callable, Iterator from dataclasses import dataclass from typing import Protocol from uuid import UUID @@ -18,7 +18,7 @@ StatusRevision, StatusSnapshot, ) -from .types import StatusEventKind +from .types import StatusEventConnectionState, StatusEventKind from .validation import require_int, require_safe_identifier @@ -126,7 +126,14 @@ def serve( class StatusEventClient(Protocol): """Consume status events from a platform event transport.""" - def events(self, stop_event: object) -> Iterator[StatusEvent]: + def events( + self, + stop_event: object, + *, + on_connection_state: ( + Callable[[StatusEventConnectionState], None] | None + ) = None, + ) -> Iterator[StatusEvent]: """Yield status events until the caller signals shutdown.""" diff --git a/src/TimeLocker/system_control/tray_client.py b/src/TimeLocker/system_control/tray_client.py index b4f510f..9a1d0f7 100644 --- a/src/TimeLocker/system_control/tray_client.py +++ b/src/TimeLocker/system_control/tray_client.py @@ -16,6 +16,7 @@ ProtocolErrorCode, ResponseStatus, RunState, + StatusEventConnectionState, StatusEventKind, ) @@ -76,8 +77,27 @@ def serve( raise TypeError("stop_event must be a threading.Event") applied = None refresh_pending = True + last_unavailable: str | None = None + + def connection_state_changed(state: StatusEventConnectionState) -> None: + nonlocal last_unavailable + if state is StatusEventConnectionState.CONNECTED: + last_unavailable = None + return + reason = ( + "denied" + if state is StatusEventConnectionState.DENIED + else "unavailable" + ) + if on_unavailable is not None and reason != last_unavailable: + on_unavailable(reason) + last_unavailable = reason + try: - for event in self._event_client.events(stop_event): + for event in self._event_client.events( + stop_event, + on_connection_state=connection_state_changed, + ): if stop_event.is_set(): return if event.kind is StatusEventKind.HEARTBEAT and not refresh_pending: @@ -110,7 +130,7 @@ def serve( refresh_pending = False on_snapshot(snapshot) except StatusEventAccessDenied: - if on_unavailable is not None: + if on_unavailable is not None and last_unavailable != "denied": on_unavailable("denied") diff --git a/src/TimeLocker/system_control/types.py b/src/TimeLocker/system_control/types.py index b2489fd..fb420aa 100644 --- a/src/TimeLocker/system_control/types.py +++ b/src/TimeLocker/system_control/types.py @@ -44,6 +44,14 @@ class StatusEventKind(StrEnum): RESYNC_REQUIRED = "resync_required" +class StatusEventConnectionState(StrEnum): + """Safe connection states exposed by platform event clients.""" + + CONNECTED = "connected" + DENIED = "denied" + UNAVAILABLE = "unavailable" + + class ProtocolErrorCode(StrEnum): """Stable response errors with metadata-free, code-owned summaries.""" diff --git a/tests/TimeLocker/project/test_t011_linux_acceptance.py b/tests/TimeLocker/project/test_t011_linux_acceptance.py new file mode 100644 index 0000000..6a80d35 --- /dev/null +++ b/tests/TimeLocker/project/test_t011_linux_acceptance.py @@ -0,0 +1,113 @@ +"""Contract tests for the Spec 010 T011 live-evidence validator.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] + + +def _load_validator() -> ModuleType: + path = ROOT / "scripts/validate_t011_linux_acceptance.py" + spec = importlib.util.spec_from_file_location( + "validate_t011_linux_acceptance", + path, + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _evidence() -> dict[str, object]: + return { + "schema_version": 1, + "authorization": { + "authorized_initial_snapshot": True, + "denied_state": "denied", + }, + "status_change": { + "mutation_completed_monotonic": 100.0, + "tray_rendered_monotonic": 101.5, + }, + "backend_restart": { + "restart_requested_monotonic": 200.0, + "service_started_monotonic": 202.5, + "tray_rendered_monotonic": 203.4, + "previous_session_id": "526719f9-4c46-42ac-b286-2623079bc335", + "current_session_id": "eb53eaf9-42c5-45e2-b772-a3c6d7ace818", + "fresh_snapshot_rendered": True, + }, + "idle_output": { + "observation_seconds": 90.0, + "stdout_bytes": 0, + "stderr_bytes": 0, + }, + "last_success": { + "matches_latest_successful_backup": True, + "failed_attempt_did_not_replace_success": True, + }, + "tray_restart": {"fresh_snapshot_rendered": True}, + "action_independence": { + "control_available_without_event_socket": True, + "backup_timer_healthy": True, + "retention_timer_healthy": True, + }, + "rollback": { + "prior_release_reselected": True, + "control_available": True, + "backup_timer_healthy": True, + "retention_timer_healthy": True, + }, + } + + +def test_validator_separates_shutdown_from_restart_convergence() -> None: + validator = _load_validator() + + metrics = validator.validate_evidence(_evidence()) + + assert metrics == { + "status_change_latency_seconds": 1.5, + "backend_restart_shutdown_seconds": 2.5, + "backend_restart_convergence_seconds": pytest.approx(0.9), + } + + +def test_validator_rejects_slow_status_change_not_slow_shutdown() -> None: + validator = _load_validator() + evidence = _evidence() + evidence["status_change"]["tray_rendered_monotonic"] = 102.01 + + with pytest.raises( + validator.AcceptanceEvidenceError, + match="two-second", + ): + validator.validate_evidence(evidence) + + +@pytest.mark.parametrize( + ("section", "field"), + [ + ("authorization", "authorized_initial_snapshot"), + ("last_success", "matches_latest_successful_backup"), + ("tray_restart", "fresh_snapshot_rendered"), + ("action_independence", "control_available_without_event_socket"), + ("rollback", "prior_release_reselected"), + ], +) +def test_validator_rejects_missing_acceptance_proof( + section: str, + field: str, +) -> None: + validator = _load_validator() + evidence = _evidence() + del evidence[section][field] + + with pytest.raises(validator.AcceptanceEvidenceError): + validator.validate_evidence(evidence) diff --git a/tests/TimeLocker/system_control/test_backend_entry.py b/tests/TimeLocker/system_control/test_backend_entry.py index 0327163..219d2de 100644 --- a/tests/TimeLocker/system_control/test_backend_entry.py +++ b/tests/TimeLocker/system_control/test_backend_entry.py @@ -185,6 +185,21 @@ def test_systemd_descriptor_names_remove_order_dependency() -> None: assert status == 3 +@pytest.mark.unit +def test_systemd_descriptor_contract_allows_control_without_event_socket() -> None: + control, status = backend_entry._systemd_socket_descriptors( + { + "LISTEN_PID": "123", + "LISTEN_FDS": "1", + "LISTEN_FDNAMES": "control", + }, + process_id=123, + ) + + assert control == 3 + assert status is None + + @pytest.mark.unit @pytest.mark.parametrize( "environment", @@ -198,7 +213,7 @@ def test_systemd_descriptor_names_remove_order_dependency() -> None: { "LISTEN_PID": "123", "LISTEN_FDS": "1", - "LISTEN_FDNAMES": "control", + "LISTEN_FDNAMES": "status-events", }, { "LISTEN_PID": "123", @@ -214,6 +229,36 @@ def test_systemd_descriptor_contract_fails_closed( backend_entry._systemd_socket_descriptors(environment, process_id=123) +@pytest.mark.unit +def test_main_runs_control_backend_when_event_socket_is_absent( + monkeypatch, + tmp_path: Path, +) -> None: + captured: dict[str, object] = {} + monkeypatch.setattr( + backend_entry, + "run_linux_backend", + lambda **kwargs: captured.update(kwargs), + ) + monkeypatch.setenv("LISTEN_PID", str(os.getpid())) + monkeypatch.setenv("LISTEN_FDS", "1") + monkeypatch.setenv("LISTEN_FDNAMES", "control") + + backend_entry.main( + [ + "--systemd-socket", + "--policy", + str(tmp_path / "policy.json"), + "--state-root", + str(tmp_path / "state"), + ] + ) + + assert captured["systemd_descriptor"] == 3 + assert captured["status_systemd_descriptor"] is None + assert captured["status_socket_mode"] == "disabled" + + @pytest.mark.unit def test_build_linux_backend_rejects_status_listener_without_listener_mode( tmp_path: Path, diff --git a/tests/TimeLocker/system_control/test_linux_adapter.py b/tests/TimeLocker/system_control/test_linux_adapter.py index 086a02b..1ea7358 100644 --- a/tests/TimeLocker/system_control/test_linux_adapter.py +++ b/tests/TimeLocker/system_control/test_linux_adapter.py @@ -239,9 +239,13 @@ def test_socket_and_service_templates_enforce_narrow_boundary(self) -> None: "Sockets=timelocker-control.socket timelocker-status-events.socket" in service_unit ) + assert ( + "Requires=timelocker-control.socket" in service_unit + ) + assert "Wants=timelocker-status-events.socket" in service_unit assert ( "Requires=timelocker-control.socket timelocker-status-events.socket" - in service_unit + not in service_unit ) assert "UMask=0077" in service_unit assert "RuntimeDirectory=" not in service_unit diff --git a/tests/TimeLocker/system_control/test_status_contracts.py b/tests/TimeLocker/system_control/test_status_contracts.py index db42c13..c966a19 100644 --- a/tests/TimeLocker/system_control/test_status_contracts.py +++ b/tests/TimeLocker/system_control/test_status_contracts.py @@ -326,8 +326,9 @@ def serve(self, broker, identity_provider, membership_resolver) -> None: assert broker.current_revision() == snapshot.revision class FakeClient: - def events(self, stop_event: object): + def events(self, stop_event: object, *, on_connection_state=None): del stop_event + del on_connection_state yield event provider = cast(StatusSnapshotProvider, FakeProvider()) diff --git a/tests/TimeLocker/system_control/test_status_event_transport.py b/tests/TimeLocker/system_control/test_status_event_transport.py index 1c69dbb..d728fa1 100644 --- a/tests/TimeLocker/system_control/test_status_event_transport.py +++ b/tests/TimeLocker/system_control/test_status_event_transport.py @@ -17,7 +17,10 @@ from TimeLocker.system_control.linux_adapter import LinuxStatusEventTransport from TimeLocker.system_control.models import StatusEvent from TimeLocker.system_control.status_events import BoundedStatusEventBroker -from TimeLocker.system_control.types import StatusEventKind +from TimeLocker.system_control.types import ( + StatusEventConnectionState, + StatusEventKind, +) SESSION_ID = UUID("58d95acd-aa24-4461-96bb-74d3421e8e42") @@ -280,3 +283,51 @@ def test_event_client_rejects_denial_without_status_disclosure() -> None: ) with pytest.raises(StatusEventAccessDenied, match="access denied"): next(event_client.events(Event())) + + +def test_event_client_projects_os_permission_denial_without_retry() -> None: + calls = 0 + + def denied_connect() -> socket.socket: + nonlocal calls + calls += 1 + raise PermissionError("private socket path") + + states: list[StatusEventConnectionState] = [] + event_client = UnixSocketStatusEventClient( + connection_factory=denied_connect, + ) + + with pytest.raises(StatusEventAccessDenied, match="access denied"): + next(event_client.events(Event(), on_connection_state=states.append)) + + assert calls == 1 + assert states == [StatusEventConnectionState.DENIED] + + +def test_event_client_reports_unavailable_once_while_retrying() -> None: + stop_event = Event() + states: list[StatusEventConnectionState] = [] + calls = 0 + + def unavailable_connect() -> socket.socket: + nonlocal calls + calls += 1 + if calls == 2: + stop_event.set() + raise FileNotFoundError("socket missing") + + event_client = UnixSocketStatusEventClient( + connection_factory=unavailable_connect, + base_retry_delay_seconds=0.001, + max_retry_delay_seconds=0.002, + ) + + assert list( + event_client.events( + stop_event, + on_connection_state=states.append, + ) + ) == [] + assert calls == 2 + assert states == [StatusEventConnectionState.UNAVAILABLE] diff --git a/tests/TimeLocker/system_control/test_tray_status_subscription.py b/tests/TimeLocker/system_control/test_tray_status_subscription.py index e4069a6..41bd0a8 100644 --- a/tests/TimeLocker/system_control/test_tray_status_subscription.py +++ b/tests/TimeLocker/system_control/test_tray_status_subscription.py @@ -17,6 +17,7 @@ BackendStatus, ProtocolErrorCode, ResponseStatus, + StatusEventConnectionState, StatusEventKind, ) @@ -47,7 +48,9 @@ class _EventClient: def __init__(self, events: list[StatusEvent]) -> None: self._events = events - def events(self, _stop_event: Event): + def events(self, _stop_event: Event, *, on_connection_state=None): + if on_connection_state is not None: + on_connection_state(StatusEventConnectionState.CONNECTED) yield from self._events @@ -116,7 +119,9 @@ def test_older_snapshot_never_regresses_presentation() -> None: def test_denied_subscription_projects_only_safe_unavailable_state() -> None: class _DeniedClient: - def events(self, _stop_event: Event): + def events(self, _stop_event: Event, *, on_connection_state=None): + if on_connection_state is not None: + on_connection_state(StatusEventConnectionState.DENIED) raise StatusEventAccessDenied("secret backend detail") yield @@ -132,6 +137,33 @@ def events(self, _stop_event: Event): assert unavailable == ["denied"] +def test_event_transport_unavailability_projects_safe_state_once() -> None: + class _UnavailableThenConnectedClient: + def events(self, _stop_event: Event, *, on_connection_state=None): + assert on_connection_state is not None + on_connection_state(StatusEventConnectionState.UNAVAILABLE) + on_connection_state(StatusEventConnectionState.UNAVAILABLE) + on_connection_state(StatusEventConnectionState.CONNECTED) + yield StatusEvent( + StatusRevision(SESSION_ONE, 0), + StatusEventKind.SNAPSHOT_REQUIRED, + ) + + unavailable: list[str] = [] + applied: list[StatusSnapshot] = [] + TrayStatusSubscriptionClient( + control_client=_ControlClient([_snapshot(SESSION_ONE, 0)]), + event_client=_UnavailableThenConnectedClient(), + ).serve( + Event(), + on_snapshot=applied.append, + on_unavailable=unavailable.append, + ) + + assert unavailable == ["unavailable"] + assert applied == [_snapshot(SESSION_ONE, 0)] + + def test_heartbeat_retries_initial_snapshot_only_while_not_current() -> None: class _RecoveringControl: def __init__(self) -> None: From 8e8ebada197e713b60285d5105fe8b7ad8b9b8dc Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:35:00 +0100 Subject: [PATCH 55/72] fix: harden T011 deployment transaction --- .../010-event-driven-tray-status/tasks.md | 29 +- .../verification.md | 23 +- scripts/README.md | 29 +- scripts/deploy_t011_linux.py | 793 ++++++++++++++++++ .../system_control/release_admin.py | 6 +- .../system_control/release_launcher.py | 91 +- .../project/test_t011_linux_deployment.py | 568 +++++++++++++ .../test_release_entrypoints.py | 28 + .../system_control/test_release_launcher.py | 17 + 9 files changed, 1551 insertions(+), 33 deletions(-) create mode 100755 scripts/deploy_t011_linux.py create mode 100644 tests/TimeLocker/project/test_t011_linux_deployment.py diff --git a/docs/specs/010-event-driven-tray-status/tasks.md b/docs/specs/010-event-driven-tray-status/tasks.md index bcc35c3..ce0c960 100644 --- a/docs/specs/010-event-driven-tray-status/tasks.md +++ b/docs/specs/010-event-driven-tray-status/tasks.md @@ -249,9 +249,34 @@ T009 -> T010 -> T011 -> T012 -> T013 semantics, 90-second idle silence, backend restart, tray restart, action independence, timer health, and rollback are evidenced from the installed artifact. - - Evidence: User approved remediation of T011 review findings. Implementation now maps OS socket permission denial to the safe denied state, reports other transport failures as unavailable with bounded reconnect, accepts control-only systemd activation, and makes the event socket a weak service dependency. A repository-owned redacted-evidence validator separates ordinary mutation-to-presentation latency from graceful shutdown and restart convergence. Durable documents now say Linux Mint acceptance is in progress. The focused remediation suite passed 59 tests; the system-control, monitoring-tray, and validator regression passed 270 tests; scoped Ruff, compileall, patch integrity, lifecycle lint, and lifecycle scan passed. Live redeployment and acceptance remain pending. `systemd-analyze verify` parsed the changed units but could not provide clean host evidence because an unrelated unit was unreadable and the protected installed launcher was not executable to the unprivileged verifier. + - Evidence: User approved remediation of T011 review findings. Implementation + maps OS socket permission denial to the safe denied state, reports other + transport failures as unavailable with bounded reconnect, accepts + control-only systemd activation, and makes the event socket a weak service + dependency. A repository-owned redacted-evidence validator separates + ordinary mutation-to-presentation latency from graceful shutdown and restart + convergence. After a temporary deployment script failed because its `0660` + probe was executed as an identity that could not read it, the rollout path + was replaced with the repository-owned `scripts/deploy_t011_linux.py` + harness. It snapshots exact inputs into private root-owned evidence, runs + inline authorized and denied identity probes before protected mutation, + rejects packaged assets outside the staged release, uses a locked + expected-current selector compare-and-swap, and restores the selector, + service unit, sockets, service, and timer gates on failure or interruption. + Focused harness, selector, deployment, and evidence validation passed 46 + tests; the complete system-control plus harness/evidence regression passed + 272 tests. Scoped Ruff, compileall, and patch integrity passed. A fresh wheel + and sdist passed artifact validation with 27 package-data files; the wheel + passed installed-artifact smoke and explicit installed selector-contract + checks. Wheel SHA-256: + `5603dd6c4aae461f5e6e673eea97b2d2b2972e843b6d9a32f8f3d8347e1c3dde`; + sdist SHA-256: + `fc0f4bda037a7c41128a8834129a7be9c20040d0efd8580dab05ff0599427748`. + Live redeployment and acceptance remain pending; no backup, retention, + selector, unit, or protected host state was changed by this remediation. - - Status: Remediation approved after failed live acceptance exposed harness and implementation defects. + - Status: Harness remediation implemented and locally validated; a committed + release artifact and renewed live deployment approval remain required. - [ ] T012 Run the TimeLocker expert review and address findings. - Depends on: T011 - Requirements: Requirement 1-Requirement 7 diff --git a/docs/specs/010-event-driven-tray-status/verification.md b/docs/specs/010-event-driven-tray-status/verification.md index df2c416..2cf0d1c 100644 --- a/docs/specs/010-event-driven-tray-status/verification.md +++ b/docs/specs/010-event-driven-tray-status/verification.md @@ -26,7 +26,7 @@ closure. | Tray and event integration tests pass | yes | pass | T007 focused event-driven integration checkpoint: 58 passed | | Windows platform contract tests pass | yes | pass | T008 injected named-pipe contract; live Windows acceptance remains deferred | | Package and deployment checks pass | yes | pass | T009 deployment contract and T010 wheel/sdist installed-artifact checks passed | -| Approved Linux Mint acceptance passes | yes | in progress | T011 remediation corrects authorization, systemd independence, and evidence timing before another deployment | +| Approved Linux Mint acceptance passes | yes | in progress | Repository-owned preflight-first harness and evidence validator pass locally; committed live artifact and host acceptance remain | | Expert review findings resolved | yes | pending | T012 | | Configured regression and coverage gate pass | yes | pending | T013 | | Durable documentation promoted | yes | pending | T013 | @@ -108,7 +108,7 @@ closure. | T008 | complete | Token-derived bounded Windows named-pipe event contracts, four new platform tests, 232 system-control tests | No live Windows service or acceptance is claimed. | | T009 | complete | Named protected event socket asset, named systemd descriptor mapping, dual-protocol release metadata, structured activation/rollback gates, 246 system-control tests | Installed-artifact and live-host evidence remain T010-T011. | | T010 | complete | Five deterministic accessible logo badges, honest never-run/failure projection, 268-test regression, 27-file package-data validation, SHA-256 verification, and clean-install wheel/sdist smoke | No live selector, unit, socket, timer, backup, retention, or protected path changed. | -| T011 | in progress | Approved review remediation; repository-owned evidence validator added | Live redeployment and acceptance remain pending. | +| T011 | in progress | Repository-owned evidence validator and preflight-first transactional deployment harness; 272-test regression | Committed live artifact, redeployment, and acceptance remain pending. | | T012-T013 | pending | none | Sequenced by the task dependency graph. | ## Evidence Log @@ -145,6 +145,10 @@ closure. | 2026-07-27 | T011 remediation focused tests | 59 passed | OS permission denial, unavailable-state reporting, control-only activation, weak event dependency, and evidence timing boundaries. | | 2026-07-27 | T011 system-control, monitoring tray, and evidence-validator regression | 270 passed | Scoped Ruff, compileall, `git diff --check`, lifecycle lint, and lifecycle scan also passed. | | 2026-07-27 | `systemd-analyze verify` for changed control/event units | environment-limited | Changed directives parsed; an unrelated unreadable unit and unprivileged access to the protected installed launcher prevented clean host verification. Live installed-unit proof remains T011. | +| 2026-07-27 | Failed temporary-script deployment review | fail closed; rollback passed | A `0660` temporary probe was unreadable to UID/GID 65534, and the script selected the release before identity probes, contrary to the approved order. Prior release, unit, sockets, service, and timers were restored; the candidate was removed. | +| 2026-07-27 | Repository-owned T011 deployment harness focused regression | 46 passed | Restrictive umask, inline target identities, preflight-before-selection, input snapshotting, package-boundary enforcement, compare-and-swap, signal recovery, full simulated activation, and forced post-activation rollback. | +| 2026-07-27 | System-control plus T011 harness/evidence regression | 272 passed | Scoped Ruff, compileall, and patch integrity passed. No protected host mutation occurred. | +| 2026-07-27 | Fresh T011 harness-remediation package validation | pass | Wheel and sdist contained 27 package-data files; wheel SHA-256 `5603dd6c4aae461f5e6e673eea97b2d2b2972e843b6d9a32f8f3d8347e1c3dde`; sdist SHA-256 `fc0f4bda037a7c41128a8834129a7be9c20040d0efd8580dab05ff0599427748`. Wheel installed-artifact smoke and installed expected-current selector checks passed. | ## Manual Or External Verification @@ -153,13 +157,14 @@ The reviewed sequence is: 1. Record the current and previous selected release IDs plus active/enabled backup and retention timer states. -2. Stage the validated artifact as an immutable root-owned release with - schema-2 control/event protocol metadata. -3. Install the exact hashed assets, reload systemd, and enable the protected - control and status-event sockets without changing backup or retention - policy. -4. Run staged CLI/backend/tray/protocol probes and verify both existing timers - before atomically selecting the new release. +2. Use the committed repository-owned `scripts/deploy_t011_linux.py` harness to + copy the exact wheel and manifest into private root-owned evidence, then + stage an immutable release with schema-2 control/event protocol metadata. +3. Run staged CLI/backend/protocol, authorized-event, denied-event, systemd, + and timer probes before changing the selected release or service unit. +4. Install the validated service unit, select with the locked expected-current + compare-and-swap, restart the backend, and recheck both existing timers + without changing backup or retention policy. 5. Run authorized/denied event, status, silence, restart, and independence acceptance checks. 6. Probe and atomically roll back to the previous release, then verify explicit diff --git a/scripts/README.md b/scripts/README.md index 18b11c5..26c8227 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,6 +1,31 @@ -# CLI Extraction Scripts +# Repository Automation Scripts -This directory contains automation scripts for the CLI refactoring project. +This directory contains reviewed repository automation and validation scripts. + +## T011 Linux Deployment + +`deploy_t011_linux.py` is the repository-owned privileged deployment harness +for Spec 010 T011. It: + +- copies the exact wheel and release manifest into root-owned private evidence + before validating or installing them; +- runs candidate CLI, backend, authorized-event, denied-event, systemd, and + timer preflights before changing the service unit or release selector; +- uses inline identity probes, so restrictive umasks cannot make temporary + probe files unreadable to their intended identities; +- selects the candidate with an expected-current compare-and-swap guard; and +- restores the prior selector and service unit after an exception, interrupt, + termination, or failed post-activation check. + +The harness does not run backup or retention. Its arguments must identify a +committed, freshly built release, and protected execution remains explicitly +approval-gated by the active spec. + +Use `python scripts/deploy_t011_linux.py --help` to inspect its required, +hash-bound inputs. Do not copy it to `/tmp` or replace its inline probes with +external temporary scripts. + +## CLI Extraction ## extract_cli_commands.py diff --git a/scripts/deploy_t011_linux.py b/scripts/deploy_t011_linux.py new file mode 100755 index 0000000..8f5e19d --- /dev/null +++ b/scripts/deploy_t011_linux.py @@ -0,0 +1,793 @@ +#!/usr/bin/env python3 +"""Safely stage and activate a TimeLocker release for Spec 010 T011. + +This operator-facing harness deliberately keeps candidate probes independent of +temporary Python files. Every identity-sensitive probe runs against the staged +release before the selected-release document or systemd service unit changes. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from contextlib import contextmanager +from dataclasses import dataclass +import fcntl +import hashlib +import json +import os +from pathlib import Path +import pwd +import re +import shutil +import signal +import subprocess +import sys +from types import FrameType +from typing import TextIO + + +RELEASE_ID_PATTERN = re.compile(r"[0-9a-f]{40}") +REQUIRED_ENTRYPOINTS = ( + "timelocker", + "tl", + "timelocker-tray", + "timelocker-system-control", +) +REQUIRED_ACTIVE_UNITS = ( + "timelocker-control.service", + "timelocker-control.socket", + "timelocker-status-events.socket", + "timelocker-npbackup-migration.timer", + "timelocker-retention.timer", +) +REQUIRED_ENABLED_UNITS = ( + "timelocker-control.socket", + "timelocker-status-events.socket", + "timelocker-npbackup-migration.timer", + "timelocker-retention.timer", +) + +AUTHORIZED_EVENT_PROBE = """\ +import json +from threading import Event +from TimeLocker.system_control.event_client import UnixSocketStatusEventClient +stop = Event() +events = UnixSocketStatusEventClient().events(stop) +event = next(events) +stop.set() +print(json.dumps({ + "kind": event.kind.value, + "sequence": event.revision.sequence, + "session_id": str(event.revision.session_id), +}, sort_keys=True)) +""" + +DENIED_EVENT_PROBE = """\ +from threading import Event +from TimeLocker.system_control.event_client import ( + StatusEventAccessDenied, + UnixSocketStatusEventClient, +) +try: + next(UnixSocketStatusEventClient().events(Event())) +except StatusEventAccessDenied: + print("denied") +else: + raise SystemExit("unauthorized event subscription unexpectedly succeeded") +""" + +BACKEND_IMPORT_PROBE = """\ +from TimeLocker.system_control.backend_entry import main +from TimeLocker.system_control.models import ( + PROTOCOL_VERSION, + STATUS_EVENT_PROTOCOL_VERSION, +) +assert callable(main) +print(f"{PROTOCOL_VERSION}:{STATUS_EVENT_PROTOCOL_VERSION}") +""" + +PACKAGED_UNIT_PROBE = """\ +from importlib.resources import files +print(files("TimeLocker.system_control.assets") / "timelocker-control.service") +""" + + +class DeploymentFailure(RuntimeError): + """Raised when a deployment gate fails or rollback cannot complete.""" + + +class DeploymentInterrupted(DeploymentFailure): + """Raised when SIGINT or SIGTERM interrupts a deployment.""" + + +@dataclass(frozen=True, slots=True) +class DeploymentPaths: + """Protected paths used by the Linux immutable-release deployment.""" + + releases_root: Path = Path("/opt/timelocker/releases") + selector: Path = Path("/opt/timelocker/selected-release.json") + service_unit: Path = Path("/etc/systemd/system/timelocker-control.service") + evidence_root: Path = Path("/var/lib/timelocker/migration-backup") + lock_file: Path = Path("/run/lock/timelocker-t011-deploy.lock") + + +@dataclass(frozen=True, slots=True) +class DeploymentRequest: + """Validated inputs identifying the exact release artifact to deploy.""" + + release_id: str + expected_current: str + wheel: Path + wheel_sha256: str + manifest: Path + operator_user: str + + +class CommandExecutor: + """Run bounded commands and optionally retain their redacted output.""" + + def run( + self, + arguments: Sequence[str | Path], + *, + timeout: int = 30, + output: Path | None = None, + capture: bool = False, + check: bool = True, + ) -> str: + command = [str(argument) for argument in arguments] + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + combined = completed.stdout + if completed.stderr: + combined += completed.stderr + if output is not None: + _write_private_text(output, combined) + if check and completed.returncode != 0: + raise DeploymentFailure( + f"command failed ({completed.returncode}): {_display_command(command)}" + ) + return completed.stdout if capture else combined + + +class T011LinuxDeployer: + """Preflight-first, rollback-safe Linux deployment transaction.""" + + def __init__( + self, + request: DeploymentRequest, + *, + paths: DeploymentPaths | None = None, + executor: CommandExecutor | None = None, + owner_uid: int | None = 0, + owner_gid: int | None = 0, + ) -> None: + self.request = request + self.paths = paths or DeploymentPaths() + self.executor = executor or CommandExecutor() + self.owner_uid = owner_uid + self.owner_gid = owner_gid + self.release = self.paths.releases_root / request.release_id + self.evidence: Path | None = None + self.staged_wheel: Path | None = None + self.staged_manifest: Path | None = None + self.mutation_started = False + self.completed = False + + def deploy(self) -> Path: + """Stage, preflight, activate, and verify one exact release.""" + self.validate_request() + self.capture_baseline() + try: + self.stage_release() + self.preflight_staged_release() + self.activate() + self.verify_activation() + except BaseException: + self.recover() + raise + self.completed = True + assert self.evidence is not None + return self.evidence + + def validate_request(self) -> None: + """Reject unsafe or incoherent inputs before creating host state.""" + for field, value in ( + ("release_id", self.request.release_id), + ("expected_current", self.request.expected_current), + ): + if RELEASE_ID_PATTERN.fullmatch(value) is None: + raise DeploymentFailure(f"{field} must be a 40-character Git SHA") + if ( + len(self.request.wheel_sha256) != 64 + or any( + character not in "0123456789abcdef" + for character in self.request.wheel_sha256 + ) + ): + raise DeploymentFailure("wheel_sha256 must be a lowercase SHA-256 digest") + _require_regular_file(self.request.wheel, "wheel") + _require_regular_file(self.request.manifest, "manifest") + try: + pwd.getpwnam(self.request.operator_user) + except KeyError as error: + raise DeploymentFailure("operator_user does not exist") from error + if self.release.exists(): + raise DeploymentFailure(f"candidate release already exists: {self.release}") + if _selected_release(self.paths.selector) != self.request.expected_current: + raise DeploymentFailure("selected release changed before deployment") + for unit in REQUIRED_ACTIVE_UNITS: + self._systemctl_gate("is-active", unit) + for unit in REQUIRED_ENABLED_UNITS: + self._systemctl_gate("is-enabled", unit) + + def capture_baseline(self) -> None: + """Create private evidence and immutable rollback inputs.""" + timestamp = subprocess.run( + ["date", "-u", "+%Y%m%dT%H%M%SZ"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + self.evidence = ( + self.paths.evidence_root + / f"t011-hardened-deploy-{timestamp}-{os.getpid()}" + ) + _mkdir(self.evidence, mode=0o750, uid=self.owner_uid, gid=self.owner_gid) + _atomic_copy( + self.paths.selector, + self.evidence / "selected-release.before.json", + mode=0o600, + uid=self.owner_uid, + gid=self.owner_gid, + ) + _atomic_copy( + self.paths.service_unit, + self.evidence / "timelocker-control.service.before", + mode=0o600, + uid=self.owner_uid, + gid=self.owner_gid, + ) + self.staged_wheel = self.evidence / "candidate.whl" + self.staged_manifest = self.evidence / "candidate-release.json" + _atomic_copy( + self.request.wheel, + self.staged_wheel, + mode=0o600, + uid=self.owner_uid, + gid=self.owner_gid, + ) + _atomic_copy( + self.request.manifest, + self.staged_manifest, + mode=0o600, + uid=self.owner_uid, + gid=self.owner_gid, + ) + if _sha256(self.staged_wheel) != self.request.wheel_sha256: + raise DeploymentFailure("copied wheel SHA-256 does not match") + manifest = _read_json(self.staged_manifest) + expected_manifest = { + "schema_version": 2, + "release_id": self.request.release_id, + "control_protocol_version": 1, + "event_protocol_version": 1, + "entrypoint": "venv/bin/timelocker", + } + for field, expected in expected_manifest.items(): + if manifest.get(field) != expected: + raise DeploymentFailure(f"manifest {field} is incompatible") + + def stage_release(self) -> None: + """Install the wheel into an inert, immutable release directory.""" + assert self.evidence is not None + assert self.staged_wheel is not None + assert self.staged_manifest is not None + _mkdir(self.release, mode=0o755, uid=self.owner_uid, gid=self.owner_gid) + self.executor.run( + ["python3", "-m", "venv", "--system-site-packages", self.release / "venv"], + timeout=120, + output=self.evidence / "venv-create.txt", + ) + python = self.release / "venv/bin/python" + self.executor.run( + [ + python, + "-m", + "pip", + "install", + "--disable-pip-version-check", + self.staged_wheel, + ], + timeout=600, + output=self.evidence / "pip-install.txt", + ) + _atomic_copy( + self.staged_manifest, + self.release / "release.json", + mode=0o644, + uid=self.owner_uid, + gid=self.owner_gid, + ) + _make_tree_immutable( + self.release, + uid=self.owner_uid, + gid=self.owner_gid, + ) + + def preflight_staged_release(self) -> None: + """Exercise every target identity before protected activation.""" + assert self.evidence is not None + python = self.release / "venv/bin/python" + for entrypoint in REQUIRED_ENTRYPOINTS: + path = self.release / "venv/bin" / entrypoint + _require_regular_file(path, f"staged entrypoint {entrypoint}") + expected = f"#!{python}" + try: + actual = path.open(encoding="utf-8").readline().rstrip("\n") + except OSError as error: + raise DeploymentFailure( + f"cannot inspect staged entrypoint: {entrypoint}" + ) from error + if actual != expected or not os.access(path, os.X_OK): + raise DeploymentFailure( + f"staged entrypoint is not executable at its final path: {entrypoint}" + ) + + protocol_output = self.executor.run( + [python, "-c", BACKEND_IMPORT_PROBE], + capture=True, + ).strip() + if protocol_output != "1:1": + raise DeploymentFailure("staged backend protocol probe failed") + packaged_unit = Path( + self.executor.run( + [python, "-c", PACKAGED_UNIT_PROBE], + capture=True, + ).strip() + ) + self._validate_packaged_unit(packaged_unit) + self.executor.run( + ["systemd-analyze", "verify", packaged_unit], + timeout=30, + output=self.evidence / "systemd-analyze-preflight.txt", + ) + + candidate_cli = self.release / "venv/bin/timelocker" + self.executor.run( + [ + "timeout", + "15", + "runuser", + "-u", + self.request.operator_user, + "--", + candidate_cli, + "runs", + "list", + "--limit", + "3", + "--json", + ], + timeout=20, + output=self.evidence / "preflight-authorized-runs.json", + ) + self.executor.run( + [ + "timeout", + "10", + "runuser", + "-u", + self.request.operator_user, + "--", + python, + "-c", + AUTHORIZED_EVENT_PROBE, + ], + timeout=15, + output=self.evidence / "preflight-authorized-event.json", + ) + denied = self.executor.run( + [ + "timeout", + "10", + "setpriv", + "--reuid=65534", + "--regid=65534", + "--clear-groups", + python, + "-c", + DENIED_EVENT_PROBE, + ], + timeout=15, + capture=True, + ).strip() + if denied != "denied": + raise DeploymentFailure("staged denied-identity probe did not deny access") + _write_private_text(self.evidence / "preflight-denied-event.txt", denied + "\n") + + if _selected_release(self.paths.selector) != self.request.expected_current: + raise DeploymentFailure("selector changed during staged preflight") + for unit in REQUIRED_ACTIVE_UNITS: + self._systemctl_gate("is-active", unit) + for unit in REQUIRED_ENABLED_UNITS: + self._systemctl_gate("is-enabled", unit) + + def activate(self) -> None: + """Perform the bounded protected mutation after all preflights pass.""" + assert self.evidence is not None + if _selected_release(self.paths.selector) != self.request.expected_current: + raise DeploymentFailure("selector changed immediately before activation") + python = self.release / "venv/bin/python" + packaged_unit = Path( + self.executor.run( + [python, "-c", PACKAGED_UNIT_PROBE], + capture=True, + ).strip() + ) + self.mutation_started = True + _atomic_copy( + packaged_unit, + self.paths.service_unit, + mode=0o644, + uid=self.owner_uid, + gid=self.owner_gid, + ) + self.executor.run(["systemctl", "daemon-reload"]) + self.executor.run( + [ + python, + "-m", + "TimeLocker.system_control.release_admin", + "select", + self.request.release_id, + "--expected-current", + self.request.expected_current, + ], + output=self.evidence / "selected-release.txt", + ) + self.executor.run(["systemctl", "restart", "timelocker-control.service"]) + + def verify_activation(self) -> None: + """Verify the selected release without running backup or retention.""" + assert self.evidence is not None + if _selected_release(self.paths.selector) != self.request.release_id: + raise DeploymentFailure("candidate release was not selected") + for unit in REQUIRED_ACTIVE_UNITS: + self._systemctl_gate("is-active", unit) + for unit in REQUIRED_ENABLED_UNITS: + self._systemctl_gate("is-enabled", unit) + self.executor.run( + [ + "timeout", + "15", + "runuser", + "-u", + self.request.operator_user, + "--", + self.release / "venv/bin/timelocker", + "runs", + "list", + "--limit", + "3", + "--json", + ], + timeout=20, + output=self.evidence / "activated-authorized-runs.json", + ) + activated_event = self.executor.run( + [ + "timeout", + "10", + "runuser", + "-u", + self.request.operator_user, + "--", + self.release / "venv/bin/python", + "-c", + AUTHORIZED_EVENT_PROBE, + ], + timeout=15, + capture=True, + ) + try: + activated_payload = json.loads(activated_event) + except json.JSONDecodeError as error: + raise DeploymentFailure( + "activated event probe returned invalid JSON" + ) from error + if not isinstance(activated_payload, dict): + raise DeploymentFailure("activated event probe returned invalid JSON") + _write_private_text( + self.evidence / "activated-authorized-event.json", + activated_event, + ) + _atomic_copy( + self.paths.selector, + self.evidence / "selected-release.after.json", + mode=0o600, + uid=self.owner_uid, + gid=self.owner_gid, + ) + + def recover(self) -> None: + """Restore baseline state after any failed or interrupted transaction.""" + if self.completed: + return + errors: list[str] = [] + if self.mutation_started and self.evidence is not None: + for source, destination, mode in ( + ( + self.evidence / "selected-release.before.json", + self.paths.selector, + 0o644, + ), + ( + self.evidence / "timelocker-control.service.before", + self.paths.service_unit, + 0o644, + ), + ): + try: + _atomic_copy( + source, + destination, + mode=mode, + uid=self.owner_uid, + gid=self.owner_gid, + ) + except OSError as error: + errors.append(f"restore {destination}: {error}") + for command in ( + ("systemctl", "daemon-reload"), + ("systemctl", "restart", "timelocker-control.socket"), + ("systemctl", "restart", "timelocker-status-events.socket"), + ("systemctl", "restart", "timelocker-control.service"), + ): + try: + self.executor.run(command, check=False) + except (DeploymentFailure, OSError) as error: + errors.append(f"{' '.join(command)}: {error}") + try: + if ( + _selected_release(self.paths.selector) + != self.request.expected_current + ): + errors.append("restored selector does not name prior release") + except DeploymentFailure as error: + errors.append(f"validate restored selector: {error}") + for action, units in ( + ("is-active", REQUIRED_ACTIVE_UNITS), + ("is-enabled", REQUIRED_ENABLED_UNITS), + ): + for unit in units: + try: + self._systemctl_gate(action, unit) + except DeploymentFailure as error: + errors.append(f"{action} {unit}: {error}") + if self.release.exists(): + try: + shutil.rmtree(self.release) + except OSError as error: + errors.append(f"remove candidate release: {error}") + if errors: + raise DeploymentFailure( + "deployment failed and rollback was incomplete: " + "; ".join(errors) + ) + + def _validate_packaged_unit(self, packaged_unit: Path) -> None: + _require_regular_file(packaged_unit, "packaged service unit") + try: + packaged_unit.resolve().relative_to(self.release.resolve()) + except ValueError as error: + raise DeploymentFailure( + "packaged service unit escapes the staged release" + ) from error + text = packaged_unit.read_text(encoding="utf-8") + required_lines = { + "Requires=timelocker-control.socket", + "Wants=timelocker-status-events.socket", + "Sockets=timelocker-control.socket timelocker-status-events.socket", + } + lines = set(text.splitlines()) + missing = required_lines - lines + if missing: + raise DeploymentFailure( + "packaged service unit is missing: " + ", ".join(sorted(missing)) + ) + if ( + "Requires=timelocker-control.socket timelocker-status-events.socket" + in lines + ): + raise DeploymentFailure("packaged service still hard-requires event socket") + + def _systemctl_gate(self, action: str, unit: str) -> None: + self.executor.run( + ["systemctl", action, "--quiet", unit], + timeout=15, + ) + + +def _display_command(command: Sequence[str]) -> str: + safe: list[str] = [] + for argument in command: + if "\n" in argument or len(argument) > 160: + safe.append("") + else: + safe.append(argument) + return " ".join(safe) + + +def _require_regular_file(path: Path, field: str) -> None: + if not path.is_file() or path.is_symlink(): + raise DeploymentFailure(f"{field} must be a regular non-symlink file") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _read_json(path: Path) -> dict[str, object]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise DeploymentFailure(f"cannot read JSON: {path}") from error + if not isinstance(value, dict): + raise DeploymentFailure(f"JSON document must be an object: {path}") + return value + + +def _selected_release(selector: Path) -> str: + value = _read_json(selector).get("selected") + if not isinstance(value, str) or RELEASE_ID_PATTERN.fullmatch(value) is None: + raise DeploymentFailure("selected-release document is invalid") + return value + + +def _mkdir( + path: Path, + *, + mode: int, + uid: int | None, + gid: int | None, +) -> None: + path.mkdir(parents=True, exist_ok=False) + os.chmod(path, mode) + if uid is not None and gid is not None: + os.chown(path, uid, gid) + + +def _atomic_copy( + source: Path, + destination: Path, + *, + mode: int, + uid: int | None, + gid: int | None, +) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name( + f".{destination.name}.timelocker-{os.getpid()}.tmp" + ) + try: + with source.open("rb") as source_stream, temporary.open("xb") as target: + shutil.copyfileobj(source_stream, target) + target.flush() + os.fsync(target.fileno()) + os.chmod(temporary, mode) + if uid is not None and gid is not None: + os.chown(temporary, uid, gid) + os.replace(temporary, destination) + finally: + temporary.unlink(missing_ok=True) + + +def _make_tree_immutable( + root: Path, + *, + uid: int | None, + gid: int | None, +) -> None: + for path in (root, *root.rglob("*")): + if path.is_symlink(): + continue + mode = path.stat().st_mode & 0o777 + if path.is_dir(): + mode |= 0o555 + else: + mode |= 0o444 + mode &= ~0o022 + os.chmod(path, mode) + if uid is not None and gid is not None: + os.chown(path, uid, gid) + + +def _write_private_text(path: Path, content: str) -> None: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(content) + except BaseException: + path.unlink(missing_ok=True) + raise + os.chmod(path, 0o600) + + +@contextmanager +def _deployment_lock(path: Path) -> TextIO: + path.parent.mkdir(parents=True, exist_ok=True) + stream = path.open("w", encoding="utf-8") + try: + try: + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + raise DeploymentFailure("another TimeLocker deployment is active") from error + yield stream + finally: + stream.close() + + +def _signal_handler(signum: int, _frame: FrameType | None) -> None: + name = signal.Signals(signum).name + raise DeploymentInterrupted(f"deployment interrupted by {name}") + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Stage and activate a T011 TimeLocker Linux release safely." + ) + parser.add_argument("--release-id", required=True) + parser.add_argument("--expected-current", required=True) + parser.add_argument("--wheel", required=True, type=Path) + parser.add_argument("--wheel-sha256", required=True) + parser.add_argument("--manifest", required=True, type=Path) + parser.add_argument("--operator-user", required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + arguments = _parser().parse_args(argv) + if os.geteuid() != 0: + print("T011 deployment must be run with sudo.", file=sys.stderr) + return 77 + request = DeploymentRequest( + release_id=arguments.release_id, + expected_current=arguments.expected_current, + wheel=arguments.wheel.resolve(), + wheel_sha256=arguments.wheel_sha256, + manifest=arguments.manifest.resolve(), + operator_user=arguments.operator_user, + ) + paths = DeploymentPaths() + prior_handlers = { + signum: signal.signal(signum, _signal_handler) + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP) + } + try: + with _deployment_lock(paths.lock_file): + evidence = T011LinuxDeployer(request, paths=paths).deploy() + except (DeploymentFailure, OSError, subprocess.SubprocessError) as error: + print(f"T011 deployment failed: {error}", file=sys.stderr) + return 1 + finally: + for signum, handler in prior_handlers.items(): + signal.signal(signum, handler) + print(f"release={request.release_id}") + print(f"evidence_root={evidence}") + print("preflight_identity_checks=passed") + print("backup_or_retention_triggered=no") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/TimeLocker/system_control/release_admin.py b/src/TimeLocker/system_control/release_admin.py index 40460af..6d59cbd 100644 --- a/src/TimeLocker/system_control/release_admin.py +++ b/src/TimeLocker/system_control/release_admin.py @@ -11,12 +11,16 @@ def main() -> None: subcommands = parser.add_subparsers(dest="command", required=True) select = subcommands.add_parser("select") select.add_argument("release_id") + select.add_argument("--expected-current") subcommands.add_parser("rollback") arguments = parser.parse_args() resolver = ImmutableReleaseResolver() try: if arguments.command == "select": - state = resolver.select(arguments.release_id) + select_options = {} + if arguments.expected_current is not None: + select_options["expected_current"] = arguments.expected_current + state = resolver.select(arguments.release_id, **select_options) else: state = resolver.rollback() except ReleaseResolutionError as error: diff --git a/src/TimeLocker/system_control/release_launcher.py b/src/TimeLocker/system_control/release_launcher.py index bf25028..ee8b71b 100644 --- a/src/TimeLocker/system_control/release_launcher.py +++ b/src/TimeLocker/system_control/release_launcher.py @@ -3,9 +3,10 @@ import json import os import stat +from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path -from typing import Mapping, NoReturn +from typing import Iterator, Mapping, NoReturn from .models import PROTOCOL_VERSION, STATUS_EVENT_PROTOCOL_VERSION from .validation import require_exact_mapping, require_int, require_safe_identifier @@ -201,34 +202,86 @@ def resolve_entrypoint( selector = SelectedRelease.from_mapping(_read_json(self.selector_path)) return self._resolve_release(selector.selected, entrypoint=entrypoint) - def select(self, release_id: str) -> SelectedRelease: + def select( + self, + release_id: str, + *, + expected_current: str | None = None, + ) -> SelectedRelease: """Atomically select a validated staged release for administrator tooling.""" release_id = _release_id(release_id) + if expected_current is not None: + expected_current = _release_id(expected_current) self._require_trusted_directory(self.selector_path.parent) self._resolve_release(release_id) - current = self._read_selector_optional() - next_state = SelectedRelease( - selected=release_id, - previous=current.selected - if current and current.selected != release_id - else (current.previous if current else None), - ) - _atomic_write_json(self.selector_path, next_state.to_wire()) + with self._selector_lock(): + current = self._read_selector_optional() + if expected_current is not None and ( + current is None or current.selected != expected_current + ): + raise ReleaseResolutionError( + "selected release changed before activation" + ) + next_state = SelectedRelease( + selected=release_id, + previous=current.selected + if current and current.selected != release_id + else (current.previous if current else None), + ) + _atomic_write_json(self.selector_path, next_state.to_wire()) return next_state def rollback(self) -> SelectedRelease: """Atomically swap selected and previous validated releases.""" - current = self._read_selector_optional() - if current is None or current.previous is None: - raise ReleaseResolutionError("no previous release is available") - self._resolve_release(current.previous) - next_state = SelectedRelease( - selected=current.previous, - previous=current.selected, - ) - _atomic_write_json(self.selector_path, next_state.to_wire()) + with self._selector_lock(): + current = self._read_selector_optional() + if current is None or current.previous is None: + raise ReleaseResolutionError("no previous release is available") + self._resolve_release(current.previous) + next_state = SelectedRelease( + selected=current.previous, + previous=current.selected, + ) + _atomic_write_json(self.selector_path, next_state.to_wire()) return next_state + @contextmanager + def _selector_lock(self) -> Iterator[None]: + """Serialize administrator writes without affecting atomic readers.""" + try: + import fcntl + except ImportError as error: # pragma: no cover - Linux deployment only + raise ReleaseResolutionError( + "release selection locking is unavailable" + ) from error + lock_path = self.selector_path.with_suffix( + f"{self.selector_path.suffix}.lock" + ) + try: + flags = os.O_RDWR | os.O_CREAT + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(lock_path, flags, 0o600) + except OSError as error: + raise ReleaseResolutionError( + "release selection lock is unavailable" + ) from error + try: + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != self.expected_owner_uid + or metadata.st_mode & 0o022 + ): + raise ReleaseResolutionError( + "release selection lock is not trusted" + ) + os.fchmod(descriptor, 0o600) + fcntl.flock(descriptor, fcntl.LOCK_EX) + yield + finally: + os.close(descriptor) + def _read_selector_optional(self) -> SelectedRelease | None: self._require_trusted_directory(self.selector_path.parent) if not self.selector_path.exists(): diff --git a/tests/TimeLocker/project/test_t011_linux_deployment.py b/tests/TimeLocker/project/test_t011_linux_deployment.py new file mode 100644 index 0000000..f5ec986 --- /dev/null +++ b/tests/TimeLocker/project/test_t011_linux_deployment.py @@ -0,0 +1,568 @@ +"""Safety contracts for the repository-owned T011 Linux deployment harness.""" + +from __future__ import annotations + +import getpass +import importlib.util +import json +import os +from pathlib import Path +import sys +from types import ModuleType + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +RELEASE_A = "a" * 40 +RELEASE_B = "b" * 40 + + +def _load_harness() -> ModuleType: + path = ROOT / "scripts/deploy_t011_linux.py" + spec = importlib.util.spec_from_file_location("deploy_t011_linux", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class FakeExecutor: + """Capture commands and return deterministic candidate-probe output.""" + + def __init__(self, harness: ModuleType, packaged_unit: Path) -> None: + self.harness = harness + self.packaged_unit = packaged_unit + self.commands: list[list[str]] = [] + + def run( + self, + arguments, + *, + timeout=30, + output=None, + capture=False, + check=True, + ) -> str: + del timeout, check + command = [str(argument) for argument in arguments] + self.commands.append(command) + result = "" + if command[-2:] == ["-c", self.harness.BACKEND_IMPORT_PROBE]: + result = "1:1\n" + elif command[-2:] == ["-c", self.harness.PACKAGED_UNIT_PROBE]: + result = f"{self.packaged_unit}\n" + elif command[-2:] == ["-c", self.harness.DENIED_EVENT_PROBE]: + result = "denied\n" + elif command[-2:] == ["-c", self.harness.AUTHORIZED_EVENT_PROBE]: + result = json.dumps( + { + "kind": "snapshot", + "sequence": 1, + "session_id": "526719f9-4c46-42ac-b286-2623079bc335", + } + ) + if output is not None: + self.harness._write_private_text(output, result) + return result + + +class SimulatedHostExecutor(FakeExecutor): + """Model the filesystem effects of venv, pip, and release selection.""" + + def __init__( + self, + harness: ModuleType, + packaged_unit: Path, + paths, + *, + fail_activated_event: bool = False, + ) -> None: + super().__init__(harness, packaged_unit) + self.paths = paths + self.fail_activated_event = fail_activated_event + self.authorized_event_calls = 0 + + def run( + self, + arguments, + *, + timeout=30, + output=None, + capture=False, + check=True, + ) -> str: + command = [str(argument) for argument in arguments] + if command[:4] == ["python3", "-m", "venv", "--system-site-packages"]: + release = Path(command[4]).parent + python = release / "venv/bin/python" + python.parent.mkdir(parents=True) + python.write_text("#!/bin/sh\n", encoding="utf-8") + python.chmod(0o755) + elif len(command) >= 5 and command[1:4] == ["-m", "pip", "install"]: + release = Path(command[0]).parents[2] + python = release / "venv/bin/python" + for name in self.harness.REQUIRED_ENTRYPOINTS: + entrypoint = release / "venv/bin" / name + entrypoint.write_text(f"#!{python}\n", encoding="utf-8") + entrypoint.chmod(0o755) + self.packaged_unit.parent.mkdir(parents=True) + self.packaged_unit.write_text( + "\n".join( + ( + "[Unit]", + "Requires=timelocker-control.socket", + "Wants=timelocker-status-events.socket", + "[Service]", + ( + "Sockets=timelocker-control.socket " + "timelocker-status-events.socket" + ), + "", + ) + ), + encoding="utf-8", + ) + elif ( + "TimeLocker.system_control.release_admin" in command + and "select" in command + ): + state = json.loads(self.paths.selector.read_text(encoding="utf-8")) + state["previous"] = state["selected"] + state["selected"] = command[command.index("select") + 1] + self.paths.selector.write_text(json.dumps(state), encoding="utf-8") + result = super().run( + arguments, + timeout=timeout, + output=output, + capture=capture, + check=check, + ) + if command[-2:] == ["-c", self.harness.AUTHORIZED_EVENT_PROBE]: + self.authorized_event_calls += 1 + if self.fail_activated_event and self.authorized_event_calls == 2: + return "not-json" + return result + + +def _paths(harness: ModuleType, root: Path): + return harness.DeploymentPaths( + releases_root=root / "opt/timelocker/releases", + selector=root / "opt/timelocker/selected-release.json", + service_unit=root / "etc/systemd/system/timelocker-control.service", + evidence_root=root / "var/lib/timelocker/migration-backup", + lock_file=root / "run/lock/timelocker-t011-deploy.lock", + ) + + +def _request(harness: ModuleType, root: Path): + wheel = root / "timelocker.whl" + wheel.write_bytes(b"validated wheel") + digest = harness._sha256(wheel) + manifest = root / "release.json" + manifest.write_text( + json.dumps( + { + "schema_version": 2, + "release_id": RELEASE_B, + "package_version": "0.9.1", + "control_protocol_version": 1, + "event_protocol_version": 1, + "entrypoint": "venv/bin/timelocker", + } + ), + encoding="utf-8", + ) + return harness.DeploymentRequest( + release_id=RELEASE_B, + expected_current=RELEASE_A, + wheel=wheel, + wheel_sha256=digest, + manifest=manifest, + operator_user=getpass.getuser(), + ) + + +def _baseline(paths) -> None: + paths.selector.parent.mkdir(parents=True) + paths.selector.write_text( + json.dumps( + { + "schema_version": 1, + "selected": RELEASE_A, + "previous": None, + } + ), + encoding="utf-8", + ) + paths.service_unit.parent.mkdir(parents=True) + paths.service_unit.write_text("old service\n", encoding="utf-8") + paths.evidence_root.mkdir(parents=True) + paths.releases_root.mkdir(parents=True) + + +def _staged_release(deployer, packaged_unit: Path) -> None: + python = deployer.release / "venv/bin/python" + python.parent.mkdir(parents=True) + python.write_text("#!/bin/sh\n", encoding="utf-8") + python.chmod(0o755) + for name in ( + "timelocker", + "tl", + "timelocker-tray", + "timelocker-system-control", + ): + entrypoint = deployer.release / "venv/bin" / name + entrypoint.write_text(f"#!{python}\n", encoding="utf-8") + entrypoint.chmod(0o755) + packaged_unit.parent.mkdir(parents=True) + packaged_unit.write_text( + "\n".join( + ( + "[Unit]", + "Requires=timelocker-control.socket", + "Wants=timelocker-status-events.socket", + "[Service]", + "Sockets=timelocker-control.socket timelocker-status-events.socket", + "", + ) + ), + encoding="utf-8", + ) + + +def test_identity_preflights_are_inline_and_precede_mutation_under_restrictive_umask( + tmp_path: Path, +) -> None: + harness = _load_harness() + paths = _paths(harness, tmp_path) + _baseline(paths) + request = _request(harness, tmp_path) + packaged_unit = ( + paths.releases_root + / RELEASE_B + / "venv/lib/python3.12/site-packages/TimeLocker/system_control/assets" + / "timelocker-control.service" + ) + executor = FakeExecutor(harness, packaged_unit) + deployer = harness.T011LinuxDeployer( + request, + paths=paths, + executor=executor, + owner_uid=None, + owner_gid=None, + ) + deployer.validate_request() + old_umask = os.umask(0o077) + try: + deployer.capture_baseline() + _staged_release(deployer, packaged_unit) + deployer.preflight_staged_release() + finally: + os.umask(old_umask) + + assert json.loads(paths.selector.read_text())["selected"] == RELEASE_A + assert paths.service_unit.read_text() == "old service\n" + target_identity_commands = [ + command + for command in executor.commands + if "runuser" in command or "setpriv" in command + ] + assert len(target_identity_commands) == 3 + assert all("-c" in command for command in target_identity_commands[1:]) + assert all( + not any(argument.endswith(".py") for argument in command) + for command in target_identity_commands + ) + assert deployer.evidence is not None + evidence_modes = { + path.name: path.stat().st_mode & 0o777 + for path in deployer.evidence.iterdir() + if path.is_file() + } + assert evidence_modes + assert set(evidence_modes.values()) == {0o600} + + +def test_preflight_failure_never_calls_activation() -> None: + harness = _load_harness() + calls: list[str] = [] + + class FailingDeployer(harness.T011LinuxDeployer): + def validate_request(self): + calls.append("validate") + + def capture_baseline(self): + calls.append("baseline") + + def stage_release(self): + calls.append("stage") + + def preflight_staged_release(self): + calls.append("preflight") + raise harness.DeploymentFailure("preflight rejected") + + def activate(self): + calls.append("activate") + + def recover(self): + calls.append("recover") + + deployer = object.__new__(FailingDeployer) + + with pytest.raises(harness.DeploymentFailure, match="preflight rejected"): + deployer.deploy() + + assert calls == ["validate", "baseline", "stage", "preflight", "recover"] + + +def test_interruption_after_mutation_runs_recovery() -> None: + harness = _load_harness() + calls: list[str] = [] + + class InterruptedDeployer(harness.T011LinuxDeployer): + def validate_request(self): + calls.append("validate") + + def capture_baseline(self): + calls.append("baseline") + + def stage_release(self): + calls.append("stage") + + def preflight_staged_release(self): + calls.append("preflight") + + def activate(self): + calls.append("activate") + raise KeyboardInterrupt + + def recover(self): + calls.append("recover") + + deployer = object.__new__(InterruptedDeployer) + + with pytest.raises(KeyboardInterrupt): + deployer.deploy() + + assert calls == [ + "validate", + "baseline", + "stage", + "preflight", + "activate", + "recover", + ] + + +def test_recovery_restores_selector_and_service_and_removes_candidate( + tmp_path: Path, +) -> None: + harness = _load_harness() + paths = _paths(harness, tmp_path) + _baseline(paths) + request = _request(harness, tmp_path) + packaged_unit = tmp_path / "packaged/timelocker-control.service" + executor = FakeExecutor(harness, packaged_unit) + deployer = harness.T011LinuxDeployer( + request, + paths=paths, + executor=executor, + owner_uid=None, + owner_gid=None, + ) + deployer.capture_baseline() + deployer.release.mkdir(parents=True) + (deployer.release / "inert").write_text("candidate", encoding="utf-8") + paths.selector.write_text( + json.dumps({"schema_version": 1, "selected": RELEASE_B}), + encoding="utf-8", + ) + paths.service_unit.write_text("candidate service\n", encoding="utf-8") + deployer.mutation_started = True + + deployer.recover() + + assert json.loads(paths.selector.read_text())["selected"] == RELEASE_A + assert paths.service_unit.read_text() == "old service\n" + assert not deployer.release.exists() + assert [ + command[:2] + for command in executor.commands[:4] + ] == [ + ["systemctl", "daemon-reload"], + ["systemctl", "restart"], + ["systemctl", "restart"], + ["systemctl", "restart"], + ] + assert any("is-active" in command for command in executor.commands[4:]) + assert any("is-enabled" in command for command in executor.commands[4:]) + + +def test_packaged_service_must_keep_event_socket_as_weak_dependency( + tmp_path: Path, +) -> None: + harness = _load_harness() + paths = _paths(harness, tmp_path) + _baseline(paths) + request = _request(harness, tmp_path) + packaged_unit = ( + paths.releases_root + / RELEASE_B + / "venv/lib/python3.12/site-packages/TimeLocker/system_control/assets" + / "timelocker-control.service" + ) + packaged_unit.parent.mkdir(parents=True) + packaged_unit.write_text( + "\n".join( + ( + "Requires=timelocker-control.socket timelocker-status-events.socket", + "Sockets=timelocker-control.socket timelocker-status-events.socket", + ) + ), + encoding="utf-8", + ) + deployer = harness.T011LinuxDeployer( + request, + paths=paths, + executor=FakeExecutor(harness, packaged_unit), + owner_uid=None, + owner_gid=None, + ) + + with pytest.raises(harness.DeploymentFailure, match="missing"): + deployer._validate_packaged_unit(packaged_unit) + + +def test_packaged_service_cannot_escape_staged_release(tmp_path: Path) -> None: + harness = _load_harness() + paths = _paths(harness, tmp_path) + _baseline(paths) + request = _request(harness, tmp_path) + packaged_unit = tmp_path / "outside/timelocker-control.service" + packaged_unit.parent.mkdir() + packaged_unit.write_text( + "\n".join( + ( + "Requires=timelocker-control.socket", + "Wants=timelocker-status-events.socket", + "Sockets=timelocker-control.socket timelocker-status-events.socket", + ) + ), + encoding="utf-8", + ) + deployer = harness.T011LinuxDeployer( + request, + paths=paths, + executor=FakeExecutor(harness, packaged_unit), + owner_uid=None, + owner_gid=None, + ) + + with pytest.raises(harness.DeploymentFailure, match="escapes"): + deployer._validate_packaged_unit(packaged_unit) + + +def test_private_writer_overrides_permissive_umask(tmp_path: Path) -> None: + harness = _load_harness() + output = tmp_path / "evidence.json" + old_umask = os.umask(0) + try: + harness._write_private_text(output, "{}\n") + finally: + os.umask(old_umask) + + assert output.stat().st_mode & 0o777 == 0o600 + + +def test_signal_handler_converts_termination_to_transaction_exception() -> None: + harness = _load_harness() + + with pytest.raises(harness.DeploymentInterrupted, match="SIGTERM"): + harness._signal_handler(15, None) + + +def test_full_simulated_transaction_runs_preflight_before_selection( + tmp_path: Path, +) -> None: + harness = _load_harness() + paths = _paths(harness, tmp_path) + _baseline(paths) + request = _request(harness, tmp_path) + packaged_unit = ( + paths.releases_root + / RELEASE_B + / "venv/lib/python3.12/site-packages/TimeLocker/system_control/assets" + / "timelocker-control.service" + ) + executor = SimulatedHostExecutor(harness, packaged_unit, paths) + deployer = harness.T011LinuxDeployer( + request, + paths=paths, + executor=executor, + owner_uid=None, + owner_gid=None, + ) + old_umask = os.umask(0o077) + try: + evidence = deployer.deploy() + finally: + os.umask(old_umask) + + assert json.loads(paths.selector.read_text())["selected"] == RELEASE_B + denied_index = next( + index + for index, command in enumerate(executor.commands) + if command[-2:] == ["-c", harness.DENIED_EVENT_PROBE] + ) + selection_index = next( + index + for index, command in enumerate(executor.commands) + if "TimeLocker.system_control.release_admin" in command + and "select" in command + ) + assert denied_index < selection_index + assert deployer.release.exists() + assert paths.service_unit.read_text() == packaged_unit.read_text() + assert all( + path.stat().st_mode & 0o022 == 0 + for path in deployer.release.rglob("*") + if not path.is_symlink() + ) + assert evidence.stat().st_mode & 0o777 == 0o750 + + +def test_full_simulated_post_activation_failure_rolls_back( + tmp_path: Path, +) -> None: + harness = _load_harness() + paths = _paths(harness, tmp_path) + _baseline(paths) + request = _request(harness, tmp_path) + packaged_unit = ( + paths.releases_root + / RELEASE_B + / "venv/lib/python3.12/site-packages/TimeLocker/system_control/assets" + / "timelocker-control.service" + ) + executor = SimulatedHostExecutor( + harness, + packaged_unit, + paths, + fail_activated_event=True, + ) + deployer = harness.T011LinuxDeployer( + request, + paths=paths, + executor=executor, + owner_uid=None, + owner_gid=None, + ) + + with pytest.raises(harness.DeploymentFailure, match="invalid JSON"): + deployer.deploy() + + assert json.loads(paths.selector.read_text())["selected"] == RELEASE_A + assert paths.service_unit.read_text() == "old service\n" + assert not deployer.release.exists() diff --git a/tests/TimeLocker/system_control/test_release_entrypoints.py b/tests/TimeLocker/system_control/test_release_entrypoints.py index b361904..af8d623 100644 --- a/tests/TimeLocker/system_control/test_release_entrypoints.py +++ b/tests/TimeLocker/system_control/test_release_entrypoints.py @@ -66,3 +66,31 @@ def test_release_admin_returns_configuration_exit_on_invalid_release( release_admin.main() assert caught.value.code == 78 assert "release selection failed" in capsys.readouterr().err + + +@pytest.mark.unit +def test_release_admin_forwards_expected_current_compare_and_swap( + capsys: pytest.CaptureFixture[str], +) -> None: + resolver = Mock() + resolver.select.return_value = SelectedRelease(selected="b" * 40) + with ( + patch.object(release_admin, "ImmutableReleaseResolver", return_value=resolver), + patch( + "sys.argv", + [ + "timelocker-release-select", + "select", + "b" * 40, + "--expected-current", + "a" * 40, + ], + ), + ): + release_admin.main() + + resolver.select.assert_called_once_with( + "b" * 40, + expected_current="a" * 40, + ) + assert capsys.readouterr().out.strip() == "b" * 40 diff --git a/tests/TimeLocker/system_control/test_release_launcher.py b/tests/TimeLocker/system_control/test_release_launcher.py index b230762..e02d021 100644 --- a/tests/TimeLocker/system_control/test_release_launcher.py +++ b/tests/TimeLocker/system_control/test_release_launcher.py @@ -97,6 +97,19 @@ def test_release_switch_and_rollback_are_atomic_and_symmetric(tmp_path: Path) -> assert resolver.resolve({}) == release_a +@pytest.mark.unit +def test_release_selection_rejects_stale_expected_current(tmp_path: Path) -> None: + _stage_release(tmp_path, RELEASE_A) + _stage_release(tmp_path, RELEASE_B) + resolver = _resolver(tmp_path) + resolver.select(RELEASE_A) + + with pytest.raises(ReleaseResolutionError, match="changed before activation"): + resolver.select(RELEASE_B, expected_current=RELEASE_B) + + assert resolver.resolve({}).parts[-4] == RELEASE_A + + @pytest.mark.unit def test_release_selector_mode_ignores_restrictive_process_umask( tmp_path: Path, @@ -110,6 +123,10 @@ def test_release_selector_mode_ignores_restrictive_process_umask( os.umask(previous_umask) assert resolver.selector_path.stat().st_mode & 0o777 == 0o644 + lock_path = resolver.selector_path.with_suffix( + f"{resolver.selector_path.suffix}.lock" + ) + assert lock_path.stat().st_mode & 0o777 == 0o600 assert resolver.resolve({}).name == "timelocker" From a67c83ac09ac29b94a3ed481ee536b3380db3337 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:56:54 +0100 Subject: [PATCH 56/72] fix(deploy): preserve staged wheel filename Keep the original validated wheel basename when copying release inputs into private T011 evidence so pip can install the staged artifact. Reject malformed wheel names before creating host state and record the fail-closed deployment evidence in Spec 010. --- .../010-event-driven-tray-status/tasks.md | 18 ++++++-- .../verification.md | 5 ++- scripts/deploy_t011_linux.py | 19 ++++++++- .../project/test_t011_linux_deployment.py | 42 ++++++++++++++++++- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/docs/specs/010-event-driven-tray-status/tasks.md b/docs/specs/010-event-driven-tray-status/tasks.md index ce0c960..54e75ee 100644 --- a/docs/specs/010-event-driven-tray-status/tasks.md +++ b/docs/specs/010-event-driven-tray-status/tasks.md @@ -272,11 +272,21 @@ T009 -> T010 -> T011 -> T012 -> T013 `5603dd6c4aae461f5e6e673eea97b2d2b2972e843b6d9a32f8f3d8347e1c3dde`; sdist SHA-256: `fc0f4bda037a7c41128a8834129a7be9c20040d0efd8580dab05ff0599427748`. - Live redeployment and acceptance remain pending; no backup, retention, - selector, unit, or protected host state was changed by this remediation. + The first commit-bound harness deployment failed closed during staged pip + installation because the private evidence copy renamed the valid wheel to + `candidate.whl`, which pip rejects before inspecting the artifact. Recovery + removed the inert candidate; the prior selector, unit, sockets, service, + and timers remained unchanged. The harness now validates the supplied wheel + basename, preserves it in private evidence, and rejects an invalid basename + before creating host state. Eleven focused harness tests, scoped Ruff, + compileall, patch integrity, and an exact `/usr/bin/python3` staging + rehearsal with the release wheel passed. Live redeployment and acceptance + remain pending; no backup, retention, selector, unit, or protected host + state was changed by this correction. - - Status: Harness remediation implemented and locally validated; a committed - release artifact and renewed live deployment approval remain required. + - Status: Wheel-filename correction implemented and locally validated; a new + committed release artifact and renewed live deployment approval remain + required. - [ ] T012 Run the TimeLocker expert review and address findings. - Depends on: T011 - Requirements: Requirement 1-Requirement 7 diff --git a/docs/specs/010-event-driven-tray-status/verification.md b/docs/specs/010-event-driven-tray-status/verification.md index 2cf0d1c..7d64c4c 100644 --- a/docs/specs/010-event-driven-tray-status/verification.md +++ b/docs/specs/010-event-driven-tray-status/verification.md @@ -108,7 +108,7 @@ closure. | T008 | complete | Token-derived bounded Windows named-pipe event contracts, four new platform tests, 232 system-control tests | No live Windows service or acceptance is claimed. | | T009 | complete | Named protected event socket asset, named systemd descriptor mapping, dual-protocol release metadata, structured activation/rollback gates, 246 system-control tests | Installed-artifact and live-host evidence remain T010-T011. | | T010 | complete | Five deterministic accessible logo badges, honest never-run/failure projection, 268-test regression, 27-file package-data validation, SHA-256 verification, and clean-install wheel/sdist smoke | No live selector, unit, socket, timer, backup, retention, or protected path changed. | -| T011 | in progress | Repository-owned evidence validator and preflight-first transactional deployment harness; 272-test regression | Committed live artifact, redeployment, and acceptance remain pending. | +| T011 | in progress | Repository-owned evidence validator and preflight-first transactional deployment harness; fail-closed wheel-filename correction and exact staging rehearsal | New committed artifact, redeployment, and acceptance remain pending. | | T012-T013 | pending | none | Sequenced by the task dependency graph. | ## Evidence Log @@ -149,6 +149,9 @@ closure. | 2026-07-27 | Repository-owned T011 deployment harness focused regression | 46 passed | Restrictive umask, inline target identities, preflight-before-selection, input snapshotting, package-boundary enforcement, compare-and-swap, signal recovery, full simulated activation, and forced post-activation rollback. | | 2026-07-27 | System-control plus T011 harness/evidence regression | 272 passed | Scoped Ruff, compileall, and patch integrity passed. No protected host mutation occurred. | | 2026-07-27 | Fresh T011 harness-remediation package validation | pass | Wheel and sdist contained 27 package-data files; wheel SHA-256 `5603dd6c4aae461f5e6e673eea97b2d2b2972e843b6d9a32f8f3d8347e1c3dde`; sdist SHA-256 `fc0f4bda037a7c41128a8834129a7be9c20040d0efd8580dab05ff0599427748`. Wheel installed-artifact smoke and installed expected-current selector checks passed. | +| 2026-07-27 | Commit-bound hardened deployment staging | fail closed; rollback passed | Pip rejected the private evidence copy because `candidate.whl` is not a valid wheel filename. Failure occurred before activation; selector `d540b453864fce9b1c96a85ad9ecf604b98b7f57`, service, sockets, and timers remained healthy, and candidate `8e8ebada197e713b60285d5105fe8b7ad8b9b8dc` was removed. | +| 2026-07-27 | Wheel-filename correction focused validation | pass | The harness preserves and validates the original wheel basename, rejects `candidate.whl` before host-state creation, and passes 11 focused tests, scoped Ruff, compileall, and patch integrity. | +| 2026-07-27 | Exact system-Python staging rehearsal | pass | `/usr/bin/python3` staged `timelocker-0.9.1-py3-none-any.whl` through the corrected harness into an isolated `/tmp` release and imported installed TimeLocker version `0.9.1`; no protected host path or service was changed. | ## Manual Or External Verification diff --git a/scripts/deploy_t011_linux.py b/scripts/deploy_t011_linux.py index 8f5e19d..b6166f6 100755 --- a/scripts/deploy_t011_linux.py +++ b/scripts/deploy_t011_linux.py @@ -28,6 +28,9 @@ RELEASE_ID_PATTERN = re.compile(r"[0-9a-f]{40}") +WHEEL_FILENAME_PATTERN = re.compile( + r"[A-Za-z0-9_.+!]+(?:-[A-Za-z0-9_.+!]+){4,}\.whl" +) REQUIRED_ENTRYPOINTS = ( "timelocker", "tl", @@ -214,6 +217,7 @@ def validate_request(self) -> None: raise DeploymentFailure("wheel_sha256 must be a lowercase SHA-256 digest") _require_regular_file(self.request.wheel, "wheel") _require_regular_file(self.request.manifest, "manifest") + _validated_wheel_filename(self.request.wheel) try: pwd.getpwnam(self.request.operator_user) except KeyError as error: @@ -254,7 +258,9 @@ def capture_baseline(self) -> None: uid=self.owner_uid, gid=self.owner_gid, ) - self.staged_wheel = self.evidence / "candidate.whl" + self.staged_wheel = self.evidence / _validated_wheel_filename( + self.request.wheel + ) self.staged_manifest = self.evidence / "candidate-release.json" _atomic_copy( self.request.wheel, @@ -637,6 +643,17 @@ def _sha256(path: Path) -> str: return digest.hexdigest() +def _validated_wheel_filename(path: Path) -> str: + """Return a pip-compatible wheel basename without changing its identity.""" + filename = path.name + if WHEEL_FILENAME_PATTERN.fullmatch(filename) is None: + raise DeploymentFailure( + "wheel must use a valid wheel filename, for example " + "timelocker-0.9.1-py3-none-any.whl" + ) + return filename + + def _read_json(path: Path) -> dict[str, object]: try: value = json.loads(path.read_text(encoding="utf-8")) diff --git a/tests/TimeLocker/project/test_t011_linux_deployment.py b/tests/TimeLocker/project/test_t011_linux_deployment.py index f5ec986..1f9eca0 100644 --- a/tests/TimeLocker/project/test_t011_linux_deployment.py +++ b/tests/TimeLocker/project/test_t011_linux_deployment.py @@ -157,7 +157,7 @@ def _paths(harness: ModuleType, root: Path): def _request(harness: ModuleType, root: Path): - wheel = root / "timelocker.whl" + wheel = root / "timelocker-0.9.1-py3-none-any.whl" wheel.write_bytes(b"validated wheel") digest = harness._sha256(wheel) manifest = root / "release.json" @@ -276,6 +276,8 @@ def test_identity_preflights_are_inline_and_precede_mutation_under_restrictive_u for command in target_identity_commands ) assert deployer.evidence is not None + assert deployer.staged_wheel is not None + assert deployer.staged_wheel.name == request.wheel.name evidence_modes = { path.name: path.stat().st_mode & 0o777 for path in deployer.evidence.iterdir() @@ -285,6 +287,38 @@ def test_identity_preflights_are_inline_and_precede_mutation_under_restrictive_u assert set(evidence_modes.values()) == {0o600} +def test_invalid_wheel_filename_is_rejected_before_host_state( + tmp_path: Path, +) -> None: + harness = _load_harness() + paths = _paths(harness, tmp_path) + _baseline(paths) + request = _request(harness, tmp_path) + invalid_wheel = tmp_path / "candidate.whl" + request.wheel.replace(invalid_wheel) + request = harness.DeploymentRequest( + release_id=request.release_id, + expected_current=request.expected_current, + wheel=invalid_wheel, + wheel_sha256=harness._sha256(invalid_wheel), + manifest=request.manifest, + operator_user=request.operator_user, + ) + deployer = harness.T011LinuxDeployer( + request, + paths=paths, + executor=FakeExecutor(harness, tmp_path / "unused.service"), + owner_uid=None, + owner_gid=None, + ) + + with pytest.raises(harness.DeploymentFailure, match="valid wheel filename"): + deployer.validate_request() + + assert list(paths.evidence_root.iterdir()) == [] + assert not deployer.release.exists() + + def test_preflight_failure_never_calls_activation() -> None: harness = _load_harness() calls: list[str] = [] @@ -522,7 +556,13 @@ def test_full_simulated_transaction_runs_preflight_before_selection( if "TimeLocker.system_control.release_admin" in command and "select" in command ) + pip_command = next( + command + for command in executor.commands + if len(command) >= 5 and command[1:4] == ["-m", "pip", "install"] + ) assert denied_index < selection_index + assert Path(pip_command[-1]).name == request.wheel.name assert deployer.release.exists() assert paths.service_unit.read_text() == packaged_unit.read_text() assert all( From ae329a5a01994ca2a2a7dbf57e2d72d82fbcfef1 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:11:29 +0100 Subject: [PATCH 57/72] docs(spec): define protected system deployment --- .../010-event-driven-tray-status/tasks.md | 20 +- .../traceability.md | 4 +- .../verification.md | 4 +- .../011-protected-system-deployment/README.md | 46 +++ .../canonical-context.md | 76 ++++ .../requirements.md | 364 ++++++++++++++++++ docs/specs/README.md | 27 +- 7 files changed, 522 insertions(+), 19 deletions(-) create mode 100644 docs/specs/011-protected-system-deployment/README.md create mode 100644 docs/specs/011-protected-system-deployment/canonical-context.md create mode 100644 docs/specs/011-protected-system-deployment/requirements.md diff --git a/docs/specs/010-event-driven-tray-status/tasks.md b/docs/specs/010-event-driven-tray-status/tasks.md index 54e75ee..704825d 100644 --- a/docs/specs/010-event-driven-tray-status/tasks.md +++ b/docs/specs/010-event-driven-tray-status/tasks.md @@ -281,12 +281,17 @@ T009 -> T010 -> T011 -> T012 -> T013 before creating host state. Eleven focused harness tests, scoped Ruff, compileall, patch integrity, and an exact `/usr/bin/python3` staging rehearsal with the release wheel passed. Live redeployment and acceptance - remain pending; no backup, retention, selector, unit, or protected host - state was changed by this correction. + remained separately approval-gated. The subsequent commit-bound deployment + of `a67c83ac09ac29b94a3ed481ee536b3380db3337` succeeded on Linux Mint: + preflight identity checks passed, no backup or retention was triggered, the + selector retained `d540b453864fce9b1c96a85ad9ecf604b98b7f57` as the + previous release, the control service, sockets, backup timer, and retention + timer remained healthy, and installed CLI/tray status probes passed. The + absence of a supported general deployment entrypoint is routed to + [Spec 011](../011-protected-system-deployment/README.md). - - Status: Wheel-filename correction implemented and locally validated; a new - committed release artifact and renewed live deployment approval remain - required. + - Status: Corrected release activated successfully; the remaining installed + T011 acceptance checks and evidence validation are in progress. - [ ] T012 Run the TimeLocker expert review and address findings. - Depends on: T011 - Requirements: Requirement 1-Requirement 7 @@ -304,8 +309,9 @@ T009 -> T010 -> T011 -> T012 -> T013 `docs/specs/README.md`, `docs/history/` - Acceptance: Configured regression and all required focused/platform/ security/package checks pass; accepted behavior is promoted; Windows live - work has one follow-up destination; lifecycle evidence, traceability, - closure, final-spec commit, cleanup, and history indexes are complete. + work has one follow-up destination; general protected deployment workflow + debt is owned by Spec 011; lifecycle evidence, traceability, closure, + final-spec commit, cleanup, and history indexes are complete. - Evidence: Pending. ## Execution Rules diff --git a/docs/specs/010-event-driven-tray-status/traceability.md b/docs/specs/010-event-driven-tray-status/traceability.md index 32838bc..4f9bf02 100644 --- a/docs/specs/010-event-driven-tray-status/traceability.md +++ b/docs/specs/010-event-driven-tray-status/traceability.md @@ -37,7 +37,7 @@ last_reviewed: 2026-07-27 | Requirement 4 | must-have | T002-T005, T007-T011, T013 | V2-V5, V7-V10 | architecture, troubleshooting | partial | Linux reconnect, bounds, and event/control independence complete; integration, deployment, and live acceptance remain | | Requirement 5 | must-have | T006-T007, T010-T013 | V5, V6, V8-V10 | tray setup, command reference | partial | Honest rows, actions, and deterministic non-colour-only Linux logo badges pass local and installed-artifact checks; live acceptance remains | | Requirement 6 | must-have | T006-T007, T011-T013 | V6, V10 | tray setup, troubleshooting | partial | Healthy serve silence and explicit one-shot output passed; live idle capture remains | -| Requirement 7 | must-have | T001, T005, T008-T013 | V1-V3, V7-V11, V13 | requirements, architecture, installation | partial | T001 platform-neutral interfaces complete; platform transports and rollout remain | +| Requirement 7 | must-have | T001, T005, T008-T013 | V1-V3, V7-V11, V13 | requirements, architecture, installation | partial | Linux activation passed; remaining live acceptance stays in T011, while the supported general deployment workflow is routed to Spec 011 | ## Correctness Property Coverage @@ -59,7 +59,7 @@ last_reviewed: 2026-07-27 | Linux event transport | Requirement 1, Requirement 2, Requirement 4, Requirement 7 | T005 | Linux adapter, backend, event client | V3-V5 | pass | Authenticated bounded listener, reconnect, revocation, restart, and independence tests passed | | Tray presentation | Requirement 3, Requirement 5, Requirement 6 | T006 | tray client, entry, platform integration | V5-V6 | pass | Snapshot-driven rows, local last-success, menu actions, and quiet serve validated | | Windows event contract | Requirement 2, Requirement 4, Requirement 7 | T008 | Windows adapter and platform tests | V7 | not-covered | T008 | -| Deployment and compatibility | Requirement 4, Requirement 7 | T009-T011 | assets, deployment, release probes | V8-V10 | partial | T009 local contract passed; built artifact and live host remain T010-T011 | +| Deployment and compatibility | Requirement 4, Requirement 7 | T009-T011 | assets, deployment, release probes | V8-V10 | partial | Corrected Linux activation passed; remaining installed acceptance stays in T011 and reusable deployment workflow debt is routed to Spec 011 | | Promotion and closure | Requirement 1-Requirement 7 | T012-T013 | durable docs and lifecycle artifacts | V11-V15 | not-covered | T012-T013 | ## Open Decision Impact diff --git a/docs/specs/010-event-driven-tray-status/verification.md b/docs/specs/010-event-driven-tray-status/verification.md index 7d64c4c..d070835 100644 --- a/docs/specs/010-event-driven-tray-status/verification.md +++ b/docs/specs/010-event-driven-tray-status/verification.md @@ -108,7 +108,7 @@ closure. | T008 | complete | Token-derived bounded Windows named-pipe event contracts, four new platform tests, 232 system-control tests | No live Windows service or acceptance is claimed. | | T009 | complete | Named protected event socket asset, named systemd descriptor mapping, dual-protocol release metadata, structured activation/rollback gates, 246 system-control tests | Installed-artifact and live-host evidence remain T010-T011. | | T010 | complete | Five deterministic accessible logo badges, honest never-run/failure projection, 268-test regression, 27-file package-data validation, SHA-256 verification, and clean-install wheel/sdist smoke | No live selector, unit, socket, timer, backup, retention, or protected path changed. | -| T011 | in progress | Repository-owned evidence validator and preflight-first transactional deployment harness; fail-closed wheel-filename correction and exact staging rehearsal | New committed artifact, redeployment, and acceptance remain pending. | +| T011 | in progress | Repository-owned evidence validator, preflight-first transaction, fail-closed wheel-filename correction, and successful commit-bound Linux Mint activation | Remaining installed acceptance checks and evidence validation are pending; general deployment workflow is routed to Spec 011. | | T012-T013 | pending | none | Sequenced by the task dependency graph. | ## Evidence Log @@ -152,6 +152,8 @@ closure. | 2026-07-27 | Commit-bound hardened deployment staging | fail closed; rollback passed | Pip rejected the private evidence copy because `candidate.whl` is not a valid wheel filename. Failure occurred before activation; selector `d540b453864fce9b1c96a85ad9ecf604b98b7f57`, service, sockets, and timers remained healthy, and candidate `8e8ebada197e713b60285d5105fe8b7ad8b9b8dc` was removed. | | 2026-07-27 | Wheel-filename correction focused validation | pass | The harness preserves and validates the original wheel basename, rejects `candidate.whl` before host-state creation, and passes 11 focused tests, scoped Ruff, compileall, and patch integrity. | | 2026-07-27 | Exact system-Python staging rehearsal | pass | `/usr/bin/python3` staged `timelocker-0.9.1-py3-none-any.whl` through the corrected harness into an isolated `/tmp` release and imported installed TimeLocker version `0.9.1`; no protected host path or service was changed. | +| 2026-07-28 | Corrected commit-bound Linux Mint deployment | activation passed | Release `a67c83ac09ac29b94a3ed481ee536b3380db3337` was selected with `d540b453864fce9b1c96a85ad9ecf604b98b7f57` retained as previous. Identity preflights passed; deployment triggered no backup or retention. Independent reads confirmed the control service, both sockets, backup timer, and retention timer active, required units enabled, installed CLI version `0.9.1`, system run access, and tray status success. | +| 2026-07-28 | General deployment workflow routing | follow-up created | Draft [Spec 011](../011-protected-system-deployment/README.md) owns the supported install, upgrade, status, rollback, staging, provenance, and evidence workflow. Its implementation waits for Spec 010 closure. | ## Manual Or External Verification diff --git a/docs/specs/011-protected-system-deployment/README.md b/docs/specs/011-protected-system-deployment/README.md new file mode 100644 index 0000000..6f18493 --- /dev/null +++ b/docs/specs/011-protected-system-deployment/README.md @@ -0,0 +1,46 @@ +--- +title: Protected system deployment +doc_type: spec +artifact_type: overview +status: draft +owner: Auriora Team +last_reviewed: 2026-07-28 +--- + +# Protected System Deployment + +## Purpose + +Replace acceptance-specific deployment commands, operator-authored manifests, +and externally managed temporary artifacts with one supported, repeatable, +transactional workflow for installing, upgrading, inspecting, and rolling back +protected TimeLocker releases. + +The package exists because Spec 010 proved the immutable-release architecture +but also demonstrated that its T011 acceptance harness is not a general +administrator deployment interface. + +## Current Stage + +- Requirements are drafted for review. +- Design and task authoring have not started. +- Implementation is not approved. +- Spec 010 remains the active implementation and acceptance package. +- Spec 011 requirements and design may proceed concurrently because they do not + change runtime behavior. Implementation must wait until Spec 010 completes + T013 closure and promotes its accepted deployment behavior. + +## Package + +- [Requirements](./requirements.md) +- [Canonical context](./canonical-context.md) + +Design, tasks, change impact, traceability, and verification artifacts will be +added in their lifecycle stages after the requirements are reviewed. + +## Approval Boundary + +Creating and refining this package does not authorize implementation, +installation, upgrade, rollback, service mutation, release publication, or +backup and retention execution. Protected host changes remain explicitly +approval-gated. diff --git a/docs/specs/011-protected-system-deployment/canonical-context.md b/docs/specs/011-protected-system-deployment/canonical-context.md new file mode 100644 index 0000000..96018fc --- /dev/null +++ b/docs/specs/011-protected-system-deployment/canonical-context.md @@ -0,0 +1,76 @@ +--- +title: Protected system deployment canonical context +doc_type: spec +artifact_type: canonical-context +status: draft +owner: Auriora Team +last_reviewed: 2026-07-28 +--- + +# Canonical Context + +## Purpose + +This package turns a Spec 010 acceptance harness into a future supported +administrator workflow. This map prevents the proposed workflow, temporary +acceptance evidence, or removed spec history from being mistaken for current +installation behavior. + +## Authority Hierarchy + +The package is canonical only for its approved implementation slice while +active. It does not override user or platform instructions, `AGENTS.md`, +`CHARTER.md`, security policy, source and test contracts, generated artifacts, +or live system evidence. + +## Always-Canonical External Sources + +| Source | Authority reason | Handling | +|--------|------------------|----------| +| `AGENTS.md` and `docs/guides/ai-agent/` | Repository workflow and operational instructions | Read before authoring, implementation, validation, or deployment. | +| `CHARTER.md` | Project mandate, boundaries, governance, and approval rights | Stop if deployment work expands into a remote management service or unattended product update policy. | +| Current source, tests, package metadata, and live host evidence | Implementation and runtime truth | Reconcile conflicts; proposed prose does not override current behavior. | +| `docs/1-requirements/system-operations.md` | Accepted protected-operation and administrator boundary | Extend without weakening authorization, immutable release, or fail-closed requirements. | +| `docs/processes/version-management.md` | Accepted release preparation, publication, activation, and rollback separation | Preserve the publication/deployment boundary. | + +## Spec-Canonical Working Sources + +| Source | Role | Scope | Notes | +|--------|------|-------|-------| +| `requirements.md` | Proposed observable deployment behavior | Spec 011 | Requires review before design. | +| future `design.md` | Deployment architecture and decisions | Spec 011 | Must reconcile with the proven Spec 010 transaction. | +| future `tasks.md` | Dependency-aware execution index | Spec 011 | Implementation must not begin from tasks alone. | + +## Imported Sources + +| Spec path | Source path | Source revision or date | Status | Canonical scope | Promotion target | +|-----------|-------------|-------------------------|--------|-----------------|------------------| +| requirements | `docs/guides/user/installation.md` | reviewed 2026-07-27 | supersedes | Statement that no supported protected installer exists | same path | +| requirements | `docs/processes/version-management.md` | current checkout | adapted | Protected activation and rollback invariants | same path | +| requirements | `docs/1-requirements/system-operations.md` | reviewed 2026-07-26 | adapted | Root-only maintenance and immutable release requirements | same path | +| requirements | `scripts/deploy_t011_linux.py` | commit `a67c83ac09ac29b94a3ed481ee536b3380db3337` | background | Proven acceptance transaction and failure lessons | future supported deployment implementation | +| requirements | Spec 010 T011 live evidence | 2026-07-27 to 2026-07-28 | summarized | Successful Linux Mint activation and retained rollback state | verification and operator runbook | + +## Non-Canonical Background Sources + +| Source | Reason non-canonical | Handling | +|--------|----------------------|----------| +| Removed Specs 007-009 recovered from Git | Closed delivery scaffolding | Use only for historical rationale; durable promoted documents own current behavior. | +| `/tmp/timelocker-*` scripts and artifacts from acceptance work | Ephemeral, unversioned, or build-local evidence | Do not use as a supported deployment interface or durable procedure. | +| `scripts/deploy_t011_linux.py` after Spec 010 closure | Acceptance-specific name and contract | Preserve as evidence or compatibility input until Spec 011 replaces or retires it explicitly. | + +## Promotion Map + +| Spec-local content | Durable destination or route | Required before closure | +|--------------------|------------------------------|-------------------------| +| Supported install, upgrade, status, and rollback behavior | `docs/1-requirements/system-operations.md` | yes | +| Deployment components, trust boundaries, and platform adapters | `docs/2-architecture/system-architecture.md` | yes | +| Administrator procedure and troubleshooting | `docs/guides/user/installation.md` and a durable deployment runbook | yes | +| Release artifact and host activation relationship | `docs/processes/version-management.md` | yes | +| Administrator command reference | `docs/reference/timelocker-cli-command-hierarchy.md` or a dedicated reference | yes | +| Live Windows implementation | follow-up spec or issue if not accepted in this package | yes | + +## Related Artifacts + +- Requirements: [requirements.md](./requirements.md) +- Overview: [README.md](./README.md) diff --git a/docs/specs/011-protected-system-deployment/requirements.md b/docs/specs/011-protected-system-deployment/requirements.md new file mode 100644 index 0000000..ee99fcf --- /dev/null +++ b/docs/specs/011-protected-system-deployment/requirements.md @@ -0,0 +1,364 @@ +--- +title: Protected system deployment requirements +doc_type: spec +artifact_type: requirements +status: draft +owner: Auriora Team +last_reviewed: 2026-07-28 +--- + +# Requirements + +## Introduction + +TimeLocker has validated primitives for immutable releases, packaged system +assets, compatibility probes, atomic selection, and rollback. It does not have +one supported administrator workflow that prepares an artifact and manifest, +stages trusted inputs, performs the transaction, reports evidence, and offers a +repeatable rollback. + +Spec 010 therefore used a repository-owned T011 acceptance harness plus +manually supplied commit IDs, hashes, manifests, and temporary artifact paths. +That harness successfully activated the accepted Linux Mint release, but it is +not an appropriate long-term installation or upgrade interface. + +## Goals + +- Provide one supported administrator entrypoint for protected installation, + upgrade, inspection, and rollback. +- Derive and verify artifact identity, release metadata, hashes, and staging + paths without requiring operators to assemble them manually. +- Preserve the proven preflight-first, fail-closed, rollback-safe transaction. +- Use trusted, root-owned staging and evidence locations with bounded cleanup. +- Keep release publication, protected host deployment, and backup or retention + execution as distinct approval boundaries. +- Preserve a portable deployment model while delivering and accepting Linux + systemd behavior first. + +## Non-Goals + +- Publishing TimeLocker to PyPI or automatically creating a GitHub release. +- An unattended update daemon, silent automatic upgrades, or remote fleet + management. +- Changing backup, restore, selection-set, retention-policy, or repository + credential semantics. +- Triggering backup or retention as a side effect of deployment. +- A general-purpose package manager or replacement for operating-system + packaging. +- Removing protected configuration, credentials, schedules, or durable run + records during rollback. +- Claiming a live Windows deployment before its platform implementation and + acceptance are separately evidenced. + +## Glossary + +| Term | Definition | +|------|------------| +| Deployment entrypoint | The supported administrator command or executable that owns protected install, upgrade, status, and rollback orchestration. | +| Release artifact | A validated TimeLocker wheel or an approved published release containing the wheel and its integrity metadata. | +| Deployment transaction | The bounded sequence of input validation, private staging, candidate installation, preflight, activation, verification, evidence capture, and recovery. | +| Staging root | A trusted root-owned location used internally by the deployment entrypoint; it is not an operator-authored temporary script or manifest location. | +| Selected release | The immutable release referenced by `/opt/timelocker/selected-release.json` and resolved by stable system launchers. | +| Deployment evidence | Redacted, root-owned records sufficient to determine inputs, gates, outcome, rollback state, and residual action without exposing credentials. | + +## Durable Source Baseline + +| Source | Current behavior relied on | Confidence | Notes | +|--------|----------------------------|------------|-------| +| `CHARTER.md` | TimeLocker is CLI-first and prioritizes dependable backup and recovery operation. | high | Governing mandate. | +| `docs/1-requirements/system-operations.md` | Installation, upgrade, service changes, activation, and rollback are root-only; releases are immutable and fail closed. | high | Extend with supported deployment UX. | +| `docs/2-architecture/system-architecture.md` | Stable launchers resolve one protected selected release independently of user environment. | high | Preserve trust boundary. | +| `docs/guides/user/installation.md` | The repository currently exposes primitives but no general protected installer command. | high | This spec closes that documented gap. | +| `docs/processes/version-management.md` | Publication and protected host activation are separate, approval-gated transactions. | high | Do not collapse the boundaries. | +| `src/TimeLocker/system_control/deployment.py` and release launcher modules | Asset validation, compatibility probes, immutable selection, and rollback primitives exist. | high | Design should reuse or consolidate these contracts. | +| `scripts/deploy_t011_linux.py` and its tests | Spec 010 proved a preflight-first transaction and exposed risks from temporary scripts, renamed wheels, and manual inputs. | high | Acceptance harness is input, not the final public interface. | +| Linux Mint live deployment of commit `a67c83ac09ac29b94a3ed481ee536b3380db3337` | Candidate selection, previous-release preservation, services, sockets, timers, CLI, and tray status succeeded. | high | Runtime evidence from 2026-07-28. | + +## Durable Impact + +| Durable area | Action | Target | Notes | +|--------------|--------|--------|-------| +| requirements | modify | `docs/1-requirements/system-operations.md` | Add supported deployment lifecycle and evidence requirements. | +| architecture | modify | `docs/2-architecture/system-architecture.md` | Add deployment entrypoint, staging, transaction, and platform boundary. | +| process | modify | `docs/processes/version-management.md` | Define artifact-to-host activation procedure. | +| runbook | add or modify | `docs/guides/user/installation.md` and deployment runbook | Replace manual assembly with supported commands. | +| command reference | modify | `docs/reference/timelocker-cli-command-hierarchy.md` | Document administrator-only deployment surface. | +| testing | clarify | `docs/4-testing/` if reusable deployment validation is added | Separate simulated, installed-artifact, and live acceptance evidence. | + +## Staged Readiness + +- **Current stage:** requirements +- **Next stage:** design +- **Ready to design when:** requirements and correctness properties are + reviewed, the Spec 010 dependency is explicit, and design owners agree which + artifact sources and administrator command surface must be evaluated. +- **Design-first exception:** no +- **Optional artifacts recommended:** `change-impact.md`, `traceability.md`, + `verification.md`; add `open-decisions.md` only if command or artifact-source + decisions remain blocking after design exploration. +- **Downstream review needed:** design, tasks, traceability, verification + +## Requirements + +### Requirement 1: One Supported Administrator Entrypoint + +**User Story:** As a system administrator, I want one documented deployment +entrypoint, so that installation and upgrades do not depend on generated +one-off scripts or manually reconstructed commands. + +**Priority:** must-have + +#### Acceptance Criteria + +1. THE SYSTEM SHALL provide one supported administrator entrypoint for + protected install, upgrade, deployment status, and rollback operations. +2. WHEN the entrypoint requires root authority, THEN it SHALL either run under + an explicit elevation mechanism or return one actionable elevation + instruction without falling back to user-local state. +3. THE ENTRYPOINT SHALL expose stable help, exit status, and machine-readable + result contracts for automation and troubleshooting. +4. THE SUPPORTED PROCEDURE SHALL NOT require an operator to create or edit a + deployment Python or shell script. +5. WHERE an acceptance-specific compatibility wrapper remains, THE + DOCUMENTATION SHALL identify the supported entrypoint as authoritative and + the wrapper as internal or deprecated. + +### Requirement 2: Artifact Identity And Provenance + +**User Story:** As a release maintainer, I want deployment inputs bound to an +approved release identity, so that the host cannot activate an ambiguous or +substituted artifact. + +**Priority:** must-have + +#### Acceptance Criteria + +1. GIVEN a local release artifact, WHEN deployment is requested, THEN the + entrypoint SHALL validate its wheel filename, package metadata, package + version, SHA-256 digest, and required protected assets before host mutation. +2. GIVEN a published release reference, WHEN it is supported by the chosen + design, THEN the entrypoint SHALL verify the approved release identity and + integrity metadata before staging. +3. THE ENTRYPOINT SHALL derive the release manifest from validated inputs and + SHALL NOT require the operator to hand-author the manifest or digest. +4. IF the artifact, release identity, package version, manifest, protocol + versions, or digest disagree, THEN deployment SHALL fail before candidate + installation or protected host mutation. +5. THE DEPLOYMENT EVIDENCE SHALL identify the non-secret artifact provenance, + digest, release identity, and invoking workflow. + +### Requirement 3: Trusted Staging And Cleanup + +**User Story:** As a security-conscious administrator, I want deployment inputs +copied into trusted staging, so that world-writable paths and cleanup races +cannot change what is installed. + +**Priority:** must-have + +#### Acceptance Criteria + +1. BEFORE installing a candidate, THE ENTRYPOINT SHALL copy exact validated + inputs into a private, root-owned staging or evidence boundary and recheck + their identity after copying. +2. THE SUPPORTED OPERATOR PROCEDURE SHALL NOT depend on persistent artifacts, + manifests, or scripts under `/tmp`. +3. IF an external source path is used as input, THEN the deployment transaction + SHALL snapshot it before relying on its contents and SHALL not reread the + mutable source after snapshot validation. +4. THE ENTRYPOINT SHALL preserve valid artifact filenames required by the + package installer. +5. WHEN a transaction finishes or fails, THEN bounded temporary staging SHALL + be removed or retained according to an explicit evidence policy without + deleting the selected or previous immutable release. +6. IF a staging path is a symlink, unexpectedly writable, outside its allowed + root, or has untrusted ownership, THEN deployment SHALL fail closed. + +### Requirement 4: Preflight-First Transactional Activation + +**User Story:** As an operator, I want compatibility and authorization checked +before activation, so that a bad candidate cannot interrupt scheduled +protection. + +**Priority:** must-have + +#### Acceptance Criteria + +1. BEFORE changing a service unit, stable launcher, or selected release, THE + ENTRYPOINT SHALL verify the staged CLI, backend, tray, packaged assets, + control protocol, event protocol, authorized access, denied access, and + required timer health. +2. WHEN selecting a release, THE ENTRYPOINT SHALL use a locked + expected-current compare-and-swap operation. +3. IF the selected release changes after the transaction begins, THEN the + entrypoint SHALL reject activation rather than overwrite the newer state. +4. THE TRANSACTION SHALL define one mutation boundary after which every + exception, interruption, termination signal, or failed verification invokes + recovery. +5. DEPLOYMENT SHALL NOT trigger backup or retention and SHALL preserve active + and enabled backup and retention scheduling. + +### Requirement 5: Verified Rollback And State Preservation + +**User Story:** As an administrator, I want a repeatable rollback command, so +that I can recover the prior working release without reconstructing an old +deployment script. + +**Priority:** must-have + +#### Acceptance Criteria + +1. GIVEN a compatible previous release, WHEN rollback is requested, THEN the + supported entrypoint SHALL probe it before atomically exchanging selected + and previous release identities. +2. IF activation fails after mutation begins, THEN recovery SHALL restore the + prior selector and required service state and SHALL verify control-channel + and timer health. +3. ROLLBACK SHALL preserve protected configuration, credential references, + schedules, retention enablement, and durable run records. +4. IF no compatible previous release exists, THEN rollback SHALL fail with an + actionable result and SHALL NOT modify the selected release. +5. A SUCCESSFUL install, upgrade, or rollback result SHALL report the selected + and previous release identities and the evidence location. + +### Requirement 6: Idempotency, Concurrency, And Recovery + +**User Story:** As an administrator, I want deployment retries to be safe, so +that interruption or repeated invocation does not corrupt release state. + +**Priority:** must-have + +#### Acceptance Criteria + +1. WHILE another deployment transaction holds the deployment lock, A SECOND + MUTATING REQUEST SHALL fail without changing host state. +2. GIVEN the same already-selected release and identical verified inputs, WHEN + deployment is repeated, THEN the entrypoint SHALL return an idempotent + outcome or perform a no-op verification rather than create ambiguous state. +3. WHEN a stale inert candidate from an interrupted pre-mutation attempt is + found, THEN the entrypoint SHALL either prove and resume it or remove it + safely before proceeding. +4. WHEN prior transaction evidence indicates incomplete post-mutation + recovery, THEN status SHALL report an attention state and mutating commands + SHALL fail until the state is reconciled. +5. INTERRUPTION handling SHALL be bounded and SHALL never report success + before post-activation verification completes. + +### Requirement 7: Redacted Evidence And Operator Diagnostics + +**User Story:** As an administrator, I want concise deployment evidence and +diagnostics, so that I can understand failures without exposing credentials or +reading implementation-specific scratch files. + +**Priority:** must-have + +#### Acceptance Criteria + +1. EVERY mutating transaction SHALL create root-owned evidence containing + bounded command outcomes, gate results, release identities, timestamps, and + rollback disposition. +2. THE EVIDENCE AND USER-FACING OUTPUT SHALL NOT contain repository passwords, + cloud credentials, environment-file contents, raw secret arguments, or + credential-bearing URLs. +3. WHEN a gate fails, THEN output SHALL identify the failed stage, state + whether protected mutation began, and provide the evidence location and safe + next action. +4. THE STATUS OPERATION SHALL report selected and previous releases, transaction + attention state, service/socket state, and backup/retention timer health + without triggering any operation. +5. THE ENTRYPOINT SHALL distinguish warnings, failed validation, failed + activation with successful recovery, and failed recovery through stable + result codes. + +### Requirement 8: Portable Deployment Boundary + +**User Story:** As a maintainer, I want platform-neutral deployment contracts, +so that Linux delivery does not embed systemd assumptions into future Windows +support. + +**Priority:** should-have + +#### Acceptance Criteria + +1. THE ARTIFACT, manifest, transaction state, evidence, activation, status, and + rollback contracts SHALL be platform-neutral. +2. Linux SHALL implement root-owned paths, stable launchers, peer-authorized + local services, and systemd unit/timer verification through a Linux adapter. +3. Windows-specific service control, named-pipe authorization, installation + paths, and elevation SHALL remain behind injectable platform contracts. +4. THIS PACKAGE SHALL NOT claim live Windows deployment until install, upgrade, + rollback, authorization, interruption, and recovery are accepted on a + Windows host. +5. WHERE a platform operation is unsupported, THE ENTRYPOINT SHALL fail + explicitly without partial installation. + +## Correctness Properties + +- **CP-001:** No protected selector, service, launcher, or timer mutation occurs + before artifact identity and all pre-mutation gates pass. +- **CP-002:** A selector changes only from the locked expected-current release + to the exact compatible candidate, or through a verified selected/previous + rollback exchange. +- **CP-003:** Any failure or interruption after the mutation boundary either + restores the prior selected release and required service/timer health or + leaves a durable attention state that blocks further mutation. +- **CP-004:** Artifact bytes installed into the immutable release are identical + to the bytes whose digest and metadata were recorded after private staging. +- **CP-005:** Deployment, upgrade, status, and rollback never execute backup, + retention, restore, or repository-pruning operations. +- **CP-006:** Secret categories remain absent from command output, evidence, + manifests, and transaction records for every success and failure path. +- **CP-007:** Platform-specific paths, service management, identity, and + elevation are reachable only through the selected platform adapter. + +## Technical Context + +- **Language/Version:** Python 3.12 and 3.13 +- **Primary Dependencies:** Python packaging, existing immutable-release and + system-control contracts, operating-system service manager +- **Target Platform:** Linux Mint/systemd acceptance first; portable Windows + contract retained +- **Constraints:** root-only mutation; offline/local artifact support; no + credential disclosure; no caller pyenv, home, checkout, or working-directory + dependency after installation +- **Performance Goals:** local validation and status should complete promptly; + network artifact acquisition, when supported, must have explicit timeouts + +## Success Criteria + +- **SC-001:** A documented administrator can install or upgrade a clean Linux + host using one supported entrypoint without manually creating a manifest, + digest, or temporary script. +- **SC-002:** The supported entrypoint deploys a validated wheel, reports the + exact selected and previous releases, and passes installed CLI, backend, + tray, socket, service, and timer checks. +- **SC-003:** Forced failures at every transaction stage demonstrate no + pre-boundary mutation and verified post-boundary recovery or attention state. +- **SC-004:** An approved rollback restores the previous compatible release + while protected configuration, schedules, run records, backup, and retention + remain intact. +- **SC-005:** Repeated and concurrent deployment attempts satisfy idempotency + and lock behavior under automated tests and Linux live acceptance. +- **SC-006:** Durable installation, release-management, command-reference, and + troubleshooting documentation contains no `/tmp`-based operator workflow. +- **SC-007:** Linux live acceptance is recorded; Windows support remains + explicitly contractual until separately accepted. + +## Design Decisions Deferred To The Next Stage + +- Administrator command name and whether it is a standalone bootstrap + executable or an installed `timelocker` subcommand. +- Supported artifact sources for the first slice: committed local build, + downloaded GitHub release, or both. +- Root-owned staging and retained-evidence directory layout. +- Whether initial installation and later upgrades share one command or one + transaction engine behind separate verbs. +- Linux packaging boundary and the minimum Windows adapter delivered in this + package. + +## Related Artifacts + +- Overview: [README.md](./README.md) +- Canonical Context: [canonical-context.md](./canonical-context.md) +- Design: to be created after requirements review +- Tasks: to be created after design review +- Verification: to be created with design and task traceability diff --git a/docs/specs/README.md b/docs/specs/README.md index 85dae26..a392505 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -3,7 +3,7 @@ title: "Active Specification Packages" doc_type: reference status: active owner: "Auriora Team" -last_reviewed: 2026-07-26 +last_reviewed: 2026-07-28 --- # Active Specification Packages @@ -18,17 +18,26 @@ accepted content has been promoted and the package is closed. - [`010-event-driven-tray-status`](./010-event-driven-tray-status/README.md) - active implementation package for replacing tray status polling with an authenticated event subscription and accurate, quiet status presentation. - Implementation was approved on 2026-07-27; T001 is the first slice. + Implementation was approved on 2026-07-27; T011 live acceptance is in + progress. +- [`011-protected-system-deployment`](./011-protected-system-deployment/README.md) - + draft requirements package for replacing acceptance-specific deployment + commands and temporary operator inputs with one supported transactional + install, upgrade, status, and rollback workflow. ## Active-Package Sequencing -Spec 010 is the only active package. Specs 007, 008, and 009 are closed. Their -final package commits, cleanup commits, verification summaries, and residual -follow-up are recorded in `docs/history/`. Closed packages remain recoverable -from Git rather than kept in this active path. Spec 010 may rely on the durable -behavior promoted by Spec 009, but not on its removed package as current -authority. Repository implementation approval does not authorize release -publication or deployment. +Spec 010 remains the only implementation-approved package. Spec 011 may proceed +through requirements and design concurrently because those documentation stages +do not change the runtime surface under live acceptance. Spec 011 implementation +must wait until Spec 010 completes T013 promotion and closure. + +Specs 007, 008, and 009 are closed. Their final package commits, cleanup +commits, verification summaries, and residual follow-up are recorded in +`docs/history/`. Closed packages remain recoverable from Git rather than kept +in this active path. Spec 010 may rely on the durable behavior promoted by Spec +009, but not on its removed package as current authority. Repository +implementation approval does not authorize release publication or deployment. ## When a Spec Is Needed From 2e1b565c823dd9a2714e43ed976338d45a9cbee5 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:49:46 +0100 Subject: [PATCH 58/72] feat: make tray status health-aware --- .../010-event-driven-tray-status/design.md | 39 ++- .../requirements.md | 50 ++-- .../010-event-driven-tray-status/tasks.md | 60 ++++- .../traceability.md | 9 +- .../verification.md | 9 +- scripts/deploy_t011_linux.py | 2 +- scripts/generate_tray_status_icons.py | 7 + scripts/smoke_release_artifact.py | 1 + src/TimeLocker/__init__.py | 93 ++++--- .../monitoring/system_tray_integration.py | 53 ++-- src/TimeLocker/system_control/__init__.py | 226 +++++++++------- .../assets/system-control-policy.json | 2 +- .../assets/timelocker-icon-connecting.png | Bin 0 -> 33561 bytes .../system_control/backend_entry.py | 68 ++++- src/TimeLocker/system_control/deployment.py | 9 +- src/TimeLocker/system_control/dispatcher.py | 7 +- src/TimeLocker/system_control/models.py | 20 +- src/TimeLocker/system_control/protocol.py | 1 + .../system_control/release_launcher.py | 2 +- .../system_control/schedule_health.py | 249 ++++++++++++++++++ .../system_control/status_events.py | 48 ++++ src/TimeLocker/system_control/tray_client.py | 86 +++--- src/TimeLocker/system_control/tray_entry.py | 16 ++ src/TimeLocker/system_control/types.py | 9 + .../test_system_tray_integration.py | 11 +- .../project/test_release_artifacts.py | 1 + .../project/test_t011_linux_deployment.py | 2 +- .../project/test_tray_icon_assets.py | 2 +- .../system_control/test_deployment.py | 1 + .../system_control/test_dispatcher.py | 4 +- .../TimeLocker/system_control/test_models.py | 7 +- .../system_control/test_protocol.py | 11 +- .../system_control/test_release_launcher.py | 9 +- .../system_control/test_schedule_health.py | 236 +++++++++++++++++ .../system_control/test_status_contracts.py | 2 + .../test_status_snapshot_action.py | 2 +- .../system_control/test_tray_client.py | 47 +++- .../test_tray_process_boundary.py | 111 ++++++++ 38 files changed, 1241 insertions(+), 271 deletions(-) create mode 100644 src/TimeLocker/system_control/assets/timelocker-icon-connecting.png create mode 100644 src/TimeLocker/system_control/schedule_health.py create mode 100644 tests/TimeLocker/system_control/test_schedule_health.py diff --git a/docs/specs/010-event-driven-tray-status/design.md b/docs/specs/010-event-driven-tray-status/design.md index d251dd1..e407c0c 100644 --- a/docs/specs/010-event-driven-tray-status/design.md +++ b/docs/specs/010-event-driven-tray-status/design.md @@ -24,9 +24,9 @@ steady-state tray polling. |-------------|---------------------|-----------------|---------------------| | Requirement 1 | AC1-AC5 | Subscription handshake, session revisions, invalidation stream | Protocol, broker, integration, idle tests | | Requirement 2 | AC1-AC5 | Peer identity, per-frame authorization, allowlisted models | Security and negative-control tests | -| Requirement 3 | AC1-AC5 | Backend-derived `StatusSnapshot` and local presentation | Model, store, tray tests | +| Requirement 3 | AC1-AC7 | Backend-derived `StatusSnapshot`, systemd schedule health, and local presentation | Model, store, schedule, tray tests | | Requirement 4 | AC1-AC5 | Separate transport, backoff, heartbeat, bounded clients | Failure, restart, slow-client tests | -| Requirement 5 | AC1-AC6 | Disabled status rows, action-only mutations, and deterministic logo badges | Platform menu/icon tests and Linux acceptance | +| Requirement 5 | AC1-AC7 | Three disabled status rows, action-only mutations, and deterministic logo badges | Platform menu/icon tests and Linux acceptance | | Requirement 6 | AC1-AC4 | Silent serve loop and logging boundary | Captured-stream and logging tests | | Requirement 7 | AC1-AC5 | Portable interfaces, Linux adapter, Windows contracts, release probes | Platform, package, deployment, rollback tests | @@ -40,6 +40,7 @@ steady-state tray polling. | CP-004 | Initial/reconnect flow always fetches `status.snapshot`. | Restart, gap, and reconnect integration tests | Events are invalidations, not state. | | CP-005 | Event components have read/status dependencies only. | Interface and lock-spy tests | Mutations retain existing action path. | | CP-006 | Serve path has no successful-state `print`. | Captured 90-second idle test with shortened test clock | One-shot output remains tested separately. | +| CP-007 | Systemd occurrence, grace deadline, and durable run matching derive backup health. | Fake-clock/systemd tables and deadline tests | Failed runs remain distinct from missed runs. | ## High-Level Design @@ -82,6 +83,9 @@ requests or mutation actions. - Explicitly notify after TimeLocker-owned run and schedule mutations. - Monitor protected atomic record/schedule state changes produced by separate workers through an injectable platform change-watcher boundary. + - On Linux, use filesystem notifications for protected run-record changes + and a one-shot deadline monitor for the next scheduled occurrence; do not + add fixed-interval tray polling. - **Linux event transport** - Adopt a systemd-owned AF_UNIX listener. - Derive `SO_PEERCRED`, enforce current NSS membership, bound connections and @@ -95,7 +99,11 @@ requests or mutation actions. - Signal the presentation loop to fetch a fresh snapshot after newer events. - Reconnect with bounded exponential backoff and coalesce refresh requests. - **Tray presentation** + - Construct and process an explicit connecting badge before starting the + background subscription worker. - Replace `View Status` with non-actionable status rows. + - Render exactly `State`, `Activity`, and `Last Backup`. + - Keep health (`State`) separate from transient work (`Activity`). - Keep `Open TimeLocker` absent. - Remove periodic successful stdout rendering from `serve`. @@ -121,6 +129,7 @@ StatusSnapshot latest_retention: optional safe run summary next_backup_at: optional UTC datetime next_retention_at: optional UTC datetime + backup_schedule_health: healthy | missed | disabled | unavailable ``` The exact snapshot schema must reuse existing stable enums and safe summaries. @@ -129,15 +138,17 @@ or backend output. ### Data Flow -1. Tray connects to the event socket. -2. Backend derives peer identity and authorizes current group membership. -3. Backend sends `snapshot_required` with the current revision. -4. Tray requests `status.snapshot` through the control socket and renders it. -5. A durable run or managed schedule change advances the broker sequence. -6. Backend reauthorizes each subscriber and sends one coalesced `changed` +1. Tray constructs and processes its connecting presentation. +2. A background worker connects to the event socket without blocking the + desktop event loop. +3. Backend derives peer identity and authorizes current group membership. +4. Backend sends `snapshot_required` with the current revision. +5. Tray requests `status.snapshot` through the control socket and renders it. +6. A durable run or managed schedule change advances the broker sequence. +7. Backend reauthorizes each subscriber and sends one coalesced `changed` event. -7. Tray fetches and renders the newest snapshot if its revision is newer. -8. On disconnect, session change, gap, or `resync_required`, the tray reconnects +8. Tray fetches and renders the newest snapshot if its revision is newer. +9. On disconnect, session change, gap, or `resync_required`, the tray reconnects and repeats the initial snapshot flow. ## Low-Level Design @@ -162,8 +173,10 @@ on_tray_event(event): build_status_snapshot(): runs = protected_store.list_for_status() + schedule = system_schedule_provider.snapshot() successful = backup runs with state SUCCEEDED and completed_at present last_success = max(successful, key=completed_at, default=None) + backup_health = reconcile(schedule, runs, now, grace) return sanitized snapshot at broker.current_revision ``` @@ -205,10 +218,16 @@ class StatusEventClient(Protocol): `unavailable` while bounded reconnect continues. - Event channel unavailability changes tray presentation to unavailable but does not disable explicit control-channel commands. +- Initial connection and later reconnect attempts run outside the desktop event + loop; no socket timeout or backoff delay may postpone the first connecting + presentation. - Backoff is bounded and resets only after a successful authorized handshake. - Slow subscribers retain at most the newest pending revision; if they cannot keep up, the backend disconnects them. - Watcher overflow or uncertainty emits `resync_required`. +- The schedule deadline monitor sleeps only until the next expected occurrence + plus grace, publishes one invalidation, then rearms from a fresh systemd + projection. It does not poll the tray or protected backend on a fixed cadence. - Logging uses stable codes and redacted summaries with repetition control. ### Security, Trust, and Access diff --git a/docs/specs/010-event-driven-tray-status/requirements.md b/docs/specs/010-event-driven-tray-status/requirements.md index 2fa23a0..332a262 100644 --- a/docs/specs/010-event-driven-tray-status/requirements.md +++ b/docs/specs/010-event-driven-tray-status/requirements.md @@ -93,8 +93,9 @@ that status is current without repeated polling and terminal output. 2. WHILE a subscription is healthy, THE TRAY SHALL NOT issue periodic status snapshot requests solely because a fixed refresh interval elapsed. 3. WHEN backup, retention, backend-availability, or TimeLocker-managed schedule - status changes, THEN an authorized connected tray SHALL be prompted to - refresh within two seconds under normal local-host load. + status changes in the backend process or a separate protected worker, THEN + an authorized connected tray SHALL be prompted to refresh within two seconds + under normal local-host load. 4. WHEN multiple changes occur faster than the tray can render them, THEN the system SHALL coalesce them without applying an older revision after a newer revision. @@ -125,7 +126,7 @@ control or expose secrets. 5. WHEN an unauthorized local client attempts to subscribe, THEN it SHALL receive no status payload beyond a bounded safe denial. -### Requirement 3: Accurate Backup And Retention Status +### Requirement 3: Accurate Backup Health And Activity **User Story:** As an operator, I want the tray's backup time to mean successful completion, so that a failed or running attempt cannot misrepresent protection. @@ -134,8 +135,8 @@ completion, so that a failed or running attempt cannot misrepresent protection. #### Acceptance Criteria -1. THE `Last successful backup` value SHALL be selected only from backup runs - in `SUCCEEDED` state and SHALL display that run's `completed_at` time. +1. THE `Last Backup` value SHALL be selected only from backup runs in + `SUCCEEDED` state and SHALL display that run's `completed_at` time. 2. WHEN a newer backup is queued, running, failed, skipped, or interrupted, THEN it SHALL NOT replace the last successful backup completion time. 3. WHEN no successful backup exists, THEN the tray SHALL display `Never` or @@ -146,6 +147,13 @@ completion, so that a failed or running attempt cannot misrepresent protection. 5. WHEN a timestamp is displayed, THEN the tray SHALL convert the stored timezone-aware UTC value to the desktop session's local time and identify the timezone. +6. THE BACKEND SHALL derive backup schedule health from the configured system + timer, its service state, and durable backup-run records without granting the + tray direct systemd or protected-file access. +7. WHEN an enabled scheduled occurrence passes its configured grace deadline + without a matching active or terminal backup run, THEN schedule health SHALL + become `backup_missed`. A failed matching run SHALL be `backup_failed`, not + `backup_missed`. ### Requirement 4: Resilience And Process Independence @@ -156,8 +164,10 @@ recoverable, so that presentation failures never disrupt backup or retention. #### Acceptance Criteria -1. WHEN the backend or event channel is unavailable, THEN the tray SHALL remain - responsive and reconnect using bounded exponential backoff. +1. WHEN the tray process starts, THEN it SHALL present a connecting state before + beginning backend subscription work. WHEN the backend or event channel is + unavailable, THEN the presented tray SHALL remain responsive and reconnect + using bounded exponential backoff. 2. WHEN the backend restarts, THEN a connected or reconnecting tray SHALL establish a new subscription session and obtain a fresh snapshot. 3. THE BACKEND SHALL bound subscriber count, frame size, queued event state, @@ -177,9 +187,10 @@ status, so that its labels accurately describe what they do. #### Acceptance Criteria -1. THE MENU SHALL show backend availability, current activity, last successful - backup completion, latest retention result, and next known schedules when - those values are available. +1. THE MENU SHALL contain exactly three non-actionable status rows: `State`, + `Activity`, and `Last Backup`. `State` SHALL contain health only; `Activity` + SHALL contain transient work such as connecting, backup running, retention + running, or idle. 2. THE MENU SHALL NOT show `Open TimeLocker` until an implemented desktop application exists. 3. THE MENU SHALL NOT show an actionable `View Status` item unless activating @@ -190,9 +201,14 @@ status, so that its labels accurately describe what they do. 5. WHEN a status event arrives, THEN the visible menu SHALL update without restarting the tray process. 6. ON Linux, THE TRAY SHALL preserve the TimeLocker logo while applying a - distinct non-colour-only status badge for running, successful, warning or - never-run, and failed/interrupted states. WHEN no backup attempt exists, the - icon SHALL use the warning or never-run state rather than implying success. + distinct non-colour-only badge consistent with health and activity. Running + activity MAY temporarily select the running badge; otherwise failed, missed, + unavailable, disabled, healthy, and never-run health SHALL select an honest + error, warning, success, or idle badge. +7. `State` SHALL use bounded user-facing values including `Healthy`, + `Backup failed`, `Backup missed`, `Schedule disabled`, + `Backend unavailable`, and `Access denied`. Connection progress and running + operations SHALL NOT be reported as health states. ### Requirement 6: Quiet Background Operation @@ -249,6 +265,9 @@ implementation. lock or alter an active run except through existing allowlisted requests. - **CP-006:** A healthy tray over any interval emits zero periodic successful status records to stdout or stderr. +- **CP-007:** An enabled backup occurrence becomes missed only after its grace + deadline when no matching active or terminal backup run exists; any matching + failed run is reported as failed instead. ## Technical Context @@ -259,8 +278,9 @@ implementation. contracts and tests - **Constraints:** local-only IPC, current group authorization, bounded frames, safe projections, immutable releases, no GUI dependency in CLI/backend -- **Performance Goals:** event-to-menu update within two seconds under normal - local load; no steady-state snapshot polling; bounded idle heartbeat +- **Performance Goals:** present the connecting tray before starting backend + subscription work; event-to-menu update within two seconds under normal local + load; no steady-state snapshot polling; bounded idle heartbeat ## Success Criteria diff --git a/docs/specs/010-event-driven-tray-status/tasks.md b/docs/specs/010-event-driven-tray-status/tasks.md index 704825d..d352a9b 100644 --- a/docs/specs/010-event-driven-tray-status/tasks.md +++ b/docs/specs/010-event-driven-tray-status/tasks.md @@ -247,8 +247,9 @@ T009 -> T010 -> T011 -> T012 -> T013 execution approval required. - Acceptance: Authorized and denied subscription, event latency, last-success semantics, 90-second idle silence, backend restart, tray restart, action - independence, timer health, and rollback are evidenced from the installed - artifact. + independence, timer health, external-worker invalidation, missed-backup + health, the exact three-row menu, and rollback are evidenced from the + installed artifact. - Evidence: User approved remediation of T011 review findings. Implementation maps OS socket permission denial to the safe denied state, reports other transport failures as unavailable with bounded reconnect, accepts @@ -288,10 +289,61 @@ T009 -> T010 -> T011 -> T012 -> T013 previous release, the control service, sockets, backup timer, and retention timer remained healthy, and installed CLI/tray status probes passed. The absence of a supported general deployment entrypoint is routed to - [Spec 011](../011-protected-system-deployment/README.md). + [Spec 011](../011-protected-system-deployment/README.md). A subsequent + startup correction adds an explicit deterministic connecting badge, + processes the desktop event loop before starting the subscription worker, + and lazily loads unrelated package and system-control exports. Focused tray, + asset, deployment, and artifact tests passed 42 cases; the broader + system-control, tray-monitoring, and backup compatibility regression passed + 415 tests. Scoped Ruff, compileall, patch integrity, and public lazy-export + compatibility checks passed. Direct source startup measurements were + approximately 0.11 seconds for the launcher import and 0.56 seconds for the + full tray entry import. A fresh wheel and sdist passed release validation + with 28 package-data files, and the wheel passed clean installed-artifact + smoke. Wheel SHA-256: + `d9fb99bbe856c7659304701ab8b12d5dd8d97fc194f5b8ce5825d33a559609c8`; + sdist SHA-256: + `bb5df2b2450db07335ccbb848f03e01b010ed568d6609f29cdd3b24f827ffeea`. + No protected host mutation occurred. - Status: Corrected release activated successfully; the remaining installed - T011 acceptance checks and evidence validation are in progress. + T011 acceptance checks, including visible connecting-state startup, and + evidence validation are in progress. + - [x] T011.1 Reconcile and test backup-health and tray-row contracts. + - Acceptance: State is health-only; Activity is transient; Last Backup is + successful completion or Never; exact wire and menu tests fail before the + implementation change. + - Evidence: User approved the corrected State/Activity distinction on + 2026-07-28. Requirements, design, traceability, verification, and tests + now define health-only State, transient Activity, and successful-only + Last Backup. The strict status snapshot contract includes bounded backup + schedule health and control protocol version 2; schema-1 release metadata + and preserved protocol-1 policy declarations remain readable for upgrade + and rollback without accepting protocol-1 traffic as the new contract. + - [x] T011.2 Implement external-worker invalidation and backup schedule health. + - Acceptance: Separate protected run-record writes prompt a snapshot within + two seconds; systemd timer/service state and durable runs distinguish + healthy, failed, missed, disabled, and unavailable without fixed polling. + - Evidence: Added watchdog-backed native filesystem invalidation for + protected run-record atomic renames, a bounded systemd timer/service + observer, durable run matching, a 15-minute missed-occurrence grace + deadline, and a one-shot deadline monitor. Tests cover unavailable, + disabled, healthy, missed, failed-run matching, late manual backup, + numeric systemd timestamps, deadline publication, and an external + `AtomicRecordStore` write. The current host's read-only observer returned + an enabled/active timer with the expected last and next trigger times. + - [x] T011.3 Implement and validate the exact three-row tray presentation. + - Acceptance: All platform adapters show only State, Activity, and Last + Backup; retention affects Activity only while running and never health. + - Evidence: Tray projection and all platform menu adapters now expose only + `State`, `Activity`, and `Last Backup`. Backup failure/miss, schedule + disabled/unavailable, backend unavailable, access denial, and healthy + states are separate from connecting, backup-running, + retention-running, combined-running, and idle activity. Retention + terminal outcomes do not affect health. A 416-test system-control, + tray, deployment, artifact, backup, and CLI regression passed; scoped + Ruff, compileall, patch integrity, wheel/sdist build, and installed-wheel + smoke passed. No protected host mutation or live backup/retention ran. - [ ] T012 Run the TimeLocker expert review and address findings. - Depends on: T011 - Requirements: Requirement 1-Requirement 7 diff --git a/docs/specs/010-event-driven-tray-status/traceability.md b/docs/specs/010-event-driven-tray-status/traceability.md index 4f9bf02..df556e4 100644 --- a/docs/specs/010-event-driven-tray-status/traceability.md +++ b/docs/specs/010-event-driven-tray-status/traceability.md @@ -23,7 +23,7 @@ last_reviewed: 2026-07-27 | T008 | Requirement 2, Requirement 4, Requirement 7 | Requirement 2 AC1-AC5; Requirement 4 AC3-AC5; Requirement 7 AC1, AC3 | Windows Event Contract | V3, V7 | requirements, architecture | | T009 | Requirement 4, Requirement 7 | Requirement 4 AC5; Requirement 7 AC2, AC4-AC5 | Migration and Compatibility | V8, V9 | installation, version management | | T010 | Requirement 5, Requirement 7 | Requirement 5 AC6; Requirement 7 all | Tray Presentation, Operational Considerations | V5, V8, V9 | tray setup | -| T011 | Requirement 1-Requirement 7 | all Linux acceptance criteria | Complete Linux flow | V10 | operational docs | +| T011 | Requirement 1-Requirement 7 | all Linux acceptance criteria, including external-worker invalidation, Requirement 3 AC6-AC7, Requirement 5 AC1 and AC7 | Complete Linux flow | V10 | operational docs | | T012 | Requirement 1-Requirement 7 | review disposition | Security, Reliability, Portability | V11 | all promotion targets | | T013 | Requirement 1-Requirement 7 | all | Promotion and Closure | V12-V15 | all promotion targets and history | @@ -34,8 +34,8 @@ last_reviewed: 2026-07-27 | Requirement 1 | must-have | T001, T003-T005, T007, T011-T013 | V1, V2, V4, V5, V10 | requirements, architecture, tray setup | partial | Contracts through event-driven tray integration complete; deployment and live acceptance remain | | Requirement 2 | must-have | T001-T005, T007-T008, T011-T013 | V1-V5, V7, V10-V11 | requirements, architecture | partial | Allowlisted models and Linux continuous authorization complete; Windows contract and live acceptance remain | | Requirement 3 | must-have | T001-T002, T006-T007, T011-T013 | V1, V5-V6, V10 | requirements, tray setup | partial | Last-success contract, backend snapshot, local tray projection, and `Never` fallback complete; live acceptance remains | -| Requirement 4 | must-have | T002-T005, T007-T011, T013 | V2-V5, V7-V10 | architecture, troubleshooting | partial | Linux reconnect, bounds, and event/control independence complete; integration, deployment, and live acceptance remain | -| Requirement 5 | must-have | T006-T007, T010-T013 | V5, V6, V8-V10 | tray setup, command reference | partial | Honest rows, actions, and deterministic non-colour-only Linux logo badges pass local and installed-artifact checks; live acceptance remains | +| Requirement 4 | must-have | T002-T005, T007-T011, T013 | V2-V5, V7-V10 | architecture, troubleshooting | partial | Immediate connecting presentation, Linux reconnect, bounds, and event/control independence pass local regression; installed startup and remaining live acceptance remain | +| Requirement 5 | must-have | T006-T007, T010-T013 | V5, V6, V8-V10 | tray setup, command reference | partial | Honest rows, actions, and deterministic non-colour-only Linux logo badges, including connecting, pass local checks; installed connecting-state and remaining live acceptance remain | | Requirement 6 | must-have | T006-T007, T011-T013 | V6, V10 | tray setup, troubleshooting | partial | Healthy serve silence and explicit one-shot output passed; live idle capture remains | | Requirement 7 | must-have | T001, T005, T008-T013 | V1-V3, V7-V11, V13 | requirements, architecture, installation | partial | Linux activation passed; remaining live acceptance stays in T011, while the supported general deployment workflow is routed to Spec 011 | @@ -49,6 +49,7 @@ last_reviewed: 2026-07-27 | CP-004 | Requirement 1, Requirement 4 | T001-T005, T008, T011 | V1-V5, V7, V10 | none for accepted Linux slice | | CP-005 | Requirement 2, Requirement 4 | T002-T005, T008, T011 | V3-V5, V7, V10 | none | | CP-006 | Requirement 6 | T006-T007, T011 | V6, V10 | desktop session capture variation | +| CP-007 | Requirement 3, Requirement 5 | T011 | V1, V4-V6, V10 | live systemd deadline timing remains host-sensitive | ## Design To Implementation Matrix @@ -57,7 +58,7 @@ last_reviewed: 2026-07-27 | Status models and snapshot | Requirement 2, Requirement 3 | T001-T002 | models, protocol, backend, storage | V1, V3 | partial-pass | T001 contracts passed; T002 backend action remains | | Event broker and change sources | Requirement 1, Requirement 4 | T003 | new broker/watcher modules | V2, V4 | pass | Bounded broker, mutation seams, snapshot race boundary, and watcher resync validated | | Linux event transport | Requirement 1, Requirement 2, Requirement 4, Requirement 7 | T005 | Linux adapter, backend, event client | V3-V5 | pass | Authenticated bounded listener, reconnect, revocation, restart, and independence tests passed | -| Tray presentation | Requirement 3, Requirement 5, Requirement 6 | T006 | tray client, entry, platform integration | V5-V6 | pass | Snapshot-driven rows, local last-success, menu actions, and quiet serve validated | +| Tray presentation | Requirement 3-Requirement 6 | T006, T011 | tray client, entry, platform integration | V5-V6, V10 | partial | Exact State/Activity/Last Backup rows, local last-success, health/activity separation, menu actions, quiet serve, and connecting-before-subscription ordering pass local checks; installed visual startup remains T011 | | Windows event contract | Requirement 2, Requirement 4, Requirement 7 | T008 | Windows adapter and platform tests | V7 | not-covered | T008 | | Deployment and compatibility | Requirement 4, Requirement 7 | T009-T011 | assets, deployment, release probes | V8-V10 | partial | Corrected Linux activation passed; remaining installed acceptance stays in T011 and reusable deployment workflow debt is routed to Spec 011 | | Promotion and closure | Requirement 1-Requirement 7 | T012-T013 | durable docs and lifecycle artifacts | V11-V15 | not-covered | T012-T013 | diff --git a/docs/specs/010-event-driven-tray-status/verification.md b/docs/specs/010-event-driven-tray-status/verification.md index d070835..d9bfe3b 100644 --- a/docs/specs/010-event-driven-tray-status/verification.md +++ b/docs/specs/010-event-driven-tray-status/verification.md @@ -11,7 +11,7 @@ last_reviewed: 2026-07-27 ## Scope -This plan covers Requirements 1-7, CP-001-CP-006, T001-T013, the protected +This plan covers Requirements 1-7, CP-001-CP-007, T001-T013, the protected snapshot/event contracts, Linux implementation, Windows contract tests, tray presentation, packaging, approved live acceptance, durable promotion, and closure. @@ -57,7 +57,7 @@ closure. | Requirement 2 | AC1-AC5 | T001-T005, T008, T011-T012 | pending | | Requirement 3 | AC1-AC5 | T001-T002, T006-T007, T011 | pending | | Requirement 4 | AC1-AC5 | T002-T005, T007-T011 | pending | -| Requirement 5 | AC1-AC6 | T006-T007, T010-T011 | partial-pass; deterministic Linux badges and honest never-run/failure projection passed, live acceptance pending | +| Requirement 5 | AC1-AC7 | T006-T007, T010-T011 | partial-pass; exact three-row health/activity projection, deterministic Linux badges, and honest never-run/failure/missed projection passed; live acceptance pending | | Requirement 6 | AC1-AC4 | T006-T007, T011 | pending | | Requirement 7 | AC1-AC5 | T001, T005, T008-T011 | pending | @@ -71,6 +71,7 @@ closure. | CP-004 | Restart, session change, gap, and snapshot convergence tests | partial-pass | Reconnect, new session, gap, and initial-snapshot recovery passed; live integration remains | | CP-005 | Interface isolation, lock spy, and live operation independence | partial-pass | Separate event/control failure isolation passed; live operation evidence remains | | CP-006 | Captured idle serve test and live 90-second observation | partial-pass | Healthy serve is silent and one-shot output remains; live 90-second evidence pending | +| CP-007 | Fake systemd projections, grace deadlines, and run matching | partial-pass | Systemd parsing, bounded health derivation, one-shot deadline publication, and atomic-record invalidation passed; live timer deadline evidence remains T011 | ## Scope Reconciliation Before Closure @@ -108,7 +109,7 @@ closure. | T008 | complete | Token-derived bounded Windows named-pipe event contracts, four new platform tests, 232 system-control tests | No live Windows service or acceptance is claimed. | | T009 | complete | Named protected event socket asset, named systemd descriptor mapping, dual-protocol release metadata, structured activation/rollback gates, 246 system-control tests | Installed-artifact and live-host evidence remain T010-T011. | | T010 | complete | Five deterministic accessible logo badges, honest never-run/failure projection, 268-test regression, 27-file package-data validation, SHA-256 verification, and clean-install wheel/sdist smoke | No live selector, unit, socket, timer, backup, retention, or protected path changed. | -| T011 | in progress | Repository-owned evidence validator, preflight-first transaction, fail-closed wheel-filename correction, and successful commit-bound Linux Mint activation | Remaining installed acceptance checks and evidence validation are pending; general deployment workflow is routed to Spec 011. | +| T011 | in progress | Repository-owned evidence validator, preflight-first transaction, fail-closed wheel-filename correction, successful commit-bound Linux Mint activation, and immediate connecting-state startup correction | Remaining installed acceptance checks, including visible startup behavior, and evidence validation are pending; general deployment workflow is routed to Spec 011. | | T012-T013 | pending | none | Sequenced by the task dependency graph. | ## Evidence Log @@ -154,6 +155,8 @@ closure. | 2026-07-27 | Exact system-Python staging rehearsal | pass | `/usr/bin/python3` staged `timelocker-0.9.1-py3-none-any.whl` through the corrected harness into an isolated `/tmp` release and imported installed TimeLocker version `0.9.1`; no protected host path or service was changed. | | 2026-07-28 | Corrected commit-bound Linux Mint deployment | activation passed | Release `a67c83ac09ac29b94a3ed481ee536b3380db3337` was selected with `d540b453864fce9b1c96a85ad9ecf604b98b7f57` retained as previous. Identity preflights passed; deployment triggered no backup or retention. Independent reads confirmed the control service, both sockets, backup timer, and retention timer active, required units enabled, installed CLI version `0.9.1`, system run access, and tray status success. | | 2026-07-28 | General deployment workflow routing | follow-up created | Draft [Spec 011](../011-protected-system-deployment/README.md) owns the supported install, upgrade, status, rollback, staging, provenance, and evidence workflow. Its implementation waits for Spec 010 closure. | +| 2026-07-28 | Immediate connecting-state implementation | pass | The tray processes a deterministic connecting badge before starting its background subscription worker. Lazy package boundaries reduce direct source startup to approximately 0.11 seconds for launcher import and 0.56 seconds for full tray-entry import. Focused tray/asset/deployment/artifact tests passed 42 cases; broader system-control, tray-monitoring, and backup compatibility regression passed 415 tests. Scoped Ruff, compileall, patch integrity, and lazy public-export compatibility passed. No protected host mutation occurred. | +| 2026-07-28 | Connecting-badge release artifacts | pass | Fresh wheel and sdist validation found 28 package-data files; the wheel passed clean installed-artifact smoke. Wheel SHA-256 `d9fb99bbe856c7659304701ab8b12d5dd8d97fc194f5b8ce5825d33a559609c8`; sdist SHA-256 `bb5df2b2450db07335ccbb848f03e01b010ed568d6609f29cdd3b24f827ffeea`. | ## Manual Or External Verification diff --git a/scripts/deploy_t011_linux.py b/scripts/deploy_t011_linux.py index b6166f6..4895557 100755 --- a/scripts/deploy_t011_linux.py +++ b/scripts/deploy_t011_linux.py @@ -282,7 +282,7 @@ def capture_baseline(self) -> None: expected_manifest = { "schema_version": 2, "release_id": self.request.release_id, - "control_protocol_version": 1, + "control_protocol_version": 2, "event_protocol_version": 1, "entrypoint": "venv/bin/timelocker", } diff --git a/scripts/generate_tray_status_icons.py b/scripts/generate_tray_status_icons.py index b37fdb2..d7b8e79 100644 --- a/scripts/generate_tray_status_icons.py +++ b/scripts/generate_tray_status_icons.py @@ -44,6 +44,12 @@ def _draw_idle(draw: ImageDraw.ImageDraw) -> None: draw.ellipse((812, 876, 850, 914), fill="white") +def _draw_connecting(draw: ImageDraw.ImageDraw) -> None: + _draw_circle_badge(draw, fill="#1570EF") + for left in (746, 814, 882): + draw.ellipse((left, 794, left + 40, 834), fill="white") + + def _draw_running(draw: ImageDraw.ImageDraw) -> None: _draw_circle_badge(draw, fill="#1570EF") draw.ellipse( @@ -82,6 +88,7 @@ def _draw_error(draw: ImageDraw.ImageDraw) -> None: DRAWERS = { + "connecting": _draw_connecting, "idle": _draw_idle, "running": _draw_running, "success": _draw_success, diff --git a/scripts/smoke_release_artifact.py b/scripts/smoke_release_artifact.py index aad0781..db36a57 100755 --- a/scripts/smoke_release_artifact.py +++ b/scripts/smoke_release_artifact.py @@ -50,6 +50,7 @@ def smoke_system_contract(python: Path, expected_version: str) -> None: "timelocker-status-events.socket", "timelocker-retention.service", "timelocker-retention.timer", + "timelocker-icon-connecting.png", "timelocker-icon-idle.png", "timelocker-icon-running.png", "timelocker-icon-success.png", diff --git a/src/TimeLocker/__init__.py b/src/TimeLocker/__init__.py index ee3ccb9..9986126 100644 --- a/src/TimeLocker/__init__.py +++ b/src/TimeLocker/__init__.py @@ -15,53 +15,66 @@ along with this program. If not, see . """ -# Core components -from .backup_manager import BackupManager -from .backup_repository import BackupRepository -from .backup_snapshot import BackupSnapshot -from .backup_target import BackupTarget -from .restore_manager import RestoreManager -from .snapshot_manager import SnapshotManager -from .file_selections import FileSelection, PatternGroup +from __future__ import annotations -# Security components -from .security import SecurityService, CredentialManager, SecurityLogger +from importlib import import_module +from typing import Any -# Monitoring components -from .monitoring import StatusReporter, NotificationService +__version__ = "0.9.1" -# Configuration components -from .config import ConfigurationModule -from .config.configuration_manager import ConfigurationManager +_LAZY_EXPORTS = { + "BackupManager": (".backup_manager", "BackupManager"), + "BackupRepository": (".backup_repository", "BackupRepository"), + "BackupSnapshot": (".backup_snapshot", "BackupSnapshot"), + "BackupTarget": (".backup_target", "BackupTarget"), + "RestoreManager": (".restore_manager", "RestoreManager"), + "SnapshotManager": (".snapshot_manager", "SnapshotManager"), + "FileSelection": (".file_selections", "FileSelection"), + "PatternGroup": (".file_selections", "PatternGroup"), + "SecurityService": (".security", "SecurityService"), + "CredentialManager": (".security", "CredentialManager"), + "SecurityLogger": (".security", "SecurityLogger"), + "StatusReporter": (".monitoring", "StatusReporter"), + "NotificationService": (".monitoring", "NotificationService"), + "ConfigurationModule": (".config", "ConfigurationModule"), + "ConfigurationManager": ( + ".config.configuration_manager", + "ConfigurationManager", + ), + "IntegrationService": (".integration", "IntegrationService"), +} -# Integration components -from .integration import IntegrationService -__version__ = "0.9.1" +def __getattr__(name: str) -> Any: + """Load legacy package exports only when callers request them.""" + try: + module_name, attribute_name = _LAZY_EXPORTS[name] + except KeyError: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None + value = getattr(import_module(module_name, __name__), attribute_name) + globals()[name] = value + return value -__all__ = [ - # Core components - 'BackupManager', - 'BackupRepository', - 'BackupSnapshot', - 'BackupTarget', - 'RestoreManager', - 'SnapshotManager', - 'FileSelection', - 'PatternGroup', - # Security components - 'SecurityService', - 'CredentialManager', - 'SecurityLogger', +def __dir__() -> list[str]: + """Include lazy compatibility exports in interactive discovery.""" + return sorted({*globals(), *_LAZY_EXPORTS}) - # Monitoring components - 'StatusReporter', - 'NotificationService', - # Configuration components - 'ConfigurationManager', - - # Integration components - 'IntegrationService', +__all__ = [ + "BackupManager", + "BackupRepository", + "BackupSnapshot", + "BackupTarget", + "RestoreManager", + "SnapshotManager", + "FileSelection", + "PatternGroup", + "SecurityService", + "CredentialManager", + "SecurityLogger", + "StatusReporter", + "NotificationService", + "ConfigurationManager", + "IntegrationService", ] diff --git a/src/TimeLocker/monitoring/system_tray_integration.py b/src/TimeLocker/monitoring/system_tray_integration.py index d20c69a..7d119eb 100644 --- a/src/TimeLocker/monitoring/system_tray_integration.py +++ b/src/TimeLocker/monitoring/system_tray_integration.py @@ -79,6 +79,7 @@ class SystemTrayError(Exception): class TrayStatus(Enum): """System tray status indicators""" + CONNECTING = "connecting" IDLE = "idle" RUNNING = "running" SUCCESS = "success" @@ -110,6 +111,8 @@ class TrayStatusInfo: status: TrayStatus tooltip: str + health: str = "Unknown" + activity: str = "Connecting" backend_available: bool = False last_successful_backup_time: Optional[datetime] = None latest_backup_status: Optional[str] = None @@ -127,26 +130,14 @@ def _format_local_time(value: datetime | None, *, missing: str) -> str: def _status_menu_labels(status_info: TrayStatusInfo) -> tuple[str, ...]: """Return platform-neutral, non-actionable status menu labels.""" - activity = ( - f"{status_info.active_operations} active" - if status_info.active_operations - else "Idle" - ) return ( - "Backend: " - + ("Available" if status_info.backend_available else "Unavailable"), - f"Activity: {activity}", - "Last successful backup: " + f"State: {status_info.health}", + f"Activity: {status_info.activity}", + "Last Backup: " + _format_local_time( status_info.last_successful_backup_time, missing="Never", ), - f"Latest backup: {status_info.latest_backup_status or 'Unknown'}", - f"Latest retention: {status_info.latest_retention_status or 'Unknown'}", - "Next backup: " - + _format_local_time(status_info.next_backup_time, missing="Unknown"), - "Next retention: " - + _format_local_time(status_info.next_retention_time, missing="Unknown"), ) @@ -178,9 +169,10 @@ def __init__( if menu_actions is not None else {"backup_now", "retention_now", "quit"} ) - self.current_status = TrayStatus.IDLE + self.current_status = TrayStatus.CONNECTING self.status_info = TrayStatusInfo( - status=TrayStatus.IDLE, tooltip="TimeLocker - No recent activity" + status=TrayStatus.CONNECTING, + tooltip="TimeLocker - Connecting", ) # Platform-specific implementation @@ -288,20 +280,7 @@ def _format_tooltip(self, status_info: TrayStatusInfo) -> str: str: Formatted tooltip text """ lines = [f"{self.app_name} - {status_info.status.value.title()}"] - - if status_info.last_successful_backup_time: - time_str = _format_local_time( - status_info.last_successful_backup_time, - missing="Never", - ) - lines.append(f"Last successful backup: {time_str}") - - if status_info.latest_backup_status: - lines.append(f"Latest backup: {status_info.latest_backup_status}") - - if status_info.active_operations > 0: - lines.append(f"Active operations: {status_info.active_operations}") - + lines.extend(_status_menu_labels(status_info)) return "\n".join(lines) def set_on_click_callback(self, callback: Callable): @@ -402,7 +381,7 @@ def _initialize_tray(self): self._use_gtk = True self._indicator = self._indicator_module.Indicator.new( self.app_name, - _linux_tray_icon_path(TrayStatus.IDLE), + _linux_tray_icon_path(TrayStatus.CONNECTING), self._indicator_module.IndicatorCategory.APPLICATION_STATUS, ) self._indicator.set_status(self._indicator_module.IndicatorStatus.ACTIVE) @@ -427,7 +406,7 @@ def _create_gtk_menu(self): # AppIndicator tooltips are not consistently available on Linux. self._status_items = [] initial_status = TrayStatusInfo( - status=TrayStatus.IDLE, + status=TrayStatus.CONNECTING, tooltip="TimeLocker - Connecting", ) for label in _status_menu_labels(initial_status): @@ -563,7 +542,7 @@ def __init__( try: import rumps - self._app = rumps.App(app_name, "⏰") + self._app = rumps.App(app_name, "…") self._create_menu() logger.info("Using rumps for macOS system tray") except ImportError: @@ -584,7 +563,7 @@ def _create_menu(self): rumps.MenuItem(label) for label in _status_menu_labels( TrayStatusInfo( - status=TrayStatus.IDLE, + status=TrayStatus.CONNECTING, tooltip="TimeLocker - Connecting", ) ) @@ -642,6 +621,7 @@ def update_icon(self, status: TrayStatus): return icon_map = { + TrayStatus.CONNECTING: "…", TrayStatus.IDLE: "⏰", TrayStatus.RUNNING: "🔄", TrayStatus.SUCCESS: "✅", @@ -757,7 +737,7 @@ def _create_menu(self): self, "_status_info", TrayStatusInfo( - status=TrayStatus.IDLE, + status=TrayStatus.CONNECTING, tooltip="TimeLocker - Connecting", ), ) @@ -816,6 +796,7 @@ def _create_status_icon(self, status: TrayStatus): from PIL import Image, ImageDraw color_map = { + TrayStatus.CONNECTING: "deepskyblue", TrayStatus.IDLE: "gray", TrayStatus.RUNNING: "blue", TrayStatus.SUCCESS: "green", diff --git a/src/TimeLocker/system_control/__init__.py b/src/TimeLocker/system_control/__init__.py index fa99464..4ad433e 100644 --- a/src/TimeLocker/system_control/__init__.py +++ b/src/TimeLocker/system_control/__init__.py @@ -1,98 +1,9 @@ """Platform-neutral contracts for privileged TimeLocker system operations.""" -from .interfaces import ( - ControlRequestHandler, - GroupMembershipResolver, - LocalControlTransport, - PeerIdentity, - PeerIdentityProvider, - StatusEventBroker, - StatusEventClient, - StatusEventTransport, - StatusSnapshotProvider, - StatusSubscription, - SystemControlClient, -) -from .dispatcher import AuditEvent, AuditSink, LocalControlDispatcher -from .action_policy import ( - ActionClass, - ActionRoute, - UnknownPublicActionError, - classify_public_action, -) -from .client import ( - SystemControlClientError, - UnixSocketSystemControlClient, -) -from .event_client import ( - StatusEventAccessDenied, - UnixSocketStatusEventClient, -) -from .models import ( - ActionReceipt, - BackupActionRequest, - DiagnosticQuery, - DiagnosticRecord, - DiagnosticView, - RetentionActionRequest, - RetentionPolicy, - ScheduleSummary, - RunQuery, - RunRecord, - RunRecordView, - StatusEvent, - StatusRevision, - StatusSnapshot, - RunTransition, - STATUS_EVENT_PROTOCOL_VERSION, - STATUS_EVENT_SCHEMA_VERSION, - SystemPolicy, -) -from .protocol import RequestEnvelope, ResponseEnvelope, project_response -from .retention import ( - RetentionAdapter, - RetentionExecutionResult, - RetentionExecutor, - RetentionPlan, - RetentionRequestHandler, - RetentionTriggerCoordinator, - RetentionTriggerStore, -) -from .storage import ( - AtomicRecordStore, - InvalidTransitionError, - MutationConflictError, - RecordCorruptionError, - RecordNotFoundError, - RecordStoreError, - RepositoryMutationLease, - RepositoryMutationLock, - reconcile_abandoned_runs, -) -from .status_events import ( - BoundedStatusEventBroker, - BoundedStatusSubscription, - ProtectedStateChangeMonitor, - ProtectedStateWatcher, - StatusChangeCoordinator, - StatusSubscriptionLimitError, - StatusWatchSignal, -) -from .types import ( - DiagnosticCode, - DiagnosticComponent, - DiagnosticLevel, - OperationTrigger, - OperationType, - ProtocolErrorCode, - ResponseStatus, - ResultCode, - RunState, - BackendStatus, - StatusEventConnectionState, - StatusEventKind, - SystemAction, -) +from __future__ import annotations + +from importlib import import_module +from typing import Any __all__ = [ "ActionReceipt", @@ -101,10 +12,12 @@ "AuditEvent", "AuditSink", "BackendStatus", + "BackupScheduleHealth", "AtomicRecordStore", "BackupActionRequest", "BoundedStatusEventBroker", "BoundedStatusSubscription", + "FileSystemProtectedStateWatcher", "ControlRequestHandler", "DiagnosticCode", "DiagnosticComponent", @@ -175,3 +88,130 @@ "project_response", "reconcile_abandoned_runs", ] + +_MODULE_EXPORTS = { + ".action_policy": ( + "ActionClass", + "ActionRoute", + "UnknownPublicActionError", + "classify_public_action", + ), + ".client": ( + "SystemControlClientError", + "UnixSocketSystemControlClient", + ), + ".dispatcher": ( + "AuditEvent", + "AuditSink", + "LocalControlDispatcher", + ), + ".event_client": ( + "StatusEventAccessDenied", + "UnixSocketStatusEventClient", + ), + ".interfaces": ( + "ControlRequestHandler", + "GroupMembershipResolver", + "LocalControlTransport", + "PeerIdentity", + "PeerIdentityProvider", + "StatusEventBroker", + "StatusEventClient", + "StatusEventTransport", + "StatusSnapshotProvider", + "StatusSubscription", + "SystemControlClient", + ), + ".models": ( + "ActionReceipt", + "BackupActionRequest", + "DiagnosticQuery", + "DiagnosticRecord", + "DiagnosticView", + "RetentionActionRequest", + "RetentionPolicy", + "RunQuery", + "RunRecord", + "RunRecordView", + "RunTransition", + "ScheduleSummary", + "StatusEvent", + "StatusRevision", + "StatusSnapshot", + "STATUS_EVENT_PROTOCOL_VERSION", + "STATUS_EVENT_SCHEMA_VERSION", + "SystemPolicy", + ), + ".protocol": ( + "RequestEnvelope", + "ResponseEnvelope", + "project_response", + ), + ".retention": ( + "RetentionAdapter", + "RetentionExecutionResult", + "RetentionExecutor", + "RetentionPlan", + "RetentionRequestHandler", + "RetentionTriggerCoordinator", + "RetentionTriggerStore", + ), + ".status_events": ( + "BoundedStatusEventBroker", + "BoundedStatusSubscription", + "FileSystemProtectedStateWatcher", + "ProtectedStateChangeMonitor", + "ProtectedStateWatcher", + "StatusChangeCoordinator", + "StatusSubscriptionLimitError", + "StatusWatchSignal", + ), + ".storage": ( + "AtomicRecordStore", + "InvalidTransitionError", + "MutationConflictError", + "RecordCorruptionError", + "RecordNotFoundError", + "RecordStoreError", + "RepositoryMutationLease", + "RepositoryMutationLock", + "reconcile_abandoned_runs", + ), + ".types": ( + "BackendStatus", + "BackupScheduleHealth", + "DiagnosticCode", + "DiagnosticComponent", + "DiagnosticLevel", + "OperationTrigger", + "OperationType", + "ProtocolErrorCode", + "ResponseStatus", + "ResultCode", + "RunState", + "StatusEventConnectionState", + "StatusEventKind", + "SystemAction", + ), +} +_LAZY_EXPORTS = { + name: module_name + for module_name, names in _MODULE_EXPORTS.items() + for name in names +} + + +def __getattr__(name: str) -> Any: + """Load public system-control contracts only when requested.""" + try: + module_name = _LAZY_EXPORTS[name] + except KeyError: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None + value = getattr(import_module(module_name, __name__), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + """Include lazy public contracts in interactive discovery.""" + return sorted({*globals(), *_LAZY_EXPORTS}) diff --git a/src/TimeLocker/system_control/assets/system-control-policy.json b/src/TimeLocker/system_control/assets/system-control-policy.json index d728e54..851b932 100644 --- a/src/TimeLocker/system_control/assets/system-control-policy.json +++ b/src/TimeLocker/system_control/assets/system-control-policy.json @@ -1,7 +1,7 @@ { "operator_group": "timelocker-operators", "transport_identifier": "/run/timelocker/control.sock", - "protocol_version": 1, + "protocol_version": 2, "max_request_bytes": 65536, "max_response_records": 100, "retention": { diff --git a/src/TimeLocker/system_control/assets/timelocker-icon-connecting.png b/src/TimeLocker/system_control/assets/timelocker-icon-connecting.png new file mode 100644 index 0000000000000000000000000000000000000000..ceb2970640a749e4a3add07210c5d9a22acaed2b GIT binary patch literal 33561 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 literal 0 HcmV?d00001 diff --git a/src/TimeLocker/system_control/backend_entry.py b/src/TimeLocker/system_control/backend_entry.py index 2456ec9..749e100 100644 --- a/src/TimeLocker/system_control/backend_entry.py +++ b/src/TimeLocker/system_control/backend_entry.py @@ -66,9 +66,21 @@ RepositoryMutationLock, reconcile_abandoned_runs, ) -from .status_events import BoundedStatusEventBroker, StatusChangeCoordinator +from .schedule_health import ( + BackupScheduleObservation, + ScheduleDeadlineMonitor, + SystemdScheduleSummaryProvider, + derive_backup_schedule_health, +) +from .status_events import ( + BoundedStatusEventBroker, + FileSystemProtectedStateWatcher, + ProtectedStateChangeMonitor, + StatusChangeCoordinator, +) from .types import ( BackendStatus, + BackupScheduleHealth, DiagnosticCode, DiagnosticComponent, DiagnosticLevel, @@ -109,6 +121,13 @@ def get_schedule_summary(self) -> ScheduleSummary: """Return a safe schedule projection.""" +class BackupScheduleObserver(Protocol): + """Return current protected backup-schedule facts.""" + + def observe_backup_schedule(self) -> BackupScheduleObservation: + """Return the current bounded schedule observation.""" + + class FailClosedBackupMutationAdapter: """Default backup adapter that intentionally exposes no mutation path.""" @@ -305,12 +324,14 @@ class LinuxBackendService: status_event_broker: BoundedStatusEventBroker status_change_coordinator: StatusChangeCoordinator membership_resolver: GroupMembershipResolver + state_change_monitors: tuple[object, ...] = () reconciled_run_ids: tuple[UUID, ...] = () def serve_forever(self, *, install_signal_handlers: bool = True) -> None: if install_signal_handlers: self.install_signal_handlers() status_thread = None + monitor_threads: list[Thread] = [] if self.status_event_transport is not None: status_thread = Thread( target=self._serve_status_events, @@ -318,6 +339,15 @@ def serve_forever(self, *, install_signal_handlers: bool = True) -> None: daemon=True, ) status_thread.start() + for index, monitor in enumerate(self.state_change_monitors): + monitor_thread = Thread( + target=monitor.run, + args=(self.stop_event,), + name=f"timelocker-state-monitor-{index}", + daemon=True, + ) + monitor_thread.start() + monitor_threads.append(monitor_thread) try: self.transport.serve(self.dispatcher) except OSError: @@ -327,6 +357,8 @@ def serve_forever(self, *, install_signal_handlers: bool = True) -> None: self.stop() if status_thread is not None: status_thread.join(timeout=1.0) + for monitor_thread in monitor_threads: + monitor_thread.join(timeout=1.0) def _serve_status_events(self) -> None: assert self.status_event_transport is not None @@ -382,6 +414,7 @@ def build_linux_backend( retention_plan_provider: RetentionPlanProvider | None = None, production_target_path: Path | None = None, schedule_summary_provider: ScheduleSummaryProvider | None = None, + backup_schedule_observer: BackupScheduleObserver | None = None, max_diagnostics: int = 1_000, stop_event: Event | None = None, clock: Callable[[], datetime] | None = None, @@ -427,6 +460,11 @@ def build_linux_backend( schedule_summary_provider = ( schedule_summary_provider or StaticScheduleSummaryProvider() ) + if backup_schedule_observer is None and hasattr( + schedule_summary_provider, + "observe_backup_schedule", + ): + backup_schedule_observer = schedule_summary_provider # type: ignore[assignment] membership_resolver = membership_resolver or LinuxNssGroupMembershipResolver() if ( retention_adapter is None @@ -486,12 +524,27 @@ def build_linux_backend( retention_adapter=retention_adapter, retention_plan_provider=retention_plan_provider, schedule_summary_provider=schedule_summary_provider, + backup_schedule_observer=backup_schedule_observer, status_change_coordinator=status_change_coordinator, trigger_root=paths.trigger_root, clock=now, ), audit_sink=audit_sink, ) + state_change_monitors: list[object] = [ + ProtectedStateChangeMonitor( + FileSystemProtectedStateWatcher((paths.record_root / "runs",)), + status_change_coordinator, + ) + ] + if isinstance(backup_schedule_observer, SystemdScheduleSummaryProvider): + state_change_monitors.append( + ScheduleDeadlineMonitor( + backup_schedule_observer, + status_change_coordinator, + clock=now, + ) + ) return LinuxBackendService( policy=policy, store=store, @@ -504,6 +557,7 @@ def build_linux_backend( status_event_broker=status_event_broker, status_change_coordinator=status_change_coordinator, membership_resolver=membership_resolver, + state_change_monitors=tuple(state_change_monitors), reconciled_run_ids=tuple(record.run_id for record in reconciled), ) @@ -738,6 +792,7 @@ def main(argv: list[str] | None = None) -> None: "systemd" if status_descriptor is not None else "disabled" ), production_target_path=arguments.production_target, + schedule_summary_provider=SystemdScheduleSummaryProvider(), ) except (OSError, PermissionError, RuntimeError, TypeError, ValueError): parser.exit(78, "TimeLocker system backend failed to initialize safely.\n") @@ -807,6 +862,7 @@ def _build_handlers( status_change_coordinator: StatusChangeCoordinator | None = None, trigger_root: Path, clock: Callable[[], datetime], + backup_schedule_observer: BackupScheduleObserver | None = None, ) -> Mapping[SystemAction, Callable[[object], object]]: from .protocol import RequestEnvelope @@ -883,6 +939,15 @@ def build_snapshot(revision: StatusRevision) -> StatusSnapshot: "schedule_summary_provider returned an invalid summary" ) runs = store.list_status_runs() + schedule_health = ( + derive_backup_schedule_health( + backup_schedule_observer.observe_backup_schedule(), + runs, + now=clock(), + ) + if backup_schedule_observer is not None + else BackupScheduleHealth.HEALTHY + ) return StatusSnapshot.from_run_history( revision=revision, backend_status=BackendStatus.AVAILABLE, @@ -891,6 +956,7 @@ def build_snapshot(revision: StatusRevision) -> StatusSnapshot: for record in runs ), runs=runs, + backup_schedule_health=schedule_health, next_backup_at=summary.next_backup_at, next_retention_at=summary.next_retention_at, ) diff --git a/src/TimeLocker/system_control/deployment.py b/src/TimeLocker/system_control/deployment.py index 42ec045..6019894 100644 --- a/src/TimeLocker/system_control/deployment.py +++ b/src/TimeLocker/system_control/deployment.py @@ -340,7 +340,14 @@ def linux_asset_targets( icon_root / f"timelocker-{status}.png", 0o644, ) - for status in ("idle", "running", "success", "warning", "error") + for status in ( + "connecting", + "idle", + "running", + "success", + "warning", + "error", + ) ), ) diff --git a/src/TimeLocker/system_control/dispatcher.py b/src/TimeLocker/system_control/dispatcher.py index 61000c2..834e08b 100644 --- a/src/TimeLocker/system_control/dispatcher.py +++ b/src/TimeLocker/system_control/dispatcher.py @@ -10,7 +10,7 @@ from uuid import UUID from .interfaces import GroupMembershipResolver, PeerIdentity -from .models import SystemPolicy +from .models import PROTOCOL_VERSION, SystemPolicy from .protocol import RequestEnvelope, ResponseEnvelope from .types import ProtocolErrorCode, ResponseStatus, SystemAction @@ -208,7 +208,10 @@ def _extract_request_id(request: object) -> UUID: def _parse_error_code(request: bytes) -> ProtocolErrorCode: try: value: Any = json.loads(request.decode("utf-8")) - if isinstance(value, Mapping) and value.get("protocol_version") != 1: + if ( + isinstance(value, Mapping) + and value.get("protocol_version") != PROTOCOL_VERSION + ): return ProtocolErrorCode.CONTRACT_VERSION_UNSUPPORTED except (UnicodeDecodeError, json.JSONDecodeError): pass diff --git a/src/TimeLocker/system_control/models.py b/src/TimeLocker/system_control/models.py index c12de6a..fd4f9e4 100644 --- a/src/TimeLocker/system_control/models.py +++ b/src/TimeLocker/system_control/models.py @@ -8,6 +8,7 @@ from .types import ( BackendStatus, + BackupScheduleHealth, DiagnosticCode, DiagnosticComponent, DiagnosticLevel, @@ -36,7 +37,7 @@ ) -PROTOCOL_VERSION = 1 +PROTOCOL_VERSION = 2 STATUS_EVENT_SCHEMA_VERSION = 1 STATUS_EVENT_PROTOCOL_VERSION = 1 DEFAULT_MAX_REQUEST_BYTES = 65_536 @@ -168,7 +169,7 @@ def __post_init__(self) -> None: maximum=255, ), ) - if self.protocol_version != PROTOCOL_VERSION: + if self.protocol_version not in {1, PROTOCOL_VERSION}: raise ValueError("protocol_version is unsupported") object.__setattr__( self, @@ -858,6 +859,7 @@ class StatusSnapshot: revision: StatusRevision backend_status: BackendStatus active_operations: int + backup_schedule_health: BackupScheduleHealth = BackupScheduleHealth.HEALTHY latest_backup: RunRecordView | None = None last_successful_backup_completed_at: datetime | None = None latest_retention: RunRecordView | None = None @@ -891,6 +893,15 @@ def __post_init__(self) -> None: field="backend_status", ), ) + object.__setattr__( + self, + "backup_schedule_health", + require_enum( + self.backup_schedule_health, + BackupScheduleHealth, + field="backup_schedule_health", + ), + ) object.__setattr__( self, "active_operations", @@ -938,6 +949,7 @@ def from_mapping(cls, value: object) -> "StatusSnapshot": { "revision", "backend_status", + "backup_schedule_health", "active_operations", "latest_backup", "last_successful_backup_completed_at", @@ -950,6 +962,7 @@ def from_mapping(cls, value: object) -> "StatusSnapshot": return cls( revision=snapshot["revision"], backend_status=snapshot["backend_status"], + backup_schedule_health=snapshot["backup_schedule_health"], active_operations=snapshot["active_operations"], latest_backup=snapshot["latest_backup"], last_successful_backup_completed_at=require_optional_wire_utc_datetime( @@ -973,6 +986,7 @@ def from_run_history( *, revision: StatusRevision, backend_status: BackendStatus, + backup_schedule_health: BackupScheduleHealth = BackupScheduleHealth.HEALTHY, active_operations: int, runs: Iterable[RunRecord | RunRecordView], next_backup_at: datetime | None = None, @@ -994,6 +1008,7 @@ def from_run_history( return cls( revision=revision, backend_status=backend_status, + backup_schedule_health=backup_schedule_health, active_operations=active_operations, latest_backup=_latest_run_view(backup_runs), last_successful_backup_completed_at=( @@ -1011,6 +1026,7 @@ def to_wire(self) -> dict[str, Any]: return { "revision": self.revision.to_wire(), "backend_status": self.backend_status.value, + "backup_schedule_health": self.backup_schedule_health.value, "active_operations": self.active_operations, "latest_backup": ( self.latest_backup.to_wire() if self.latest_backup is not None else None diff --git a/src/TimeLocker/system_control/protocol.py b/src/TimeLocker/system_control/protocol.py index 550a4d1..f87c359 100644 --- a/src/TimeLocker/system_control/protocol.py +++ b/src/TimeLocker/system_control/protocol.py @@ -88,6 +88,7 @@ { "revision", "backend_status", + "backup_schedule_health", "active_operations", "latest_backup", "last_successful_backup_completed_at", diff --git a/src/TimeLocker/system_control/release_launcher.py b/src/TimeLocker/system_control/release_launcher.py index ee8b71b..0c8bbcb 100644 --- a/src/TimeLocker/system_control/release_launcher.py +++ b/src/TimeLocker/system_control/release_launcher.py @@ -156,7 +156,7 @@ def _from_legacy_mapping(cls, value: Mapping[str, object]) -> "ReleaseManifest": control_protocol_version=require_int( mapping["protocol_version"], field="protocol_version", - minimum=PROTOCOL_VERSION, + minimum=1, maximum=PROTOCOL_VERSION, ), event_protocol_version=None, diff --git a/src/TimeLocker/system_control/schedule_health.py b/src/TimeLocker/system_control/schedule_health.py new file mode 100644 index 0000000..86ccea2 --- /dev/null +++ b/src/TimeLocker/system_control/schedule_health.py @@ -0,0 +1,249 @@ +"""Linux systemd schedule observation and event-driven deadline checks.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +import json +import subprocess +from threading import Event +from typing import Protocol + +from .models import RunRecord, RunRecordView, ScheduleSummary +from .status_events import StatusChangeCoordinator +from .types import BackupScheduleHealth, OperationType, RunState + + +DEFAULT_BACKUP_TIMER = "timelocker-npbackup-migration.timer" +DEFAULT_BACKUP_SERVICE = "timelocker-npbackup-migration.service" +DEFAULT_MISSED_BACKUP_GRACE = timedelta(minutes=15) + + +class CommandRunner(Protocol): + """Run one bounded local command without a shell.""" + + def __call__(self, command: tuple[str, ...]) -> subprocess.CompletedProcess[str]: + """Return captured command output.""" + + +def _run_command(command: tuple[str, ...]) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 - fixed allowlisted systemctl arguments + command, + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + +@dataclass(frozen=True, slots=True) +class BackupScheduleObservation: + """Raw, safe scheduler facts used to derive backup health.""" + + available: bool + enabled: bool + active: bool + service_active: bool + last_trigger_at: datetime | None + next_trigger_at: datetime | None + + +class SystemdScheduleSummaryProvider: + """Read the installed backup timer without exposing systemd internals.""" + + def __init__( + self, + *, + timer_unit: str = DEFAULT_BACKUP_TIMER, + service_unit: str = DEFAULT_BACKUP_SERVICE, + runner: CommandRunner = _run_command, + ) -> None: + self._timer_unit = timer_unit + self._service_unit = service_unit + self._runner = runner + + def get_schedule_summary(self) -> ScheduleSummary: + observation = self.observe_backup_schedule() + return ScheduleSummary( + next_backup_at=observation.next_trigger_at, + next_retention_at=None, + ) + + def observe_backup_schedule(self) -> BackupScheduleObservation: + try: + properties = self._runner( + ( + "systemctl", + "show", + self._timer_unit, + "--property=LoadState,ActiveState,UnitFileState", + "--no-pager", + ) + ) + timers = self._runner( + ( + "systemctl", + "list-timers", + self._timer_unit, + "--all", + "--output=json", + "--no-pager", + ) + ) + service = self._runner( + ( + "systemctl", + "show", + self._service_unit, + "--property=ActiveState", + "--no-pager", + ) + ) + except (OSError, subprocess.SubprocessError): + return _unavailable_observation() + if properties.returncode != 0 or timers.returncode != 0: + return _unavailable_observation() + + values = _parse_properties(properties.stdout) + if values.get("LoadState") != "loaded": + return _unavailable_observation() + timer_row = _parse_timer_row(timers.stdout, self._timer_unit) + service_values = ( + _parse_properties(service.stdout) if service.returncode == 0 else {} + ) + return BackupScheduleObservation( + available=True, + enabled=values.get("UnitFileState") == "enabled", + active=values.get("ActiveState") == "active", + service_active=service_values.get("ActiveState") in {"active", "activating"}, + last_trigger_at=_timestamp_from_microseconds(timer_row.get("last")), + next_trigger_at=_timestamp_from_microseconds(timer_row.get("next")), + ) + + +def derive_backup_schedule_health( + observation: BackupScheduleObservation, + runs: Iterable[RunRecord | RunRecordView], + *, + now: datetime, + grace: timedelta = DEFAULT_MISSED_BACKUP_GRACE, +) -> BackupScheduleHealth: + """Reconcile timer facts and run records into one user-facing health state.""" + if now.tzinfo is None or now.utcoffset() is None: + raise ValueError("now must be timezone-aware") + if grace <= timedelta(0): + raise ValueError("grace must be positive") + if not observation.available: + return BackupScheduleHealth.UNAVAILABLE + if not observation.enabled or not observation.active: + return BackupScheduleHealth.DISABLED + if observation.service_active: + return BackupScheduleHealth.HEALTHY + last_trigger = observation.last_trigger_at + if last_trigger is None or now <= last_trigger + grace: + return BackupScheduleHealth.HEALTHY + earliest_match = last_trigger - timedelta(minutes=5) + matching_run = any( + run.operation is OperationType.BACKUP + and run.started_at >= earliest_match + and run.started_at <= last_trigger + grace + and run.state + in { + RunState.QUEUED, + RunState.RUNNING, + RunState.SUCCEEDED, + RunState.FAILED, + RunState.INTERRUPTED, + } + for run in runs + ) + return ( + BackupScheduleHealth.HEALTHY + if matching_run + else BackupScheduleHealth.MISSED + ) + + +class ScheduleDeadlineMonitor: + """Publish one invalidation after each known backup deadline plus grace.""" + + def __init__( + self, + provider: SystemdScheduleSummaryProvider, + coordinator: StatusChangeCoordinator, + *, + clock: Callable[[], datetime], + grace: timedelta = DEFAULT_MISSED_BACKUP_GRACE, + ) -> None: + self._provider = provider + self._coordinator = coordinator + self._clock = clock + self._grace = grace + + def run(self, stop_event: Event) -> None: + while not stop_event.is_set(): + observation = self._provider.observe_backup_schedule() + next_trigger = observation.next_trigger_at + if ( + not observation.available + or not observation.enabled + or not observation.active + or next_trigger is None + ): + stop_event.wait() + return + delay = max( + 0.0, + (next_trigger + self._grace - self._clock()).total_seconds(), + ) + if stop_event.wait(delay): + return + self._coordinator.schedule_changed() + + +def _parse_properties(output: str) -> dict[str, str]: + return { + key: value + for line in output.splitlines() + if "=" in line + for key, value in (line.split("=", 1),) + } + + +def _parse_timer_row(output: str, unit: str) -> dict[str, object]: + try: + value = json.loads(output) + except (json.JSONDecodeError, TypeError): + return {} + if not isinstance(value, list): + return {} + return next( + ( + row + for row in value + if isinstance(row, dict) and row.get("unit") == unit + ), + {}, + ) + + +def _timestamp_from_microseconds(value: object) -> datetime | None: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + return None + return datetime.fromtimestamp(value / 1_000_000, tz=UTC) + + +def _unavailable_observation() -> BackupScheduleObservation: + return BackupScheduleObservation(False, False, False, False, None, None) + + +__all__ = [ + "BackupScheduleObservation", + "DEFAULT_BACKUP_SERVICE", + "DEFAULT_BACKUP_TIMER", + "DEFAULT_MISSED_BACKUP_GRACE", + "ScheduleDeadlineMonitor", + "SystemdScheduleSummaryProvider", + "derive_backup_schedule_health", +] diff --git a/src/TimeLocker/system_control/status_events.py b/src/TimeLocker/system_control/status_events.py index 9a13ce3..82002fa 100644 --- a/src/TimeLocker/system_control/status_events.py +++ b/src/TimeLocker/system_control/status_events.py @@ -5,6 +5,8 @@ from collections import deque from collections.abc import Callable, Iterator from enum import StrEnum +from pathlib import Path +from queue import Empty, Full, Queue from threading import Condition, Event, RLock from typing import Protocol, TypeVar from uuid import UUID, uuid4 @@ -35,6 +37,52 @@ def events(self, stop_event: Event) -> Iterator[StatusWatchSignal]: """Yield bounded change signals until shutdown.""" +class FileSystemProtectedStateWatcher: + """Watch protected JSON records using native filesystem notifications.""" + + def __init__(self, roots: tuple[Path, ...]) -> None: + if not roots or any(not isinstance(root, Path) for root in roots): + raise TypeError("roots must contain at least one Path") + self._roots = roots + + def events(self, stop_event: Event) -> Iterator[StatusWatchSignal]: + from watchdog.events import FileSystemEvent, FileSystemEventHandler + from watchdog.observers import Observer + + signals: Queue[StatusWatchSignal] = Queue(maxsize=1) + + class Handler(FileSystemEventHandler): + def on_any_event(self, event: FileSystemEvent) -> None: + if event.is_directory: + return + paths = ( + str(event.src_path), + str(getattr(event, "dest_path", "")), + ) + if not any(path.endswith(".json") for path in paths): + return + try: + signals.put_nowait(StatusWatchSignal.CHANGED) + except Full: + return + + observer = Observer() + handler = Handler() + for root in self._roots: + root.mkdir(parents=True, exist_ok=True) + observer.schedule(handler, str(root), recursive=True) + observer.start() + try: + while not stop_event.is_set(): + try: + yield signals.get(timeout=0.25) + except Empty: + continue + finally: + observer.stop() + observer.join(timeout=1.0) + + class BoundedStatusSubscription: """One subscriber retaining at most the newest pending event.""" diff --git a/src/TimeLocker/system_control/tray_client.py b/src/TimeLocker/system_control/tray_client.py index 9a1d0f7..aa51145 100644 --- a/src/TimeLocker/system_control/tray_client.py +++ b/src/TimeLocker/system_control/tray_client.py @@ -13,6 +13,7 @@ from .models import BackupActionRequest, RetentionActionRequest, StatusSnapshot from .types import ( BackendStatus, + BackupScheduleHealth, ProtocolErrorCode, ResponseStatus, RunState, @@ -34,6 +35,8 @@ class TrayDisplayState: status: str tooltip: str + health: str + activity: str active_operations: int backend_available: bool last_successful_backup_completed_at: datetime | None @@ -194,55 +197,66 @@ def project_snapshot(snapshot: StatusSnapshot) -> TrayDisplayState: """Project one coherent backend snapshot into safe desktop fields.""" if not isinstance(snapshot, StatusSnapshot): raise TypeError("snapshot must be a StatusSnapshot") - latest_runs = tuple( - run - for run in (snapshot.latest_backup, snapshot.latest_retention) - if run is not None + backup_active = ( + snapshot.latest_backup is not None + and snapshot.latest_backup.state in {RunState.QUEUED, RunState.RUNNING} + ) + retention_active = ( + snapshot.latest_retention is not None + and snapshot.latest_retention.state + in {RunState.QUEUED, RunState.RUNNING} ) if snapshot.backend_status is BackendStatus.UNAVAILABLE: status = "warning" - elif snapshot.active_operations: - status = "running" - elif any( - run.state in {RunState.FAILED, RunState.INTERRUPTED} - for run in latest_runs - ): - status = "error" - elif snapshot.latest_backup is None: + health = "Backend unavailable" + elif snapshot.backup_schedule_health is BackupScheduleHealth.DISABLED: status = "warning" - elif any(run.state is RunState.SKIPPED for run in latest_runs): + health = "Schedule disabled" + elif snapshot.backup_schedule_health is BackupScheduleHealth.MISSED: + status = "error" + health = "Backup missed" + elif snapshot.backup_schedule_health is BackupScheduleHealth.UNAVAILABLE: status = "warning" - elif any(run.state is RunState.SUCCEEDED for run in latest_runs): + health = "Schedule unavailable" + elif ( + snapshot.latest_backup is not None + and snapshot.latest_backup.state + in {RunState.FAILED, RunState.INTERRUPTED} + ): + status = "error" + health = "Backup failed" + else: status = "success" + health = "Healthy" + + if backup_active and retention_active: + activity = "Backup and retention running" + elif backup_active: + activity = "Backup running" + elif retention_active: + activity = "Retention running" + elif snapshot.active_operations: + activity = "Operation running" else: - status = "idle" + activity = "Idle" + + if activity != "Idle": + status = "running" + elif health == "Healthy" and snapshot.latest_backup is None: + status = "warning" tooltip_lines = [ "TimeLocker", - f"Backend: {snapshot.backend_status.value.title()}", - f"Active operations: {snapshot.active_operations}", - "Last successful backup: " + f"State: {health}", + f"Activity: {activity}", + "Last Backup: " + _format_local_time(snapshot.last_successful_backup_completed_at), ] - if snapshot.latest_backup is not None: - tooltip_lines.append( - f"Latest backup: {snapshot.latest_backup.safe_summary}" - ) - if snapshot.latest_retention is not None: - tooltip_lines.append( - f"Latest retention: {snapshot.latest_retention.safe_summary}" - ) - if snapshot.next_backup_at is not None: - tooltip_lines.append( - f"Next backup: {_format_local_time(snapshot.next_backup_at)}" - ) - if snapshot.next_retention_at is not None: - tooltip_lines.append( - f"Next retention: {_format_local_time(snapshot.next_retention_at)}" - ) return TrayDisplayState( status=status, tooltip="\n".join(tooltip_lines), + health=health, + activity=activity, active_operations=snapshot.active_operations, backend_available=snapshot.backend_status is BackendStatus.AVAILABLE, last_successful_backup_completed_at=( @@ -333,6 +347,10 @@ def unavailable_state( return TrayDisplayState( status="warning", tooltip=tooltip, + health=( + "Access denied" if backend_available else "Backend unavailable" + ), + activity="Idle" if backend_available else "Connecting", active_operations=0, backend_available=backend_available, last_successful_backup_completed_at=None, diff --git a/src/TimeLocker/system_control/tray_entry.py b/src/TimeLocker/system_control/tray_entry.py index cf08450..6fe979b 100644 --- a/src/TimeLocker/system_control/tray_entry.py +++ b/src/TimeLocker/system_control/tray_entry.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import os from queue import Empty, Full, Queue import signal @@ -35,6 +36,7 @@ DEFAULT_REFRESH_SECONDS = 15 TRAY_STATUS_ACTIONS = {"status", "backup_now", "retention_now", "quit"} _runtime_directory = os.environ.get("XDG_RUNTIME_DIR") +logger = logging.getLogger(__name__) LOCK_PATH = ( Path(_runtime_directory) / "timelocker" / "tray.lock" if _runtime_directory and Path(_runtime_directory).is_absolute() @@ -43,6 +45,7 @@ _STATUS_MAP = { + "connecting": TrayStatus.CONNECTING, "running": TrayStatus.RUNNING, "error": TrayStatus.ERROR, "warning": TrayStatus.WARNING, @@ -150,6 +153,8 @@ def _apply_state( TrayStatusInfo( status=_status_to_tray(state.status), tooltip=state.tooltip, + health=state.health, + activity=state.activity, backend_available=state.backend_available, last_successful_backup_time=( state.last_successful_backup_completed_at @@ -228,6 +233,7 @@ def _offer_latest( def main() -> None: + startup_started = time.monotonic() arguments = _parse_args() if ( arguments.action == "retention_now" @@ -261,6 +267,12 @@ def main() -> None: ) except SystemTrayError: tray = None + if tray is not None and tray.is_available(): + tray.process_events() + logger.debug( + "Tray icon ready before status subscription (startup_ms=%.1f)", + (time.monotonic() - startup_started) * 1_000, + ) stop_requested = False subscription_stop = Event() @@ -317,6 +329,10 @@ def _subscribe() -> None: daemon=True, ) subscription_thread.start() + logger.debug( + "Tray status subscription worker started (startup_ms=%.1f)", + (time.monotonic() - startup_started) * 1_000, + ) while not stop_requested: if tray is not None: diff --git a/src/TimeLocker/system_control/types.py b/src/TimeLocker/system_control/types.py index fb420aa..b167bd7 100644 --- a/src/TimeLocker/system_control/types.py +++ b/src/TimeLocker/system_control/types.py @@ -35,6 +35,15 @@ class BackendStatus(StrEnum): UNAVAILABLE = "unavailable" +class BackupScheduleHealth(StrEnum): + """Health of the protected system backup schedule.""" + + HEALTHY = "healthy" + MISSED = "missed" + DISABLED = "disabled" + UNAVAILABLE = "unavailable" + + class StatusEventKind(StrEnum): """Allowlisted status event kinds for the event-driven tray contract.""" diff --git a/tests/TimeLocker/monitoring/test_system_tray_integration.py b/tests/TimeLocker/monitoring/test_system_tray_integration.py index 2350c19..056b276 100644 --- a/tests/TimeLocker/monitoring/test_system_tray_integration.py +++ b/tests/TimeLocker/monitoring/test_system_tray_integration.py @@ -68,7 +68,7 @@ def test_initialization(self, monkeypatch): tray = SystemTrayIntegration(app_name="TestApp") assert tray.app_name == "TestApp" - assert tray.current_status == TrayStatus.IDLE + assert tray.current_status == TrayStatus.CONNECTING assert tray.is_available() is True linux_tray.assert_called_once_with( "TestApp", @@ -101,6 +101,7 @@ def test_headless_linux_skips_native_tray_initialization(self, monkeypatch): @pytest.mark.unit def test_tray_status_enum(self): """Test TrayStatus enum values""" + assert TrayStatus.CONNECTING.value == "connecting" assert TrayStatus.IDLE.value == "idle" assert TrayStatus.RUNNING.value == "running" assert TrayStatus.SUCCESS.value == "success" @@ -166,7 +167,7 @@ def test_uses_packaged_status_icons_for_initial_and_updated_status(self): indicator_module.Indicator.new.assert_called_once_with( "TimeLocker", - str(PACKAGED_TRAY_STATUS_ICON_PATHS[TrayStatus.IDLE]), + str(PACKAGED_TRAY_STATUS_ICON_PATHS[TrayStatus.CONNECTING]), indicator_module.IndicatorCategory.APPLICATION_STATUS, ) indicator.set_icon.assert_called_once_with( @@ -190,7 +191,7 @@ def test_status_icon_falls_back_to_base_logo(self): def test_linux_menu_shows_last_backup_in_local_time(self): gtk = Mock() indicator_module = Mock() - status_items = [Mock() for _ in range(7)] + status_items = [Mock() for _ in range(3)] backup_item = Mock() quit_item = Mock() gtk.MenuItem.side_effect = [ @@ -212,6 +213,8 @@ def test_linux_menu_shows_last_backup_in_local_time(self): TrayStatusInfo( status=TrayStatus.SUCCESS, tooltip="TimeLocker", + health="Healthy", + activity="Idle", backend_available=True, last_successful_backup_time=backup_time, latest_backup_status="Backup completed successfully.", @@ -226,7 +229,7 @@ def test_linux_menu_shows_last_backup_in_local_time(self): status_item.set_sensitive.assert_called_once_with(False) expected_time = backup_time.astimezone().strftime("%Y-%m-%d %H:%M %Z") status_items[2].set_label.assert_called_once_with( - f"Last successful backup: {expected_time}".rstrip() + f"Last Backup: {expected_time}".rstrip() ) @pytest.mark.monitoring diff --git a/tests/TimeLocker/project/test_release_artifacts.py b/tests/TimeLocker/project/test_release_artifacts.py index d5a8823..4d03aa5 100644 --- a/tests/TimeLocker/project/test_release_artifacts.py +++ b/tests/TimeLocker/project/test_release_artifacts.py @@ -84,6 +84,7 @@ def test_artifact_smoke_covers_system_entrypoints_protocols_and_assets(): "STATUS_EVENT_PROTOCOL_VERSION", "timelocker-status-events.socket", "timelocker-retention.timer", + "timelocker-icon-connecting.png", "timelocker-icon-idle.png", "timelocker-icon-error.png", ): diff --git a/tests/TimeLocker/project/test_t011_linux_deployment.py b/tests/TimeLocker/project/test_t011_linux_deployment.py index 1f9eca0..1ff75ed 100644 --- a/tests/TimeLocker/project/test_t011_linux_deployment.py +++ b/tests/TimeLocker/project/test_t011_linux_deployment.py @@ -167,7 +167,7 @@ def _request(harness: ModuleType, root: Path): "schema_version": 2, "release_id": RELEASE_B, "package_version": "0.9.1", - "control_protocol_version": 1, + "control_protocol_version": 2, "event_protocol_version": 1, "entrypoint": "venv/bin/timelocker", } diff --git a/tests/TimeLocker/project/test_tray_icon_assets.py b/tests/TimeLocker/project/test_tray_icon_assets.py index 3d005f5..14db331 100644 --- a/tests/TimeLocker/project/test_tray_icon_assets.py +++ b/tests/TimeLocker/project/test_tray_icon_assets.py @@ -16,7 +16,7 @@ PROJECT_ROOT / "src" / "TimeLocker" / "system_control" / "assets" ) BASE_ICON = ASSET_ROOT / "timelocker-icon.png" -STATUSES = ("idle", "running", "success", "warning", "error") +STATUSES = ("connecting", "idle", "running", "success", "warning", "error") @mark.unit diff --git a/tests/TimeLocker/system_control/test_deployment.py b/tests/TimeLocker/system_control/test_deployment.py index 76c7be2..821d018 100644 --- a/tests/TimeLocker/system_control/test_deployment.py +++ b/tests/TimeLocker/system_control/test_deployment.py @@ -174,6 +174,7 @@ def test_linux_asset_set_covers_launchers_backend_tray_and_schedules( "timelocker-retention.timer", "timelocker-tray.desktop", "timelocker-icon.png", + "timelocker-icon-connecting.png", "timelocker-icon-idle.png", "timelocker-icon-running.png", "timelocker-icon-success.png", diff --git a/tests/TimeLocker/system_control/test_dispatcher.py b/tests/TimeLocker/system_control/test_dispatcher.py index 0a831ec..e0ed67d 100644 --- a/tests/TimeLocker/system_control/test_dispatcher.py +++ b/tests/TimeLocker/system_control/test_dispatcher.py @@ -43,7 +43,7 @@ def request( action: str = "health", parameters: dict[str, object] | None = None, *, - version: int = 1, + version: int = 2, ) -> bytes: return json.dumps( { @@ -151,7 +151,7 @@ def test_denial_does_not_disclose_handler_or_protected_metadata(self) -> None: ("payload", "error_code"), [ (b"{", "invalid_request"), - (request(version=2), "contract_version_unsupported"), + (request(version=3), "contract_version_unsupported"), ( request( "backup.request", diff --git a/tests/TimeLocker/system_control/test_models.py b/tests/TimeLocker/system_control/test_models.py index fac7400..ba7536e 100644 --- a/tests/TimeLocker/system_control/test_models.py +++ b/tests/TimeLocker/system_control/test_models.py @@ -254,7 +254,12 @@ def test_unknown_grouping_field_is_rejected(self) -> None: def test_system_policy_rejects_unsupported_protocol_version(self) -> None: with pytest.raises(ValueError, match="unsupported"): - SystemPolicy(protocol_version=2) + SystemPolicy(protocol_version=3) + + def test_system_policy_accepts_legacy_protocol_declaration_for_upgrade( + self, + ) -> None: + assert SystemPolicy(protocol_version=1).protocol_version == 1 @pytest.mark.unit diff --git a/tests/TimeLocker/system_control/test_protocol.py b/tests/TimeLocker/system_control/test_protocol.py index 0f368db..5567aad 100644 --- a/tests/TimeLocker/system_control/test_protocol.py +++ b/tests/TimeLocker/system_control/test_protocol.py @@ -24,7 +24,7 @@ def request_payload( ) -> dict[str, object]: """Build one otherwise-valid protocol request.""" return { - "protocol_version": 1, + "protocol_version": 2, "request_id": str(uuid4()), "action": action, "parameters": parameters or {}, @@ -148,7 +148,7 @@ def test_retention_request_requires_exact_policy_fingerprint(self) -> None: ) ) - @pytest.mark.parametrize("version", [0, 2, True, "1"]) + @pytest.mark.parametrize("version", [0, 3, True, "2"]) def test_unsupported_or_mistyped_protocol_version_is_rejected( self, version: object, @@ -242,6 +242,7 @@ def test_status_snapshot_projection_drops_non_allowlisted_fields(self) -> None: assert set(projected) == { "revision", "backend_status", + "backup_schedule_health", "active_operations", "latest_backup", "last_successful_backup_completed_at", @@ -365,7 +366,7 @@ def test_error_response_summary_is_owned_by_error_code(self) -> None: def test_untrusted_error_summary_is_rejected(self) -> None: payload = { - "protocol_version": 1, + "protocol_version": 2, "request_id": str(uuid4()), "status": "denied", "result": None, @@ -389,7 +390,7 @@ def test_success_response_rejects_error_fields(self) -> None: def test_success_response_round_trip_reprojects_untrusted_result(self) -> None: request_id = uuid4() payload = { - "protocol_version": 1, + "protocol_version": 2, "request_id": str(request_id), "status": "ok", "result": {"runs": [run_payload(environment={"PASSWORD": "secret"})]}, @@ -416,7 +417,7 @@ def test_success_response_round_trip_reprojects_untrusted_result(self) -> None: "safe_summary": "System access denied.", }, {"status": ResponseStatus.DENIED, "result": None}, - {"status": ResponseStatus.OK, "result": {}, "protocol_version": 2}, + {"status": ResponseStatus.OK, "result": {}, "protocol_version": 3}, ], ) def test_response_envelope_rejects_inconsistent_shapes( diff --git a/tests/TimeLocker/system_control/test_release_launcher.py b/tests/TimeLocker/system_control/test_release_launcher.py index e02d021..67ea282 100644 --- a/tests/TimeLocker/system_control/test_release_launcher.py +++ b/tests/TimeLocker/system_control/test_release_launcher.py @@ -34,10 +34,11 @@ def _stage_release(root: Path, release_id: str) -> Path: manifest.write_text( json.dumps( { - "schema_version": 1, + "schema_version": 2, "release_id": release_id, "package_version": "0.9.1", - "protocol_version": 1, + "control_protocol_version": 2, + "event_protocol_version": 1, "entrypoint": "venv/bin/timelocker", } ), @@ -232,13 +233,13 @@ def test_schema_two_manifest_binds_control_and_event_protocols() -> None: "schema_version": 2, "release_id": RELEASE_A, "package_version": "0.9.1", - "control_protocol_version": 1, + "control_protocol_version": 2, "event_protocol_version": 1, "entrypoint": "venv/bin/timelocker", } ) - assert manifest.control_protocol_version == 1 + assert manifest.control_protocol_version == 2 assert manifest.event_protocol_version == 1 diff --git a/tests/TimeLocker/system_control/test_schedule_health.py b/tests/TimeLocker/system_control/test_schedule_health.py new file mode 100644 index 0000000..35a830b --- /dev/null +++ b/tests/TimeLocker/system_control/test_schedule_health.py @@ -0,0 +1,236 @@ +"""Schedule-health and native protected-record invalidation tests.""" + +from datetime import UTC, datetime, timedelta +import subprocess +from threading import Event, Thread +from time import monotonic +from uuid import uuid4 + +import pytest + +from TimeLocker.system_control.models import RunRecord +from TimeLocker.system_control.schedule_health import ( + BackupScheduleObservation, + ScheduleDeadlineMonitor, + SystemdScheduleSummaryProvider, + derive_backup_schedule_health, +) +from TimeLocker.system_control.status_events import ( + FileSystemProtectedStateWatcher, + StatusWatchSignal, +) +from TimeLocker.system_control.storage import AtomicRecordStore +from TimeLocker.system_control.types import ( + BackupScheduleHealth, + OperationTrigger, + OperationType, + ResultCode, + RunState, +) + + +NOW = datetime(2026, 7, 28, 6, 0, tzinfo=UTC) + + +def _observation(**overrides: object) -> BackupScheduleObservation: + values = { + "available": True, + "enabled": True, + "active": True, + "service_active": False, + "last_trigger_at": NOW - timedelta(hours=2), + "next_trigger_at": NOW + timedelta(hours=22), + } + values.update(overrides) + return BackupScheduleObservation(**values) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("observation", "expected"), + ( + ( + _observation(available=False), + BackupScheduleHealth.UNAVAILABLE, + ), + ( + _observation(enabled=False), + BackupScheduleHealth.DISABLED, + ), + ( + _observation(active=False), + BackupScheduleHealth.DISABLED, + ), + ( + _observation(), + BackupScheduleHealth.MISSED, + ), + ( + _observation(last_trigger_at=NOW - timedelta(minutes=5)), + BackupScheduleHealth.HEALTHY, + ), + ), +) +def test_schedule_health_is_derived_from_timer_and_grace( + observation: BackupScheduleObservation, + expected: BackupScheduleHealth, +) -> None: + assert derive_backup_schedule_health(observation, (), now=NOW) is expected + + +@pytest.mark.unit +def test_run_started_for_last_trigger_prevents_false_missed_state() -> None: + run = RunRecord( + run_id=uuid4(), + operation=OperationType.BACKUP, + trigger=OperationTrigger.SCHEDULED, + target_id="production", + started_at=NOW - timedelta(hours=2), + completed_at=NOW - timedelta(hours=1, minutes=55), + state=RunState.FAILED, + result_code=ResultCode.OPERATION_FAILED, + ) + + assert ( + derive_backup_schedule_health(_observation(), (run,), now=NOW) + is BackupScheduleHealth.HEALTHY + ) + + +@pytest.mark.unit +def test_late_manual_backup_does_not_erase_a_missed_occurrence() -> None: + run = RunRecord( + run_id=uuid4(), + operation=OperationType.BACKUP, + trigger=OperationTrigger.EXPLICIT, + target_id="production", + started_at=NOW - timedelta(hours=1), + completed_at=NOW - timedelta(minutes=55), + state=RunState.SUCCEEDED, + result_code=ResultCode.BACKUP_SUCCEEDED, + ) + + assert ( + derive_backup_schedule_health(_observation(), (run,), now=NOW) + is BackupScheduleHealth.MISSED + ) + + +@pytest.mark.unit +def test_systemd_provider_parses_numeric_timer_timestamps() -> None: + responses = iter( + ( + subprocess.CompletedProcess( + (), + 0, + "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\n", + "", + ), + subprocess.CompletedProcess( + (), + 0, + ( + '[{"next":1785292200000000,"last":1785205806710183,' + '"unit":"timelocker-npbackup-migration.timer"}]' + ), + "", + ), + subprocess.CompletedProcess((), 0, "ActiveState=inactive\n", ""), + ) + ) + provider = SystemdScheduleSummaryProvider( + runner=lambda _command: next(responses) + ) + + observation = provider.observe_backup_schedule() + + assert observation.available is True + assert observation.enabled is True + assert observation.active is True + assert observation.next_trigger_at == datetime.fromtimestamp( + 1785292200, + tz=UTC, + ) + assert observation.last_trigger_at == datetime.fromtimestamp( + 1785205806.710183, + tz=UTC, + ) + + +@pytest.mark.unit +def test_schedule_deadline_publishes_once_without_fixed_polling() -> None: + observations = iter( + ( + _observation(next_trigger_at=NOW - timedelta(minutes=20)), + _observation(enabled=False, next_trigger_at=None), + ) + ) + + class Provider: + def observe_backup_schedule(self) -> BackupScheduleObservation: + return next(observations) + + class Coordinator: + calls = 0 + + def schedule_changed(self) -> None: + self.calls += 1 + + class StopSignal: + def is_set(self) -> bool: + return False + + def wait(self, timeout: float | None = None) -> bool: + return timeout is None + + coordinator = Coordinator() + monitor = ScheduleDeadlineMonitor( + Provider(), # type: ignore[arg-type] + coordinator, # type: ignore[arg-type] + clock=lambda: NOW, + ) + + monitor.run(StopSignal()) # type: ignore[arg-type] + + assert coordinator.calls == 1 + + +@pytest.mark.unit +def test_filesystem_watcher_reports_external_record_change(tmp_path) -> None: + record_root = tmp_path / "records" + store = AtomicRecordStore(record_root) + root = record_root / "runs" + stop_event = Event() + watcher = FileSystemProtectedStateWatcher((root,)) + observed: list[StatusWatchSignal] = [] + + def consume() -> None: + for signal in watcher.events(stop_event): + observed.append(signal) + stop_event.set() + + thread = Thread(target=consume) + thread.start() + try: + deadline = monotonic() + 1.5 + attempt = 0 + while not observed and monotonic() < deadline: + store.create_run( + RunRecord( + run_id=uuid4(), + operation=OperationType.BACKUP, + trigger=OperationTrigger.SCHEDULED, + target_id=f"external-{attempt}", + started_at=NOW, + state=RunState.RUNNING, + result_code=ResultCode.OPERATION_RUNNING, + ) + ) + attempt += 1 + stop_event.wait(0.02) + thread.join(timeout=2) + finally: + stop_event.set() + thread.join(timeout=2) + + assert observed == [StatusWatchSignal.CHANGED] diff --git a/tests/TimeLocker/system_control/test_status_contracts.py b/tests/TimeLocker/system_control/test_status_contracts.py index c966a19..e4378a9 100644 --- a/tests/TimeLocker/system_control/test_status_contracts.py +++ b/tests/TimeLocker/system_control/test_status_contracts.py @@ -136,6 +136,7 @@ def test_status_snapshot_round_trip_and_rejects_mismatched_run_operations() -> N { "revision": {"session_id": str(SESSION_ID), "sequence": 4}, "backend_status": "available", + "backup_schedule_health": "healthy", "active_operations": 1, "latest_backup": latest_backup.to_wire(), "last_successful_backup_completed_at": None, @@ -165,6 +166,7 @@ def test_status_snapshot_rejects_unknown_fields_and_bool_as_active_operations() payload = { "revision": {"session_id": str(SESSION_ID), "sequence": 1}, "backend_status": "available", + "backup_schedule_health": "healthy", "active_operations": True, "latest_backup": None, "last_successful_backup_completed_at": None, diff --git a/tests/TimeLocker/system_control/test_status_snapshot_action.py b/tests/TimeLocker/system_control/test_status_snapshot_action.py index 239c107..a9f5521 100644 --- a/tests/TimeLocker/system_control/test_status_snapshot_action.py +++ b/tests/TimeLocker/system_control/test_status_snapshot_action.py @@ -155,7 +155,7 @@ def test_unauthorized_snapshot_receives_only_safe_denial(tmp_path: Path) -> None audit_sink=AuditSink(), ) request = { - "protocol_version": 1, + "protocol_version": 2, "request_id": "44444444-4444-4444-8444-444444444444", "action": "status.snapshot", "parameters": {}, diff --git a/tests/TimeLocker/system_control/test_tray_client.py b/tests/TimeLocker/system_control/test_tray_client.py index 05fca31..c02a3a1 100644 --- a/tests/TimeLocker/system_control/test_tray_client.py +++ b/tests/TimeLocker/system_control/test_tray_client.py @@ -24,6 +24,7 @@ from TimeLocker.system_control.models import OperationTrigger from TimeLocker.system_control.types import ( BackendStatus, + BackupScheduleHealth, OperationType as BackendOperationType, ResultCode, RunState, @@ -92,6 +93,36 @@ def request_retention(self, request: RetentionActionRequest): return None +@mark.unit +@pytest.mark.parametrize( + ("schedule_health", "expected_health", "expected_status"), + ( + (BackupScheduleHealth.HEALTHY, "Healthy", "warning"), + (BackupScheduleHealth.MISSED, "Backup missed", "error"), + (BackupScheduleHealth.DISABLED, "Schedule disabled", "warning"), + (BackupScheduleHealth.UNAVAILABLE, "Schedule unavailable", "warning"), + ), +) +def test_schedule_health_is_kept_separate_from_activity( + schedule_health: BackupScheduleHealth, + expected_health: str, + expected_status: str, +) -> None: + snapshot = StatusSnapshot.from_run_history( + revision=StatusRevision(uuid4(), 1), + backend_status=BackendStatus.AVAILABLE, + backup_schedule_health=schedule_health, + active_operations=0, + runs=(), + ) + + state = TrayControlClient.project_snapshot(snapshot) + + assert state.health == expected_health + assert state.activity == "Idle" + assert state.status == expected_status + + @mark.unit def test_refresh_status_orders_runs_by_newest_and_projects_summary() -> None: base_time = datetime(2026, 7, 26, 12, 0, tzinfo=UTC) @@ -138,8 +169,10 @@ def test_refresh_status_orders_runs_by_newest_and_projects_summary() -> None: state = client.refresh_status() - assert state.status == "error" - assert "Next backup" in state.tooltip + assert state.status == "success" + assert state.health == "Healthy" + assert state.activity == "Idle" + assert "Next backup" not in state.tooltip assert state.latest_retention_status == "Operation failed." assert state.latest_backup_status == "Backup completed successfully." assert state.last_successful_backup_completed_at == ( @@ -186,6 +219,8 @@ def test_queued_backup_is_active_and_overrides_stale_interruption() -> None: state = client.refresh_status() assert state.status == "running" + assert state.health == "Healthy" + assert state.activity == "Backup running" assert state.active_operations == 1 @@ -272,7 +307,7 @@ def test_failed_newer_backup_does_not_replace_last_successful_completion() -> No assert state.latest_backup_status == "Operation failed." assert state.last_successful_backup_completed_at == successful_completion expected = successful_completion.astimezone().strftime("%Y-%m-%d %H:%M %Z") - assert f"Last successful backup: {expected}".rstrip() in state.tooltip + assert f"Last Backup: {expected}".rstrip() in state.tooltip @mark.unit @@ -283,7 +318,7 @@ def test_no_successful_backup_is_presented_as_never() -> None: assert state.last_successful_backup_completed_at is None assert state.status == "warning" - assert "Last successful backup: Never" in state.tooltip + assert "Last Backup: Never" in state.tooltip @mark.unit @@ -345,6 +380,8 @@ def test_unavailable_backend_errors_are_retriable() -> None: assert unavailable.backend_available is False assert unavailable.status == "warning" + assert unavailable.health == "Backend unavailable" + assert unavailable.activity == "Connecting" assert "backend unavailable" in unavailable.tooltip.lower() backend = client._client @@ -372,6 +409,8 @@ def test_denied_backend_is_rendered_without_protected_detail() -> None: state = client.refresh_status() assert state.backend_available is True + assert state.health == "Access denied" + assert state.activity == "Idle" assert state.tooltip == "TimeLocker - Access denied" assert "detail" not in state.tooltip diff --git a/tests/TimeLocker/system_control/test_tray_process_boundary.py b/tests/TimeLocker/system_control/test_tray_process_boundary.py index fd3b341..c5bced8 100644 --- a/tests/TimeLocker/system_control/test_tray_process_boundary.py +++ b/tests/TimeLocker/system_control/test_tray_process_boundary.py @@ -44,6 +44,62 @@ def test_cli_import_does_not_load_platform_tray_module() -> None: assert result.returncode == 0, result.stderr +@pytest.mark.unit +def test_package_import_defers_backup_and_cloud_dependencies() -> None: + environment = dict(os.environ) + environment["PYTHONPATH"] = "src" + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; import TimeLocker; " + "assert 'TimeLocker.backup_manager' not in sys.modules; " + "assert 'boto3' not in sys.modules; " + "assert 'b2sdk' not in sys.modules; " + "from TimeLocker import BackupManager; " + "assert BackupManager.__name__ == 'BackupManager'" + ), + ], + cwd=Path(__file__).parents[3], + env=environment, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.unit +def test_tray_launcher_import_defers_unrelated_system_services() -> None: + environment = dict(os.environ) + environment["PYTHONPATH"] = "src" + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; " + "import TimeLocker.system_control.tray_launcher_entry; " + "assert 'TimeLocker.system_control.retention' not in sys.modules; " + "assert 'TimeLocker.system_control.storage' not in sys.modules; " + "assert 'boto3' not in sys.modules; " + "assert 'b2sdk' not in sys.modules" + ), + ], + cwd=Path(__file__).parents[3], + env=environment, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + + assert result.returncode == 0, result.stderr + + @pytest.mark.unit def test_tray_single_instance_lock_rejects_second_owner(tmp_path) -> None: lock_path = tmp_path / "tray.lock" @@ -101,6 +157,8 @@ def test_apply_state_projects_last_backup_time_to_tray() -> None: state = TrayDisplayState( status="success", tooltip="TimeLocker\nLast backup: 2026-07-26T12:34:00+00:00", + health="Healthy", + activity="Idle", active_operations=0, backend_available=True, last_successful_backup_completed_at=backup_time, @@ -116,6 +174,8 @@ def test_apply_state_projects_last_backup_time_to_tray() -> None: status_info = tray.update_status_info.call_args.args[0] assert status_info.last_successful_backup_time == backup_time + assert status_info.health == "Healthy" + assert status_info.activity == "Idle" @pytest.mark.unit @@ -138,6 +198,8 @@ def test_healthy_serve_is_silent_and_applies_event_snapshot( state = TrayDisplayState( status="success", tooltip="TimeLocker", + health="Healthy", + activity="Idle", active_operations=0, backend_available=True, last_successful_backup_completed_at=datetime( @@ -184,6 +246,53 @@ def serve(self, _stop_event, *, on_snapshot, on_unavailable) -> None: client.refresh_status.assert_not_called() +@pytest.mark.unit +def test_connecting_icon_is_processed_before_subscription_starts( + monkeypatch, +) -> None: + arguments = type( + "Arguments", + (), + { + "action": "serve", + "once": True, + "refresh_seconds": 15, + "target_id": "production", + "retention_policy_fingerprint": None, + "dry_run_retention": False, + }, + )() + events: list[str] = [] + tray = Mock() + tray.is_available.return_value = True + tray.process_events.side_effect = lambda: events.append("ui-ready") + + class _Subscription: + def serve(self, _stop_event, *, on_snapshot, on_unavailable) -> None: + events.append("subscription-started") + on_snapshot(object()) + + monkeypatch.setattr(tray_entry, "_parse_args", lambda: arguments) + monkeypatch.setattr( + tray_entry, + "_build_client", + lambda **_kwargs: Mock(), + ) + monkeypatch.setattr(tray_entry, "SystemTrayIntegration", lambda **_kwargs: tray) + monkeypatch.setattr( + tray_entry, + "TrayStatusSubscriptionClient", + lambda: _Subscription(), + ) + monkeypatch.setattr(tray_entry, "_single_instance", lambda: nullcontext()) + monkeypatch.setattr(tray_entry.signal, "signal", lambda *_args: None) + + tray_entry.main() + + assert events[0] == "ui-ready" + assert "subscription-started" in events + + @pytest.mark.unit def test_explicit_status_action_still_renders_bounded_output( monkeypatch, @@ -202,6 +311,8 @@ def test_explicit_status_action_still_renders_bounded_output( state = TrayDisplayState( status="idle", tooltip="TimeLocker", + health="Healthy", + activity="Idle", active_operations=0, backend_available=True, last_successful_backup_completed_at=None, From a122b80bd06a711ad4b4a120325d5175845f16e8 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:02:48 +0100 Subject: [PATCH 59/72] fix(deploy): bind backend probe to release manifest --- .../010-event-driven-tray-status/tasks.md | 12 ++++- .../verification.md | 4 +- scripts/deploy_t011_linux.py | 14 ++++- .../project/test_t011_linux_deployment.py | 53 ++++++++++++++++++- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/docs/specs/010-event-driven-tray-status/tasks.md b/docs/specs/010-event-driven-tray-status/tasks.md index d352a9b..8e675db 100644 --- a/docs/specs/010-event-driven-tray-status/tasks.md +++ b/docs/specs/010-event-driven-tray-status/tasks.md @@ -304,7 +304,17 @@ T009 -> T010 -> T011 -> T012 -> T013 `d9fb99bbe856c7659304701ab8b12d5dd8d97fc194f5b8ce5825d33a559609c8`; sdist SHA-256: `bb5df2b2450db07335ccbb848f03e01b010ed568d6609f29cdd3b24f827ffeea`. - No protected host mutation occurred. + No protected host mutation occurred. The first protocol-2 deployment of + commit `2e1b565c823dd9a2714e43ed976338d45a9cbee5` failed closed during + staged backend preflight because the repository deployer still compared + the candidate's correct `2:1` protocol report to a stale hard-coded `1:1` + expectation. Activation did not begin and the candidate release was + removed. The deployer now derives the expected signature from the staged, + already-validated schema-2 manifest and records the probe output in private + evidence. A regression proves a stale `1:1` candidate fails before + activation; the exact committed wheel independently reported `2:1`. + Twelve focused harness tests, a 284-test system-control/artifact regression, + scoped Ruff, and patch integrity passed before another deployment attempt. - Status: Corrected release activated successfully; the remaining installed T011 acceptance checks, including visible connecting-state startup, and diff --git a/docs/specs/010-event-driven-tray-status/verification.md b/docs/specs/010-event-driven-tray-status/verification.md index d9bfe3b..0d90ee7 100644 --- a/docs/specs/010-event-driven-tray-status/verification.md +++ b/docs/specs/010-event-driven-tray-status/verification.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: verification status: active owner: Auriora Team -last_reviewed: 2026-07-27 +last_reviewed: 2026-07-28 --- # Verification @@ -157,6 +157,8 @@ closure. | 2026-07-28 | General deployment workflow routing | follow-up created | Draft [Spec 011](../011-protected-system-deployment/README.md) owns the supported install, upgrade, status, rollback, staging, provenance, and evidence workflow. Its implementation waits for Spec 010 closure. | | 2026-07-28 | Immediate connecting-state implementation | pass | The tray processes a deterministic connecting badge before starting its background subscription worker. Lazy package boundaries reduce direct source startup to approximately 0.11 seconds for launcher import and 0.56 seconds for full tray-entry import. Focused tray/asset/deployment/artifact tests passed 42 cases; broader system-control, tray-monitoring, and backup compatibility regression passed 415 tests. Scoped Ruff, compileall, patch integrity, and lazy public-export compatibility passed. No protected host mutation occurred. | | 2026-07-28 | Connecting-badge release artifacts | pass | Fresh wheel and sdist validation found 28 package-data files; the wheel passed clean installed-artifact smoke. Wheel SHA-256 `d9fb99bbe856c7659304701ab8b12d5dd8d97fc194f5b8ce5825d33a559609c8`; sdist SHA-256 `bb5df2b2450db07335ccbb848f03e01b010ed568d6609f29cdd3b24f827ffeea`. | +| 2026-07-28 | Protocol-2 commit-bound deployment staging | fail closed before activation | Commit `2e1b565c823dd9a2714e43ed976338d45a9cbee5` correctly reported candidate protocols `2:1`, but the deployer retained a stale hard-coded `1:1` expectation. The harness recovered the inert candidate without selecting it; no backup or retention was triggered. | +| 2026-07-28 | Manifest-bound backend probe correction | pass | The deployer now compares the staged backend report to the staged, validated release manifest and retains the report in private evidence. The exact committed wheel reported `2:1`; the mismatch regression, 12 focused harness tests, 284 system-control/artifact tests, scoped Ruff, and patch integrity passed. | ## Manual Or External Verification diff --git a/scripts/deploy_t011_linux.py b/scripts/deploy_t011_linux.py index 4895557..a4dd097 100755 --- a/scripts/deploy_t011_linux.py +++ b/scripts/deploy_t011_linux.py @@ -348,10 +348,20 @@ def preflight_staged_release(self) -> None: protocol_output = self.executor.run( [python, "-c", BACKEND_IMPORT_PROBE], + output=self.evidence / "preflight-backend-protocol.txt", capture=True, ).strip() - if protocol_output != "1:1": - raise DeploymentFailure("staged backend protocol probe failed") + assert self.staged_manifest is not None + manifest = _read_json(self.staged_manifest) + expected_protocol_output = ( + f"{manifest['control_protocol_version']}:" + f"{manifest['event_protocol_version']}" + ) + if protocol_output != expected_protocol_output: + raise DeploymentFailure( + "staged backend protocol probe failed: " + f"expected {expected_protocol_output}, got {protocol_output or ''}" + ) packaged_unit = Path( self.executor.run( [python, "-c", PACKAGED_UNIT_PROBE], diff --git a/tests/TimeLocker/project/test_t011_linux_deployment.py b/tests/TimeLocker/project/test_t011_linux_deployment.py index 1ff75ed..e96397a 100644 --- a/tests/TimeLocker/project/test_t011_linux_deployment.py +++ b/tests/TimeLocker/project/test_t011_linux_deployment.py @@ -31,9 +31,16 @@ def _load_harness() -> ModuleType: class FakeExecutor: """Capture commands and return deterministic candidate-probe output.""" - def __init__(self, harness: ModuleType, packaged_unit: Path) -> None: + def __init__( + self, + harness: ModuleType, + packaged_unit: Path, + *, + backend_protocol: str = "2:1", + ) -> None: self.harness = harness self.packaged_unit = packaged_unit + self.backend_protocol = backend_protocol self.commands: list[list[str]] = [] def run( @@ -50,7 +57,7 @@ def run( self.commands.append(command) result = "" if command[-2:] == ["-c", self.harness.BACKEND_IMPORT_PROBE]: - result = "1:1\n" + result = f"{self.backend_protocol}\n" elif command[-2:] == ["-c", self.harness.PACKAGED_UNIT_PROBE]: result = f"{self.packaged_unit}\n" elif command[-2:] == ["-c", self.harness.DENIED_EVENT_PROBE]: @@ -287,6 +294,48 @@ def test_identity_preflights_are_inline_and_precede_mutation_under_restrictive_u assert set(evidence_modes.values()) == {0o600} +def test_backend_protocol_probe_must_match_validated_release_manifest( + tmp_path: Path, +) -> None: + harness = _load_harness() + paths = _paths(harness, tmp_path) + _baseline(paths) + request = _request(harness, tmp_path) + packaged_unit = ( + paths.releases_root + / RELEASE_B + / "venv/lib/python3.12/site-packages/TimeLocker/system_control/assets" + / "timelocker-control.service" + ) + deployer = harness.T011LinuxDeployer( + request, + paths=paths, + executor=FakeExecutor( + harness, + packaged_unit, + backend_protocol="1:1", + ), + owner_uid=None, + owner_gid=None, + ) + deployer.validate_request() + deployer.capture_baseline() + _staged_release(deployer, packaged_unit) + + with pytest.raises( + harness.DeploymentFailure, + match=r"expected 2:1, got 1:1", + ): + deployer.preflight_staged_release() + + assert json.loads(paths.selector.read_text())["selected"] == RELEASE_A + assert paths.service_unit.read_text() == "old service\n" + assert deployer.evidence is not None + assert ( + deployer.evidence / "preflight-backend-protocol.txt" + ).read_text(encoding="utf-8") == "1:1\n" + + def test_invalid_wheel_filename_is_rejected_before_host_state( tmp_path: Path, ) -> None: From 5166242145b5552c8f232101e1254e79771a57f0 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:05:44 +0100 Subject: [PATCH 60/72] fix(deploy): defer cross-version system probe --- .../010-event-driven-tray-status/tasks.md | 7 ++++++ .../verification.md | 1 + scripts/deploy_t011_linux.py | 25 +++++++++++-------- .../project/test_t011_linux_deployment.py | 15 +++++++++++ 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/docs/specs/010-event-driven-tray-status/tasks.md b/docs/specs/010-event-driven-tray-status/tasks.md index 8e675db..d5ee8a8 100644 --- a/docs/specs/010-event-driven-tray-status/tasks.md +++ b/docs/specs/010-event-driven-tray-status/tasks.md @@ -315,6 +315,13 @@ T009 -> T010 -> T011 -> T012 -> T013 activation; the exact committed wheel independently reported `2:1`. Twelve focused harness tests, a 284-test system-control/artifact regression, scoped Ruff, and patch integrity passed before another deployment attempt. + A further exact preflight rehearsal found that a protocol-2 candidate + `runs list` cannot query the still-active protocol-1 backend. Before any + retry, the pre-activation CLI check was narrowed to the candidate's local, + manifest-bound version; the real system read remains a required + post-activation check after the candidate backend is coherently selected. + Simulated transaction ordering proves no protocol-2 system read occurs + before selection and that the post-activation read still runs. - Status: Corrected release activated successfully; the remaining installed T011 acceptance checks, including visible connecting-state startup, and diff --git a/docs/specs/010-event-driven-tray-status/verification.md b/docs/specs/010-event-driven-tray-status/verification.md index 0d90ee7..794ce8c 100644 --- a/docs/specs/010-event-driven-tray-status/verification.md +++ b/docs/specs/010-event-driven-tray-status/verification.md @@ -159,6 +159,7 @@ closure. | 2026-07-28 | Connecting-badge release artifacts | pass | Fresh wheel and sdist validation found 28 package-data files; the wheel passed clean installed-artifact smoke. Wheel SHA-256 `d9fb99bbe856c7659304701ab8b12d5dd8d97fc194f5b8ce5825d33a559609c8`; sdist SHA-256 `bb5df2b2450db07335ccbb848f03e01b010ed568d6609f29cdd3b24f827ffeea`. | | 2026-07-28 | Protocol-2 commit-bound deployment staging | fail closed before activation | Commit `2e1b565c823dd9a2714e43ed976338d45a9cbee5` correctly reported candidate protocols `2:1`, but the deployer retained a stale hard-coded `1:1` expectation. The harness recovered the inert candidate without selecting it; no backup or retention was triggered. | | 2026-07-28 | Manifest-bound backend probe correction | pass | The deployer now compares the staged backend report to the staged, validated release manifest and retains the report in private evidence. The exact committed wheel reported `2:1`; the mismatch regression, 12 focused harness tests, 284 system-control/artifact tests, scoped Ruff, and patch integrity passed. | +| 2026-07-28 | Cross-version preflight rehearsal | defect found and corrected before retry | The protocol-2 candidate CLI correctly rejected the active protocol-1 backend response, showing that a pre-activation `runs list` cannot prove a coherent protocol upgrade. The pre-activation CLI probe now verifies the candidate's manifest-bound local version; simulated ordering requires the real system read only after candidate selection and backend restart. | ## Manual Or External Verification diff --git a/scripts/deploy_t011_linux.py b/scripts/deploy_t011_linux.py index a4dd097..1efe8f7 100755 --- a/scripts/deploy_t011_linux.py +++ b/scripts/deploy_t011_linux.py @@ -376,24 +376,29 @@ def preflight_staged_release(self) -> None: ) candidate_cli = self.release / "venv/bin/timelocker" - self.executor.run( + candidate_version = self.executor.run( [ "timeout", - "15", + "10", "runuser", "-u", self.request.operator_user, "--", candidate_cli, - "runs", - "list", - "--limit", - "3", - "--json", + "version", + "--short", ], - timeout=20, - output=self.evidence / "preflight-authorized-runs.json", - ) + timeout=15, + output=self.evidence / "preflight-cli-version.txt", + capture=True, + ).strip() + expected_package_version = manifest["package_version"] + if candidate_version != expected_package_version: + raise DeploymentFailure( + "staged CLI version probe failed: " + f"expected {expected_package_version}, " + f"got {candidate_version or ''}" + ) self.executor.run( [ "timeout", diff --git a/tests/TimeLocker/project/test_t011_linux_deployment.py b/tests/TimeLocker/project/test_t011_linux_deployment.py index e96397a..c268831 100644 --- a/tests/TimeLocker/project/test_t011_linux_deployment.py +++ b/tests/TimeLocker/project/test_t011_linux_deployment.py @@ -58,6 +58,8 @@ def run( result = "" if command[-2:] == ["-c", self.harness.BACKEND_IMPORT_PROBE]: result = f"{self.backend_protocol}\n" + elif command[-2:] == ["version", "--short"]: + result = "0.9.1\n" elif command[-2:] == ["-c", self.harness.PACKAGED_UNIT_PROBE]: result = f"{self.packaged_unit}\n" elif command[-2:] == ["-c", self.harness.DENIED_EVENT_PROBE]: @@ -605,12 +607,25 @@ def test_full_simulated_transaction_runs_preflight_before_selection( if "TimeLocker.system_control.release_admin" in command and "select" in command ) + version_index = next( + index + for index, command in enumerate(executor.commands) + if command[-2:] == ["version", "--short"] + ) + system_read_indexes = [ + index + for index, command in enumerate(executor.commands) + if command[-5:] == ["runs", "list", "--limit", "3", "--json"] + ] pip_command = next( command for command in executor.commands if len(command) >= 5 and command[1:4] == ["-m", "pip", "install"] ) assert denied_index < selection_index + assert version_index < selection_index + assert system_read_indexes + assert all(index > selection_index for index in system_read_indexes) assert Path(pip_command[-1]).name == request.wheel.name assert deployer.release.exists() assert paths.service_unit.read_text() == packaged_unit.read_text() From 18990e168108e23479193563a35a72f773120aec Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:23:22 +0100 Subject: [PATCH 61/72] fix(deploy): upgrade stable launcher transactionally --- .../010-event-driven-tray-status/design.md | 8 +- .../010-event-driven-tray-status/tasks.md | 17 +- .../verification.md | 1 + scripts/deploy_t011_linux.py | 169 +++++++++++++++++- .../system_control/release_launcher.py | 19 +- .../project/test_t011_linux_deployment.py | 87 ++++++++- .../system_control/test_release_launcher.py | 45 ++++- 7 files changed, 329 insertions(+), 17 deletions(-) diff --git a/docs/specs/010-event-driven-tray-status/design.md b/docs/specs/010-event-driven-tray-status/design.md index e407c0c..cc9f6f1 100644 --- a/docs/specs/010-event-driven-tray-status/design.md +++ b/docs/specs/010-event-driven-tray-status/design.md @@ -248,8 +248,12 @@ class StatusEventClient(Protocol): - Existing CLI control actions remain request/response compatible. - Release metadata records both control and event protocol compatibility. -- Activation installs and probes the event socket/service assets before - selecting the release. +- A stable launcher parses bounded cross-version release metadata but permits + explicit selection only when the target protocols match the selector + implementation. Rollback may still resolve the previously accepted release. +- Activation stages and probes a replacement launcher environment plus the + event socket/service assets before selection, then atomically retains the + prior launcher environment for recovery before selecting the release. - A new tray paired with an incompatible backend shows a safe unavailable state rather than reverting to indefinite status polling. - Linux uses packaged deterministic variants of the TimeLocker logo. A diff --git a/docs/specs/010-event-driven-tray-status/tasks.md b/docs/specs/010-event-driven-tray-status/tasks.md index d5ee8a8..436b27c 100644 --- a/docs/specs/010-event-driven-tray-status/tasks.md +++ b/docs/specs/010-event-driven-tray-status/tasks.md @@ -322,10 +322,21 @@ T009 -> T010 -> T011 -> T012 -> T013 post-activation check after the candidate backend is coherently selected. Simulated transaction ordering proves no protocol-2 system read occurs before selection and that the post-activation read still runs. + Inspection of the installed stable launcher then found that its protocol-1 + manifest parser could not resolve a protocol-2 selected release. The + launcher contract now parses bounded cross-version metadata while allowing + normal selection only for its own protocols. The deployment transaction + stages a separate candidate launcher environment, verifies that it resolves + both current and candidate manifests, swaps it at the mutation boundary, + and retains the prior environment for rollback. Forced post-activation + failure restores the prior launcher before restarting the prior backend. + A 299-test system-control, deployment, artifact, and tray-icon regression + passed with scoped Ruff, compileall, and patch integrity. - - Status: Corrected release activated successfully; the remaining installed - T011 acceptance checks, including visible connecting-state startup, and - evidence validation are in progress. + - Status: Prior release `a67c83ac09ac29b94a3ed481ee536b3380db3337` + remains active and healthy. Corrected protocol-2 release deployment and the + remaining installed T011 acceptance checks, including visible + connecting-state startup and evidence validation, are in progress. - [x] T011.1 Reconcile and test backup-health and tray-row contracts. - Acceptance: State is health-only; Activity is transient; Last Backup is successful completion or Never; exact wire and menu tests fail before the diff --git a/docs/specs/010-event-driven-tray-status/verification.md b/docs/specs/010-event-driven-tray-status/verification.md index 794ce8c..97033fd 100644 --- a/docs/specs/010-event-driven-tray-status/verification.md +++ b/docs/specs/010-event-driven-tray-status/verification.md @@ -160,6 +160,7 @@ closure. | 2026-07-28 | Protocol-2 commit-bound deployment staging | fail closed before activation | Commit `2e1b565c823dd9a2714e43ed976338d45a9cbee5` correctly reported candidate protocols `2:1`, but the deployer retained a stale hard-coded `1:1` expectation. The harness recovered the inert candidate without selecting it; no backup or retention was triggered. | | 2026-07-28 | Manifest-bound backend probe correction | pass | The deployer now compares the staged backend report to the staged, validated release manifest and retains the report in private evidence. The exact committed wheel reported `2:1`; the mismatch regression, 12 focused harness tests, 284 system-control/artifact tests, scoped Ruff, and patch integrity passed. | | 2026-07-28 | Cross-version preflight rehearsal | defect found and corrected before retry | The protocol-2 candidate CLI correctly rejected the active protocol-1 backend response, showing that a pre-activation `runs list` cannot prove a coherent protocol upgrade. The pre-activation CLI probe now verifies the candidate's manifest-bound local version; simulated ordering requires the real system read only after candidate selection and backend restart. | +| 2026-07-28 | Stable-launcher compatibility review and remediation | pass before retry | The installed protocol-1 launcher could not parse a protocol-2 selected manifest. The launcher now reads bounded cross-version metadata while restricting normal selection to its own protocols; the deployer stages and probes a separate launcher environment, swaps it with the release, and restores the previous environment before backend rollback on failure. A 299-test system-control/deployment/artifact regression, scoped Ruff, compileall, and patch integrity passed. | ## Manual Or External Verification diff --git a/scripts/deploy_t011_linux.py b/scripts/deploy_t011_linux.py index 1efe8f7..59d50c5 100755 --- a/scripts/deploy_t011_linux.py +++ b/scripts/deploy_t011_linux.py @@ -95,6 +95,19 @@ print(files("TimeLocker.system_control.assets") / "timelocker-control.service") """ +LAUNCHER_COMPATIBILITY_PROBE = """\ +import sys +from TimeLocker.system_control.release_launcher import ImmutableReleaseResolver +resolver = ImmutableReleaseResolver() +current = resolver.release_manifest(sys.argv[1]) +candidate = resolver.release_manifest(sys.argv[2]) +assert current.release_id == sys.argv[1] +assert candidate.release_id == sys.argv[2] +assert candidate.control_protocol_version == int(sys.argv[3]) +assert candidate.event_protocol_version == int(sys.argv[4]) +print("compatible") +""" + class DeploymentFailure(RuntimeError): """Raised when a deployment gate fails or rollback cannot complete.""" @@ -113,6 +126,7 @@ class DeploymentPaths: service_unit: Path = Path("/etc/systemd/system/timelocker-control.service") evidence_root: Path = Path("/var/lib/timelocker/migration-backup") lock_file: Path = Path("/run/lock/timelocker-t011-deploy.lock") + launcher_venv: Path = Path("/opt/timelocker/launcher/venv") @dataclass(frozen=True, slots=True) @@ -180,6 +194,14 @@ def __init__( self.evidence: Path | None = None self.staged_wheel: Path | None = None self.staged_manifest: Path | None = None + self.staged_launcher = self.paths.launcher_venv.with_name( + f".venv.{request.release_id}.staged" + ) + self.previous_launcher = self.paths.launcher_venv.with_name( + f"venv.previous.{request.expected_current}" + ) + self.launcher_prior_moved = False + self.launcher_swapped = False self.mutation_started = False self.completed = False @@ -192,10 +214,10 @@ def deploy(self) -> Path: self.preflight_staged_release() self.activate() self.verify_activation() + self.completed = True except BaseException: self.recover() raise - self.completed = True assert self.evidence is not None return self.evidence @@ -224,6 +246,26 @@ def validate_request(self) -> None: raise DeploymentFailure("operator_user does not exist") from error if self.release.exists(): raise DeploymentFailure(f"candidate release already exists: {self.release}") + if self.staged_launcher.exists(): + raise DeploymentFailure( + f"staged launcher already exists: {self.staged_launcher}" + ) + if self.previous_launcher.exists(): + raise DeploymentFailure( + f"launcher rollback path already exists: {self.previous_launcher}" + ) + _require_trusted_directory( + self.paths.launcher_venv.parent, + expected_owner_uid=self.owner_uid, + ) + _require_trusted_directory( + self.paths.launcher_venv, + expected_owner_uid=self.owner_uid, + ) + _require_trusted_executable( + self.paths.launcher_venv / "bin/python", + expected_owner_uid=self.owner_uid, + ) if _selected_release(self.paths.selector) != self.request.expected_current: raise DeploymentFailure("selected release changed before deployment") for unit in REQUIRED_ACTIVE_UNITS: @@ -326,6 +368,35 @@ def stage_release(self) -> None: uid=self.owner_uid, gid=self.owner_gid, ) + self.executor.run( + [ + "python3", + "-m", + "venv", + "--system-site-packages", + self.staged_launcher, + ], + timeout=120, + output=self.evidence / "launcher-venv-create.txt", + ) + self.executor.run( + [ + self.staged_launcher / "bin/python", + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--no-deps", + self.staged_wheel, + ], + timeout=300, + output=self.evidence / "launcher-pip-install.txt", + ) + _make_tree_immutable( + self.staged_launcher, + uid=self.owner_uid, + gid=self.owner_gid, + ) def preflight_staged_release(self) -> None: """Exercise every target identity before protected activation.""" @@ -362,6 +433,21 @@ def preflight_staged_release(self) -> None: "staged backend protocol probe failed: " f"expected {expected_protocol_output}, got {protocol_output or ''}" ) + launcher_output = self.executor.run( + [ + self.staged_launcher / "bin/python", + "-c", + LAUNCHER_COMPATIBILITY_PROBE, + self.request.expected_current, + self.request.release_id, + str(manifest["control_protocol_version"]), + str(manifest["event_protocol_version"]), + ], + output=self.evidence / "preflight-launcher-compatibility.txt", + capture=True, + ).strip() + if launcher_output != "compatible": + raise DeploymentFailure("staged launcher compatibility probe failed") packaged_unit = Path( self.executor.run( [python, "-c", PACKAGED_UNIT_PROBE], @@ -452,7 +538,28 @@ def activate(self) -> None: capture=True, ).strip() ) + assert self.staged_manifest is not None + manifest = _read_json(self.staged_manifest) self.mutation_started = True + self.launcher_prior_moved = True + os.replace(self.paths.launcher_venv, self.previous_launcher) + os.replace(self.staged_launcher, self.paths.launcher_venv) + self.launcher_swapped = True + launcher_output = self.executor.run( + [ + self.paths.launcher_venv / "bin/python", + "-c", + LAUNCHER_COMPATIBILITY_PROBE, + self.request.expected_current, + self.request.release_id, + str(manifest["control_protocol_version"]), + str(manifest["event_protocol_version"]), + ], + output=self.evidence / "activated-launcher-compatibility.txt", + capture=True, + ).strip() + if launcher_output != "compatible": + raise DeploymentFailure("activated launcher compatibility probe failed") _atomic_copy( packaged_unit, self.paths.service_unit, @@ -565,6 +672,10 @@ def recover(self) -> None: ) except OSError as error: errors.append(f"restore {destination}: {error}") + try: + self._restore_launcher() + except OSError as error: + errors.append(f"restore stable launcher: {error}") for command in ( ("systemctl", "daemon-reload"), ("systemctl", "restart", "timelocker-control.socket"), @@ -597,11 +708,29 @@ def recover(self) -> None: shutil.rmtree(self.release) except OSError as error: errors.append(f"remove candidate release: {error}") + if self.staged_launcher.exists(): + try: + shutil.rmtree(self.staged_launcher) + except OSError as error: + errors.append(f"remove staged launcher: {error}") if errors: raise DeploymentFailure( "deployment failed and rollback was incomplete: " + "; ".join(errors) ) + def _restore_launcher(self) -> None: + """Restore the prior immutable launcher after activation begins.""" + if not self.launcher_prior_moved: + return + if not self.previous_launcher.exists(): + self.launcher_prior_moved = False + return + if self.paths.launcher_venv.exists(): + os.replace(self.paths.launcher_venv, self.staged_launcher) + self.launcher_swapped = False + os.replace(self.previous_launcher, self.paths.launcher_venv) + self.launcher_prior_moved = False + def _validate_packaged_unit(self, packaged_unit: Path) -> None: _require_regular_file(packaged_unit, "packaged service unit") try: @@ -650,6 +779,44 @@ def _require_regular_file(path: Path, field: str) -> None: raise DeploymentFailure(f"{field} must be a regular non-symlink file") +def _require_trusted_directory( + path: Path, + *, + expected_owner_uid: int | None, +) -> None: + try: + metadata = path.lstat() + except OSError as error: + raise DeploymentFailure(f"trusted directory is unavailable: {path}") from error + if not path.is_dir() or path.is_symlink(): + raise DeploymentFailure(f"trusted directory is invalid: {path}") + if expected_owner_uid is not None and metadata.st_uid != expected_owner_uid: + raise DeploymentFailure(f"trusted directory has wrong owner: {path}") + if metadata.st_mode & 0o022: + raise DeploymentFailure(f"trusted directory is group/world writable: {path}") + + +def _require_trusted_executable( + path: Path, + *, + expected_owner_uid: int | None, +) -> None: + _require_trusted_directory( + path.parent, + expected_owner_uid=expected_owner_uid, + ) + try: + metadata = path.resolve(strict=True).stat() + except OSError as error: + raise DeploymentFailure(f"trusted executable is unavailable: {path}") from error + if not path.resolve().is_file() or not os.access(path, os.X_OK): + raise DeploymentFailure(f"trusted executable is invalid: {path}") + if expected_owner_uid is not None and metadata.st_uid != expected_owner_uid: + raise DeploymentFailure(f"trusted executable has wrong owner: {path}") + if metadata.st_mode & 0o022: + raise DeploymentFailure(f"trusted executable is group/world writable: {path}") + + def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: diff --git a/src/TimeLocker/system_control/release_launcher.py b/src/TimeLocker/system_control/release_launcher.py index 0c8bbcb..991e439 100644 --- a/src/TimeLocker/system_control/release_launcher.py +++ b/src/TimeLocker/system_control/release_launcher.py @@ -15,6 +15,7 @@ DEFAULT_RELEASES_ROOT = Path("/opt/timelocker/releases") DEFAULT_SELECTOR_PATH = Path("/opt/timelocker/selected-release.json") LAUNCH_GUARD = "TIMELOCKER_SYSTEM_LAUNCH_ACTIVE" +MAX_DECLARED_PROTOCOL_VERSION = 65_535 _ENTRYPOINTS = { "cli": "venv/bin/timelocker", "backend": "venv/bin/timelocker-system-control", @@ -114,14 +115,14 @@ def from_mapping(cls, value: object) -> "ReleaseManifest": control_protocol_version=require_int( mapping["control_protocol_version"], field="control_protocol_version", - minimum=PROTOCOL_VERSION, - maximum=PROTOCOL_VERSION, + minimum=1, + maximum=MAX_DECLARED_PROTOCOL_VERSION, ), event_protocol_version=require_int( mapping["event_protocol_version"], field="event_protocol_version", - minimum=STATUS_EVENT_PROTOCOL_VERSION, - maximum=STATUS_EVENT_PROTOCOL_VERSION, + minimum=1, + maximum=MAX_DECLARED_PROTOCOL_VERSION, ), entrypoint=entrypoint, ) @@ -157,7 +158,7 @@ def _from_legacy_mapping(cls, value: Mapping[str, object]) -> "ReleaseManifest": mapping["protocol_version"], field="protocol_version", minimum=1, - maximum=PROTOCOL_VERSION, + maximum=MAX_DECLARED_PROTOCOL_VERSION, ), event_protocol_version=None, entrypoint=entrypoint, @@ -214,6 +215,14 @@ def select( expected_current = _release_id(expected_current) self._require_trusted_directory(self.selector_path.parent) self._resolve_release(release_id) + manifest = self.release_manifest(release_id) + if ( + manifest.control_protocol_version != PROTOCOL_VERSION + or manifest.event_protocol_version != STATUS_EVENT_PROTOCOL_VERSION + ): + raise ReleaseResolutionError( + "release protocols are incompatible with selector" + ) with self._selector_lock(): current = self._read_selector_optional() if expected_current is not None and ( diff --git a/tests/TimeLocker/project/test_t011_linux_deployment.py b/tests/TimeLocker/project/test_t011_linux_deployment.py index c268831..de17397 100644 --- a/tests/TimeLocker/project/test_t011_linux_deployment.py +++ b/tests/TimeLocker/project/test_t011_linux_deployment.py @@ -58,6 +58,8 @@ def run( result = "" if command[-2:] == ["-c", self.harness.BACKEND_IMPORT_PROBE]: result = f"{self.backend_protocol}\n" + elif self.harness.LAUNCHER_COMPATIBILITY_PROBE in command: + result = "compatible\n" elif command[-2:] == ["version", "--short"]: result = "0.9.1\n" elif command[-2:] == ["-c", self.harness.PACKAGED_UNIT_PROBE]: @@ -104,12 +106,16 @@ def run( ) -> str: command = [str(argument) for argument in arguments] if command[:4] == ["python3", "-m", "venv", "--system-site-packages"]: - release = Path(command[4]).parent - python = release / "venv/bin/python" + venv_path = Path(command[4]) + python = venv_path / "bin/python" python.parent.mkdir(parents=True) python.write_text("#!/bin/sh\n", encoding="utf-8") python.chmod(0o755) - elif len(command) >= 5 and command[1:4] == ["-m", "pip", "install"]: + elif ( + len(command) >= 5 + and command[1:4] == ["-m", "pip", "install"] + and "--no-deps" not in command + ): release = Path(command[0]).parents[2] python = release / "venv/bin/python" for name in self.harness.REQUIRED_ENTRYPOINTS: @@ -162,6 +168,7 @@ def _paths(harness: ModuleType, root: Path): service_unit=root / "etc/systemd/system/timelocker-control.service", evidence_root=root / "var/lib/timelocker/migration-backup", lock_file=root / "run/lock/timelocker-t011-deploy.lock", + launcher_venv=root / "opt/timelocker/launcher/venv", ) @@ -209,6 +216,33 @@ def _baseline(paths) -> None: paths.service_unit.write_text("old service\n", encoding="utf-8") paths.evidence_root.mkdir(parents=True) paths.releases_root.mkdir(parents=True) + launcher_python = paths.launcher_venv / "bin/python" + launcher_python.parent.mkdir(parents=True) + paths.launcher_venv.parent.chmod(0o755) + paths.launcher_venv.chmod(0o755) + launcher_python.parent.chmod(0o755) + launcher_python.write_text("#!/bin/sh\n# old launcher\n", encoding="utf-8") + launcher_python.chmod(0o755) + current_release = paths.releases_root / RELEASE_A + current_entrypoints = current_release / "venv/bin" + current_entrypoints.mkdir(parents=True) + for name in ("timelocker", "timelocker-system-control", "timelocker-tray"): + entrypoint = current_entrypoints / name + entrypoint.write_text("#!/bin/sh\n", encoding="utf-8") + entrypoint.chmod(0o755) + (current_release / "release.json").write_text( + json.dumps( + { + "schema_version": 2, + "release_id": RELEASE_A, + "package_version": "0.9.1", + "control_protocol_version": 1, + "event_protocol_version": 1, + "entrypoint": "venv/bin/timelocker", + } + ), + encoding="utf-8", + ) def _staged_release(deployer, packaged_unit: Path) -> None: @@ -441,6 +475,41 @@ def recover(self): ] +@pytest.mark.parametrize("candidate_at_canonical_path", [False, True]) +def test_launcher_restore_uses_filesystem_state_across_swap_interruptions( + tmp_path: Path, + candidate_at_canonical_path: bool, +) -> None: + harness = _load_harness() + paths = _paths(harness, tmp_path) + _baseline(paths) + deployer = harness.T011LinuxDeployer( + _request(harness, tmp_path), + paths=paths, + executor=FakeExecutor(harness, tmp_path / "unused.service"), + owner_uid=None, + owner_gid=None, + ) + deployer.staged_launcher.mkdir() + candidate_python = deployer.staged_launcher / "python" + candidate_python.write_text("candidate launcher", encoding="utf-8") + os.replace(paths.launcher_venv, deployer.previous_launcher) + if candidate_at_canonical_path: + os.replace(deployer.staged_launcher, paths.launcher_venv) + deployer.launcher_prior_moved = True + deployer.launcher_swapped = False + + deployer._restore_launcher() + + assert "# old launcher" in ( + paths.launcher_venv / "bin/python" + ).read_text(encoding="utf-8") + assert not deployer.previous_launcher.exists() + assert (deployer.staged_launcher / "python").read_text( + encoding="utf-8" + ) == "candidate launcher" + + def test_recovery_restores_selector_and_service_and_removes_candidate( tmp_path: Path, ) -> None: @@ -628,6 +697,13 @@ def test_full_simulated_transaction_runs_preflight_before_selection( assert all(index > selection_index for index in system_read_indexes) assert Path(pip_command[-1]).name == request.wheel.name assert deployer.release.exists() + assert ( + "# old launcher" + not in (paths.launcher_venv / "bin/python").read_text(encoding="utf-8") + ) + assert "# old launcher" in ( + deployer.previous_launcher / "bin/python" + ).read_text(encoding="utf-8") assert paths.service_unit.read_text() == packaged_unit.read_text() assert all( path.stat().st_mode & 0o022 == 0 @@ -670,3 +746,8 @@ def test_full_simulated_post_activation_failure_rolls_back( assert json.loads(paths.selector.read_text())["selected"] == RELEASE_A assert paths.service_unit.read_text() == "old service\n" assert not deployer.release.exists() + assert "# old launcher" in ( + paths.launcher_venv / "bin/python" + ).read_text(encoding="utf-8") + assert not deployer.previous_launcher.exists() + assert not deployer.staged_launcher.exists() diff --git a/tests/TimeLocker/system_control/test_release_launcher.py b/tests/TimeLocker/system_control/test_release_launcher.py index 67ea282..4d2fbc8 100644 --- a/tests/TimeLocker/system_control/test_release_launcher.py +++ b/tests/TimeLocker/system_control/test_release_launcher.py @@ -18,7 +18,13 @@ RELEASE_B = "b" * 40 -def _stage_release(root: Path, release_id: str) -> Path: +def _stage_release( + root: Path, + release_id: str, + *, + control_protocol_version: int = 2, + event_protocol_version: int = 1, +) -> Path: release = root / "releases" / release_id executable = release / "venv" / "bin" / "timelocker" executable.parent.mkdir(parents=True) @@ -37,8 +43,8 @@ def _stage_release(root: Path, release_id: str) -> Path: "schema_version": 2, "release_id": release_id, "package_version": "0.9.1", - "control_protocol_version": 2, - "event_protocol_version": 1, + "control_protocol_version": control_protocol_version, + "event_protocol_version": event_protocol_version, "entrypoint": "venv/bin/timelocker", } ), @@ -243,6 +249,39 @@ def test_schema_two_manifest_binds_control_and_event_protocols() -> None: assert manifest.event_protocol_version == 1 +@pytest.mark.unit +def test_launcher_reads_bounded_cross_version_manifests_but_selects_current_only( + tmp_path: Path, +) -> None: + older = _stage_release( + tmp_path, + RELEASE_A, + control_protocol_version=1, + ) + _stage_release(tmp_path, RELEASE_B) + resolver = _resolver(tmp_path) + + assert resolver.release_manifest(RELEASE_A).control_protocol_version == 1 + with pytest.raises(ReleaseResolutionError, match="protocols are incompatible"): + resolver.select(RELEASE_A) + + resolver.select(RELEASE_B) + resolver.selector_path.write_text( + json.dumps( + { + "schema_version": 1, + "selected": RELEASE_B, + "previous": RELEASE_A, + } + ), + encoding="utf-8", + ) + state = resolver.rollback() + + assert state.selected == RELEASE_A + assert resolver.resolve({}) == older + + @pytest.mark.unit def test_schema_one_manifest_remains_readable_only_without_event_claim() -> None: manifest = ReleaseManifest.from_mapping( From 6544a8a42839f6f0eb890aeb8624f4373510ac26 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:32:55 +0100 Subject: [PATCH 62/72] docs(spec): record protocol 2 deployment --- .../010-event-driven-tray-status/tasks.md | 22 +++++++++++++++---- .../verification.md | 2 ++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/specs/010-event-driven-tray-status/tasks.md b/docs/specs/010-event-driven-tray-status/tasks.md index 436b27c..3f1c04f 100644 --- a/docs/specs/010-event-driven-tray-status/tasks.md +++ b/docs/specs/010-event-driven-tray-status/tasks.md @@ -331,12 +331,26 @@ T009 -> T010 -> T011 -> T012 -> T013 and retains the prior environment for rollback. Forced post-activation failure restores the prior launcher before restarting the prior backend. A 299-test system-control, deployment, artifact, and tray-icon regression - passed with scoped Ruff, compileall, and patch integrity. + passed with scoped Ruff, compileall, and patch integrity. The exact + commit-bound wheel for + `18990e168108e23479193563a35a72f773120aec` passed installed-artifact smoke, + cross-version manifest parsing, and a real staged-launcher directory rename + rehearsal; its SHA-256 is + `f097cbeb2d4a02ae0e84d335fdac1fc3cc7f93738e5cbee5f4cf3931e15129ba`. + The protected deployment then succeeded with preflight identity checks + passing and no backup or retention execution. Independent post-activation + reads confirmed protocol `2:1` in the stable launcher, the candidate + selected with `a67c83ac09ac29b94a3ed481ee536b3380db3337` retained as + previous, all five protected units active, required units enabled, system + run access, and the tray process running from the selected release. An + authorized event subscription received its initial event in approximately + 0.10 seconds. - - Status: Prior release `a67c83ac09ac29b94a3ed481ee536b3380db3337` - remains active and healthy. Corrected protocol-2 release deployment and the + - Status: Corrected protocol-2 release + `18990e168108e23479193563a35a72f773120aec` is active and healthy. The remaining installed T011 acceptance checks, including visible - connecting-state startup and evidence validation, are in progress. + connecting-state startup, event mutation latency, idle silence, restart, + rollback/reselection, and evidence validation, are in progress. - [x] T011.1 Reconcile and test backup-health and tray-row contracts. - Acceptance: State is health-only; Activity is transient; Last Backup is successful completion or Never; exact wire and menu tests fail before the diff --git a/docs/specs/010-event-driven-tray-status/verification.md b/docs/specs/010-event-driven-tray-status/verification.md index 97033fd..8a957fa 100644 --- a/docs/specs/010-event-driven-tray-status/verification.md +++ b/docs/specs/010-event-driven-tray-status/verification.md @@ -161,6 +161,8 @@ closure. | 2026-07-28 | Manifest-bound backend probe correction | pass | The deployer now compares the staged backend report to the staged, validated release manifest and retains the report in private evidence. The exact committed wheel reported `2:1`; the mismatch regression, 12 focused harness tests, 284 system-control/artifact tests, scoped Ruff, and patch integrity passed. | | 2026-07-28 | Cross-version preflight rehearsal | defect found and corrected before retry | The protocol-2 candidate CLI correctly rejected the active protocol-1 backend response, showing that a pre-activation `runs list` cannot prove a coherent protocol upgrade. The pre-activation CLI probe now verifies the candidate's manifest-bound local version; simulated ordering requires the real system read only after candidate selection and backend restart. | | 2026-07-28 | Stable-launcher compatibility review and remediation | pass before retry | The installed protocol-1 launcher could not parse a protocol-2 selected manifest. The launcher now reads bounded cross-version metadata while restricting normal selection to its own protocols; the deployer stages and probes a separate launcher environment, swaps it with the release, and restores the previous environment before backend rollback on failure. A 299-test system-control/deployment/artifact regression, scoped Ruff, compileall, and patch integrity passed. | +| 2026-07-28 | Exact protocol-2 artifact and launcher relocation rehearsal | pass | Commit `18990e168108e23479193563a35a72f773120aec` produced wheel SHA-256 `f097cbeb2d4a02ae0e84d335fdac1fc3cc7f93738e5cbee5f4cf3931e15129ba`. Installed-artifact smoke passed; the installed launcher parsed both the active protocol-1 and candidate protocol-2 manifests before its staged virtual environment was renamed, then imported protocol `2:1` successfully from the final path. | +| 2026-07-28 | Protocol-2 protected Linux Mint deployment | activation passed | The repository-owned harness selected release `18990e168108e23479193563a35a72f773120aec`, retained `a67c83ac09ac29b94a3ed481ee536b3380db3337` as previous, passed preflight identity checks, and triggered no backup or retention. Independent reads confirmed stable-launcher protocol `2:1`; control service, both sockets, backup timer, and retention timer active; required units enabled; installed CLI run access; tray status success; and the tray process executing from the selected release. An authorized initial event arrived in approximately 0.10 seconds. Private deployment evidence is rooted at `/var/lib/timelocker/migration-backup/t011-hardened-deploy-20260728T062845Z-3230661`. | ## Manual Or External Verification From 8820e655ee1f0d754d196b0b4b8d967ca7aa02cf Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:04:23 +0100 Subject: [PATCH 63/72] docs(spec): close rejected resident tray design --- CHARTER.md | 7 +- docs/1-requirements/system-operations.md | 23 ++++++- docs/2-architecture/system-architecture.md | 36 +++++++++-- .../service-layer-integration.md | 16 +++-- docs/SYSTEM-TRAY-SETUP.md | 50 +++++++++------ .../user/backup-operations-troubleshooting.md | 27 +++++++- .../010-event-driven-tray-status/README.md | 24 +++++-- .../canonical-context.md | 7 +- .../010-event-driven-tray-status/tasks.md | 59 +++++++++++++---- .../traceability.md | 31 +++++---- .../verification.md | 64 ++++++++++--------- .../011-protected-system-deployment/README.md | 18 +++--- .../canonical-context.md | 6 +- .../requirements.md | 55 ++++++++++++++-- docs/specs/README.md | 22 ++++--- 15 files changed, 324 insertions(+), 121 deletions(-) diff --git a/CHARTER.md b/CHARTER.md index 8a0ae8b..def2247 100644 --- a/CHARTER.md +++ b/CHARTER.md @@ -9,7 +9,7 @@ audience: - contributors - maintainers - ai-developers -last_reviewed: 2026-07-18 +last_reviewed: 2026-08-12 source_of_truth: true --- @@ -74,6 +74,11 @@ something goes wrong. 7. **Current documentation over visible history.** Durable docs describe the accepted state; Git and lifecycle history preserve completed delivery context. +8. **Zero idle residency.** TimeLocker must not require a continuously resident + daemon or privileged background process. Scheduled and explicit work should + use bounded, short-lived processes that exit when the operation or request + completes. Optional user-session presentation may remain open only by + explicit operator choice and must not require a resident privileged backend. ## Current Scope diff --git a/docs/1-requirements/system-operations.md b/docs/1-requirements/system-operations.md index 3014057..c2c87ed 100644 --- a/docs/1-requirements/system-operations.md +++ b/docs/1-requirements/system-operations.md @@ -3,7 +3,7 @@ title: "System Operations Requirements" doc_type: requirements status: active owner: Auriora Team -last_reviewed: 2026-07-26 +last_reviewed: 2026-08-12 --- # System Operations Requirements @@ -25,7 +25,10 @@ backup, retention, status, diagnostics, and tray operations. ## Authorization Requirements -- Protected reads and actions must use a local authenticated backend. +- Protected reads and actions must use a local authenticated, least-privilege + boundary. That boundary must be activated for a bounded request or operation + and must exit when its work is complete; authorization does not justify a + continuously resident TimeLocker daemon. - Only current members of the configured operator group may read system run records, read structured system diagnostics, or trigger the allowlisted backup and retention actions. @@ -35,6 +38,21 @@ backup, retention, status, diagnostics, and tray operations. policy approval, service changes, activation, and rollback, remains root-only. - Denial and unavailability must not fall back to direct privileged execution. +## Resource Residency Requirements + +- TimeLocker must consume no CPU and retain no service process while no backup, + retention, restore, explicit query, or explicit control action is running. +- Scheduled backup and retention must use one-shot scheduler jobs rather than a + continuously resident TimeLocker scheduler or control daemon. +- Protected queries and manual actions must use short-lived authenticated + helpers or one-shot service activation. +- A sanitized status snapshot may be written atomically for unprivileged + readers. Reading status must not wake a privileged process repeatedly or + create a read-notify-read feedback loop. +- The optional user-session tray may remain open only by explicit operator + choice. It must observe sanitized state directly and must not require a + resident privileged event broker, heartbeat, or status service. + ## Operation Requirements - System backup and retention must create durable, queryable run records. @@ -71,6 +89,7 @@ backup, retention, status, diagnostics, and tray operations. - It may request only allowlisted actions through the protected backend. - Tray failure, exit, or restart must not affect backend services or active operations. +- Tray operation must not keep a privileged TimeLocker process resident. - A full desktop UI is not part of the current product surface. ## Platform Requirement diff --git a/docs/2-architecture/system-architecture.md b/docs/2-architecture/system-architecture.md index fd2b558..fc8dc62 100644 --- a/docs/2-architecture/system-architecture.md +++ b/docs/2-architecture/system-architecture.md @@ -4,7 +4,7 @@ id: "arch-system-architecture" type: [ architecture ] status: [ approved ] owner: "Architecture Team" -last_reviewed: "2026-07-26" +last_reviewed: "2026-08-12" tags: [architecture, system, layers] links: tooling: [] @@ -50,6 +50,29 @@ user CLI user-session tray Restic command adapter ``` +## Approved Residency Constraint And Current Non-Conformance + +The root-owned continuously resident system-control backend shown above is the +currently deployed implementation, not the accepted long-term operating model. +On 2026-07-28 the project direction was clarified: TimeLocker must not require +a resident daemon. The deployed backend also demonstrated the reason for that +constraint by entering a read-notify-read status loop and consuming substantial +CPU while no backup or retention operation was running. + +The replacement architecture must use: + +- existing one-shot scheduler units for scheduled backup and retention; +- bounded, short-lived authenticated helpers for protected queries and manual + actions; +- atomically written sanitized status state for unprivileged readers; and +- direct filesystem notification in the optional tray, without a privileged + event broker or heartbeat process. + +Until that replacement is implemented, the diagram remains implementation +truth but records a known architectural non-conformance. Spec 010 acceptance of +the resident backend is halted, and Spec 011 owns the daemonless protected +deployment boundary. + ## Component Boundaries - **CLI boundary** — `src/TimeLocker/cli.py` owns the installed entry point; @@ -63,10 +86,14 @@ user CLI user-session tray - **System-control boundary** — `src/TimeLocker/system_control/` owns the versioned local protocol, peer identity, current group authorization, allowlisted dispatch, protected adapters, repository locking, durable run - records, safe diagnostics, deployment assets, and release activation. + records, safe diagnostics, deployment assets, and release activation. Its + current resident backend is transitional and must be replaced by bounded + one-shot execution. - **Tray boundary** — `timelocker-tray` is an independent unprivileged - user-session process. It polls and requests allowlisted actions through the - same protected backend; CLI startup never initializes it. + user-session process. The current release requests status and allowlisted + actions through the protected backend; the accepted replacement observes + sanitized state directly and invokes only short-lived protected helpers. CLI + startup never initializes it. - **Application boundary** — managers, orchestrators, and focused services coordinate repositories, backups, snapshots, recovery, policies, schedules, validation, and monitoring. CLI modules should delegate domain work here. @@ -87,6 +114,7 @@ user CLI user-session tray ## Invariants - The CLI is the public application interface. +- No TimeLocker-owned privileged process remains resident while idle. - Protected reads and actions fail closed if the authenticated backend, authorization, selected release, policy approval, or protected target cannot be validated. diff --git a/docs/3-implementation/service-layer-integration.md b/docs/3-implementation/service-layer-integration.md index b1f7421..893b19f 100644 --- a/docs/3-implementation/service-layer-integration.md +++ b/docs/3-implementation/service-layer-integration.md @@ -2,7 +2,7 @@ **Document Type**: Implementation Guide **Status**: Active -**Last Updated**: 2026-07-18 +**Last Updated**: 2026-08-12 ## Overview @@ -42,10 +42,17 @@ command names remain unchanged. host-level backup, retention, status, and diagnostics. It is not part of the legacy compatibility facade described below. +The deployed release currently serves this boundary through a continuously +resident root process and a privileged status-event socket. That process model +is rejected and transitional: protected requests must become bounded, +socket-activated one-shot executions, and the optional tray must consume an +atomically published sanitized snapshot directly. The accepted boundary is the +authorization and allowlisted-operation contract, not daemon residency. + - `release_launcher.py` resolves the root-owned selected immutable release for CLI, backend, and tray entrypoints and fails closed on untrusted state. -- `client.py` exposes the typed local client used by protected CLI reads and the - tray. +- `client.py` exposes the typed local client used for bounded protected CLI and + tray actions. Each request must allow the protected helper to exit. - `linux_adapter.py` obtains peer credentials from the AF_UNIX connection and rechecks current NSS group membership for every request. - `dispatcher.py` validates the versioned protocol and dispatches only @@ -57,7 +64,8 @@ legacy compatibility facade described below. - `storage.py` owns atomic run/diagnostic records and the shared repository mutation lock. - `tray_entry.py` and `tray_client.py` own the independent user-session process; - normal CLI setup must not import or initialize tray integration. + normal CLI setup must not import or initialize tray integration. The current + privileged event subscription is a known non-conformance owned by Spec 011. The protected boundary returns safe summaries, result codes, states, and counters. It never returns passwords, environment contents, raw journal data, diff --git a/docs/SYSTEM-TRAY-SETUP.md b/docs/SYSTEM-TRAY-SETUP.md index 6d900cc..2de9494 100644 --- a/docs/SYSTEM-TRAY-SETUP.md +++ b/docs/SYSTEM-TRAY-SETUP.md @@ -3,29 +3,36 @@ title: Independent System Tray Setup doc_type: guide status: active owner: Auriora Team -last_reviewed: 2026-07-27 +last_reviewed: 2026-08-12 --- # Independent System Tray Setup The TimeLocker tray is an optional, independent user-session process. Normal -CLI startup never initializes the tray. The tray communicates with the -protected local backend and can disappear or restart without affecting an -active backup or retention run. +CLI startup never initializes the tray, and the tray can disappear or restart +without affecting an active backup or retention run. -## Current Capability +The currently deployed tray communicates with a resident protected backend. +That process model is rejected because it violates TimeLocker's zero-idle- +residency constraint and exhibited an idle read-notify-read CPU loop. Spec 011 +owns the replacement: direct observation of an atomically published sanitized +status snapshot plus short-lived protected helpers for explicit actions. -The tray can: +## Accepted Presentation Contract + +The reusable tray behavior can: - show backend availability; -- show active operation count; -- show the latest backup and retention status; -- show the next known backup and retention times; +- show State, Activity, and Last Backup as three distinct rows; - request the allowlisted system backup; - request retention when supplied the exact approved policy fingerprint; and -- degrade to a warning state when the backend is unavailable or access is +- degrade to a warning state when protected state is unavailable or access is denied. +State describes backup health only. Activity describes transient backup or +retention work. Last Backup is the latest successful completion time, or +`Never`; a failed or interrupted run must not replace the last-success value. + `open_ui` is a reserved no-op. TimeLocker does not currently provide a full desktop UI. The default system autostart does not contain the approved retention fingerprint, so it hides `Run Retention`; operators can still request @@ -35,9 +42,11 @@ future managed tray configuration may enable the same action. ## Authorization The tray runs as the signed-in desktop user, never as root. The user must be a -current member of `timelocker-operators`; the backend rechecks group membership -for each request. After adding a user to the group, start a new login session -before relying on the tray. +current member of `timelocker-operators`. Protected explicit actions must +reauthorize the caller for each bounded request. Status observation must expose +only the sanitized snapshot and must not wake or retain a privileged process. +After adding a user to the group, start a new login session before relying on +the tray. ## Linux Setup @@ -79,18 +88,19 @@ timelocker-tray serve ## Failure Behavior - `Access denied` means the desktop user is not currently authorized. -- `System backend unavailable` means the local socket/backend is unavailable; - the tray retries with bounded backoff. +- `System status unavailable` means the sanitized snapshot cannot be read or + validated. It must not cause the tray to poll or keep a privileged service + alive. - A backup or retention conflict is reported by the backend and does not start overlapping repository work. -- Quitting the tray does not stop backend services, timers, or operations. +- Quitting the tray does not stop timers or active one-shot operations. ## Platform Status -The process boundary is platform-neutral and the source contains a Windows -adapter. Linux Mint live acceptance is in progress under Spec 010; package and -installed-artifact checks have passed, but this document does not yet claim a -live-accepted protected deployment. It also does not claim a live-accepted +The presentation contract is platform-neutral and the source contains a +Windows adapter. Spec 010 validated the presentation semantics but rejected +the resident backend during Linux Mint acceptance. Spec 011 owns daemonless +Linux deployment and acceptance. This document does not claim a live-accepted Windows installation. ## References diff --git a/docs/guides/user/backup-operations-troubleshooting.md b/docs/guides/user/backup-operations-troubleshooting.md index 9a5b566..2a1fc3a 100644 --- a/docs/guides/user/backup-operations-troubleshooting.md +++ b/docs/guides/user/backup-operations-troubleshooting.md @@ -3,7 +3,7 @@ title: "Backup Operations Troubleshooting Guide" doc_type: guide status: active owner: Auriora Team -last_reviewed: 2026-07-26 +last_reviewed: 2026-08-12 --- # Backup Operations Troubleshooting Guide @@ -54,6 +54,31 @@ The socket should be owned by root and the operator group with group read/write access. The public CLI returns a bounded backend-unavailable error; it does not fall back to a checkout, pyenv shim, root home, or legacy configuration. +### Temporarily Stop The Resident Backend + +The continuously resident backend is a known architectural non-conformance +pending the daemonless replacement in Spec 011. To stop it for the current boot, +first stop the user tray so that it does not reconnect, then stop the service +and both activation sockets: + +```bash +pkill -TERM -x timelocker-tray +sudo systemctl stop timelocker-control.service \ + timelocker-control.socket timelocker-status-events.socket +``` + +This does not stop or disable the independent backup and retention timers. +Protected interactive status and tray actions are unavailable while these +sockets are stopped; scheduled one-shot backup and retention remain independent. +Because the sockets remain enabled, they return on the next boot. To restore +protected interactive access before then: + +```bash +sudo systemctl start timelocker-control.socket \ + timelocker-status-events.socket +timelocker-tray +``` + ## Scheduled Backup Did Not Run ```bash diff --git a/docs/specs/010-event-driven-tray-status/README.md b/docs/specs/010-event-driven-tray-status/README.md index 5cbebd8..3ef3dca 100644 --- a/docs/specs/010-event-driven-tray-status/README.md +++ b/docs/specs/010-event-driven-tray-status/README.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: overview status: active owner: Auriora Team -last_reviewed: 2026-07-27 +last_reviewed: 2026-08-12 --- # Event-Driven Tray Status @@ -22,9 +22,17 @@ tray without a reliable source of truth. ## Current Stage - Requirements, design, tasks, traceability, change impact, canonical context, - and verification planning are approved for implementation. -- **Implementation approval:** user approval recorded on 2026-07-27. -- T001 is the first implementation slice. + and verification planning produced a deployed Linux acceptance candidate. +- **Architecture decision, 2026-07-28:** approval of the continuously resident + privileged backend is withdrawn. TimeLocker must have zero idle service + residency; protected queries and actions must use bounded one-shot execution. +- T011 live acceptance exposed a read-notify-read feedback loop in the resident + backend. Further acceptance, promotion, and release work for that runtime + design is halted. +- Accurate status semantics and tray presentation remain reusable, but the + transport and privileged-process design require disposition through Spec 011. +- T011 is dispositioned to Spec 011; T012 review and T013 promotion/closure are + the only remaining work in this package. - There are no active predecessor specs. Spec 009 is closed and its promoted durable documents are the current-state baseline. - The working tree already contains the separately requested removal of the @@ -43,6 +51,8 @@ tray without a reliable source of truth. ## Approval Boundary -Implementation is approved within this package. Protected host deployment, -operator-group mutation, live backup or retention execution, release -publication, and rollback retain their normal explicit approval gates. +No further implementation or live acceptance of the resident backend is +approved. Documentation reconciliation and safe shutdown guidance are approved. +The user explicitly approved Spec 011 implementation on 2026-08-12 after Spec +010 closure. Protected host mutation, live backup or retention execution, +publication, and rollback retain their separate operational approval gates. diff --git a/docs/specs/010-event-driven-tray-status/canonical-context.md b/docs/specs/010-event-driven-tray-status/canonical-context.md index 5fdd7f6..9e7affd 100644 --- a/docs/specs/010-event-driven-tray-status/canonical-context.md +++ b/docs/specs/010-event-driven-tray-status/canonical-context.md @@ -65,9 +65,10 @@ or live system evidence. | Event-driven tray behavior and authorization | `docs/1-requirements/system-operations.md` | yes | | Snapshot/event architecture and platform split | `docs/2-architecture/system-architecture.md` | yes | | Component ownership and interfaces | `docs/3-implementation/service-layer-integration.md` | yes | -| Setup, status rows, failure, reconnect, and rollback | `docs/SYSTEM-TRAY-SETUP.md` and user/developer guides | yes | -| Concrete Windows live service and acceptance | follow-up spec or issue | yes, as routed work | -| Full desktop application | product backlog/roadmap | no implementation; retain exclusion | +| Setup and accepted status-row behavior | `docs/SYSTEM-TRAY-SETUP.md` | yes | +| Failure and temporary shutdown behavior | `docs/guides/user/backup-operations-troubleshooting.md` | yes | +| Concrete Windows live service and acceptance | `docs/specs/011-protected-system-deployment/requirements.md` | yes, as routed work | +| Full desktop application exclusion | `CHARTER.md` | no implementation; retain exclusion | ## Worktree Caution diff --git a/docs/specs/010-event-driven-tray-status/tasks.md b/docs/specs/010-event-driven-tray-status/tasks.md index 3f1c04f..8a3dac9 100644 --- a/docs/specs/010-event-driven-tray-status/tasks.md +++ b/docs/specs/010-event-driven-tray-status/tasks.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: tasks status: active owner: Auriora Team -last_reviewed: 2026-07-27 +last_reviewed: 2026-08-12 --- # Tasks @@ -239,7 +239,7 @@ T009 -> T010 -> T011 -> T012 -> T013 - Evidence mode: implementation ## Phase 4: Acceptance, Review, Promotion, And Closure -- [~] T011 Perform approved Linux Mint acceptance. +- [x] T011 Disposition Linux Mint acceptance after the resident design was rejected. - Depends on: T010 - Requirements: Requirement 1-Requirement 7 - Properties: CP-001-CP-006 @@ -346,11 +346,23 @@ T009 -> T010 -> T011 -> T012 -> T013 authorized event subscription received its initial event in approximately 0.10 seconds. - - Status: Corrected protocol-2 release - `18990e168108e23479193563a35a72f773120aec` is active and healthy. The - remaining installed T011 acceptance checks, including visible - connecting-state startup, event mutation latency, idle silence, restart, - rollback/reselection, and evidence validation, are in progress. + - Status: routed - independently valid status and tray semantics are retained; + further resident-backend acceptance is superseded by human decision and + transferred to Spec 011's daemonless acceptance contract. + - Decision update, 2026-07-28: Further acceptance is halted. Live diagnosis + showed that reading a protected JSON run record is reported as a filesystem + change; the tray then requests another snapshot, which reads the record + again and sustains a read-notify-read loop. The unit accumulated more than + five CPU-hours during roughly eight hours of uptime without backup or + retention work. An isolated reproduction confirmed that a read alone emits + `CHANGED`. The installed release and current checkout use identical + relevant watcher, snapshot, and schedule-observer source. The user rejected + the resident daemon architecture and restored the zero-idle-residency + constraint. Spec 011 owns the daemonless replacement; this task must not + resume under the current architecture. + - Evidence mode: reasoned and live runtime observation + - Destination: Spec 011, Requirement 9 and its daemonless implementation and + acceptance tasks. - [x] T011.1 Reconcile and test backup-health and tray-row contracts. - Acceptance: State is health-only; Activity is transient; Last Backup is successful completion or Never; exact wire and menu tests fail before the @@ -386,7 +398,7 @@ T009 -> T010 -> T011 -> T012 -> T013 tray, deployment, artifact, backup, and CLI regression passed; scoped Ruff, compileall, patch integrity, wheel/sdist build, and installed-wheel smoke passed. No protected host mutation or live backup/retention ran. -- [ ] T012 Run the TimeLocker expert review and address findings. +- [x] T012 Run the TimeLocker expert review and address findings. - Depends on: T011 - Requirements: Requirement 1-Requirement 7 - Review: Use `$review-timelocker` with project stewardship, Restic, @@ -394,9 +406,22 @@ T009 -> T010 -> T011 -> T012 -> T013 operations/portability, and documentation lifecycle perspectives. - Acceptance: Blocking findings are fixed; advisory findings are fixed, rejected with rationale, or routed to one owned destination. - - Evidence: Pending. + - Evidence: Completed a bounded implementation-and-closure review on + 2026-08-12 using all seven TimeLocker expert roles. TLR-010-001 found that + lower-case traceability column names prevented lifecycle coverage parsing; + the headings were normalized and closure parsing was rerun. TLR-010-002 + found stale review dates and ambiguous current-versus-accepted backend + wording in promoted documents; the dates, transitional status, tray + contract, and shutdown consequences were corrected. No Restic command, + credential, backup, restore, retention, or protected host behavior changed + in this documentation-only closure slice. Remaining daemon-removal risk is + owned by Spec 011 rather than accepted here. Post-remediation evidence: + `lint_spec_package(mode=full)` reported 0 errors, warnings, or information + findings; `closure_check` accepted all seven requirement dispositions and + reported only pending T013. + - Evidence mode: review and direct documentation correction -- [ ] T013 Promote durable documentation, run final validation, and close. +- [x] T013 Promote durable documentation, run final validation, and prepare closure. - Depends on: T012 - Requirements: Requirement 1-Requirement 7 - Files: promotion targets in `change-impact.md`, `verification.md`, @@ -406,7 +431,19 @@ T009 -> T010 -> T011 -> T012 -> T013 work has one follow-up destination; general protected deployment workflow debt is owned by Spec 011; lifecycle evidence, traceability, closure, final-spec commit, cleanup, and history indexes are complete. - - Evidence: Pending. + - Evidence: Promoted the zero-idle-residency mandate, authorization and + resource requirements, current architecture non-conformance, component + ownership, accepted tray semantics, and temporary shutdown procedure to + durable documentation. Spec 011 owns every resident-runtime residual. + `python3 -m pytest tests/TimeLocker/system_control -q` with the configured + non-coverage test options completed with 274 passed in 29.39 seconds; + `git diff --check` passed. Agent Workbench checked all 14 changed Markdown + documents: ten were clean and the remaining findings were advisory + pre-existing table-width warnings. Spec Lifecycle Manager full lint reported + 0 findings, active-spec scan reported both packages healthy, promotion found + no missing targets, and closure accepted all requirement dispositions. + Final-spec and cleanup commit hashes are recorded by the closure workflow. + - Evidence mode: documentation promotion and executed validation ## Execution Rules diff --git a/docs/specs/010-event-driven-tray-status/traceability.md b/docs/specs/010-event-driven-tray-status/traceability.md index df556e4..7a2eb04 100644 --- a/docs/specs/010-event-driven-tray-status/traceability.md +++ b/docs/specs/010-event-driven-tray-status/traceability.md @@ -4,7 +4,7 @@ doc_type: spec artifact_type: traceability status: active owner: Auriora Team -last_reviewed: 2026-07-27 +last_reviewed: 2026-08-12 --- # Traceability Matrix @@ -29,15 +29,15 @@ last_reviewed: 2026-07-27 ## Requirement To Delivery Matrix -| Requirement | Priority | Tasks | Verification gates | Durable targets | Coverage state | Residual destination | +| Requirement | Priority | Tasks | Verification gates | Durable targets | Coverage State | Residual Destination | |-------------|----------|-------|--------------------|-----------------|----------------|----------------------| -| Requirement 1 | must-have | T001, T003-T005, T007, T011-T013 | V1, V2, V4, V5, V10 | requirements, architecture, tray setup | partial | Contracts through event-driven tray integration complete; deployment and live acceptance remain | -| Requirement 2 | must-have | T001-T005, T007-T008, T011-T013 | V1-V5, V7, V10-V11 | requirements, architecture | partial | Allowlisted models and Linux continuous authorization complete; Windows contract and live acceptance remain | -| Requirement 3 | must-have | T001-T002, T006-T007, T011-T013 | V1, V5-V6, V10 | requirements, tray setup | partial | Last-success contract, backend snapshot, local tray projection, and `Never` fallback complete; live acceptance remains | -| Requirement 4 | must-have | T002-T005, T007-T011, T013 | V2-V5, V7-V10 | architecture, troubleshooting | partial | Immediate connecting presentation, Linux reconnect, bounds, and event/control independence pass local regression; installed startup and remaining live acceptance remain | -| Requirement 5 | must-have | T006-T007, T010-T013 | V5, V6, V8-V10 | tray setup, command reference | partial | Honest rows, actions, and deterministic non-colour-only Linux logo badges, including connecting, pass local checks; installed connecting-state and remaining live acceptance remain | -| Requirement 6 | must-have | T006-T007, T011-T013 | V6, V10 | tray setup, troubleshooting | partial | Healthy serve silence and explicit one-shot output passed; live idle capture remains | -| Requirement 7 | must-have | T001, T005, T008-T013 | V1-V3, V7-V11, V13 | requirements, architecture, installation | partial | Linux activation passed; remaining live acceptance stays in T011, while the supported general deployment workflow is routed to Spec 011 | +| Requirement 1 | must-have | T001, T003-T005, T007, T011-T013 | V1, V2, V4, V5, V10 | requirements, architecture, tray setup | partial-routed | Human decision superseded resident event delivery; reusable snapshot semantics are retained and daemonless delivery is routed to Spec 011 Requirement 9. | +| Requirement 2 | must-have | T001-T005, T007-T008, T011-T013 | V1-V5, V7, V10-V11 | requirements, architecture | partial-routed | Human decision superseded continuous resident authorization; allowlisted models are retained and bounded authentication is routed to Spec 011. | +| Requirement 3 | must-have | T001-T002, T006-T007, T011-T013 | V1, V5-V6, V10 | requirements, tray setup | complete | Last-success, health/activity separation, schedule health, local tray projection, and `Never` fallback are implemented and regression-tested. | +| Requirement 4 | must-have | T002-T005, T007-T011, T013 | V2-V5, V7-V10 | architecture, troubleshooting | partial-routed | Human decision superseded resident reconnect and heartbeat behavior; process independence and daemonless resilience are routed to Spec 011. | +| Requirement 5 | must-have | T006-T007, T010-T013 | V5, V6, V8-V10 | tray setup, command reference | complete | Honest three-row presentation, actions, deterministic non-colour-only badges, and connecting state passed local and package checks. | +| Requirement 6 | must-have | T006-T007, T011-T013 | V6, V10 | tray setup, troubleshooting | partial-routed | One-shot output silence is retained; human decision superseded idle resident service operation and zero-idle acceptance is routed to Spec 011 Requirement 9. | +| Requirement 7 | must-have | T001, T005, T008-T013 | V1-V3, V7-V11, V13 | requirements, architecture, installation | partial-routed | Human decision superseded rollout of the resident architecture; supported daemonless deployment and remaining platform acceptance are routed to Spec 011. | ## Correctness Property Coverage @@ -65,9 +65,16 @@ last_reviewed: 2026-07-27 ## Open Decision Impact -There are no open decisions. Changing the dedicated event channel, -invalidation-plus-snapshot approach, continuous authorization, or Linux-now/ -Windows-contract slice requires explicit design reconciliation and approval. +The dedicated privileged event channel, continuous resident backend, and +heartbeat design were rejected by explicit user direction on 2026-07-28 after +T011 live diagnosis demonstrated an idle CPU feedback loop. This is a resolved +project-direction decision, not an open implementation choice. + +Spec 010 may preserve independently valid snapshot semantics, last-success +meaning, and tray presentation. It must not promote or resume acceptance of the +resident backend. Spec 011 owns traceability for zero idle residency, +short-lived authenticated helpers, atomically published sanitized status, and +daemonless live acceptance. ## Verification Gate Key diff --git a/docs/specs/010-event-driven-tray-status/verification.md b/docs/specs/010-event-driven-tray-status/verification.md index 8a957fa..007a09d 100644 --- a/docs/specs/010-event-driven-tray-status/verification.md +++ b/docs/specs/010-event-driven-tray-status/verification.md @@ -53,13 +53,13 @@ closure. | Requirement | Acceptance criteria covered | Evidence | Residual risk | |-------------|-----------------------------|----------|---------------| -| Requirement 1 | AC1-AC5 | T001, T003-T005, T007, T011 | pending | -| Requirement 2 | AC1-AC5 | T001-T005, T008, T011-T012 | pending | -| Requirement 3 | AC1-AC5 | T001-T002, T006-T007, T011 | pending | -| Requirement 4 | AC1-AC5 | T002-T005, T007-T011 | pending | -| Requirement 5 | AC1-AC7 | T006-T007, T010-T011 | partial-pass; exact three-row health/activity projection, deterministic Linux badges, and honest never-run/failure/missed projection passed; live acceptance pending | -| Requirement 6 | AC1-AC4 | T006-T007, T011 | pending | -| Requirement 7 | AC1-AC5 | T001, T005, T008-T011 | pending | +| Requirement 1 | AC1-AC5 | T001, T003-T005, T007, T011 | partial-routed; resident delivery was human-superseded and daemonless delivery belongs to Spec 011 | +| Requirement 2 | AC1-AC5 | T001-T005, T008, T011-T012 | partial-routed; reusable authorization contracts remain, bounded activation belongs to Spec 011 | +| Requirement 3 | AC1-AC5 | T001-T002, T006-T007, T011 | pass; health/activity/last-success and schedule semantics are regression-covered | +| Requirement 4 | AC1-AC5 | T002-T005, T007-T011 | partial-routed; resident reconnect/heartbeat acceptance was human-superseded by the zero-idle decision | +| Requirement 5 | AC1-AC7 | T006-T007, T010-T011 | pass; exact three-row health/activity projection, deterministic Linux badges, and honest never-run/failure/missed projection passed | +| Requirement 6 | AC1-AC4 | T006-T007, T011 | partial-routed; one-shot silence passed and idle resident operation was rejected | +| Requirement 7 | AC1-AC5 | T001, T005, T008-T011 | partial-routed; resident rollout was human-superseded and daemonless deployment belongs to Spec 011 | ## Correctness Property Coverage @@ -80,7 +80,7 @@ closure. | Event-driven Linux tray status | T001-T007, T009-T011 | partial | none | rollout tasks T009-T011 | yes | T001-T007 implementation and Phase 2 checkpoint passed | | Continuous authorization/privacy | T001-T005, T008, T011-T012 | partial | Windows live revocation | Windows follow-up spec | yes for Linux; no for Windows live | | Accurate and quiet tray UX | T001-T002, T006-T007, T011 | partial | Live desktop acceptance remains | T007, T011 | yes | Correct local last-success rows and silent serve passed in T006 | -| Portable Windows architecture | T001, T008 | covered | Concrete Windows service and live acceptance | follow-up spec or issue | no after routing | Injected token-derived named-pipe event contract passed 232-test checkpoint | +| Portable Windows architecture | T001, T008 | covered | Concrete Windows service and live acceptance | `docs/specs/011-protected-system-deployment/requirements.md` | no after routing | Injected token-derived named-pipe event contract passed 232-test checkpoint | | Full desktop application | none | out-of-scope | Product UI | backlog/roadmap | no | charter and requirements | ## Agent Readiness Evidence @@ -204,8 +204,10 @@ silently folding graceful shutdown time into that latency result. ## Residual Risks -- A long-lived subscription expands denial-of-service and revocation concerns; - bound subscribers and reauthorize each event/heartbeat. +- The long-lived privileged backend and subscription are rejected architecture, + not residual accepted risk. Live T011 evidence showed a read-notify-read loop + that accumulated more than five CPU-hours during roughly eight hours of + uptime without backup or retention work. - Cross-process record changes may race event publication; watcher uncertainty and session/snapshot recovery are mandatory. - Desktop toolkits differ in dynamic menu behavior; keep platform tests and @@ -218,33 +220,34 @@ silently folding graceful shutdown time into that latency result. | Spec content | Durable destination or deferral | Status | Evidence | |--------------|---------------------------------|--------|----------| -| Requirements and security behavior | `docs/1-requirements/system-operations.md` | pending | | -| Architecture and platform contract | `docs/2-architecture/system-architecture.md` | pending | | -| Component/interface ownership | `docs/3-implementation/service-layer-integration.md` | pending | | -| Tray setup, menu, reconnect, rollback | `docs/SYSTEM-TRAY-SETUP.md` | pending | | -| Command/action reference | `docs/reference/timelocker-cli-command-hierarchy.md` | pending | | -| Troubleshooting and installation | user guides and version process | pending | | -| Windows live implementation | follow-up spec or issue | pending routing | | -| Full desktop UI | product backlog/roadmap | excluded | | +| Requirements and security behavior | `docs/1-requirements/system-operations.md` | complete | Zero-idle authorization and resource-residency contracts promoted 2026-08-12. | +| Architecture and platform contract | `docs/2-architecture/system-architecture.md` | complete | Rejected resident implementation and accepted daemonless target promoted 2026-08-12. | +| Component/interface ownership | `docs/3-implementation/service-layer-integration.md` | complete | Transitional resident seams and bounded target ownership documented. | +| Tray setup, menu, reconnect, rollback | `docs/SYSTEM-TRAY-SETUP.md` | complete | Accepted three-row semantics separated from rejected transport. | +| Command/action reference | `docs/reference/timelocker-cli-command-hierarchy.md` | no change | Existing public commands remain unchanged in the accepted Spec 010 slice. | +| Troubleshooting and installation | user guides and version process | complete | Temporary resident-backend shutdown and consequences documented. | +| Windows live implementation | Spec 011 | routed | Platform acceptance belongs to the daemonless implementation. | +| Full desktop UI | `CHARTER.md` current-scope exclusion | excluded | | ### Spec Cleanup Decision - **Cleanup action:** remove after final spec commit and promotion - **Reason:** Repository policy uses Git plus compact history indexes. -- **Final spec commit:** pending +- **Final spec commit:** recorded by closure workflow - **Closure log path:** `docs/history/spec-closure-log.md` -- **Closure log entry updated:** no -- **Closure cleanup commit:** pending -- **Active indexes updated:** no -- **Durable docs linked back to evidence where useful:** no +- **Closure log entry updated:** by closure workflow +- **Closure cleanup commit:** recorded by closure workflow +- **Active indexes updated:** by closure workflow +- **Durable docs linked back to evidence where useful:** yes - **Residual spec-only content:** none expected ## Ship Or Closure Risk -- **Risk level:** high until security review and live acceptance; expected - medium after all gates +- **Risk level:** high; the deployed resident backend is explicitly rejected + and must not proceed to acceptance or release - **Breaking change:** coherent local protocol/release upgrade required -- **Blast radius checked:** no +- **Blast radius checked:** yes - bounded to system-control/tray documentation and + the 274-test system-control regression - **Rollback path:** designed, not yet verified - **Requires human review:** yes - **Release notes needed:** yes if shipped in a release @@ -261,11 +264,12 @@ weaken those boundaries. ## Readiness Decision -- **Ready to implement:** yes - lifecycle review passed and user approval was - recorded on 2026-07-27 -- **Ready for promotion:** no +- **Ready to implement:** no - the 2026-07-27 approval does not cover continued + implementation of the resident backend after the 2026-07-28 architecture + decision +- **Ready for promotion:** yes - accepted content is in durable documents - **Ready for release:** no -- **Ready for closure:** no +- **Ready for closure:** yes - final commit and cleanup remain lifecycle actions ## Related Artifacts diff --git a/docs/specs/011-protected-system-deployment/README.md b/docs/specs/011-protected-system-deployment/README.md index 6f18493..235f29c 100644 --- a/docs/specs/011-protected-system-deployment/README.md +++ b/docs/specs/011-protected-system-deployment/README.md @@ -12,9 +12,10 @@ last_reviewed: 2026-07-28 ## Purpose Replace acceptance-specific deployment commands, operator-authored manifests, -and externally managed temporary artifacts with one supported, repeatable, -transactional workflow for installing, upgrading, inspecting, and rolling back -protected TimeLocker releases. +externally managed temporary artifacts, and the continuously resident protected +backend with one supported, repeatable, daemonless workflow for installing, +upgrading, inspecting, rolling back, querying, and invoking bounded protected +TimeLocker operations. The package exists because Spec 010 proved the immutable-release architecture but also demonstrated that its T011 acceptance harness is not a general @@ -22,13 +23,14 @@ administrator deployment interface. ## Current Stage -- Requirements are drafted for review. +- Requirements are being reconciled with the approved zero-idle-residency + constraint. - Design and task authoring have not started. - Implementation is not approved. -- Spec 010 remains the active implementation and acceptance package. -- Spec 011 requirements and design may proceed concurrently because they do not - change runtime behavior. Implementation must wait until Spec 010 completes - T013 closure and promotes its accepted deployment behavior. +- Spec 010 resident-backend acceptance is halted; only independently valid + status semantics may be retained. +- Spec 011 now owns removal of the resident privileged backend as well as the + supported deployment transaction. ## Package diff --git a/docs/specs/011-protected-system-deployment/canonical-context.md b/docs/specs/011-protected-system-deployment/canonical-context.md index 96018fc..90c6f92 100644 --- a/docs/specs/011-protected-system-deployment/canonical-context.md +++ b/docs/specs/011-protected-system-deployment/canonical-context.md @@ -28,9 +28,9 @@ or live system evidence. | Source | Authority reason | Handling | |--------|------------------|----------| | `AGENTS.md` and `docs/guides/ai-agent/` | Repository workflow and operational instructions | Read before authoring, implementation, validation, or deployment. | -| `CHARTER.md` | Project mandate, boundaries, governance, and approval rights | Stop if deployment work expands into a remote management service or unattended product update policy. | +| `CHARTER.md` | Project mandate, boundaries, governance, approval rights, and zero-idle-residency constraint | Reject any design that requires a continuously resident TimeLocker daemon; also stop if work expands into a remote management service or unattended product update policy. | | Current source, tests, package metadata, and live host evidence | Implementation and runtime truth | Reconcile conflicts; proposed prose does not override current behavior. | -| `docs/1-requirements/system-operations.md` | Accepted protected-operation and administrator boundary | Extend without weakening authorization, immutable release, or fail-closed requirements. | +| `docs/1-requirements/system-operations.md` | Accepted protected-operation, administrator, and resource-residency boundary | Extend without weakening authorization, immutable release, fail-closed behavior, or zero idle service residency. | | `docs/processes/version-management.md` | Accepted release preparation, publication, activation, and rollback separation | Preserve the publication/deployment boundary. | ## Spec-Canonical Working Sources @@ -50,6 +50,7 @@ or live system evidence. | requirements | `docs/1-requirements/system-operations.md` | reviewed 2026-07-26 | adapted | Root-only maintenance and immutable release requirements | same path | | requirements | `scripts/deploy_t011_linux.py` | commit `a67c83ac09ac29b94a3ed481ee536b3380db3337` | background | Proven acceptance transaction and failure lessons | future supported deployment implementation | | requirements | Spec 010 T011 live evidence | 2026-07-27 to 2026-07-28 | summarized | Successful Linux Mint activation and retained rollback state | verification and operator runbook | +| requirements | Spec 010 T011 idle-resource diagnosis | 2026-07-28 | supersedes resident-runtime acceptance | Read-only JSON access was observed to emit a change and sustain a tray snapshot loop; the deployed unit accumulated more than five CPU-hours in roughly eight hours | daemonless design, regression tests, and live idle acceptance | ## Non-Canonical Background Sources @@ -64,6 +65,7 @@ or live system evidence. | Spec-local content | Durable destination or route | Required before closure | |--------------------|------------------------------|-------------------------| | Supported install, upgrade, status, and rollback behavior | `docs/1-requirements/system-operations.md` | yes | +| Zero-idle-residency and short-lived protected execution | `CHARTER.md`, `docs/1-requirements/system-operations.md`, and `docs/2-architecture/system-architecture.md` | yes | | Deployment components, trust boundaries, and platform adapters | `docs/2-architecture/system-architecture.md` | yes | | Administrator procedure and troubleshooting | `docs/guides/user/installation.md` and a durable deployment runbook | yes | | Release artifact and host activation relationship | `docs/processes/version-management.md` | yes | diff --git a/docs/specs/011-protected-system-deployment/requirements.md b/docs/specs/011-protected-system-deployment/requirements.md index ee99fcf..96b71f5 100644 --- a/docs/specs/011-protected-system-deployment/requirements.md +++ b/docs/specs/011-protected-system-deployment/requirements.md @@ -20,7 +20,11 @@ repeatable rollback. Spec 010 therefore used a repository-owned T011 acceptance harness plus manually supplied commit IDs, hashes, manifests, and temporary artifact paths. That harness successfully activated the accepted Linux Mint release, but it is -not an appropriate long-term installation or upgrade interface. +not an appropriate long-term installation or upgrade interface. Live operation +also showed that its continuously resident privileged backend can enter a +read-notify-read feedback loop and consume CPU while no backup or retention +operation is running. A resident TimeLocker daemon is therefore rejected as an +architectural requirement, not merely scheduled for performance tuning. ## Goals @@ -34,12 +38,16 @@ not an appropriate long-term installation or upgrade interface. execution as distinct approval boundaries. - Preserve a portable deployment model while delivering and accepting Linux systemd behavior first. +- Replace the resident privileged backend with bounded one-shot helpers and + sanitized atomically written status state. ## Non-Goals - Publishing TimeLocker to PyPI or automatically creating a GitHub release. - An unattended update daemon, silent automatic upgrades, or remote fleet management. +- A continuously resident TimeLocker-owned privileged control daemon, event + broker, heartbeat process, or status service. - Changing backup, restore, selection-set, retention-policy, or repository credential semantics. - Triggering backup or retention as a side effect of deployment. @@ -263,8 +271,8 @@ reading implementation-specific scratch files. whether protected mutation began, and provide the evidence location and safe next action. 4. THE STATUS OPERATION SHALL report selected and previous releases, transaction - attention state, service/socket state, and backup/retention timer health - without triggering any operation. + attention state, one-shot helper readiness, and backup/retention timer health + without leaving a TimeLocker service process resident. 5. THE ENTRYPOINT SHALL distinguish warnings, failed validation, failed activation with successful recovery, and failed recovery through stable result codes. @@ -282,7 +290,8 @@ support. 1. THE ARTIFACT, manifest, transaction state, evidence, activation, status, and rollback contracts SHALL be platform-neutral. 2. Linux SHALL implement root-owned paths, stable launchers, peer-authorized - local services, and systemd unit/timer verification through a Linux adapter. + short-lived helpers or one-shot services, and systemd unit/timer verification + through a Linux adapter. 3. Windows-specific service control, named-pipe authorization, installation paths, and elevation SHALL remain behind injectable platform contracts. 4. THIS PACKAGE SHALL NOT claim live Windows deployment until install, upgrade, @@ -291,6 +300,35 @@ support. 5. WHERE a platform operation is unsupported, THE ENTRYPOINT SHALL fail explicitly without partial installation. +### Requirement 9: Zero Idle Service Residency + +**User Story:** As an operator, I want TimeLocker to consume no service CPU or +resident memory while idle, so that backup tooling does not waste host +resources or create daemon-specific failure modes. + +**Priority:** must-have + +#### Acceptance Criteria + +1. WHILE no backup, retention, restore, explicit query, or explicit control + action is running, THE SYSTEM SHALL have no TimeLocker-owned privileged + process resident. +2. Scheduled backup and retention SHALL execute as bounded one-shot jobs and + SHALL NOT depend on a continuously resident TimeLocker scheduler or control + service. +3. Protected queries and manual actions SHALL activate a short-lived + authenticated helper that exits after one bounded request or operation. +4. Protected workers SHALL atomically publish a sanitized status snapshot that + an authorized unprivileged tray can read without invoking a privileged + status service. +5. The optional tray SHALL observe status-file changes directly and SHALL NOT + require a privileged event socket, heartbeat, or resident event broker. +6. Reading status or run records SHALL NOT itself publish a change event or + cause an unbounded read-notify-read cycle. +7. Automated and live acceptance SHALL prove zero TimeLocker privileged + processes and zero TimeLocker service CPU consumption during an idle + observation interval of at least 90 seconds. + ## Correctness Properties - **CP-001:** No protected selector, service, launcher, or timer mutation occurs @@ -309,6 +347,8 @@ support. manifests, and transaction records for every success and failure path. - **CP-007:** Platform-specific paths, service management, identity, and elevation are reachable only through the selected platform adapter. +- **CP-008:** When the set of active protected operations is empty, the set of + resident TimeLocker-owned privileged processes is also empty. ## Technical Context @@ -319,9 +359,10 @@ support. contract retained - **Constraints:** root-only mutation; offline/local artifact support; no credential disclosure; no caller pyenv, home, checkout, or working-directory - dependency after installation + dependency after installation; no resident TimeLocker daemon - **Performance Goals:** local validation and status should complete promptly; - network artifact acquisition, when supported, must have explicit timeouts + network artifact acquisition, when supported, must have explicit timeouts; + idle privileged CPU and resident memory are both zero ## Success Criteria @@ -342,6 +383,8 @@ support. troubleshooting documentation contains no `/tmp`-based operator workflow. - **SC-007:** Linux live acceptance is recorded; Windows support remains explicitly contractual until separately accepted. +- **SC-008:** Linux live acceptance shows no TimeLocker-owned privileged + process during at least 90 seconds with no protected operation running. ## Design Decisions Deferred To The Next Stage diff --git a/docs/specs/README.md b/docs/specs/README.md index a392505..c92aa61 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -16,21 +16,23 @@ accepted content has been promoted and the package is closed. ## Current Packages - [`010-event-driven-tray-status`](./010-event-driven-tray-status/README.md) - - active implementation package for replacing tray status polling with an - authenticated event subscription and accurate, quiet status presentation. - Implementation was approved on 2026-07-27; T011 live acceptance is in - progress. + active implementation package whose resident-backend acceptance is halted. + The status semantics remain useful, but the privileged event-broker design + conflicts with the approved zero-idle-residency constraint discovered during + T011 live acceptance. - [`011-protected-system-deployment`](./011-protected-system-deployment/README.md) - draft requirements package for replacing acceptance-specific deployment - commands and temporary operator inputs with one supported transactional - install, upgrade, status, and rollback workflow. + commands and the resident control backend with a daemonless transactional + install, upgrade, status, rollback, query, and action workflow. ## Active-Package Sequencing -Spec 010 remains the only implementation-approved package. Spec 011 may proceed -through requirements and design concurrently because those documentation stages -do not change the runtime surface under live acceptance. Spec 011 implementation -must wait until Spec 010 completes T013 promotion and closure. +Further live acceptance of Spec 010's resident backend is halted. Spec 010 must +record the rejected runtime design and preserve only independently valid status +semantics before it can be dispositioned. Spec 011 is the owning package for +the daemonless protected-operation and deployment design. Its requirements and +design may proceed now; implementation still requires explicit approval after +the revised design and task package are reviewed. Specs 007, 008, and 009 are closed. Their final package commits, cleanup commits, verification summaries, and residual follow-up are recorded in From 41227465c48bee596c9bb5cc22d1f0837a3f82d7 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:06:29 +0100 Subject: [PATCH 64/72] docs(spec): remove closed spec 010 package --- docs/history/spec-archive-index.md | 1 + docs/history/spec-closure-log.md | 19 + .../010-event-driven-tray-status/README.md | 58 --- .../canonical-context.md | 86 ---- .../change-impact.md | 96 ---- .../010-event-driven-tray-status/design.md | 322 ------------ .../requirements.md | 304 ------------ .../010-event-driven-tray-status/tasks.md | 467 ------------------ .../traceability.md | 97 ---- .../verification.md | 279 ----------- docs/specs/README.md | 26 +- 11 files changed, 30 insertions(+), 1725 deletions(-) delete mode 100644 docs/specs/010-event-driven-tray-status/README.md delete mode 100644 docs/specs/010-event-driven-tray-status/canonical-context.md delete mode 100644 docs/specs/010-event-driven-tray-status/change-impact.md delete mode 100644 docs/specs/010-event-driven-tray-status/design.md delete mode 100644 docs/specs/010-event-driven-tray-status/requirements.md delete mode 100644 docs/specs/010-event-driven-tray-status/tasks.md delete mode 100644 docs/specs/010-event-driven-tray-status/traceability.md delete mode 100644 docs/specs/010-event-driven-tray-status/verification.md diff --git a/docs/history/spec-archive-index.md b/docs/history/spec-archive-index.md index 1ba0163..8529805 100644 --- a/docs/history/spec-archive-index.md +++ b/docs/history/spec-archive-index.md @@ -16,6 +16,7 @@ contracts. | Spec ID | Title | Package path | Status | Final spec commit | Cleanup commit | Closure action | Durable destinations | Verification | |---------|-------|--------------|--------|-------------------|----------------|----------------|----------------------|--------------| +| 010-event-driven-tray-status | Event-driven tray status requirements | `docs/specs/010-event-driven-tray-status/` | removed | 8820e65 | pending-cleanup-commit | removed | `CHARTER.md`; `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/3-implementation/service-layer-integration.md`; `docs/SYSTEM-TRAY-SETUP.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/specs/011-protected-system-deployment/requirements.md` | `docs/history/spec-closure-log.md` | | 009-system-cli-tray-retention | System CLI, independent tray, retention, and control | removed; recover from Git | removed | `d4ce71dd05cb5d7278bf36a9fc43e557d68e1e31` | aba95875f453dd6abf39a1fdc6af25fd38c62db4 | removed | `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/2-architecture/scheduling-system.md`; `docs/3-implementation/service-layer-integration.md`; `docs/guides/user/installation.md`; `docs/guides/developer/scheduling-guide.md`; `docs/SYSTEM-TRAY-SETUP.md`; `docs/reference/timelocker-cli-command-hierarchy.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/processes/version-management.md`; `docs/README.md`; `docs/DOCUMENTATION-STATUS.md`; `docs/specs/README.md` | `docs/history/spec-closure-log.md` | | 007-release-readiness-stabilization | Release readiness stabilization requirements | removed; recover from Git | removed | `7fd11f9aa1cbc670d5e8b429aede4a7c01e185a4` | `6334af0690b5b9e8b6575042269e5b73914a9295` | removed | `README.md`; `CHANGELOG.md`; `.github/workflows/test-suite.yml`; `.github/workflows/artifact-smoke.yml`; `.github/workflows/release-validation.yml`; `.github/workflows/release.yml`; `docs/4-testing/README.md`; `docs/guides/user/installation.md`; `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md`; `docs/processes/version-management.md`; `docs/processes/README.md` | `docs/history/spec-closure-log.md` | | 008-npbackup-migration-parity | NPBackup migration parity requirements | removed; recover from Git | removed | `5830194` | `1bfea08` | removed | `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md` | `docs/history/spec-closure-log.md` | diff --git a/docs/history/spec-closure-log.md b/docs/history/spec-closure-log.md index bd63cb9..e8dbfb1 100644 --- a/docs/history/spec-closure-log.md +++ b/docs/history/spec-closure-log.md @@ -15,6 +15,25 @@ final spec commit preserves the complete package. ## Entries +### 2026-08-12 - 010-event-driven-tray-status + +- **Spec:** `docs/specs/010-event-driven-tray-status/` +- **Title:** Event-driven tray status requirements +- **Final spec commit:** `8820e65` +- **Closure cleanup commit:** `pending-cleanup-commit` +- **Closure action:** removed +- **Durable docs updated:** + - `CHARTER.md` + - `docs/1-requirements/system-operations.md` + - `docs/2-architecture/system-architecture.md` + - `docs/3-implementation/service-layer-integration.md` + - `docs/SYSTEM-TRAY-SETUP.md` + - `docs/guides/user/backup-operations-troubleshooting.md` + - `docs/specs/011-protected-system-deployment/requirements.md` +- **Verification summary:** Closure validation not yet executed. +- **Residual risks:** + - none +- **Follow-up:** none ### 2026-07-26 - 009-system-cli-tray-retention - **Spec:** removed; recover from Git diff --git a/docs/specs/010-event-driven-tray-status/README.md b/docs/specs/010-event-driven-tray-status/README.md deleted file mode 100644 index 3ef3dca..0000000 --- a/docs/specs/010-event-driven-tray-status/README.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -title: Event-driven tray status -doc_type: spec -artifact_type: overview -status: active -owner: Auriora Team -last_reviewed: 2026-08-12 ---- - -# Event-Driven Tray Status - -## Purpose - -Replace the tray's periodic status polling with an authenticated event-driven -status path, and make the status it presents accurate, quiet, and useful. - -This is one package because the backend subscription contract, status snapshot, -tray presentation, authorization, deployment, and recovery behavior form one -end-to-end feature. Splitting them would leave either an unused protocol or a -tray without a reliable source of truth. - -## Current Stage - -- Requirements, design, tasks, traceability, change impact, canonical context, - and verification planning produced a deployed Linux acceptance candidate. -- **Architecture decision, 2026-07-28:** approval of the continuously resident - privileged backend is withdrawn. TimeLocker must have zero idle service - residency; protected queries and actions must use bounded one-shot execution. -- T011 live acceptance exposed a read-notify-read feedback loop in the resident - backend. Further acceptance, promotion, and release work for that runtime - design is halted. -- Accurate status semantics and tray presentation remain reusable, but the - transport and privileged-process design require disposition through Spec 011. -- T011 is dispositioned to Spec 011; T012 review and T013 promotion/closure are - the only remaining work in this package. -- There are no active predecessor specs. Spec 009 is closed and its promoted - durable documents are the current-state baseline. -- The working tree already contains the separately requested removal of the - inactive `Open TimeLocker` tray item. Implementation must preserve and - reconcile that change rather than overwrite it. - -## Package - -- [Requirements](./requirements.md) -- [Technical design](./design.md) -- [Tasks](./tasks.md) -- [Change impact](./change-impact.md) -- [Traceability](./traceability.md) -- [Verification](./verification.md) -- [Canonical context](./canonical-context.md) - -## Approval Boundary - -No further implementation or live acceptance of the resident backend is -approved. Documentation reconciliation and safe shutdown guidance are approved. -The user explicitly approved Spec 011 implementation on 2026-08-12 after Spec -010 closure. Protected host mutation, live backup or retention execution, -publication, and rollback retain their separate operational approval gates. diff --git a/docs/specs/010-event-driven-tray-status/canonical-context.md b/docs/specs/010-event-driven-tray-status/canonical-context.md deleted file mode 100644 index 9e7affd..0000000 --- a/docs/specs/010-event-driven-tray-status/canonical-context.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: Event-driven tray status canonical context -doc_type: spec -artifact_type: canonical-context -status: active -owner: Auriora Team -last_reviewed: 2026-07-27 ---- - -# Canonical Context - -## Purpose - -This package changes behavior promoted by closed Spec 009 and spans several -durable documents. This map prevents removed specification history or proposed -event behavior from being mistaken for current implementation truth. - -## Authority Hierarchy - -The package is canonical only for the approved implementation slice while -active. It does not override user/platform instructions, `AGENTS.md`, -`CHARTER.md`, security policy, source contracts, tests, generated artifacts, -or live system evidence. - -## Always-Canonical External Sources - -| Source | Authority reason | Handling | -|--------|------------------|----------| -| `AGENTS.md` and `docs/guides/ai-agent/` | Repository behavior and workflow instructions | Read before implementation and validation. | -| `CHARTER.md` | Mandate, boundaries, governance, and approval rights | Stop if scope expands to a full GUI, remote service, or changed security boundary. | -| Current source, tests, package metadata, and live evidence | Implementation and runtime truth | Reconcile conflicts; do not overwrite based on draft prose. | -| `pyproject.toml` and `docs/4-testing/README.md` | Test discovery and final coverage profile | Use focused tests first and the configured profile before closure. | - -## Spec-Canonical Working Sources - -| Source | Role | Scope | Notes | -|--------|------|-------|-------| -| `requirements.md` | Intended observable behavior | Spec 010 | Requires approval before implementation. | -| `design.md` | Snapshot/event architecture | Spec 010 | Reconcile if implementation changes transport or security decisions. | -| `tasks.md` | Dependency-aware execution index | Spec 010 | Never implement from tasks alone. | -| `traceability.md` | Requirement/task/verification routing | Spec 010 | Gaps block readiness. | -| `verification.md` | Required evidence and approval gates | Spec 010 | Live host actions require explicit approval. | - -## Imported Sources - -| Spec path | Source path | Source revision or date | Status | Canonical scope | Promotion target | -|-----------|-------------|-------------------------|--------|-----------------|------------------| -| requirements/design/change impact | `docs/1-requirements/system-operations.md` | reviewed 2026-07-26 | summarized | Current authorization, tray, and portability baseline | same path | -| requirements/design/change impact | `docs/2-architecture/system-architecture.md` | reviewed 2026-07-26 | supersedes | Polling tray boundary for this slice only | same path | -| design/tasks | `docs/3-implementation/service-layer-integration.md` | reviewed 2026-07-18 | adapted | Existing `system_control` ownership | same path | -| requirements/change impact | `docs/SYSTEM-TRAY-SETUP.md` | current checkout | supersedes | Current menu and polling-related operation for this slice | same path | - -## Non-Canonical Background Sources - -| Source | Reason non-canonical | Handling | -|--------|----------------------|----------| -| Removed `docs/specs/009-system-cli-tray-retention/` recovered from Git | Closed delivery scaffolding | Use only for historical rationale; durable promoted docs own current state. | -| `docs/history/spec-closure-log.md` and archive index | Lifecycle history | Use for identity and provenance, not product behavior. | -| Generic integration event-bus documentation | Different in-process integration boundary | Do not reuse as the protected system event contract without explicit reconciliation. | - -## Promotion Map - -| Spec-local content | Durable destination or route | Required before closure | -|--------------------|------------------------------|-------------------------| -| Event-driven tray behavior and authorization | `docs/1-requirements/system-operations.md` | yes | -| Snapshot/event architecture and platform split | `docs/2-architecture/system-architecture.md` | yes | -| Component ownership and interfaces | `docs/3-implementation/service-layer-integration.md` | yes | -| Setup and accepted status-row behavior | `docs/SYSTEM-TRAY-SETUP.md` | yes | -| Failure and temporary shutdown behavior | `docs/guides/user/backup-operations-troubleshooting.md` | yes | -| Concrete Windows live service and acceptance | `docs/specs/011-protected-system-deployment/requirements.md` | yes, as routed work | -| Full desktop application exclusion | `CHARTER.md` | no implementation; retain exclusion | - -## Worktree Caution - -The working tree contains a user-requested, tested removal of the inactive -`Open TimeLocker` menu item that predates this package. It is implementation -evidence to reconcile under T006, not permission to revert or silently broaden -the current commit. - -## Related Artifacts - -- Requirements: [requirements.md](./requirements.md) -- Design: [design.md](./design.md) -- Tasks: [tasks.md](./tasks.md) -- Change impact: [change-impact.md](./change-impact.md) -- Verification: [verification.md](./verification.md) diff --git a/docs/specs/010-event-driven-tray-status/change-impact.md b/docs/specs/010-event-driven-tray-status/change-impact.md deleted file mode 100644 index 8d8ded8..0000000 --- a/docs/specs/010-event-driven-tray-status/change-impact.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: Event-driven tray status change impact -doc_type: spec -artifact_type: change-impact -status: active -owner: Auriora Team -last_reviewed: 2026-07-27 ---- - -# Change Impact - -## Purpose - -Record the durable behavior changed by event-driven tray status and the -documents that must describe the accepted implementation before closure. - -## Durable Source Mapping - -| Source | Current behavior relied on | Confidence | Notes | -|--------|----------------------------|------------|-------| -| `docs/1-requirements/system-operations.md` | Independent authorized tray and safe system visibility. | high | Modify tray and platform requirements. | -| `docs/2-architecture/system-architecture.md` | Tray polls the protected AF_UNIX backend. | high | Supersede polling with snapshot plus events. | -| `docs/3-implementation/service-layer-integration.md` | `system_control` owns protocol, backend, records, and tray client. | high | Add event ownership without changing layer ownership. | -| `docs/SYSTEM-TRAY-SETUP.md` | Current menu, authorization, Linux setup, and troubleshooting. | high | Update menu and event-channel operation. | -| `docs/reference/timelocker-cli-command-hierarchy.md` | Tray executable and reserved actions. | high | Remove placeholder UI action from visible behavior. | -| `docs/guides/user/backup-operations-troubleshooting.md` | Current stale-status and backend guidance. | high | Add event socket and reconnect diagnostics. | - -## Change Type - -- **Primary type:** feature -- **Secondary types:** bug_fix, refactor, operational, clarification -- **Breaking change:** no for documented CLI actions; event protocol requires a - coherent release -- **Durable docs required:** yes -- **External behavior affected:** yes, optional tray and deployment assets - -## Proposed Changes - -| Change | Type | Source of truth | New durable destination | Promotion required | -|--------|------|-----------------|-------------------------|-------------------| -| Replace tray status polling with authenticated event invalidations and snapshots. | modify | system architecture and code | `docs/2-architecture/system-architecture.md` | yes | -| Define continuous subscription authorization and privacy. | add | system operations requirements | `docs/1-requirements/system-operations.md` | yes | -| Make last backup mean last successful completion. | bug_fix | run model and tray code | requirements and tray setup | yes | -| Replace non-functional status/open menu actions with honest status rows. | bug_fix | tray code | `docs/SYSTEM-TRAY-SETUP.md` | yes | -| Silence healthy background tray output. | bug_fix | tray entrypoint | tray setup and troubleshooting | yes | -| Add event socket, probes, and rollback checks. | operational | deployment code/assets | installation, tray setup, version management | yes | -| Preserve Windows-portable contracts without support claim. | clarify | platform adapters | requirements and architecture | yes | - -## Promotion Targets - -| Spec content | Durable destination | Promotion status | Notes | -|--------------|---------------------|------------------|-------| -| Accepted behavior and security invariants | `docs/1-requirements/system-operations.md` | pending | | -| Snapshot/event architecture and platform boundary | `docs/2-architecture/system-architecture.md` | pending | | -| Component ownership and integration seams | `docs/3-implementation/service-layer-integration.md` | pending | | -| Menu, setup, failure, and restart behavior | `docs/SYSTEM-TRAY-SETUP.md` | pending | | -| Tray executable/action reference | `docs/reference/timelocker-cli-command-hierarchy.md` | pending | | -| Event-channel diagnostics | `docs/guides/user/backup-operations-troubleshooting.md` | pending | | -| Installation and release activation | `docs/guides/user/installation.md`, `docs/processes/version-management.md` | pending | | -| Test profiles or live acceptance guidance | `docs/4-testing/` if reusable guidance changes | pending | Promote only durable procedure. | - -## Unchanged Durable Areas - -| Durable area | Reviewed source | Reason unchanged | -|--------------|-----------------|------------------| -| Project mandate | `CHARTER.md` | Optional local tray status remains within the CLI-first mandate. | -| Restic backup/restore semantics | current backup and recovery docs | Event delivery does not change Restic execution or repository format. | -| Retention policy | `docs/1-requirements/system-operations.md` | Trigger and policy semantics remain unchanged. | -| Full desktop UI scope | `CHARTER.md`, `docs/README.md` | Still excluded. | - -## Bug Fix Details - -- **Observed behavior:** newest backup attempt start time is labeled last backup; - healthy polling prints every cycle; `View Status` refreshes but opens no view; - `Open TimeLocker` has no app to open. -- **Expected behavior:** last successful completion is explicit, healthy service - is quiet, status is visible in menu rows, and placeholder UI actions are - absent. -- **Root cause evidence:** `tray_client.py` selects the latest run by - `started_at`; `tray_entry.py` prints every refresh; platform menus define - actions without a rendered view or registered app callback. -- **Regression risk:** moderate because status, security, IPC, packaging, and - user-session presentation cross process and platform boundaries. -- **Durable doc update needed:** yes, all promotion targets above. - -## Open Questions - -None. Scope expansion to a full UI or live Windows deployment requires a -separate approved intake. - -## Related Artifacts - -- Requirements: [requirements.md](./requirements.md) -- Design: [design.md](./design.md) -- Tasks: [tasks.md](./tasks.md) -- Verification: [verification.md](./verification.md) diff --git a/docs/specs/010-event-driven-tray-status/design.md b/docs/specs/010-event-driven-tray-status/design.md deleted file mode 100644 index cc9f6f1..0000000 --- a/docs/specs/010-event-driven-tray-status/design.md +++ /dev/null @@ -1,322 +0,0 @@ ---- -title: Event-driven tray status design -doc_type: spec -artifact_type: design -status: active -owner: Auriora Team -last_reviewed: 2026-07-27 ---- - -# Technical Design - -## Overview - -Add a typed status snapshot to the existing authenticated control protocol and -a separate authenticated event subscription transport. Events are sanitized -revisioned invalidations, not copies of protected records. The tray subscribes, -fetches an initial snapshot, and refreshes only after a newer event or -reconnection. This preserves the request/response control path while removing -steady-state tray polling. - -## Requirement Coverage - -| Requirement | Acceptance Criteria | Design Coverage | Validation Approach | -|-------------|---------------------|-----------------|---------------------| -| Requirement 1 | AC1-AC5 | Subscription handshake, session revisions, invalidation stream | Protocol, broker, integration, idle tests | -| Requirement 2 | AC1-AC5 | Peer identity, per-frame authorization, allowlisted models | Security and negative-control tests | -| Requirement 3 | AC1-AC7 | Backend-derived `StatusSnapshot`, systemd schedule health, and local presentation | Model, store, schedule, tray tests | -| Requirement 4 | AC1-AC5 | Separate transport, backoff, heartbeat, bounded clients | Failure, restart, slow-client tests | -| Requirement 5 | AC1-AC7 | Three disabled status rows, action-only mutations, and deterministic logo badges | Platform menu/icon tests and Linux acceptance | -| Requirement 6 | AC1-AC4 | Silent serve loop and logging boundary | Captured-stream and logging tests | -| Requirement 7 | AC1-AC5 | Portable interfaces, Linux adapter, Windows contracts, release probes | Platform, package, deployment, rollback tests | - -## Correctness Property Coverage - -| Property | Design Behavior | Validation Direction | Notes | -|----------|-----------------|----------------------|-------| -| CP-001 | Snapshot builder selects maximum successful backup `completed_at`. | Generated histories plus conventional edge tests | No new property dependency required if Hypothesis is unavailable. | -| CP-002 | `(session_id, sequence)` ordering and coalescing guard. | Generated event sequences | Older/duplicate revisions are ignored. | -| CP-003 | Membership resolver runs before every emitted event or heartbeat. | Revocation and denied-subscription tests | Connection is closed on denial. | -| CP-004 | Initial/reconnect flow always fetches `status.snapshot`. | Restart, gap, and reconnect integration tests | Events are invalidations, not state. | -| CP-005 | Event components have read/status dependencies only. | Interface and lock-spy tests | Mutations retain existing action path. | -| CP-006 | Serve path has no successful-state `print`. | Captured 90-second idle test with shortened test clock | One-shot output remains tested separately. | -| CP-007 | Systemd occurrence, grace deadline, and durable run matching derive backup health. | Fake-clock/systemd tables and deadline tests | Failed runs remain distinct from missed runs. | - -## High-Level Design - -### System Architecture - -```text -user-session tray - | request/response | long-lived subscription - v v -control.sock status-events.sock - | | - v v -authenticated dispatcher authenticated event transport - | | - +------- status.snapshot <---- status event broker - ^ - | - record/schedule change sources -``` - -The control socket remains bounded to one request and response per connection. -The event socket is separate so a long-lived subscriber cannot block CLI -requests or mutation actions. - -### Components and Changes - -- **Status models and snapshot builder** - - Add allowlisted `StatusSnapshot`, `StatusRevision`, and `StatusEvent` - models. - - Compute last successful backup by maximum successful `completed_at`. - - Preserve latest attempt state separately. -- **Control protocol** - - Add `status.snapshot` as a read-only authorized action. - - Bump and negotiate protocol compatibility if the wire schema changes. -- **Event broker** - - Own one random backend-session ID and a monotonic sequence. - - Coalesce pending changes; retain no unbounded event history. - - Emit only invalidation, heartbeat, and resynchronization event kinds. -- **Change sources** - - Explicitly notify after TimeLocker-owned run and schedule mutations. - - Monitor protected atomic record/schedule state changes produced by separate - workers through an injectable platform change-watcher boundary. - - On Linux, use filesystem notifications for protected run-record changes - and a one-shot deadline monitor for the next scheduled occurrence; do not - add fixed-interval tray polling. -- **Linux event transport** - - Adopt a systemd-owned AF_UNIX listener. - - Derive `SO_PEERCRED`, enforce current NSS membership, bound connections and - frames, and disconnect slow or unauthorized clients. -- **Windows event contract** - - Define injectable named-pipe acceptor, peer-token, subscription, and send - interfaces with contract/security tests. - - Defer concrete service deployment and live acceptance. -- **Tray subscription client** - - Run blocking event reads independently from the desktop event loop. - - Signal the presentation loop to fetch a fresh snapshot after newer events. - - Reconnect with bounded exponential backoff and coalesce refresh requests. -- **Tray presentation** - - Construct and process an explicit connecting badge before starting the - background subscription worker. - - Replace `View Status` with non-actionable status rows. - - Render exactly `State`, `Activity`, and `Last Backup`. - - Keep health (`State`) separate from transient work (`Activity`). - - Keep `Open TimeLocker` absent. - - Remove periodic successful stdout rendering from `serve`. - -### Data Models - -```text -StatusRevision - session_id: UUID - sequence: non-negative integer - -StatusEvent - schema_version: integer - protocol_version: integer - revision: StatusRevision - kind: snapshot_required | changed | heartbeat | resync_required - -StatusSnapshot - revision: StatusRevision - backend_status: bounded enum - active_operations: non-negative integer - latest_backup: optional safe run summary - last_successful_backup_completed_at: optional UTC datetime - latest_retention: optional safe run summary - next_backup_at: optional UTC datetime - next_retention_at: optional UTC datetime - backup_schedule_health: healthy | missed | disabled | unavailable -``` - -The exact snapshot schema must reuse existing stable enums and safe summaries. -It must not include arbitrary strings, raw commands, paths, environment data, -or backend output. - -### Data Flow - -1. Tray constructs and processes its connecting presentation. -2. A background worker connects to the event socket without blocking the - desktop event loop. -3. Backend derives peer identity and authorizes current group membership. -4. Backend sends `snapshot_required` with the current revision. -5. Tray requests `status.snapshot` through the control socket and renders it. -6. A durable run or managed schedule change advances the broker sequence. -7. Backend reauthorizes each subscriber and sends one coalesced `changed` - event. -8. Tray fetches and renders the newest snapshot if its revision is newer. -9. On disconnect, session change, gap, or `resync_required`, the tray reconnects - and repeats the initial snapshot flow. - -## Low-Level Design - -### Algorithms and Logic - -```text -on_subscription_connected(peer): - authorize(peer) - send(snapshot_required, broker.current_revision) - while connected: - event = broker.next_event_or_heartbeat() - authorize(peer) - send(event) - -on_tray_event(event): - if event.session_id != applied.session_id: - request_snapshot() - elif event.sequence > applied.sequence: - coalesce_refresh_request(event.sequence) - ignore duplicate or older revisions - -build_status_snapshot(): - runs = protected_store.list_for_status() - schedule = system_schedule_provider.snapshot() - successful = backup runs with state SUCCEEDED and completed_at present - last_success = max(successful, key=completed_at, default=None) - backup_health = reconcile(schedule, runs, now, grace) - return sanitized snapshot at broker.current_revision -``` - -The snapshot builder and revision read must use a synchronization boundary that -prevents returning a snapshot marked newer than the state it contains. If a -change races with snapshot construction, the resulting newer event causes -another refresh. - -### Function Signatures and Interfaces - -```text -class StatusSnapshotProvider(Protocol): - def snapshot(self) -> StatusSnapshot: ... - -class StatusEventBroker(Protocol): - def current_revision(self) -> StatusRevision: ... - def publish_change(self, kind: StatusChangeKind) -> StatusRevision: ... - def subscribe(self) -> StatusSubscription: ... - -class StatusEventTransport(Protocol): - def serve(self, broker, identity_provider, membership_resolver) -> None: ... - -class StatusEventClient(Protocol): - def events( - self, - stop_event, - *, - on_connection_state: Callable[[StatusEventConnectionState], None] | None, - ) -> Iterator[StatusEvent]: ... -``` - -### Error Handling - -- Invalid, oversized, unknown-version, or unauthorized subscription frames fail - closed with a stable safe result and connection close. -- Platform clients project `connected`, `denied`, and `unavailable` connection - states through the platform-neutral callback. Linux maps an operating-system - socket `PermissionError` to `denied`; other transport failures map to - `unavailable` while bounded reconnect continues. -- Event channel unavailability changes tray presentation to unavailable but - does not disable explicit control-channel commands. -- Initial connection and later reconnect attempts run outside the desktop event - loop; no socket timeout or backoff delay may postpone the first connecting - presentation. -- Backoff is bounded and resets only after a successful authorized handshake. -- Slow subscribers retain at most the newest pending revision; if they cannot - keep up, the backend disconnects them. -- Watcher overflow or uncertainty emits `resync_required`. -- The schedule deadline monitor sleeps only until the next expected occurrence - plus grace, publishes one invalidation, then rearms from a fresh systemd - projection. It does not poll the tray or protected backend on a fixed cadence. -- Logging uses stable codes and redacted summaries with repetition control. - -### Security, Trust, and Access - -- `/run/timelocker/status-events.sock` is root-owned and group-accessible only - to the configured operator group. -- Linux identity comes from `SO_PEERCRED`; Windows identity comes from the - connected named-pipe token. Request content never asserts identity. -- Membership is checked at subscription and before each event or heartbeat, - bounding group-removal latency by the heartbeat interval. -- Event payloads are allowlisted and independently size-bounded. -- The tray never reads `/var/lib/timelocker`, `/etc/timelocker`, environment - files, journal content, or repository credentials. -- The event path cannot invoke backup, retention, release selection, or - arbitrary commands. - -### Migration and Compatibility - -- Existing CLI control actions remain request/response compatible. -- Release metadata records both control and event protocol compatibility. -- A stable launcher parses bounded cross-version release metadata but permits - explicit selection only when the target protocols match the selector - implementation. Rollback may still resolve the previously accepted release. -- Activation stages and probes a replacement launcher environment plus the - event socket/service assets before selection, then atomically retains the - prior launcher environment for recovery before selecting the release. -- A new tray paired with an incompatible backend shows a safe unavailable state - rather than reverting to indefinite status polling. -- Linux uses packaged deterministic variants of the TimeLocker logo. A - shape-coded badge distinguishes running, success, warning or never-run, and - failure without requiring a runtime image library or relying on colour alone. -- Rollback selects the prior coherent CLI/backend/tray release. Additional - event assets may remain inert, but must not break the prior control socket, - backup timer, or retention timer. - -### Slice Boundary And Residual Architecture - -| Design target | In this slice | Out of this slice | Follow-up destination | Blocks closure? | -|---------------|---------------|-------------------|-----------------------|-----------------| -| Event-driven local tray status | Snapshot, broker, subscription, Linux transport, tray client | Remote/network subscribers | rejected: outside charter | no | -| Portable desktop contract | Platform-neutral protocol and Windows contract tests | Concrete Windows service, installer, live acceptance | follow-up Windows acceptance spec | no | -| Tray status presentation | Current status rows and mutation actions | Full desktop app or restore UI | product backlog/roadmap | no | -| Reliable change detection | TimeLocker-owned run/schedule changes and resync on uncertainty | Arbitrary external systemd edits without TimeLocker mediation | operator restart/reload guidance | no | - -## Validation Strategy - -| Validation | Covers | Evidence Location | Residual Risk | -|------------|--------|-------------------|---------------| -| Model/protocol/property tests | Requirements 1-3; CP-001-CP-004 | `verification.md`, task evidence | Generated histories may not represent all host races. | -| Transport/security tests | Requirements 2, 4, 7; CP-003, CP-005 | `verification.md`, security review | NSS and named-pipe behavior need live platform evidence. | -| Tray/menu/output tests | Requirements 3, 5, 6; CP-006 | `verification.md`, focused tests | Desktop toolkit variations. | -| Configured regression and package smoke | Compatibility and release integrity | `verification.md`, CI/command evidence | Host timing and optional integrations. | -| Approved Linux Mint acceptance | End-to-end event, restart, authorization, rollback | `verification.md`, root-owned evidence path | Requires explicit deployment and operation approval. | - -## Downstream Task Guidance - -- Complete protocol/security review before transport implementation. -- Give CP-001, CP-002, CP-003, CP-004, and CP-006 explicit test coverage. -- Preserve the existing uncommitted removal of `Open TimeLocker`. -- Run `$review-timelocker` after a runnable implementation and before live - deployment. -- Reconcile design and traceability if concrete Windows delivery enters scope. - -## Operational Considerations - -- Install the event socket with the same operator-group ownership model as the - control socket. -- Keep the control socket as the service's required activation dependency and - the event socket as a weak dependency. The backend accepts a named control - descriptor without an event descriptor and disables only event delivery in - that mode, so an event-unit failure cannot disable explicit control actions. -- Expose health without raw subscriber identities or payloads. -- Record bounded connection counts and safe error codes, not user data. -- Activation and rollback must verify both timers remain active and enabled. -- Live acceptance must avoid production mutation unless separately approved. -- Measure ordinary status-change latency from completed state mutation to tray - presentation. Measure backend restart as separate graceful-shutdown and - new-service-start-to-fresh-presentation intervals; do not count shutdown time - against the ordinary two-second change budget. - -## Open Questions - -None currently block implementation review. Any change from a dedicated event -transport, per-event authorization, or invalidation-plus-snapshot model requires -design reconciliation and user approval. - -## Related Artifacts - -- Requirements: [requirements.md](./requirements.md) -- Change Impact: [change-impact.md](./change-impact.md) -- Tasks: [tasks.md](./tasks.md) -- Verification: [verification.md](./verification.md) diff --git a/docs/specs/010-event-driven-tray-status/requirements.md b/docs/specs/010-event-driven-tray-status/requirements.md deleted file mode 100644 index 332a262..0000000 --- a/docs/specs/010-event-driven-tray-status/requirements.md +++ /dev/null @@ -1,304 +0,0 @@ ---- -title: Event-driven tray status requirements -doc_type: spec -artifact_type: requirements -status: active -owner: Auriora Team -last_reviewed: 2026-07-27 ---- - -# Requirements - -## Introduction - -The independent tray currently polls the protected backend every 30 seconds, -prints each successful refresh to standard output, reports the newest backup -attempt's start time as the last backup, and exposes a `View Status` action -that does not open a view. The tray needs an authenticated event-driven status -path and precise operator-facing semantics without becoming part of the CLI or -privileged backend. - -## Goals - -- Deliver backend status changes to an authorized tray without steady-state - status polling. -- Show the completion time of the most recent successfully completed backup. -- Present useful status directly in the tray menu and keep background operation - quiet. -- Preserve fail-closed authorization, privacy, process independence, immutable - release rollback, and portable Linux/Windows contracts. - -## Non-Goals - -- A full desktop application, settings window, restore browser, or remote API. -- Direct tray access to protected record files, journals, credentials, or - privileged commands. -- Live Windows deployment acceptance in this package. -- Replacing the existing request/response control channel for CLI actions. -- Guaranteeing delivery across process failure without reconnecting and - obtaining a fresh snapshot. - -## Glossary - -| Term | Definition | -|------|------------| -| Status snapshot | A typed, sanitized backend projection of current activity, recent results, and known schedules. | -| Status event | A bounded notification that a newer status snapshot may be available. | -| Subscription revision | A backend-session identifier and monotonic sequence used to order and coalesce status events. | -| Healthy subscription | An authorized event connection that has completed its initial snapshot and has not failed or timed out. | -| Last successful backup | The backup run in `SUCCEEDED` state with the greatest non-null `completed_at` value. | - -## Durable Source Baseline - -| Source | Current behavior relied on | Confidence | Notes | -|--------|----------------------------|------------|-------| -| `CHARTER.md` | The CLI remains primary; optional tray integration must support dependable, observable backup operations. | high | Governing mandate. | -| `docs/1-requirements/system-operations.md` | Protected reads are group-authorized; the tray is independent and may show run and schedule status. | high | Current durable requirements. | -| `docs/2-architecture/system-architecture.md` | The tray currently polls the AF_UNIX backend; protected output is structured and redacted. | high | This spec changes the polling statement. | -| `docs/3-implementation/service-layer-integration.md` | `system_control` owns typed IPC, authorization, records, and the independent tray client. | high | Ownership remains unchanged. | -| `docs/SYSTEM-TRAY-SETUP.md` | Linux tray setup, authorization, failure behavior, and current menu capability. | high | Promotion target. | -| `src/TimeLocker/system_control/` and focused tests | Current protocol is one bounded request/response per control connection. | high | Code-derived contract. | - -## Durable Impact - -See [change-impact.md](./change-impact.md). Accepted behavior must be promoted -before closure. - -## Staged Readiness - -- **Current stage:** implementation -- **Next stage:** T001 contract implementation -- **Ready to implement:** yes - lifecycle lint and readiness pass, acceptance - criteria are traceable, and user approval was recorded on 2026-07-27. -- **Design-first exception:** no -- **Optional artifacts included:** `change-impact.md`, `traceability.md`, - `verification.md`, `canonical-context.md` -- **Downstream review needed:** requirements, design, tasks, traceability, - verification - -## Requirements - -### Requirement 1: Event-Driven Status Delivery - -**User Story:** As an operator, I want the tray to react to backend changes, so -that status is current without repeated polling and terminal output. - -**Priority:** must-have - -#### Acceptance Criteria - -1. GIVEN an authorized tray starts or reconnects, WHEN it establishes a status - subscription, THEN it SHALL obtain an initial status snapshot before - presenting the connection as current. -2. WHILE a subscription is healthy, THE TRAY SHALL NOT issue periodic status - snapshot requests solely because a fixed refresh interval elapsed. -3. WHEN backup, retention, backend-availability, or TimeLocker-managed schedule - status changes in the backend process or a separate protected worker, THEN - an authorized connected tray SHALL be prompted to refresh within two seconds - under normal local-host load. -4. WHEN multiple changes occur faster than the tray can render them, THEN the - system SHALL coalesce them without applying an older revision after a newer - revision. -5. WHEN the subscription session changes or a revision gap is detected, THEN - the tray SHALL discard incremental assumptions and obtain a fresh snapshot. - -### Requirement 2: Authorization And Privacy - -**User Story:** As an administrator, I want event subscriptions to preserve the -protected control boundary, so that continuous status does not weaken access -control or expose secrets. - -**Priority:** must-have - -#### Acceptance Criteria - -1. WHEN a client subscribes, THEN the backend SHALL derive its identity from - the operating-system transport and verify current operator-group membership. -2. BEFORE sending each status event or heartbeat, THE BACKEND SHALL re-evaluate - current membership, and SHALL disconnect a client whose authorization is no - longer valid. -3. THE STATUS SNAPSHOT AND EVENT CONTRACTS SHALL contain only versioned, - allowlisted fields and SHALL NOT expose credentials, environment contents, - raw backend output, raw journal content, or unnecessary protected paths. -4. IF event authorization or transport validation fails, THEN the tray SHALL - show a safe unavailable or denied state and SHALL NOT fall back to privileged - execution or direct protected-file access. -5. WHEN an unauthorized local client attempts to subscribe, THEN it SHALL - receive no status payload beyond a bounded safe denial. - -### Requirement 3: Accurate Backup Health And Activity - -**User Story:** As an operator, I want the tray's backup time to mean successful -completion, so that a failed or running attempt cannot misrepresent protection. - -**Priority:** must-have - -#### Acceptance Criteria - -1. THE `Last Backup` value SHALL be selected only from backup runs in - `SUCCEEDED` state and SHALL display that run's `completed_at` time. -2. WHEN a newer backup is queued, running, failed, skipped, or interrupted, - THEN it SHALL NOT replace the last successful backup completion time. -3. WHEN no successful backup exists, THEN the tray SHALL display `Never` or - `Unknown`, not the time of another run state. -4. WHERE a latest backup or retention attempt exists, THE STATUS SNAPSHOT SHALL - preserve its safe state and summary separately from the last successful - backup completion. -5. WHEN a timestamp is displayed, THEN the tray SHALL convert the stored - timezone-aware UTC value to the desktop session's local time and identify - the timezone. -6. THE BACKEND SHALL derive backup schedule health from the configured system - timer, its service state, and durable backup-run records without granting the - tray direct systemd or protected-file access. -7. WHEN an enabled scheduled occurrence passes its configured grace deadline - without a matching active or terminal backup run, THEN schedule health SHALL - become `backup_missed`. A failed matching run SHALL be `backup_failed`, not - `backup_missed`. - -### Requirement 4: Resilience And Process Independence - -**User Story:** As an operator, I want tray failures and backend restarts to be -recoverable, so that presentation failures never disrupt backup or retention. - -**Priority:** must-have - -#### Acceptance Criteria - -1. WHEN the tray process starts, THEN it SHALL present a connecting state before - beginning backend subscription work. WHEN the backend or event channel is - unavailable, THEN the presented tray SHALL remain responsive and reconnect - using bounded exponential backoff. -2. WHEN the backend restarts, THEN a connected or reconnecting tray SHALL - establish a new subscription session and obtain a fresh snapshot. -3. THE BACKEND SHALL bound subscriber count, frame size, queued event state, - heartbeat interval, and slow-client handling. -4. WHEN a tray exits, crashes, or is killed, THEN backend services and active - backup or retention operations SHALL continue unaffected. -5. WHEN the event channel fails while the request/response control channel - remains available, THEN explicit CLI status and action requests SHALL remain - functional. - -### Requirement 5: Useful And Honest Tray Presentation - -**User Story:** As an operator, I want the tray menu to show actionable current -status, so that its labels accurately describe what they do. - -**Priority:** must-have - -#### Acceptance Criteria - -1. THE MENU SHALL contain exactly three non-actionable status rows: `State`, - `Activity`, and `Last Backup`. `State` SHALL contain health only; `Activity` - SHALL contain transient work such as connecting, backup running, retention - running, or idle. -2. THE MENU SHALL NOT show `Open TimeLocker` until an implemented desktop - application exists. -3. THE MENU SHALL NOT show an actionable `View Status` item unless activating - it opens a distinct status view; for this slice, status SHALL be represented - by non-actionable menu rows. -4. `Backup Now` and conditionally configured `Run Retention` SHALL remain the - only mutation actions exposed by this slice, in addition to `Quit`. -5. WHEN a status event arrives, THEN the visible menu SHALL update without - restarting the tray process. -6. ON Linux, THE TRAY SHALL preserve the TimeLocker logo while applying a - distinct non-colour-only badge consistent with health and activity. Running - activity MAY temporarily select the running badge; otherwise failed, missed, - unavailable, disabled, healthy, and never-run health SHALL select an honest - error, warning, success, or idle badge. -7. `State` SHALL use bounded user-facing values including `Healthy`, - `Backup failed`, `Backup missed`, `Schedule disabled`, - `Backend unavailable`, and `Access denied`. Connection progress and running - operations SHALL NOT be reported as health states. - -### Requirement 6: Quiet Background Operation - -**User Story:** As a desktop user, I want the background tray to be silent -during normal operation, so that it does not pollute session output or logs. - -**Priority:** must-have - -#### Acceptance Criteria - -1. WHILE `timelocker-tray serve` is healthy, THE PROCESS SHALL NOT write - periodic successful status snapshots to standard output or standard error. -2. WHEN an operator explicitly invokes a one-shot `status` action, THEN the - command SHALL continue to render a bounded human-readable result. -3. WHEN a recoverable connection failure repeats, THEN diagnostics SHALL use - the configured logging path with bounded repetition rather than unbounded - terminal output. -4. WHEN debug logging is explicitly enabled, THEN connection and event - diagnostics MAY be emitted without including protected or secret values. - -### Requirement 7: Portable Contract And Safe Rollout - -**User Story:** As a maintainer, I want the event contract separated from its -transport, so that Linux is deliverable now without blocking a later Windows -implementation. - -**Priority:** must-have - -#### Acceptance Criteria - -1. THE STATUS SNAPSHOT, EVENT, subscription, reconnect, and authorization - interfaces SHALL be platform-neutral. -2. Linux SHALL provide a protected local event transport with peer-derived - identity and systemd-managed deployment assets. -3. Windows SHALL have injectable named-pipe event-transport contracts and - platform tests, without this package claiming live Windows acceptance. -4. Activation SHALL verify compatible CLI, backend, tray, control protocol, and - event protocol artifacts before selecting a release. -5. Rollback SHALL restore the prior selected release without disabling backup, - retention, or explicit control-channel status commands. - -## Correctness Properties - -- **CP-001:** For any run history, the displayed last successful backup is - either absent or equals the maximum `completed_at` among successful backup - runs. -- **CP-002:** Within one subscription session, applied event revisions are - strictly increasing; duplicate or older events do not regress presentation. -- **CP-003:** No event payload is delivered after the backend observes that the - subscriber is no longer an operator-group member. -- **CP-004:** Reconnect or session change always converges to the same snapshot - that an authorized one-shot status request would return. -- **CP-005:** Tray lifecycle operations cannot acquire the repository mutation - lock or alter an active run except through existing allowlisted requests. -- **CP-006:** A healthy tray over any interval emits zero periodic successful - status records to stdout or stderr. -- **CP-007:** An enabled backup occurrence becomes missed only after its grace - deadline when no matching active or terminal backup run exists; any matching - failed run is reported as failed instead. - -## Technical Context - -- **Language/Version:** Python 3.12-3.13 -- **Primary Dependencies:** standard library sockets/threading, existing GTK or - platform tray adapters, systemd on accepted Linux deployments -- **Target Platform:** production acceptance on Linux Mint; portable Windows - contracts and tests -- **Constraints:** local-only IPC, current group authorization, bounded frames, - safe projections, immutable releases, no GUI dependency in CLI/backend -- **Performance Goals:** present the connecting tray before starting backend - subscription work; event-to-menu update within two seconds under normal local - load; no steady-state snapshot polling; bounded idle heartbeat - -## Success Criteria - -- **SC-001:** Integration evidence shows zero fixed-interval status requests - during at least 90 seconds of healthy idle subscription. -- **SC-002:** A successful backup transition updates the tray within two - seconds, while a later failed transition leaves its last-success time intact. -- **SC-003:** Authorization tests prove denial at subscribe time and disconnect - before the next event/heartbeat after membership removal. -- **SC-004:** Restart and revision-gap tests converge to a fresh snapshot - without restarting the desktop session. -- **SC-005:** Focused, platform-contract, security, configured regression, - packaging, and approved Linux acceptance checks pass with no secret-bearing - output. - -## Related Artifacts - -- Change Impact: [change-impact.md](./change-impact.md) -- Design: [design.md](./design.md) -- Tasks: [tasks.md](./tasks.md) -- Verification: [verification.md](./verification.md) diff --git a/docs/specs/010-event-driven-tray-status/tasks.md b/docs/specs/010-event-driven-tray-status/tasks.md deleted file mode 100644 index 8a3dac9..0000000 --- a/docs/specs/010-event-driven-tray-status/tasks.md +++ /dev/null @@ -1,467 +0,0 @@ ---- -title: Event-driven tray status tasks -doc_type: spec -artifact_type: tasks -status: active -owner: Auriora Team -last_reviewed: 2026-08-12 ---- - -# Tasks - -**Input:** All artifacts in `docs/specs/010-event-driven-tray-status/` - -**Prerequisites:** Approved requirements and design, complete traceability, -implementation approval, and preserved unrelated worktree changes. - -## Task Dependency Graph - -```text -T001 -> T002 -> T003 -> T004 -T004 -> T005 -> T006 -> T007 -T007 -> T008 -> T009 -T009 -> T010 -> T011 -> T012 -> T013 -``` - -## Phase 1: Status And Event Contracts - -- [x] T001 Add typed status snapshot and event contracts. - - Depends on: none - - Requirements: Requirement 1, Requirement 2, Requirement 3, Requirement 7 - - Properties: CP-001, CP-002, CP-004 - - Files: `src/TimeLocker/system_control/models.py`, - `src/TimeLocker/system_control/types.py`, - `src/TimeLocker/system_control/protocol.py`, - `src/TimeLocker/system_control/interfaces.py`, focused tests - - Acceptance: Allowlisted snapshot, revision, and event models validate exact - schemas; last-success selection uses maximum successful `completed_at`; - protocol compatibility and safe failures are tested. - - Evidence: Implemented immutable allowlisted StatusRevision, StatusEvent, and StatusSnapshot contracts; added platform-neutral provider, broker, transport, and client protocols; and added permutation/table-driven CP-001 and CP-002 coverage. Validation on 2026-07-27: `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control/test_status_contracts.py tests/TimeLocker/system_control/test_models.py tests/TimeLocker/system_control/test_protocol.py tests/TimeLocker/system_control/test_interfaces.py` -> 75 passed; `PYENV_VERSION=3.12.4 ruff check src/TimeLocker/system_control/models.py src/TimeLocker/system_control/types.py src/TimeLocker/system_control/interfaces.py src/TimeLocker/system_control/__init__.py tests/TimeLocker/system_control/test_status_contracts.py` -> passed; `git diff --check` -> passed. - - Status: T001 complete; T002 is now dependency-ready. No backend action, deployment, or live host state was changed. - - Evidence mode: implementation - - [x] T001.1 Add failing model and protocol tests. - - Evidence: Added focused model and compatibility tests in `tests/TimeLocker/system_control/test_status_contracts.py`; the final focused run passed as part of the 75-test T001 suite. - - Status: Complete. - - Evidence mode: implementation - - [x] T001.2 Implement exact wire models and version handling. - - Evidence: Implemented exact immutable `StatusRevision`, `StatusEvent`, and `StatusSnapshot` wire models with strict version, enum, UUID, integer, UTC timestamp, nested run, and unknown-field validation. Existing control protocol behavior remained compatible. - - Status: Complete. - - Evidence mode: implementation - - [x] T001.3 Add generated or table-driven CP-001 and CP-002 coverage. - - - Evidence: Added permutation coverage proving last-success selection is order-independent and equals the maximum successful backup `completed_at`, plus strict same-session revision-ordering cases for duplicate, older, newer, and changed-session revisions. - - Status: Complete. - - Evidence mode: implementation -- [x] T002 Add the authorized `status.snapshot` control action. - - Depends on: T001 - - Requirements: Requirement 2, Requirement 3, Requirement 4 - - Properties: CP-001, CP-004, CP-005 - - Files: `src/TimeLocker/system_control/action_policy.py`, - `src/TimeLocker/system_control/backend_entry.py`, - `src/TimeLocker/system_control/client.py`, - `src/TimeLocker/system_control/storage.py`, focused tests - - Acceptance: Authorized clients receive one coherent safe snapshot; - unauthorized clients receive only a safe denial; explicit existing - control actions remain compatible. - - Evidence: Added the read-only `status.snapshot` SystemAction and public system-read classification; strict response projection; `UnixSocketSystemControlClient.get_status_snapshot()`; an internal locked full-history store read; and a backend snapshot handler with one backend-session revision, active-operation count, latest safe attempts, last successful backup completion, and schedule projection. Authorized/denied, redaction, client, protocol, storage, dispatcher, backend, interface, and T001 contract checks passed: `PYENV_VERSION=3.12.6 python -m pytest --no-cov ...` -> 108 passed. Scoped Ruff and `git diff --check` passed. No mutation route, event transport, deployment, or live host state changed. - - - Status: T002 complete; T003 is dependency-ready. - - Evidence mode: implementation -- [x] T003 Implement the bounded status event broker and change sources. - - Depends on: T002 - - Requirements: Requirement 1, Requirement 2, Requirement 4 - - Properties: CP-002, CP-004, CP-005 - - Files: new focused module under `src/TimeLocker/system_control/`, run and - schedule mutation seams, focused tests - - Acceptance: Broker revisions are monotonic per session, changes coalesce, - subscriber state is bounded, and watcher uncertainty forces - resynchronization. - - Evidence: Implemented `status_events.py` with bounded session broker/subscriptions, monotonic revisions, one-event coalescing, subscriber bounds, synchronized snapshot/publication coordinator, schedule and durable-run change seams, and injectable watcher uncertainty-to-resync handling. Integrated the broker/coordinator and post-persistence run callbacks into Linux backend composition while isolating event failures from mutations. Validation: focused T001-T003/status/storage/backend/protocol/client/dispatcher suite -> 96 passed; scoped Ruff -> passed; `git diff --check` -> passed. No transport, deployment, or live host operations were performed. - - Status: T003 complete; Phase 1 checkpoint T004 is dependency-ready. - - Evidence mode: implementation - - [x] T003.1 Implement session revision and coalescing behavior. - - Evidence: Implemented `BoundedStatusEventBroker` and `BoundedStatusSubscription` with random session identity, monotonic bounded sequence, initial snapshot-required event, one-slot per-subscriber coalescing, subscriber limits, and close/unregister behavior. Focused broker and race tests passed. - - Status: Complete. - - Evidence mode: implementation - - [x] T003.2 Publish after TimeLocker-owned durable state changes. - - Evidence: Integrated a shared `StatusChangeCoordinator` into Linux backend composition; durable run create/transition operations publish after atomic persistence, schedule changes have an explicit publication seam, and callback failures are isolated from completed mutations. Snapshot building shares the coordinator boundary. - - Status: Complete; concrete schedule change call sites do not yet exist in the protected backend and later watcher/transport tasks consume the seam. - - Evidence mode: implementation - - [x] T003.3 Add injectable protected-state watcher and overflow handling. - - - Evidence: Added the injectable `ProtectedStateWatcher`/`ProtectedStateChangeMonitor` boundary and sanitized `StatusWatchSignal`; uncertain or unknown observations publish a coalesced `resync_required` event. Focused uncertainty test passed. - - Status: Complete; platform watcher implementation is consumed by the Linux transport/integration slice. - - Evidence mode: implementation -- [x] T004 Checkpoint - contract, security, and broker validation. - - Depends on: T003 - - Requirements: Requirement 1-Requirement 4 - - Acceptance: Focused tests pass, protocol and privacy review has no blocking - findings, and `verification.md` contains concrete phase evidence before - transport work begins. - - Validation: Focused system-control model, protocol, storage, dispatcher, - and broker tests. - - Evidence: Completed the bounded Phase 1 security/protocol checkpoint using `$review-timelocker` across project/operator, Python/IPC architecture, security/privacy, reliability/testing, operations/portability, and documentation lifecycle lenses; Restic data semantics were not materially changed. The review found one actionable watcher-failure resync gap, which was fixed and regression-tested. No blocking findings remain in T001-T003 scope. Validation: `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` -> 208 passed; scoped Ruff, `python -m compileall -q src/TimeLocker/system_control`, and `git diff --check` -> passed. Agent Workbench static diagnostics had no actionable findings but no Python diagnostics provider was available. - - - Status: Phase 1 complete; T005 Linux event transport is dependency-ready. Review excluded T005+ transport, deployment, live operations, and durable promotion. - - Evidence mode: implementation -## Phase 2: Transport And Tray Client - -- [x] T005 Implement authenticated Linux event transport and subscription - client. - - Depends on: T004 - - Requirements: Requirement 1, Requirement 2, Requirement 4, Requirement 7 - - Properties: CP-002, CP-003, CP-004, CP-005 - - Files: `src/TimeLocker/system_control/linux_adapter.py`, - `src/TimeLocker/system_control/backend_entry.py`, - `src/TimeLocker/system_control/tray_client.py`, new event client module, - focused tests - - Acceptance: The dedicated socket does not block control requests; peer - identity and membership are rechecked; slow clients, oversized frames, - disconnect, heartbeat, restart, and revision gaps are bounded and tested. - - Evidence: Implemented the separate authenticated Linux event channel, bounded concurrent subscriber transport, systemd listener adoption, reconnecting bounded-frame event client, and event-driven tray snapshot coordinator. Negative controls cover peer-derived authorization, per-event/heartbeat membership rechecks, revocation, safe denial, slow senders, frame overflow, disconnect/reconnect, backend-session restart, revision gaps/duplicates, stale snapshots, initial snapshot recovery, and event/control independence. Validation on 2026-07-27: focused T005 suite -> 13 passed; `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` -> 224 passed; scoped Ruff, compileall, and `git diff --check` passed. Agent Workbench reported no actionable diagnostics but no Python diagnostics provider was available. No deployment or live host mutation was performed. - - Status: T005 complete; T006 tray presentation is dependency-ready. Deployment assets and live host changes remain gated to later tasks. - - Evidence mode: implementation - - [x] T005.1 Add transport and security negative-control tests. - - Evidence: Added eight negative-control cases in `tests/TimeLocker/system_control/test_status_event_transport.py`; the focused T005 run passed 13 tests, including authorization, revocation, denial, heartbeat, slow sender, systemd adoption, overflow/reconnect, and denial parsing. - - Status: Complete. - - Evidence mode: implementation - - [x] T005.2 Implement systemd listener adoption and bounded subscribers. - - Evidence: Implemented `LinuxStatusEventTransport` in `src/TimeLocker/system_control/linux_adapter.py` and backend isolation in `backend_entry.py`; systemd adoption, bounded sender, revocation, heartbeat, and event/control independence cases passed in the 13-test focused T005 run. - - Status: Complete. - - Evidence mode: implementation - - [x] T005.3 Implement tray reconnect, coalescing, and fresh-snapshot flow. - - Evidence: Added `event_client.py` and `TrayStatusSubscriptionClient` in `tray_client.py`; four cases in `test_tray_status_subscription.py` passed for initial snapshot, gaps/restart, duplicate/older revisions, stale snapshots, denied state, and heartbeat-only recovery while unsynchronized. - - Status: Complete. - - Evidence mode: implementation - - [x] T005.4 Prove CP-003 and CP-004 with revocation/restart tests. - - - Evidence: CP-003/CP-004 controls in `test_status_event_transport.py`, `test_tray_status_subscription.py`, and `test_backend_entry.py` passed in the 13-test focused T005 run: revocation before delivery, safe denial, overflow/disconnect reconnect, new backend session, revision gap, stale snapshot rejection, and event/control independence. - - Status: Complete. - - Evidence mode: implementation -- [x] T006 Correct and simplify tray presentation. - - Depends on: T005 - - Requirements: Requirement 3, Requirement 5, Requirement 6 - - Properties: CP-001, CP-006 - - Files: `src/TimeLocker/system_control/tray_entry.py`, - `src/TimeLocker/system_control/tray_client.py`, - `src/TimeLocker/monitoring/system_tray_integration.py`, focused tests - - Acceptance: Menu rows show current safe status; last backup means - successful completion; `Open TimeLocker` and non-functional `View Status` - are absent; healthy serve mode is silent; explicit one-shot status remains. - - Worktree caution: Reconcile and retain the pre-spec menu-removal changes. - - Evidence: Implemented coherent snapshot-to-tray projection, local-time last-success semantics, seven non-actionable status rows, cross-platform menu removal of `Open TimeLocker`/`View Status`, event-driven UI updates, and silent healthy serve behavior while retaining bounded explicit one-shot output. Validation on 2026-07-27: `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` -> 228 passed; focused monitoring tray suite -> 9 passed; scoped Ruff, compileall, and `git diff --check` passed. No deployment or live host state changed. - - Status: T006 complete; Phase 2 integration checkpoint T007 is dependency-ready. - - Evidence mode: implementation - - [x] T006.1 Reconcile the existing `Open TimeLocker` removal. - - Evidence: Reconciled and retained the pre-spec menu removal in `system_tray_integration.py`; focused menu tests confirm neither `Open TimeLocker` nor `View Status` is created. - - Status: Complete. - - Evidence mode: implementation - - [x] T006.2 Replace `View Status` with non-actionable status rows. - - Evidence: Replaced actionable status entries with seven disabled backend/activity/backup/retention/schedule rows in `system_tray_integration.py`; Linux dynamic-row tests passed and macOS/Windows adapters use the same platform-neutral labels. - - Status: Complete. - - Evidence mode: implementation - - [x] T006.3 Project `last_successful_backup_completed_at` in local time. - - Evidence: `TrayControlClient.project_snapshot()` now projects `last_successful_backup_completed_at`, and platform menu labels convert it with `astimezone()` including the timezone; tests prove a newer failed backup does not replace the successful completion and no success renders `Never`. - - Status: Complete. - - Evidence mode: implementation - - [x] T006.4 Remove periodic stdout/stderr success output and test CP-006. - - - Evidence: `tray_entry.py` now consumes a coalesced event-update queue without periodic `refresh_status()` calls or successful stdout/stderr writes. `test_healthy_serve_is_silent_and_applies_event_snapshot` passed while the explicit one-shot status rendering test remained green. - - Status: Complete. - - Evidence mode: implementation -- [x] T007 Checkpoint - event-driven tray integration. - - Depends on: T006 - - Requirements: Requirement 1-Requirement 6 - - Acceptance: Integration tests show initial snapshot, event update, - coalescing, reconnection, honest last-success semantics, live menu update, - and no steady-state status polling or output. - - Validation: Focused tray, client, transport, monitoring, security, and - integration tests. - - Evidence: Completed the Phase 2 integration checkpoint. The 58-test focused transport/broker/snapshot/subscription/tray/menu suite passed initial snapshot, invalidation update, one-slot coalescing, oversized-frame disconnect/reconnect, backend-session restart, duplicate/older/gap handling, per-delivery revocation, honest last-success/`Never` semantics, dynamic disabled menu rows, silent serve, explicit one-shot output, and an assertion that healthy serve never calls the legacy polling method. Scoped Ruff, compileall, and `git diff --check` passed; the broader system-control suite passed 228 tests during T006. - - - Status: Phase 2 complete; T008 Windows-portable event contracts are dependency-ready. No deployment or live host state changed. - - Evidence mode: validation -## Phase 3: Portability And Deployment - -- [x] T008 Add Windows-portable event transport contracts and tests. - - Depends on: T007 - - Requirements: Requirement 2, Requirement 4, Requirement 7 - - Properties: CP-002-CP-005 - - Files: `src/TimeLocker/system_control/windows_adapter.py`, platform tests, - protocol tests - - Acceptance: Named-pipe interfaces derive peer identity from token - providers, enforce the same bounded event contract, and pass injected - contract/security tests without claiming a live Windows service. - - Evidence: Added injectable `NamedPipeEventConnection`/`NamedPipeEventAcceptor` contracts and `WindowsNamedPipeStatusEventTransport` in `windows_adapter.py`. The adapter derives identity only from the injected token provider, rechecks current group membership before every event/heartbeat, emits only a safe denial, shares the bounded broker, bounds frames/heartbeat/send timeout, closes slow clients, and releases subscription capacity. Four new Windows event tests plus existing platform-neutral contracts passed; `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` -> 232 passed. Scoped Ruff, compileall, and `git diff --check` passed. This is an injected contract tested on Linux and does not claim a live Windows service or deployment. - - - Status: T008 complete; T009 protected deployment/compatibility assets are dependency-ready. - - Evidence mode: implementation -- [x] T009 Add protected deployment, compatibility, and rollback assets. - - Depends on: T008 - - Requirements: Requirement 4, Requirement 7 - - Files: `src/TimeLocker/system_control/assets/`, - `src/TimeLocker/system_control/deployment.py`, - release manifest/launcher code, deployment and package tests - - Acceptance: Event socket ownership/mode, release probes, packaging, atomic - selection, rollback, and existing backup/retention timers are verified. - - Evidence: Added the root-owned/group-accessible `timelocker-status-events.socket` package asset with mode 0660 and named `status-events` descriptor; the control socket is named `control`. Backend activation resolves descriptors from validated `LISTEN_PID`/`LISTEN_FDS`/`LISTEN_FDNAMES` rather than positional assumptions. Schema-2 release metadata binds control and event protocol versions while schema 1 remains readable for legacy rollback without claiming event compatibility. Structured activation probes require compatible CLI/backend/tray, explicit control status, event channel, and active+enabled backup/retention timers before atomic selection; rollback requires coherent artifacts, control status, and both timer states while permitting the added event socket to remain inert. Focused deployment/release/backend/Linux asset suite: 52 passed. Full system-control suite: 246 passed. Scoped Ruff and `git diff --check` passed. `systemd-analyze verify` was environment-limited by unrelated host permissions and absent protected installed launcher executability, so clean installed-unit evidence remains T011. The original unit required both sockets; T011 remediation makes the event dependency non-fatal while retaining named descriptor validation. No protected host mutation occurred. - - - Status: T009 complete; T010 local package build and installed-artifact smoke are dependency-ready. Live deployment remains gated at T011. - - Evidence mode: validation -- [x] T010 Checkpoint - package and deployment readiness. - - Depends on: T009 - - Requirements: Requirement 5, Requirement 7 - - Acceptance: Linux packages deterministic TimeLocker-logo variants for - running, success, warning or never-run, and failure; snapshot state selects - them honestly; wheel/sdist validation and installed-artifact smoke pass; - live deployment commands and rollback are reviewed; no protected host - mutation has occurred without explicit approval. - - Evidence: Added a deterministic Pillow build script and five packaged - TimeLocker-logo variants with non-colour-only glyphs for idle/connecting, - running, success, warning/never-run, and failed/interrupted. Linux - AppIndicator now selects the projected variant with safe base-logo/theme - fallbacks. Snapshot projection treats no backup attempt as warning even - after successful retention, preserves latest failed/interrupted as error, - and keeps the last-successful timestamp independent. Deployment targets - and installed-artifact smoke cover every new asset. Validation on - 2026-07-27: focused UX/deployment suite -> 53 passed; system-control, - monitoring, icon, and release-artifact regression suite -> 268 passed; - scoped Ruff, compileall, and `git diff --check` -> passed. Isolated wheel - and sdist validation passed with 27 package-data files; both clean-install - smoke contracts passed. SHA-256: wheel - `ceb610a5eafeedc1d0b13f0626d0ac9a74f33a4cb46735778c37fc4712b5bb7b`; - sdist - `ac9371a6e3087dc515dc5cd0c871687dd7cd23e7ce3468cc2d7d3b09c65bb7e0`. - Visual inspection confirmed the base logo remains recognizable and each - status uses a distinct shape/glyph. No protected host mutation occurred. - - - Status: T010 complete; T011 remains separately approval-gated. - - Evidence mode: implementation -## Phase 4: Acceptance, Review, Promotion, And Closure - -- [x] T011 Disposition Linux Mint acceptance after the resident design was rejected. - - Depends on: T010 - - Requirements: Requirement 1-Requirement 7 - - Properties: CP-001-CP-006 - - Approval: Explicit protected deployment and any live backup/retention - execution approval required. - - Acceptance: Authorized and denied subscription, event latency, last-success - semantics, 90-second idle silence, backend restart, tray restart, action - independence, timer health, external-worker invalidation, missed-backup - health, the exact three-row menu, and rollback are evidenced from the - installed artifact. - - Evidence: User approved remediation of T011 review findings. Implementation - maps OS socket permission denial to the safe denied state, reports other - transport failures as unavailable with bounded reconnect, accepts - control-only systemd activation, and makes the event socket a weak service - dependency. A repository-owned redacted-evidence validator separates - ordinary mutation-to-presentation latency from graceful shutdown and restart - convergence. After a temporary deployment script failed because its `0660` - probe was executed as an identity that could not read it, the rollout path - was replaced with the repository-owned `scripts/deploy_t011_linux.py` - harness. It snapshots exact inputs into private root-owned evidence, runs - inline authorized and denied identity probes before protected mutation, - rejects packaged assets outside the staged release, uses a locked - expected-current selector compare-and-swap, and restores the selector, - service unit, sockets, service, and timer gates on failure or interruption. - Focused harness, selector, deployment, and evidence validation passed 46 - tests; the complete system-control plus harness/evidence regression passed - 272 tests. Scoped Ruff, compileall, and patch integrity passed. A fresh wheel - and sdist passed artifact validation with 27 package-data files; the wheel - passed installed-artifact smoke and explicit installed selector-contract - checks. Wheel SHA-256: - `5603dd6c4aae461f5e6e673eea97b2d2b2972e843b6d9a32f8f3d8347e1c3dde`; - sdist SHA-256: - `fc0f4bda037a7c41128a8834129a7be9c20040d0efd8580dab05ff0599427748`. - The first commit-bound harness deployment failed closed during staged pip - installation because the private evidence copy renamed the valid wheel to - `candidate.whl`, which pip rejects before inspecting the artifact. Recovery - removed the inert candidate; the prior selector, unit, sockets, service, - and timers remained unchanged. The harness now validates the supplied wheel - basename, preserves it in private evidence, and rejects an invalid basename - before creating host state. Eleven focused harness tests, scoped Ruff, - compileall, patch integrity, and an exact `/usr/bin/python3` staging - rehearsal with the release wheel passed. Live redeployment and acceptance - remained separately approval-gated. The subsequent commit-bound deployment - of `a67c83ac09ac29b94a3ed481ee536b3380db3337` succeeded on Linux Mint: - preflight identity checks passed, no backup or retention was triggered, the - selector retained `d540b453864fce9b1c96a85ad9ecf604b98b7f57` as the - previous release, the control service, sockets, backup timer, and retention - timer remained healthy, and installed CLI/tray status probes passed. The - absence of a supported general deployment entrypoint is routed to - [Spec 011](../011-protected-system-deployment/README.md). A subsequent - startup correction adds an explicit deterministic connecting badge, - processes the desktop event loop before starting the subscription worker, - and lazily loads unrelated package and system-control exports. Focused tray, - asset, deployment, and artifact tests passed 42 cases; the broader - system-control, tray-monitoring, and backup compatibility regression passed - 415 tests. Scoped Ruff, compileall, patch integrity, and public lazy-export - compatibility checks passed. Direct source startup measurements were - approximately 0.11 seconds for the launcher import and 0.56 seconds for the - full tray entry import. A fresh wheel and sdist passed release validation - with 28 package-data files, and the wheel passed clean installed-artifact - smoke. Wheel SHA-256: - `d9fb99bbe856c7659304701ab8b12d5dd8d97fc194f5b8ce5825d33a559609c8`; - sdist SHA-256: - `bb5df2b2450db07335ccbb848f03e01b010ed568d6609f29cdd3b24f827ffeea`. - No protected host mutation occurred. The first protocol-2 deployment of - commit `2e1b565c823dd9a2714e43ed976338d45a9cbee5` failed closed during - staged backend preflight because the repository deployer still compared - the candidate's correct `2:1` protocol report to a stale hard-coded `1:1` - expectation. Activation did not begin and the candidate release was - removed. The deployer now derives the expected signature from the staged, - already-validated schema-2 manifest and records the probe output in private - evidence. A regression proves a stale `1:1` candidate fails before - activation; the exact committed wheel independently reported `2:1`. - Twelve focused harness tests, a 284-test system-control/artifact regression, - scoped Ruff, and patch integrity passed before another deployment attempt. - A further exact preflight rehearsal found that a protocol-2 candidate - `runs list` cannot query the still-active protocol-1 backend. Before any - retry, the pre-activation CLI check was narrowed to the candidate's local, - manifest-bound version; the real system read remains a required - post-activation check after the candidate backend is coherently selected. - Simulated transaction ordering proves no protocol-2 system read occurs - before selection and that the post-activation read still runs. - Inspection of the installed stable launcher then found that its protocol-1 - manifest parser could not resolve a protocol-2 selected release. The - launcher contract now parses bounded cross-version metadata while allowing - normal selection only for its own protocols. The deployment transaction - stages a separate candidate launcher environment, verifies that it resolves - both current and candidate manifests, swaps it at the mutation boundary, - and retains the prior environment for rollback. Forced post-activation - failure restores the prior launcher before restarting the prior backend. - A 299-test system-control, deployment, artifact, and tray-icon regression - passed with scoped Ruff, compileall, and patch integrity. The exact - commit-bound wheel for - `18990e168108e23479193563a35a72f773120aec` passed installed-artifact smoke, - cross-version manifest parsing, and a real staged-launcher directory rename - rehearsal; its SHA-256 is - `f097cbeb2d4a02ae0e84d335fdac1fc3cc7f93738e5cbee5f4cf3931e15129ba`. - The protected deployment then succeeded with preflight identity checks - passing and no backup or retention execution. Independent post-activation - reads confirmed protocol `2:1` in the stable launcher, the candidate - selected with `a67c83ac09ac29b94a3ed481ee536b3380db3337` retained as - previous, all five protected units active, required units enabled, system - run access, and the tray process running from the selected release. An - authorized event subscription received its initial event in approximately - 0.10 seconds. - - - Status: routed - independently valid status and tray semantics are retained; - further resident-backend acceptance is superseded by human decision and - transferred to Spec 011's daemonless acceptance contract. - - Decision update, 2026-07-28: Further acceptance is halted. Live diagnosis - showed that reading a protected JSON run record is reported as a filesystem - change; the tray then requests another snapshot, which reads the record - again and sustains a read-notify-read loop. The unit accumulated more than - five CPU-hours during roughly eight hours of uptime without backup or - retention work. An isolated reproduction confirmed that a read alone emits - `CHANGED`. The installed release and current checkout use identical - relevant watcher, snapshot, and schedule-observer source. The user rejected - the resident daemon architecture and restored the zero-idle-residency - constraint. Spec 011 owns the daemonless replacement; this task must not - resume under the current architecture. - - Evidence mode: reasoned and live runtime observation - - Destination: Spec 011, Requirement 9 and its daemonless implementation and - acceptance tasks. - - [x] T011.1 Reconcile and test backup-health and tray-row contracts. - - Acceptance: State is health-only; Activity is transient; Last Backup is - successful completion or Never; exact wire and menu tests fail before the - implementation change. - - Evidence: User approved the corrected State/Activity distinction on - 2026-07-28. Requirements, design, traceability, verification, and tests - now define health-only State, transient Activity, and successful-only - Last Backup. The strict status snapshot contract includes bounded backup - schedule health and control protocol version 2; schema-1 release metadata - and preserved protocol-1 policy declarations remain readable for upgrade - and rollback without accepting protocol-1 traffic as the new contract. - - [x] T011.2 Implement external-worker invalidation and backup schedule health. - - Acceptance: Separate protected run-record writes prompt a snapshot within - two seconds; systemd timer/service state and durable runs distinguish - healthy, failed, missed, disabled, and unavailable without fixed polling. - - Evidence: Added watchdog-backed native filesystem invalidation for - protected run-record atomic renames, a bounded systemd timer/service - observer, durable run matching, a 15-minute missed-occurrence grace - deadline, and a one-shot deadline monitor. Tests cover unavailable, - disabled, healthy, missed, failed-run matching, late manual backup, - numeric systemd timestamps, deadline publication, and an external - `AtomicRecordStore` write. The current host's read-only observer returned - an enabled/active timer with the expected last and next trigger times. - - [x] T011.3 Implement and validate the exact three-row tray presentation. - - Acceptance: All platform adapters show only State, Activity, and Last - Backup; retention affects Activity only while running and never health. - - Evidence: Tray projection and all platform menu adapters now expose only - `State`, `Activity`, and `Last Backup`. Backup failure/miss, schedule - disabled/unavailable, backend unavailable, access denial, and healthy - states are separate from connecting, backup-running, - retention-running, combined-running, and idle activity. Retention - terminal outcomes do not affect health. A 416-test system-control, - tray, deployment, artifact, backup, and CLI regression passed; scoped - Ruff, compileall, patch integrity, wheel/sdist build, and installed-wheel - smoke passed. No protected host mutation or live backup/retention ran. -- [x] T012 Run the TimeLocker expert review and address findings. - - Depends on: T011 - - Requirements: Requirement 1-Requirement 7 - - Review: Use `$review-timelocker` with project stewardship, Restic, - Python/IPC architecture, security/privacy, reliability/testing, - operations/portability, and documentation lifecycle perspectives. - - Acceptance: Blocking findings are fixed; advisory findings are fixed, - rejected with rationale, or routed to one owned destination. - - Evidence: Completed a bounded implementation-and-closure review on - 2026-08-12 using all seven TimeLocker expert roles. TLR-010-001 found that - lower-case traceability column names prevented lifecycle coverage parsing; - the headings were normalized and closure parsing was rerun. TLR-010-002 - found stale review dates and ambiguous current-versus-accepted backend - wording in promoted documents; the dates, transitional status, tray - contract, and shutdown consequences were corrected. No Restic command, - credential, backup, restore, retention, or protected host behavior changed - in this documentation-only closure slice. Remaining daemon-removal risk is - owned by Spec 011 rather than accepted here. Post-remediation evidence: - `lint_spec_package(mode=full)` reported 0 errors, warnings, or information - findings; `closure_check` accepted all seven requirement dispositions and - reported only pending T013. - - Evidence mode: review and direct documentation correction - -- [x] T013 Promote durable documentation, run final validation, and prepare closure. - - Depends on: T012 - - Requirements: Requirement 1-Requirement 7 - - Files: promotion targets in `change-impact.md`, `verification.md`, - `docs/specs/README.md`, `docs/history/` - - Acceptance: Configured regression and all required focused/platform/ - security/package checks pass; accepted behavior is promoted; Windows live - work has one follow-up destination; general protected deployment workflow - debt is owned by Spec 011; lifecycle evidence, traceability, closure, - final-spec commit, cleanup, and history indexes are complete. - - Evidence: Promoted the zero-idle-residency mandate, authorization and - resource requirements, current architecture non-conformance, component - ownership, accepted tray semantics, and temporary shutdown procedure to - durable documentation. Spec 011 owns every resident-runtime residual. - `python3 -m pytest tests/TimeLocker/system_control -q` with the configured - non-coverage test options completed with 274 passed in 29.39 seconds; - `git diff --check` passed. Agent Workbench checked all 14 changed Markdown - documents: ten were clean and the remaining findings were advisory - pre-existing table-width warnings. Spec Lifecycle Manager full lint reported - 0 findings, active-spec scan reported both packages healthy, promotion found - no missing targets, and closure accepted all requirement dispositions. - Final-spec and cleanup commit hashes are recorded by the closure workflow. - - Evidence mode: documentation promotion and executed validation - -## Execution Rules - -- Do not implement from this file alone. Read the full package and durable - baseline first. -- Mark only one implementation task `[~]` at a time unless non-conflicting work - is explicitly approved. -- Preserve unrelated worktree changes and reconcile the existing tray-menu - correction rather than reverting it. -- Record commands and results under the task and in `verification.md`. -- A focused `--no-cov` run is diagnostic only; the configured final profile - owns the 50 percent coverage gate. -- Protected deployment, group changes, live backup, live retention, rollback, - publication, and release selection retain explicit approval gates. - -## Related Artifacts - -- Requirements: [requirements.md](./requirements.md) -- Change Impact: [change-impact.md](./change-impact.md) -- Design: [design.md](./design.md) -- Verification: [verification.md](./verification.md) diff --git a/docs/specs/010-event-driven-tray-status/traceability.md b/docs/specs/010-event-driven-tray-status/traceability.md deleted file mode 100644 index 7a2eb04..0000000 --- a/docs/specs/010-event-driven-tray-status/traceability.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: Event-driven tray status traceability -doc_type: spec -artifact_type: traceability -status: active -owner: Auriora Team -last_reviewed: 2026-08-12 ---- - -# Traceability Matrix - -## Task To Context Matrix - -| Task | Requirements | Acceptance criteria | Design coverage | Verification | Durable targets | -|------|--------------|---------------------|-----------------|--------------|-----------------| -| T001 | Requirement 1, Requirement 2, Requirement 3, Requirement 7 | Requirement 1 AC4-AC5; Requirement 2 AC3; Requirement 3 AC1-AC5; Requirement 7 AC1 | Data Models, Function Signatures | V1, V2 | requirements, architecture | -| T002 | Requirement 2, Requirement 3, Requirement 4 | Requirement 2 AC1, AC3-AC5; Requirement 3 AC1-AC4; Requirement 4 AC5 | Control Protocol, Snapshot Builder | V1, V3 | requirements, implementation | -| T003 | Requirement 1, Requirement 2, Requirement 4 | Requirement 1 AC3-AC5; Requirement 2 AC2-AC3; Requirement 4 AC2-AC3 | Event Broker, Change Sources | V2, V4 | architecture, implementation | -| T004 | Requirement 1-Requirement 4 | Phase 1 criteria | Validation Strategy | V1-V4 | none | -| T005 | Requirement 1, Requirement 2, Requirement 4, Requirement 7 | Requirement 1 AC1-AC5; Requirement 2 AC1-AC5; Requirement 4 AC1-AC5; Requirement 7 AC1-AC3 | Linux Transport, Tray Client, Security | V2-V5 | architecture, tray setup | -| T006 | Requirement 3, Requirement 5, Requirement 6 | all | Tray Presentation, Error Handling | V1, V5, V6 | tray setup, reference, troubleshooting | -| T007 | Requirement 1-Requirement 6 | Phase 2 criteria | Data Flow, Failure Handling | V1-V6 | none | -| T008 | Requirement 2, Requirement 4, Requirement 7 | Requirement 2 AC1-AC5; Requirement 4 AC3-AC5; Requirement 7 AC1, AC3 | Windows Event Contract | V3, V7 | requirements, architecture | -| T009 | Requirement 4, Requirement 7 | Requirement 4 AC5; Requirement 7 AC2, AC4-AC5 | Migration and Compatibility | V8, V9 | installation, version management | -| T010 | Requirement 5, Requirement 7 | Requirement 5 AC6; Requirement 7 all | Tray Presentation, Operational Considerations | V5, V8, V9 | tray setup | -| T011 | Requirement 1-Requirement 7 | all Linux acceptance criteria, including external-worker invalidation, Requirement 3 AC6-AC7, Requirement 5 AC1 and AC7 | Complete Linux flow | V10 | operational docs | -| T012 | Requirement 1-Requirement 7 | review disposition | Security, Reliability, Portability | V11 | all promotion targets | -| T013 | Requirement 1-Requirement 7 | all | Promotion and Closure | V12-V15 | all promotion targets and history | - -## Requirement To Delivery Matrix - -| Requirement | Priority | Tasks | Verification gates | Durable targets | Coverage State | Residual Destination | -|-------------|----------|-------|--------------------|-----------------|----------------|----------------------| -| Requirement 1 | must-have | T001, T003-T005, T007, T011-T013 | V1, V2, V4, V5, V10 | requirements, architecture, tray setup | partial-routed | Human decision superseded resident event delivery; reusable snapshot semantics are retained and daemonless delivery is routed to Spec 011 Requirement 9. | -| Requirement 2 | must-have | T001-T005, T007-T008, T011-T013 | V1-V5, V7, V10-V11 | requirements, architecture | partial-routed | Human decision superseded continuous resident authorization; allowlisted models are retained and bounded authentication is routed to Spec 011. | -| Requirement 3 | must-have | T001-T002, T006-T007, T011-T013 | V1, V5-V6, V10 | requirements, tray setup | complete | Last-success, health/activity separation, schedule health, local tray projection, and `Never` fallback are implemented and regression-tested. | -| Requirement 4 | must-have | T002-T005, T007-T011, T013 | V2-V5, V7-V10 | architecture, troubleshooting | partial-routed | Human decision superseded resident reconnect and heartbeat behavior; process independence and daemonless resilience are routed to Spec 011. | -| Requirement 5 | must-have | T006-T007, T010-T013 | V5, V6, V8-V10 | tray setup, command reference | complete | Honest three-row presentation, actions, deterministic non-colour-only badges, and connecting state passed local and package checks. | -| Requirement 6 | must-have | T006-T007, T011-T013 | V6, V10 | tray setup, troubleshooting | partial-routed | One-shot output silence is retained; human decision superseded idle resident service operation and zero-idle acceptance is routed to Spec 011 Requirement 9. | -| Requirement 7 | must-have | T001, T005, T008-T013 | V1-V3, V7-V11, V13 | requirements, architecture, installation | partial-routed | Human decision superseded rollout of the resident architecture; supported daemonless deployment and remaining platform acceptance are routed to Spec 011. | - -## Correctness Property Coverage - -| Property | Requirements | Tasks | Tests or verification | Residual risk | -|----------|--------------|-------|-----------------------|---------------| -| CP-001 | Requirement 3 | T001, T002, T006, T011 | V1 permutation coverage passed; backend, tray, and live evidence pending | none after live evidence | -| CP-002 | Requirement 1, Requirement 4 | T001, T003, T005, T008 | V1 strict revision ordering passed; broker/coalescing evidence pending | concurrency scheduling remains host-sensitive | -| CP-003 | Requirement 2 | T005, T008, T011 | V3, V5, V7, V10 | Windows live revocation deferred | -| CP-004 | Requirement 1, Requirement 4 | T001-T005, T008, T011 | V1-V5, V7, V10 | none for accepted Linux slice | -| CP-005 | Requirement 2, Requirement 4 | T002-T005, T008, T011 | V3-V5, V7, V10 | none | -| CP-006 | Requirement 6 | T006-T007, T011 | V6, V10 | desktop session capture variation | -| CP-007 | Requirement 3, Requirement 5 | T011 | V1, V4-V6, V10 | live systemd deadline timing remains host-sensitive | - -## Design To Implementation Matrix - -| Design section | Requirements | Tasks | Interfaces or files | Verification | Coverage state | Residual destination | -|----------------|--------------|-------|---------------------|--------------|----------------|----------------------| -| Status models and snapshot | Requirement 2, Requirement 3 | T001-T002 | models, protocol, backend, storage | V1, V3 | partial-pass | T001 contracts passed; T002 backend action remains | -| Event broker and change sources | Requirement 1, Requirement 4 | T003 | new broker/watcher modules | V2, V4 | pass | Bounded broker, mutation seams, snapshot race boundary, and watcher resync validated | -| Linux event transport | Requirement 1, Requirement 2, Requirement 4, Requirement 7 | T005 | Linux adapter, backend, event client | V3-V5 | pass | Authenticated bounded listener, reconnect, revocation, restart, and independence tests passed | -| Tray presentation | Requirement 3-Requirement 6 | T006, T011 | tray client, entry, platform integration | V5-V6, V10 | partial | Exact State/Activity/Last Backup rows, local last-success, health/activity separation, menu actions, quiet serve, and connecting-before-subscription ordering pass local checks; installed visual startup remains T011 | -| Windows event contract | Requirement 2, Requirement 4, Requirement 7 | T008 | Windows adapter and platform tests | V7 | not-covered | T008 | -| Deployment and compatibility | Requirement 4, Requirement 7 | T009-T011 | assets, deployment, release probes | V8-V10 | partial | Corrected Linux activation passed; remaining installed acceptance stays in T011 and reusable deployment workflow debt is routed to Spec 011 | -| Promotion and closure | Requirement 1-Requirement 7 | T012-T013 | durable docs and lifecycle artifacts | V11-V15 | not-covered | T012-T013 | - -## Open Decision Impact - -The dedicated privileged event channel, continuous resident backend, and -heartbeat design were rejected by explicit user direction on 2026-07-28 after -T011 live diagnosis demonstrated an idle CPU feedback loop. This is a resolved -project-direction decision, not an open implementation choice. - -Spec 010 may preserve independently valid snapshot semantics, last-success -meaning, and tray presentation. It must not promote or resume acceptance of the -resident backend. Spec 011 owns traceability for zero idle residency, -short-lived authenticated helpers, atomically published sanitized status, and -daemonless live acceptance. - -## Verification Gate Key - -| Gate | Description | -|------|-------------| -| V1 | Model, snapshot, protocol, and CP-001/CP-002 tests | -| V2 | Broker revision, coalescing, and race tests | -| V3 | Authorization and privacy negative controls | -| V4 | Change-source, watcher overflow, and resync tests | -| V5 | Linux transport, reconnect, restart, and independence tests | -| V6 | Tray menu, last-success, local-time, one-shot, and idle-output tests | -| V7 | Windows named-pipe contract and platform tests | -| V8 | Deployment asset and rollback tests | -| V9 | Wheel/sdist validation and installed-artifact smoke | -| V10 | Approved Linux Mint live acceptance | -| V11 | `$review-timelocker` expert review and disposition | -| V12 | Configured normal regression and coverage profile | -| V13 | Ruff, compile, link, Markdown, and Git checks | -| V14 | Lifecycle lint, readiness, task, evidence, promotion, and closure checks | -| V15 | Final spec commit, cleanup, active index, closure log, and archive index | diff --git a/docs/specs/010-event-driven-tray-status/verification.md b/docs/specs/010-event-driven-tray-status/verification.md deleted file mode 100644 index 007a09d..0000000 --- a/docs/specs/010-event-driven-tray-status/verification.md +++ /dev/null @@ -1,279 +0,0 @@ ---- -title: Event-driven tray status verification -doc_type: spec -artifact_type: verification -status: active -owner: Auriora Team -last_reviewed: 2026-07-28 ---- - -# Verification - -## Scope - -This plan covers Requirements 1-7, CP-001-CP-007, T001-T013, the protected -snapshot/event contracts, Linux implementation, Windows contract tests, tray -presentation, packaging, approved live acceptance, durable promotion, and -closure. - -## Quality Gates - -| Gate | Required? | Status | Evidence | -|------|-----------|--------|----------| -| Requirements and acceptance criteria reviewed | yes | pass | User approval recorded 2026-07-27; lifecycle readiness passed | -| Traceability complete | yes | pass | Lifecycle lint and readiness found no blocking gaps | -| Focused model/protocol/security tests pass | yes | partial-pass | T001-T005 system-control slice: 224 passed; tray presentation T006 pending | -| Tray and event integration tests pass | yes | pass | T007 focused event-driven integration checkpoint: 58 passed | -| Windows platform contract tests pass | yes | pass | T008 injected named-pipe contract; live Windows acceptance remains deferred | -| Package and deployment checks pass | yes | pass | T009 deployment contract and T010 wheel/sdist installed-artifact checks passed | -| Approved Linux Mint acceptance passes | yes | in progress | Repository-owned preflight-first harness and evidence validator pass locally; committed live artifact and host acceptance remain | -| Expert review findings resolved | yes | pending | T012 | -| Configured regression and coverage gate pass | yes | pending | T013 | -| Durable documentation promoted | yes | pending | T013 | -| Closure and cleanup evidence complete | yes | pending | T013 | - -## Validation Commands - -| Command | Purpose | Result | Evidence | -|---------|---------|--------|----------| -| `python -m pytest --no-cov tests/TimeLocker/system_control/ tests/TimeLocker/monitoring/test_system_tray_integration.py` | Fast focused diagnostic during implementation | pending | Not final coverage evidence | -| `python -m pytest -m "unit or security or platform" --no-cov` with selected event files | Focused contract and negative controls | pending | Exact selection recorded per task | -| `python -m pytest -m "not performance and not stress and not minio"` | Normal correctness and 50 percent coverage gate | pending | Final suite evidence | -| `python -m pytest -m "performance or stress" --no-cov` | Timing/stress only if affected or required by review | pending | May be waived with rationale | -| `PYENV_VERSION=3.12.4 ruff check src tests` | Static style and lint | pending | | -| `python -m compileall -q src` | Package syntax/import compilation | pending | | -| `python -m build` and `python scripts/validate_release_artifacts.py --expected-version 0.9.1 --dist dist` | Artifact completeness and hashes | pending | Use current version at execution | -| isolated wheel/sdist CLI, backend, tray, control, and event-protocol smoke | Installed-artifact contract | pending | Exact commands recorded at T010 | -| Agent Workbench Markdown document checks for changed Markdown files | Markdown structure, frontmatter, links, lists, and tables | pending | MCP evidence | -| `python scripts/link_checker.py` | Internal links | pending | | -| `git diff --check` | Patch integrity | pending | | -| lifecycle lint/readiness/traceability/evidence/promotion/closure tools | Spec gates | pending | MCP outputs preferred | - -## Requirement Coverage - -| Requirement | Acceptance criteria covered | Evidence | Residual risk | -|-------------|-----------------------------|----------|---------------| -| Requirement 1 | AC1-AC5 | T001, T003-T005, T007, T011 | partial-routed; resident delivery was human-superseded and daemonless delivery belongs to Spec 011 | -| Requirement 2 | AC1-AC5 | T001-T005, T008, T011-T012 | partial-routed; reusable authorization contracts remain, bounded activation belongs to Spec 011 | -| Requirement 3 | AC1-AC5 | T001-T002, T006-T007, T011 | pass; health/activity/last-success and schedule semantics are regression-covered | -| Requirement 4 | AC1-AC5 | T002-T005, T007-T011 | partial-routed; resident reconnect/heartbeat acceptance was human-superseded by the zero-idle decision | -| Requirement 5 | AC1-AC7 | T006-T007, T010-T011 | pass; exact three-row health/activity projection, deterministic Linux badges, and honest never-run/failure/missed projection passed | -| Requirement 6 | AC1-AC4 | T006-T007, T011 | partial-routed; one-shot silence passed and idle resident operation was rejected | -| Requirement 7 | AC1-AC5 | T001, T005, T008-T011 | partial-routed; resident rollout was human-superseded and daemonless deployment belongs to Spec 011 | - -## Correctness Property Coverage - -| Property | Covered by | Evidence | Residual risk | -|----------|------------|----------|---------------| -| CP-001 | Generated/table-driven histories, snapshot tests, live result | partial-pass | Model, backend, and tray projection passed; live evidence pending | -| CP-002 | Revision sequence and coalescing tests | partial-pass | Broker, duplicate/older rejection, gap, and stale-snapshot controls passed; live integration remains | -| CP-003 | Subscribe, per-frame revocation, and denied-client tests | partial-pass | Linux per-delivery revocation and denial passed; Windows live evidence deferred | -| CP-004 | Restart, session change, gap, and snapshot convergence tests | partial-pass | Reconnect, new session, gap, and initial-snapshot recovery passed; live integration remains | -| CP-005 | Interface isolation, lock spy, and live operation independence | partial-pass | Separate event/control failure isolation passed; live operation evidence remains | -| CP-006 | Captured idle serve test and live 90-second observation | partial-pass | Healthy serve is silent and one-shot output remains; live 90-second evidence pending | -| CP-007 | Fake systemd projections, grace deadlines, and run matching | partial-pass | Systemd parsing, bounded health derivation, one-shot deadline publication, and atomic-record invalidation passed; live timer deadline evidence remains T011 | - -## Scope Reconciliation Before Closure - -| Broad target | Implemented in this spec | Coverage state | Deferred or rejected work | Destination | Blocks closure? | Evidence | -|--------------|--------------------------|----------------|---------------------------|-------------|-----------------|----------| -| Event-driven Linux tray status | T001-T007, T009-T011 | partial | none | rollout tasks T009-T011 | yes | T001-T007 implementation and Phase 2 checkpoint passed | -| Continuous authorization/privacy | T001-T005, T008, T011-T012 | partial | Windows live revocation | Windows follow-up spec | yes for Linux; no for Windows live | -| Accurate and quiet tray UX | T001-T002, T006-T007, T011 | partial | Live desktop acceptance remains | T007, T011 | yes | Correct local last-success rows and silent serve passed in T006 | -| Portable Windows architecture | T001, T008 | covered | Concrete Windows service and live acceptance | `docs/specs/011-protected-system-deployment/requirements.md` | no after routing | Injected token-derived named-pipe event contract passed 232-test checkpoint | -| Full desktop application | none | out-of-scope | Product UI | backlog/roadmap | no | charter and requirements | - -## Agent Readiness Evidence - -| Field | Evidence | Residual risk | -|-------|----------|---------------| -| Scope and out-of-scope files | Requirements, design slice table, change impact | Review pending | -| Must-read context | `canonical-context.md` and linked durable sources | Source may change before implementation | -| Permissions and approval points | `README.md`, tasks execution rules, T011 | Live commands require renewed approval | -| Validation commands | This file and testing conventions | Exact new test paths finalized during T001 | -| Review needs | T004 security/protocol checkpoint and T012 expert panel | Review pending | -| Durable-doc and closure impact | `change-impact.md` and promotion table | Promotion pending | -| Repository evidence caveats | Agent Workbench is routing evidence; direct reads and commands establish claims | Re-run after relevant changes | - -## Task Evidence - -| Task | Status | Evidence | Notes | -|------|--------|----------|-------| -| T001 | complete | Immutable status models, platform-neutral interfaces, 75 focused tests, Ruff, patch integrity | Public snapshot action remains T002. | -| T002 | complete | Authorized read-only snapshot action, backend builder, client parsing, safe denial, 108 focused tests | Event publication remains T003. | -| T003 | complete | Bounded broker, coalescing, synchronized revision boundary, mutation seams, watcher resync, 96 focused tests | Platform transport remains T005. | -| T004 | complete | Bounded expert checkpoint; 208 system-control tests, Ruff, compileall, patch integrity | No blocking Phase 1 findings remain. | -| T005 | complete | Authenticated bounded Linux event transport, reconnecting client, fresh-snapshot coordinator, 224 system-control tests | Deployment and live-host evidence remain in T009-T011. | -| T006 | complete | Snapshot-driven status rows, accurate local last-success time, silent serve, 228 system-control and 9 monitoring tests | Phase 2 integration checkpoint remains T007. | -| T007 | complete | 58-test Phase 2 transport, security, reconnect, snapshot, tray, menu, and no-polling checkpoint | Windows, deployment, package, and live acceptance remain. | -| T008 | complete | Token-derived bounded Windows named-pipe event contracts, four new platform tests, 232 system-control tests | No live Windows service or acceptance is claimed. | -| T009 | complete | Named protected event socket asset, named systemd descriptor mapping, dual-protocol release metadata, structured activation/rollback gates, 246 system-control tests | Installed-artifact and live-host evidence remain T010-T011. | -| T010 | complete | Five deterministic accessible logo badges, honest never-run/failure projection, 268-test regression, 27-file package-data validation, SHA-256 verification, and clean-install wheel/sdist smoke | No live selector, unit, socket, timer, backup, retention, or protected path changed. | -| T011 | in progress | Repository-owned evidence validator, preflight-first transaction, fail-closed wheel-filename correction, successful commit-bound Linux Mint activation, and immediate connecting-state startup correction | Remaining installed acceptance checks, including visible startup behavior, and evidence validation are pending; general deployment workflow is routed to Spec 011. | -| T012-T013 | pending | none | Sequenced by the task dependency graph. | - -## Evidence Log - -| Date | Evidence | Result | Notes | -|------|----------|--------|-------| -| 2026-07-27 | Source and durable-doc inspection for spec authoring | pass | Current polling, stdout, menu, authorization, transport, and deployment boundaries read directly. | -| 2026-07-27 | Existing menu-removal focused tests | 14 passed | Pre-spec partial implementation evidence; final suite not run for this package. | -| 2026-07-27 | Ruff and `git diff --check` for existing menu removal | pass | Does not validate event-driven behavior. | -| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control/test_status_contracts.py tests/TimeLocker/system_control/test_models.py tests/TimeLocker/system_control/test_protocol.py tests/TimeLocker/system_control/test_interfaces.py` | 75 passed | T001 exact models, safe failures, existing protocol compatibility, CP-001, and CP-002. | -| 2026-07-27 | T001 scoped Ruff and `git diff --check` | pass | No static or patch-integrity findings in the contract slice. | -| 2026-07-27 | T002 authorized snapshot action focused suite | 108 passed | Safe projection, denial, client, storage, backend, and compatibility evidence. | -| 2026-07-27 | T003 broker/change-source focused suite | 96 passed | Monotonicity, coalescing, bounds, race boundary, mutation isolation, and watcher resync evidence. | -| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` | 208 passed | Phase 1 system-control regression checkpoint. | -| 2026-07-27 | Scoped Ruff, `compileall`, and `git diff --check` | pass | Phase 1 static, import-syntax, and patch-integrity evidence. | -| 2026-07-27 | `$review-timelocker` bounded Phase 1 security/protocol review | pass after direct fix | One watcher-failure resync gap was found, fixed, and regression-tested; no remaining blocking findings. Scope excluded T005+ transport, deployment, live operations, and durable-doc promotion. | -| 2026-07-27 | T005 transport, security, reconnect, and tray subscription negative controls | 13 passed | Authorized/denied subscription, per-delivery revocation, heartbeat, slow sender, frame overflow, disconnect/restart, revision gap/staleness, initial recovery, and event/control independence. | -| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` | 224 passed | T005 system-control regression evidence. | -| 2026-07-27 | Scoped Ruff, `compileall`, and `git diff --check` | pass | T005 static, import-syntax, and patch-integrity evidence; Agent Workbench had no Python diagnostics provider. | -| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` | 228 passed | T006 snapshot projection, event-driven serve, action, and quiet-output regression evidence. | -| 2026-07-27 | Focused monitoring tray suite | 9 passed | Disabled status rows, local timezone, icon/menu lifecycle, and absence of misleading actions. | -| 2026-07-27 | T007 focused event-driven integration checkpoint | 58 passed | Initial snapshot/update, coalescing, reconnect/restart, revocation, honest last-success, dynamic menu, silence, and no legacy polling. | -| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` after T008 | 232 passed | Windows injected event transport, token identity, per-delivery authorization, bounded heartbeat/frame/send behavior, and no live Windows claim. | -| 2026-07-27 | T009 focused deployment, backend-entry, Linux asset, release, and snapshot suite | 52 passed | Event socket ownership/mode, named descriptor order independence, dual-protocol release metadata, fail-closed activation, timer gates, atomic selection, and rollback. | -| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m pytest --no-cov tests/TimeLocker/system_control` after T009 | 246 passed | Phase 3 deployment-contract regression checkpoint. | -| 2026-07-27 | T009 scoped Ruff and `git diff --check` | pass | Ruff used the repository-installed Python 3.12.4 toolchain; Agent Workbench reported no provider-backed Python diagnostics. | -| 2026-07-27 | `systemd-analyze verify` against packaged units | environment-limited | Unit directives parsed without an unknown-directive finding, but host-wide permission errors and absent protected launcher executability prevented a clean verification claim; installed-host evidence remains T011. | -| 2026-07-27 | `PYENV_VERSION=3.12.6 python -m build --outdir /tmp/timelocker-spec010-build.xcoQSY` | pass after network retry | Isolated build produced the `0.9.1` wheel and sdist without overwriting the repository's existing `dist/` artifacts. | -| 2026-07-27 | `validate_release_artifacts.py` against isolated T010 output | pass | Validated versions, Python constraint, four entrypoints, 22 package-data files, and SHA-256 hashes. Wheel: `231cfe2289cdb408ad6d4e8194909cfa867a11aeae8f847b41d32c53ab4dc5c2`; sdist: `9712e7484eca2d5a5f878a7fb9f76d2fd7c93f25511d3eb5b964b682bd734761`. | -| 2026-07-27 | Clean-install smoke for isolated wheel and sdist on Python 3.12.6 | pass | Both artifacts passed `timelocker`, `tl`, backend and tray help, dual-protocol import, and packaged system-asset checks. | -| 2026-07-27 | Release-artifact tests, scoped Ruff, compileall, and patch integrity | pass | 10 artifact tests passed; source/system-control/project-test Ruff, compilation, and `git diff --check` passed. | -| 2026-07-27 | T010 Linux status-badge focused and regression validation | pass | 53 focused tests and 268 system-control/monitoring/icon/release-artifact tests passed; scoped Ruff, compileall, and patch integrity passed. | -| 2026-07-27 | Rebuilt badge-aware wheel/sdist validation and clean-install smoke | pass | Validator found 27 package-data files; both artifacts passed four-entrypoint, dual-protocol, system-asset, and five-icon smoke checks. Wheel SHA-256: `ceb610a5eafeedc1d0b13f0626d0ac9a74f33a4cb46735778c37fc4712b5bb7b`; sdist SHA-256: `ac9371a6e3087dc515dc5cd0c871687dd7cd23e7ce3468cc2d7d3b09c65bb7e0`. | -| 2026-07-27 | T011 remediation focused tests | 59 passed | OS permission denial, unavailable-state reporting, control-only activation, weak event dependency, and evidence timing boundaries. | -| 2026-07-27 | T011 system-control, monitoring tray, and evidence-validator regression | 270 passed | Scoped Ruff, compileall, `git diff --check`, lifecycle lint, and lifecycle scan also passed. | -| 2026-07-27 | `systemd-analyze verify` for changed control/event units | environment-limited | Changed directives parsed; an unrelated unreadable unit and unprivileged access to the protected installed launcher prevented clean host verification. Live installed-unit proof remains T011. | -| 2026-07-27 | Failed temporary-script deployment review | fail closed; rollback passed | A `0660` temporary probe was unreadable to UID/GID 65534, and the script selected the release before identity probes, contrary to the approved order. Prior release, unit, sockets, service, and timers were restored; the candidate was removed. | -| 2026-07-27 | Repository-owned T011 deployment harness focused regression | 46 passed | Restrictive umask, inline target identities, preflight-before-selection, input snapshotting, package-boundary enforcement, compare-and-swap, signal recovery, full simulated activation, and forced post-activation rollback. | -| 2026-07-27 | System-control plus T011 harness/evidence regression | 272 passed | Scoped Ruff, compileall, and patch integrity passed. No protected host mutation occurred. | -| 2026-07-27 | Fresh T011 harness-remediation package validation | pass | Wheel and sdist contained 27 package-data files; wheel SHA-256 `5603dd6c4aae461f5e6e673eea97b2d2b2972e843b6d9a32f8f3d8347e1c3dde`; sdist SHA-256 `fc0f4bda037a7c41128a8834129a7be9c20040d0efd8580dab05ff0599427748`. Wheel installed-artifact smoke and installed expected-current selector checks passed. | -| 2026-07-27 | Commit-bound hardened deployment staging | fail closed; rollback passed | Pip rejected the private evidence copy because `candidate.whl` is not a valid wheel filename. Failure occurred before activation; selector `d540b453864fce9b1c96a85ad9ecf604b98b7f57`, service, sockets, and timers remained healthy, and candidate `8e8ebada197e713b60285d5105fe8b7ad8b9b8dc` was removed. | -| 2026-07-27 | Wheel-filename correction focused validation | pass | The harness preserves and validates the original wheel basename, rejects `candidate.whl` before host-state creation, and passes 11 focused tests, scoped Ruff, compileall, and patch integrity. | -| 2026-07-27 | Exact system-Python staging rehearsal | pass | `/usr/bin/python3` staged `timelocker-0.9.1-py3-none-any.whl` through the corrected harness into an isolated `/tmp` release and imported installed TimeLocker version `0.9.1`; no protected host path or service was changed. | -| 2026-07-28 | Corrected commit-bound Linux Mint deployment | activation passed | Release `a67c83ac09ac29b94a3ed481ee536b3380db3337` was selected with `d540b453864fce9b1c96a85ad9ecf604b98b7f57` retained as previous. Identity preflights passed; deployment triggered no backup or retention. Independent reads confirmed the control service, both sockets, backup timer, and retention timer active, required units enabled, installed CLI version `0.9.1`, system run access, and tray status success. | -| 2026-07-28 | General deployment workflow routing | follow-up created | Draft [Spec 011](../011-protected-system-deployment/README.md) owns the supported install, upgrade, status, rollback, staging, provenance, and evidence workflow. Its implementation waits for Spec 010 closure. | -| 2026-07-28 | Immediate connecting-state implementation | pass | The tray processes a deterministic connecting badge before starting its background subscription worker. Lazy package boundaries reduce direct source startup to approximately 0.11 seconds for launcher import and 0.56 seconds for full tray-entry import. Focused tray/asset/deployment/artifact tests passed 42 cases; broader system-control, tray-monitoring, and backup compatibility regression passed 415 tests. Scoped Ruff, compileall, patch integrity, and lazy public-export compatibility passed. No protected host mutation occurred. | -| 2026-07-28 | Connecting-badge release artifacts | pass | Fresh wheel and sdist validation found 28 package-data files; the wheel passed clean installed-artifact smoke. Wheel SHA-256 `d9fb99bbe856c7659304701ab8b12d5dd8d97fc194f5b8ce5825d33a559609c8`; sdist SHA-256 `bb5df2b2450db07335ccbb848f03e01b010ed568d6609f29cdd3b24f827ffeea`. | -| 2026-07-28 | Protocol-2 commit-bound deployment staging | fail closed before activation | Commit `2e1b565c823dd9a2714e43ed976338d45a9cbee5` correctly reported candidate protocols `2:1`, but the deployer retained a stale hard-coded `1:1` expectation. The harness recovered the inert candidate without selecting it; no backup or retention was triggered. | -| 2026-07-28 | Manifest-bound backend probe correction | pass | The deployer now compares the staged backend report to the staged, validated release manifest and retains the report in private evidence. The exact committed wheel reported `2:1`; the mismatch regression, 12 focused harness tests, 284 system-control/artifact tests, scoped Ruff, and patch integrity passed. | -| 2026-07-28 | Cross-version preflight rehearsal | defect found and corrected before retry | The protocol-2 candidate CLI correctly rejected the active protocol-1 backend response, showing that a pre-activation `runs list` cannot prove a coherent protocol upgrade. The pre-activation CLI probe now verifies the candidate's manifest-bound local version; simulated ordering requires the real system read only after candidate selection and backend restart. | -| 2026-07-28 | Stable-launcher compatibility review and remediation | pass before retry | The installed protocol-1 launcher could not parse a protocol-2 selected manifest. The launcher now reads bounded cross-version metadata while restricting normal selection to its own protocols; the deployer stages and probes a separate launcher environment, swaps it with the release, and restores the previous environment before backend rollback on failure. A 299-test system-control/deployment/artifact regression, scoped Ruff, compileall, and patch integrity passed. | -| 2026-07-28 | Exact protocol-2 artifact and launcher relocation rehearsal | pass | Commit `18990e168108e23479193563a35a72f773120aec` produced wheel SHA-256 `f097cbeb2d4a02ae0e84d335fdac1fc3cc7f93738e5cbee5f4cf3931e15129ba`. Installed-artifact smoke passed; the installed launcher parsed both the active protocol-1 and candidate protocol-2 manifests before its staged virtual environment was renamed, then imported protocol `2:1` successfully from the final path. | -| 2026-07-28 | Protocol-2 protected Linux Mint deployment | activation passed | The repository-owned harness selected release `18990e168108e23479193563a35a72f773120aec`, retained `a67c83ac09ac29b94a3ed481ee536b3380db3337` as previous, passed preflight identity checks, and triggered no backup or retention. Independent reads confirmed stable-launcher protocol `2:1`; control service, both sockets, backup timer, and retention timer active; required units enabled; installed CLI run access; tray status success; and the tray process executing from the selected release. An authorized initial event arrived in approximately 0.10 seconds. Private deployment evidence is rooted at `/var/lib/timelocker/migration-backup/t011-hardened-deploy-20260728T062845Z-3230661`. | - -## Manual Or External Verification - -T011 requires explicit approval before protected deployment or live operations. -The reviewed sequence is: - -1. Record the current and previous selected release IDs plus active/enabled - backup and retention timer states. -2. Use the committed repository-owned `scripts/deploy_t011_linux.py` harness to - copy the exact wheel and manifest into private root-owned evidence, then - stage an immutable release with schema-2 control/event protocol metadata. -3. Run staged CLI/backend/protocol, authorized-event, denied-event, systemd, - and timer probes before changing the selected release or service unit. -4. Install the validated service unit, select with the locked expected-current - compare-and-swap, restart the backend, and recheck both existing timers - without changing backup or retention policy. -5. Run authorized/denied event, status, silence, restart, and independence - acceptance checks. -6. Probe and atomically roll back to the previous release, then verify explicit - control status and both timers before deciding whether to reselect the - candidate. - -Record selected release IDs, artifact hashes, service/socket/timer states, -authorized and denied observations, event latency, idle-output capture, restart -recovery, and rollback without recording credentials or raw protected content. -Validate the resulting redacted JSON with -`python scripts/validate_t011_linux_acceptance.py EVIDENCE.json`. - -The evidence collector must use these timing boundaries: - -- ordinary change latency: completed state mutation to tray presentation; -- backend restart shutdown: restart request to replacement service start; -- backend restart convergence: replacement service start to a fresh snapshot - from a new backend session. - -Only ordinary change latency carries the Requirement 1 two-second bound. -Backend restart must demonstrate a new session and fresh presentation without -silently folding graceful shutdown time into that latency result. - -## Residual Risks - -- The long-lived privileged backend and subscription are rejected architecture, - not residual accepted risk. Live T011 evidence showed a read-notify-read loop - that accumulated more than five CPU-hours during roughly eight hours of - uptime without backup or retention work. -- Cross-process record changes may race event publication; watcher uncertainty - and session/snapshot recovery are mandatory. -- Desktop toolkits differ in dynamic menu behavior; keep platform tests and - Linux Mint visual acceptance. -- Concrete Windows service behavior remains unverified and must not be claimed. -- Immutable rollback can leave new inert unit assets; verify prior control and - timers remain healthy. - -## Durable Promotion And Cleanup - -| Spec content | Durable destination or deferral | Status | Evidence | -|--------------|---------------------------------|--------|----------| -| Requirements and security behavior | `docs/1-requirements/system-operations.md` | complete | Zero-idle authorization and resource-residency contracts promoted 2026-08-12. | -| Architecture and platform contract | `docs/2-architecture/system-architecture.md` | complete | Rejected resident implementation and accepted daemonless target promoted 2026-08-12. | -| Component/interface ownership | `docs/3-implementation/service-layer-integration.md` | complete | Transitional resident seams and bounded target ownership documented. | -| Tray setup, menu, reconnect, rollback | `docs/SYSTEM-TRAY-SETUP.md` | complete | Accepted three-row semantics separated from rejected transport. | -| Command/action reference | `docs/reference/timelocker-cli-command-hierarchy.md` | no change | Existing public commands remain unchanged in the accepted Spec 010 slice. | -| Troubleshooting and installation | user guides and version process | complete | Temporary resident-backend shutdown and consequences documented. | -| Windows live implementation | Spec 011 | routed | Platform acceptance belongs to the daemonless implementation. | -| Full desktop UI | `CHARTER.md` current-scope exclusion | excluded | | - -### Spec Cleanup Decision - -- **Cleanup action:** remove after final spec commit and promotion -- **Reason:** Repository policy uses Git plus compact history indexes. -- **Final spec commit:** recorded by closure workflow -- **Closure log path:** `docs/history/spec-closure-log.md` -- **Closure log entry updated:** by closure workflow -- **Closure cleanup commit:** recorded by closure workflow -- **Active indexes updated:** by closure workflow -- **Durable docs linked back to evidence where useful:** yes -- **Residual spec-only content:** none expected - -## Ship Or Closure Risk - -- **Risk level:** high; the deployed resident backend is explicitly rejected - and must not proceed to acceptance or release -- **Breaking change:** coherent local protocol/release upgrade required -- **Blast radius checked:** yes - bounded to system-control/tray documentation and - the 274-test system-control regression -- **Rollback path:** designed, not yet verified -- **Requires human review:** yes -- **Release notes needed:** yes if shipped in a release -- **Follow-up issue or spec needed:** yes, Windows live implementation and - acceptance - -### Risk Rationale - -The change crosses a privileged backend, continuous local authorization, -cross-process state observation, desktop presentation, systemd deployment, and -rollback. No repository secrets or Restic mutation semantics need to change, -but implementation and live evidence must prove that the new read path cannot -weaken those boundaries. - -## Readiness Decision - -- **Ready to implement:** no - the 2026-07-27 approval does not cover continued - implementation of the resident backend after the 2026-07-28 architecture - decision -- **Ready for promotion:** yes - accepted content is in durable documents -- **Ready for release:** no -- **Ready for closure:** yes - final commit and cleanup remain lifecycle actions - -## Related Artifacts - -- Requirements: [requirements.md](./requirements.md) -- Change Impact: [change-impact.md](./change-impact.md) -- Design: [design.md](./design.md) -- Tasks: [tasks.md](./tasks.md) diff --git a/docs/specs/README.md b/docs/specs/README.md index c92aa61..072fa1e 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -3,7 +3,7 @@ title: "Active Specification Packages" doc_type: reference status: active owner: "Auriora Team" -last_reviewed: 2026-07-28 +last_reviewed: 2026-08-12 --- # Active Specification Packages @@ -15,26 +15,20 @@ accepted content has been promoted and the package is closed. ## Current Packages -- [`010-event-driven-tray-status`](./010-event-driven-tray-status/README.md) - - active implementation package whose resident-backend acceptance is halted. - The status semantics remain useful, but the privileged event-broker design - conflicts with the approved zero-idle-residency constraint discovered during - T011 live acceptance. - [`011-protected-system-deployment`](./011-protected-system-deployment/README.md) - - draft requirements package for replacing acceptance-specific deployment - commands and the resident control backend with a daemonless transactional - install, upgrade, status, rollback, query, and action workflow. + active package for replacing acceptance-specific deployment commands and the + resident control backend with a daemonless transactional install, upgrade, + status, rollback, query, and action workflow. ## Active-Package Sequencing -Further live acceptance of Spec 010's resident backend is halted. Spec 010 must -record the rejected runtime design and preserve only independently valid status -semantics before it can be dispositioned. Spec 011 is the owning package for -the daemonless protected-operation and deployment design. Its requirements and -design may proceed now; implementation still requires explicit approval after -the revised design and task package are reviewed. +Spec 010 is closed. Its accepted status semantics are promoted to durable docs, +and its rejected resident-runtime work is routed to Spec 011. The user approved +Spec 011 implementation on 2026-08-12; live protected-host mutation, backup or +retention execution, publication, and rollback remain separate operational +approval boundaries. -Specs 007, 008, and 009 are closed. Their final package commits, cleanup +Specs 007, 008, 009, and 010 are closed. Their final package commits, cleanup commits, verification summaries, and residual follow-up are recorded in `docs/history/`. Closed packages remain recoverable from Git rather than kept in this active path. Spec 010 may rely on the durable behavior promoted by Spec From a9f6252d30fdd55d452499dc63f851e80ac5b8aa Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:06:39 +0100 Subject: [PATCH 65/72] docs(spec): resolve spec 010 closure metadata --- docs/history/spec-archive-index.md | 2 +- docs/history/spec-closure-log.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/history/spec-archive-index.md b/docs/history/spec-archive-index.md index 8529805..c8f15c3 100644 --- a/docs/history/spec-archive-index.md +++ b/docs/history/spec-archive-index.md @@ -16,7 +16,7 @@ contracts. | Spec ID | Title | Package path | Status | Final spec commit | Cleanup commit | Closure action | Durable destinations | Verification | |---------|-------|--------------|--------|-------------------|----------------|----------------|----------------------|--------------| -| 010-event-driven-tray-status | Event-driven tray status requirements | `docs/specs/010-event-driven-tray-status/` | removed | 8820e65 | pending-cleanup-commit | removed | `CHARTER.md`; `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/3-implementation/service-layer-integration.md`; `docs/SYSTEM-TRAY-SETUP.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/specs/011-protected-system-deployment/requirements.md` | `docs/history/spec-closure-log.md` | +| 010-event-driven-tray-status | Event-driven tray status requirements | `docs/specs/010-event-driven-tray-status/` | removed | 8820e65 | 4122746 | removed | `CHARTER.md`; `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/3-implementation/service-layer-integration.md`; `docs/SYSTEM-TRAY-SETUP.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/specs/011-protected-system-deployment/requirements.md` | `docs/history/spec-closure-log.md` | | 009-system-cli-tray-retention | System CLI, independent tray, retention, and control | removed; recover from Git | removed | `d4ce71dd05cb5d7278bf36a9fc43e557d68e1e31` | aba95875f453dd6abf39a1fdc6af25fd38c62db4 | removed | `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/2-architecture/scheduling-system.md`; `docs/3-implementation/service-layer-integration.md`; `docs/guides/user/installation.md`; `docs/guides/developer/scheduling-guide.md`; `docs/SYSTEM-TRAY-SETUP.md`; `docs/reference/timelocker-cli-command-hierarchy.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/processes/version-management.md`; `docs/README.md`; `docs/DOCUMENTATION-STATUS.md`; `docs/specs/README.md` | `docs/history/spec-closure-log.md` | | 007-release-readiness-stabilization | Release readiness stabilization requirements | removed; recover from Git | removed | `7fd11f9aa1cbc670d5e8b429aede4a7c01e185a4` | `6334af0690b5b9e8b6575042269e5b73914a9295` | removed | `README.md`; `CHANGELOG.md`; `.github/workflows/test-suite.yml`; `.github/workflows/artifact-smoke.yml`; `.github/workflows/release-validation.yml`; `.github/workflows/release.yml`; `docs/4-testing/README.md`; `docs/guides/user/installation.md`; `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md`; `docs/processes/version-management.md`; `docs/processes/README.md` | `docs/history/spec-closure-log.md` | | 008-npbackup-migration-parity | NPBackup migration parity requirements | removed; recover from Git | removed | `5830194` | `1bfea08` | removed | `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md` | `docs/history/spec-closure-log.md` | diff --git a/docs/history/spec-closure-log.md b/docs/history/spec-closure-log.md index e8dbfb1..472baac 100644 --- a/docs/history/spec-closure-log.md +++ b/docs/history/spec-closure-log.md @@ -20,7 +20,7 @@ final spec commit preserves the complete package. - **Spec:** `docs/specs/010-event-driven-tray-status/` - **Title:** Event-driven tray status requirements - **Final spec commit:** `8820e65` -- **Closure cleanup commit:** `pending-cleanup-commit` +- **Closure cleanup commit:** `4122746` - **Closure action:** removed - **Durable docs updated:** - `CHARTER.md` From b91c0ff7a644aa0d0343b112b17c700bce820952 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:09:35 +0100 Subject: [PATCH 66/72] feat(system-control): replace resident daemon with one-shot service --- docs/1-requirements/system-operations.md | 11 +- docs/2-architecture/system-architecture.md | 37 +- .../service-layer-integration.md | 30 +- docs/SYSTEM-TRAY-SETUP.md | 20 +- .../user/backup-operations-troubleshooting.md | 28 +- docs/guides/user/installation.md | 48 +- docs/processes/version-management.md | 20 +- .../timelocker-cli-command-hierarchy.md | 24 +- .../011-protected-system-deployment/README.md | 25 +- .../canonical-context.md | 29 +- .../011-protected-system-deployment/design.md | 220 +++ .../requirements.md | 33 +- .../011-protected-system-deployment/tasks.md | 101 ++ .../traceability.md | 92 + .../verification.md | 116 ++ pyproject.toml | 1 + scripts/deploy_t011_linux.py | 988 +---------- scripts/smoke_release_artifact.py | 19 +- scripts/validate_release_artifacts.py | 1 + .../assets/timelocker-control.service | 13 +- .../assets/timelocker-deploy-launcher | 5 + .../assets/timelocker-status-events.socket | 15 - .../system_control/backend_entry.py | 360 ++-- src/TimeLocker/system_control/deployment.py | 26 +- .../system_control/deployment_entry.py | 1567 +++++++++++++++++ .../system_control/linux_adapter.py | 16 +- .../system_control/release_launcher.py | 43 +- .../system_control/status_snapshot.py | 188 ++ src/TimeLocker/system_control/tray_client.py | 70 +- src/TimeLocker/system_control/tray_entry.py | 4 + .../project/test_release_artifacts.py | 5 +- .../project/test_t011_linux_deployment.py | 1035 ++++------- .../system_control/test_backend_entry.py | 159 +- .../system_control/test_deployment.py | 12 +- .../system_control/test_linux_adapter.py | 34 +- .../system_control/test_status_snapshot.py | 109 ++ .../test_tray_process_boundary.py | 2 +- .../test_tray_status_subscription.py | 193 +- 38 files changed, 3281 insertions(+), 2418 deletions(-) create mode 100644 docs/specs/011-protected-system-deployment/design.md create mode 100644 docs/specs/011-protected-system-deployment/tasks.md create mode 100644 docs/specs/011-protected-system-deployment/traceability.md create mode 100644 docs/specs/011-protected-system-deployment/verification.md create mode 100644 src/TimeLocker/system_control/assets/timelocker-deploy-launcher delete mode 100644 src/TimeLocker/system_control/assets/timelocker-status-events.socket create mode 100644 src/TimeLocker/system_control/deployment_entry.py create mode 100644 src/TimeLocker/system_control/status_snapshot.py create mode 100644 tests/TimeLocker/system_control/test_status_snapshot.py diff --git a/docs/1-requirements/system-operations.md b/docs/1-requirements/system-operations.md index c2c87ed..c86e3b9 100644 --- a/docs/1-requirements/system-operations.md +++ b/docs/1-requirements/system-operations.md @@ -22,6 +22,10 @@ backup, retention, status, diagnostics, and tray operations. - Missing, incompatible, or untrusted release metadata must fail closed. - Activation and rollback must verify compatible CLI, backend, and tray entrypoints before changing the selected release. +- `timelocker-deploy` is the supported root administration surface for local- + wheel install, upgrade, status, and rollback. It derives identity and + manifests, uses private staging, emits JSON results, and never triggers + backup or retention. ## Authorization Requirements @@ -95,9 +99,10 @@ backup, retention, status, diagnostics, and tray operations. ## Platform Requirement The architecture must preserve portable contracts for Linux and Windows -adapters. Linux Mint live acceptance for the protected installation and -independent tray is in progress under Spec 010. This requirement does not yet -claim an accepted Linux or Windows deployment. +adapters. The daemonless Linux implementation is automated-test accepted. +Protected host mutation and the 90-second live idle observation remain +separately approved operational evidence; no live Windows deployment is +claimed. ## References diff --git a/docs/2-architecture/system-architecture.md b/docs/2-architecture/system-architecture.md index fc8dc62..0cce01a 100644 --- a/docs/2-architecture/system-architecture.md +++ b/docs/2-architecture/system-architecture.md @@ -1,5 +1,6 @@ --- title: "Architecture Document: System Architecture" +doc_type: architecture id: "arch-system-architecture" type: [ architecture ] status: [ approved ] @@ -38,7 +39,7 @@ user CLI user-session tray authenticated local AF_UNIX protocol | v - root-owned system-control backend + socket-activated one-request helper | | | v v v backup retention run/diagnostic @@ -50,16 +51,9 @@ user CLI user-session tray Restic command adapter ``` -## Approved Residency Constraint And Current Non-Conformance +## Zero-Idle Protected Runtime -The root-owned continuously resident system-control backend shown above is the -currently deployed implementation, not the accepted long-term operating model. -On 2026-07-28 the project direction was clarified: TimeLocker must not require -a resident daemon. The deployed backend also demonstrated the reason for that -constraint by entering a read-notify-read status loop and consuming substantial -CPU while no backup or retention operation was running. - -The replacement architecture must use: +The accepted protected runtime uses: - existing one-shot scheduler units for scheduled backup and retention; - bounded, short-lived authenticated helpers for protected queries and manual @@ -68,10 +62,12 @@ The replacement architecture must use: - direct filesystem notification in the optional tray, without a privileged event broker or heartbeat process. -Until that replacement is implemented, the diagram remains implementation -truth but records a known architectural non-conformance. Spec 010 acceptance of -the resident backend is halted, and Spec 011 owns the daemonless protected -deployment boundary. +The enabled AF_UNIX socket is kernel state, not a TimeLocker process. systemd +starts the selected root helper for one connection; the helper authenticates, +serves one bounded request, closes, and exits. Backup and retention workers +publish `/run/timelocker/status.json` atomically after durable run-state +changes. The optional tray registers a filesystem watch before its initial +read and ignores read/open notifications, preventing read-notify-read loops. ## Component Boundaries @@ -87,13 +83,11 @@ deployment boundary. versioned local protocol, peer identity, current group authorization, allowlisted dispatch, protected adapters, repository locking, durable run records, safe diagnostics, deployment assets, and release activation. Its - current resident backend is transitional and must be replaced by bounded - one-shot execution. + one-request socket activation and sanitized status publication. - **Tray boundary** — `timelocker-tray` is an independent unprivileged - user-session process. The current release requests status and allowlisted - actions through the protected backend; the accepted replacement observes - sanitized state directly and invokes only short-lived protected helpers. CLI - startup never initializes it. + user-session process. It observes sanitized state directly and invokes a + short-lived protected helper only for explicit actions. CLI startup never + initializes it. - **Application boundary** — managers, orchestrators, and focused services coordinate repositories, backups, snapshots, recovery, policies, schedules, validation, and monitoring. CLI modules should delegate domain work here. @@ -139,7 +133,8 @@ Restic 0.18.0 or later must be available on `PATH`. User configuration locations are resolved through `ConfigurationPathResolver`. Protected deployment configuration is root-owned under `/etc/timelocker`; durable system state is under `/var/lib/timelocker`; the local socket is -`/run/timelocker/control.sock`. Unattended credentials are referenced from +`/run/timelocker/control.sock`; sanitized transient status is +`/run/timelocker/status.json`. Unattended credentials are referenced from protected files and are never copied into user-readable configuration. ## Validation diff --git a/docs/3-implementation/service-layer-integration.md b/docs/3-implementation/service-layer-integration.md index 893b19f..65f65ec 100644 --- a/docs/3-implementation/service-layer-integration.md +++ b/docs/3-implementation/service-layer-integration.md @@ -1,8 +1,12 @@ -# Service Layer Integration Guide +--- +title: "Service Layer Integration Guide" +doc_type: implementation +status: active +owner: "Auriora Team" +last_reviewed: 2026-08-12 +--- -**Document Type**: Implementation Guide -**Status**: Active -**Last Updated**: 2026-08-12 +# Service Layer Integration Guide ## Overview @@ -42,12 +46,10 @@ command names remain unchanged. host-level backup, retention, status, and diagnostics. It is not part of the legacy compatibility facade described below. -The deployed release currently serves this boundary through a continuously -resident root process and a privileged status-event socket. That process model -is rejected and transitional: protected requests must become bounded, -socket-activated one-shot executions, and the optional tray must consume an -atomically published sanitized snapshot directly. The accepted boundary is the -authorization and allowlisted-operation contract, not daemon residency. +The deployed contract uses bounded, socket-activated one-request executions. +The optional tray consumes an atomically published sanitized snapshot directly; +there is no privileged status-event socket, event broker, heartbeat, or +resident TimeLocker service process. - `release_launcher.py` resolves the root-owned selected immutable release for CLI, backend, and tray entrypoints and fails closed on untrusted state. @@ -63,9 +65,13 @@ authorization and allowlisted-operation contract, not daemon residency. the fixed retention command without returning backend output. - `storage.py` owns atomic run/diagnostic records and the shared repository mutation lock. +- `status_snapshot.py` owns the exact `0640` sanitized status file and direct + filesystem observation. Read/open events are ignored so reads cannot notify + themselves. +- `deployment_entry.py` owns the supported `timelocker-deploy` install, + upgrade, status, and rollback workflow; the Spec 010 script is deprecated. - `tray_entry.py` and `tray_client.py` own the independent user-session process; - normal CLI setup must not import or initialize tray integration. The current - privileged event subscription is a known non-conformance owned by Spec 011. + normal CLI setup must not import or initialize tray integration. The protected boundary returns safe summaries, result codes, states, and counters. It never returns passwords, environment contents, raw journal data, diff --git a/docs/SYSTEM-TRAY-SETUP.md b/docs/SYSTEM-TRAY-SETUP.md index 2de9494..3959f44 100644 --- a/docs/SYSTEM-TRAY-SETUP.md +++ b/docs/SYSTEM-TRAY-SETUP.md @@ -12,11 +12,14 @@ The TimeLocker tray is an optional, independent user-session process. Normal CLI startup never initializes the tray, and the tray can disappear or restart without affecting an active backup or retention run. -The currently deployed tray communicates with a resident protected backend. -That process model is rejected because it violates TimeLocker's zero-idle- -residency constraint and exhibited an idle read-notify-read CPU loop. Spec 011 -owns the replacement: direct observation of an atomically published sanitized -status snapshot plus short-lived protected helpers for explicit actions. +On launch, the tray performs one explicit protected status request; the helper +answers and exits. The tray then reads `/run/timelocker/status.json` and watches +that file directly, including waiting for its first creation after a clean boot. +Backup and retention workers replace the sanitized snapshot atomically after +durable state changes. Read/open filesystem notifications are ignored, so a +read cannot trigger another read. Explicit actions use the control socket and +start one short-lived protected helper; no privileged tray event service, +heartbeat, or resident backend is required. ## Accepted Presentation Contract @@ -98,10 +101,9 @@ timelocker-tray serve ## Platform Status The presentation contract is platform-neutral and the source contains a -Windows adapter. Spec 010 validated the presentation semantics but rejected -the resident backend during Linux Mint acceptance. Spec 011 owns daemonless -Linux deployment and acceptance. This document does not claim a live-accepted -Windows installation. +Windows adapter. The daemonless Linux implementation has automated acceptance; +the protected 90-second live-host observation remains separately approved. +This document does not claim a live-accepted Windows installation. ## References diff --git a/docs/guides/user/backup-operations-troubleshooting.md b/docs/guides/user/backup-operations-troubleshooting.md index 2a1fc3a..0ec3f11 100644 --- a/docs/guides/user/backup-operations-troubleshooting.md +++ b/docs/guides/user/backup-operations-troubleshooting.md @@ -54,12 +54,13 @@ The socket should be owned by root and the operator group with group read/write access. The public CLI returns a bounded backend-unavailable error; it does not fall back to a checkout, pyenv shim, root home, or legacy configuration. -### Temporarily Stop The Resident Backend +### Stop A Legacy Resident Backend -The continuously resident backend is a known architectural non-conformance -pending the daemonless replacement in Spec 011. To stop it for the current boot, -first stop the user tray so that it does not reconnect, then stop the service -and both activation sockets: +Current schema-3 deployments do not keep this service resident: after one +request, `timelocker-control.service` returns to inactive while +`timelocker-control.socket` remains listening in the kernel. If an older +schema-1/2 deployment is consuming resources, stop the tray, service, and both +legacy activation sockets immediately: ```bash pkill -TERM -x timelocker-tray @@ -70,15 +71,24 @@ sudo systemctl stop timelocker-control.service \ This does not stop or disable the independent backup and retention timers. Protected interactive status and tray actions are unavailable while these sockets are stopped; scheduled one-shot backup and retention remain independent. -Because the sockets remain enabled, they return on the next boot. To restore -protected interactive access before then: +Because the old sockets remain enabled, they return on the next boot. Upgrade +with `timelocker-deploy` to remove that architecture. To restore only protected +interactive access before upgrading, start the control socket; do not restart +the legacy event socket: ```bash -sudo systemctl start timelocker-control.socket \ - timelocker-status-events.socket +sudo systemctl start timelocker-control.socket timelocker-tray ``` +After a daemonless upgrade, remove stale legacy activation explicitly if it +was not already removed by the transaction: + +```bash +sudo systemctl disable --now timelocker-status-events.socket +sudo rm -f /run/timelocker/status-events.sock +``` + ## Scheduled Backup Did Not Run ```bash diff --git a/docs/guides/user/installation.md b/docs/guides/user/installation.md index 4622ab4..b5d3c81 100644 --- a/docs/guides/user/installation.md +++ b/docs/guides/user/installation.md @@ -148,6 +148,7 @@ is distinct from a user/source installation. It provides: /usr/local/bin/timelocker /usr/local/bin/tl /usr/local/bin/timelocker-tray +/usr/local/sbin/timelocker-deploy /usr/local/libexec/timelocker-system-control /usr/local/share/icons/hicolor/1024x1024/apps/timelocker.png /opt/timelocker/releases/RELEASE_ID/ @@ -161,11 +162,29 @@ current working directory. A staged release is selected only after its CLI, backend, and tray entrypoints pass compatibility probes. System control is exposed through `/run/timelocker/control.sock`. -The repository contains validated deployment primitives and packaged assets; -it does not currently expose a general end-user installer command. An -administrator must stage the release, install the root-owned assets, configure -the protected target and credentials by reference, approve retention, and -enable the required systemd units. +Build or obtain the approved local wheel, then use the supported administrator +entrypoint. It derives the artifact digest, release ID, schema-3 daemonless +manifest, and private staging paths; do not create a manifest or deployment +script manually. + +```bash +python -m build --wheel +sudo "$(pwd)/.venv/bin/timelocker-deploy" install \ + dist/timelocker-0.9.1-py3-none-any.whl --operator-user "$USER" + +sudo /usr/local/sbin/timelocker-deploy upgrade \ + /absolute/path/to/timelocker-NEW-py3-none-any.whl \ + --operator-user "$USER" +/usr/local/sbin/timelocker-deploy status +sudo /usr/local/sbin/timelocker-deploy rollback +``` + +Each command returns one JSON object and a stable exit status. Install/upgrade +creates or reuses `timelocker-operators`, privately snapshots the wheel, +validates its filename, package metadata, digest, and complete protected asset +set, then activates it transactionally. It stops/disables the legacy event +socket and never starts backup or retention. A rollback to a schema-1/2 release +is rejected because those releases can require the removed resident service. Verify an installed host without reading secrets: @@ -181,8 +200,9 @@ systemctl status timelocker-retention.timer ``` Protected reads and `system backup`/`system retention` requests require current -membership in `timelocker-operators`. These commands use the privileged backend -without elevating the caller process. +membership in `timelocker-operators`. These commands activate one privileged +helper for one request without elevating the caller process. The helper exits +after its response. Installation, group changes, policy approval, service changes, release selection, and rollback require root. @@ -203,19 +223,19 @@ still require a compatible Restic executable and any backend-specific credentials. No PyPI distribution is currently published; use the source path above until an authorized release provides downloadable artifacts. -The protected immutable-release, local-backend, systemd scheduling, and -independent-tray deployment is undergoing live acceptance on Linux Mint under -Spec 010. Package and installed-artifact checks have passed, but protected -deployment acceptance is not yet complete. The portable architecture includes -a Windows adapter, but a protected Windows deployment is not yet claimed as -live-accepted. +The daemonless immutable-release, systemd scheduling, and independent-tray +implementation has automated acceptance. Live protected-host mutation and the +90-second idle residency observation require separate operational approval. +The portable architecture includes a Windows adapter, but a protected Windows +deployment is not yet claimed as live-accepted. ### 4.9 Understand Modern Packaging Features - `pyproject.toml` for modern builds (PEP 517/518). - Optional dependency groups (`dev`, `gui`). S3 and B2 runtime dependencies are included in the base installation. -- Entry points install both `timelocker` and `tl` commands. +- Entry points install `timelocker`, `tl`, `timelocker-tray`, + `timelocker-system-control`, and `timelocker-deploy`. ### 4.10 Configure Environment diff --git a/docs/processes/version-management.md b/docs/processes/version-management.md index d63d958..cfc495e 100644 --- a/docs/processes/version-management.md +++ b/docs/processes/version-management.md @@ -106,8 +106,8 @@ After the workflow completes: 1. Confirm the release tag and GitHub release point to the approved commit. 2. Download both distributions and `SHA256SUMS` from the release. 3. Compare hashes and smoke a clean install through `timelocker`, `tl`, - `timelocker-system-control`, and `timelocker-tray`, including the packaged - control/event protocol contract and protected system assets. + `timelocker-system-control`, `timelocker-tray`, and `timelocker-deploy`, + including the packaged control protocol and daemonless protected assets. 4. Confirm the published body matches the corresponding changelog section. 5. Announce the release only after these checks pass. @@ -136,21 +136,21 @@ immutable history. Publishing a GitHub release and selecting a protected host release are separate boundaries. A protected host stages an immutable release under `/opt/timelocker/releases/RELEASE_ID/` with a manifest that binds its release -identity, package version, control protocol version, event protocol version, -and entrypoint. +identity, package version, control protocol version, and entrypoint. Schema 3 +explicitly has no privileged event protocol or status service. -Before activation, the deployment probes the staged CLI, backend, tray, -explicit control status, protected event channel, and active/enabled backup and -retention timers. Only then may the root-only selector atomically update +Before activation, `timelocker-deploy` validates and privately stages the local +wheel, derives its manifest, and probes the staged CLI, backend, tray, explicit +control status, and protected timers. Only then may the root-only selector update `/opt/timelocker/selected-release.json`, preserving the prior release identifier for rollback. Stable launchers resolve that selector and fail closed on missing, untrusted, incompatible, recursively invoked, or non-allowlisted state. -Rollback probes the previous release, explicit control status, and both timer +Rollback probes a schema-3 previous release, explicit control status, and timer states before swapping selected and previous identifiers. It does not delete protected configuration, credential references, retention policy, or durable -run records. A newer event socket asset may remain installed but inert when a -legacy release is selected. +run records. Schema-1/2 rollback is rejected because it can re-enable the +removed resident event service. ## Current Deferrals diff --git a/docs/reference/timelocker-cli-command-hierarchy.md b/docs/reference/timelocker-cli-command-hierarchy.md index 9663ed3..5a078b4 100644 --- a/docs/reference/timelocker-cli-command-hierarchy.md +++ b/docs/reference/timelocker-cli-command-hierarchy.md @@ -1,5 +1,6 @@ --- title: "Reference: TimeLocker CLI Command Hierarchy" +doc_type: reference id: "ref-cli-hierarchy" type: [ reference ] status: [ approved ] @@ -108,13 +109,26 @@ approved policy fingerprint and is hidden from the graphical menu when the tray was not configured with one. The tray communicates with the protected backend and does not own backup execution. -## Administrator Release Tool +## Administrator Deployment Tool + +`timelocker-deploy` is the supported root administration surface and is +installed at `/usr/local/sbin/timelocker-deploy`: + +```text +timelocker-deploy install WHEEL --operator-user ACCOUNT +timelocker-deploy upgrade WHEEL --operator-user ACCOUNT +timelocker-deploy status +timelocker-deploy rollback +``` + +Every operation returns one JSON object. Mutating operations require root; +status does not start a TimeLocker service process. The command derives the +wheel digest, release identity, and manifest rather than accepting manually +assembled identity inputs. `timelocker-release-select` is a root-only deployment tool. It is deliberately -not part of the public CLI hierarchy and is installed with restricted -permissions. Administrators use it to select or roll back compatible immutable -releases; ordinary operators do not gain release-management authority through -group membership. +not the supported operator workflow; it remains a restricted internal +primitive used by the transactional entrypoint. ## Routing Rules diff --git a/docs/specs/011-protected-system-deployment/README.md b/docs/specs/011-protected-system-deployment/README.md index 235f29c..7b9153b 100644 --- a/docs/specs/011-protected-system-deployment/README.md +++ b/docs/specs/011-protected-system-deployment/README.md @@ -2,9 +2,9 @@ title: Protected system deployment doc_type: spec artifact_type: overview -status: draft +status: active owner: Auriora Team -last_reviewed: 2026-07-28 +last_reviewed: 2026-08-12 --- # Protected System Deployment @@ -23,22 +23,21 @@ administrator deployment interface. ## Current Stage -- Requirements are being reconciled with the approved zero-idle-residency - constraint. -- Design and task authoring have not started. -- Implementation is not approved. -- Spec 010 resident-backend acceptance is halted; only independently valid - status semantics may be retained. -- Spec 011 now owns removal of the resident privileged backend as well as the - supported deployment transaction. +- Requirements and design are approved. +- Daemonless runtime and supported deployment implementation are complete. +- Automated validation, MoE review, promotion, and lifecycle closure are in + progress. +- Protected host mutation and the 90-second live idle observation remain a + separate operational approval boundary. ## Package - [Requirements](./requirements.md) - [Canonical context](./canonical-context.md) - -Design, tasks, change impact, traceability, and verification artifacts will be -added in their lifecycle stages after the requirements are reviewed. +- [Design](./design.md) +- [Tasks](./tasks.md) +- [Traceability](./traceability.md) +- [Verification](./verification.md) ## Approval Boundary diff --git a/docs/specs/011-protected-system-deployment/canonical-context.md b/docs/specs/011-protected-system-deployment/canonical-context.md index 90c6f92..0e7c948 100644 --- a/docs/specs/011-protected-system-deployment/canonical-context.md +++ b/docs/specs/011-protected-system-deployment/canonical-context.md @@ -2,16 +2,16 @@ title: Protected system deployment canonical context doc_type: spec artifact_type: canonical-context -status: draft +status: active owner: Auriora Team -last_reviewed: 2026-07-28 +last_reviewed: 2026-08-12 --- # Canonical Context ## Purpose -This package turns a Spec 010 acceptance harness into a future supported +This package turns a Spec 010 acceptance harness into the supported administrator workflow. This map prevents the proposed workflow, temporary acceptance evidence, or removed spec history from being mistaken for current installation behavior. @@ -37,9 +37,9 @@ or live system evidence. | Source | Role | Scope | Notes | |--------|------|-------|-------| -| `requirements.md` | Proposed observable deployment behavior | Spec 011 | Requires review before design. | -| future `design.md` | Deployment architecture and decisions | Spec 011 | Must reconcile with the proven Spec 010 transaction. | -| future `tasks.md` | Dependency-aware execution index | Spec 011 | Implementation must not begin from tasks alone. | +| `requirements.md` | Approved observable deployment behavior | Spec 011 | Implemented contract. | +| `design.md` | Deployment architecture and decisions | Spec 011 | Reconciled with the proven Spec 010 transaction. | +| `tasks.md` | Dependency-aware execution index | Spec 011 | Evidence is updated through lifecycle task states. | ## Imported Sources @@ -58,21 +58,28 @@ or live system evidence. |--------|----------------------|----------| | Removed Specs 007-009 recovered from Git | Closed delivery scaffolding | Use only for historical rationale; durable promoted documents own current behavior. | | `/tmp/timelocker-*` scripts and artifacts from acceptance work | Ephemeral, unversioned, or build-local evidence | Do not use as a supported deployment interface or durable procedure. | -| `scripts/deploy_t011_linux.py` after Spec 010 closure | Acceptance-specific name and contract | Preserve as evidence or compatibility input until Spec 011 replaces or retires it explicitly. | +| `scripts/deploy_t011_linux.py` after Spec 010 closure | Acceptance-specific name and contract | Deprecated compatibility wrapper; `timelocker-deploy` is authoritative. | ## Promotion Map | Spec-local content | Durable destination or route | Required before closure | |--------------------|------------------------------|-------------------------| | Supported install, upgrade, status, and rollback behavior | `docs/1-requirements/system-operations.md` | yes | -| Zero-idle-residency and short-lived protected execution | `CHARTER.md`, `docs/1-requirements/system-operations.md`, and `docs/2-architecture/system-architecture.md` | yes | +| Zero-idle project boundary | `CHARTER.md` | yes | +| Zero-idle operational requirement | `docs/1-requirements/system-operations.md` | yes | +| Short-lived protected-execution architecture | `docs/2-architecture/system-architecture.md` | yes | | Deployment components, trust boundaries, and platform adapters | `docs/2-architecture/system-architecture.md` | yes | -| Administrator procedure and troubleshooting | `docs/guides/user/installation.md` and a durable deployment runbook | yes | +| Administrator installation procedure | `docs/guides/user/installation.md` | yes | +| Administrator troubleshooting procedure | `docs/guides/user/backup-operations-troubleshooting.md` | yes | | Release artifact and host activation relationship | `docs/processes/version-management.md` | yes | -| Administrator command reference | `docs/reference/timelocker-cli-command-hierarchy.md` or a dedicated reference | yes | -| Live Windows implementation | follow-up spec or issue if not accepted in this package | yes | +| Administrator command reference | `docs/reference/timelocker-cli-command-hierarchy.md` | yes | +| Live Windows implementation routing | `docs/2-architecture/system-architecture.md` | yes | ## Related Artifacts - Requirements: [requirements.md](./requirements.md) - Overview: [README.md](./README.md) +- Design: [design.md](./design.md) +- Tasks: [tasks.md](./tasks.md) +- Traceability: [traceability.md](./traceability.md) +- Verification: [verification.md](./verification.md) diff --git a/docs/specs/011-protected-system-deployment/design.md b/docs/specs/011-protected-system-deployment/design.md new file mode 100644 index 0000000..d600029 --- /dev/null +++ b/docs/specs/011-protected-system-deployment/design.md @@ -0,0 +1,220 @@ +--- +title: Daemonless protected system deployment design +doc_type: spec +artifact_type: design +status: approved +owner: Auriora Team +last_reviewed: 2026-08-12 +--- + +# Technical Design + +## Overview + +Spec 011 replaces the resident root backend with a systemd socket-activated, +single-request helper. The socket remains a kernel-owned authorization entry +point; each TimeLocker process accepts one connection, derives peer identity, +serves one allowlisted request, atomically publishes a sanitized status +snapshot when appropriate, and exits. Scheduled backup and retention remain +one-shot units. + +The privileged status-event socket, heartbeat broker, resident filesystem +observer, and resident schedule monitor are removed from the installed asset +set. The optional user tray reads a group-authorized sanitized snapshot and +watches that file directly. Explicit tray actions still use the protected +single-request socket. + +The supported administrator entrypoint is `timelocker-deploy`. It owns local +wheel validation, trusted staging, manifest derivation, transactional install, +status, and rollback. It reuses the immutable-release resolver and deployment +primitives proven by Spec 010, while replacing acceptance-only inputs and +resident-service health gates. + +## Requirement Coverage + +| Requirement | Acceptance Criteria | Design Coverage | Validation Approach | +|-------------|---------------------|-----------------|---------------------| +| Requirement 1 | AC1-AC5 | `timelocker-deploy` install, upgrade, status, rollback command | CLI and installed-artifact tests | +| Requirement 2 | AC1-AC5 | Wheel metadata/digest validation and derived manifests | Tamper and mismatch tests | +| Requirement 3 | AC1-AC6 | Root-owned staging snapshot and bounded cleanup | Symlink, mode, ownership, and source-swap tests | +| Requirement 4 | AC1-AC5 | Preflight transaction and expected-current selection | Failure-injection transaction tests | +| Requirement 5 | AC1-AC5 | Probed rollback with state preservation | Rollback and preservation tests | +| Requirement 6 | AC1-AC5 | Deployment lock, idempotency, attention record | Concurrency and interruption tests | +| Requirement 7 | AC1-AC5 | Typed redacted results and root-owned evidence | Schema, permission, and redaction tests | +| Requirement 8 | AC1-AC5 | Platform-neutral engine with Linux adapter | Interface and unsupported-platform tests | +| Requirement 9 | AC1-AC7 | Single-request helper and direct snapshot watcher | Process-exit, asset, tray, and 90-second live checks | + +## Correctness Property Coverage + +| Property | Design Behavior | Validation Direction | Notes | +|----------|-----------------|----------------------|-------| +| CP-001 | Validation and preflight precede the mutation boundary | Failure injection at every preflight gate | No host writes before boundary | +| CP-002 | Selector uses locked expected-current compare-and-swap | Competing selector tests | Existing resolver retained | +| CP-003 | Post-boundary failure restores state or writes attention | Forced failure and signal tests | Mutation remains fail closed | +| CP-004 | Staged digest is rechecked before installation | Mutable-source and digest tests | Source is never reread | +| CP-005 | Deployment dispatcher has no backup/retention execution route | Action-spy tests | Timer state may be inspected only | +| CP-006 | Evidence schema admits only allowlisted non-secret fields | Redaction and exact-schema tests | No raw environment or subprocess output | +| CP-007 | Systemd and filesystem operations live behind Linux adapters | Interface and fake-adapter tests | Windows remains contractual | +| CP-008 | Helper serves one request and exits; no event service is installed | Unit, package, and live process checks | Socket unit is not a process | + +## High-Level Design + +### System Architecture + +```text +CLI or tray action -> systemd control socket -> one root helper -> response -> exit + | +scheduled one-shot worker --------------------------+ + v + atomic sanitized status snapshot + | +optional user tray -> initial read + filesystem watch (no privileged event service) + +administrator -> timelocker-deploy -> trusted staging -> preflight -> atomic activation +``` + +### Components and Changes + +- `backend_entry.py` and `linux_adapter.py`: add single-request serving and + remove resident monitors from the production path. +- `status_snapshot.py`: own exact atomic sanitized snapshot persistence and + authorized reads. +- `tray_client.py` and `tray_entry.py`: replace privileged event subscription + with direct snapshot-file observation; retain socket use for explicit actions. +- packaged systemd assets: remove the status-event socket and make the control + service exit after one request. +- `deployment.py` and new deployment entrypoint: derive trusted release inputs, + stage privately, transact, report status, and roll back. +- deployment and artifact tests: require the daemonless asset set and reject + resident event/service dependencies. + +### Data Models + +`SanitizedStatusFile` uses the existing strict `StatusSnapshot` wire model plus +an outer file schema version. It contains no repository URI, credential, +environment, journal, command, or arbitrary path fields. Writes use a temporary +file in the destination directory, `fsync`, mode `0640`, and atomic replace. + +`DeploymentResult` contains operation, stable result code, selected/previous +release IDs, mutation-started and recovery fields, and evidence location. +`DeploymentEvidence` contains only validated digest, package/release identity, +stage outcomes, timestamps, and rollback disposition. + +### Data Flow + +1. A client connects to `/run/timelocker/control.sock`. +2. systemd launches the selected helper only if no instance is active. +3. The helper accepts one bounded frame and derives the kernel peer identity. +4. The dispatcher rechecks group membership and executes one allowlisted action. +5. Status-producing paths atomically refresh the sanitized snapshot. +6. The helper sends one bounded response and exits. +7. The tray reads the current snapshot and receives direct filesystem changes. + +## Low-Level Design + +### Algorithms and Logic + +```text +serve_one_request: + adopt systemd control socket + accept one connection + derive peer identity and dispatch one bounded request + send one bounded response + close listener and exit + +deploy_local_wheel: + require root and acquire deployment lock + validate source filename, metadata, assets, and digest + copy once into private root-owned staging; revalidate digest + derive release and asset manifests + run candidate and authorization preflights + mark mutation boundary + install assets and compare-and-swap selector + verify one-shot helper, timers, and installed entrypoints + write redacted evidence and result + on failure after boundary, restore and verify or write attention +``` + +### Function Signatures and Interfaces + +```text +LinuxUnixSocketTransport.serve_once(handler) -> None +AtomicStatusSnapshotStore.read() -> StatusSnapshot +AtomicStatusSnapshotStore.write(snapshot) -> None +StatusSnapshotWatcher.events(stop_event) -> Iterator[StatusSnapshot] +DeploymentEntrypoint.install(artifact) -> DeploymentResult +DeploymentEntrypoint.upgrade(artifact) -> DeploymentResult +DeploymentEntrypoint.status() -> DeploymentResult +DeploymentEntrypoint.rollback() -> DeploymentResult +``` + +### Error Handling + +Malformed requests, identity failures, unavailable snapshots, and invalid +deployment inputs use stable bounded errors. No error includes raw paths beyond +documented evidence locations, environment values, repository identifiers, or +subprocess output. Pre-boundary deployment errors mutate nothing. Post-boundary +errors recover or create an attention record that blocks later mutation. + +### Security, Trust, and Access + +Kernel peer credentials and current operator-group membership remain the +authorization source. The sanitized snapshot directory is root-owned and +operator-group readable, never writable by the tray. Deployment requires root, +rejects symlinks/untrusted writable inputs, snapshots mutable input once, and +does not read caller configuration or credentials. No shell command strings are +constructed from untrusted values. + +### Migration and Compatibility + +Upgrade installs the new service and control socket, stops and disables the +legacy status-event socket, and removes its socket path. Existing protected +configuration, run records, timers, selected/previous releases, and public +status semantics are retained. Old releases remain rollback candidates only if +their activation does not re-enable a rejected resident backend; otherwise the +entrypoint fails with an explicit incompatible-rollback result. + +### Slice Boundary And Residual Architecture + +| Design target | In this slice | Out of this slice | Follow-up destination | Blocks closure? | +|---------------|---------------|-------------------|-----------------------|-----------------| +| Daemonless Linux protected runtime | Single-request socket helper, snapshot, tray watch, assets | none | none | yes | +| Supported local-wheel deployment | install, upgrade, status, rollback | network release acquisition | future spec if requested | no | +| Windows portability | interfaces and fail-closed unsupported result | live Windows implementation/acceptance | future Windows spec | no | +| Release publication | deployment consumes a local artifact | PyPI/GitHub publication | existing release process | no | + +## Validation Strategy + +| Validation | Covers | Evidence Location | Residual Risk | +|------------|--------|-------------------|---------------| +| Focused system-control and deployment tests | Requirements 1-9, CP-001-CP-008 | tasks and `verification.md` | systemd integration remains host-specific | +| Package asset and installed-wheel smoke | Entrypoints and daemonless asset set | `verification.md` | distro packaging differences | +| TimeLocker MoE review | architecture, backup safety, security, tests, operations, docs | T review task | bounded review limitations | +| Linux live acceptance with 90-second idle observation | Requirement 9 and operational migration | protected evidence | requires separate host-mutation approval | + +## Downstream Task Guidance + +- Implement daemonless runtime and tests before deployment consolidation. +- Cover every CP property in task and traceability artifacts. +- Create change impact, tasks, traceability, and verification before code edits. +- Repeat expert review after implementation and before promotion. + +## Operational Considerations + +The kernel may retain an enabled socket while no TimeLocker process exists. +That is compliant zero process/CPU residency. The optional tray is an explicitly +chosen user process, not privileged, and must tolerate a missing or stale +snapshot. Migration must stop the existing daemon and event socket without +triggering backup or retention. + +## Open Questions + +None blocking. The approved initial artifact source is a local wheel; remote +release acquisition is explicitly outside this implementation slice. + +## Related Artifacts + +- Requirements: [requirements.md](./requirements.md) +- Canonical context: [canonical-context.md](./canonical-context.md) +- Tasks: [tasks.md](./tasks.md) +- Verification: [verification.md](./verification.md) diff --git a/docs/specs/011-protected-system-deployment/requirements.md b/docs/specs/011-protected-system-deployment/requirements.md index 96b71f5..1e6f1e5 100644 --- a/docs/specs/011-protected-system-deployment/requirements.md +++ b/docs/specs/011-protected-system-deployment/requirements.md @@ -2,9 +2,9 @@ title: Protected system deployment requirements doc_type: spec artifact_type: requirements -status: draft +status: implemented owner: Auriora Team -last_reviewed: 2026-07-28 +last_reviewed: 2026-08-12 --- # Requirements @@ -193,8 +193,8 @@ protection. 1. BEFORE changing a service unit, stable launcher, or selected release, THE ENTRYPOINT SHALL verify the staged CLI, backend, tray, packaged assets, - control protocol, event protocol, authorized access, denied access, and - required timer health. + control protocol, daemonless manifest schema, authorized access, denied + access, and required timer health. 2. WHEN selecting a release, THE ENTRYPOINT SHALL use a locked expected-current compare-and-swap operation. 3. IF the selected release changes after the transaction begins, THEN the @@ -386,22 +386,21 @@ resources or create daemon-specific failure modes. - **SC-008:** Linux live acceptance shows no TimeLocker-owned privileged process during at least 90 seconds with no protected operation running. -## Design Decisions Deferred To The Next Stage +## Resolved Design Decisions -- Administrator command name and whether it is a standalone bootstrap - executable or an installed `timelocker` subcommand. -- Supported artifact sources for the first slice: committed local build, - downloaded GitHub release, or both. -- Root-owned staging and retained-evidence directory layout. -- Whether initial installation and later upgrades share one command or one - transaction engine behind separate verbs. -- Linux packaging boundary and the minimum Windows adapter delivered in this - package. +- The supported command is the installed standalone `timelocker-deploy` + entrypoint with install, upgrade, status, and rollback verbs. +- The accepted initial artifact source is one local wheel. +- Private inputs and retained evidence live below the protected deployment + evidence root; operator workflows do not use `/tmp`. +- Install and upgrade share one transaction engine and expose separate verbs. +- Linux/systemd is implemented; Windows remains an explicit future platform + implementation and acceptance boundary. ## Related Artifacts - Overview: [README.md](./README.md) - Canonical Context: [canonical-context.md](./canonical-context.md) -- Design: to be created after requirements review -- Tasks: to be created after design review -- Verification: to be created with design and task traceability +- Design: [design.md](./design.md) +- Tasks: [tasks.md](./tasks.md) +- Verification: [verification.md](./verification.md) diff --git a/docs/specs/011-protected-system-deployment/tasks.md b/docs/specs/011-protected-system-deployment/tasks.md new file mode 100644 index 0000000..bc79487 --- /dev/null +++ b/docs/specs/011-protected-system-deployment/tasks.md @@ -0,0 +1,101 @@ +--- +title: Protected system deployment tasks +doc_type: spec +artifact_type: tasks +status: active +owner: Auriora Team +last_reviewed: 2026-08-12 +--- + +# Tasks + +**Input:** All artifacts in `docs/specs/011-protected-system-deployment/` + +## Dependency Graph + +`T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007` + +## Phase 1: Daemonless Runtime + +- [x] T001 Make the protected Linux helper serve one request and exit. + - Depends on: none + - Requirements: Requirement 4, Requirement 8, Requirement 9 + - Properties: CP-005, CP-007, CP-008 + - Files: backend entry, Linux transport, systemd assets, focused tests + - Acceptance: Production socket activation handles one authorized request, + closes, and exits; no event socket, heartbeat, watcher, or resident monitor + is required by installed assets. + - Evidence: `src/TimeLocker/system_control/backend_entry.py`, `linux_adapter.py`, and `assets/timelocker-control.service` use one-request `serve_once`; `python3 -m pytest` focused run passed 295 tests, including `test_run_linux_backend_serves_one_request_and_exits` and unit asset assertions. + +- [x] T002 Publish and consume an atomic sanitized status snapshot. + - Depends on: T001 + - Requirements: Requirement 2, Requirement 7, Requirement 9 + - Properties: CP-004, CP-006, CP-008 + - Files: snapshot store/watcher, backend worker hooks, tray client/entry, tests + - Acceptance: Root-owned workers write exact group-readable status state; + reads do not publish changes; the tray performs an initial read and direct + filesystem observation without a privileged event channel. + - Evidence: `status_snapshot.py`, `tray_client.py`, and `tray_entry.py` provide atomic 0640 publication, fd-safe reads, startup refresh, and direct filesystem watching; the 295-test focused pytest run includes `test_status_snapshot.py` and tray subscription tests. + +- [x] T003 Replace resident deployment assets and migration gates. + - Depends on: T002 + - Requirements: Requirement 4, Requirement 5, Requirement 9 + - Properties: CP-003, CP-005, CP-008 + - Files: packaged units, asset manifest, activation/rollback checks, tests + - Acceptance: The status-event socket is absent, service startup is + socket-only and non-resident, legacy units are stopped/disabled during + activation, timers and protected state are preserved. + - Evidence: Release schema 3 and packaged asset tests assert `timelocker-status-events.socket` is absent, legacy units are disabled, only the control socket is enabled, timers are preserved, and `RuntimeDirectoryPreserve=yes`; focused pytest passed 295 tests. + +## Phase 2: Supported Deployment Workflow + +- [x] T004 Add the supported local-wheel administrator deployment entrypoint. + - Depends on: T003 + - Requirements: Requirement 1-Requirement 8 + - Properties: CP-001-CP-007 + - Files: deployment engine/entrypoint, packaging metadata, tests + - Acceptance: Install/upgrade/status/rollback expose stable JSON results; + validate artifact identity, privately stage once, lock mutation, derive + manifests, preserve rollback state, and produce redacted evidence. + - Evidence: `deployment_entry.py`, `timelocker-deploy-launcher`, and the `timelocker-deploy` project entry point cover offline local-wheel install, upgrade, status, and rollback; focused pytest passed 295 tests and artifact smoke executed the installed wheel. + +- [x] T005 Run focused, package, full regression, and Linux-safe acceptance. + - Depends on: T004 + - Requirements: Requirement 1-Requirement 9 + - Properties: CP-001-CP-008 + - Acceptance: Focused tests, full configured regression, Ruff, compile, + package validation, installed-artifact smoke, lifecycle checks, and a + non-mutating process-residency probe pass. Protected deployment and the + 90-second live host interval require their separate operational approval. + - Evidence: `git diff --check`, scoped Ruff, compileall, 295 focused pytest tests, wheel/sdist validation of 28 package-data files, and installed-wheel smoke on Python 3.12.6 passed. Full pytest recorded 3166 passed, 1 skipped, and one unrelated repository-resolver timing failure. + +## Phase 3: Review And Closure + +- [x] T006 Run the TimeLocker MoE review and address findings. + - Depends on: T005 + - Requirements: Requirement 1-Requirement 9 + - Acceptance: All seven expert roles are applied; actionable findings are + fixed, rejected with evidence, or routed once. + - Evidence: The seven-role review table in `verification.md` records each conclusion and disposition. Remediation is directly covered by `test_t011_linux_deployment.py`, `test_status_snapshot.py`, backend, tray, release-artifact, and deployment tests in the 295-test passing run. + +- [x] T007 Promote durable documentation and close Spec 011. + - Depends on: T006 + - Requirements: Requirement 1-Requirement 9 + - Acceptance: Accepted behavior is promoted, residual Windows/live-host work + is explicitly routed, lifecycle closure passes, the complete final package + is committed, and cleanup metadata is resolved. + - Evidence: Promotion changes are present in `docs/1-requirements/system-operations.md`, architecture, service integration, installation, troubleshooting, tray, version management, and command reference. `lint_spec_package` and `task_state_audit` report zero errors and zero warnings; separate Windows and protected-host acceptance are recorded in `verification.md`. + +## Execution Rules + +- Do not mutate the protected host, run backup/retention, publish a release, or + activate a candidate without the separate operational approval. +- Preserve protected configuration, credentials, timers, and run records. +- Record executed checks and review disposition under the owning task. + +## Related Artifacts + +- Requirements: [requirements.md](./requirements.md) +- Design: [design.md](./design.md) +- Traceability: [traceability.md](./traceability.md) +- Verification: [verification.md](./verification.md) diff --git a/docs/specs/011-protected-system-deployment/traceability.md b/docs/specs/011-protected-system-deployment/traceability.md new file mode 100644 index 0000000..acb9ec1 --- /dev/null +++ b/docs/specs/011-protected-system-deployment/traceability.md @@ -0,0 +1,92 @@ +--- +title: Protected system deployment traceability +doc_type: spec +artifact_type: traceability +status: active +owner: Auriora Team +last_reviewed: 2026-08-12 +--- + +# Traceability Matrix + +## Task To Context Matrix + +| Task | Requirements | Acceptance criteria | Design coverage | Verification | Durable targets | +|------|--------------|---------------------|-----------------|--------------|-----------------| +| T001 | Requirement 4, Requirement 8, Requirement 9 | R4 AC1/AC4; R8 AC2/AC5; R9 AC1-AC3/AC6 | single-request helper | V1, V3 | architecture, operations | +| T002 | Requirement 2, Requirement 7, Requirement 9 | R2 AC5; R7 AC2/AC4; R9 AC4-AC6 | snapshot store and watcher | V1-V3 | requirements, tray guide | +| T003 | Requirement 4, Requirement 5, Requirement 9 | R4 AC1/AC5; R5 AC2-AC3; R9 AC1-AC3/AC5 | daemonless assets and migration | V1, V3-V4 | architecture, installation | +| T004 | Requirement 1-Requirement 8 | all local-wheel criteria | deployment entrypoint and transaction | V1-V5 | installation, version process, reference | +| T005 | Requirement 1-Requirement 9 | all automated criteria | validation strategy | V1-V7 | testing docs | +| T006 | Requirement 1-Requirement 9 | review disposition | all security and operational sections | V8 | all targets | +| T007 | Requirement 1-Requirement 9 | promotion and closure | residual architecture | V9-V10 | all targets and history | + +## Requirement To Delivery Matrix + +| Requirement | Priority | Tasks | Verification gates | Durable targets | Coverage State | Residual Destination | +|-------------|----------|-------|--------------------|-----------------|----------------|----------------------| +| Requirement 1 | must-have | T004-T007 | V1, V5-V10 | installation, reference | complete | Supported entrypoint implementation and documentation. | +| Requirement 2 | must-have | T002, T004-T007 | V1-V2, V5-V10 | requirements, version process | complete | Local-wheel provenance; remote acquisition is outside this slice. | +| Requirement 3 | must-have | T004-T007 | V1-V2, V5-V10 | installation, security guidance | complete | Private staging and bounded cleanup. | +| Requirement 4 | must-have | T001, T003-T007 | V1, V3-V10 | architecture, operations | complete | Preflight-first daemonless activation. | +| Requirement 5 | must-have | T003-T007 | V1, V4-V10 | installation, version process | complete | Rollback and protected-state preservation. | +| Requirement 6 | must-have | T004-T007 | V1, V5-V10 | operations | complete | Lock, idempotency, and attention evidence. | +| Requirement 7 | must-have | T002, T004-T007 | V1-V2, V5-V10 | troubleshooting, reference | complete | Redacted typed evidence and status. | +| Requirement 8 | should-have | T001, T004-T007 | V1, V5-V10 | architecture | partial-routed | Platform-neutral contracts included; live Windows implementation routed to a future Windows spec. | +| Requirement 9 | must-have | T001-T003, T005-T007 | V1-V4, V6-V10 | charter, requirements, architecture, tray guide | complete | Automated zero-residency proof; protected live deployment remains operationally approval-gated. | + +## Correctness Property Coverage + +| Property | Tasks | Verification | Residual risk | +|----------|-------|--------------|---------------| +| CP-001 | T004-T005 | V1, V5 | platform command behavior | +| CP-002 | T003-T005 | V1, V4-V5 | live competing administrator | +| CP-003 | T003-T005 | V1, V4-V5 | signal timing on live systemd | +| CP-004 | T002, T004-T005 | V1-V2, V5 | filesystem-specific durability | +| CP-005 | T001, T003-T005 | V1, V3-V5 | none | +| CP-006 | T002, T004-T006 | V1-V2, V5, V8 | unknown future secret categories | +| CP-007 | T001, T004-T006 | V1, V5, V8 | Windows live implementation | +| CP-008 | T001-T003, T005-T006 | V1-V4, V6, V8 | live 90-second interval requires approval | + +## Design To Implementation Matrix + +| Design element | Implementation | Direct verification | +|----------------|----------------|---------------------| +| Single-request protected helper | `backend_entry.py`, `linux_adapter.py`, `timelocker-control.service` | one-shot backend and descriptor-contract tests | +| Sanitized atomic status | `status_snapshot.py`, backend publication hooks, `tray_client.py` | permissions, schema, atomic-replace, real watcher, and tray subscription tests | +| Removal of resident Linux event service | deleted status-event socket asset, schema-3 deployment manifest, release launcher | asset, release, artifact-validator, and installed-wheel smoke checks | +| Supported administrator command | `deployment_entry.py`, `timelocker-deploy-launcher`, project entry point | local-wheel, status, activation, rollback, and wrapper tests | +| Preflight, recovery, and evidence | deployment transaction, trusted lock/staging paths, attention evidence | validation, recovery, symlink, idempotency, and timer-health tests | +| Durable operator guidance | architecture, installation, troubleshooting, tray, release, and command docs | Markdown set checks and lifecycle promotion review | + +## Open Decision Impact + +| Decision | Delivery impact | Disposition | +|----------|-----------------|-------------| +| Linux must have zero idle TimeLocker service residency | The kernel may retain the socket; the privileged Python helper handles one request and exits. | accepted and implemented | +| Status must not require a privileged event daemon | Workers atomically publish one sanitized group-readable file; the optional tray watches that file directly. | accepted and implemented | +| Protected host mutation requires separate approval | Automated fakes, source/asset checks, packaging, and non-mutating probes are used here. | live install and 90-second observation routed | +| Windows deployment must not be implied by Linux delivery | Imports/help fail safely across platforms, while service-control acceptance remains unclaimed. | future Windows spec | +| Legacy event abstractions may remain as compatibility code | They are not referenced by Linux production composition or packaged service assets. | accepted low-risk cleanup debt | + +## Verification Gate Key + +| Gate | Description | +|------|-------------| +| V1 | Focused daemonless runtime and deployment tests | +| V2 | Snapshot schema, permissions, atomicity, watcher, and redaction tests | +| V3 | Unit/asset proof that one request exits and no event service is installed | +| V4 | Rollback/migration state-preservation tests | +| V5 | Administrator entrypoint, failure injection, package and artifact smoke | +| V6 | Process-residency probe and separately approved 90-second live check | +| V7 | Full configured regression, Ruff, compile, Markdown, and Git checks | +| V8 | TimeLocker MoE review and disposition | +| V9 | Durable promotion and lifecycle closure checks | +| V10 | Final-spec commit, package cleanup, and resolved history metadata | + +## Related Artifacts + +- Requirements: [requirements.md](./requirements.md) +- Design: [design.md](./design.md) +- Tasks: [tasks.md](./tasks.md) +- Verification: [verification.md](./verification.md) diff --git a/docs/specs/011-protected-system-deployment/verification.md b/docs/specs/011-protected-system-deployment/verification.md new file mode 100644 index 0000000..3736bfc --- /dev/null +++ b/docs/specs/011-protected-system-deployment/verification.md @@ -0,0 +1,116 @@ +--- +title: Protected system deployment verification +doc_type: spec +artifact_type: verification +status: active +owner: Auriora Team +last_reviewed: 2026-08-12 +--- + +# Verification + +## Scope + +Verify daemonless protected request execution, sanitized status publication and +tray observation, supported local-wheel deployment, rollback safety, evidence +redaction, packaging, and lifecycle closure without mutating the protected host +unless separately approved. + +## Quality Gates + +| Gate | Status | Evidence | +|------|--------|----------| +| Requirements and design reviewed | pass | User approved implementation on 2026-08-12. | +| Focused runtime and deployment regression | pass | 295 focused tests passed; configured suite executed with one unrelated timing-benchmark failure routed below. | +| Package and installed-artifact checks | pass | Wheel/sdist validation and installed-wheel smoke passed. | +| MoE review | pass | All seven roles completed; findings remediated or routed. | +| Durable promotion and closure | pass | Current-state documents promoted; lifecycle checks recorded below. | + +## Validation Commands + +- Focused pytest paths selected by changed-file impact. +- Full configured `python3 -m pytest`. +- Scoped `ruff check` and `python3 -m compileall`. +- Wheel/sdist build, asset validation, and installed-wheel smoke. +- Agent Workbench diagnostics, Markdown checks, and verification plan. +- Spec Lifecycle Manager lint, task audit, evidence, promotion, and closure. +- `git diff --check`. + +## Live Or Protected Verification + +Protected installation, unit mutation, backup/retention execution, rollback, +and the 90-second root-process/CPU observation retain separate operational +approval. Without that approval, automated systemd fakes, packaged-unit +inspection, process-exit tests, and a non-mutating current-process probe are +recorded; live acceptance is routed rather than implied. + +## Durable Promotion And Cleanup + +| Spec content | Durable destination | Status | Evidence | +|--------------|---------------------|--------|----------| +| Zero-idle and authorization requirements | `CHARTER.md`, `docs/1-requirements/system-operations.md` | complete | T007 | +| Daemonless runtime/deployment architecture | `docs/2-architecture/system-architecture.md` | complete | T007 | +| Component ownership | `docs/3-implementation/service-layer-integration.md` | complete | T007 | +| Installation and troubleshooting | `docs/guides/user/installation.md`, `docs/guides/user/backup-operations-troubleshooting.md` | complete | T007 | +| Tray behavior | `docs/SYSTEM-TRAY-SETUP.md` | complete | T007 | +| Release activation | `docs/processes/version-management.md` | complete | T007 | +| Command surface | `docs/reference/timelocker-cli-command-hierarchy.md` | complete | T007 | +| Windows live work | future Windows spec | routed | T007 | + +## MoE Review + +The repository-local TimeLocker review method was applied across all seven +roles after implementation. Findings were deduplicated before remediation. + +| Expert role | Review conclusion | Finding disposition | +|-------------|-------------------|---------------------| +| Project steward | The change restores the chartered zero-idle boundary while retaining explicit protected actions. | accepted; resident Linux event service removed | +| Restic backup and recovery | Deployment and verification do not execute backup or retention; existing timer state is preserved and checked. | fixed activation so only the control socket is enabled; added rollback timer-health verification | +| Python CLI architecture | One supported command owns install, upgrade, status, and rollback; the old script is only a compatibility wrapper. | fixed non-POSIX imports, stable failure results, retry behavior, and launcher packaging | +| Security and privacy | Kernel peer identity remains authoritative; status and evidence are bounded, sanitized, and protected. | fixed status-read TOCTOU, symlink-safe evidence writes, trusted lock roots, missing-group failure, and raw-error disclosure | +| Reliability and testing | One-shot execution, atomic publication, idempotency, recovery attention, and inert cleanup have direct tests. | fixed initial-install recovery, rollback health verification, stale input cleanup, and timer-start regression | +| Operations and portability | Linux uses socket activation without a resident service; live host mutation remains approval-gated. | fixed offline wheel installation, clean-host status, unit health output, and executable wrapper mode; Windows live work routed | +| Documentation lifecycle | Durable documents describe current behavior and immediate legacy shutdown. | promoted current state; Spec 010 rejection and Spec 011 ownership remain explicit | + +No unresolved high- or medium-severity finding remains in the automated scope. + +## Evidence Log + +| Date | Requirements | Gate | Result | Evidence | +|------|--------------|------|--------|----------| +| 2026-08-12 | Requirement 1-Requirement 9 | Focused daemonless system-control suite | pass | 295 tests passed after MoE remediation | +| 2026-08-12 | Requirement 2-Requirement 7, Requirement 9 | New deployment and snapshot contracts | pass | Focused coverage includes symlink, clean-host status, timer activation, rollback health, initial missing snapshot, and runtime-directory preservation | +| 2026-08-12 | Requirement 1-Requirement 9 | Static checks | pass | scoped Ruff and Python compile checks passed; `git diff --check` clean | +| 2026-08-12 | Requirement 1-Requirement 9 | Package contract | pass | wheel and sdist built; 28 package-data files and SHA-256 hashes validated; installed-wheel smoke passed on Python 3.12.6 | +| 2026-08-12 | Requirement 1-Requirement 9 | Full configured regression | routed | 3166 passed, 1 skipped, 1 failed: pre-existing `test_repository_resolver_performance` exceeded its 0.2-second threshold (0.3097 seconds; isolated rerun 0.3637 seconds) outside changed modules | +| 2026-08-12 | Requirement 1-Requirement 9 | Agent Workbench routing | limited | changed-file context was stale for the deleted asset; direct source and executed checks were used as authority | + +## Residual Risks + +- The protected install/upgrade/rollback transaction and 90-second live + root-process observation were not run because they require separate host + mutation approval. The exact operational check remains documented. +- Windows service-control, named-pipe deployment, elevation, interruption, and + rollback acceptance remain routed to a future Windows spec; no live Windows + support is claimed. +- Legacy event protocol classes remain as uncomposed compatibility code. They + are absent from Linux production composition and packaged service assets and + therefore create no idle process residency; removal can be handled as narrow + cleanup after downstream compatibility is assessed. +- The full configured suite retains one unrelated repository-resolver timing + benchmark failure. Its functional assertions pass elsewhere in the suite; + performance-threshold investigation is routed outside this system-control + change. + +## Readiness Decision + +- **Ready to implement:** yes - user approval recorded 2026-08-12 +- **Ready for promotion:** yes +- **Ready for closure:** yes, subject to the mechanical closure transaction + +## Related Artifacts + +- Requirements: [requirements.md](./requirements.md) +- Design: [design.md](./design.md) +- Tasks: [tasks.md](./tasks.md) +- Traceability: [traceability.md](./traceability.md) diff --git a/pyproject.toml b/pyproject.toml index 9e254bf..abd1008 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,7 @@ timelocker = "TimeLocker.cli:main" tl = "TimeLocker.cli:main" timelocker-tray = "TimeLocker.system_control.tray_entry:main" timelocker-system-control = "TimeLocker.system_control.backend_entry:main" +timelocker-deploy = "TimeLocker.system_control.deployment_entry:main" [project.urls] Homepage = "https://github.com/Auriora/TimeLocker" diff --git a/scripts/deploy_t011_linux.py b/scripts/deploy_t011_linux.py index 59d50c5..ca54810 100755 --- a/scripts/deploy_t011_linux.py +++ b/scripts/deploy_t011_linux.py @@ -1,991 +1,7 @@ #!/usr/bin/env python3 -"""Safely stage and activate a TimeLocker release for Spec 010 T011. +"""Deprecated Spec 010 compatibility wrapper for ``timelocker-deploy``.""" -This operator-facing harness deliberately keeps candidate probes independent of -temporary Python files. Every identity-sensitive probe runs against the staged -release before the selected-release document or systemd service unit changes. -""" - -from __future__ import annotations - -import argparse -from collections.abc import Sequence -from contextlib import contextmanager -from dataclasses import dataclass -import fcntl -import hashlib -import json -import os -from pathlib import Path -import pwd -import re -import shutil -import signal -import subprocess -import sys -from types import FrameType -from typing import TextIO - - -RELEASE_ID_PATTERN = re.compile(r"[0-9a-f]{40}") -WHEEL_FILENAME_PATTERN = re.compile( - r"[A-Za-z0-9_.+!]+(?:-[A-Za-z0-9_.+!]+){4,}\.whl" -) -REQUIRED_ENTRYPOINTS = ( - "timelocker", - "tl", - "timelocker-tray", - "timelocker-system-control", -) -REQUIRED_ACTIVE_UNITS = ( - "timelocker-control.service", - "timelocker-control.socket", - "timelocker-status-events.socket", - "timelocker-npbackup-migration.timer", - "timelocker-retention.timer", -) -REQUIRED_ENABLED_UNITS = ( - "timelocker-control.socket", - "timelocker-status-events.socket", - "timelocker-npbackup-migration.timer", - "timelocker-retention.timer", -) - -AUTHORIZED_EVENT_PROBE = """\ -import json -from threading import Event -from TimeLocker.system_control.event_client import UnixSocketStatusEventClient -stop = Event() -events = UnixSocketStatusEventClient().events(stop) -event = next(events) -stop.set() -print(json.dumps({ - "kind": event.kind.value, - "sequence": event.revision.sequence, - "session_id": str(event.revision.session_id), -}, sort_keys=True)) -""" - -DENIED_EVENT_PROBE = """\ -from threading import Event -from TimeLocker.system_control.event_client import ( - StatusEventAccessDenied, - UnixSocketStatusEventClient, -) -try: - next(UnixSocketStatusEventClient().events(Event())) -except StatusEventAccessDenied: - print("denied") -else: - raise SystemExit("unauthorized event subscription unexpectedly succeeded") -""" - -BACKEND_IMPORT_PROBE = """\ -from TimeLocker.system_control.backend_entry import main -from TimeLocker.system_control.models import ( - PROTOCOL_VERSION, - STATUS_EVENT_PROTOCOL_VERSION, -) -assert callable(main) -print(f"{PROTOCOL_VERSION}:{STATUS_EVENT_PROTOCOL_VERSION}") -""" - -PACKAGED_UNIT_PROBE = """\ -from importlib.resources import files -print(files("TimeLocker.system_control.assets") / "timelocker-control.service") -""" - -LAUNCHER_COMPATIBILITY_PROBE = """\ -import sys -from TimeLocker.system_control.release_launcher import ImmutableReleaseResolver -resolver = ImmutableReleaseResolver() -current = resolver.release_manifest(sys.argv[1]) -candidate = resolver.release_manifest(sys.argv[2]) -assert current.release_id == sys.argv[1] -assert candidate.release_id == sys.argv[2] -assert candidate.control_protocol_version == int(sys.argv[3]) -assert candidate.event_protocol_version == int(sys.argv[4]) -print("compatible") -""" - - -class DeploymentFailure(RuntimeError): - """Raised when a deployment gate fails or rollback cannot complete.""" - - -class DeploymentInterrupted(DeploymentFailure): - """Raised when SIGINT or SIGTERM interrupts a deployment.""" - - -@dataclass(frozen=True, slots=True) -class DeploymentPaths: - """Protected paths used by the Linux immutable-release deployment.""" - - releases_root: Path = Path("/opt/timelocker/releases") - selector: Path = Path("/opt/timelocker/selected-release.json") - service_unit: Path = Path("/etc/systemd/system/timelocker-control.service") - evidence_root: Path = Path("/var/lib/timelocker/migration-backup") - lock_file: Path = Path("/run/lock/timelocker-t011-deploy.lock") - launcher_venv: Path = Path("/opt/timelocker/launcher/venv") - - -@dataclass(frozen=True, slots=True) -class DeploymentRequest: - """Validated inputs identifying the exact release artifact to deploy.""" - - release_id: str - expected_current: str - wheel: Path - wheel_sha256: str - manifest: Path - operator_user: str - - -class CommandExecutor: - """Run bounded commands and optionally retain their redacted output.""" - - def run( - self, - arguments: Sequence[str | Path], - *, - timeout: int = 30, - output: Path | None = None, - capture: bool = False, - check: bool = True, - ) -> str: - command = [str(argument) for argument in arguments] - completed = subprocess.run( - command, - check=False, - capture_output=True, - text=True, - timeout=timeout, - ) - combined = completed.stdout - if completed.stderr: - combined += completed.stderr - if output is not None: - _write_private_text(output, combined) - if check and completed.returncode != 0: - raise DeploymentFailure( - f"command failed ({completed.returncode}): {_display_command(command)}" - ) - return completed.stdout if capture else combined - - -class T011LinuxDeployer: - """Preflight-first, rollback-safe Linux deployment transaction.""" - - def __init__( - self, - request: DeploymentRequest, - *, - paths: DeploymentPaths | None = None, - executor: CommandExecutor | None = None, - owner_uid: int | None = 0, - owner_gid: int | None = 0, - ) -> None: - self.request = request - self.paths = paths or DeploymentPaths() - self.executor = executor or CommandExecutor() - self.owner_uid = owner_uid - self.owner_gid = owner_gid - self.release = self.paths.releases_root / request.release_id - self.evidence: Path | None = None - self.staged_wheel: Path | None = None - self.staged_manifest: Path | None = None - self.staged_launcher = self.paths.launcher_venv.with_name( - f".venv.{request.release_id}.staged" - ) - self.previous_launcher = self.paths.launcher_venv.with_name( - f"venv.previous.{request.expected_current}" - ) - self.launcher_prior_moved = False - self.launcher_swapped = False - self.mutation_started = False - self.completed = False - - def deploy(self) -> Path: - """Stage, preflight, activate, and verify one exact release.""" - self.validate_request() - self.capture_baseline() - try: - self.stage_release() - self.preflight_staged_release() - self.activate() - self.verify_activation() - self.completed = True - except BaseException: - self.recover() - raise - assert self.evidence is not None - return self.evidence - - def validate_request(self) -> None: - """Reject unsafe or incoherent inputs before creating host state.""" - for field, value in ( - ("release_id", self.request.release_id), - ("expected_current", self.request.expected_current), - ): - if RELEASE_ID_PATTERN.fullmatch(value) is None: - raise DeploymentFailure(f"{field} must be a 40-character Git SHA") - if ( - len(self.request.wheel_sha256) != 64 - or any( - character not in "0123456789abcdef" - for character in self.request.wheel_sha256 - ) - ): - raise DeploymentFailure("wheel_sha256 must be a lowercase SHA-256 digest") - _require_regular_file(self.request.wheel, "wheel") - _require_regular_file(self.request.manifest, "manifest") - _validated_wheel_filename(self.request.wheel) - try: - pwd.getpwnam(self.request.operator_user) - except KeyError as error: - raise DeploymentFailure("operator_user does not exist") from error - if self.release.exists(): - raise DeploymentFailure(f"candidate release already exists: {self.release}") - if self.staged_launcher.exists(): - raise DeploymentFailure( - f"staged launcher already exists: {self.staged_launcher}" - ) - if self.previous_launcher.exists(): - raise DeploymentFailure( - f"launcher rollback path already exists: {self.previous_launcher}" - ) - _require_trusted_directory( - self.paths.launcher_venv.parent, - expected_owner_uid=self.owner_uid, - ) - _require_trusted_directory( - self.paths.launcher_venv, - expected_owner_uid=self.owner_uid, - ) - _require_trusted_executable( - self.paths.launcher_venv / "bin/python", - expected_owner_uid=self.owner_uid, - ) - if _selected_release(self.paths.selector) != self.request.expected_current: - raise DeploymentFailure("selected release changed before deployment") - for unit in REQUIRED_ACTIVE_UNITS: - self._systemctl_gate("is-active", unit) - for unit in REQUIRED_ENABLED_UNITS: - self._systemctl_gate("is-enabled", unit) - - def capture_baseline(self) -> None: - """Create private evidence and immutable rollback inputs.""" - timestamp = subprocess.run( - ["date", "-u", "+%Y%m%dT%H%M%SZ"], - check=True, - capture_output=True, - text=True, - ).stdout.strip() - self.evidence = ( - self.paths.evidence_root - / f"t011-hardened-deploy-{timestamp}-{os.getpid()}" - ) - _mkdir(self.evidence, mode=0o750, uid=self.owner_uid, gid=self.owner_gid) - _atomic_copy( - self.paths.selector, - self.evidence / "selected-release.before.json", - mode=0o600, - uid=self.owner_uid, - gid=self.owner_gid, - ) - _atomic_copy( - self.paths.service_unit, - self.evidence / "timelocker-control.service.before", - mode=0o600, - uid=self.owner_uid, - gid=self.owner_gid, - ) - self.staged_wheel = self.evidence / _validated_wheel_filename( - self.request.wheel - ) - self.staged_manifest = self.evidence / "candidate-release.json" - _atomic_copy( - self.request.wheel, - self.staged_wheel, - mode=0o600, - uid=self.owner_uid, - gid=self.owner_gid, - ) - _atomic_copy( - self.request.manifest, - self.staged_manifest, - mode=0o600, - uid=self.owner_uid, - gid=self.owner_gid, - ) - if _sha256(self.staged_wheel) != self.request.wheel_sha256: - raise DeploymentFailure("copied wheel SHA-256 does not match") - manifest = _read_json(self.staged_manifest) - expected_manifest = { - "schema_version": 2, - "release_id": self.request.release_id, - "control_protocol_version": 2, - "event_protocol_version": 1, - "entrypoint": "venv/bin/timelocker", - } - for field, expected in expected_manifest.items(): - if manifest.get(field) != expected: - raise DeploymentFailure(f"manifest {field} is incompatible") - - def stage_release(self) -> None: - """Install the wheel into an inert, immutable release directory.""" - assert self.evidence is not None - assert self.staged_wheel is not None - assert self.staged_manifest is not None - _mkdir(self.release, mode=0o755, uid=self.owner_uid, gid=self.owner_gid) - self.executor.run( - ["python3", "-m", "venv", "--system-site-packages", self.release / "venv"], - timeout=120, - output=self.evidence / "venv-create.txt", - ) - python = self.release / "venv/bin/python" - self.executor.run( - [ - python, - "-m", - "pip", - "install", - "--disable-pip-version-check", - self.staged_wheel, - ], - timeout=600, - output=self.evidence / "pip-install.txt", - ) - _atomic_copy( - self.staged_manifest, - self.release / "release.json", - mode=0o644, - uid=self.owner_uid, - gid=self.owner_gid, - ) - _make_tree_immutable( - self.release, - uid=self.owner_uid, - gid=self.owner_gid, - ) - self.executor.run( - [ - "python3", - "-m", - "venv", - "--system-site-packages", - self.staged_launcher, - ], - timeout=120, - output=self.evidence / "launcher-venv-create.txt", - ) - self.executor.run( - [ - self.staged_launcher / "bin/python", - "-m", - "pip", - "install", - "--disable-pip-version-check", - "--no-deps", - self.staged_wheel, - ], - timeout=300, - output=self.evidence / "launcher-pip-install.txt", - ) - _make_tree_immutable( - self.staged_launcher, - uid=self.owner_uid, - gid=self.owner_gid, - ) - - def preflight_staged_release(self) -> None: - """Exercise every target identity before protected activation.""" - assert self.evidence is not None - python = self.release / "venv/bin/python" - for entrypoint in REQUIRED_ENTRYPOINTS: - path = self.release / "venv/bin" / entrypoint - _require_regular_file(path, f"staged entrypoint {entrypoint}") - expected = f"#!{python}" - try: - actual = path.open(encoding="utf-8").readline().rstrip("\n") - except OSError as error: - raise DeploymentFailure( - f"cannot inspect staged entrypoint: {entrypoint}" - ) from error - if actual != expected or not os.access(path, os.X_OK): - raise DeploymentFailure( - f"staged entrypoint is not executable at its final path: {entrypoint}" - ) - - protocol_output = self.executor.run( - [python, "-c", BACKEND_IMPORT_PROBE], - output=self.evidence / "preflight-backend-protocol.txt", - capture=True, - ).strip() - assert self.staged_manifest is not None - manifest = _read_json(self.staged_manifest) - expected_protocol_output = ( - f"{manifest['control_protocol_version']}:" - f"{manifest['event_protocol_version']}" - ) - if protocol_output != expected_protocol_output: - raise DeploymentFailure( - "staged backend protocol probe failed: " - f"expected {expected_protocol_output}, got {protocol_output or ''}" - ) - launcher_output = self.executor.run( - [ - self.staged_launcher / "bin/python", - "-c", - LAUNCHER_COMPATIBILITY_PROBE, - self.request.expected_current, - self.request.release_id, - str(manifest["control_protocol_version"]), - str(manifest["event_protocol_version"]), - ], - output=self.evidence / "preflight-launcher-compatibility.txt", - capture=True, - ).strip() - if launcher_output != "compatible": - raise DeploymentFailure("staged launcher compatibility probe failed") - packaged_unit = Path( - self.executor.run( - [python, "-c", PACKAGED_UNIT_PROBE], - capture=True, - ).strip() - ) - self._validate_packaged_unit(packaged_unit) - self.executor.run( - ["systemd-analyze", "verify", packaged_unit], - timeout=30, - output=self.evidence / "systemd-analyze-preflight.txt", - ) - - candidate_cli = self.release / "venv/bin/timelocker" - candidate_version = self.executor.run( - [ - "timeout", - "10", - "runuser", - "-u", - self.request.operator_user, - "--", - candidate_cli, - "version", - "--short", - ], - timeout=15, - output=self.evidence / "preflight-cli-version.txt", - capture=True, - ).strip() - expected_package_version = manifest["package_version"] - if candidate_version != expected_package_version: - raise DeploymentFailure( - "staged CLI version probe failed: " - f"expected {expected_package_version}, " - f"got {candidate_version or ''}" - ) - self.executor.run( - [ - "timeout", - "10", - "runuser", - "-u", - self.request.operator_user, - "--", - python, - "-c", - AUTHORIZED_EVENT_PROBE, - ], - timeout=15, - output=self.evidence / "preflight-authorized-event.json", - ) - denied = self.executor.run( - [ - "timeout", - "10", - "setpriv", - "--reuid=65534", - "--regid=65534", - "--clear-groups", - python, - "-c", - DENIED_EVENT_PROBE, - ], - timeout=15, - capture=True, - ).strip() - if denied != "denied": - raise DeploymentFailure("staged denied-identity probe did not deny access") - _write_private_text(self.evidence / "preflight-denied-event.txt", denied + "\n") - - if _selected_release(self.paths.selector) != self.request.expected_current: - raise DeploymentFailure("selector changed during staged preflight") - for unit in REQUIRED_ACTIVE_UNITS: - self._systemctl_gate("is-active", unit) - for unit in REQUIRED_ENABLED_UNITS: - self._systemctl_gate("is-enabled", unit) - - def activate(self) -> None: - """Perform the bounded protected mutation after all preflights pass.""" - assert self.evidence is not None - if _selected_release(self.paths.selector) != self.request.expected_current: - raise DeploymentFailure("selector changed immediately before activation") - python = self.release / "venv/bin/python" - packaged_unit = Path( - self.executor.run( - [python, "-c", PACKAGED_UNIT_PROBE], - capture=True, - ).strip() - ) - assert self.staged_manifest is not None - manifest = _read_json(self.staged_manifest) - self.mutation_started = True - self.launcher_prior_moved = True - os.replace(self.paths.launcher_venv, self.previous_launcher) - os.replace(self.staged_launcher, self.paths.launcher_venv) - self.launcher_swapped = True - launcher_output = self.executor.run( - [ - self.paths.launcher_venv / "bin/python", - "-c", - LAUNCHER_COMPATIBILITY_PROBE, - self.request.expected_current, - self.request.release_id, - str(manifest["control_protocol_version"]), - str(manifest["event_protocol_version"]), - ], - output=self.evidence / "activated-launcher-compatibility.txt", - capture=True, - ).strip() - if launcher_output != "compatible": - raise DeploymentFailure("activated launcher compatibility probe failed") - _atomic_copy( - packaged_unit, - self.paths.service_unit, - mode=0o644, - uid=self.owner_uid, - gid=self.owner_gid, - ) - self.executor.run(["systemctl", "daemon-reload"]) - self.executor.run( - [ - python, - "-m", - "TimeLocker.system_control.release_admin", - "select", - self.request.release_id, - "--expected-current", - self.request.expected_current, - ], - output=self.evidence / "selected-release.txt", - ) - self.executor.run(["systemctl", "restart", "timelocker-control.service"]) - - def verify_activation(self) -> None: - """Verify the selected release without running backup or retention.""" - assert self.evidence is not None - if _selected_release(self.paths.selector) != self.request.release_id: - raise DeploymentFailure("candidate release was not selected") - for unit in REQUIRED_ACTIVE_UNITS: - self._systemctl_gate("is-active", unit) - for unit in REQUIRED_ENABLED_UNITS: - self._systemctl_gate("is-enabled", unit) - self.executor.run( - [ - "timeout", - "15", - "runuser", - "-u", - self.request.operator_user, - "--", - self.release / "venv/bin/timelocker", - "runs", - "list", - "--limit", - "3", - "--json", - ], - timeout=20, - output=self.evidence / "activated-authorized-runs.json", - ) - activated_event = self.executor.run( - [ - "timeout", - "10", - "runuser", - "-u", - self.request.operator_user, - "--", - self.release / "venv/bin/python", - "-c", - AUTHORIZED_EVENT_PROBE, - ], - timeout=15, - capture=True, - ) - try: - activated_payload = json.loads(activated_event) - except json.JSONDecodeError as error: - raise DeploymentFailure( - "activated event probe returned invalid JSON" - ) from error - if not isinstance(activated_payload, dict): - raise DeploymentFailure("activated event probe returned invalid JSON") - _write_private_text( - self.evidence / "activated-authorized-event.json", - activated_event, - ) - _atomic_copy( - self.paths.selector, - self.evidence / "selected-release.after.json", - mode=0o600, - uid=self.owner_uid, - gid=self.owner_gid, - ) - - def recover(self) -> None: - """Restore baseline state after any failed or interrupted transaction.""" - if self.completed: - return - errors: list[str] = [] - if self.mutation_started and self.evidence is not None: - for source, destination, mode in ( - ( - self.evidence / "selected-release.before.json", - self.paths.selector, - 0o644, - ), - ( - self.evidence / "timelocker-control.service.before", - self.paths.service_unit, - 0o644, - ), - ): - try: - _atomic_copy( - source, - destination, - mode=mode, - uid=self.owner_uid, - gid=self.owner_gid, - ) - except OSError as error: - errors.append(f"restore {destination}: {error}") - try: - self._restore_launcher() - except OSError as error: - errors.append(f"restore stable launcher: {error}") - for command in ( - ("systemctl", "daemon-reload"), - ("systemctl", "restart", "timelocker-control.socket"), - ("systemctl", "restart", "timelocker-status-events.socket"), - ("systemctl", "restart", "timelocker-control.service"), - ): - try: - self.executor.run(command, check=False) - except (DeploymentFailure, OSError) as error: - errors.append(f"{' '.join(command)}: {error}") - try: - if ( - _selected_release(self.paths.selector) - != self.request.expected_current - ): - errors.append("restored selector does not name prior release") - except DeploymentFailure as error: - errors.append(f"validate restored selector: {error}") - for action, units in ( - ("is-active", REQUIRED_ACTIVE_UNITS), - ("is-enabled", REQUIRED_ENABLED_UNITS), - ): - for unit in units: - try: - self._systemctl_gate(action, unit) - except DeploymentFailure as error: - errors.append(f"{action} {unit}: {error}") - if self.release.exists(): - try: - shutil.rmtree(self.release) - except OSError as error: - errors.append(f"remove candidate release: {error}") - if self.staged_launcher.exists(): - try: - shutil.rmtree(self.staged_launcher) - except OSError as error: - errors.append(f"remove staged launcher: {error}") - if errors: - raise DeploymentFailure( - "deployment failed and rollback was incomplete: " + "; ".join(errors) - ) - - def _restore_launcher(self) -> None: - """Restore the prior immutable launcher after activation begins.""" - if not self.launcher_prior_moved: - return - if not self.previous_launcher.exists(): - self.launcher_prior_moved = False - return - if self.paths.launcher_venv.exists(): - os.replace(self.paths.launcher_venv, self.staged_launcher) - self.launcher_swapped = False - os.replace(self.previous_launcher, self.paths.launcher_venv) - self.launcher_prior_moved = False - - def _validate_packaged_unit(self, packaged_unit: Path) -> None: - _require_regular_file(packaged_unit, "packaged service unit") - try: - packaged_unit.resolve().relative_to(self.release.resolve()) - except ValueError as error: - raise DeploymentFailure( - "packaged service unit escapes the staged release" - ) from error - text = packaged_unit.read_text(encoding="utf-8") - required_lines = { - "Requires=timelocker-control.socket", - "Wants=timelocker-status-events.socket", - "Sockets=timelocker-control.socket timelocker-status-events.socket", - } - lines = set(text.splitlines()) - missing = required_lines - lines - if missing: - raise DeploymentFailure( - "packaged service unit is missing: " + ", ".join(sorted(missing)) - ) - if ( - "Requires=timelocker-control.socket timelocker-status-events.socket" - in lines - ): - raise DeploymentFailure("packaged service still hard-requires event socket") - - def _systemctl_gate(self, action: str, unit: str) -> None: - self.executor.run( - ["systemctl", action, "--quiet", unit], - timeout=15, - ) - - -def _display_command(command: Sequence[str]) -> str: - safe: list[str] = [] - for argument in command: - if "\n" in argument or len(argument) > 160: - safe.append("") - else: - safe.append(argument) - return " ".join(safe) - - -def _require_regular_file(path: Path, field: str) -> None: - if not path.is_file() or path.is_symlink(): - raise DeploymentFailure(f"{field} must be a regular non-symlink file") - - -def _require_trusted_directory( - path: Path, - *, - expected_owner_uid: int | None, -) -> None: - try: - metadata = path.lstat() - except OSError as error: - raise DeploymentFailure(f"trusted directory is unavailable: {path}") from error - if not path.is_dir() or path.is_symlink(): - raise DeploymentFailure(f"trusted directory is invalid: {path}") - if expected_owner_uid is not None and metadata.st_uid != expected_owner_uid: - raise DeploymentFailure(f"trusted directory has wrong owner: {path}") - if metadata.st_mode & 0o022: - raise DeploymentFailure(f"trusted directory is group/world writable: {path}") - - -def _require_trusted_executable( - path: Path, - *, - expected_owner_uid: int | None, -) -> None: - _require_trusted_directory( - path.parent, - expected_owner_uid=expected_owner_uid, - ) - try: - metadata = path.resolve(strict=True).stat() - except OSError as error: - raise DeploymentFailure(f"trusted executable is unavailable: {path}") from error - if not path.resolve().is_file() or not os.access(path, os.X_OK): - raise DeploymentFailure(f"trusted executable is invalid: {path}") - if expected_owner_uid is not None and metadata.st_uid != expected_owner_uid: - raise DeploymentFailure(f"trusted executable has wrong owner: {path}") - if metadata.st_mode & 0o022: - raise DeploymentFailure(f"trusted executable is group/world writable: {path}") - - -def _sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(1024 * 1024), b""): - digest.update(block) - return digest.hexdigest() - - -def _validated_wheel_filename(path: Path) -> str: - """Return a pip-compatible wheel basename without changing its identity.""" - filename = path.name - if WHEEL_FILENAME_PATTERN.fullmatch(filename) is None: - raise DeploymentFailure( - "wheel must use a valid wheel filename, for example " - "timelocker-0.9.1-py3-none-any.whl" - ) - return filename - - -def _read_json(path: Path) -> dict[str, object]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise DeploymentFailure(f"cannot read JSON: {path}") from error - if not isinstance(value, dict): - raise DeploymentFailure(f"JSON document must be an object: {path}") - return value - - -def _selected_release(selector: Path) -> str: - value = _read_json(selector).get("selected") - if not isinstance(value, str) or RELEASE_ID_PATTERN.fullmatch(value) is None: - raise DeploymentFailure("selected-release document is invalid") - return value - - -def _mkdir( - path: Path, - *, - mode: int, - uid: int | None, - gid: int | None, -) -> None: - path.mkdir(parents=True, exist_ok=False) - os.chmod(path, mode) - if uid is not None and gid is not None: - os.chown(path, uid, gid) - - -def _atomic_copy( - source: Path, - destination: Path, - *, - mode: int, - uid: int | None, - gid: int | None, -) -> None: - destination.parent.mkdir(parents=True, exist_ok=True) - temporary = destination.with_name( - f".{destination.name}.timelocker-{os.getpid()}.tmp" - ) - try: - with source.open("rb") as source_stream, temporary.open("xb") as target: - shutil.copyfileobj(source_stream, target) - target.flush() - os.fsync(target.fileno()) - os.chmod(temporary, mode) - if uid is not None and gid is not None: - os.chown(temporary, uid, gid) - os.replace(temporary, destination) - finally: - temporary.unlink(missing_ok=True) - - -def _make_tree_immutable( - root: Path, - *, - uid: int | None, - gid: int | None, -) -> None: - for path in (root, *root.rglob("*")): - if path.is_symlink(): - continue - mode = path.stat().st_mode & 0o777 - if path.is_dir(): - mode |= 0o555 - else: - mode |= 0o444 - mode &= ~0o022 - os.chmod(path, mode) - if uid is not None and gid is not None: - os.chown(path, uid, gid) - - -def _write_private_text(path: Path, content: str) -> None: - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as stream: - stream.write(content) - except BaseException: - path.unlink(missing_ok=True) - raise - os.chmod(path, 0o600) - - -@contextmanager -def _deployment_lock(path: Path) -> TextIO: - path.parent.mkdir(parents=True, exist_ok=True) - stream = path.open("w", encoding="utf-8") - try: - try: - fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError as error: - raise DeploymentFailure("another TimeLocker deployment is active") from error - yield stream - finally: - stream.close() - - -def _signal_handler(signum: int, _frame: FrameType | None) -> None: - name = signal.Signals(signum).name - raise DeploymentInterrupted(f"deployment interrupted by {name}") - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Stage and activate a T011 TimeLocker Linux release safely." - ) - parser.add_argument("--release-id", required=True) - parser.add_argument("--expected-current", required=True) - parser.add_argument("--wheel", required=True, type=Path) - parser.add_argument("--wheel-sha256", required=True) - parser.add_argument("--manifest", required=True, type=Path) - parser.add_argument("--operator-user", required=True) - return parser - - -def main(argv: list[str] | None = None) -> int: - arguments = _parser().parse_args(argv) - if os.geteuid() != 0: - print("T011 deployment must be run with sudo.", file=sys.stderr) - return 77 - request = DeploymentRequest( - release_id=arguments.release_id, - expected_current=arguments.expected_current, - wheel=arguments.wheel.resolve(), - wheel_sha256=arguments.wheel_sha256, - manifest=arguments.manifest.resolve(), - operator_user=arguments.operator_user, - ) - paths = DeploymentPaths() - prior_handlers = { - signum: signal.signal(signum, _signal_handler) - for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP) - } - try: - with _deployment_lock(paths.lock_file): - evidence = T011LinuxDeployer(request, paths=paths).deploy() - except (DeploymentFailure, OSError, subprocess.SubprocessError) as error: - print(f"T011 deployment failed: {error}", file=sys.stderr) - return 1 - finally: - for signum, handler in prior_handlers.items(): - signal.signal(signum, handler) - print(f"release={request.release_id}") - print(f"evidence_root={evidence}") - print("preflight_identity_checks=passed") - print("backup_or_retention_triggered=no") - return 0 +from TimeLocker.system_control.deployment_entry import main if __name__ == "__main__": diff --git a/scripts/smoke_release_artifact.py b/scripts/smoke_release_artifact.py index db36a57..420ebb9 100755 --- a/scripts/smoke_release_artifact.py +++ b/scripts/smoke_release_artifact.py @@ -37,17 +37,14 @@ def smoke_system_contract(python: Path, expected_version: str) -> None: contract = """ import sys from importlib.resources import files -from TimeLocker.system_control.models import ( - PROTOCOL_VERSION, - STATUS_EVENT_PROTOCOL_VERSION, -) +from TimeLocker.system_control.models import PROTOCOL_VERSION from TimeLocker.system_control.release_launcher import ReleaseManifest assets = files("TimeLocker.system_control").joinpath("assets") for name in ( "timelocker-control.service", "timelocker-control.socket", - "timelocker-status-events.socket", + "timelocker-deploy-launcher", "timelocker-retention.service", "timelocker-retention.timer", "timelocker-icon-connecting.png", @@ -58,18 +55,18 @@ def smoke_system_contract(python: Path, expected_version: str) -> None: "timelocker-icon-error.png", ): assert assets.joinpath(name).is_file(), name +assert not assets.joinpath("timelocker-status-events.socket").is_file() manifest = ReleaseManifest.from_mapping( { - "schema_version": 2, + "schema_version": 3, "release_id": "a" * 40, "package_version": sys.argv[1], "control_protocol_version": PROTOCOL_VERSION, - "event_protocol_version": STATUS_EVENT_PROTOCOL_VERSION, "entrypoint": "venv/bin/timelocker", } ) assert manifest.control_protocol_version == PROTOCOL_VERSION -assert manifest.event_protocol_version == STATUS_EVENT_PROTOCOL_VERSION +assert manifest.event_protocol_version is None """ run([str(python), "-c", contract, expected_version]) @@ -92,7 +89,11 @@ def main() -> None: command = executable(environment, command_name) run([str(command), "version", "--short"], expected=args.expected_version) run([str(command), "--help"]) - for command_name in ("timelocker-system-control", "timelocker-tray"): + for command_name in ( + "timelocker-system-control", + "timelocker-tray", + "timelocker-deploy", + ): run([str(executable(environment, command_name)), "--help"]) smoke_system_contract(python, args.expected_version) print(f"Smoke contract passed for {artifact.name} on Python {sys.version.split()[0]}") diff --git a/scripts/validate_release_artifacts.py b/scripts/validate_release_artifacts.py index 721e87e..fb2da24 100755 --- a/scripts/validate_release_artifacts.py +++ b/scripts/validate_release_artifacts.py @@ -16,6 +16,7 @@ EXPECTED_REQUIRES_PYTHON = ">=3.12,<3.14" EXPECTED_ENTRY_POINTS = { "timelocker": "TimeLocker.cli:main", + "timelocker-deploy": "TimeLocker.system_control.deployment_entry:main", "timelocker-system-control": "TimeLocker.system_control.backend_entry:main", "timelocker-tray": "TimeLocker.system_control.tray_entry:main", "tl": "TimeLocker.cli:main", diff --git a/src/TimeLocker/system_control/assets/timelocker-control.service b/src/TimeLocker/system_control/assets/timelocker-control.service index 57fbae8..98f2e73 100644 --- a/src/TimeLocker/system_control/assets/timelocker-control.service +++ b/src/TimeLocker/system_control/assets/timelocker-control.service @@ -1,16 +1,18 @@ [Unit] Description=TimeLocker privileged local system-control backend Requires=timelocker-control.socket -Wants=timelocker-status-events.socket After=local-fs.target [Service] -Type=simple -Sockets=timelocker-control.socket timelocker-status-events.socket +Type=exec +Sockets=timelocker-control.socket User=root -Group=root +Group=timelocker-operators UMask=0077 EnvironmentFile=-/etc/timelocker/retention.env +RuntimeDirectory=timelocker +RuntimeDirectoryMode=0750 +RuntimeDirectoryPreserve=yes StateDirectory=timelocker StateDirectoryMode=0750 ExecStart=/usr/local/libexec/timelocker-system-control --systemd-socket @@ -26,6 +28,3 @@ RestrictSUIDSGID=yes LockPersonality=yes RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 ReadWritePaths=/run/timelocker /var/lib/timelocker - -[Install] -WantedBy=multi-user.target diff --git a/src/TimeLocker/system_control/assets/timelocker-deploy-launcher b/src/TimeLocker/system_control/assets/timelocker-deploy-launcher new file mode 100644 index 0000000..71fed91 --- /dev/null +++ b/src/TimeLocker/system_control/assets/timelocker-deploy-launcher @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +exec /opt/timelocker/launcher/venv/bin/python \ + -m TimeLocker.system_control.deployment_entry "$@" diff --git a/src/TimeLocker/system_control/assets/timelocker-status-events.socket b/src/TimeLocker/system_control/assets/timelocker-status-events.socket deleted file mode 100644 index d80fcd8..0000000 --- a/src/TimeLocker/system_control/assets/timelocker-status-events.socket +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=TimeLocker protected local status-event socket - -[Socket] -ListenStream=/run/timelocker/status-events.sock -DirectoryMode=0755 -SocketUser=root -SocketGroup=timelocker-operators -SocketMode=0660 -FileDescriptorName=status-events -RemoveOnStop=yes -Service=timelocker-control.service - -[Install] -WantedBy=sockets.target diff --git a/src/TimeLocker/system_control/backend_entry.py b/src/TimeLocker/system_control/backend_entry.py index 749e100..174da53 100644 --- a/src/TimeLocker/system_control/backend_entry.py +++ b/src/TimeLocker/system_control/backend_entry.py @@ -12,16 +12,20 @@ import signal import socket import stat -from threading import Event, Thread +from threading import Event from types import FrameType from typing import Protocol from uuid import UUID, uuid4 +try: + import grp +except ImportError: # pragma: no cover - Linux-only backend runtime. + grp = None # type: ignore[assignment] + from .dispatcher import AuditEvent, AuditSink, LocalControlDispatcher from .interfaces import GroupMembershipResolver from .linux_adapter import ( LinuxNssGroupMembershipResolver, - LinuxStatusEventTransport, LinuxUnixSocketTransport, ) from .models import ( @@ -68,16 +72,10 @@ ) from .schedule_health import ( BackupScheduleObservation, - ScheduleDeadlineMonitor, SystemdScheduleSummaryProvider, derive_backup_schedule_health, ) -from .status_events import ( - BoundedStatusEventBroker, - FileSystemProtectedStateWatcher, - ProtectedStateChangeMonitor, - StatusChangeCoordinator, -) +from .status_snapshot import AtomicStatusSnapshotStore from .types import ( BackendStatus, BackupScheduleHealth, @@ -184,6 +182,7 @@ class LinuxBackendPaths: trigger_root: Path audit_log_path: Path expected_owner: int = 0 + status_path: Path = Path("/run/timelocker/status.json") def __post_init__(self) -> None: for field_name in ( @@ -192,6 +191,7 @@ def __post_init__(self) -> None: "lock_root", "trigger_root", "audit_log_path", + "status_path", ): value = getattr(self, field_name) if not isinstance(value, Path): @@ -318,58 +318,17 @@ class LinuxBackendService: locks: RepositoryMutationLock dispatcher: LocalControlDispatcher transport: LinuxUnixSocketTransport - status_event_transport: LinuxStatusEventTransport | None audit_sink: AuditSink stop_event: Event - status_event_broker: BoundedStatusEventBroker - status_change_coordinator: StatusChangeCoordinator membership_resolver: GroupMembershipResolver - state_change_monitors: tuple[object, ...] = () reconciled_run_ids: tuple[UUID, ...] = () - def serve_forever(self, *, install_signal_handlers: bool = True) -> None: - if install_signal_handlers: - self.install_signal_handlers() - status_thread = None - monitor_threads: list[Thread] = [] - if self.status_event_transport is not None: - status_thread = Thread( - target=self._serve_status_events, - name="timelocker-status-events", - daemon=True, - ) - status_thread.start() - for index, monitor in enumerate(self.state_change_monitors): - monitor_thread = Thread( - target=monitor.run, - args=(self.stop_event,), - name=f"timelocker-state-monitor-{index}", - daemon=True, - ) - monitor_thread.start() - monitor_threads.append(monitor_thread) + def serve_once(self) -> None: + """Serve one control request and release all process resources.""" try: - self.transport.serve(self.dispatcher) - except OSError: - if not self.stop_event.is_set(): - raise + self.transport.serve_once(self.dispatcher) finally: self.stop() - if status_thread is not None: - status_thread.join(timeout=1.0) - for monitor_thread in monitor_threads: - monitor_thread.join(timeout=1.0) - - def _serve_status_events(self) -> None: - assert self.status_event_transport is not None - try: - self.status_event_transport.serve( - self.status_event_broker, - self.transport.identity_provider, - self.membership_resolver, - ) - except OSError: - return def install_signal_handlers(self) -> None: def _handle_signal(_signum: int, _frame: FrameType | None) -> None: @@ -386,16 +345,6 @@ def stop(self) -> None: listener.close() except OSError: pass - status_listener = ( - getattr(self.status_event_transport, "listener", None) - if self.status_event_transport is not None - else None - ) - if isinstance(status_listener, socket.socket): - try: - status_listener.close() - except OSError: - pass def build_linux_backend( @@ -403,10 +352,7 @@ def build_linux_backend( paths: LinuxBackendPaths, socket_mode: str = "systemd", listener: socket.socket | None = None, - status_listener: socket.socket | None = None, systemd_descriptor: int = 3, - status_systemd_descriptor: int | None = 4, - status_socket_mode: str = "systemd", request_timeout_seconds: float = 5.0, membership_resolver: GroupMembershipResolver | None = None, backup_adapter: BackupMutationAdapter | None = None, @@ -418,39 +364,56 @@ def build_linux_backend( max_diagnostics: int = 1_000, stop_event: Event | None = None, clock: Callable[[], datetime] | None = None, + status_snapshot_store: AtomicStatusSnapshotStore | None = None, ) -> LinuxBackendService: """Compose the Linux backend from strict local components.""" if type(max_diagnostics) is not int or not 1 <= max_diagnostics <= 100_000: raise ValueError("max_diagnostics must be between 1 and 100000") if socket_mode not in {"systemd", "listener"}: raise ValueError("socket_mode must be 'systemd' or 'listener'") - if status_socket_mode not in {"systemd", "listener", "disabled"}: - raise ValueError( - "status_socket_mode must be 'systemd', 'listener', or 'disabled'" - ) if socket_mode == "listener": if listener is None: raise ValueError("listener socket is required for listener mode") elif listener is not None: raise ValueError("listener socket can only be provided in listener mode") - if status_socket_mode == "listener": - if status_listener is None: - raise ValueError("status socket listener is required for listener mode") - elif status_listener is not None: - raise ValueError("status socket listener can only be provided in listener mode") - if status_socket_mode == "systemd" and status_systemd_descriptor is None: - raise ValueError("status systemd descriptor is required for systemd mode") - now = clock or _utc_now stop_event = stop_event or Event() policy = load_system_policy(paths.policy_path, expected_owner=paths.expected_owner) - status_event_broker = BoundedStatusEventBroker() - status_change_coordinator = StatusChangeCoordinator(status_event_broker) + if status_snapshot_store is None: + if grp is None: + raise RuntimeError("system operator groups are unavailable") + try: + group_gid = grp.getgrnam(policy.operator_group).gr_gid + except KeyError as error: + raise RuntimeError("system operator group is unavailable") from error + status_snapshot_store = AtomicStatusSnapshotStore( + paths.status_path, + expected_owner_uid=paths.expected_owner, + group_gid=group_gid, + ) + snapshot_session = uuid4() + snapshot_sequence = 0 + store_ref: dict[str, AtomicRecordStore] = {} + + def publish_status_change() -> None: + nonlocal snapshot_sequence + snapshot_sequence += 1 + status_snapshot_store.write( + _build_status_snapshot( + store=store_ref["store"], + schedule_summary_provider=schedule_summary_provider, + backup_schedule_observer=backup_schedule_observer, + revision=StatusRevision(snapshot_session, snapshot_sequence), + clock=now, + ) + ) + store = AtomicRecordStore( paths.record_root, max_diagnostics=max_diagnostics, - status_change_callback=status_change_coordinator.run_changed, + status_change_callback=publish_status_change, ) + store_ref["store"] = store locks = RepositoryMutationLock(paths.lock_root) audit_sink = RootOnlyJsonlAuditSink( paths.audit_log_path, @@ -502,17 +465,6 @@ def build_linux_backend( request_timeout_seconds=request_timeout_seconds, stop_event=stop_event, ) - status_event_transport = ( - None - if status_socket_mode == "disabled" - else _build_status_transport( - policy=policy, - socket_mode=status_socket_mode, - listener=status_listener if status_socket_mode == "listener" else None, - systemd_descriptor=status_systemd_descriptor, - stop_event=stop_event, - ) - ) dispatcher = LocalControlDispatcher( policy=policy, membership_resolver=membership_resolver, @@ -525,47 +477,28 @@ def build_linux_backend( retention_plan_provider=retention_plan_provider, schedule_summary_provider=schedule_summary_provider, backup_schedule_observer=backup_schedule_observer, - status_change_coordinator=status_change_coordinator, trigger_root=paths.trigger_root, clock=now, ), audit_sink=audit_sink, ) - state_change_monitors: list[object] = [ - ProtectedStateChangeMonitor( - FileSystemProtectedStateWatcher((paths.record_root / "runs",)), - status_change_coordinator, - ) - ] - if isinstance(backup_schedule_observer, SystemdScheduleSummaryProvider): - state_change_monitors.append( - ScheduleDeadlineMonitor( - backup_schedule_observer, - status_change_coordinator, - clock=now, - ) - ) return LinuxBackendService( policy=policy, store=store, locks=locks, dispatcher=dispatcher, transport=transport, - status_event_transport=status_event_transport, audit_sink=audit_sink, stop_event=stop_event, - status_event_broker=status_event_broker, - status_change_coordinator=status_change_coordinator, membership_resolver=membership_resolver, - state_change_monitors=tuple(state_change_monitors), reconciled_run_ids=tuple(record.run_id for record in reconciled), ) def run_linux_backend(**kwargs: object) -> None: - """Build and serve the Linux backend until the process is stopped.""" + """Build a socket-activated helper, serve one request, and exit.""" service = build_linux_backend(**kwargs) - service.serve_forever() + service.serve_once() def run_scheduled_retention( @@ -589,7 +522,13 @@ def run_scheduled_retention( paths.policy_path, expected_owner=paths.expected_owner, ) - store = AtomicRecordStore(paths.record_root) + schedule_provider = SystemdScheduleSummaryProvider() + store = _record_store_with_status( + paths=paths, + policy=policy, + schedule_summary_provider=schedule_provider, + backup_schedule_observer=schedule_provider, + ) locks = RepositoryMutationLock(paths.lock_root) adapter, provider = load_production_retention_components( target_path=production_target_path, @@ -622,8 +561,18 @@ def run_backup_record_start( production_target_path, expected_owner=paths.expected_owner, ) + policy = load_system_policy( + paths.policy_path, + expected_owner=paths.expected_owner, + ) + schedule_provider = SystemdScheduleSummaryProvider() SystemBackupRunCoordinator( - store=AtomicRecordStore(paths.record_root), + store=_record_store_with_status( + paths=paths, + policy=policy, + schedule_summary_provider=schedule_provider, + backup_schedule_observer=schedule_provider, + ), target_id=target.target_id, worker_root=paths.record_root.parent / "backup-worker", ).start() @@ -641,8 +590,18 @@ def run_backup_record_finish( production_target_path, expected_owner=paths.expected_owner, ) + policy = load_system_policy( + paths.policy_path, + expected_owner=paths.expected_owner, + ) + schedule_provider = SystemdScheduleSummaryProvider() SystemBackupRunCoordinator( - store=AtomicRecordStore(paths.record_root), + store=_record_store_with_status( + paths=paths, + policy=policy, + schedule_summary_provider=schedule_provider, + backup_schedule_observer=schedule_provider, + ), target_id=target.target_id, worker_root=paths.record_root.parent / "backup-worker", ).finish(result=result, exit_status=exit_status) @@ -656,12 +615,12 @@ def _systemd_exit_status(value: str | None) -> int | None: return parsed if 0 <= parsed <= 255 else None -def _systemd_socket_descriptors( +def _systemd_socket_descriptor( environment: Mapping[str, str] | None = None, *, process_id: int | None = None, -) -> tuple[int, int | None]: - """Resolve required control and optional event systemd descriptors.""" +) -> int: + """Resolve the single required control systemd descriptor.""" environment = os.environ if environment is None else environment process_id = os.getpid() if process_id is None else process_id if type(process_id) is not int or process_id <= 0: @@ -674,19 +633,13 @@ def _systemd_socket_descriptors( or int(listen_pid) != process_id or not listen_fds.isascii() or not listen_fds.isdecimal() - or int(listen_fds) not in {1, 2} + or int(listen_fds) != 1 ): raise RuntimeError("required systemd socket for control is unavailable") names = environment.get("LISTEN_FDNAMES", "").split(":") - expected_names = ( - {"control"} - if int(listen_fds) == 1 - else {"control", "status-events"} - ) - if len(names) != int(listen_fds) or set(names) != expected_names: + if names != ["control"]: raise RuntimeError("required systemd socket name for control is unavailable") - descriptors = {name: 3 + index for index, name in enumerate(names)} - return descriptors["control"], descriptors.get("status-events") + return 3 def main(argv: list[str] | None = None) -> None: @@ -782,15 +735,11 @@ def main(argv: list[str] | None = None) -> None: exit_status=_systemd_exit_status(os.environ.get("EXIT_STATUS")), ) else: - control_descriptor, status_descriptor = _systemd_socket_descriptors() + control_descriptor = _systemd_socket_descriptor() run_linux_backend( paths=paths, socket_mode="systemd", systemd_descriptor=control_descriptor, - status_systemd_descriptor=status_descriptor, - status_socket_mode=( - "systemd" if status_descriptor is not None else "disabled" - ), production_target_path=arguments.production_target, schedule_summary_provider=SystemdScheduleSummaryProvider(), ) @@ -823,33 +772,6 @@ def _build_transport( ) -def _build_status_transport( - *, - policy: SystemPolicy, - socket_mode: str, - listener: socket.socket | None, - systemd_descriptor: int | None, - stop_event: Event, -) -> LinuxStatusEventTransport: - if socket_mode == "listener": - assert listener is not None - return LinuxStatusEventTransport( - listener, - max_frame_bytes=policy.max_request_bytes, - heartbeat_interval_seconds=5.0, - operator_group=policy.operator_group, - stop_event=stop_event, - ) - assert systemd_descriptor is not None - return LinuxStatusEventTransport.from_systemd( - descriptor=systemd_descriptor, - heartbeat_interval_seconds=5.0, - max_frame_bytes=policy.max_request_bytes, - operator_group=policy.operator_group, - stop_event=stop_event, - ) - - def _build_handlers( *, policy: SystemPolicy, @@ -859,16 +781,13 @@ def _build_handlers( retention_adapter: RetentionAdapter, retention_plan_provider: RetentionPlanProvider, schedule_summary_provider: ScheduleSummaryProvider, - status_change_coordinator: StatusChangeCoordinator | None = None, trigger_root: Path, clock: Callable[[], datetime], backup_schedule_observer: BackupScheduleObserver | None = None, ) -> Mapping[SystemAction, Callable[[object], object]]: from .protocol import RequestEnvelope - status_change_coordinator = status_change_coordinator or StatusChangeCoordinator( - BoundedStatusEventBroker() - ) + query_revision = StatusRevision(uuid4(), 0) def health(_request: object) -> Mapping[str, object]: return { @@ -932,36 +851,13 @@ def schedule_summary(_request: object) -> Mapping[str, object]: return _schedule_to_wire(summary) def status_snapshot(_request: object) -> Mapping[str, object]: - def build_snapshot(revision: StatusRevision) -> StatusSnapshot: - summary = schedule_summary_provider.get_schedule_summary() - if not isinstance(summary, ScheduleSummary): - raise TypeError( - "schedule_summary_provider returned an invalid summary" - ) - runs = store.list_status_runs() - schedule_health = ( - derive_backup_schedule_health( - backup_schedule_observer.observe_backup_schedule(), - runs, - now=clock(), - ) - if backup_schedule_observer is not None - else BackupScheduleHealth.HEALTHY - ) - return StatusSnapshot.from_run_history( - revision=revision, - backend_status=BackendStatus.AVAILABLE, - active_operations=sum( - record.state in {RunState.QUEUED, RunState.RUNNING} - for record in runs - ), - runs=runs, - backup_schedule_health=schedule_health, - next_backup_at=summary.next_backup_at, - next_retention_at=summary.next_retention_at, - ) - - return status_change_coordinator.snapshot(build_snapshot).to_wire() + return _build_status_snapshot( + store=store, + schedule_summary_provider=schedule_summary_provider, + backup_schedule_observer=backup_schedule_observer, + revision=query_revision, + clock=clock, + ).to_wire() def ui_availability(_request: object) -> Mapping[str, object]: return {"available": False} @@ -1000,6 +896,82 @@ def ui_availability(_request: object) -> Mapping[str, object]: return handlers +def _record_store_with_status( + *, + paths: LinuxBackendPaths, + policy: SystemPolicy, + schedule_summary_provider: ScheduleSummaryProvider, + backup_schedule_observer: BackupScheduleObserver | None, +) -> AtomicRecordStore: + """Build a run store whose durable mutations refresh sanitized status.""" + if grp is None: + raise RuntimeError("system operator groups are unavailable") + try: + group_gid = grp.getgrnam(policy.operator_group).gr_gid + except KeyError as error: + raise RuntimeError("system operator group is unavailable") from error + snapshot_store = AtomicStatusSnapshotStore( + paths.status_path, + expected_owner_uid=paths.expected_owner, + group_gid=group_gid, + ) + session_id = uuid4() + sequence = 0 + store_ref: dict[str, AtomicRecordStore] = {} + + def publish() -> None: + nonlocal sequence + sequence += 1 + snapshot_store.write( + _build_status_snapshot( + store=store_ref["store"], + schedule_summary_provider=schedule_summary_provider, + backup_schedule_observer=backup_schedule_observer, + revision=StatusRevision(session_id, sequence), + clock=_utc_now, + ) + ) + + store = AtomicRecordStore(paths.record_root, status_change_callback=publish) + store_ref["store"] = store + return store + + +def _build_status_snapshot( + *, + store: AtomicRecordStore, + schedule_summary_provider: ScheduleSummaryProvider, + backup_schedule_observer: BackupScheduleObserver | None, + revision: StatusRevision, + clock: Callable[[], datetime], +) -> StatusSnapshot: + """Project protected records into the exact sanitized status contract.""" + summary = schedule_summary_provider.get_schedule_summary() + if not isinstance(summary, ScheduleSummary): + raise TypeError("schedule_summary_provider returned an invalid summary") + runs = store.list_status_runs() + schedule_health = ( + derive_backup_schedule_health( + backup_schedule_observer.observe_backup_schedule(), + runs, + now=clock(), + ) + if backup_schedule_observer is not None + else BackupScheduleHealth.HEALTHY + ) + return StatusSnapshot.from_run_history( + revision=revision, + backend_status=BackendStatus.AVAILABLE, + active_operations=sum( + record.state in {RunState.QUEUED, RunState.RUNNING} for record in runs + ), + runs=runs, + backup_schedule_health=schedule_health, + next_backup_at=summary.next_backup_at, + next_retention_at=summary.next_retention_at, + ) + + def _apply_policy_defaults( plan: RetentionPlan, policy: RetentionPolicy, diff --git a/src/TimeLocker/system_control/deployment.py b/src/TimeLocker/system_control/deployment.py index 6019894..12cf977 100644 --- a/src/TimeLocker/system_control/deployment.py +++ b/src/TimeLocker/system_control/deployment.py @@ -188,13 +188,9 @@ def activate( ) -> SelectedRelease: """Select a release only after its complete compatibility probe passes.""" manifest = self.resolver.release_manifest(release_id) - if manifest.event_protocol_version is None: - raise DeploymentError( - "staged release does not declare event protocol compatibility" - ) targets = self._probe_targets(release_id, manifest) result = health_probe(targets) - if not self._probe_passed(result, targets, require_event=True): + if not self._probe_passed(result, targets, require_event=False): raise DeploymentError("staged release compatibility probe failed") return self.resolver.select(release_id) @@ -230,6 +226,10 @@ def rollback( if current is None or current.previous is None: raise DeploymentError("no previous release is available") manifest = self.resolver.release_manifest(current.previous) + if manifest.schema_version < 3: + raise DeploymentError( + "rollback release requires the rejected resident status service" + ) targets = self._probe_targets(current.previous, manifest) result = health_probe(targets) if not self._probe_passed(result, targets, require_event=False): @@ -268,6 +268,7 @@ def _probe_passed( def linux_asset_targets( *, bin_root: Path = Path("/usr/local/bin"), + admin_bin_root: Path = Path("/usr/local/sbin"), libexec_root: Path = Path("/usr/local/libexec"), unit_root: Path = Path("/etc/systemd/system"), config_root: Path = Path("/etc/timelocker"), @@ -283,6 +284,11 @@ def linux_asset_targets( bin_root / "timelocker-release-select", 0o750, ), + AssetTarget( + "timelocker-deploy-launcher", + admin_bin_root / "timelocker-deploy", + 0o750, + ), AssetTarget( "timelocker-system-control-launcher", libexec_root / "timelocker-system-control", @@ -303,11 +309,6 @@ def linux_asset_targets( unit_root / "timelocker-control.socket", 0o644, ), - AssetTarget( - "timelocker-status-events.socket", - unit_root / "timelocker-status-events.socket", - 0o644, - ), AssetTarget( "timelocker-retention.service", unit_root / "timelocker-retention.service", @@ -372,9 +373,9 @@ def build_release_manifest( release_id: str, package_version: str, ) -> dict[str, object]: - """Build schema-2 metadata binding all selected process protocols.""" + """Build daemonless schema-3 metadata for one selected release.""" mapping: dict[str, object] = { - "schema_version": 2, + "schema_version": 3, "release_id": release_id, "package_version": require_safe_identifier( package_version, @@ -382,7 +383,6 @@ def build_release_manifest( maximum=64, ), "control_protocol_version": PROTOCOL_VERSION, - "event_protocol_version": STATUS_EVENT_PROTOCOL_VERSION, "entrypoint": "venv/bin/timelocker", } ReleaseManifest.from_mapping(mapping) diff --git a/src/TimeLocker/system_control/deployment_entry.py b/src/TimeLocker/system_control/deployment_entry.py new file mode 100644 index 0000000..40caa8a --- /dev/null +++ b/src/TimeLocker/system_control/deployment_entry.py @@ -0,0 +1,1567 @@ +#!/usr/bin/env python3 +"""Supported protected TimeLocker installation and release administration. + +This operator-facing harness deliberately keeps candidate probes independent of +temporary Python files. Every identity-sensitive probe runs against the staged +release before the selected-release document or systemd service unit changes. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timezone +from email.parser import BytesParser +import hashlib +import json +import os +from pathlib import Path +from packaging.utils import canonicalize_name, parse_wheel_filename +import re +import shutil +import signal +import stat +import subprocess +import time +from types import FrameType +from typing import Callable, TextIO +import zipfile + +try: + import fcntl + import grp + import pwd +except ImportError: # pragma: no cover - reported explicitly on non-POSIX hosts. + fcntl = None # type: ignore[assignment] + grp = None # type: ignore[assignment] + pwd = None # type: ignore[assignment] + +from .deployment import ( + AssetTarget, + SystemReleaseDeployment, + build_asset_manifest, + linux_asset_targets, +) +from .models import PROTOCOL_VERSION +from .release_launcher import ImmutableReleaseResolver + + +RELEASE_ID_PATTERN = re.compile(r"[0-9a-f]{40}") +WHEEL_FILENAME_PATTERN = re.compile( + r"[A-Za-z0-9_.+!]+(?:-[A-Za-z0-9_.+!]+){4,}\.whl" +) +REQUIRED_ENTRYPOINTS = ( + "timelocker", + "tl", + "timelocker-tray", + "timelocker-system-control", +) +REQUIRED_ACTIVE_UNITS = ( + "timelocker-control.socket", + "timelocker-npbackup-migration.timer", + "timelocker-retention.timer", +) +REQUIRED_ENABLED_UNITS = ( + "timelocker-control.socket", + "timelocker-npbackup-migration.timer", + "timelocker-retention.timer", +) + +BACKEND_IMPORT_PROBE = """\ +from TimeLocker.system_control.backend_entry import main +from TimeLocker.system_control.models import PROTOCOL_VERSION +assert callable(main) +print(PROTOCOL_VERSION) +""" + +PACKAGED_UNIT_PROBE = """\ +from importlib.resources import files +print(files("TimeLocker.system_control.assets") / "timelocker-control.service") +""" + +LAUNCHER_COMPATIBILITY_PROBE = """\ +import sys +from TimeLocker.system_control.release_launcher import ImmutableReleaseResolver +resolver = ImmutableReleaseResolver() +current = resolver.release_manifest(sys.argv[1]) +candidate = resolver.release_manifest(sys.argv[2]) +assert current.release_id == sys.argv[1] +assert candidate.release_id == sys.argv[2] +assert candidate.control_protocol_version == int(sys.argv[3]) +assert candidate.schema_version == 3 +assert candidate.event_protocol_version is None +print("compatible") +""" + + +class DeploymentFailure(RuntimeError): + """Raised when a deployment gate fails or rollback cannot complete.""" + + +class DeploymentInterrupted(DeploymentFailure): + """Raised when SIGINT or SIGTERM interrupts a deployment.""" + + +@dataclass(frozen=True, slots=True) +class DeploymentPaths: + """Protected paths used by the Linux immutable-release deployment.""" + + releases_root: Path = Path("/opt/timelocker/releases") + selector: Path = Path("/opt/timelocker/selected-release.json") + service_unit: Path = Path("/etc/systemd/system/timelocker-control.service") + evidence_root: Path = Path("/var/lib/timelocker/migration-backup") + lock_file: Path = Path("/run/lock/timelocker/deploy.lock") + launcher_venv: Path = Path("/opt/timelocker/launcher/venv") + legacy_event_socket: Path = Path("/run/timelocker/status-events.sock") + attention_file: Path = Path("/var/lib/timelocker/deployment-attention.json") + expected_owner_uid: int = 0 + + +@dataclass(frozen=True, slots=True) +class DeploymentRequest: + """Validated inputs identifying the exact release artifact to deploy.""" + + release_id: str + expected_current: str | None + wheel: Path + wheel_sha256: str + manifest: Path + operator_user: str + + +class CommandExecutor: + """Run bounded commands and optionally retain their redacted output.""" + + def run( + self, + arguments: Sequence[str | Path], + *, + timeout: int = 30, + output: Path | None = None, + capture: bool = False, + check: bool = True, + ) -> str: + command = [str(argument) for argument in arguments] + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + combined = completed.stdout + if completed.stderr: + combined += completed.stderr + if output is not None: + _write_private_text(output, combined) + if check and completed.returncode != 0: + raise DeploymentFailure( + f"command failed ({completed.returncode}): {_display_command(command)}" + ) + return completed.stdout if capture else combined + + +class T011LinuxDeployer: + """Preflight-first, rollback-safe Linux deployment transaction.""" + + def __init__( + self, + request: DeploymentRequest, + *, + paths: DeploymentPaths | None = None, + executor: CommandExecutor | None = None, + owner_uid: int | None = 0, + owner_gid: int | None = 0, + asset_targets: tuple[AssetTarget, ...] | None = None, + ) -> None: + self.request = request + self.paths = paths or DeploymentPaths() + self.executor = executor or CommandExecutor() + self.owner_uid = owner_uid + self.owner_gid = owner_gid + self.asset_targets = asset_targets or linux_asset_targets() + self.release = self.paths.releases_root / request.release_id + self.evidence: Path | None = None + self.staged_wheel: Path | None = None + self.staged_manifest: Path | None = None + self.staged_launcher = self.paths.launcher_venv.with_name( + f".venv.{request.release_id}.staged" + ) + self.previous_launcher = self.paths.launcher_venv.with_name( + f"venv.previous.{request.expected_current or 'initial'}" + ) + self.launcher_prior_moved = False + self.launcher_swapped = False + self.mutation_started = False + self.completed = False + + def deploy(self) -> Path: + """Stage, preflight, activate, and verify one exact release.""" + self.validate_request() + self.capture_baseline() + try: + self.stage_release() + self.preflight_staged_release() + self.activate() + self.verify_activation() + if self.previous_launcher.exists(): + shutil.rmtree(self.previous_launcher) + self.completed = True + except BaseException: + self.recover() + raise + assert self.evidence is not None + return self.evidence + + def validate_request(self) -> None: + """Reject unsafe or incoherent inputs before creating host state.""" + for field, value in (("release_id", self.request.release_id),): + if RELEASE_ID_PATTERN.fullmatch(value) is None: + raise DeploymentFailure(f"{field} must be a 40-character identity") + if self.request.expected_current is not None and RELEASE_ID_PATTERN.fullmatch( + self.request.expected_current + ) is None: + raise DeploymentFailure( + "expected_current must be a 40-character identity" + ) + if ( + len(self.request.wheel_sha256) != 64 + or any( + character not in "0123456789abcdef" + for character in self.request.wheel_sha256 + ) + ): + raise DeploymentFailure("wheel_sha256 must be a lowercase SHA-256 digest") + _require_regular_file(self.request.wheel, "wheel") + _require_regular_file(self.request.manifest, "manifest") + _validated_wheel_filename(self.request.wheel) + if pwd is None: + raise DeploymentFailure("protected deployment is unsupported on this platform") + try: + pwd.getpwnam(self.request.operator_user) + except KeyError as error: + raise DeploymentFailure("operator_user does not exist") from error + if self.release.exists(): + raise DeploymentFailure(f"candidate release already exists: {self.release}") + if self.staged_launcher.exists(): + raise DeploymentFailure( + f"staged launcher already exists: {self.staged_launcher}" + ) + if self.previous_launcher.exists(): + raise DeploymentFailure( + f"launcher rollback path already exists: {self.previous_launcher}" + ) + _require_trusted_directory( + self.paths.launcher_venv.parent, + expected_owner_uid=self.owner_uid, + ) + if self.request.expected_current is not None: + _require_trusted_directory( + self.paths.launcher_venv, + expected_owner_uid=self.owner_uid, + ) + _require_trusted_executable( + self.paths.launcher_venv / "bin/python", + expected_owner_uid=self.owner_uid, + ) + if _selected_release_optional(self.paths.selector) != self.request.expected_current: + raise DeploymentFailure("selected release changed before deployment") + if self.request.expected_current is not None: + for unit in REQUIRED_ACTIVE_UNITS: + self._systemctl_gate("is-active", unit) + for unit in REQUIRED_ENABLED_UNITS: + self._systemctl_gate("is-enabled", unit) + + def capture_baseline(self) -> None: + """Create private evidence and immutable rollback inputs.""" + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + self.evidence = ( + self.paths.evidence_root + / f"t011-hardened-deploy-{timestamp}-{os.getpid()}" + ) + _mkdir(self.evidence, mode=0o750, uid=self.owner_uid, gid=self.owner_gid) + if self.paths.selector.exists(): + _atomic_copy( + self.paths.selector, + self.evidence / "selected-release.before.json", + mode=0o600, + uid=self.owner_uid, + gid=self.owner_gid, + ) + asset_baseline = self.evidence / "assets-before" + _mkdir( + asset_baseline, + mode=0o700, + uid=self.owner_uid, + gid=self.owner_gid, + ) + existing_assets: list[str] = [] + for target in self.asset_targets: + if not target.destination.exists(): + continue + _require_regular_file(target.destination, "installed asset") + _atomic_copy( + target.destination, + asset_baseline / target.source_name, + mode=0o600, + uid=self.owner_uid, + gid=self.owner_gid, + ) + existing_assets.append(target.source_name) + _write_private_text( + self.evidence / "assets-before.json", + json.dumps(sorted(existing_assets), separators=(",", ":")) + "\n", + ) + self.staged_wheel = self.evidence / _validated_wheel_filename( + self.request.wheel + ) + self.staged_manifest = self.evidence / "candidate-release.json" + _atomic_copy( + self.request.wheel, + self.staged_wheel, + mode=0o600, + uid=self.owner_uid, + gid=self.owner_gid, + ) + _atomic_copy( + self.request.manifest, + self.staged_manifest, + mode=0o600, + uid=self.owner_uid, + gid=self.owner_gid, + ) + if _sha256(self.staged_wheel) != self.request.wheel_sha256: + raise DeploymentFailure("copied wheel SHA-256 does not match") + manifest = _read_json(self.staged_manifest) + expected_manifest = { + "schema_version": 3, + "release_id": self.request.release_id, + "control_protocol_version": 2, + "entrypoint": "venv/bin/timelocker", + } + for field, expected in expected_manifest.items(): + if manifest.get(field) != expected: + raise DeploymentFailure(f"manifest {field} is incompatible") + + def stage_release(self) -> None: + """Install the wheel into an inert, immutable release directory.""" + assert self.evidence is not None + assert self.staged_wheel is not None + assert self.staged_manifest is not None + _mkdir(self.release, mode=0o755, uid=self.owner_uid, gid=self.owner_gid) + self.executor.run( + ["python3", "-m", "venv", "--system-site-packages", self.release / "venv"], + timeout=120, + output=self.evidence / "venv-create.txt", + ) + python = self.release / "venv/bin/python" + self.executor.run( + [ + python, + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--no-index", + self.staged_wheel, + ], + timeout=600, + output=self.evidence / "pip-install.txt", + ) + _atomic_copy( + self.staged_manifest, + self.release / "release.json", + mode=0o644, + uid=self.owner_uid, + gid=self.owner_gid, + ) + _make_tree_immutable( + self.release, + uid=self.owner_uid, + gid=self.owner_gid, + ) + self.executor.run( + [ + "python3", + "-m", + "venv", + "--system-site-packages", + self.staged_launcher, + ], + timeout=120, + output=self.evidence / "launcher-venv-create.txt", + ) + self.executor.run( + [ + self.staged_launcher / "bin/python", + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--no-index", + "--no-deps", + self.staged_wheel, + ], + timeout=300, + output=self.evidence / "launcher-pip-install.txt", + ) + _make_tree_immutable( + self.staged_launcher, + uid=self.owner_uid, + gid=self.owner_gid, + ) + + def preflight_staged_release(self) -> None: + """Exercise every target identity before protected activation.""" + assert self.evidence is not None + python = self.release / "venv/bin/python" + for entrypoint in REQUIRED_ENTRYPOINTS: + path = self.release / "venv/bin" / entrypoint + _require_regular_file(path, f"staged entrypoint {entrypoint}") + expected = f"#!{python}" + try: + actual = path.open(encoding="utf-8").readline().rstrip("\n") + except OSError as error: + raise DeploymentFailure( + f"cannot inspect staged entrypoint: {entrypoint}" + ) from error + if actual != expected or not os.access(path, os.X_OK): + raise DeploymentFailure( + f"staged entrypoint is not executable at its final path: {entrypoint}" + ) + + protocol_output = self.executor.run( + [python, "-c", BACKEND_IMPORT_PROBE], + output=self.evidence / "preflight-backend-protocol.txt", + capture=True, + ).strip() + assert self.staged_manifest is not None + manifest = _read_json(self.staged_manifest) + expected_protocol_output = str(manifest["control_protocol_version"]) + if protocol_output != expected_protocol_output: + raise DeploymentFailure( + "staged backend protocol probe failed: " + f"expected {expected_protocol_output}, got {protocol_output or ''}" + ) + if self.request.expected_current is not None: + launcher_output = self.executor.run( + [ + self.staged_launcher / "bin/python", + "-c", + LAUNCHER_COMPATIBILITY_PROBE, + self.request.expected_current, + self.request.release_id, + str(manifest["control_protocol_version"]), + ], + output=self.evidence / "preflight-launcher-compatibility.txt", + capture=True, + ).strip() + if launcher_output != "compatible": + raise DeploymentFailure("staged launcher compatibility probe failed") + packaged_unit = Path( + self.executor.run( + [python, "-c", PACKAGED_UNIT_PROBE], + capture=True, + ).strip() + ) + self._validate_packaged_unit(packaged_unit) + self.executor.run( + ["systemd-analyze", "verify", packaged_unit], + timeout=30, + output=self.evidence / "systemd-analyze-preflight.txt", + ) + + candidate_cli = self.release / "venv/bin/timelocker" + candidate_version = self.executor.run( + [ + "timeout", + "10", + "runuser", + "-u", + self.request.operator_user, + "--", + candidate_cli, + "version", + "--short", + ], + timeout=15, + output=self.evidence / "preflight-cli-version.txt", + capture=True, + ).strip() + expected_package_version = manifest["package_version"] + if candidate_version != expected_package_version: + raise DeploymentFailure( + "staged CLI version probe failed: " + f"expected {expected_package_version}, " + f"got {candidate_version or ''}" + ) + if _selected_release_optional(self.paths.selector) != self.request.expected_current: + raise DeploymentFailure("selector changed during staged preflight") + if self.request.expected_current is not None: + for unit in REQUIRED_ACTIVE_UNITS: + self._systemctl_gate("is-active", unit) + for unit in REQUIRED_ENABLED_UNITS: + self._systemctl_gate("is-enabled", unit) + + def activate(self) -> None: + """Perform the bounded protected mutation after all preflights pass.""" + assert self.evidence is not None + if _selected_release_optional(self.paths.selector) != self.request.expected_current: + raise DeploymentFailure("selector changed immediately before activation") + python = self.release / "venv/bin/python" + packaged_unit = Path( + self.executor.run( + [python, "-c", PACKAGED_UNIT_PROBE], + capture=True, + ).strip() + ) + assert self.staged_manifest is not None + manifest = _read_json(self.staged_manifest) + self.mutation_started = True + if self.paths.launcher_venv.exists(): + self.launcher_prior_moved = True + os.replace(self.paths.launcher_venv, self.previous_launcher) + os.replace(self.staged_launcher, self.paths.launcher_venv) + self.launcher_swapped = True + if self.request.expected_current is not None: + launcher_output = self.executor.run( + [ + self.paths.launcher_venv / "bin/python", + "-c", + LAUNCHER_COMPATIBILITY_PROBE, + self.request.expected_current, + self.request.release_id, + str(manifest["control_protocol_version"]), + ], + output=self.evidence / "activated-launcher-compatibility.txt", + capture=True, + ).strip() + if launcher_output != "compatible": + raise DeploymentFailure("activated launcher compatibility probe failed") + asset_root = packaged_unit.parent + asset_manifest = build_asset_manifest( + asset_root=asset_root, + release_id=self.request.release_id, + package_version=str(manifest["package_version"]), + asset_names=tuple(target.source_name for target in self.asset_targets), + ) + SystemReleaseDeployment( + resolver=ImmutableReleaseResolver( + releases_root=self.paths.releases_root, + selector_path=self.paths.selector, + expected_owner_uid=self.owner_uid or 0, + ), + targets=self.asset_targets, + expected_owner_uid=self.owner_uid or 0, + ).install_assets( + asset_root, + asset_manifest, + ) + self.executor.run(["systemctl", "daemon-reload"]) + # Remove the rejected resident event channel before selecting the + # candidate. These commands are idempotent for clean installations. + self.executor.run( + ["systemctl", "disable", "--now", "timelocker-status-events.socket"], + check=False, + ) + self.executor.run( + ["systemctl", "stop", "timelocker-control.service"], + check=False, + ) + self.paths.legacy_event_socket.unlink(missing_ok=True) + select_command = [ + python, + "-m", + "TimeLocker.system_control.release_admin", + "select", + self.request.release_id, + ] + if self.request.expected_current is not None: + select_command.extend(["--expected-current", self.request.expected_current]) + self.executor.run( + select_command, + output=self.evidence / "selected-release.txt", + ) + self.executor.run(["systemctl", "restart", "timelocker-control.socket"]) + self.executor.run( + [ + "systemctl", + "enable", + "--now", + "timelocker-control.socket", + ] + ) + + def verify_activation(self) -> None: + """Verify the selected release without running backup or retention.""" + assert self.evidence is not None + if _selected_release(self.paths.selector) != self.request.release_id: + raise DeploymentFailure("candidate release was not selected") + active_units = ( + REQUIRED_ACTIVE_UNITS + if self.request.expected_current is not None + else ("timelocker-control.socket",) + ) + enabled_units = ( + REQUIRED_ENABLED_UNITS + if self.request.expected_current is not None + else ("timelocker-control.socket",) + ) + for unit in active_units: + self._systemctl_gate("is-active", unit) + for unit in enabled_units: + self._systemctl_gate("is-enabled", unit) + self.executor.run( + [ + "timeout", + "15", + "runuser", + "-u", + self.request.operator_user, + "--", + self.release / "venv/bin/timelocker", + "runs", + "list", + "--limit", + "3", + "--json", + ], + timeout=20, + output=self.evidence / "activated-authorized-runs.json", + ) + _atomic_copy( + self.paths.selector, + self.evidence / "selected-release.after.json", + mode=0o600, + uid=self.owner_uid, + gid=self.owner_gid, + ) + + def recover(self) -> None: + """Restore baseline state after any failed or interrupted transaction.""" + if self.completed: + return + errors: list[str] = [] + if self.mutation_started and self.evidence is not None: + for source, destination, mode in ( + ( + self.evidence / "selected-release.before.json", + self.paths.selector, + 0o644, + ), + ): + try: + if source.exists(): + _atomic_copy( + source, + destination, + mode=mode, + uid=self.owner_uid, + gid=self.owner_gid, + ) + else: + destination.unlink(missing_ok=True) + except OSError as error: + errors.append(f"restore {destination}: {error}") + try: + existing_assets = set( + json.loads( + (self.evidence / "assets-before.json").read_text( + encoding="utf-8" + ) + ) + ) + for target in self.asset_targets: + if target.source_name in existing_assets: + _atomic_copy( + self.evidence / "assets-before" / target.source_name, + target.destination, + mode=target.mode, + uid=self.owner_uid, + gid=self.owner_gid, + ) + else: + target.destination.unlink(missing_ok=True) + except (OSError, TypeError, ValueError, json.JSONDecodeError) as error: + errors.append(f"restore installed assets: {error}") + try: + self._restore_launcher() + except OSError as error: + errors.append(f"restore stable launcher: {error}") + for command in ( + ("systemctl", "daemon-reload"), + ("systemctl", "restart", "timelocker-control.socket"), + ): + try: + self.executor.run(command, check=False) + except (DeploymentFailure, OSError, subprocess.SubprocessError) as error: + errors.append(f"{' '.join(command)}: {error}") + try: + if ( + _selected_release_optional(self.paths.selector) + != self.request.expected_current + ): + errors.append("restored selector does not name prior release") + except DeploymentFailure as error: + errors.append(f"validate restored selector: {error}") + if self.request.expected_current is not None: + for action, units in ( + ("is-active", REQUIRED_ACTIVE_UNITS), + ("is-enabled", REQUIRED_ENABLED_UNITS), + ): + for unit in units: + try: + self._systemctl_gate(action, unit) + except (DeploymentFailure, subprocess.SubprocessError) as error: + errors.append(f"{action} {unit}: {error}") + if self.release.exists(): + try: + shutil.rmtree(self.release) + except OSError as error: + errors.append(f"remove candidate release: {error}") + if self.staged_launcher.exists(): + try: + shutil.rmtree(self.staged_launcher) + except OSError as error: + errors.append(f"remove staged launcher: {error}") + if errors: + _write_private_text( + self.paths.attention_file, + json.dumps( + { + "schema_version": 1, + "reason": "deployment_recovery_failed", + }, + sort_keys=True, + separators=(",", ":"), + ) + + "\n", + ) + raise DeploymentFailure( + "deployment failed and rollback was incomplete: " + "; ".join(errors) + ) + + def _restore_launcher(self) -> None: + """Restore the prior immutable launcher after activation begins.""" + if not self.launcher_prior_moved: + if self.launcher_swapped and self.paths.launcher_venv.exists(): + os.replace(self.paths.launcher_venv, self.staged_launcher) + self.launcher_swapped = False + return + if not self.previous_launcher.exists(): + self.launcher_prior_moved = False + return + if self.paths.launcher_venv.exists(): + os.replace(self.paths.launcher_venv, self.staged_launcher) + self.launcher_swapped = False + os.replace(self.previous_launcher, self.paths.launcher_venv) + self.launcher_prior_moved = False + + def _validate_packaged_unit(self, packaged_unit: Path) -> None: + _require_regular_file(packaged_unit, "packaged service unit") + try: + packaged_unit.resolve().relative_to(self.release.resolve()) + except ValueError as error: + raise DeploymentFailure( + "packaged service unit escapes the staged release" + ) from error + text = packaged_unit.read_text(encoding="utf-8") + required_lines = { + "Requires=timelocker-control.socket", + "Sockets=timelocker-control.socket", + "Type=exec", + "RuntimeDirectoryPreserve=yes", + } + lines = set(text.splitlines()) + missing = required_lines - lines + if missing: + raise DeploymentFailure( + "packaged service unit is missing: " + ", ".join(sorted(missing)) + ) + if "status-events" in text: + raise DeploymentFailure("packaged service still references event socket") + + def _systemctl_gate(self, action: str, unit: str) -> None: + self.executor.run( + ["systemctl", action, "--quiet", unit], + timeout=15, + ) + + +def _display_command(command: Sequence[str]) -> str: + safe: list[str] = [] + for argument in command: + if "\n" in argument or len(argument) > 160: + safe.append("") + else: + safe.append(argument) + return " ".join(safe) + + +def _require_regular_file(path: Path, field: str) -> None: + if not path.is_file() or path.is_symlink(): + raise DeploymentFailure(f"{field} must be a regular non-symlink file") + + +def _require_trusted_directory( + path: Path, + *, + expected_owner_uid: int | None, +) -> None: + try: + metadata = path.lstat() + except OSError as error: + raise DeploymentFailure(f"trusted directory is unavailable: {path}") from error + if not path.is_dir() or path.is_symlink(): + raise DeploymentFailure(f"trusted directory is invalid: {path}") + if expected_owner_uid is not None and metadata.st_uid != expected_owner_uid: + raise DeploymentFailure(f"trusted directory has wrong owner: {path}") + if metadata.st_mode & 0o022: + raise DeploymentFailure(f"trusted directory is group/world writable: {path}") + + +def _require_trusted_executable( + path: Path, + *, + expected_owner_uid: int | None, +) -> None: + _require_trusted_directory( + path.parent, + expected_owner_uid=expected_owner_uid, + ) + try: + metadata = path.resolve(strict=True).stat() + except OSError as error: + raise DeploymentFailure(f"trusted executable is unavailable: {path}") from error + if not path.resolve().is_file() or not os.access(path, os.X_OK): + raise DeploymentFailure(f"trusted executable is invalid: {path}") + if expected_owner_uid is not None and metadata.st_uid != expected_owner_uid: + raise DeploymentFailure(f"trusted executable has wrong owner: {path}") + if metadata.st_mode & 0o022: + raise DeploymentFailure(f"trusted executable is group/world writable: {path}") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _validated_wheel_filename(path: Path) -> str: + """Return a pip-compatible wheel basename without changing its identity.""" + filename = path.name + if WHEEL_FILENAME_PATTERN.fullmatch(filename) is None: + raise DeploymentFailure( + "wheel must use a valid wheel filename, for example " + "timelocker-0.9.1-py3-none-any.whl" + ) + return filename + + +def _read_json(path: Path) -> dict[str, object]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise DeploymentFailure(f"cannot read JSON: {path}") from error + if not isinstance(value, dict): + raise DeploymentFailure(f"JSON document must be an object: {path}") + return value + + +def _selected_release(selector: Path) -> str: + value = _read_json(selector).get("selected") + if not isinstance(value, str) or RELEASE_ID_PATTERN.fullmatch(value) is None: + raise DeploymentFailure("selected-release document is invalid") + return value + + +def _selected_release_optional(selector: Path) -> str | None: + if not selector.exists(): + return None + return _selected_release(selector) + + +def _mkdir( + path: Path, + *, + mode: int, + uid: int | None, + gid: int | None, +) -> None: + path.mkdir(parents=True, exist_ok=False) + os.chmod(path, mode) + if uid is not None and gid is not None: + os.chown(path, uid, gid) + + +def _atomic_copy( + source: Path, + destination: Path, + *, + mode: int, + uid: int | None, + gid: int | None, +) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name( + f".{destination.name}.timelocker-{os.getpid()}.tmp" + ) + try: + with source.open("rb") as source_stream, temporary.open("xb") as target: + shutil.copyfileobj(source_stream, target) + target.flush() + os.fsync(target.fileno()) + os.chmod(temporary, mode) + if uid is not None and gid is not None: + os.chown(temporary, uid, gid) + os.replace(temporary, destination) + finally: + temporary.unlink(missing_ok=True) + + +def _make_tree_immutable( + root: Path, + *, + uid: int | None, + gid: int | None, +) -> None: + for path in (root, *root.rglob("*")): + if path.is_symlink(): + continue + mode = path.stat().st_mode & 0o777 + if path.is_dir(): + mode |= 0o555 + else: + mode |= 0o444 + mode &= ~0o022 + os.chmod(path, mode) + if uid is not None and gid is not None: + os.chown(path, uid, gid) + + +def _write_private_text(path: Path, content: str) -> None: + flags = os.O_WRONLY | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(path, flags, 0o600) + try: + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or metadata.st_mode & 0o022 + ): + raise DeploymentFailure("private deployment file is not trusted") + os.fchmod(descriptor, 0o600) + os.ftruncate(descriptor, 0) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + descriptor = -1 + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + except BaseException: + if descriptor >= 0: + os.close(descriptor) + raise + + +@contextmanager +def _deployment_lock(path: Path) -> TextIO: + if fcntl is None: + raise DeploymentFailure("protected deployment is unsupported on this platform") + _require_trusted_directory(path.parent, expected_owner_uid=os.geteuid()) + flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(path, flags, 0o600) + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or metadata.st_mode & 0o022 + ): + os.close(descriptor) + raise DeploymentFailure("deployment lock is not trusted") + os.fchmod(descriptor, 0o600) + stream = os.fdopen(descriptor, "w", encoding="utf-8") + try: + try: + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + raise DeploymentFailure("another TimeLocker deployment is active") from error + yield stream + finally: + stream.close() + + +def _signal_handler(signum: int, _frame: FrameType | None) -> None: + name = signal.Signals(signum).name + raise DeploymentInterrupted(f"deployment interrupted by {name}") + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="timelocker-deploy", + description="Install, inspect, upgrade, or roll back protected TimeLocker.", + ) + commands = parser.add_subparsers(dest="operation", required=True) + for operation in ("install", "upgrade"): + command = commands.add_parser(operation) + command.add_argument("wheel", type=Path) + command.add_argument( + "--operator-user", + default=os.environ.get("SUDO_USER"), + help="Account authorized to exercise the installed CLI.", + ) + commands.add_parser("status") + commands.add_parser("rollback") + return parser + + +def main(argv: list[str] | None = None) -> int: + arguments = _parser().parse_args(argv) + paths = DeploymentPaths() + if os.name != "posix": + print( + json.dumps( + { + "operation": arguments.operation, + "result_code": "platform_unsupported", + }, + sort_keys=True, + ) + ) + return 69 + if arguments.operation == "status": + try: + payload = _deployment_status(paths) + except (OSError, RuntimeError, TypeError, ValueError): + payload = { + "operation": "status", + "result_code": "status_unavailable", + "selected_release": None, + "previous_release": None, + } + print(json.dumps(payload, sort_keys=True)) + return 1 + print(json.dumps(payload, sort_keys=True)) + return 0 + if os.geteuid() != 0: + print( + json.dumps( + { + "operation": arguments.operation, + "result_code": "elevation_required", + "next_action": "run this command with sudo", + }, + sort_keys=True, + ) + ) + return 77 + try: + _prepare_protected_roots(paths) + except (DeploymentFailure, OSError): + return _print_failure(arguments.operation, "deployment_roots_unavailable") + if paths.attention_file.exists(): + return _print_failure(arguments.operation, "attention_required") + if arguments.operation == "rollback": + return _run_rollback(paths) + if not arguments.operator_user: + print( + json.dumps( + { + "operation": arguments.operation, + "result_code": "operator_user_required", + "next_action": "pass --operator-user ACCOUNT", + }, + sort_keys=True, + ) + ) + return 2 + resolver = ImmutableReleaseResolver( + releases_root=paths.releases_root, + selector_path=paths.selector, + expected_owner_uid=paths.expected_owner_uid, + ) + try: + state = resolver._read_selector_optional() + except (OSError, RuntimeError, TypeError, ValueError): + return _print_failure(arguments.operation, "selector_untrusted") + current = state.selected if state is not None else None + if arguments.operation == "upgrade" and current is None: + return _print_failure(arguments.operation, "not_installed") + prior_handlers = { + signum: signal.signal(signum, _signal_handler) + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP) + } + stage = "artifact_validation" + deployer: T011LinuxDeployer | None = None + try: + request = _derive_request( + arguments.wheel.resolve(), + expected_current=current, + operator_user=arguments.operator_user, + paths=paths, + ) + with _deployment_lock(paths.lock_file): + stage = "idempotency_check" + disposition = _release_request_disposition( + arguments.operation, + current=current, + candidate=request.release_id, + ) + if disposition == "already_selected": + resolver.release_manifest(request.release_id) + result = { + "operation": arguments.operation, + "result_code": "already_selected", + "selected_release": request.release_id, + "previous_release": state.previous if state else None, + "artifact_sha256": request.wheel_sha256, + "mutation_completed": False, + "backup_or_retention_triggered": False, + } + evidence = _write_operation_evidence(paths, result) + result["evidence_location"] = str(evidence) + print(json.dumps(result, sort_keys=True)) + return 0 + if disposition == "already_installed": + raise DeploymentFailure("a different release is already installed") + _cleanup_inert_candidate( + paths, + request.release_id, + selected=current, + previous=state.previous if state else None, + ) + _ensure_operator_membership( + request.operator_user, + executor=CommandExecutor(), + ) + stage = "deployment_transaction" + deployer = T011LinuxDeployer(request, paths=paths) + evidence = deployer.deploy() + except (RuntimeError, OSError, ValueError, subprocess.SubprocessError) as error: + attention_required = paths.attention_file.exists() + mutation_started = deployer is not None and deployer.mutation_started + if attention_required: + result_code = "recovery_failed" + next_action = "inspect deployment attention evidence before retrying" + elif mutation_started: + result_code = "activation_failed_recovered" + next_action = "inspect deployment evidence and retry the verified artifact" + elif isinstance(error, DeploymentInterrupted): + result_code = "deployment_interrupted" + next_action = "verify deployment status before retrying" + else: + result_code = "validation_failed" + next_action = "correct the rejected input or host precondition" + payload: dict[str, object] = { + "operation": arguments.operation, + "result_code": result_code, + "failed_stage": stage, + "mutation_completed": False, + "recovery_completed": mutation_started and not attention_required, + "backup_or_retention_triggered": False, + "next_action": next_action, + } + if deployer is not None and deployer.evidence is not None: + _write_private_text( + deployer.evidence / "deployment-result.json", + json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n", + ) + evidence_location = deployer.evidence + else: + evidence_location = _write_operation_evidence(paths, payload) + payload["evidence_location"] = str(evidence_location) + print(json.dumps(payload, sort_keys=True)) + return 1 + finally: + for signum, handler in prior_handlers.items(): + signal.signal(signum, handler) + if "request" in locals(): + shutil.rmtree(request.manifest.parent, ignore_errors=True) + result = { + "operation": arguments.operation, + "result_code": "deployed", + "selected_release": request.release_id, + "previous_release": request.expected_current, + "artifact_sha256": request.wheel_sha256, + "evidence_location": str(evidence), + "mutation_completed": True, + "backup_or_retention_triggered": False, + } + _write_private_text( + evidence / "deployment-result.json", + json.dumps(result, sort_keys=True, separators=(",", ":")) + "\n", + ) + print(json.dumps(result, sort_keys=True)) + return 0 + + +def _derive_request( + wheel: Path, + *, + expected_current: str | None, + operator_user: str, + paths: DeploymentPaths, +) -> DeploymentRequest: + """Validate one local wheel and derive all identity-sensitive inputs.""" + _require_regular_file(wheel, "wheel") + filename = _validated_wheel_filename(wheel) + try: + wheel_name, wheel_version, _build, _tags = parse_wheel_filename(filename) + with zipfile.ZipFile(wheel) as archive: + names = set(archive.namelist()) + metadata_names = [name for name in names if name.endswith(".dist-info/METADATA")] + if len(metadata_names) != 1: + raise DeploymentFailure("wheel contains ambiguous package metadata") + metadata = BytesParser().parsebytes(archive.read(metadata_names[0])) + except (OSError, ValueError, zipfile.BadZipFile, KeyError) as error: + raise DeploymentFailure("wheel metadata is invalid") from error + package_name = metadata.get("Name") + package_version = metadata.get("Version") + if ( + not isinstance(package_name, str) + or canonicalize_name(package_name) != "timelocker" + or canonicalize_name(str(wheel_name)) != "timelocker" + or package_version != str(wheel_version) + ): + raise DeploymentFailure("wheel filename and package metadata disagree") + required_assets = { + f"TimeLocker/system_control/assets/{target.source_name}" + for target in linux_asset_targets() + } + missing = required_assets - names + if missing or any(name.endswith("timelocker-status-events.socket") for name in names): + raise DeploymentFailure("wheel protected asset set is incompatible") + digest = _sha256(wheel) + release_id = digest[:40] + _require_trusted_directory(paths.evidence_root, expected_owner_uid=os.geteuid()) + input_root = paths.evidence_root / f"input-{release_id}-{os.getpid()}" + _mkdir( + input_root, + mode=0o700, + uid=os.geteuid(), + gid=os.getegid(), + ) + manifest = input_root / "release.json" + _write_private_text( + manifest, + json.dumps( + { + "schema_version": 3, + "release_id": release_id, + "package_version": package_version, + "control_protocol_version": PROTOCOL_VERSION, + "entrypoint": "venv/bin/timelocker", + }, + sort_keys=True, + separators=(",", ":"), + ) + + "\n", + ) + return DeploymentRequest( + release_id=release_id, + expected_current=expected_current, + wheel=wheel, + wheel_sha256=digest, + manifest=manifest, + operator_user=operator_user, + ) + + +def _release_request_disposition( + operation: str, + *, + current: str | None, + candidate: str, +) -> str | None: + """Classify a verified artifact retry before protected mutation.""" + if current == candidate: + return "already_selected" + if operation == "install" and current is not None: + return "already_installed" + return None + + +def _systemctl_unit_healthy(action: str, unit: str) -> bool: + try: + completed = subprocess.run( + ["systemctl", action, "--quiet", unit], + check=False, + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return completed.returncode == 0 + + +def _deployment_status( + paths: DeploymentPaths, + *, + unit_probe: Callable[[str, str], bool] = _systemctl_unit_healthy, +) -> dict[str, object]: + resolver = ImmutableReleaseResolver( + releases_root=paths.releases_root, + selector_path=paths.selector, + expected_owner_uid=paths.expected_owner_uid, + ) + if paths.selector.is_symlink(): + raise DeploymentFailure("selected-release document is not trusted") + state = resolver._read_selector_optional() if paths.selector.exists() else None + selected = state.selected if state is not None else None + previous = state.previous if state is not None else None + attention_required = paths.attention_file.is_file() + service_text = "" + try: + service_text = paths.service_unit.read_text(encoding="utf-8") + except OSError: + pass + daemonless_unit = all( + marker in service_text + for marker in ( + "Type=exec", + "Sockets=timelocker-control.socket", + "RuntimeDirectoryPreserve=yes", + ) + ) and "status-events" not in service_text + unit_health = { + "control_socket_active": unit_probe( + "is-active", "timelocker-control.socket" + ), + "control_socket_enabled": unit_probe( + "is-enabled", "timelocker-control.socket" + ), + "backup_timer_active": unit_probe( + "is-active", "timelocker-npbackup-migration.timer" + ), + "backup_timer_enabled": unit_probe( + "is-enabled", "timelocker-npbackup-migration.timer" + ), + "retention_timer_active": unit_probe( + "is-active", "timelocker-retention.timer" + ), + "retention_timer_enabled": unit_probe( + "is-enabled", "timelocker-retention.timer" + ), + } + return { + "operation": "status", + "result_code": ( + "attention_required" + if attention_required + else ("installed" if selected is not None else "not_installed") + ), + "selected_release": selected, + "previous_release": previous, + "one_shot_helper_ready": ( + selected is not None + and daemonless_unit + and unit_health["control_socket_active"] + and unit_health["control_socket_enabled"] + ), + "resident_service_required": False, + "attention_required": attention_required, + **unit_health, + } + + +def _cleanup_inert_candidate( + paths: DeploymentPaths, + release_id: str, + *, + selected: str | None, + previous: str | None, +) -> None: + """Remove only trusted, unselected remnants of this exact artifact.""" + if release_id in {selected, previous}: + if release_id == previous: + raise DeploymentFailure("candidate is retained as the rollback release") + return + candidates = ( + paths.releases_root / release_id, + paths.launcher_venv.with_name(f".venv.{release_id}.staged"), + ) + for candidate in candidates: + if not candidate.exists(): + continue + _require_trusted_directory( + candidate, + expected_owner_uid=paths.expected_owner_uid, + ) + shutil.rmtree(candidate) + + +def _write_operation_evidence( + paths: DeploymentPaths, + payload: dict[str, object], +) -> Path: + """Write one exact redacted operation result into protected evidence.""" + operation = str(payload.get("operation", "deployment")) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + destination = paths.evidence_root / f"{operation}-{timestamp}-{os.getpid()}.json" + _write_private_text( + destination, + json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n", + ) + return destination + + +def _ensure_operator_membership( + operator_user: str, + *, + executor: CommandExecutor, +) -> None: + """Create the fixed local authorization group and enroll one operator.""" + if pwd is None or grp is None: + raise DeploymentFailure("protected deployment is unsupported on this platform") + try: + account = pwd.getpwnam(operator_user) + except KeyError as error: + raise DeploymentFailure("operator_user does not exist") from error + try: + group = grp.getgrnam("timelocker-operators") + except KeyError: + executor.run(["groupadd", "--system", "timelocker-operators"]) + group = grp.getgrnam("timelocker-operators") + if account.pw_gid != group.gr_gid and operator_user not in group.gr_mem: + executor.run( + [ + "usermod", + "--append", + "--groups", + "timelocker-operators", + operator_user, + ] + ) + + +def _prepare_protected_roots(paths: DeploymentPaths) -> None: + """Create only trusted staging roots before candidate preflight.""" + for directory, mode in ( + (paths.releases_root, 0o755), + (paths.selector.parent, 0o755), + (paths.launcher_venv.parent, 0o755), + (paths.evidence_root, 0o750), + (paths.lock_file.parent, 0o755), + ): + _create_directory_tree(directory, leaf_mode=mode) + _require_trusted_directory(directory, expected_owner_uid=os.geteuid()) + cutoff = time.time() - 86_400 + for candidate in paths.evidence_root.glob("input-*"): + if not re.fullmatch(r"input-[0-9a-f]{40}-[0-9]+", candidate.name): + continue + metadata = candidate.lstat() + if ( + stat.S_ISDIR(metadata.st_mode) + and not stat.S_ISLNK(metadata.st_mode) + and metadata.st_uid == os.geteuid() + and metadata.st_mtime < cutoff + ): + shutil.rmtree(candidate) + + +def _create_directory_tree(path: Path, *, leaf_mode: int) -> None: + """Create missing path components without changing existing directories.""" + missing: list[Path] = [] + candidate = path + while not candidate.exists(): + missing.append(candidate) + candidate = candidate.parent + for directory in reversed(missing): + mode = leaf_mode if directory == path else 0o755 + directory.mkdir(mode=mode) + directory.chmod(mode) + + +def _run_rollback(paths: DeploymentPaths) -> int: + resolver = ImmutableReleaseResolver( + releases_root=paths.releases_root, + selector_path=paths.selector, + expected_owner_uid=paths.expected_owner_uid, + ) + mutated = False + try: + current = resolver._read_selector_optional() + if current is None or current.previous is None: + raise DeploymentFailure("no previous release is available") + if resolver.release_manifest(current.previous).schema_version < 3: + raise DeploymentFailure("previous release requires a resident service") + with _deployment_lock(paths.lock_file): + selected = resolver.rollback() + mutated = True + executor = CommandExecutor() + executor.run(["systemctl", "restart", "timelocker-control.socket"]) + _verify_required_unit_health(executor) + except (DeploymentFailure, OSError, RuntimeError, subprocess.SubprocessError): + recovery_failed = False + if mutated: + try: + resolver.rollback() + executor = CommandExecutor() + executor.run(["systemctl", "restart", "timelocker-control.socket"]) + _verify_required_unit_health(executor) + except (OSError, RuntimeError, subprocess.SubprocessError): + recovery_failed = True + payload: dict[str, object] = { + "operation": "rollback", + "result_code": ( + "rollback_recovery_failed" + if recovery_failed + else "rollback_failed" + ), + "mutation_completed": mutated, + "recovery_completed": mutated and not recovery_failed, + "backup_or_retention_triggered": False, + "message": "rollback failed safely", + } + evidence = _write_operation_evidence(paths, payload) + payload["evidence_location"] = str(evidence) + if recovery_failed: + _write_private_text( + paths.attention_file, + json.dumps( + { + "schema_version": 1, + "reason": "rollback_recovery_failed", + }, + sort_keys=True, + separators=(",", ":"), + ) + + "\n", + ) + print(json.dumps(payload, sort_keys=True)) + return 1 + payload = { + "operation": "rollback", + "result_code": "rolled_back", + "selected_release": selected.selected, + "previous_release": selected.previous, + "mutation_completed": True, + "recovery_completed": False, + "backup_or_retention_triggered": False, + } + evidence = _write_operation_evidence(paths, payload) + payload["evidence_location"] = str(evidence) + print(json.dumps(payload, sort_keys=True)) + return 0 + + +def _verify_required_unit_health(executor: CommandExecutor) -> None: + for action, units in ( + ("is-active", REQUIRED_ACTIVE_UNITS), + ("is-enabled", REQUIRED_ENABLED_UNITS), + ): + for unit in units: + executor.run(["systemctl", action, "--quiet", unit], timeout=15) + + +def _print_failure(operation: str, result_code: str) -> int: + print(json.dumps({"operation": operation, "result_code": result_code}, sort_keys=True)) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/TimeLocker/system_control/linux_adapter.py b/src/TimeLocker/system_control/linux_adapter.py index 72a98e9..b2cc636 100644 --- a/src/TimeLocker/system_control/linux_adapter.py +++ b/src/TimeLocker/system_control/linux_adapter.py @@ -132,12 +132,16 @@ def from_systemd( def serve(self, handler: ControlRequestHandler) -> None: """Serve until stopped, isolating malformed clients to one connection.""" while not self.stop_event.is_set(): - connection, _address = self.listener.accept() - with connection: - try: - self.serve_connection(connection, handler) - except OSError: - continue + self.serve_once(handler) + + def serve_once(self, handler: ControlRequestHandler) -> None: + """Serve exactly one bounded request for daemonless socket activation.""" + connection, _address = self.listener.accept() + with connection: + try: + self.serve_connection(connection, handler) + except OSError: + return def serve_connection( self, diff --git a/src/TimeLocker/system_control/release_launcher.py b/src/TimeLocker/system_control/release_launcher.py index 991e439..bfb728b 100644 --- a/src/TimeLocker/system_control/release_launcher.py +++ b/src/TimeLocker/system_control/release_launcher.py @@ -73,7 +73,7 @@ class ReleaseManifest: control_protocol_version: int event_protocol_version: int | None entrypoint: str = "venv/bin/timelocker" - schema_version: int = 2 + schema_version: int = 3 @classmethod def from_mapping(cls, value: object) -> "ReleaseManifest": @@ -83,10 +83,44 @@ def from_mapping(cls, value: object) -> "ReleaseManifest": value.get("schema_version"), field="schema_version", minimum=1, - maximum=2, + maximum=3, ) if schema_version == 1: return cls._from_legacy_mapping(value) + if schema_version == 3: + mapping = require_exact_mapping( + value, + field="release manifest", + required=frozenset( + { + "schema_version", + "release_id", + "package_version", + "control_protocol_version", + "entrypoint", + } + ), + ) + entrypoint = mapping["entrypoint"] + if entrypoint != "venv/bin/timelocker": + raise ReleaseResolutionError("release entrypoint is not allowlisted") + return cls( + schema_version=3, + release_id=_release_id(mapping["release_id"]), + package_version=require_safe_identifier( + mapping["package_version"], + field="package_version", + maximum=64, + ), + control_protocol_version=require_int( + mapping["control_protocol_version"], + field="control_protocol_version", + minimum=1, + maximum=MAX_DECLARED_PROTOCOL_VERSION, + ), + event_protocol_version=None, + entrypoint=entrypoint, + ) mapping = require_exact_mapping( value, field="release manifest", @@ -218,7 +252,10 @@ def select( manifest = self.release_manifest(release_id) if ( manifest.control_protocol_version != PROTOCOL_VERSION - or manifest.event_protocol_version != STATUS_EVENT_PROTOCOL_VERSION + or ( + manifest.schema_version < 3 + and manifest.event_protocol_version != STATUS_EVENT_PROTOCOL_VERSION + ) ): raise ReleaseResolutionError( "release protocols are incompatible with selector" diff --git a/src/TimeLocker/system_control/status_snapshot.py b/src/TimeLocker/system_control/status_snapshot.py new file mode 100644 index 0000000..a24e6df --- /dev/null +++ b/src/TimeLocker/system_control/status_snapshot.py @@ -0,0 +1,188 @@ +"""Daemonless sanitized status snapshot persistence and observation.""" + +from __future__ import annotations + +from collections.abc import Iterator +import json +import os +from pathlib import Path +from queue import Empty, Full, Queue +import stat +from threading import Event +from uuid import uuid4 + +from .models import StatusSnapshot + + +DEFAULT_STATUS_SNAPSHOT_PATH = Path("/run/timelocker/status.json") +MAX_STATUS_SNAPSHOT_BYTES = 1_048_576 + + +class StatusSnapshotUnavailable(RuntimeError): + """The sanitized status snapshot is missing or cannot be trusted.""" + + +class AtomicStatusSnapshotStore: + """Read and atomically replace one exact, sanitized status snapshot.""" + + def __init__( + self, + path: Path = DEFAULT_STATUS_SNAPSHOT_PATH, + *, + expected_owner_uid: int = 0, + group_gid: int | None = None, + ) -> None: + if not isinstance(path, Path) or not path.is_absolute(): + raise ValueError("status snapshot path must be absolute") + if type(expected_owner_uid) is not int or expected_owner_uid < 0: + raise ValueError("expected_owner_uid must be a non-negative integer") + self.path = path + self.expected_owner_uid = expected_owner_uid + if group_gid is not None and (type(group_gid) is not int or group_gid < 0): + raise ValueError("group_gid must be a non-negative integer") + self.group_gid = group_gid + + def read(self) -> StatusSnapshot: + """Read without producing any state-change notification.""" + descriptor = -1 + try: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(self.path, flags) + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode): + raise StatusSnapshotUnavailable("system status is unavailable") + if metadata.st_uid != self.expected_owner_uid or metadata.st_mode & 0o022: + raise StatusSnapshotUnavailable("system status is unavailable") + if metadata.st_size > MAX_STATUS_SNAPSHOT_BYTES: + raise StatusSnapshotUnavailable("system status is unavailable") + blocks: list[bytes] = [] + remaining = MAX_STATUS_SNAPSHOT_BYTES + 1 + while remaining > 0: + block = os.read(descriptor, min(65_536, remaining)) + if not block: + break + blocks.append(block) + remaining -= len(block) + raw = b"".join(blocks) + if len(raw) > MAX_STATUS_SNAPSHOT_BYTES: + raise StatusSnapshotUnavailable("system status is unavailable") + value = json.loads(raw.decode("utf-8")) + if not isinstance(value, dict) or set(value) != { + "schema_version", + "snapshot", + } or value["schema_version"] != 1: + raise ValueError("invalid status file") + return StatusSnapshot.from_mapping(value["snapshot"]) + except StatusSnapshotUnavailable: + raise + except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError): + raise StatusSnapshotUnavailable("system status is unavailable") from None + finally: + if descriptor >= 0: + os.close(descriptor) + + def write(self, snapshot: StatusSnapshot) -> None: + """Publish one allowlisted snapshot with atomic replacement.""" + if not isinstance(snapshot, StatusSnapshot): + raise TypeError("snapshot must be a StatusSnapshot") + parent_existed = self.path.parent.exists() + self.path.parent.mkdir(mode=0o750, parents=True, exist_ok=True) + if not parent_existed: + self.path.parent.chmod(0o750) + parent = self.path.parent.lstat() + if ( + not stat.S_ISDIR(parent.st_mode) + or stat.S_ISLNK(parent.st_mode) + or parent.st_uid != self.expected_owner_uid + or parent.st_mode & 0o022 + ): + raise PermissionError("status directory is not trusted") + payload = ( + json.dumps( + {"schema_version": 1, "snapshot": snapshot.to_wire()}, + sort_keys=True, + separators=(",", ":"), + ) + + "\n" + ).encode("utf-8") + if len(payload) > MAX_STATUS_SNAPSHOT_BYTES: + raise ValueError("status snapshot exceeds configured bound") + temporary = self.path.with_name( + f".{self.path.name}.{os.getpid()}.{uuid4().hex}.tmp" + ) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(temporary, flags, 0o640) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + temporary.chmod(0o640) + if self.group_gid is not None: + os.chown(temporary, self.expected_owner_uid, self.group_gid) + os.replace(temporary, self.path) + finally: + temporary.unlink(missing_ok=True) + + +class StatusSnapshotFileWatcher: + """Yield the initial status and direct filesystem changes without polling.""" + + def __init__(self, store: AtomicStatusSnapshotStore | None = None) -> None: + self.store = store or AtomicStatusSnapshotStore() + + def snapshots(self, stop_event: Event) -> Iterator[StatusSnapshot]: + if not isinstance(stop_event, Event): + raise TypeError("stop_event must be a threading.Event") + try: + from watchdog.events import FileSystemEvent, FileSystemEventHandler + from watchdog.observers import Observer + except ImportError as error: # pragma: no cover - packaging failure + raise StatusSnapshotUnavailable("system status is unavailable") from error + + changes: Queue[bool] = Queue(maxsize=1) + watched = self.store.path + + class Handler(FileSystemEventHandler): + def on_any_event(self, event: FileSystemEvent) -> None: + # Reads generate open/close events on Linux. Ignoring those is + # the invariant that prevents a read-notify-read CPU loop. + if event.event_type not in {"created", "modified", "moved"}: + return + paths = {Path(event.src_path)} + destination = getattr(event, "dest_path", None) + if destination: + paths.add(Path(destination)) + if watched not in paths: + return + try: + changes.put_nowait(True) + except Full: + pass + + observer = Observer() + observer.schedule(Handler(), str(watched.parent), recursive=False) + observer.start() + try: + # Register observation before the initial read so an atomic replace + # cannot disappear into a read/watch setup race. + try: + initial = self.store.read() + except StatusSnapshotUnavailable: + initial = None + if initial is not None: + yield initial + while not stop_event.is_set(): + try: + changes.get(timeout=0.25) + except Empty: + continue + try: + yield self.store.read() + except StatusSnapshotUnavailable: + continue + finally: + observer.stop() + observer.join(timeout=1.0) diff --git a/src/TimeLocker/system_control/tray_client.py b/src/TimeLocker/system_control/tray_client.py index aa51145..e83db18 100644 --- a/src/TimeLocker/system_control/tray_client.py +++ b/src/TimeLocker/system_control/tray_client.py @@ -8,17 +8,18 @@ from typing import Callable, TypeVar from .client import SystemControlClientError, UnixSocketSystemControlClient -from .event_client import StatusEventAccessDenied, UnixSocketStatusEventClient -from .interfaces import StatusEventClient, SystemControlClient +from .interfaces import SystemControlClient from .models import BackupActionRequest, RetentionActionRequest, StatusSnapshot +from .status_snapshot import ( + StatusSnapshotFileWatcher, + StatusSnapshotUnavailable, +) from .types import ( BackendStatus, BackupScheduleHealth, ProtocolErrorCode, ResponseStatus, RunState, - StatusEventConnectionState, - StatusEventKind, ) @@ -57,16 +58,14 @@ class TrayBackendUnavailable(RuntimeError): class TrayStatusSubscriptionClient: - """Refresh snapshots only when the authenticated event stream invalidates.""" + """Consume the sanitized status file without a privileged event service.""" def __init__( self, *, - control_client: SystemControlClient | None = None, - event_client: StatusEventClient | None = None, + watcher: StatusSnapshotFileWatcher | None = None, ) -> None: - self._control_client = control_client or UnixSocketSystemControlClient() - self._event_client = event_client or UnixSocketStatusEventClient() + self._watcher = watcher or StatusSnapshotFileWatcher() def serve( self, @@ -78,63 +77,22 @@ def serve( """Consume invalidations and publish coherent snapshots to the tray.""" if not isinstance(stop_event, Event): raise TypeError("stop_event must be a threading.Event") - applied = None - refresh_pending = True - last_unavailable: str | None = None - - def connection_state_changed(state: StatusEventConnectionState) -> None: - nonlocal last_unavailable - if state is StatusEventConnectionState.CONNECTED: - last_unavailable = None - return - reason = ( - "denied" - if state is StatusEventConnectionState.DENIED - else "unavailable" - ) - if on_unavailable is not None and reason != last_unavailable: - on_unavailable(reason) - last_unavailable = reason - try: - for event in self._event_client.events( - stop_event, - on_connection_state=connection_state_changed, - ): + applied = None + for snapshot in self._watcher.snapshots(stop_event): if stop_event.is_set(): return - if event.kind is StatusEventKind.HEARTBEAT and not refresh_pending: - continue - if event.kind is not StatusEventKind.HEARTBEAT and ( - applied is not None - and event.revision.session_id == applied.session_id - and event.revision.sequence <= applied.sequence - ): - continue - refresh_pending = True - try: - snapshot = self._control_client.get_status_snapshot() - except SystemControlClientError as error: - if on_unavailable is not None: - state = ( - "denied" - if error.status is ResponseStatus.DENIED - else "unavailable" - ) - on_unavailable(state) - continue if ( applied is not None and snapshot.revision.session_id == applied.session_id - and snapshot.revision.sequence < applied.sequence + and snapshot.revision.sequence <= applied.sequence ): continue applied = snapshot.revision - refresh_pending = False on_snapshot(snapshot) - except StatusEventAccessDenied: - if on_unavailable is not None and last_unavailable != "denied": - on_unavailable("denied") + except StatusSnapshotUnavailable: + if on_unavailable is not None: + on_unavailable("unavailable") class TrayControlClient: diff --git a/src/TimeLocker/system_control/tray_entry.py b/src/TimeLocker/system_control/tray_entry.py index 6fe979b..913e7b3 100644 --- a/src/TimeLocker/system_control/tray_entry.py +++ b/src/TimeLocker/system_control/tray_entry.py @@ -301,6 +301,10 @@ def _request_stop(*_args: Any) -> None: tray.show_context_menu() updates: Queue[TrayDisplayState] = Queue(maxsize=1) + # An explicit tray launch may wake the one-shot helper once. The + # helper answers and exits; subsequent updates come directly from + # the sanitized status file without polling the privileged socket. + _offer_latest(updates, client.refresh_status()) subscription = TrayStatusSubscriptionClient() def _subscribe() -> None: diff --git a/tests/TimeLocker/project/test_release_artifacts.py b/tests/TimeLocker/project/test_release_artifacts.py index 4d03aa5..298d83f 100644 --- a/tests/TimeLocker/project/test_release_artifacts.py +++ b/tests/TimeLocker/project/test_release_artifacts.py @@ -80,15 +80,16 @@ def test_artifact_smoke_covers_system_entrypoints_protocols_and_assets(): smoke = (ROOT / "scripts/smoke_release_artifact.py").read_text() for expected in ( "timelocker-system-control", + "timelocker-deploy", "timelocker-tray", - "STATUS_EVENT_PROTOCOL_VERSION", - "timelocker-status-events.socket", + '"schema_version": 3', "timelocker-retention.timer", "timelocker-icon-connecting.png", "timelocker-icon-idle.png", "timelocker-icon-error.png", ): assert expected in smoke + assert 'assert not assets.joinpath("timelocker-status-events.socket").is_file()' in smoke @pytest.mark.platform diff --git a/tests/TimeLocker/project/test_t011_linux_deployment.py b/tests/TimeLocker/project/test_t011_linux_deployment.py index de17397..eb824a0 100644 --- a/tests/TimeLocker/project/test_t011_linux_deployment.py +++ b/tests/TimeLocker/project/test_t011_linux_deployment.py @@ -1,753 +1,442 @@ -"""Safety contracts for the repository-owned T011 Linux deployment harness.""" +"""Supported daemonless protected deployment entrypoint contracts.""" from __future__ import annotations import getpass -import importlib.util import json import os from pathlib import Path -import sys -from types import ModuleType +from types import SimpleNamespace +import zipfile import pytest +from TimeLocker.system_control import deployment_entry as entry +from TimeLocker.system_control.deployment import AssetTarget, linux_asset_targets -ROOT = Path(__file__).resolve().parents[3] -RELEASE_A = "a" * 40 -RELEASE_B = "b" * 40 - - -def _load_harness() -> ModuleType: - path = ROOT / "scripts/deploy_t011_linux.py" - spec = importlib.util.spec_from_file_location("deploy_t011_linux", path) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -class FakeExecutor: - """Capture commands and return deterministic candidate-probe output.""" - - def __init__( - self, - harness: ModuleType, - packaged_unit: Path, - *, - backend_protocol: str = "2:1", - ) -> None: - self.harness = harness - self.packaged_unit = packaged_unit - self.backend_protocol = backend_protocol - self.commands: list[list[str]] = [] - - def run( - self, - arguments, - *, - timeout=30, - output=None, - capture=False, - check=True, - ) -> str: - del timeout, check - command = [str(argument) for argument in arguments] - self.commands.append(command) - result = "" - if command[-2:] == ["-c", self.harness.BACKEND_IMPORT_PROBE]: - result = f"{self.backend_protocol}\n" - elif self.harness.LAUNCHER_COMPATIBILITY_PROBE in command: - result = "compatible\n" - elif command[-2:] == ["version", "--short"]: - result = "0.9.1\n" - elif command[-2:] == ["-c", self.harness.PACKAGED_UNIT_PROBE]: - result = f"{self.packaged_unit}\n" - elif command[-2:] == ["-c", self.harness.DENIED_EVENT_PROBE]: - result = "denied\n" - elif command[-2:] == ["-c", self.harness.AUTHORIZED_EVENT_PROBE]: - result = json.dumps( - { - "kind": "snapshot", - "sequence": 1, - "session_id": "526719f9-4c46-42ac-b286-2623079bc335", - } - ) - if output is not None: - self.harness._write_private_text(output, result) - return result - - -class SimulatedHostExecutor(FakeExecutor): - """Model the filesystem effects of venv, pip, and release selection.""" - def __init__( - self, - harness: ModuleType, - packaged_unit: Path, - paths, - *, - fail_activated_event: bool = False, - ) -> None: - super().__init__(harness, packaged_unit) - self.paths = paths - self.fail_activated_event = fail_activated_event - self.authorized_event_calls = 0 - - def run( - self, - arguments, - *, - timeout=30, - output=None, - capture=False, - check=True, - ) -> str: - command = [str(argument) for argument in arguments] - if command[:4] == ["python3", "-m", "venv", "--system-site-packages"]: - venv_path = Path(command[4]) - python = venv_path / "bin/python" - python.parent.mkdir(parents=True) - python.write_text("#!/bin/sh\n", encoding="utf-8") - python.chmod(0o755) - elif ( - len(command) >= 5 - and command[1:4] == ["-m", "pip", "install"] - and "--no-deps" not in command - ): - release = Path(command[0]).parents[2] - python = release / "venv/bin/python" - for name in self.harness.REQUIRED_ENTRYPOINTS: - entrypoint = release / "venv/bin" / name - entrypoint.write_text(f"#!{python}\n", encoding="utf-8") - entrypoint.chmod(0o755) - self.packaged_unit.parent.mkdir(parents=True) - self.packaged_unit.write_text( - "\n".join( - ( - "[Unit]", - "Requires=timelocker-control.socket", - "Wants=timelocker-status-events.socket", - "[Service]", - ( - "Sockets=timelocker-control.socket " - "timelocker-status-events.socket" - ), - "", - ) - ), - encoding="utf-8", - ) - elif ( - "TimeLocker.system_control.release_admin" in command - and "select" in command - ): - state = json.loads(self.paths.selector.read_text(encoding="utf-8")) - state["previous"] = state["selected"] - state["selected"] = command[command.index("select") + 1] - self.paths.selector.write_text(json.dumps(state), encoding="utf-8") - result = super().run( - arguments, - timeout=timeout, - output=output, - capture=capture, - check=check, - ) - if command[-2:] == ["-c", self.harness.AUTHORIZED_EVENT_PROBE]: - self.authorized_event_calls += 1 - if self.fail_activated_event and self.authorized_event_calls == 2: - return "not-json" - return result +RELEASE_A = "a" * 40 -def _paths(harness: ModuleType, root: Path): - return harness.DeploymentPaths( +def _paths(root: Path) -> entry.DeploymentPaths: + return entry.DeploymentPaths( releases_root=root / "opt/timelocker/releases", selector=root / "opt/timelocker/selected-release.json", service_unit=root / "etc/systemd/system/timelocker-control.service", - evidence_root=root / "var/lib/timelocker/migration-backup", - lock_file=root / "run/lock/timelocker-t011-deploy.lock", + evidence_root=root / "var/lib/timelocker/deployments", + lock_file=root / "run/lock/timelocker-deploy.lock", launcher_venv=root / "opt/timelocker/launcher/venv", + legacy_event_socket=root / "run/timelocker/status-events.sock", + attention_file=root / "var/lib/timelocker/deployment-attention.json", + expected_owner_uid=os.getuid(), ) -def _request(harness: ModuleType, root: Path): +def _wheel(root: Path, *, include_legacy_event: bool = False) -> Path: wheel = root / "timelocker-0.9.1-py3-none-any.whl" - wheel.write_bytes(b"validated wheel") - digest = harness._sha256(wheel) - manifest = root / "release.json" - manifest.write_text( - json.dumps( - { - "schema_version": 2, - "release_id": RELEASE_B, - "package_version": "0.9.1", - "control_protocol_version": 2, - "event_protocol_version": 1, - "entrypoint": "venv/bin/timelocker", - } - ), - encoding="utf-8", - ) - return harness.DeploymentRequest( - release_id=RELEASE_B, + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + "timelocker-0.9.1.dist-info/METADATA", + "Metadata-Version: 2.1\nName: timelocker\nVersion: 0.9.1\n", + ) + for target in linux_asset_targets(): + content = "asset\n" + if target.source_name == "timelocker-control.service": + content = ( + "[Unit]\nRequires=timelocker-control.socket\n" + "[Service]\nType=exec\nSockets=timelocker-control.socket\n" + "RuntimeDirectoryPreserve=yes\n" + ) + archive.writestr( + f"TimeLocker/system_control/assets/{target.source_name}", content + ) + if include_legacy_event: + archive.writestr( + "TimeLocker/system_control/assets/timelocker-status-events.socket", + "legacy\n", + ) + return wheel + + +def _prepare_roots(paths: entry.DeploymentPaths) -> None: + entry._prepare_protected_roots(paths) + + +@pytest.mark.unit +def test_local_wheel_identity_and_daemonless_manifest_are_derived(tmp_path: Path) -> None: + paths = _paths(tmp_path) + _prepare_roots(paths) + wheel = _wheel(tmp_path) + + request = entry._derive_request( + wheel, expected_current=RELEASE_A, - wheel=wheel, - wheel_sha256=digest, - manifest=manifest, operator_user=getpass.getuser(), + paths=paths, ) + assert request.release_id == entry._sha256(wheel)[:40] + assert request.wheel_sha256 == entry._sha256(wheel) + manifest = json.loads(request.manifest.read_text()) + assert manifest == { + "schema_version": 3, + "release_id": request.release_id, + "package_version": "0.9.1", + "control_protocol_version": 2, + "entrypoint": "venv/bin/timelocker", + } -def _baseline(paths) -> None: - paths.selector.parent.mkdir(parents=True) - paths.selector.write_text( - json.dumps( - { - "schema_version": 1, - "selected": RELEASE_A, - "previous": None, - } - ), - encoding="utf-8", - ) - paths.service_unit.parent.mkdir(parents=True) - paths.service_unit.write_text("old service\n", encoding="utf-8") - paths.evidence_root.mkdir(parents=True) - paths.releases_root.mkdir(parents=True) - launcher_python = paths.launcher_venv / "bin/python" - launcher_python.parent.mkdir(parents=True) - paths.launcher_venv.parent.chmod(0o755) - paths.launcher_venv.chmod(0o755) - launcher_python.parent.chmod(0o755) - launcher_python.write_text("#!/bin/sh\n# old launcher\n", encoding="utf-8") - launcher_python.chmod(0o755) - current_release = paths.releases_root / RELEASE_A - current_entrypoints = current_release / "venv/bin" - current_entrypoints.mkdir(parents=True) - for name in ("timelocker", "timelocker-system-control", "timelocker-tray"): - entrypoint = current_entrypoints / name - entrypoint.write_text("#!/bin/sh\n", encoding="utf-8") - entrypoint.chmod(0o755) - (current_release / "release.json").write_text( - json.dumps( - { - "schema_version": 2, - "release_id": RELEASE_A, - "package_version": "0.9.1", - "control_protocol_version": 1, - "event_protocol_version": 1, - "entrypoint": "venv/bin/timelocker", - } - ), - encoding="utf-8", - ) +@pytest.mark.unit +def test_local_wheel_rejects_legacy_event_service_asset(tmp_path: Path) -> None: + paths = _paths(tmp_path) + _prepare_roots(paths) -def _staged_release(deployer, packaged_unit: Path) -> None: - python = deployer.release / "venv/bin/python" - python.parent.mkdir(parents=True) - python.write_text("#!/bin/sh\n", encoding="utf-8") - python.chmod(0o755) - for name in ( - "timelocker", - "tl", - "timelocker-tray", - "timelocker-system-control", - ): - entrypoint = deployer.release / "venv/bin" / name - entrypoint.write_text(f"#!{python}\n", encoding="utf-8") - entrypoint.chmod(0o755) - packaged_unit.parent.mkdir(parents=True) - packaged_unit.write_text( - "\n".join( - ( - "[Unit]", - "Requires=timelocker-control.socket", - "Wants=timelocker-status-events.socket", - "[Service]", - "Sockets=timelocker-control.socket timelocker-status-events.socket", - "", - ) - ), - encoding="utf-8", - ) + with pytest.raises(entry.DeploymentFailure, match="asset set"): + entry._derive_request( + _wheel(tmp_path, include_legacy_event=True), + expected_current=None, + operator_user=getpass.getuser(), + paths=paths, + ) -def test_identity_preflights_are_inline_and_precede_mutation_under_restrictive_umask( - tmp_path: Path, -) -> None: - harness = _load_harness() - paths = _paths(harness, tmp_path) - _baseline(paths) - request = _request(harness, tmp_path) - packaged_unit = ( - paths.releases_root - / RELEASE_B - / "venv/lib/python3.12/site-packages/TimeLocker/system_control/assets" - / "timelocker-control.service" +@pytest.mark.unit +def test_verified_release_retry_is_idempotent() -> None: + assert ( + entry._release_request_disposition( + "install", current=RELEASE_A, candidate=RELEASE_A + ) + == "already_selected" ) - executor = FakeExecutor(harness, packaged_unit) - deployer = harness.T011LinuxDeployer( - request, - paths=paths, - executor=executor, - owner_uid=None, - owner_gid=None, + assert ( + entry._release_request_disposition( + "upgrade", current=RELEASE_A, candidate=RELEASE_A + ) + == "already_selected" ) - deployer.validate_request() - old_umask = os.umask(0o077) - try: - deployer.capture_baseline() - _staged_release(deployer, packaged_unit) - deployer.preflight_staged_release() - finally: - os.umask(old_umask) - - assert json.loads(paths.selector.read_text())["selected"] == RELEASE_A - assert paths.service_unit.read_text() == "old service\n" - target_identity_commands = [ - command - for command in executor.commands - if "runuser" in command or "setpriv" in command - ] - assert len(target_identity_commands) == 3 - assert all("-c" in command for command in target_identity_commands[1:]) - assert all( - not any(argument.endswith(".py") for argument in command) - for command in target_identity_commands + assert ( + entry._release_request_disposition( + "install", current=RELEASE_A, candidate="b" * 40 + ) + == "already_installed" ) - assert deployer.evidence is not None - assert deployer.staged_wheel is not None - assert deployer.staged_wheel.name == request.wheel.name - evidence_modes = { - path.name: path.stat().st_mode & 0o777 - for path in deployer.evidence.iterdir() - if path.is_file() - } - assert evidence_modes - assert set(evidence_modes.values()) == {0o600} -def test_backend_protocol_probe_must_match_validated_release_manifest( +@pytest.mark.unit +def test_packaged_service_requires_single_control_socket_and_no_event_service( tmp_path: Path, ) -> None: - harness = _load_harness() - paths = _paths(harness, tmp_path) - _baseline(paths) - request = _request(harness, tmp_path) - packaged_unit = ( - paths.releases_root - / RELEASE_B - / "venv/lib/python3.12/site-packages/TimeLocker/system_control/assets" - / "timelocker-control.service" - ) - deployer = harness.T011LinuxDeployer( - request, - paths=paths, - executor=FakeExecutor( - harness, - packaged_unit, - backend_protocol="1:1", - ), - owner_uid=None, - owner_gid=None, + release = tmp_path / ("b" * 40) + unit = release / "assets/timelocker-control.service" + unit.parent.mkdir(parents=True) + unit.write_text( + "[Unit]\nRequires=timelocker-control.socket\n" + "[Service]\nType=exec\nSockets=timelocker-control.socket\n" + "RuntimeDirectoryPreserve=yes\n" + ) + request = entry.DeploymentRequest( + release_id="b" * 40, + expected_current=None, + wheel=tmp_path / "unused.whl", + wheel_sha256="c" * 64, + manifest=tmp_path / "release.json", + operator_user=getpass.getuser(), ) - deployer.validate_request() - deployer.capture_baseline() - _staged_release(deployer, packaged_unit) - - with pytest.raises( - harness.DeploymentFailure, - match=r"expected 2:1, got 1:1", - ): - deployer.preflight_staged_release() - - assert json.loads(paths.selector.read_text())["selected"] == RELEASE_A - assert paths.service_unit.read_text() == "old service\n" - assert deployer.evidence is not None - assert ( - deployer.evidence / "preflight-backend-protocol.txt" - ).read_text(encoding="utf-8") == "1:1\n" + deployer = entry.T011LinuxDeployer(request, owner_uid=os.getuid()) + deployer.release = release + + deployer._validate_packaged_unit(unit) + unit.write_text(unit.read_text() + "Wants=timelocker-status-events.socket\n") + with pytest.raises(entry.DeploymentFailure, match="event socket"): + deployer._validate_packaged_unit(unit) -def test_invalid_wheel_filename_is_rejected_before_host_state( +@pytest.mark.unit +def test_initial_install_validation_does_not_require_running_units( tmp_path: Path, ) -> None: - harness = _load_harness() - paths = _paths(harness, tmp_path) - _baseline(paths) - request = _request(harness, tmp_path) - invalid_wheel = tmp_path / "candidate.whl" - request.wheel.replace(invalid_wheel) - request = harness.DeploymentRequest( - release_id=request.release_id, - expected_current=request.expected_current, - wheel=invalid_wheel, - wheel_sha256=harness._sha256(invalid_wheel), - manifest=request.manifest, - operator_user=request.operator_user, - ) - deployer = harness.T011LinuxDeployer( - request, + paths = _paths(tmp_path) + _prepare_roots(paths) + wheel = tmp_path / "timelocker-0.9.1-py3-none-any.whl" + wheel.write_bytes(b"wheel") + manifest = tmp_path / "release.json" + manifest.write_text("{}") + commands: list[list[str]] = [] + + class Executor: + def run(self, arguments, **_kwargs): + commands.append([str(value) for value in arguments]) + return "" + + deployer = entry.T011LinuxDeployer( + entry.DeploymentRequest( + release_id="b" * 40, + expected_current=None, + wheel=wheel, + wheel_sha256=entry._sha256(wheel), + manifest=manifest, + operator_user=getpass.getuser(), + ), paths=paths, - executor=FakeExecutor(harness, tmp_path / "unused.service"), - owner_uid=None, - owner_gid=None, + executor=Executor(), + owner_uid=os.getuid(), + owner_gid=os.getgid(), + asset_targets=( + AssetTarget("timelocker-control.service", paths.service_unit, 0o644), + ), ) - with pytest.raises(harness.DeploymentFailure, match="valid wheel filename"): - deployer.validate_request() - - assert list(paths.evidence_root.iterdir()) == [] - assert not deployer.release.exists() - - -def test_preflight_failure_never_calls_activation() -> None: - harness = _load_harness() - calls: list[str] = [] - - class FailingDeployer(harness.T011LinuxDeployer): - def validate_request(self): - calls.append("validate") - - def capture_baseline(self): - calls.append("baseline") - - def stage_release(self): - calls.append("stage") - - def preflight_staged_release(self): - calls.append("preflight") - raise harness.DeploymentFailure("preflight rejected") - - def activate(self): - calls.append("activate") - - def recover(self): - calls.append("recover") - - deployer = object.__new__(FailingDeployer) - - with pytest.raises(harness.DeploymentFailure, match="preflight rejected"): - deployer.deploy() - - assert calls == ["validate", "baseline", "stage", "preflight", "recover"] - + deployer.validate_request() + assert commands == [] -def test_interruption_after_mutation_runs_recovery() -> None: - harness = _load_harness() - calls: list[str] = [] - class InterruptedDeployer(harness.T011LinuxDeployer): - def validate_request(self): - calls.append("validate") +@pytest.mark.unit +def test_status_reports_zero_resident_service_contract(tmp_path: Path) -> None: + paths = _paths(tmp_path) + paths.selector.parent.mkdir(parents=True) + paths.selector.parent.chmod(0o755) + paths.selector.write_text( + json.dumps( + {"schema_version": 1, "selected": RELEASE_A, "previous": None} + ) + ) + paths.selector.chmod(0o644) + paths.service_unit.parent.mkdir(parents=True) + paths.service_unit.write_text( + "[Service]\nType=exec\nSockets=timelocker-control.socket\n" + "RuntimeDirectoryPreserve=yes\n" + ) + + assert entry._deployment_status(paths, unit_probe=lambda _action, _unit: True) == { + "operation": "status", + "result_code": "installed", + "selected_release": RELEASE_A, + "previous_release": None, + "one_shot_helper_ready": True, + "resident_service_required": False, + "attention_required": False, + "control_socket_active": True, + "control_socket_enabled": True, + "backup_timer_active": True, + "backup_timer_enabled": True, + "retention_timer_active": True, + "retention_timer_enabled": True, + } - def capture_baseline(self): - calls.append("baseline") - def stage_release(self): - calls.append("stage") +@pytest.mark.unit +def test_status_on_clean_host_is_not_installed_and_reports_unit_health( + tmp_path: Path, +) -> None: + paths = _paths(tmp_path) - def preflight_staged_release(self): - calls.append("preflight") + payload = entry._deployment_status( + paths, + unit_probe=lambda _action, _unit: False, + ) - def activate(self): - calls.append("activate") - raise KeyboardInterrupt + assert payload["result_code"] == "not_installed" + assert payload["one_shot_helper_ready"] is False + assert payload["backup_timer_active"] is False + assert payload["retention_timer_enabled"] is False - def recover(self): - calls.append("recover") - deployer = object.__new__(InterruptedDeployer) +@pytest.mark.unit +def test_private_evidence_writer_rejects_symlink(tmp_path: Path) -> None: + target = tmp_path / "target" + target.write_text("unchanged") + link = tmp_path / "evidence.json" + link.symlink_to(target) - with pytest.raises(KeyboardInterrupt): - deployer.deploy() + with pytest.raises(OSError): + entry._write_private_text(link, "replacement") - assert calls == [ - "validate", - "baseline", - "stage", - "preflight", - "activate", - "recover", - ] + assert target.read_text() == "unchanged" -@pytest.mark.parametrize("candidate_at_canonical_path", [False, True]) -def test_launcher_restore_uses_filesystem_state_across_swap_interruptions( +@pytest.mark.unit +def test_activation_enables_only_the_on_demand_control_socket( tmp_path: Path, - candidate_at_canonical_path: bool, + monkeypatch: pytest.MonkeyPatch, ) -> None: - harness = _load_harness() - paths = _paths(harness, tmp_path) - _baseline(paths) - deployer = harness.T011LinuxDeployer( - _request(harness, tmp_path), - paths=paths, - executor=FakeExecutor(harness, tmp_path / "unused.service"), - owner_uid=None, - owner_gid=None, + paths = _paths(tmp_path) + _prepare_roots(paths) + release_id = "b" * 40 + manifest = tmp_path / "release.json" + manifest.write_text( + json.dumps( + { + "schema_version": 3, + "release_id": release_id, + "package_version": "0.9.1", + "control_protocol_version": 2, + "entrypoint": "venv/bin/timelocker", + } + ) ) - deployer.staged_launcher.mkdir() - candidate_python = deployer.staged_launcher / "python" - candidate_python.write_text("candidate launcher", encoding="utf-8") - os.replace(paths.launcher_venv, deployer.previous_launcher) - if candidate_at_canonical_path: - os.replace(deployer.staged_launcher, paths.launcher_venv) - deployer.launcher_prior_moved = True - deployer.launcher_swapped = False - - deployer._restore_launcher() - - assert "# old launcher" in ( - paths.launcher_venv / "bin/python" - ).read_text(encoding="utf-8") - assert not deployer.previous_launcher.exists() - assert (deployer.staged_launcher / "python").read_text( - encoding="utf-8" - ) == "candidate launcher" - - -def test_recovery_restores_selector_and_service_and_removes_candidate( - tmp_path: Path, -) -> None: - harness = _load_harness() - paths = _paths(harness, tmp_path) - _baseline(paths) - request = _request(harness, tmp_path) + wheel = tmp_path / "timelocker-0.9.1-py3-none-any.whl" + wheel.write_bytes(b"wheel") + commands: list[list[str]] = [] packaged_unit = tmp_path / "packaged/timelocker-control.service" - executor = FakeExecutor(harness, packaged_unit) - deployer = harness.T011LinuxDeployer( - request, - paths=paths, - executor=executor, - owner_uid=None, - owner_gid=None, - ) - deployer.capture_baseline() - deployer.release.mkdir(parents=True) - (deployer.release / "inert").write_text("candidate", encoding="utf-8") - paths.selector.write_text( - json.dumps({"schema_version": 1, "selected": RELEASE_B}), - encoding="utf-8", - ) - paths.service_unit.write_text("candidate service\n", encoding="utf-8") - deployer.mutation_started = True - - deployer.recover() - - assert json.loads(paths.selector.read_text())["selected"] == RELEASE_A - assert paths.service_unit.read_text() == "old service\n" - assert not deployer.release.exists() - assert [ - command[:2] - for command in executor.commands[:4] - ] == [ - ["systemctl", "daemon-reload"], - ["systemctl", "restart"], - ["systemctl", "restart"], - ["systemctl", "restart"], - ] - assert any("is-active" in command for command in executor.commands[4:]) - assert any("is-enabled" in command for command in executor.commands[4:]) - - -def test_packaged_service_must_keep_event_socket_as_weak_dependency( - tmp_path: Path, -) -> None: - harness = _load_harness() - paths = _paths(harness, tmp_path) - _baseline(paths) - request = _request(harness, tmp_path) - packaged_unit = ( - paths.releases_root - / RELEASE_B - / "venv/lib/python3.12/site-packages/TimeLocker/system_control/assets" - / "timelocker-control.service" - ) - packaged_unit.parent.mkdir(parents=True) - packaged_unit.write_text( - "\n".join( - ( - "Requires=timelocker-control.socket timelocker-status-events.socket", - "Sockets=timelocker-control.socket timelocker-status-events.socket", - ) - ), - encoding="utf-8", + packaged_unit.parent.mkdir() + packaged_unit.write_text("[Service]\nType=exec\n") + + class Executor: + def run(self, arguments, **kwargs): + command = [str(value) for value in arguments] + commands.append(command) + if "PACKAGED_UNIT_PROBE" in str(arguments): + return str(packaged_unit) + if "importlib.resources" in str(arguments): + return str(packaged_unit) + return "" + + class AssetInstaller: + def __init__(self, **_kwargs): + pass + + def install_assets(self, _root, _manifest): + return None + + request = entry.DeploymentRequest( + release_id=release_id, + expected_current=None, + wheel=wheel, + wheel_sha256=entry._sha256(wheel), + manifest=manifest, + operator_user=getpass.getuser(), ) - deployer = harness.T011LinuxDeployer( + deployer = entry.T011LinuxDeployer( request, paths=paths, - executor=FakeExecutor(harness, packaged_unit), - owner_uid=None, - owner_gid=None, - ) - - with pytest.raises(harness.DeploymentFailure, match="missing"): - deployer._validate_packaged_unit(packaged_unit) - - -def test_packaged_service_cannot_escape_staged_release(tmp_path: Path) -> None: - harness = _load_harness() - paths = _paths(harness, tmp_path) - _baseline(paths) - request = _request(harness, tmp_path) - packaged_unit = tmp_path / "outside/timelocker-control.service" - packaged_unit.parent.mkdir() - packaged_unit.write_text( - "\n".join( - ( - "Requires=timelocker-control.socket", - "Wants=timelocker-status-events.socket", - "Sockets=timelocker-control.socket timelocker-status-events.socket", - ) + executor=Executor(), + owner_uid=os.getuid(), + owner_gid=os.getgid(), + asset_targets=( + AssetTarget("timelocker-control.service", paths.service_unit, 0o644), ), - encoding="utf-8", ) - deployer = harness.T011LinuxDeployer( - request, - paths=paths, - executor=FakeExecutor(harness, packaged_unit), - owner_uid=None, - owner_gid=None, + deployer.release.mkdir(parents=True) + (deployer.release / "venv/bin").mkdir(parents=True) + deployer.staged_launcher.mkdir(parents=True) + deployer.evidence = tmp_path / "evidence" + deployer.evidence.mkdir() + deployer.staged_manifest = manifest + monkeypatch.setattr(entry, "SystemReleaseDeployment", AssetInstaller) + monkeypatch.setattr(entry, "build_asset_manifest", lambda **_kwargs: object()) + monkeypatch.setattr(entry, "_selected_release_optional", lambda _path: None) + + deployer.activate() + + enable_commands = [command for command in commands if "enable" in command] + assert enable_commands == [ + ["systemctl", "enable", "--now", "timelocker-control.socket"] + ] + assert all("timelocker-retention.timer" not in command for command in commands) + assert all( + "timelocker-npbackup-migration.timer" not in command for command in commands ) - with pytest.raises(harness.DeploymentFailure, match="escapes"): - deployer._validate_packaged_unit(packaged_unit) - - -def test_private_writer_overrides_permissive_umask(tmp_path: Path) -> None: - harness = _load_harness() - output = tmp_path / "evidence.json" - old_umask = os.umask(0) - try: - harness._write_private_text(output, "{}\n") - finally: - os.umask(old_umask) - assert output.stat().st_mode & 0o777 == 0o600 - - -def test_signal_handler_converts_termination_to_transaction_exception() -> None: - harness = _load_harness() +@pytest.mark.unit +def test_mutating_command_returns_one_json_elevation_instruction( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(entry.os, "geteuid", lambda: 1000) - with pytest.raises(harness.DeploymentInterrupted, match="SIGTERM"): - harness._signal_handler(15, None) + assert entry.main(["upgrade", "/not/read.whl", "--operator-user", "user"]) == 77 + payload = json.loads(capsys.readouterr().out) + assert payload["result_code"] == "elevation_required" + assert payload["next_action"] == "run this command with sudo" -def test_full_simulated_transaction_runs_preflight_before_selection( +@pytest.mark.unit +def test_rollback_rejects_release_that_requires_resident_event_service( tmp_path: Path, + capsys: pytest.CaptureFixture[str], ) -> None: - harness = _load_harness() - paths = _paths(harness, tmp_path) - _baseline(paths) - request = _request(harness, tmp_path) - packaged_unit = ( - paths.releases_root - / RELEASE_B - / "venv/lib/python3.12/site-packages/TimeLocker/system_control/assets" - / "timelocker-control.service" - ) - executor = SimulatedHostExecutor(harness, packaged_unit, paths) - deployer = harness.T011LinuxDeployer( - request, - paths=paths, - executor=executor, - owner_uid=None, - owner_gid=None, - ) - old_umask = os.umask(0o077) - try: - evidence = deployer.deploy() - finally: - os.umask(old_umask) - - assert json.loads(paths.selector.read_text())["selected"] == RELEASE_B - denied_index = next( - index - for index, command in enumerate(executor.commands) - if command[-2:] == ["-c", harness.DENIED_EVENT_PROBE] - ) - selection_index = next( - index - for index, command in enumerate(executor.commands) - if "TimeLocker.system_control.release_admin" in command - and "select" in command - ) - version_index = next( - index - for index, command in enumerate(executor.commands) - if command[-2:] == ["version", "--short"] - ) - system_read_indexes = [ - index - for index, command in enumerate(executor.commands) - if command[-5:] == ["runs", "list", "--limit", "3", "--json"] - ] - pip_command = next( - command - for command in executor.commands - if len(command) >= 5 and command[1:4] == ["-m", "pip", "install"] - ) - assert denied_index < selection_index - assert version_index < selection_index - assert system_read_indexes - assert all(index > selection_index for index in system_read_indexes) - assert Path(pip_command[-1]).name == request.wheel.name - assert deployer.release.exists() - assert ( - "# old launcher" - not in (paths.launcher_venv / "bin/python").read_text(encoding="utf-8") - ) - assert "# old launcher" in ( - deployer.previous_launcher / "bin/python" - ).read_text(encoding="utf-8") - assert paths.service_unit.read_text() == packaged_unit.read_text() - assert all( - path.stat().st_mode & 0o022 == 0 - for path in deployer.release.rglob("*") - if not path.is_symlink() + paths = _paths(tmp_path) + _prepare_roots(paths) + for release_id in (RELEASE_A, "b" * 40): + executable = paths.releases_root / release_id / "venv/bin/timelocker" + executable.parent.mkdir(parents=True) + executable.write_text("#!/bin/sh\n") + executable.chmod(0o755) + for name in ("timelocker-system-control", "timelocker-tray"): + sibling = executable.with_name(name) + sibling.write_text("#!/bin/sh\n") + sibling.chmod(0o755) + (executable.parents[2] / "release.json").write_text( + json.dumps( + { + "schema_version": 2, + "release_id": release_id, + "package_version": "0.9.1", + "control_protocol_version": 2, + "event_protocol_version": 1, + "entrypoint": "venv/bin/timelocker", + } + ) + ) + paths.selector.write_text( + json.dumps( + { + "schema_version": 1, + "selected": "b" * 40, + "previous": RELEASE_A, + } + ) ) - assert evidence.stat().st_mode & 0o777 == 0o750 + assert entry._run_rollback(paths) == 1 + assert json.loads(capsys.readouterr().out)["result_code"] == "rollback_failed" -def test_full_simulated_post_activation_failure_rolls_back( + +@pytest.mark.unit +def test_rollback_verifies_control_and_timer_health( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], ) -> None: - harness = _load_harness() - paths = _paths(harness, tmp_path) - _baseline(paths) - request = _request(harness, tmp_path) - packaged_unit = ( - paths.releases_root - / RELEASE_B - / "venv/lib/python3.12/site-packages/TimeLocker/system_control/assets" - / "timelocker-control.service" - ) - executor = SimulatedHostExecutor( - harness, - packaged_unit, - paths, - fail_activated_event=True, - ) - deployer = harness.T011LinuxDeployer( - request, - paths=paths, - executor=executor, - owner_uid=None, - owner_gid=None, - ) - - with pytest.raises(harness.DeploymentFailure, match="invalid JSON"): - deployer.deploy() - - assert json.loads(paths.selector.read_text())["selected"] == RELEASE_A - assert paths.service_unit.read_text() == "old service\n" - assert not deployer.release.exists() - assert "# old launcher" in ( - paths.launcher_venv / "bin/python" - ).read_text(encoding="utf-8") - assert not deployer.previous_launcher.exists() - assert not deployer.staged_launcher.exists() + paths = _paths(tmp_path) + _prepare_roots(paths) + commands: list[list[str]] = [] + + class Resolver: + def __init__(self, **_kwargs): + pass + + def _read_selector_optional(self): + return SimpleNamespace(selected="b" * 40, previous=RELEASE_A) + + def release_manifest(self, _release_id): + return SimpleNamespace(schema_version=3) + + def rollback(self): + return SimpleNamespace(selected=RELEASE_A, previous="b" * 40) + + class Executor: + def run(self, arguments, **_kwargs): + commands.append([str(value) for value in arguments]) + return "" + + monkeypatch.setattr(entry, "ImmutableReleaseResolver", Resolver) + monkeypatch.setattr(entry, "CommandExecutor", Executor) + + assert entry._run_rollback(paths) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["result_code"] == "rolled_back" + assert ["systemctl", "restart", "timelocker-control.socket"] in commands + for unit in entry.REQUIRED_ACTIVE_UNITS: + assert ["systemctl", "is-active", "--quiet", unit] in commands + for unit in entry.REQUIRED_ENABLED_UNITS: + assert ["systemctl", "is-enabled", "--quiet", unit] in commands + + +@pytest.mark.unit +def test_compatibility_wrapper_routes_to_installed_entrypoint() -> None: + wrapper = Path("scripts/deploy_t011_linux.py").read_text() + assert "Deprecated Spec 010 compatibility wrapper" in wrapper + assert "deployment_entry" in wrapper diff --git a/tests/TimeLocker/system_control/test_backend_entry.py b/tests/TimeLocker/system_control/test_backend_entry.py index 219d2de..1f9e5c3 100644 --- a/tests/TimeLocker/system_control/test_backend_entry.py +++ b/tests/TimeLocker/system_control/test_backend_entry.py @@ -1,7 +1,6 @@ """Entrypoint checks for the privileged system-control backend.""" import os -import socket from pathlib import Path from threading import Event from typing import cast @@ -9,10 +8,6 @@ import pytest from TimeLocker.system_control import backend_entry -from TimeLocker.system_control.status_events import ( - BoundedStatusEventBroker, - StatusChangeCoordinator, -) from TimeLocker.system_control.types import OperationTrigger @@ -125,8 +120,8 @@ def test_main_composes_only_explicit_system_paths(monkeypatch, tmp_path: Path) - lambda **kwargs: captured.update(kwargs), ) monkeypatch.setenv("LISTEN_PID", str(os.getpid())) - monkeypatch.setenv("LISTEN_FDS", "2") - monkeypatch.setenv("LISTEN_FDNAMES", "control:status-events") + monkeypatch.setenv("LISTEN_FDS", "1") + monkeypatch.setenv("LISTEN_FDNAMES", "control") backend_entry.main( [ @@ -144,7 +139,7 @@ def test_main_composes_only_explicit_system_paths(monkeypatch, tmp_path: Path) - assert paths.record_root == state / "records" assert captured["socket_mode"] == "systemd" assert captured["systemd_descriptor"] == 3 - assert captured["status_systemd_descriptor"] == 4 + assert "status_systemd_descriptor" not in captured assert ( captured["production_target_path"] == backend_entry.DEFAULT_PRODUCTION_TARGET_PATH @@ -171,23 +166,8 @@ def test_main_redacts_initialization_failures(monkeypatch, capsys) -> None: @pytest.mark.unit -def test_systemd_descriptor_names_remove_order_dependency() -> None: - control, status = backend_entry._systemd_socket_descriptors( - { - "LISTEN_PID": "123", - "LISTEN_FDS": "2", - "LISTEN_FDNAMES": "status-events:control", - }, - process_id=123, - ) - - assert control == 4 - assert status == 3 - - -@pytest.mark.unit -def test_systemd_descriptor_contract_allows_control_without_event_socket() -> None: - control, status = backend_entry._systemd_socket_descriptors( +def test_systemd_descriptor_contract_requires_only_control_socket() -> None: + control = backend_entry._systemd_socket_descriptor( { "LISTEN_PID": "123", "LISTEN_FDS": "1", @@ -197,7 +177,6 @@ def test_systemd_descriptor_contract_allows_control_without_event_socket() -> No ) assert control == 3 - assert status is None @pytest.mark.unit @@ -207,8 +186,8 @@ def test_systemd_descriptor_contract_allows_control_without_event_socket() -> No {}, { "LISTEN_PID": "122", - "LISTEN_FDS": "2", - "LISTEN_FDNAMES": "control:status-events", + "LISTEN_FDS": "1", + "LISTEN_FDNAMES": "control", }, { "LISTEN_PID": "123", @@ -226,7 +205,7 @@ def test_systemd_descriptor_contract_fails_closed( environment: dict[str, str], ) -> None: with pytest.raises(RuntimeError, match="systemd socket"): - backend_entry._systemd_socket_descriptors(environment, process_id=123) + backend_entry._systemd_socket_descriptor(environment, process_id=123) @pytest.mark.unit @@ -255,142 +234,32 @@ def test_main_runs_control_backend_when_event_socket_is_absent( ) assert captured["systemd_descriptor"] == 3 - assert captured["status_systemd_descriptor"] is None - assert captured["status_socket_mode"] == "disabled" - - -@pytest.mark.unit -def test_build_linux_backend_rejects_status_listener_without_listener_mode( - tmp_path: Path, -) -> None: - paths = backend_entry.LinuxBackendPaths.from_state_root( - policy_path=tmp_path / "policy.json", - state_root=tmp_path / "state-root", - expected_owner=os.getuid(), - ) - - with pytest.raises( - ValueError, - match="status socket listener can only be provided in listener mode", - ): - backend_entry.build_linux_backend( - paths=paths, - status_socket_mode="systemd", - status_listener=socket.socket(socket.AF_UNIX, socket.SOCK_STREAM), - ) - - -@pytest.mark.unit -def test_build_linux_backend_rejects_listener_status_mode_without_status_listener() -> None: - paths = backend_entry.LinuxBackendPaths.from_state_root( - policy_path=Path("/tmp/never-read"), - state_root=Path("/tmp/never-read-state"), - expected_owner=os.getuid(), - ) - - with pytest.raises( - ValueError, - match="status socket listener is required for listener mode", - ): - backend_entry.build_linux_backend(paths=paths, status_socket_mode="listener") - - -@pytest.mark.unit -def test_build_linux_backend_listener_status_mode_uses_supplied_listener( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - captured: dict[str, object] = {} - control_listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - status_listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - paths = backend_entry.LinuxBackendPaths.from_state_root( - policy_path=tmp_path / "policy.json", - state_root=tmp_path / "state-root", - expected_owner=os.getuid(), - ) - - monkeypatch.setattr( - backend_entry, - "load_system_policy", - lambda *_args, **_kwargs: backend_entry.SystemPolicy(), - ) - monkeypatch.setattr( - backend_entry, - "_build_transport", - lambda **_kwargs: _StubTransport(control_listener), - ) - monkeypatch.setattr( - backend_entry, - "_build_status_transport", - lambda **kwargs: ( - captured.__setitem__("status_listener", kwargs["listener"]) - or _StubTransport(status_listener) - ), - ) - monkeypatch.setattr( - backend_entry, - "_build_handlers", - lambda **_kwargs: {}, - ) - monkeypatch.setattr( - backend_entry, - "reconcile_abandoned_runs", - lambda *_args, **_kwargs: [], - ) - monkeypatch.setattr( - backend_entry, - "_emit_startup_diagnostics", - lambda *_, **__: None, - ) - - service = backend_entry.build_linux_backend( - paths=paths, - status_socket_mode="listener", - status_listener=status_listener, - ) - - assert captured["status_listener"] is status_listener - assert service.status_event_transport is not None - service.stop() - assert control_listener.fileno() == -1 - assert status_listener.fileno() == -1 + assert "status_systemd_descriptor" not in captured + assert "status_socket_mode" not in captured @pytest.mark.unit -def test_event_transport_failure_does_not_block_control_requests() -> None: +def test_one_shot_service_serves_one_request_and_stops() -> None: control_served = Event() - event_started = Event() class _ControlTransport: listener = None - identity_provider = object() - def serve(self, _dispatcher: object) -> None: + def serve_once(self, _dispatcher: object) -> None: control_served.set() - class _FailingEventTransport: - listener = None - - def serve(self, *_args: object) -> None: - event_started.set() - raise OSError("event socket unavailable") - - broker = BoundedStatusEventBroker() service = backend_entry.LinuxBackendService( policy=backend_entry.SystemPolicy(), store=cast(object, None), locks=cast(object, None), dispatcher=cast(object, None), transport=cast(object, _ControlTransport()), - status_event_transport=cast(object, _FailingEventTransport()), audit_sink=cast(object, None), stop_event=Event(), - status_event_broker=broker, - status_change_coordinator=StatusChangeCoordinator(broker), membership_resolver=cast(object, None), ) - service.serve_forever(install_signal_handlers=False) + service.serve_once() - assert event_started.is_set() assert control_served.is_set() + assert service.stop_event.is_set() diff --git a/tests/TimeLocker/system_control/test_deployment.py b/tests/TimeLocker/system_control/test_deployment.py index 821d018..69461f7 100644 --- a/tests/TimeLocker/system_control/test_deployment.py +++ b/tests/TimeLocker/system_control/test_deployment.py @@ -154,6 +154,7 @@ def test_linux_asset_set_covers_launchers_backend_tray_and_schedules( ) -> None: targets = linux_asset_targets( bin_root=tmp_path / "bin", + admin_bin_root=tmp_path / "sbin", libexec_root=tmp_path / "libexec", unit_root=tmp_path / "units", config_root=tmp_path / "etc", @@ -166,10 +167,10 @@ def test_linux_asset_set_covers_launchers_backend_tray_and_schedules( "timelocker-launcher", "tl-launcher", "timelocker-system-control-launcher", + "timelocker-deploy-launcher", "timelocker-tray-launcher", "timelocker-control.service", "timelocker-control.socket", - "timelocker-status-events.socket", "timelocker-retention.service", "timelocker-retention.timer", "timelocker-tray.desktop", @@ -181,6 +182,12 @@ def test_linux_asset_set_covers_launchers_backend_tray_and_schedules( "timelocker-icon-warning.png", "timelocker-icon-error.png", } <= sources + deploy = next( + target for target in targets if target.source_name == "timelocker-deploy-launcher" + ) + assert deploy.destination == tmp_path / "sbin" / "timelocker-deploy" + assert deploy.mode == 0o750 + assert "timelocker-status-events.socket" not in sources policy = next( target for target in targets @@ -206,7 +213,6 @@ def test_linux_asset_set_covers_launchers_backend_tray_and_schedules( "failed_field", [ "control_status_available", - "event_channel_available", "backup_timer_active", "backup_timer_enabled", "retention_timer_active", @@ -249,7 +255,7 @@ def probe(targets: ReleaseProbeTargets) -> ReleaseProbeResult: @pytest.mark.unit -def test_rollback_allows_inert_event_socket_but_requires_control_and_timers( +def test_rollback_does_not_require_legacy_event_socket( tmp_path: Path, ) -> None: _stage_release(tmp_path, RELEASE_A) diff --git a/tests/TimeLocker/system_control/test_linux_adapter.py b/tests/TimeLocker/system_control/test_linux_adapter.py index 1ea7358..f0f0582 100644 --- a/tests/TimeLocker/system_control/test_linux_adapter.py +++ b/tests/TimeLocker/system_control/test_linux_adapter.py @@ -215,9 +215,6 @@ def test_policy_rejects_group_writable_or_unknown_fields( def test_socket_and_service_templates_enforce_narrow_boundary(self) -> None: socket_unit = (ASSET_DIRECTORY / "timelocker-control.socket").read_text() - event_socket_unit = ( - ASSET_DIRECTORY / "timelocker-status-events.socket" - ).read_text() service_unit = (ASSET_DIRECTORY / "timelocker-control.service").read_text() assert "ListenStream=/run/timelocker/control.sock" in socket_unit @@ -225,30 +222,17 @@ def test_socket_and_service_templates_enforce_narrow_boundary(self) -> None: assert "SocketGroup=timelocker-operators" in socket_unit assert "SocketMode=0660" in socket_unit assert "FileDescriptorName=control" in socket_unit - assert "ListenStream=/run/timelocker/status-events.sock" in event_socket_unit - assert "DirectoryMode=0755" in event_socket_unit - assert "SocketUser=root" in event_socket_unit - assert "SocketGroup=timelocker-operators" in event_socket_unit - assert "SocketMode=0660" in event_socket_unit - assert "FileDescriptorName=status-events" in event_socket_unit - assert ( - "Service=timelocker-control.service" in event_socket_unit - ) + assert not (ASSET_DIRECTORY / "timelocker-status-events.socket").exists() assert "User=root" in service_unit - assert ( - "Sockets=timelocker-control.socket timelocker-status-events.socket" - in service_unit - ) - assert ( - "Requires=timelocker-control.socket" in service_unit - ) - assert "Wants=timelocker-status-events.socket" in service_unit - assert ( - "Requires=timelocker-control.socket timelocker-status-events.socket" - not in service_unit - ) + assert "Group=timelocker-operators" in service_unit + assert "Sockets=timelocker-control.socket" in service_unit + assert "Requires=timelocker-control.socket" in service_unit + assert "status-events" not in service_unit assert "UMask=0077" in service_unit - assert "RuntimeDirectory=" not in service_unit + assert "Type=exec" in service_unit + assert "RuntimeDirectory=timelocker" in service_unit + assert "RuntimeDirectoryMode=0750" in service_unit + assert "RuntimeDirectoryPreserve=yes" in service_unit assert "StateDirectoryMode=0750" in service_unit assert "NoNewPrivileges=yes" in service_unit assert "ProtectSystem=strict" in service_unit diff --git a/tests/TimeLocker/system_control/test_status_snapshot.py b/tests/TimeLocker/system_control/test_status_snapshot.py new file mode 100644 index 0000000..4146769 --- /dev/null +++ b/tests/TimeLocker/system_control/test_status_snapshot.py @@ -0,0 +1,109 @@ +"""Atomic daemonless status-file contracts.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +import os +from pathlib import Path +import stat +from threading import Event +from uuid import UUID + +import pytest + +from TimeLocker.system_control.models import StatusRevision, StatusSnapshot +from TimeLocker.system_control.status_snapshot import ( + AtomicStatusSnapshotStore, + StatusSnapshotFileWatcher, + StatusSnapshotUnavailable, +) +from TimeLocker.system_control.types import BackendStatus + + +def _snapshot(sequence: int) -> StatusSnapshot: + return StatusSnapshot( + revision=StatusRevision( + UUID("526719f9-4c46-42ac-b286-2623079bc335"), sequence + ), + backend_status=BackendStatus.AVAILABLE, + active_operations=sequence, + ) + + +def _store(path: Path) -> AtomicStatusSnapshotStore: + path.parent.mkdir(mode=0o750, parents=True, exist_ok=True) + path.parent.chmod(0o750) + return AtomicStatusSnapshotStore(path, expected_owner_uid=os.getuid()) + + +@pytest.mark.unit +def test_atomic_status_round_trip_is_group_readable_and_read_only(tmp_path: Path) -> None: + path = tmp_path / "runtime" / "status.json" + store = _store(path) + store.write(_snapshot(1)) + before = path.stat() + + assert store.read() == _snapshot(1) + after = path.stat() + assert stat.S_IMODE(after.st_mode) == 0o640 + assert (after.st_ino, after.st_mtime_ns) == (before.st_ino, before.st_mtime_ns) + + +@pytest.mark.unit +def test_status_reader_rejects_writable_symlink_and_invalid_schema( + tmp_path: Path, +) -> None: + path = tmp_path / "status.json" + store = _store(path) + store.write(_snapshot(0)) + path.chmod(0o660) + with pytest.raises(StatusSnapshotUnavailable, match="unavailable"): + store.read() + + target = tmp_path / "target.json" + path.rename(target) + path.symlink_to(target) + with pytest.raises(StatusSnapshotUnavailable, match="unavailable"): + store.read() + + path.unlink() + path.write_text('{"schema_version":2,"snapshot":{}}') + path.chmod(0o640) + with pytest.raises(StatusSnapshotUnavailable, match="unavailable"): + store.read() + + +@pytest.mark.unit +def test_watcher_registers_before_initial_read_and_observes_atomic_replace( + tmp_path: Path, +) -> None: + store = _store(tmp_path / "runtime" / "status.json") + store.write(_snapshot(0)) + stop = Event() + snapshots = StatusSnapshotFileWatcher(store).snapshots(stop) + assert next(snapshots) == _snapshot(0) + + with ThreadPoolExecutor(max_workers=1) as executor: + changed = executor.submit(next, snapshots) + store.write(_snapshot(1)) + assert changed.result(timeout=3) == _snapshot(1) + + stop.set() + snapshots.close() + + +@pytest.mark.unit +def test_watcher_waits_for_first_snapshot_when_status_is_initially_absent( + tmp_path: Path, +) -> None: + store = _store(tmp_path / "runtime" / "status.json") + stop = Event() + snapshots = StatusSnapshotFileWatcher(store).snapshots(stop) + + with ThreadPoolExecutor(max_workers=1) as executor: + created = executor.submit(next, snapshots) + store.write(_snapshot(1)) + assert created.result(timeout=3) == _snapshot(1) + + stop.set() + snapshots.close() diff --git a/tests/TimeLocker/system_control/test_tray_process_boundary.py b/tests/TimeLocker/system_control/test_tray_process_boundary.py index c5bced8..3db8f76 100644 --- a/tests/TimeLocker/system_control/test_tray_process_boundary.py +++ b/tests/TimeLocker/system_control/test_tray_process_boundary.py @@ -243,7 +243,7 @@ def serve(self, _stop_event, *, on_snapshot, on_unavailable) -> None: assert captured.out == "" assert captured.err == "" assert tray.update_status_info.call_count == 1 - client.refresh_status.assert_not_called() + client.refresh_status.assert_called_once_with() @pytest.mark.unit diff --git a/tests/TimeLocker/system_control/test_tray_status_subscription.py b/tests/TimeLocker/system_control/test_tray_status_subscription.py index 41bd0a8..103faee 100644 --- a/tests/TimeLocker/system_control/test_tray_status_subscription.py +++ b/tests/TimeLocker/system_control/test_tray_status_subscription.py @@ -1,25 +1,14 @@ -"""Event-driven tray snapshot refresh tests.""" +"""Daemonless tray status snapshot observation tests.""" from __future__ import annotations from threading import Event from uuid import UUID -from TimeLocker.system_control.client import SystemControlClientError -from TimeLocker.system_control.event_client import StatusEventAccessDenied -from TimeLocker.system_control.models import ( - StatusEvent, - StatusRevision, - StatusSnapshot, -) +from TimeLocker.system_control.models import StatusRevision, StatusSnapshot +from TimeLocker.system_control.status_snapshot import StatusSnapshotUnavailable from TimeLocker.system_control.tray_client import TrayStatusSubscriptionClient -from TimeLocker.system_control.types import ( - BackendStatus, - ProtocolErrorCode, - ResponseStatus, - StatusEventConnectionState, - StatusEventKind, -) +from TimeLocker.system_control.types import BackendStatus SESSION_ONE = UUID("526719f9-4c46-42ac-b286-2623079bc335") @@ -34,178 +23,70 @@ def _snapshot(session_id: UUID, sequence: int) -> StatusSnapshot: ) -class _ControlClient: +class _Watcher: def __init__(self, snapshots: list[StatusSnapshot]) -> None: - self.snapshots = iter(snapshots) - self.calls = 0 - - def get_status_snapshot(self) -> StatusSnapshot: - self.calls += 1 - return next(self.snapshots) - - -class _EventClient: - def __init__(self, events: list[StatusEvent]) -> None: - self._events = events - - def events(self, _stop_event: Event, *, on_connection_state=None): - if on_connection_state is not None: - on_connection_state(StatusEventConnectionState.CONNECTED) - yield from self._events - - -def test_initial_gap_and_backend_restart_each_fetch_a_fresh_snapshot() -> None: - events = [ - StatusEvent( - StatusRevision(SESSION_ONE, 0), - StatusEventKind.SNAPSHOT_REQUIRED, - ), - StatusEvent(StatusRevision(SESSION_ONE, 0), StatusEventKind.CHANGED), - StatusEvent(StatusRevision(SESSION_ONE, 2), StatusEventKind.CHANGED), - StatusEvent(StatusRevision(SESSION_ONE, 1), StatusEventKind.CHANGED), - StatusEvent(StatusRevision(SESSION_ONE, 2), StatusEventKind.HEARTBEAT), - StatusEvent( - StatusRevision(SESSION_TWO, 0), - StatusEventKind.SNAPSHOT_REQUIRED, - ), - ] - control = _ControlClient( - [ - _snapshot(SESSION_ONE, 0), - _snapshot(SESSION_ONE, 2), - _snapshot(SESSION_TWO, 0), - ] - ) + self._snapshots = snapshots + + def snapshots(self, _stop_event: Event): + yield from self._snapshots + + +def test_initial_snapshot_and_direct_changes_are_applied() -> None: applied: list[StatusSnapshot] = [] TrayStatusSubscriptionClient( - control_client=control, - event_client=_EventClient(events), + watcher=_Watcher( + [ + _snapshot(SESSION_ONE, 0), + _snapshot(SESSION_ONE, 1), + _snapshot(SESSION_TWO, 0), + ] + ) ).serve(Event(), on_snapshot=applied.append) - assert control.calls == 3 assert [snapshot.revision for snapshot in applied] == [ StatusRevision(SESSION_ONE, 0), - StatusRevision(SESSION_ONE, 2), + StatusRevision(SESSION_ONE, 1), StatusRevision(SESSION_TWO, 0), ] -def test_older_snapshot_never_regresses_presentation() -> None: - control = _ControlClient( - [ - _snapshot(SESSION_ONE, 2), - _snapshot(SESSION_ONE, 1), - ] - ) +def test_duplicate_and_older_same_session_snapshots_do_not_regress() -> None: applied: list[StatusSnapshot] = [] TrayStatusSubscriptionClient( - control_client=control, - event_client=_EventClient( + watcher=_Watcher( [ - StatusEvent( - StatusRevision(SESSION_ONE, 2), - StatusEventKind.SNAPSHOT_REQUIRED, - ), - StatusEvent( - StatusRevision(SESSION_ONE, 3), - StatusEventKind.CHANGED, - ), + _snapshot(SESSION_ONE, 2), + _snapshot(SESSION_ONE, 2), + _snapshot(SESSION_ONE, 1), ] - ), + ) ).serve(Event(), on_snapshot=applied.append) assert [snapshot.revision.sequence for snapshot in applied] == [2] -def test_denied_subscription_projects_only_safe_unavailable_state() -> None: - class _DeniedClient: - def events(self, _stop_event: Event, *, on_connection_state=None): - if on_connection_state is not None: - on_connection_state(StatusEventConnectionState.DENIED) - raise StatusEventAccessDenied("secret backend detail") +def test_untrusted_or_unavailable_status_file_projects_safe_state_once() -> None: + class _UnavailableWatcher: + def snapshots(self, _stop_event: Event): + raise StatusSnapshotUnavailable("secret path") yield unavailable: list[str] = [] - TrayStatusSubscriptionClient( - control_client=_ControlClient([]), - event_client=_DeniedClient(), - ).serve( + TrayStatusSubscriptionClient(watcher=_UnavailableWatcher()).serve( Event(), on_snapshot=lambda _snapshot: None, on_unavailable=unavailable.append, ) - assert unavailable == ["denied"] - - -def test_event_transport_unavailability_projects_safe_state_once() -> None: - class _UnavailableThenConnectedClient: - def events(self, _stop_event: Event, *, on_connection_state=None): - assert on_connection_state is not None - on_connection_state(StatusEventConnectionState.UNAVAILABLE) - on_connection_state(StatusEventConnectionState.UNAVAILABLE) - on_connection_state(StatusEventConnectionState.CONNECTED) - yield StatusEvent( - StatusRevision(SESSION_ONE, 0), - StatusEventKind.SNAPSHOT_REQUIRED, - ) - - unavailable: list[str] = [] - applied: list[StatusSnapshot] = [] - TrayStatusSubscriptionClient( - control_client=_ControlClient([_snapshot(SESSION_ONE, 0)]), - event_client=_UnavailableThenConnectedClient(), - ).serve( - Event(), - on_snapshot=applied.append, - on_unavailable=unavailable.append, - ) assert unavailable == ["unavailable"] - assert applied == [_snapshot(SESSION_ONE, 0)] -def test_heartbeat_retries_initial_snapshot_only_while_not_current() -> None: - class _RecoveringControl: - def __init__(self) -> None: - self.calls = 0 - - def get_status_snapshot(self) -> StatusSnapshot: - self.calls += 1 - if self.calls == 1: - raise SystemControlClientError( - ProtocolErrorCode.SYSTEM_BACKEND_UNAVAILABLE, - "unavailable", - status=ResponseStatus.UNAVAILABLE, - ) - return _snapshot(SESSION_ONE, 0) - - control = _RecoveringControl() +def test_pre_stopped_subscription_does_not_apply_snapshot() -> None: + stop = Event() + stop.set() applied: list[StatusSnapshot] = [] - unavailable: list[str] = [] TrayStatusSubscriptionClient( - control_client=control, - event_client=_EventClient( - [ - StatusEvent( - StatusRevision(SESSION_ONE, 0), - StatusEventKind.SNAPSHOT_REQUIRED, - ), - StatusEvent( - StatusRevision(SESSION_ONE, 0), - StatusEventKind.HEARTBEAT, - ), - StatusEvent( - StatusRevision(SESSION_ONE, 0), - StatusEventKind.HEARTBEAT, - ), - ] - ), - ).serve( - Event(), - on_snapshot=applied.append, - on_unavailable=unavailable.append, - ) + watcher=_Watcher([_snapshot(SESSION_ONE, 0)]) + ).serve(stop, on_snapshot=applied.append) - assert unavailable == ["unavailable"] - assert control.calls == 2 - assert applied == [_snapshot(SESSION_ONE, 0)] + assert applied == [] From b43acc57a00e854cb8b8d328590316c9cb959ba8 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:10:38 +0100 Subject: [PATCH 67/72] docs(specs): close protected system deployment --- docs/history/spec-archive-index.md | 1 + docs/history/spec-closure-log.md | 19 + .../011-protected-system-deployment/README.md | 47 -- .../canonical-context.md | 85 ---- .../011-protected-system-deployment/design.md | 220 ---------- .../requirements.md | 406 ------------------ .../011-protected-system-deployment/tasks.md | 101 ----- .../traceability.md | 92 ---- .../verification.md | 116 ----- 9 files changed, 20 insertions(+), 1067 deletions(-) delete mode 100644 docs/specs/011-protected-system-deployment/README.md delete mode 100644 docs/specs/011-protected-system-deployment/canonical-context.md delete mode 100644 docs/specs/011-protected-system-deployment/design.md delete mode 100644 docs/specs/011-protected-system-deployment/requirements.md delete mode 100644 docs/specs/011-protected-system-deployment/tasks.md delete mode 100644 docs/specs/011-protected-system-deployment/traceability.md delete mode 100644 docs/specs/011-protected-system-deployment/verification.md diff --git a/docs/history/spec-archive-index.md b/docs/history/spec-archive-index.md index c8f15c3..7a34f7a 100644 --- a/docs/history/spec-archive-index.md +++ b/docs/history/spec-archive-index.md @@ -16,6 +16,7 @@ contracts. | Spec ID | Title | Package path | Status | Final spec commit | Cleanup commit | Closure action | Durable destinations | Verification | |---------|-------|--------------|--------|-------------------|----------------|----------------|----------------------|--------------| +| 011-protected-system-deployment | Protected system deployment requirements | `docs/specs/011-protected-system-deployment/` | removed | b91c0ff7a644aa0d0343b112b17c700bce820952 | pending-cleanup-commit | removed | `CHARTER.md`; `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/guides/user/installation.md`; `docs/processes/version-management.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/reference/timelocker-cli-command-hierarchy.md` | `docs/history/spec-closure-log.md` | | 010-event-driven-tray-status | Event-driven tray status requirements | `docs/specs/010-event-driven-tray-status/` | removed | 8820e65 | 4122746 | removed | `CHARTER.md`; `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/3-implementation/service-layer-integration.md`; `docs/SYSTEM-TRAY-SETUP.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/specs/011-protected-system-deployment/requirements.md` | `docs/history/spec-closure-log.md` | | 009-system-cli-tray-retention | System CLI, independent tray, retention, and control | removed; recover from Git | removed | `d4ce71dd05cb5d7278bf36a9fc43e557d68e1e31` | aba95875f453dd6abf39a1fdc6af25fd38c62db4 | removed | `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/2-architecture/scheduling-system.md`; `docs/3-implementation/service-layer-integration.md`; `docs/guides/user/installation.md`; `docs/guides/developer/scheduling-guide.md`; `docs/SYSTEM-TRAY-SETUP.md`; `docs/reference/timelocker-cli-command-hierarchy.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/processes/version-management.md`; `docs/README.md`; `docs/DOCUMENTATION-STATUS.md`; `docs/specs/README.md` | `docs/history/spec-closure-log.md` | | 007-release-readiness-stabilization | Release readiness stabilization requirements | removed; recover from Git | removed | `7fd11f9aa1cbc670d5e8b429aede4a7c01e185a4` | `6334af0690b5b9e8b6575042269e5b73914a9295` | removed | `README.md`; `CHANGELOG.md`; `.github/workflows/test-suite.yml`; `.github/workflows/artifact-smoke.yml`; `.github/workflows/release-validation.yml`; `.github/workflows/release.yml`; `docs/4-testing/README.md`; `docs/guides/user/installation.md`; `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md`; `docs/processes/version-management.md`; `docs/processes/README.md` | `docs/history/spec-closure-log.md` | diff --git a/docs/history/spec-closure-log.md b/docs/history/spec-closure-log.md index 472baac..9eb8c37 100644 --- a/docs/history/spec-closure-log.md +++ b/docs/history/spec-closure-log.md @@ -15,6 +15,25 @@ final spec commit preserves the complete package. ## Entries +### 2026-08-12 - 011-protected-system-deployment + +- **Spec:** `docs/specs/011-protected-system-deployment/` +- **Title:** Protected system deployment requirements +- **Final spec commit:** `b91c0ff7a644aa0d0343b112b17c700bce820952` +- **Closure cleanup commit:** `pending-cleanup-commit` +- **Closure action:** removed +- **Durable docs updated:** + - `CHARTER.md` + - `docs/1-requirements/system-operations.md` + - `docs/2-architecture/system-architecture.md` + - `docs/guides/user/installation.md` + - `docs/processes/version-management.md` + - `docs/guides/user/backup-operations-troubleshooting.md` + - `docs/reference/timelocker-cli-command-hierarchy.md` +- **Verification summary:** Closure validation not yet executed. +- **Residual risks:** + - none +- **Follow-up:** none ### 2026-08-12 - 010-event-driven-tray-status - **Spec:** `docs/specs/010-event-driven-tray-status/` diff --git a/docs/specs/011-protected-system-deployment/README.md b/docs/specs/011-protected-system-deployment/README.md deleted file mode 100644 index 7b9153b..0000000 --- a/docs/specs/011-protected-system-deployment/README.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -title: Protected system deployment -doc_type: spec -artifact_type: overview -status: active -owner: Auriora Team -last_reviewed: 2026-08-12 ---- - -# Protected System Deployment - -## Purpose - -Replace acceptance-specific deployment commands, operator-authored manifests, -externally managed temporary artifacts, and the continuously resident protected -backend with one supported, repeatable, daemonless workflow for installing, -upgrading, inspecting, rolling back, querying, and invoking bounded protected -TimeLocker operations. - -The package exists because Spec 010 proved the immutable-release architecture -but also demonstrated that its T011 acceptance harness is not a general -administrator deployment interface. - -## Current Stage - -- Requirements and design are approved. -- Daemonless runtime and supported deployment implementation are complete. -- Automated validation, MoE review, promotion, and lifecycle closure are in - progress. -- Protected host mutation and the 90-second live idle observation remain a - separate operational approval boundary. - -## Package - -- [Requirements](./requirements.md) -- [Canonical context](./canonical-context.md) -- [Design](./design.md) -- [Tasks](./tasks.md) -- [Traceability](./traceability.md) -- [Verification](./verification.md) - -## Approval Boundary - -Creating and refining this package does not authorize implementation, -installation, upgrade, rollback, service mutation, release publication, or -backup and retention execution. Protected host changes remain explicitly -approval-gated. diff --git a/docs/specs/011-protected-system-deployment/canonical-context.md b/docs/specs/011-protected-system-deployment/canonical-context.md deleted file mode 100644 index 0e7c948..0000000 --- a/docs/specs/011-protected-system-deployment/canonical-context.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: Protected system deployment canonical context -doc_type: spec -artifact_type: canonical-context -status: active -owner: Auriora Team -last_reviewed: 2026-08-12 ---- - -# Canonical Context - -## Purpose - -This package turns a Spec 010 acceptance harness into the supported -administrator workflow. This map prevents the proposed workflow, temporary -acceptance evidence, or removed spec history from being mistaken for current -installation behavior. - -## Authority Hierarchy - -The package is canonical only for its approved implementation slice while -active. It does not override user or platform instructions, `AGENTS.md`, -`CHARTER.md`, security policy, source and test contracts, generated artifacts, -or live system evidence. - -## Always-Canonical External Sources - -| Source | Authority reason | Handling | -|--------|------------------|----------| -| `AGENTS.md` and `docs/guides/ai-agent/` | Repository workflow and operational instructions | Read before authoring, implementation, validation, or deployment. | -| `CHARTER.md` | Project mandate, boundaries, governance, approval rights, and zero-idle-residency constraint | Reject any design that requires a continuously resident TimeLocker daemon; also stop if work expands into a remote management service or unattended product update policy. | -| Current source, tests, package metadata, and live host evidence | Implementation and runtime truth | Reconcile conflicts; proposed prose does not override current behavior. | -| `docs/1-requirements/system-operations.md` | Accepted protected-operation, administrator, and resource-residency boundary | Extend without weakening authorization, immutable release, fail-closed behavior, or zero idle service residency. | -| `docs/processes/version-management.md` | Accepted release preparation, publication, activation, and rollback separation | Preserve the publication/deployment boundary. | - -## Spec-Canonical Working Sources - -| Source | Role | Scope | Notes | -|--------|------|-------|-------| -| `requirements.md` | Approved observable deployment behavior | Spec 011 | Implemented contract. | -| `design.md` | Deployment architecture and decisions | Spec 011 | Reconciled with the proven Spec 010 transaction. | -| `tasks.md` | Dependency-aware execution index | Spec 011 | Evidence is updated through lifecycle task states. | - -## Imported Sources - -| Spec path | Source path | Source revision or date | Status | Canonical scope | Promotion target | -|-----------|-------------|-------------------------|--------|-----------------|------------------| -| requirements | `docs/guides/user/installation.md` | reviewed 2026-07-27 | supersedes | Statement that no supported protected installer exists | same path | -| requirements | `docs/processes/version-management.md` | current checkout | adapted | Protected activation and rollback invariants | same path | -| requirements | `docs/1-requirements/system-operations.md` | reviewed 2026-07-26 | adapted | Root-only maintenance and immutable release requirements | same path | -| requirements | `scripts/deploy_t011_linux.py` | commit `a67c83ac09ac29b94a3ed481ee536b3380db3337` | background | Proven acceptance transaction and failure lessons | future supported deployment implementation | -| requirements | Spec 010 T011 live evidence | 2026-07-27 to 2026-07-28 | summarized | Successful Linux Mint activation and retained rollback state | verification and operator runbook | -| requirements | Spec 010 T011 idle-resource diagnosis | 2026-07-28 | supersedes resident-runtime acceptance | Read-only JSON access was observed to emit a change and sustain a tray snapshot loop; the deployed unit accumulated more than five CPU-hours in roughly eight hours | daemonless design, regression tests, and live idle acceptance | - -## Non-Canonical Background Sources - -| Source | Reason non-canonical | Handling | -|--------|----------------------|----------| -| Removed Specs 007-009 recovered from Git | Closed delivery scaffolding | Use only for historical rationale; durable promoted documents own current behavior. | -| `/tmp/timelocker-*` scripts and artifacts from acceptance work | Ephemeral, unversioned, or build-local evidence | Do not use as a supported deployment interface or durable procedure. | -| `scripts/deploy_t011_linux.py` after Spec 010 closure | Acceptance-specific name and contract | Deprecated compatibility wrapper; `timelocker-deploy` is authoritative. | - -## Promotion Map - -| Spec-local content | Durable destination or route | Required before closure | -|--------------------|------------------------------|-------------------------| -| Supported install, upgrade, status, and rollback behavior | `docs/1-requirements/system-operations.md` | yes | -| Zero-idle project boundary | `CHARTER.md` | yes | -| Zero-idle operational requirement | `docs/1-requirements/system-operations.md` | yes | -| Short-lived protected-execution architecture | `docs/2-architecture/system-architecture.md` | yes | -| Deployment components, trust boundaries, and platform adapters | `docs/2-architecture/system-architecture.md` | yes | -| Administrator installation procedure | `docs/guides/user/installation.md` | yes | -| Administrator troubleshooting procedure | `docs/guides/user/backup-operations-troubleshooting.md` | yes | -| Release artifact and host activation relationship | `docs/processes/version-management.md` | yes | -| Administrator command reference | `docs/reference/timelocker-cli-command-hierarchy.md` | yes | -| Live Windows implementation routing | `docs/2-architecture/system-architecture.md` | yes | - -## Related Artifacts - -- Requirements: [requirements.md](./requirements.md) -- Overview: [README.md](./README.md) -- Design: [design.md](./design.md) -- Tasks: [tasks.md](./tasks.md) -- Traceability: [traceability.md](./traceability.md) -- Verification: [verification.md](./verification.md) diff --git a/docs/specs/011-protected-system-deployment/design.md b/docs/specs/011-protected-system-deployment/design.md deleted file mode 100644 index d600029..0000000 --- a/docs/specs/011-protected-system-deployment/design.md +++ /dev/null @@ -1,220 +0,0 @@ ---- -title: Daemonless protected system deployment design -doc_type: spec -artifact_type: design -status: approved -owner: Auriora Team -last_reviewed: 2026-08-12 ---- - -# Technical Design - -## Overview - -Spec 011 replaces the resident root backend with a systemd socket-activated, -single-request helper. The socket remains a kernel-owned authorization entry -point; each TimeLocker process accepts one connection, derives peer identity, -serves one allowlisted request, atomically publishes a sanitized status -snapshot when appropriate, and exits. Scheduled backup and retention remain -one-shot units. - -The privileged status-event socket, heartbeat broker, resident filesystem -observer, and resident schedule monitor are removed from the installed asset -set. The optional user tray reads a group-authorized sanitized snapshot and -watches that file directly. Explicit tray actions still use the protected -single-request socket. - -The supported administrator entrypoint is `timelocker-deploy`. It owns local -wheel validation, trusted staging, manifest derivation, transactional install, -status, and rollback. It reuses the immutable-release resolver and deployment -primitives proven by Spec 010, while replacing acceptance-only inputs and -resident-service health gates. - -## Requirement Coverage - -| Requirement | Acceptance Criteria | Design Coverage | Validation Approach | -|-------------|---------------------|-----------------|---------------------| -| Requirement 1 | AC1-AC5 | `timelocker-deploy` install, upgrade, status, rollback command | CLI and installed-artifact tests | -| Requirement 2 | AC1-AC5 | Wheel metadata/digest validation and derived manifests | Tamper and mismatch tests | -| Requirement 3 | AC1-AC6 | Root-owned staging snapshot and bounded cleanup | Symlink, mode, ownership, and source-swap tests | -| Requirement 4 | AC1-AC5 | Preflight transaction and expected-current selection | Failure-injection transaction tests | -| Requirement 5 | AC1-AC5 | Probed rollback with state preservation | Rollback and preservation tests | -| Requirement 6 | AC1-AC5 | Deployment lock, idempotency, attention record | Concurrency and interruption tests | -| Requirement 7 | AC1-AC5 | Typed redacted results and root-owned evidence | Schema, permission, and redaction tests | -| Requirement 8 | AC1-AC5 | Platform-neutral engine with Linux adapter | Interface and unsupported-platform tests | -| Requirement 9 | AC1-AC7 | Single-request helper and direct snapshot watcher | Process-exit, asset, tray, and 90-second live checks | - -## Correctness Property Coverage - -| Property | Design Behavior | Validation Direction | Notes | -|----------|-----------------|----------------------|-------| -| CP-001 | Validation and preflight precede the mutation boundary | Failure injection at every preflight gate | No host writes before boundary | -| CP-002 | Selector uses locked expected-current compare-and-swap | Competing selector tests | Existing resolver retained | -| CP-003 | Post-boundary failure restores state or writes attention | Forced failure and signal tests | Mutation remains fail closed | -| CP-004 | Staged digest is rechecked before installation | Mutable-source and digest tests | Source is never reread | -| CP-005 | Deployment dispatcher has no backup/retention execution route | Action-spy tests | Timer state may be inspected only | -| CP-006 | Evidence schema admits only allowlisted non-secret fields | Redaction and exact-schema tests | No raw environment or subprocess output | -| CP-007 | Systemd and filesystem operations live behind Linux adapters | Interface and fake-adapter tests | Windows remains contractual | -| CP-008 | Helper serves one request and exits; no event service is installed | Unit, package, and live process checks | Socket unit is not a process | - -## High-Level Design - -### System Architecture - -```text -CLI or tray action -> systemd control socket -> one root helper -> response -> exit - | -scheduled one-shot worker --------------------------+ - v - atomic sanitized status snapshot - | -optional user tray -> initial read + filesystem watch (no privileged event service) - -administrator -> timelocker-deploy -> trusted staging -> preflight -> atomic activation -``` - -### Components and Changes - -- `backend_entry.py` and `linux_adapter.py`: add single-request serving and - remove resident monitors from the production path. -- `status_snapshot.py`: own exact atomic sanitized snapshot persistence and - authorized reads. -- `tray_client.py` and `tray_entry.py`: replace privileged event subscription - with direct snapshot-file observation; retain socket use for explicit actions. -- packaged systemd assets: remove the status-event socket and make the control - service exit after one request. -- `deployment.py` and new deployment entrypoint: derive trusted release inputs, - stage privately, transact, report status, and roll back. -- deployment and artifact tests: require the daemonless asset set and reject - resident event/service dependencies. - -### Data Models - -`SanitizedStatusFile` uses the existing strict `StatusSnapshot` wire model plus -an outer file schema version. It contains no repository URI, credential, -environment, journal, command, or arbitrary path fields. Writes use a temporary -file in the destination directory, `fsync`, mode `0640`, and atomic replace. - -`DeploymentResult` contains operation, stable result code, selected/previous -release IDs, mutation-started and recovery fields, and evidence location. -`DeploymentEvidence` contains only validated digest, package/release identity, -stage outcomes, timestamps, and rollback disposition. - -### Data Flow - -1. A client connects to `/run/timelocker/control.sock`. -2. systemd launches the selected helper only if no instance is active. -3. The helper accepts one bounded frame and derives the kernel peer identity. -4. The dispatcher rechecks group membership and executes one allowlisted action. -5. Status-producing paths atomically refresh the sanitized snapshot. -6. The helper sends one bounded response and exits. -7. The tray reads the current snapshot and receives direct filesystem changes. - -## Low-Level Design - -### Algorithms and Logic - -```text -serve_one_request: - adopt systemd control socket - accept one connection - derive peer identity and dispatch one bounded request - send one bounded response - close listener and exit - -deploy_local_wheel: - require root and acquire deployment lock - validate source filename, metadata, assets, and digest - copy once into private root-owned staging; revalidate digest - derive release and asset manifests - run candidate and authorization preflights - mark mutation boundary - install assets and compare-and-swap selector - verify one-shot helper, timers, and installed entrypoints - write redacted evidence and result - on failure after boundary, restore and verify or write attention -``` - -### Function Signatures and Interfaces - -```text -LinuxUnixSocketTransport.serve_once(handler) -> None -AtomicStatusSnapshotStore.read() -> StatusSnapshot -AtomicStatusSnapshotStore.write(snapshot) -> None -StatusSnapshotWatcher.events(stop_event) -> Iterator[StatusSnapshot] -DeploymentEntrypoint.install(artifact) -> DeploymentResult -DeploymentEntrypoint.upgrade(artifact) -> DeploymentResult -DeploymentEntrypoint.status() -> DeploymentResult -DeploymentEntrypoint.rollback() -> DeploymentResult -``` - -### Error Handling - -Malformed requests, identity failures, unavailable snapshots, and invalid -deployment inputs use stable bounded errors. No error includes raw paths beyond -documented evidence locations, environment values, repository identifiers, or -subprocess output. Pre-boundary deployment errors mutate nothing. Post-boundary -errors recover or create an attention record that blocks later mutation. - -### Security, Trust, and Access - -Kernel peer credentials and current operator-group membership remain the -authorization source. The sanitized snapshot directory is root-owned and -operator-group readable, never writable by the tray. Deployment requires root, -rejects symlinks/untrusted writable inputs, snapshots mutable input once, and -does not read caller configuration or credentials. No shell command strings are -constructed from untrusted values. - -### Migration and Compatibility - -Upgrade installs the new service and control socket, stops and disables the -legacy status-event socket, and removes its socket path. Existing protected -configuration, run records, timers, selected/previous releases, and public -status semantics are retained. Old releases remain rollback candidates only if -their activation does not re-enable a rejected resident backend; otherwise the -entrypoint fails with an explicit incompatible-rollback result. - -### Slice Boundary And Residual Architecture - -| Design target | In this slice | Out of this slice | Follow-up destination | Blocks closure? | -|---------------|---------------|-------------------|-----------------------|-----------------| -| Daemonless Linux protected runtime | Single-request socket helper, snapshot, tray watch, assets | none | none | yes | -| Supported local-wheel deployment | install, upgrade, status, rollback | network release acquisition | future spec if requested | no | -| Windows portability | interfaces and fail-closed unsupported result | live Windows implementation/acceptance | future Windows spec | no | -| Release publication | deployment consumes a local artifact | PyPI/GitHub publication | existing release process | no | - -## Validation Strategy - -| Validation | Covers | Evidence Location | Residual Risk | -|------------|--------|-------------------|---------------| -| Focused system-control and deployment tests | Requirements 1-9, CP-001-CP-008 | tasks and `verification.md` | systemd integration remains host-specific | -| Package asset and installed-wheel smoke | Entrypoints and daemonless asset set | `verification.md` | distro packaging differences | -| TimeLocker MoE review | architecture, backup safety, security, tests, operations, docs | T review task | bounded review limitations | -| Linux live acceptance with 90-second idle observation | Requirement 9 and operational migration | protected evidence | requires separate host-mutation approval | - -## Downstream Task Guidance - -- Implement daemonless runtime and tests before deployment consolidation. -- Cover every CP property in task and traceability artifacts. -- Create change impact, tasks, traceability, and verification before code edits. -- Repeat expert review after implementation and before promotion. - -## Operational Considerations - -The kernel may retain an enabled socket while no TimeLocker process exists. -That is compliant zero process/CPU residency. The optional tray is an explicitly -chosen user process, not privileged, and must tolerate a missing or stale -snapshot. Migration must stop the existing daemon and event socket without -triggering backup or retention. - -## Open Questions - -None blocking. The approved initial artifact source is a local wheel; remote -release acquisition is explicitly outside this implementation slice. - -## Related Artifacts - -- Requirements: [requirements.md](./requirements.md) -- Canonical context: [canonical-context.md](./canonical-context.md) -- Tasks: [tasks.md](./tasks.md) -- Verification: [verification.md](./verification.md) diff --git a/docs/specs/011-protected-system-deployment/requirements.md b/docs/specs/011-protected-system-deployment/requirements.md deleted file mode 100644 index 1e6f1e5..0000000 --- a/docs/specs/011-protected-system-deployment/requirements.md +++ /dev/null @@ -1,406 +0,0 @@ ---- -title: Protected system deployment requirements -doc_type: spec -artifact_type: requirements -status: implemented -owner: Auriora Team -last_reviewed: 2026-08-12 ---- - -# Requirements - -## Introduction - -TimeLocker has validated primitives for immutable releases, packaged system -assets, compatibility probes, atomic selection, and rollback. It does not have -one supported administrator workflow that prepares an artifact and manifest, -stages trusted inputs, performs the transaction, reports evidence, and offers a -repeatable rollback. - -Spec 010 therefore used a repository-owned T011 acceptance harness plus -manually supplied commit IDs, hashes, manifests, and temporary artifact paths. -That harness successfully activated the accepted Linux Mint release, but it is -not an appropriate long-term installation or upgrade interface. Live operation -also showed that its continuously resident privileged backend can enter a -read-notify-read feedback loop and consume CPU while no backup or retention -operation is running. A resident TimeLocker daemon is therefore rejected as an -architectural requirement, not merely scheduled for performance tuning. - -## Goals - -- Provide one supported administrator entrypoint for protected installation, - upgrade, inspection, and rollback. -- Derive and verify artifact identity, release metadata, hashes, and staging - paths without requiring operators to assemble them manually. -- Preserve the proven preflight-first, fail-closed, rollback-safe transaction. -- Use trusted, root-owned staging and evidence locations with bounded cleanup. -- Keep release publication, protected host deployment, and backup or retention - execution as distinct approval boundaries. -- Preserve a portable deployment model while delivering and accepting Linux - systemd behavior first. -- Replace the resident privileged backend with bounded one-shot helpers and - sanitized atomically written status state. - -## Non-Goals - -- Publishing TimeLocker to PyPI or automatically creating a GitHub release. -- An unattended update daemon, silent automatic upgrades, or remote fleet - management. -- A continuously resident TimeLocker-owned privileged control daemon, event - broker, heartbeat process, or status service. -- Changing backup, restore, selection-set, retention-policy, or repository - credential semantics. -- Triggering backup or retention as a side effect of deployment. -- A general-purpose package manager or replacement for operating-system - packaging. -- Removing protected configuration, credentials, schedules, or durable run - records during rollback. -- Claiming a live Windows deployment before its platform implementation and - acceptance are separately evidenced. - -## Glossary - -| Term | Definition | -|------|------------| -| Deployment entrypoint | The supported administrator command or executable that owns protected install, upgrade, status, and rollback orchestration. | -| Release artifact | A validated TimeLocker wheel or an approved published release containing the wheel and its integrity metadata. | -| Deployment transaction | The bounded sequence of input validation, private staging, candidate installation, preflight, activation, verification, evidence capture, and recovery. | -| Staging root | A trusted root-owned location used internally by the deployment entrypoint; it is not an operator-authored temporary script or manifest location. | -| Selected release | The immutable release referenced by `/opt/timelocker/selected-release.json` and resolved by stable system launchers. | -| Deployment evidence | Redacted, root-owned records sufficient to determine inputs, gates, outcome, rollback state, and residual action without exposing credentials. | - -## Durable Source Baseline - -| Source | Current behavior relied on | Confidence | Notes | -|--------|----------------------------|------------|-------| -| `CHARTER.md` | TimeLocker is CLI-first and prioritizes dependable backup and recovery operation. | high | Governing mandate. | -| `docs/1-requirements/system-operations.md` | Installation, upgrade, service changes, activation, and rollback are root-only; releases are immutable and fail closed. | high | Extend with supported deployment UX. | -| `docs/2-architecture/system-architecture.md` | Stable launchers resolve one protected selected release independently of user environment. | high | Preserve trust boundary. | -| `docs/guides/user/installation.md` | The repository currently exposes primitives but no general protected installer command. | high | This spec closes that documented gap. | -| `docs/processes/version-management.md` | Publication and protected host activation are separate, approval-gated transactions. | high | Do not collapse the boundaries. | -| `src/TimeLocker/system_control/deployment.py` and release launcher modules | Asset validation, compatibility probes, immutable selection, and rollback primitives exist. | high | Design should reuse or consolidate these contracts. | -| `scripts/deploy_t011_linux.py` and its tests | Spec 010 proved a preflight-first transaction and exposed risks from temporary scripts, renamed wheels, and manual inputs. | high | Acceptance harness is input, not the final public interface. | -| Linux Mint live deployment of commit `a67c83ac09ac29b94a3ed481ee536b3380db3337` | Candidate selection, previous-release preservation, services, sockets, timers, CLI, and tray status succeeded. | high | Runtime evidence from 2026-07-28. | - -## Durable Impact - -| Durable area | Action | Target | Notes | -|--------------|--------|--------|-------| -| requirements | modify | `docs/1-requirements/system-operations.md` | Add supported deployment lifecycle and evidence requirements. | -| architecture | modify | `docs/2-architecture/system-architecture.md` | Add deployment entrypoint, staging, transaction, and platform boundary. | -| process | modify | `docs/processes/version-management.md` | Define artifact-to-host activation procedure. | -| runbook | add or modify | `docs/guides/user/installation.md` and deployment runbook | Replace manual assembly with supported commands. | -| command reference | modify | `docs/reference/timelocker-cli-command-hierarchy.md` | Document administrator-only deployment surface. | -| testing | clarify | `docs/4-testing/` if reusable deployment validation is added | Separate simulated, installed-artifact, and live acceptance evidence. | - -## Staged Readiness - -- **Current stage:** requirements -- **Next stage:** design -- **Ready to design when:** requirements and correctness properties are - reviewed, the Spec 010 dependency is explicit, and design owners agree which - artifact sources and administrator command surface must be evaluated. -- **Design-first exception:** no -- **Optional artifacts recommended:** `change-impact.md`, `traceability.md`, - `verification.md`; add `open-decisions.md` only if command or artifact-source - decisions remain blocking after design exploration. -- **Downstream review needed:** design, tasks, traceability, verification - -## Requirements - -### Requirement 1: One Supported Administrator Entrypoint - -**User Story:** As a system administrator, I want one documented deployment -entrypoint, so that installation and upgrades do not depend on generated -one-off scripts or manually reconstructed commands. - -**Priority:** must-have - -#### Acceptance Criteria - -1. THE SYSTEM SHALL provide one supported administrator entrypoint for - protected install, upgrade, deployment status, and rollback operations. -2. WHEN the entrypoint requires root authority, THEN it SHALL either run under - an explicit elevation mechanism or return one actionable elevation - instruction without falling back to user-local state. -3. THE ENTRYPOINT SHALL expose stable help, exit status, and machine-readable - result contracts for automation and troubleshooting. -4. THE SUPPORTED PROCEDURE SHALL NOT require an operator to create or edit a - deployment Python or shell script. -5. WHERE an acceptance-specific compatibility wrapper remains, THE - DOCUMENTATION SHALL identify the supported entrypoint as authoritative and - the wrapper as internal or deprecated. - -### Requirement 2: Artifact Identity And Provenance - -**User Story:** As a release maintainer, I want deployment inputs bound to an -approved release identity, so that the host cannot activate an ambiguous or -substituted artifact. - -**Priority:** must-have - -#### Acceptance Criteria - -1. GIVEN a local release artifact, WHEN deployment is requested, THEN the - entrypoint SHALL validate its wheel filename, package metadata, package - version, SHA-256 digest, and required protected assets before host mutation. -2. GIVEN a published release reference, WHEN it is supported by the chosen - design, THEN the entrypoint SHALL verify the approved release identity and - integrity metadata before staging. -3. THE ENTRYPOINT SHALL derive the release manifest from validated inputs and - SHALL NOT require the operator to hand-author the manifest or digest. -4. IF the artifact, release identity, package version, manifest, protocol - versions, or digest disagree, THEN deployment SHALL fail before candidate - installation or protected host mutation. -5. THE DEPLOYMENT EVIDENCE SHALL identify the non-secret artifact provenance, - digest, release identity, and invoking workflow. - -### Requirement 3: Trusted Staging And Cleanup - -**User Story:** As a security-conscious administrator, I want deployment inputs -copied into trusted staging, so that world-writable paths and cleanup races -cannot change what is installed. - -**Priority:** must-have - -#### Acceptance Criteria - -1. BEFORE installing a candidate, THE ENTRYPOINT SHALL copy exact validated - inputs into a private, root-owned staging or evidence boundary and recheck - their identity after copying. -2. THE SUPPORTED OPERATOR PROCEDURE SHALL NOT depend on persistent artifacts, - manifests, or scripts under `/tmp`. -3. IF an external source path is used as input, THEN the deployment transaction - SHALL snapshot it before relying on its contents and SHALL not reread the - mutable source after snapshot validation. -4. THE ENTRYPOINT SHALL preserve valid artifact filenames required by the - package installer. -5. WHEN a transaction finishes or fails, THEN bounded temporary staging SHALL - be removed or retained according to an explicit evidence policy without - deleting the selected or previous immutable release. -6. IF a staging path is a symlink, unexpectedly writable, outside its allowed - root, or has untrusted ownership, THEN deployment SHALL fail closed. - -### Requirement 4: Preflight-First Transactional Activation - -**User Story:** As an operator, I want compatibility and authorization checked -before activation, so that a bad candidate cannot interrupt scheduled -protection. - -**Priority:** must-have - -#### Acceptance Criteria - -1. BEFORE changing a service unit, stable launcher, or selected release, THE - ENTRYPOINT SHALL verify the staged CLI, backend, tray, packaged assets, - control protocol, daemonless manifest schema, authorized access, denied - access, and required timer health. -2. WHEN selecting a release, THE ENTRYPOINT SHALL use a locked - expected-current compare-and-swap operation. -3. IF the selected release changes after the transaction begins, THEN the - entrypoint SHALL reject activation rather than overwrite the newer state. -4. THE TRANSACTION SHALL define one mutation boundary after which every - exception, interruption, termination signal, or failed verification invokes - recovery. -5. DEPLOYMENT SHALL NOT trigger backup or retention and SHALL preserve active - and enabled backup and retention scheduling. - -### Requirement 5: Verified Rollback And State Preservation - -**User Story:** As an administrator, I want a repeatable rollback command, so -that I can recover the prior working release without reconstructing an old -deployment script. - -**Priority:** must-have - -#### Acceptance Criteria - -1. GIVEN a compatible previous release, WHEN rollback is requested, THEN the - supported entrypoint SHALL probe it before atomically exchanging selected - and previous release identities. -2. IF activation fails after mutation begins, THEN recovery SHALL restore the - prior selector and required service state and SHALL verify control-channel - and timer health. -3. ROLLBACK SHALL preserve protected configuration, credential references, - schedules, retention enablement, and durable run records. -4. IF no compatible previous release exists, THEN rollback SHALL fail with an - actionable result and SHALL NOT modify the selected release. -5. A SUCCESSFUL install, upgrade, or rollback result SHALL report the selected - and previous release identities and the evidence location. - -### Requirement 6: Idempotency, Concurrency, And Recovery - -**User Story:** As an administrator, I want deployment retries to be safe, so -that interruption or repeated invocation does not corrupt release state. - -**Priority:** must-have - -#### Acceptance Criteria - -1. WHILE another deployment transaction holds the deployment lock, A SECOND - MUTATING REQUEST SHALL fail without changing host state. -2. GIVEN the same already-selected release and identical verified inputs, WHEN - deployment is repeated, THEN the entrypoint SHALL return an idempotent - outcome or perform a no-op verification rather than create ambiguous state. -3. WHEN a stale inert candidate from an interrupted pre-mutation attempt is - found, THEN the entrypoint SHALL either prove and resume it or remove it - safely before proceeding. -4. WHEN prior transaction evidence indicates incomplete post-mutation - recovery, THEN status SHALL report an attention state and mutating commands - SHALL fail until the state is reconciled. -5. INTERRUPTION handling SHALL be bounded and SHALL never report success - before post-activation verification completes. - -### Requirement 7: Redacted Evidence And Operator Diagnostics - -**User Story:** As an administrator, I want concise deployment evidence and -diagnostics, so that I can understand failures without exposing credentials or -reading implementation-specific scratch files. - -**Priority:** must-have - -#### Acceptance Criteria - -1. EVERY mutating transaction SHALL create root-owned evidence containing - bounded command outcomes, gate results, release identities, timestamps, and - rollback disposition. -2. THE EVIDENCE AND USER-FACING OUTPUT SHALL NOT contain repository passwords, - cloud credentials, environment-file contents, raw secret arguments, or - credential-bearing URLs. -3. WHEN a gate fails, THEN output SHALL identify the failed stage, state - whether protected mutation began, and provide the evidence location and safe - next action. -4. THE STATUS OPERATION SHALL report selected and previous releases, transaction - attention state, one-shot helper readiness, and backup/retention timer health - without leaving a TimeLocker service process resident. -5. THE ENTRYPOINT SHALL distinguish warnings, failed validation, failed - activation with successful recovery, and failed recovery through stable - result codes. - -### Requirement 8: Portable Deployment Boundary - -**User Story:** As a maintainer, I want platform-neutral deployment contracts, -so that Linux delivery does not embed systemd assumptions into future Windows -support. - -**Priority:** should-have - -#### Acceptance Criteria - -1. THE ARTIFACT, manifest, transaction state, evidence, activation, status, and - rollback contracts SHALL be platform-neutral. -2. Linux SHALL implement root-owned paths, stable launchers, peer-authorized - short-lived helpers or one-shot services, and systemd unit/timer verification - through a Linux adapter. -3. Windows-specific service control, named-pipe authorization, installation - paths, and elevation SHALL remain behind injectable platform contracts. -4. THIS PACKAGE SHALL NOT claim live Windows deployment until install, upgrade, - rollback, authorization, interruption, and recovery are accepted on a - Windows host. -5. WHERE a platform operation is unsupported, THE ENTRYPOINT SHALL fail - explicitly without partial installation. - -### Requirement 9: Zero Idle Service Residency - -**User Story:** As an operator, I want TimeLocker to consume no service CPU or -resident memory while idle, so that backup tooling does not waste host -resources or create daemon-specific failure modes. - -**Priority:** must-have - -#### Acceptance Criteria - -1. WHILE no backup, retention, restore, explicit query, or explicit control - action is running, THE SYSTEM SHALL have no TimeLocker-owned privileged - process resident. -2. Scheduled backup and retention SHALL execute as bounded one-shot jobs and - SHALL NOT depend on a continuously resident TimeLocker scheduler or control - service. -3. Protected queries and manual actions SHALL activate a short-lived - authenticated helper that exits after one bounded request or operation. -4. Protected workers SHALL atomically publish a sanitized status snapshot that - an authorized unprivileged tray can read without invoking a privileged - status service. -5. The optional tray SHALL observe status-file changes directly and SHALL NOT - require a privileged event socket, heartbeat, or resident event broker. -6. Reading status or run records SHALL NOT itself publish a change event or - cause an unbounded read-notify-read cycle. -7. Automated and live acceptance SHALL prove zero TimeLocker privileged - processes and zero TimeLocker service CPU consumption during an idle - observation interval of at least 90 seconds. - -## Correctness Properties - -- **CP-001:** No protected selector, service, launcher, or timer mutation occurs - before artifact identity and all pre-mutation gates pass. -- **CP-002:** A selector changes only from the locked expected-current release - to the exact compatible candidate, or through a verified selected/previous - rollback exchange. -- **CP-003:** Any failure or interruption after the mutation boundary either - restores the prior selected release and required service/timer health or - leaves a durable attention state that blocks further mutation. -- **CP-004:** Artifact bytes installed into the immutable release are identical - to the bytes whose digest and metadata were recorded after private staging. -- **CP-005:** Deployment, upgrade, status, and rollback never execute backup, - retention, restore, or repository-pruning operations. -- **CP-006:** Secret categories remain absent from command output, evidence, - manifests, and transaction records for every success and failure path. -- **CP-007:** Platform-specific paths, service management, identity, and - elevation are reachable only through the selected platform adapter. -- **CP-008:** When the set of active protected operations is empty, the set of - resident TimeLocker-owned privileged processes is also empty. - -## Technical Context - -- **Language/Version:** Python 3.12 and 3.13 -- **Primary Dependencies:** Python packaging, existing immutable-release and - system-control contracts, operating-system service manager -- **Target Platform:** Linux Mint/systemd acceptance first; portable Windows - contract retained -- **Constraints:** root-only mutation; offline/local artifact support; no - credential disclosure; no caller pyenv, home, checkout, or working-directory - dependency after installation; no resident TimeLocker daemon -- **Performance Goals:** local validation and status should complete promptly; - network artifact acquisition, when supported, must have explicit timeouts; - idle privileged CPU and resident memory are both zero - -## Success Criteria - -- **SC-001:** A documented administrator can install or upgrade a clean Linux - host using one supported entrypoint without manually creating a manifest, - digest, or temporary script. -- **SC-002:** The supported entrypoint deploys a validated wheel, reports the - exact selected and previous releases, and passes installed CLI, backend, - tray, socket, service, and timer checks. -- **SC-003:** Forced failures at every transaction stage demonstrate no - pre-boundary mutation and verified post-boundary recovery or attention state. -- **SC-004:** An approved rollback restores the previous compatible release - while protected configuration, schedules, run records, backup, and retention - remain intact. -- **SC-005:** Repeated and concurrent deployment attempts satisfy idempotency - and lock behavior under automated tests and Linux live acceptance. -- **SC-006:** Durable installation, release-management, command-reference, and - troubleshooting documentation contains no `/tmp`-based operator workflow. -- **SC-007:** Linux live acceptance is recorded; Windows support remains - explicitly contractual until separately accepted. -- **SC-008:** Linux live acceptance shows no TimeLocker-owned privileged - process during at least 90 seconds with no protected operation running. - -## Resolved Design Decisions - -- The supported command is the installed standalone `timelocker-deploy` - entrypoint with install, upgrade, status, and rollback verbs. -- The accepted initial artifact source is one local wheel. -- Private inputs and retained evidence live below the protected deployment - evidence root; operator workflows do not use `/tmp`. -- Install and upgrade share one transaction engine and expose separate verbs. -- Linux/systemd is implemented; Windows remains an explicit future platform - implementation and acceptance boundary. - -## Related Artifacts - -- Overview: [README.md](./README.md) -- Canonical Context: [canonical-context.md](./canonical-context.md) -- Design: [design.md](./design.md) -- Tasks: [tasks.md](./tasks.md) -- Verification: [verification.md](./verification.md) diff --git a/docs/specs/011-protected-system-deployment/tasks.md b/docs/specs/011-protected-system-deployment/tasks.md deleted file mode 100644 index bc79487..0000000 --- a/docs/specs/011-protected-system-deployment/tasks.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -title: Protected system deployment tasks -doc_type: spec -artifact_type: tasks -status: active -owner: Auriora Team -last_reviewed: 2026-08-12 ---- - -# Tasks - -**Input:** All artifacts in `docs/specs/011-protected-system-deployment/` - -## Dependency Graph - -`T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007` - -## Phase 1: Daemonless Runtime - -- [x] T001 Make the protected Linux helper serve one request and exit. - - Depends on: none - - Requirements: Requirement 4, Requirement 8, Requirement 9 - - Properties: CP-005, CP-007, CP-008 - - Files: backend entry, Linux transport, systemd assets, focused tests - - Acceptance: Production socket activation handles one authorized request, - closes, and exits; no event socket, heartbeat, watcher, or resident monitor - is required by installed assets. - - Evidence: `src/TimeLocker/system_control/backend_entry.py`, `linux_adapter.py`, and `assets/timelocker-control.service` use one-request `serve_once`; `python3 -m pytest` focused run passed 295 tests, including `test_run_linux_backend_serves_one_request_and_exits` and unit asset assertions. - -- [x] T002 Publish and consume an atomic sanitized status snapshot. - - Depends on: T001 - - Requirements: Requirement 2, Requirement 7, Requirement 9 - - Properties: CP-004, CP-006, CP-008 - - Files: snapshot store/watcher, backend worker hooks, tray client/entry, tests - - Acceptance: Root-owned workers write exact group-readable status state; - reads do not publish changes; the tray performs an initial read and direct - filesystem observation without a privileged event channel. - - Evidence: `status_snapshot.py`, `tray_client.py`, and `tray_entry.py` provide atomic 0640 publication, fd-safe reads, startup refresh, and direct filesystem watching; the 295-test focused pytest run includes `test_status_snapshot.py` and tray subscription tests. - -- [x] T003 Replace resident deployment assets and migration gates. - - Depends on: T002 - - Requirements: Requirement 4, Requirement 5, Requirement 9 - - Properties: CP-003, CP-005, CP-008 - - Files: packaged units, asset manifest, activation/rollback checks, tests - - Acceptance: The status-event socket is absent, service startup is - socket-only and non-resident, legacy units are stopped/disabled during - activation, timers and protected state are preserved. - - Evidence: Release schema 3 and packaged asset tests assert `timelocker-status-events.socket` is absent, legacy units are disabled, only the control socket is enabled, timers are preserved, and `RuntimeDirectoryPreserve=yes`; focused pytest passed 295 tests. - -## Phase 2: Supported Deployment Workflow - -- [x] T004 Add the supported local-wheel administrator deployment entrypoint. - - Depends on: T003 - - Requirements: Requirement 1-Requirement 8 - - Properties: CP-001-CP-007 - - Files: deployment engine/entrypoint, packaging metadata, tests - - Acceptance: Install/upgrade/status/rollback expose stable JSON results; - validate artifact identity, privately stage once, lock mutation, derive - manifests, preserve rollback state, and produce redacted evidence. - - Evidence: `deployment_entry.py`, `timelocker-deploy-launcher`, and the `timelocker-deploy` project entry point cover offline local-wheel install, upgrade, status, and rollback; focused pytest passed 295 tests and artifact smoke executed the installed wheel. - -- [x] T005 Run focused, package, full regression, and Linux-safe acceptance. - - Depends on: T004 - - Requirements: Requirement 1-Requirement 9 - - Properties: CP-001-CP-008 - - Acceptance: Focused tests, full configured regression, Ruff, compile, - package validation, installed-artifact smoke, lifecycle checks, and a - non-mutating process-residency probe pass. Protected deployment and the - 90-second live host interval require their separate operational approval. - - Evidence: `git diff --check`, scoped Ruff, compileall, 295 focused pytest tests, wheel/sdist validation of 28 package-data files, and installed-wheel smoke on Python 3.12.6 passed. Full pytest recorded 3166 passed, 1 skipped, and one unrelated repository-resolver timing failure. - -## Phase 3: Review And Closure - -- [x] T006 Run the TimeLocker MoE review and address findings. - - Depends on: T005 - - Requirements: Requirement 1-Requirement 9 - - Acceptance: All seven expert roles are applied; actionable findings are - fixed, rejected with evidence, or routed once. - - Evidence: The seven-role review table in `verification.md` records each conclusion and disposition. Remediation is directly covered by `test_t011_linux_deployment.py`, `test_status_snapshot.py`, backend, tray, release-artifact, and deployment tests in the 295-test passing run. - -- [x] T007 Promote durable documentation and close Spec 011. - - Depends on: T006 - - Requirements: Requirement 1-Requirement 9 - - Acceptance: Accepted behavior is promoted, residual Windows/live-host work - is explicitly routed, lifecycle closure passes, the complete final package - is committed, and cleanup metadata is resolved. - - Evidence: Promotion changes are present in `docs/1-requirements/system-operations.md`, architecture, service integration, installation, troubleshooting, tray, version management, and command reference. `lint_spec_package` and `task_state_audit` report zero errors and zero warnings; separate Windows and protected-host acceptance are recorded in `verification.md`. - -## Execution Rules - -- Do not mutate the protected host, run backup/retention, publish a release, or - activate a candidate without the separate operational approval. -- Preserve protected configuration, credentials, timers, and run records. -- Record executed checks and review disposition under the owning task. - -## Related Artifacts - -- Requirements: [requirements.md](./requirements.md) -- Design: [design.md](./design.md) -- Traceability: [traceability.md](./traceability.md) -- Verification: [verification.md](./verification.md) diff --git a/docs/specs/011-protected-system-deployment/traceability.md b/docs/specs/011-protected-system-deployment/traceability.md deleted file mode 100644 index acb9ec1..0000000 --- a/docs/specs/011-protected-system-deployment/traceability.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: Protected system deployment traceability -doc_type: spec -artifact_type: traceability -status: active -owner: Auriora Team -last_reviewed: 2026-08-12 ---- - -# Traceability Matrix - -## Task To Context Matrix - -| Task | Requirements | Acceptance criteria | Design coverage | Verification | Durable targets | -|------|--------------|---------------------|-----------------|--------------|-----------------| -| T001 | Requirement 4, Requirement 8, Requirement 9 | R4 AC1/AC4; R8 AC2/AC5; R9 AC1-AC3/AC6 | single-request helper | V1, V3 | architecture, operations | -| T002 | Requirement 2, Requirement 7, Requirement 9 | R2 AC5; R7 AC2/AC4; R9 AC4-AC6 | snapshot store and watcher | V1-V3 | requirements, tray guide | -| T003 | Requirement 4, Requirement 5, Requirement 9 | R4 AC1/AC5; R5 AC2-AC3; R9 AC1-AC3/AC5 | daemonless assets and migration | V1, V3-V4 | architecture, installation | -| T004 | Requirement 1-Requirement 8 | all local-wheel criteria | deployment entrypoint and transaction | V1-V5 | installation, version process, reference | -| T005 | Requirement 1-Requirement 9 | all automated criteria | validation strategy | V1-V7 | testing docs | -| T006 | Requirement 1-Requirement 9 | review disposition | all security and operational sections | V8 | all targets | -| T007 | Requirement 1-Requirement 9 | promotion and closure | residual architecture | V9-V10 | all targets and history | - -## Requirement To Delivery Matrix - -| Requirement | Priority | Tasks | Verification gates | Durable targets | Coverage State | Residual Destination | -|-------------|----------|-------|--------------------|-----------------|----------------|----------------------| -| Requirement 1 | must-have | T004-T007 | V1, V5-V10 | installation, reference | complete | Supported entrypoint implementation and documentation. | -| Requirement 2 | must-have | T002, T004-T007 | V1-V2, V5-V10 | requirements, version process | complete | Local-wheel provenance; remote acquisition is outside this slice. | -| Requirement 3 | must-have | T004-T007 | V1-V2, V5-V10 | installation, security guidance | complete | Private staging and bounded cleanup. | -| Requirement 4 | must-have | T001, T003-T007 | V1, V3-V10 | architecture, operations | complete | Preflight-first daemonless activation. | -| Requirement 5 | must-have | T003-T007 | V1, V4-V10 | installation, version process | complete | Rollback and protected-state preservation. | -| Requirement 6 | must-have | T004-T007 | V1, V5-V10 | operations | complete | Lock, idempotency, and attention evidence. | -| Requirement 7 | must-have | T002, T004-T007 | V1-V2, V5-V10 | troubleshooting, reference | complete | Redacted typed evidence and status. | -| Requirement 8 | should-have | T001, T004-T007 | V1, V5-V10 | architecture | partial-routed | Platform-neutral contracts included; live Windows implementation routed to a future Windows spec. | -| Requirement 9 | must-have | T001-T003, T005-T007 | V1-V4, V6-V10 | charter, requirements, architecture, tray guide | complete | Automated zero-residency proof; protected live deployment remains operationally approval-gated. | - -## Correctness Property Coverage - -| Property | Tasks | Verification | Residual risk | -|----------|-------|--------------|---------------| -| CP-001 | T004-T005 | V1, V5 | platform command behavior | -| CP-002 | T003-T005 | V1, V4-V5 | live competing administrator | -| CP-003 | T003-T005 | V1, V4-V5 | signal timing on live systemd | -| CP-004 | T002, T004-T005 | V1-V2, V5 | filesystem-specific durability | -| CP-005 | T001, T003-T005 | V1, V3-V5 | none | -| CP-006 | T002, T004-T006 | V1-V2, V5, V8 | unknown future secret categories | -| CP-007 | T001, T004-T006 | V1, V5, V8 | Windows live implementation | -| CP-008 | T001-T003, T005-T006 | V1-V4, V6, V8 | live 90-second interval requires approval | - -## Design To Implementation Matrix - -| Design element | Implementation | Direct verification | -|----------------|----------------|---------------------| -| Single-request protected helper | `backend_entry.py`, `linux_adapter.py`, `timelocker-control.service` | one-shot backend and descriptor-contract tests | -| Sanitized atomic status | `status_snapshot.py`, backend publication hooks, `tray_client.py` | permissions, schema, atomic-replace, real watcher, and tray subscription tests | -| Removal of resident Linux event service | deleted status-event socket asset, schema-3 deployment manifest, release launcher | asset, release, artifact-validator, and installed-wheel smoke checks | -| Supported administrator command | `deployment_entry.py`, `timelocker-deploy-launcher`, project entry point | local-wheel, status, activation, rollback, and wrapper tests | -| Preflight, recovery, and evidence | deployment transaction, trusted lock/staging paths, attention evidence | validation, recovery, symlink, idempotency, and timer-health tests | -| Durable operator guidance | architecture, installation, troubleshooting, tray, release, and command docs | Markdown set checks and lifecycle promotion review | - -## Open Decision Impact - -| Decision | Delivery impact | Disposition | -|----------|-----------------|-------------| -| Linux must have zero idle TimeLocker service residency | The kernel may retain the socket; the privileged Python helper handles one request and exits. | accepted and implemented | -| Status must not require a privileged event daemon | Workers atomically publish one sanitized group-readable file; the optional tray watches that file directly. | accepted and implemented | -| Protected host mutation requires separate approval | Automated fakes, source/asset checks, packaging, and non-mutating probes are used here. | live install and 90-second observation routed | -| Windows deployment must not be implied by Linux delivery | Imports/help fail safely across platforms, while service-control acceptance remains unclaimed. | future Windows spec | -| Legacy event abstractions may remain as compatibility code | They are not referenced by Linux production composition or packaged service assets. | accepted low-risk cleanup debt | - -## Verification Gate Key - -| Gate | Description | -|------|-------------| -| V1 | Focused daemonless runtime and deployment tests | -| V2 | Snapshot schema, permissions, atomicity, watcher, and redaction tests | -| V3 | Unit/asset proof that one request exits and no event service is installed | -| V4 | Rollback/migration state-preservation tests | -| V5 | Administrator entrypoint, failure injection, package and artifact smoke | -| V6 | Process-residency probe and separately approved 90-second live check | -| V7 | Full configured regression, Ruff, compile, Markdown, and Git checks | -| V8 | TimeLocker MoE review and disposition | -| V9 | Durable promotion and lifecycle closure checks | -| V10 | Final-spec commit, package cleanup, and resolved history metadata | - -## Related Artifacts - -- Requirements: [requirements.md](./requirements.md) -- Design: [design.md](./design.md) -- Tasks: [tasks.md](./tasks.md) -- Verification: [verification.md](./verification.md) diff --git a/docs/specs/011-protected-system-deployment/verification.md b/docs/specs/011-protected-system-deployment/verification.md deleted file mode 100644 index 3736bfc..0000000 --- a/docs/specs/011-protected-system-deployment/verification.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -title: Protected system deployment verification -doc_type: spec -artifact_type: verification -status: active -owner: Auriora Team -last_reviewed: 2026-08-12 ---- - -# Verification - -## Scope - -Verify daemonless protected request execution, sanitized status publication and -tray observation, supported local-wheel deployment, rollback safety, evidence -redaction, packaging, and lifecycle closure without mutating the protected host -unless separately approved. - -## Quality Gates - -| Gate | Status | Evidence | -|------|--------|----------| -| Requirements and design reviewed | pass | User approved implementation on 2026-08-12. | -| Focused runtime and deployment regression | pass | 295 focused tests passed; configured suite executed with one unrelated timing-benchmark failure routed below. | -| Package and installed-artifact checks | pass | Wheel/sdist validation and installed-wheel smoke passed. | -| MoE review | pass | All seven roles completed; findings remediated or routed. | -| Durable promotion and closure | pass | Current-state documents promoted; lifecycle checks recorded below. | - -## Validation Commands - -- Focused pytest paths selected by changed-file impact. -- Full configured `python3 -m pytest`. -- Scoped `ruff check` and `python3 -m compileall`. -- Wheel/sdist build, asset validation, and installed-wheel smoke. -- Agent Workbench diagnostics, Markdown checks, and verification plan. -- Spec Lifecycle Manager lint, task audit, evidence, promotion, and closure. -- `git diff --check`. - -## Live Or Protected Verification - -Protected installation, unit mutation, backup/retention execution, rollback, -and the 90-second root-process/CPU observation retain separate operational -approval. Without that approval, automated systemd fakes, packaged-unit -inspection, process-exit tests, and a non-mutating current-process probe are -recorded; live acceptance is routed rather than implied. - -## Durable Promotion And Cleanup - -| Spec content | Durable destination | Status | Evidence | -|--------------|---------------------|--------|----------| -| Zero-idle and authorization requirements | `CHARTER.md`, `docs/1-requirements/system-operations.md` | complete | T007 | -| Daemonless runtime/deployment architecture | `docs/2-architecture/system-architecture.md` | complete | T007 | -| Component ownership | `docs/3-implementation/service-layer-integration.md` | complete | T007 | -| Installation and troubleshooting | `docs/guides/user/installation.md`, `docs/guides/user/backup-operations-troubleshooting.md` | complete | T007 | -| Tray behavior | `docs/SYSTEM-TRAY-SETUP.md` | complete | T007 | -| Release activation | `docs/processes/version-management.md` | complete | T007 | -| Command surface | `docs/reference/timelocker-cli-command-hierarchy.md` | complete | T007 | -| Windows live work | future Windows spec | routed | T007 | - -## MoE Review - -The repository-local TimeLocker review method was applied across all seven -roles after implementation. Findings were deduplicated before remediation. - -| Expert role | Review conclusion | Finding disposition | -|-------------|-------------------|---------------------| -| Project steward | The change restores the chartered zero-idle boundary while retaining explicit protected actions. | accepted; resident Linux event service removed | -| Restic backup and recovery | Deployment and verification do not execute backup or retention; existing timer state is preserved and checked. | fixed activation so only the control socket is enabled; added rollback timer-health verification | -| Python CLI architecture | One supported command owns install, upgrade, status, and rollback; the old script is only a compatibility wrapper. | fixed non-POSIX imports, stable failure results, retry behavior, and launcher packaging | -| Security and privacy | Kernel peer identity remains authoritative; status and evidence are bounded, sanitized, and protected. | fixed status-read TOCTOU, symlink-safe evidence writes, trusted lock roots, missing-group failure, and raw-error disclosure | -| Reliability and testing | One-shot execution, atomic publication, idempotency, recovery attention, and inert cleanup have direct tests. | fixed initial-install recovery, rollback health verification, stale input cleanup, and timer-start regression | -| Operations and portability | Linux uses socket activation without a resident service; live host mutation remains approval-gated. | fixed offline wheel installation, clean-host status, unit health output, and executable wrapper mode; Windows live work routed | -| Documentation lifecycle | Durable documents describe current behavior and immediate legacy shutdown. | promoted current state; Spec 010 rejection and Spec 011 ownership remain explicit | - -No unresolved high- or medium-severity finding remains in the automated scope. - -## Evidence Log - -| Date | Requirements | Gate | Result | Evidence | -|------|--------------|------|--------|----------| -| 2026-08-12 | Requirement 1-Requirement 9 | Focused daemonless system-control suite | pass | 295 tests passed after MoE remediation | -| 2026-08-12 | Requirement 2-Requirement 7, Requirement 9 | New deployment and snapshot contracts | pass | Focused coverage includes symlink, clean-host status, timer activation, rollback health, initial missing snapshot, and runtime-directory preservation | -| 2026-08-12 | Requirement 1-Requirement 9 | Static checks | pass | scoped Ruff and Python compile checks passed; `git diff --check` clean | -| 2026-08-12 | Requirement 1-Requirement 9 | Package contract | pass | wheel and sdist built; 28 package-data files and SHA-256 hashes validated; installed-wheel smoke passed on Python 3.12.6 | -| 2026-08-12 | Requirement 1-Requirement 9 | Full configured regression | routed | 3166 passed, 1 skipped, 1 failed: pre-existing `test_repository_resolver_performance` exceeded its 0.2-second threshold (0.3097 seconds; isolated rerun 0.3637 seconds) outside changed modules | -| 2026-08-12 | Requirement 1-Requirement 9 | Agent Workbench routing | limited | changed-file context was stale for the deleted asset; direct source and executed checks were used as authority | - -## Residual Risks - -- The protected install/upgrade/rollback transaction and 90-second live - root-process observation were not run because they require separate host - mutation approval. The exact operational check remains documented. -- Windows service-control, named-pipe deployment, elevation, interruption, and - rollback acceptance remain routed to a future Windows spec; no live Windows - support is claimed. -- Legacy event protocol classes remain as uncomposed compatibility code. They - are absent from Linux production composition and packaged service assets and - therefore create no idle process residency; removal can be handled as narrow - cleanup after downstream compatibility is assessed. -- The full configured suite retains one unrelated repository-resolver timing - benchmark failure. Its functional assertions pass elsewhere in the suite; - performance-threshold investigation is routed outside this system-control - change. - -## Readiness Decision - -- **Ready to implement:** yes - user approval recorded 2026-08-12 -- **Ready for promotion:** yes -- **Ready for closure:** yes, subject to the mechanical closure transaction - -## Related Artifacts - -- Requirements: [requirements.md](./requirements.md) -- Design: [design.md](./design.md) -- Tasks: [tasks.md](./tasks.md) -- Traceability: [traceability.md](./traceability.md) From 0abf0061254410fd0b7298f8bbbcd44e0b7427c9 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:11:08 +0100 Subject: [PATCH 68/72] docs(specs): resolve protected deployment closure metadata --- docs/history/spec-archive-index.md | 4 ++-- docs/history/spec-closure-log.md | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/history/spec-archive-index.md b/docs/history/spec-archive-index.md index 7a34f7a..40bd89b 100644 --- a/docs/history/spec-archive-index.md +++ b/docs/history/spec-archive-index.md @@ -16,8 +16,8 @@ contracts. | Spec ID | Title | Package path | Status | Final spec commit | Cleanup commit | Closure action | Durable destinations | Verification | |---------|-------|--------------|--------|-------------------|----------------|----------------|----------------------|--------------| -| 011-protected-system-deployment | Protected system deployment requirements | `docs/specs/011-protected-system-deployment/` | removed | b91c0ff7a644aa0d0343b112b17c700bce820952 | pending-cleanup-commit | removed | `CHARTER.md`; `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/guides/user/installation.md`; `docs/processes/version-management.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/reference/timelocker-cli-command-hierarchy.md` | `docs/history/spec-closure-log.md` | -| 010-event-driven-tray-status | Event-driven tray status requirements | `docs/specs/010-event-driven-tray-status/` | removed | 8820e65 | 4122746 | removed | `CHARTER.md`; `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/3-implementation/service-layer-integration.md`; `docs/SYSTEM-TRAY-SETUP.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/specs/011-protected-system-deployment/requirements.md` | `docs/history/spec-closure-log.md` | +| 011-protected-system-deployment | Protected system deployment requirements | `docs/specs/011-protected-system-deployment/` | removed | b91c0ff7a644aa0d0343b112b17c700bce820952 | b43acc57a00e854cb8b8d328590316c9cb959ba8 | removed | `CHARTER.md`; `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/guides/user/installation.md`; `docs/processes/version-management.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/reference/timelocker-cli-command-hierarchy.md` | `docs/history/spec-closure-log.md` | +| 010-event-driven-tray-status | Event-driven tray status requirements | `docs/specs/010-event-driven-tray-status/` | removed | 8820e65 | 4122746 | removed | `CHARTER.md`; `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/3-implementation/service-layer-integration.md`; `docs/SYSTEM-TRAY-SETUP.md`; `docs/guides/user/backup-operations-troubleshooting.md` | `docs/history/spec-closure-log.md` | | 009-system-cli-tray-retention | System CLI, independent tray, retention, and control | removed; recover from Git | removed | `d4ce71dd05cb5d7278bf36a9fc43e557d68e1e31` | aba95875f453dd6abf39a1fdc6af25fd38c62db4 | removed | `docs/1-requirements/system-operations.md`; `docs/2-architecture/system-architecture.md`; `docs/2-architecture/scheduling-system.md`; `docs/3-implementation/service-layer-integration.md`; `docs/guides/user/installation.md`; `docs/guides/developer/scheduling-guide.md`; `docs/SYSTEM-TRAY-SETUP.md`; `docs/reference/timelocker-cli-command-hierarchy.md`; `docs/guides/user/backup-operations-troubleshooting.md`; `docs/processes/version-management.md`; `docs/README.md`; `docs/DOCUMENTATION-STATUS.md`; `docs/specs/README.md` | `docs/history/spec-closure-log.md` | | 007-release-readiness-stabilization | Release readiness stabilization requirements | removed; recover from Git | removed | `7fd11f9aa1cbc670d5e8b429aede4a7c01e185a4` | `6334af0690b5b9e8b6575042269e5b73914a9295` | removed | `README.md`; `CHANGELOG.md`; `.github/workflows/test-suite.yml`; `.github/workflows/artifact-smoke.yml`; `.github/workflows/release-validation.yml`; `.github/workflows/release.yml`; `docs/4-testing/README.md`; `docs/guides/user/installation.md`; `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md`; `docs/processes/version-management.md`; `docs/processes/README.md` | `docs/history/spec-closure-log.md` | | 008-npbackup-migration-parity | NPBackup migration parity requirements | removed; recover from Git | removed | `5830194` | `1bfea08` | removed | `docs/guides/user/recovery-operations-guide.md`; `docs/guides/developer/scheduling-guide.md` | `docs/history/spec-closure-log.md` | diff --git a/docs/history/spec-closure-log.md b/docs/history/spec-closure-log.md index 9eb8c37..fcd1663 100644 --- a/docs/history/spec-closure-log.md +++ b/docs/history/spec-closure-log.md @@ -20,7 +20,7 @@ final spec commit preserves the complete package. - **Spec:** `docs/specs/011-protected-system-deployment/` - **Title:** Protected system deployment requirements - **Final spec commit:** `b91c0ff7a644aa0d0343b112b17c700bce820952` -- **Closure cleanup commit:** `pending-cleanup-commit` +- **Closure cleanup commit:** `b43acc57a00e854cb8b8d328590316c9cb959ba8` - **Closure action:** removed - **Durable docs updated:** - `CHARTER.md` @@ -48,7 +48,6 @@ final spec commit preserves the complete package. - `docs/3-implementation/service-layer-integration.md` - `docs/SYSTEM-TRAY-SETUP.md` - `docs/guides/user/backup-operations-troubleshooting.md` - - `docs/specs/011-protected-system-deployment/requirements.md` - **Verification summary:** Closure validation not yet executed. - **Residual risks:** - none From 20cc4b271b4805f8415b351ad30c83d5224da7c0 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:51:25 +0100 Subject: [PATCH 69/72] fix(deploy): allow upgrade from stopped legacy service --- .../system_control/deployment_entry.py | 8 ++- .../project/test_t011_linux_deployment.py | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/TimeLocker/system_control/deployment_entry.py b/src/TimeLocker/system_control/deployment_entry.py index 40caa8a..ffda47c 100644 --- a/src/TimeLocker/system_control/deployment_entry.py +++ b/src/TimeLocker/system_control/deployment_entry.py @@ -63,6 +63,10 @@ "timelocker-npbackup-migration.timer", "timelocker-retention.timer", ) +PRE_ACTIVATION_ACTIVE_UNITS = ( + "timelocker-npbackup-migration.timer", + "timelocker-retention.timer", +) REQUIRED_ENABLED_UNITS = ( "timelocker-control.socket", "timelocker-npbackup-migration.timer", @@ -269,7 +273,7 @@ def validate_request(self) -> None: if _selected_release_optional(self.paths.selector) != self.request.expected_current: raise DeploymentFailure("selected release changed before deployment") if self.request.expected_current is not None: - for unit in REQUIRED_ACTIVE_UNITS: + for unit in PRE_ACTIVATION_ACTIVE_UNITS: self._systemctl_gate("is-active", unit) for unit in REQUIRED_ENABLED_UNITS: self._systemctl_gate("is-enabled", unit) @@ -500,7 +504,7 @@ def preflight_staged_release(self) -> None: if _selected_release_optional(self.paths.selector) != self.request.expected_current: raise DeploymentFailure("selector changed during staged preflight") if self.request.expected_current is not None: - for unit in REQUIRED_ACTIVE_UNITS: + for unit in PRE_ACTIVATION_ACTIVE_UNITS: self._systemctl_gate("is-active", unit) for unit in REQUIRED_ENABLED_UNITS: self._systemctl_gate("is-enabled", unit) diff --git a/tests/TimeLocker/project/test_t011_linux_deployment.py b/tests/TimeLocker/project/test_t011_linux_deployment.py index eb824a0..8d66001 100644 --- a/tests/TimeLocker/project/test_t011_linux_deployment.py +++ b/tests/TimeLocker/project/test_t011_linux_deployment.py @@ -191,6 +191,60 @@ def run(self, arguments, **_kwargs): assert commands == [] +@pytest.mark.unit +def test_upgrade_validation_allows_stopped_legacy_control_socket( + tmp_path: Path, +) -> None: + paths = _paths(tmp_path) + _prepare_roots(paths) + paths.launcher_venv.mkdir(parents=True) + paths.launcher_venv.chmod(0o755) + launcher_python = paths.launcher_venv / "bin/python" + launcher_python.parent.mkdir() + launcher_python.parent.chmod(0o755) + launcher_python.write_text("#!/bin/sh\n") + launcher_python.chmod(0o755) + paths.selector.write_text( + json.dumps( + {"schema_version": 1, "selected": RELEASE_A, "previous": None} + ) + ) + paths.selector.chmod(0o644) + wheel = tmp_path / "timelocker-0.9.1-py3-none-any.whl" + wheel.write_bytes(b"wheel") + manifest = tmp_path / "release.json" + manifest.write_text("{}") + commands: list[list[str]] = [] + + class Executor: + def run(self, arguments, **_kwargs): + commands.append([str(value) for value in arguments]) + return "" + + deployer = entry.T011LinuxDeployer( + entry.DeploymentRequest( + release_id="b" * 40, + expected_current=RELEASE_A, + wheel=wheel, + wheel_sha256=entry._sha256(wheel), + manifest=manifest, + operator_user=getpass.getuser(), + ), + paths=paths, + executor=Executor(), + owner_uid=os.getuid(), + owner_gid=os.getgid(), + ) + + deployer.validate_request() + + assert ["systemctl", "is-active", "--quiet", "timelocker-control.socket"] not in commands + for unit in entry.PRE_ACTIVATION_ACTIVE_UNITS: + assert ["systemctl", "is-active", "--quiet", unit] in commands + for unit in entry.REQUIRED_ENABLED_UNITS: + assert ["systemctl", "is-enabled", "--quiet", unit] in commands + + @pytest.mark.unit def test_status_reports_zero_resident_service_contract(tmp_path: Path) -> None: paths = _paths(tmp_path) From fed8e1645bc118e0509de9bef7dbe8c20fe4d0a0 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:54:43 +0100 Subject: [PATCH 70/72] fix(deploy): seed offline upgrade dependencies --- docs/guides/user/installation.md | 3 ++ .../system_control/deployment_entry.py | 47 +++++++++++++++++++ .../project/test_t011_linux_deployment.py | 28 +++++++++++ 3 files changed, 78 insertions(+) diff --git a/docs/guides/user/installation.md b/docs/guides/user/installation.md index b5d3c81..ad3acf9 100644 --- a/docs/guides/user/installation.md +++ b/docs/guides/user/installation.md @@ -185,6 +185,9 @@ validates its filename, package metadata, digest, and complete protected asset set, then activates it transactionally. It stops/disables the legacy event socket and never starts backup or retention. A rollback to a schema-1/2 release is rejected because those releases can require the removed resident service. +An offline upgrade seeds dependencies from the currently selected immutable +release, excludes the old TimeLocker package, installs the candidate wheel, and +then runs the normal staged compatibility probes before activation. Verify an installed host without reading secrets: diff --git a/src/TimeLocker/system_control/deployment_entry.py b/src/TimeLocker/system_control/deployment_entry.py index ffda47c..89783be 100644 --- a/src/TimeLocker/system_control/deployment_entry.py +++ b/src/TimeLocker/system_control/deployment_entry.py @@ -360,6 +360,14 @@ def stage_release(self) -> None: timeout=120, output=self.evidence / "venv-create.txt", ) + pip_arguments: list[str | Path] = [] + if self.request.expected_current is not None: + _seed_dependencies_from_release( + self.paths.releases_root / self.request.expected_current / "venv", + self.release / "venv", + expected_owner_uid=self.owner_uid, + ) + pip_arguments.append("--no-deps") python = self.release / "venv/bin/python" self.executor.run( [ @@ -369,6 +377,7 @@ def stage_release(self) -> None: "install", "--disable-pip-version-check", "--no-index", + *pip_arguments, self.staged_wheel, ], timeout=600, @@ -947,6 +956,44 @@ def _make_tree_immutable( os.chown(path, uid, gid) +def _seed_dependencies_from_release( + source_venv: Path, + destination_venv: Path, + *, + expected_owner_uid: int | None, +) -> None: + """Copy trusted dependencies, but not TimeLocker, into an upgrade candidate.""" + _require_trusted_directory(source_venv, expected_owner_uid=expected_owner_uid) + source_matches = tuple(source_venv.glob("lib/python3.*/site-packages")) + destination_matches = tuple(destination_venv.glob("lib/python3.*/site-packages")) + if len(source_matches) != 1 or len(destination_matches) != 1: + raise DeploymentFailure("release dependency environment is ambiguous") + source = source_matches[0] + destination = destination_matches[0] + _require_trusted_directory(source, expected_owner_uid=expected_owner_uid) + for item in source.iterdir(): + normalized = item.name.lower().replace("_", "-") + if normalized == "timelocker" or ( + normalized.startswith("timelocker-") + and normalized.endswith((".dist-info", ".egg-info")) + ): + continue + if item.is_symlink(): + raise DeploymentFailure("selected release dependency is a symlink") + target = destination / item.name + if item.is_dir(): + shutil.copytree( + item, + target, + copy_function=shutil.copy2, + dirs_exist_ok=True, + ) + elif item.is_file(): + shutil.copy2(item, target) + else: + raise DeploymentFailure("selected release dependency is not a regular file") + + def _write_private_text(path: Path, content: str) -> None: flags = os.O_WRONLY | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) descriptor = os.open(path, flags, 0o600) diff --git a/tests/TimeLocker/project/test_t011_linux_deployment.py b/tests/TimeLocker/project/test_t011_linux_deployment.py index 8d66001..a0a4b79 100644 --- a/tests/TimeLocker/project/test_t011_linux_deployment.py +++ b/tests/TimeLocker/project/test_t011_linux_deployment.py @@ -245,6 +245,34 @@ def run(self, arguments, **_kwargs): assert ["systemctl", "is-enabled", "--quiet", unit] in commands +@pytest.mark.unit +def test_upgrade_seeds_only_dependencies_from_selected_release(tmp_path: Path) -> None: + source_venv = tmp_path / "selected/venv" + destination_venv = tmp_path / "candidate/venv" + source = source_venv / "lib/python3.12/site-packages" + destination = destination_venv / "lib/python3.12/site-packages" + source.mkdir(parents=True) + destination.mkdir(parents=True) + source.chmod(0o755) + source_venv.chmod(0o755) + (source / "dependency.py").write_text("VALUE = 1\n") + (source / "dependency-1.0.dist-info").mkdir() + (source / "TimeLocker").mkdir() + (source / "TimeLocker/old.py").write_text("OLD = True\n") + (source / "timelocker-0.9.0.dist-info").mkdir() + + entry._seed_dependencies_from_release( + source_venv, + destination_venv, + expected_owner_uid=os.getuid(), + ) + + assert (destination / "dependency.py").read_text() == "VALUE = 1\n" + assert (destination / "dependency-1.0.dist-info").is_dir() + assert not (destination / "TimeLocker").exists() + assert not (destination / "timelocker-0.9.0.dist-info").exists() + + @pytest.mark.unit def test_status_reports_zero_resident_service_contract(tmp_path: Path) -> None: paths = _paths(tmp_path) From 1f110bfe5d0298ca22e49744b6c13fc7df2d3618 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:56:19 +0100 Subject: [PATCH 71/72] fix(deploy): expose read-only deployment status --- src/TimeLocker/system_control/deployment.py | 2 +- tests/TimeLocker/system_control/test_deployment.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TimeLocker/system_control/deployment.py b/src/TimeLocker/system_control/deployment.py index 12cf977..cb785e0 100644 --- a/src/TimeLocker/system_control/deployment.py +++ b/src/TimeLocker/system_control/deployment.py @@ -287,7 +287,7 @@ def linux_asset_targets( AssetTarget( "timelocker-deploy-launcher", admin_bin_root / "timelocker-deploy", - 0o750, + 0o755, ), AssetTarget( "timelocker-system-control-launcher", diff --git a/tests/TimeLocker/system_control/test_deployment.py b/tests/TimeLocker/system_control/test_deployment.py index 69461f7..a6abb85 100644 --- a/tests/TimeLocker/system_control/test_deployment.py +++ b/tests/TimeLocker/system_control/test_deployment.py @@ -186,7 +186,7 @@ def test_linux_asset_set_covers_launchers_backend_tray_and_schedules( target for target in targets if target.source_name == "timelocker-deploy-launcher" ) assert deploy.destination == tmp_path / "sbin" / "timelocker-deploy" - assert deploy.mode == 0o750 + assert deploy.mode == 0o755 assert "timelocker-status-events.socket" not in sources policy = next( target From 68d8a6f6bc5a9cd1afa8e6564a53a3ac2b214733 Mon Sep 17 00:00:00 2001 From: bcherrington <993834+bcherrington@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:58:49 +0100 Subject: [PATCH 72/72] fix(release): reconcile v0.9.1 publication gates Remove the POSIX-only import from the Windows artifact smoke path and add regression coverage. Reconcile the changelog, installation guidance, lifecycle index, and closure evidence with the completed daemonless release.\n\nRefs #22\nRefs #23\nRefs #24\nRefs #25 --- CHANGELOG.md | 42 +++++++++++++------ README.md | 4 +- docs/guides/user/installation.md | 6 +-- docs/history/spec-closure-log.md | 8 +++- docs/processes/version-management.md | 12 +++--- docs/specs/README.md | 25 ++++------- .../system_control/backend_entry.py | 12 +++--- .../project/test_release_artifacts.py | 22 ++++++++++ 8 files changed, 84 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8355e4f..b8a68ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,14 @@ All notable changes to TimeLocker are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -A version section records release contents, not proof that publication occurred. -The `Prepared` qualifier on `0.9.1` marks it as a Beta release candidate until -the release maintainer approves and finalizes the production tag. +A version section records the evidence-backed contents of a release. GitHub is +the authority for whether its corresponding tag and release were published. ## [Unreleased] -No changes have been assigned beyond the `0.9.1` release candidate. +No changes have been assigned beyond `0.9.1`. -## [0.9.1] - Prepared 2026-07-19 +## [0.9.1] - 2026-08-13 ### Added @@ -24,6 +23,14 @@ No changes have been assigned beyond the `0.9.1` release candidate. macOS, and Windows with Python 3.12 and 3.13. - A reusable, read-only release rehearsal that derives its release-body preview from this changelog section. +- NPBackup-compatible selection, exclusion, scheduling, and recovery migration + with a retained rollback path. +- Protected system backup, run-status, retention, and tray workflows with + authenticated local requests and sanitized durable status. +- Transactional local-wheel install, upgrade, status, and rollback through + `timelocker-deploy`, including immutable release selection and evidence. +- Branded tray states for connecting, idle, running, success, warning, and + failure conditions. ### Changed @@ -33,22 +40,33 @@ No changes have been assigned beyond the `0.9.1` release candidate. calibrated correctness and timing contract. - Isolated GitHub release creation behind successful validation and a single job-scoped `contents: write` permission. +- Replaced the resident privileged event backend with socket-activated, + one-request execution that exits after each explicit request. +- Preserved independent backup and retention timers while making tray status + observation daemonless and filesystem-based. ### Fixed - Prevented normal CI from contacting an unprovisioned MinIO service. - Made root CLI help safe for the Windows default `cp1252` encoding. - Aligned package, source, and version-bump metadata at `0.9.1`. +- Preserved native Restic repository URIs and selective recovery paths during + migration. +- Prevented headless commands from initializing the system tray and prevented + schedule installation from immediately starting its backup service. +- Hardened protected release staging, protocol upgrades, rollback, offline + dependency installation, and unprivileged read-only deployment status. +- Made the Linux-only system-control entry point importable for `--help` and + package smoke validation on Windows. ### Known Limitations -- This is a Beta release candidate. A production tag and GitHub release still - require separate maintainer approval. -- TimeLocker is not published to PyPI; install from source until an authorized - GitHub release provides downloadable artifacts. -- The first production tag will exercise GitHub release creation in the live - repository for the first time. The non-publishing rehearsal cannot reproduce - that final external write. +- TimeLocker is distributed through GitHub Releases and is not published to + PyPI. +- Protected system deployment is accepted on Linux Mint/systemd. Windows has + package and adapter coverage but no live protected-service acceptance. +- Optional tray presentation remains a user-session process by operator choice; + no continuously resident privileged TimeLocker backend is required. - GitHub Actions currently reports a non-blocking upstream Node.js runtime deprecation advisory for pinned actions. diff --git a/README.md b/README.md index 80c54cf..f9e51d1 100644 --- a/README.md +++ b/README.md @@ -394,7 +394,7 @@ This is particularly suitable for libraries and applications that you want to re ## Document Information -- Version: 0.9.1 (prepared, not published) -- Last Updated: 2026-07-19 +- Version: 0.9.1 +- Last Updated: 2026-08-13 - Author: Bruce Cherrington - Copyright © Bruce Cherrington diff --git a/docs/guides/user/installation.md b/docs/guides/user/installation.md index ad3acf9..b5a1f93 100644 --- a/docs/guides/user/installation.md +++ b/docs/guides/user/installation.md @@ -41,9 +41,9 @@ After completing this guide you will have TimeLocker installed, dependencies con ### 4.1 Review Release Status -- **Current status**: Beta, version 0.9.1 is prepared but not published. -- **Distribution**: Source checkout only; TimeLocker is not currently published - to PyPI. +- **Current status**: version 0.9.1 is distributed through GitHub Releases. +- **Distribution**: GitHub Release wheel and source archive; TimeLocker is not + published to PyPI. - **Quality gate**: The configured test suite enforces at least 50% coverage. ### 4.2 Understand TimeLocker diff --git a/docs/history/spec-closure-log.md b/docs/history/spec-closure-log.md index fcd1663..a95c0fc 100644 --- a/docs/history/spec-closure-log.md +++ b/docs/history/spec-closure-log.md @@ -3,7 +3,7 @@ title: Spec closure log doc_type: history status: active owner: Auriora Team -last_reviewed: 2026-07-26 +last_reviewed: 2026-08-13 --- # Spec Closure Log @@ -30,7 +30,11 @@ final spec commit preserves the complete package. - `docs/processes/version-management.md` - `docs/guides/user/backup-operations-troubleshooting.md` - `docs/reference/timelocker-cli-command-hierarchy.md` -- **Verification summary:** Closure validation not yet executed. +- **Verification summary:** All seven tasks completed; 295 focused tests, + package validation, installed-wheel smoke, scoped Ruff, compile, lifecycle, + and seven-role review gates passed. The daemonless release was subsequently + activated on the protected Linux host and a 90-second observation found no + resident privileged TimeLocker helper. - **Residual risks:** - none - **Follow-up:** none diff --git a/docs/processes/version-management.md b/docs/processes/version-management.md index cfc495e..4caceb3 100644 --- a/docs/processes/version-management.md +++ b/docs/processes/version-management.md @@ -3,7 +3,7 @@ title: Version management and GitHub releases doc_type: process status: active owner: Auriora Team -last_reviewed: 2026-07-26 +last_reviewed: 2026-08-13 --- # Version Management And GitHub Releases @@ -85,8 +85,8 @@ Publication requires all of the following: 1. The release candidate is committed on the intended protected branch and CI is green. -2. The active release-readiness spec is ready for closure and its residual - risks have an owner. +2. The release-readiness lifecycle package is closed or ready for closure, and + its residual risks have an owner. 3. The version guard, artifacts, supported OS/Python matrix, changelog preview, and non-publishing rehearsal pass. 4. The release maintainer explicitly approves the exact commit and version. @@ -154,6 +154,6 @@ removed resident event service. ## Current Deferrals -Version `0.9.1` remains a Beta GitHub release candidate until separately -approved. PyPI distribution and the `1.0.0` milestone remain deferred and are -not implied by completing this procedure. +Version `0.9.1` is distributed through its GitHub release. PyPI distribution +and the `1.0.0` milestone remain deferred and are not implied by completing +this procedure. diff --git a/docs/specs/README.md b/docs/specs/README.md index 072fa1e..1cb27b0 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -3,7 +3,7 @@ title: "Active Specification Packages" doc_type: reference status: active owner: "Auriora Team" -last_reviewed: 2026-08-12 +last_reviewed: 2026-08-13 --- # Active Specification Packages @@ -15,25 +15,16 @@ accepted content has been promoted and the package is closed. ## Current Packages -- [`011-protected-system-deployment`](./011-protected-system-deployment/README.md) - - active package for replacing acceptance-specific deployment commands and the - resident control backend with a daemonless transactional install, upgrade, - status, rollback, query, and action workflow. +There are no active specification packages. ## Active-Package Sequencing -Spec 010 is closed. Its accepted status semantics are promoted to durable docs, -and its rejected resident-runtime work is routed to Spec 011. The user approved -Spec 011 implementation on 2026-08-12; live protected-host mutation, backup or -retention execution, publication, and rollback remain separate operational -approval boundaries. - -Specs 007, 008, 009, and 010 are closed. Their final package commits, cleanup -commits, verification summaries, and residual follow-up are recorded in -`docs/history/`. Closed packages remain recoverable from Git rather than kept -in this active path. Spec 010 may rely on the durable behavior promoted by Spec -009, but not on its removed package as current authority. Repository -implementation approval does not authorize release publication or deployment. +Specs 007 through 011 are closed. Their final package commits, cleanup commits, +verification summaries, and residual follow-up are recorded in `docs/history/`. +Closed packages remain recoverable from Git rather than kept in this active +path. Repository implementation approval does not authorize release +publication or protected-host deployment; those remain separately approved +operational boundaries. ## When a Spec Is Needed diff --git a/src/TimeLocker/system_control/backend_entry.py b/src/TimeLocker/system_control/backend_entry.py index 174da53..165a42e 100644 --- a/src/TimeLocker/system_control/backend_entry.py +++ b/src/TimeLocker/system_control/backend_entry.py @@ -14,7 +14,7 @@ import stat from threading import Event from types import FrameType -from typing import Protocol +from typing import TYPE_CHECKING, Protocol from uuid import UUID, uuid4 try: @@ -24,10 +24,8 @@ from .dispatcher import AuditEvent, AuditSink, LocalControlDispatcher from .interfaces import GroupMembershipResolver -from .linux_adapter import ( - LinuxNssGroupMembershipResolver, - LinuxUnixSocketTransport, -) +if TYPE_CHECKING: + from .linux_adapter import LinuxUnixSocketTransport from .models import ( ActionReceipt, BackupActionRequest, @@ -367,6 +365,8 @@ def build_linux_backend( status_snapshot_store: AtomicStatusSnapshotStore | None = None, ) -> LinuxBackendService: """Compose the Linux backend from strict local components.""" + from .linux_adapter import LinuxNssGroupMembershipResolver + if type(max_diagnostics) is not int or not 1 <= max_diagnostics <= 100_000: raise ValueError("max_diagnostics must be between 1 and 100000") if socket_mode not in {"systemd", "listener"}: @@ -756,6 +756,8 @@ def _build_transport( request_timeout_seconds: float, stop_event: Event, ) -> LinuxUnixSocketTransport: + from .linux_adapter import LinuxUnixSocketTransport + if socket_mode == "listener": assert listener is not None return LinuxUnixSocketTransport( diff --git a/tests/TimeLocker/project/test_release_artifacts.py b/tests/TimeLocker/project/test_release_artifacts.py index 298d83f..5f63ac0 100644 --- a/tests/TimeLocker/project/test_release_artifacts.py +++ b/tests/TimeLocker/project/test_release_artifacts.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.util +import os import subprocess import sys import tomllib @@ -100,6 +101,27 @@ def test_root_help_is_compatible_with_windows_default_encoding(): result.output.encode("cp1252") +@pytest.mark.platform +@pytest.mark.unit +def test_system_control_help_does_not_require_posix_account_modules(): + script = """ +import sys +sys.modules["grp"] = None +sys.modules["pwd"] = None +from TimeLocker.system_control import backend_entry +backend_entry.main(["--help"]) +""" + result = subprocess.run( + [sys.executable, "-c", script], + cwd=ROOT, + env={**os.environ, "PYTHONPATH": str(ROOT / "src")}, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert "usage:" in result.stdout + + @pytest.mark.config @pytest.mark.unit def test_validator_cli_rejects_a_version_mismatch_before_artifact_checks(tmp_path):