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")