Add verified Ansible release handoff

This commit is contained in:
mathew 2026-10-01 07:32:43 +01:00
parent 864100b813
commit 3293472cad
6 changed files with 548 additions and 0 deletions

31
deploy/ansible/README.md Normal file
View File

@ -0,0 +1,31 @@
# GuestOps Ansible handoff
This playbook is the application-owned deployment contract. Copy or import it into the private Futuresens Ansible repository and bind the `guestops` inventory group there. Do not commit inventory secrets, `.env` contents, provider credentials, hostnames other than the public service name, or restricted evidence paths to GuestOps.
Required extra variables:
- `guestops_release_version`: matched application version, for example `0.2.1`;
- `guestops_release_commit`: full 40-character packaged commit;
- `guestops_archive`: controller path to the generated `.tar.gz`;
- `guestops_archive_sha256`: approved lowercase SHA-256;
- `guestops_source_record`: controller path to the matching `.source.json`;
- `guestops_evidence_directory`: existing restricted controller directory for the fetched release record.
The target must already have Docker Engine with Compose, Python 3, and `/etc/guestops/guestops.env` owned by root with mode `0600`. The private environment file supplies MongoDB passwords, image references, provider-file paths and disabled-by-default feature flags. Provision it through Ansible Vault or the existing Futuresens secret process, never through this repository.
Example invocation from the central Ansible checkout:
```sh
ansible-playbook guestops.yml \
-l guestops_sandbox \
-e guestops_release_version=0.2.1 \
-e guestops_release_commit=FULL_40_CHARACTER_SHA \
-e guestops_archive=/secure/releases/GuestOps-0.2.1-COMMIT.tar.gz \
-e guestops_archive_sha256=LOWERCASE_SHA256 \
-e guestops_source_record=/secure/releases/GuestOps-0.2.1-COMMIT.source.json \
-e guestops_evidence_directory=/secure/evidence/guestops/0.2.1
```
The controller verifies the source package before transfer. The host independently checks the transferred archive checksum, extracts into a commit-specific directory, verifies the package again, selects the commit-tagged images and disabled send/FAQ defaults in the private environment, builds the API and worker images, records immutable image IDs, runs the offline preflight, validates Compose without printing expanded secrets, starts with `--no-build`, waits for readiness, and only then changes the `current` symlink. The source archive remains in the restricted controller store; the temporary host copy is removed after success.
This playbook does not provision DNS, TLS, Nginx, firewall rules, backup keys, monitoring, or the private environment. Those remain explicit Milestone 10 and 11 acceptance activities.

292
deploy/ansible/guestops.yml Normal file
View File

