Add verified Ansible release handoff
This commit is contained in:
parent
864100b813
commit
3293472cad
31
deploy/ansible/README.md
Normal file
31
deploy/ansible/README.md
Normal 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
292
deploy/ansible/guestops.yml
Normal 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 }}"
|
||||
104
deploy/verify_source_package.py
Normal file
104
deploy/verify_source_package.py
Normal 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()
|
||||
@ -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 .
|
||||
|
||||
47
tests/test_ansible_handoff.py
Normal file
47
tests/test_ansible_handoff.py
Normal 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()
|
||||
72
tests/test_verify_source_package.py
Normal file
72
tests/test_verify_source_package.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user