Initial import of the netOrk deploy script
CI / check (push) Successful in 12s

This commit is contained in:
Christian Manivong
2026-09-28 19:55:22 +02:00
commit a928d5eec3
9 changed files with 773 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
"""What a deploy needs from the host running it: this script, ssh, and deploy.env.
No checkout of the application repository. The compose files come out of the
engine image for the tag being deployed, and migrations run from that same image,
so nothing is rsynced from a working tree any more.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
import pytest
DEPLOY = Path(__file__).resolve().parent.parent / "deploy.sh"
def _logical_lines() -> list[str]:
joined = DEPLOY.read_text().replace("\\\n", " ")
return [line for line in joined.splitlines() if not line.lstrip().startswith("#")]
def _position(needle: str) -> int:
text = DEPLOY.read_text()
assert text.count(needle) == 1, f"{needle!r} appears {text.count(needle)} times"
return text.index(needle)
@pytest.fixture
def run(tmp_path: Path):
"""Run the script with no deploy.env and an ssh that records being reached."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
marker = tmp_path / "ssh-was-called"
fake = bin_dir / "ssh"
fake.write_text(f"#!/bin/sh\ntouch {marker}\nexit 255\n")
fake.chmod(0o755)
def _run(*args: str, **env: str) -> subprocess.CompletedProcess[str]:
base = {
"PATH": f"{bin_dir}:{os.environ['PATH']}",
"HOME": str(tmp_path),
"DEPLOY_ENV_FILE": "/dev/null",
}
result = subprocess.run(
["bash", str(DEPLOY), *args],
env={**base, **env},
capture_output=True,
text=True,
timeout=30,
check=False,
)
assert not marker.exists(), "the script reached ssh before refusing"
return result
return _run
class TestNoCheckoutNeeded:
def test_nothing_is_rsynced(self) -> None:
offenders = [line.strip() for line in _logical_lines() if "rsync" in line]
assert not offenders, f"deploy still copies files from a working tree: {offenders}"
def test_compose_files_come_from_the_engine_image(self) -> None:
assert "/app/deploy/" in DEPLOY.read_text()
def test_compose_files_are_fetched_before_the_pull(self) -> None:
assert _position("Fetching compose files") < _position("Pulling images...")
class TestTheRegistryPasswordStaysOffCommandLines:
"""Anything inside the ssh argument becomes the remote shell's command line,
readable by every user on the host through `ps`. The password travels over
ssh's stdin instead."""
def test_no_ssh_argument_carries_the_password(self) -> None:
offenders = [
line.strip()
for line in _logical_lines()
if "ssh " in line and "PASS" in line.split("ssh ", 1)[1]
]
assert not offenders, f"password on a remote command line: {offenders}"
def test_login_reads_the_password_from_stdin(self) -> None:
assert any("--password-stdin" in line and "| ssh " in line for line in _logical_lines()), (
"docker login no longer gets the password piped through ssh"
)
class TestRefusesBeforeTouchingAHost:
"""Every one of these must stop before the first ssh — the fixture's fake ssh
fails the test if it is reached."""
def test_an_unpublished_version_is_refused(self, run) -> None:
r = run("--version=bogus", "host", REGISTRY_HOST="registry.example")
assert r.returncode != 0
assert "Refusing to deploy version 'bogus'" in r.stderr
def test_no_registry_is_refused(self, run) -> None:
r = run("host")
assert r.returncode != 0
assert "REGISTRY_HOST" in r.stderr
def test_no_servers_is_refused(self, run) -> None:
r = run(REGISTRY_HOST="registry.example")
assert r.returncode != 0
assert "No servers" in r.stderr
@pytest.mark.parametrize("flag", ["--no-cache", "--bogus"])
def test_an_unknown_flag_is_refused(self, run, flag: str) -> None:
r = run(flag, "host", REGISTRY_HOST="registry.example")
assert r.returncode != 0
assert f"Unknown option: {flag}" in r.stderr
+131
View File
@@ -0,0 +1,131 @@
"""A load-bearing deploy step must not report success when it failed.
`deploy.sh` runs its real work over ssh. The exit status ssh hands back
is the status of the *remote* command — and when that command ends in a pipe,
the status belongs to the last stage of the pipe, not to the work:
ssh "$SERVER" "docker compose pull … 2>&1 | grep -E 'Pulled|Already|Error'"
A failed pull prints a line containing `Error`, `grep` matches it and exits 0, and
the deploy walks on to swap the containers. `set -o pipefail` cannot see this: the
remote pipeline ran inside the remote shell and from here the ssh succeeded.
A sibling service hit the same shape on 2026-09-15 with
`alembic upgrade head 2>&1 | tail -5`. The migration raised a TypeError, the
script printed the traceback and swapped the containers anyway, and the service
answered 500 on every request touching the changed table for eleven minutes.
So: tail and grep locally, on captured output, where `pipefail` and `set -e` can
still act. The rule below is deliberately narrow — it covers only the steps whose
failure must stop a deploy. `docker image prune | tail -1` is cosmetic and stays
exempt, because a rule that forbids every pipe would be worked around rather than
followed.
"""
from __future__ import annotations
import re
from pathlib import Path
DEPLOY = Path(__file__).resolve().parent.parent / "deploy.sh"
#: The steps a deploy must not walk past. Anything else may pipe as it likes.
LOAD_BEARING = re.compile(
r"docker pull|compose[^\n]*\bpull\b|compose[^\n]*\bup -d\b|alembic upgrade|/app/deploy/"
)
#: Filters that replace the exit status of everything before them.
SWALLOWS_STATUS = re.compile(r"\|\s*(tail|head|grep)\b")
#: Every ssh line ends `| sed "s/^/[${SERVER}] /"`, which runs *here*. Local
#: pipes are covered by `set -o pipefail`; only what runs on the far side is
#: invisible, so the local suffix is cut off before the check.
LOCAL_SUFFIX = '| sed "s/^/'
def _logical_lines() -> list[str]:
"""The script with backslash-continuations joined, so one command is one line.
Comments are dropped. The first version of this check flagged the comment
inside `run_remote` that quotes the offending line in order to explain it —
prose about a statement is not the statement, and a checker that cannot tell
them apart makes the explanation unwritable.
"""
joined = DEPLOY.read_text().replace("\\\n", " ")
return [line for line in joined.splitlines() if not line.lstrip().startswith("#")]
def _remote_part(line: str) -> str:
head, _, _ = line.partition(LOCAL_SUFFIX)
return head
def _offenders() -> list[str]:
return [
" ".join(line.split())[:120]
for line in _logical_lines()
if "ssh " in line
and LOAD_BEARING.search(line)
and SWALLOWS_STATUS.search(_remote_part(line))
]
def test_the_detector_sees_the_shape_it_exists_for() -> None:
"""Guards the guard, against the exact line that caused the outage."""
broken = 'ssh -n "$SERVER" "cd ~/x && docker compose pull api 2>&1 | grep -E \'Pulled\'"'
assert LOAD_BEARING.search(broken)
assert SWALLOWS_STATUS.search(_remote_part(broken))
def test_a_local_tail_is_not_an_offence() -> None:
"""`pipefail` already covers this side of the ssh; the rule is about the far
side. A check that flagged both would push the fix in the wrong direction."""
fine = 'ssh -n "$SERVER" "docker compose pull api" | sed "s/^/[x] /" | tail -3'
assert not SWALLOWS_STATUS.search(_remote_part(fine))
def test_the_script_is_there_and_has_ssh_steps() -> None:
"""An unreadable or renamed script would make the test below vacuously green."""
assert [line for line in _logical_lines() if "ssh " in line]
def test_no_load_bearing_step_hides_its_exit_status() -> None:
offenders = _offenders()
assert not offenders, "remote pipes swallow the status of:\n " + "\n ".join(offenders)
class TestTheHostRecordsOnlyWhatItRuns:
"""`~/netork/.env` on the server is a record, not an intention.
It used to be written before the pull. A deploy that then failed — a tag the
registry does not have, say — left the host recording a version it had never
run, and the next `docker compose up` a human typed there reached for an
image that does not exist. Observed on a test host while verifying the
failure path above: the deploy stopped exactly where it should, and still
left `NETORK_VERSION=main-0000000` behind.
So it is written after the swap and after the image-id check agreed, and this
holds that order. Positional rather than behavioural, which is the honest
limit of a static check — but the order is the whole property.
"""
@staticmethod
def _position(needle: str) -> int:
text = DEPLOY.read_text()
assert text.count(needle) == 1, f"{needle!r} appears {text.count(needle)} times"
return text.index(needle)
def test_env_is_recorded_after_the_image_check(self) -> None:
verified = self._position("Verified: containers run")
recorded = self._position("Recording registry settings")
assert recorded > verified, (
"the host records NETORK_VERSION before the deploy is verified; "
"a failed deploy then leaves it pointing at a version never run"
)
def test_env_is_recorded_after_the_pull(self) -> None:
assert self._position("Recording registry settings") > self._position("Pulling images...")