183 lines
8.3 KiB
Markdown
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.
|