From 214850dae482b7725a6426c30a94d742d399de72 Mon Sep 17 00:00:00 2001 From: grenade Date: Tue, 23 Jun 2026 12:17:10 +0300 Subject: [PATCH] Add lair/containers image-build repo; hermes as first image Builds container images for lair infra and publishes to git.lair.cafe. Hermes Agent (NousResearch) is built directly from its upstream Dockerfile at the latest release tag, published as git.lair.cafe/lair/hermes; the build is release-triggered (daily API poll) and self-healing (gated on registry presence, not a committable pin). Includes a draft rootful quadlet for bob matching the agent-zero/open-webui convention. Convention follows gongfoo. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011D3YeWKpjg5bT488fVanCH --- .gitea/workflows/images.yml | 73 ++++++++++++++++++++++++++++++++++ .gitignore | 5 +++ images/hermes/build.sh | 27 +++++++++++++ images/hermes/hermes.container | 49 +++++++++++++++++++++++ images/hermes/readme.md | 59 +++++++++++++++++++++++++++ readme.md | 50 +++++++++++++++++++++++ 6 files changed, 263 insertions(+) create mode 100644 .gitea/workflows/images.yml create mode 100644 .gitignore create mode 100755 images/hermes/build.sh create mode 100644 images/hermes/hermes.container create mode 100644 images/hermes/readme.md create mode 100644 readme.md diff --git a/.gitea/workflows/images.yml b/.gitea/workflows/images.yml new file mode 100644 index 0000000..4f2be97 --- /dev/null +++ b/.gitea/workflows/images.yml @@ -0,0 +1,73 @@ +name: images +# Build container images required by lair infra and publish them to the Gitea +# registry at git.lair.cafe. Convention mirrors gongfoo/.gitea/workflows/images.yml. +# +# Hermes is the first image: built directly from NousResearch/hermes-agent's own +# Dockerfile at the latest upstream release tag, and published as +# git.lair.cafe/lair/hermes:{,latest}. bob then pulls it via a normal +# AutoUpdate=registry quadlet. +on: + push: + branches: [main] + paths: + - "images/**" + - ".gitea/workflows/images.yml" + schedule: + # Daily poll for new upstream releases (Gitea can't subscribe to GitHub + # release webhooks, so we poll). Release-triggered in effect: a build only + # runs when the resolved upstream version isn't already in our registry. + - cron: "0 7 * * *" + workflow_dispatch: + inputs: + force: + description: "Rebuild even if the version is already published" + type: boolean + default: false + +jobs: + hermes: + runs-on: + - metal + - podman + steps: + - uses: actions/checkout@v4 + + - name: resolve latest upstream release + id: rel + run: | + # Prefer a published release; fall back to the newest tag. + tag=$(curl -fsS 'https://api.github.com/repos/NousResearch/hermes-agent/releases/latest' | jq -r '.tag_name // empty') + if [ -z "$tag" ]; then + tag=$(curl -fsS 'https://api.github.com/repos/NousResearch/hermes-agent/tags' | jq -r '.[0].name // empty') + fi + if [ -z "$tag" ] || [ "$tag" = "null" ]; then + echo "ERROR: could not resolve an upstream hermes release/tag"; exit 1 + fi + echo "upstream latest: $tag" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + + - name: login to registry + run: podman login -u ${{ gitea.actor }} -p ${{ secrets.REGISTRY_TOKEN }} git.lair.cafe + + - name: build & push (release-triggered, self-healing) + env: + TAG: ${{ steps.rel.outputs.tag }} + VERSION: ${{ steps.rel.outputs.version }} + FORCE: ${{ github.event.inputs.force }} + run: | + IMAGE=git.lair.cafe/lair/hermes + # Self-healing: the source of truth is "is this version in the registry?" + # — not a committed pin that can desync if a prior build failed. + if [ "$FORCE" != "true" ] && skopeo inspect "docker://${IMAGE}:${VERSION}" >/dev/null 2>&1; then + echo "${IMAGE}:${VERSION} already published — nothing to build" + exit 0 + fi + echo "building ${IMAGE}:${VERSION} from NousResearch/hermes-agent#${TAG}" + podman build --pull=newer \ + -t "${IMAGE}:${VERSION}" \ + -t "${IMAGE}:latest" \ + "https://github.com/NousResearch/hermes-agent.git#${TAG}" + podman push "${IMAGE}:${VERSION}" + podman push "${IMAGE}:latest" + echo "published ${IMAGE}:${VERSION} (and :latest)" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99daa05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# no build artifacts or secrets in this repo +*.env +.env* +*.tar +*.log diff --git a/images/hermes/build.sh b/images/hermes/build.sh new file mode 100755 index 0000000..133837f --- /dev/null +++ b/images/hermes/build.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Build the Hermes Agent image locally, mirroring what the `images` workflow does. +# +# Hermes ships its own Dockerfile, so there is no vendored Containerfile here — we +# build straight from the upstream git context at a release tag. Override the ref +# with HERMES_REF (e.g. v0.2.0); empty resolves the latest upstream release. +set -euo pipefail + +REGISTRY="${REGISTRY:-git.lair.cafe}" +IMAGE_NAME="${REGISTRY}/lair/hermes" +HERMES_REF="${HERMES_REF:-}" + +if [ -z "${HERMES_REF}" ]; then + HERMES_REF=$(curl -fsS 'https://api.github.com/repos/NousResearch/hermes-agent/releases/latest' | jq -r '.tag_name // empty') + [ -n "${HERMES_REF}" ] || HERMES_REF=$(curl -fsS 'https://api.github.com/repos/NousResearch/hermes-agent/tags' | jq -r '.[0].name // empty') +fi +[ -n "${HERMES_REF}" ] || { echo "could not resolve an upstream hermes ref"; exit 1; } +VERSION="${HERMES_REF#v}" + +echo "building ${IMAGE_NAME}:${VERSION} from NousResearch/hermes-agent#${HERMES_REF}" +podman build --pull=newer \ + -t "${IMAGE_NAME}:${VERSION}" \ + -t "${IMAGE_NAME}:latest" \ + "https://github.com/NousResearch/hermes-agent.git#${HERMES_REF}" + +echo "built ${IMAGE_NAME}:${VERSION} and :latest" +echo "push with: podman push ${IMAGE_NAME}:${VERSION} && podman push ${IMAGE_NAME}:latest" diff --git a/images/hermes/hermes.container b/images/hermes/hermes.container new file mode 100644 index 0000000..7c23c95 --- /dev/null +++ b/images/hermes/hermes.container @@ -0,0 +1,49 @@ +# DRAFT reference quadlet for deploying Hermes on bob (bob.hanzalova.internal). +# Deploy to /etc/containers/systemd/hermes.container (rootful, matching the +# existing agent-zero.container and open-webui.container), then: +# sudo install -d -o 10000 -g 10000 /var/lib/hermes # /opt/data owner = HERMES_UID +# # drop config.yaml + .env into /var/lib/hermes (LLM backend, secrets) — see readme.md +# sudo systemctl daemon-reload && sudo systemctl start hermes.service +# +# Once git.lair.cafe/lair/hermes:latest is published by the `images` workflow, +# this is a normal pull + AutoUpdate=registry quadlet — same lifecycle as the +# other two services on bob. +# +# UNRESOLVED before first deploy (confirm against hermes dashboard docs): +# The dashboard binds 127.0.0.1:9119 by default. To expose it on the LAN at +# :5100 (the agent-zero=5080 / open-webui=5090 convention) the dashboard must +# be told to bind 0.0.0.0 INSIDE the container — set that in +# /var/lib/hermes/config.yaml (or a hermes dashboard-host env) and keep the +# PublishPort below. ⚠ It stores provider API keys and has no auth, so only +# expose on a trusted LAN — consider a reverse proxy with auth for anything wider. + +[Unit] +Description=Hermes Agent +After=network-online.target +Wants=network-online.target + +[Container] +Image=git.lair.cafe/lair/hermes:latest +ContainerName=hermes +AutoUpdate=registry +# Bridge + PublishPort keeps the 50X0 LAN convention. Requires the dashboard to +# bind 0.0.0.0:9119 inside the container (see note above). If you instead accept +# host networking like upstream's compose, replace the next two lines with +# `Network=host` and configure the dashboard bind/port directly. +PublishPort=5100:9119 +Volume=/var/lib/hermes:/opt/data:Z +# Upstream drops to the non-root hermes user (uid/gid 10000); /var/lib/hermes +# must be owned by 10000:10000 on the host (see install -d above). +Environment=HERMES_UID=10000 +Environment=HERMES_GID=10000 +# LLM backend: point hermes at the local sovereign inference at +# http://hanzalova.internal:31313/v1 (same endpoint open-webui uses). Hermes is +# OpenRouter-first with per-provider base URLs and no plain OpenAI slot, so the +# model routing is configured in /var/lib/hermes/config.yaml, not here. See readme.md. + +[Service] +Restart=always +TimeoutStartSec=300 + +[Install] +WantedBy=default.target diff --git a/images/hermes/readme.md b/images/hermes/readme.md new file mode 100644 index 0000000..2525712 --- /dev/null +++ b/images/hermes/readme.md @@ -0,0 +1,59 @@ +# hermes + +[NousResearch Hermes Agent](https://github.com/NousResearch/hermes-agent) — a +self-improving AI agent — packaged for lair infra and published to +`git.lair.cafe/lair/hermes`. + +## How it's built + +Upstream ships its own `Dockerfile` (debian 13 + s6-overlay), so there is **no +vendored Containerfile here**. The `images` workflow (and `build.sh`) build +straight from the upstream git context at the latest release tag: + +``` +podman build github.com/NousResearch/hermes-agent.git# \ + -t git.lair.cafe/lair/hermes: -t git.lair.cafe/lair/hermes:latest +``` + +Builds are **release-triggered** (daily poll of the GitHub releases API; a build +runs only when that version isn't already in our registry) and **self-healing** +(a failed build leaves the version absent, so the next poll retries). Force a +rebuild via the workflow's `force` dispatch input, or locally: + +``` +HERMES_REF=v0.2.0 ./build.sh +``` + +## One image, two roles + +Upstream's compose runs a `gateway` (the agent) and a `dashboard` (web UI on +`127.0.0.1:9119`) from the **same image**. Persistent state — `config.yaml`, +`.env`, sessions, memory, skills — all lives under `/opt/data` (the single +volume). Provider keys and the model backend go in those mounted files, never in +the image. + +## Deploying on bob + +See [`hermes.container`](hermes.container) — a rootful quadlet matching the +existing `agent-zero` / `open-webui` services on bob. Summary: + +1. `git.lair.cafe/lair/hermes:latest` must be published first (run the `images` + workflow). +2. `sudo install -d -o 10000 -g 10000 /var/lib/hermes` +3. Drop `config.yaml` + `.env` into `/var/lib/hermes`: + - **LLM backend → local sovereign inference.** Point hermes at + `http://hanzalova.internal:31313/v1` (the endpoint open-webui already uses). + Hermes is OpenRouter-first with per-provider base URLs and no plain + OpenAI-base slot, so define the OpenAI-compatible provider/model in + `config.yaml` (confirm the exact schema against hermes docs). + - Secrets (any provider keys, tool keys) go in `.env`, not the quadlet. +4. Install `hermes.container` to `/etc/containers/systemd/`, `daemon-reload`, + `start hermes.service`. + +### Open item: dashboard LAN exposure + +The dashboard defaults to `127.0.0.1:9119` and **stores API keys with no auth**. +The draft quadlet publishes it on the LAN at `:5100` (the 5080/5090 convention), +which requires telling the dashboard to bind `0.0.0.0` inside the container (a +`config.yaml`/env setting to confirm). Only expose on a trusted LAN; front it +with an authenticating reverse proxy for anything wider. diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..8c647d5 --- /dev/null +++ b/readme.md @@ -0,0 +1,50 @@ +# lair/containers + +Container images required by lair infrastructure, built and published to the +Gitea registry at **`git.lair.cafe`**. Convention follows +[`gongfoo`](https://git.lair.cafe/gongfoo/gongfoo)'s `images/` setup. + +## Layout + +``` +images// one directory per image + Containerfile (when we author the image ourselves) + build.sh local build helper + readme.md what it is and how it's built +.gitea/workflows/ + images.yml builds + publishes every image, on push / daily / dispatch +``` + +## Images + +| Image | Published as | Source | +|-------|--------------|--------| +| [hermes](images/hermes/readme.md) | `git.lair.cafe/lair/hermes:{version,latest}` | built from NousResearch/hermes-agent's Dockerfile at the latest release tag | + +## How builds work + +- **Trigger:** push under `images/**`, a daily cron poll, or manual dispatch. +- **Release-tracking:** each image job resolves the upstream's latest release via + its API and builds that exact ref. For upstreams that ship their own Dockerfile + (hermes) we build directly from the upstream git context; for images we author, + the version is passed as a `--build-arg` with the Containerfile pin as fallback. +- **Self-healing:** a build runs only when the resolved version isn't already in + the registry — and because the registry (not a committed pin) is the source of + truth, a failed build simply retries on the next poll instead of stranding a + stale image. (Lesson borrowed from gongfoo.) + +## Adding an image + +1. `mkdir images/`, add a `Containerfile` (or build from an upstream + context) + `build.sh` + `readme.md`. +2. Add a job to `.gitea/workflows/images.yml` that logs in, builds + `git.lair.cafe/lair/:latest`, and pushes. +3. Consumers pull `git.lair.cafe/lair/:latest` with `AutoUpdate=registry`. + +## Required secret + +| Secret | Purpose | +|--------|---------| +| `REGISTRY_TOKEN` | Gitea token with `write:package` for `git.lair.cafe`; used as `podman login -u $GITEA_ACTOR -p $REGISTRY_TOKEN`. Set in this repo's (or the `lair` org's) Actions secrets. | + +Build jobs run on self-hosted runners labelled `metal` + `podman`.