commit 1878df96acd19fb0411f909d5dbf91ca26e5bb76 Author: Ryan McGuire Date: Sun Aug 9 11:31:54 2026 -0400 Initial commit: image registry structure and node-agent Set up this repo as a base image registry. Each image is self-contained in its own directory under images/ (Dockerfile + README.md + optional test.sh); CI discovers them by glob, so adding an image needs no workflow changes. template/ is the skeleton to copy. The Makefile is the single entry point for both local work and CI, so a green `make all` locally means a green pipeline. Workflows: * lint.yaml — layout check, hadolint, shellcheck * build.yaml — diffs against the base commit to build only the images that changed, smoke-tests each one before anything is published, then pushes. Releases are per-image tags (/vX.Y.Z); main publishes :edge. First image, node-agent: node:22-alpine plus a GNU userland (Alpine ships BusyBox, whose applets take narrower flags than scripts and models expect), helm, kubectl, jq, yq, bind-tools, curl, git, ripgrep, fd and friends. Nothing is version-pinned — rebuilding is how upstream updates land, and the published tag is what pins things for consumers. Its smoke test asserts the deployment contract as well as tool presence: the image must work non-root, with a read-only root filesystem and all capabilities dropped, which is how ToolHive runs it. Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bc5dc36 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yaml,yml,md}] +indent_style = space +indent_size = 2 + +[*.sh] +indent_style = tab + +[Makefile] +indent_style = tab diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..9d2a26f --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,92 @@ +name: Build Images + +# Only images whose files changed get built. A release is cut by pushing a tag +# named /vX.Y.Z, which publishes that one image. +on: + push: + branches: [main] + tags: ["*/v*"] + pull_request: + branches: [main] + workflow_dispatch: + inputs: + image: + description: Image to build, or "all" + default: all + required: true + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: gitea.libretechconsulting.com + NAMESPACE: rmcguire + DOCKER_USER: rmcguire + +jobs: + select: + name: Select images + runs-on: ubuntu-latest + outputs: + images: ${{ steps.select.outputs.images }} + any: ${{ steps.select.outputs.any }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # need history to diff against the base commit + + - id: select + run: ./hack/select-images.sh >> "$GITHUB_OUTPUT" + env: + DISPATCH_IMAGE: ${{ github.event.inputs.image }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + + build: + name: ${{ matrix.image }} + needs: select + if: needs.select.outputs.any == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + image: ${{ fromJSON(needs.select.outputs.images) }} + steps: + - uses: actions/checkout@v7 + + - id: meta + run: ./hack/docker-tags.sh '${{ matrix.image }}' >> "$GITHUB_OUTPUT" + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + + - uses: docker/setup-buildx-action@v4 + + # Loaded rather than pushed, so the smoke test runs before anything is + # published. provenance is off because Gitea's registry rejects buildkit + # attestation manifests. + - name: Build + uses: docker/build-push-action@v7 + with: + context: images/${{ matrix.image }} + load: true + tags: ${{ steps.meta.outputs.tags }} + provenance: false + build-args: | + VERSION=${{ steps.meta.outputs.version }} + REVISION=${{ github.sha }} + CREATED=${{ steps.meta.outputs.created }} + + - name: Smoke test + run: make test IMAGE='${{ matrix.image }}' REF='${{ steps.meta.outputs.primary }}' + + - name: Log in to ${{ env.REGISTRY }} + if: steps.meta.outputs.push == 'true' + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ env.DOCKER_USER }} + password: ${{ secrets.API_TOKEN }} + + - name: Push + if: steps.meta.outputs.push == 'true' + run: printf '%s\n' '${{ steps.meta.outputs.tags }}' | xargs -r -n1 -t docker push diff --git a/.gitea/workflows/lint.yaml b/.gitea/workflows/lint.yaml new file mode 100644 index 0000000..eaee22a --- /dev/null +++ b/.gitea/workflows/lint.yaml @@ -0,0 +1,23 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Same targets you run locally with `make check lint`. + - name: Repository layout + run: make check + + - name: Dockerfiles + run: make hadolint + + - name: Shell scripts + run: make shellcheck diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e18d54f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.tar +*.tar.gz +*.log +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4874f6f --- /dev/null +++ b/Makefile @@ -0,0 +1,68 @@ +# Entry point for local work and for CI. The Gitea workflows call these same +# targets, so a green `make all` locally means a green pipeline. + +REGISTRY ?= gitea.libretechconsulting.com +NAMESPACE ?= rmcguire + +# Every directory under images/ that has a Dockerfile is an image. +IMAGES := $(patsubst images/%/Dockerfile,%,$(wildcard images/*/Dockerfile)) + +# Build/test a single image with `make build IMAGE=node-agent`; unset means all. +IMAGE ?= +TARGETS := $(if $(IMAGE),$(IMAGE),$(IMAGES)) + +# CI overrides REF with the real published ref so the smoke test runs against it. +REF ?= + +HADOLINT ?= hadolint/hadolint:latest-alpine +SHELLCHECK ?= koalaman/shellcheck:stable +SCRIPTS := $(wildcard hack/*.sh images/*/test.sh template/test.sh) + +.DEFAULT_GOAL := help +.PHONY: help list check lint hadolint shellcheck build test all + +help: ## Show available targets + @awk 'BEGIN{FS=":.*##"} /^[a-z][a-z-]*:.*##/ {printf " \033[36m%-11s\033[0m %s\n",$$1,$$2}' $(MAKEFILE_LIST) + +list: ## List every image in this repository + @printf '%s\n' $(IMAGES) + +check: ## Verify each image directory follows the expected layout + @rc=0; \ + if [ -z "$(IMAGES)" ]; then echo "no images found under images/"; rc=1; fi; \ + for i in $(IMAGES); do \ + [ -f images/$$i/README.md ] || { echo "images/$$i: missing README.md"; rc=1; }; \ + if [ -e images/$$i/test.sh ] && [ ! -x images/$$i/test.sh ]; then \ + echo "images/$$i/test.sh: not executable"; rc=1; \ + fi; \ + done; \ + [ $$rc -eq 0 ] && echo "layout ok: $(IMAGES)"; \ + exit $$rc + +hadolint: ## Lint every Dockerfile + @docker run --rm -v "$(CURDIR)":/repo:ro -w /repo $(HADOLINT) \ + hadolint $(addprefix images/,$(addsuffix /Dockerfile,$(IMAGES))) template/Dockerfile + @echo "hadolint ok" + +shellcheck: ## Lint every shell script + @docker run --rm -v "$(CURDIR)":/repo:ro -w /repo $(SHELLCHECK) $(SCRIPTS) + @echo "shellcheck ok" + +lint: hadolint shellcheck ## Run all linters + +build: ## Build IMAGE, or every image when IMAGE is unset + @for i in $(TARGETS); do \ + echo "==> building $$i"; \ + docker build -t $(REGISTRY)/$(NAMESPACE)/$$i:dev images/$$i || exit 1; \ + done + +test: ## Smoke-test IMAGE, or every image when IMAGE is unset + @for i in $(TARGETS); do \ + if [ -x images/$$i/test.sh ]; then \ + images/$$i/test.sh "$(if $(REF),$(REF),$(REGISTRY)/$(NAMESPACE)/$$i:dev)" || exit 1; \ + else \ + echo "==> $$i has no test.sh, skipping"; \ + fi; \ + done + +all: check lint build test ## Everything CI does, locally diff --git a/README.md b/README.md new file mode 100644 index 0000000..c605ccc --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# images + +Custom container images, published to the public +[`rmcguire`](https://gitea.libretechconsulting.com/rmcguire/-/packages) registry +on Gitea. + +Each image is self-contained in its own directory under `images/`: a `Dockerfile`, +a `README.md`, and an optional `test.sh`. + +## Images + +| Image | Base | Purpose | +| --- | --- | --- | +| [`node-agent`](images/node-agent) | `node:22-alpine` | Node 22 plus a GNU shell, network and Kubernetes toolchain — a general-purpose command execution environment for AI agents | + +## Pulling + +```sh +docker pull gitea.libretechconsulting.com/rmcguire/node-agent:latest +``` + +The registry is public, so no pull secret is required. + +## Tags + +| Tag | Points at | +| --- | --- | +| `vX.Y.Z` / `vX.Y` / `vX` | a released build | +| `latest` | the most recent release | +| `edge` | the current tip of `main` | +| `main-` | one specific commit on `main` | + +Releases are cut per image by pushing a tag named `/vX.Y.Z`, which builds +and publishes that image alone: + +```sh +git tag node-agent/v1.0.0 && git push origin node-agent/v1.0.0 +``` + +Base images and package versions are deliberately **not** pinned — rebuilding is +how upstream updates land, and the published tag is what pins things for whoever +pulls it. + +## Adding an image + +```sh +cp -r template images/my-image +``` + +1. Edit `images/my-image/Dockerfile`, or base it on an image already published + here (`FROM gitea.libretechconsulting.com/rmcguire/node-agent:latest`). +2. Fill in `images/my-image/README.md`. +3. Extend `images/my-image/test.sh` to assert whatever the image promises. +4. `make build test IMAGE=my-image` + +CI needs no changes — it discovers every directory under `images/` that contains +a `Dockerfile`. + +## Local development + +```sh +make help # list targets +make all # everything CI does +make build test IMAGE=node-agent # one image +``` + +## CI + +Two Gitea workflows, both of which just call the `make` targets above: + +- **`lint.yaml`** — repository layout, `hadolint`, `shellcheck`. +- **`build.yaml`** — builds only the images whose files changed, smoke-tests each + one before anything is published, then pushes on `main` or a release tag. diff --git a/hack/docker-tags.sh b/hack/docker-tags.sh new file mode 100755 index 0000000..efadc49 --- /dev/null +++ b/hack/docker-tags.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Work out how one image should be tagged, and whether it should be published. +# Prints GITHUB_OUTPUT lines: +# +# tags= every ref to build, one per line +# primary= the ref the smoke test runs against +# version= org.opencontainers.image.version +# created= org.opencontainers.image.created +# push=true|false whether these refs get published +# +# Tag scheme: +# tag /vX.Y.Z -> :vX.Y.Z :vX.Y :vX :latest published release +# push to main -> :edge :main- tip of main +# pull request -> :pr- built, never published +# anything else -> :dev- built, never published +set -euo pipefail + +image=${1:?usage: docker-tags.sh } +repo="${REGISTRY:?REGISTRY is not set}/${NAMESPACE:?NAMESPACE is not set}/${image}" + +sha=$(git rev-parse --short=7 HEAD) +created=$(date -u +%Y-%m-%dT%H:%M:%SZ) +push=false + +if [ "${GITHUB_REF_TYPE:-}" = tag ]; then + version=${GITHUB_REF_NAME##*/} + [[ $version =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "tag '${GITHUB_REF_NAME}' is not of the form /vX.Y.Z" >&2 + exit 1 + } + n=${version#v} + tags=("$repo:$version" "$repo:v${n%.*}" "$repo:v${n%%.*}" "$repo:latest") + push=true +elif [ "${GITHUB_EVENT_NAME:-}" = pull_request ]; then + version="pr-${PR_NUMBER:-0}" + tags=("$repo:$version") +elif [ "${GITHUB_REF_NAME:-}" = main ]; then + version="edge-$sha" + tags=("$repo:edge" "$repo:main-$sha") + push=true +else + version="dev-$sha" + tags=("$repo:$version") +fi + +printf 'version %s, push %s, tags: %s\n' "$version" "$push" "${tags[*]}" >&2 + +echo "tags<<__TAGS__" +printf '%s\n' "${tags[@]}" +echo "__TAGS__" +echo "primary=${tags[0]}" +echo "version=$version" +echo "created=$created" +echo "push=$push" diff --git a/hack/select-images.sh b/hack/select-images.sh new file mode 100755 index 0000000..caa90d2 --- /dev/null +++ b/hack/select-images.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Decide which images CI should build. Prints GITHUB_OUTPUT lines: +# +# images=["node-agent",...] JSON array consumed by the build job's matrix +# any=true|false whether there is anything to build at all +# +# Rules, first match wins: +# 1. workflow_dispatch naming one image -> that image +# 2. tag push (/vX.Y.Z) -> the image named in the tag +# 3. shared build plumbing changed -> every image +# 4. anything else -> images with changed files +# +# When the diff base is unknown (first push, force-push, shallow clone) this builds +# everything. Rebuilding too much is the safe direction to fail. +set -euo pipefail +cd "$(dirname "$0")/.." + +# A change to any of these can affect how every image is built. +SHARED_PATHS='^(hack/|Makefile|\.hadolint\.yaml|\.gitea/workflows/)' + +all_images() { + local dockerfile + for dockerfile in images/*/Dockerfile; do + # Guards against the glob staying literal when there are no images. + [ -f "$dockerfile" ] || continue + basename "$(dirname "$dockerfile")" + done +} + +# Names on stdin -> ["a","b"]. Built by hand so the runner needs no jq. +as_json() { + local out='' name + while IFS= read -r name; do + [ -n "$name" ] || continue + out="${out:+$out,}\"$name\"" + done + printf '[%s]' "$out" +} + +emit() { + local names=$1 reason=$2 any=false + [ -n "$names" ] && any=true + echo "selected (${reason}): ${names:-}" >&2 + printf 'images=%s\n' "$(printf '%s\n' "$names" | as_json)" + printf 'any=%s\n' "$any" + exit 0 +} + +require_image() { + [ -f "images/$1/Dockerfile" ] || { + echo "no such image: images/$1/Dockerfile does not exist" >&2 + exit 1 + } +} + +# 1. Explicit request via workflow_dispatch. +case "${DISPATCH_IMAGE:-}" in + '') ;; + all) emit "$(all_images)" 'workflow_dispatch: all' ;; + *) + require_image "$DISPATCH_IMAGE" + emit "$DISPATCH_IMAGE" "workflow_dispatch: $DISPATCH_IMAGE" + ;; +esac + +# 2. Release tag: /vX.Y.Z. +if [ "${GITHUB_REF_TYPE:-}" = tag ]; then + image=${GITHUB_REF_NAME%/*} + require_image "$image" + emit "$image" "tag ${GITHUB_REF_NAME}" +fi + +# 3 and 4 both need a usable diff base. +base=${BASE_SHA:-} +if [ -z "$base" ] || [[ $base =~ ^0+$ ]] || ! git cat-file -e "${base}^{commit}" 2>/dev/null; then + emit "$(all_images)" 'diff base unavailable, building everything' +fi + +changed=$(git diff --name-only "$base" HEAD) + +if printf '%s\n' "$changed" | grep -qE "$SHARED_PATHS"; then + emit "$(all_images)" 'shared build plumbing changed' +fi + +# Map changed paths back to image names, dropping any that no longer exist so a +# deleted image directory does not fail the build. +selected=$( + printf '%s\n' "$changed" | + sed -n 's#^images/\([^/]*\)/.*#\1#p' | + sort -u | + while IFS= read -r i; do + [ -f "images/$i/Dockerfile" ] && echo "$i" + done +) + +emit "$selected" 'changed paths' diff --git a/images/node-agent/Dockerfile b/images/node-agent/Dockerfile new file mode 100644 index 0000000..d4f8752 --- /dev/null +++ b/images/node-agent/Dockerfile @@ -0,0 +1,64 @@ +# syntax=docker/dockerfile:1 + +# Node 22 LTS on Alpine. The tag is deliberately unpinned: rebuilding this image is +# how you pick up new Node patches, Alpine packages and tool releases. +FROM node:22-alpine + +# Alpine package versions are intentionally unpinned. This image tracks "current" — +# reproducibility comes from the published image tag, not from version pins that +# would rot against Alpine's rolling repositories. +# hadolint ignore=DL3018 +RUN apk add --no-cache \ + bash \ + ca-certificates openssl \ + coreutils findutils diffutils gawk grep sed \ + file less patch tree \ + curl wget \ + jq \ + bind-tools iputils-ping netcat-openbsd socat rsync \ + git git-lfs openssh-client-default \ + ripgrep fd \ + tar gzip xz zip unzip \ + helm kubectl \ + make procps-ng su-exec tini tzdata + +# Use bash with pipefail for the remaining build steps so a failure anywhere in a +# pipeline fails the layer. +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# yq is not packaged for Alpine, so take the current upstream release. Running the +# binary immediately is the verification that matters here: it catches a truncated +# or HTML-error-page download, which is the realistic failure mode. +RUN arch="$(apk --print-arch | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/')" \ + && curl -fsSL -o /usr/local/bin/yq \ + "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${arch}" \ + && chmod 0755 /usr/local/bin/yq \ + && yq --version + +# Defaults that keep non-interactive agent shells from hanging or failing: +# * pagers off — git/kubectl/helm otherwise block on `less` with no TTY +# * no git prompts — a private remote without creds fails fast instead of waiting +# * helm homes /tmp — lets the image run with a read-only root filesystem +ENV PAGER=cat \ + GIT_PAGER=cat \ + GIT_TERMINAL_PROMPT=0 \ + HELM_CACHE_HOME=/tmp/.cache/helm \ + HELM_CONFIG_HOME=/tmp/.config/helm \ + HELM_DATA_HOME=/tmp/.local/share/helm + +# tini reaps the grandchildren a shell workload leaves behind; the base image's +# docker-entrypoint.sh is kept so `node`-style invocations still work. +ENTRYPOINT ["/sbin/tini", "--", "docker-entrypoint.sh"] +CMD ["bash"] + +# Last, so bumping build metadata does not invalidate the layers above. +ARG VERSION=dev +ARG REVISION=unknown +ARG CREATED=unknown +LABEL org.opencontainers.image.title="node-agent" \ + org.opencontainers.image.description="Node 22 LTS on Alpine with a GNU shell, network, and Kubernetes toolchain for AI agent command execution" \ + org.opencontainers.image.source="https://gitea.libretechconsulting.com/rmcguire/images" \ + org.opencontainers.image.base.name="docker.io/library/node:22-alpine" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${REVISION}" \ + org.opencontainers.image.created="${CREATED}" diff --git a/images/node-agent/README.md b/images/node-agent/README.md new file mode 100644 index 0000000..9610fb9 --- /dev/null +++ b/images/node-agent/README.md @@ -0,0 +1,87 @@ +# node-agent + +A general-purpose command execution environment for AI agents: Node 22 on Alpine, +with the shell, text, network and Kubernetes tooling an agent actually reaches for +already installed. + +``` +gitea.libretechconsulting.com/rmcguire/node-agent +``` + +Alpine's stock userland is BusyBox, whose applets accept a narrower set of flags +than the GNU tools most scripts and models assume. This image replaces the ones +that matter with their GNU equivalents, so `grep -P`, `sed -i`, `find -printf`, +`sort -h` and friends behave as expected. + +Base: `node:22-alpine` · ~400 MB uncompressed (`kubectl` and `helm` are about +110 MB of that) + +## Contents + +| Group | Tools | +| --- | --- | +| Shell | `bash`, GNU `coreutils`, `findutils`, `diffutils`, `gawk`, `grep`, `sed` | +| Text & search | `jq`, `yq`, `ripgrep` (`rg`), `fd`, `less`, `file`, `patch`, `tree` | +| Network | `curl`, `wget`, `dig`/`nslookup`/`host` (bind-tools), `nc`, `socat`, `rsync`, `ping`, `openssl` | +| Kubernetes | `helm`, `kubectl` | +| Source control | `git`, `git-lfs`, `ssh`/`scp`/`sftp` | +| Archives | `tar`, `gzip`, `xz`, `zip`, `unzip` | +| System | `ps`/`top`/`free` (procps-ng), `make`, `su-exec`, `tini`, `tzdata`, `ca-certificates` | +| Runtime | `node`, `npm`, `npx` (from the base image) | + +Versions track Alpine's repositories and upstream releases at build time — nothing +is pinned. Run ` --version` in the image for what a given tag actually shipped. + +`yq` comes from upstream releases because Alpine does not package it; everything +else is an Alpine package. + +## Environment + +| Variable | Value | Why | +| --- | --- | --- | +| `PAGER`, `GIT_PAGER` | `cat` | `git`, `kubectl` and `helm` otherwise block on `less` when there is no TTY | +| `GIT_TERMINAL_PROMPT` | `0` | a remote needing credentials fails fast instead of hanging on a prompt | +| `HELM_CACHE_HOME`, `HELM_CONFIG_HOME`, `HELM_DATA_HOME` | under `/tmp` | lets `helm` run with a read-only root filesystem | + +`ENTRYPOINT` is `tini -- docker-entrypoint.sh`, so orphaned grandchildren of a +shell command get reaped. `CMD` is `bash`. + +## Usage + +```sh +docker run --rm -it gitea.libretechconsulting.com/rmcguire/node-agent:latest +``` + +The image is built to run locked down, and is tested that way — non-root, +read-only root filesystem, all capabilities dropped: + +```sh +docker run --rm -it \ + --user 1000:1000 --read-only --tmpfs /tmp --cap-drop ALL \ + -e HOME=/tmp \ + gitea.libretechconsulting.com/rmcguire/node-agent:latest +``` + +In Kubernetes, `/tmp` must be a writable volume; `uid 1000` is the base image's +`node` user. See `toolhive/mcpserver-shell.yaml` in the `50W/kube-manifests` repo +for the deployed example. + +## Notes + +- **`ping` needs `CAP_NET_RAW`.** With `capabilities: drop: [ALL]` it fails; `dig`, + `curl` and `nc` are the alternatives that work without it. +- **No `kubeconfig` is baked in.** `kubectl` and `helm` are present as tools; they + only reach a cluster if credentials are mounted. +- **No shell completions.** They are dead weight in a non-interactive environment. + +## Extending + +The image does not set `USER`, so it builds as root just like its base — add what +you need and let the deployment enforce non-root at runtime. + +```dockerfile +FROM gitea.libretechconsulting.com/rmcguire/node-agent:latest + +# hadolint ignore=DL3018 +RUN apk add --no-cache sops age +``` diff --git a/images/node-agent/test.sh b/images/node-agent/test.sh new file mode 100755 index 0000000..9014a82 --- /dev/null +++ b/images/node-agent/test.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Smoke-test node-agent. +# +# Two things are verified: every tool the README advertises is present and runnable, +# and the image behaves under the restrictions it is actually deployed with +# (non-root, read-only root filesystem, all capabilities dropped). +# +# Usage: test.sh [image-ref] +set -euo pipefail + +REF="${1:-node-agent:dev}" +echo "==> smoke-testing ${REF}" + +# Deliberately mirrors the ToolHive securityContext for the shell MCP server: +# runAsUser 1000, readOnlyRootFilesystem, capabilities drop ALL. +docker run --rm --interactive \ + --user 1000:1000 \ + --read-only \ + --tmpfs /tmp:rw,size=64m \ + --cap-drop ALL \ + --env HOME=/tmp \ + "$REF" bash -s <<'INNER' +set -uo pipefail +fail=0 + +ok() { printf ' \033[32mok\033[0m %s\n' "$1"; } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=1; } +have() { command -v "$1" >/dev/null 2>&1 && ok "$1" || bad "$1 (not on PATH)"; } +works(){ local d=$1; shift; if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi; } + +# Only the first line is checked, because a BusyBox applet's later output can +# mention the GNU GPL and would false-positive. `sed -n 1p` rather than `head -1` +# so the reader consumes all input: head exits early, which under `pipefail` +# SIGPIPEs the producer and fails the pipeline nondeterministically. +gnu() { + local first + first=$("$1" --version 2>/dev/null | sed -n '1p') + case $first in + *GNU*) ok "$1 is GNU" ;; + *) bad "$1 is not GNU (${first:-no output})" ;; + esac +} + +echo "-- present on PATH" +for t in bash sh curl wget jq yq helm kubectl git git-lfs ssh scp dig nslookup host \ + nc socat rsync ping rg fd tar gzip xz zip unzip less file patch tree make \ + ps top free tini su-exec node npm npx openssl base64 env xargs; do + have "$t" +done + +echo "-- GNU userland, not busybox (flag compatibility matters for agent scripts)" +for t in grep sed awk find sort diff; do gnu "$t"; done + +echo "-- functional" +works 'jq filters' bash -c 'echo "{\"a\":1}" | jq -e ".a == 1"' +works 'yq filters' bash -c 'printf "a: 1\n" | yq -e ".a == 1"' +works 'helm runs' helm version --short +works 'helm template' bash -c 'helm create /tmp/c >/dev/null && helm template /tmp/c >/dev/null' +works 'kubectl client' kubectl version --client +works 'git init' bash -c 'git init -q /tmp/r && git -C /tmp/r status --short' +works 'rg searches' bash -c 'printf "needle\n" >/tmp/h && rg -q needle /tmp/h' +works 'fd finds' bash -c 'fd -1 . /tmp >/dev/null' +works 'dig runs' dig -v +works 'openssl runs' openssl version +works 'node runs' node -e 'process.exit(0)' + +echo "-- deployment contract" +[ "$(id -u)" = 1000 ] && ok 'runs as uid 1000' || bad "runs as uid $(id -u), want 1000" +touch /tmp/writable 2>/dev/null && ok '/tmp is writable' || bad '/tmp is not writable' +if touch /should-not-work 2>/dev/null; then + bad 'root filesystem is writable (expected read-only)' +else + ok 'root filesystem is read-only' +fi +# Pagers must be disabled or git/kubectl/helm hang with no TTY attached. +[ "${PAGER:-}" = cat ] && ok 'PAGER=cat' || bad "PAGER=${PAGER:-unset}, want cat" +[ "${GIT_TERMINAL_PROMPT:-}" = 0 ] && ok 'GIT_TERMINAL_PROMPT=0' \ + || bad 'GIT_TERMINAL_PROMPT not 0' + +echo +[ "$fail" = 0 ] && echo "all checks passed" || echo "FAILURES above" +exit "$fail" +INNER + +# The default CMD should land in a usable shell rather than the node REPL. Pass no +# arguments so the image's own CMD runs, and feed the script on stdin — the base +# image's docker-entrypoint.sh rewrites a leading "-c" into a `node` invocation. +echo "==> checking default CMD" +if [ "$(echo 'echo shell-ok' | docker run --rm --interactive "$REF")" = shell-ok ]; then + echo " ok default CMD is bash" +else + echo " FAIL default CMD is not bash" + exit 1 +fi diff --git a/template/Dockerfile b/template/Dockerfile new file mode 100644 index 0000000..22c24c1 --- /dev/null +++ b/template/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +# Skeleton for a new image. Copy this directory to images// and edit. +# +# Conventions worth keeping: +# * leave upstream tags and package versions unpinned, so a rebuild picks up +# updates — the published image tag is what pins things for consumers +# * keep the ARG/LABEL block last, so changing build metadata does not +# invalidate the layers above it +# +# To build on top of an image already published here, replace the FROM with: +# FROM gitea.libretechconsulting.com/rmcguire/node-agent:latest +FROM alpine:3 + +# hadolint ignore=DL3018 +RUN apk add --no-cache bash ca-certificates + +CMD ["bash"] + +ARG VERSION=dev +ARG REVISION=unknown +ARG CREATED=unknown +LABEL org.opencontainers.image.title="CHANGEME" \ + org.opencontainers.image.description="CHANGEME" \ + org.opencontainers.image.source="https://gitea.libretechconsulting.com/rmcguire/images" \ + org.opencontainers.image.base.name="docker.io/library/alpine:3" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${REVISION}" \ + org.opencontainers.image.created="${CREATED}" diff --git a/template/README.md b/template/README.md new file mode 100644 index 0000000..3478981 --- /dev/null +++ b/template/README.md @@ -0,0 +1,32 @@ +# CHANGEME + +One or two sentences: what this image is for, and who pulls it. + +``` +gitea.libretechconsulting.com/rmcguire/CHANGEME +``` + +## Contents + +Base: `alpine:3` + +| Group | Tools | +| ----- | ----- | +| Shell | `bash` | + +## Environment + +| Variable | Value | Why | +| -------- | ----- | --- | +| | | | + +## Usage + +```sh +docker run --rm -it gitea.libretechconsulting.com/rmcguire/CHANGEME:latest +``` + +## Notes + +Anything a consumer would otherwise trip over — required capabilities, writable +paths, credentials the image expects to be mounted. diff --git a/template/test.sh b/template/test.sh new file mode 100755 index 0000000..5972e60 --- /dev/null +++ b/template/test.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Smoke-test skeleton. Optional, but CI runs it whenever it exists and is +# executable, so it is the cheapest place to assert what the image promises. +# +# Usage: test.sh [image-ref] +set -euo pipefail + +REF="${1:?usage: test.sh }" +echo "==> smoke-testing ${REF}" + +# Run the checks in one container. Mirror the securityContext the image is +# actually deployed with, so CI catches read-only or non-root breakage. +docker run --rm --interactive \ + --read-only \ + --tmpfs /tmp:rw,size=64m \ + "$REF" bash -s <<'INNER' +set -uo pipefail +fail=0 + +ok() { printf ' \033[32mok\033[0m %s\n' "$1"; } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=1; } + +for t in bash; do + command -v "$t" >/dev/null 2>&1 && ok "$t" || bad "$t (not on PATH)" +done + +[ "$fail" = 0 ] && echo "all checks passed" || echo "FAILURES above" +exit "$fail" +INNER