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 (<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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Executable
+54
@@ -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"
|
||||
Executable
+96
@@ -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'
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
```
|
||||
Executable
+94
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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.
|
||||
Executable
+29
@@ -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
|
||||
Reference in New Issue
Block a user