Files
images/AGENTS.md
T
rmcguire d39c2c8254
Build Images / Select images (push) Successful in 16s
Lint / Lint (push) Successful in 1m16s
Build Images / Build changed images (push) Successful in 2m49s
update agents md
2026-10-05 16:45:21 -04:00

183 lines
8.3 KiB
Markdown

# AGENTS.md
Guidance for coding agents when working in this repository.
## Overview
A base image registry. Each image is self-contained in its own directory under
`images/` and is published to the **public** `rmcguire` namespace on Gitea at
`gitea.libretechconsulting.com/rmcguire/<image>`. Anonymous pulls work, so
consumers need no `imagePullSecret`.
```
images/<name>/Dockerfile the image
images/<name>/README.md required — `make check` fails without it
images/<name>/test.sh optional smoke test; must be executable to run
template/ skeleton to copy when adding an image
hack/ the three scripts CI and the Makefile share
Makefile single entry point for local work and CI
.gitea/workflows/ lint.yaml, build.yaml
```
## Core principle: do not pin, rebuild instead
Base image tags, apk package versions and tool releases are deliberately
unpinned. Rebuilding is how upstream updates land; the **published image tag** is
what pins things for consumers. Do not add `=version` to `apk add`, and do not
pin `FROM` to a digest.
Corollary: prefer an Alpine package over an upstream download when one exists —
one install mechanism beats two. Only fetch from upstream when Alpine does not
package the tool at all (`yq` is the sole example today). Where a major version
must be held back, constrain the major only and still track latest within it.
## Adding an image
```sh
cp -r template images/my-image
$EDITOR images/my-image/Dockerfile images/my-image/README.md images/my-image/test.sh
make build IMAGE=my-image # builds and smoke-tests
make check lint
```
No workflow changes are ever needed — `hack/select-images.sh` discovers any
directory under `images/` containing a `Dockerfile`.
Dockerfile conventions, all visible in `images/node-agent/Dockerfile`:
- Keep the `ARG VERSION/REVISION/CREATED` + `LABEL` block **last**, so changing
build metadata does not invalidate the layers above it.
- `# hadolint ignore=DL3018` above `apk add`, since versions are unpinned on
purpose. State the reason in a comment.
- Set `SHELL ["/bin/bash", "-o", "pipefail", "-c"]` before any `RUN` containing a
pipe (only after bash is installed).
- For a tool fetched from upstream, run it in the same layer (`yq --version`).
That is the check that matters: it catches a truncated download or an HTML
error page, which is the realistic failure mode.
## Tagging and releases
Tagging is derived from the git ref by `hack/build-images.sh`, identically for
every image in a run:
| Ref | Tags | Published |
| --- | --- | --- |
| push to `main` | `:latest`, `:main-<sha>` | yes |
| tag `<image>/vX.Y.Z` | `:vX.Y.Z`, `:vX.Y`, `:vX` | yes |
| pull request | `:pr-<n>` | no |
| anything else / local | `:dev` | no |
`latest` has exactly one owner — `main` — so a release tag and a `main` build can
never race to define it. Release tags are per image: `git tag node-agent/v1.0.0`
builds and publishes that image alone.
## Smoke tests
`images/<name>/test.sh` takes an image ref and must exit non-zero on failure. Two
conventions worth keeping, both in `images/node-agent/test.sh`:
- **Assert the deployment contract, not just tool presence.** node-agent runs
the checks under `--user 1000:1000 --read-only --tmpfs /tmp --cap-drop ALL`,
mirroring the securityContext it is actually deployed with, so CI catches
read-only or non-root breakage.
- **Do not `set -e` inside the container script.** Use a `fail` counter so every
check reports, then exit with it.
## Gitea Actions gotchas
These cost real debugging time. All three are Gitea behaviours, not bugs in this
repo, and all three will look fine locally.
**Never bind-mount the workspace into a sibling container.** The job itself runs
in a container and the checkout lives in a docker volume, not on the host. A
`docker run -v "$PWD":/repo` started through the host's docker socket resolves
that path against the **host**, where it does not exist — Docker silently creates
an empty directory, and the tool reports every file as missing. Stream files in
on stdin instead; `hack/lint.sh` does this for hadolint and shellcheck. Note
`docker build` and `docker run` without `-v` are unaffected, because the build
context is streamed over the API.
**Do not use a dynamic `fromJSON(needs...)` matrix.** Gitea resolves a job's
matrix when it *parses* the workflow, before `needs` outputs exist, so you get a
single job whose name is the raw uninterpolated expression. See
`nameWithMatrix` in Gitea's `modules/actions/jobparser` and
[go-gitea/gitea#28207](https://github.com/go-gitea/gitea/issues/28207). That is
why `build.yaml` loops over images inside one job and emits `::group::` markers
per image instead.
**Do not put `${{ }}` in a job or step `name:`.** Gitea does not interpolate
expression contexts in names — `name: Log in to ${{ env.REGISTRY }}` renders
literally. Give every step an explicit static `name:`, or it displays as the raw
`run:` command.
## Verifying
```sh
make all # check + lint + build + smoke-test; what CI does
make help # all targets
```
To reproduce the CI container environment locally — worth it before touching
anything in `hack/` or the workflows, since bind-mount and path assumptions only
break there:
```sh
docker volume create ci-sim
docker run --rm -v ci-sim:/w -v "$PWD":/src:ro alpine sh -c 'cp -a /src/. /w/'
docker run --rm -v ci-sim:/workspace/rmcguire/images \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/rmcguire/images -e GITHUB_ACTIONS=true \
--entrypoint bash gitea/runner-images:ubuntu-latest -c 'make check lint'
docker volume rm ci-sim
```
The runner is a local Docker container (`gitea-gitea_runner-1`), config at
`~/work/docker/gitea/data/act_runner/config/config.yaml`. Its cached
`gitea/runner-images:ubuntu-latest` can go stale; current action majors all
declare `using: node24`, so refresh it with
`docker pull gitea/runner-images:ubuntu-latest` if JS actions misbehave.
When bumping action versions, check the real latest major rather than copying
from a sibling repo — several were a major behind.
## Downstream consumers
`node-agent` backs **three** MCP servers in the **`50W/kube-manifests`** repo:
- **`shell`** — an arbitrary command runner. When this image's toolset changes,
update all three of these or agents will not know what they can run:
- `toolhive/mcpserver-shell.yaml` — the `WHAT'S IN THIS CONTAINER` comment block.
- `toolhive/mcptoolconfig-shell.yaml` — `toolsOverride.run_process.description`,
the tool description models actually read.
- `ai/kagent/remotemcpserver-shell.yaml` — `spec.description`.
- **`yamllint`** — a small stdio MCP server shipped as a ConfigMap
(`toolhive/cm-yamllint-mcp.yaml`) that runs on this image and shells out to
`yamllint` and `kubectl` to expose `lint_yaml` / `lint_manifest`. This is why
`yamllint` is in the apk list; it is the only reason that package is here.
- **`fetch-jq`** — another ConfigMap-shipped stdio MCP server
(`toolhive/cm-fetch-jq-mcp.yaml`), exposing `fetch_json_jq` / `fetch_json`. It
pipes a fetched HTTP response straight into `jq`'s stdin (`spawnSync` with an
`input:` option, no temp file), so a model never has to scan a large raw JSON
array by eye to find one value. `jq` was already in the apk list before this
consumer existed — nothing had to be added for it, unlike `yamllint`.
Neither `shell` nor `yamllint` gets a pull secret — the registry is public. Same
for `fetch-jq`.
The three pods have very different privileges, so **do not assume `kubectl` in
this image implies cluster access** — that depends entirely on the consuming
pod's ServiceAccount:
| | `shell` | `yamllint` | `fetch-jq` |
| --- | --- | --- | --- |
| ServiceAccount | cluster-wide **read** (incl. pod logs) | **no role bindings at all** | **none mounted** |
| Egress | HTTPS + API server | API server only, no internet | HTTPS to whatever URL a caller passes — no API server |
| `kubectl` can | `get`/`list`/`watch` most kinds | only fetch the OpenAPI schema | n/a — never invoked |
`fetch-jq` is the odd one out: it's the only one of the three with open internet
egress and the only one with zero Kubernetes access of any kind — see
`toolhive/mcpserver-fetch-jq.yaml` for why it mounts no ServiceAccount at all.
`node-agent`'s smoke test mirrors the stricter shared parts of that contract —
non-root, read-only root filesystem, all capabilities dropped, `/tmp` a tmpfs.