@ -0,0 +1,292 @@
---
- name: Install a verified GuestOps source release
hosts: guestops
become: true
gather_facts: true
vars:
guestops_root: /opt/guestops
guestops_config_root: /etc/guestops
guestops_release_root: "{{ guestops_root }}/releases/{{ guestops_release_commit }}"
guestops_staging_archive: "/var/tmp/{{ guestops_archive | basename }}"
guestops_staging_record: "/var/tmp/{{ guestops_source_record | basename }}"
guestops_api_image: "guestops-api:{{ guestops_release_commit }}"
guestops_worker_image: "guestops-worker:{{ guestops_release_commit }}"
pre_tasks:
- name: Validate required release variables
ansible.builtin.assert:
that:
- guestops_release_version is match('^[0-9]+\\.[0-9]+\\.[0-9]+$')
- guestops_release_commit is match('^[0-9a-f]{40}$')
- guestops_archive_sha256 is match('^[0-9a-f]{64}$')
- guestops_archive | length > 0
- guestops_source_record | length > 0
- guestops_evidence_directory | length > 0
fail_msg: Release version, full commit, archive, SHA-256, source record and evidence directory are required.
- name: Verify source package on the Ansible controller
ansible.builtin.command:
argv:
- python3
- "{{ playbook_dir }}/../verify_source_package.py"
- --archive
- "{{ guestops_archive }}"
- --record
- "{{ guestops_source_record }}"
- --expected-commit
- "{{ guestops_release_commit }}"
- --expected-version
- "{{ guestops_release_version }}"
delegate_to: localhost
become: false
changed_when: false
- name: Check private GuestOps environment exists
ansible.builtin.stat:
path: "{{ guestops_config_root }}/guestops.env"
register: guestops_environment
- name: Require a private pre-provisioned environment file
ansible.builtin.assert:
that:
- guestops_environment.stat.exists
- guestops_environment.stat.isreg
- guestops_environment.stat.mode == '0600'
fail_msg: /etc/guestops/guestops.env must already exist with mode 0600.
tasks:
- name: Create release and evidence directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: root
group: root
mode: "{{ item.mode }}"
loop:
- { path: "{{ guestops_root }}/releases", mode: "0755" }
- { path: "{{ guestops_release_root }}", mode: "0755" }
- { path: "{{ guestops_config_root }}/release-records", mode: "0700" }
- name: Copy source archive to the host
ansible.builtin.copy:
src: "{{ guestops_archive }}"
dest: "{{ guestops_staging_archive }}"
owner: root
group: root
mode: "0600"
- name: Copy source record to the host
ansible.builtin.copy:
src: "{{ guestops_source_record }}"
dest: "{{ guestops_staging_record }}"
owner: root
group: root
mode: "0600"
- name: Calculate transferred archive checksum
ansible.builtin.stat:
path: "{{ guestops_staging_archive }}"
checksum_algorithm: sha256
register: guestops_transferred_archive
- name: Reject a changed transferred archive
ansible.builtin.assert:
that:
- guestops_transferred_archive.stat.checksum == guestops_archive_sha256
fail_msg: Transferred GuestOps archive does not match the approved SHA-256.
- name: Extract committed application files
ansible.builtin.unarchive:
src: "{{ guestops_staging_archive }}"
dest: "{{ guestops_release_root }}"
remote_src: true
extra_opts:
- --strip-components=1
- name: Verify transferred package and record on the target
ansible.builtin.command:
argv:
- python3
- deploy/verify_source_package.py
- --archive
- "{{ guestops_staging_archive }}"
- --record
- "{{ guestops_staging_record }}"
- --expected-commit
- "{{ guestops_release_commit }}"
- --expected-version
- "{{ guestops_release_version }}"
- --output
- "{{ guestops_config_root }}/release-records/{{ guestops_release_commit }}.source-verification.json"
chdir: "{{ guestops_release_root }}"
changed_when: false
- name: Retain approved source record on the target
ansible.builtin.copy:
src: "{{ guestops_staging_record }}"
dest: "{{ guestops_config_root }}/release-records/{{ guestops_release_commit }}.source.json"
remote_src: true
owner: root
group: root
mode: "0600"
- name: Select release images and safe pilot defaults in the private environment
ansible.builtin.lineinfile:
path: "{{ guestops_config_root }}/guestops.env"
regexp: "^{{ item.name }}="
line: "{{ item.name }}={{ item.value }}"
create: false
mode: "0600"
loop:
- { name: GUESTOPS_API_IMAGE, value: "{{ guestops_api_image }}" }
- { name: GUESTOPS_WORKER_IMAGE, value: "{{ guestops_worker_image }}" }
- { name: GOOGLE_ENABLE_SENDING, value: "false" }
- { name: AUTO_REPLY_ENABLE_LIVE, value: "false" }
no_log: true
- name: Copy private deployment environment into the release
ansible.builtin.copy:
src: "{{ guestops_config_root }}/guestops.env"
dest: "{{ guestops_release_root }}/.env"
remote_src: true
owner: root
group: root
mode: "0600"
- name: Build API image from the verified source
ansible.builtin.command:
argv:
- docker
- build
- --target
- api
- --tag
- "{{ guestops_api_image }}"
- .
chdir: "{{ guestops_release_root }}"
register: guestops_api_build
changed_when: true
- name: Build worker image from the verified source
ansible.builtin.command:
argv:
- docker
- build
- --target
- worker
- --tag
- "{{ guestops_worker_image }}"
- .
chdir: "{{ guestops_release_root }}"
register: guestops_worker_build
changed_when: true
- name: Inspect API image identity
ansible.builtin.command:
argv: [docker, image, inspect, --format, "{{ '{{.Id}}' }}", "{{ guestops_api_image }}"]
register: guestops_api_identity
changed_when: false
- name: Inspect worker image identity
ansible.builtin.command:
argv: [docker, image, inspect, --format, "{{ '{{.Id}}' }}", "{{ guestops_worker_image }}"]
register: guestops_worker_identity
changed_when: false
- name: Create post-build release record
ansible.builtin.command:
argv:
- python3
- deploy/release_record.py
- --artifact
- "{{ guestops_staging_archive }}"
- --commit
- "{{ guestops_release_commit }}"
- --api-image
- "{{ guestops_api_image }}"
- --api-id
- "{{ guestops_api_identity.stdout }}"
- --worker-image
- "{{ guestops_worker_image }}"
- --worker-id
- "{{ guestops_worker_identity.stdout }}"
- --output
- "{{ guestops_config_root }}/release-records/{{ guestops_release_commit }}.json"
chdir: "{{ guestops_release_root }}"
- name: Run offline deployment preflight
ansible.builtin.command:
argv:
- python3
- deploy/ops.py
- preflight
- --offline
chdir: "{{ guestops_release_root }}"
changed_when: false
- name: Validate Compose without exposing expanded configuration
ansible.builtin.command:
argv:
- docker
- compose
- --env-file
- "{{ guestops_config_root }}/guestops.env"
- config
- --quiet
chdir: "{{ guestops_release_root }}"
changed_when: false
- name: Start the verified release without rebuilding
ansible.builtin.command:
argv:
- docker
- compose
- --env-file
- "{{ guestops_config_root }}/guestops.env"
- up
- --detach
- --no-build
chdir: "{{ guestops_release_root }}"
environment:
GUESTOPS_API_IMAGE: "{{ guestops_api_image }}"
GUESTOPS_WORKER_IMAGE: "{{ guestops_worker_image }}"
changed_when: true
- name: Wait for loopback readiness
ansible.builtin.uri:
url: http://127.0.0.1:8080/health/ready
method: GET
status_code: 200
return_content: false
register: guestops_readiness
retries: 30
delay: 2
until: guestops_readiness.status == 200
- name: Select the current successful release
ansible.builtin.file:
src: "{{ guestops_release_root }}"
dest: "{{ guestops_root }}/current"
state: link
force: true
- name: Retain release record on the Ansible controller
ansible.builtin.fetch:
src: "{{ guestops_config_root }}/release-records/{{ guestops_release_commit }}.json"
dest: "{{ guestops_evidence_directory }}/release-record.json"
flat: true
- name: Retain target source verification on the Ansible controller
ansible.builtin.fetch:
src: "{{ guestops_config_root }}/release-records/{{ guestops_release_commit }}.source-verification.json"
dest: "{{ guestops_evidence_directory }}/source-verification.json"
flat: true
- name: Remove transferred staging files after successful deployment
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- "{{ guestops_staging_archive }}"
- "{{ guestops_staging_record }}"

