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..1dfc2ec --- /dev/null +++ b/.github/actions/agent/install/action.yml @@ -0,0 +1,178 @@ +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-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. + 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_TEST_IMAGE_DIRECTORY: ${{ inputs.agent-test-image-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 "${AGENT_TEST_IMAGE_DIRECTORY}" + sha256sum --check SHA256SUMS + ) > "${EVIDENCE_DIRECTORY}/agent-test-image-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_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_test_image}/packaging/rpm/Dockerfile.test" \ + --tag "${CONTAINER_IMAGE}" \ + "${agent_test_image}/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 new file mode 100644 index 0000000..17ad5b0 --- /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[@]}" 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..d50ccc5 --- /dev/null +++ b/.github/actions/evidence/collect/action.yml @@ -0,0 +1,106 @@ +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 host directory below the runner temp directory. + required: true + evidence-directory: + description: Host 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: + CONTAINER_NAME: ${{ inputs.container-name }} + 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" + + 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 \ + -printf '%M %u:%g %s %p\n' \ + > "${EVIDENCE_DIRECTORY}/runtime-inventory.txt" + 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}" + 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/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..0df2ecd --- /dev/null +++ b/.github/actions/provider/crash/action.yml @@ -0,0 +1,61 @@ +name: Crash an installed StateForge Provider +description: Kill one fixture and preserve its stale endpoint evidence. + +inputs: + 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. + required: true + socket-path: + description: Generation-specific socket path observed before the crash. + required: true + evidence-directory: + description: Host directory receiving deterministic fault evidence. + required: true + timeout-seconds: + description: Maximum time to wait for process termination. + required: false + default: "30" + +runs: + using: composite + steps: + - name: Terminate fixture without graceful cleanup + shell: bash + env: + CONTAINER_NAME: ${{ inputs.container-name }} + DESCRIPTOR_PATH: ${{ inputs.descriptor-path }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + SOCKET_PATH: ${{ inputs.socket-path }} + TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }} + UNIT_NAME: ${{ inputs.unit-name }} + run: | + set -euo pipefail + mkdir -p "${EVIDENCE_DIRECTORY}" + docker exec "${CONTAINER_NAME}" \ + systemctl kill --kill-whom=main --signal=KILL "${UNIT_NAME}" + deadline=$((SECONDS + TIMEOUT_SECONDS)) + while (( SECONDS < deadline )); do + state="$(docker exec "${CONTAINER_NAME}" \ + systemctl is-active "${UNIT_NAME}" 2>/dev/null || true)" + if [[ "${state}" == "failed" || "${state}" == "inactive" ]]; then + break + fi + sleep 1 + done + if [[ "${state:-}" != "failed" && "${state:-}" != "inactive" ]]; then + echo "Provider process survived SIGKILL" >&2 + exit 1 + fi + docker exec "${CONTAINER_NAME}" test -f "${DESCRIPTOR_PATH}" + docker exec "${CONTAINER_NAME}" test -S "${SOCKET_PATH}" + printf '%s\n' \ + "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 new file mode 100644 index 0000000..426c4a1 --- /dev/null +++ b/.github/actions/provider/create-fixture/action.yml @@ -0,0 +1,62 @@ +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 }} + 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 + 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}" + state_name="${STATE_NAME:-${INSTANCE_NAME}}" + fixture="${root}/provider.py" + mkdir -p "${root}" + 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 "instance-name=${INSTANCE_NAME}" + echo "state-name=${state_name}" + } >> "${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..b9a7283 --- /dev/null +++ b/.github/actions/provider/start/action.yml @@ -0,0 +1,166 @@ +name: Start an installed StateForge Provider +description: Run a public-API fixture beside the installed Agent in Fedora. + +inputs: + 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 + display-name: + description: Author-declared Provider display name. + required: false + default: StateForge CI Provider + evidence-directory: + description: Host directory receiving process and descriptor evidence. + required: true + timeout-seconds: + description: Maximum time to wait for endpoint publication. + required: false + default: "60" + +outputs: + unit-name: + description: Transient systemd unit running the Provider. + value: ${{ steps.start.outputs.unit-name }} + descriptor-path: + description: Descriptor path inside the Fedora system. + value: ${{ steps.start.outputs.descriptor-path }} + provider-instance-id: + 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 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 + steps: + - name: Start fixture and wait for endpoint publication + id: start + shell: bash + env: + CONTAINER_NAME: ${{ inputs.container-name }} + DISPLAY_NAME: ${{ inputs.display-name }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + FIXTURE_PATH: ${{ inputs.fixture-path }} + IDENTITY: ${{ inputs.identity }} + INSTANCE_NAME: ${{ inputs.instance-name }} + STATE_NAME: ${{ inputs.state-name }} + TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }} + run: | + set -euo pipefail + 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}" + + 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 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 + + 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 /run/stateforge-agent/providers \ + --state-directory "${state_directory}" \ + --event-log "${event_log}" + + deadline=$((SECONDS + TIMEOUT_SECONDS)) + while (( SECONDS < deadline )); do + 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 + if ! docker exec "${CONTAINER_NAME}" \ + systemctl is-active --quiet "${unit_name}"; then + break + fi + sleep 1 + done + 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 + + 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 "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 new file mode 100644 index 0000000..e0a8e61 --- /dev/null +++ b/.github/actions/provider/stop/action.yml @@ -0,0 +1,74 @@ +name: Stop an installed StateForge Provider +description: Stop one transient Provider service and verify owned cleanup. + +inputs: + 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. + required: true + socket-path: + description: Generation-specific socket path observed at startup. + required: true + event-log: + description: Lifecycle event log inside Fedora. + required: true + evidence-directory: + description: Host directory receiving shutdown evidence. + required: true + timeout-seconds: + description: Maximum time to wait for graceful shutdown. + required: false + default: "30" + +runs: + using: composite + steps: + - name: Stop fixture and verify cleanup + shell: bash + env: + CONTAINER_NAME: ${{ inputs.container-name }} + DESCRIPTOR_PATH: ${{ inputs.descriptor-path }} + EVENT_LOG: ${{ inputs.event-log }} + EVIDENCE_DIRECTORY: ${{ inputs.evidence-directory }} + SOCKET_PATH: ${{ inputs.socket-path }} + TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }} + UNIT_NAME: ${{ inputs.unit-name }} + run: | + set -euo pipefail + mkdir -p "${EVIDENCE_DIRECTORY}" + docker exec "${CONTAINER_NAME}" systemctl stop "${UNIT_NAME}" + + deadline=$((SECONDS + TIMEOUT_SECONDS)) + while (( SECONDS < deadline )); do + state="$(docker exec "${CONTAINER_NAME}" \ + systemctl is-active "${UNIT_NAME}" 2>/dev/null || true)" + if [[ "${state}" == "inactive" || "${state}" == "failed" ]]; then + break + fi + sleep 1 + done + if [[ "${state:-}" != "inactive" && "${state:-}" != "failed" ]]; then + echo "Provider did not stop within ${TIMEOUT_SECONDS}s" >&2 + exit 1 + fi + if docker exec "${CONTAINER_NAME}" test -e "${SOCKET_PATH}"; then + echo "Provider socket remained after graceful shutdown" >&2 + exit 1 + fi + if docker exec "${CONTAINER_NAME}" test -e "${DESCRIPTOR_PATH}"; then + echo "Provider descriptor remained after graceful shutdown" >&2 + exit 1 + fi + 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 new file mode 100644 index 0000000..73b5246 --- /dev/null +++ b/.github/actions/scenarios/adversarial/invalid-peer/action.yml @@ -0,0 +1,92 @@ +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-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. + required: true + 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: system + uses: ./.github/actions/agent/install + with: + agent-artifact-directory: ${{ inputs.agent-artifact-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 + + - 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-valid + identity: stateforge.test.recovery + + - name: Start recovery Provider + id: provider + uses: ./.github/actions/provider/start + with: + 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.recovery + evidence-directory: ${{ inputs.evidence-directory }}/provider + + - name: Assert recovered Agent-accepted session + uses: ./.github/actions/agent/assert-session + with: + 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 recovery Provider + uses: ./.github/actions/provider/stop + with: + 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.provider.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 + 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 new file mode 100644 index 0000000..e7a0b39 --- /dev/null +++ b/.github/actions/scenarios/destructive/uninstall-reinstall/action.yml @@ -0,0 +1,107 @@ +name: Test Provider uninstall and reinstall +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-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. + required: true + 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: system + uses: ./.github/actions/agent/install + with: + agent-artifact-directory: ${{ inputs.agent-artifact-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 + + - 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 }} + run: | + set -euo pipefail + { + 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 + 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 + 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: + 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 + evidence-directory: ${{ inputs.evidence-directory }}/provider + + - name: Assert reinstalled Agent-accepted session + uses: ./.github/actions/agent/assert-session + with: + 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: + 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.provider.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 + 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 new file mode 100644 index 0000000..4b6be9f --- /dev/null +++ b/.github/actions/scenarios/lifecycle/concurrent-providers/action.yml @@ -0,0 +1,134 @@ +name: Test concurrent installed Providers +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-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. + required: true + 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: system + uses: ./.github/actions/agent/install + with: + agent-artifact-directory: ${{ inputs.agent-artifact-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 + + - 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 + 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: + 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 + evidence-directory: ${{ inputs.evidence-directory }}/provider-a + + - name: Start second Provider + id: provider-b + uses: ./.github/actions/provider/start + with: + 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 + evidence-directory: ${{ inputs.evidence-directory }}/provider-b + + - name: Assert independent 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 Agent-accepted session + uses: ./.github/actions/agent/assert-session + with: + 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 Agent-accepted session + uses: ./.github/actions/agent/assert-session + with: + 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: + 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.provider-a.outputs.event-log }} + evidence-directory: ${{ inputs.evidence-directory }}/provider-a + + - name: Stop second Provider + uses: ./.github/actions/provider/stop + with: + 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.provider-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 + 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 new file mode 100644 index 0000000..fb766ce --- /dev/null +++ b/.github/actions/scenarios/lifecycle/same-instance-replacement/action.yml @@ -0,0 +1,143 @@ +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-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. + required: true + 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: system + uses: ./.github/actions/agent/install + with: + agent-artifact-directory: ${{ inputs.agent-artifact-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 + + - 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 + with: + runtime-directory: ${{ inputs.runtime-directory }} + instance-name: provider-old + 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 + with: + runtime-directory: ${{ inputs.runtime-directory }} + instance-name: provider-new + state-name: replacement + identity: stateforge.test.replacement + + - name: Start replacement generation + id: provider-new + uses: ./.github/actions/provider/start + with: + 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 + 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 Agent-accepted session + uses: ./.github/actions/agent/assert-session + with: + 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: + 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.provider-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 + 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 new file mode 100644 index 0000000..f95419e --- /dev/null +++ b/.github/actions/scenarios/resilience/crash-restart/action.yml @@ -0,0 +1,142 @@ +name: Test Provider crash and restart +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-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. + required: true + 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: system + uses: ./.github/actions/agent/install + with: + agent-artifact-directory: ${{ inputs.agent-artifact-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 + + - name: Start installed Agent + uses: ./.github/actions/agent/start + with: + container-name: ${{ steps.system.outputs.container-name }} + + - name: Create first generation 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: Start first 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.restart + 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: + 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: + 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 + 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 Agent-accepted session + uses: ./.github/actions/agent/assert-session + with: + 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: + 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.provider-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 + 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 new file mode 100644 index 0000000..633ffd6 --- /dev/null +++ b/.github/actions/scenarios/smoke/normal-lifecycle/action.yml @@ -0,0 +1,86 @@ +name: Test normal installed Provider lifecycle +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-test-image-directory: + description: Bundled exact Agent Fedora test-image definition. + required: true + 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: system + uses: ./.github/actions/agent/install + with: + agent-artifact-directory: ${{ inputs.agent-artifact-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 + + - 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 + 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: + 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 + evidence-directory: ${{ inputs.evidence-directory }}/provider + + - name: Assert Agent-accepted session + uses: ./.github/actions/agent/assert-session + with: + 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: + 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.provider.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 + container-name: ${{ steps.system.outputs.container-name }} + runtime-directory: ${{ inputs.runtime-directory }} + evidence-directory: ${{ inputs.evidence-directory }} diff --git a/.github/workflows/provider-build-and-verify.yml b/.github/workflows/provider-build-and-verify.yml new file mode 100644 index 0000000..956660b --- /dev/null +++ b/.github/workflows/provider-build-and-verify.yml @@ -0,0 +1,107 @@ +name: Provider / Build and verify + +on: + workflow_call: + inputs: + artifact-name: + description: Name of the immutable Provider, Protocol, and Agent 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 + agent-ref: + description: Agent 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 + 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. + 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 }} + 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 + +jobs: + build: + name: Build immutable runtime bundle + 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 }} + secrets: + source_app_id: ${{ secrets.source_app_id }} + source_private_key: ${{ secrets.source_private_key }} + + 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 + - source + 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 + - source + 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..1adb605 --- /dev/null +++ b/.github/workflows/provider-build.yml @@ -0,0 +1,220 @@ +name: Provider / Build immutable artifacts + +on: + workflow_call: + inputs: + artifact-name: + description: Name of the Provider, Protocol, and Agent 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 + 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 + 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. + 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 }} + 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, 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-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: 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: + repository: stateforge-org/stateforge-protocol + 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 + with: + repository: stateforge-org/stateforge-agent + 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 + 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: ./.stateforge-ci/protocol/.github/actions/python/build + with: + 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: 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: + 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}" \ + "provider-wheel-sha256=${PROVIDER_WHEEL_SHA256}" \ + "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 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.artifact-name }} + path: | + dist/agent + 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..f815a68 --- /dev/null +++ b/.github/workflows/provider-ci.yml @@ -0,0 +1,29 @@ +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 }} + agent-ref: mvp/implementation + 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-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..e1ed089 --- /dev/null +++ b/.github/workflows/provider-integration.yml @@ -0,0 +1,40 @@ +name: Provider integration + +on: + push: + branches: + - main + - 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 + 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 }} + agent-ref: ${{ inputs.agent-ref || 'mvp/implementation' }} + 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 new file mode 100644 index 0000000..e2611d2 --- /dev/null +++ b/.github/workflows/provider-release-candidate.yml @@ -0,0 +1,49 @@ +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 + 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 }} + agent-ref: ${{ inputs.agent-ref }} + 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/.github/workflows/provider-runtime-scenarios.yml b/.github/workflows/provider-runtime-scenarios.yml new file mode 100644 index 0000000..d15d256 --- /dev/null +++ b/.github/workflows/provider-runtime-scenarios.yml @@ -0,0 +1,154 @@ +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: Normal lifecycle / installed Agent RPM + runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - name: Check out scenario automation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - 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: + agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/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 + 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 }}-normal-lifecycle + path: evidence/runtime/normal-lifecycle + if-no-files-found: warn + retention-days: ${{ inputs.retention-days }} + + full: + name: ${{ matrix.scenario }} / installed Agent RPM + if: ${{ inputs.scenario-tier == 'full' }} + runs-on: ubuntu-24.04 + timeout-minutes: 35 + 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: 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: + agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/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 + 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: + agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/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 + evidence-directory: evidence/runtime/${{ matrix.scenario }} + + - name: Run invalid-peer scenario + if: ${{ matrix.scenario == 'invalid-peer' }} + uses: ./.github/actions/scenarios/adversarial/invalid-peer + with: + agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/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 + evidence-directory: evidence/runtime/${{ matrix.scenario }} + + - name: Run crash-restart scenario + if: ${{ matrix.scenario == 'crash-restart' }} + uses: ./.github/actions/scenarios/resilience/crash-restart + with: + agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/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 + evidence-directory: evidence/runtime/${{ matrix.scenario }} + + - name: Run uninstall-reinstall scenario + if: ${{ matrix.scenario == 'uninstall-reinstall' }} + uses: ./.github/actions/scenarios/destructive/uninstall-reinstall + with: + agent-artifact-directory: ${{ runner.temp }}/stateforge-provider-bundle/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 + 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 }} + 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..48cbd46 --- /dev/null +++ b/.github/workflows/provider-source-verify.yml @@ -0,0 +1,122 @@ +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 + 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: 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 + python -m pip freeze --all \ + > evidence-source-environment.txt + + - 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 + install -m 0644 evidence-source-environment.txt \ + evidence/source/environment.txt + + - 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/.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 0c02e24..d4d20c4 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,11 @@ 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, 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 new file mode 100644 index 0000000..1c7f734 --- /dev/null +++ b/docs/ci-automation.md @@ -0,0 +1,170 @@ +# 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, 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. +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 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`; +- 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 + provenance. + +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. + +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 +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: + +```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 +``` + +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: + +```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 | +| --- | --- | --- | +| `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 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 immutable Agent and Protocol commit SHAs when +selecting a candidate. + +## Runtime scenarios + +The runtime suite currently covers: + +- 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 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. 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 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 +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/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/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 new file mode 100644 index 0000000..f8df5db --- /dev/null +++ b/tests/test_ci_automation.py @@ -0,0 +1,348 @@ +"""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 = 3 +DEPENDENCY_CHECKOUT_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 = { + "agent/assert-invalid-descriptor", + "agent/assert-session", + "agent/install", + "agent/start", + "environment/install", + "evidence/collect", + "package/build", + "package/verify-installed", + "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", + } + 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: + """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") + + assert "python -m build" in provider + assert build.count("./.github/actions/package/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 ".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 + assert "dist/agent" in build + assert "needs: build" in aggregate + assert ( + aggregate.count("needs.build.outputs.artifact-name") + == DOWNSTREAM_BUNDLE_CONSUMER_COUNT + ) + + +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") + + for content in (build, aggregate, release): + assert "protocol-ref:" in content + assert "default: mvp/implementation" in content + 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_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") + 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_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") + + 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_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 "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: + """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/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 + + +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 "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 "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: + """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 "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 + + +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: + """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 "agent-journal.txt" in collector + assert "provider-journal.txt" in collector + assert "rpm -e stateforge-agent" in collector 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()