From b1f17ae4975c5052af5533da8e7b55f2443098ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Miralles=20Ranca=C3=B1o?= <68760931+FlagshipDev@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:41:24 +0200 Subject: [PATCH] Getting started: nineteen blanks become four, and step one stops being a compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five items about the same thing — what somebody sees before Hullwork works for them — and every one of them found the product already knew the answer and was not saying it. ## `init` says what *you* need, not what everybody needs It wrote a 120-line environment file with nineteen empty variables and printed the same five numbered steps to every reader. Those steps were the union: an instance that only ingests errors needs four of the nineteen, one that attempts fixes needs seven, and nothing said which reader was which. It now asks five questions — enter answers all of them — writes what you gave it, and reports what is still missing **for the capabilities you asked for**, each with what it buys. Four lines instead of nineteen blanks. No question asks for a secret: they ask which things exist, never what they are, and the report never prints a value. ## The checks arrive before the containers, not after Every check `hullwork doctor` makes now runs during `init`, before anything is built or started. That was always possible and nobody had noticed: the checks need a database session, and an in-memory one satisfies both that and the rule that a setup command must not create a database file in whatever directory you are standing in. And one check that did not exist anywhere: **does the forge you named actually answer, and what may your token do there.** `doctor` touches no network — it reported `ok` for a hostname that does not resolve, because the question it asks is *which forge is this configured for*. So the first sign that a token was wrong used to be `projects add` refusing it. Three answers, kept apart: reached it and it is fine, reached it and it is wrong, and **could not reach it from here** — which is a fact about your machine as much as about the forge, is reported as such, and never sets the exit code. A laptop behind a VPN does not fail your install. `init` is safe to run again and again; the second run is the report on its own, which is what you want after pasting a credential. It reaches the network only when a forge is configured, and its help says so. ## Step one is no longer a compile Two compose files shipped, and the one written for real deployments was the one that needed a checkout: it built from source and did not contain the string `ghcr.io` anywhere, while the file that pulls the published image was labelled the *evaluation* stack. So the documented path was clone the source, build 500 MB, and never find out a published image exists. `init` now writes a compose file that pulls a pinned published image — the version of the image that wrote it, which is provably one you just pulled. Building is still there, explicit, four commented lines and the sentence saying when you want it: when you are changing Hullwork's own code, which is what the build variable was always for. It is no longer among the things the report says a person must supply. The gateway that observes and seals model traffic used to run from a hardcoded image name, whose own comment named the assumption that pulling breaks — *the image this installation built*. It now follows the image the instance runs. ## What is not claimed Nobody outside this project has installed Hullwork, and this changes what the first hour looks like rather than proving anything about it. The measurements here are of the commands, not of a stranger using them. `docs/status.md` remains the honest scope. Signed-off-by: Javier Miralles Rancaño <68760931+FlagshipDev@users.noreply.github.com> --- hullwork/cli.py | 124 ++++++--- hullwork/config.py | 3 + hullwork/doctor.py | 34 ++- hullwork/preflight.py | 222 ++++++++++++++++ hullwork/sandbox/net.py | 17 ++ hullwork/scaffold.py | 239 ++++++++++++++++-- hullwork/work.py | 5 + tests/test_init_meets_a_real_directory.py | 51 ++-- tests/test_one_door.py | 154 +++++++++++ tests/test_scaffold.py | 14 +- ...test_the_default_is_the_published_image.py | 111 ++++++++ tests/test_the_nineteen_blanks.py | 168 ++++++++++++ tests/test_the_preflight.py | 213 ++++++++++++++++ 13 files changed, 1278 insertions(+), 77 deletions(-) create mode 100644 hullwork/preflight.py create mode 100644 tests/test_one_door.py create mode 100644 tests/test_the_default_is_the_published_image.py create mode 100644 tests/test_the_nineteen_blanks.py create mode 100644 tests/test_the_preflight.py diff --git a/hullwork/cli.py b/hullwork/cli.py index 190174d..24b29ca 100644 --- a/hullwork/cli.py +++ b/hullwork/cli.py @@ -3405,6 +3405,23 @@ def _cmd_prune( return 0 +def _init_description() -> str: + """What `init` is, said where a person meets it. Item 200. + + It reaches the network now, which it never used to, and that belongs here rather than in a + release note nobody reads. + """ + return ( + "Write the compose file and environment a real deployment needs, then say what is still " + "missing — for the capabilities you asked for, not for everybody.\n\n" + "Safe to run again: it never overwrites a file that is already there, and the second run " + "is the report on its own, which is what you want after pasting a credential.\n\n" + "**It reaches the network** when a forge is configured: one connection to it, and one " + "authenticated request to ask what your token may do. With nothing configured it contacts " + "nobody. It writes nothing outside the directory you give it and creates no database." + ) + + def _cmd_init(args: argparse.Namespace, out: TextIO) -> int: """Write the files a real deployment needs, and say what only a person can do. Item 115. @@ -3417,6 +3434,16 @@ def _cmd_init(args: argparse.Namespace, out: TextIO) -> int: into = Path(args.into).resolve() gid = scaffold.docker_socket_group() + # **Asked only where there is somebody to ask** (item 197). This command is documented as + # running from inside the image, before the package exists anywhere, and an installer script has + # no terminal to answer with — so with no TTY it does what it has always done. Pressing + # enter at every question produces the same files, which is what keeps the documented path and + # the lazy path the same path. + answers = scaffold.Answers() + if sys.stdin.isatty() and not args.no_questions: + print("Five questions, and enter is an answer to all of them.\n", file=out) + answers = scaffold.ask(lambda q, hint: input(f" {q}\n [{hint}] ")) + print("", file=out) try: done = scaffold.write(into, docker_gid=gid) except OSError as exc: @@ -3446,40 +3473,66 @@ def _cmd_init(args: argparse.Namespace, out: TextIO) -> int: for note in done.notes: print(f" note {note}", file=out) if not done.created: - print("\nNothing to do: both files already exist. Nothing was changed.", file=out) - return 0 + # **No longer a no-op** (item 200). This said *nothing to do* at the exact moment somebody + # has pasted a token and wants to know whether it works — the least useful output in the + # product, printed on the run where the reader has the most to ask. + print("\nBoth files were already there, so nothing was written.", file=out) + + # Only what was answered, and only into a file this run created — `write` refuses to overwrite + # (item 115), and filling in a file somebody already had would be that refusal with extra steps. + environment = into / scaffold.ENVIRONMENT_FILE + if answers.assigned() and scaffold.ENVIRONMENT_FILE in done.created: + environment.write_text( + scaffold.filled(environment.read_text(encoding="utf-8"), answers), encoding="utf-8" + ) + print( + f"\n filled in {len(answers.assigned())} value(s) you gave, in " + f"{scaffold.ENVIRONMENT_FILE}", + file=out, + ) + + # **One report, assembled in one place** (item 200). This called `what_is_still_needed` and + # printed it, while `preflight` answered the same question its own way four hours later — two + # enumerations of what is missing, kept equal by nobody. `preflight.examine` now asks the + # capability question too, so a variable's consequence is written in the capability table and + # read from there by whoever prints it. + from hullwork import preflight + + # **Its own settings, because this runs before `main` builds any** (item 115's `scaffolding` + # hook). A configuration this process cannot even parse is the most useful thing a report can + # say, so it is shown rather than raised: `init` is where somebody is still fixing it. + try: + settings = get_settings() + except ConfigError as exc: + print(f"\n broken configuration\n {exc}", file=out) + return 1 + + found = preflight.examine( + settings, answers=answers, environment_file=into / scaffold.ENVIRONMENT_FILE + ) + print("\nWhere this deployment stands:\n", file=out) + for one in found: + if one.state is doctor.State.OK: + continue + print(f" {one.state.value:9}{one.check}", file=out) + print(f" {one.detail}", file=out) print( - f"\nWhat only you can do, in this order:\n" + f"\nThen:\n" f"\n" - f" 1. Mint a forge token that can read content and write issues, and **not** push, and\n" - f" put it in {scaffold.ENVIRONMENT_FILE} as HULLWORK_FORGE_TOKEN. A token cannot mint\n" - f" a token, so this is a web interface and a human, once.\n" - f" 2. Set HULLWORK_BASE_URL to an address your error tracker can actually reach.\n" - f" Hosted GlitchTip refuses to call private addresses at all — the deploy notes §1\n" - f" is about that and nothing else.\n" - # **Step 3 was missing and step 4 could not work without it** (2026-08-04). This list claims - # to be everything only a person can do, and it omitted the one value with no sensible - # default: the build context. A stranger followed steps 1-3 verbatim and got - # `failed to read dockerfile` — from a directory this command chose for them. It is now - # written empty rather than as `.`, so the failure names itself, and it is named here too. - f" 3. Set BUILD_SOURCE to the checkout you cloned. It is **not** this directory — a\n" - f" clone carries a docker-compose.yml of its own — and the build cannot find a\n" - f" Dockerfile until you set it.\n" - f" 4. set -a; . ./{scaffold.ENVIRONMENT_FILE}; set +a; docker compose up -d --build\n" - f" 5. hullwork doctor — it names what is still missing, one line each.\n" - f"\n" - f"That gives you ingest, deduplication, triage and issues — one container, and step 3\n" - f"starts exactly that. Attempting fixes needs two more credentials (a code token and a\n" - f"model key), is opted into per project in each repository's own hullwork.yml, and\n" - f"runs in\n" - f"a second container this file keeps behind a profile:\n" - f"\n" - f" docker compose --profile autofix up -d\n" - f"\n" - f"Nothing here turns it on, and now the compose file agrees (item 135).", + f" set -a; . ./{scaffold.ENVIRONMENT_FILE}; set +a; docker compose up -d --build\n" + f" hullwork doctor — it names what is still missing, one line each.\n", file=out, ) + if not answers.autofix: + print( + "Attempting fixes is off, which is a whole product and the default. It is opted into " + "per project in each repository's own hullwork.yml, needs two more credentials, and " + "runs in a second container this compose file keeps behind a profile:\n" + "\n" + " docker compose --profile autofix up -d\n", + file=out, + ) return 0 @@ -3941,15 +3994,26 @@ def build_parser() -> argparse.ArgumentParser: gateway.set_defaults(func=_cmd_gateway) starting = subparsers.add_parser( - "init", help="write the compose file and environment a real deployment needs" + "init", + help="write what a deployment needs, and say what is still missing", + description=_init_description(), ) starting.add_argument( "--into", default=".", help="where to write them (default: the current directory)" ) + starting.add_argument( + "--no-questions", + action="store_true", + help=( + "write the files without asking anything, which is what happens anyway when there is " + "no terminal. Answering every question with enter produces the same files" + ), + ) # No session: this runs before there is an instance, and opening the database here would # create an empty one in the operator's working directory. starting.set_defaults(func=None, scaffolding=_cmd_init) + page_token = subparsers.add_parser( "page-token", help="mint the credential that opens the read-only page", diff --git a/hullwork/config.py b/hullwork/config.py index f8c2083..1f2991c 100644 --- a/hullwork/config.py +++ b/hullwork/config.py @@ -202,6 +202,9 @@ class Settings(BaseSettings): #: #: Bind-mount them read-only and name them here. Unset is not an error and not a pass: `doctor` #: says the deployment was not checked, and why. + #: What the gateway runs from, set by the scaffold and never by a person (item 201). Absent + #: means `hullwork:dev`, which is what every deployment written before that item has. + gateway_image: str | None = None deployment_env_file: str | None = None deployment_compose_file: str | None = None diff --git a/hullwork/doctor.py b/hullwork/doctor.py index 55bdd6d..f10539d 100644 --- a/hullwork/doctor.py +++ b/hullwork/doctor.py @@ -1206,6 +1206,7 @@ def examine( env_file: Path, compose_file: Path | None, docker: str = "docker", + before_there_is_an_instance: bool = False, ) -> list[Finding]: """Every check, in the order an attempt needs them. @@ -1221,6 +1222,18 @@ def examine( are different answers and only one of them is true here. """ database = database_built(session, settings) + if before_there_is_an_instance: + # **The pre-flight's own state, said here rather than patched afterwards** (item 199). There + # being no schema is what a pre-flight is *for*, so `expected` is the honest answer — and + # the branch below, which tells a reader to fix the database, is false advice when there is + # nothing yet to fix. One flag, one source of truth, rather than a caller rewriting strings. + database = Finding( + "database", + State.EXPECTED, + "there is no instance yet, which is what this command is for. `docker compose up` " + "creates it and runs the migrations; `hullwork doctor` from inside says whether it " + "worked.", + ) docker_says = docker_daemon(docker) findings = [ git_on_path(), @@ -1232,19 +1245,24 @@ def examine( policies(settings), nothing_was_left_behind(docker, asked=docker_says.state is State.OK), ] - if database.state is State.BROKEN: + if database.state is not State.OK: + why = ( + "there is no instance yet, so nothing knows which repositories it will watch. This " + "one is answered after `docker compose up`, by `hullwork doctor` from inside." + if before_there_is_an_instance + else "the database above cannot be queried for the active projects, so which " + "repositories this instance watches is unknown. Fix the database and run this again." + ) + findings.append(Finding("code token", State.UNKNOWN, f"not asked: {why}")) findings.append( Finding( - "code token", + "inventory", State.UNKNOWN, - "not asked: the database above cannot be queried for the active projects, so " - "which repositories this instance watches is unknown. Fix the database and run " - "this again.", + "not asked: there is no instance yet." + if before_there_is_an_instance + else "not asked: the database cannot be queried.", ) ) - findings.append( - Finding("inventory", State.UNKNOWN, "not asked: the database cannot be queried.") - ) findings.append( Finding( "deliveries", diff --git a/hullwork/preflight.py b/hullwork/preflight.py new file mode 100644 index 0000000..a458d00 --- /dev/null +++ b/hullwork/preflight.py @@ -0,0 +1,222 @@ +"""What is wrong before anything is built. Item 199. + +Item 198 measured that `doctor` already answers twenty-six checks against an in-memory session, with +no instance in existence — so the guidance was there and arrived one `docker compose up --build` too +late. Two things stood between it and the operator, and this module is both of them. + +**One: a session, without a deployment.** `doctor.examine` takes one, `init` deliberately opens +none, and nobody had noticed that an in-memory engine satisfies both — a real database file would be +created in whatever directory the operator is standing in, which is the trap item 115 exists for. + +**Two: reachability, which existed nowhere.** `doctor` touches no network in any check. It reports +`ok` for `https://forge.example.com`, an address that does not resolve, because the question it asks +is *which forge is this configured for*. That is right for what it is, and it is why a pre-flight +built only from it would send somebody to `projects add` to discover their token is wrong. + +The three states are the whole discipline here. *Reached it and it was fine*, *reached it and it is +wrong*, and **could not reach it** are three different facts, and collapsing the third into either +of the others is item 073's permanently-on signal in the first output a stranger ever sees. +""" + +from __future__ import annotations + +import socket +import urllib.error +import urllib.request +from pathlib import Path +from urllib.parse import urlparse + +from hullwork import credentials +from hullwork.config import Settings +from hullwork.db import make_engine, make_session_factory +from hullwork.doctor import Finding, State +from hullwork.doctor import examine as _examine_an_instance +from hullwork.scaffold import Answers + +#: Long enough for a forge behind a slow link, short enough that a pre-flight run on a laptop with +#: no route out is over before anybody reaches for the interrupt. +TIMEOUT_SECONDS = 5.0 + +#: A repository nobody has: the token probe needs one, and the interesting answers — 401, 403, and +#: *this token cannot write code* — do not depend on it existing. Asking about a real repository +#: would make the result depend on which one, which is a question this command has no way to ask. +_ANY_REPOSITORY = "hullwork/preflight" + + +def _answers(url: str, timeout: float = TIMEOUT_SECONDS) -> bool | None: + """Whether the host at `url` accepts a connection. `None` means the question could not be put. + + Deliberately a socket rather than an HTTP request: this asks *is there something there*, and a + forge that answers `404` to an unauthenticated `GET /` is answering. Anything HTTP-shaped would + mix reachability with a second question that has its own check below. + """ + parsed = urlparse(url) + if not parsed.hostname: + return None + port = parsed.port or (443 if parsed.scheme == "https" else 80) + try: + with socket.create_connection((parsed.hostname, port), timeout=timeout): + return True + except (TimeoutError, OSError): + return None + + +def _may_push(url: str, token: str, repo: str, declared: str | None) -> bool | None: + """What the **token** may do, or `None` when the forge would not say. + + Item 073's probe, reused rather than rewritten. + """ + try: + return credentials.token_may_write_code(url, token, repo, declared_kind=declared) + except (urllib.error.URLError, TimeoutError, OSError, ValueError): + return None + + +def _reachability(settings: Settings) -> list[Finding]: + """The layer `doctor` does not have. **Absent rather than guessed** when nothing is configured. + + A row reading `unknown` about a forge nobody named would be noise dressed as rigour, and this is + the command somebody runs before they have configured anything at all. + """ + found: list[Finding] = [] + if not settings.forge_url: + return found + + reached = _answers(settings.forge_url) + if reached: + found.append(Finding("forge answers", State.OK, "the host accepted a connection.")) + else: + found.append( + Finding( + "forge answers", + State.UNKNOWN, + "could not reach it from here. That is a fact about this machine as much as about " + "the forge — a VPN, a private address, or a name this host does not resolve — so " + "it is not counted against you. Run this again from where the instance will live.", + ) + ) + + if not settings.forge_token: + return found + + # **Only when the host answered.** Asking a token question of an unreachable host produces a + # network failure dressed as an authorisation answer, which is the collapse this module exists + # to refuse. + if not reached: + found.append( + Finding( + "forge token", + State.UNKNOWN, + "not asked: the forge did not answer, so nothing here knows what it may do.", + ) + ) + return found + + may = _may_push( + settings.forge_url, + settings.forge_token.get_secret_value(), + _ANY_REPOSITORY, + settings.forge_kind, + ) + if may is None: + found.append( + Finding( + "forge token", + State.UNKNOWN, + "the forge answered but would not say what this token may do. Nothing is claimed " + "about it either way.", + ) + ) + elif may: + found.append( + Finding( + "forge token", + State.BROKEN, + "this token can push code. The always-on service must not hold one that can — " + "issue write and content read is the whole of what it needs, and DR-0005 splits " + "them so that a compromise of the receiver cannot reach your branches.", + ) + ) + else: + found.append( + Finding( + "forge token", State.OK, "accepted, and it cannot push code. That is the shape." + ) + ) + return found + + +def _what_the_file_is_missing(answers: Answers | None, text: str | None) -> list[Finding]: + """The capability question, in the same listing as everything else. Item 200. + + `scaffold.what_is_still_needed` is where a variable's consequence is written, and it stays the + only place: this reads it and gives each line a `Finding` so one report can hold *this variable + is empty and here is what it buys* beside *the forge answered*. Two sections repeating each + other is how the two answers drifted apart in the first place. + """ + if answers is None or text is None: + return [] + from hullwork.scaffold import what_is_still_needed + + said: list[Finding] = [] + capability = "" + for line in what_is_still_needed(answers, text): + if not line.startswith(" "): + capability = line.rstrip(":").removeprefix("For ") + continue + name, _, why = line.strip().partition(" — ") + said.append(Finding(name, State.BROKEN, f"{why} Needed for: {capability}.")) + return said + + +def examine( + settings: Settings, + *, + answers: Answers | None = None, + environment_file: Path | None = None, +) -> list[Finding]: + """Every check `doctor` makes, before there is anything to make them against, plus reachability. + + The database check is rewritten rather than dropped: there being no schema is the **expected** + state of a pre-flight, and `State.EXPECTED` exists for exactly this — a gap that is real, + deliberate, and must not be closed. Leaving it `broken` would put a red herring on the first + line and teach the reader to skim the rest. + """ + # **The real file when there is one** (item 200). `_NOWHERE` is a sentinel and it was reaching + # the operator: the `deployment` check named `/nonexistent/preflight/.env` in its own output, + # which is a path nobody has and an instruction nobody can follow. `init` knows where the file + # it just wrote lives, so it says so. + factory = make_session_factory(make_engine("sqlite:///:memory:")) + with factory() as session: + found = _examine_an_instance( + session, + settings, + code_forge=None, + env_file=environment_file or _NOWHERE, + compose_file=None, + before_there_is_an_instance=True, + ) + text = None + if environment_file is not None and environment_file.exists(): + text = environment_file.read_text(encoding="utf-8") + return [ + *found, + *_what_the_file_is_missing(answers, text), + *_reachability(settings), + ] + + +def exit_code(found: list[Finding]) -> int: + """`1` when something is broken, and **never for an `unknown`** (item 073). + + A warning wired into an exit code with no action available to clear it is not a signal. The + likeliest `unknown` here is a laptop that cannot reach the forge it is configuring, and failing + somebody's install script over a fact about their laptop is how a check gets ignored for ever. + """ + return 1 if any(one.state is State.BROKEN for one in found) else 0 + + +#: `environment_gaps` wants a path; there is no deployment file to compare against yet, and a path +#: that does not exist is the honest input — the check itself reports *not checked* for it, which is +#: the right answer here and the one item 194 made sure it gives. +_NOWHERE = __import__("pathlib").Path("/nonexistent/preflight/.env") diff --git a/hullwork/sandbox/net.py b/hullwork/sandbox/net.py index b0f85b9..2e127bf 100644 --- a/hullwork/sandbox/net.py +++ b/hullwork/sandbox/net.py @@ -72,6 +72,23 @@ #: one fails loudly at `docker run` rather than quietly. GATEWAY_IMAGE = "hullwork:dev" + +def gateway_image(configured: str | None) -> str: + """The image the gateway runs from — **the instance's own, not a constant** (item 201). + + The constant above named the image *this installation built*, which stopped being true the day + a deployment could pull a published one instead: there is no `hullwork:dev` on a host that never + built, so the gateway — the component that observes and seals model traffic — could not start. + That is item 191's failure, and it was already being worked around by a `docker tag` on every + deploy, with a comment recording the day nobody ran it and the gateway was four days behind the + dispatcher it serves. + + The default is unchanged on purpose. Every deployment written before this item names no image, + and moving them to something they do not have would be this item causing the failure it exists + to prevent. + """ + return configured or GATEWAY_IMAGE + #: Where the credential and the journal live inside the gateway. One directory, one volume — see #: `_seed_volume` for why it is a volume and not two bind mounts. RUN_DIR = "/run/hullwork" diff --git a/hullwork/scaffold.py b/hullwork/scaffold.py index 614d16e..95b6ea2 100644 --- a/hullwork/scaffold.py +++ b/hullwork/scaffold.py @@ -39,6 +39,8 @@ from enum import StrEnum from pathlib import Path +from hullwork import __version__ + #: What the scaffold writes. Two files, and neither of them is a secret. COMPOSE_FILE = "docker-compose.yml" ENVIRONMENT_FILE = "deploy.env" @@ -140,6 +142,8 @@ class Reach(StrEnum): # **The one line that only exists on one side.** DR-0009, and the receiver refuses to start if # it finds it. "forge_code_token": Reach.DISPATCHER, + # Only the dispatcher runs a gateway; the receiver never starts a container (item 201). + "gateway_image": Reach.DISPATCHER, # The model route: only the half that runs attempts talks to a provider. "model_endpoint": Reach.DISPATCHER, "model_auth_style": Reach.DISPATCHER, @@ -277,6 +281,11 @@ def compose(*, docker_gid: str | None) -> str: ) receiver_env = environment_block(Reach.RECEIVER) dispatcher_env = environment_block(Reach.DISPATCHER) + # **The image doing the scaffolding pins itself** (item 201). There is no data file to read — + # only `hullwork` is packaged — and there does not need to be: `init` is run from the image, so + # the version it writes is provably one you just pulled. A dev checkout pins its own + # `__version__`, which is the honest answer for a tree that may be ahead of any release. + pinned = __version__ return f"""# Hullwork, as a real deployment. Written by `hullwork init`. # # Two programs, and the split is the product rather than a precaution (DR-0009, spec M2 §1): @@ -288,7 +297,7 @@ def compose(*, docker_gid: str | None) -> str: # # Start it with both files loaded, in this order: # -# set -a; . ./{ENVIRONMENT_FILE}; set +a; docker compose up -d --build +# set -a; . ./{ENVIRONMENT_FILE}; set +a; docker compose up -d # # `docker compose up` on its own gives you ingest, deduplication, triage and issues. It does not # attempt fixes and no setting here turns that on: that is `autofix.agent` in each project's own @@ -296,21 +305,31 @@ def compose(*, docker_gid: str | None) -> str: services: api: - build: - # **Where the source is, and it is not here** (item 127). This directory is your deployment, - # and `hullwork init` asks that it not be the checkout — a clone already carries a - # `docker-compose.yml` of its own, which `init` would keep. So the context is a variable, and - # `.` only works if you ignored that advice. - context: ${{BUILD_SOURCE:-.}} - args: - # Empty by default: a self-hosted tool should not install an error-reporting SDK you did - # not ask for. Set `BUILD_EXTRAS=[telemetry]` when you set HULLWORK_ERROR_DSN, or the - # receiver refuses to start — it will not pretend to be watched when it is not. - EXTRAS: "${{BUILD_EXTRAS:-}}" - # **Tagged with the instance** (item 130). A constant here means the second instance on a host - # takes the name and the first keeps running an image nothing points at — measured on the host - # that runs two. The default keeps a single-instance deployment on `hullwork:dev`. - image: hullwork:${{HULLWORK_INSTANCE:-dev}} + # **Pulled, not built** (item 201). Until then this file built from a checkout and never + # mentioned the published image at all, so the documented path was: clone the source, compile + # 500 MB, and never find out one exists. Nothing about running Hullwork needs its source. + # + # **Not `HULLWORK_IMAGE`**: that namespace belongs to `Settings`, which refuses to start on a + # name it does not know — the guard that exists so a typo is an error rather than a feature + # silently off. This is a compose knob, not a setting, so it is named like the file's others. + # + # Pinned rather than floating: `edge` follows `main` and pinning documentation to it would be + # pinning to nothing. Move it deliberately, and `hullwork init` will tell you what changed. + image: ${{RUN_IMAGE:-ghcr.io/easybytehub/hullwork:{pinned}}} + # **To build instead** — you are changing Hullwork's own code, which is the only reason to: + # set BUILD_SOURCE to your checkout, uncomment the four lines below, and add `--build` to + # `docker compose up`. That is what BUILD_SOURCE was always for. + # + # **And set RUN_IMAGE per instance if you build more than one here** (item 130, measured on + # the host that runs two): a built image takes the name it is given, so two instances sharing + # one leaves the second holding the tag and the first running an image nothing points at. + # `RUN_IMAGE=hullwork:${{HULLWORK_INSTANCE:-dev}}` restores exactly what that item decided. + # Pulling has no such problem — nothing is being tagged. + # + # build: + # context: ${{BUILD_SOURCE:-.}} + # args: + # EXTRAS: "${{BUILD_EXTRAS:-}}" restart: unless-stopped # Bound to an address of your choosing, and the default is loopback because the webhook # endpoint is real: the token is a path segment, and anything that can reach this URL can post @@ -369,7 +388,7 @@ def compose(*, docker_gid: str | None) -> str: profiles: [autofix] # The same tag as the receiver above, always: two halves of one instance on two builds is a # worse failure than the one item 130 is about. - image: hullwork:${{HULLWORK_INSTANCE:-dev}} + image: ${{RUN_IMAGE:-ghcr.io/easybytehub/hullwork:{pinned}}} depends_on: [api] restart: unless-stopped # **No `ports:`, no healthcheck, nothing listening.** The dangerous property is listening *and* @@ -490,7 +509,7 @@ def environment(*, docker_gid: str | None) -> str: # # Loaded into the shell before compose, so the file is read by you and not by the application: # -# set -a; . ./{ENVIRONMENT_FILE}; set +a; docker compose up -d --build +# set -a; . ./{ENVIRONMENT_FILE}; set +a; docker compose up -d # # It is deliberately not `.env`: `Settings` reads that one with `extra="forbid"` and refuses to # start on any key in it that is not a setting — which is correct, and which makes `.env` the wrong @@ -579,6 +598,12 @@ def environment(*, docker_gid: str | None) -> str: # beats a default that fails obscurely. BUILD_SOURCE= +# **Set by `hullwork init`, not by you** (item 201). The gateway that observes and seals model +# traffic runs Hullwork's own code, so it needs an image — and hardcoding one meant a deployment +# that pulls had no such image on the host and no gateway. Change it only if you changed the image +# above. +HULLWORK_GATEWAY_IMAGE= + # Who this instance is, when a host runs more than one. Every container, network and volume an # attempt creates is labelled with it, and this instance's reaper removes only what carries its # own. **Set it to something distinct on the second instance you put on a host** — with both at @@ -722,3 +747,181 @@ def write(into: Path, *, docker_gid: str | None) -> Written: f"not: `{STAT_GROUP}` on the host, into `group_add` in the compose file." ) return done + + +# --- the addressable minimum (item 197) ---------------------------------------------------------- + +#: One thing an instance can do, the variables it cannot do it without, and what each one buys. +#: +#: **Grounded in what `doctor` already checks**, not invented here: every variable below is one some +#: check refuses to work without, and the sentence beside it is that check's own consequence. The +#: point of the table is that the *union* of it is the nineteen blanks — and nobody needs the union. +@dataclass(frozen=True) +class Capability: + name: str + #: `variable -> what having it buys`. Ordered: the first missing one is the one to do next. + needs: tuple[tuple[str, str], ...] + + +INGEST = Capability( + "errors arrive, are deduplicated and triaged, and become issues", + ( + ( + "HULLWORK_FORGE_URL", + "which forge holds your repositories. Without it there is nowhere to file", + ), + ( + "HULLWORK_FORGE_TOKEN", + "content read and issue write, and **not** push. A token cannot mint a token, so this " + "is a web interface and a human, once", + ), + ( + "HULLWORK_BASE_URL", + "where your instance is reachable from your tracker. Hosted GlitchTip refuses to call " + "private addresses at all", + ), + ), +) + +ENRICHMENT = Capability( + "the full error behind each issue: frames, culprit, release", + ( + ("HULLWORK_TRACKER_URL", "where to ask for the occurrence a webhook only summarised"), + ("HULLWORK_TRACKER_TOKEN", "a read on issues. It never needs to write anything"), + ("HULLWORK_TRACKER_ORG", "which organisation on that tracker to ask about"), + ), +) + +AUTOFIX = Capability( + "attempting a fix, behind the `autofix` profile and opted into per project", + ( + ( + "HULLWORK_FORGE_CODE_TOKEN", + "the only credential here that can push. The always-on service refuses to start " + "holding it, so it reaches the dispatcher and nothing else", + ), + ( + "HULLWORK_MODEL_KEY", + "an agent has nothing to think with otherwise, and every attempt fails before the " + "sandbox starts", + ), + ), +) + +CAPABILITIES = (INGEST, ENRICHMENT, AUTOFIX) + + +@dataclass(frozen=True) +class Answers: + """What a person said when asked, and **nothing they typed in confidence**. + + No field here holds a credential, and `test_no_secret_is_ever_written_by_an_answer` asserts that + by reading the field names: a setup command is not worth putting a token into a terminal's + scrollback for, so the questions ask which things exist and never what they are. What is missing + is then named, and the operator pastes it into a file with a mode that already protects it. + """ + + forge_url: str | None = None + tracker_url: str | None = None + base_url: str | None = None + build_source: str | None = None + #: Whether this instance should attempt fixes at all. `False` is the product without an agent, + #: which is a whole product and the documented default. + autofix: bool = False + + def wanted(self) -> tuple[Capability, ...]: + """Which capabilities this operator asked for. Ingest is not optional: it is the product.""" + chosen = [INGEST] + if self.tracker_url is not None: + chosen.append(ENRICHMENT) + if self.autofix: + chosen.append(AUTOFIX) + return tuple(chosen) + + def assigned(self) -> dict[str, str]: + """Variable to value, for the answers that are values rather than choices.""" + pairs = ( + ("HULLWORK_FORGE_URL", self.forge_url), + ("HULLWORK_TRACKER_URL", self.tracker_url), + ("HULLWORK_BASE_URL", self.base_url), + ("BUILD_SOURCE", self.build_source), + ) + return {name: value for name, value in pairs if value} + + +def filled(text: str, answers: Answers) -> str: + """The environment file with what was answered written in, and nothing else touched. + + **Assignment only, never deletion.** A variable nobody answered keeps its blank line and the + comment above it that says what it is for — the file is the reference as well as the + configuration, and a scaffold that removed the lines it judged irrelevant would be deciding for + somebody what they will never want. + """ + for name, value in answers.assigned().items(): + text = text.replace(f"\n{name}=\n", f"\n{name}={value}\n", 1) + return text + + +def what_is_still_needed(answers: Answers, text: str) -> list[str]: + """What is left to do, for the capabilities that were asked for and no others. + + This is the item's whole subject. `init` used to print five numbered steps, the same list for + everybody, and it was the union: an instance that only ingests needs four of nineteen variables + and was shown the lot. A minimum nobody can address is not a minimum. + + Values are never echoed. The report names variables and consequences, so that pasting it into an + issue cannot leak what it was just told. + """ + empty = { + line.split("=", 1)[0] + for line in text.splitlines() + if line.endswith("=") and line[:-1].isupper() + } + assigned = answers.assigned() + said: list[str] = [] + for capability in answers.wanted(): + missing = [ + (name, why) + for name, why in capability.needs + if name in empty and name not in assigned + ] + if not missing: + continue + said.append(f"For {capability.name}:") + said += [f" {name} — {why}" for name, why in missing] + return said + + +#: What is asked, in the order the answers matter. Every one has a default reachable with enter, and +#: **enter everywhere produces the file the non-interactive run produces** — the documented path and +#: the lazy path are the same path, which is the only way the documentation stays true. +QUESTIONS = ( + ("forge_url", "Which forge holds your repositories?", "URL, or enter to fill it in later"), + ( + "tracker_url", + "Where does your error tracker live?", + "URL, or enter for none yet — errors can still arrive by webhook", + ), + ( + "base_url", + "Where will this instance be reachable?", + "URL your tracker can call, or enter", + ), + ("build_source", "Where is the checkout you cloned?", "path, or enter"), + ("autofix", "Should it attempt fixes?", "y/N — no is a whole product, and the default"), +) + + +def ask(prompt: object) -> Answers: + """Put the questions to a person. `prompt(question, hint)` returns their answer as text. + + Injected rather than reading `input` directly so the questions can be tested without a terminal, + and so the caller owns the decision that there is a terminal at all. + """ + said: dict[str, object] = {} + for field_name, question, hint in QUESTIONS: + answer = str(prompt(question, hint) or "").strip() # type: ignore[operator] + if not answer: + continue + said[field_name] = answer.lower().startswith("y") if field_name == "autofix" else answer + return Answers(**said) # type: ignore[arg-type] diff --git a/hullwork/work.py b/hullwork/work.py index c2cb4b4..ad328be 100644 --- a/hullwork/work.py +++ b/hullwork/work.py @@ -1624,6 +1624,7 @@ def _attempt( from hullwork.forge import ForgeError from hullwork.ingest import _manifest_for from hullwork.sandbox import image as image_module + from hullwork.sandbox import net as net_module from hullwork.sandbox.net import Cable from hullwork.sandbox.run import Sandbox @@ -1782,6 +1783,10 @@ def _attempt( allowed_models=_allowed_models(settings), max_tokens=settings.max_attempt_tokens, auth_style=settings.model_auth_style, + # **The instance's image, not a constant** (item 201). A deployment that pulls the + # published image has no `hullwork:dev` on its host, and the gateway is the + # component that observes and seals model traffic. + image=net_module.gateway_image(settings.gateway_image), ) ) # Before the model is called, and it raises rather than warns. diff --git a/tests/test_init_meets_a_real_directory.py b/tests/test_init_meets_a_real_directory.py index 99991c9..5855bf8 100644 --- a/tests/test_init_meets_a_real_directory.py +++ b/tests/test_init_meets_a_real_directory.py @@ -98,23 +98,28 @@ def test_the_build_context_is_said_rather_than_assumed() -> None: `Dockerfile: no such file or directory` while the assertion below guaranteed one — a stranger hit it, from a value they never chose. The interpolation stays; the value ships empty, failing at the same step with `BUILD_SOURCE` named in the error. - """ - import yaml - built = yaml.safe_load(scaffold.compose(docker_gid="989"))["services"]["api"]["build"] + **Read from the text since item 201**, and that is a weakening worth naming. Pulling became the + default, so the build block is commented out — a live `build:` beside an `image:` makes + `docker compose up` build on any host that does not already have the image, which is every + fresh one, which is the whole thing item 201 removed. The guard is now that the instructions a + builder uncomments still carry the right value, rather than that the YAML does. Prose-shaped, + and this repository distrusts those: it is here because the alternative was deleting it. + """ + text = scaffold.compose(docker_gid="989") - assert built["context"] == "${BUILD_SOURCE:-.}" + assert "context: ${BUILD_SOURCE:-.}" in text assert "\nBUILD_SOURCE=\n" in scaffold.environment(docker_gid="989") def test_the_dsn_and_the_extra_that_makes_it_usable_are_written_together() -> None: """`deploy.env` named `HULLWORK_ERROR_DSN` and the compose beside it built with no extras, so setting the variable the scaffold wrote made the receiver refuse to start. The refusal is right; - handing somebody an unusable variable is not.""" - import yaml + handing somebody an unusable variable is not. - built = yaml.safe_load(scaffold.compose(docker_gid="989"))["services"]["api"]["build"] - assert built["args"]["EXTRAS"] == "${BUILD_EXTRAS:-}" + Read from the text since item 201, for the reason the test above gives. + """ + assert 'EXTRAS: "${BUILD_EXTRAS:-}"' in scaffold.compose(docker_gid="989") written = scaffold.environment(docker_gid="989") dsn_at = written.index("HULLWORK_ERROR_DSN=") @@ -156,29 +161,39 @@ def test_the_image_is_tagged_with_the_instance() -> None: A deployment directory, a database, a set of sandbox objects and an image name are the four things two instances on one host both want. Item 125 did the third; this is the fourth. + + **Item 201 broke this and the derived tree's gates caught it.** The default became a pulled + image, which has no such problem — nothing is being tagged, so two instances sharing one name + collide over nothing. The problem is only a *built* image taking a name, so what this asserts + now is that a builder still has the knob and is told to use it. The property is intact; the + literal that used to carry it is not, and it was the literal that failed. """ import yaml - services = yaml.safe_load(scaffold.compose(docker_gid="989"))["services"] + text = scaffold.compose(docker_gid="989") + services = yaml.safe_load(text)["services"] - assert services["api"]["image"] == "hullwork:${HULLWORK_INSTANCE:-dev}" + assert "RUN_IMAGE" in services["api"]["image"], "the per-instance knob is still there" + assert "HULLWORK_INSTANCE" in text, "and a builder is told what to set it to" assert services["dispatcher"]["image"] == services["api"]["image"], ( "two halves of one instance on two builds is a worse failure than the one being fixed" ) -def test_one_instance_keeps_the_name_it_has_today() -> None: - """The default is the old constant, so nobody who never heard of this has to do anything — - the same rule item 125 and item 127 both followed. +def test_one_instance_needs_no_checkout_and_no_build() -> None: + """**This asserted the opposite until item 201**, and deliberately: the default was the old + constant so that nobody who never heard of per-instance tagging had to do anything. - Asserted per service and by equality, not by presence: the compose names the image twice, and a - check that only asks whether the string appears *somewhere* is satisfied by the other one. Found - by reintroducing exactly that. + The default changed on purpose, because the old one made step one of an installation a `git + clone` and a 500 MB compile for a product that publishes its image. What survives unchanged is + the shape of the assertion — **per service and by equality, not by presence**: the compose names + the image twice, and a check that only asks whether a string appears somewhere is satisfied by + the other one. Found, back then, by reintroducing exactly that. """ import yaml services = yaml.safe_load(scaffold.compose(docker_gid="989"))["services"] for name in ("api", "dispatcher"): - assert services[name]["image"] == "hullwork:${HULLWORK_INSTANCE:-dev}", name - assert ":-dev}" in services[name]["image"], f"{name}: unset must resolve as it always did" + assert services[name]["image"].startswith("${RUN_IMAGE:-ghcr.io/"), name + assert "build" not in services[name], f"{name}: pulling is the default, building is opt-in" diff --git a/tests/test_one_door.py b/tests/test_one_door.py new file mode 100644 index 0000000..130aa6d --- /dev/null +++ b/tests/test_one_door.py @@ -0,0 +1,154 @@ +"""One command answers *what is still missing*. Item 200. + +Two functions answered it four hours apart, both written the same day: `what_is_still_needed` named +variables scoped to what the operator asked for, and `preflight.examine` named checks and could say +whether the forge answered. Neither contained the other and nothing made them agree — which is items +193 and 194 with different nouns, caught before it drifted rather than after. + +`init` is the door. The `preflight` subcommand is gone. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +import socket +import urllib.request +from io import StringIO +from pathlib import Path + +import pytest + +from hullwork import preflight, scaffold +from hullwork.cli import build_parser, main +from hullwork.config import Settings + + +def _init(tmp_path: Path, *extra: str) -> str: + out = StringIO() + assert main(["init", "--into", str(tmp_path), *extra], out=out) == 0 + return out.getvalue() + + +# --- one door ------------------------------------------------------------------------------------ + + +def test_the_second_run_says_where_you_stand(tmp_path: Path) -> None: + """**The run that matters, and it was the least useful output in the product.** It said + *nothing to do: both files already exist* — at the exact moment somebody has pasted a token and + wants to know whether it works. + """ + _init(tmp_path) + + again = _init(tmp_path) + + assert "Nothing to do" not in again + assert "HULLWORK_FORGE_TOKEN" in again, "the report, on a run that writes nothing" + + +def test_the_first_run_says_it_too(tmp_path: Path) -> None: + """Both runs, or the report is a consolation prize for having done it twice.""" + assert "HULLWORK_FORGE_TOKEN" in _init(tmp_path) + + +def test_there_is_no_preflight_subcommand() -> None: + """Nineteen subcommands is a lot; twenty is more. A second door to a room that already has one + is surface, and this repository's own rule is that a subcommand is declared before it exists.""" + from hullwork import upstream + + with pytest.raises(SystemExit): + build_parser().parse_args(["preflight"]) + + assert "cli:preflight" not in upstream.OPERATIONS + + +def test_one_function_answers_what_is_missing() -> None: + """**Asserted by construction**, because two lists kept equal by hand is what produced this. + + The capability table is where a variable's consequence is written. A second enumeration of + variables and reasons — anywhere — is the defect coming back. + """ + import inspect + + from hullwork.cli import _cmd_init + + source = inspect.getsource(_cmd_init) + + assert "preflight.examine" in source, "the report comes from the one place that assembles it" + assert "HULLWORK_" not in source, ( + "a variable's consequence is written in the capability table and read from there" + ) + + +# --- what it costs, and the bound on it ---------------------------------------------------------- + + +def test_nothing_configured_reaches_nothing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """**The commonest first contact there is.** A setup command that quietly opens a socket is one + somebody has to audit, so an unconfigured run must be able to say it contacted nobody — asserted + by making every route out raise, the way `features` does it. + """ + + def forbidden(*_a: object, **_k: object) -> None: + raise AssertionError("init reached the network with nothing configured") + + monkeypatch.setattr(socket, "create_connection", forbidden) + monkeypatch.setattr(urllib.request, "urlopen", forbidden) + + assert _init(tmp_path) + + +def test_the_help_says_it_reaches_the_network() -> None: + """It reached nothing before this item. A behaviour change in the first command a stranger runs + belongs in that command's own help, not in a release note nobody reads.""" + parser = build_parser() + + said = parser.parse_args(["init", "--into", "."]) + + del said + text = parser.format_help() + assert "init" in text + from hullwork.cli import _init_description + + assert "network" in _init_description().lower() + + +def test_no_sentinel_path_reaches_the_operator(tmp_path: Path) -> None: + """**Found by running it.** The environment check named `/nonexistent/preflight/.env` — a + sentinel this module uses when there is no file to compare against, printed at somebody who has + a real one two lines above. A path nobody has is an instruction nobody can follow. + """ + scaffold.write(tmp_path, docker_gid=None) + + found = preflight.examine( + Settings(), environment_file=tmp_path / scaffold.ENVIRONMENT_FILE + ) + + assert "nonexistent" not in " ".join(one.detail for one in found) + + +def test_an_unknown_still_never_sets_the_exit_code() -> None: + """Item 073's rule survives the move. A laptop that cannot reach the forge it is configuring + must not fail somebody's install script for a fact about the laptop.""" + from hullwork.doctor import Finding, State + + assert preflight.exit_code([Finding("x", State.UNKNOWN, "y")]) == 0 + assert preflight.exit_code([Finding("x", State.BROKEN, "y")]) == 1 + + +def test_the_report_names_the_capability_a_variable_belongs_to(tmp_path: Path) -> None: + """One listing, not two sections repeating each other. A variable with no capability beside it + is a name; the capability is what makes it a decision somebody can take.""" + scaffold.write(tmp_path, docker_gid=None) + + found = preflight.examine( + Settings(), + answers=scaffold.Answers(), + environment_file=tmp_path / scaffold.ENVIRONMENT_FILE, + ) + + said = " ".join(one.detail for one in found) + assert "become issues" in said, "the capability, in the same listing as the variable" + assert any(one.check == "HULLWORK_FORGE_TOKEN" for one in found) diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py index 3a87c2d..e2ecf20 100644 --- a/tests/test_scaffold.py +++ b/tests/test_scaffold.py @@ -134,6 +134,13 @@ def test_init_says_what_only_a_person_can_do(tmp_path: Path, capsys: object) -> The three things it cannot do are the three that need a human: mint a token that can file issues and not push, choose an address the error tracker can actually reach, and decide whether fixes are attempted at all — which is per project, in the project's own manifest. + + **Rewritten by substance in item 197, and the wording it used to pin is deliberately gone.** It + asserted `"Mint a forge token"` from a numbered list printed identically to everybody, which was + the union of every capability's requirements — nineteen variables' worth of instruction for a + reader who needed four. The three properties above are what mattered and all three survive; the + sentences carrying them are now per-variable and per-capability. A test that pinned the copy + would have made improving it look like breaking it. """ from io import StringIO @@ -141,10 +148,11 @@ def test_init_says_what_only_a_person_can_do(tmp_path: Path, capsys: object) -> assert main(["init", "--into", str(tmp_path)], out=out) == 0 said = out.getvalue() - assert "Mint a forge token" in said + assert "HULLWORK_FORGE_TOKEN" in said assert "not** push" in said - assert "your error tracker can actually reach" in said - assert "Nothing here turns it on" in said, "attempting fixes is a per-project decision" + assert "reachable from your tracker" in said + assert "opted into per project" in said, "attempting fixes is a per-project decision" + assert "Attempting fixes is off" in said, "and it is off until somebody says otherwise" def test_init_touches_no_database(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_the_default_is_the_published_image.py b/tests/test_the_default_is_the_published_image.py new file mode 100644 index 0000000..7585c32 --- /dev/null +++ b/tests/test_the_default_is_the_published_image.py @@ -0,0 +1,111 @@ +"""What a deployment runs before anybody has cloned anything. Item 201. + +Two compose files shipped, and the one for real deployments was the one that needed a clone: this +repository's own pulls `ghcr.io/easybytehub/hullwork` and is labelled the *evaluation* stack, while +the file `hullwork init` writes builds from `${BUILD_SOURCE}` and does not contain the string +`ghcr.io` anywhere. So the documented path was clone, build 500 MB, and never find out a published +image exists. + +The gateway is why this is more than a compose edit: its image was the constant `hullwork:dev`, and +its own docstring names the assumption — *the image this installation built* — that pulling breaks. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from hullwork import scaffold +from hullwork.sandbox import net + +ROOT = Path(__file__).resolve().parent.parent +SURFACE = json.loads((ROOT / "docs/published-surface.json").read_text(encoding="utf-8")) + + +def _compose() -> str: + return scaffold.compose(docker_gid=None) + + +# --- the default --------------------------------------------------------------------------------- + + +def test_the_scaffolded_compose_pulls_a_published_image() -> None: + """The whole item. A deployment should not have to compile the product to run it.""" + text = _compose() + + assert "ghcr.io/easybytehub/hullwork:" in text + + +def test_it_pins_the_release_this_repository_documents() -> None: + """**Asserted against the recorded surface**, not against a literal typed twice. A compose file + telling somebody to run a version the documentation does not describe is the two-halves problem + item 192 closed, arriving in a third file. + """ + text = _compose() + + assert f"ghcr.io/easybytehub/hullwork:{SURFACE['version']}" in text + + +def test_building_is_still_possible_and_now_explicit() -> None: + """Item 197's rule: a shorter file is not the goal. Somebody changing the code still needs this, + and it is what `BUILD_SOURCE` was always actually for.""" + text = _compose() + + assert "BUILD_SOURCE" in text + assert "build:" in text + + +def test_a_checkout_is_no_longer_something_only_a_person_can_supply() -> None: + """`BUILD_SOURCE` was one of four variables the report said a person had to fill in, for a + deployment with no business needing a checkout at all.""" + needed = [ + name + for capability in scaffold.CAPABILITIES + for name, _ in capability.needs + ] + + assert "BUILD_SOURCE" not in needed + + +# --- the gateway, which pulling would otherwise break -------------------------------------------- + + +def test_the_gateway_runs_the_instance_image_rather_than_a_constant() -> None: + """**Item 191's failure, waiting to happen again.** With a pulled deployment there is no + `hullwork:dev` on the host, so a constant sends the gateway to an image nobody has — and the + gateway is the component that observes and seals model traffic. + """ + assert net.gateway_image("ghcr.io/easybytehub/hullwork:0.1.0a8") == ( + "ghcr.io/easybytehub/hullwork:0.1.0a8" + ) + + +def test_a_deployment_that_builds_gets_the_image_it_built() -> None: + """The other side, and the reason this is a function rather than a rename: this repository's own + instance builds on purpose, and its gateway has to be what it built.""" + assert net.gateway_image("hullwork:dogfood") == "hullwork:dogfood" + + +def test_with_nothing_configured_it_is_what_it_always_was() -> None: + """Every deployment that exists today was written before this item. Changing the default under + them would turn a working instance into one whose gateway cannot start, which is the failure + this item is preventing rather than causing.""" + assert net.gateway_image(None) == "hullwork:dev" + + +def test_the_scaffold_tells_the_dispatcher_which_image_to_use() -> None: + """One value, set by the scaffold and never by a person — so `deploy-atlas.sh`'s retag can go + and a pulled deployment's gateway is the version it says it is.""" + assert "HULLWORK_GATEWAY_IMAGE" in _compose() + + +def test_the_deploy_script_no_longer_retags() -> None: + """A shell command keeping two names equal is the same defect as items 193, 194 and 200. It ran + on every deploy, and the day nobody ran it the gateway was four days behind the dispatcher.""" + script = ROOT / "scripts/deploy-atlas.sh" + if not script.exists(): # pragma: no cover - withheld from publication + return + + assert "docker tag hullwork:" not in script.read_text(encoding="utf-8") diff --git a/tests/test_the_nineteen_blanks.py b/tests/test_the_nineteen_blanks.py new file mode 100644 index 0000000..53c52f7 --- /dev/null +++ b/tests/test_the_nineteen_blanks.py @@ -0,0 +1,168 @@ +"""What `hullwork init` asks, and what it says is still missing. Item 197. + +Measured before anything was written: `init` produces a `deploy.env` of **120 lines and nineteen +empty variables**, then prints the same five numbered steps to everybody. The file is right and its +comments are good; what is absent is any way to address the *minimum*. An instance that only ingests +errors needs four of the nineteen, one that attempts fixes needs seven, and nothing in the output +says which reader is which. + +The operator's direction on 2026-08-10: what a developer sees is the product, and it has to be +reachable with the minimum. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +from pathlib import Path + +from hullwork import scaffold + + +def _written(tmp_path: Path) -> str: + scaffold.write(tmp_path, docker_gid=None) + return (tmp_path / scaffold.ENVIRONMENT_FILE).read_text(encoding="utf-8") + + +# --- the three properties that must survive ------------------------------------------------------ + + +def test_answering_nothing_writes_what_it_writes_today(tmp_path: Path) -> None: + """**The default path stays the documented one.** `init` is documented as running from inside + the image, before the package exists anywhere; an installer has no terminal to answer with, and + a stranger pressing enter is following the same instructions as one who could not be asked. + """ + silent = _written(tmp_path / "silent") + + answered = scaffold.filled(_written(tmp_path / "answered"), scaffold.Answers()) + + assert answered == silent + + +def test_an_answer_reaches_the_file(tmp_path: Path) -> None: + """The whole point of a harness: what you said is written where it goes, not repeated back to + you as an instruction to type it yourself.""" + text = scaffold.filled( + _written(tmp_path), scaffold.Answers(forge_url="https://forge.example.com") + ) + + assert "HULLWORK_FORGE_URL=https://forge.example.com" in text + assert "\nHULLWORK_FORGE_URL=\n" not in text, "the blank it replaced is gone, not duplicated" + + +def test_it_never_writes_a_variable_it_was_not_given(tmp_path: Path) -> None: + """Everything unanswered stays exactly as the scaffold wrote it — blank, above the comment that + says what it is for. A shorter file is not the goal; an addressable minimum is.""" + text = scaffold.filled(_written(tmp_path), scaffold.Answers(forge_url="https://f.example")) + + assert "\nHULLWORK_TRACKER_TOKEN=\n" in text + assert "\nHULLWORK_MODEL_KEY=\n" in text + + +# --- what it says is still missing --------------------------------------------------------------- + + +def test_it_names_what_is_missing_for_what_was_asked_for(tmp_path: Path) -> None: + """Not the union. A reader who said they do not want fixes yet is not shown the two credentials + that only fixing needs — that list is what made the minimum unaddressable.""" + answers = scaffold.Answers(forge_url="https://f.example", autofix=False) + + said = " ".join(scaffold.what_is_still_needed(answers, _written(tmp_path))) + + assert "HULLWORK_FORGE_TOKEN" in said, "it cannot file an issue without one" + assert "HULLWORK_MODEL_KEY" not in said + assert "HULLWORK_FORGE_CODE_TOKEN" not in said + + +def test_asking_for_fixes_puts_those_two_back(tmp_path: Path) -> None: + """The other side of the same answer, so the report is a function of what was said rather than + a shorter list that happens to be right once.""" + answers = scaffold.Answers(forge_url="https://f.example", autofix=True) + + said = " ".join(scaffold.what_is_still_needed(answers, _written(tmp_path))) + + assert "HULLWORK_MODEL_KEY" in said + assert "HULLWORK_FORGE_CODE_TOKEN" in said + + +def test_every_missing_variable_says_what_it_buys(tmp_path: Path) -> None: + """A name with no consequence beside it is the nineteen blanks again, one indentation deeper.""" + lines = scaffold.what_is_still_needed(scaffold.Answers(), _written(tmp_path)) + + assert lines + for line in lines: + assert len(line) > 40, f"a variable named with no reason beside it: {line!r}" + + +def test_what_was_answered_is_not_reported_as_missing(tmp_path: Path) -> None: + """Obvious, and it is the failure that would make the report noise: a harness that asks and then + tells you to go and do it anyway.""" + answers = scaffold.Answers(forge_url="https://f.example") + + text = scaffold.filled(_written(tmp_path), answers) + + said = " ".join(scaffold.what_is_still_needed(answers, text)) + + assert "HULLWORK_FORGE_URL" not in said + + +def test_an_answer_counts_before_the_file_is_written(tmp_path: Path) -> None: + """**Found by mutation**: the test above passes the *filled* text, where an answered variable is + no longer blank — so it cannot tell whether the report is reading the file or remembering the + answer, and the guard that does the remembering was removable without failing anything. + + The contract is the stronger of the two: what somebody just said is not still missing, whether + or not it has reached disk yet. That keeps the function honest away from its one caller, which + happens to write the file first. + """ + answers = scaffold.Answers(forge_url="https://f.example", base_url="https://h.example") + + said = " ".join(scaffold.what_is_still_needed(answers, _written(tmp_path))) + + assert "HULLWORK_FORGE_URL" not in said + assert "HULLWORK_BASE_URL" not in said + assert "HULLWORK_FORGE_TOKEN" in said, "and the ones nobody answered are still there" + + +def test_enter_at_every_question_is_the_same_as_not_being_asked() -> None: + """**The link between the two paths**, and the reason the documentation stays true: the answer + a hurried reader gives and the answer an installer cannot give have to arrive at the same + place. + """ + said = scaffold.ask(lambda question, hint: "") + + assert said == scaffold.Answers() + + +def test_a_question_that_changes_nothing_is_not_asked() -> None: + """Every prompt has to change which variables are written or which sentence is printed. A + question whose answer changes neither teaches its reader that the tool wastes their time.""" + fields = set(scaffold.Answers.__dataclass_fields__) + + asked = {name for name, _, _ in scaffold.QUESTIONS} + + assert asked == fields, "a question with no field behind it, or a field nobody is asked about" + + +# --- the bound on what it may ask ---------------------------------------------------------------- + + +def test_no_secret_is_ever_written_by_an_answer(tmp_path: Path) -> None: + """**Secrets stay the operator's to paste.** Nothing about a setup command is worth putting a + token into a terminal's scrollback for, so the questions ask which things exist and never what + they are. + """ + for field in scaffold.Answers.__dataclass_fields__: + assert "token" not in field and "key" not in field, ( + f"`{field}` invites a secret into an answer; ask whether one exists instead" + ) + + +def test_the_report_never_prints_a_value(tmp_path: Path) -> None: + """It names variables and consequences. A report that echoed what it had just been told would + put a forge URL — and one day something worse — into a log somebody pastes into an issue.""" + answers = scaffold.Answers(forge_url="https://forge.example.com") + + said = " ".join(scaffold.what_is_still_needed(answers, _written(tmp_path))) + + assert "forge.example.com" not in said diff --git a/tests/test_the_preflight.py b/tests/test_the_preflight.py new file mode 100644 index 0000000..62db880 --- /dev/null +++ b/tests/test_the_preflight.py @@ -0,0 +1,213 @@ +"""What is wrong before anything is built. Item 199. + +Item 198 measured that `doctor` answers 26 checks against an in-memory session with no instance in +existence, and that it **touches no network at all** — it said `ok` to `https://forge.example.com`, +an address that does not resolve, because the question it asks is *which forge is this configured +for*. So the guidance already existed one `docker compose up --build` too late, and half of it was +about shape rather than reality. + +This is the command that runs before the containers, and the layer that asks whether the things you +named actually answer. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pydantic import SecretStr + +from hullwork import preflight +from hullwork.config import Settings +from hullwork.doctor import Finding, State + + +class _UnreachableError(Exception): + pass + + +def _nothing_answers(*_a: object, **_k: object) -> None: + raise _UnreachableError + + +def _named(found: list[Finding], check: str) -> Finding: + return next(one for one in found if one.check == check) + + +# --- it runs before there is anything to run against --------------------------------------------- + + +def test_it_writes_nothing_and_creates_no_database(tmp_path: Path) -> None: + """**The trap item 115 exists for**, and the reason `init` opens no database: a stray session + creates an empty `hullwork.db` in whatever directory the operator happens to be standing in. + This command runs in exactly that directory, before there is a deployment at all. + """ + before = sorted(p.name for p in tmp_path.iterdir()) + + preflight.examine(Settings(database_url=f"sqlite:///{tmp_path}/hullwork.db")) + + assert sorted(p.name for p in tmp_path.iterdir()) == before + + +def test_a_missing_schema_is_the_expected_state_not_a_fault(tmp_path: Path) -> None: + """A pre-flight whose first line is a red herring teaches its reader to skim the rest. There + being no database yet is what a pre-flight is *for*, so `expected` is the honest state — which + `State` already distinguishes from `ok` on purpose.""" + found = preflight.examine(Settings()) + + database = _named(found, "database") + + assert database.state is State.EXPECTED + + +def test_every_check_the_doctor_makes_is_still_here() -> None: + """It is the same command, earlier — not a smaller one somebody has to run twice. + + **Asserted against `doctor` itself rather than against a number.** The first version of this + demanded twenty distinct checks, which came from a run whose `env_file` was a real `deploy.env` + — that file contributes one finding per variable, so the threshold was measuring the fixture. + A check added to `doctor` tomorrow has to reach here without anybody remembering. + """ + settings = Settings(forge_url="https://forge.example.com") + from hullwork.db import make_engine, make_session_factory + from hullwork.doctor import examine as doctors + + with make_session_factory(make_engine("sqlite:///:memory:"))() as session: + theirs = { + one.check + for one in doctors( + session, settings, code_forge=None, env_file=preflight._NOWHERE, compose_file=None + ) + } + + ours = {one.check for one in preflight.examine(settings)} + + assert theirs <= ours, f"the pre-flight lost: {sorted(theirs - ours)}" + + +def test_nothing_tells_the_reader_to_fix_a_database_that_does_not_exist() -> None: + """**Found by running it, not by testing it.** The `database` check read correctly as `expected` + and the two checks that depend on it still carried their instance-flavoured advice — *fix the + database and run this again*, about a database the reader has not created yet and should not. + + A red herring one layer down is still a red herring: it is the first output a stranger sees, and + the first instruction in it would have been to repair nothing. + """ + said = " ".join(one.detail for one in preflight.examine(Settings())) + + assert "Fix the database" not in said + assert "cannot be queried" not in said + + +# --- the layer that did not exist: does the thing you named answer ------------------------------- + + +def test_a_forge_that_answers_is_reported_as_reached(monkeypatch: pytest.MonkeyPatch) -> None: + """The question `doctor` never asked. Reported apart from *which forge is configured*, because + a URL that parses and a host that answers are different facts and only one of them was known.""" + monkeypatch.setattr(preflight, "_answers", lambda url, timeout=5.0: True) + + found = preflight.examine(Settings(forge_url="https://forge.example.com")) + + assert _named(found, "forge answers").state is State.OK + + +def test_a_forge_that_does_not_resolve_is_unknown_with_the_reason( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """**The distinction this repository has got wrong three times in two days.** Not `ok`, which + would be the permanently-on signal inverted in the first output a stranger sees; and not + `broken`, which asserts the forge is wrong when what is known is that this machine could not + reach it — a laptop behind a VPN is the commonest cause and is nobody's defect. + """ + monkeypatch.setattr(preflight, "_answers", lambda url, timeout=5.0: None) + + found = preflight.examine(Settings(forge_url="https://forge.example.com")) + + answer = _named(found, "forge answers") + + assert answer.state is State.UNKNOWN + assert "could not" in answer.detail.lower() + + +def test_what_the_token_may_do_is_a_separate_answer(monkeypatch: pytest.MonkeyPatch) -> None: + """Reachability and authority are two questions, and a token with the wrong scopes against a + forge that answers perfectly is the failure `projects add` currently discovers for you.""" + monkeypatch.setattr(preflight, "_answers", lambda url, timeout=5.0: True) + monkeypatch.setattr(preflight, "_may_push", lambda *a, **k: False) + + found = preflight.examine( + Settings(forge_url="https://forge.example.com", forge_token=SecretStr("t")) + ) + + assert _named(found, "forge token").state is State.OK + + +def test_the_token_is_not_asked_about_when_the_host_did_not_answer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """**Found by mutation, and it is the collapse this module exists to refuse.** Asking what a + token may do of a host that never answered produces a network failure wearing an authorisation + answer's clothes — and the honest reading of a refused connection is *nothing is known about + this token*, not *this token is fine* and not *this token is wrong*. + + Covered by neither of the two tests either side of it: one has a reachable host with a token, + the other an unreachable host with none. + """ + monkeypatch.setattr(preflight, "_answers", lambda url, timeout=5.0: None) + + def never(*_a: object, **_k: object) -> bool: + raise AssertionError("it asked the forge about a token it could not reach") + + monkeypatch.setattr(preflight, "_may_push", never) + + found = preflight.examine( + Settings(forge_url="https://forge.example.com", forge_token=SecretStr("t")) + ) + + answer = _named(found, "forge token") + assert answer.state is State.UNKNOWN + assert "not asked" in answer.detail + + +def test_nothing_is_asked_of_the_network_without_a_credential( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """**Asserted by making every route out explode**, the way `features` does it. An unconfigured + pre-flight is the commonest first contact there is, and a setup command that quietly starts + making outbound calls is one somebody has to audit.""" + import socket + import urllib.request + + monkeypatch.setattr(socket, "socket", _nothing_answers) + monkeypatch.setattr(urllib.request, "urlopen", _nothing_answers) + + found = preflight.examine(Settings()) + + assert found + + +def test_the_reachability_checks_are_absent_rather_than_guessed() -> None: + """With nothing configured there is nothing to reach, and a row saying `unknown` about a forge + nobody named would be noise dressed as rigour.""" + checks = {one.check for one in preflight.examine(Settings())} + + assert "forge answers" not in checks + + +# --- the exit code ------------------------------------------------------------------------------- + + +def test_an_unknown_never_sets_the_exit_code() -> None: + """Item 073's rule, and this command is the most likely place to break it: a warning wired into + an exit code with no action available to clear it is not a signal. A laptop behind a VPN would + otherwise fail somebody's install script for a fact about the laptop.""" + assert preflight.exit_code([_Finding(State.UNKNOWN), _Finding(State.OK)]) == 0 + assert preflight.exit_code([_Finding(State.EXPECTED)]) == 0 + assert preflight.exit_code([_Finding(State.BROKEN)]) == 1 + + +def _Finding(state: State) -> Finding: # noqa: N802 - reads as the type it stands for + return Finding("x", state, "y")