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