From 599567a1acfac73915915334139ef2a07b25b4c6 Mon Sep 17 00:00:00 2001 From: Noam Alum Date: Sun, 11 Oct 2026 13:21:23 +0300 Subject: [PATCH 1/5] [TASK][MVP] Build reusable Provider package and runtime CI/CD pipelines --- .../actions/environment/install/action.yml | 98 ++++++++ .github/actions/evidence/collect/action.yml | 93 +++++++ .../package/verify-installed/action.yml | 45 ++++ .../package/verify-installed/verify.py | 85 +++++++ .github/actions/provider/crash/action.yml | 58 +++++ .../provider/create-fixture/action.yml | 70 ++++++ .../provider/create-fixture/fixture.py | 118 +++++++++ .github/actions/provider/start/action.yml | 138 +++++++++++ .github/actions/provider/stop/action.yml | 87 +++++++ .../adversarial/invalid-peer/action.yml | 72 ++++++ .../uninstall-reinstall/action.yml | 105 ++++++++ .../lifecycle/concurrent-providers/action.yml | 121 ++++++++++ .../same-instance-replacement/action.yml | 130 ++++++++++ .../resilience/crash-restart/action.yml | 121 ++++++++++ .../smoke/normal-lifecycle/action.yml | 80 ++++++ .../actions/sfp/assert-bootstrap/action.yml | 33 +++ .../sfp/assert-invalid-peer/action.yml | 33 +++ .../actions/sfp/assert-manifest/action.yml | 38 +++ .github/actions/sfp/probe.py | 214 +++++++++++++++++ .../workflows/provider-build-and-verify.yml | 80 ++++++ .github/workflows/provider-build.yml | 115 +++++++++ .github/workflows/provider-ci.yml | 25 ++ .github/workflows/provider-installed-test.yml | 75 ++++++ .github/workflows/provider-integration.yml | 31 +++ .../workflows/provider-release-candidate.yml | 40 +++ .../workflows/provider-runtime-scenarios.yml | 167 +++++++++++++ .github/workflows/provider-source-verify.yml | 95 ++++++++ README.md | 7 + docs/ci-automation.md | 113 +++++++++ mkdocs.yml | 1 + pyproject.toml | 1 + tests/test_ci_automation.py | 227 ++++++++++++++++++ 32 files changed, 2716 insertions(+) create mode 100644 .github/actions/environment/install/action.yml create mode 100644 .github/actions/evidence/collect/action.yml create mode 100644 .github/actions/package/verify-installed/action.yml create mode 100644 .github/actions/package/verify-installed/verify.py create mode 100644 .github/actions/provider/crash/action.yml create mode 100644 .github/actions/provider/create-fixture/action.yml create mode 100644 .github/actions/provider/create-fixture/fixture.py create mode 100644 .github/actions/provider/start/action.yml create mode 100644 .github/actions/provider/stop/action.yml create mode 100644 .github/actions/scenarios/adversarial/invalid-peer/action.yml create mode 100644 .github/actions/scenarios/destructive/uninstall-reinstall/action.yml create mode 100644 .github/actions/scenarios/lifecycle/concurrent-providers/action.yml create mode 100644 .github/actions/scenarios/lifecycle/same-instance-replacement/action.yml create mode 100644 .github/actions/scenarios/resilience/crash-restart/action.yml create mode 100644 .github/actions/scenarios/smoke/normal-lifecycle/action.yml create mode 100644 .github/actions/sfp/assert-bootstrap/action.yml create mode 100644 .github/actions/sfp/assert-invalid-peer/action.yml create mode 100644 .github/actions/sfp/assert-manifest/action.yml create mode 100644 .github/actions/sfp/probe.py create mode 100644 .github/workflows/provider-build-and-verify.yml create mode 100644 .github/workflows/provider-build.yml create mode 100644 .github/workflows/provider-ci.yml create mode 100644 .github/workflows/provider-installed-test.yml create mode 100644 .github/workflows/provider-integration.yml create mode 100644 .github/workflows/provider-release-candidate.yml create mode 100644 .github/workflows/provider-runtime-scenarios.yml create mode 100644 .github/workflows/provider-source-verify.yml create mode 100644 docs/ci-automation.md create mode 100644 tests/test_ci_automation.py diff --git a/.github/actions/environment/install/action.yml b/.github/actions/environment/install/action.yml new file mode 100644 index 0000000..5c985e4 --- /dev/null +++ b/.github/actions/environment/install/action.yml @@ -0,0 +1,98 @@ +name: Install exact Provider and Protocol artifacts +description: Create an isolated environment outside both source checkouts. + +inputs: + provider-artifact-directory: + description: Directory containing the Provider wheel and provenance. + required: true + protocol-artifact-directory: + description: Directory containing the Protocol wheel and provenance. + required: true + environment-directory: + description: New virtual environment path under the runner temporary directory. + required: true + evidence-directory: + description: Directory receiving installation evidence. + required: true + +outputs: + python: + description: Absolute isolated Python interpreter path. + value: ${{ steps.install.outputs.python }} + provider-version: + description: Installed Provider version expected from build provenance. + value: ${{ steps.install.outputs.provider-version }} + provider-wheel: + description: Absolute path to the exact Provider wheel. + value: ${{ steps.install.outputs.provider-wheel }} + protocol-wheel: + description: Absolute path to the exact Protocol wheel. + value: ${{ steps.install.outputs.protocol-wheel }} + +runs: + using: composite + steps: + - name: Verify artifact identity and create environment + id: install + shell: bash + env: + ENVIRONMENT_DIRECTORY: ${{ inputs.environment-directory }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + PROVIDER_ARTIFACT_DIRECTORY: ${{ inputs.provider-artifact-directory }} + PROTOCOL_ARTIFACT_DIRECTORY: ${{ inputs.protocol-artifact-directory }} + run: | + set -euo pipefail + if [[ -e "${ENVIRONMENT_DIRECTORY}" ]]; then + echo "Refusing to replace existing environment: ${ENVIRONMENT_DIRECTORY}" >&2 + exit 1 + fi + mkdir -p "${EVIDENCE_DIRECTORY}" + + if [[ "${RUNNER_OS:-}" == "macOS" ]]; then + checksum_command=(shasum --algorithm 256 --check) + else + checksum_command=(sha256sum --check) + fi + ( + cd "${PROVIDER_ARTIFACT_DIRECTORY}" + "${checksum_command[@]}" SHA256SUMS + ) > "${EVIDENCE_DIRECTORY}/provider-checksums.txt" + ( + cd "${PROTOCOL_ARTIFACT_DIRECTORY}" + "${checksum_command[@]}" PROTOCOL_SHA256SUMS + ) > "${EVIDENCE_DIRECTORY}/protocol-checksums.txt" + + shopt -s nullglob + provider_wheels=("${PROVIDER_ARTIFACT_DIRECTORY}"/stateforge_provider-*.whl) + protocol_wheels=("${PROTOCOL_ARTIFACT_DIRECTORY}"/stateforge_protocol-*.whl) + if [[ ${#provider_wheels[@]} -ne 1 || ${#protocol_wheels[@]} -ne 1 ]]; then + echo "Expected exactly one Provider wheel and one Protocol wheel" >&2 + exit 1 + fi + provider_wheel="$(cd "$(dirname "${provider_wheels[0]}")" && pwd)/$(basename "${provider_wheels[0]}")" + protocol_wheel="$(cd "$(dirname "${protocol_wheels[0]}")" && pwd)/$(basename "${protocol_wheels[0]}")" + provider_version="$(awk -F= '$1 == "version" {print $2}' \ + "${PROVIDER_ARTIFACT_DIRECTORY}/PROVENANCE")" + if [[ -z "${provider_version}" ]]; then + echo "Provider provenance does not contain a version" >&2 + exit 1 + fi + + python -m venv "${ENVIRONMENT_DIRECTORY}" + if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + isolated_python="${ENVIRONMENT_DIRECTORY}/Scripts/python.exe" + else + isolated_python="${ENVIRONMENT_DIRECTORY}/bin/python" + fi + { + "${isolated_python}" -m pip install --disable-pip-version-check \ + "${protocol_wheel}" "${provider_wheel}" + "${isolated_python}" -m pip freeze + } 2>&1 | tee "${EVIDENCE_DIRECTORY}/install.log" + + { + echo "python=${isolated_python}" + echo "provider-version=${provider_version}" + echo "provider-wheel=${provider_wheel}" + echo "protocol-wheel=${protocol_wheel}" + } >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/evidence/collect/action.yml b/.github/actions/evidence/collect/action.yml new file mode 100644 index 0000000..64019db --- /dev/null +++ b/.github/actions/evidence/collect/action.yml @@ -0,0 +1,93 @@ +name: Collect bounded Provider runtime evidence +description: Preserve safe scenario evidence and remove temporary runtime state. + +inputs: + stage: + description: Stable scenario name. + required: true + runtime-directory: + description: Scenario-owned directory below the runner temp directory. + required: true + evidence-directory: + description: Directory containing and receiving scenario evidence. + required: true + max-file-bytes: + description: Maximum retained size of an individual evidence file. + required: false + default: "262144" + +runs: + using: composite + steps: + - name: Preserve evidence and clean scenario state + shell: bash + env: + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + EVIDENCE_STAGE: ${{ inputs.stage }} + MAX_FILE_BYTES: ${{ inputs.max-file-bytes }} + RUNTIME_DIRECTORY: ${{ inputs.runtime-directory }} + run: | + set -euo pipefail + if [[ ! "${MAX_FILE_BYTES}" =~ ^[0-9]+$ ]] || \ + (( MAX_FILE_BYTES < 1024 )); then + echo "max-file-bytes must be an integer of at least 1024" >&2 + exit 1 + fi + case "${RUNTIME_DIRECTORY}" in + "${RUNNER_TEMP}"/*) ;; + *) + echo "runtime-directory must be below RUNNER_TEMP" >&2 + exit 1 + ;; + esac + + mkdir -p "${EVIDENCE_DIRECTORY}" + printf '%s\n' \ + "stage=${EVIDENCE_STAGE}" \ + "repository=${GITHUB_REPOSITORY:-local}" \ + "ref=${GITHUB_REF:-local}" \ + "sha=${GITHUB_SHA:-local}" \ + "runner-os=${RUNNER_OS:-local}" \ + "runner-arch=${RUNNER_ARCH:-unknown}" \ + > "${EVIDENCE_DIRECTORY}/runner-context.txt" + + while IFS= read -r -d '' pid_file; do + pid="$(cat "${pid_file}")" + if [[ "${pid}" =~ ^[0-9]+$ ]] && kill -0 "${pid}" 2>/dev/null; then + kill -TERM "${pid}" 2>/dev/null || true + for _ in {1..30}; do + state="$(ps -o stat= -p "${pid}" 2>/dev/null || true)" + if [[ -z "${state}" || "${state}" == Z* ]]; then + break + fi + sleep 0.1 + done + kill -KILL "${pid}" 2>/dev/null || true + fi + done < <(find "${EVIDENCE_DIRECTORY}" -name provider.pid -print0) + + if [[ -d "${RUNTIME_DIRECTORY}" ]]; then + find "${RUNTIME_DIRECTORY}" -maxdepth 5 \ + -path "${RUNTIME_DIRECTORY}/environment" -prune -o \ + -printf '%M %u:%g %s %p\n' \ + > "${EVIDENCE_DIRECTORY}/runtime-inventory.txt" + find "${RUNTIME_DIRECTORY}" \ + -path "${RUNTIME_DIRECTORY}/environment" -prune -o -type f \ + \( -name '*.json' -o -name '*.log' -o -name '*.txt' \) \ + -print0 | while IFS= read -r -d '' file; do + relative="${file#"${RUNTIME_DIRECTORY}"/}" + destination="${EVIDENCE_DIRECTORY}/runtime/${relative}" + mkdir -p "$(dirname "${destination}")" + cp "${file}" "${destination}" + done + fi + + while IFS= read -r -d '' file; do + size="$(wc -c < "${file}")" + if (( size > MAX_FILE_BYTES )); then + tail -c "${MAX_FILE_BYTES}" "${file}" > "${file}.bounded" + mv "${file}.bounded" "${file}" + fi + done < <(find "${EVIDENCE_DIRECTORY}" -type f -print0) + + rm -rf -- "${RUNTIME_DIRECTORY}" diff --git a/.github/actions/package/verify-installed/action.yml b/.github/actions/package/verify-installed/action.yml new file mode 100644 index 0000000..73be425 --- /dev/null +++ b/.github/actions/package/verify-installed/action.yml @@ -0,0 +1,45 @@ +name: Verify an installed StateForge Provider wheel +description: Prove exact installed identity, import origin, and public API. + +inputs: + provider-artifact-directory: + description: Directory containing the Provider distribution artifact. + required: true + protocol-artifact-directory: + description: Directory containing the Protocol distribution artifact. + required: true + environment-directory: + description: New isolated environment path under the runner temp directory. + required: true + evidence-directory: + description: Directory receiving installed-package evidence. + required: true + +runs: + using: composite + steps: + - name: Install exact artifacts outside the checkout + id: environment + uses: ./.github/actions/environment/install + with: + provider-artifact-directory: ${{ inputs.provider-artifact-directory }} + protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} + environment-directory: ${{ inputs.environment-directory }} + evidence-directory: ${{ inputs.evidence-directory }} + + - name: Verify installed package identity and API + shell: bash + env: + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + EXPECTED_VERSION: ${{ steps.environment.outputs.provider-version }} + ISOLATED_PYTHON: ${{ steps.environment.outputs.python }} + VERIFY_SCRIPT: ${{ github.workspace }}/.github/actions/package/verify-installed/verify.py + run: | + set -euo pipefail + output="${GITHUB_WORKSPACE}/${EVIDENCE_DIRECTORY}/package.json" + cd "${RUNNER_TEMP}" + unset PYTHONPATH PYTHONHOME + "${ISOLATED_PYTHON}" -I "${VERIFY_SCRIPT}" \ + --expected-version "${EXPECTED_VERSION}" \ + --workspace "${GITHUB_WORKSPACE}" \ + --output "${output}" diff --git a/.github/actions/package/verify-installed/verify.py b/.github/actions/package/verify-installed/verify.py new file mode 100644 index 0000000..2b58966 --- /dev/null +++ b/.github/actions/package/verify-installed/verify.py @@ -0,0 +1,85 @@ +"""Verify installed StateForge distributions without importing source trees.""" + +from __future__ import annotations + +import argparse +import json +from importlib.metadata import distribution +from pathlib import Path + +import stateforge_protocol + +import stateforge_provider + + +def _arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--expected-version", required=True) + parser.add_argument("--workspace", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + return parser.parse_args() + + +def _outside(path: Path, workspace: Path) -> bool: + try: + path.relative_to(workspace) + except ValueError: + return True + return False + + +def main() -> None: + """Assert package identity, import origin, and the public API.""" + arguments = _arguments() + workspace = arguments.workspace.resolve() + provider_file = Path(stateforge_provider.__file__).resolve() + protocol_file = Path(stateforge_protocol.__file__).resolve() + if not _outside(provider_file, workspace): + raise SystemExit("stateforge_provider was imported from the checkout") + if not _outside(protocol_file, workspace): + raise SystemExit("stateforge_protocol was imported from the checkout") + + provider = distribution("stateforge-provider") + protocol = distribution("stateforge-protocol") + if provider.metadata["Name"] != "stateforge-provider": + raise SystemExit("Installed Provider distribution name is incorrect") + if provider.version != arguments.expected_version: + raise SystemExit("Installed Provider version is incorrect") + + required_api = { + "ManifestError", + "Platform", + "ProtocolVersion", + "Provider", + "ProviderModel", + "ProviderServerError", + "RuntimeRequirement", + "Trigger", + "operation", + } + missing = sorted(required_api.difference(stateforge_provider.__all__)) + if missing: + raise SystemExit(f"Public Provider API is incomplete: {missing}") + + evidence = { + "provider": { + "file": str(provider_file), + "name": provider.metadata["Name"], + "version": provider.version, + }, + "protocol": { + "file": str(protocol_file), + "name": protocol.metadata["Name"], + "version": protocol.version, + }, + "public_api": sorted(required_api), + } + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/.github/actions/provider/crash/action.yml b/.github/actions/provider/crash/action.yml new file mode 100644 index 0000000..866eef6 --- /dev/null +++ b/.github/actions/provider/crash/action.yml @@ -0,0 +1,58 @@ +name: Crash an installed StateForge Provider +description: Terminate one fixture abruptly and record its stale endpoint. + +inputs: + pid: + description: Fixture process ID returned by the start action. + required: true + descriptor-path: + description: Stable descriptor path observed before the crash. + required: true + socket-path: + description: Generation-specific socket path observed before the crash. + required: true + evidence-directory: + description: Directory receiving deterministic fault evidence. + required: true + timeout-seconds: + description: Maximum time to wait for process termination. + required: false + default: "10" + +runs: + using: composite + steps: + - name: Terminate fixture without graceful cleanup + shell: bash + env: + DESCRIPTOR_PATH: ${{ inputs.descriptor-path }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + PROVIDER_PID: ${{ inputs.pid }} + SOCKET_PATH: ${{ inputs.socket-path }} + TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }} + run: | + set -euo pipefail + mkdir -p "${EVIDENCE_DIRECTORY}" + kill -KILL "${PROVIDER_PID}" + deadline=$((SECONDS + TIMEOUT_SECONDS)) + while (( SECONDS < deadline )); do + state="$(ps -o stat= -p "${PROVIDER_PID}" 2>/dev/null || true)" + if [[ -z "${state}" || "${state}" == Z* ]]; then + break + fi + sleep 0.1 + done + state="$(ps -o stat= -p "${PROVIDER_PID}" 2>/dev/null || true)" + if [[ -n "${state}" && "${state}" != Z* ]]; then + echo "Provider process survived SIGKILL" >&2 + exit 1 + fi + if [[ ! -f "${DESCRIPTOR_PATH}" || ! -S "${SOCKET_PATH}" ]]; then + echo "Crash did not leave the expected discovery evidence" >&2 + exit 1 + fi + printf '%s\n' \ + "pid=${PROVIDER_PID}" \ + "descriptor=${DESCRIPTOR_PATH}" \ + "socket=${SOCKET_PATH}" \ + > "${EVIDENCE_DIRECTORY}/crash.txt" diff --git a/.github/actions/provider/create-fixture/action.yml b/.github/actions/provider/create-fixture/action.yml new file mode 100644 index 0000000..d1859f3 --- /dev/null +++ b/.github/actions/provider/create-fixture/action.yml @@ -0,0 +1,70 @@ +name: Create a public-API Provider fixture +description: Stage one declarative Provider outside the source checkout. + +inputs: + runtime-directory: + description: Scenario-owned runtime root under the runner temp directory. + required: true + instance-name: + description: Stable local name for this fixture process. + required: true + identity: + description: Author-declared Provider identity. + required: true + display-name: + description: Author-declared Provider display name. + required: false + default: StateForge CI Provider + state-name: + description: Shared installation-state name; empty uses instance-name. + required: false + default: "" + +outputs: + fixture-path: + description: Absolute staged fixture entry point. + value: ${{ steps.fixture.outputs.fixture-path }} + discovery-directory: + description: Shared scenario discovery directory. + value: ${{ steps.fixture.outputs.discovery-directory }} + state-directory: + description: Isolated state directory for this Provider installation. + value: ${{ steps.fixture.outputs.state-directory }} + event-log: + description: Author lifecycle event log. + value: ${{ steps.fixture.outputs.event-log }} + +runs: + using: composite + steps: + - name: Stage declarative Provider fixture + id: fixture + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + DISPLAY_NAME: ${{ inputs.display-name }} + IDENTITY: ${{ inputs.identity }} + INSTANCE_NAME: ${{ inputs.instance-name }} + RUNTIME_DIRECTORY: ${{ inputs.runtime-directory }} + STATE_NAME: ${{ inputs.state-name }} + run: | + set -euo pipefail + root="${RUNTIME_DIRECTORY}/${INSTANCE_NAME}" + discovery="${RUNTIME_DIRECTORY}/discovery" + state_name="${STATE_NAME:-${INSTANCE_NAME}}" + state="${RUNTIME_DIRECTORY}/state/${state_name}" + event_log="${root}/events.log" + fixture="${root}/provider.py" + mkdir -p "${root}" "${discovery}" + chmod 2770 "${discovery}" + install -m 0644 "${ACTION_PATH}/fixture.py" "${fixture}" + printf '%s\n' \ + "identity=${IDENTITY}" \ + "display-name=${DISPLAY_NAME}" \ + > "${root}/fixture-identity.txt" + { + echo "fixture-path=${fixture}" + echo "discovery-directory=${discovery}" + echo "state-directory=${state}" + echo "event-log=${event_log}" + } >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/provider/create-fixture/fixture.py b/.github/actions/provider/create-fixture/fixture.py new file mode 100644 index 0000000..bf77fae --- /dev/null +++ b/.github/actions/provider/create-fixture/fixture.py @@ -0,0 +1,118 @@ +"""Public-API Provider used by installed-runtime CI scenarios.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from pydantic import SecretStr + +from stateforge_provider import ( + Platform, + Provider, + ProviderModel, + RuntimeRequirement, + Trigger, + operation, +) + + +class FixtureConfiguration(ProviderModel): + """Describe configuration shape without publishing configured values.""" + + endpoint: str + token: SecretStr + + +class EchoInput(ProviderModel): + """Describe the fixture operation input.""" + + value: str + + +class EchoResult(ProviderModel): + """Describe the fixture operation result.""" + + value: str + + +class ChangedPayload(ProviderModel): + """Describe the fixture trigger payload.""" + + reference: str + + +class FixtureProvider(Provider): + """Exercise the supported authoring and lifecycle surface only.""" + + identity = "stateforge.test.provider" + display_name = "StateForge CI Provider" + version = "1.0.0" + platforms = (Platform("linux", "x86_64"),) + configuration_model = FixtureConfiguration + runtime_requirements = ( + RuntimeRequirement( + "runtime.python", + "Python interpreter", + ">=3.11", + ), + ) + triggers = (Trigger("stateforge.test/changed@1", ChangedPayload),) + + def __init__( + self, + *, + discovery_directory: Path, + state_directory: Path, + event_log: Path, + ) -> None: + """Bind the fixture to isolated scenario-owned paths.""" + super().__init__( + discovery_directory=discovery_directory, + state_directory=state_directory, + ) + self._event_log = event_log + + async def on_startup(self) -> None: + """Record that author resources were initialized.""" + self._record("startup") + + async def on_shutdown(self) -> None: + """Record that author resources were released.""" + self._record("shutdown") + + @operation("stateforge.test/echo@1") + async def echo(self, request: EchoInput) -> EchoResult: + """Declare one typed operation without invoking it in R11.""" + return EchoResult(value=request.value) + + def _record(self, event: str) -> None: + self._event_log.parent.mkdir(parents=True, exist_ok=True) + with self._event_log.open("a", encoding="utf-8") as stream: + stream.write(f"{event}\n") + + +def _arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--identity", required=True) + parser.add_argument("--display-name", required=True) + parser.add_argument("--discovery-directory", type=Path, required=True) + parser.add_argument("--state-directory", type=Path, required=True) + parser.add_argument("--event-log", type=Path, required=True) + return parser.parse_args() + + +def main() -> None: + """Run one standalone fixture Provider until it receives a signal.""" + arguments = _arguments() + FixtureProvider.identity = arguments.identity + FixtureProvider.display_name = arguments.display_name + FixtureProvider( + discovery_directory=arguments.discovery_directory.resolve(), + state_directory=arguments.state_directory.resolve(), + event_log=arguments.event_log.resolve(), + ).run() + + +if __name__ == "__main__": + main() diff --git a/.github/actions/provider/start/action.yml b/.github/actions/provider/start/action.yml new file mode 100644 index 0000000..506f980 --- /dev/null +++ b/.github/actions/provider/start/action.yml @@ -0,0 +1,138 @@ +name: Start an installed StateForge Provider +description: Start a staged public-API fixture and wait for its descriptor. + +inputs: + python: + description: Python interpreter containing the installed Provider package. + required: true + fixture-path: + description: Staged Provider fixture entry point. + required: true + identity: + description: Author-declared Provider identity. + required: true + display-name: + description: Author-declared Provider display name. + required: false + default: StateForge CI Provider + discovery-directory: + description: Shared discovery directory. + required: true + state-directory: + description: Persistent state directory for this Provider identity. + required: true + event-log: + description: Lifecycle event log written by the fixture. + required: true + evidence-directory: + description: Directory receiving process and descriptor evidence. + required: true + timeout-seconds: + description: Maximum time to wait for publication. + required: false + default: "15" + +outputs: + pid: + description: Fixture process ID. + value: ${{ steps.start.outputs.pid }} + descriptor-path: + description: Published endpoint descriptor path. + value: ${{ steps.start.outputs.descriptor-path }} + provider-instance-id: + description: Stable installation identity. + value: ${{ steps.start.outputs.provider-instance-id }} + endpoint-generation: + description: Allocated endpoint generation. + value: ${{ steps.start.outputs.endpoint-generation }} + socket-path: + description: Generation-specific Unix socket. + value: ${{ steps.start.outputs.socket-path }} + +runs: + using: composite + steps: + - name: Start fixture and wait for endpoint publication + id: start + shell: bash + env: + DISCOVERY_DIRECTORY: ${{ inputs.discovery-directory }} + DISPLAY_NAME: ${{ inputs.display-name }} + EVENT_LOG: ${{ inputs.event-log }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + FIXTURE_PATH: ${{ inputs.fixture-path }} + IDENTITY: ${{ inputs.identity }} + ISOLATED_PYTHON: ${{ inputs.python }} + STATE_DIRECTORY: ${{ inputs.state-directory }} + TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }} + run: | + set -euo pipefail + if [[ ! "${TIMEOUT_SECONDS}" =~ ^[0-9]+$ ]] || (( TIMEOUT_SECONDS < 1 )); then + echo "timeout-seconds must be a positive integer" >&2 + exit 1 + fi + mkdir -p "${EVIDENCE_DIRECTORY}" + log="${EVIDENCE_DIRECTORY}/provider.log" + previous_generation=0 + if [[ -f "${STATE_DIRECTORY}/identity.json" ]]; then + previous_generation="$(jq -r '.last_endpoint_generation // 0' \ + "${STATE_DIRECTORY}/identity.json")" + if [[ ! "${previous_generation}" =~ ^[0-9]+$ ]]; then + echo "Existing endpoint generation is invalid" >&2 + exit 1 + fi + fi + nohup env -u PYTHONPATH -u PYTHONHOME \ + "${ISOLATED_PYTHON}" -I "${FIXTURE_PATH}" \ + --identity "${IDENTITY}" \ + --display-name "${DISPLAY_NAME}" \ + --discovery-directory "${DISCOVERY_DIRECTORY}" \ + --state-directory "${STATE_DIRECTORY}" \ + --event-log "${EVENT_LOG}" \ + > "${log}" 2>&1 & + pid=$! + printf '%s\n' "${pid}" > "${EVIDENCE_DIRECTORY}/provider.pid" + + deadline=$((SECONDS + TIMEOUT_SECONDS)) + descriptor="" + while (( SECONDS < deadline )); do + if ! kill -0 "${pid}" 2>/dev/null; then + cat "${log}" >&2 + echo "Provider exited before publishing its descriptor" >&2 + exit 1 + fi + if [[ -f "${STATE_DIRECTORY}/identity.json" ]]; then + provider_id="$(jq -r '.provider_instance_id' \ + "${STATE_DIRECTORY}/identity.json")" + descriptor="${DISCOVERY_DIRECTORY}/${provider_id}.sfp.json" + if [[ -f "${descriptor}" ]]; then + generation="$(jq -r '.endpoint_generation // 0' \ + "${descriptor}")" + if [[ "${generation}" =~ ^[0-9]+$ ]] && \ + (( generation > previous_generation )); then + break + fi + fi + fi + sleep 0.1 + done + if [[ -z "${descriptor}" || ! -f "${descriptor}" ]]; then + cat "${log}" >&2 + echo "Provider did not publish within ${TIMEOUT_SECONDS}s" >&2 + exit 1 + fi + + generation="$(jq -r '.endpoint_generation' "${descriptor}")" + socket_path="$(jq -r '.transport.socket_path' "${descriptor}")" + if [[ ! -S "${socket_path}" ]]; then + echo "Published Provider socket is not available" >&2 + exit 1 + fi + cp "${descriptor}" "${EVIDENCE_DIRECTORY}/descriptor.json" + { + echo "pid=${pid}" + echo "descriptor-path=${descriptor}" + echo "provider-instance-id=${provider_id}" + echo "endpoint-generation=${generation}" + echo "socket-path=${socket_path}" + } >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/provider/stop/action.yml b/.github/actions/provider/stop/action.yml new file mode 100644 index 0000000..4ddf54e --- /dev/null +++ b/.github/actions/provider/stop/action.yml @@ -0,0 +1,87 @@ +name: Stop an installed StateForge Provider +description: Request bounded shutdown and verify generation-owned cleanup. + +inputs: + pid: + description: Fixture process ID returned by the start action. + required: true + descriptor-path: + description: Stable descriptor path observed at startup. + required: true + socket-path: + description: Generation-specific socket path observed at startup. + required: true + event-log: + description: Lifecycle event log written by the fixture. + required: true + evidence-directory: + description: Directory receiving shutdown evidence. + required: true + descriptor-expectation: + description: Whether the stable descriptor must be removed or preserved. + required: false + default: removed + timeout-seconds: + description: Maximum time to wait for graceful shutdown. + required: false + default: "15" + +runs: + using: composite + steps: + - name: Stop fixture and verify cleanup + shell: bash + env: + DESCRIPTOR_EXPECTATION: ${{ inputs.descriptor-expectation }} + DESCRIPTOR_PATH: ${{ inputs.descriptor-path }} + EVENT_LOG: ${{ inputs.event-log }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + PROVIDER_PID: ${{ inputs.pid }} + SOCKET_PATH: ${{ inputs.socket-path }} + TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }} + run: | + set -euo pipefail + if [[ "${DESCRIPTOR_EXPECTATION}" != "removed" && \ + "${DESCRIPTOR_EXPECTATION}" != "preserved" ]]; then + echo "descriptor-expectation must be removed or preserved" >&2 + exit 1 + fi + if kill -0 "${PROVIDER_PID}" 2>/dev/null; then + kill -TERM "${PROVIDER_PID}" + fi + + deadline=$((SECONDS + TIMEOUT_SECONDS)) + while (( SECONDS < deadline )); do + state="$(ps -o stat= -p "${PROVIDER_PID}" 2>/dev/null || true)" + if [[ -z "${state}" || "${state}" == Z* ]]; then + break + fi + sleep 0.1 + done + state="$(ps -o stat= -p "${PROVIDER_PID}" 2>/dev/null || true)" + if [[ -n "${state}" && "${state}" != Z* ]]; then + kill -KILL "${PROVIDER_PID}" 2>/dev/null || true + echo "Provider did not stop within ${TIMEOUT_SECONDS}s" >&2 + exit 1 + fi + + if [[ -e "${SOCKET_PATH}" ]]; then + echo "Provider socket remained after graceful shutdown" >&2 + exit 1 + fi + if [[ "${DESCRIPTOR_EXPECTATION}" == "removed" && \ + -e "${DESCRIPTOR_PATH}" ]]; then + echo "Provider descriptor remained after graceful shutdown" >&2 + exit 1 + fi + if [[ "${DESCRIPTOR_EXPECTATION}" == "preserved" && \ + ! -e "${DESCRIPTOR_PATH}" ]]; then + echo "Older generation removed the replacement descriptor" >&2 + exit 1 + fi + if ! grep -Fxq startup "${EVENT_LOG}" || \ + ! grep -Fxq shutdown "${EVENT_LOG}"; then + echo "Provider lifecycle hooks did not both complete" >&2 + exit 1 + fi + cp "${EVENT_LOG}" "${EVIDENCE_DIRECTORY}/lifecycle-events.txt" diff --git a/.github/actions/scenarios/adversarial/invalid-peer/action.yml b/.github/actions/scenarios/adversarial/invalid-peer/action.yml new file mode 100644 index 0000000..4448e40 --- /dev/null +++ b/.github/actions/scenarios/adversarial/invalid-peer/action.yml @@ -0,0 +1,72 @@ +name: Test invalid SFP peers +description: Verify invalid identity/session rejection and subsequent recovery. + +inputs: + provider-artifact-directory: + description: Directory containing the exact Provider build. + required: true + protocol-artifact-directory: + description: Directory containing the exact Protocol build. + required: true + runtime-directory: + description: Scenario-owned directory under the runner temp directory. + required: true + evidence-directory: + description: Directory receiving scenario evidence. + required: true + +runs: + using: composite + steps: + - name: Install exact runtime artifacts + id: environment + uses: ./.github/actions/environment/install + with: + provider-artifact-directory: ${{ inputs.provider-artifact-directory }} + protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} + environment-directory: ${{ inputs.runtime-directory }}/environment + evidence-directory: ${{ inputs.evidence-directory }}/installation + + - name: Create Provider fixture + id: fixture + uses: ./.github/actions/provider/create-fixture + with: + runtime-directory: ${{ inputs.runtime-directory }} + instance-name: provider + identity: stateforge.test.invalid + + - name: Start Provider fixture + id: provider + uses: ./.github/actions/provider/start + with: + python: ${{ steps.environment.outputs.python }} + fixture-path: ${{ steps.fixture.outputs.fixture-path }} + identity: stateforge.test.invalid + discovery-directory: ${{ steps.fixture.outputs.discovery-directory }} + state-directory: ${{ steps.fixture.outputs.state-directory }} + event-log: ${{ steps.fixture.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider + + - name: Reject invalid peers and recover + uses: ./.github/actions/sfp/assert-invalid-peer + with: + python: ${{ steps.environment.outputs.python }} + descriptor-path: ${{ steps.provider.outputs.descriptor-path }} + evidence-directory: ${{ inputs.evidence-directory }}/sfp + + - name: Stop Provider fixture + uses: ./.github/actions/provider/stop + with: + pid: ${{ steps.provider.outputs.pid }} + descriptor-path: ${{ steps.provider.outputs.descriptor-path }} + socket-path: ${{ steps.provider.outputs.socket-path }} + event-log: ${{ steps.fixture.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider + + - name: Preserve bounded evidence and clean up + if: ${{ always() }} + uses: ./.github/actions/evidence/collect + with: + stage: invalid-peer + runtime-directory: ${{ inputs.runtime-directory }} + evidence-directory: ${{ inputs.evidence-directory }} diff --git a/.github/actions/scenarios/destructive/uninstall-reinstall/action.yml b/.github/actions/scenarios/destructive/uninstall-reinstall/action.yml new file mode 100644 index 0000000..31f32db --- /dev/null +++ b/.github/actions/scenarios/destructive/uninstall-reinstall/action.yml @@ -0,0 +1,105 @@ +name: Test Provider uninstall and reinstall +description: Verify the exact Provider wheel remains usable after replacement. + +inputs: + provider-artifact-directory: + description: Directory containing the exact Provider build. + required: true + protocol-artifact-directory: + description: Directory containing the exact Protocol build. + required: true + runtime-directory: + description: Scenario-owned directory under the runner temp directory. + required: true + evidence-directory: + description: Directory receiving scenario evidence. + required: true + +runs: + using: composite + steps: + - name: Install exact runtime artifacts + id: environment + uses: ./.github/actions/environment/install + with: + provider-artifact-directory: ${{ inputs.provider-artifact-directory }} + protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} + environment-directory: ${{ inputs.runtime-directory }}/environment + evidence-directory: ${{ inputs.evidence-directory }}/installation + + - name: Uninstall and reinstall the exact Provider wheel + shell: bash + env: + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + EXPECTED_VERSION: ${{ steps.environment.outputs.provider-version }} + ISOLATED_PYTHON: ${{ steps.environment.outputs.python }} + PROVIDER_WHEEL: ${{ steps.environment.outputs.provider-wheel }} + VERIFY_SCRIPT: ${{ github.workspace }}/.github/actions/package/verify-installed/verify.py + run: | + set -euo pipefail + { + "${ISOLATED_PYTHON}" -m pip uninstall --yes stateforge-provider + if ( + cd "${RUNNER_TEMP}" + unset PYTHONPATH PYTHONHOME + "${ISOLATED_PYTHON}" -I -c 'import stateforge_provider' + ); then + echo "Provider remained importable after uninstall" >&2 + exit 1 + fi + "${ISOLATED_PYTHON}" -m pip install --disable-pip-version-check \ + "${PROVIDER_WHEEL}" + } 2>&1 | tee "${EVIDENCE_DIRECTORY}/uninstall-reinstall.log" + ( + cd "${RUNNER_TEMP}" + unset PYTHONPATH PYTHONHOME + "${ISOLATED_PYTHON}" -I "${VERIFY_SCRIPT}" \ + --expected-version "${EXPECTED_VERSION}" \ + --workspace "${GITHUB_WORKSPACE}" \ + --output "${GITHUB_WORKSPACE}/${EVIDENCE_DIRECTORY}/reinstalled.json" + ) + + - name: Create reinstalled Provider fixture + id: fixture + uses: ./.github/actions/provider/create-fixture + with: + runtime-directory: ${{ inputs.runtime-directory }} + instance-name: provider + identity: stateforge.test.reinstalled + + - name: Start reinstalled Provider + id: provider + uses: ./.github/actions/provider/start + with: + python: ${{ steps.environment.outputs.python }} + fixture-path: ${{ steps.fixture.outputs.fixture-path }} + identity: stateforge.test.reinstalled + discovery-directory: ${{ steps.fixture.outputs.discovery-directory }} + state-directory: ${{ steps.fixture.outputs.state-directory }} + event-log: ${{ steps.fixture.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider + + - name: Assert reinstalled Manifest + uses: ./.github/actions/sfp/assert-manifest + with: + python: ${{ steps.environment.outputs.python }} + descriptor-path: ${{ steps.provider.outputs.descriptor-path }} + identity: stateforge.test.reinstalled + evidence-directory: ${{ inputs.evidence-directory }}/sfp + + - name: Stop reinstalled Provider + uses: ./.github/actions/provider/stop + with: + pid: ${{ steps.provider.outputs.pid }} + descriptor-path: ${{ steps.provider.outputs.descriptor-path }} + socket-path: ${{ steps.provider.outputs.socket-path }} + event-log: ${{ steps.fixture.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider + + - name: Preserve bounded evidence and clean up + if: ${{ always() }} + uses: ./.github/actions/evidence/collect + with: + stage: uninstall-reinstall + runtime-directory: ${{ inputs.runtime-directory }} + evidence-directory: ${{ inputs.evidence-directory }} diff --git a/.github/actions/scenarios/lifecycle/concurrent-providers/action.yml b/.github/actions/scenarios/lifecycle/concurrent-providers/action.yml new file mode 100644 index 0000000..022d825 --- /dev/null +++ b/.github/actions/scenarios/lifecycle/concurrent-providers/action.yml @@ -0,0 +1,121 @@ +name: Test concurrent installed Providers +description: Verify two distinct Provider installations can run concurrently. + +inputs: + provider-artifact-directory: + description: Directory containing the exact Provider build. + required: true + protocol-artifact-directory: + description: Directory containing the exact Protocol build. + required: true + runtime-directory: + description: Scenario-owned directory under the runner temp directory. + required: true + evidence-directory: + description: Directory receiving scenario evidence. + required: true + +runs: + using: composite + steps: + - name: Install exact runtime artifacts + id: environment + uses: ./.github/actions/environment/install + with: + provider-artifact-directory: ${{ inputs.provider-artifact-directory }} + protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} + environment-directory: ${{ inputs.runtime-directory }}/environment + evidence-directory: ${{ inputs.evidence-directory }}/installation + + - name: Create first Provider fixture + id: fixture-a + uses: ./.github/actions/provider/create-fixture + with: + runtime-directory: ${{ inputs.runtime-directory }} + instance-name: provider-a + identity: stateforge.test.concurrent.a + + - name: Create second Provider fixture + id: fixture-b + uses: ./.github/actions/provider/create-fixture + with: + runtime-directory: ${{ inputs.runtime-directory }} + instance-name: provider-b + identity: stateforge.test.concurrent.b + + - name: Start first Provider + id: provider-a + uses: ./.github/actions/provider/start + with: + python: ${{ steps.environment.outputs.python }} + fixture-path: ${{ steps.fixture-a.outputs.fixture-path }} + identity: stateforge.test.concurrent.a + discovery-directory: ${{ steps.fixture-a.outputs.discovery-directory }} + state-directory: ${{ steps.fixture-a.outputs.state-directory }} + event-log: ${{ steps.fixture-a.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-a + + - name: Start second Provider + id: provider-b + uses: ./.github/actions/provider/start + with: + python: ${{ steps.environment.outputs.python }} + fixture-path: ${{ steps.fixture-b.outputs.fixture-path }} + identity: stateforge.test.concurrent.b + discovery-directory: ${{ steps.fixture-b.outputs.discovery-directory }} + state-directory: ${{ steps.fixture-b.outputs.state-directory }} + event-log: ${{ steps.fixture-b.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-b + + - name: Assert distinct Provider identities + shell: bash + env: + FIRST_ID: ${{ steps.provider-a.outputs.provider-instance-id }} + SECOND_ID: ${{ steps.provider-b.outputs.provider-instance-id }} + run: | + if [[ "${FIRST_ID}" == "${SECOND_ID}" ]]; then + echo "Distinct Providers reused one installation identity" >&2 + exit 1 + fi + + - name: Assert first Manifest + uses: ./.github/actions/sfp/assert-manifest + with: + python: ${{ steps.environment.outputs.python }} + descriptor-path: ${{ steps.provider-a.outputs.descriptor-path }} + identity: stateforge.test.concurrent.a + evidence-directory: ${{ inputs.evidence-directory }}/provider-a/sfp + + - name: Assert second Manifest + uses: ./.github/actions/sfp/assert-manifest + with: + python: ${{ steps.environment.outputs.python }} + descriptor-path: ${{ steps.provider-b.outputs.descriptor-path }} + identity: stateforge.test.concurrent.b + evidence-directory: ${{ inputs.evidence-directory }}/provider-b/sfp + + - name: Stop first Provider + uses: ./.github/actions/provider/stop + with: + pid: ${{ steps.provider-a.outputs.pid }} + descriptor-path: ${{ steps.provider-a.outputs.descriptor-path }} + socket-path: ${{ steps.provider-a.outputs.socket-path }} + event-log: ${{ steps.fixture-a.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-a + + - name: Stop second Provider + uses: ./.github/actions/provider/stop + with: + pid: ${{ steps.provider-b.outputs.pid }} + descriptor-path: ${{ steps.provider-b.outputs.descriptor-path }} + socket-path: ${{ steps.provider-b.outputs.socket-path }} + event-log: ${{ steps.fixture-b.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-b + + - name: Preserve bounded evidence and clean up + if: ${{ always() }} + uses: ./.github/actions/evidence/collect + with: + stage: concurrent-providers + runtime-directory: ${{ inputs.runtime-directory }} + evidence-directory: ${{ inputs.evidence-directory }} diff --git a/.github/actions/scenarios/lifecycle/same-instance-replacement/action.yml b/.github/actions/scenarios/lifecycle/same-instance-replacement/action.yml new file mode 100644 index 0000000..23130c8 --- /dev/null +++ b/.github/actions/scenarios/lifecycle/same-instance-replacement/action.yml @@ -0,0 +1,130 @@ +name: Test same-instance Provider replacement +description: Verify a newer generation replaces and survives the older one. + +inputs: + provider-artifact-directory: + description: Directory containing the exact Provider build. + required: true + protocol-artifact-directory: + description: Directory containing the exact Protocol build. + required: true + runtime-directory: + description: Scenario-owned directory under the runner temp directory. + required: true + evidence-directory: + description: Directory receiving scenario evidence. + required: true + +runs: + using: composite + steps: + - name: Install exact runtime artifacts + id: environment + uses: ./.github/actions/environment/install + with: + provider-artifact-directory: ${{ inputs.provider-artifact-directory }} + protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} + environment-directory: ${{ inputs.runtime-directory }}/environment + evidence-directory: ${{ inputs.evidence-directory }}/installation + + - name: Create older generation fixture + id: fixture-old + uses: ./.github/actions/provider/create-fixture + with: + runtime-directory: ${{ inputs.runtime-directory }} + instance-name: provider-old + state-name: replacement + identity: stateforge.test.replacement + + - name: Create replacement fixture + id: fixture-new + uses: ./.github/actions/provider/create-fixture + with: + runtime-directory: ${{ inputs.runtime-directory }} + instance-name: provider-new + state-name: replacement + identity: stateforge.test.replacement + + - name: Start older generation + id: provider-old + uses: ./.github/actions/provider/start + with: + python: ${{ steps.environment.outputs.python }} + fixture-path: ${{ steps.fixture-old.outputs.fixture-path }} + identity: stateforge.test.replacement + discovery-directory: ${{ steps.fixture-old.outputs.discovery-directory }} + state-directory: ${{ steps.fixture-old.outputs.state-directory }} + event-log: ${{ steps.fixture-old.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-old + + - name: Start replacement generation + id: provider-new + uses: ./.github/actions/provider/start + with: + python: ${{ steps.environment.outputs.python }} + fixture-path: ${{ steps.fixture-new.outputs.fixture-path }} + identity: stateforge.test.replacement + discovery-directory: ${{ steps.fixture-new.outputs.discovery-directory }} + state-directory: ${{ steps.fixture-new.outputs.state-directory }} + event-log: ${{ steps.fixture-new.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-new + + - name: Assert stable identity and newer generation + shell: bash + env: + NEW_GENERATION: ${{ steps.provider-new.outputs.endpoint-generation }} + NEW_ID: ${{ steps.provider-new.outputs.provider-instance-id }} + OLD_GENERATION: ${{ steps.provider-old.outputs.endpoint-generation }} + OLD_ID: ${{ steps.provider-old.outputs.provider-instance-id }} + run: | + set -euo pipefail + if [[ "${OLD_ID}" != "${NEW_ID}" ]]; then + echo "Replacement changed the stable Provider identity" >&2 + exit 1 + fi + if (( NEW_GENERATION <= OLD_GENERATION )); then + echo "Replacement did not advance endpoint generation" >&2 + exit 1 + fi + + - name: Assert replacement Manifest + uses: ./.github/actions/sfp/assert-manifest + with: + python: ${{ steps.environment.outputs.python }} + descriptor-path: ${{ steps.provider-new.outputs.descriptor-path }} + identity: stateforge.test.replacement + evidence-directory: ${{ inputs.evidence-directory }}/provider-new/sfp + + - name: Stop older generation without withdrawing replacement + uses: ./.github/actions/provider/stop + with: + pid: ${{ steps.provider-old.outputs.pid }} + descriptor-path: ${{ steps.provider-old.outputs.descriptor-path }} + socket-path: ${{ steps.provider-old.outputs.socket-path }} + event-log: ${{ steps.fixture-old.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-old + descriptor-expectation: preserved + + - name: Reassert replacement after older shutdown + uses: ./.github/actions/sfp/assert-bootstrap + with: + python: ${{ steps.environment.outputs.python }} + descriptor-path: ${{ steps.provider-new.outputs.descriptor-path }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-new/post-replacement + + - name: Stop replacement generation + uses: ./.github/actions/provider/stop + with: + pid: ${{ steps.provider-new.outputs.pid }} + descriptor-path: ${{ steps.provider-new.outputs.descriptor-path }} + socket-path: ${{ steps.provider-new.outputs.socket-path }} + event-log: ${{ steps.fixture-new.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-new + + - name: Preserve bounded evidence and clean up + if: ${{ always() }} + uses: ./.github/actions/evidence/collect + with: + stage: same-instance-replacement + runtime-directory: ${{ inputs.runtime-directory }} + evidence-directory: ${{ inputs.evidence-directory }} diff --git a/.github/actions/scenarios/resilience/crash-restart/action.yml b/.github/actions/scenarios/resilience/crash-restart/action.yml new file mode 100644 index 0000000..3a30efa --- /dev/null +++ b/.github/actions/scenarios/resilience/crash-restart/action.yml @@ -0,0 +1,121 @@ +name: Test Provider crash and restart +description: Verify a crashed installation can publish a newer valid generation. + +inputs: + provider-artifact-directory: + description: Directory containing the exact Provider build. + required: true + protocol-artifact-directory: + description: Directory containing the exact Protocol build. + required: true + runtime-directory: + description: Scenario-owned directory under the runner temp directory. + required: true + evidence-directory: + description: Directory receiving scenario evidence. + required: true + +runs: + using: composite + steps: + - name: Install exact runtime artifacts + id: environment + uses: ./.github/actions/environment/install + with: + provider-artifact-directory: ${{ inputs.provider-artifact-directory }} + protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} + environment-directory: ${{ inputs.runtime-directory }}/environment + evidence-directory: ${{ inputs.evidence-directory }}/installation + + - name: Create first fixture + id: fixture-old + uses: ./.github/actions/provider/create-fixture + with: + runtime-directory: ${{ inputs.runtime-directory }} + instance-name: provider-crashed + state-name: restart + identity: stateforge.test.restart + + - name: Create restart fixture + id: fixture-new + uses: ./.github/actions/provider/create-fixture + with: + runtime-directory: ${{ inputs.runtime-directory }} + instance-name: provider-restarted + state-name: restart + identity: stateforge.test.restart + + - name: Start first generation + id: provider-old + uses: ./.github/actions/provider/start + with: + python: ${{ steps.environment.outputs.python }} + fixture-path: ${{ steps.fixture-old.outputs.fixture-path }} + identity: stateforge.test.restart + discovery-directory: ${{ steps.fixture-old.outputs.discovery-directory }} + state-directory: ${{ steps.fixture-old.outputs.state-directory }} + event-log: ${{ steps.fixture-old.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-crashed + + - name: Crash first generation + uses: ./.github/actions/provider/crash + with: + pid: ${{ steps.provider-old.outputs.pid }} + descriptor-path: ${{ steps.provider-old.outputs.descriptor-path }} + socket-path: ${{ steps.provider-old.outputs.socket-path }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-crashed + + - name: Start newer generation + id: provider-new + uses: ./.github/actions/provider/start + with: + python: ${{ steps.environment.outputs.python }} + fixture-path: ${{ steps.fixture-new.outputs.fixture-path }} + identity: stateforge.test.restart + discovery-directory: ${{ steps.fixture-new.outputs.discovery-directory }} + state-directory: ${{ steps.fixture-new.outputs.state-directory }} + event-log: ${{ steps.fixture-new.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-restarted + + - name: Assert restart identity and generation + shell: bash + env: + NEW_GENERATION: ${{ steps.provider-new.outputs.endpoint-generation }} + NEW_ID: ${{ steps.provider-new.outputs.provider-instance-id }} + OLD_GENERATION: ${{ steps.provider-old.outputs.endpoint-generation }} + OLD_ID: ${{ steps.provider-old.outputs.provider-instance-id }} + run: | + set -euo pipefail + if [[ "${OLD_ID}" != "${NEW_ID}" ]]; then + echo "Restart changed the stable Provider identity" >&2 + exit 1 + fi + if (( NEW_GENERATION <= OLD_GENERATION )); then + echo "Restart did not advance endpoint generation" >&2 + exit 1 + fi + + - name: Assert restarted Manifest + uses: ./.github/actions/sfp/assert-manifest + with: + python: ${{ steps.environment.outputs.python }} + descriptor-path: ${{ steps.provider-new.outputs.descriptor-path }} + identity: stateforge.test.restart + evidence-directory: ${{ inputs.evidence-directory }}/provider-restarted/sfp + + - name: Stop restarted generation + uses: ./.github/actions/provider/stop + with: + pid: ${{ steps.provider-new.outputs.pid }} + descriptor-path: ${{ steps.provider-new.outputs.descriptor-path }} + socket-path: ${{ steps.provider-new.outputs.socket-path }} + event-log: ${{ steps.fixture-new.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-restarted + + - name: Preserve bounded evidence and clean up + if: ${{ always() }} + uses: ./.github/actions/evidence/collect + with: + stage: crash-restart + runtime-directory: ${{ inputs.runtime-directory }} + evidence-directory: ${{ inputs.evidence-directory }} diff --git a/.github/actions/scenarios/smoke/normal-lifecycle/action.yml b/.github/actions/scenarios/smoke/normal-lifecycle/action.yml new file mode 100644 index 0000000..0e86eb5 --- /dev/null +++ b/.github/actions/scenarios/smoke/normal-lifecycle/action.yml @@ -0,0 +1,80 @@ +name: Test normal installed Provider lifecycle +description: Verify installation, Bootstrap, Manifest, shutdown, and cleanup. + +inputs: + provider-artifact-directory: + description: Directory containing the exact Provider build. + required: true + protocol-artifact-directory: + description: Directory containing the exact Protocol build. + required: true + runtime-directory: + description: Scenario-owned directory under the runner temp directory. + required: true + evidence-directory: + description: Directory receiving scenario evidence. + required: true + +runs: + using: composite + steps: + - name: Install exact runtime artifacts + id: environment + uses: ./.github/actions/environment/install + with: + provider-artifact-directory: ${{ inputs.provider-artifact-directory }} + protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} + environment-directory: ${{ inputs.runtime-directory }}/environment + evidence-directory: ${{ inputs.evidence-directory }}/installation + + - name: Create Provider fixture + id: fixture + uses: ./.github/actions/provider/create-fixture + with: + runtime-directory: ${{ inputs.runtime-directory }} + instance-name: provider + identity: stateforge.test.normal + + - name: Start Provider fixture + id: provider + uses: ./.github/actions/provider/start + with: + python: ${{ steps.environment.outputs.python }} + fixture-path: ${{ steps.fixture.outputs.fixture-path }} + identity: stateforge.test.normal + discovery-directory: ${{ steps.fixture.outputs.discovery-directory }} + state-directory: ${{ steps.fixture.outputs.state-directory }} + event-log: ${{ steps.fixture.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider + + - name: Assert Bootstrap + uses: ./.github/actions/sfp/assert-bootstrap + with: + python: ${{ steps.environment.outputs.python }} + descriptor-path: ${{ steps.provider.outputs.descriptor-path }} + evidence-directory: ${{ inputs.evidence-directory }}/sfp + + - name: Assert Manifest + uses: ./.github/actions/sfp/assert-manifest + with: + python: ${{ steps.environment.outputs.python }} + descriptor-path: ${{ steps.provider.outputs.descriptor-path }} + identity: stateforge.test.normal + evidence-directory: ${{ inputs.evidence-directory }}/sfp + + - name: Stop Provider fixture + uses: ./.github/actions/provider/stop + with: + pid: ${{ steps.provider.outputs.pid }} + descriptor-path: ${{ steps.provider.outputs.descriptor-path }} + socket-path: ${{ steps.provider.outputs.socket-path }} + event-log: ${{ steps.fixture.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider + + - name: Preserve bounded evidence and clean up + if: ${{ always() }} + uses: ./.github/actions/evidence/collect + with: + stage: normal-lifecycle + runtime-directory: ${{ inputs.runtime-directory }} + evidence-directory: ${{ inputs.evidence-directory }} diff --git a/.github/actions/sfp/assert-bootstrap/action.yml b/.github/actions/sfp/assert-bootstrap/action.yml new file mode 100644 index 0000000..1306ce3 --- /dev/null +++ b/.github/actions/sfp/assert-bootstrap/action.yml @@ -0,0 +1,33 @@ +name: Assert SFP Bootstrap +description: Negotiate SFP 1.0 against one installed Provider endpoint. + +inputs: + python: + description: Isolated Python containing exact Provider and Protocol artifacts. + required: true + descriptor-path: + description: Provider endpoint descriptor. + required: true + evidence-directory: + description: Directory receiving Bootstrap evidence. + required: true + +runs: + using: composite + steps: + - name: Negotiate the installed endpoint + shell: bash + env: + DESCRIPTOR_PATH: ${{ inputs.descriptor-path }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + ISOLATED_PYTHON: ${{ inputs.python }} + PROBE: ${{ github.workspace }}/.github/actions/sfp/probe.py + run: | + set -euo pipefail + mkdir -p "${EVIDENCE_DIRECTORY}" + output="${GITHUB_WORKSPACE}/${EVIDENCE_DIRECTORY}/bootstrap.json" + cd "${RUNNER_TEMP}" + unset PYTHONPATH PYTHONHOME + "${ISOLATED_PYTHON}" -I "${PROBE}" bootstrap \ + --descriptor "${DESCRIPTOR_PATH}" \ + --output "${output}" diff --git a/.github/actions/sfp/assert-invalid-peer/action.yml b/.github/actions/sfp/assert-invalid-peer/action.yml new file mode 100644 index 0000000..bb81668 --- /dev/null +++ b/.github/actions/sfp/assert-invalid-peer/action.yml @@ -0,0 +1,33 @@ +name: Assert invalid SFP peers are rejected +description: Reject mismatched identities and sessions, then prove recovery. + +inputs: + python: + description: Isolated Python containing exact Provider and Protocol artifacts. + required: true + descriptor-path: + description: Provider endpoint descriptor. + required: true + evidence-directory: + description: Directory receiving rejection evidence. + required: true + +runs: + using: composite + steps: + - name: Exercise invalid and valid peers + shell: bash + env: + DESCRIPTOR_PATH: ${{ inputs.descriptor-path }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + ISOLATED_PYTHON: ${{ inputs.python }} + PROBE: ${{ github.workspace }}/.github/actions/sfp/probe.py + run: | + set -euo pipefail + mkdir -p "${EVIDENCE_DIRECTORY}" + output="${GITHUB_WORKSPACE}/${EVIDENCE_DIRECTORY}/invalid-peer.json" + cd "${RUNNER_TEMP}" + unset PYTHONPATH PYTHONHOME + "${ISOLATED_PYTHON}" -I "${PROBE}" invalid-peer \ + --descriptor "${DESCRIPTOR_PATH}" \ + --output "${output}" diff --git a/.github/actions/sfp/assert-manifest/action.yml b/.github/actions/sfp/assert-manifest/action.yml new file mode 100644 index 0000000..5dd84d5 --- /dev/null +++ b/.github/actions/sfp/assert-manifest/action.yml @@ -0,0 +1,38 @@ +name: Assert SFP Provider Manifest +description: Read and validate the installed fixture's complete manifest. + +inputs: + python: + description: Isolated Python containing exact Provider and Protocol artifacts. + required: true + descriptor-path: + description: Provider endpoint descriptor. + required: true + identity: + description: Expected author-declared Provider identity. + required: true + evidence-directory: + description: Directory receiving Manifest evidence. + required: true + +runs: + using: composite + steps: + - name: Read the installed Provider manifest + shell: bash + env: + DESCRIPTOR_PATH: ${{ inputs.descriptor-path }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + EXPECTED_IDENTITY: ${{ inputs.identity }} + ISOLATED_PYTHON: ${{ inputs.python }} + PROBE: ${{ github.workspace }}/.github/actions/sfp/probe.py + run: | + set -euo pipefail + mkdir -p "${EVIDENCE_DIRECTORY}" + output="${GITHUB_WORKSPACE}/${EVIDENCE_DIRECTORY}/manifest.json" + cd "${RUNNER_TEMP}" + unset PYTHONPATH PYTHONHOME + "${ISOLATED_PYTHON}" -I "${PROBE}" manifest \ + --descriptor "${DESCRIPTOR_PATH}" \ + --identity "${EXPECTED_IDENTITY}" \ + --output "${output}" diff --git a/.github/actions/sfp/probe.py b/.github/actions/sfp/probe.py new file mode 100644 index 0000000..c787c18 --- /dev/null +++ b/.github/actions/sfp/probe.py @@ -0,0 +1,214 @@ +"""Exercise installed Bootstrap and Manifest services over a Unix socket.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import grpc +from google.protobuf.json_format import MessageToDict +from stateforge_protocol.sfp.bootstrap.v1 import ( + bootstrap_pb2, + bootstrap_pb2_grpc, +) +from stateforge_protocol.sfp.manifest.v1 import ( + manifest_pb2, + manifest_pb2_grpc, +) + + +def _descriptor(path: Path) -> dict[str, Any]: + document = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError("Provider descriptor must be an object") + transport = document.get("transport") + if not isinstance(transport, dict) or transport.get("type") != "unix": + raise ValueError("Provider descriptor must select a Unix transport") + return document + + +def _handshake_request( + descriptor: dict[str, Any], + *, + provider_instance_id: str | None = None, +) -> bootstrap_pb2.HandshakeRequest: + return bootstrap_pb2.HandshakeRequest( + agent_runtime=bootstrap_pb2.AgentRuntime( + runtime_id=str(uuid4()), + software_version="0.1.0-ci", + ), + expected_provider_instance_id=( + provider_instance_id or str(descriptor["provider_instance_id"]) + ), + expected_endpoint_generation=int(descriptor["endpoint_generation"]), + supported_sfp_versions=[ + bootstrap_pb2.ProtocolVersion(major=1, minor=0) + ], + ) + + +async def _handshake( + stub: bootstrap_pb2_grpc.BootstrapServiceStub, + descriptor: dict[str, Any], +) -> bootstrap_pb2.HandshakeResponse: + response = await stub.Handshake( + _handshake_request(descriptor), + timeout=5, + ) + if response.provider_instance_id != descriptor["provider_instance_id"]: + raise AssertionError( + "Handshake returned a different Provider identity" + ) + if response.endpoint_generation != descriptor["endpoint_generation"]: + raise AssertionError("Handshake returned a different generation") + if ( + response.selected_sfp_version.major, + response.selected_sfp_version.minor, + ) != (1, 0): + raise AssertionError("Handshake did not negotiate SFP 1.0") + if not response.session_id or not response.provider_runtime_id: + raise AssertionError("Handshake omitted runtime or session identity") + return response + + +async def _manifest( + stub: manifest_pb2_grpc.ManifestServiceStub, + handshake: bootstrap_pb2.HandshakeResponse, +) -> manifest_pb2.GetManifestResponse: + response = await stub.GetManifest( + manifest_pb2.GetManifestRequest( + expected_provider_instance_id=handshake.provider_instance_id, + expected_endpoint_generation=handshake.endpoint_generation, + supported_manifest_schema_versions=[1], + expected_session_id=handshake.session_id, + ), + timeout=5, + ) + if response.provider_instance_id != handshake.provider_instance_id: + raise AssertionError("Manifest returned a different Provider identity") + if response.endpoint_generation != handshake.endpoint_generation: + raise AssertionError("Manifest returned a different generation") + if response.session_id != handshake.session_id: + raise AssertionError("Manifest returned a different session") + if response.manifest_schema_version != 1: + raise AssertionError("Manifest did not select schema 1") + if not response.manifest_revision.startswith("sha256:"): + raise AssertionError("Manifest revision is not a SHA-256 identity") + return response + + +async def _run(arguments: argparse.Namespace) -> dict[str, Any]: + descriptor = _descriptor(arguments.descriptor) + socket_path = descriptor["transport"]["socket_path"] + channel = grpc.aio.insecure_channel(f"unix:{socket_path}") + try: + await asyncio.wait_for(channel.channel_ready(), timeout=5) + bootstrap = bootstrap_pb2_grpc.BootstrapServiceStub(channel) + manifest = manifest_pb2_grpc.ManifestServiceStub(channel) + + if arguments.command == "bootstrap": + response = await _handshake(bootstrap, descriptor) + return MessageToDict( + response, + always_print_fields_with_no_presence=True, + ) + + if arguments.command == "manifest": + handshake = await _handshake(bootstrap, descriptor) + response = await _manifest(manifest, handshake) + if response.manifest.provider_identity != arguments.identity: + raise AssertionError( + "Manifest Provider identity is unexpected" + ) + if "stateforge.test/echo@1" not in response.manifest.operations: + raise AssertionError("Manifest omitted the fixture operation") + if "stateforge.test/changed@1" not in response.manifest.triggers: + raise AssertionError("Manifest omitted the fixture trigger") + return MessageToDict( + response, + always_print_fields_with_no_presence=True, + ) + + wrong_identity = str(uuid4()) + try: + await bootstrap.Handshake( + _handshake_request( + descriptor, + provider_instance_id=wrong_identity, + ), + timeout=5, + ) + except grpc.aio.AioRpcError as error: + if error.code() is not grpc.StatusCode.FAILED_PRECONDITION: + raise AssertionError( + "Invalid Bootstrap peer returned an unexpected status" + ) from error + invalid_bootstrap = error.code().name + else: + raise AssertionError("Invalid Bootstrap peer was accepted") + + handshake = await _handshake(bootstrap, descriptor) + try: + await manifest.GetManifest( + manifest_pb2.GetManifestRequest( + expected_provider_instance_id=( + handshake.provider_instance_id + ), + expected_endpoint_generation=( + handshake.endpoint_generation + ), + supported_manifest_schema_versions=[1], + expected_session_id=str(uuid4()), + ), + timeout=5, + ) + except grpc.aio.AioRpcError as error: + if error.code() is not grpc.StatusCode.FAILED_PRECONDITION: + raise AssertionError( + "Invalid Manifest peer returned an unexpected status" + ) from error + invalid_manifest = error.code().name + else: + raise AssertionError("Invalid Manifest peer was accepted") + + valid_manifest = await _manifest(manifest, handshake) + return { + "invalid_bootstrap_status": invalid_bootstrap, + "invalid_manifest_status": invalid_manifest, + "valid_manifest_revision": valid_manifest.manifest_revision, + } + finally: + await channel.close() + + +def _arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "command", + choices=("bootstrap", "manifest", "invalid-peer"), + ) + parser.add_argument("--descriptor", type=Path, required=True) + parser.add_argument("--identity") + parser.add_argument("--output", type=Path, required=True) + return parser.parse_args() + + +def main() -> None: + """Run the selected probe and write deterministic JSON evidence.""" + arguments = _arguments() + if arguments.command == "manifest" and not arguments.identity: + raise SystemExit("--identity is required for manifest assertions") + result = asyncio.run(_run(arguments)) + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text( + json.dumps(result, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/provider-build-and-verify.yml b/.github/workflows/provider-build-and-verify.yml new file mode 100644 index 0000000..c0b1a98 --- /dev/null +++ b/.github/workflows/provider-build-and-verify.yml @@ -0,0 +1,80 @@ +name: Provider / Build and verify + +on: + workflow_call: + inputs: + artifact-name: + description: Name of the immutable Provider and Protocol bundle. + required: false + type: string + default: stateforge-provider-runtime-bundle + protocol-ref: + description: Protocol branch, tag, or commit SHA. + required: false + type: string + default: mvp/implementation + scenario-tier: + description: Runtime scenario tier. + required: false + type: string + default: full + retention-days: + description: Number of days to retain distributions and evidence. + required: false + type: number + default: 14 + outputs: + artifact-name: + description: Exact tested Provider and Protocol bundle. + value: ${{ jobs.build.outputs.artifact-name }} + provider-version: + description: Version read from the tested Provider wheel. + value: ${{ jobs.build.outputs.provider-version }} + provider-wheel-sha256: + description: SHA-256 digest of the tested Provider wheel. + value: ${{ jobs.build.outputs.provider-wheel-sha256 }} + protocol-commit: + description: Resolved Protocol commit used by every test. + value: ${{ jobs.build.outputs.protocol-commit }} + protocol-wheel-sha256: + description: SHA-256 digest of the tested Protocol wheel. + value: ${{ jobs.build.outputs.protocol-wheel-sha256 }} + +permissions: + contents: read + +jobs: + source: + name: Verify source and automation + uses: ./.github/workflows/provider-source-verify.yml + with: + evidence-name: stateforge-provider-source-${{ github.run_id }}-${{ github.run_attempt }} + retention-days: ${{ inputs.retention-days }} + + build: + name: Build immutable runtime bundle + needs: source + uses: ./.github/workflows/provider-build.yml + with: + artifact-name: ${{ inputs.artifact-name }} + protocol-ref: ${{ inputs.protocol-ref }} + retention-days: ${{ inputs.retention-days }} + + installed: + name: Verify installed packages + needs: build + uses: ./.github/workflows/provider-installed-test.yml + with: + artifact-name: ${{ needs.build.outputs.artifact-name }} + evidence-prefix: stateforge-provider-installed-${{ github.run_id }}-${{ github.run_attempt }} + retention-days: ${{ inputs.retention-days }} + + runtime: + name: Verify installed runtime + needs: build + uses: ./.github/workflows/provider-runtime-scenarios.yml + with: + artifact-name: ${{ needs.build.outputs.artifact-name }} + scenario-tier: ${{ inputs.scenario-tier }} + evidence-prefix: stateforge-provider-runtime-${{ github.run_id }}-${{ github.run_attempt }} + retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/provider-build.yml b/.github/workflows/provider-build.yml new file mode 100644 index 0000000..762f322 --- /dev/null +++ b/.github/workflows/provider-build.yml @@ -0,0 +1,115 @@ +name: Provider / Build immutable artifacts + +on: + workflow_call: + inputs: + artifact-name: + description: Name of the Provider and Protocol artifact bundle. + required: false + type: string + default: stateforge-provider-runtime-bundle + protocol-ref: + description: Protocol branch, tag, or commit SHA. + required: false + type: string + default: mvp/implementation + retention-days: + description: Number of days to retain distributions and build evidence. + required: false + type: number + default: 14 + outputs: + artifact-name: + description: Immutable Provider and Protocol distribution bundle. + value: ${{ jobs.build.outputs.artifact-name }} + provider-version: + description: Version read from the Provider wheel. + value: ${{ jobs.build.outputs.provider-version }} + provider-wheel-sha256: + description: SHA-256 digest of the Provider wheel. + value: ${{ jobs.build.outputs.provider-wheel-sha256 }} + protocol-commit: + description: Resolved Protocol source commit. + value: ${{ jobs.build.outputs.protocol-commit }} + protocol-wheel-sha256: + description: SHA-256 digest of the Protocol wheel. + value: ${{ jobs.build.outputs.protocol-wheel-sha256 }} + +permissions: + contents: read + +jobs: + build: + name: Build Provider and Protocol once + runs-on: ubuntu-24.04 + timeout-minutes: 25 + outputs: + artifact-name: ${{ inputs.artifact-name }} + provider-version: ${{ steps.provider.outputs.version }} + provider-wheel-sha256: ${{ steps.provider.outputs.wheel-sha256 }} + protocol-commit: ${{ steps.protocol.outputs.resolved-commit }} + protocol-wheel-sha256: ${{ steps.protocol.outputs.wheel-sha256 }} + steps: + - name: Check out Provider source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install package builder + shell: bash + run: python -m pip install --disable-pip-version-check "build>=1.2" + + - name: Build Provider distributions + id: provider + uses: ./.github/actions/package/build + + - name: Build pinned Protocol distributions + id: protocol + uses: ./.github/actions/protocol/build + with: + ref: ${{ inputs.protocol-ref }} + + - name: Record bundle identity + shell: bash + env: + PROTOCOL_COMMIT: ${{ steps.protocol.outputs.resolved-commit }} + PROTOCOL_REF: ${{ inputs.protocol-ref }} + PROTOCOL_WHEEL_SHA256: ${{ steps.protocol.outputs.wheel-sha256 }} + PROVIDER_VERSION: ${{ steps.provider.outputs.version }} + PROVIDER_WHEEL_SHA256: ${{ steps.provider.outputs.wheel-sha256 }} + run: | + printf '%s\n' \ + "provider-version=${PROVIDER_VERSION}" \ + "provider-wheel-sha256=${PROVIDER_WHEEL_SHA256}" \ + "protocol-requested-ref=${PROTOCOL_REF}" \ + "protocol-resolved-commit=${PROTOCOL_COMMIT}" \ + "protocol-wheel-sha256=${PROTOCOL_WHEEL_SHA256}" \ + > dist/BUNDLE_PROVENANCE + + - name: Upload immutable runtime bundle + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.artifact-name }} + path: | + dist/provider + dist/protocol + dist/BUNDLE_PROVENANCE + if-no-files-found: error + retention-days: ${{ inputs.retention-days }} + compression-level: 0 + + - name: Upload build evidence + if: ${{ always() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: stateforge-provider-build-${{ github.run_id }}-${{ github.run_attempt }} + path: evidence/build + if-no-files-found: warn + retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/provider-ci.yml b/.github/workflows/provider-ci.yml new file mode 100644 index 0000000..239008b --- /dev/null +++ b/.github/workflows/provider-ci.yml @@ -0,0 +1,25 @@ +name: Provider CI + +on: + pull_request: + branches: + - main + - mvp/implementation + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: provider-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + provider: + name: Build and verify Provider + uses: ./.github/workflows/provider-build-and-verify.yml + with: + artifact-name: stateforge-provider-pr-${{ github.run_id }}-${{ github.run_attempt }} + protocol-ref: mvp/implementation + scenario-tier: full + retention-days: 7 diff --git a/.github/workflows/provider-installed-test.yml b/.github/workflows/provider-installed-test.yml new file mode 100644 index 0000000..37c7937 --- /dev/null +++ b/.github/workflows/provider-installed-test.yml @@ -0,0 +1,75 @@ +name: Provider / Installed package verification + +on: + workflow_call: + inputs: + artifact-name: + description: Runtime bundle produced by provider-build.yml. + required: true + type: string + evidence-prefix: + description: Prefix for per-platform evidence artifacts. + required: false + type: string + default: stateforge-provider-installed + retention-days: + description: Number of days to retain installed-package evidence. + required: false + type: number + default: 14 + +permissions: + contents: read + +jobs: + verify: + name: ${{ matrix.os }} / Python ${{ matrix.python }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + max-parallel: 6 + matrix: + os: + - ubuntu-24.04 + - macos-14 + - windows-2022 + python: + - "3.11" + - "3.12" + - "3.13" + steps: + - name: Check out verification automation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: pyproject.toml + + - name: Download exact runtime bundle + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ inputs.artifact-name }} + path: ${{ runner.temp }}/stateforge-provider-bundle + + - name: Verify installed artifacts + uses: ./.github/actions/package/verify-installed + with: + provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider + protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol + environment-directory: ${{ runner.temp }}/stateforge-provider-installed + evidence-directory: evidence/installed + + - name: Upload installed-package evidence + if: ${{ always() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.evidence-prefix }}-${{ matrix.os }}-py${{ matrix.python }} + path: evidence/installed + if-no-files-found: warn + retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/provider-integration.yml b/.github/workflows/provider-integration.yml new file mode 100644 index 0000000..9be7fdb --- /dev/null +++ b/.github/workflows/provider-integration.yml @@ -0,0 +1,31 @@ +name: Provider integration + +on: + push: + branches: + - main + - mvp/implementation + workflow_dispatch: + inputs: + protocol-ref: + description: Protocol branch, tag, or commit SHA. + required: false + type: string + default: mvp/implementation + +permissions: + contents: read + +concurrency: + group: provider-integration-${{ github.ref }} + cancel-in-progress: true + +jobs: + provider: + name: Build and verify integration + uses: ./.github/workflows/provider-build-and-verify.yml + with: + artifact-name: stateforge-provider-integration-${{ github.run_id }}-${{ github.run_attempt }} + protocol-ref: ${{ inputs.protocol-ref || 'mvp/implementation' }} + scenario-tier: full + retention-days: 14 diff --git a/.github/workflows/provider-release-candidate.yml b/.github/workflows/provider-release-candidate.yml new file mode 100644 index 0000000..460e0ed --- /dev/null +++ b/.github/workflows/provider-release-candidate.yml @@ -0,0 +1,40 @@ +name: Provider release candidate + +on: + workflow_dispatch: + inputs: + protocol-ref: + description: Protocol branch, tag, or commit SHA to test. + required: false + type: string + default: mvp/implementation + scenario-tier: + description: Runtime scenario tier. + required: false + type: choice + options: + - full + - smoke + default: full + retention-days: + description: Number of days to retain the candidate and evidence. + required: false + type: number + default: 30 + +permissions: + contents: read + +concurrency: + group: provider-release-candidate-${{ github.ref }} + cancel-in-progress: false + +jobs: + provider: + name: Build and verify release candidate + uses: ./.github/workflows/provider-build-and-verify.yml + with: + artifact-name: stateforge-provider-candidate-${{ github.run_id }}-${{ github.run_attempt }} + protocol-ref: ${{ inputs.protocol-ref }} + scenario-tier: ${{ inputs.scenario-tier }} + retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/provider-runtime-scenarios.yml b/.github/workflows/provider-runtime-scenarios.yml new file mode 100644 index 0000000..0d8cf75 --- /dev/null +++ b/.github/workflows/provider-runtime-scenarios.yml @@ -0,0 +1,167 @@ +name: Provider / Installed runtime scenarios + +on: + workflow_call: + inputs: + artifact-name: + description: Runtime bundle produced by provider-build.yml. + required: true + type: string + scenario-tier: + description: Run smoke coverage or the complete scenario catalog. + required: false + type: string + default: full + evidence-prefix: + description: Prefix for per-scenario evidence artifacts. + required: false + type: string + default: stateforge-provider-runtime + retention-days: + description: Number of days to retain runtime evidence. + required: false + type: number + default: 14 + +permissions: + contents: read + +jobs: + smoke: + name: ${{ matrix.scenario }} / Python ${{ matrix.python }} + runs-on: ubuntu-24.04 + timeout-minutes: 25 + strategy: + fail-fast: false + max-parallel: 4 + matrix: + include: + - scenario: normal-lifecycle + python: "3.11" + - scenario: normal-lifecycle + python: "3.12" + - scenario: normal-lifecycle + python: "3.13" + steps: + - name: Check out scenario automation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: pyproject.toml + + - name: Download exact runtime bundle + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ inputs.artifact-name }} + path: ${{ runner.temp }}/stateforge-provider-bundle + + - name: Run normal-lifecycle scenario + uses: ./.github/actions/scenarios/smoke/normal-lifecycle + with: + provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider + protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol + runtime-directory: ${{ runner.temp }}/sfp + evidence-directory: evidence/runtime/${{ matrix.scenario }} + + - name: Upload runtime evidence + if: ${{ always() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.evidence-prefix }}-${{ matrix.scenario }}-py${{ matrix.python }} + path: evidence/runtime/${{ matrix.scenario }} + if-no-files-found: warn + retention-days: ${{ inputs.retention-days }} + + full: + name: ${{ matrix.scenario }} / Python 3.13 + if: ${{ inputs.scenario-tier == 'full' }} + runs-on: ubuntu-24.04 + timeout-minutes: 25 + strategy: + fail-fast: false + max-parallel: 4 + matrix: + scenario: + - concurrent-providers + - same-instance-replacement + - invalid-peer + - crash-restart + - uninstall-reinstall + steps: + - name: Check out scenario automation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Download exact runtime bundle + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ inputs.artifact-name }} + path: ${{ runner.temp }}/stateforge-provider-bundle + + - name: Run concurrent-providers scenario + if: ${{ matrix.scenario == 'concurrent-providers' }} + uses: ./.github/actions/scenarios/lifecycle/concurrent-providers + with: + provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider + protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol + runtime-directory: ${{ runner.temp }}/sfp + evidence-directory: evidence/runtime/${{ matrix.scenario }} + + - name: Run same-instance-replacement scenario + if: ${{ matrix.scenario == 'same-instance-replacement' }} + uses: ./.github/actions/scenarios/lifecycle/same-instance-replacement + with: + provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider + protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol + runtime-directory: ${{ runner.temp }}/sfp + evidence-directory: evidence/runtime/${{ matrix.scenario }} + + - name: Run invalid-peer scenario + if: ${{ matrix.scenario == 'invalid-peer' }} + uses: ./.github/actions/scenarios/adversarial/invalid-peer + with: + provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider + protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol + runtime-directory: ${{ runner.temp }}/sfp + evidence-directory: evidence/runtime/${{ matrix.scenario }} + + - name: Run crash-restart scenario + if: ${{ matrix.scenario == 'crash-restart' }} + uses: ./.github/actions/scenarios/resilience/crash-restart + with: + provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider + protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol + runtime-directory: ${{ runner.temp }}/sfp + evidence-directory: evidence/runtime/${{ matrix.scenario }} + + - name: Run uninstall-reinstall scenario + if: ${{ matrix.scenario == 'uninstall-reinstall' }} + uses: ./.github/actions/scenarios/destructive/uninstall-reinstall + with: + provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider + protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol + runtime-directory: ${{ runner.temp }}/sfp + evidence-directory: evidence/runtime/${{ matrix.scenario }} + + - name: Upload runtime evidence + if: ${{ always() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.evidence-prefix }}-${{ matrix.scenario }}-py3.13 + path: evidence/runtime/${{ matrix.scenario }} + if-no-files-found: warn + retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/provider-source-verify.yml b/.github/workflows/provider-source-verify.yml new file mode 100644 index 0000000..c93e27f --- /dev/null +++ b/.github/workflows/provider-source-verify.yml @@ -0,0 +1,95 @@ +name: Provider / Source verification + +on: + workflow_call: + inputs: + evidence-name: + description: Name used to upload source-verification evidence. + required: false + type: string + default: stateforge-provider-source-evidence + retention-days: + description: Number of days to retain source-verification evidence. + required: false + type: number + default: 14 + +permissions: + contents: read + +jobs: + verify: + name: Validate source, automation, tests, typing, and docs + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install development dependencies + shell: bash + run: | + python -m pip install --disable-pip-version-check --upgrade pip + python -m pip install --disable-pip-version-check \ + --group dev \ + --group docs \ + . + + - name: Install actionlint + shell: bash + env: + ACTIONLINT_VERSION: v1.7.12 + run: | + actionlint_directory="${RUNNER_TEMP}/actionlint" + mkdir -p "${actionlint_directory}" + GOBIN="${actionlint_directory}" go install \ + "github.com/rhysd/actionlint/cmd/actionlint@${ACTIONLINT_VERSION}" + echo "${actionlint_directory}" >> "${GITHUB_PATH}" + + - name: Validate workflow metadata + shell: bash + run: actionlint -color + + - name: Audit GitHub Actions security + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + inputs: . + collect: all + online-audits: false + persona: auditor + min-severity: medium + version: v1.30.1 + token: unused-offline + advanced-security: false + annotations: true + + - name: Run repository quality gates + shell: bash + run: | + set -euo pipefail + mkdir -p evidence/source + { + python -m ruff format --check . + python -m ruff check . + python -m mypy src + python -m pytest + python -m mkdocs build --strict + } 2>&1 | tee evidence/source/quality.log + + - name: Upload source-verification evidence + if: ${{ always() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.evidence-name }} + path: evidence/source + if-no-files-found: warn + retention-days: ${{ inputs.retention-days }} diff --git a/README.md b/README.md index 0c02e24..cd288d9 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,10 @@ python -m pip install -e . --group dev --group docs Validate with Ruff, strict mypy, pytest, `python -m build`, and `mkdocs build --strict`. + +The repository also provides reusable package and installed-runtime pipelines. +They build the Provider and a selected Protocol revision once, install those +exact artifacts outside both checkouts, and exercise public-API Provider +fixtures over real Bootstrap and Manifest RPCs. See the +[CI/CD automation guide](docs/ci-automation.md) for the action catalog, local +parity commands, scenario tiers, inputs, and retained evidence. diff --git a/docs/ci-automation.md b/docs/ci-automation.md new file mode 100644 index 0000000..9519031 --- /dev/null +++ b/docs/ci-automation.md @@ -0,0 +1,113 @@ +# Package and runtime CI/CD + +The Provider repository validates the distributable package and the runtime +that package actually installs. CI does not import `stateforge_provider` from +the source checkout and does not maintain a second implementation of endpoint, +Bootstrap, or Manifest behavior. + +## Architecture + +The automation has three layers: + +1. Focused composite actions in `.github/actions/` own one repeated operation: + package construction, isolated installation, fixture staging, process + lifecycle, SFP assertions, or evidence collection. +2. Each directory under `.github/actions/scenarios/` is one independently + selectable runtime scenario. A scenario composes the focused actions and + always collects evidence and removes its temporary state. +3. Reusable `workflow_call` workflows own job topology, matrices, immutable + artifact transfer, and retention. The pull-request, integration, and + release-candidate workflows are thin callers. + +The build job produces one bundle containing: + +- the Provider wheel and source distribution; +- the Protocol wheel and source distribution built from `protocol-ref`; +- SHA-256 checksum files; +- the requested Protocol ref and its resolved commit; and +- Provider, Protocol, Python, runner, and source provenance. + +Every installed-package and runtime job downloads that same bundle. No test job +rebuilds either distribution. + +## Local parity + +Run the repository gates with the package metadata as their source of truth: + +```sh +python -m pip install -e . --group dev --group docs +python -m ruff format --check . +python -m ruff check . +python -m mypy src +python -m pytest +python -m mkdocs build --strict +``` + +Build the Provider artifacts with the same canonical command used by the build +action: + +```sh +python -m build --sdist --wheel --outdir dist/provider +``` + +To reproduce an installed-package check, create a clean environment outside the +repository and install an explicitly selected Protocol wheel together with the +built Provider wheel: + +```sh +python -m venv /tmp/stateforge-provider-installed +/tmp/stateforge-provider-installed/bin/python -m pip install \ + /absolute/path/to/stateforge_protocol-*.whl \ + /absolute/path/to/stateforge_provider-*.whl +cd /tmp +unset PYTHONPATH PYTHONHOME +/tmp/stateforge-provider-installed/bin/python -I -c \ + 'import stateforge_provider; print(stateforge_provider.__file__)' +``` + +The printed module path must belong to the isolated environment, not this +checkout. + +## Workflow inputs + +`provider-build-and-verify.yml` is the aggregate reusable gate. Its significant +inputs are: + +| Input | Default | Meaning | +| --- | --- | --- | +| `protocol-ref` | `mvp/implementation` | Protocol branch, tag, or commit to resolve and package | +| `scenario-tier` | `full` | `smoke` runs normal lifecycle on every supported Python; `full` adds the complete catalog | +| `retention-days` | `14` | Retention for distributions and bounded evidence | + +Release-candidate runs may use the convenient default branch during +development, but should pass an immutable Protocol commit SHA when selecting a +candidate. + +## Runtime scenarios + +The runtime suite currently covers: + +- normal Bootstrap, Manifest, graceful shutdown, and cleanup; +- two distinct Provider installations running concurrently; +- same-installation replacement with a stable identity and newer generation; +- rejection of invalid Bootstrap identity and Manifest session data; +- abrupt process failure followed by a valid newer generation; and +- uninstall and reinstall of the exact Provider wheel. + +The fixtures import only the public `stateforge_provider` API. Runtime scenarios +run on Linux because the current endpoint contract uses Unix-domain sockets. +Package identity and import checks run independently on Linux, macOS, and +Windows for Python 3.11, 3.12, and 3.13. + +## Evidence and security + +Build provenance and checksums are retained with the immutable artifact bundle. +Each scenario retains bounded package-install logs, descriptors, SFP responses, +lifecycle events, runtime inventory, and failure logs. Temporary runtime state +is removed even when an assertion fails. + +External actions are pinned to complete commit SHAs. Workflows have read-only +repository permissions, do not persist checkout credentials, use bounded job +timeouts, and do not pass credentials to Provider processes. `actionlint`, +Zizmor, Ruff, mypy, pytest with coverage, and strict MkDocs are part of the +aggregate gate. diff --git a/mkdocs.yml b/mkdocs.yml index 79422ce..7c7f753 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,7 @@ nav: - Endpoint lifecycle: endpoint-lifecycle.md - Runtime architecture: runtime-architecture.md - Manifest declarations: manifest-authoring.md + - Package and runtime CI/CD: ci-automation.md - Python API: - Provider: reference/provider.md - Declarations: reference/declarations.md diff --git a/pyproject.toml b/pyproject.toml index f40c2e9..cf436e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ docs = [ "mkdocs>=1.6,<2", "mkdocs-material>=9.6,<10", "mkdocstrings[python]>=0.30,<1", + "pymdown-extensions>=10.2,<12", ] diff --git a/tests/test_ci_automation.py b/tests/test_ci_automation.py new file mode 100644 index 0000000..4a6c2ab --- /dev/null +++ b/tests/test_ci_automation.py @@ -0,0 +1,227 @@ +"""Contract tests for reusable Provider package and runtime automation.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" +ACTIONS = ROOT / ".github" / "actions" +SHA_LENGTH = 40 +SUPPORTED_PYTHON_COUNT = 3 +SCENARIO_COUNT = 6 +DOWNSTREAM_BUNDLE_CONSUMER_COUNT = 2 + + +def read(path: Path) -> str: + """Read one automation file as UTF-8 text.""" + return path.read_text(encoding="utf-8") + + +def test_action_library_has_focused_domain_boundaries() -> None: + """Keep repeated operations and scenarios independently reusable.""" + expected = { + "environment/install", + "evidence/collect", + "package/build", + "package/verify-installed", + "protocol/build", + "provider/crash", + "provider/create-fixture", + "provider/start", + "provider/stop", + "scenarios/adversarial/invalid-peer", + "scenarios/destructive/uninstall-reinstall", + "scenarios/lifecycle/concurrent-providers", + "scenarios/lifecycle/same-instance-replacement", + "scenarios/resilience/crash-restart", + "scenarios/smoke/normal-lifecycle", + "sfp/assert-bootstrap", + "sfp/assert-invalid-peer", + "sfp/assert-manifest", + } + observed = { + str(path.parent.relative_to(ACTIONS)) + for path in ACTIONS.rglob("action.yml") + } + assert observed == expected + + +def test_build_is_single_and_provenance_bound() -> None: + """Build each package once and pass one immutable bundle downstream.""" + build = read(WORKFLOWS / "provider-build.yml") + aggregate = read(WORKFLOWS / "provider-build-and-verify.yml") + provider = read(ACTIONS / "package" / "build" / "action.yml") + protocol = read(ACTIONS / "protocol" / "build" / "action.yml") + + assert "python -m build" in provider + assert "python -m build" in protocol + assert build.count("./.github/actions/package/build") == 1 + assert build.count("./.github/actions/protocol/build") == 1 + assert "dist/BUNDLE_PROVENANCE" in build + assert "protocol-resolved-commit=" in build + assert "needs: build" in aggregate + assert ( + aggregate.count("needs.build.outputs.artifact-name") + == DOWNSTREAM_BUNDLE_CONSUMER_COUNT + ) + + +def test_protocol_ref_is_configurable_and_resolved() -> None: + """Support convenient refs while recording an immutable Protocol commit.""" + build = read(WORKFLOWS / "provider-build.yml") + aggregate = read(WORKFLOWS / "provider-build-and-verify.yml") + release = read(WORKFLOWS / "provider-release-candidate.yml") + action = read(ACTIONS / "protocol" / "build" / "action.yml") + + for content in (build, aggregate, release): + assert "protocol-ref:" in content + assert "default: mvp/implementation" in content + assert 'git -C "${PROTOCOL_SOURCE}" rev-parse --verify HEAD' in action + assert "requested-ref=${PROTOCOL_REF}" in action + assert "resolved-commit=${resolved_commit}" in action + assert "PROTOCOL_SHA256SUMS" in action + + +def test_installed_checks_cannot_use_source_imports() -> None: + """Install exact wheels outside checkouts with Python isolation enabled.""" + install = read(ACTIONS / "environment" / "install" / "action.yml") + verify = read(ACTIONS / "package" / "verify-installed" / "action.yml") + verifier = read(ACTIONS / "package" / "verify-installed" / "verify.py") + + assert '"${protocol_wheel}" "${provider_wheel}"' in install + assert "python -m venv" in install + assert "unset PYTHONPATH PYTHONHOME" in verify + assert '"${ISOLATED_PYTHON}" -I' in verify + assert "was imported from the checkout" in verifier + assert 'distribution("stateforge-provider")' in verifier + assert "stateforge_provider.__all__" in verifier + + +def test_fixture_uses_only_the_public_provider_api() -> None: + """Do not reproduce or reach into private runtime implementation.""" + fixture = read(ACTIONS / "provider" / "create-fixture" / "fixture.py") + + assert "from stateforge_provider import (" in fixture + assert "stateforge_provider._" not in fixture + assert "Provider(" in fixture + assert "@operation(" in fixture + assert "on_startup" in fixture + assert "on_shutdown" in fixture + + +def test_provider_start_waits_for_a_new_generation() -> None: + """Do not confuse an existing stable descriptor with new publication.""" + action = read(ACTIONS / "provider" / "start" / "action.yml") + + assert "previous_generation=0" in action + assert ".last_endpoint_generation // 0" in action + assert "generation > previous_generation" in action + + +def test_each_runtime_scenario_is_an_independent_action() -> None: + """Keep scenario behavior visible and composed from shared actions.""" + scenario_root = ACTIONS / "scenarios" + scenarios = sorted(scenario_root.rglob("action.yml")) + assert len(scenarios) == SCENARIO_COUNT + for scenario in scenarios: + content = read(scenario) + assert "./.github/actions/environment/install" in content + assert "./.github/actions/provider/create-fixture" in content + assert "./.github/actions/evidence/collect" in content + assert "if: ${{ always() }}" in content + + +def test_runtime_catalog_covers_required_behavior() -> None: + """Keep all approved installed-runtime cases in the workflow matrix.""" + workflow = read(WORKFLOWS / "provider-runtime-scenarios.yml") + scenarios = { + "normal-lifecycle", + "concurrent-providers", + "same-instance-replacement", + "invalid-peer", + "crash-restart", + "uninstall-reinstall", + } + for scenario in scenarios: + assert scenario in workflow + assert 'python: "3.11"' in workflow + assert 'python: "3.12"' in workflow + assert 'python-version: "3.13"' in workflow + assert "if: ${{ inputs.scenario-tier == 'full' }}" in workflow + assert "runtime-directory: ${{ runner.temp }}/sfp" in workflow + assert "stateforge-provider-${{ matrix.scenario }}" not in workflow + assert "runs-on: ubuntu-24.04" in workflow + + +def test_package_matrix_is_separate_from_unix_runtime() -> None: + """Do not imply Windows support for the Unix-socket runtime.""" + installed = read(WORKFLOWS / "provider-installed-test.yml") + runtime = read(WORKFLOWS / "provider-runtime-scenarios.yml") + + assert "windows-2022" in installed + assert "macos-14" in installed + assert installed.count(' - "3.1') == SUPPORTED_PYTHON_COUNT + assert "windows-" not in runtime + assert "macos-" not in runtime + + +def test_triggered_workflows_are_thin_callers() -> None: + """Keep pull-request, integration, and release entry points declarative.""" + for filename in ( + "provider-ci.yml", + "provider-integration.yml", + "provider-release-candidate.yml", + ): + workflow = read(WORKFLOWS / filename) + assert ( + "uses: ./.github/workflows/provider-build-and-verify.yml" + in workflow + ) + assert "run:" not in workflow + + +def test_external_actions_are_immutable() -> None: + """Reject executable GitHub Actions referenced by mutable tags.""" + automation_files = [ + *WORKFLOWS.glob("*.yml"), + *ACTIONS.rglob("action.yml"), + ] + for path in automation_files: + for line in read(path).splitlines(): + stripped = line.strip() + if not stripped.startswith("uses: ") or stripped.startswith( + "uses: ./" + ): + continue + reference = stripped.split("@", maxsplit=1)[1].split()[0] + assert len(reference) == SHA_LENGTH, path + assert all( + character in "0123456789abcdef" for character in reference + ), path + + +def test_workflow_security_and_bounds_are_explicit() -> None: + """Preserve least privilege, bounded jobs, and credential hygiene.""" + workflows = list(WORKFLOWS.glob("*.yml")) + combined = "\n".join(read(path) for path in workflows) + + assert "pull_request_target:" not in combined + for path in workflows: + content = read(path) + assert "permissions:\n contents: read" in content + executable = [path for path in workflows if "runs-on:" in read(path)] + for path in executable: + content = read(path) + assert "timeout-minutes:" in content + assert "persist-credentials: false" in content + assert "retention-days:" in combined + + +def test_failure_evidence_and_cleanup_are_bounded() -> None: + """Always retain useful logs without preserving unlimited runner state.""" + collector = read(ACTIONS / "evidence" / "collect" / "action.yml") + + assert "MAX_FILE_BYTES" in collector + assert "runner-context.txt" in collector + assert "runtime-inventory.txt" in collector + assert 'rm -rf -- "${RUNTIME_DIRECTORY}"' in collector + assert "provider.pid" in collector From af33ce0cac833cced7fece4bd4cd543859b15f8d Mon Sep 17 00:00:00 2001 From: Noam Alum Date: Sun, 11 Oct 2026 14:39:32 +0300 Subject: [PATCH 2/5] [TASK][MVP] Test Provider runtime against packaged Agent --- .../assert-invalid-descriptor/action.yml | 50 ++++ .../actions/agent/assert-session/action.yml | 61 +++++ .github/actions/agent/install/action.yml | 172 ++++++++++++++ .github/actions/agent/start/action.yml | 46 ++++ .../actions/environment/install/action.yml | 2 +- .github/actions/evidence/collect/action.yml | 71 +++--- .github/actions/package/build/action.yml | 101 +++++++++ .github/actions/provider/crash/action.yml | 37 +-- .../provider/create-fixture/action.yml | 26 +-- .github/actions/provider/start/action.yml | 164 ++++++++------ .github/actions/provider/stop/action.yml | 69 +++--- .../adversarial/invalid-peer/action.yml | 64 ++++-- .../uninstall-reinstall/action.yml | 78 +++---- .../lifecycle/concurrent-providers/action.yml | 71 +++--- .../same-instance-replacement/action.yml | 105 +++++---- .../resilience/crash-restart/action.yml | 83 ++++--- .../smoke/normal-lifecycle/action.yml | 52 +++-- .../actions/sfp/assert-bootstrap/action.yml | 33 --- .../sfp/assert-invalid-peer/action.yml | 33 --- .../actions/sfp/assert-manifest/action.yml | 38 ---- .github/actions/sfp/probe.py | 214 ------------------ .../workflows/provider-build-and-verify.yml | 17 +- .github/workflows/provider-build.yml | 85 ++++++- .github/workflows/provider-ci.yml | 1 + .github/workflows/provider-integration.yml | 6 + .../workflows/provider-release-candidate.yml | 6 + .../workflows/provider-runtime-scenarios.yml | 65 +++--- .gitignore | 2 + README.md | 7 +- docs/ci-automation.md | 72 ++++-- tests/test_ci_automation.py | 105 +++++++-- 31 files changed, 1175 insertions(+), 761 deletions(-) create mode 100644 .github/actions/agent/assert-invalid-descriptor/action.yml create mode 100644 .github/actions/agent/assert-session/action.yml create mode 100644 .github/actions/agent/install/action.yml create mode 100644 .github/actions/agent/start/action.yml create mode 100644 .github/actions/package/build/action.yml delete mode 100644 .github/actions/sfp/assert-bootstrap/action.yml delete mode 100644 .github/actions/sfp/assert-invalid-peer/action.yml delete mode 100644 .github/actions/sfp/assert-manifest/action.yml delete mode 100644 .github/actions/sfp/probe.py diff --git a/.github/actions/agent/assert-invalid-descriptor/action.yml b/.github/actions/agent/assert-invalid-descriptor/action.yml new file mode 100644 index 0000000..d2f5811 --- /dev/null +++ b/.github/actions/agent/assert-invalid-descriptor/action.yml @@ -0,0 +1,50 @@ +name: Assert rejection of invalid Provider discovery data +description: Publish a malformed descriptor and prove Agent recovery. + +inputs: + container-name: + description: Fedora container running the installed Agent. + required: true + evidence-directory: + description: Host directory receiving rejection evidence. + required: true + timeout-seconds: + description: Maximum time to wait for rejection. + required: false + default: "60" + +runs: + using: composite + steps: + - name: Reject malformed discovery data + shell: bash + env: + CONTAINER_NAME: ${{ inputs.container-name }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }} + run: | + set -euo pipefail + mkdir -p "${EVIDENCE_DIRECTORY}" + provider_id="$(docker exec "${CONTAINER_NAME}" \ + python3 -c 'import uuid; print(uuid.uuid4())')" + descriptor="/run/stateforge-agent/providers/${provider_id}.sfp.json" + docker exec "${CONTAINER_NAME}" bash -c \ + 'umask 0027; printf "{" > "$1"' -- "${descriptor}" + + deadline=$((SECONDS + TIMEOUT_SECONDS)) + while (( SECONDS < deadline )); do + journal="$(docker exec "${CONTAINER_NAME}" \ + journalctl -u stateforge-agent.service --no-pager -o cat)" + if grep -F 'Ignoring invalid Provider endpoint descriptor:' \ + <<< "${journal}" | grep -Fq "path=${descriptor}"; then + printf 'Rejected malformed descriptor %s.\n' "${descriptor}" \ + > "${EVIDENCE_DIRECTORY}/invalid-descriptor.txt" + docker exec "${CONTAINER_NAME}" \ + systemctl is-active --quiet stateforge-agent.service + exit 0 + fi + sleep 1 + done + + echo "Agent did not reject malformed Provider discovery data" >&2 + exit 1 diff --git a/.github/actions/agent/assert-session/action.yml b/.github/actions/agent/assert-session/action.yml new file mode 100644 index 0000000..6f3dd51 --- /dev/null +++ b/.github/actions/agent/assert-session/action.yml @@ -0,0 +1,61 @@ +name: Assert Agent acceptance of a Provider session +description: Wait until the installed Agent accepts the expected generation. + +inputs: + container-name: + description: Fedora container running the Agent and Provider. + required: true + provider-instance-id: + description: Stable Provider installation identity. + required: true + endpoint-generation: + description: Provider endpoint generation that must be accepted. + required: true + evidence-directory: + description: Host directory receiving the accepted session record. + required: true + timeout-seconds: + description: Maximum time to wait for Agent acceptance. + required: false + default: "60" + +runs: + using: composite + steps: + - name: Assert current Bootstrap and Manifest session + shell: bash + env: + CONTAINER_NAME: ${{ inputs.container-name }} + ENDPOINT_GENERATION: ${{ inputs.endpoint-generation }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + PROVIDER_INSTANCE_ID: ${{ inputs.provider-instance-id }} + TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }} + run: | + set -euo pipefail + mkdir -p "${EVIDENCE_DIRECTORY}" + deadline=$((SECONDS + TIMEOUT_SECONDS)) + while (( SECONDS < deadline )); do + journal="$(docker exec "${CONTAINER_NAME}" \ + journalctl -u stateforge-agent.service --no-pager -o cat)" + if grep -F 'Rejecting Provider manifest:' <<< "${journal}" \ + | grep -F "provider_instance_id=${PROVIDER_INSTANCE_ID}" \ + | grep -Fq "endpoint_generation=${ENDPOINT_GENERATION}"; then + echo "Agent rejected the expected Provider manifest" >&2 + exit 1 + fi + session="$(grep -F 'SFP session established:' <<< "${journal}" \ + | grep -F "provider_instance_id=${PROVIDER_INSTANCE_ID}" \ + | grep -F "endpoint_generation=${ENDPOINT_GENERATION}" \ + | tail -n 1 || true)" + if [[ -n "${session}" ]]; then + printf '%s\n' "${session}" \ + > "${EVIDENCE_DIRECTORY}/accepted-session.txt" + exit 0 + fi + sleep 1 + done + + docker exec "${CONTAINER_NAME}" \ + journalctl -u stateforge-agent.service --no-pager || true + echo "Agent did not accept the expected Provider generation" >&2 + exit 1 diff --git a/.github/actions/agent/install/action.yml b/.github/actions/agent/install/action.yml new file mode 100644 index 0000000..7786009 --- /dev/null +++ b/.github/actions/agent/install/action.yml @@ -0,0 +1,172 @@ +name: Install the exact Agent and Provider runtime +description: Install the tested RPM and wheels in an isolated Fedora system. + +inputs: + agent-artifact-directory: + description: Directory containing the exact Agent RPM build. + required: true + agent-source-directory: + description: Exact Agent checkout providing its Fedora test image. + required: true + provider-artifact-directory: + description: Directory containing the exact Provider wheel. + required: true + protocol-artifact-directory: + description: Directory containing the exact Protocol wheel. + required: true + evidence-directory: + description: Host directory receiving installation evidence. + required: true + container-name: + description: Name of the isolated Fedora system. + required: false + default: stateforge-provider-system-test + container-image: + description: Local tag for the Fedora system-test image. + required: false + default: stateforge-provider-system-test:fedora42 + +outputs: + container-name: + description: Fedora container containing the installed runtime. + value: ${{ steps.install.outputs.container-name }} + +runs: + using: composite + steps: + - name: Install immutable runtime artifacts + id: install + shell: bash + env: + AGENT_ARTIFACT_DIRECTORY: ${{ inputs.agent-artifact-directory }} + AGENT_SOURCE_DIRECTORY: ${{ inputs.agent-source-directory }} + CONTAINER_IMAGE: ${{ inputs.container-image }} + CONTAINER_NAME: ${{ inputs.container-name }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + PROTOCOL_ARTIFACT_DIRECTORY: ${{ inputs.protocol-artifact-directory }} + PROVIDER_ARTIFACT_DIRECTORY: ${{ inputs.provider-artifact-directory }} + run: | + set -euo pipefail + mkdir -p "${EVIDENCE_DIRECTORY}" + + mapfile -t agent_rpms < <( + find "${AGENT_ARTIFACT_DIRECTORY}" -maxdepth 1 -type f \ + -name 'stateforge-agent-*.rpm' ! -name '*.src.rpm' -print + ) + mapfile -t provider_wheels < <( + find "${PROVIDER_ARTIFACT_DIRECTORY}" -maxdepth 1 -type f \ + -name 'stateforge_provider-*.whl' -print + ) + mapfile -t protocol_wheels < <( + find "${PROTOCOL_ARTIFACT_DIRECTORY}" -maxdepth 1 -type f \ + -name 'stateforge_protocol-*.whl' -print + ) + if (( ${#agent_rpms[@]} != 1 || \ + ${#provider_wheels[@]} != 1 || \ + ${#protocol_wheels[@]} != 1 )); then + echo "Expected one Agent RPM, Provider wheel, and Protocol wheel" >&2 + exit 1 + fi + + ( + cd "${AGENT_ARTIFACT_DIRECTORY}" + sha256sum --check SHA256SUMS + ) > "${EVIDENCE_DIRECTORY}/agent-checksums.txt" + ( + cd "${PROVIDER_ARTIFACT_DIRECTORY}" + sha256sum --check SHA256SUMS + ) > "${EVIDENCE_DIRECTORY}/provider-checksums.txt" + ( + cd "${PROTOCOL_ARTIFACT_DIRECTORY}" + sha256sum --check SHA256SUMS + ) > "${EVIDENCE_DIRECTORY}/protocol-checksums.txt" + + agent_artifacts="$(cd "${AGENT_ARTIFACT_DIRECTORY}" && pwd -P)" + agent_source="$(cd "${AGENT_SOURCE_DIRECTORY}" && pwd -P)" + evidence="$(cd "${EVIDENCE_DIRECTORY}" && pwd -P)" + provider_artifacts="$(cd "${PROVIDER_ARTIFACT_DIRECTORY}" && pwd -P)" + protocol_artifacts="$(cd "${PROTOCOL_ARTIFACT_DIRECTORY}" && pwd -P)" + agent_rpm="$(basename "${agent_rpms[0]}")" + + docker build \ + --file "${agent_source}/packaging/rpm/Dockerfile.test" \ + --tag "${CONTAINER_IMAGE}" \ + "${agent_source}/packaging/rpm" + docker rm --force "${CONTAINER_NAME}" >/dev/null 2>&1 || true + docker run --detach \ + --name "${CONTAINER_NAME}" \ + --privileged \ + --cgroupns=host \ + --tmpfs /run \ + --tmpfs /run/lock \ + --volume /sys/fs/cgroup:/sys/fs/cgroup:rw \ + --mount \ + "type=bind,source=${agent_artifacts},target=/artifacts/agent,readonly" \ + --mount \ + "type=bind,source=${provider_artifacts},target=/artifacts/provider,readonly" \ + --mount \ + "type=bind,source=${protocol_artifacts},target=/artifacts/protocol,readonly" \ + --mount "type=bind,source=${evidence},target=/evidence" \ + "${CONTAINER_IMAGE}" >/dev/null + + for _ in {1..60}; do + state="$(docker exec "${CONTAINER_NAME}" \ + systemctl is-system-running 2>/dev/null || true)" + if [[ "${state}" == "running" || "${state}" == "degraded" ]]; then + break + fi + sleep 1 + done + if [[ "${state:-}" != "running" && "${state:-}" != "degraded" ]]; then + echo "systemd did not become ready in ${CONTAINER_NAME}" >&2 + exit 1 + fi + + docker exec "${CONTAINER_NAME}" \ + rpm -Uvh --nodeps "/artifacts/agent/${agent_rpm}" \ + > "${EVIDENCE_DIRECTORY}/agent-rpm-install.txt" + docker exec "${CONTAINER_NAME}" rpm -V --nodeps stateforge-agent \ + > "${EVIDENCE_DIRECTORY}/agent-rpm-verify.txt" + docker exec "${CONTAINER_NAME}" python3 -c ' + import re + from importlib.metadata import requires + + dependencies = [] + for requirement in requires("stateforge-agent") or (): + name = re.split(r"[ ;<>=!~\[(]", requirement, maxsplit=1)[0] + if name.strip().lower().replace("_", "-") != "stateforge-protocol": + dependencies.append(requirement) + print("\n".join(dependencies)) + ' > "${EVIDENCE_DIRECTORY}/agent-requirements.txt" + docker exec "${CONTAINER_NAME}" bash -lc \ + 'python3 -m pip install --no-cache-dir --break-system-packages --prefix /usr --requirement /evidence/agent-requirements.txt /artifacts/protocol/stateforge_protocol-*.whl /artifacts/provider/stateforge_provider-*.whl' \ + > "${EVIDENCE_DIRECTORY}/runtime-install.txt" + + docker exec "${CONTAINER_NAME}" \ + sed -i \ + 's|url = ""|url = "https://control.stateforge.test"|' \ + /etc/stateforge-agent/config.toml + docker exec "${CONTAINER_NAME}" python3 -c ' + import json + from importlib.metadata import distribution + from pathlib import Path + + import stateforge_agent + import stateforge_provider + import stateforge_protocol + + packages = {} + for name, module in ( + ("stateforge-agent", stateforge_agent), + ("stateforge-provider", stateforge_provider), + ("stateforge-protocol", stateforge_protocol), + ): + package = distribution(name) + packages[name] = { + "module_file": str(Path(module.__file__).resolve()), + "version": package.version, + } + print(json.dumps(packages, indent=2, sort_keys=True)) + ' > "${EVIDENCE_DIRECTORY}/installed-runtime.json" + + echo "container-name=${CONTAINER_NAME}" >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/agent/start/action.yml b/.github/actions/agent/start/action.yml new file mode 100644 index 0000000..63f64b2 --- /dev/null +++ b/.github/actions/agent/start/action.yml @@ -0,0 +1,46 @@ +name: Start the installed StateForge Agent +description: Start the packaged systemd service and wait for readiness. + +inputs: + container-name: + description: Fedora container containing the installed Agent. + required: true + timeout-seconds: + description: Maximum time to wait for service readiness. + required: false + default: "60" + +runs: + using: composite + steps: + - name: Start Agent service + shell: bash + env: + CONTAINER_NAME: ${{ inputs.container-name }} + TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }} + run: | + set -euo pipefail + docker exec "${CONTAINER_NAME}" systemctl daemon-reload + docker exec "${CONTAINER_NAME}" systemctl start stateforge-agent.service + + deadline=$((SECONDS + TIMEOUT_SECONDS)) + while (( SECONDS < deadline )); do + state="$(docker exec "${CONTAINER_NAME}" \ + systemctl is-active stateforge-agent.service 2>/dev/null || true)" + if [[ "${state}" == "active" ]] && docker exec \ + "${CONTAINER_NAME}" journalctl -u stateforge-agent.service \ + --no-pager -o cat | grep -Fq 'StateForge Agent started'; then + exit 0 + fi + if [[ "${state}" == "failed" || "${state}" == "inactive" ]]; then + break + fi + sleep 1 + done + + docker exec "${CONTAINER_NAME}" \ + systemctl status stateforge-agent.service --no-pager || true + docker exec "${CONTAINER_NAME}" \ + journalctl -u stateforge-agent.service --no-pager || true + echo "Agent service did not become ready" >&2 + exit 1 diff --git a/.github/actions/environment/install/action.yml b/.github/actions/environment/install/action.yml index 5c985e4..17ad5b0 100644 --- a/.github/actions/environment/install/action.yml +++ b/.github/actions/environment/install/action.yml @@ -59,7 +59,7 @@ runs: ) > "${EVIDENCE_DIRECTORY}/provider-checksums.txt" ( cd "${PROTOCOL_ARTIFACT_DIRECTORY}" - "${checksum_command[@]}" PROTOCOL_SHA256SUMS + "${checksum_command[@]}" SHA256SUMS ) > "${EVIDENCE_DIRECTORY}/protocol-checksums.txt" shopt -s nullglob diff --git a/.github/actions/evidence/collect/action.yml b/.github/actions/evidence/collect/action.yml index 64019db..d50ccc5 100644 --- a/.github/actions/evidence/collect/action.yml +++ b/.github/actions/evidence/collect/action.yml @@ -1,15 +1,18 @@ -name: Collect bounded Provider runtime evidence -description: Preserve safe scenario evidence and remove temporary runtime state. +name: Collect bounded installed-runtime evidence +description: Capture Agent and Provider state, then remove the Fedora system. inputs: stage: description: Stable scenario name. required: true + container-name: + description: Fedora container owning this scenario. + required: true runtime-directory: - description: Scenario-owned directory below the runner temp directory. + description: Scenario-owned host directory below the runner temp directory. required: true evidence-directory: - description: Directory containing and receiving scenario evidence. + description: Host directory containing and receiving scenario evidence. required: true max-file-bytes: description: Maximum retained size of an individual evidence file. @@ -22,6 +25,7 @@ runs: - name: Preserve evidence and clean scenario state shell: bash env: + CONTAINER_NAME: ${{ inputs.container-name }} EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} EVIDENCE_STAGE: ${{ inputs.stage }} MAX_FILE_BYTES: ${{ inputs.max-file-bytes }} @@ -51,37 +55,45 @@ runs: "runner-arch=${RUNNER_ARCH:-unknown}" \ > "${EVIDENCE_DIRECTORY}/runner-context.txt" - while IFS= read -r -d '' pid_file; do - pid="$(cat "${pid_file}")" - if [[ "${pid}" =~ ^[0-9]+$ ]] && kill -0 "${pid}" 2>/dev/null; then - kill -TERM "${pid}" 2>/dev/null || true - for _ in {1..30}; do - state="$(ps -o stat= -p "${pid}" 2>/dev/null || true)" - if [[ -z "${state}" || "${state}" == Z* ]]; then - break - fi - sleep 0.1 - done - kill -KILL "${pid}" 2>/dev/null || true - fi - done < <(find "${EVIDENCE_DIRECTORY}" -name provider.pid -print0) + status=0 + if docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then + docker inspect "${CONTAINER_NAME}" \ + > "${EVIDENCE_DIRECTORY}/container-inspect.json" 2>&1 || true + docker logs "${CONTAINER_NAME}" \ + > "${EVIDENCE_DIRECTORY}/container.log" 2>&1 || true + docker exec "${CONTAINER_NAME}" \ + systemctl status stateforge-agent.service --no-pager \ + > "${EVIDENCE_DIRECTORY}/agent-service-status.txt" 2>&1 || true + docker exec "${CONTAINER_NAME}" \ + journalctl -u stateforge-agent.service --no-pager -n 500 \ + > "${EVIDENCE_DIRECTORY}/agent-journal.txt" 2>&1 || true + docker exec "${CONTAINER_NAME}" bash -lc \ + "journalctl --no-pager -n 500 -u 'stateforge-test-*'" \ + > "${EVIDENCE_DIRECTORY}/provider-journal.txt" 2>&1 || true + docker exec "${CONTAINER_NAME}" ps -ef \ + > "${EVIDENCE_DIRECTORY}/processes.txt" 2>&1 || true + docker exec "${CONTAINER_NAME}" bash -lc \ + 'find /run/stateforge-agent/providers -maxdepth 1 -printf "%M %u:%g %s %p\n" -type f -name "*.sfp.json" -exec cat {} \;' \ + > "${EVIDENCE_DIRECTORY}/provider-descriptors.txt" 2>&1 || true + docker exec "${CONTAINER_NAME}" rpm -V --nodeps stateforge-agent \ + > "${EVIDENCE_DIRECTORY}/agent-rpm-final-verify.txt" 2>&1 || true + + docker exec "${CONTAINER_NAME}" \ + systemctl stop 'stateforge-test-*' stateforge-agent.service \ + > "${EVIDENCE_DIRECTORY}/service-stop.txt" 2>&1 || true + docker exec "${CONTAINER_NAME}" rpm -e stateforge-agent \ + > "${EVIDENCE_DIRECTORY}/agent-rpm-remove.txt" 2>&1 || status=$? + docker rm --force "${CONTAINER_NAME}" >/dev/null 2>&1 || status=1 + else + echo "Scenario container was not created" \ + > "${EVIDENCE_DIRECTORY}/container-missing.txt" + fi if [[ -d "${RUNTIME_DIRECTORY}" ]]; then find "${RUNTIME_DIRECTORY}" -maxdepth 5 \ - -path "${RUNTIME_DIRECTORY}/environment" -prune -o \ -printf '%M %u:%g %s %p\n' \ > "${EVIDENCE_DIRECTORY}/runtime-inventory.txt" - find "${RUNTIME_DIRECTORY}" \ - -path "${RUNTIME_DIRECTORY}/environment" -prune -o -type f \ - \( -name '*.json' -o -name '*.log' -o -name '*.txt' \) \ - -print0 | while IFS= read -r -d '' file; do - relative="${file#"${RUNTIME_DIRECTORY}"/}" - destination="${EVIDENCE_DIRECTORY}/runtime/${relative}" - mkdir -p "$(dirname "${destination}")" - cp "${file}" "${destination}" - done fi - while IFS= read -r -d '' file; do size="$(wc -c < "${file}")" if (( size > MAX_FILE_BYTES )); then @@ -91,3 +103,4 @@ runs: done < <(find "${EVIDENCE_DIRECTORY}" -type f -print0) rm -rf -- "${RUNTIME_DIRECTORY}" + exit "${status}" diff --git a/.github/actions/package/build/action.yml b/.github/actions/package/build/action.yml new file mode 100644 index 0000000..118a829 --- /dev/null +++ b/.github/actions/package/build/action.yml @@ -0,0 +1,101 @@ +name: Build StateForge Provider distributions +description: Build one wheel and sdist with checksums and source provenance. + +inputs: + output-directory: + description: Directory receiving Provider distributions and identity records. + required: false + default: dist/provider + evidence-directory: + description: Directory receiving the package build log. + required: false + default: evidence/build/provider + +outputs: + version: + description: Version read from the built wheel metadata. + value: ${{ steps.identity.outputs.version }} + wheel-path: + description: Absolute path to the built Provider wheel. + value: ${{ steps.identity.outputs.wheel-path }} + wheel-sha256: + description: SHA-256 digest of the built Provider wheel. + value: ${{ steps.identity.outputs.wheel-sha256 }} + +runs: + using: composite + steps: + - name: Build wheel and source distribution once + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + OUTPUT_DIRECTORY: ${{ inputs.output-directory }} + run: | + set -euo pipefail + source_directory="$(cd "${ACTION_PATH}/../../../.." && pwd -P)" + evidence_directory="$(mkdir -p "${EVIDENCE_DIRECTORY}" && \ + cd "${EVIDENCE_DIRECTORY}" && pwd -P)" + output_directory="$(mkdir -p "${OUTPUT_DIRECTORY}" && \ + cd "${OUTPUT_DIRECTORY}" && pwd -P)" + python -m build \ + --sdist \ + --wheel \ + --outdir "${output_directory}" \ + "${source_directory}" \ + 2>&1 | tee "${evidence_directory}/build.log" + + - name: Record Provider artifact identity + id: identity + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + OUTPUT_DIRECTORY: ${{ inputs.output-directory }} + run: | + set -euo pipefail + source_directory="$(cd "${ACTION_PATH}/../../../.." && pwd -P)" + output_directory="$(cd "${OUTPUT_DIRECTORY}" && pwd -P)" + shopt -s nullglob + wheels=("${output_directory}"/stateforge_provider-*.whl) + sdists=("${output_directory}"/stateforge_provider-*.tar.gz) + if [[ ${#wheels[@]} -ne 1 || ${#sdists[@]} -ne 1 ]]; then + echo "Expected exactly one Provider wheel and one sdist" >&2 + exit 1 + fi + + wheel="${wheels[0]}" + sdist="${sdists[0]}" + distribution="$(unzip -p "${wheel}" '*.dist-info/METADATA' | sed -n 's/^Name: //p')" + version="$(unzip -p "${wheel}" '*.dist-info/METADATA' | sed -n 's/^Version: //p')" + if [[ "${distribution}" != "stateforge-provider" || -z "${version}" ]]; then + echo "Built wheel has unexpected package metadata" >&2 + exit 1 + fi + + source_sha="$(git -C "${source_directory}" rev-parse --verify HEAD)" + source_ref="$( + git -C "${source_directory}" symbolic-ref --quiet --short HEAD || \ + printf 'detached' + )" + ( + cd "${output_directory}" + printf '%s\n' \ + "distribution=${distribution}" \ + "version=${version}" \ + "source-sha=${source_sha}" \ + "source-ref=${source_ref}" \ + "builder-python=$(python --version 2>&1)" \ + "runner-os=${RUNNER_OS:-local}" \ + "runner-arch=${RUNNER_ARCH:-unknown}" \ + > PROVENANCE + sha256sum "$(basename "${wheel}")" "$(basename "${sdist}")" \ + PROVENANCE > SHA256SUMS + ) + + wheel_path="${output_directory}/$(basename "${wheel}")" + wheel_sha256="$(sha256sum "${wheel}" | awk '{print $1}')" + { + echo "version=${version}" + echo "wheel-path=${wheel_path}" + echo "wheel-sha256=${wheel_sha256}" + } >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/provider/crash/action.yml b/.github/actions/provider/crash/action.yml index 866eef6..0df2ecd 100644 --- a/.github/actions/provider/crash/action.yml +++ b/.github/actions/provider/crash/action.yml @@ -1,9 +1,12 @@ name: Crash an installed StateForge Provider -description: Terminate one fixture abruptly and record its stale endpoint. +description: Kill one fixture and preserve its stale endpoint evidence. inputs: - pid: - description: Fixture process ID returned by the start action. + container-name: + description: Fedora container running the Provider. + required: true + unit-name: + description: Transient systemd unit returned by the start action. required: true descriptor-path: description: Stable descriptor path observed before the crash. @@ -12,12 +15,12 @@ inputs: description: Generation-specific socket path observed before the crash. required: true evidence-directory: - description: Directory receiving deterministic fault evidence. + description: Host directory receiving deterministic fault evidence. required: true timeout-seconds: description: Maximum time to wait for process termination. required: false - default: "10" + default: "30" runs: using: composite @@ -25,34 +28,34 @@ runs: - name: Terminate fixture without graceful cleanup shell: bash env: + CONTAINER_NAME: ${{ inputs.container-name }} DESCRIPTOR_PATH: ${{ inputs.descriptor-path }} EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} - PROVIDER_PID: ${{ inputs.pid }} SOCKET_PATH: ${{ inputs.socket-path }} TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }} + UNIT_NAME: ${{ inputs.unit-name }} run: | set -euo pipefail mkdir -p "${EVIDENCE_DIRECTORY}" - kill -KILL "${PROVIDER_PID}" + docker exec "${CONTAINER_NAME}" \ + systemctl kill --kill-whom=main --signal=KILL "${UNIT_NAME}" deadline=$((SECONDS + TIMEOUT_SECONDS)) while (( SECONDS < deadline )); do - state="$(ps -o stat= -p "${PROVIDER_PID}" 2>/dev/null || true)" - if [[ -z "${state}" || "${state}" == Z* ]]; then + state="$(docker exec "${CONTAINER_NAME}" \ + systemctl is-active "${UNIT_NAME}" 2>/dev/null || true)" + if [[ "${state}" == "failed" || "${state}" == "inactive" ]]; then break fi - sleep 0.1 + sleep 1 done - state="$(ps -o stat= -p "${PROVIDER_PID}" 2>/dev/null || true)" - if [[ -n "${state}" && "${state}" != Z* ]]; then + if [[ "${state:-}" != "failed" && "${state:-}" != "inactive" ]]; then echo "Provider process survived SIGKILL" >&2 exit 1 fi - if [[ ! -f "${DESCRIPTOR_PATH}" || ! -S "${SOCKET_PATH}" ]]; then - echo "Crash did not leave the expected discovery evidence" >&2 - exit 1 - fi + docker exec "${CONTAINER_NAME}" test -f "${DESCRIPTOR_PATH}" + docker exec "${CONTAINER_NAME}" test -S "${SOCKET_PATH}" printf '%s\n' \ - "pid=${PROVIDER_PID}" \ + "unit=${UNIT_NAME}" \ "descriptor=${DESCRIPTOR_PATH}" \ "socket=${SOCKET_PATH}" \ > "${EVIDENCE_DIRECTORY}/crash.txt" diff --git a/.github/actions/provider/create-fixture/action.yml b/.github/actions/provider/create-fixture/action.yml index d1859f3..426c4a1 100644 --- a/.github/actions/provider/create-fixture/action.yml +++ b/.github/actions/provider/create-fixture/action.yml @@ -24,15 +24,12 @@ outputs: fixture-path: description: Absolute staged fixture entry point. value: ${{ steps.fixture.outputs.fixture-path }} - discovery-directory: - description: Shared scenario discovery directory. - value: ${{ steps.fixture.outputs.discovery-directory }} - state-directory: - description: Isolated state directory for this Provider installation. - value: ${{ steps.fixture.outputs.state-directory }} - event-log: - description: Author lifecycle event log. - value: ${{ steps.fixture.outputs.event-log }} + instance-name: + description: Safe process name used for the transient service. + value: ${{ steps.fixture.outputs.instance-name }} + state-name: + description: Stable installation-state name used inside Fedora. + value: ${{ steps.fixture.outputs.state-name }} runs: using: composite @@ -50,13 +47,9 @@ runs: run: | set -euo pipefail root="${RUNTIME_DIRECTORY}/${INSTANCE_NAME}" - discovery="${RUNTIME_DIRECTORY}/discovery" state_name="${STATE_NAME:-${INSTANCE_NAME}}" - state="${RUNTIME_DIRECTORY}/state/${state_name}" - event_log="${root}/events.log" fixture="${root}/provider.py" - mkdir -p "${root}" "${discovery}" - chmod 2770 "${discovery}" + mkdir -p "${root}" install -m 0644 "${ACTION_PATH}/fixture.py" "${fixture}" printf '%s\n' \ "identity=${IDENTITY}" \ @@ -64,7 +57,6 @@ runs: > "${root}/fixture-identity.txt" { echo "fixture-path=${fixture}" - echo "discovery-directory=${discovery}" - echo "state-directory=${state}" - echo "event-log=${event_log}" + echo "instance-name=${INSTANCE_NAME}" + echo "state-name=${state_name}" } >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/provider/start/action.yml b/.github/actions/provider/start/action.yml index 506f980..b9a7283 100644 --- a/.github/actions/provider/start/action.yml +++ b/.github/actions/provider/start/action.yml @@ -1,13 +1,19 @@ name: Start an installed StateForge Provider -description: Start a staged public-API fixture and wait for its descriptor. +description: Run a public-API fixture beside the installed Agent in Fedora. inputs: - python: - description: Python interpreter containing the installed Provider package. + container-name: + description: Fedora container running the installed Agent. required: true fixture-path: description: Staged Provider fixture entry point. required: true + instance-name: + description: Safe name used for the fixture process and service. + required: true + state-name: + description: Stable state name; reuse it to preserve installation identity. + required: true identity: description: Author-declared Provider identity. required: true @@ -15,39 +21,33 @@ inputs: description: Author-declared Provider display name. required: false default: StateForge CI Provider - discovery-directory: - description: Shared discovery directory. - required: true - state-directory: - description: Persistent state directory for this Provider identity. - required: true - event-log: - description: Lifecycle event log written by the fixture. - required: true evidence-directory: - description: Directory receiving process and descriptor evidence. + description: Host directory receiving process and descriptor evidence. required: true timeout-seconds: - description: Maximum time to wait for publication. + description: Maximum time to wait for endpoint publication. required: false - default: "15" + default: "60" outputs: - pid: - description: Fixture process ID. - value: ${{ steps.start.outputs.pid }} + unit-name: + description: Transient systemd unit running the Provider. + value: ${{ steps.start.outputs.unit-name }} descriptor-path: - description: Published endpoint descriptor path. + description: Descriptor path inside the Fedora system. value: ${{ steps.start.outputs.descriptor-path }} provider-instance-id: - description: Stable installation identity. + description: Stable Provider installation identity. value: ${{ steps.start.outputs.provider-instance-id }} endpoint-generation: description: Allocated endpoint generation. value: ${{ steps.start.outputs.endpoint-generation }} socket-path: - description: Generation-specific Unix socket. + description: Generation-specific Unix socket inside Fedora. value: ${{ steps.start.outputs.socket-path }} + event-log: + description: Lifecycle event log inside Fedora. + value: ${{ steps.start.outputs.event-log }} runs: using: composite @@ -56,83 +56,111 @@ runs: id: start shell: bash env: - DISCOVERY_DIRECTORY: ${{ inputs.discovery-directory }} + CONTAINER_NAME: ${{ inputs.container-name }} DISPLAY_NAME: ${{ inputs.display-name }} - EVENT_LOG: ${{ inputs.event-log }} EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} FIXTURE_PATH: ${{ inputs.fixture-path }} IDENTITY: ${{ inputs.identity }} - ISOLATED_PYTHON: ${{ inputs.python }} - STATE_DIRECTORY: ${{ inputs.state-directory }} + INSTANCE_NAME: ${{ inputs.instance-name }} + STATE_NAME: ${{ inputs.state-name }} TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }} run: | set -euo pipefail - if [[ ! "${TIMEOUT_SECONDS}" =~ ^[0-9]+$ ]] || (( TIMEOUT_SECONDS < 1 )); then - echo "timeout-seconds must be a positive integer" >&2 - exit 1 + if [[ ! "${INSTANCE_NAME}" =~ ^[a-z][a-z0-9-]{0,20}$ || \ + ! "${STATE_NAME}" =~ ^[a-z][a-z0-9-]{0,20}$ ]]; then + echo "Provider fixture and state names must be safe identifiers" >&2 + exit 2 fi mkdir -p "${EVIDENCE_DIRECTORY}" - log="${EVIDENCE_DIRECTORY}/provider.log" + + fixture_root="/opt/stateforge-provider-tests/${INSTANCE_NAME}" + state_directory="/var/lib/stateforge-provider-tests/${STATE_NAME}" + log_directory="/var/log/stateforge-provider-tests/${INSTANCE_NAME}" + event_log="${log_directory}/events.log" + service_user="sf-${STATE_NAME}" + unit_name="stateforge-test-${INSTANCE_NAME}.service" + + docker exec "${CONTAINER_NAME}" mkdir -p \ + "${fixture_root}" "${state_directory}" "${log_directory}" + docker cp "${FIXTURE_PATH}" \ + "${CONTAINER_NAME}:${fixture_root}/provider.py" + if ! docker exec "${CONTAINER_NAME}" id "${service_user}" \ + >/dev/null 2>&1; then + docker exec "${CONTAINER_NAME}" \ + useradd --system --no-create-home \ + --groups stateforge-providers "${service_user}" + fi + docker exec "${CONTAINER_NAME}" chown -R \ + "${service_user}:${service_user}" \ + "${state_directory}" "${log_directory}" + docker exec "${CONTAINER_NAME}" chmod 0700 "${state_directory}" + docker exec "${CONTAINER_NAME}" chmod -R a+rX "${fixture_root}" + + identity_path="${state_directory}/identity.json" previous_generation=0 - if [[ -f "${STATE_DIRECTORY}/identity.json" ]]; then - previous_generation="$(jq -r '.last_endpoint_generation // 0' \ - "${STATE_DIRECTORY}/identity.json")" - if [[ ! "${previous_generation}" =~ ^[0-9]+$ ]]; then - echo "Existing endpoint generation is invalid" >&2 - exit 1 + if docker exec "${CONTAINER_NAME}" test -f "${identity_path}"; then + provider_id="$(docker exec "${CONTAINER_NAME}" \ + jq -r .provider_instance_id "${identity_path}")" + descriptor="/run/stateforge-agent/providers/${provider_id}.sfp.json" + if docker exec "${CONTAINER_NAME}" test -f "${descriptor}"; then + previous_generation="$(docker exec "${CONTAINER_NAME}" \ + jq -r '.endpoint_generation // 0' "${descriptor}")" fi fi - nohup env -u PYTHONPATH -u PYTHONHOME \ - "${ISOLATED_PYTHON}" -I "${FIXTURE_PATH}" \ + + docker exec "${CONTAINER_NAME}" systemd-run \ + --unit "${unit_name}" \ + --property "User=${service_user}" \ + --property "Group=${service_user}" \ + --property "SupplementaryGroups=stateforge-providers" \ + --property "WorkingDirectory=${fixture_root}" \ + /usr/bin/python3 "${fixture_root}/provider.py" \ --identity "${IDENTITY}" \ --display-name "${DISPLAY_NAME}" \ - --discovery-directory "${DISCOVERY_DIRECTORY}" \ - --state-directory "${STATE_DIRECTORY}" \ - --event-log "${EVENT_LOG}" \ - > "${log}" 2>&1 & - pid=$! - printf '%s\n' "${pid}" > "${EVIDENCE_DIRECTORY}/provider.pid" + --discovery-directory /run/stateforge-agent/providers \ + --state-directory "${state_directory}" \ + --event-log "${event_log}" deadline=$((SECONDS + TIMEOUT_SECONDS)) - descriptor="" while (( SECONDS < deadline )); do - if ! kill -0 "${pid}" 2>/dev/null; then - cat "${log}" >&2 - echo "Provider exited before publishing its descriptor" >&2 - exit 1 - fi - if [[ -f "${STATE_DIRECTORY}/identity.json" ]]; then - provider_id="$(jq -r '.provider_instance_id' \ - "${STATE_DIRECTORY}/identity.json")" - descriptor="${DISCOVERY_DIRECTORY}/${provider_id}.sfp.json" - if [[ -f "${descriptor}" ]]; then - generation="$(jq -r '.endpoint_generation // 0' \ - "${descriptor}")" + if docker exec "${CONTAINER_NAME}" test -f "${identity_path}"; then + provider_id="$(docker exec "${CONTAINER_NAME}" \ + jq -r .provider_instance_id "${identity_path}")" + descriptor="/run/stateforge-agent/providers/${provider_id}.sfp.json" + if docker exec "${CONTAINER_NAME}" test -f "${descriptor}"; then + generation="$(docker exec "${CONTAINER_NAME}" \ + jq -r '.endpoint_generation // 0' "${descriptor}")" if [[ "${generation}" =~ ^[0-9]+$ ]] && \ (( generation > previous_generation )); then break fi fi fi - sleep 0.1 + if ! docker exec "${CONTAINER_NAME}" \ + systemctl is-active --quiet "${unit_name}"; then + break + fi + sleep 1 done - if [[ -z "${descriptor}" || ! -f "${descriptor}" ]]; then - cat "${log}" >&2 - echo "Provider did not publish within ${TIMEOUT_SECONDS}s" >&2 + if [[ -z "${descriptor:-}" || ! "${generation:-}" =~ ^[0-9]+$ ]] || \ + (( generation <= previous_generation )) || \ + ! docker exec "${CONTAINER_NAME}" test -f "${descriptor}"; then + docker exec "${CONTAINER_NAME}" \ + journalctl -u "${unit_name}" --no-pager || true + echo "Provider did not publish its endpoint" >&2 exit 1 fi - generation="$(jq -r '.endpoint_generation' "${descriptor}")" - socket_path="$(jq -r '.transport.socket_path' "${descriptor}")" - if [[ ! -S "${socket_path}" ]]; then - echo "Published Provider socket is not available" >&2 - exit 1 - fi - cp "${descriptor}" "${EVIDENCE_DIRECTORY}/descriptor.json" + socket_path="$(docker exec "${CONTAINER_NAME}" \ + jq -r .transport.socket_path "${descriptor}")" + docker exec "${CONTAINER_NAME}" test -S "${socket_path}" + docker exec "${CONTAINER_NAME}" cat "${descriptor}" \ + > "${EVIDENCE_DIRECTORY}/descriptor.json" { - echo "pid=${pid}" + echo "unit-name=${unit_name}" echo "descriptor-path=${descriptor}" echo "provider-instance-id=${provider_id}" echo "endpoint-generation=${generation}" echo "socket-path=${socket_path}" + echo "event-log=${event_log}" } >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/provider/stop/action.yml b/.github/actions/provider/stop/action.yml index 4ddf54e..e0a8e61 100644 --- a/.github/actions/provider/stop/action.yml +++ b/.github/actions/provider/stop/action.yml @@ -1,9 +1,12 @@ name: Stop an installed StateForge Provider -description: Request bounded shutdown and verify generation-owned cleanup. +description: Stop one transient Provider service and verify owned cleanup. inputs: - pid: - description: Fixture process ID returned by the start action. + container-name: + description: Fedora container running the Provider. + required: true + unit-name: + description: Transient systemd unit returned by the start action. required: true descriptor-path: description: Stable descriptor path observed at startup. @@ -12,19 +15,15 @@ inputs: description: Generation-specific socket path observed at startup. required: true event-log: - description: Lifecycle event log written by the fixture. + description: Lifecycle event log inside Fedora. required: true evidence-directory: - description: Directory receiving shutdown evidence. + description: Host directory receiving shutdown evidence. required: true - descriptor-expectation: - description: Whether the stable descriptor must be removed or preserved. - required: false - default: removed timeout-seconds: description: Maximum time to wait for graceful shutdown. required: false - default: "15" + default: "30" runs: using: composite @@ -32,56 +31,44 @@ runs: - name: Stop fixture and verify cleanup shell: bash env: - DESCRIPTOR_EXPECTATION: ${{ inputs.descriptor-expectation }} + CONTAINER_NAME: ${{ inputs.container-name }} DESCRIPTOR_PATH: ${{ inputs.descriptor-path }} EVENT_LOG: ${{ inputs.event-log }} EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} - PROVIDER_PID: ${{ inputs.pid }} SOCKET_PATH: ${{ inputs.socket-path }} TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }} + UNIT_NAME: ${{ inputs.unit-name }} run: | set -euo pipefail - if [[ "${DESCRIPTOR_EXPECTATION}" != "removed" && \ - "${DESCRIPTOR_EXPECTATION}" != "preserved" ]]; then - echo "descriptor-expectation must be removed or preserved" >&2 - exit 1 - fi - if kill -0 "${PROVIDER_PID}" 2>/dev/null; then - kill -TERM "${PROVIDER_PID}" - fi + mkdir -p "${EVIDENCE_DIRECTORY}" + docker exec "${CONTAINER_NAME}" systemctl stop "${UNIT_NAME}" deadline=$((SECONDS + TIMEOUT_SECONDS)) while (( SECONDS < deadline )); do - state="$(ps -o stat= -p "${PROVIDER_PID}" 2>/dev/null || true)" - if [[ -z "${state}" || "${state}" == Z* ]]; then + state="$(docker exec "${CONTAINER_NAME}" \ + systemctl is-active "${UNIT_NAME}" 2>/dev/null || true)" + if [[ "${state}" == "inactive" || "${state}" == "failed" ]]; then break fi - sleep 0.1 + sleep 1 done - state="$(ps -o stat= -p "${PROVIDER_PID}" 2>/dev/null || true)" - if [[ -n "${state}" && "${state}" != Z* ]]; then - kill -KILL "${PROVIDER_PID}" 2>/dev/null || true + if [[ "${state:-}" != "inactive" && "${state:-}" != "failed" ]]; then echo "Provider did not stop within ${TIMEOUT_SECONDS}s" >&2 exit 1 fi - - if [[ -e "${SOCKET_PATH}" ]]; then + if docker exec "${CONTAINER_NAME}" test -e "${SOCKET_PATH}"; then echo "Provider socket remained after graceful shutdown" >&2 exit 1 fi - if [[ "${DESCRIPTOR_EXPECTATION}" == "removed" && \ - -e "${DESCRIPTOR_PATH}" ]]; then + if docker exec "${CONTAINER_NAME}" test -e "${DESCRIPTOR_PATH}"; then echo "Provider descriptor remained after graceful shutdown" >&2 exit 1 fi - if [[ "${DESCRIPTOR_EXPECTATION}" == "preserved" && \ - ! -e "${DESCRIPTOR_PATH}" ]]; then - echo "Older generation removed the replacement descriptor" >&2 - exit 1 - fi - if ! grep -Fxq startup "${EVENT_LOG}" || \ - ! grep -Fxq shutdown "${EVENT_LOG}"; then - echo "Provider lifecycle hooks did not both complete" >&2 - exit 1 - fi - cp "${EVENT_LOG}" "${EVIDENCE_DIRECTORY}/lifecycle-events.txt" + lifecycle="$(docker exec "${CONTAINER_NAME}" cat "${EVENT_LOG}")" + grep -Fxq startup <<< "${lifecycle}" + grep -Fxq shutdown <<< "${lifecycle}" + printf '%s\n' "${lifecycle}" \ + > "${EVIDENCE_DIRECTORY}/lifecycle-events.txt" + docker exec "${CONTAINER_NAME}" \ + journalctl -u "${UNIT_NAME}" --no-pager \ + > "${EVIDENCE_DIRECTORY}/provider-journal.txt" diff --git a/.github/actions/scenarios/adversarial/invalid-peer/action.yml b/.github/actions/scenarios/adversarial/invalid-peer/action.yml index 4448e40..e9586b0 100644 --- a/.github/actions/scenarios/adversarial/invalid-peer/action.yml +++ b/.github/actions/scenarios/adversarial/invalid-peer/action.yml @@ -1,7 +1,13 @@ -name: Test invalid SFP peers -description: Verify invalid identity/session rejection and subsequent recovery. +name: Test invalid Provider discovery data +description: Verify the real Agent rejects malformed data and accepts recovery. inputs: + agent-artifact-directory: + description: Directory containing the exact Agent RPM build. + required: true + agent-source-directory: + description: Exact Agent checkout providing its Fedora test image. + required: true provider-artifact-directory: description: Directory containing the exact Provider build. required: true @@ -19,48 +25,61 @@ runs: using: composite steps: - name: Install exact runtime artifacts - id: environment - uses: ./.github/actions/environment/install + id: system + uses: ./.github/actions/agent/install with: + agent-artifact-directory: ${{ inputs.agent-artifact-directory }} + agent-source-directory: ${{ inputs.agent-source-directory }} provider-artifact-directory: ${{ inputs.provider-artifact-directory }} protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} - environment-directory: ${{ inputs.runtime-directory }}/environment evidence-directory: ${{ inputs.evidence-directory }}/installation - - name: Create Provider fixture + - name: Start installed Agent + uses: ./.github/actions/agent/start + with: + container-name: ${{ steps.system.outputs.container-name }} + + - name: Assert invalid descriptor rejection + uses: ./.github/actions/agent/assert-invalid-descriptor + with: + container-name: ${{ steps.system.outputs.container-name }} + evidence-directory: ${{ inputs.evidence-directory }}/invalid + + - name: Create recovery Provider fixture id: fixture uses: ./.github/actions/provider/create-fixture with: runtime-directory: ${{ inputs.runtime-directory }} - instance-name: provider - identity: stateforge.test.invalid + instance-name: provider-valid + identity: stateforge.test.recovery - - name: Start Provider fixture + - name: Start recovery Provider id: provider uses: ./.github/actions/provider/start with: - python: ${{ steps.environment.outputs.python }} + container-name: ${{ steps.system.outputs.container-name }} fixture-path: ${{ steps.fixture.outputs.fixture-path }} - identity: stateforge.test.invalid - discovery-directory: ${{ steps.fixture.outputs.discovery-directory }} - state-directory: ${{ steps.fixture.outputs.state-directory }} - event-log: ${{ steps.fixture.outputs.event-log }} + instance-name: ${{ steps.fixture.outputs.instance-name }} + state-name: ${{ steps.fixture.outputs.state-name }} + identity: stateforge.test.recovery evidence-directory: ${{ inputs.evidence-directory }}/provider - - name: Reject invalid peers and recover - uses: ./.github/actions/sfp/assert-invalid-peer + - name: Assert recovered Agent-accepted session + uses: ./.github/actions/agent/assert-session with: - python: ${{ steps.environment.outputs.python }} - descriptor-path: ${{ steps.provider.outputs.descriptor-path }} - evidence-directory: ${{ inputs.evidence-directory }}/sfp + container-name: ${{ steps.system.outputs.container-name }} + provider-instance-id: ${{ steps.provider.outputs.provider-instance-id }} + endpoint-generation: ${{ steps.provider.outputs.endpoint-generation }} + evidence-directory: ${{ inputs.evidence-directory }}/agent - - name: Stop Provider fixture + - name: Stop recovery Provider uses: ./.github/actions/provider/stop with: - pid: ${{ steps.provider.outputs.pid }} + container-name: ${{ steps.system.outputs.container-name }} + unit-name: ${{ steps.provider.outputs.unit-name }} descriptor-path: ${{ steps.provider.outputs.descriptor-path }} socket-path: ${{ steps.provider.outputs.socket-path }} - event-log: ${{ steps.fixture.outputs.event-log }} + event-log: ${{ steps.provider.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider - name: Preserve bounded evidence and clean up @@ -68,5 +87,6 @@ runs: uses: ./.github/actions/evidence/collect with: stage: invalid-peer + container-name: ${{ steps.system.outputs.container-name }} runtime-directory: ${{ inputs.runtime-directory }} evidence-directory: ${{ inputs.evidence-directory }} diff --git a/.github/actions/scenarios/destructive/uninstall-reinstall/action.yml b/.github/actions/scenarios/destructive/uninstall-reinstall/action.yml index 31f32db..04396d4 100644 --- a/.github/actions/scenarios/destructive/uninstall-reinstall/action.yml +++ b/.github/actions/scenarios/destructive/uninstall-reinstall/action.yml @@ -1,7 +1,13 @@ name: Test Provider uninstall and reinstall -description: Verify the exact Provider wheel remains usable after replacement. +description: Verify the real Agent accepts the exact Provider after reinstallation. inputs: + agent-artifact-directory: + description: Directory containing the exact Agent RPM build. + required: true + agent-source-directory: + description: Exact Agent checkout providing its Fedora test image. + required: true provider-artifact-directory: description: Directory containing the exact Provider build. required: true @@ -19,45 +25,40 @@ runs: using: composite steps: - name: Install exact runtime artifacts - id: environment - uses: ./.github/actions/environment/install + id: system + uses: ./.github/actions/agent/install with: + agent-artifact-directory: ${{ inputs.agent-artifact-directory }} + agent-source-directory: ${{ inputs.agent-source-directory }} provider-artifact-directory: ${{ inputs.provider-artifact-directory }} protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} - environment-directory: ${{ inputs.runtime-directory }}/environment evidence-directory: ${{ inputs.evidence-directory }}/installation - - name: Uninstall and reinstall the exact Provider wheel + - name: Start installed Agent + uses: ./.github/actions/agent/start + with: + container-name: ${{ steps.system.outputs.container-name }} + + - name: Uninstall and reinstall exact Provider wheel shell: bash env: + CONTAINER_NAME: ${{ steps.system.outputs.container-name }} EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} - EXPECTED_VERSION: ${{ steps.environment.outputs.provider-version }} - ISOLATED_PYTHON: ${{ steps.environment.outputs.python }} - PROVIDER_WHEEL: ${{ steps.environment.outputs.provider-wheel }} - VERIFY_SCRIPT: ${{ github.workspace }}/.github/actions/package/verify-installed/verify.py run: | set -euo pipefail { - "${ISOLATED_PYTHON}" -m pip uninstall --yes stateforge-provider - if ( - cd "${RUNNER_TEMP}" - unset PYTHONPATH PYTHONHOME - "${ISOLATED_PYTHON}" -I -c 'import stateforge_provider' - ); then + docker exec "${CONTAINER_NAME}" \ + python3 -m pip uninstall --yes stateforge-provider + if docker exec "${CONTAINER_NAME}" \ + python3 -I -c 'import stateforge_provider'; then echo "Provider remained importable after uninstall" >&2 exit 1 fi - "${ISOLATED_PYTHON}" -m pip install --disable-pip-version-check \ - "${PROVIDER_WHEEL}" - } 2>&1 | tee "${EVIDENCE_DIRECTORY}/uninstall-reinstall.log" - ( - cd "${RUNNER_TEMP}" - unset PYTHONPATH PYTHONHOME - "${ISOLATED_PYTHON}" -I "${VERIFY_SCRIPT}" \ - --expected-version "${EXPECTED_VERSION}" \ - --workspace "${GITHUB_WORKSPACE}" \ - --output "${GITHUB_WORKSPACE}/${EVIDENCE_DIRECTORY}/reinstalled.json" - ) + docker exec "${CONTAINER_NAME}" bash -lc \ + 'python3 -m pip install --no-cache-dir --break-system-packages --prefix /usr --no-deps /artifacts/provider/stateforge_provider-*.whl' + docker exec "${CONTAINER_NAME}" python3 -I -c \ + 'from importlib.metadata import version; import stateforge_provider; print(version("stateforge-provider")); print(stateforge_provider.__file__)' + } > "${EVIDENCE_DIRECTORY}/uninstall-reinstall.txt" 2>&1 - name: Create reinstalled Provider fixture id: fixture @@ -71,29 +72,29 @@ runs: id: provider uses: ./.github/actions/provider/start with: - python: ${{ steps.environment.outputs.python }} + container-name: ${{ steps.system.outputs.container-name }} fixture-path: ${{ steps.fixture.outputs.fixture-path }} + instance-name: ${{ steps.fixture.outputs.instance-name }} + state-name: ${{ steps.fixture.outputs.state-name }} identity: stateforge.test.reinstalled - discovery-directory: ${{ steps.fixture.outputs.discovery-directory }} - state-directory: ${{ steps.fixture.outputs.state-directory }} - event-log: ${{ steps.fixture.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider - - name: Assert reinstalled Manifest - uses: ./.github/actions/sfp/assert-manifest + - name: Assert reinstalled Agent-accepted session + uses: ./.github/actions/agent/assert-session with: - python: ${{ steps.environment.outputs.python }} - descriptor-path: ${{ steps.provider.outputs.descriptor-path }} - identity: stateforge.test.reinstalled - evidence-directory: ${{ inputs.evidence-directory }}/sfp + container-name: ${{ steps.system.outputs.container-name }} + provider-instance-id: ${{ steps.provider.outputs.provider-instance-id }} + endpoint-generation: ${{ steps.provider.outputs.endpoint-generation }} + evidence-directory: ${{ inputs.evidence-directory }}/agent - name: Stop reinstalled Provider uses: ./.github/actions/provider/stop with: - pid: ${{ steps.provider.outputs.pid }} + container-name: ${{ steps.system.outputs.container-name }} + unit-name: ${{ steps.provider.outputs.unit-name }} descriptor-path: ${{ steps.provider.outputs.descriptor-path }} socket-path: ${{ steps.provider.outputs.socket-path }} - event-log: ${{ steps.fixture.outputs.event-log }} + event-log: ${{ steps.provider.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider - name: Preserve bounded evidence and clean up @@ -101,5 +102,6 @@ runs: uses: ./.github/actions/evidence/collect with: stage: uninstall-reinstall + container-name: ${{ steps.system.outputs.container-name }} runtime-directory: ${{ inputs.runtime-directory }} evidence-directory: ${{ inputs.evidence-directory }} diff --git a/.github/actions/scenarios/lifecycle/concurrent-providers/action.yml b/.github/actions/scenarios/lifecycle/concurrent-providers/action.yml index 022d825..4dcff47 100644 --- a/.github/actions/scenarios/lifecycle/concurrent-providers/action.yml +++ b/.github/actions/scenarios/lifecycle/concurrent-providers/action.yml @@ -1,7 +1,13 @@ name: Test concurrent installed Providers -description: Verify two distinct Provider installations can run concurrently. +description: Verify the real Agent accepts two independent Provider installations. inputs: + agent-artifact-directory: + description: Directory containing the exact Agent RPM build. + required: true + agent-source-directory: + description: Exact Agent checkout providing its Fedora test image. + required: true provider-artifact-directory: description: Directory containing the exact Provider build. required: true @@ -19,14 +25,20 @@ runs: using: composite steps: - name: Install exact runtime artifacts - id: environment - uses: ./.github/actions/environment/install + id: system + uses: ./.github/actions/agent/install with: + agent-artifact-directory: ${{ inputs.agent-artifact-directory }} + agent-source-directory: ${{ inputs.agent-source-directory }} provider-artifact-directory: ${{ inputs.provider-artifact-directory }} protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} - environment-directory: ${{ inputs.runtime-directory }}/environment evidence-directory: ${{ inputs.evidence-directory }}/installation + - name: Start installed Agent + uses: ./.github/actions/agent/start + with: + container-name: ${{ steps.system.outputs.container-name }} + - name: Create first Provider fixture id: fixture-a uses: ./.github/actions/provider/create-fixture @@ -47,27 +59,25 @@ runs: id: provider-a uses: ./.github/actions/provider/start with: - python: ${{ steps.environment.outputs.python }} + container-name: ${{ steps.system.outputs.container-name }} fixture-path: ${{ steps.fixture-a.outputs.fixture-path }} + instance-name: ${{ steps.fixture-a.outputs.instance-name }} + state-name: ${{ steps.fixture-a.outputs.state-name }} identity: stateforge.test.concurrent.a - discovery-directory: ${{ steps.fixture-a.outputs.discovery-directory }} - state-directory: ${{ steps.fixture-a.outputs.state-directory }} - event-log: ${{ steps.fixture-a.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider-a - name: Start second Provider id: provider-b uses: ./.github/actions/provider/start with: - python: ${{ steps.environment.outputs.python }} + container-name: ${{ steps.system.outputs.container-name }} fixture-path: ${{ steps.fixture-b.outputs.fixture-path }} + instance-name: ${{ steps.fixture-b.outputs.instance-name }} + state-name: ${{ steps.fixture-b.outputs.state-name }} identity: stateforge.test.concurrent.b - discovery-directory: ${{ steps.fixture-b.outputs.discovery-directory }} - state-directory: ${{ steps.fixture-b.outputs.state-directory }} - event-log: ${{ steps.fixture-b.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider-b - - name: Assert distinct Provider identities + - name: Assert independent Provider identities shell: bash env: FIRST_ID: ${{ steps.provider-a.outputs.provider-instance-id }} @@ -78,38 +88,40 @@ runs: exit 1 fi - - name: Assert first Manifest - uses: ./.github/actions/sfp/assert-manifest + - name: Assert first Agent-accepted session + uses: ./.github/actions/agent/assert-session with: - python: ${{ steps.environment.outputs.python }} - descriptor-path: ${{ steps.provider-a.outputs.descriptor-path }} - identity: stateforge.test.concurrent.a - evidence-directory: ${{ inputs.evidence-directory }}/provider-a/sfp + container-name: ${{ steps.system.outputs.container-name }} + provider-instance-id: ${{ steps.provider-a.outputs.provider-instance-id }} + endpoint-generation: ${{ steps.provider-a.outputs.endpoint-generation }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-a/agent - - name: Assert second Manifest - uses: ./.github/actions/sfp/assert-manifest + - name: Assert second Agent-accepted session + uses: ./.github/actions/agent/assert-session with: - python: ${{ steps.environment.outputs.python }} - descriptor-path: ${{ steps.provider-b.outputs.descriptor-path }} - identity: stateforge.test.concurrent.b - evidence-directory: ${{ inputs.evidence-directory }}/provider-b/sfp + container-name: ${{ steps.system.outputs.container-name }} + provider-instance-id: ${{ steps.provider-b.outputs.provider-instance-id }} + endpoint-generation: ${{ steps.provider-b.outputs.endpoint-generation }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-b/agent - name: Stop first Provider uses: ./.github/actions/provider/stop with: - pid: ${{ steps.provider-a.outputs.pid }} + container-name: ${{ steps.system.outputs.container-name }} + unit-name: ${{ steps.provider-a.outputs.unit-name }} descriptor-path: ${{ steps.provider-a.outputs.descriptor-path }} socket-path: ${{ steps.provider-a.outputs.socket-path }} - event-log: ${{ steps.fixture-a.outputs.event-log }} + event-log: ${{ steps.provider-a.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider-a - name: Stop second Provider uses: ./.github/actions/provider/stop with: - pid: ${{ steps.provider-b.outputs.pid }} + container-name: ${{ steps.system.outputs.container-name }} + unit-name: ${{ steps.provider-b.outputs.unit-name }} descriptor-path: ${{ steps.provider-b.outputs.descriptor-path }} socket-path: ${{ steps.provider-b.outputs.socket-path }} - event-log: ${{ steps.fixture-b.outputs.event-log }} + event-log: ${{ steps.provider-b.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider-b - name: Preserve bounded evidence and clean up @@ -117,5 +129,6 @@ runs: uses: ./.github/actions/evidence/collect with: stage: concurrent-providers + container-name: ${{ steps.system.outputs.container-name }} runtime-directory: ${{ inputs.runtime-directory }} evidence-directory: ${{ inputs.evidence-directory }} diff --git a/.github/actions/scenarios/lifecycle/same-instance-replacement/action.yml b/.github/actions/scenarios/lifecycle/same-instance-replacement/action.yml index 23130c8..70fcb6a 100644 --- a/.github/actions/scenarios/lifecycle/same-instance-replacement/action.yml +++ b/.github/actions/scenarios/lifecycle/same-instance-replacement/action.yml @@ -1,7 +1,13 @@ -name: Test same-instance Provider replacement -description: Verify a newer generation replaces and survives the older one. +name: Test same-installation Provider replacement +description: Verify the Agent accepts a newer generation of one installation. inputs: + agent-artifact-directory: + description: Directory containing the exact Agent RPM build. + required: true + agent-source-directory: + description: Exact Agent checkout providing its Fedora test image. + required: true provider-artifact-directory: description: Directory containing the exact Provider build. required: true @@ -19,14 +25,20 @@ runs: using: composite steps: - name: Install exact runtime artifacts - id: environment - uses: ./.github/actions/environment/install + id: system + uses: ./.github/actions/agent/install with: + agent-artifact-directory: ${{ inputs.agent-artifact-directory }} + agent-source-directory: ${{ inputs.agent-source-directory }} provider-artifact-directory: ${{ inputs.provider-artifact-directory }} protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} - environment-directory: ${{ inputs.runtime-directory }}/environment evidence-directory: ${{ inputs.evidence-directory }}/installation + - name: Start installed Agent + uses: ./.github/actions/agent/start + with: + container-name: ${{ steps.system.outputs.container-name }} + - name: Create older generation fixture id: fixture-old uses: ./.github/actions/provider/create-fixture @@ -36,6 +48,35 @@ runs: state-name: replacement identity: stateforge.test.replacement + - name: Start older generation + id: provider-old + uses: ./.github/actions/provider/start + with: + container-name: ${{ steps.system.outputs.container-name }} + fixture-path: ${{ steps.fixture-old.outputs.fixture-path }} + instance-name: ${{ steps.fixture-old.outputs.instance-name }} + state-name: ${{ steps.fixture-old.outputs.state-name }} + identity: stateforge.test.replacement + evidence-directory: ${{ inputs.evidence-directory }}/provider-old + + - name: Assert older Agent-accepted session + uses: ./.github/actions/agent/assert-session + with: + container-name: ${{ steps.system.outputs.container-name }} + provider-instance-id: ${{ steps.provider-old.outputs.provider-instance-id }} + endpoint-generation: ${{ steps.provider-old.outputs.endpoint-generation }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-old/agent + + - name: Stop older generation + uses: ./.github/actions/provider/stop + with: + container-name: ${{ steps.system.outputs.container-name }} + unit-name: ${{ steps.provider-old.outputs.unit-name }} + descriptor-path: ${{ steps.provider-old.outputs.descriptor-path }} + socket-path: ${{ steps.provider-old.outputs.socket-path }} + event-log: ${{ steps.provider-old.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-old + - name: Create replacement fixture id: fixture-new uses: ./.github/actions/provider/create-fixture @@ -45,28 +86,15 @@ runs: state-name: replacement identity: stateforge.test.replacement - - name: Start older generation - id: provider-old - uses: ./.github/actions/provider/start - with: - python: ${{ steps.environment.outputs.python }} - fixture-path: ${{ steps.fixture-old.outputs.fixture-path }} - identity: stateforge.test.replacement - discovery-directory: ${{ steps.fixture-old.outputs.discovery-directory }} - state-directory: ${{ steps.fixture-old.outputs.state-directory }} - event-log: ${{ steps.fixture-old.outputs.event-log }} - evidence-directory: ${{ inputs.evidence-directory }}/provider-old - - name: Start replacement generation id: provider-new uses: ./.github/actions/provider/start with: - python: ${{ steps.environment.outputs.python }} + container-name: ${{ steps.system.outputs.container-name }} fixture-path: ${{ steps.fixture-new.outputs.fixture-path }} + instance-name: ${{ steps.fixture-new.outputs.instance-name }} + state-name: ${{ steps.fixture-new.outputs.state-name }} identity: stateforge.test.replacement - discovery-directory: ${{ steps.fixture-new.outputs.discovery-directory }} - state-directory: ${{ steps.fixture-new.outputs.state-directory }} - event-log: ${{ steps.fixture-new.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider-new - name: Assert stable identity and newer generation @@ -87,38 +115,22 @@ runs: exit 1 fi - - name: Assert replacement Manifest - uses: ./.github/actions/sfp/assert-manifest + - name: Assert replacement Agent-accepted session + uses: ./.github/actions/agent/assert-session with: - python: ${{ steps.environment.outputs.python }} - descriptor-path: ${{ steps.provider-new.outputs.descriptor-path }} - identity: stateforge.test.replacement - evidence-directory: ${{ inputs.evidence-directory }}/provider-new/sfp - - - name: Stop older generation without withdrawing replacement - uses: ./.github/actions/provider/stop - with: - pid: ${{ steps.provider-old.outputs.pid }} - descriptor-path: ${{ steps.provider-old.outputs.descriptor-path }} - socket-path: ${{ steps.provider-old.outputs.socket-path }} - event-log: ${{ steps.fixture-old.outputs.event-log }} - evidence-directory: ${{ inputs.evidence-directory }}/provider-old - descriptor-expectation: preserved - - - name: Reassert replacement after older shutdown - uses: ./.github/actions/sfp/assert-bootstrap - with: - python: ${{ steps.environment.outputs.python }} - descriptor-path: ${{ steps.provider-new.outputs.descriptor-path }} - evidence-directory: ${{ inputs.evidence-directory }}/provider-new/post-replacement + container-name: ${{ steps.system.outputs.container-name }} + provider-instance-id: ${{ steps.provider-new.outputs.provider-instance-id }} + endpoint-generation: ${{ steps.provider-new.outputs.endpoint-generation }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-new/agent - name: Stop replacement generation uses: ./.github/actions/provider/stop with: - pid: ${{ steps.provider-new.outputs.pid }} + container-name: ${{ steps.system.outputs.container-name }} + unit-name: ${{ steps.provider-new.outputs.unit-name }} descriptor-path: ${{ steps.provider-new.outputs.descriptor-path }} socket-path: ${{ steps.provider-new.outputs.socket-path }} - event-log: ${{ steps.fixture-new.outputs.event-log }} + event-log: ${{ steps.provider-new.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider-new - name: Preserve bounded evidence and clean up @@ -126,5 +138,6 @@ runs: uses: ./.github/actions/evidence/collect with: stage: same-instance-replacement + container-name: ${{ steps.system.outputs.container-name }} runtime-directory: ${{ inputs.runtime-directory }} evidence-directory: ${{ inputs.evidence-directory }} diff --git a/.github/actions/scenarios/resilience/crash-restart/action.yml b/.github/actions/scenarios/resilience/crash-restart/action.yml index 3a30efa..70d369a 100644 --- a/.github/actions/scenarios/resilience/crash-restart/action.yml +++ b/.github/actions/scenarios/resilience/crash-restart/action.yml @@ -1,7 +1,13 @@ name: Test Provider crash and restart -description: Verify a crashed installation can publish a newer valid generation. +description: Verify the Agent replaces a crashed installation with a newer generation. inputs: + agent-artifact-directory: + description: Directory containing the exact Agent RPM build. + required: true + agent-source-directory: + description: Exact Agent checkout providing its Fedora test image. + required: true provider-artifact-directory: description: Directory containing the exact Provider build. required: true @@ -19,29 +25,26 @@ runs: using: composite steps: - name: Install exact runtime artifacts - id: environment - uses: ./.github/actions/environment/install + id: system + uses: ./.github/actions/agent/install with: + agent-artifact-directory: ${{ inputs.agent-artifact-directory }} + agent-source-directory: ${{ inputs.agent-source-directory }} provider-artifact-directory: ${{ inputs.provider-artifact-directory }} protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} - environment-directory: ${{ inputs.runtime-directory }}/environment evidence-directory: ${{ inputs.evidence-directory }}/installation - - name: Create first fixture - id: fixture-old - uses: ./.github/actions/provider/create-fixture + - name: Start installed Agent + uses: ./.github/actions/agent/start with: - runtime-directory: ${{ inputs.runtime-directory }} - instance-name: provider-crashed - state-name: restart - identity: stateforge.test.restart + container-name: ${{ steps.system.outputs.container-name }} - - name: Create restart fixture - id: fixture-new + - name: Create first generation fixture + id: fixture-old uses: ./.github/actions/provider/create-fixture with: runtime-directory: ${{ inputs.runtime-directory }} - instance-name: provider-restarted + instance-name: provider-crashed state-name: restart identity: stateforge.test.restart @@ -49,32 +52,48 @@ runs: id: provider-old uses: ./.github/actions/provider/start with: - python: ${{ steps.environment.outputs.python }} + container-name: ${{ steps.system.outputs.container-name }} fixture-path: ${{ steps.fixture-old.outputs.fixture-path }} + instance-name: ${{ steps.fixture-old.outputs.instance-name }} + state-name: ${{ steps.fixture-old.outputs.state-name }} identity: stateforge.test.restart - discovery-directory: ${{ steps.fixture-old.outputs.discovery-directory }} - state-directory: ${{ steps.fixture-old.outputs.state-directory }} - event-log: ${{ steps.fixture-old.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider-crashed + - name: Assert first Agent-accepted session + uses: ./.github/actions/agent/assert-session + with: + container-name: ${{ steps.system.outputs.container-name }} + provider-instance-id: ${{ steps.provider-old.outputs.provider-instance-id }} + endpoint-generation: ${{ steps.provider-old.outputs.endpoint-generation }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-crashed/agent + - name: Crash first generation uses: ./.github/actions/provider/crash with: - pid: ${{ steps.provider-old.outputs.pid }} + container-name: ${{ steps.system.outputs.container-name }} + unit-name: ${{ steps.provider-old.outputs.unit-name }} descriptor-path: ${{ steps.provider-old.outputs.descriptor-path }} socket-path: ${{ steps.provider-old.outputs.socket-path }} evidence-directory: ${{ inputs.evidence-directory }}/provider-crashed + - name: Create restarted fixture + id: fixture-new + uses: ./.github/actions/provider/create-fixture + with: + runtime-directory: ${{ inputs.runtime-directory }} + instance-name: provider-restarted + state-name: restart + identity: stateforge.test.restart + - name: Start newer generation id: provider-new uses: ./.github/actions/provider/start with: - python: ${{ steps.environment.outputs.python }} + container-name: ${{ steps.system.outputs.container-name }} fixture-path: ${{ steps.fixture-new.outputs.fixture-path }} + instance-name: ${{ steps.fixture-new.outputs.instance-name }} + state-name: ${{ steps.fixture-new.outputs.state-name }} identity: stateforge.test.restart - discovery-directory: ${{ steps.fixture-new.outputs.discovery-directory }} - state-directory: ${{ steps.fixture-new.outputs.state-directory }} - event-log: ${{ steps.fixture-new.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider-restarted - name: Assert restart identity and generation @@ -95,21 +114,22 @@ runs: exit 1 fi - - name: Assert restarted Manifest - uses: ./.github/actions/sfp/assert-manifest + - name: Assert restarted Agent-accepted session + uses: ./.github/actions/agent/assert-session with: - python: ${{ steps.environment.outputs.python }} - descriptor-path: ${{ steps.provider-new.outputs.descriptor-path }} - identity: stateforge.test.restart - evidence-directory: ${{ inputs.evidence-directory }}/provider-restarted/sfp + container-name: ${{ steps.system.outputs.container-name }} + provider-instance-id: ${{ steps.provider-new.outputs.provider-instance-id }} + endpoint-generation: ${{ steps.provider-new.outputs.endpoint-generation }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-restarted/agent - name: Stop restarted generation uses: ./.github/actions/provider/stop with: - pid: ${{ steps.provider-new.outputs.pid }} + container-name: ${{ steps.system.outputs.container-name }} + unit-name: ${{ steps.provider-new.outputs.unit-name }} descriptor-path: ${{ steps.provider-new.outputs.descriptor-path }} socket-path: ${{ steps.provider-new.outputs.socket-path }} - event-log: ${{ steps.fixture-new.outputs.event-log }} + event-log: ${{ steps.provider-new.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider-restarted - name: Preserve bounded evidence and clean up @@ -117,5 +137,6 @@ runs: uses: ./.github/actions/evidence/collect with: stage: crash-restart + container-name: ${{ steps.system.outputs.container-name }} runtime-directory: ${{ inputs.runtime-directory }} evidence-directory: ${{ inputs.evidence-directory }} diff --git a/.github/actions/scenarios/smoke/normal-lifecycle/action.yml b/.github/actions/scenarios/smoke/normal-lifecycle/action.yml index 0e86eb5..7ba3df4 100644 --- a/.github/actions/scenarios/smoke/normal-lifecycle/action.yml +++ b/.github/actions/scenarios/smoke/normal-lifecycle/action.yml @@ -1,7 +1,13 @@ name: Test normal installed Provider lifecycle -description: Verify installation, Bootstrap, Manifest, shutdown, and cleanup. +description: Verify the real Agent accepts one Provider and observes shutdown. inputs: + agent-artifact-directory: + description: Directory containing the exact Agent RPM build. + required: true + agent-source-directory: + description: Exact Agent checkout providing its Fedora test image. + required: true provider-artifact-directory: description: Directory containing the exact Provider build. required: true @@ -19,14 +25,20 @@ runs: using: composite steps: - name: Install exact runtime artifacts - id: environment - uses: ./.github/actions/environment/install + id: system + uses: ./.github/actions/agent/install with: + agent-artifact-directory: ${{ inputs.agent-artifact-directory }} + agent-source-directory: ${{ inputs.agent-source-directory }} provider-artifact-directory: ${{ inputs.provider-artifact-directory }} protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} - environment-directory: ${{ inputs.runtime-directory }}/environment evidence-directory: ${{ inputs.evidence-directory }}/installation + - name: Start installed Agent + uses: ./.github/actions/agent/start + with: + container-name: ${{ steps.system.outputs.container-name }} + - name: Create Provider fixture id: fixture uses: ./.github/actions/provider/create-fixture @@ -39,36 +51,29 @@ runs: id: provider uses: ./.github/actions/provider/start with: - python: ${{ steps.environment.outputs.python }} + container-name: ${{ steps.system.outputs.container-name }} fixture-path: ${{ steps.fixture.outputs.fixture-path }} + instance-name: ${{ steps.fixture.outputs.instance-name }} + state-name: ${{ steps.fixture.outputs.state-name }} identity: stateforge.test.normal - discovery-directory: ${{ steps.fixture.outputs.discovery-directory }} - state-directory: ${{ steps.fixture.outputs.state-directory }} - event-log: ${{ steps.fixture.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider - - name: Assert Bootstrap - uses: ./.github/actions/sfp/assert-bootstrap + - name: Assert Agent-accepted session + uses: ./.github/actions/agent/assert-session with: - python: ${{ steps.environment.outputs.python }} - descriptor-path: ${{ steps.provider.outputs.descriptor-path }} - evidence-directory: ${{ inputs.evidence-directory }}/sfp - - - name: Assert Manifest - uses: ./.github/actions/sfp/assert-manifest - with: - python: ${{ steps.environment.outputs.python }} - descriptor-path: ${{ steps.provider.outputs.descriptor-path }} - identity: stateforge.test.normal - evidence-directory: ${{ inputs.evidence-directory }}/sfp + container-name: ${{ steps.system.outputs.container-name }} + provider-instance-id: ${{ steps.provider.outputs.provider-instance-id }} + endpoint-generation: ${{ steps.provider.outputs.endpoint-generation }} + evidence-directory: ${{ inputs.evidence-directory }}/agent - name: Stop Provider fixture uses: ./.github/actions/provider/stop with: - pid: ${{ steps.provider.outputs.pid }} + container-name: ${{ steps.system.outputs.container-name }} + unit-name: ${{ steps.provider.outputs.unit-name }} descriptor-path: ${{ steps.provider.outputs.descriptor-path }} socket-path: ${{ steps.provider.outputs.socket-path }} - event-log: ${{ steps.fixture.outputs.event-log }} + event-log: ${{ steps.provider.outputs.event-log }} evidence-directory: ${{ inputs.evidence-directory }}/provider - name: Preserve bounded evidence and clean up @@ -76,5 +81,6 @@ runs: uses: ./.github/actions/evidence/collect with: stage: normal-lifecycle + container-name: ${{ steps.system.outputs.container-name }} runtime-directory: ${{ inputs.runtime-directory }} evidence-directory: ${{ inputs.evidence-directory }} diff --git a/.github/actions/sfp/assert-bootstrap/action.yml b/.github/actions/sfp/assert-bootstrap/action.yml deleted file mode 100644 index 1306ce3..0000000 --- a/.github/actions/sfp/assert-bootstrap/action.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Assert SFP Bootstrap -description: Negotiate SFP 1.0 against one installed Provider endpoint. - -inputs: - python: - description: Isolated Python containing exact Provider and Protocol artifacts. - required: true - descriptor-path: - description: Provider endpoint descriptor. - required: true - evidence-directory: - description: Directory receiving Bootstrap evidence. - required: true - -runs: - using: composite - steps: - - name: Negotiate the installed endpoint - shell: bash - env: - DESCRIPTOR_PATH: ${{ inputs.descriptor-path }} - EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} - ISOLATED_PYTHON: ${{ inputs.python }} - PROBE: ${{ github.workspace }}/.github/actions/sfp/probe.py - run: | - set -euo pipefail - mkdir -p "${EVIDENCE_DIRECTORY}" - output="${GITHUB_WORKSPACE}/${EVIDENCE_DIRECTORY}/bootstrap.json" - cd "${RUNNER_TEMP}" - unset PYTHONPATH PYTHONHOME - "${ISOLATED_PYTHON}" -I "${PROBE}" bootstrap \ - --descriptor "${DESCRIPTOR_PATH}" \ - --output "${output}" diff --git a/.github/actions/sfp/assert-invalid-peer/action.yml b/.github/actions/sfp/assert-invalid-peer/action.yml deleted file mode 100644 index bb81668..0000000 --- a/.github/actions/sfp/assert-invalid-peer/action.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Assert invalid SFP peers are rejected -description: Reject mismatched identities and sessions, then prove recovery. - -inputs: - python: - description: Isolated Python containing exact Provider and Protocol artifacts. - required: true - descriptor-path: - description: Provider endpoint descriptor. - required: true - evidence-directory: - description: Directory receiving rejection evidence. - required: true - -runs: - using: composite - steps: - - name: Exercise invalid and valid peers - shell: bash - env: - DESCRIPTOR_PATH: ${{ inputs.descriptor-path }} - EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} - ISOLATED_PYTHON: ${{ inputs.python }} - PROBE: ${{ github.workspace }}/.github/actions/sfp/probe.py - run: | - set -euo pipefail - mkdir -p "${EVIDENCE_DIRECTORY}" - output="${GITHUB_WORKSPACE}/${EVIDENCE_DIRECTORY}/invalid-peer.json" - cd "${RUNNER_TEMP}" - unset PYTHONPATH PYTHONHOME - "${ISOLATED_PYTHON}" -I "${PROBE}" invalid-peer \ - --descriptor "${DESCRIPTOR_PATH}" \ - --output "${output}" diff --git a/.github/actions/sfp/assert-manifest/action.yml b/.github/actions/sfp/assert-manifest/action.yml deleted file mode 100644 index 5dd84d5..0000000 --- a/.github/actions/sfp/assert-manifest/action.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Assert SFP Provider Manifest -description: Read and validate the installed fixture's complete manifest. - -inputs: - python: - description: Isolated Python containing exact Provider and Protocol artifacts. - required: true - descriptor-path: - description: Provider endpoint descriptor. - required: true - identity: - description: Expected author-declared Provider identity. - required: true - evidence-directory: - description: Directory receiving Manifest evidence. - required: true - -runs: - using: composite - steps: - - name: Read the installed Provider manifest - shell: bash - env: - DESCRIPTOR_PATH: ${{ inputs.descriptor-path }} - EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} - EXPECTED_IDENTITY: ${{ inputs.identity }} - ISOLATED_PYTHON: ${{ inputs.python }} - PROBE: ${{ github.workspace }}/.github/actions/sfp/probe.py - run: | - set -euo pipefail - mkdir -p "${EVIDENCE_DIRECTORY}" - output="${GITHUB_WORKSPACE}/${EVIDENCE_DIRECTORY}/manifest.json" - cd "${RUNNER_TEMP}" - unset PYTHONPATH PYTHONHOME - "${ISOLATED_PYTHON}" -I "${PROBE}" manifest \ - --descriptor "${DESCRIPTOR_PATH}" \ - --identity "${EXPECTED_IDENTITY}" \ - --output "${output}" diff --git a/.github/actions/sfp/probe.py b/.github/actions/sfp/probe.py deleted file mode 100644 index c787c18..0000000 --- a/.github/actions/sfp/probe.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Exercise installed Bootstrap and Manifest services over a Unix socket.""" - -from __future__ import annotations - -import argparse -import asyncio -import json -from pathlib import Path -from typing import Any -from uuid import uuid4 - -import grpc -from google.protobuf.json_format import MessageToDict -from stateforge_protocol.sfp.bootstrap.v1 import ( - bootstrap_pb2, - bootstrap_pb2_grpc, -) -from stateforge_protocol.sfp.manifest.v1 import ( - manifest_pb2, - manifest_pb2_grpc, -) - - -def _descriptor(path: Path) -> dict[str, Any]: - document = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(document, dict): - raise ValueError("Provider descriptor must be an object") - transport = document.get("transport") - if not isinstance(transport, dict) or transport.get("type") != "unix": - raise ValueError("Provider descriptor must select a Unix transport") - return document - - -def _handshake_request( - descriptor: dict[str, Any], - *, - provider_instance_id: str | None = None, -) -> bootstrap_pb2.HandshakeRequest: - return bootstrap_pb2.HandshakeRequest( - agent_runtime=bootstrap_pb2.AgentRuntime( - runtime_id=str(uuid4()), - software_version="0.1.0-ci", - ), - expected_provider_instance_id=( - provider_instance_id or str(descriptor["provider_instance_id"]) - ), - expected_endpoint_generation=int(descriptor["endpoint_generation"]), - supported_sfp_versions=[ - bootstrap_pb2.ProtocolVersion(major=1, minor=0) - ], - ) - - -async def _handshake( - stub: bootstrap_pb2_grpc.BootstrapServiceStub, - descriptor: dict[str, Any], -) -> bootstrap_pb2.HandshakeResponse: - response = await stub.Handshake( - _handshake_request(descriptor), - timeout=5, - ) - if response.provider_instance_id != descriptor["provider_instance_id"]: - raise AssertionError( - "Handshake returned a different Provider identity" - ) - if response.endpoint_generation != descriptor["endpoint_generation"]: - raise AssertionError("Handshake returned a different generation") - if ( - response.selected_sfp_version.major, - response.selected_sfp_version.minor, - ) != (1, 0): - raise AssertionError("Handshake did not negotiate SFP 1.0") - if not response.session_id or not response.provider_runtime_id: - raise AssertionError("Handshake omitted runtime or session identity") - return response - - -async def _manifest( - stub: manifest_pb2_grpc.ManifestServiceStub, - handshake: bootstrap_pb2.HandshakeResponse, -) -> manifest_pb2.GetManifestResponse: - response = await stub.GetManifest( - manifest_pb2.GetManifestRequest( - expected_provider_instance_id=handshake.provider_instance_id, - expected_endpoint_generation=handshake.endpoint_generation, - supported_manifest_schema_versions=[1], - expected_session_id=handshake.session_id, - ), - timeout=5, - ) - if response.provider_instance_id != handshake.provider_instance_id: - raise AssertionError("Manifest returned a different Provider identity") - if response.endpoint_generation != handshake.endpoint_generation: - raise AssertionError("Manifest returned a different generation") - if response.session_id != handshake.session_id: - raise AssertionError("Manifest returned a different session") - if response.manifest_schema_version != 1: - raise AssertionError("Manifest did not select schema 1") - if not response.manifest_revision.startswith("sha256:"): - raise AssertionError("Manifest revision is not a SHA-256 identity") - return response - - -async def _run(arguments: argparse.Namespace) -> dict[str, Any]: - descriptor = _descriptor(arguments.descriptor) - socket_path = descriptor["transport"]["socket_path"] - channel = grpc.aio.insecure_channel(f"unix:{socket_path}") - try: - await asyncio.wait_for(channel.channel_ready(), timeout=5) - bootstrap = bootstrap_pb2_grpc.BootstrapServiceStub(channel) - manifest = manifest_pb2_grpc.ManifestServiceStub(channel) - - if arguments.command == "bootstrap": - response = await _handshake(bootstrap, descriptor) - return MessageToDict( - response, - always_print_fields_with_no_presence=True, - ) - - if arguments.command == "manifest": - handshake = await _handshake(bootstrap, descriptor) - response = await _manifest(manifest, handshake) - if response.manifest.provider_identity != arguments.identity: - raise AssertionError( - "Manifest Provider identity is unexpected" - ) - if "stateforge.test/echo@1" not in response.manifest.operations: - raise AssertionError("Manifest omitted the fixture operation") - if "stateforge.test/changed@1" not in response.manifest.triggers: - raise AssertionError("Manifest omitted the fixture trigger") - return MessageToDict( - response, - always_print_fields_with_no_presence=True, - ) - - wrong_identity = str(uuid4()) - try: - await bootstrap.Handshake( - _handshake_request( - descriptor, - provider_instance_id=wrong_identity, - ), - timeout=5, - ) - except grpc.aio.AioRpcError as error: - if error.code() is not grpc.StatusCode.FAILED_PRECONDITION: - raise AssertionError( - "Invalid Bootstrap peer returned an unexpected status" - ) from error - invalid_bootstrap = error.code().name - else: - raise AssertionError("Invalid Bootstrap peer was accepted") - - handshake = await _handshake(bootstrap, descriptor) - try: - await manifest.GetManifest( - manifest_pb2.GetManifestRequest( - expected_provider_instance_id=( - handshake.provider_instance_id - ), - expected_endpoint_generation=( - handshake.endpoint_generation - ), - supported_manifest_schema_versions=[1], - expected_session_id=str(uuid4()), - ), - timeout=5, - ) - except grpc.aio.AioRpcError as error: - if error.code() is not grpc.StatusCode.FAILED_PRECONDITION: - raise AssertionError( - "Invalid Manifest peer returned an unexpected status" - ) from error - invalid_manifest = error.code().name - else: - raise AssertionError("Invalid Manifest peer was accepted") - - valid_manifest = await _manifest(manifest, handshake) - return { - "invalid_bootstrap_status": invalid_bootstrap, - "invalid_manifest_status": invalid_manifest, - "valid_manifest_revision": valid_manifest.manifest_revision, - } - finally: - await channel.close() - - -def _arguments() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument( - "command", - choices=("bootstrap", "manifest", "invalid-peer"), - ) - parser.add_argument("--descriptor", type=Path, required=True) - parser.add_argument("--identity") - parser.add_argument("--output", type=Path, required=True) - return parser.parse_args() - - -def main() -> None: - """Run the selected probe and write deterministic JSON evidence.""" - arguments = _arguments() - if arguments.command == "manifest" and not arguments.identity: - raise SystemExit("--identity is required for manifest assertions") - result = asyncio.run(_run(arguments)) - arguments.output.parent.mkdir(parents=True, exist_ok=True) - arguments.output.write_text( - json.dumps(result, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - - -if __name__ == "__main__": - main() diff --git a/.github/workflows/provider-build-and-verify.yml b/.github/workflows/provider-build-and-verify.yml index c0b1a98..71aea12 100644 --- a/.github/workflows/provider-build-and-verify.yml +++ b/.github/workflows/provider-build-and-verify.yml @@ -4,7 +4,7 @@ on: workflow_call: inputs: artifact-name: - description: Name of the immutable Provider and Protocol bundle. + description: Name of the immutable Provider, Protocol, and Agent bundle. required: false type: string default: stateforge-provider-runtime-bundle @@ -13,6 +13,11 @@ on: required: false type: string default: mvp/implementation + agent-ref: + description: Agent branch, tag, or commit SHA. + required: false + type: string + default: mvp/implementation scenario-tier: description: Runtime scenario tier. required: false @@ -25,7 +30,7 @@ on: default: 14 outputs: artifact-name: - description: Exact tested Provider and Protocol bundle. + description: Exact tested Provider, Protocol, and Agent bundle. value: ${{ jobs.build.outputs.artifact-name }} provider-version: description: Version read from the tested Provider wheel. @@ -39,6 +44,12 @@ on: protocol-wheel-sha256: description: SHA-256 digest of the tested Protocol wheel. value: ${{ jobs.build.outputs.protocol-wheel-sha256 }} + agent-commit: + description: Resolved Agent commit used by every runtime scenario. + value: ${{ jobs.build.outputs.agent-commit }} + agent-rpm-sha256: + description: SHA-256 digest of the tested Agent RPM. + value: ${{ jobs.build.outputs.agent-rpm-sha256 }} permissions: contents: read @@ -57,6 +68,7 @@ jobs: uses: ./.github/workflows/provider-build.yml with: artifact-name: ${{ inputs.artifact-name }} + agent-ref: ${{ inputs.agent-ref }} protocol-ref: ${{ inputs.protocol-ref }} retention-days: ${{ inputs.retention-days }} @@ -75,6 +87,7 @@ jobs: uses: ./.github/workflows/provider-runtime-scenarios.yml with: artifact-name: ${{ needs.build.outputs.artifact-name }} + agent-commit: ${{ needs.build.outputs.agent-commit }} scenario-tier: ${{ inputs.scenario-tier }} evidence-prefix: stateforge-provider-runtime-${{ github.run_id }}-${{ github.run_attempt }} retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/provider-build.yml b/.github/workflows/provider-build.yml index 762f322..c114d5b 100644 --- a/.github/workflows/provider-build.yml +++ b/.github/workflows/provider-build.yml @@ -4,7 +4,7 @@ on: workflow_call: inputs: artifact-name: - description: Name of the Provider and Protocol artifact bundle. + description: Name of the Provider, Protocol, and Agent artifact bundle. required: false type: string default: stateforge-provider-runtime-bundle @@ -13,6 +13,11 @@ on: required: false type: string default: mvp/implementation + agent-ref: + description: Agent branch, tag, or commit SHA. + required: false + type: string + default: mvp/implementation retention-days: description: Number of days to retain distributions and build evidence. required: false @@ -20,7 +25,7 @@ on: default: 14 outputs: artifact-name: - description: Immutable Provider and Protocol distribution bundle. + description: Immutable Provider, Protocol, and Agent runtime bundle. value: ${{ jobs.build.outputs.artifact-name }} provider-version: description: Version read from the Provider wheel. @@ -34,27 +39,51 @@ on: protocol-wheel-sha256: description: SHA-256 digest of the Protocol wheel. value: ${{ jobs.build.outputs.protocol-wheel-sha256 }} + agent-commit: + description: Resolved Agent source commit. + value: ${{ jobs.build.outputs.agent-commit }} + agent-rpm-sha256: + description: SHA-256 digest of the Agent RPM. + value: ${{ jobs.build.outputs.agent-rpm-sha256 }} permissions: contents: read jobs: build: - name: Build Provider and Protocol once + name: Build Provider, Protocol, and Agent once runs-on: ubuntu-24.04 timeout-minutes: 25 outputs: artifact-name: ${{ inputs.artifact-name }} provider-version: ${{ steps.provider.outputs.version }} provider-wheel-sha256: ${{ steps.provider.outputs.wheel-sha256 }} - protocol-commit: ${{ steps.protocol.outputs.resolved-commit }} + protocol-commit: ${{ steps.protocol-source.outputs.commit }} protocol-wheel-sha256: ${{ steps.protocol.outputs.wheel-sha256 }} + agent-commit: ${{ steps.agent-source.outputs.commit }} + agent-rpm-sha256: ${{ steps.agent.outputs.rpm-sha256 }} steps: - name: Check out Provider source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Check out Protocol source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: stateforge-org/stateforge-protocol + ref: ${{ inputs.protocol-ref }} + path: .stateforge-ci/protocol + persist-credentials: false + + - name: Check out Agent source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: stateforge-org/stateforge-agent + ref: ${{ inputs.agent-ref }} + path: .stateforge-ci/agent + persist-credentials: false + - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -72,18 +101,55 @@ jobs: - name: Build pinned Protocol distributions id: protocol - uses: ./.github/actions/protocol/build + uses: ./.stateforge-ci/protocol/.github/actions/python/build with: - ref: ${{ inputs.protocol-ref }} + output-directory: dist/protocol + evidence-directory: evidence/build/protocol + + - name: Record resolved Protocol source + id: protocol-source + shell: bash + env: + PROTOCOL_REF: ${{ inputs.protocol-ref }} + run: | + set -euo pipefail + commit="$(git -C .stateforge-ci/protocol rev-parse --verify HEAD)" + { + echo "commit=${commit}" + echo "requested-ref=${PROTOCOL_REF}" + } >> "${GITHUB_OUTPUT}" + + - name: Record resolved Agent source + id: agent-source + shell: bash + env: + AGENT_REF: ${{ inputs.agent-ref }} + run: | + set -euo pipefail + commit="$(git -C .stateforge-ci/agent rev-parse --verify HEAD)" + { + echo "commit=${commit}" + echo "requested-ref=${AGENT_REF}" + } >> "${GITHUB_OUTPUT}" + + - name: Build pinned Agent RPM + id: agent + uses: ./.stateforge-ci/agent/.github/actions/rpm/build + with: + output-directory: dist/agent - name: Record bundle identity shell: bash env: - PROTOCOL_COMMIT: ${{ steps.protocol.outputs.resolved-commit }} + PROTOCOL_COMMIT: ${{ steps.protocol-source.outputs.commit }} PROTOCOL_REF: ${{ inputs.protocol-ref }} PROTOCOL_WHEEL_SHA256: ${{ steps.protocol.outputs.wheel-sha256 }} PROVIDER_VERSION: ${{ steps.provider.outputs.version }} PROVIDER_WHEEL_SHA256: ${{ steps.provider.outputs.wheel-sha256 }} + AGENT_COMMIT: ${{ steps.agent-source.outputs.commit }} + AGENT_REF: ${{ inputs.agent-ref }} + AGENT_RPM_SHA256: ${{ steps.agent.outputs.rpm-sha256 }} + AGENT_VERSION: ${{ steps.agent.outputs.version }} run: | printf '%s\n' \ "provider-version=${PROVIDER_VERSION}" \ @@ -91,6 +157,10 @@ jobs: "protocol-requested-ref=${PROTOCOL_REF}" \ "protocol-resolved-commit=${PROTOCOL_COMMIT}" \ "protocol-wheel-sha256=${PROTOCOL_WHEEL_SHA256}" \ + "agent-requested-ref=${AGENT_REF}" \ + "agent-resolved-commit=${AGENT_COMMIT}" \ + "agent-version=${AGENT_VERSION}" \ + "agent-rpm-sha256=${AGENT_RPM_SHA256}" \ > dist/BUNDLE_PROVENANCE - name: Upload immutable runtime bundle @@ -98,6 +168,7 @@ jobs: with: name: ${{ inputs.artifact-name }} path: | + dist/agent dist/provider dist/protocol dist/BUNDLE_PROVENANCE diff --git a/.github/workflows/provider-ci.yml b/.github/workflows/provider-ci.yml index 239008b..0bafd52 100644 --- a/.github/workflows/provider-ci.yml +++ b/.github/workflows/provider-ci.yml @@ -20,6 +20,7 @@ jobs: uses: ./.github/workflows/provider-build-and-verify.yml with: artifact-name: stateforge-provider-pr-${{ github.run_id }}-${{ github.run_attempt }} + agent-ref: mvp/implementation protocol-ref: mvp/implementation scenario-tier: full retention-days: 7 diff --git a/.github/workflows/provider-integration.yml b/.github/workflows/provider-integration.yml index 9be7fdb..23dde23 100644 --- a/.github/workflows/provider-integration.yml +++ b/.github/workflows/provider-integration.yml @@ -7,6 +7,11 @@ on: - mvp/implementation workflow_dispatch: inputs: + agent-ref: + description: Agent branch, tag, or commit SHA. + required: false + type: string + default: mvp/implementation protocol-ref: description: Protocol branch, tag, or commit SHA. required: false @@ -26,6 +31,7 @@ jobs: uses: ./.github/workflows/provider-build-and-verify.yml with: artifact-name: stateforge-provider-integration-${{ github.run_id }}-${{ github.run_attempt }} + agent-ref: ${{ inputs.agent-ref || 'mvp/implementation' }} protocol-ref: ${{ inputs.protocol-ref || 'mvp/implementation' }} scenario-tier: full retention-days: 14 diff --git a/.github/workflows/provider-release-candidate.yml b/.github/workflows/provider-release-candidate.yml index 460e0ed..edce90c 100644 --- a/.github/workflows/provider-release-candidate.yml +++ b/.github/workflows/provider-release-candidate.yml @@ -3,6 +3,11 @@ name: Provider release candidate on: workflow_dispatch: inputs: + agent-ref: + description: Agent branch, tag, or commit SHA to test. + required: false + type: string + default: mvp/implementation protocol-ref: description: Protocol branch, tag, or commit SHA to test. required: false @@ -35,6 +40,7 @@ jobs: uses: ./.github/workflows/provider-build-and-verify.yml with: artifact-name: stateforge-provider-candidate-${{ github.run_id }}-${{ github.run_attempt }} + agent-ref: ${{ inputs.agent-ref }} protocol-ref: ${{ inputs.protocol-ref }} scenario-tier: ${{ inputs.scenario-tier }} retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/provider-runtime-scenarios.yml b/.github/workflows/provider-runtime-scenarios.yml index 0d8cf75..528dd46 100644 --- a/.github/workflows/provider-runtime-scenarios.yml +++ b/.github/workflows/provider-runtime-scenarios.yml @@ -7,6 +7,10 @@ on: description: Runtime bundle produced by provider-build.yml. required: true type: string + agent-commit: + description: Exact Agent source commit matching the bundled RPM. + required: true + type: string scenario-tier: description: Run smoke coverage or the complete scenario catalog. required: false @@ -28,32 +32,22 @@ permissions: jobs: smoke: - name: ${{ matrix.scenario }} / Python ${{ matrix.python }} + name: Normal lifecycle / installed Agent RPM runs-on: ubuntu-24.04 - timeout-minutes: 25 - strategy: - fail-fast: false - max-parallel: 4 - matrix: - include: - - scenario: normal-lifecycle - python: "3.11" - - scenario: normal-lifecycle - python: "3.12" - - scenario: normal-lifecycle - python: "3.13" + timeout-minutes: 35 steps: - name: Check out scenario automation uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - name: Check out exact Agent test image + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - python-version: ${{ matrix.python }} - cache: pip - cache-dependency-path: pyproject.toml + repository: stateforge-org/stateforge-agent + ref: ${{ inputs.agent-commit }} + path: .stateforge-ci/agent + persist-credentials: false - name: Download exact runtime bundle uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -64,25 +58,27 @@ jobs: - name: Run normal-lifecycle scenario uses: ./.github/actions/scenarios/smoke/normal-lifecycle with: + agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent + agent-source-directory: ${{ github.workspace }}/.stateforge-ci/agent provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol runtime-directory: ${{ runner.temp }}/sfp - evidence-directory: evidence/runtime/${{ matrix.scenario }} + evidence-directory: evidence/runtime/normal-lifecycle - name: Upload runtime evidence if: ${{ always() }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: ${{ inputs.evidence-prefix }}-${{ matrix.scenario }}-py${{ matrix.python }} - path: evidence/runtime/${{ matrix.scenario }} + name: ${{ inputs.evidence-prefix }}-normal-lifecycle + path: evidence/runtime/normal-lifecycle if-no-files-found: warn retention-days: ${{ inputs.retention-days }} full: - name: ${{ matrix.scenario }} / Python 3.13 + name: ${{ matrix.scenario }} / installed Agent RPM if: ${{ inputs.scenario-tier == 'full' }} runs-on: ubuntu-24.04 - timeout-minutes: 25 + timeout-minutes: 35 strategy: fail-fast: false max-parallel: 4 @@ -99,12 +95,13 @@ jobs: with: persist-credentials: false - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - name: Check out exact Agent test image + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - python-version: "3.13" - cache: pip - cache-dependency-path: pyproject.toml + repository: stateforge-org/stateforge-agent + ref: ${{ inputs.agent-commit }} + path: .stateforge-ci/agent + persist-credentials: false - name: Download exact runtime bundle uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -116,6 +113,8 @@ jobs: if: ${{ matrix.scenario == 'concurrent-providers' }} uses: ./.github/actions/scenarios/lifecycle/concurrent-providers with: + agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent + agent-source-directory: ${{ github.workspace }}/.stateforge-ci/agent provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol runtime-directory: ${{ runner.temp }}/sfp @@ -125,6 +124,8 @@ jobs: if: ${{ matrix.scenario == 'same-instance-replacement' }} uses: ./.github/actions/scenarios/lifecycle/same-instance-replacement with: + agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent + agent-source-directory: ${{ github.workspace }}/.stateforge-ci/agent provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol runtime-directory: ${{ runner.temp }}/sfp @@ -134,6 +135,8 @@ jobs: if: ${{ matrix.scenario == 'invalid-peer' }} uses: ./.github/actions/scenarios/adversarial/invalid-peer with: + agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent + agent-source-directory: ${{ github.workspace }}/.stateforge-ci/agent provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol runtime-directory: ${{ runner.temp }}/sfp @@ -143,6 +146,8 @@ jobs: if: ${{ matrix.scenario == 'crash-restart' }} uses: ./.github/actions/scenarios/resilience/crash-restart with: + agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent + agent-source-directory: ${{ github.workspace }}/.stateforge-ci/agent provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol runtime-directory: ${{ runner.temp }}/sfp @@ -152,6 +157,8 @@ jobs: if: ${{ matrix.scenario == 'uninstall-reinstall' }} uses: ./.github/actions/scenarios/destructive/uninstall-reinstall with: + agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent + agent-source-directory: ${{ github.workspace }}/.stateforge-ci/agent provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol runtime-directory: ${{ runner.temp }}/sfp @@ -161,7 +168,7 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: ${{ inputs.evidence-prefix }}-${{ matrix.scenario }}-py3.13 + name: ${{ inputs.evidence-prefix }}-${{ matrix.scenario }} path: evidence/runtime/${{ matrix.scenario }} if-no-files-found: warn retention-days: ${{ inputs.retention-days }} diff --git a/.gitignore b/.gitignore index e80b8ac..801f3cf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ __pycache__/ # Distribution / packaging .Python build/ +!.github/actions/package/build/ +!.github/actions/package/build/** develop-eggs/ dist/ downloads/ diff --git a/README.md b/README.md index cd288d9..d4d20c4 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,9 @@ Validate with Ruff, strict mypy, pytest, `python -m build`, and `mkdocs build --strict`. The repository also provides reusable package and installed-runtime pipelines. -They build the Provider and a selected Protocol revision once, install those -exact artifacts outside both checkouts, and exercise public-API Provider -fixtures over real Bootstrap and Manifest RPCs. See the +They build the Provider, a selected Protocol revision, and a selected Agent RPM +once; install those exact artifacts outside all source checkouts; start the +packaged Agent service; and exercise public-API Provider fixtures through the +real Agent. See the [CI/CD automation guide](docs/ci-automation.md) for the action catalog, local parity commands, scenario tiers, inputs, and retained evidence. diff --git a/docs/ci-automation.md b/docs/ci-automation.md index 9519031..311b93a 100644 --- a/docs/ci-automation.md +++ b/docs/ci-automation.md @@ -10,8 +10,9 @@ Bootstrap, or Manifest behavior. The automation has three layers: 1. Focused composite actions in `.github/actions/` own one repeated operation: - package construction, isolated installation, fixture staging, process - lifecycle, SFP assertions, or evidence collection. + package construction, isolated installation, Agent service lifecycle, + Provider fixture lifecycle, Agent-observed assertions, or evidence + collection. 2. Each directory under `.github/actions/scenarios/` is one independently selectable runtime scenario. A scenario composes the focused actions and always collects evidence and removes its temporary state. @@ -19,16 +20,20 @@ The automation has three layers: artifact transfer, and retention. The pull-request, integration, and release-candidate workflows are thin callers. -The build job produces one bundle containing: +The build job checks out the selected Protocol and Agent revisions, invokes +each repository's canonical portable builder, and produces one bundle +containing: - the Provider wheel and source distribution; - the Protocol wheel and source distribution built from `protocol-ref`; -- SHA-256 checksum files; -- the requested Protocol ref and its resolved commit; and -- Provider, Protocol, Python, runner, and source provenance. +- the Agent binary and source RPMs built from `agent-ref`; +- each component's SHA-256 checksum and provenance files; +- the requested Agent and Protocol refs and their resolved commits; and +- the combined Provider, Protocol, Agent, Python, runner, and source + provenance. Every installed-package and runtime job downloads that same bundle. No test job -rebuilds either distribution. +rebuilds a distribution or RPM. ## Local parity @@ -50,6 +55,27 @@ action: python -m build --sdist --wheel --outdir dist/provider ``` +The Provider-owned build action is location-independent. Another StateForge +repository can check out an exact Provider revision below its own workspace +and invoke the action from that checkout: + +```yaml +- uses: actions/checkout@ + with: + repository: stateforge-org/stateforge-provider-python + ref: + path: .stateforge-ci/provider + persist-credentials: false + +- uses: ./.stateforge-ci/provider/.github/actions/package/build + with: + output-directory: dist/provider + evidence-directory: evidence/provider-build +``` + +The action locates and builds the repository snapshot that contains it. It +does not assume that the caller's workspace root is Provider source. + To reproduce an installed-package check, create a clean environment outside the repository and install an explicitly selected Protocol wheel together with the built Provider wheel: @@ -75,36 +101,44 @@ inputs are: | Input | Default | Meaning | | --- | --- | --- | +| `agent-ref` | `mvp/implementation` | Agent branch, tag, or commit to resolve and package as an RPM | | `protocol-ref` | `mvp/implementation` | Protocol branch, tag, or commit to resolve and package | -| `scenario-tier` | `full` | `smoke` runs normal lifecycle on every supported Python; `full` adds the complete catalog | +| `scenario-tier` | `full` | `smoke` runs the normal installed-service lifecycle; `full` adds the complete catalog | | `retention-days` | `14` | Retention for distributions and bounded evidence | Release-candidate runs may use the convenient default branch during -development, but should pass an immutable Protocol commit SHA when selecting a -candidate. +development, but should pass immutable Agent and Protocol commit SHAs when +selecting a candidate. ## Runtime scenarios The runtime suite currently covers: -- normal Bootstrap, Manifest, graceful shutdown, and cleanup; +- normal startup, Agent-observed Bootstrap and Manifest acceptance, graceful + shutdown, and cleanup; - two distinct Provider installations running concurrently; - same-installation replacement with a stable identity and newer generation; -- rejection of invalid Bootstrap identity and Manifest session data; +- rejection of malformed Provider discovery data followed by valid recovery; - abrupt process failure followed by a valid newer generation; and - uninstall and reinstall of the exact Provider wheel. -The fixtures import only the public `stateforge_provider` API. Runtime scenarios -run on Linux because the current endpoint contract uses Unix-domain sockets. -Package identity and import checks run independently on Linux, macOS, and -Windows for Python 3.11, 3.12, and 3.13. +The fixtures import only the public `stateforge_provider` API. Each runtime +scenario installs the exact Agent RPM into an isolated Fedora system, starts +the packaged `stateforge-agent.service`, installs the exact Provider and +Protocol wheels beside it, and observes acceptance or rejection through the +real Agent journal. Runtime scenarios run on Linux because the current endpoint +contract uses Unix-domain sockets and the Agent is packaged as a Linux service. +Package identity and import checks remain independent and run on Linux, macOS, +and Windows for Python 3.11, 3.12, and 3.13. ## Evidence and security Build provenance and checksums are retained with the immutable artifact bundle. -Each scenario retains bounded package-install logs, descriptors, SFP responses, -lifecycle events, runtime inventory, and failure logs. Temporary runtime state -is removed even when an assertion fails. +Each scenario retains bounded RPM and wheel installation logs, Agent and +Provider journals, descriptors, lifecycle events, runtime inventory, and +failure diagnostics. It stops transient services, verifies and removes the +Agent RPM, destroys the Fedora system, and deletes temporary runtime state even +when an assertion fails. External actions are pinned to complete commit SHAs. Workflows have read-only repository permissions, do not persist checkout credentials, use bounded job diff --git a/tests/test_ci_automation.py b/tests/test_ci_automation.py index 4a6c2ab..8cc5113 100644 --- a/tests/test_ci_automation.py +++ b/tests/test_ci_automation.py @@ -19,11 +19,14 @@ def read(path: Path) -> str: def test_action_library_has_focused_domain_boundaries() -> None: """Keep repeated operations and scenarios independently reusable.""" expected = { + "agent/assert-invalid-descriptor", + "agent/assert-session", + "agent/install", + "agent/start", "environment/install", "evidence/collect", "package/build", "package/verify-installed", - "protocol/build", "provider/crash", "provider/create-fixture", "provider/start", @@ -34,9 +37,6 @@ def test_action_library_has_focused_domain_boundaries() -> None: "scenarios/lifecycle/same-instance-replacement", "scenarios/resilience/crash-restart", "scenarios/smoke/normal-lifecycle", - "sfp/assert-bootstrap", - "sfp/assert-invalid-peer", - "sfp/assert-manifest", } observed = { str(path.parent.relative_to(ACTIONS)) @@ -46,18 +46,22 @@ def test_action_library_has_focused_domain_boundaries() -> None: def test_build_is_single_and_provenance_bound() -> None: - """Build each package once and pass one immutable bundle downstream.""" + """Use each repository's builder once and pass one bundle downstream.""" build = read(WORKFLOWS / "provider-build.yml") aggregate = read(WORKFLOWS / "provider-build-and-verify.yml") provider = read(ACTIONS / "package" / "build" / "action.yml") - protocol = read(ACTIONS / "protocol" / "build" / "action.yml") assert "python -m build" in provider - assert "python -m build" in protocol assert build.count("./.github/actions/package/build") == 1 - assert build.count("./.github/actions/protocol/build") == 1 + assert ( + build.count("./.stateforge-ci/protocol/.github/actions/python/build") + == 1 + ) + assert build.count("./.stateforge-ci/agent/.github/actions/rpm/build") == 1 assert "dist/BUNDLE_PROVENANCE" in build assert "protocol-resolved-commit=" in build + assert "agent-resolved-commit=" in build + assert "dist/agent" in build assert "needs: build" in aggregate assert ( aggregate.count("needs.build.outputs.artifact-name") @@ -65,20 +69,44 @@ def test_build_is_single_and_provenance_bound() -> None: ) +def test_provider_builder_is_location_independent() -> None: + """Allow other repositories to invoke the canonical Provider builder.""" + action = read(ACTIONS / "package" / "build" / "action.yml") + + assert "ACTION_PATH: ${{ github.action_path }}" in action + assert "${ACTION_PATH}/../../../.." in action + assert 'git -C "${source_directory}" rev-parse --verify HEAD' in action + assert '"${source_directory}"' in action + assert "${GITHUB_SHA" not in action + + def test_protocol_ref_is_configurable_and_resolved() -> None: """Support convenient refs while recording an immutable Protocol commit.""" build = read(WORKFLOWS / "provider-build.yml") aggregate = read(WORKFLOWS / "provider-build-and-verify.yml") release = read(WORKFLOWS / "provider-release-candidate.yml") - action = read(ACTIONS / "protocol" / "build" / "action.yml") for content in (build, aggregate, release): assert "protocol-ref:" in content assert "default: mvp/implementation" in content - assert 'git -C "${PROTOCOL_SOURCE}" rev-parse --verify HEAD' in action - assert "requested-ref=${PROTOCOL_REF}" in action - assert "resolved-commit=${resolved_commit}" in action - assert "PROTOCOL_SHA256SUMS" in action + assert "repository: stateforge-org/stateforge-protocol" in build + assert "git -C .stateforge-ci/protocol rev-parse --verify HEAD" in build + assert "protocol-requested-ref=${PROTOCOL_REF}" in build + + +def test_agent_ref_is_configurable_and_resolved() -> None: + """Build the exact selected Agent through its portable RPM action.""" + build = read(WORKFLOWS / "provider-build.yml") + aggregate = read(WORKFLOWS / "provider-build-and-verify.yml") + release = read(WORKFLOWS / "provider-release-candidate.yml") + + for content in (build, aggregate, release): + assert "agent-ref:" in content + assert "default: mvp/implementation" in content + assert "repository: stateforge-org/stateforge-agent" in build + assert "git -C .stateforge-ci/agent rev-parse --verify HEAD" in build + assert "agent-requested-ref=${AGENT_REF}" in build + assert "agent-rpm-sha256=${AGENT_RPM_SHA256}" in build def test_installed_checks_cannot_use_source_imports() -> None: @@ -108,13 +136,20 @@ def test_fixture_uses_only_the_public_provider_api() -> None: assert "on_shutdown" in fixture -def test_provider_start_waits_for_a_new_generation() -> None: - """Do not confuse an existing stable descriptor with new publication.""" +def test_provider_start_uses_installed_runtime_in_agent_system() -> None: + """Run public fixtures beside the RPM-installed Agent.""" action = read(ACTIONS / "provider" / "start" / "action.yml") + assert 'docker exec "${CONTAINER_NAME}" systemd-run' in action + assert "/usr/bin/python3" in action + assert "/run/stateforge-agent/providers" in action + assert "import stateforge_provider" not in action assert "previous_generation=0" in action - assert ".last_endpoint_generation // 0" in action assert "generation > previous_generation" in action + assert ( + 'log_directory="/var/log/stateforge-provider-tests/${INSTANCE_NAME}"' + in action + ) def test_each_runtime_scenario_is_an_independent_action() -> None: @@ -124,7 +159,8 @@ def test_each_runtime_scenario_is_an_independent_action() -> None: assert len(scenarios) == SCENARIO_COUNT for scenario in scenarios: content = read(scenario) - assert "./.github/actions/environment/install" in content + assert "./.github/actions/agent/install" in content + assert "./.github/actions/agent/start" in content assert "./.github/actions/provider/create-fixture" in content assert "./.github/actions/evidence/collect" in content assert "if: ${{ always() }}" in content @@ -143,13 +179,38 @@ def test_runtime_catalog_covers_required_behavior() -> None: } for scenario in scenarios: assert scenario in workflow - assert 'python: "3.11"' in workflow - assert 'python: "3.12"' in workflow - assert 'python-version: "3.13"' in workflow assert "if: ${{ inputs.scenario-tier == 'full' }}" in workflow assert "runtime-directory: ${{ runner.temp }}/sfp" in workflow assert "stateforge-provider-${{ matrix.scenario }}" not in workflow assert "runs-on: ubuntu-24.04" in workflow + assert "agent-commit:" in workflow + + +def test_runtime_uses_real_agent_instead_of_synthetic_sfp_client() -> None: + """Exercise Provider behavior through the installed Agent daemon.""" + install = read(ACTIONS / "agent" / "install" / "action.yml") + start = read(ACTIONS / "agent" / "start" / "action.yml") + session = read(ACTIONS / "agent" / "assert-session" / "action.yml") + + assert not (ACTIONS / "sfp" / "probe.py").exists() + assert "rpm -Uvh --nodeps" in install + assert "stateforge-agent.service" in start + assert "SFP session established:" in session + assert "Rejecting Provider manifest:" in session + + +def test_provider_reinstall_expands_the_wheel_inside_fedora() -> None: + """Do not leave a container-local wheel glob for the host shell.""" + action = read( + ACTIONS + / "scenarios" + / "destructive" + / "uninstall-reinstall" + / "action.yml" + ) + + assert 'docker exec "${CONTAINER_NAME}" bash -lc' in action + assert "/artifacts/provider/stateforge_provider-*.whl'" in action def test_package_matrix_is_separate_from_unix_runtime() -> None: @@ -224,4 +285,6 @@ def test_failure_evidence_and_cleanup_are_bounded() -> None: assert "runner-context.txt" in collector assert "runtime-inventory.txt" in collector assert 'rm -rf -- "${RUNTIME_DIRECTORY}"' in collector - assert "provider.pid" in collector + assert "agent-journal.txt" in collector + assert "provider-journal.txt" in collector + assert "rpm -e stateforge-agent" in collector From c79a4650edb3d5dba007b508310fa66eeff3a7a3 Mon Sep 17 00:00:00 2001 From: Noam Alum Date: Sun, 11 Oct 2026 14:47:10 +0300 Subject: [PATCH 3/5] fix(ci): install StateForge packages from runtime bundle --- .../workflows/provider-build-and-verify.yml | 25 +++++++++------ .github/workflows/provider-source-verify.yml | 31 +++++++++++++++++-- docs/ci-automation.md | 7 +++-- tests/test_ci_automation.py | 21 ++++++++++++- 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/.github/workflows/provider-build-and-verify.yml b/.github/workflows/provider-build-and-verify.yml index 71aea12..71b3d45 100644 --- a/.github/workflows/provider-build-and-verify.yml +++ b/.github/workflows/provider-build-and-verify.yml @@ -55,16 +55,8 @@ permissions: contents: read jobs: - source: - name: Verify source and automation - uses: ./.github/workflows/provider-source-verify.yml - with: - evidence-name: stateforge-provider-source-${{ github.run_id }}-${{ github.run_attempt }} - retention-days: ${{ inputs.retention-days }} - build: name: Build immutable runtime bundle - needs: source uses: ./.github/workflows/provider-build.yml with: artifact-name: ${{ inputs.artifact-name }} @@ -72,9 +64,20 @@ jobs: protocol-ref: ${{ inputs.protocol-ref }} retention-days: ${{ inputs.retention-days }} + source: + name: Verify source and automation + needs: build + uses: ./.github/workflows/provider-source-verify.yml + with: + artifact-name: ${{ needs.build.outputs.artifact-name }} + evidence-name: stateforge-provider-source-${{ github.run_id }}-${{ github.run_attempt }} + retention-days: ${{ inputs.retention-days }} + installed: name: Verify installed packages - needs: build + needs: + - build + - source uses: ./.github/workflows/provider-installed-test.yml with: artifact-name: ${{ needs.build.outputs.artifact-name }} @@ -83,7 +86,9 @@ jobs: runtime: name: Verify installed runtime - needs: build + needs: + - build + - source uses: ./.github/workflows/provider-runtime-scenarios.yml with: artifact-name: ${{ needs.build.outputs.artifact-name }} diff --git a/.github/workflows/provider-source-verify.yml b/.github/workflows/provider-source-verify.yml index c93e27f..48cbd46 100644 --- a/.github/workflows/provider-source-verify.yml +++ b/.github/workflows/provider-source-verify.yml @@ -3,6 +3,10 @@ name: Provider / Source verification on: workflow_call: inputs: + artifact-name: + description: Immutable runtime bundle produced by provider-build.yml. + required: true + type: string evidence-name: description: Name used to upload source-verification evidence. required: false @@ -35,14 +39,35 @@ jobs: cache: pip cache-dependency-path: pyproject.toml + - name: Download exact runtime bundle + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ inputs.artifact-name }} + path: ${{ runner.temp }}/stateforge-provider-bundle + - name: Install development dependencies shell: bash + env: + BUNDLE_DIRECTORY: ${{ runner.temp }}/stateforge-provider-bundle run: | + set -euo pipefail python -m pip install --disable-pip-version-check --upgrade pip + ( + cd "${BUNDLE_DIRECTORY}/protocol" + sha256sum --check SHA256SUMS + ) + ( + cd "${BUNDLE_DIRECTORY}/provider" + sha256sum --check SHA256SUMS + ) + python -m pip install --disable-pip-version-check \ + "${BUNDLE_DIRECTORY}"/protocol/stateforge_protocol-*.whl \ + "${BUNDLE_DIRECTORY}"/provider/stateforge_provider-*.whl python -m pip install --disable-pip-version-check \ --group dev \ - --group docs \ - . + --group docs + python -m pip freeze --all \ + > evidence-source-environment.txt - name: Install actionlint shell: bash @@ -84,6 +109,8 @@ jobs: python -m pytest python -m mkdocs build --strict } 2>&1 | tee evidence/source/quality.log + install -m 0644 evidence-source-environment.txt \ + evidence/source/environment.txt - name: Upload source-verification evidence if: ${{ always() }} diff --git a/docs/ci-automation.md b/docs/ci-automation.md index 311b93a..8437101 100644 --- a/docs/ci-automation.md +++ b/docs/ci-automation.md @@ -32,8 +32,11 @@ containing: - the combined Provider, Protocol, Agent, Python, runner, and source provenance. -Every installed-package and runtime job downloads that same bundle. No test job -rebuilds a distribution or RPM. +Every source-verification, installed-package, and runtime job downloads that +same bundle. The source gate installs its exact Provider and Protocol wheels +before running tests, then validates the working-tree source with Ruff, mypy, +pytest, and MkDocs. No verification job rebuilds a distribution or RPM or +resolves an unpublished StateForge package from PyPI. ## Local parity diff --git a/tests/test_ci_automation.py b/tests/test_ci_automation.py index 8cc5113..87bc6f0 100644 --- a/tests/test_ci_automation.py +++ b/tests/test_ci_automation.py @@ -8,7 +8,7 @@ SHA_LENGTH = 40 SUPPORTED_PYTHON_COUNT = 3 SCENARIO_COUNT = 6 -DOWNSTREAM_BUNDLE_CONSUMER_COUNT = 2 +DOWNSTREAM_BUNDLE_CONSUMER_COUNT = 3 def read(path: Path) -> str: @@ -124,6 +124,25 @@ def test_installed_checks_cannot_use_source_imports() -> None: assert "stateforge_provider.__all__" in verifier +def test_source_verification_uses_the_immutable_bundle() -> None: + """Do not resolve the unpublished Protocol dependency from PyPI.""" + source = read(WORKFLOWS / "provider-source-verify.yml") + aggregate = read(WORKFLOWS / "provider-build-and-verify.yml") + + assert "artifact-name:" in source + assert "actions/download-artifact@" in source + assert "stateforge_protocol-*.whl" in source + assert "stateforge_provider-*.whl" in source + assert "sha256sum --check SHA256SUMS" in source + assert "--group dev" in source + assert "--group docs" in source + assert " .\n" not in source + assert ( + "source:\n name: Verify source and automation\n needs: build" + in aggregate + ) + + def test_fixture_uses_only_the_public_provider_api() -> None: """Do not reproduce or reach into private runtime implementation.""" fixture = read(ACTIONS / "provider" / "create-fixture" / "fixture.py") From ad220da1f0398209c3f6338b19bba7e1cffbc459 Mon Sep 17 00:00:00 2001 From: Noam Alum Date: Sun, 11 Oct 2026 15:14:15 +0300 Subject: [PATCH 4/5] fix(ci): authenticate private dependency checkouts --- .../workflows/provider-build-and-verify.yml | 10 ++++++ .github/workflows/provider-build.yml | 21 ++++++++++++ .github/workflows/provider-ci.yml | 3 ++ .github/workflows/provider-integration.yml | 3 ++ .../workflows/provider-release-candidate.yml | 3 ++ docs/ci-automation.md | 9 +++++ tests/test_ci_automation.py | 33 +++++++++++++++++++ 7 files changed, 82 insertions(+) diff --git a/.github/workflows/provider-build-and-verify.yml b/.github/workflows/provider-build-and-verify.yml index 71b3d45..dcf732d 100644 --- a/.github/workflows/provider-build-and-verify.yml +++ b/.github/workflows/provider-build-and-verify.yml @@ -28,6 +28,13 @@ on: required: false type: number default: 14 + secrets: + source_app_id: + description: GitHub App ID with read access to dependency sources. + required: true + source_private_key: + description: Private key for the dependency-source GitHub App. + required: true outputs: artifact-name: description: Exact tested Provider, Protocol, and Agent bundle. @@ -63,6 +70,9 @@ jobs: agent-ref: ${{ inputs.agent-ref }} protocol-ref: ${{ inputs.protocol-ref }} retention-days: ${{ inputs.retention-days }} + secrets: + source_app_id: ${{ secrets.source_app_id }} + source_private_key: ${{ secrets.source_private_key }} source: name: Verify source and automation diff --git a/.github/workflows/provider-build.yml b/.github/workflows/provider-build.yml index c114d5b..ca7b067 100644 --- a/.github/workflows/provider-build.yml +++ b/.github/workflows/provider-build.yml @@ -23,6 +23,13 @@ on: required: false type: number default: 14 + secrets: + source_app_id: + description: GitHub App ID with read access to dependency sources. + required: true + source_private_key: + description: Private key for the dependency-source GitHub App. + required: true outputs: artifact-name: description: Immutable Provider, Protocol, and Agent runtime bundle. @@ -68,6 +75,18 @@ jobs: with: persist-credentials: false + - name: Mint dependency source token + id: source-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ secrets.source_app_id }} + private-key: ${{ secrets.source_private_key }} + owner: stateforge-org + repositories: | + stateforge-agent + stateforge-protocol + permission-contents: read + - name: Check out Protocol source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -75,6 +94,7 @@ jobs: ref: ${{ inputs.protocol-ref }} path: .stateforge-ci/protocol persist-credentials: false + token: ${{ steps.source-token.outputs.token }} - name: Check out Agent source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -83,6 +103,7 @@ jobs: ref: ${{ inputs.agent-ref }} path: .stateforge-ci/agent persist-credentials: false + token: ${{ steps.source-token.outputs.token }} - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/provider-ci.yml b/.github/workflows/provider-ci.yml index 0bafd52..f815a68 100644 --- a/.github/workflows/provider-ci.yml +++ b/.github/workflows/provider-ci.yml @@ -24,3 +24,6 @@ jobs: protocol-ref: mvp/implementation scenario-tier: full retention-days: 7 + secrets: + source_app_id: ${{ secrets.STATEFORGE_CI_APP_ID }} + source_private_key: ${{ secrets.STATEFORGE_CI_PRIVATE_KEY }} diff --git a/.github/workflows/provider-integration.yml b/.github/workflows/provider-integration.yml index 23dde23..e1ed089 100644 --- a/.github/workflows/provider-integration.yml +++ b/.github/workflows/provider-integration.yml @@ -35,3 +35,6 @@ jobs: protocol-ref: ${{ inputs.protocol-ref || 'mvp/implementation' }} scenario-tier: full retention-days: 14 + secrets: + source_app_id: ${{ secrets.STATEFORGE_CI_APP_ID }} + source_private_key: ${{ secrets.STATEFORGE_CI_PRIVATE_KEY }} diff --git a/.github/workflows/provider-release-candidate.yml b/.github/workflows/provider-release-candidate.yml index edce90c..e2611d2 100644 --- a/.github/workflows/provider-release-candidate.yml +++ b/.github/workflows/provider-release-candidate.yml @@ -44,3 +44,6 @@ jobs: protocol-ref: ${{ inputs.protocol-ref }} scenario-tier: ${{ inputs.scenario-tier }} retention-days: ${{ inputs.retention-days }} + secrets: + source_app_id: ${{ secrets.STATEFORGE_CI_APP_ID }} + source_private_key: ${{ secrets.STATEFORGE_CI_PRIVATE_KEY }} diff --git a/docs/ci-automation.md b/docs/ci-automation.md index 8437101..4076f7b 100644 --- a/docs/ci-automation.md +++ b/docs/ci-automation.md @@ -38,6 +38,15 @@ before running tests, then validates the working-tree source with Ruff, mypy, pytest, and MkDocs. No verification job rebuilds a distribution or RPM or resolves an unpublished StateForge package from PyPI. +Because the Agent and Protocol repositories are private, entry-point workflows +pass `STATEFORGE_CI_APP_ID` and `STATEFORGE_CI_PRIVATE_KEY` into the reusable +build workflow. That workflow mints one short-lived installation token scoped +to read-only contents access for only `stateforge-agent` and +`stateforge-protocol`. The token authenticates those two source checkouts and +is not forwarded to verification jobs, runtime containers, or Provider +processes. The StateForge CI GitHub App must be installed on both dependency +repositories. + ## Local parity Run the repository gates with the package metadata as their source of truth: diff --git a/tests/test_ci_automation.py b/tests/test_ci_automation.py index 87bc6f0..9866474 100644 --- a/tests/test_ci_automation.py +++ b/tests/test_ci_automation.py @@ -9,6 +9,7 @@ SUPPORTED_PYTHON_COUNT = 3 SCENARIO_COUNT = 6 DOWNSTREAM_BUNDLE_CONSUMER_COUNT = 3 +DEPENDENCY_CHECKOUT_COUNT = 2 def read(path: Path) -> str: @@ -109,6 +110,38 @@ def test_agent_ref_is_configurable_and_resolved() -> None: assert "agent-rpm-sha256=${AGENT_RPM_SHA256}" in build +def test_private_dependency_checkouts_use_read_only_app_token() -> None: + """Authenticate sibling checkouts without widening workflow permissions.""" + build = read(WORKFLOWS / "provider-build.yml") + aggregate = read(WORKFLOWS / "provider-build-and-verify.yml") + + assert "source_app_id:" in build + assert "source_private_key:" in build + assert build.count("actions/create-github-app-token@") == 1 + assert "owner: stateforge-org" in build + assert "stateforge-agent" in build + assert "stateforge-protocol" in build + assert "permission-contents: read" in build + assert ( + build.count("token: ${{ steps.source-token.outputs.token }}") + == DEPENDENCY_CHECKOUT_COUNT + ) + assert "source_app_id: ${{ secrets.source_app_id }}" in aggregate + assert "source_private_key: ${{ secrets.source_private_key }}" in aggregate + + for filename in ( + "provider-ci.yml", + "provider-integration.yml", + "provider-release-candidate.yml", + ): + workflow = read(WORKFLOWS / filename) + assert "source_app_id: ${{ secrets.STATEFORGE_CI_APP_ID }}" in workflow + assert ( + "source_private_key: " + "${{ secrets.STATEFORGE_CI_PRIVATE_KEY }}" in workflow + ) + + def test_installed_checks_cannot_use_source_imports() -> None: """Install exact wheels outside checkouts with Python isolation enabled.""" install = read(ACTIONS / "environment" / "install" / "action.yml") From 49be1ac2bf9a6436d3ba8d5177656c342bc1a692 Mon Sep 17 00:00:00 2001 From: Noam Alum Date: Sun, 11 Oct 2026 15:28:50 +0300 Subject: [PATCH 5/5] fix(ci): make installed verification self-contained --- .github/actions/agent/install/action.yml | 18 +++++++---- .../adversarial/invalid-peer/action.yml | 6 ++-- .../uninstall-reinstall/action.yml | 6 ++-- .../lifecycle/concurrent-providers/action.yml | 6 ++-- .../same-instance-replacement/action.yml | 6 ++-- .../resilience/crash-restart/action.yml | 6 ++-- .../smoke/normal-lifecycle/action.yml | 6 ++-- .../workflows/provider-build-and-verify.yml | 1 - .github/workflows/provider-build.yml | 13 ++++++++ .../workflows/provider-runtime-scenarios.yml | 32 ++++--------------- docs/ci-automation.md | 11 +++++++ src/stateforge_provider/_runtime/identity.py | 30 ++++++++++++++--- tests/test_ci_automation.py | 8 ++++- tests/test_identity.py | 24 ++++++++++++++ 14 files changed, 117 insertions(+), 56 deletions(-) diff --git a/.github/actions/agent/install/action.yml b/.github/actions/agent/install/action.yml index 7786009..1dfc2ec 100644 --- a/.github/actions/agent/install/action.yml +++ b/.github/actions/agent/install/action.yml @@ -5,8 +5,8 @@ inputs: agent-artifact-directory: description: Directory containing the exact Agent RPM build. required: true - agent-source-directory: - description: Exact Agent checkout providing its Fedora test image. + agent-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. required: true provider-artifact-directory: description: Directory containing the exact Provider wheel. @@ -39,7 +39,7 @@ runs: shell: bash env: AGENT_ARTIFACT_DIRECTORY: ${{ inputs.agent-artifact-directory }} - AGENT_SOURCE_DIRECTORY: ${{ inputs.agent-source-directory }} + AGENT_TEST_IMAGE_DIRECTORY: ${{ inputs.agent-test-image-directory }} CONTAINER_IMAGE: ${{ inputs.container-image }} CONTAINER_NAME: ${{ inputs.container-name }} EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} @@ -72,6 +72,10 @@ runs: cd "${AGENT_ARTIFACT_DIRECTORY}" sha256sum --check SHA256SUMS ) > "${EVIDENCE_DIRECTORY}/agent-checksums.txt" + ( + cd "${AGENT_TEST_IMAGE_DIRECTORY}" + sha256sum --check SHA256SUMS + ) > "${EVIDENCE_DIRECTORY}/agent-test-image-checksums.txt" ( cd "${PROVIDER_ARTIFACT_DIRECTORY}" sha256sum --check SHA256SUMS @@ -82,16 +86,18 @@ runs: ) > "${EVIDENCE_DIRECTORY}/protocol-checksums.txt" agent_artifacts="$(cd "${AGENT_ARTIFACT_DIRECTORY}" && pwd -P)" - agent_source="$(cd "${AGENT_SOURCE_DIRECTORY}" && pwd -P)" + agent_test_image="$( + cd "${AGENT_TEST_IMAGE_DIRECTORY}" && pwd -P + )" evidence="$(cd "${EVIDENCE_DIRECTORY}" && pwd -P)" provider_artifacts="$(cd "${PROVIDER_ARTIFACT_DIRECTORY}" && pwd -P)" protocol_artifacts="$(cd "${PROTOCOL_ARTIFACT_DIRECTORY}" && pwd -P)" agent_rpm="$(basename "${agent_rpms[0]}")" docker build \ - --file "${agent_source}/packaging/rpm/Dockerfile.test" \ + --file "${agent_test_image}/packaging/rpm/Dockerfile.test" \ --tag "${CONTAINER_IMAGE}" \ - "${agent_source}/packaging/rpm" + "${agent_test_image}/packaging/rpm" docker rm --force "${CONTAINER_NAME}" >/dev/null 2>&1 || true docker run --detach \ --name "${CONTAINER_NAME}" \ diff --git a/.github/actions/scenarios/adversarial/invalid-peer/action.yml b/.github/actions/scenarios/adversarial/invalid-peer/action.yml index e9586b0..73b5246 100644 --- a/.github/actions/scenarios/adversarial/invalid-peer/action.yml +++ b/.github/actions/scenarios/adversarial/invalid-peer/action.yml @@ -5,8 +5,8 @@ inputs: agent-artifact-directory: description: Directory containing the exact Agent RPM build. required: true - agent-source-directory: - description: Exact Agent checkout providing its Fedora test image. + agent-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. required: true provider-artifact-directory: description: Directory containing the exact Provider build. @@ -29,7 +29,7 @@ runs: uses: ./.github/actions/agent/install with: agent-artifact-directory: ${{ inputs.agent-artifact-directory }} - agent-source-directory: ${{ inputs.agent-source-directory }} + agent-test-image-directory: ${{ inputs.agent-test-image-directory }} provider-artifact-directory: ${{ inputs.provider-artifact-directory }} protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} evidence-directory: ${{ inputs.evidence-directory }}/installation diff --git a/.github/actions/scenarios/destructive/uninstall-reinstall/action.yml b/.github/actions/scenarios/destructive/uninstall-reinstall/action.yml index 04396d4..e7a0b39 100644 --- a/.github/actions/scenarios/destructive/uninstall-reinstall/action.yml +++ b/.github/actions/scenarios/destructive/uninstall-reinstall/action.yml @@ -5,8 +5,8 @@ inputs: agent-artifact-directory: description: Directory containing the exact Agent RPM build. required: true - agent-source-directory: - description: Exact Agent checkout providing its Fedora test image. + agent-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. required: true provider-artifact-directory: description: Directory containing the exact Provider build. @@ -29,7 +29,7 @@ runs: uses: ./.github/actions/agent/install with: agent-artifact-directory: ${{ inputs.agent-artifact-directory }} - agent-source-directory: ${{ inputs.agent-source-directory }} + agent-test-image-directory: ${{ inputs.agent-test-image-directory }} provider-artifact-directory: ${{ inputs.provider-artifact-directory }} protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} evidence-directory: ${{ inputs.evidence-directory }}/installation diff --git a/.github/actions/scenarios/lifecycle/concurrent-providers/action.yml b/.github/actions/scenarios/lifecycle/concurrent-providers/action.yml index 4dcff47..4b6be9f 100644 --- a/.github/actions/scenarios/lifecycle/concurrent-providers/action.yml +++ b/.github/actions/scenarios/lifecycle/concurrent-providers/action.yml @@ -5,8 +5,8 @@ inputs: agent-artifact-directory: description: Directory containing the exact Agent RPM build. required: true - agent-source-directory: - description: Exact Agent checkout providing its Fedora test image. + agent-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. required: true provider-artifact-directory: description: Directory containing the exact Provider build. @@ -29,7 +29,7 @@ runs: uses: ./.github/actions/agent/install with: agent-artifact-directory: ${{ inputs.agent-artifact-directory }} - agent-source-directory: ${{ inputs.agent-source-directory }} + agent-test-image-directory: ${{ inputs.agent-test-image-directory }} provider-artifact-directory: ${{ inputs.provider-artifact-directory }} protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} evidence-directory: ${{ inputs.evidence-directory }}/installation diff --git a/.github/actions/scenarios/lifecycle/same-instance-replacement/action.yml b/.github/actions/scenarios/lifecycle/same-instance-replacement/action.yml index 70fcb6a..fb766ce 100644 --- a/.github/actions/scenarios/lifecycle/same-instance-replacement/action.yml +++ b/.github/actions/scenarios/lifecycle/same-instance-replacement/action.yml @@ -5,8 +5,8 @@ inputs: agent-artifact-directory: description: Directory containing the exact Agent RPM build. required: true - agent-source-directory: - description: Exact Agent checkout providing its Fedora test image. + agent-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. required: true provider-artifact-directory: description: Directory containing the exact Provider build. @@ -29,7 +29,7 @@ runs: uses: ./.github/actions/agent/install with: agent-artifact-directory: ${{ inputs.agent-artifact-directory }} - agent-source-directory: ${{ inputs.agent-source-directory }} + agent-test-image-directory: ${{ inputs.agent-test-image-directory }} provider-artifact-directory: ${{ inputs.provider-artifact-directory }} protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} evidence-directory: ${{ inputs.evidence-directory }}/installation diff --git a/.github/actions/scenarios/resilience/crash-restart/action.yml b/.github/actions/scenarios/resilience/crash-restart/action.yml index 70d369a..f95419e 100644 --- a/.github/actions/scenarios/resilience/crash-restart/action.yml +++ b/.github/actions/scenarios/resilience/crash-restart/action.yml @@ -5,8 +5,8 @@ inputs: agent-artifact-directory: description: Directory containing the exact Agent RPM build. required: true - agent-source-directory: - description: Exact Agent checkout providing its Fedora test image. + agent-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. required: true provider-artifact-directory: description: Directory containing the exact Provider build. @@ -29,7 +29,7 @@ runs: uses: ./.github/actions/agent/install with: agent-artifact-directory: ${{ inputs.agent-artifact-directory }} - agent-source-directory: ${{ inputs.agent-source-directory }} + agent-test-image-directory: ${{ inputs.agent-test-image-directory }} provider-artifact-directory: ${{ inputs.provider-artifact-directory }} protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} evidence-directory: ${{ inputs.evidence-directory }}/installation diff --git a/.github/actions/scenarios/smoke/normal-lifecycle/action.yml b/.github/actions/scenarios/smoke/normal-lifecycle/action.yml index 7ba3df4..633ffd6 100644 --- a/.github/actions/scenarios/smoke/normal-lifecycle/action.yml +++ b/.github/actions/scenarios/smoke/normal-lifecycle/action.yml @@ -5,8 +5,8 @@ inputs: agent-artifact-directory: description: Directory containing the exact Agent RPM build. required: true - agent-source-directory: - description: Exact Agent checkout providing its Fedora test image. + agent-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. required: true provider-artifact-directory: description: Directory containing the exact Provider build. @@ -29,7 +29,7 @@ runs: uses: ./.github/actions/agent/install with: agent-artifact-directory: ${{ inputs.agent-artifact-directory }} - agent-source-directory: ${{ inputs.agent-source-directory }} + agent-test-image-directory: ${{ inputs.agent-test-image-directory }} provider-artifact-directory: ${{ inputs.provider-artifact-directory }} protocol-artifact-directory: ${{ inputs.protocol-artifact-directory }} evidence-directory: ${{ inputs.evidence-directory }}/installation diff --git a/.github/workflows/provider-build-and-verify.yml b/.github/workflows/provider-build-and-verify.yml index dcf732d..956660b 100644 --- a/.github/workflows/provider-build-and-verify.yml +++ b/.github/workflows/provider-build-and-verify.yml @@ -102,7 +102,6 @@ jobs: uses: ./.github/workflows/provider-runtime-scenarios.yml with: artifact-name: ${{ needs.build.outputs.artifact-name }} - agent-commit: ${{ needs.build.outputs.agent-commit }} scenario-tier: ${{ inputs.scenario-tier }} evidence-prefix: stateforge-provider-runtime-${{ github.run_id }}-${{ github.run_attempt }} retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/provider-build.yml b/.github/workflows/provider-build.yml index ca7b067..1adb605 100644 --- a/.github/workflows/provider-build.yml +++ b/.github/workflows/provider-build.yml @@ -159,6 +159,19 @@ jobs: with: output-directory: dist/agent + - name: Bundle exact Agent runtime-test image + shell: bash + run: | + set -euo pipefail + image_directory="dist/agent/test-image" + install -D -m 0644 \ + .stateforge-ci/agent/packaging/rpm/Dockerfile.test \ + "${image_directory}/packaging/rpm/Dockerfile.test" + ( + cd "${image_directory}" + sha256sum packaging/rpm/Dockerfile.test > SHA256SUMS + ) + - name: Record bundle identity shell: bash env: diff --git a/.github/workflows/provider-runtime-scenarios.yml b/.github/workflows/provider-runtime-scenarios.yml index 528dd46..d15d256 100644 --- a/.github/workflows/provider-runtime-scenarios.yml +++ b/.github/workflows/provider-runtime-scenarios.yml @@ -7,10 +7,6 @@ on: description: Runtime bundle produced by provider-build.yml. required: true type: string - agent-commit: - description: Exact Agent source commit matching the bundled RPM. - required: true - type: string scenario-tier: description: Run smoke coverage or the complete scenario catalog. required: false @@ -41,14 +37,6 @@ jobs: with: persist-credentials: false - - name: Check out exact Agent test image - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: stateforge-org/stateforge-agent - ref: ${{ inputs.agent-commit }} - path: .stateforge-ci/agent - persist-credentials: false - - name: Download exact runtime bundle uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: @@ -59,7 +47,7 @@ jobs: uses: ./.github/actions/scenarios/smoke/normal-lifecycle with: agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent - agent-source-directory: ${{ github.workspace }}/.stateforge-ci/agent + agent-test-image-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent/test-image provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol runtime-directory: ${{ runner.temp }}/sfp @@ -95,14 +83,6 @@ jobs: with: persist-credentials: false - - name: Check out exact Agent test image - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: stateforge-org/stateforge-agent - ref: ${{ inputs.agent-commit }} - path: .stateforge-ci/agent - persist-credentials: false - - name: Download exact runtime bundle uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: @@ -114,7 +94,7 @@ jobs: uses: ./.github/actions/scenarios/lifecycle/concurrent-providers with: agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent - agent-source-directory: ${{ github.workspace }}/.stateforge-ci/agent + agent-test-image-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent/test-image provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol runtime-directory: ${{ runner.temp }}/sfp @@ -125,7 +105,7 @@ jobs: uses: ./.github/actions/scenarios/lifecycle/same-instance-replacement with: agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent - agent-source-directory: ${{ github.workspace }}/.stateforge-ci/agent + agent-test-image-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent/test-image provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol runtime-directory: ${{ runner.temp }}/sfp @@ -136,7 +116,7 @@ jobs: uses: ./.github/actions/scenarios/adversarial/invalid-peer with: agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent - agent-source-directory: ${{ github.workspace }}/.stateforge-ci/agent + agent-test-image-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent/test-image provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol runtime-directory: ${{ runner.temp }}/sfp @@ -147,7 +127,7 @@ jobs: uses: ./.github/actions/scenarios/resilience/crash-restart with: agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent - agent-source-directory: ${{ github.workspace }}/.stateforge-ci/agent + agent-test-image-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent/test-image provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol runtime-directory: ${{ runner.temp }}/sfp @@ -158,7 +138,7 @@ jobs: uses: ./.github/actions/scenarios/destructive/uninstall-reinstall with: agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent - agent-source-directory: ${{ github.workspace }}/.stateforge-ci/agent + agent-test-image-directory: ${{ runner.temp }}/stateforge-provider-bundle/agent/test-image provider-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/provider protocol-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/protocol runtime-directory: ${{ runner.temp }}/sfp diff --git a/docs/ci-automation.md b/docs/ci-automation.md index 4076f7b..1c7f734 100644 --- a/docs/ci-automation.md +++ b/docs/ci-automation.md @@ -27,6 +27,7 @@ containing: - the Provider wheel and source distribution; - the Protocol wheel and source distribution built from `protocol-ref`; - the Agent binary and source RPMs built from `agent-ref`; +- the exact Agent-owned Fedora runtime-test image definition; - each component's SHA-256 checksum and provenance files; - the requested Agent and Protocol refs and their resolved commits; and - the combined Provider, Protocol, Agent, Python, runner, and source @@ -38,6 +39,11 @@ before running tests, then validates the working-tree source with Ruff, mypy, pytest, and MkDocs. No verification job rebuilds a distribution or RPM or resolves an unpublished StateForge package from PyPI. +The runtime jobs build their isolated Fedora system from the Agent test-image +definition carried in that bundle. They do not perform a second private Agent +checkout, so the RPM and its test environment stay bound to the same resolved +Agent commit and dependency credentials remain confined to the build job. + Because the Agent and Protocol repositories are private, entry-point workflows pass `STATEFORGE_CI_APP_ID` and `STATEFORGE_CI_PRIVATE_KEY` into the reusable build workflow. That workflow mints one short-lived installation token scoped @@ -157,3 +163,8 @@ repository permissions, do not persist checkout credentials, use bounded job timeouts, and do not pass credentials to Provider processes. `actionlint`, Zizmor, Ruff, mypy, pytest with coverage, and strict MkDocs are part of the aggregate gate. + +The installed-package matrix verifies that the distribution, metadata, and +public API can be imported on Linux, macOS, and Windows. Starting a Provider +runtime still requires POSIX advisory locking and Unix-domain sockets; an +unsupported platform receives an explicit runtime error when startup begins. diff --git a/src/stateforge_provider/_runtime/identity.py b/src/stateforge_provider/_runtime/identity.py index 99e5322..08a7378 100644 --- a/src/stateforge_provider/_runtime/identity.py +++ b/src/stateforge_provider/_runtime/identity.py @@ -2,7 +2,7 @@ from __future__ import annotations -import fcntl +import importlib import json import os import stat @@ -10,7 +10,7 @@ from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path -from typing import TypeGuard +from typing import Protocol, TypeGuard, cast from uuid import UUID, uuid4 from stateforge_provider._runtime.filesystem import ( @@ -38,6 +38,27 @@ class ProviderIdentityError(RuntimeError): """Report unusable or exhausted Provider identity state.""" +class _AdvisoryFileLocks(Protocol): + """Describe the POSIX file-locking surface used by the runtime.""" + + LOCK_EX: int + LOCK_UN: int + + def flock(self, descriptor: int, operation: int, /) -> None: + """Apply or release an advisory lock.""" + + +def _advisory_file_locks() -> _AdvisoryFileLocks: + """Load POSIX locking only when the Provider runtime starts.""" + try: + module = importlib.import_module("fcntl") + except ModuleNotFoundError as error: + raise ProviderIdentityError( + "Provider runtime requires POSIX advisory file locking" + ) from error + return cast(_AdvisoryFileLocks, module) + + def is_canonical_uuid4(value: str) -> bool: """Return whether a string is a canonical lowercase UUIDv4.""" try: @@ -161,6 +182,7 @@ def prepare(self) -> None: @contextmanager def locked(self) -> Iterator[None]: """Hold the installation lock across an identity-sensitive update.""" + file_locks = _advisory_file_locks() flags = os.O_RDWR | os.O_CREAT | os.O_CLOEXEC if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW @@ -188,7 +210,7 @@ def locked(self) -> Iterator[None]: f"Provider identity lock mode must be " f"{_STATE_FILE_MODE:#05o}: {self.lock_path}" ) - fcntl.flock(descriptor, fcntl.LOCK_EX) + file_locks.flock(descriptor, file_locks.LOCK_EX) except OSError as error: os.close(descriptor) raise ProviderIdentityError( @@ -202,7 +224,7 @@ def locked(self) -> Iterator[None]: try: yield finally: - fcntl.flock(descriptor, fcntl.LOCK_UN) + file_locks.flock(descriptor, file_locks.LOCK_UN) os.close(descriptor) # Keep persisted-state validation visible as one security review surface. diff --git a/tests/test_ci_automation.py b/tests/test_ci_automation.py index 9866474..f8df5db 100644 --- a/tests/test_ci_automation.py +++ b/tests/test_ci_automation.py @@ -59,6 +59,8 @@ def test_build_is_single_and_provenance_bound() -> None: == 1 ) assert build.count("./.stateforge-ci/agent/.github/actions/rpm/build") == 1 + assert ".stateforge-ci/agent/packaging/rpm/Dockerfile.test" in build + assert "dist/agent/test-image" in build assert "dist/BUNDLE_PROVENANCE" in build assert "protocol-resolved-commit=" in build assert "agent-resolved-commit=" in build @@ -235,7 +237,10 @@ def test_runtime_catalog_covers_required_behavior() -> None: assert "runtime-directory: ${{ runner.temp }}/sfp" in workflow assert "stateforge-provider-${{ matrix.scenario }}" not in workflow assert "runs-on: ubuntu-24.04" in workflow - assert "agent-commit:" in workflow + assert "repository: stateforge-org/stateforge-agent" not in workflow + assert "agent-commit:" not in workflow + assert "agent-test-image-directory:" in workflow + assert "/agent/test-image" in workflow def test_runtime_uses_real_agent_instead_of_synthetic_sfp_client() -> None: @@ -246,6 +251,7 @@ def test_runtime_uses_real_agent_instead_of_synthetic_sfp_client() -> None: assert not (ACTIONS / "sfp" / "probe.py").exists() assert "rpm -Uvh --nodeps" in install + assert "agent-test-image-checksums.txt" in install assert "stateforge-agent.service" in start assert "SFP session established:" in session assert "Rejecting Provider manifest:" in session diff --git a/tests/test_identity.py b/tests/test_identity.py index c64c449..6b4a705 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -7,6 +7,7 @@ import pytest +from stateforge_provider._runtime import identity as identity_module from stateforge_provider._runtime.identity import ( ProviderIdentityError, ProviderIdentityStore, @@ -122,3 +123,26 @@ def test_rejects_symbolic_link_state_directory(tmp_path: Path) -> None: with pytest.raises(ProviderIdentityError, match="symbolic link"): ProviderIdentityStore(linked_directory).allocate_endpoint() + + +def test_importable_package_reports_unavailable_posix_locking( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep public imports portable while rejecting unsupported runtime use.""" + + def unavailable(_name: str) -> None: + raise ModuleNotFoundError("No module named 'fcntl'") + + monkeypatch.setattr( + identity_module.importlib, "import_module", unavailable + ) + store = ProviderIdentityStore(tmp_path / "state") + + with pytest.raises( + ProviderIdentityError, + match="requires POSIX advisory file locking", + ): + store.allocate_endpoint() + + assert not store.lock_path.exists()