Initial commit: image registry structure and node-agent
Lint / lint (push) Failing after 34s
Build Images / ${{ fromJSON(needs.select.outputs.images) }} (push) Canceled after 7m37s
Build Images / Select images (push) Successful in 17s

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 (<image>/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) <[email protected]>
This commit is contained in:
2026-08-09 11:40:29 -04:00
co-authored by Claude Opus 5
commit 1878df96ac
14 changed files with 762 additions and 0 deletions
+17
View File
@@ -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
+92
View File
@@ -0,0 +1,92 @@
name: Build Images
# Only images whose files changed get built. A release is cut by pushing a tag
# named <image>/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
+23
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
*.tar
*.tar.gz
*.log
.DS_Store
+68
View File
@@ -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
+73
View File
@@ -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-<sha>` | one specific commit on `main` |
Releases are cut per image by pushing a tag named `<image>/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.
+54
View File
@@ -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=<multiline> every ref to build, one per line
# primary=<ref> the ref the smoke test runs against
# version=<string> org.opencontainers.image.version
# created=<rfc3339> org.opencontainers.image.created
# push=true|false whether these refs get published
#
# Tag scheme:
# tag <image>/vX.Y.Z -> :vX.Y.Z :vX.Y :vX :latest published release
# push to main -> :edge :main-<sha> tip of main
# pull request -> :pr-<n> built, never published
# anything else -> :dev-<sha> built, never published
set -euo pipefail
image=${1:?usage: docker-tags.sh <image>}
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 <image>/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"
+96
View File
@@ -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 (<image>/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:-<none>}" >&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: <image>/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'
+64
View File
@@ -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}"
+87
View File
@@ -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 `<tool> --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
```
+94
View File
@@ -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
+29
View File
@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1
# Skeleton for a new image. Copy this directory to images/<name>/ 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}"
+32
View File
@@ -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.
+29
View File
@@ -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 <image-ref>}"
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