View File

@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Verify a GuestOps source archive against its deterministic source record."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
import re
import sys
FULL_SHA = re.compile(r"[0-9a-f]{40}")
SHA256 = re.compile(r"[0-9a-f]{64}")
SEMVER = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+")
def require(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def validate(
archive: Path,
record_path: Path,
expected_commit: str | None = None,
expected_version: str | None = None,
) -> dict[str, object]:
require(archive.is_file(), "Source archive does not exist.")
require(record_path.is_file(), "Source record does not exist.")
record = json.loads(record_path.read_text(encoding="utf-8"))
require(isinstance(record, dict), "Source record must be a JSON object.")
require(record.get("schemaVersion") == 1, "Unsupported source-record schema.")
commit = record.get("commit")
version = record.get("version")
require(isinstance(commit, str) and FULL_SHA.fullmatch(commit) is not None,
"Source record commit must be a full lowercase Git SHA.")
require(isinstance(version, str) and SEMVER.fullmatch(version) is not None,
"Source record version must use MAJOR.MINOR.PATCH.")
if expected_commit is not None:
require(FULL_SHA.fullmatch(expected_commit) is not None,
"Expected commit must be a full lowercase Git SHA.")
require(commit == expected_commit, "Source-record commit does not match the selected release.")
if expected_version is not None:
require(SEMVER.fullmatch(expected_version) is not None,
"Expected version must use MAJOR.MINOR.PATCH.")
require(version == expected_version, "Source-record version does not match the selected release.")
artifact = record.get("artifact")
require(isinstance(artifact, dict), "Source record has no artifact object.")
require(artifact.get("name") == archive.name, "Source archive filename does not match the record.")
require(artifact.get("size") == archive.stat().st_size, "Source archive size does not match the record.")
recorded_sha = artifact.get("sha256")
require(isinstance(recorded_sha, str) and SHA256.fullmatch(recorded_sha) is not None,
"Source archive record must contain a lowercase SHA-256 digest.")
actual_sha = sha256(archive)
require(recorded_sha == actual_sha, "Source archive SHA-256 does not match the record.")
return {
"artifact": {"name": archive.name, "sha256": actual_sha, "size": archive.stat().st_size},
"commit": commit,
"schemaVersion": 1,
"sourceRecord": {"name": record_path.name, "sha256": sha256(record_path)},
"verified": True,
"version": version,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--archive", required=True, type=Path)
parser.add_argument("--record", required=True, type=Path)
parser.add_argument("--expected-commit")
parser.add_argument("--expected-version")
parser.add_argument("--output", type=Path)
args = parser.parse_args()
try:
result = validate(
args.archive,
args.record,
args.expected_commit,
args.expected_version,
)
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"Source package verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
rendered = json.dumps(result, indent=2, sort_keys=True) + "\n"
if args.output:
args.output.write_text(rendered, encoding="utf-8")
print(rendered, end="")
if __name__ == "__main__":
main()

View File

@ -30,6 +30,8 @@ The packaging command resolves the ref to a full commit, reads both application
Store the archive and source record under a versioned GuestOps files directory in the private Ansible repository. The GuestOps playbook and environment variables should select that version, verify the archive against the source record, extract it into the application directory, preserve the private `.env` and provider configuration, and build images tagged with the full source commit:
The application-owned handoff playbook and required variables are documented in [`deploy/ansible/README.md`](../deploy/ansible/README.md). Import or copy that playbook into the central private Ansible repository, bind its `guestops` inventory group, and keep inventory and secret values there.
```sh
docker build --target api -t guestops-api:FULL_40_CHARACTER_SHA .
docker build --target worker -t guestops-worker:FULL_40_CHARACTER_SHA .

View File

@ -0,0 +1,47 @@
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
PLAYBOOK = ROOT / "deploy" / "ansible" / "guestops.yml"
class AnsibleHandoffTests(unittest.TestCase):
def test_release_is_verified_before_build_and_start(self):
text = PLAYBOOK.read_text(encoding="utf-8")
controller_verify = text.index("Verify source package on the Ansible controller")
target_verify = text.index("Verify transferred package and record on the target")
api_build = text.index("Build API image from the verified source")
preflight = text.index("Run offline deployment preflight")
compose_start = text.index("Start the verified release without rebuilding")
readiness = text.index("Wait for loopback readiness")
current = text.index("Select the current successful release")
self.assertLess(controller_verify, target_verify)
self.assertLess(target_verify, api_build)
self.assertLess(api_build, preflight)
self.assertLess(preflight, compose_start)
self.assertLess(compose_start, readiness)
self.assertLess(readiness, current)
def test_playbook_uses_safe_release_controls(self):
text = PLAYBOOK.read_text(encoding="utf-8")
for required in (
"guestops_release_commit is match('^[0-9a-f]{40}$')",
"checksum_algorithm: sha256",
"deploy/verify_source_package.py",
"deploy/release_record.py",
"deploy/ops.py",
"--offline",
"--no-build",
"GOOGLE_ENABLE_SENDING",
"AUTO_REPLY_ENABLE_LIVE",
"no_log: true",
"http://127.0.0.1:8080/health/ready",
):
self.assertIn(required, text)
self.assertNotIn("ansible.builtin.shell", text)
self.assertNotIn("password=", text.lower())
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,72 @@
import hashlib
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location(
"verify_source_package", ROOT / "deploy" / "verify_source_package.py"
)
verify_source_package = importlib.util.module_from_spec(spec)
spec.loader.exec_module(verify_source_package)
COMMIT = "a" * 40
VERSION = "0.2.1"
class VerifySourcePackageTests(unittest.TestCase):
def fixture(self, directory: str):
root = Path(directory)
archive = root / "GuestOps-0.2.1-aaaaaaaaaaaa.tar.gz"
record = root / "GuestOps-0.2.1-aaaaaaaaaaaa.source.json"
archive.write_bytes(b"committed source package")
payload = {
"artifact": {
"name": archive.name,
"sha256": hashlib.sha256(archive.read_bytes()).hexdigest(),
"size": archive.stat().st_size,
},
"commit": COMMIT,
"schemaVersion": 1,
"version": VERSION,
}
record.write_text(json.dumps(payload), encoding="utf-8")
return archive, record, payload
def test_verifies_archive_record_and_expected_identity(self):
with tempfile.TemporaryDirectory() as directory:
archive, record, _ = self.fixture(directory)
result = verify_source_package.validate(archive, record, COMMIT, VERSION)
self.assertTrue(result["verified"])
self.assertEqual(result["commit"], COMMIT)
self.assertEqual(result["artifact"]["sha256"], hashlib.sha256(archive.read_bytes()).hexdigest())
self.assertEqual(result["sourceRecord"]["sha256"], hashlib.sha256(record.read_bytes()).hexdigest())
def test_rejects_changed_archive(self):
with tempfile.TemporaryDirectory() as directory:
archive, record, _ = self.fixture(directory)
archive.write_bytes(b"changed source package")
with self.assertRaisesRegex(ValueError, "size does not match"):
verify_source_package.validate(archive, record, COMMIT, VERSION)
def test_rejects_wrong_commit_version_and_filename(self):
cases = [
(lambda value: value.update(commit="b" * 40), "commit does not match"),
(lambda value: value.update(version="0.3.0"), "version does not match"),
(lambda value: value["artifact"].update(name="other.tar.gz"), "filename does not match"),
]
for mutate, message in cases:
with self.subTest(message=message), tempfile.TemporaryDirectory() as directory:
archive, record, payload = self.fixture(directory)
mutate(payload)
record.write_text(json.dumps(payload), encoding="utf-8")
with self.assertRaisesRegex(ValueError, message):
verify_source_package.validate(archive, record, COMMIT, VERSION)
if __name__ == "__main__":
unittest.main()