Add Clay agent interface, fix registry UI routing, and misc improvements
- Deploy Clay (claude Code web UI) at agent.ladkau.de behind basic auth; Traefik proxies to clay's HTTPS port 2633 with insecureSkipVerify - Document MCP server persistence: binaries need to be baked into the Dockerfile, config in /opt/clay/data survives rebuilds - Document scheduled agent workflows via Gitea Actions on.schedule with email reporting via Gmail SMTP - Fix registry UI: split /v2/ (registry) and / (UI) into separate Traefik routers; add registry_internal network - Add weekly registry GC cron job (/usr/local/bin/registry-gc) - Remove rate-limit middleware from Gitea router (act_runner polling exceeded 60 req/min limit) - Set Traefik websecure readTimeout: 0 to fix large layer upload 499s
This commit is contained in:
@@ -75,6 +75,18 @@ The mail role deploys four containers on `mail_internal`: `mail-db` (PostgreSQL
|
||||
- Fetchmail 6.6.x rcfile syntax: `ssl` is a user-level option (inline after `password`), port is `smtphost dovecot/24` (slash-separated), `smtpport` keyword does not exist
|
||||
- `mail.ladkau.de` is the Roundcube webmail client, not an MTA — no outgoing SMTP server is configured
|
||||
|
||||
### Clay (Claude Code web interface)
|
||||
|
||||
Clay (`agent.ladkau.de`) provides a browser-based UI for Claude Code. The role
|
||||
builds a Docker image from `ansible/roles/clay/files/Dockerfile` (Node 20 +
|
||||
`@anthropic-ai/claude-code` + `clay-server`) at deploy time. Key details:
|
||||
|
||||
- `ANTHROPIC_API_KEY` is passed as an environment variable from the vault
|
||||
- Sessions and config are persisted at `/opt/clay/data` → `/root/.clay` in container
|
||||
- Projects (git repos) are mounted from `/opt/clay/workspace` → `/workspace`
|
||||
- Traefik basic auth (`clay-auth` middleware) uses `clay_users` from the vault
|
||||
- Scheduled agent workflows use Gitea Actions `on.schedule` + the existing act_runners
|
||||
|
||||
### Gitea Actions runners
|
||||
|
||||
The `act_runner` role deploys three `gitea/act_runner` containers (Docker executor). Each runner handles one concurrent job. Key details:
|
||||
|
||||
@@ -124,3 +124,13 @@ dl_sftp_port: 2223
|
||||
|
||||
# Status dashboard (public — cloud.ladkau.de root)
|
||||
dashboard_data_dir: /opt/dashboard
|
||||
|
||||
# Clay — Claude Code web interface (agent.ladkau.de)
|
||||
domain_clay: "agent.{{ domain_base }}"
|
||||
clay_data_dir: /opt/clay
|
||||
clay_users: [] # override in vault with [{username: ..., password: ...}]
|
||||
# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault)
|
||||
# anthropic_api_key: "" # from console.anthropic.com → API keys
|
||||
# clay_users:
|
||||
# - username: admin
|
||||
# password: "your-password"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# Claude Code needs git and bash to operate on repositories
|
||||
RUN apk add --no-cache git bash curl
|
||||
|
||||
# Install Claude Code CLI
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Install clay web server
|
||||
RUN npm install -g clay-server
|
||||
|
||||
# MCP servers and skills — add packages here to survive container rebuilds
|
||||
# RUN npm install -g @clay-ai/clay-ralph
|
||||
# RUN npm install -g @modelcontextprotocol/server-filesystem
|
||||
|
||||
RUN mkdir -p /workspace /root/.clay
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
EXPOSE 2633
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
- name: Rebuild and restart clay
|
||||
community.docker.docker_image:
|
||||
name: clay:local
|
||||
build:
|
||||
path: "{{ clay_data_dir }}"
|
||||
source: build
|
||||
force_source: true
|
||||
state: present
|
||||
notify: Restart clay
|
||||
|
||||
- name: Restart clay
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ clay_data_dir }}"
|
||||
state: present
|
||||
recreate: always
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
- name: Create clay directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ clay_data_dir }}"
|
||||
- "{{ clay_data_dir }}/data"
|
||||
- "{{ clay_data_dir }}/workspace"
|
||||
tags: clay
|
||||
|
||||
- name: Deploy Dockerfile
|
||||
ansible.builtin.copy:
|
||||
src: Dockerfile
|
||||
dest: "{{ clay_data_dir }}/Dockerfile"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Rebuild and restart clay
|
||||
tags: clay
|
||||
|
||||
- name: Deploy Docker Compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ clay_data_dir }}/docker-compose.yml"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart clay
|
||||
tags: clay
|
||||
|
||||
- name: Build clay image
|
||||
community.docker.docker_image:
|
||||
name: clay:local
|
||||
build:
|
||||
path: "{{ clay_data_dir }}"
|
||||
source: build
|
||||
state: present
|
||||
tags: clay
|
||||
|
||||
- name: Start clay
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ clay_data_dir }}"
|
||||
state: present
|
||||
tags: clay
|
||||
@@ -0,0 +1,35 @@
|
||||
# Managed by Ansible — do not edit manually
|
||||
services:
|
||||
clay:
|
||||
image: clay:local
|
||||
build:
|
||||
context: {{ clay_data_dir }}
|
||||
dockerfile: Dockerfile
|
||||
container_name: clay
|
||||
restart: unless-stopped
|
||||
tty: true
|
||||
environment:
|
||||
- ANTHROPIC_API_KEY={{ anthropic_api_key }}
|
||||
volumes:
|
||||
- {{ clay_data_dir }}/data:/root/.clay
|
||||
- {{ clay_data_dir }}/workspace:/workspace
|
||||
command: ["clay-server", "--yes", "-p", "2633"]
|
||||
networks:
|
||||
- traefik_public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.clay.rule=Host(`{{ domain_clay }}`)"
|
||||
- "traefik.http.routers.clay.entrypoints=websecure"
|
||||
- "traefik.http.routers.clay.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.clay.loadbalancer.server.scheme=https"
|
||||
- "traefik.http.services.clay.loadbalancer.server.port=2633"
|
||||
- "traefik.http.routers.clay.middlewares=clay-auth"
|
||||
{% set ns = namespace(entries=[]) %}
|
||||
{% for user in clay_users %}
|
||||
{% set ns.entries = ns.entries + [user.username + ':' + (user.password | password_hash('bcrypt', (user.username | hash('md5'))[:22]) | replace('$', '$$'))] %}
|
||||
{% endfor %}
|
||||
- "traefik.http.middlewares.clay-auth.basicauth.users={{ ns.entries | join(',') }}"
|
||||
|
||||
networks:
|
||||
traefik_public:
|
||||
external: true
|
||||
@@ -25,6 +25,7 @@ SERVICES = [
|
||||
("k8s", "https://{{ domain_k8s }}", 200, "https://{{ domain_k8s }}"),
|
||||
("Vaultwarden", "https://{{ domain_vault }}", 200, "https://{{ domain_vault }}"),
|
||||
("Downloads", "https://{{ domain_dl }}", 200, "https://{{ domain_dl }}"),
|
||||
("Clay", "https://{{ domain_clay }}", 401, "https://{{ domain_clay }}"),
|
||||
]
|
||||
{% raw %}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ entryPoints:
|
||||
imaps:
|
||||
address: ":993"
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: true # allows Traefik to proxy clay's self-signed d.clay.studio cert on port 2633
|
||||
|
||||
providers:
|
||||
docker:
|
||||
exposedByDefault: false
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
- k8s
|
||||
- vaultwarden
|
||||
- dl
|
||||
- clay
|
||||
- dashboard
|
||||
|
||||
@@ -25,6 +25,7 @@ The script checks these endpoints and verifies the expected HTTP status code:
|
||||
| `https://k8s.ladkau.de` | 200 | Placeholder page |
|
||||
| `https://vault.ladkau.de` | 200 | Vaultwarden web vault |
|
||||
| `https://dl.ladkau.de` | 200 | Public download server |
|
||||
| `https://agent.ladkau.de` | 401 | Clay — basic-auth prompt, correct without credentials |
|
||||
|
||||
A `000` result means the connection was refused or timed out — a container that
|
||||
did not start. Keycloak and Nextcloud may return `502` for up to 90 seconds on
|
||||
@@ -468,3 +469,164 @@ and tags, inspecting image manifests and digests, and deleting images.
|
||||
Traefik routes `/v2/` (Docker API) to the registry container and everything else
|
||||
to the UI container — both on the same domain, so no CORS configuration is
|
||||
needed. `docker push` and `docker pull` work exactly as before.
|
||||
|
||||
## 9. Clay — Claude Code web interface
|
||||
|
||||
Clay is a browser-based front end for Claude Code, served at
|
||||
`https://agent.ladkau.de`. Access is protected by HTTP basic auth using the
|
||||
`clay_users` credentials from the vault.
|
||||
|
||||
### 9.1 Add projects
|
||||
|
||||
The container mounts `/opt/clay/workspace` at `/workspace`. Clone Gitea
|
||||
repositories there to make them available to Claude Code:
|
||||
|
||||
```bash
|
||||
ssh -i keys/notroot_cloud_ladkau_de deploy@217.154.207.148
|
||||
cd /opt/clay/workspace
|
||||
git clone https://gitea.ladkau.de/<username>/<repo>.git
|
||||
```
|
||||
|
||||
Then in the clay web UI, use **Add project** and point it at `/workspace/<repo>`.
|
||||
|
||||
### 9.2 MCP servers and skills
|
||||
|
||||
Clay configuration (MCP server entries, session history, project registrations)
|
||||
is stored in `~/.clay`, which is mounted from `/opt/clay/data` on the host and
|
||||
survives container rebuilds.
|
||||
|
||||
MCP server **binaries** installed interactively inside a running container (e.g.
|
||||
via `npm install -g` in a clay terminal) are part of the container filesystem
|
||||
and are lost when the image is rebuilt.
|
||||
|
||||
To make a package survive rebuilds, add it to
|
||||
`ansible/roles/clay/files/Dockerfile` and redeploy:
|
||||
|
||||
```dockerfile
|
||||
# MCP servers and skills — add packages here to survive container rebuilds
|
||||
RUN npm install -g @clay-ai/clay-ralph
|
||||
RUN npm install -g @modelcontextprotocol/server-filesystem
|
||||
```
|
||||
|
||||
```bash
|
||||
ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags clay --ask-vault-pass
|
||||
```
|
||||
|
||||
Ansible detects the Dockerfile change and triggers a rebuild automatically.
|
||||
|
||||
### 9.3 Rebuild after clay updates
|
||||
|
||||
The Docker image is built from `ansible/roles/clay/files/Dockerfile` at deploy
|
||||
time. To pull upstream changes to `clay-server` or `@anthropic-ai/claude-code`,
|
||||
rebuild the image and restart the container:
|
||||
|
||||
```bash
|
||||
ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags clay --ask-vault-pass
|
||||
```
|
||||
|
||||
The Ansible role forces a rebuild whenever the Dockerfile changes; to force a
|
||||
rebuild without a Dockerfile change, delete the cached image first:
|
||||
|
||||
```bash
|
||||
ssh -i keys/notroot_cloud_ladkau_de deploy@217.154.207.148 \
|
||||
"sudo docker image rm clay:local && sudo docker compose -f /opt/clay/docker-compose.yml up -d"
|
||||
```
|
||||
|
||||
### 9.4 Interactive sessions vs. scheduled runs
|
||||
|
||||
Clay and Gitea Actions serve different purposes and are independent of each
|
||||
other:
|
||||
|
||||
| | Clay | Gitea Actions |
|
||||
|---|---|---|
|
||||
| **Trigger** | You, in the browser | Schedule or manual button click |
|
||||
| **Use case** | Exploratory, iterative work | Recurring automated tasks |
|
||||
| **Project** | Repos cloned into `/opt/clay/workspace/` | Checked out fresh per run |
|
||||
| **Output** | Interactive UI | Logs + optional email |
|
||||
|
||||
Clay has no scheduling API — there is no way to trigger a Clay session
|
||||
programmatically. For recurring agent tasks, Gitea Actions runs Claude Code
|
||||
directly on the act_runners, independent of Clay. If both operate on the same
|
||||
git repository the context is identical, but the sessions are separate.
|
||||
|
||||
### 9.5 Scheduled Claude Code runs via Gitea Actions
|
||||
|
||||
The act_runner containers can run Claude Code on a recurring schedule using
|
||||
Gitea's `on.schedule` trigger. Adding `workflow_dispatch` alongside it allows
|
||||
the same workflow to be triggered manually from the Gitea UI without waiting
|
||||
for the next scheduled run.
|
||||
|
||||
**Set up secrets**
|
||||
|
||||
In the repository go to **Settings → Secrets → Actions** (or
|
||||
**Site Administration → Actions → Secrets** for org-wide secrets) and add:
|
||||
|
||||
| Secret | Value |
|
||||
|--------|-------|
|
||||
| `ANTHROPIC_API_KEY` | API key from the vault |
|
||||
| `SMTP_USERNAME` | Gmail address used as the SMTP sender |
|
||||
| `SMTP_PASSWORD` | Gmail App Password — myaccount.google.com → Security → App passwords |
|
||||
|
||||
**Workflow file**
|
||||
|
||||
Add `.gitea/workflows/weekly-agent.yml` to the repository:
|
||||
|
||||
```yaml
|
||||
name: Weekly agent run
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Every Monday at 06:00 UTC
|
||||
workflow_dispatch: # also runnable manually from the Gitea Actions UI
|
||||
|
||||
jobs:
|
||||
agent:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Claude Code
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Run agent
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
claude --print "Your prompt here" \
|
||||
--output-format text \
|
||||
| tee agent-output.txt
|
||||
|
||||
- name: Send report email
|
||||
env:
|
||||
SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }}
|
||||
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
||||
run: |
|
||||
python3 - <<'EOF'
|
||||
import smtplib, os
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
with open("agent-output.txt") as f:
|
||||
body = f.read()
|
||||
|
||||
msg = MIMEText(body)
|
||||
msg["Subject"] = "Weekly agent report"
|
||||
msg["From"] = os.environ["SMTP_USERNAME"]
|
||||
msg["To"] = os.environ["SMTP_USERNAME"]
|
||||
|
||||
with smtplib.SMTP("smtp.gmail.com", 587) as s:
|
||||
s.starttls()
|
||||
s.login(os.environ["SMTP_USERNAME"], os.environ["SMTP_PASSWORD"])
|
||||
s.send_message(msg)
|
||||
EOF
|
||||
```
|
||||
|
||||
Adjust the `cron` expression, prompt, and `msg["To"]` address as needed.
|
||||
|
||||
**Run manually**
|
||||
|
||||
Go to **Repository → Actions**, select **Weekly agent run** from the workflow
|
||||
list, and click **Run workflow**. The run appears immediately in the Actions log.
|
||||
|
||||
**Verify the schedule is active**
|
||||
|
||||
After pushing the workflow file, scheduled runs appear automatically in
|
||||
**Repository → Actions** at the configured time.
|
||||
|
||||
@@ -81,6 +81,7 @@ Let's Encrypt certificates on first start and DNS must resolve at that point.
|
||||
| k8s.ladkau.de | A → server IP |
|
||||
| vault.ladkau.de | A → server IP |
|
||||
| dl.ladkau.de | A → server IP |
|
||||
| agent.ladkau.de | A → server IP |
|
||||
|
||||
### 4. Create the vault and populate secrets
|
||||
|
||||
@@ -121,6 +122,13 @@ registry_users:
|
||||
- username: alice
|
||||
password: "your-password"
|
||||
|
||||
# Clay — Claude Code web interface at agent.ladkau.de
|
||||
# Generate API key at console.anthropic.com → API keys
|
||||
anthropic_api_key: ""
|
||||
clay_users:
|
||||
- username: admin
|
||||
password: "your-password"
|
||||
|
||||
# Gitea Actions runners — token obtained after Gitea is running
|
||||
# See step 3.4 of runbook-configuration.md; replace after Gitea admin account is created
|
||||
gitea_runner_registration_token: "placeholder"
|
||||
@@ -221,7 +229,8 @@ This runs all roles in order:
|
||||
| 10 | `k8s` | Placeholder page at k8s.ladkau.de |
|
||||
| 11 | `vaultwarden` | Vaultwarden password vault at vault.ladkau.de |
|
||||
| 12 | `dl` | Public download server at dl.ladkau.de — nginx HTTPS + SFTP upload |
|
||||
| 13 | `dashboard` | Public status dashboard at cloud.ladkau.de — service health and server stats |
|
||||
| 13 | `clay` | Claude Code web interface at agent.ladkau.de — basic auth, Anthropic API key required |
|
||||
| 14 | `dashboard` | Public status dashboard at cloud.ladkau.de — service health and server stats |
|
||||
|
||||
> **Note:** The `act_runner` role requires `gitea_runner_registration_token` in the
|
||||
> vault, which can only be obtained after Gitea is running and an admin account has
|
||||
|
||||
@@ -18,6 +18,7 @@ CHECKS=(
|
||||
"https://k8s.ladkau.de 200 10"
|
||||
"https://vault.ladkau.de 200 10"
|
||||
"https://dl.ladkau.de 200 10"
|
||||
"https://agent.ladkau.de 401 10"
|
||||
)
|
||||
|
||||
if ! command -v curl &>/dev/null; then
|
||||
|
||||
@@ -44,6 +44,7 @@ required_scalars = [
|
||||
"roundcube_db_password",
|
||||
"roundcube_des_key",
|
||||
"vaultwarden_admin_token",
|
||||
"anthropic_api_key",
|
||||
]
|
||||
|
||||
# Required non-empty lists (must contain at least one entry)
|
||||
@@ -51,6 +52,7 @@ required_lists = [
|
||||
"traefik_dashboard_users",
|
||||
"registry_users",
|
||||
"dovecot_users",
|
||||
"clay_users",
|
||||
]
|
||||
|
||||
# Optional scalars: validated only when present — (key, validator_fn, hint)
|
||||
|
||||
Reference in New Issue
Block a user