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..32aeb64 --- /dev/null +++ b/.github/workflows/artifact-smoke.yml @@ -0,0 +1,71 @@ +name: Release Artifact Smoke + +on: + pull_request: + 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/.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/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index f37e3fd..800f299 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' @@ -84,11 +84,101 @@ jobs: uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.os }}-${{ matrix.python-version }} + include-hidden-files: true path: | htmlcov/ .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 +205,7 @@ jobs: quality-gate: runs-on: ubuntu-latest - needs: [test] + needs: [test, minio-test] permissions: contents: read pull-requests: write @@ -221,18 +311,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/CHANGELOG.md b/CHANGELOG.md index c63ec9f..b8a68ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,53 +4,79 @@ 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 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 `0.9.1`. + +## [0.9.1] - 2026-08-13 + ### 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. +- 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 -- 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. +- 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 -- 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`. +- 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 -## Current Beta Baseline +- 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. -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/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/README.md b/README.md index a73f10a..f9e51d1 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) @@ -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 @@ -108,7 +109,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`) @@ -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 @@ -385,7 +394,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 +- Last Updated: 2026-08-13 - Author: Bruce Cherrington - Copyright © Bruce Cherrington 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..c86e3b9 --- /dev/null +++ b/docs/1-requirements/system-operations.md @@ -0,0 +1,111 @@ +--- +title: "System Operations Requirements" +doc_type: requirements +status: active +owner: Auriora Team +last_reviewed: 2026-08-12 +--- + +# 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. +- `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 + +- 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. +- 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. + +## 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. +- 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. +- 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. + +## 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. +- Tray operation must not keep a privileged TimeLocker process resident. +- 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 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 + +- [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..0cce01a 100644 --- a/docs/2-architecture/system-architecture.md +++ b/docs/2-architecture/system-architecture.md @@ -1,10 +1,11 @@ --- title: "Architecture Document: System Architecture" +doc_type: architecture id: "arch-system-architecture" type: [ architecture ] status: [ approved ] owner: "Architecture Team" -last_reviewed: "18-07-2026" +last_reviewed: "2026-08-12" tags: [architecture, system, layers] links: tooling: [] @@ -19,44 +20,74 @@ 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 + socket-activated one-request helper + | | | + v v v + backup retention run/diagnostic + adapter adapter stores + \ / + shared repository lock + | + v + Restic command adapter ``` +## Zero-Idle Protected Runtime + +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 + 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. + +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 - **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, 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. +- **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. Its + one-request socket activation and sanitized status publication. +- **Tray boundary** — `timelocker-tray` is an independent unprivileged + 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. @@ -69,13 +100,23 @@ 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. +- 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. +- 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 +129,13 @@ 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`; sanitized transient status is +`/run/timelocker/status.json`. Unattended credentials are referenced from +protected files and are never copied into user-readable configuration. ## Validation @@ -113,3 +157,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..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-07-18 +# Service Layer Integration Guide ## Overview @@ -36,6 +40,44 @@ 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. + +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. +- `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 + 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. +- `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 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/4-testing/README.md b/docs/4-testing/README.md index d5f7bf6..3cae36c 100644 --- a/docs/4-testing/README.md +++ b/docs/4-testing/README.md @@ -1,11 +1,11 @@ --- -title: "Testing Documentation" +title: Testing documentation doc_type: reference 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: [ ] @@ -13,44 +13,88 @@ 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. Run it without coverage because +instrumentation changes timing enough to invalidate performance comparisons. -- [Testing Quick Start](./quickstart-testing.md) - Start here for testing -- CI artifacts and Git history preserve point-in-time test results. +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 + +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/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..3959f44 100644 --- a/docs/SYSTEM-TRAY-SETUP.md +++ b/docs/SYSTEM-TRAY-SETUP.md @@ -1,70 +1,112 @@ -# System Tray Integration Setup (Optional) - -TimeLocker's system tray integration is **optional**. The CLI works perfectly without it. - -> **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. +--- +title: Independent System Tray Setup +doc_type: guide +status: active +owner: Auriora Team +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, and the tray can disappear or restart +without affecting an active backup or retention run. + +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 + +The reusable tray behavior can: + +- show backend availability; +- 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 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 +retention with `timelocker system retention --policy-fingerprint ...`, and a +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`. 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 + +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 +``` -## Quick Install +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. -### Linux (Ubuntu/Debian) +On Linux Mint/Ubuntu with Cinnamon or GNOME-compatible panels, install the GTK +and AppIndicator runtime: ```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 - -# Then install TimeLocker with system tray support -pip install -e .[gui] +sudo apt install python3-gi gir1.2-gtk-3.0 \ + gir1.2-ayatanaappindicator3-0.1 ``` -### macOS +Log out and back in to load the system autostart entry. For a one-shot +diagnostic that does not create a persistent tray icon: ```bash -# No system dependencies needed -pip install -e .[gui] +timelocker-tray status --once ``` -### Windows +To run the foreground process for troubleshooting: ```bash -# No system dependencies needed -pip install -e .[gui] +timelocker-tray serve ``` -## Do You Need This? - -**NO** if you're: - -- Using CLI only -- Running on a headless server -- Connecting via SSH - -**YES** if you want: - -- System tray notifications -- Desktop integration -- Visual status indicators - -## Full Documentation - -See [guides/gui-dependencies.md](guides/gui-dependencies.md) for complete installation instructions and troubleshooting. - -## What This Provides - -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 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 timers or active one-shot operations. -## What This Does NOT Provide +## Platform Status -This is **not** a full desktop GUI application. TimeLocker does not have: +The presentation contract is platform-neutral and the source contains a +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. -- 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 9baef91..3252730 100644 --- a/docs/guides/developer/scheduling-guide.md +++ b/docs/guides/developer/scheduling-guide.md @@ -1,127 +1,144 @@ --- -title: "Developer 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: "01-11-2025" -tags: [guide, developer, scheduling] +owner: "Auriora Team" +last_reviewed: "2026-07-26" +tags: [guide, developer, operator, scheduling, retention] links: tooling: [] --- -# Developer 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` +# Operator Guide: Scheduling Backups And Retention + +## Purpose + +Configure reviewable user schedules and operate the protected Linux system +backup and retention schedules without exposing credentials. + +## User-Managed Schedules + +Create schedules disabled, then generate assets into a staging directory: + +```bash +tl schedule create nightly-documents \ + --repository my-repository \ + --source "$HOME/Documents" \ + --cron-expression "30 3 * * *" \ + --environment-file "$HOME/.config/timelocker/backup.env" + +tl schedule generate-scripts nightly-documents \ + --platform systemd \ + --output "$HOME/.local/share/timelocker/staged-schedules" +``` + +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. + +Inspect the stored definition with: + +```bash +tl schedule list +tl schedule show nightly-documents +tl schedule test nightly-documents +``` + +## 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 +systemctl status timelocker-control.socket +systemctl status timelocker-npbackup-migration.timer +systemctl status timelocker-retention.timer +systemctl list-timers 'timelocker-*' +``` + +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. + +## Retention Safety + +The current protected policy is: + +```text +keep daily: 5 +keep weekly: 4 +keep monthly: 12 +keep yearly: 3 +group by: host,paths +prune: disabled +``` + +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. + +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. + +## Operator Visibility + +Current members of `timelocker-operators` can inspect protected records: + +```bash +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 +``` + +`timelocker logs view` without `--scope system` reads the invoking user's local +CLI log and does not contain protected scheduled-run records. + +## 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. + +## Rollback + +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 + +- [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..0ec3f11 100644 --- a/docs/guides/user/backup-operations-troubleshooting.md +++ b/docs/guides/user/backup-operations-troubleshooting.md @@ -1,721 +1,216 @@ -# 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. +--- +title: "Backup Operations Troubleshooting Guide" +doc_type: guide +status: active +owner: Auriora Team +last_reviewed: 2026-08-12 +--- -## Quick Diagnostic Checklist - -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 +# Protected system backup and retention records +timelocker runs list --limit 20 +timelocker logs view --scope system --lines 100 ``` -ToolNotAvailableError: Backup tool not found at: /usr/bin/restic -``` - -**Cause**: The backup tool executable is not installed or not at the expected location. - -**Solutions**: - -1. **Verify Installation**: - ```bash - which restic - # or - which borg - ``` -2. **Install Missing Tool**: - ```bash - # For Restic - sudo apt install restic - # or download from https://restic.net - - # For Borg - sudo apt install borgbackup - ``` +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. -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 -``` +## Access Denied -**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" +Only current members of the configured operator group can view system runs, +view structured system diagnostics, or trigger protected actions. +```bash +id +getent group timelocker-operators ``` -BackupExecutionError: Insufficient disk space on repository -``` - -**Cause**: Not enough space available on the backup destination. - -**Solutions**: -1. **Check Available Space**: - ```bash - df -h /path/to/repository - ``` +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. -2. **Clean Up Old Snapshots**: - ```python - # Apply retention policy - repository.apply_retention_policy( - keep_daily=7, - keep_weekly=4, - keep_monthly=6 - ) - ``` +## Backend Unavailable -3. **Prune Repository**: - ```bash - restic -r /path/to/repo prune - ``` +Check the socket and service: -### Validation Errors - -#### Error: "Invalid job configuration" - -``` -ValidationError: Invalid job configuration: missing required field 'repository_id' -``` - -**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" - -``` -ValidationError: Data selection rules incompatible with backup tool 'borg' +```bash +systemctl status timelocker-control.socket +systemctl status timelocker-control.service +ls -l /run/timelocker/control.sock ``` -**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) - ``` +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. -3. **Use Compatible Tool**: - ```python - # Switch to tool with better selection support - config.tool_type = "restic" # Better pattern support - ``` +### Stop A Legacy Resident Backend -### Retry and Recovery Errors +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: -#### Error: "Maximum retry attempts exceeded" - -``` -BackupExecutionError: Maximum retry attempts exceeded (3 attempts) -Last error: Connection timeout +```bash +pkill -TERM -x timelocker-tray +sudo systemctl stop timelocker-control.service \ + timelocker-control.socket timelocker-status-events.socket ``` -**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 -``` +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 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: -**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 +```bash +sudo systemctl start timelocker-control.socket +timelocker-tray ``` -**Solutions**: - -1. **Check for Running Processes**: - ```bash - ps aux | grep restic - ``` +After a daemonless upgrade, remove stale legacy activation explicitly if it +was not already removed by the transaction: -2. **Remove Stale Lock** (if no process is running): - ```bash - restic -r /path/to/repo unlock - ``` - -3. **Wait for Lock Release**: - ```python - # Implement lock wait in configuration - config.additional_options = { - "lock-wait": 300 # Wait up to 5 minutes - } - ``` +```bash +sudo systemctl disable --now timelocker-status-events.socket +sudo rm -f /run/timelocker/status-events.sock +``` -#### Issue: Pack file corruption +## Scheduled Backup Did Not Run -**Error**: -``` -pack file is corrupted: checksum mismatch +```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**: +Distinguish: -1. **Run Repository Check**: - ```bash - restic -r /path/to/repo check - ``` +- 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. -2. **Rebuild Index**: - ```bash - restic -r /path/to/repo rebuild-index - ``` +## Retention Did Not Run -3. **Recover from Corruption**: - ```bash - restic -r /path/to/repo check --read-data - restic -r /path/to/repo prune - ``` +Retention has three valid triggers: -### Borg Issues +- after a successful backup; +- the independent retention timer; or +- an authorized explicit request. -#### Issue: Repository upgrade needed +Inspect records and the timer: -**Error**: -``` -repository version is too old, please upgrade +```bash +timelocker runs list --operation retention --limit 20 +systemctl status timelocker-retention.timer ``` -**Solutions**: +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. -1. **Upgrade Repository**: - ```bash - borg upgrade /path/to/repo - ``` +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. -2. **Check Borg Version**: - ```bash - borg --version - ``` +## Repository Password Or Credentials Requested -#### Issue: Checkpoint handling +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. -**Error**: -``` -checkpoint detected, resuming backup -``` - -**Note**: This is informational, not an error. Borg is resuming an interrupted backup. +Inspect metadata only: -**Actions**: -- Allow backup to continue -- Monitor progress -- Ensure stable connection for completion +```bash +sudo stat /etc/timelocker/production-target.json +sudo systemctl cat timelocker-control.service +``` -## Monitoring and Debugging +Do not paste secrets into command history, logs, issue reports, or ordinary +TimeLocker configuration. -### Enable Debug Logging +## Repository Lock Conflict -```python -import 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: -# 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 - -```python -# Enable detailed performance monitoring -config.additional_options = { - "verbose": True, - "stats": True, - "progress": True -} +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. -result = orchestrator.execute_backup_job(config) +## Tray Problems -# 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 +Share only structured safe summaries. Do not attach raw environment files, +repository configuration, passwords, cloud keys, raw journald output, or +protected path contents. -Configure notifications for backup events: +## Rollback -```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/..." -) -``` +For a faulty selected release or schedule: -### 4. Regular Maintenance +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. -Perform regular repository maintenance: +Rollback should preserve `/var/lib/timelocker` run and policy state. -```bash -# Weekly: Check repository integrity -restic -r /path/to/repo check - -# Monthly: Prune old data -restic -r /path/to/repo forget --keep-daily 7 --keep-weekly 4 --prune - -# 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 eefdebc..b5a1f93 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: "18-07-2026" +last_reviewed: "2026-07-27" tags: [guide, user, installation] links: tooling: [] @@ -15,12 +16,13 @@ links: - **Owner**: Documentation Team - **Status**: Approved - **Created Date**: 19-12-2024 -- **Last Updated**: 18-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 @@ -28,7 +30,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,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.0. -- **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 @@ -53,20 +57,34 @@ 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 ``` +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 +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 -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). @@ -113,20 +131,116 @@ 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 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/sbin/timelocker-deploy +/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/ +/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`. + +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. +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. -### 4.7 Understand Modern Packaging Features +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 +/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 and `system backup`/`system retention` requests require current +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. + +### 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 +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 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. + +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.8 Configure Environment +### 4.10 Configure Environment Basic configuration focuses on setting up repositories and targets. For cloud backends, export credentials: @@ -141,10 +255,6 @@ export B2_ACCOUNT_ID=your_account_id export B2_ACCOUNT_KEY=your_account_key ``` -### 4.9 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. @@ -152,6 +262,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) @@ -163,3 +278,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/guides/user/recovery-operations-guide.md b/docs/guides/user/recovery-operations-guide.md index 9b4e707..ca58ab6 100644 --- a/docs/guides/user/recovery-operations-guide.md +++ b/docs/guides/user/recovery-operations-guide.md @@ -1,539 +1,196 @@ -# 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 - -```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 -``` - -## 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" - -# Restore with exclusions -timelocker restore selective abc123 --target /restore/data \ - --include "**/*" --exclude "*/temp/*" --exclude "*.tmp" - -# Restore specific directory tree -timelocker restore selective abc123 --target /restore/projects \ - --include "/home/user/projects/**" --exclude "**/node_modules/**" -``` - -### Size and Date Filtering - -```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" - -# 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" -``` - -### Using Selection Templates - -```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" -``` - -## Monitoring Recovery Progress - -### Real-Time Progress Display - -```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 -``` - -### Checking Recovery Status - -```bash -# List active recovery operations -timelocker restore status - -# Check specific operation status -timelocker restore status recovery-001 - -# Monitor operation until completion -timelocker restore status recovery-001 --follow -``` - -### Cancelling Recovery Operations +For a configured repository named `primary`, load the intended environment and +initialize without placing the password on the command line: ```bash -# Cancel a running recovery operation -timelocker restore cancel recovery-001 - -# Cancel with cleanup -timelocker restore cancel recovery-001 --cleanup +set -a +. ~/.config/timelocker/backup.env +set +a +tl repos init primary --yes --config-dir ~/.config/timelocker ``` -## Verifying Restored Data - -### Automatic Verification +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. -```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 -``` +## Preview a backup -### Manual Verification +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 -# Verify completed recovery operation -timelocker restore verify recovery-001 +tl backup create ~/Documents/report.odt \ + --repository primary \ + --dry-run \ + --config-dir ~/.config/timelocker -# 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 +tl backup create ~/Documents \ + --repository primary \ + --dry-run \ + --config-dir ~/.config/timelocker ``` -### Handling Verification Failures +Missing sources and invalid targets fail before retry. Alternatively, use one +configured selection: ```bash -# Retry failed files -timelocker restore retry recovery-001 --failed-only - -# Re-verify after retry -timelocker restore verify recovery-001 +tl backup create \ + --selection documents \ + --repository primary \ + --dry-run \ + --config-dir ~/.config/timelocker ``` -## Common Scenarios +## Create a snapshot -### Scenario 1: Recovering Deleted Files +Remove `--dry-run` only after reviewing the preview: ```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 backup create ~/Documents/report.odt \ + --repository primary \ + --tags manual-check \ + --config-dir ~/.config/timelocker ``` -### Scenario 2: Disaster Recovery +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 -# 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 +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 \ + --config-dir ~/.config/timelocker ``` -### Scenario 3: Recovering Specific File Versions +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. -```bash -# 1. Compare snapshots to find version -timelocker snapshot compare abc123 def456 --path /home/user/document.txt +Record the full snapshot ID from the result or JSON listing. Reported file and +byte counts come from Restic's summary. -# 2. Browse older snapshot -timelocker snapshot browse abc123 --path /home/user +## List and inspect snapshots -# 3. Restore specific version -timelocker restore selective abc123 --target /restore/versions \ - --include "/home/user/document.txt" -``` - -### Scenario 4: Recovering Large Datasets +The restore listing exposes the full ID, canonical timestamp, host, user, tags, +and source paths: ```bash -# 1. Check available space -df -h /restore - -# 2. Estimate recovery size -timelocker snapshot info abc123 --size - -# 3. Perform recovery with progress monitoring -timelocker restore full abc123 --target /restore/large-dataset \ - --progress --verify --max-retries 5 - -# 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 +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 ``` -## Troubleshooting +`latest` resolves to the newest snapshot. An exact full ID or unambiguous ID +prefix is also accepted. -### Issue: Snapshot Not Found +## Restore safely -**Symptoms**: Error message "Snapshot not found" +Restore into a fresh directory first: -**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 +mkdir -p ~/timelocker-restore-check +tl restore full primary latest ~/timelocker-restore-check \ + --config-dir ~/.config/timelocker ``` -### Issue: Permission Denied +Restore an exact snapshot when reproducing a particular recovery point: -**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 +tl restore full primary 0123456789abcdef ~/timelocker-exact-check \ + --config-dir ~/.config/timelocker ``` -### Issue: Insufficient Disk Space +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. -**Symptoms**: Recovery fails with "No space left on device" +For selected paths: -**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 +tl restore files primary latest /home/user/Documents/report.odt \ + --target ~/timelocker-file-check \ + --config-dir ~/.config/timelocker ``` -### Issue: Slow Recovery Performance +## Verify recovered content -**Symptoms**: Recovery is taking longer than expected +Compare a known reference after the restore: -**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 +sha256sum ~/Documents/report.odt +sha256sum ~/timelocker-restore-check/home/user/Documents/report.odt ``` -### Issue: Verification Failures +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. -**Symptoms**: Files fail integrity verification +## Common failures -**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/history/spec-archive-index.md b/docs/history/spec-archive-index.md index e238a69..40bd89b 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-26 --- # Spec Archive Index @@ -16,6 +16,11 @@ 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 | 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` | | 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..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-18 +last_reviewed: 2026-08-13 --- # Spec Closure Log @@ -15,6 +15,133 @@ 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:** `b43acc57a00e854cb8b8d328590316c9cb959ba8` +- **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:** 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 +### 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:** `4122746` +- **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` +- **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 +- **Title:** System CLI, independent tray, retention, and control +- **Final spec commit:** `d4ce71dd05cb5d7278bf36a9fc43e557d68e1e31` +- **Closure cleanup commit:** `aba95875f453dd6abf39a1fdc6af25fd38c62db4` +- **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 +- **Title:** Release readiness stabilization requirements +- **Final spec commit:** `7fd11f9aa1cbc670d5e8b429aede4a7c01e185a4` +- **Closure cleanup commit:** `6334af0690b5b9e8b6575042269e5b73914a9295` +- **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 +- **Title:** NPBackup migration parity requirements +- **Final spec commit:** `5830194` +- **Closure cleanup commit:** `1bfea08` +- **Closure action:** removed +- **Durable docs updated:** + - `docs/guides/user/recovery-operations-guide.md` + - `docs/guides/developer/scheduling-guide.md` +- **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:** + - 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 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..4caceb3 100644 --- a/docs/processes/version-management.md +++ b/docs/processes/version-management.md @@ -3,213 +3,157 @@ title: Version management and GitHub releases doc_type: process status: active owner: Auriora Team -last_reviewed: 2026-07-18 +last_reviewed: 2026-08-13 --- # 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 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. +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`, `tl`, + `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. + +## 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. + +## 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, control protocol version, and entrypoint. Schema 3 +explicitly has no privileged event protocol or status service. + +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 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. Schema-1/2 rollback is rejected because it can re-enable the +removed resident event service. + +## Current Deferrals + +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/reference/timelocker-cli-command-hierarchy.md b/docs/reference/timelocker-cli-command-hierarchy.md index be96f7c..5a078b4 100644 --- a/docs/reference/timelocker-cli-command-hierarchy.md +++ b/docs/reference/timelocker-cli-command-hierarchy.md @@ -1,10 +1,11 @@ --- title: "Reference: TimeLocker CLI Command Hierarchy" +doc_type: reference 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 +13,135 @@ 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 +├── system +└── restore +``` + +Run `timelocker GROUP --help` for the current leaf commands and options. -## 1. Purpose +## Protected System Reads -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. +```text +timelocker runs list + [--limit N] + [--operation backup|retention] + [--state STATE] + [--json] -## 2. Specification +timelocker runs show RUN_ID [--json] -### 2.1 Design Philosophy +timelocker logs view + [--scope local|system] + [--lines N] + [--level LEVEL] + [--component COMPONENT] + [--since TIME] +``` -- 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. +`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. -### 2.2 Root Command Summary +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. -- **Root**: `timelocker` (alias `tl`) -- **Description**: TimeLocker – backup orchestration with Rich terminal output -- **Framework**: Typer + Rich +## Protected System Actions -### 2.3 Command Tree +```text +timelocker system backup [--target TARGET] +timelocker system retention + --policy-fingerprint FINGERPRINT + [--dry-run] ``` -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 + +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: + +```text +timelocker-tray + status + serve + backup_now + retention_now + open_ui + quit ``` -### 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 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 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 the supported operator workflow; it remains a restricted internal +primitive used by the transactional entrypoint. + +## 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/README.md b/docs/specs/README.md index 22a4e53..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-07-18 +last_reviewed: 2026-08-13 --- # Active Specification Packages @@ -15,12 +15,16 @@ accepted content has been promoted and the package is closed. ## Current Packages -None. +There are no active specification packages. ## 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. +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 @@ -59,6 +63,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 5df977f..abd1008 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", @@ -74,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", @@ -88,6 +88,9 @@ gui = [ [project.scripts] 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" @@ -106,7 +109,18 @@ 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", + "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", ] [tool.pytest.ini_options] @@ -139,6 +153,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/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..ca54810 --- /dev/null +++ b/scripts/deploy_t011_linux.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""Deprecated Spec 010 compatibility wrapper for ``timelocker-deploy``.""" + +from TimeLocker.system_control.deployment_entry import main + + +if __name__ == "__main__": + raise SystemExit(main()) 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/generate_tray_status_icons.py b/scripts/generate_tray_status_icons.py new file mode 100644 index 0000000..d7b8e79 --- /dev/null +++ b/scripts/generate_tray_status_icons.py @@ -0,0 +1,129 @@ +#!/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_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( + (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 = { + "connecting": _draw_connecting, + "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 new file mode 100755 index 0000000..420ebb9 --- /dev/null +++ b/scripts/smoke_release_artifact.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Install one built artifact and smoke its public and system entry points.""" + +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, 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}, " + 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 +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-deploy-launcher", + "timelocker-retention.service", + "timelocker-retention.timer", + "timelocker-icon-connecting.png", + "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 +assert not assets.joinpath("timelocker-status-events.socket").is_file() +manifest = ReleaseManifest.from_mapping( + { + "schema_version": 3, + "release_id": "a" * 40, + "package_version": sys.argv[1], + "control_protocol_version": PROTOCOL_VERSION, + "entrypoint": "venv/bin/timelocker", + } +) +assert manifest.control_protocol_version == PROTOCOL_VERSION +assert manifest.event_protocol_version is None +""" + run([str(python), "-c", contract, expected_version]) + + +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"]) + 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]}") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_release_artifacts.py b/scripts/validate_release_artifacts.py new file mode 100755 index 0000000..fb2da24 --- /dev/null +++ b/scripts/validate_release_artifacts.py @@ -0,0 +1,162 @@ +#!/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", + "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", +} + + +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/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/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/__init__.py b/src/TimeLocker/__init__.py index c932260..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.0" +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', - # Monitoring components - 'StatusReporter', - 'NotificationService', +def __dir__() -> list[str]: + """Include lazy compatibility exports in interactive discovery.""" + return sorted({*globals(), *_LAZY_EXPORTS}) - # 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/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_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 4831aad..1cf233e 100644 --- a/src/TimeLocker/backup_snapshot.py +++ b/src/TimeLocker/backup_snapshot.py @@ -42,15 +42,30 @@ 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, 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""" @@ -89,10 +104,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/backup_target.py b/src/TimeLocker/backup_target.py index db1c9f2..3f15cc9 100644 --- a/src/TimeLocker/backup_target.py +++ b/src/TimeLocker/backup_target.py @@ -35,6 +35,11 @@ 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, + exclude_files: Optional[List[str]] = None, + exclude_caches: bool = False, + backend_options: Optional[List[str]] = None, **kwargs): """ Initialize a backup target @@ -45,6 +50,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 +91,11 @@ def __init__(self, self.selection = selection self.tags = tags or [] 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 @@ -167,4 +179,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.py b/src/TimeLocker/cli.py index ddf9772..5821090 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,29 +408,33 @@ 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 with ❤️ 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 = "⟨OPTIONS⟩" +app.info.options_metavar = "" def _merge_typer_app(target_app: typer.Typer, source_app: typer.Typer) -> None: @@ -415,21 +444,35 @@ 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⟩" - -snapshots_app = typer.Typer(help="Snapshot operations", context_settings=CLI_CONTEXT_SETTINGS) -snapshots_app.info.options_metavar = "⟨OPTIONS⟩" -repos_app = typer.Typer(help="Repository operations", context_settings=CLI_CONTEXT_SETTINGS) -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⟩" -credentials_app = typer.Typer(help="Credential management commands", context_settings=CLI_CONTEXT_SETTINGS) -credentials_app.info.options_metavar = "⟨OPTIONS⟩" +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.info.options_metavar = "" +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.info.options_metavar = "" +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.info.options_metavar = "⟨OPTIONS⟩" +security_app = typer.Typer( + help="Security management commands", context_settings=CLI_CONTEXT_SETTINGS +) +security_app.info.options_metavar = "" # Add sub-apps to main app app.add_typer(backup_app, name="backup") @@ -442,15 +485,22 @@ 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.info.options_metavar = "⟨OPTIONS⟩" +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.info.options_metavar = "⟨OPTIONS⟩" +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.info.options_metavar = "⟨OPTIONS⟩" +migrate_app = typer.Typer( + help="Configuration migration and validation commands", + context_settings=CLI_CONTEXT_SETTINGS, +) +migrate_app.info.options_metavar = "" # Add config sub-apps config_app.add_typer(config_import_app, name="import") @@ -460,8 +510,10 @@ 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.info.options_metavar = "⟨OPTIONS⟩" +repos_credentials_app = typer.Typer( + help="Repository credential management", context_settings=CLI_CONTEXT_SETTINGS +) +repos_credentials_app.info.options_metavar = "" # Add repos sub-apps repos_app.add_typer(repos_credentials_app, name="credentials") @@ -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,55 @@ 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]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" + ) console.print("[bold]Quick Start:[/bold]") console.print(" 1. Add a repository:") @@ -522,7 +611,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:") @@ -544,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]") @@ -561,18 +654,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 +691,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 +704,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 +742,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 +779,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 +804,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 +814,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 +858,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 +920,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 +951,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 +976,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 +1000,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 +1028,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 +1052,63 @@ 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 == "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("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 +1116,42 @@ 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, runs, system, " + "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 +1167,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 +1189,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 +1202,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 +1213,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 +1222,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 +1232,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 +1349,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 +1361,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 +1383,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 +1408,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 +1432,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 +1471,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 +1544,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 +1568,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 +1591,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 +1603,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 +1616,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 +1642,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 +1727,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 +1737,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 +1756,8 @@ def config_import_timeshift( backup_paths=None, assume_yes=assume_yes_flag, dry_run=dry_run, - )) + ), + ) success_flag = result.success @@ -1349,7 +1767,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 +1779,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 +1806,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 @@ -1443,10 +1869,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" @@ -1467,10 +1889,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 +1921,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 +1948,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 +1989,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 +2009,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 +2020,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 +2045,24 @@ 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))) + credential_dir = config_dir / "credentials" if config_dir is not None else None + return cast( + _CredentialManagerLike, + cast(object, CredentialManager(config_dir=credential_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 +2070,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 +2107,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 +2124,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 +2149,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 +2176,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 +2200,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 +2224,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 +2247,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 +2275,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 +2285,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 +2315,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 +2353,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 +2388,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 +2398,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 +2425,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 +2446,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 +2492,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 +2505,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 +2526,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 +2611,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 +2625,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 +2649,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 +2690,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 +2700,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 +2752,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 +2791,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 +2810,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 +2829,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 +2868,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 +2903,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 +2922,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 +2934,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 +2970,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 +2987,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 +3031,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 +3075,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 +3098,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 +3128,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 +3202,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,14 +3223,23 @@ 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}") +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 @@ -2559,7 +3249,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/backup.py b/src/TimeLocker/cli_modules/commands/backup.py index 43f46c1..58ce31b 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, ...] @@ -105,6 +105,18 @@ 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, + 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, @@ -236,7 +248,12 @@ 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, + 'exclude_files': exclude_file or [], + 'exclude_caches': exclude_caches, + 'backend_options': backend_option or [], } try: @@ -281,7 +298,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 +340,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 "" @@ -401,6 +427,11 @@ def backup_create( tags=tags or [], include_patterns=include or [], 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/base.py b/src/TimeLocker/cli_modules/commands/base.py index 430806b..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/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/cli_modules/commands/repositories.py b/src/TimeLocker/cli_modules/commands/repositories.py index e9611dd..288b05b 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, @@ -185,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) @@ -1699,16 +1705,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") @@ -1920,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[ @@ -1947,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_modules/commands/restore.py b/src/TimeLocker/cli_modules/commands/restore.py index 9ea156e..c66cac3 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): @@ -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..955610b 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,22 +83,91 @@ 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)]) + + 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)]) + 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'}: + 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())]) + 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") + 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') - policy = schedule.get('policy', 'N/A') - - table.add_row(name, policy, frequency, next_run, enabled) + repository = schedule.get('repository', 'N/A') + 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'])}") + 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") return table @@ -106,38 +176,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 +232,20 @@ 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, + "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, "frequency": frequency, "cron_expression": cron_expression, "enabled": enabled, @@ -196,33 +256,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 +310,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,18 +320,14 @@ 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] Description=TimeLocker Backup Timer - {schedule_name} -Requires=timelocker-{schedule_name}.service [Timer] OnCalendar={oncalendar} @@ -280,8 +342,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 +366,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 +384,26 @@ 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, + 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", + 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, @@ -342,8 +425,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 +457,19 @@ 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, + "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, "frequency": frequency or "custom", "cron_expression": cron_expression, "enabled": enabled, @@ -383,7 +486,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 +548,16 @@ 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]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" f"[bold]Cron Expression:[/bold] {schedule.get('cron_expression', 'N/A')}\n" f"[bold]Status:[/bold] {enabled_status}\n" @@ -463,6 +576,25 @@ 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, + 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", + 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, @@ -482,6 +614,32 @@ 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 tags is not None: + 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: + schedule['one_file_system'] = one_file_system if frequency is not None: schedule['frequency'] = frequency if cron is not None: @@ -634,7 +792,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 +818,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 +872,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/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/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/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/cli_services.py b/src/TimeLocker/cli_services.py index 9ae54aa..33d0db4 100644 --- a/src/TimeLocker/cli_services.py +++ b/src/TimeLocker/cli_services.py @@ -185,6 +185,11 @@ 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 + exclude_files: List[Path] = None + exclude_caches: bool = False + backend_options: List[str] = None dry_run: bool = False def __post_init__(self): @@ -194,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: @@ -772,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 @@ -837,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, @@ -1010,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.""" @@ -1025,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, @@ -1126,7 +1143,12 @@ 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, + '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: @@ -1169,7 +1191,12 @@ 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, + '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/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/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/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/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 8cdbdd0..7d119eb 100644 --- a/src/TimeLocker/monitoring/system_tray_integration.py +++ b/src/TimeLocker/monitoring/system_tray_integration.py @@ -16,24 +16,70 @@ """ import logging +import importlib +import os import sys import threading from datetime import datetime -from enum import Enum from pathlib import Path -from typing import Optional, Callable, Dict, Any +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.""" + 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: + 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 class TrayStatus(Enum): """System tray status indicators""" + + CONNECTING = "connecting" IDLE = "idle" RUNNING = "running" SUCCESS = "success" @@ -41,88 +87,144 @@ 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 + health: str = "Unknown" + activity: str = "Connecting" + 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.""" + return ( + f"State: {status_info.health}", + f"Activity: {status_info.activity}", + "Last Backup: " + + _format_local_time( + status_info.last_successful_backup_time, + missing="Never", + ), + ) + + class SystemTrayIntegration: """ System tray integration for TimeLocker 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 """ - - 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 - + Args: app_name: Application name for tray icon """ self.app_name = app_name - self.current_status = TrayStatus.IDLE + self.menu_actions = frozenset( + menu_actions + if menu_actions is not None + else {"backup_now", "retention_now", "quit"} + ) + 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 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: if sys.platform == "linux": - self._tray_impl = LinuxSystemTray(self.app_name) + 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, 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 - + 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 @@ -130,99 +232,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)) + 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}") - + 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}") - + lines.extend(_status_menu_labels(status_info)) 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 update_last_backup_time(self, backup_time: datetime | None) -> None: + """Compatibility helper for callers not yet using complete status info.""" + if not self.is_available(): + return + + 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]): """ 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(): @@ -236,243 +346,318 @@ 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 - + Args: app_name: Application name """ self.app_name = app_name + self.menu_actions = ( + menu_actions + if menu_actions is not None + else frozenset({"backup_now", "retention_now", "quit"}) + ) self._icon = None 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 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 + _linux_tray_icon_path(TrayStatus.CONNECTING), + 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") raise SystemTrayError("System tray not available on this Linux system") - + def _create_gtk_menu(self): """Create GTK context menu""" try: - from gi.repository import Gtk - + 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")) - self._menu.append(status_item) - + + # AppIndicator tooltips are not consistently available on Linux. + self._status_items = [] + initial_status = TrayStatusInfo( + status=TrayStatus.CONNECTING, + tooltip="TimeLocker - Connecting", + ) + 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") - 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 + 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()) - + # 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" - } - - icon_name = icon_map.get(status, "dialog-information") + try: - self._indicator.set_icon(icon_name) + self._indicator.set_icon(_linux_tray_icon_path(status)) 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 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 + + try: + 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 tray status rows: {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 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: - 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}") 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 - + Args: app_name: Application name """ self.app_name = app_name + self.menu_actions = ( + menu_actions + if menu_actions is not None + else frozenset({"backup_now", "retention_now", "quit"}) + ) 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._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")), - None, # Separator - rumps.MenuItem("Quit", callback=lambda _: self._trigger_menu_action("quit")) + + self._status_items = [ + rumps.MenuItem(label) + for label in _status_menu_labels( + TrayStatusInfo( + status=TrayStatus.CONNECTING, + tooltip="TimeLocker - Connecting", + ) + ) ] + menu = [ + *self._status_items, + rumps.MenuItem( + "Backup Now", + callback=lambda _: self._trigger_menu_action("backup_now"), + ), + ] + 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}") - + + 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: 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.CONNECTING: "…", 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: @@ -484,90 +669,119 @@ 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 - + Args: app_name: Application name """ self.app_name = app_name + self.menu_actions = ( + menu_actions + if menu_actions is not None + else frozenset({"backup_now", "retention_now", "quit"}) + ) 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")) + + status_info = getattr( + self, + "_status_info", + TrayStatusInfo( + status=TrayStatus.CONNECTING, + tooltip="TimeLocker - Connecting", + ), + ) + items = [ + 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( + "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 - + + 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: 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) @@ -575,52 +789,53 @@ 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.CONNECTING: "deepskyblue", + 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/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/recovery_orchestrator.py b/src/TimeLocker/recovery_orchestrator.py index 0dc2b2e..3ea2c78 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" ) @@ -852,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 d3c2181..660bd14 100644 --- a/src/TimeLocker/restic/restic_repository.py +++ b/src/TimeLocker/restic/restic_repository.py @@ -321,6 +321,42 @@ 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" + ) + + 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 []) @@ -336,9 +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: @@ -610,7 +659,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 +667,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: @@ -633,6 +684,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'") @@ -642,6 +695,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/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/src/TimeLocker/services/backup_orchestrator.py b/src/TimeLocker/services/backup_orchestrator.py index a8b3cac..5103bad 100644 --- a/src/TimeLocker/services/backup_orchestrator.py +++ b/src/TimeLocker/services/backup_orchestrator.py @@ -872,10 +872,16 @@ 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)), + 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) @@ -918,7 +924,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 +954,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 +1004,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 +1036,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 +1060,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 +1071,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 +1084,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}") @@ -1128,7 +1152,12 @@ 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)), + 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/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/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/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/system_control/__init__.py b/src/TimeLocker/system_control/__init__.py new file mode 100644 index 0000000..4ad433e --- /dev/null +++ b/src/TimeLocker/system_control/__init__.py @@ -0,0 +1,217 @@ +"""Platform-neutral contracts for privileged TimeLocker system operations.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any + +__all__ = [ + "ActionReceipt", + "ActionClass", + "ActionRoute", + "AuditEvent", + "AuditSink", + "BackendStatus", + "BackupScheduleHealth", + "AtomicRecordStore", + "BackupActionRequest", + "BoundedStatusEventBroker", + "BoundedStatusSubscription", + "FileSystemProtectedStateWatcher", + "ControlRequestHandler", + "DiagnosticCode", + "DiagnosticComponent", + "DiagnosticLevel", + "DiagnosticQuery", + "DiagnosticRecord", + "DiagnosticView", + "GroupMembershipResolver", + "InvalidTransitionError", + "LocalControlTransport", + "LocalControlDispatcher", + "MutationConflictError", + "OperationTrigger", + "OperationType", + "PeerIdentity", + "PeerIdentityProvider", + "ProtocolErrorCode", + "ProtectedStateChangeMonitor", + "ProtectedStateWatcher", + "RecordCorruptionError", + "RecordNotFoundError", + "RecordStoreError", + "RequestEnvelope", + "ResponseEnvelope", + "ResponseStatus", + "ResultCode", + "RetentionActionRequest", + "RetentionAdapter", + "RetentionExecutionResult", + "RetentionExecutor", + "RetentionPlan", + "RetentionPolicy", + "RetentionRequestHandler", + "RetentionTriggerCoordinator", + "RetentionTriggerStore", + "ScheduleSummary", + "RepositoryMutationLease", + "RepositoryMutationLock", + "RunQuery", + "RunRecord", + "RunRecordView", + "RunTransition", + "RunState", + "StatusEvent", + "StatusEventAccessDenied", + "StatusEventBroker", + "StatusEventClient", + "StatusEventConnectionState", + "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", + "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/action_policy.py b/src/TimeLocker/system_control/action_policy.py new file mode 100644 index 0000000..c20bc05 --- /dev/null +++ b/src/TimeLocker/system_control/action_policy.py @@ -0,0 +1,215 @@ +"""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"), + ("system", "status"), + ("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/system-control-policy.json b/src/TimeLocker/system_control/assets/system-control-policy.json new file mode 100644 index 0000000..851b932 --- /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": 2, + "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..98f2e73 --- /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=exec +Sockets=timelocker-control.socket +User=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 +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +ReadWritePaths=/run/timelocker /var/lib/timelocker 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..5f7b2bd --- /dev/null +++ b/src/TimeLocker/system_control/assets/timelocker-control.socket @@ -0,0 +1,15 @@ +[Unit] +Description=TimeLocker local system-control socket + +[Socket] +ListenStream=/run/timelocker/control.sock +DirectoryMode=0755 +SocketUser=root +SocketGroup=timelocker-operators +SocketMode=0660 +FileDescriptorName=control +RemoveOnStop=yes +Service=timelocker-control.service + +[Install] +WantedBy=sockets.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-icon-connecting.png b/src/TimeLocker/system_control/assets/timelocker-icon-connecting.png new file mode 100644 index 0000000..ceb2970 Binary files /dev/null and b/src/TimeLocker/system_control/assets/timelocker-icon-connecting.png differ 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 0000000..fae8020 Binary files /dev/null and b/src/TimeLocker/system_control/assets/timelocker-icon-error.png differ 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 0000000..6bf3303 Binary files /dev/null and b/src/TimeLocker/system_control/assets/timelocker-icon-idle.png differ 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 0000000..e288324 Binary files /dev/null and b/src/TimeLocker/system_control/assets/timelocker-icon-running.png differ diff --git a/src/TimeLocker/system_control/assets/timelocker-icon-success.png b/src/TimeLocker/system_control/assets/timelocker-icon-success.png new file mode 100644 index 0000000..4859479 Binary files /dev/null and b/src/TimeLocker/system_control/assets/timelocker-icon-success.png differ 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 0000000..42cd0ff Binary files /dev/null and b/src/TimeLocker/system_control/assets/timelocker-icon-warning.png differ 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 0000000..811ae04 Binary files /dev/null and b/src/TimeLocker/system_control/assets/timelocker-icon.png differ 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/timelocker-retention.service b/src/TimeLocker/system_control/assets/timelocker-retention.service new file mode 100644 index 0000000..3ba89ed --- /dev/null +++ b/src/TimeLocker/system_control/assets/timelocker-retention.service @@ -0,0 +1,17 @@ +[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 +EnvironmentFile=-/etc/timelocker/retention.env +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..72a53d8 --- /dev/null +++ b/src/TimeLocker/system_control/assets/timelocker-tray.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=TimeLocker +Comment=Backup status and approved actions +Exec=/usr/local/bin/timelocker-tray +Icon=timelocker +Terminal=false +NoDisplay=true +X-GNOME-Autostart-enabled=true 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/backend_entry.py b/src/TimeLocker/system_control/backend_entry.py new file mode 100644 index 0000000..165a42e --- /dev/null +++ b/src/TimeLocker/system_control/backend_entry.py @@ -0,0 +1,1066 @@ +"""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 TYPE_CHECKING, 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 +if TYPE_CHECKING: + from .linux_adapter import LinuxUnixSocketTransport +from .models import ( + ActionReceipt, + BackupActionRequest, + DiagnosticQuery, + DiagnosticRecord, + DiagnosticView, + PROTOCOL_VERSION, + RetentionPolicy, + RunQuery, + RunRecordView, + ScheduleSummary, + StatusRevision, + StatusSnapshot, + 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, +) +from .retention import ( + RetentionAdapter, + RetentionExecutionResult, + RetentionExecutor, + RetentionPlan, + RetentionRequestHandler, + RetentionTriggerCoordinator, + RetentionTriggerStore, +) +from .storage import ( + AtomicRecordStore, + RepositoryMutationLock, + reconcile_abandoned_runs, +) +from .schedule_health import ( + BackupScheduleObservation, + SystemdScheduleSummaryProvider, + derive_backup_schedule_health, +) +from .status_snapshot import AtomicStatusSnapshotStore +from .types import ( + BackendStatus, + BackupScheduleHealth, + DiagnosticCode, + DiagnosticComponent, + DiagnosticLevel, + OperationType, + OperationTrigger, + 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 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.""" + + 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 + status_path: Path = Path("/run/timelocker/status.json") + + def __post_init__(self) -> None: + for field_name in ( + "policy_path", + "record_root", + "lock_root", + "trigger_root", + "audit_log_path", + "status_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 + membership_resolver: GroupMembershipResolver + reconciled_run_ids: tuple[UUID, ...] = () + + def serve_once(self) -> None: + """Serve one control request and release all process resources.""" + try: + self.transport.serve_once(self.dispatcher) + finally: + self.stop() + + 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, + 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, + 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"}: + 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) + 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=publish_status_change, + ) + store_ref["store"] = store + 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() + ) + 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 + 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, + ) + ) + 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() + ) + + 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, + backup_schedule_observer=backup_schedule_observer, + 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, + membership_resolver=membership_resolver, + reconciled_run_ids=tuple(record.run_id for record in reconciled), + ) + + +def run_linux_backend(**kwargs: object) -> None: + """Build a socket-activated helper, serve one request, and exit.""" + service = build_linux_backend(**kwargs) + service.serve_once() + + +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, + ) + 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, + expected_owner=paths.expected_owner, + ) + plan = _apply_policy_defaults( + provider.resolve_retention_plan(policy), + policy.retention, + ) + run = RetentionExecutor( + store=store, + locks=locks, + adapter=adapter, + ).execute( + plan, + trigger=trigger, + dry_run=False, + ) + if run.state is RunState.FAILED: + 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, + ) + policy = load_system_policy( + paths.policy_path, + expected_owner=paths.expected_owner, + ) + schedule_provider = SystemdScheduleSummaryProvider() + SystemBackupRunCoordinator( + 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() + + +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, + ) + policy = load_system_policy( + paths.policy_path, + expected_owner=paths.expected_owner, + ) + schedule_provider = SystemdScheduleSummaryProvider() + SystemBackupRunCoordinator( + 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) + + +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 _systemd_socket_descriptor( + environment: Mapping[str, str] | None = None, + *, + process_id: int | None = None, +) -> 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: + 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) != 1 + ): + raise RuntimeError("required systemd socket for control is unavailable") + names = environment.get("LISTEN_FDNAMES", "").split(":") + if names != ["control"]: + raise RuntimeError("required systemd socket name for control is unavailable") + return 3 + + +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, + ) + 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, + 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, + ) + parser.add_argument( + "--production-target", + type=Path, + default=DEFAULT_PRODUCTION_TARGET_PATH, + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--retention-trigger", + choices=("scheduled", "backup-success"), + 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( + policy_path=arguments.policy, + state_root=arguments.state_root, + ) + if arguments.scheduled_retention: + run_scheduled_retention( + paths=paths, + production_target_path=arguments.production_target, + trigger=( + OperationTrigger.BACKUP_SUCCESS + if arguments.retention_trigger == "backup-success" + 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: + control_descriptor = _systemd_socket_descriptor() + run_linux_backend( + paths=paths, + socket_mode="systemd", + systemd_descriptor=control_descriptor, + 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") + + +def _build_transport( + *, + policy: SystemPolicy, + socket_mode: str, + listener: socket.socket | None, + systemd_descriptor: int, + request_timeout_seconds: float, + stop_event: Event, +) -> LinuxUnixSocketTransport: + from .linux_adapter import 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], + backup_schedule_observer: BackupScheduleObserver | None = None, +) -> Mapping[SystemAction, Callable[[object], object]]: + from .protocol import RequestEnvelope + + query_revision = StatusRevision(uuid4(), 0) + + 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 status_snapshot(_request: object) -> Mapping[str, object]: + 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} + + 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.STATUS_SNAPSHOT: status_snapshot, + 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 _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, +) -> 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", + "run_backup_record_finish", + "run_backup_record_start", +] + + +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/client.py b/src/TimeLocker/system_control/client.py new file mode 100644 index 0000000..15945f3 --- /dev/null +++ b/src/TimeLocker/system_control/client.py @@ -0,0 +1,198 @@ +"""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, + ScheduleSummary, + RetentionActionRequest, + RunQuery, + RunRecordView, + StatusSnapshot, +) +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 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, + 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/deployment.py b/src/TimeLocker/system_control/deployment.py new file mode 100644 index 0000000..cb785e0 --- /dev/null +++ b/src/TimeLocker/system_control/deployment.py @@ -0,0 +1,412 @@ +"""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, STATUS_EVENT_PROTOCOL_VERSION +from .release_launcher import ( + ImmutableReleaseResolver, + ReleaseManifest, + SelectedRelease, +) +from .validation import require_bool, 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") + + +@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.""" + + 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: ReleaseHealthProbe, + ) -> SelectedRelease: + """Select a release only after its complete compatibility probe passes.""" + manifest = self.resolver.release_manifest(release_id) + targets = self._probe_targets(release_id, manifest) + result = health_probe(targets) + if not self._probe_passed(result, targets, require_event=False): + 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: ReleaseHealthProbe, + ) -> SelectedRelease: + """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") + 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): + 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( + *, + 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"), + autostart_root: Path = Path("/etc/xdg/autostart"), + icon_root: Path = Path("/usr/local/share/icons/hicolor/1024x1024/apps"), +) -> 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-deploy-launcher", + admin_bin_root / "timelocker-deploy", + 0o755, + ), + 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, + ), + AssetTarget( + "timelocker-icon.png", + icon_root / "timelocker.png", + 0o644, + ), + *( + AssetTarget( + f"timelocker-icon-{status}.png", + icon_root / f"timelocker-{status}.png", + 0o644, + ) + for status in ( + "connecting", + "idle", + "running", + "success", + "warning", + "error", + ) + ), + ) + + +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 build_release_manifest( + *, + release_id: str, + package_version: str, +) -> dict[str, object]: + """Build daemonless schema-3 metadata for one selected release.""" + mapping: dict[str, object] = { + "schema_version": 3, + "release_id": release_id, + "package_version": require_safe_identifier( + package_version, + field="package_version", + maximum=64, + ), + "control_protocol_version": 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() + 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/deployment_entry.py b/src/TimeLocker/system_control/deployment_entry.py new file mode 100644 index 0000000..89783be --- /dev/null +++ b/src/TimeLocker/system_control/deployment_entry.py @@ -0,0 +1,1618 @@ +#!/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", +) +PRE_ACTIVATION_ACTIVE_UNITS = ( + "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 PRE_ACTIVATION_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", + ) + 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( + [ + python, + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--no-index", + *pip_arguments, + 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 PRE_ACTIVATION_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 _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) + 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/dispatcher.py b/src/TimeLocker/system_control/dispatcher.py new file mode 100644 index 0000000..834e08b --- /dev/null +++ b/src/TimeLocker/system_control/dispatcher.py @@ -0,0 +1,218 @@ +"""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 PROTOCOL_VERSION, 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") != PROTOCOL_VERSION + ): + return ProtocolErrorCode.CONTRACT_VERSION_UNSUPPORTED + except (UnicodeDecodeError, json.JSONDecodeError): + pass + return ProtocolErrorCode.INVALID_REQUEST diff --git a/src/TimeLocker/system_control/event_client.py b/src/TimeLocker/system_control/event_client.py new file mode 100644 index 0000000..066e9de --- /dev/null +++ b/src/TimeLocker/system_control/event_client.py @@ -0,0 +1,159 @@ +"""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 +from .types import StatusEventConnectionState + + +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, + *, + 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): + report(StatusEventConnectionState.UNAVAILABLE) + finally: + if connection is not None: + try: + 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) + + 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 new file mode 100644 index 0000000..82bd14d --- /dev/null +++ b/src/TimeLocker/system_control/interfaces.py @@ -0,0 +1,162 @@ +"""Platform and client interfaces for the TimeLocker system-control boundary.""" + +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from typing import Protocol +from uuid import UUID + +from .models import ( + ActionReceipt, + BackupActionRequest, + DiagnosticQuery, + DiagnosticView, + ScheduleSummary, + RetentionActionRequest, + RunQuery, + RunRecordView, + StatusEvent, + StatusRevision, + StatusSnapshot, +) +from .types import StatusEventConnectionState, StatusEventKind +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 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, + *, + on_connection_state: ( + Callable[[StatusEventConnectionState], None] | None + ) = None, + ) -> Iterator[StatusEvent]: + """Yield status events until the caller signals shutdown.""" + + +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.""" + + 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/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/linux_adapter.py b/src/TimeLocker/system_control/linux_adapter.py new file mode 100644 index 0000000..b2cc636 --- /dev/null +++ b/src/TimeLocker/system_control/linux_adapter.py @@ -0,0 +1,381 @@ +"""Linux peer identity, NSS authorization, and Unix-socket transport adapters.""" + +from __future__ import annotations + +import grp +import json +import pwd +import socket +import struct +from threading import BoundedSemaphore, Event, Lock, Thread, current_thread + +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: + """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(): + 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, + 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) + + +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 + 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..fd4f9e4 --- /dev/null +++ b/src/TimeLocker/system_control/models.py @@ -0,0 +1,1218 @@ +"""Strict platform-neutral models for TimeLocker system operations.""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from types import MappingProxyType +from typing import Any, ClassVar, Iterable, Mapping +from uuid import UUID + +from .types import ( + BackendStatus, + BackupScheduleHealth, + DiagnosticCode, + DiagnosticComponent, + DiagnosticLevel, + OperationTrigger, + OperationType, + ResultCode, + RunState, + StatusEventKind, +) +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 = 2 +STATUS_EVENT_SCHEMA_VERSION = 1 +STATUS_EVENT_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 not in {1, 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 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.""" + + 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 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 + backup_schedule_health: BackupScheduleHealth = BackupScheduleHealth.HEALTHY + 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, + "backup_schedule_health", + require_enum( + self.backup_schedule_health, + BackupScheduleHealth, + field="backup_schedule_health", + ), + ) + 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", + "backup_schedule_health", + "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"], + 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( + 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, + backup_schedule_health: BackupScheduleHealth = BackupScheduleHealth.HEALTHY, + 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, + backup_schedule_health=backup_schedule_health, + 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, + "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 + ), + "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.""" + + 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, + ) + + +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/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/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/src/TimeLocker/system_control/production_retention.py b/src/TimeLocker/system_control/production_retention.py new file mode 100644 index 0000000..e1cc38f --- /dev/null +++ b/src/TimeLocker/system_control/production_retention.py @@ -0,0 +1,289 @@ +"""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") +DEFAULT_RETENTION_ENABLE_MARKER = Path("/etc/timelocker/retention-enabled") +_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 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: + 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) & 0o077: + raise PermissionError("protected file must be owner-only") + + +__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/src/TimeLocker/system_control/protocol.py b/src/TimeLocker/system_control/protocol.py new file mode 100644 index 0000000..f87c359 --- /dev/null +++ b/src/TimeLocker/system_control/protocol.py @@ -0,0 +1,513 @@ +"""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, + StatusSnapshot, +) +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.STATUS_SNAPSHOT: (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"}) +_STATUS_SNAPSHOT_FIELDS = frozenset( + { + "revision", + "backend_status", + "backup_schedule_health", + "active_operations", + "latest_backup", + "last_successful_backup_completed_at", + "latest_retention", + "next_backup_at", + "next_retention_at", + } +) + +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.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: + 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 + + +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_admin.py b/src/TimeLocker/system_control/release_admin.py new file mode 100644 index 0000000..6d59cbd --- /dev/null +++ b/src/TimeLocker/system_control/release_admin.py @@ -0,0 +1,32 @@ +"""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") + select.add_argument("--expected-current") + subcommands.add_parser("rollback") + arguments = parser.parse_args() + resolver = ImmutableReleaseResolver() + try: + if arguments.command == "select": + 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: + 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..bfb728b --- /dev/null +++ b/src/TimeLocker/system_control/release_launcher.py @@ -0,0 +1,472 @@ +"""Fail-closed resolution for root-owned immutable TimeLocker releases.""" + +import json +import os +import stat +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +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 + + +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", + "tray": "venv/bin/timelocker-tray", +} + + +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 + control_protocol_version: int + event_protocol_version: int | None + entrypoint: str = "venv/bin/timelocker" + schema_version: int = 3 + + @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=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", + required=frozenset( + { + "schema_version", + "release_id", + "package_version", + "control_protocol_version", + "event_protocol_version", + "entrypoint", + } + ), + ) + entrypoint = mapping["entrypoint"] + if entrypoint != "venv/bin/timelocker": + raise ReleaseResolutionError("release entrypoint is not allowlisted") + return cls( + 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=1, + maximum=MAX_DECLARED_PROTOCOL_VERSION, + ), + event_protocol_version=require_int( + mapping["event_protocol_version"], + field="event_protocol_version", + minimum=1, + maximum=MAX_DECLARED_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, + ), + control_protocol_version=require_int( + mapping["protocol_version"], + field="protocol_version", + minimum=1, + maximum=MAX_DECLARED_PROTOCOL_VERSION, + ), + event_protocol_version=None, + 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.""" + 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, entrypoint=entrypoint) + + 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) + manifest = self.release_manifest(release_id) + if ( + manifest.control_protocol_version != PROTOCOL_VERSION + or ( + manifest.schema_version < 3 + and 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 ( + 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.""" + 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(): + 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, + *, + 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) + 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 / 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 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() + 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], + *, + 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_entrypoint(target, 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.fchmod(stream.fileno(), 0o644) + 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/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/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 new file mode 100644 index 0000000..82002fa --- /dev/null +++ b/src/TimeLocker/system_control/status_events.py @@ -0,0 +1,272 @@ +"""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 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 + +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 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.""" + + 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/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/storage.py b/src/TimeLocker/system_control/storage.py new file mode 100644 index 0000000..4c7164c --- /dev/null +++ b/src/TimeLocker/system_control/storage.py @@ -0,0 +1,545 @@ +"""Crash-safe storage and repository mutation locking for system operations.""" + +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 +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, + 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: + 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 + 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) + 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)) + self._notify_status_change() + + 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_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(): + 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)) + self._notify_status_change() + 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 _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, + ) -> 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/tray_client.py b/src/TimeLocker/system_control/tray_client.py new file mode 100644 index 0000000..e83db18 --- /dev/null +++ b/src/TimeLocker/system_control/tray_client.py @@ -0,0 +1,327 @@ +"""Tray-facing status and action client for TimeLocker system control.""" + +from __future__ import annotations + +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 BackupActionRequest, RetentionActionRequest, StatusSnapshot +from .status_snapshot import ( + StatusSnapshotFileWatcher, + StatusSnapshotUnavailable, +) +from .types import ( + BackendStatus, + BackupScheduleHealth, + ProtocolErrorCode, + ResponseStatus, + RunState, +) + + +ALLOWED_TRAY_ACTIONS = frozenset( + {"status", "backup_now", "retention_now", "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 + health: str + activity: str + active_operations: int + backend_available: bool + 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 + + +class TrayBackendUnavailable(RuntimeError): + """Raised when the protected local backend is not currently reachable.""" + + +_ClientFactory = Callable[[], SystemControlClient] +_T = TypeVar("_T") + + +class TrayStatusSubscriptionClient: + """Consume the sanitized status file without a privileged event service.""" + + def __init__( + self, + *, + watcher: StatusSnapshotFileWatcher | None = None, + ) -> None: + self._watcher = watcher or StatusSnapshotFileWatcher() + + 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") + try: + applied = None + for snapshot in self._watcher.snapshots(stop_event): + if stop_event.is_set(): + return + if ( + applied is not None + and snapshot.revision.session_id == applied.session_id + and snapshot.revision.sequence <= applied.sequence + ): + continue + applied = snapshot.revision + on_snapshot(snapshot) + except StatusSnapshotUnavailable: + if on_unavailable is not None: + on_unavailable("unavailable") + + +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.""" + try: + snapshot = self._with_backend( + lambda backend: backend.get_status_snapshot() + ) + 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 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") + 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" + health = "Backend unavailable" + elif snapshot.backup_schedule_health is BackupScheduleHealth.DISABLED: + status = "warning" + 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" + 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: + activity = "Idle" + + if activity != "Idle": + status = "running" + elif health == "Healthy" and snapshot.latest_backup is None: + status = "warning" + + tooltip_lines = [ + "TimeLocker", + f"State: {health}", + f"Activity: {activity}", + "Last Backup: " + + _format_local_time(snapshot.last_successful_backup_completed_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=( + 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 + ) -> 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", "quit"}: + if action == "quit": + 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, + 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, + 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, + ) + + +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 new file mode 100644 index 0000000..913e7b3 --- /dev/null +++ b/src/TimeLocker/system_control/tray_entry.py @@ -0,0 +1,365 @@ +"""Standalone tray entry point for user-session TimeLocker interactions.""" + +from __future__ import annotations + +import argparse +import logging +import os +from queue import Empty, Full, Queue +import signal +import sys +import time +from contextlib import contextmanager, suppress +from pathlib import Path +from threading import Event, Thread +from typing import Any + +from .tray_client import ( + TrayControlClient, + TrayDisplayState, + TrayStatusSubscriptionClient, +) +from ..monitoring.system_tray_integration import ( + SystemTrayError, + SystemTrayIntegration, + TrayStatus, + TrayStatusInfo, +) + +try: + import fcntl +except ImportError: # pragma: no cover - Windows-specific. + fcntl = None + +from .client import UnixSocketSystemControlClient + +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() + else Path.home() / ".cache" / "timelocker" / "tray.lock" +) + + +_STATUS_MAP = { + "connecting": TrayStatus.CONNECTING, + "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}", + ] + 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: + 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_info( + 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 + ), + 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( + 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 _tray_menu_actions(retention_policy_fingerprint: str | None) -> frozenset[str]: + actions = {"backup_now", "quit"} + if retention_policy_fingerprint: + actions.add("retention_now") + return frozenset(actions) + + +def _handle_action( + action: str, + client: TrayControlClient, + *, + tray: SystemTrayIntegration | None, + dry_run_retention: bool, +) -> TrayDisplayState | None: + if action == "quit": + raise SystemExit(0) + 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 _offer_latest( + updates: Queue[TrayDisplayState], + state: TrayDisplayState, +) -> None: + """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: + startup_started = time.monotonic() + 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) + + 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=None, + dry_run_retention=arguments.dry_run_retention, + ) + if state is not None: + print(_render_status(state)) + return + + try: + tray = SystemTrayIntegration( + app_name="TimeLocker", + menu_actions=_tray_menu_actions(arguments.retention_policy_fingerprint), + ) + 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() + 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) + + 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() + + 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: + 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() + 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: + tray.process_events() + try: + state = updates.get_nowait() + except Empty: + time.sleep(0.05) + continue + if tray and tray.is_available(): + _apply_state(tray, state) + if arguments.once: + break + 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() + + +if __name__ == "__main__": + main() 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/types.py b/src/TimeLocker/system_control/types.py new file mode 100644 index 0000000..b167bd7 --- /dev/null +++ b/src/TimeLocker/system_control/types.py @@ -0,0 +1,149 @@ +"""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" + STATUS_SNAPSHOT = "status.snapshot" + 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 BackendStatus(StrEnum): + """Bounded backend availability states for status snapshots.""" + + AVAILABLE = "available" + 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.""" + + SNAPSHOT_REQUIRED = "snapshot_required" + CHANGED = "changed" + HEARTBEAT = "heartbeat" + 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.""" + + 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/src/TimeLocker/system_control/windows_adapter.py b/src/TimeLocker/system_control/windows_adapter.py new file mode 100644 index 0000000..735ab24 --- /dev/null +++ b/src/TimeLocker/system_control/windows_adapter.py @@ -0,0 +1,267 @@ +"""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 +import json +from threading import Event +from typing import Protocol + +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) +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 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.""" + + 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() + + +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/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/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_backup_operations.py b/tests/TimeLocker/backup/test_backup_operations.py index 48c64de..ae672f9 100644 --- a/tests/TimeLocker/backup/test_backup_operations.py +++ b/tests/TimeLocker/backup/test_backup_operations.py @@ -123,6 +123,78 @@ 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) + 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 + @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), + ]) + + 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/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_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/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..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 @@ -72,21 +87,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 +145,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_backup_commands.py b/tests/TimeLocker/cli/test_backup_commands.py index ab7ac8c..c9ac0a2 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 @@ -23,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.""" @@ -237,6 +259,59 @@ 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: + 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", + ]) + + 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 + 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') + 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_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_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/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..0bfa65a 100644 --- a/tests/TimeLocker/cli/test_schedule_commands.py +++ b/tests/TimeLocker/cli/test_schedule_commands.py @@ -4,10 +4,20 @@ 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 ) @@ -81,12 +91,175 @@ 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_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, + "exclude_files": [str(tmp_path / "excludes with spaces")], + "exclude_caches": True, + "backend_options": ["s3.storage-class=INTELLIGENT_TIERING"], + } + + 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 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 + + 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 + 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): + 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 + 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') + 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() + exclude_file = tmp_path / "excludes.txt" + exclude_file.write_text("*.cache\n") + + 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", + "--exclude-file", str(exclude_file), + "--exclude-caches", + "--backend-option", "s3.storage-class=INTELLIGENT_TIERING", + ]) + 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 + 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", + "--tags", "replacement", + "--exclude", "*.tmp", + "--compression", "off", + "--cross-filesystems", + "--include-caches", + ]) + 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 + assert stored['exclude_caches'] 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) + assert "Exclusion Files:" in combined_output(shown) + + @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 "Requires=timelocker-pilot.service" not in timer + 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/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/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_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..ae46722 --- /dev/null +++ b/tests/TimeLocker/integration/test_minio_profile_contract.py @@ -0,0 +1,105 @@ +"""Regression tests for normal-versus-live MinIO test ownership.""" + +from pathlib import Path + +import pytest + +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 + ) + + +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() + + 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_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/integration/test_s3_minio.py b/tests/TimeLocker/integration/test_s3_minio.py index 14be78f..0ff44c2 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,40 @@ 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 - # Set MinIO endpoint in environment for restic - os.environ['AWS_S3_ENDPOINT'] = MINIO_ENDPOINT_URL + +@pytest.fixture +def live_s3_repository( + test_repo_path: str, + minio_settings: dict[str, str], +) -> 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}" + ) + 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 @@ -144,62 +181,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 +248,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: 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/monitoring/test_system_tray_integration.py b/tests/TimeLocker/monitoring/test_system_tray_integration.py index 5ea8ab4..056b276 100644 --- a/tests/TimeLocker/monitoring/test_system_tray_integration.py +++ b/tests/TimeLocker/monitoring/test_system_tray_integration.py @@ -16,14 +16,19 @@ """ import pytest -from datetime import datetime +from datetime import UTC, datetime from unittest.mock import Mock, patch -from TimeLocker.monitoring import ( +from TimeLocker.monitoring.system_tray_integration import ( + PACKAGED_TRAY_ICON_PATH, + PACKAGED_TRAY_STATUS_ICON_PATHS, + LinuxSystemTray, + SystemTrayError, SystemTrayIntegration, TrayStatus, TrayStatusInfo, - SystemTrayError + _linux_tray_icon_path, + _load_linux_tray_modules, ) @@ -37,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, - active_operations=0 + 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 @@ -53,21 +58,187 @@ class TestSystemTrayIntegration: @pytest.mark.monitoring @pytest.mark.unit - @patch('TimeLocker.monitoring.system_tray_integration.sys.platform', 'linux') - def test_initialization(self): + @patch("TimeLocker.monitoring.system_tray_integration.sys.platform", "linux") + 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.current_status == TrayStatus.CONNECTING + assert tray.is_available() is True + linux_tray.assert_called_once_with( + "TestApp", + frozenset( + { + "backup_now", + "retention_now", + "quit", + } + ), + ) + + @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 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" 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_uses_packaged_status_icons_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_STATUS_ICON_PATHS[TrayStatus.CONNECTING]), + indicator_module.IndicatorCategory.APPLICATION_STATUS, + ) + 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() + status_items = [Mock() for _ in range(3)] + backup_item = Mock() + quit_item = Mock() + gtk.MenuItem.side_effect = [ + *status_items, + 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({"backup_now", "quit"}), + ) + tray.update_status_rows( + 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.", + ) + ) + + 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") + status_items[2].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): + 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/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) 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/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/TimeLocker/project/test_release_artifacts.py b/tests/TimeLocker/project/test_release_artifacts.py new file mode 100644 index 0000000..5f63ac0 --- /dev/null +++ b/tests/TimeLocker/project/test_release_artifacts.py @@ -0,0 +1,196 @@ +"""Contracts for non-publishing release artifact validation.""" + +from __future__ import annotations + +import importlib.util +import os +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from TimeLocker.cli import app + +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 + + +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(): + 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_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 + 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_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", + '"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 +@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.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): + 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 + + +@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/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/project/test_t011_linux_deployment.py b/tests/TimeLocker/project/test_t011_linux_deployment.py new file mode 100644 index 0000000..a0a4b79 --- /dev/null +++ b/tests/TimeLocker/project/test_t011_linux_deployment.py @@ -0,0 +1,524 @@ +"""Supported daemonless protected deployment entrypoint contracts.""" + +from __future__ import annotations + +import getpass +import json +import os +from pathlib import Path +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 + + +RELEASE_A = "a" * 40 + + +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/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 _wheel(root: Path, *, include_legacy_event: bool = False) -> Path: + wheel = root / "timelocker-0.9.1-py3-none-any.whl" + 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, + 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", + } + + +@pytest.mark.unit +def test_local_wheel_rejects_legacy_event_service_asset(tmp_path: Path) -> None: + paths = _paths(tmp_path) + _prepare_roots(paths) + + 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, + ) + + +@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" + ) + assert ( + entry._release_request_disposition( + "upgrade", current=RELEASE_A, candidate=RELEASE_A + ) + == "already_selected" + ) + assert ( + entry._release_request_disposition( + "install", current=RELEASE_A, candidate="b" * 40 + ) + == "already_installed" + ) + + +@pytest.mark.unit +def test_packaged_service_requires_single_control_socket_and_no_event_service( + tmp_path: Path, +) -> 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 = 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) + + +@pytest.mark.unit +def test_initial_install_validation_does_not_require_running_units( + tmp_path: Path, +) -> None: + 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=Executor(), + owner_uid=os.getuid(), + owner_gid=os.getgid(), + asset_targets=( + AssetTarget("timelocker-control.service", paths.service_unit, 0o644), + ), + ) + + deployer.validate_request() + 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_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) + 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, + } + + +@pytest.mark.unit +def test_status_on_clean_host_is_not_installed_and_reports_unit_health( + tmp_path: Path, +) -> None: + paths = _paths(tmp_path) + + payload = entry._deployment_status( + paths, + unit_probe=lambda _action, _unit: False, + ) + + 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 + + +@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(OSError): + entry._write_private_text(link, "replacement") + + assert target.read_text() == "unchanged" + + +@pytest.mark.unit +def test_activation_enables_only_the_on_demand_control_socket( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> 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", + } + ) + ) + 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" + 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 = entry.T011LinuxDeployer( + request, + paths=paths, + executor=Executor(), + owner_uid=os.getuid(), + owner_gid=os.getgid(), + asset_targets=( + AssetTarget("timelocker-control.service", paths.service_unit, 0o644), + ), + ) + 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 + ) + + +@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) + + 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" + + +@pytest.mark.unit +def test_rollback_rejects_release_that_requires_resident_event_service( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + 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 entry._run_rollback(paths) == 1 + assert json.loads(capsys.readouterr().out)["result_code"] == "rollback_failed" + + +@pytest.mark.unit +def test_rollback_verifies_control_and_timer_health( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + 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/project/test_tray_icon_assets.py b/tests/TimeLocker/project/test_tray_icon_assets.py new file mode 100644 index 0000000..14db331 --- /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 = ("connecting", "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/recovery/mock_recovery_repository.py b/tests/TimeLocker/recovery/mock_recovery_repository.py index e57c6cc..65ffaac 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 = {} @@ -110,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: @@ -123,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 b7fb4ba..9ddde06 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,7 +129,13 @@ 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 + 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/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/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" 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.""" diff --git a/tests/TimeLocker/services/test_backup_orchestrator_job_execution.py b/tests/TimeLocker/services/test_backup_orchestrator_job_execution.py index 4c97d3e..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""" @@ -196,6 +213,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/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") 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 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_action_policy.py b/tests/TimeLocker/system_control/test_action_policy.py new file mode 100644 index 0000000..d05a0cb --- /dev/null +++ b/tests/TimeLocker/system_control/test_action_policy.py @@ -0,0 +1,60 @@ +"""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", "status"), 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_backend_entry.py b/tests/TimeLocker/system_control/test_backend_entry.py new file mode 100644 index 0000000..1f9e5c3 --- /dev/null +++ b/tests/TimeLocker/system_control/test_backend_entry.py @@ -0,0 +1,265 @@ +"""Entrypoint checks for the privileged system-control backend.""" + +import os +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.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: + 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 **_kwargs: (_ 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_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_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] = {} + policy = tmp_path / "policy.json" + state = tmp_path / "state" + 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(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" + assert captured["systemd_descriptor"] == 3 + assert "status_systemd_descriptor" not in captured + assert ( + captured["production_target_path"] + == backend_entry.DEFAULT_PRODUCTION_TARGET_PATH + ) + + +@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 + + +@pytest.mark.unit +def test_systemd_descriptor_contract_requires_only_control_socket() -> None: + control = backend_entry._systemd_socket_descriptor( + { + "LISTEN_PID": "123", + "LISTEN_FDS": "1", + "LISTEN_FDNAMES": "control", + }, + process_id=123, + ) + + assert control == 3 + + +@pytest.mark.unit +@pytest.mark.parametrize( + "environment", + [ + {}, + { + "LISTEN_PID": "122", + "LISTEN_FDS": "1", + "LISTEN_FDNAMES": "control", + }, + { + "LISTEN_PID": "123", + "LISTEN_FDS": "1", + "LISTEN_FDNAMES": "status-events", + }, + { + "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_descriptor(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 "status_systemd_descriptor" not in captured + assert "status_socket_mode" not in captured + + +@pytest.mark.unit +def test_one_shot_service_serves_one_request_and_stops() -> None: + control_served = Event() + + class _ControlTransport: + listener = None + + def serve_once(self, _dispatcher: object) -> None: + control_served.set() + + service = backend_entry.LinuxBackendService( + policy=backend_entry.SystemPolicy(), + store=cast(object, None), + locks=cast(object, None), + dispatcher=cast(object, None), + transport=cast(object, _ControlTransport()), + audit_sink=cast(object, None), + stop_event=Event(), + membership_resolver=cast(object, None), + ) + + service.serve_once() + + assert control_served.is_set() + assert service.stop_event.is_set() diff --git a/tests/TimeLocker/system_control/test_client.py b/tests/TimeLocker/system_control/test_client.py new file mode 100644 index 0000000..4719591 --- /dev/null +++ b/tests/TimeLocker/system_control/test_client.py @@ -0,0 +1,316 @@ +"""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, + StatusRevision, + StatusSnapshot, + BackupActionRequest, + RetentionActionRequest, + ActionReceipt, +) +from TimeLocker.system_control.protocol import ResponseEnvelope +from TimeLocker.system_control.types import ( + BackendStatus, + 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_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_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"{") + 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_deployment.py b/tests/TimeLocker/system_control/test_deployment.py new file mode 100644 index 0000000..a6abb85 --- /dev/null +++ b/tests/TimeLocker/system_control/test_deployment.py @@ -0,0 +1,290 @@ +"""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, + ReleaseProbeResult, + ReleaseProbeTargets, + SystemReleaseDeployment, + build_asset_manifest, + build_release_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( + build_release_manifest( + release_id=release_id, + package_version="0.9.1", + ) + ) + ) + (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(), + ) + + +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, +) -> 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") + + 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=_passing_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", + admin_bin_root=tmp_path / "sbin", + libexec_root=tmp_path / "libexec", + 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} + + assert { + "timelocker-launcher", + "tl-launcher", + "timelocker-system-control-launcher", + "timelocker-deploy-launcher", + "timelocker-tray-launcher", + "timelocker-control.service", + "timelocker-control.socket", + "timelocker-retention.service", + "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", + "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 == 0o755 + assert "timelocker-status-events.socket" not in sources + policy = next( + target + for target in targets + 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 + 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", + "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_does_not_require_legacy_event_socket( + 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_dispatcher.py b/tests/TimeLocker/system_control/test_dispatcher.py new file mode 100644 index 0000000..e0ed67d --- /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 = 2, +) -> 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=3), "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..ee986fe --- /dev/null +++ b/tests/TimeLocker/system_control/test_interfaces.py @@ -0,0 +1,115 @@ +"""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, + StatusSnapshot, +) + + +@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(), + ) + + def get_status_snapshot(self) -> StatusSnapshot: + raise LookupError("no snapshot configured") + + +@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..f0f0582 --- /dev/null +++ b/tests/TimeLocker/system_control/test_linux_adapter.py @@ -0,0 +1,261 @@ +"""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 "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 not (ASSET_DIRECTORY / "timelocker-status-events.socket").exists() + assert "User=root" 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 "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 + assert "ProtectHome=yes" 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=-/etc/timelocker/retention.env" 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() + 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_models.py b/tests/TimeLocker/system_control/test_models.py new file mode 100644 index 0000000..ba7536e --- /dev/null +++ b/tests/TimeLocker/system_control/test_models.py @@ -0,0 +1,284 @@ +"""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=3) + + def test_system_policy_accepts_legacy_protocol_declaration_for_upgrade( + self, + ) -> None: + assert SystemPolicy(protocol_version=1).protocol_version == 1 + + +@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_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 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..082fd6a --- /dev/null +++ b/tests/TimeLocker/system_control/test_production_retention.py @@ -0,0 +1,209 @@ +"""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, + require_retention_enable_marker, +) + + +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 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()) + + +@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 be owner-only"): + 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, +) -> 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) diff --git a/tests/TimeLocker/system_control/test_protocol.py b/tests/TimeLocker/system_control/test_protocol.py new file mode 100644 index 0000000..5567aad --- /dev/null +++ b/tests/TimeLocker/system_control/test_protocol.py @@ -0,0 +1,436 @@ +"""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 ( + BackendStatus, + ProtocolErrorCode, + RequestEnvelope, + ResponseEnvelope, + ResponseStatus, + SystemAction, + StatusRevision, + StatusSnapshot, + 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": 2, + "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, 3, True, "2"]) + 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_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", + "backup_schedule_health", + "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, + {"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": 2, + "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": 2, + "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": 3}, + ], + ) + 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_release_entrypoints.py b/tests/TimeLocker/system_control/test_release_entrypoints.py new file mode 100644 index 0000000..af8d623 --- /dev/null +++ b/tests/TimeLocker/system_control/test_release_entrypoints.py @@ -0,0 +1,96 @@ +"""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 + + +@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 new file mode 100644 index 0000000..4d2fbc8 --- /dev/null +++ b/tests/TimeLocker/system_control/test_release_launcher.py @@ -0,0 +1,298 @@ +"""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, + ReleaseManifest, + ReleaseResolutionError, +) + + +RELEASE_A = "a" * 40 +RELEASE_B = "b" * 40 + + +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) + (root / "releases").chmod(0o755) + 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( + { + "schema_version": 2, + "release_id": release_id, + "package_version": "0.9.1", + "control_protocol_version": control_protocol_version, + "event_protocol_version": event_protocol_version, + "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 + 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 +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_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, +) -> 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 + 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" + + +@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") + 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 "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 + + +@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": 2, + "event_protocol_version": 1, + "entrypoint": "venv/bin/timelocker", + } + ) + + assert manifest.control_protocol_version == 2 + 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( + { + "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_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_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 new file mode 100644 index 0000000..e4378a9 --- /dev/null +++ b/tests/TimeLocker/system_control/test_status_contracts.py @@ -0,0 +1,345 @@ +"""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", + "backup_schedule_health": "healthy", + "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", + "backup_schedule_health": "healthy", + "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, *, on_connection_state=None): + del stop_event + del on_connection_state + 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..d728fa1 --- /dev/null +++ b/tests/TimeLocker/system_control/test_status_event_transport.py @@ -0,0 +1,333 @@ +"""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 ( + StatusEventConnectionState, + 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_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'] + ) + 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())) + + +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_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.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_status_snapshot_action.py b/tests/TimeLocker/system_control/test_status_snapshot_action.py new file mode 100644 index 0000000..a9f5521 --- /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": 2, + "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_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_tray_client.py b/tests/TimeLocker/system_control/test_tray_client.py new file mode 100644 index 0000000..c02a3a1 --- /dev/null +++ b/tests/TimeLocker/system_control/test_tray_client.py @@ -0,0 +1,425 @@ +"""Focused tests for the stand-alone tray service client.""" + +from datetime import UTC, datetime, timedelta +from uuid import UUID, 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, + StatusRevision, + StatusSnapshot, +) +from TimeLocker.system_control.models import OperationTrigger +from TimeLocker.system_control.types import ( + BackendStatus, + BackupScheduleHealth, + 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 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: + 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 +@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) + 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 == "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 == ( + base_time - timedelta(minutes=55) + ) + 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.health == "Healthy" + assert state.activity == "Backup 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.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 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 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 +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 unavailable.health == "Backend unavailable" + assert unavailable.activity == "Connecting" + 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 == "warning" + + +@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.health == "Access denied" + assert state.activity == "Idle" + 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..3db8f76 --- /dev/null +++ b/tests/TimeLocker/system_control/test_tray_process_boundary.py @@ -0,0 +1,335 @@ +"""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 +import sys +from unittest.mock import Mock + +import pytest + +from TimeLocker.system_control import tray_entry +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 +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_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" + + 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() + + +@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() + + +@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) + assert "open_ui" not in _tray_menu_actions(None) + assert "status" not in _tray_menu_actions(None) + + +@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", + health="Healthy", + activity="Idle", + active_operations=0, + backend_available=True, + 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, + ) + + _apply_state(tray, state) + + 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 +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", + health="Healthy", + activity="Idle", + 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_called_once_with() + + +@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, + capsys, +) -> None: + arguments = type( + "Arguments", + (), + { + "action": "status", + "target_id": "production", + "retention_policy_fingerprint": None, + "dry_run_retention": False, + }, + )() + state = TrayDisplayState( + status="idle", + tooltip="TimeLocker", + health="Healthy", + activity="Idle", + 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..103faee --- /dev/null +++ b/tests/TimeLocker/system_control/test_tray_status_subscription.py @@ -0,0 +1,92 @@ +"""Daemonless tray status snapshot observation tests.""" + +from __future__ import annotations + +from threading import Event +from uuid import UUID + +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 + + +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 _Watcher: + def __init__(self, snapshots: list[StatusSnapshot]) -> None: + 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( + watcher=_Watcher( + [ + _snapshot(SESSION_ONE, 0), + _snapshot(SESSION_ONE, 1), + _snapshot(SESSION_TWO, 0), + ] + ) + ).serve(Event(), on_snapshot=applied.append) + + assert [snapshot.revision for snapshot in applied] == [ + StatusRevision(SESSION_ONE, 0), + StatusRevision(SESSION_ONE, 1), + StatusRevision(SESSION_TWO, 0), + ] + + +def test_duplicate_and_older_same_session_snapshots_do_not_regress() -> None: + applied: list[StatusSnapshot] = [] + TrayStatusSubscriptionClient( + watcher=_Watcher( + [ + _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_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(watcher=_UnavailableWatcher()).serve( + Event(), + on_snapshot=lambda _snapshot: None, + on_unavailable=unavailable.append, + ) + + assert unavailable == ["unavailable"] + + +def test_pre_stopped_subscription_does_not_apply_snapshot() -> None: + stop = Event() + stop.set() + applied: list[StatusSnapshot] = [] + TrayStatusSubscriptionClient( + watcher=_Watcher([_snapshot(SESSION_ONE, 0)]) + ).serve(stop, on_snapshot=applied.append) + + assert applied == [] 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"}) 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..cd824a6 --- /dev/null +++ b/tests/TimeLocker/system_control/test_windows_adapter.py @@ -0,0 +1,257 @@ +"""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, +) + + +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 + + +@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" + 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 + + +@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 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.""" 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])