diff --git a/DOCS-AUDIT.md b/DOCS-AUDIT.md index 618f487..efd0a40 100644 --- a/DOCS-AUDIT.md +++ b/DOCS-AUDIT.md @@ -310,7 +310,15 @@ file naming the README recommends is invisible to the function documented at `docs/API.md:430`. `loadSession` is unaffected (it switches on `.json`/`.mjs`), so this only bites the listing API. -### 2.6 ๐ŸŸ  `docs/DOCKER.md` container name contradicts the file it points at +### 2.6 โœ… `docs/DOCKER.md` container name contradicts the file it points at โ€” **fixed** + +> **Resolved.** `example.nopysession.json` now targets +> `@docker/nopy-test-container`, matching the guide, `packages/nopy/.nopyrc.json` +> and the session example in the nopy README. `nopy-test-ubuntu` was the outlier. +> Worth knowing why it silently "worked": an identifier with no matching +> container is read as an *image*, so the run built and committed a throwaway +> image instead of failing โ€” the mode `docs/DOCKER.md` now documents at the end. +> The finding below is kept as the record of what was wrong. `docs/DOCKER.md:35` and `:45`: @@ -372,7 +380,18 @@ this is what a reader sees on npmjs.com โ€” build-from-monorepo instructions instead of `npm install -g @bitsquare/nopy`, which is what the root README and `README.PUBLISH.md:314` correctly tell people to run. -### 2.10 ๐ŸŸ  keyman README: two operations missing, one operation invented +### 2.10 โœ… keyman README: two operations missing, one operation invented โ€” **fixed** + +> **Resolved.** The README was rewritten against the code (`packages/keyman/docs/PLAN.md` +> Phase 9). All nine menu entries are documented, and a test asserts it contains +> every label `keyman.main.ts` offers, so a tenth cannot arrive undocumented. +> Encrypt is described as the union of `~/.ssh` and the tmp directory, which is +> what it does. The Quick Start now points at the Generate operation instead of +> `ssh-keygen`. Rotation stopped being an invention in Phase 10: it exists, in two +> halves (`keyman.rotate.ts`), and the README documents the sequence. The whole CLI +> surface is there too โ€” `helpText()` quoted verbatim, with a test that fails if the +> two diverge โ€” which was the other half of this, tracked as +> `packages/keyman/docs/AUDIT.md` ยง5.3. The finding below is kept as the record. `packages/keyman/README.md:90-96` lists four menu entries: List, Encrypt, Decrypt, Quit. The menu (`keyman.main.ts:54-61`) has six: @@ -403,7 +422,9 @@ Root `README.md:57-58` describes "a hard **85 % branch** floor". Both the root README is the odd one out, and it is the file a new contributor reads first. -### 2.12 ๐ŸŸก `docs/DOCKER.md` relative link is broken +### 2.12 โœ… `docs/DOCKER.md` relative link is broken โ€” **fixed** + +> **Resolved.** The link is now `../README.md`. `docs/DOCKER.md:8` links `[README.md](./README.md)`, which resolves to `packages/nopy/docs/README.md` โ€” nonexistent. It should be `../README.md`. @@ -752,8 +773,10 @@ the three has to give. with ยง4.2 this is a second path by which secrets reach stdout.~~ **Removed** alongside the `--use-defaults` work; it would have made an unattended run unreadable. The two other paths in ยง4.2 are untouched. -- `keyman.encrypt.ts:19-20` โ€” `console.log(tmpKeys); console.log(sshKeys);` - before the prompt. +- ~~`keyman.encrypt.ts:19-20` โ€” `console.log(tmpKeys); console.log(sshKeys);` + before the prompt.~~ **Removed** in Phase 2 of the keyman remediation, along + with a third one nobody had noticed: a `console.log` *inside* a `filter` + callback in `keyman.decrypt.ts`, printing a line per vault directory. ### 6.5 ๐ŸŸก No cycle detection @@ -813,8 +836,13 @@ Recording what was verified and found correct, so a future pass need not redo it scan, dotted/`node_modules` skipping, the prefixed `*.manifest.mjs` fallback, and the three-step id resolution match `cubes/loader.ts` exactly. - **pyinfra `--data` type coercion** (`README.md:101`) โ€” correct. -- **keyman config** โ€” priority (`VAULT_ROOT` > file > defaults), the four default - values, and the vault layout match `keyman.config.ts` and `keyman.encrypt.ts`. +- **keyman config** โ€” priority (`VAULT_ROOT` > file > defaults) and the four + default values match `keyman.config.ts`. The third clause of this entry used to + read "and the vault layout match[es] โ€ฆ `keyman.encrypt.ts`", which was true only + because `encrypt.ts` hardcoded `keys` and ignored the config โ€” checking a + documented layout against the file that ignores the configuration is what kept + that defect invisible here. Both are honest now: the layout is configurable and + `encrypt` reads the configuration (`packages/keyman/docs/AUDIT.md` ยง1.1, ยง5.1). --- diff --git a/packages/nopy/docs/DOCKER.md b/packages/nopy/docs/DOCKER.md index 1115806..170f671 100644 --- a/packages/nopy/docs/DOCKER.md +++ b/packages/nopy/docs/DOCKER.md @@ -5,7 +5,7 @@ This guide explains how to set up a local Docker container to test `nopy` deploy ## Prerequisites - Docker installed and running on your machine. -- `nopy` installed and linked (see [README.md](./README.md)). +- `nopy` installed and linked (see [README.md](../README.md)). ## 1. Setup SSH Key (Important) @@ -80,3 +80,48 @@ To stop and remove the container: ```bash docker rm -f nopy-test-container ``` + +## Building an image instead of targeting a container + +The `@docker` connector reads its identifier two ways, and the difference is +the whole feature: + +| Host | What pyinfra does | +| --------------------- | ------------------------------------------------------------------------------------- | +| `@docker/` | runs against that container and leaves it running โ€” the flow above | +| `@docker/` | starts a throwaway container, deploys into it, `docker commit`s it, prints the new image ID, removes the container | + +It looks for a matching container first, so nothing distinguishes the two at the +prompt: pick `docker` at host selection and enter either an existing container +or an image reference such as `ubuntu:24.04`. + +``` +$ nopy install +? Select host from inventory docker +? Specify docker container name/id, or an image to build from: ubuntu:24.04 +... +--> docker build complete, image ID: 39b782da6859 +$ docker tag 39b782da6859 myapp:1.0 +``` + +Two things the connector cannot do, both worth knowing before treating this as a +Dockerfile replacement. The commit is untagged, so the image exists only as an +ID until you tag it; and it carries the base image's metadata unchanged โ€” +`CMD`, `ENTRYPOINT`, `ENV`, `EXPOSE` have no equivalent in a cube. When either +matters, own the container yourself and commit deliberately: + +```bash +cid=$(docker run -d ubuntu:24.04 sleep infinity) +nopy install # host: docker โ†’ paste $cid at the prompt +docker commit --change 'CMD ["/usr/sbin/sshd","-D"]' "$cid" myapp:1.0 +docker rm -f "$cid" +``` + +There is no `--host` flag; for an unattended build put the identifier in a +session file's `hosts` array (as `example.nopysession.json` does) and replay it +with `nopy install -l `. + +Note also that an image target starts from a fresh container every run, so every +cube reports changes every time โ€” idempotence only shows up when you re-run +against a container id. And there is no init system in a plain container, so +service-level cubes still need the `--privileged` systemd setup above. diff --git a/packages/nopy/example.nopysession.json b/packages/nopy/example.nopysession.json index 95bdc49..32dc148 100644 --- a/packages/nopy/example.nopysession.json +++ b/packages/nopy/example.nopysession.json @@ -8,7 +8,7 @@ } } ], - "hosts": ["@docker/nopy-test-ubuntu"], + "hosts": ["@docker/nopy-test-container"], "env": { "KEY_DIR": "../../vault/tmp" }, diff --git a/packages/nopy/src/nopy.prompts.ts b/packages/nopy/src/nopy.prompts.ts index 2cd0519..88d4a57 100644 --- a/packages/nopy/src/nopy.prompts.ts +++ b/packages/nopy/src/nopy.prompts.ts @@ -117,6 +117,17 @@ export async function PasswordSelection(username: string): Promise { return password; } +/** + * Prompts for the deployment target, normalising the built-ins into the host + * strings pyinfra's connectors expect. + * + * The docker branch takes either identifier the connector accepts, and they + * mean very different things: a **container** name or id is mutated in place + * and left running, while an **image** reference makes pyinfra start a + * throwaway container, apply the deploy, commit the result as a new image and + * print its id. Only the connector can tell the two apart โ€” it looks for a + * matching container first โ€” so the prompt does not try to. + */ export async function HostSelection(hosts: string[]): Promise { const selectedHost = await inquirer.prompt([ { @@ -140,13 +151,14 @@ export async function HostSelection(hosts: string[]): Promise { }, { type: 'input', - name: 'dockerContainer', - message: 'Specify docker container name:', - when: (answers) => answers.host === 'runtime:docker', + name: 'dockerTarget', + message: 'Specify docker container name/id, or an image to build from:', + when: (answers) => answers.host === 'docker', + validate: (value: string) => value.trim().length > 0 || 'Required', }, ]); if (selectedHost.host === 'vagrant') return `@vagrant/${selectedHost.vagrantVM}`; - if (selectedHost.host === 'runtime:docker') return `@docker/${selectedHost.dockerContainer}`; + if (selectedHost.host === 'docker') return `@docker/${selectedHost.dockerTarget.trim()}`; return selectedHost.customHost ?? selectedHost.host; } diff --git a/packages/nopy/tests/prompts.test.ts b/packages/nopy/tests/prompts.test.ts index 69f735b..85fc519 100644 --- a/packages/nopy/tests/prompts.test.ts +++ b/packages/nopy/tests/prompts.test.ts @@ -199,11 +199,32 @@ describe('HostSelection', () => { }); it('prefixes a docker container', async () => { - inquirerPrompt.mockResolvedValue({ host: 'runtime:docker', dockerContainer: 'box' }); + inquirerPrompt.mockResolvedValue({ host: 'docker', dockerTarget: 'box' }); await expect(HostSelection([])).resolves.toBe('@docker/box'); }); + it('prefixes a docker image reference the same way', async () => { + inquirerPrompt.mockResolvedValue({ host: 'docker', dockerTarget: 'ubuntu:24.04' }); + + await expect(HostSelection([])).resolves.toBe('@docker/ubuntu:24.04'); + }); + + it('trims the docker identifier', async () => { + inquirerPrompt.mockResolvedValue({ host: 'docker', dockerTarget: ' ubuntu:24.04 ' }); + + await expect(HostSelection([])).resolves.toBe('@docker/ubuntu:24.04'); + }); + + it('rejects an empty docker identifier', async () => { + inquirerPrompt.mockResolvedValue({ host: 'web-1' }); + + await HostSelection([]); + + expect(question('dockerTarget')?.validate(' ')).toBe('Required'); + expect(question('dockerTarget')?.validate('ubuntu:24.04')).toBe(true); + }); + it('gates the follow-up questions on the chosen host', async () => { inquirerPrompt.mockResolvedValue({ host: 'web-1' }); @@ -213,8 +234,12 @@ describe('HostSelection', () => { expect(question('customHost')?.when({ host: 'web-1' })).toBe(false); expect(question('vagrantVM')?.when({ host: 'vagrant' })).toBe(true); expect(question('vagrantVM')?.when({ host: 'web-1' })).toBe(false); - expect(question('dockerContainer')?.when({ host: 'runtime:docker' })).toBe(true); - expect(question('dockerContainer')?.when({ host: 'web-1' })).toBe(false); + // The regression: the gate compared against the `runtime:docker` *cube id*, + // so picking `docker` from the list skipped this question entirely and the + // host came back as the literal string `docker`. + expect(question('dockerTarget')?.when({ host: 'docker' })).toBe(true); + expect(question('dockerTarget')?.when({ host: 'runtime:docker' })).toBe(false); + expect(question('dockerTarget')?.when({ host: 'web-1' })).toBe(false); }); });