diff --git a/AGENTS.md b/AGENTS.md index 7a8708e..cec272c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,10 @@ # core-ops Development Guidelines -Auto-generated from all feature plans. Last updated: 2026-03-18 +Auto-generated from all feature plans. Last updated: 2026-03-19 ## Active Technologies +- Rust (stable toolchain) + Git (CLI), systemd (systemctl), Quadlet generator, clap, thiserror, miette, journald logger (002-systemd-agent) +- Files on disk (Quadlet unit files + optional reconciliation state) (002-systemd-agent) - Rust (stable toolchain) + Git (CLI), systemd (systemctl), Podman/Quadlet generator (001-gitops-quadlet-controller) @@ -22,6 +24,7 @@ cargo test [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECH Rust (stable toolchain): Follow standard conventions ## Recent Changes +- 002-systemd-agent: Added Rust (stable toolchain) + Git (CLI), systemd (systemctl), Quadlet generator, clap, thiserror, miette, journald logger - 001-gitops-quadlet-controller: Added Rust (stable toolchain) + Git (CLI), systemd (systemctl), Podman/Quadlet generator diff --git a/docs/development.md b/docs/development.md index 97e7f6c..9f9f454 100644 --- a/docs/development.md +++ b/docs/development.md @@ -15,3 +15,35 @@ Do not assume Rust tooling is installed globally. - Test: `cargo test` (or `make test`) Assume the nix shell is already active, and do not run commands via `direnv exec`. + +## Systemd Agent Configuration + +The host agent is designed to run as a oneshot service triggered by a timer. +Use a systemd drop-in to configure the repo source and revision without editing +unit files in place. The contract units are named `core-ops.service` and +`core-ops.timer`. + +``` +systemctl edit core-ops.service +``` + +Suggested drop-in content: + +``` +[Service] +Environment=CORE_OPS_REPO=ssh://git@github.com/your-org/quadlets.git +Environment=CORE_OPS_REV=main +Environment=CORE_OPS_QUADLET_DIR=/etc/containers/systemd +Environment=CORE_OPS_SYSTEMD_UNIT_DIR=/etc/systemd/system +``` + +Apply changes with: + +- `systemctl daemon-reload` +- `systemctl restart core-ops.service` + +Timer enablement example: + +``` +systemctl enable --now core-ops.timer +``` diff --git a/specs/002-systemd-agent/checklists/requirements.md b/specs/002-systemd-agent/checklists/requirements.md new file mode 100644 index 0000000..6567c10 --- /dev/null +++ b/specs/002-systemd-agent/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Systemd-Managed Host Agent + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-19 +**Feature**: specs/002-systemd-agent/spec.md + +## Content Quality + +- [X] No implementation details (languages, frameworks, APIs) +- [X] Focused on user value and business needs +- [X] Written for non-technical stakeholders +- [X] All mandatory sections completed + +## Requirement Completeness + +- [X] No [NEEDS CLARIFICATION] markers remain +- [X] Requirements are testable and unambiguous +- [X] Success criteria are measurable +- [X] Success criteria are technology-agnostic (no implementation details) +- [X] All acceptance scenarios are defined +- [X] Edge cases are identified +- [X] Scope is clearly bounded +- [X] Dependencies and assumptions identified + +## Feature Readiness + +- [X] All functional requirements have clear acceptance criteria +- [X] User scenarios cover primary flows +- [X] Feature meets measurable outcomes defined in Success Criteria +- [X] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` diff --git a/specs/002-systemd-agent/contracts/systemd-units.md b/specs/002-systemd-agent/contracts/systemd-units.md new file mode 100644 index 0000000..b0f62dd --- /dev/null +++ b/specs/002-systemd-agent/contracts/systemd-units.md @@ -0,0 +1,19 @@ +# Contract: Systemd Service + Timer + +## Service Unit (oneshot) + +- Purpose: Execute a single reconciliation run. +- Inputs: repo source, revision, quadlet directory, optional audit export path. +- Output: journald audit events plus operator-facing report. +- Exit codes: non-zero on failure; failure class logged to journald. + +## Timer Unit + +- Purpose: Trigger the oneshot service on a schedule. +- Behavior: Must not allow overlapping runs; timer re-triggers only after the + previous run finishes. + +## CLI Invocation Contract + +- Service unit MUST call the CLI with explicit repo and revision. +- Timer unit MUST reference the service unit, not the binary directly. diff --git a/specs/002-systemd-agent/contracts/systemd/core-ops.service b/specs/002-systemd-agent/contracts/systemd/core-ops.service new file mode 100644 index 0000000..ad7557b --- /dev/null +++ b/specs/002-systemd-agent/contracts/systemd/core-ops.service @@ -0,0 +1,15 @@ +[Unit] +Description=Core Ops Quadlet GitOps Agent +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +# Set the repo + revision to reconcile against. +Environment=CORE_OPS_REPO=https://example.com/org/quadlets.git +Environment=CORE_OPS_REV=main +Environment=CORE_OPS_QUADLET_DIR=/etc/containers/systemd +ExecStart=/usr/bin/core-ops apply --repo ${CORE_OPS_REPO} --rev ${CORE_OPS_REV} --quadlet-dir ${CORE_OPS_QUADLET_DIR} + +[Install] +WantedBy=multi-user.target diff --git a/specs/002-systemd-agent/contracts/systemd/core-ops.timer b/specs/002-systemd-agent/contracts/systemd/core-ops.timer new file mode 100644 index 0000000..c1d3926 --- /dev/null +++ b/specs/002-systemd-agent/contracts/systemd/core-ops.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run Core Ops Quadlet GitOps Agent on a schedule + +[Timer] +OnBootSec=5min +OnUnitActiveSec=5min +Persistent=true +Unit=core-ops.service + +[Install] +WantedBy=timers.target diff --git a/specs/002-systemd-agent/data-model.md b/specs/002-systemd-agent/data-model.md new file mode 100644 index 0000000..3f31019 --- /dev/null +++ b/specs/002-systemd-agent/data-model.md @@ -0,0 +1,37 @@ +# Data Model: Systemd-Managed Host Agent + +## Entities + +### HostAgentRun +- **Fields**: run_id, mode, status, started_at, finished_at, failure_class +- **Relationships**: references a ReconciliationPlan and AuditEvent +- **Validation**: must always record outcome (success/failure) + +### QuadletArtifact +- **Fields**: name, artifact_type (container | socket | volume), unit_file_name, + contents, desired_state +- **Relationships**: referenced by ReconciliationPlan actions +- **Validation**: artifact_type must be supported; name must be unique per type + +### ReconciliationPlan +- **Fields**: plan_id, desired_revision_id, observed_revision_id, ordered_actions +- **Validation**: actions ordered Volume → Container → Socket + +### VerificationResult +- **Fields**: artifact_name, artifact_type, unit_state, passed, message +- **Validation**: unit_state derived from systemd query + +### AuditEvent +- **Fields**: run_id, plan_id, action_count, summary, timestamp +- **Validation**: emitted for every run under systemd + +### RunLock +- **Fields**: lock_id, acquired_at, owner +- **Validation**: only one active lock per host + +## Relationships + +- HostAgentRun → ReconciliationPlan (1:1) +- HostAgentRun → AuditEvent (1:1) +- ReconciliationPlan → QuadletArtifact (1:N) +- QuadletArtifact → VerificationResult (1:1 per run) diff --git a/specs/002-systemd-agent/plan.md b/specs/002-systemd-agent/plan.md new file mode 100644 index 0000000..1cdeec0 --- /dev/null +++ b/specs/002-systemd-agent/plan.md @@ -0,0 +1,115 @@ +# Implementation Plan: Systemd-Managed Host Agent + +**Branch**: `[002-systemd-agent]` | **Date**: 2026-03-19 | **Spec**: specs/002-systemd-agent/spec.md +**Input**: Feature specification from `/specs/002-systemd-agent/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow. + +## Summary + +Deliver a systemd-managed host agent that runs unattended via a oneshot service +triggered by a timer, emits journald audit events by default, and reconciles +container/volume Quadlet artifacts alongside native systemd socket units with +explicit ordering and verification. Generated units are not enabled/disabled by +the controller; Quadlet [Install] semantics govern enablement. The agent +preserves functional-core/imperative-shell boundaries, explicit failure +reporting, idempotence, and observability while staying within native system +primitives and avoiding generic host configuration management. + +## Technical Context + +**Language/Version**: Rust (stable toolchain) +**Primary Dependencies**: Git (CLI), systemd (systemctl), Quadlet generator, clap, thiserror, miette, journald logger +**Storage**: Files on disk (Quadlet unit files + systemd socket units + optional reconciliation state) +**Testing**: cargo test (unit + integration) +**Target Platform**: Fedora CoreOS (single host) +**Project Type**: CLI + systemd service/timer agent +**Performance Goals**: 95% of scheduled runs finish within 2 minutes for up to 50 artifacts +**Constraints**: No host configuration management beyond Quadlet/systemd/container scope; explicit failures; journald observability +**Scale/Scope**: Single host, tens of artifacts, no fleet orchestration + +## Declarative State Model + +Desired state is sourced from the Git repository’s Quadlet definitions, observed +state is derived from the host’s systemd-managed artifacts, and reconciliation +plans represent ordered actions required to converge. Runs persist their plan, +actions, and outcomes as explicit data structures surfaced through audit output. + +## Idempotence Strategy + +Reconciliation is designed to be safe to repeat: applying the same desired state +does not introduce additional changes, and repeated runs converge to the same +observed outcome. The plan phase determines no-op results when desired and +observed state already align, and apply actions are constructed to be stable on +subsequent executions. + +## Phases + +1. **Setup**: Ship systemd unit templates and deployment guidance. +2. **Foundational**: Add socket/volume types, ordering, verification model, and run lock. +3. **User Story 1 (MVP)**: Unattended agent entrypoint, CLI wiring, journald audit, lock usage. +4. **User Story 2**: Reconcile socket + volume artifacts end-to-end and report artifact types. +5. **User Story 3**: Verification checks wired through reconcile and audit outputs. +6. **Polish**: Documentation updates, performance/idempotence checks, targeted refactors. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Functional core and imperative shell boundaries are explicit; side effects are isolated. +- Desired/observed state, reconciliation plans, and outcomes are represented as data. +- Abstractions are minimal and justified; complexity tracking added if needed. +- Effects, assumptions, and failure modes are explicit in interfaces and returns. +- Idempotence and convergence strategy are defined, including retry behavior. +- Open standards and native interfaces are preferred; deviations justified. +- Observability plan covers diffs, plans, actions, failures, and dry-run/audit needs. +- Safe defaults are documented; destructive actions require explicit intent. +- Compatibility impact is assessed; breaking changes are documented with migration. +- Test strategy covers invariants, external behavior, convergence, and failures. +- Modules are structured to be regenerable from specs and tests. + +Status: PASS (pre-design). Post-design re-check: PASS. + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-systemd-agent/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +src/ +├── cli/ +├── core/ +└── io/ + +tests/ +├── integration/ +└── unit/ +``` + +**Structure Decision**: Single project with `core` (pure planning/verification), +`io` (Git/systemd/Quadlet side effects), and `cli` (entrypoints and reporting). +Systemd service/timer definitions live in `specs/002-systemd-agent/contracts/`. + +## Compatibility Impact + +No breaking changes expected for existing container-only workflows; new artifact +types and agent automation are additive. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| | | | diff --git a/specs/002-systemd-agent/quickstart.md b/specs/002-systemd-agent/quickstart.md new file mode 100644 index 0000000..d6e2f5e --- /dev/null +++ b/specs/002-systemd-agent/quickstart.md @@ -0,0 +1,60 @@ +# Quickstart: Systemd-Managed Host Agent + +**Goal**: Run the GitOps Quadlet controller unattended via systemd service + +timer on a Fedora CoreOS host. + +## Prerequisites + +- Fedora CoreOS host with systemd and Quadlet support +- Git repository containing Quadlet files (container, socket, volume) +- Operator access to install systemd unit files + +## Steps + +1. Install the provided oneshot service and timer unit files on the host. + - Copy `specs/002-systemd-agent/contracts/systemd/core-ops.service` to + `/etc/systemd/system/core-ops.service` + - Copy `specs/002-systemd-agent/contracts/systemd/core-ops.timer` to + `/etc/systemd/system/core-ops.timer` +2. Configure the repo and revision the agent should reconcile. + - Use `systemctl edit core-ops.service` and add: + ``` + [Service] + Environment=CORE_OPS_REPO=ssh://git@github.com/your-org/quadlets.git + Environment=CORE_OPS_REV=main + Environment=CORE_OPS_QUADLET_DIR=/etc/containers/systemd + Environment=CORE_OPS_SYSTEMD_UNIT_DIR=/etc/systemd/system + ``` +3. Enable and start the timer: + - `systemctl daemon-reload` + - `systemctl enable --now core-ops.timer` +4. Confirm journald output includes plan/action summaries per run: + - `journalctl -u core-ops.service -f` +5. Update the Git repository and verify the agent converges to the new state. + +## What to Expect + +- Runs are scheduled by systemd timer and execute the oneshot service. +- Journald contains structured audit events for each run. +- Artifacts are reconciled in Volume → Container → Socket ordering, including + container, socket, and volume Quadlets. +- Verification uses systemd unit state checks. + +## Environment Overrides + +You can override the agent configuration with environment variables on the +service unit: + +- `CORE_OPS_REPO` (required) +- `CORE_OPS_REV` (required) +- `CORE_OPS_QUADLET_DIR` (default `/etc/containers/systemd`) +- `CORE_OPS_SYSTEMD_UNIT_DIR` (default `/etc/systemd/system`) +- `CORE_OPS_AUDIT_DIR` (optional) +- `CORE_OPS_LOCK_PATH` (optional) + +## Non-Goals + +- Fleet orchestration or multi-host coordination +- Secret distribution +- Generic host configuration management +- Arbitrary environment file management as first-class objects diff --git a/specs/002-systemd-agent/research.md b/specs/002-systemd-agent/research.md new file mode 100644 index 0000000..246e1c3 --- /dev/null +++ b/specs/002-systemd-agent/research.md @@ -0,0 +1,24 @@ +# Research: Systemd-Managed Host Agent + +## Decision: Systemd automation mode + +**Decision**: Ship both a oneshot service and a timer that triggers it. +**Rationale**: Aligns with Fedora CoreOS operational norms and keeps unattended +execution explicit and inspectable. +**Alternatives considered**: Long-running daemon only; timer-only without +standalone service. + +## Decision: Artifact ordering + +**Decision**: Volume → Container → Socket ordering for reconciliation. +**Rationale**: Volumes must exist before containers reference them; sockets +typically depend on services or containers. +**Alternatives considered**: Socket-first ordering; container-first ordering. + +## Decision: Verification behavior + +**Decision**: Verify via systemd unit state checks (active/enabled where +applicable) for each artifact type. +**Rationale**: Uses native systemd primitives without custom probes and provides +consistent, inspectable outcomes. +**Alternatives considered**: File existence-only verification; runtime probes. diff --git a/specs/002-systemd-agent/spec.md b/specs/002-systemd-agent/spec.md new file mode 100644 index 0000000..38cc0b3 --- /dev/null +++ b/specs/002-systemd-agent/spec.md @@ -0,0 +1,157 @@ +# Feature Specification: Systemd-Managed Host Agent + +**Feature Branch**: `[002-systemd-agent]` +**Created**: 2026-03-19 +**Status**: Draft +**Input**: User description: "Specify the next iteration of the Fedora CoreOS Quadlet GitOps controller as an automated host agent. Goals: - install and operate the controller as a systemd-managed service or timer on a host - validate and rely on journald-based operational observability when running under systemd - extend reconciliation support beyond container Quadlets to include socket and volume Quadlet artifacts - define lifecycle, ordering, and verification behavior for these supported artifact types - preserve the current principles of native system primitives, explicit failure, idempotence, and observability Requirements: - the controller must be able to run unattended on a host through systemd automation - journald should be the default operational audit sink under service execution - socket artifacts must be treated according to their distinct lifecycle semantics - volume artifacts should be supported as first-class reconciled objects - mounting existing host config directories into containers may be supported where it fits naturally within Quadlet definitions - avoid expanding into generic host configuration management Non-goals for this iteration: - host templating or reusable manifest parameterization across multiple hosts - secret distribution - arbitrary environment file management as first-class managed objects - fleet orchestration" + +## Clarifications + +### Session 2026-03-19 +- Q: Which systemd automation mode should the agent ship for unattended runs? → A: Provide both a oneshot service and a timer (timer triggers service) +- Q: What ordering should apply across volume/container/socket artifacts? → A: Volume → Container → Socket ordering +- Q: What verification approach should be used per artifact type? → A: Verify by systemd unit state (active for container/socket; loaded for volume). Do not enable/disable generated units. +- Q: Where are socket artifacts installed? → A: Socket artifacts are native systemd units stored in the system unit directory (e.g., /etc/systemd/system), not in the Quadlet directory. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Automated host agent runs unattended (Priority: P1) + +As an operator, I want the controller installed as a systemd-managed service or + timer so that reconciliation runs unattended on a Fedora CoreOS host. + +**Why this priority**: Unattended host automation is the core value of this + iteration and enables reliable day-2 operations. + +**Independent Test**: Install the unit files, enable the timer or service, and + verify the agent executes on schedule without manual invocation. + +**Acceptance Scenarios**: + +1. **Given** the systemd unit files are installed, **When** the timer fires (or + the service starts), **Then** reconciliation runs without user interaction. +2. **Given** the agent runs under systemd, **When** it completes a run, **Then** + operational logs are visible in journald. + +--- + +### User Story 2 - Reconcile containers, sockets, and volumes (Priority: P2) + +As an operator, I want the controller to reconcile container, socket, and volume + Quadlet artifacts so that all supported workload types converge together. + +**Why this priority**: Broadening Quadlet support is the key functional expansion + beyond the current container-only scope. + +**Independent Test**: Place container, socket, and volume Quadlet files in the + repository and verify they are reconciled in a single run. + +**Acceptance Scenarios**: + +1. **Given** container, socket, and volume Quadlet definitions, **When** + reconciliation runs, **Then** each artifact type is created/updated or + removed according to the desired state. +2. **Given** a socket definition that depends on a container service, **When** + reconciliation runs, **Then** ordering rules ensure a stable, converged state. + +--- + +### User Story 3 - Explicit verification and observability (Priority: P3) + +As an operator, I want clear verification behavior and journal-based observability + so that I can validate changes and diagnose failures. + +**Why this priority**: Observability and verification prevent silent failures and + maintain trust in unattended automation. + +**Independent Test**: Introduce a failing Quadlet definition and confirm that the + agent logs the failure and marks the run as failed without partial success. + +**Acceptance Scenarios**: + +1. **Given** an invalid Quadlet definition, **When** the agent runs, **Then** it + reports a failure in journald with a clear reason. +2. **Given** a successful run, **When** I inspect journald logs, **Then** I can + identify the plan, actions, and outcome for that run. + +### Edge Cases + +- Timer overlaps a previous run (must avoid concurrent reconciliation). +- Socket artifacts reference services that are missing or invalid. +- Volume artifacts exist on disk but are removed from desired state. +- Host reboots during a reconcile run. +- Journald unavailable or log storage is full. +- Git repository temporarily unavailable. +- Quadlet files contain unsupported extensions. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The controller MUST run unattended via systemd service or timer + execution on a Fedora CoreOS host. The distribution MUST include both a + oneshot service and a timer that triggers it. +- **FR-002**: The controller MUST emit operational audit events to journald when + running under systemd. +- **FR-003**: The controller MUST reconcile container and volume Quadlet + artifacts (stored in the Quadlet directory) and socket artifacts as native + systemd units (stored in the system unit directory). +- **FR-004**: The controller MUST define and enforce lifecycle ordering for + socket units and volume artifacts relative to containers and services, using a + Volume → Container → Socket ordering model. +- **FR-005**: The controller MUST define explicit verification behavior for each + supported artifact type (container, socket, volume) using systemd unit state + checks. Container and socket units MUST report active; volume artifacts MUST + report a loaded unit state when applicable. The controller MUST NOT run + enable/disable for generated units; enablement remains driven by Quadlet + [Install] semantics. +- **FR-006**: The controller MUST remain limited to Quadlet/systemd/container + scope and MUST NOT expand into generic host configuration management. +- **FR-007**: Mounting existing host config directories into containers MAY be + supported when specified in Quadlet definitions; the controller MUST NOT manage + those directories as independent objects. +- **FR-008**: The controller MUST ensure reconciliation runs are idempotent and + safe to repeat. +- **FR-009**: The controller MUST surface failures explicitly in operator-visible + diagnostics and logs. +- **FR-010**: The controller MUST prevent overlapping reconcile runs (single-run + lock semantics). + +### Key Entities *(include if feature involves data)* + +- **Host Agent Run**: A single scheduled execution with plan, actions, and outcome. +- **Quadlet Artifact**: A desired-state object (container, socket, volume). +- **Reconciliation Plan**: The ordered actions required to converge. +- **Verification Result**: Outcome of post-apply checks per artifact type. +- **Audit Event**: Structured operational log entry emitted to journald. + +## Constitution Alignment *(mandatory)* + +- **Functional core vs. side effects**: Planning and verification remain pure; + systemd/Git interactions are isolated to adapters. +- **Declarative state model**: Desired/observed/plan/outcome remain explicit data. +- **Idempotence & convergence**: Repeated runs converge with no unintended change. +- **Explicit effects/failures**: Side effects and failure modes are logged and + returned explicitly. +- **Observability**: Journald provides default operational audit output; rich + reports remain operator-visible. +- **Safe defaults**: Unattended runs still require explicit desired state and + fail safely on invalid inputs. +- **Compatibility**: No fleet or host config expansion; existing semantics stay + stable for container Quadlets. +- **Test contract**: Tests cover artifact ordering, lifecycle, verification, and + unattended execution behavior. +- **Regenerability**: Spec and tests define the contract for safe regeneration. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 95% of scheduled runs complete within 2 minutes on a host with up + to 50 Quadlet artifacts. +- **SC-002**: 100% of runs emit a journald audit event with plan summary and + outcome status. +- **SC-003**: 100% of failed runs emit a journald audit event containing run_id, + plan summary, failed artifact list, and failure reason. +- **SC-004**: Reapplying the same desired state results in zero unintended + changes across three consecutive scheduled runs. diff --git a/specs/002-systemd-agent/tasks.md b/specs/002-systemd-agent/tasks.md new file mode 100644 index 0000000..109918c --- /dev/null +++ b/specs/002-systemd-agent/tasks.md @@ -0,0 +1,228 @@ +--- + +description: "Task list template for feature implementation" +--- + +# Tasks: Systemd-Managed Host Agent + +**Input**: Design documents from `/specs/002-systemd-agent/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: The examples below include test tasks. Tests are REQUIRED unless the +feature spec explicitly documents a justified exemption. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Single project**: `src/`, `tests/` at repository root +- Paths shown below assume single project + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +- [X] T001 Add systemd unit templates for agent service and timer in `specs/002-systemd-agent/contracts/systemd/` +- [X] T002 [P] Document agent deployment in `specs/002-systemd-agent/quickstart.md` +- [X] T003 [P] Add agent configuration notes in `docs/development.md` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T004 Extend artifact types to include socket and volume in `src/core/types.rs` +- [X] T005 Update Quadlet loader to parse socket and volume artifacts in `src/io/quadlet.rs` +- [X] T006 Update diffing to handle mixed artifact types in `src/core/diff.rs` +- [X] T007 Add ordering policy (Volume → Container → Socket) to planning in `src/core/planner.rs` +- [X] T008 Add verification result model for artifact checks in `src/core/types.rs` +- [X] T009 Define run lock interface and errors in `src/core/errors.rs` and `src/core/types.rs` +- [X] T010 Implement run lock adapter in `src/io/lock.rs` +- [X] T011 [P] Add unit tests for ordering rules in `tests/unit/test_planner.rs` +- [X] T012 [P] Add unit tests for quadlet type parsing in `tests/unit/test_types.rs` + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - Automated host agent runs unattended (Priority: P1) 🎯 MVP + +**Goal**: Run reconciliation unattended via systemd service + timer + +**Independent Test**: Enable the timer and verify a run occurs with journald output + +### Tests for User Story 1 (REQUIRED unless explicitly exempted) ⚠️ + +- [X] T013 [P] [US1] Integration test for single-run lock behavior in `tests/integration/test_agent_lock.rs` +- [X] T014 [P] [US1] Integration test for systemd unit templates presence in `tests/integration/test_systemd_units.rs` +- [X] T015 [P] [US1] Integration test for service-triggered run in `tests/integration/test_agent_service.rs` + +### Implementation for User Story 1 + +- [X] T016 [US1] Implement agent run entrypoint (oneshot) in `src/cli/agent.rs` +- [X] T017 [US1] Wire systemd timer/service invocation into CLI in `src/main.rs` +- [X] T018 [US1] Implement run lock acquire/release in `src/io/lock.rs` +- [X] T019 [US1] Emit journald audit events for agent runs in `src/io/audit.rs` +- [X] T020 [US1] Render operator-facing run report in `src/cli/agent.rs` and `src/cli/report.rs` + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - Reconcile containers, sockets, and volumes (Priority: P2) + +**Goal**: Support container, socket, and volume Quadlet artifacts with ordering + +**Independent Test**: Reconcile a repo containing all three artifact types in one run + +### Tests for User Story 2 (REQUIRED unless explicitly exempted) ⚠️ + +- [X] T021 [P] [US2] Integration test for socket+volume reconciliation in `tests/integration/test_quadlet_artifacts.rs` +- [X] T022 [P] [US2] Integration test for ordering (volume before container before socket) in `tests/integration/test_ordering.rs` + +### Implementation for User Story 2 + +- [X] T023 [US2] Extend observed state loading for socket and volume artifacts in `src/io/observed.rs` +- [X] T024 [US2] Extend apply adapter for socket and volume artifacts in `src/io/apply.rs` +- [X] T025 [US2] Update plan output to include artifact type in reports in `src/cli/report.rs` + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently + +--- + +## Phase 5: User Story 3 - Explicit verification and observability (Priority: P3) + +**Goal**: Define verification behavior and journald observability for all artifacts + +**Independent Test**: Introduce a failing artifact and confirm journald logs show failure details + +### Tests for User Story 3 (REQUIRED unless explicitly exempted) ⚠️ + +- [X] T026 [P] [US3] Integration test for verification failures in `tests/integration/test_verification.rs` +- [X] T027 [P] [US3] Integration test for journald audit content in `tests/integration/test_journald_audit.rs` + +### Implementation for User Story 3 + +- [X] T028 [US3] Implement verification checks using systemd unit state in `src/core/verify.rs` +- [X] T029 [US3] Wire verification into reconcile flow in `src/core/reconcile.rs` +- [X] T030 [US3] Extend audit record with verification results in `src/core/audit.rs` + +**Checkpoint**: All user stories should now be independently functional + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [x] T031 [P] Documentation updates in `specs/002-systemd-agent/quickstart.md` +- [x] T032 Refactor verification flow for clarity in `src/core/verify.rs` and `src/core/reconcile.rs` +- [x] T033 [P] Additional unit tests for artifact verification in `tests/unit/test_verification.rs` +- [x] T034 Run quickstart.md validation against `specs/002-systemd-agent/quickstart.md` +- [x] T035 [P] Integration test for idempotent repeated runs in `tests/integration/test_idempotence.rs` +- [x] T036 [P] Integration test for performance budget (50 artifacts within 2 minutes) in `tests/integration/test_performance.rs` +- [x] T037 [P] Integration test for git repository unavailable error in `tests/integration/test_repo_unavailable.rs` +- [x] T038 [P] Integration test for journald unavailable diagnostics in `tests/integration/test_journald_unavailable.rs` +- [x] T039 [P] Define expected behavior and test recovery after host reboot during reconcile in `tests/integration/test_reboot_recovery.rs` +- [x] T040 [P] Integration test for per-artifact verification rules in `tests/integration/test_verification_rules.rs` +- [x] T041 [P] Integration test ensuring apply does not call systemctl enable/disable for generated units in `tests/integration/test_no_enable_disable.rs` +- [x] T042 Guard apply to skip enable/disable for generated units in `src/io/apply.rs` +- [x] T043 [P] Write socket artifacts to the systemd unit directory in `src/io/apply.rs` and `src/io/systemd.rs` +- [x] T044 [P] Load managed socket units from the systemd unit directory in `src/io/observed.rs` and `src/io/quadlet.rs` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3+)**: All depend on Foundational phase completion + - User stories can then proceed in parallel (if staffed) + - Or sequentially in priority order (P1 → P2 → P3) +- **Polish (Final Phase)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - Integrates with US1 outputs but should be independently testable +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - Uses shared verification outputs but should be independently testable + +### Within Each User Story + +- Tests MUST be written and FAIL before implementation +- Domain types/validation/diff/plan before apply adapters +- Core implementation before integration +- Story complete before moving to next priority + +### Parallel Opportunities + +- All Setup tasks marked [P] can run in parallel +- All Foundational tasks marked [P] can run in parallel (within Phase 2) +- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) +- All tests for a user story marked [P] can run in parallel +- Different user stories can be worked on in parallel by different team members + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all tests for User Story 1 together: +Task: "Integration test for single-run lock behavior in tests/integration/test_agent_lock.rs" +Task: "Integration test for systemd unit templates presence in tests/integration/test_systemd_units.rs" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Test User Story 1 independently +5. Deploy/demo if ready + +### Incremental Delivery + +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) +3. Add User Story 2 → Test independently → Deploy/Demo +4. Add User Story 3 → Test independently → Deploy/Demo +5. Each story adds value without breaking previous stories + +### Parallel Team Strategy + +With multiple developers: + +1. Team completes Setup + Foundational together +2. Once Foundational is done: + - Developer A: User Story 1 + - Developer B: User Story 2 + - Developer C: User Story 3 +3. Stories complete and integrate independently + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- Each user story should be independently completable and testable +- Tests are mandatory unless explicitly exempted in the spec +- Verify tests fail before implementing +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence diff --git a/src/cli/agent.rs b/src/cli/agent.rs new file mode 100644 index 0000000..6c80b2b --- /dev/null +++ b/src/cli/agent.rs @@ -0,0 +1,69 @@ +use std::path::PathBuf; + +use crate::cli::apply as apply_cmd; +use crate::core::audit::build_audit_event; +use crate::core::errors::CoreError; +use crate::core::types::{FailureClass, ReconcileRun, RunLock}; +use crate::io::audit as audit_io; +use crate::io::lock::FileRunLock; + +#[derive(Debug, Clone)] +pub struct AgentConfig { + pub repo: String, + pub rev: String, + pub quadlet_dir: PathBuf, + pub audit_dir: Option, + pub reload_systemd: bool, + pub lock_path: Option, +} + +#[derive(Debug)] +pub struct AgentOutput { + pub run: ReconcileRun, + pub report: String, +} + +pub fn run_agent(config: &AgentConfig) -> Result { + let lock_path = config + .lock_path + .clone() + .unwrap_or_else(FileRunLock::default_path); + let lock = FileRunLock::new(lock_path); + let guard = lock + .acquire() + .map_err(|err| CoreError::new(FailureClass::Apply, err.to_string()))?; + + let result = apply_cmd::apply_with_report( + &config.repo, + &config.rev, + &config.quadlet_dir, + config.reload_systemd, + ); + + let release_result = lock + .release(guard) + .map_err(|err| CoreError::new(FailureClass::Apply, err.to_string())); + + let (result, report, plan) = result?; + let run = result.run; + if let Err(err) = release_result { + return Err(err); + } + + let event = build_audit_event(&run, Some(&plan), &result.verification_results); + audit_io::emit_journal_event(&event) + .map_err(|err| CoreError::new(FailureClass::Apply, err.to_string()))?; + + if let Some(dir) = &config.audit_dir { + let record = crate::core::audit::build_audit_record( + &run.run_id, + Vec::new(), + &plan, + result.verification_results, + ); + let _ = audit_io::write_audit_record(dir, &record) + .map_err(|err| CoreError::new(FailureClass::Apply, err.to_string()))?; + } + + Ok(AgentOutput { run, report }) +} diff --git a/src/cli/apply.rs b/src/cli/apply.rs index e75c9ca..ea5536a 100644 --- a/src/cli/apply.rs +++ b/src/cli/apply.rs @@ -25,15 +25,18 @@ pub fn apply( }, }; - reconcile_apply(&deps) + let result = reconcile_apply(&deps)?; + Ok(result.run) } +use crate::core::reconcile::ApplyResult; + pub fn apply_with_report( repo_source: &str, revision: &str, quadlet_dir: &Path, reload_systemd: bool, -) -> Result<(ReconcileRun, String), CoreError> { +) -> Result<(ApplyResult, String, crate::core::types::ReconciliationPlan), CoreError> { let repo_source = repo_source.to_string(); let deps = ReconcileDependencies { load_desired: &|| load_desired_state(&repo_source, revision).map_err(map_plan_error), @@ -47,8 +50,8 @@ pub fn apply_with_report( let plan_result = reconcile_plan(&deps)?; let report = format_plan_report(&plan_result.plan, &plan_result.diffs); - let run = reconcile_apply(&deps)?; - Ok((run, report)) + let result = reconcile_apply(&deps)?; + Ok((result, report, plan_result.plan)) } fn map_plan_error(err: E) -> CoreError { diff --git a/src/cli/args.rs b/src/cli/args.rs index 9275ead..73c5c2e 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -15,6 +15,8 @@ pub enum Commands { Plan(PlanArgs), /// Apply a reconciliation plan to the host. Apply(ApplyArgs), + /// Run the agent once (intended for systemd service execution). + Agent(AgentArgs), /// Display a stored audit record. Status(StatusArgs), } @@ -30,6 +32,9 @@ pub struct PlanArgs { /// System-level Quadlet directory. #[arg(long, default_value = "/etc/containers/systemd")] pub quadlet_dir: PathBuf, + /// Systemd unit directory (defaults to /etc/systemd/system). + #[arg(long)] + pub systemd_unit_dir: Option, /// Optional directory for persisted audit records. #[arg(long)] pub audit_dir: Option, @@ -46,6 +51,9 @@ pub struct ApplyArgs { /// System-level Quadlet directory. #[arg(long, default_value = "/etc/containers/systemd")] pub quadlet_dir: PathBuf, + /// Systemd unit directory (defaults to /etc/systemd/system). + #[arg(long)] + pub systemd_unit_dir: Option, /// Optional directory for persisted audit records. #[arg(long)] pub audit_dir: Option, @@ -54,6 +62,31 @@ pub struct ApplyArgs { pub no_reload: bool, } +#[derive(Args, Debug)] +pub struct AgentArgs { + /// Source repository (local path or Git URL). + #[arg(long)] + pub repo: Option, + /// Git revision (branch, tag, or commit). + #[arg(long)] + pub rev: Option, + /// System-level Quadlet directory. + #[arg(long)] + pub quadlet_dir: Option, + /// Systemd unit directory (defaults to /etc/systemd/system). + #[arg(long)] + pub systemd_unit_dir: Option, + /// Optional directory for persisted audit records. + #[arg(long)] + pub audit_dir: Option, + /// Path to the run lock file. + #[arg(long)] + pub lock_path: Option, + /// Skip systemd daemon-reload after applying changes. + #[arg(long)] + pub no_reload: bool, +} + #[derive(Args, Debug)] pub struct StatusArgs { /// Path to an audit record file. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 46bc8ce..393ec2a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,5 +3,6 @@ pub mod common; pub mod diagnostics; pub mod report; pub mod apply; +pub mod agent; pub mod plan; pub mod status; diff --git a/src/cli/plan.rs b/src/cli/plan.rs index 9be3fe6..53a277f 100644 --- a/src/cli/plan.rs +++ b/src/cli/plan.rs @@ -13,8 +13,8 @@ pub struct PlanOutput { pub fn plan(deps: &ReconcileDependencies<'_>) -> Result { let result = reconcile_plan(deps)?; let diffs = result.diffs; - let audit = build_audit_record(&result.run.run_id, diffs.clone(), &result.plan); - let event = build_audit_event(&result.run, Some(&result.plan)); + let audit = build_audit_record(&result.run.run_id, diffs.clone(), &result.plan, Vec::new()); + let event = build_audit_event(&result.run, Some(&result.plan), &[]); Ok(PlanOutput { summary: format_plan_report(&result.plan, &diffs), diff --git a/src/cli/report.rs b/src/cli/report.rs index 1b2b72c..c157aec 100644 --- a/src/cli/report.rs +++ b/src/cli/report.rs @@ -1,15 +1,62 @@ -use crate::core::types::{DiffItem, ReconciliationPlan}; +use crate::core::types::{DiffItem, QuadletType, ReconciliationPlan}; pub fn format_plan_report(plan: &ReconciliationPlan, diffs: &[DiffItem]) -> String { let mut output = String::new(); output.push_str(&format!("plan {} with {} actions\n", plan.plan_id, plan.actions.len())); output.push_str(&format!("diffs {}\n", diffs.len())); + let mut type_by_name = std::collections::HashMap::new(); for diff in diffs { - output.push_str(&format!("- {:?}: {}\n", diff.kind, diff.name)); + let quadlet_type = diff + .desired + .as_ref() + .or(diff.observed.as_ref()) + .map(|w| w.quadlet_type.clone()); + type_by_name.insert(diff.name.clone(), quadlet_type.clone()); + let quadlet_label = quadlet_type_label(quadlet_type); + output.push_str(&format!( + "- {:?}: {} [{}]\n", + diff.kind, diff.name, quadlet_label + )); } output.push_str("actions\n"); for action in &plan.actions { - output.push_str(&format!("- {:?}: {}\n", action.action_type, action.target)); + let quadlet_type = type_by_name.get(&action.target).cloned().flatten(); + let action_label = action_label(&action.action_type, quadlet_type.as_ref()); + output.push_str(&format!("- {}: {}\n", action_label, action.target)); } output } + +fn quadlet_type_label(quadlet_type: Option) -> &'static str { + match quadlet_type { + Some(QuadletType::Container) => "container", + Some(QuadletType::Socket) => "socket", + Some(QuadletType::Volume) => "volume", + Some(QuadletType::Pod) => "pod", + Some(QuadletType::Network) => "network", + None => "unknown", + } +} + +fn action_label( + action: &crate::core::types::PlanActionType, + quadlet_type: Option<&QuadletType>, +) -> String { + match action { + crate::core::types::PlanActionType::WriteQuadlet => { + if matches!(quadlet_type, Some(QuadletType::Socket)) { + "WriteUnit".to_string() + } else { + "WriteQuadlet".to_string() + } + } + crate::core::types::PlanActionType::RemoveQuadlet => { + if matches!(quadlet_type, Some(QuadletType::Socket)) { + "RemoveUnit".to_string() + } else { + "RemoveQuadlet".to_string() + } + } + _ => format!("{:?}", action), + } +} diff --git a/src/core/audit.rs b/src/core/audit.rs index dd1ec3b..2fb9841 100644 --- a/src/core/audit.rs +++ b/src/core/audit.rs @@ -1,13 +1,21 @@ -use crate::core::types::{AuditRecord, DiffItem, PlanAction, ReconcileRun, ReconciliationPlan}; +use crate::core::types::{ + AuditRecord, DiffItem, FailureClass, PlanAction, ReconcileRun, ReconciliationPlan, RunStatus, + VerificationResult, VerificationStatus, +}; -pub fn build_audit_record(run_id: &str, diffs: Vec, plan: &ReconciliationPlan) -> AuditRecord { +pub fn build_audit_record( + run_id: &str, + diffs: Vec, + plan: &ReconciliationPlan, + verification_results: Vec, +) -> AuditRecord { AuditRecord { record_id: format!("audit:{}", run_id), run_id: run_id.to_string(), diffs, plan_summary: summarize_plan(plan), actions_applied: plan.actions.clone(), - verification_results: Vec::new(), + verification_results, operator_messages: Vec::new(), } } @@ -37,6 +45,10 @@ pub fn format_audit_record(record: &AuditRecord) -> String { "actions {}\n", record.actions_applied.len() )); + output.push_str(&format!( + "verification {}\n", + record.verification_results.len() + )); output } @@ -44,15 +56,40 @@ pub fn format_audit_record(record: &AuditRecord) -> String { pub struct AuditEvent { pub run_id: String, pub plan_id: Option, + pub plan_summary: Option, pub action_count: usize, + pub status: RunStatus, + pub failure_class: Option, + pub failed_artifacts: Vec, + pub failure_reason: Option, pub summary: String, } -pub fn build_audit_event(run: &ReconcileRun, plan: Option<&ReconciliationPlan>) -> AuditEvent { +pub fn build_audit_event( + run: &ReconcileRun, + plan: Option<&ReconciliationPlan>, + verification_results: &[VerificationResult], +) -> AuditEvent { + let failed_artifacts = verification_results + .iter() + .filter(|result| result.status == VerificationStatus::Failure) + .map(|result| result.target.clone()) + .collect::>(); + let failure_reason = if run.status == RunStatus::Failure { + Some(run.summary.clone()) + } else { + None + }; + AuditEvent { run_id: run.run_id.clone(), plan_id: plan.map(|p| p.plan_id.clone()), + plan_summary: plan.map(summarize_plan), action_count: plan.map(|p| p.actions.len()).unwrap_or(0), + status: run.status.clone(), + failure_class: run.failure_class.clone(), + failed_artifacts, + failure_reason, summary: run.summary.clone(), } } @@ -62,11 +99,29 @@ pub fn format_audit_event_json(event: &AuditEvent) -> String { Some(plan_id) => format!("\"{}\"", escape_json(plan_id)), None => "null".to_string(), }; + let plan_summary = match &event.plan_summary { + Some(summary) => format!("\"{}\"", escape_json(summary)), + None => "null".to_string(), + }; + let failure_class = match &event.failure_class { + Some(class) => format!("\"{}\"", failure_class_label(class)), + None => "null".to_string(), + }; + let failure_reason = match &event.failure_reason { + Some(reason) => format!("\"{}\"", escape_json(reason)), + None => "null".to_string(), + }; + let failed_artifacts = format_string_array(&event.failed_artifacts); format!( - "{{\"run_id\":\"{}\",\"plan_id\":{},\"action_count\":{},\"summary\":\"{}\"}}", + "{{\"run_id\":\"{}\",\"plan_id\":{},\"plan_summary\":{},\"action_count\":{},\"status\":\"{}\",\"failure_class\":{},\"failed_artifacts\":{},\"failure_reason\":{},\"summary\":\"{}\"}}", escape_json(&event.run_id), plan_id, + plan_summary, event.action_count, + status_label(&event.status), + failure_class, + failed_artifacts, + failure_reason, escape_json(&event.summary) ) } @@ -77,3 +132,34 @@ fn escape_json(value: &str) -> String { .replace('\"', "\\\"") .replace('\n', "\\n") } + +fn status_label(status: &RunStatus) -> &'static str { + match status { + RunStatus::Success => "success", + RunStatus::Failure => "failure", + } +} + +fn failure_class_label(class: &FailureClass) -> &'static str { + match class { + FailureClass::Validation => "validation", + FailureClass::Plan => "plan", + FailureClass::Apply => "apply", + FailureClass::Verify => "verify", + FailureClass::Transient => "transient", + } +} + +fn format_string_array(values: &[String]) -> String { + let mut output = String::from("["); + for (idx, value) in values.iter().enumerate() { + if idx > 0 { + output.push(','); + } + output.push('"'); + output.push_str(&escape_json(value)); + output.push('"'); + } + output.push(']'); + output +} diff --git a/src/core/diff.rs b/src/core/diff.rs index 8ea6250..a755cef 100644 --- a/src/core/diff.rs +++ b/src/core/diff.rs @@ -3,8 +3,8 @@ use std::collections::BTreeSet; use crate::core::types::{DiffItem, DiffKind, Workload}; pub fn diff_workloads(desired: &[Workload], observed: &[Workload]) -> Vec { - let desired_map = index_by_name(desired); - let observed_map = index_by_name(observed); + let desired_map = index_by_unit_name(desired); + let observed_map = index_by_unit_name(observed); let mut names = BTreeSet::new(); for name in desired_map.keys() { @@ -41,10 +41,10 @@ pub fn diff_workloads(desired: &[Workload], observed: &[Workload]) -> Vec std::collections::BTreeMap { +fn index_by_unit_name(workloads: &[Workload]) -> std::collections::BTreeMap { let mut map = std::collections::BTreeMap::new(); for workload in workloads { - map.insert(workload.name.clone(), workload.clone()); + map.insert(workload.systemd_unit_name.clone(), workload.clone()); } map } diff --git a/src/core/errors.rs b/src/core/errors.rs index aa6cd84..b4a3098 100644 --- a/src/core/errors.rs +++ b/src/core/errors.rs @@ -45,3 +45,11 @@ impl ValidationError { } } } + +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum RunLockError { + #[error("run lock already held")] + AlreadyHeld, + #[error("run lock io error: {0}")] + Io(String), +} diff --git a/src/core/mod.rs b/src/core/mod.rs index d0033f9..0e88be7 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -6,4 +6,6 @@ pub mod planner; pub mod reconcile; pub mod retry; pub mod types; +pub mod unit; pub mod validation; +pub mod verify; diff --git a/src/core/planner.rs b/src/core/planner.rs index 7a08fda..0335eac 100644 --- a/src/core/planner.rs +++ b/src/core/planner.rs @@ -2,19 +2,36 @@ use crate::core::boundaries::enforce_plan_boundaries; use crate::core::diff::diff_workloads; use crate::core::errors::{CoreError, ValidationError}; use crate::core::types::{ - DiffKind, FailureClass, PlanAction, PlanActionType, ReconciliationPlan, - SafetyCheck, DesiredState, ObservedState, + DiffItem, DiffKind, FailureClass, PlanAction, PlanActionType, QuadletType, + ReconciliationPlan, SafetyCheck, DesiredState, ObservedState, }; use crate::core::validation::validate_desired_state; +use std::collections::HashSet; +use std::path::Path; pub fn plan(desired: &DesiredState, observed: &ObservedState) -> Result { validate_desired_state(desired).map_err(map_validation_error)?; - let diffs = diff_workloads(&desired.workloads, &observed.workloads); + let mut diffs = diff_workloads(&desired.workloads, &observed.workloads); + order_diffs(&mut diffs); let mut actions = Vec::new(); + let socket_stems = desired_socket_stems(&desired.workloads); + let container_stems = desired_container_stems(&desired.workloads); for diff in &diffs { - let mut diff_actions = actions_for_diff(diff.kind.clone(), &diff.name); + let quadlet_type = diff + .desired + .as_ref() + .or(diff.observed.as_ref()) + .map(|workload| workload.quadlet_type.clone()); + let mut diff_actions = + actions_for_diff( + diff.kind.clone(), + &diff.name, + quadlet_type, + &socket_stems, + &container_stems, + ); actions.append(&mut diff_actions); } @@ -44,23 +61,145 @@ pub fn plan(desired: &DesiredState, observed: &ObservedState) -> Result Vec { +fn actions_for_diff( + kind: DiffKind, + name: &str, + quadlet_type: Option, + socket_stems: &HashSet, + container_stems: &HashSet, +) -> Vec { + let manage_unit = match quadlet_type { + Some(QuadletType::Volume) => false, + Some(QuadletType::Container) => { + let stem = stem_for_unit_name(name); + match stem { + Some(stem) => !socket_stems.contains(stem), + None => true, + } + } + _ => true, + }; match kind { - DiffKind::Add => vec![ - action(PlanActionType::WriteQuadlet, name), - action(PlanActionType::ReloadSystemd, name), - action(PlanActionType::StartUnit, name), - ], - DiffKind::Remove => vec![ - action(PlanActionType::StopUnit, name), - action(PlanActionType::RemoveQuadlet, name), - action(PlanActionType::ReloadSystemd, name), - ], - DiffKind::Change => vec![ - action(PlanActionType::WriteQuadlet, name), - action(PlanActionType::ReloadSystemd, name), - action(PlanActionType::StartUnit, name), - ], + DiffKind::Add => { + let mut actions = vec![ + action(PlanActionType::WriteQuadlet, name), + action(PlanActionType::ReloadSystemd, name), + ]; + if manage_unit { + actions.push(action(PlanActionType::StartUnit, name)); + } + if should_start_service_for_socket(quadlet_type.as_ref(), name, container_stems) { + actions.push(action( + PlanActionType::StartUnit, + &format!("{}.service", stem_for_unit_name(name).unwrap_or(name)), + )); + } + actions + } + DiffKind::Remove => { + let mut actions = Vec::new(); + if manage_unit { + actions.push(action(PlanActionType::StopUnit, name)); + } + actions.push(action(PlanActionType::RemoveQuadlet, name)); + actions.push(action(PlanActionType::ReloadSystemd, name)); + actions + } + DiffKind::Change => { + let mut actions = vec![ + action(PlanActionType::WriteQuadlet, name), + action(PlanActionType::ReloadSystemd, name), + ]; + if manage_unit { + actions.push(action(PlanActionType::StartUnit, name)); + } + if should_start_service_for_socket(quadlet_type.as_ref(), name, container_stems) { + actions.push(action( + PlanActionType::StartUnit, + &format!("{}.service", stem_for_unit_name(name).unwrap_or(name)), + )); + } + actions + } + } +} + +fn desired_socket_stems(workloads: &[crate::core::types::Workload]) -> HashSet { + workloads + .iter() + .filter(|workload| workload.quadlet_type == QuadletType::Socket) + .filter_map(|workload| stem_for_unit_name(&workload.systemd_unit_name).map(|s| s.to_string())) + .collect() +} + +fn desired_container_stems(workloads: &[crate::core::types::Workload]) -> HashSet { + workloads + .iter() + .filter(|workload| workload.quadlet_type == QuadletType::Container) + .filter_map(|workload| stem_for_unit_name(&workload.systemd_unit_name).map(|s| s.to_string())) + .collect() +} + +fn stem_for_unit_name(name: &str) -> Option<&str> { + Path::new(name).file_stem().and_then(|stem| stem.to_str()) +} + +fn should_start_service_for_socket( + quadlet_type: Option<&QuadletType>, + name: &str, + container_stems: &HashSet, +) -> bool { + if !matches!(quadlet_type, Some(QuadletType::Socket)) { + return false; + } + match stem_for_unit_name(name) { + Some(stem) => container_stems.contains(stem), + None => false, + } +} + +fn order_diffs(diffs: &mut [DiffItem]) { + diffs.sort_by(|a, b| { + let a_key = ordering_key(a); + let b_key = ordering_key(b); + a_key.cmp(&b_key) + }); +} + +fn ordering_key(diff: &DiffItem) -> (u8, String) { + let quadlet_type = diff + .desired + .as_ref() + .or(diff.observed.as_ref()) + .map(|w| w.quadlet_type.clone()); + + let order = match diff.kind { + DiffKind::Remove => reverse_order_for_type(quadlet_type), + _ => order_for_type(quadlet_type), + }; + + (order, diff.name.clone()) +} + +fn order_for_type(quadlet_type: Option) -> u8 { + match quadlet_type { + Some(QuadletType::Volume) => 0, + Some(QuadletType::Container) => 1, + Some(QuadletType::Socket) => 2, + Some(QuadletType::Pod) => 3, + Some(QuadletType::Network) => 4, + None => 5, + } +} + +fn reverse_order_for_type(quadlet_type: Option) -> u8 { + match quadlet_type { + Some(QuadletType::Socket) => 0, + Some(QuadletType::Container) => 1, + Some(QuadletType::Volume) => 2, + Some(QuadletType::Pod) => 3, + Some(QuadletType::Network) => 4, + None => 5, } } diff --git a/src/core/reconcile.rs b/src/core/reconcile.rs index 1adbc5a..baf5b6d 100644 --- a/src/core/reconcile.rs +++ b/src/core/reconcile.rs @@ -3,7 +3,9 @@ use crate::core::errors::CoreError; use crate::core::planner::plan; use crate::core::types::{ DiffItem, FailureClass, ReconcileMode, ReconcileRun, ReconciliationPlan, RunStatus, + VerificationResult, VerificationStatus, }; +use crate::core::verify::verify_state; pub struct ReconcileDependencies<'a> { pub load_desired: &'a dyn Fn() -> Result, @@ -19,6 +21,11 @@ pub struct PlanResult { pub diffs: Vec, } +pub struct ApplyResult { + pub run: ReconcileRun, + pub verification_results: Vec, +} + pub fn reconcile_plan(deps: &ReconcileDependencies<'_>) -> Result { let desired = (deps.load_desired)()?; let observed = (deps.read_observed)()?; @@ -37,7 +44,7 @@ pub fn reconcile_plan(deps: &ReconcileDependencies<'_>) -> Result) -> Result { +pub fn reconcile_apply(deps: &ReconcileDependencies<'_>) -> Result { let desired = (deps.load_desired)()?; let observed = (deps.read_observed)()?; @@ -49,8 +56,12 @@ pub fn reconcile_apply(deps: &ReconcileDependencies<'_>) -> Result) -> Result, pub plan_summary: String, pub actions_applied: Vec, - pub verification_results: Vec, + pub verification_results: Vec, pub operator_messages: Vec, } @@ -103,6 +103,7 @@ pub enum DiffKind { #[derive(Clone, Debug, PartialEq, Eq)] pub enum QuadletType { Container, + Socket, Pod, Volume, Network, @@ -191,6 +192,28 @@ pub enum RunStatus { Failure, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerificationResult { + pub target: String, + pub status: VerificationStatus, + pub details: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VerificationStatus { + Success, + Failure, +} + +pub struct RunLockGuard { + pub lock_id: String, +} + +pub trait RunLock { + fn acquire(&self) -> Result; + fn release(&self, guard: RunLockGuard) -> Result<(), crate::core::errors::RunLockError>; +} + pub fn index_workloads(workloads: &[Workload]) -> BTreeMap { let mut map = BTreeMap::new(); for workload in workloads { diff --git a/src/core/unit.rs b/src/core/unit.rs new file mode 100644 index 0000000..a5a117f --- /dev/null +++ b/src/core/unit.rs @@ -0,0 +1,15 @@ +use std::path::Path; + +pub fn systemd_unit_for_quadlet_file(unit_file: &str) -> String { + let path = Path::new(unit_file); + let stem = path + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or(unit_file); + let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + + match ext { + "socket" => format!("{stem}.socket"), + _ => format!("{stem}.service"), + } +} diff --git a/src/core/validation.rs b/src/core/validation.rs index f0384e0..f8f693a 100644 --- a/src/core/validation.rs +++ b/src/core/validation.rs @@ -37,16 +37,9 @@ fn validate_boundaries(boundaries: &Boundaries) -> Result<(), ValidationError> { } fn validate_workloads(workloads: &[Workload]) -> Result<(), ValidationError> { - let mut names = HashSet::new(); let mut unit_names = HashSet::new(); for workload in workloads { - if !names.insert(workload.name.clone()) { - return Err(ValidationError::new( - ValidationErrorKind::DuplicateWorkload, - format!("duplicate workload name: {}", workload.name), - )); - } if !unit_names.insert(workload.systemd_unit_name.clone()) { return Err(ValidationError::new( ValidationErrorKind::DuplicateUnitName, diff --git a/src/core/verify.rs b/src/core/verify.rs new file mode 100644 index 0000000..9d0becc --- /dev/null +++ b/src/core/verify.rs @@ -0,0 +1,60 @@ +use crate::core::types::{ + DesiredState, ObservedState, QuadletType, UnitActiveState, VerificationResult, + VerificationStatus, +}; +use crate::core::unit::systemd_unit_for_quadlet_file; + +pub fn verify_state(desired: &DesiredState, observed: &ObservedState) -> Vec { + desired + .workloads + .iter() + .map(|workload| { + verify_workload( + workload.quadlet_type.clone(), + &workload.systemd_unit_name, + observed, + ) + }) + .collect() +} + +fn verify_workload( + quadlet_type: QuadletType, + unit_file: &str, + observed: &ObservedState, +) -> VerificationResult { + let unit_name = systemd_unit_for_quadlet_file(unit_file); + let unit = observed + .units + .iter() + .find(|unit| unit.unit_name == unit_name); + + match (quadlet_type, unit) { + (QuadletType::Volume, Some(_)) => success(unit_name), + (QuadletType::Volume, None) => failure(unit_name, "volume unit not found"), + (_, Some(unit)) => { + if unit.active_state == UnitActiveState::Active { + success(unit_name) + } else { + failure(unit_name, &format!("unit not active: {:?}", unit.active_state)) + } + } + (_, None) => failure(unit_name, "unit not found"), + } +} + +fn success(target: String) -> VerificationResult { + VerificationResult { + target, + status: VerificationStatus::Success, + details: None, + } +} + +fn failure(target: String, details: &str) -> VerificationResult { + VerificationResult { + target, + status: VerificationStatus::Failure, + details: Some(details.to_string()), + } +} diff --git a/src/io/apply.rs b/src/io/apply.rs index aa07300..bf5d38e 100644 --- a/src/io/apply.rs +++ b/src/io/apply.rs @@ -1,7 +1,9 @@ use std::fs; use std::path::{Path, PathBuf}; -use crate::core::types::{PlanAction, PlanActionType, ReconciliationPlan, Workload}; +use crate::core::types::{PlanAction, PlanActionType, QuadletType, ReconciliationPlan, Workload}; +use crate::core::unit::systemd_unit_for_quadlet_file; +use crate::io::systemd::systemd_unit_dir; #[derive(Debug)] pub enum ApplyError { @@ -55,33 +57,36 @@ pub fn apply_plan( match &action.action_type { PlanActionType::WriteQuadlet => { let workload = find_workload(desired_workloads, &action.target)?; - let path = quadlet_dir.join(&workload.systemd_unit_name); + let path = target_dir_for_workload(quadlet_dir, workload) + .join(&workload.systemd_unit_name); fs::write(&path, &workload.quadlet_contents)?; files_written.push(path.display().to_string()); } PlanActionType::RemoveQuadlet => { - for entry in fs::read_dir(quadlet_dir)? { + let target_dir = target_dir_for_name(quadlet_dir, &action.target); + let mut removed = false; + for entry in fs::read_dir(&target_dir)? { let entry = entry?; let path = entry.path(); if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) { - if file_name.starts_with(&format!("{}.", action.target)) { + if file_name == action.target + || file_name.starts_with(&format!("{}.", action.target)) + { fs::remove_file(&path)?; files_removed.push(path.display().to_string()); + removed = true; } } } + if !removed { + return Err(ApplyError::MissingWorkload(action.target.clone())); + } } PlanActionType::EnableUnit => { - return Err(ApplyError::SystemdCommandFailed( - "enable/disable is handled via Quadlet [Install], not systemctl enable" - .to_string(), - )); + // Quadlet-generated units rely on [Install] processing; no enable call is needed. } PlanActionType::DisableUnit => { - return Err(ApplyError::SystemdCommandFailed( - "enable/disable is handled via Quadlet [Install], not systemctl disable" - .to_string(), - )); + // Quadlet-generated units rely on [Install] processing; no disable call is needed. } PlanActionType::StartUnit => { let unit = unit_name_for_start_stop(desired_workloads, quadlet_dir, &action.target)?; @@ -112,13 +117,32 @@ pub fn apply_plan( }) } +fn target_dir_for_workload(quadlet_dir: &Path, workload: &Workload) -> PathBuf { + match workload.quadlet_type { + QuadletType::Socket => systemd_unit_dir(), + _ => quadlet_dir.to_path_buf(), + } +} + +fn target_dir_for_name(quadlet_dir: &Path, target: &str) -> PathBuf { + if Path::new(target) + .extension() + .and_then(|ext| ext.to_str()) + == Some("socket") + { + systemd_unit_dir() + } else { + quadlet_dir.to_path_buf() + } +} + fn find_workload<'a>( workloads: &'a [Workload], name: &str, ) -> Result<&'a Workload, ApplyError> { workloads .iter() - .find(|workload| workload.name == name) + .find(|workload| workload.systemd_unit_name == name || workload.name == name) .ok_or_else(|| ApplyError::MissingWorkload(name.to_string())) } @@ -141,27 +165,22 @@ fn unit_name_for_start_stop( quadlet_dir: &Path, target: &str, ) -> Result { - if let Some(workload) = workloads.iter().find(|w| w.name == target) { - return Ok(service_unit_name(&workload.systemd_unit_name)); + if let Some(workload) = workloads + .iter() + .find(|w| w.systemd_unit_name == target || w.name == target) + { + return Ok(systemd_unit_for_quadlet_file(&workload.systemd_unit_name)); } for entry in fs::read_dir(quadlet_dir)? { let entry = entry?; let path = entry.path(); if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) { - if file_name.starts_with(&format!("{target}.")) { - return Ok(service_unit_name(file_name)); + if file_name == target || file_name.starts_with(&format!("{target}.")) { + return Ok(systemd_unit_for_quadlet_file(file_name)); } } } - Ok(format!("{target}.service")) -} - -fn service_unit_name(unit_file: &str) -> String { - let stem = Path::new(unit_file) - .file_stem() - .and_then(|name| name.to_str()) - .unwrap_or(unit_file); - format!("{stem}.service") + Ok(systemd_unit_for_quadlet_file(target)) } diff --git a/src/io/lock.rs b/src/io/lock.rs new file mode 100644 index 0000000..0d5913f --- /dev/null +++ b/src/io/lock.rs @@ -0,0 +1,49 @@ +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::core::errors::RunLockError; +use crate::core::types::{RunLock, RunLockGuard}; + +pub struct FileRunLock { + path: PathBuf, +} + +impl FileRunLock { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn default_path() -> PathBuf { + Path::new("/run/core-ops").join("agent.lock") + } +} + +impl RunLock for FileRunLock { + fn acquire(&self) -> Result { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).map_err(|err| RunLockError::Io(err.to_string()))?; + } + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&self.path) + .map_err(|err| { + if err.kind() == std::io::ErrorKind::AlreadyExists { + RunLockError::AlreadyHeld + } else { + RunLockError::Io(err.to_string()) + } + })?; + let pid = std::process::id(); + writeln!(file, "pid={pid}").map_err(|err| RunLockError::Io(err.to_string()))?; + Ok(RunLockGuard { + lock_id: self.path.display().to_string(), + }) + } + + fn release(&self, _guard: RunLockGuard) -> Result<(), RunLockError> { + fs::remove_file(&self.path).map_err(|err| RunLockError::Io(err.to_string()))?; + Ok(()) + } +} diff --git a/src/io/mod.rs b/src/io/mod.rs index 135e8ce..3c41c8b 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -1,5 +1,7 @@ pub mod apply; pub mod audit; +pub mod lock; pub mod observed; pub mod quadlet; pub mod repo; +pub mod systemd; diff --git a/src/io/observed.rs b/src/io/observed.rs index 4f0769b..48a87ad 100644 --- a/src/io/observed.rs +++ b/src/io/observed.rs @@ -1,14 +1,23 @@ use std::path::{Path, PathBuf}; use std::process::Command; -use crate::core::types::{EnabledState, ObservedState, ObservedUnit, UnitActiveState, Workload}; -use crate::io::quadlet::{read_quadlet_dir, QuadletError}; +use crate::core::types::{ + EnabledState, ObservedState, ObservedUnit, QuadletType, RestartPolicy, UnitActiveState, + Workload, +}; +use crate::io::quadlet::{ + normalize_socket_contents, parse_quadlet_name, read_quadlet_dir, QuadletError, + SOCKET_MANAGED_MARKER, +}; +use crate::io::systemd::systemd_unit_dir; +use crate::core::unit::systemd_unit_for_quadlet_file; #[derive(Debug)] pub enum ObservedError { MissingQuadletDir(PathBuf), SystemdQueryFailed(String), Quadlet(QuadletError), + Io(std::io::Error), } impl From for ObservedError { @@ -17,6 +26,12 @@ impl From for ObservedError { } } +impl From for ObservedError { + fn from(err: std::io::Error) -> Self { + ObservedError::Io(err) + } +} + impl std::fmt::Display for ObservedError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -25,6 +40,7 @@ impl std::fmt::Display for ObservedError { } ObservedError::SystemdQueryFailed(msg) => write!(f, "systemd query failed: {}", msg), ObservedError::Quadlet(err) => write!(f, "observed state error: {}", err), + ObservedError::Io(err) => write!(f, "observed state io error: {}", err), } } } @@ -39,7 +55,9 @@ pub fn read_observed_state( return Err(ObservedError::MissingQuadletDir(quadlet_dir.to_path_buf())); } - let workloads: Vec = read_quadlet_dir(quadlet_dir)?; + let mut workloads: Vec = read_quadlet_dir(quadlet_dir)?; + let socket_dir = systemd_unit_dir(); + workloads.extend(read_socket_units(&socket_dir)?); let units = read_systemd_units(&workloads)?; Ok(ObservedState { @@ -51,6 +69,50 @@ pub fn read_observed_state( }) } +fn read_socket_units(dir: &Path) -> Result, ObservedError> { + if !dir.exists() { + return Ok(Vec::new()); + } + + let mut workloads = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + continue; + } + let file_name = match path.file_name().and_then(|name| name.to_str()) { + Some(name) => name, + None => continue, + }; + if file_name.starts_with('.') { + continue; + } + if !file_name.ends_with(".socket") { + continue; + } + let contents = std::fs::read_to_string(&path)?; + if !contents.contains(SOCKET_MANAGED_MARKER) { + continue; + } + let (name, quadlet_type) = parse_quadlet_name(file_name)?; + if quadlet_type != QuadletType::Socket { + continue; + } + let normalized = normalize_socket_contents(&contents); + workloads.push(Workload { + name, + quadlet_type, + quadlet_contents: normalized, + systemd_unit_name: file_name.to_string(), + enabled_state: EnabledState::Enabled, + restart_policy: RestartPolicy::Always, + }); + } + + Ok(workloads) +} + fn read_systemd_units(workloads: &[Workload]) -> Result, ObservedError> { if !systemctl_available() { log::warn!("systemctl unavailable; skipping unit discovery"); @@ -59,7 +121,7 @@ fn read_systemd_units(workloads: &[Workload]) -> Result, Obser let mut units = Vec::new(); for workload in workloads { - let unit_name = workload.systemd_unit_name.clone(); + let unit_name = systemd_unit_for_quadlet_file(&workload.systemd_unit_name); match query_unit_state(&unit_name)? { Some(unit) => units.push(unit), None => {} diff --git a/src/io/quadlet.rs b/src/io/quadlet.rs index 9e4d658..9f0b8ac 100644 --- a/src/io/quadlet.rs +++ b/src/io/quadlet.rs @@ -3,6 +3,8 @@ use std::path::{Path, PathBuf}; use crate::core::types::{EnabledState, QuadletType, RestartPolicy, Workload}; +pub(crate) const SOCKET_MANAGED_MARKER: &str = "# managed-by: core-ops"; + #[derive(Debug)] pub enum QuadletError { UnsupportedExtension(String), @@ -30,11 +32,21 @@ impl std::error::Error for QuadletError {} pub fn read_quadlet_dir(dir: &Path) -> Result, QuadletError> { let mut workloads = Vec::new(); + read_quadlet_dir_inner(dir, &mut workloads)?; + Ok(workloads) +} +fn read_quadlet_dir_inner(dir: &Path, workloads: &mut Vec) -> Result<(), QuadletError> { for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { + if let Some(name) = path.file_name().and_then(|name| name.to_str()) { + if name.starts_with('.') { + continue; + } + } + read_quadlet_dir_inner(&path, workloads)?; continue; } let file_name = match path.file_name().and_then(|name| name.to_str()) { @@ -53,7 +65,7 @@ pub fn read_quadlet_dir(dir: &Path) -> Result, QuadletError> { } } - Ok(workloads) + Ok(()) } fn load_quadlet_file(path: &Path) -> Result { @@ -64,7 +76,10 @@ fn load_quadlet_file(path: &Path) -> Result { let (name, quadlet_type) = parse_quadlet_name(file_name)?; - let contents = fs::read_to_string(path)?; + let mut contents = fs::read_to_string(path)?; + if quadlet_type == QuadletType::Socket { + contents = normalize_socket_contents(&contents); + } Ok(Workload { name, @@ -76,7 +91,7 @@ fn load_quadlet_file(path: &Path) -> Result { }) } -fn parse_quadlet_name(file_name: &str) -> Result<(String, QuadletType), QuadletError> { +pub(crate) fn parse_quadlet_name(file_name: &str) -> Result<(String, QuadletType), QuadletError> { let path = PathBuf::from(file_name); let stem = path .file_stem() @@ -89,6 +104,7 @@ fn parse_quadlet_name(file_name: &str) -> Result<(String, QuadletType), QuadletE let quadlet_type = match ext { "container" => QuadletType::Container, + "socket" => QuadletType::Socket, "pod" => QuadletType::Pod, "volume" => QuadletType::Volume, "network" => QuadletType::Network, @@ -97,3 +113,10 @@ fn parse_quadlet_name(file_name: &str) -> Result<(String, QuadletType), QuadletE Ok((stem.to_string(), quadlet_type)) } + +pub(crate) fn normalize_socket_contents(contents: &str) -> String { + if contents.contains(SOCKET_MANAGED_MARKER) { + return contents.to_string(); + } + format!("{SOCKET_MANAGED_MARKER}\n{contents}") +} diff --git a/src/io/systemd.rs b/src/io/systemd.rs new file mode 100644 index 0000000..5c841f1 --- /dev/null +++ b/src/io/systemd.rs @@ -0,0 +1,9 @@ +use std::path::PathBuf; + +pub const SYSTEMD_UNIT_DIR_ENV: &str = "CORE_OPS_SYSTEMD_UNIT_DIR"; + +pub fn systemd_unit_dir() -> PathBuf { + std::env::var_os(SYSTEMD_UNIT_DIR_ENV) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/etc/systemd/system")) +} diff --git a/src/main.rs b/src/main.rs index 317de7b..3e8e4bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,14 @@ use core_ops::cli::args::{Cli, Commands}; use core_ops::cli::common as cli_common; use core_ops::cli::{apply as apply_cmd, plan as plan_cmd}; +use core_ops::cli::agent as agent_cmd; use core_ops::core::errors::CoreError; use core_ops::core::reconcile::ReconcileDependencies; use core_ops::io::{audit as audit_io, observed, repo}; +use core_ops::io::systemd::SYSTEMD_UNIT_DIR_ENV; use log::LevelFilter; use clap::Parser; +use std::path::PathBuf; fn main() { init_logging(); @@ -24,6 +27,7 @@ fn run(cli: Cli) -> Result<(), CoreError> { let rev = args.rev; let quadlet_dir = args.quadlet_dir; let audit_dir = args.audit_dir; + set_systemd_unit_dir(&args.systemd_unit_dir); let deps = ReconcileDependencies { load_desired: &|| repo::load_desired_state(&repo_source, &rev).map_err(map_plan_error), @@ -50,23 +54,23 @@ fn run(cli: Cli) -> Result<(), CoreError> { let quadlet_dir = args.quadlet_dir; let audit_dir = args.audit_dir; let no_reload = args.no_reload; + set_systemd_unit_dir(&args.systemd_unit_dir); - let (run, report) = + let (result, report, plan) = apply_cmd::apply_with_report(&repo_source, &rev, &quadlet_dir, !no_reload)?; - let event = core_ops::core::audit::build_audit_event(&run, None); + let run = result.run; + let event = core_ops::core::audit::build_audit_event( + &run, + Some(&plan), + &result.verification_results, + ); audit_io::emit_journal_event(&event).map_err(map_apply_error)?; if let Some(dir) = audit_dir { let record = core_ops::core::audit::build_audit_record( &run.run_id, Vec::new(), - &core_ops::core::types::ReconciliationPlan { - plan_id: "apply".to_string(), - desired_revision_id: rev.clone(), - observed_revision_id: None, - actions: Vec::new(), - safety_checks: Vec::new(), - expected_outcomes: Vec::new(), - }, + &plan, + result.verification_results, ); let _ = audit_io::write_audit_record(&dir, &record).map_err(map_apply_error)?; } @@ -75,6 +79,40 @@ fn run(cli: Cli) -> Result<(), CoreError> { println!("{}", run.summary); Ok(()) } + Commands::Agent(args) => { + let repo = resolve_env(args.repo, "CORE_OPS_REPO")?; + let rev = resolve_env(args.rev, "CORE_OPS_REV")?; + let quadlet_dir = args + .quadlet_dir + .or_else(|| std::env::var_os("CORE_OPS_QUADLET_DIR").map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from("/etc/containers/systemd")); + if let Some(systemd_unit_dir) = args + .systemd_unit_dir + .or_else(|| std::env::var_os(SYSTEMD_UNIT_DIR_ENV).map(PathBuf::from)) + { + std::env::set_var(SYSTEMD_UNIT_DIR_ENV, systemd_unit_dir); + } + let audit_dir = args + .audit_dir + .or_else(|| std::env::var_os("CORE_OPS_AUDIT_DIR").map(PathBuf::from)); + let lock_path = args + .lock_path + .or_else(|| std::env::var_os("CORE_OPS_LOCK_PATH").map(PathBuf::from)); + + let config = agent_cmd::AgentConfig { + repo, + rev, + quadlet_dir, + audit_dir, + reload_systemd: !args.no_reload, + lock_path, + }; + + let output = agent_cmd::run_agent(&config)?; + println!("{}", output.report); + println!("{}", output.run.summary); + Ok(()) + } Commands::Status(args) => { let audit_file = args.audit_file; let contents = std::fs::read_to_string(&audit_file).map_err(map_plan_error)?; @@ -100,3 +138,24 @@ fn map_plan_error(err: E) -> CoreError { fn map_apply_error(err: E) -> CoreError { CoreError::new(core_ops::core::types::FailureClass::Apply, err.to_string()) } + +fn resolve_env(value: Option, key: &str) -> Result { + if let Some(value) = value { + return Ok(value); + } + if let Ok(value) = std::env::var(key) { + if !value.is_empty() { + return Ok(value); + } + } + Err(CoreError::new( + core_ops::core::types::FailureClass::Apply, + format!("missing required value for {key}"), + )) +} + +fn set_systemd_unit_dir(value: &Option) { + if let Some(dir) = value { + std::env::set_var(SYSTEMD_UNIT_DIR_ENV, dir); + } +} diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index 23f2671..3cf4c6e 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -1,8 +1,23 @@ pub mod env_lock; +mod test_agent_lock; +mod test_agent_service; +mod test_quadlet_artifacts; +mod test_ordering; mod test_plan; mod test_apply_report; mod test_reconcile_apply; +mod test_quickstart_validation; +mod test_idempotence; +mod test_performance; +mod test_repo_unavailable; +mod test_journald_unavailable; +mod test_no_enable_disable; +mod test_reboot_recovery; +mod test_verification_rules; mod test_repo_load; mod test_retry; mod test_unit_lifecycle; mod test_validation_fail; +mod test_systemd_units; +mod test_verification; +mod test_journald_audit; diff --git a/tests/integration/test_agent_lock.rs b/tests/integration/test_agent_lock.rs new file mode 100644 index 0000000..e74977e --- /dev/null +++ b/tests/integration/test_agent_lock.rs @@ -0,0 +1,30 @@ +use std::path::PathBuf; + +use core_ops::core::errors::RunLockError; +use core_ops::io::lock::FileRunLock; +use core_ops::core::types::RunLock; + +fn temp_dir(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos(); + path.push(format!("{}_{}", prefix, nanos)); + path +} + +#[test] +fn run_lock_prevents_overlap() { + let dir = temp_dir("core_ops_lock"); + let path = dir.join("agent.lock"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + + let lock = FileRunLock::new(&path); + let guard = lock.acquire().expect("acquire lock"); + let second = lock.acquire(); + + assert!(matches!(second, Err(RunLockError::AlreadyHeld))); + + lock.release(guard).expect("release lock"); +} diff --git a/tests/integration/test_agent_service.rs b/tests/integration/test_agent_service.rs new file mode 100644 index 0000000..75b51c9 --- /dev/null +++ b/tests/integration/test_agent_service.rs @@ -0,0 +1,128 @@ +use std::fs; +use std::path::PathBuf; + +use core_ops::cli::agent::{run_agent, AgentConfig}; +use crate::integration::env_lock::path_lock; + +fn temp_dir(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos(); + path.push(format!("{}_{}", prefix, nanos)); + path +} + +fn init_git_repo(repo: &PathBuf) -> String { + std::process::Command::new("git") + .arg("init") + .arg(repo) + .output() + .expect("git init"); + + let quadlets = repo.join("quadlets"); + fs::create_dir_all(&quadlets).expect("create quadlets"); + fs::write(quadlets.join("alpha.container"), "[Container]\nImage=alpine") + .expect("write quadlet"); + + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("add") + .arg(".") + .output() + .expect("git add"); + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("commit") + .arg("-m") + .arg("fixture") + .env("GIT_AUTHOR_NAME", "fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.com") + .env("GIT_COMMITTER_NAME", "fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.com") + .output() + .expect("git commit"); + + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("rev-parse") + .arg("HEAD") + .output() + .expect("git rev-parse"); + + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +fn write_systemctl_stub(dir: &PathBuf) { + let bin_path = dir.join("systemctl"); + let script = r#"#!/bin/sh +case "$1" in + is-system-running) + echo "running" + exit 0 + ;; + show) + echo "ActiveState=active" + echo "UnitFileState=enabled" + exit 0 + ;; + *) + exit 0 + ;; +esac +"#; + fs::write(&bin_path, script).expect("write systemctl stub"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&bin_path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&bin_path, perms).expect("chmod"); + } +} + +#[test] +fn agent_runs_once_with_service_config() { + let _lock = path_lock().lock().expect("path lock"); + let repo = temp_dir("core_ops_repo_agent"); + let rev = init_git_repo(&repo); + + let temp = temp_dir("core_ops_agent"); + fs::create_dir_all(&temp).expect("temp dir"); + write_systemctl_stub(&temp); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", temp.display(), old_path); + std::env::set_var("PATH", new_path); + let _guard = PathGuard { previous: old_path }; + + let quadlet_dir = temp.join("quadlets"); + fs::create_dir_all(&quadlet_dir).expect("quadlet dir"); + + let config = AgentConfig { + repo: repo.display().to_string(), + rev, + quadlet_dir, + audit_dir: None, + reload_systemd: true, + lock_path: Some(temp.join("agent.lock")), + }; + + let output = run_agent(&config).expect("agent run"); + assert!(output.report.contains("actions")); + assert_eq!(output.run.summary, "converged"); +} + +struct PathGuard { + previous: String, +} + +impl Drop for PathGuard { + fn drop(&mut self) { + std::env::set_var("PATH", &self.previous); + } +} diff --git a/tests/integration/test_apply_report.rs b/tests/integration/test_apply_report.rs index 9548455..9fe581e 100644 --- a/tests/integration/test_apply_report.rs +++ b/tests/integration/test_apply_report.rs @@ -106,7 +106,7 @@ fn apply_report_includes_diffs_and_actions() { let host_quadlets = temp_dir("core_ops_host_apply_report"); fs::create_dir_all(&host_quadlets).expect("create host quadlets"); - let (_run, report) = + let (_result, report, _plan) = apply_with_report(repo.to_str().unwrap(), &rev, &host_quadlets, false) .expect("apply report"); diff --git a/tests/integration/test_idempotence.rs b/tests/integration/test_idempotence.rs new file mode 100644 index 0000000..a19b3b3 --- /dev/null +++ b/tests/integration/test_idempotence.rs @@ -0,0 +1,144 @@ +use std::fs; +use std::path::PathBuf; + +use core_ops::core::errors::CoreError; +use core_ops::core::reconcile::{reconcile_apply, ReconcileDependencies}; +use core_ops::io::apply::apply_plan; +use core_ops::io::observed::read_observed_state; +use core_ops::io::repo::load_desired_state; +use crate::integration::env_lock::path_lock; + +fn temp_dir(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos(); + path.push(format!("{}_{}", prefix, nanos)); + path +} + +fn init_git_repo(repo: &PathBuf) -> String { + std::process::Command::new("git") + .arg("init") + .arg(repo) + .output() + .expect("git init"); + + let quadlets = repo.join("quadlets"); + fs::create_dir_all(&quadlets).expect("create quadlets"); + fs::write(quadlets.join("alpha.container"), "[Container]\nImage=alpine") + .expect("write quadlet"); + + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("add") + .arg(".") + .output() + .expect("git add"); + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("commit") + .arg("-m") + .arg("fixture") + .env("GIT_AUTHOR_NAME", "fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.com") + .env("GIT_COMMITTER_NAME", "fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.com") + .output() + .expect("git commit"); + + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("rev-parse") + .arg("HEAD") + .output() + .expect("git rev-parse"); + + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +fn write_systemctl_stub(dir: &PathBuf) { + let bin_path = dir.join("systemctl"); + let script = r#"#!/bin/sh +case "$1" in + is-system-running) + echo "running" + exit 0 + ;; + show) + echo "ActiveState=active" + echo "UnitFileState=enabled" + exit 0 + ;; + *) + exit 0 + ;; +esac +"#; + fs::write(&bin_path, script).expect("write systemctl stub"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&bin_path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&bin_path, perms).expect("chmod"); + } +} + +#[test] +fn repeated_runs_remain_converged() { + let _lock = path_lock().lock().expect("path lock"); + let repo = temp_dir("core_ops_repo_idempotence"); + let rev = init_git_repo(&repo); + + let temp = temp_dir("core_ops_idempotence"); + fs::create_dir_all(&temp).expect("temp dir"); + write_systemctl_stub(&temp); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", temp.display(), old_path); + std::env::set_var("PATH", new_path); + let _guard = PathGuard { previous: old_path }; + + let host_quadlets = temp.join("host_quadlets"); + fs::create_dir_all(&host_quadlets).expect("host quadlets"); + + let deps = ReconcileDependencies { + load_desired: &|| load_desired_state(repo.to_str().unwrap(), &rev).map_err(map_io_error), + read_observed: &|| { + read_observed_state(&host_quadlets, Some("obs".to_string())).map_err(map_io_error) + }, + apply_plan: &|plan, desired| { + apply_plan(plan, &desired.workloads, &host_quadlets, true) + .map(|_| ()) + .map_err(map_io_error) + }, + }; + + let first = reconcile_apply(&deps).expect("apply"); + assert_eq!(first.run.summary, "converged"); + + let second = reconcile_apply(&deps).expect("apply again"); + assert_eq!(second.run.summary, "converged"); +} + +fn map_io_error(err: E) -> CoreError { + CoreError { + class: core_ops::core::types::FailureClass::Apply, + message: err.to_string(), + } +} + +struct PathGuard { + previous: String, +} + +impl Drop for PathGuard { + fn drop(&mut self) { + std::env::set_var("PATH", &self.previous); + } +} diff --git a/tests/integration/test_journald_audit.rs b/tests/integration/test_journald_audit.rs new file mode 100644 index 0000000..b5a5849 --- /dev/null +++ b/tests/integration/test_journald_audit.rs @@ -0,0 +1,67 @@ +use core_ops::core::audit::{build_audit_event, format_audit_event_json}; +use core_ops::core::types::{ + FailureClass, PlanAction, PlanActionType, ReconcileMode, ReconcileRun, + ReconciliationPlan, RunStatus, VerificationResult, VerificationStatus, +}; + +#[test] +fn journald_audit_event_contains_summary_and_ids() { + let run = ReconcileRun { + run_id: "run:test".to_string(), + mode: ReconcileMode::Apply, + status: RunStatus::Success, + failure_class: None, + summary: "converged".to_string(), + }; + let plan = ReconciliationPlan { + plan_id: "plan:test".to_string(), + desired_revision_id: "rev".to_string(), + observed_revision_id: None, + actions: vec![PlanAction { + action_type: PlanActionType::WriteQuadlet, + target: "alpha.container".to_string(), + preconditions: Vec::new(), + postconditions: Vec::new(), + }], + safety_checks: Vec::new(), + expected_outcomes: Vec::new(), + }; + let event = build_audit_event(&run, Some(&plan), &[]); + let payload = format_audit_event_json(&event); + + assert!(payload.contains("\"run_id\":\"run:test\"")); + assert!(payload.contains("\"status\":\"success\"")); + assert!(payload.contains("\"summary\":\"converged\"")); + assert!(payload.contains("\"plan_summary\":\"plan plan:test with 1 actions\"")); +} + +#[test] +fn journald_audit_event_contains_failure_details() { + let run = ReconcileRun { + run_id: "run:fail".to_string(), + mode: ReconcileMode::Apply, + status: RunStatus::Failure, + failure_class: Some(FailureClass::Verify), + summary: "verification failed".to_string(), + }; + let verification_results = vec![ + VerificationResult { + target: "alpha.container".to_string(), + status: VerificationStatus::Failure, + details: Some("inactive".to_string()), + }, + VerificationResult { + target: "beta.socket".to_string(), + status: VerificationStatus::Success, + details: None, + }, + ]; + + let event = build_audit_event(&run, None, &verification_results); + let payload = format_audit_event_json(&event); + + assert!(payload.contains("\"status\":\"failure\"")); + assert!(payload.contains("\"failure_class\":\"verify\"")); + assert!(payload.contains("\"failed_artifacts\":[\"alpha.container\"]")); + assert!(payload.contains("\"failure_reason\":\"verification failed\"")); +} diff --git a/tests/integration/test_journald_unavailable.rs b/tests/integration/test_journald_unavailable.rs new file mode 100644 index 0000000..9612d61 --- /dev/null +++ b/tests/integration/test_journald_unavailable.rs @@ -0,0 +1,16 @@ +use core_ops::core::audit::build_audit_event; +use core_ops::core::types::{ReconcileMode, ReconcileRun, RunStatus}; +use core_ops::io::audit::emit_journal_event; + +#[test] +fn journald_unavailable_does_not_fail_emit() { + let run = ReconcileRun { + run_id: "run:journald".to_string(), + mode: ReconcileMode::Apply, + status: RunStatus::Success, + failure_class: None, + summary: "converged".to_string(), + }; + let event = build_audit_event(&run, None, &[]); + emit_journal_event(&event).expect("emit audit event"); +} diff --git a/tests/integration/test_no_enable_disable.rs b/tests/integration/test_no_enable_disable.rs new file mode 100644 index 0000000..ff5655f --- /dev/null +++ b/tests/integration/test_no_enable_disable.rs @@ -0,0 +1,101 @@ +use std::fs; +use std::path::PathBuf; + +use core_ops::core::types::{ + EnabledState, PlanAction, PlanActionType, ReconciliationPlan, RestartPolicy, Workload, +}; +use core_ops::io::apply::apply_plan; + +use crate::integration::env_lock::path_lock; + +fn temp_dir(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos(); + path.push(format!("{}_{}", prefix, nanos)); + path +} + +fn write_systemctl_stub(dir: &PathBuf, log_path: &PathBuf) -> PathBuf { + let bin_path = dir.join("systemctl"); + let script = format!( + "#!/bin/sh\n\n\ +echo \"$@\" >> \"{}\"\n\ +exit 1\n", + log_path.display() + ); + fs::write(&bin_path, script).expect("write systemctl stub"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&bin_path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&bin_path, perms).expect("chmod"); + } + bin_path +} + +#[test] +fn apply_skips_enable_disable_for_generated_units() { + let _lock = path_lock().lock().expect("path lock"); + let temp = temp_dir("core_ops_no_enable_disable"); + fs::create_dir_all(&temp).expect("temp dir"); + + let log_path = temp.join("systemctl.log"); + write_systemctl_stub(&temp, &log_path); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", temp.display(), old_path); + std::env::set_var("PATH", new_path); + let _guard = PathGuard { previous: old_path }; + + let quadlet_dir = temp.join("quadlets"); + fs::create_dir_all(&quadlet_dir).expect("quadlet dir"); + + let workload = Workload { + name: "alpha".to_string(), + quadlet_type: core_ops::core::types::QuadletType::Container, + quadlet_contents: "[Container]\nImage=alpine".to_string(), + systemd_unit_name: "alpha.container".to_string(), + enabled_state: EnabledState::Enabled, + restart_policy: RestartPolicy::Always, + }; + + let plan = ReconciliationPlan { + plan_id: "plan:no-enable-disable".to_string(), + desired_revision_id: "rev".to_string(), + observed_revision_id: None, + actions: vec![ + action(PlanActionType::WriteQuadlet, "alpha.container"), + action(PlanActionType::EnableUnit, "alpha.container"), + action(PlanActionType::DisableUnit, "alpha.container"), + ], + safety_checks: Vec::new(), + expected_outcomes: Vec::new(), + }; + + let result = apply_plan(&plan, &[workload], &quadlet_dir, true); + assert!(result.is_ok()); + assert!(!log_path.exists(), "systemctl should not be invoked"); +} + +fn action(action_type: PlanActionType, target: &str) -> PlanAction { + PlanAction { + action_type, + target: target.to_string(), + preconditions: Vec::new(), + postconditions: Vec::new(), + } +} + +struct PathGuard { + previous: String, +} + +impl Drop for PathGuard { + fn drop(&mut self) { + std::env::set_var("PATH", &self.previous); + } +} diff --git a/tests/integration/test_ordering.rs b/tests/integration/test_ordering.rs new file mode 100644 index 0000000..87faca2 --- /dev/null +++ b/tests/integration/test_ordering.rs @@ -0,0 +1,110 @@ +use std::fs; +use std::path::PathBuf; + +use core_ops::core::errors::CoreError; +use core_ops::core::reconcile::{reconcile_plan, ReconcileDependencies}; +use core_ops::io::apply::apply_plan; +use core_ops::io::observed::read_observed_state; +use core_ops::io::repo::load_desired_state; + +fn temp_dir(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos(); + path.push(format!("{}_{}", prefix, nanos)); + path +} + +fn init_git_repo(repo: &PathBuf) -> String { + std::process::Command::new("git") + .arg("init") + .arg(repo) + .output() + .expect("git init"); + + let quadlets = repo.join("quadlets"); + fs::create_dir_all(&quadlets).expect("create quadlets"); + fs::write(quadlets.join("alpha.container"), "[Container]\nImage=alpine") + .expect("write container"); + fs::write(quadlets.join("beta.socket"), "[Socket]\nListenStream=8080") + .expect("write socket"); + fs::write(quadlets.join("gamma.volume"), "[Volume]\nDriver=local") + .expect("write volume"); + + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("add") + .arg(".") + .output() + .expect("git add"); + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("commit") + .arg("-m") + .arg("fixture") + .env("GIT_AUTHOR_NAME", "fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.com") + .env("GIT_COMMITTER_NAME", "fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.com") + .output() + .expect("git commit"); + + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("rev-parse") + .arg("HEAD") + .output() + .expect("git rev-parse"); + + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +#[test] +fn plan_orders_volume_before_container_before_socket() { + let repo = temp_dir("core_ops_repo_ordering"); + let rev = init_git_repo(&repo); + + let host_quadlets = temp_dir("core_ops_host_ordering"); + fs::create_dir_all(&host_quadlets).expect("create host quadlets"); + + let deps = ReconcileDependencies { + load_desired: &|| load_desired_state(repo.to_str().unwrap(), &rev).map_err(map_io_error), + read_observed: &|| { + read_observed_state(&host_quadlets, Some("obs".to_string())).map_err(map_io_error) + }, + apply_plan: &|plan, desired| { + apply_plan(plan, &desired.workloads, &host_quadlets, false) + .map(|_| ()) + .map_err(map_io_error) + }, + }; + + let result = reconcile_plan(&deps).expect("plan"); + let mut ordered_targets = Vec::new(); + for action in result.plan.actions { + if ordered_targets.last() != Some(&action.target) { + ordered_targets.push(action.target); + } + } + + assert_eq!( + ordered_targets, + vec![ + "gamma.volume".to_string(), + "alpha.container".to_string(), + "beta.socket".to_string(), + ] + ); +} + +fn map_io_error(err: E) -> CoreError { + CoreError { + class: core_ops::core::types::FailureClass::Plan, + message: err.to_string(), + } +} diff --git a/tests/integration/test_performance.rs b/tests/integration/test_performance.rs new file mode 100644 index 0000000..9513e42 --- /dev/null +++ b/tests/integration/test_performance.rs @@ -0,0 +1,149 @@ +use std::fs; +use std::path::PathBuf; +use std::time::Instant; + +use core_ops::core::errors::CoreError; +use core_ops::core::reconcile::{reconcile_apply, ReconcileDependencies}; +use core_ops::io::apply::apply_plan; +use core_ops::io::observed::read_observed_state; +use core_ops::io::repo::load_desired_state; +use crate::integration::env_lock::path_lock; + +fn temp_dir(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos(); + path.push(format!("{}_{}", prefix, nanos)); + path +} + +fn init_git_repo(repo: &PathBuf, count: usize) -> String { + std::process::Command::new("git") + .arg("init") + .arg(repo) + .output() + .expect("git init"); + + let quadlets = repo.join("quadlets"); + fs::create_dir_all(&quadlets).expect("create quadlets"); + for idx in 0..count { + let name = format!("workload{idx}.container"); + fs::write(quadlets.join(name), "[Container]\nImage=alpine") + .expect("write quadlet"); + } + + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("add") + .arg(".") + .output() + .expect("git add"); + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("commit") + .arg("-m") + .arg("fixture") + .env("GIT_AUTHOR_NAME", "fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.com") + .env("GIT_COMMITTER_NAME", "fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.com") + .output() + .expect("git commit"); + + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("rev-parse") + .arg("HEAD") + .output() + .expect("git rev-parse"); + + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +fn write_systemctl_stub(dir: &PathBuf) { + let bin_path = dir.join("systemctl"); + let script = r#"#!/bin/sh +case "$1" in + is-system-running) + echo "running" + exit 0 + ;; + show) + echo "ActiveState=active" + echo "UnitFileState=enabled" + exit 0 + ;; + *) + exit 0 + ;; +esac +"#; + fs::write(&bin_path, script).expect("write systemctl stub"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&bin_path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&bin_path, perms).expect("chmod"); + } +} + +#[test] +fn reconcile_apply_completes_under_budget() { + let _lock = path_lock().lock().expect("path lock"); + let repo = temp_dir("core_ops_repo_perf"); + let rev = init_git_repo(&repo, 50); + + let temp = temp_dir("core_ops_perf"); + fs::create_dir_all(&temp).expect("temp dir"); + write_systemctl_stub(&temp); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", temp.display(), old_path); + std::env::set_var("PATH", new_path); + let _guard = PathGuard { previous: old_path }; + + let host_quadlets = temp.join("host_quadlets"); + fs::create_dir_all(&host_quadlets).expect("host quadlets"); + + let deps = ReconcileDependencies { + load_desired: &|| load_desired_state(repo.to_str().unwrap(), &rev).map_err(map_io_error), + read_observed: &|| { + read_observed_state(&host_quadlets, Some("obs".to_string())).map_err(map_io_error) + }, + apply_plan: &|plan, desired| { + apply_plan(plan, &desired.workloads, &host_quadlets, true) + .map(|_| ()) + .map_err(map_io_error) + }, + }; + + let start = Instant::now(); + let result = reconcile_apply(&deps).expect("apply"); + let elapsed = start.elapsed(); + + assert_eq!(result.run.summary, "converged"); + assert!(elapsed.as_secs() < 120); +} + +fn map_io_error(err: E) -> CoreError { + CoreError { + class: core_ops::core::types::FailureClass::Apply, + message: err.to_string(), + } +} + +struct PathGuard { + previous: String, +} + +impl Drop for PathGuard { + fn drop(&mut self) { + std::env::set_var("PATH", &self.previous); + } +} diff --git a/tests/integration/test_quadlet_artifacts.rs b/tests/integration/test_quadlet_artifacts.rs new file mode 100644 index 0000000..0cb60b2 --- /dev/null +++ b/tests/integration/test_quadlet_artifacts.rs @@ -0,0 +1,178 @@ +use std::fs; +use std::path::PathBuf; + +use core_ops::core::errors::CoreError; +use core_ops::core::reconcile::{reconcile_apply, ReconcileDependencies}; +use core_ops::io::apply::apply_plan; +use core_ops::io::observed::read_observed_state; +use core_ops::io::repo::load_desired_state; +use core_ops::io::systemd::SYSTEMD_UNIT_DIR_ENV; +use crate::integration::env_lock::path_lock; + +fn temp_dir(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos(); + path.push(format!("{}_{}", prefix, nanos)); + path +} + +fn init_git_repo(repo: &PathBuf) -> String { + std::process::Command::new("git") + .arg("init") + .arg(repo) + .output() + .expect("git init"); + + let quadlets = repo.join("quadlets"); + fs::create_dir_all(&quadlets).expect("create quadlets"); + fs::write(quadlets.join("alpha.container"), "[Container]\nImage=alpine") + .expect("write container"); + fs::write(quadlets.join("beta.socket"), "[Socket]\nListenStream=8080") + .expect("write socket"); + fs::write(quadlets.join("gamma.volume"), "[Volume]\nDriver=local") + .expect("write volume"); + + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("add") + .arg(".") + .output() + .expect("git add"); + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("commit") + .arg("-m") + .arg("fixture") + .env("GIT_AUTHOR_NAME", "fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.com") + .env("GIT_COMMITTER_NAME", "fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.com") + .output() + .expect("git commit"); + + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("rev-parse") + .arg("HEAD") + .output() + .expect("git rev-parse"); + + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +fn write_systemctl_stub(dir: &PathBuf) { + let bin_path = dir.join("systemctl"); + let script = r#"#!/bin/sh +case "$1" in + is-system-running) + echo "running" + exit 0 + ;; + show) + echo "ActiveState=active" + echo "UnitFileState=enabled" + exit 0 + ;; + *) + exit 0 + ;; +esac +"#; + fs::write(&bin_path, script).expect("write systemctl stub"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&bin_path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&bin_path, perms).expect("chmod"); + } +} + +#[test] +fn reconcile_apply_supports_socket_and_volume_quadlets() { + let _lock = path_lock().lock().expect("path lock"); + let repo = temp_dir("core_ops_repo_artifacts"); + let rev = init_git_repo(&repo); + + let temp = temp_dir("core_ops_artifacts"); + fs::create_dir_all(&temp).expect("temp dir"); + write_systemctl_stub(&temp); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", temp.display(), old_path); + std::env::set_var("PATH", new_path); + let _guard = PathGuard { previous: old_path }; + + let host_quadlets = temp.join("host_quadlets"); + fs::create_dir_all(&host_quadlets).expect("host quadlets"); + let systemd_units = temp.join("systemd_units"); + fs::create_dir_all(&systemd_units).expect("systemd units"); + let _systemd_guard = EnvGuard::set(SYSTEMD_UNIT_DIR_ENV, &systemd_units); + + let deps = ReconcileDependencies { + load_desired: &|| load_desired_state(repo.to_str().unwrap(), &rev).map_err(map_io_error), + read_observed: &|| { + read_observed_state(&host_quadlets, Some("obs".to_string())).map_err(map_io_error) + }, + apply_plan: &|plan, desired| { + apply_plan(plan, &desired.workloads, &host_quadlets, true) + .map(|_| ()) + .map_err(map_io_error) + }, + }; + + let result = reconcile_apply(&deps).expect("apply"); + assert_eq!(result.run.summary, "converged"); + + assert!(host_quadlets.join("alpha.container").exists()); + assert!(systemd_units.join("beta.socket").exists()); + assert!(host_quadlets.join("gamma.volume").exists()); +} + +fn map_io_error(err: E) -> CoreError { + CoreError { + class: core_ops::core::types::FailureClass::Apply, + message: err.to_string(), + } +} + +struct PathGuard { + previous: String, +} + +impl Drop for PathGuard { + fn drop(&mut self) { + std::env::set_var("PATH", &self.previous); + } +} + +struct EnvGuard { + key: String, + previous: Option, +} + +impl EnvGuard { + fn set(key: &str, value: &PathBuf) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { + key: key.to_string(), + previous, + } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(&self.key, value), + None => std::env::remove_var(&self.key), + } + } +} diff --git a/tests/integration/test_quickstart_validation.rs b/tests/integration/test_quickstart_validation.rs new file mode 100644 index 0000000..94eb2a1 --- /dev/null +++ b/tests/integration/test_quickstart_validation.rs @@ -0,0 +1,14 @@ +use std::fs; +use std::path::Path; + +#[test] +fn quickstart_mentions_systemd_units_and_env() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let quickstart = root.join("specs/002-systemd-agent/quickstart.md"); + let contents = fs::read_to_string(&quickstart).expect("read quickstart"); + + assert!(contents.contains("core-ops.service")); + assert!(contents.contains("core-ops.timer")); + assert!(contents.contains("CORE_OPS_REPO")); + assert!(contents.contains("CORE_OPS_REV")); +} diff --git a/tests/integration/test_reboot_recovery.rs b/tests/integration/test_reboot_recovery.rs new file mode 100644 index 0000000..5c01cf2 --- /dev/null +++ b/tests/integration/test_reboot_recovery.rs @@ -0,0 +1,144 @@ +use std::fs; +use std::path::PathBuf; + +use core_ops::core::errors::CoreError; +use core_ops::core::reconcile::{reconcile_apply, ReconcileDependencies}; +use core_ops::io::apply::apply_plan; +use core_ops::io::observed::read_observed_state; +use core_ops::io::repo::load_desired_state; +use crate::integration::env_lock::path_lock; + +fn temp_dir(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos(); + path.push(format!("{}_{}", prefix, nanos)); + path +} + +fn init_git_repo(repo: &PathBuf) -> String { + std::process::Command::new("git") + .arg("init") + .arg(repo) + .output() + .expect("git init"); + + let quadlets = repo.join("quadlets"); + fs::create_dir_all(&quadlets).expect("create quadlets"); + fs::write(quadlets.join("alpha.container"), "[Container]\nImage=alpine") + .expect("write quadlet"); + + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("add") + .arg(".") + .output() + .expect("git add"); + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("commit") + .arg("-m") + .arg("fixture") + .env("GIT_AUTHOR_NAME", "fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.com") + .env("GIT_COMMITTER_NAME", "fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.com") + .output() + .expect("git commit"); + + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("rev-parse") + .arg("HEAD") + .output() + .expect("git rev-parse"); + + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +fn write_systemctl_stub(dir: &PathBuf) { + let bin_path = dir.join("systemctl"); + let script = r#"#!/bin/sh +case "$1" in + is-system-running) + echo "running" + exit 0 + ;; + show) + echo "ActiveState=active" + echo "UnitFileState=enabled" + exit 0 + ;; + *) + exit 0 + ;; +esac +"#; + fs::write(&bin_path, script).expect("write systemctl stub"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&bin_path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&bin_path, perms).expect("chmod"); + } +} + +#[test] +fn reconcile_recovers_after_reboot() { + let _lock = path_lock().lock().expect("path lock"); + let repo = temp_dir("core_ops_repo_reboot"); + let rev = init_git_repo(&repo); + + let temp = temp_dir("core_ops_reboot"); + fs::create_dir_all(&temp).expect("temp dir"); + write_systemctl_stub(&temp); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", temp.display(), old_path); + std::env::set_var("PATH", new_path); + let _guard = PathGuard { previous: old_path }; + + let host_quadlets = temp.join("host_quadlets"); + fs::create_dir_all(&host_quadlets).expect("host quadlets"); + + let deps = ReconcileDependencies { + load_desired: &|| load_desired_state(repo.to_str().unwrap(), &rev).map_err(map_io_error), + read_observed: &|| { + read_observed_state(&host_quadlets, None).map_err(map_io_error) + }, + apply_plan: &|plan, desired| { + apply_plan(plan, &desired.workloads, &host_quadlets, true) + .map(|_| ()) + .map_err(map_io_error) + }, + }; + + let first = reconcile_apply(&deps).expect("apply"); + assert_eq!(first.run.summary, "converged"); + + let second = reconcile_apply(&deps).expect("post-reboot apply"); + assert_eq!(second.run.summary, "converged"); +} + +fn map_io_error(err: E) -> CoreError { + CoreError { + class: core_ops::core::types::FailureClass::Apply, + message: err.to_string(), + } +} + +struct PathGuard { + previous: String, +} + +impl Drop for PathGuard { + fn drop(&mut self) { + std::env::set_var("PATH", &self.previous); + } +} diff --git a/tests/integration/test_reconcile_apply.rs b/tests/integration/test_reconcile_apply.rs index 076b1af..ea8cedf 100644 --- a/tests/integration/test_reconcile_apply.rs +++ b/tests/integration/test_reconcile_apply.rs @@ -65,9 +65,23 @@ fn init_git_repo(repo: &PathBuf) -> String { fn write_systemctl_stub(dir: &PathBuf, log_path: &PathBuf) -> PathBuf { let bin_path = dir.join("systemctl"); let script = format!( - "#!/bin/sh\n\n\ -echo \"$@\" >> \"{}\"\n\ -exit 0\n", + r#"#!/bin/sh +echo "$@" >> "{}" +case "$1" in + is-system-running) + echo "running" + exit 0 + ;; + show) + echo "ActiveState=active" + echo "UnitFileState=enabled" + exit 0 + ;; + *) + exit 0 + ;; +esac +"#, log_path.display() ); fs::write(&bin_path, script).expect("write systemctl stub"); @@ -121,9 +135,9 @@ fn reconcile_apply_converges_to_desired_state() { }, }; - let run = reconcile_apply(&deps).expect("reconcile apply"); + let result = reconcile_apply(&deps).expect("reconcile apply"); - assert_eq!(run.summary, "converged"); + assert_eq!(result.run.summary, "converged"); } fn map_io_error(err: E) -> CoreError { diff --git a/tests/integration/test_repo_unavailable.rs b/tests/integration/test_repo_unavailable.rs new file mode 100644 index 0000000..486e203 --- /dev/null +++ b/tests/integration/test_repo_unavailable.rs @@ -0,0 +1,7 @@ +use core_ops::io::repo::load_desired_state; + +#[test] +fn repo_unavailable_returns_error() { + let result = load_desired_state("/does/not/exist", "main"); + assert!(result.is_err()); +} diff --git a/tests/integration/test_systemd_units.rs b/tests/integration/test_systemd_units.rs new file mode 100644 index 0000000..3d70c6e --- /dev/null +++ b/tests/integration/test_systemd_units.rs @@ -0,0 +1,11 @@ +use std::path::Path; + +#[test] +fn systemd_unit_templates_exist() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let service = root.join("specs/002-systemd-agent/contracts/systemd/core-ops.service"); + let timer = root.join("specs/002-systemd-agent/contracts/systemd/core-ops.timer"); + + assert!(service.exists(), "missing service template: {}", service.display()); + assert!(timer.exists(), "missing timer template: {}", timer.display()); +} diff --git a/tests/integration/test_unit_lifecycle.rs b/tests/integration/test_unit_lifecycle.rs index 136e8c2..3f4d7a1 100644 --- a/tests/integration/test_unit_lifecycle.rs +++ b/tests/integration/test_unit_lifecycle.rs @@ -67,10 +67,10 @@ fn apply_executes_unit_lifecycle_actions() { desired_revision_id: "rev".to_string(), observed_revision_id: None, actions: vec![ - action(PlanActionType::WriteQuadlet, "alpha"), - action(PlanActionType::ReloadSystemd, "alpha"), - action(PlanActionType::StartUnit, "alpha"), - action(PlanActionType::StopUnit, "alpha"), + action(PlanActionType::WriteQuadlet, "alpha.container"), + action(PlanActionType::ReloadSystemd, "alpha.container"), + action(PlanActionType::StartUnit, "alpha.container"), + action(PlanActionType::StopUnit, "alpha.container"), ], safety_checks: Vec::new(), expected_outcomes: Vec::new(), diff --git a/tests/integration/test_verification.rs b/tests/integration/test_verification.rs new file mode 100644 index 0000000..fd1c348 --- /dev/null +++ b/tests/integration/test_verification.rs @@ -0,0 +1,145 @@ +use std::fs; +use std::path::PathBuf; + +use core_ops::core::errors::CoreError; +use core_ops::core::reconcile::{reconcile_apply, ReconcileDependencies}; +use core_ops::io::apply::apply_plan; +use core_ops::io::observed::read_observed_state; +use core_ops::io::repo::load_desired_state; +use crate::integration::env_lock::path_lock; + +fn temp_dir(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos(); + path.push(format!("{}_{}", prefix, nanos)); + path +} + +fn init_git_repo(repo: &PathBuf) -> String { + std::process::Command::new("git") + .arg("init") + .arg(repo) + .output() + .expect("git init"); + + let quadlets = repo.join("quadlets"); + fs::create_dir_all(&quadlets).expect("create quadlets"); + fs::write(quadlets.join("alpha.container"), "[Container]\nImage=alpine") + .expect("write container"); + + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("add") + .arg(".") + .output() + .expect("git add"); + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("commit") + .arg("-m") + .arg("fixture") + .env("GIT_AUTHOR_NAME", "fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.com") + .env("GIT_COMMITTER_NAME", "fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.com") + .output() + .expect("git commit"); + + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("rev-parse") + .arg("HEAD") + .output() + .expect("git rev-parse"); + + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +fn write_systemctl_stub(dir: &PathBuf) { + let bin_path = dir.join("systemctl"); + let script = r#"#!/bin/sh +case "$1" in + is-system-running) + echo "running" + exit 0 + ;; + show) + echo "ActiveState=inactive" + echo "UnitFileState=disabled" + exit 0 + ;; + *) + exit 0 + ;; +esac +"#; + fs::write(&bin_path, script).expect("write systemctl stub"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&bin_path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&bin_path, perms).expect("chmod"); + } +} + +#[test] +fn reconcile_apply_reports_verification_failure() { + let _lock = path_lock().lock().expect("path lock"); + let repo = temp_dir("core_ops_repo_verify"); + let rev = init_git_repo(&repo); + + let temp = temp_dir("core_ops_verify"); + fs::create_dir_all(&temp).expect("temp dir"); + write_systemctl_stub(&temp); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", temp.display(), old_path); + std::env::set_var("PATH", new_path); + let _guard = PathGuard { previous: old_path }; + + let host_quadlets = temp.join("host_quadlets"); + fs::create_dir_all(&host_quadlets).expect("host quadlets"); + + let deps = ReconcileDependencies { + load_desired: &|| load_desired_state(repo.to_str().unwrap(), &rev).map_err(map_io_error), + read_observed: &|| { + read_observed_state(&host_quadlets, Some("obs".to_string())).map_err(map_io_error) + }, + apply_plan: &|plan, desired| { + apply_plan(plan, &desired.workloads, &host_quadlets, true) + .map(|_| ()) + .map_err(map_io_error) + }, + }; + + let result = reconcile_apply(&deps).expect("apply"); + assert_eq!(result.run.summary, "verification failed"); + assert!(result + .verification_results + .iter() + .any(|res| res.status == core_ops::core::types::VerificationStatus::Failure)); +} + +fn map_io_error(err: E) -> CoreError { + CoreError { + class: core_ops::core::types::FailureClass::Apply, + message: err.to_string(), + } +} + +struct PathGuard { + previous: String, +} + +impl Drop for PathGuard { + fn drop(&mut self) { + std::env::set_var("PATH", &self.previous); + } +} diff --git a/tests/integration/test_verification_rules.rs b/tests/integration/test_verification_rules.rs new file mode 100644 index 0000000..5b52471 --- /dev/null +++ b/tests/integration/test_verification_rules.rs @@ -0,0 +1,185 @@ +use std::fs; +use std::path::PathBuf; + +use core_ops::core::errors::CoreError; +use core_ops::core::reconcile::{reconcile_apply, ReconcileDependencies}; +use core_ops::io::apply::apply_plan; +use core_ops::io::observed::read_observed_state; +use core_ops::io::repo::load_desired_state; +use core_ops::io::systemd::SYSTEMD_UNIT_DIR_ENV; +use crate::integration::env_lock::path_lock; + +fn temp_dir(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos(); + path.push(format!("{}_{}", prefix, nanos)); + path +} + +fn init_git_repo(repo: &PathBuf) -> String { + std::process::Command::new("git") + .arg("init") + .arg(repo) + .output() + .expect("git init"); + + let quadlets = repo.join("quadlets"); + fs::create_dir_all(&quadlets).expect("create quadlets"); + fs::write(quadlets.join("alpha.container"), "[Container]\nImage=alpine") + .expect("write container"); + fs::write(quadlets.join("beta.socket"), "[Socket]\nListenStream=8080") + .expect("write socket"); + fs::write(quadlets.join("gamma.volume"), "[Volume]\nDriver=local") + .expect("write volume"); + + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("add") + .arg(".") + .output() + .expect("git add"); + std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("commit") + .arg("-m") + .arg("fixture") + .env("GIT_AUTHOR_NAME", "fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.com") + .env("GIT_COMMITTER_NAME", "fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.com") + .output() + .expect("git commit"); + + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("rev-parse") + .arg("HEAD") + .output() + .expect("git rev-parse"); + + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +fn write_systemctl_stub(dir: &PathBuf) { + let bin_path = dir.join("systemctl"); + let script = r#"#!/bin/sh +case "$1" in + is-system-running) + echo "running" + exit 0 + ;; + show) + unit="$2" + if echo "$unit" | grep -q "gamma.service"; then + echo "ActiveState=inactive" + echo "UnitFileState=disabled" + else + echo "ActiveState=active" + echo "UnitFileState=enabled" + fi + exit 0 + ;; + *) + exit 0 + ;; +esac +"#; + fs::write(&bin_path, script).expect("write systemctl stub"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&bin_path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&bin_path, perms).expect("chmod"); + } +} + +#[test] +fn verification_rules_accept_volume_inactive() { + let _lock = path_lock().lock().expect("path lock"); + let repo = temp_dir("core_ops_repo_verify_rules"); + let rev = init_git_repo(&repo); + + let temp = temp_dir("core_ops_verify_rules"); + fs::create_dir_all(&temp).expect("temp dir"); + write_systemctl_stub(&temp); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", temp.display(), old_path); + std::env::set_var("PATH", new_path); + let _guard = PathGuard { previous: old_path }; + + let host_quadlets = temp.join("host_quadlets"); + fs::create_dir_all(&host_quadlets).expect("host quadlets"); + let systemd_units = temp.join("systemd_units"); + fs::create_dir_all(&systemd_units).expect("systemd units"); + let _systemd_guard = EnvGuard::set(SYSTEMD_UNIT_DIR_ENV, &systemd_units); + + let deps = ReconcileDependencies { + load_desired: &|| load_desired_state(repo.to_str().unwrap(), &rev).map_err(map_io_error), + read_observed: &|| { + read_observed_state(&host_quadlets, Some("obs".to_string())).map_err(map_io_error) + }, + apply_plan: &|plan, desired| { + apply_plan(plan, &desired.workloads, &host_quadlets, true) + .map(|_| ()) + .map_err(map_io_error) + }, + }; + + let result = reconcile_apply(&deps).expect("apply"); + assert_eq!(result.run.summary, "converged"); + let volume_ok = result + .verification_results + .iter() + .any(|res| res.target == "gamma.service" && res.status == core_ops::core::types::VerificationStatus::Success); + assert!(volume_ok); +} + +fn map_io_error(err: E) -> CoreError { + CoreError { + class: core_ops::core::types::FailureClass::Apply, + message: err.to_string(), + } +} + +struct PathGuard { + previous: String, +} + +impl Drop for PathGuard { + fn drop(&mut self) { + std::env::set_var("PATH", &self.previous); + } +} + +struct EnvGuard { + key: String, + previous: Option, +} + +impl EnvGuard { + fn set(key: &str, value: &PathBuf) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { + key: key.to_string(), + previous, + } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(&self.key, value), + None => std::env::remove_var(&self.key), + } + } +} diff --git a/tests/unit/mod.rs b/tests/unit/mod.rs index c54522d..56e8c52 100644 --- a/tests/unit/mod.rs +++ b/tests/unit/mod.rs @@ -2,4 +2,5 @@ mod test_audit; mod test_invariants; mod test_planner; mod test_types; +mod test_verification; mod test_validation; diff --git a/tests/unit/test_audit.rs b/tests/unit/test_audit.rs index 4bf1504..5543b78 100644 --- a/tests/unit/test_audit.rs +++ b/tests/unit/test_audit.rs @@ -40,7 +40,7 @@ fn audit_record_format_includes_plan_summary() { let observed = observed_state(); let plan = plan(&desired, &observed).expect("plan"); - let record = build_audit_record("run:plan", Vec::new(), &plan); + let record = build_audit_record("run:plan", Vec::new(), &plan, Vec::new()); let output = format_audit_record(&record); assert!(output.contains("plan ")); @@ -60,11 +60,12 @@ fn audit_event_json_is_structured() { summary: "planned".to_string(), }; - let event = build_audit_event(&run, Some(&plan)); + let event = build_audit_event(&run, Some(&plan), &[]); let json = format_audit_event_json(&event); assert!(json.contains("\"run_id\"")); assert!(json.contains("\"plan_id\"")); assert!(json.contains("\"action_count\"")); + assert!(json.contains("\"status\"")); assert!(json.contains("\"summary\"")); } diff --git a/tests/unit/test_planner.rs b/tests/unit/test_planner.rs index ec6c0e5..5dc9e10 100644 --- a/tests/unit/test_planner.rs +++ b/tests/unit/test_planner.rs @@ -5,11 +5,22 @@ use core_ops::core::types::{ }; fn workload(name: &str) -> Workload { + workload_with_type(name, QuadletType::Container) +} + +fn workload_with_type(name: &str, quadlet_type: QuadletType) -> Workload { + let extension = match quadlet_type { + QuadletType::Container => "container", + QuadletType::Socket => "socket", + QuadletType::Volume => "volume", + QuadletType::Pod => "pod", + QuadletType::Network => "network", + }; Workload { name: name.to_string(), - quadlet_type: QuadletType::Container, - quadlet_contents: "[Container]".to_string(), - systemd_unit_name: format!("{}.container", name), + quadlet_type, + quadlet_contents: "[Quadlet]".to_string(), + systemd_unit_name: format!("{name}.{extension}"), enabled_state: EnabledState::Enabled, restart_policy: RestartPolicy::Always, } @@ -47,9 +58,9 @@ fn plan_is_deterministic_by_name_order() { let targets: Vec = plan.actions.iter().map(|a| a.target.clone()).collect(); let alpha_prefix = vec![ - "alpha".to_string(), - "alpha".to_string(), - "alpha".to_string(), + "alpha.container".to_string(), + "alpha.container".to_string(), + "alpha.container".to_string(), ]; assert_eq!(&targets[..3], &alpha_prefix[..]); } @@ -64,3 +75,35 @@ fn plan_has_no_actions_when_states_match() { assert!(plan.actions.is_empty()); } + +#[test] +fn plan_orders_actions_by_quadlet_type() { + let desired = desired_state(vec![ + workload_with_type("socket", QuadletType::Socket), + workload_with_type("container", QuadletType::Container), + workload_with_type("volume", QuadletType::Volume), + ]); + let observed = observed_state(Vec::new()); + + let plan = plan(&desired, &observed).expect("plan should succeed"); + let targets: Vec = plan.actions.iter().map(|a| a.target.clone()).collect(); + + let volume_prefix = vec![ + "volume.volume".to_string(), + "volume.volume".to_string(), + ]; + let container_prefix = vec![ + "container.container".to_string(), + "container.container".to_string(), + "container.container".to_string(), + ]; + let socket_prefix = vec![ + "socket.socket".to_string(), + "socket.socket".to_string(), + "socket.socket".to_string(), + ]; + + assert_eq!(&targets[..2], &volume_prefix[..]); + assert_eq!(&targets[2..5], &container_prefix[..]); + assert_eq!(&targets[5..8], &socket_prefix[..]); +} diff --git a/tests/unit/test_types.rs b/tests/unit/test_types.rs index bec11ff..d1d69ed 100644 --- a/tests/unit/test_types.rs +++ b/tests/unit/test_types.rs @@ -1,7 +1,10 @@ +use std::path::PathBuf; + use core_ops::core::types::{ Boundaries, BoundaryScope, EnabledState, Invariant, QuadletType, RestartPolicy, Workload, }; +use core_ops::io::quadlet::read_quadlet_dir; #[test] fn boundaries_reports_scopes() { @@ -33,3 +36,30 @@ fn invariants_can_be_listed_explicitly() { assert!(invariants.contains(&Invariant::BoundariesDeclared)); assert!(invariants.contains(&Invariant::DeterministicPlan)); } + +#[test] +fn quadlet_type_parsing_supports_socket_and_volume() { + let dir = temp_dir("core_ops_unit_quadlets"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + std::fs::write(dir.join("alpha.container"), "[Container]").expect("write container"); + std::fs::write(dir.join("beta.socket"), "[Socket]").expect("write socket"); + std::fs::write(dir.join("gamma.volume"), "[Volume]").expect("write volume"); + + let mut workloads = read_quadlet_dir(&dir).expect("read quadlet dir"); + workloads.sort_by(|a, b| a.name.cmp(&b.name)); + + assert_eq!(workloads.len(), 3); + assert_eq!(workloads[0].quadlet_type, QuadletType::Container); + assert_eq!(workloads[1].quadlet_type, QuadletType::Socket); + assert_eq!(workloads[2].quadlet_type, QuadletType::Volume); +} + +fn temp_dir(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + path.push(format!("{prefix}_{stamp}")); + path +} diff --git a/tests/unit/test_validation.rs b/tests/unit/test_validation.rs index 099e3d8..5ae0f48 100644 --- a/tests/unit/test_validation.rs +++ b/tests/unit/test_validation.rs @@ -98,14 +98,14 @@ fn fails_when_missing_boundary_scope() { } #[test] -fn fails_on_duplicate_workload_name() { +fn allows_same_name_for_distinct_unit_names() { let mut desired = base_desired(); let mut extra = desired.workloads[0].clone(); - extra.systemd_unit_name = "beta.container".to_string(); + extra.quadlet_type = QuadletType::Socket; + extra.systemd_unit_name = "alpha.socket".to_string(); desired.workloads.push(extra); - let err = validate_desired_state(&desired).unwrap_err(); - assert!(err.message.contains("duplicate workload")); + assert!(validate_desired_state(&desired).is_ok()); } #[test] diff --git a/tests/unit/test_verification.rs b/tests/unit/test_verification.rs new file mode 100644 index 0000000..5bd2680 --- /dev/null +++ b/tests/unit/test_verification.rs @@ -0,0 +1,66 @@ +use core_ops::core::types::{ + DesiredState, ObservedState, QuadletType, UnitActiveState, VerificationStatus, Workload, + EnabledState, RestartPolicy, Invariant, Boundaries, BoundaryScope, ObservedUnit, +}; +use core_ops::core::verify::verify_state; + +fn workload(name: &str, quadlet_type: QuadletType, unit: &str) -> Workload { + Workload { + name: name.to_string(), + quadlet_type, + quadlet_contents: "[Unit]".to_string(), + systemd_unit_name: unit.to_string(), + enabled_state: EnabledState::Enabled, + restart_policy: RestartPolicy::Always, + } +} + +fn desired_state(workloads: Vec) -> DesiredState { + DesiredState { + repository_ref: "repo".to_string(), + revision_id: "rev".to_string(), + workloads, + invariants: vec![Invariant::BoundariesDeclared, Invariant::DeterministicPlan], + boundaries: Boundaries { + scopes: vec![BoundaryScope::QuadletSystemd], + }, + } +} + +fn observed_state(units: Vec) -> ObservedState { + ObservedState { + observed_revision_id: None, + units, + workloads: Vec::new(), + last_reconcile_id: None, + host_info: None, + } +} + +#[test] +fn verify_container_requires_active_unit() { + let desired = desired_state(vec![workload("alpha", QuadletType::Container, "alpha.container")]); + let observed = observed_state(vec![ObservedUnit { + unit_name: "alpha.service".to_string(), + active_state: UnitActiveState::Inactive, + enabled_state: EnabledState::Enabled, + }]); + + let results = verify_state(&desired, &observed); + assert_eq!(results.len(), 1); + assert_eq!(results[0].status, VerificationStatus::Failure); +} + +#[test] +fn verify_volume_accepts_loaded_unit() { + let desired = desired_state(vec![workload("gamma", QuadletType::Volume, "gamma.volume")]); + let observed = observed_state(vec![ObservedUnit { + unit_name: "gamma.service".to_string(), + active_state: UnitActiveState::Inactive, + enabled_state: EnabledState::Disabled, + }]); + + let results = verify_state(&desired, &observed); + assert_eq!(results.len(), 1); + assert_eq!(results[0].status, VerificationStatus::Success); +}