5 Commits

Author SHA1 Message Date
184378ae57 fleet: one-shot homelab health snapshot (containers, disks, units)
status across configured hosts via read-only commands (claude-docker
wrapper on the Pi5, local docker/systemctl here). Stable table +
findings list, exit 0/2/1, CI job containers skipped, unreachable
hosts become findings. Parsers unit-tested; smoked against the real
fleet (19 genuine findings). Spec: doc/tool-parity.md 4.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 01:02:06 +02:00
f8bfa71f5f ci/tasks/readme: register notifyr, svg-maker, waitfor, cronr, giteactl
All checks were successful
release-tools / build-release (push) Successful in 5m1s
release.yml TOOLS + BIN cases, VS Code build/test tasks, README table,
plan.md milestone 5, tool-parity build-order status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 00:56:47 +02:00
6b4f1441ac Merge dev/giteactl: giteactl v1 2026-08-07 00:48:46 +02:00
4b54458511 giteactl: Actions runs, CI logs, wait/wait-quiet, releases
runs/log/wait/wait-quiet/release against the Gitea API. Log fetch
tries API (token) -> public web route -> ssh+zstd file storage
(hex-bucket path verified against the real Pi5 layout). wait-quiet
encodes the "serialize heavy builds" rule with private repos as
warnings, not failures. Injectable fetchers make the wait logic
testable. Spec: doc/tool-parity.md 4.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 00:48:46 +02:00
5a9d462087 Merge dev/cronr: cronr v1 2026-08-07 00:42:57 +02:00
15 changed files with 1516 additions and 32 deletions

View File

@@ -20,7 +20,7 @@ jobs:
- name: Detektera ändrade tools - name: Detektera ändrade tools
id: changed id: changed
run: | run: |
TOOLS="pixel-sprite-maker mesh-tool bitmap-font-maker sfx-maker hitbox-tool" TOOLS="pixel-sprite-maker mesh-tool bitmap-font-maker sfx-maker hitbox-tool notifyr svg-maker waitfor cronr giteactl"
BEFORE="${{ github.event.before }}" BEFORE="${{ github.event.before }}"
CHANGED="" CHANGED=""
if [ "${{ github.event_name }}" = "workflow_dispatch" ] \ if [ "${{ github.event_name }}" = "workflow_dispatch" ] \
@@ -59,6 +59,11 @@ jobs:
bitmap-font-maker) BIN=fontc ;; bitmap-font-maker) BIN=fontc ;;
sfx-maker) BIN=sfxc ;; sfx-maker) BIN=sfxc ;;
hitbox-tool) BIN=hitbox ;; hitbox-tool) BIN=hitbox ;;
notifyr) BIN=notifyr ;;
svg-maker) BIN=svgc ;;
waitfor) BIN=waitfor ;;
cronr) BIN=cronr ;;
giteactl) BIN=giteactl ;;
*) echo "okänt tool $t"; exit 1 ;; *) echo "okänt tool $t"; exit 1 ;;
esac esac
echo "=== $t ($BIN) ===" echo "=== $t ($BIN) ==="
@@ -88,6 +93,11 @@ jobs:
bitmap-font-maker) BIN=fontc ;; bitmap-font-maker) BIN=fontc ;;
sfx-maker) BIN=sfxc ;; sfx-maker) BIN=sfxc ;;
hitbox-tool) BIN=hitbox ;; hitbox-tool) BIN=hitbox ;;
notifyr) BIN=notifyr ;;
svg-maker) BIN=svgc ;;
waitfor) BIN=waitfor ;;
cronr) BIN=cronr ;;
giteactl) BIN=giteactl ;;
esac esac
TAG="$t-latest" TAG="$t-latest"
BODY="$t (binär: $BIN) - rullande bygge från senaste master. Commit: ${{ github.sha }}. Arkitekturer: linux x64 + arm64 (Pi5)." BODY="$t (binär: $BIN) - rullande bygge från senaste master. Commit: ${{ github.sha }}. Arkitekturer: linux x64 + arm64 (Pi5)."

218
.vscode/tasks.json vendored
View File

@@ -5,87 +5,261 @@
"label": "build pixel-sprite-maker", "label": "build pixel-sprite-maker",
"type": "shell", "type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/spritec .", "command": "go build -trimpath -ldflags '-s -w' -o build/spritec .",
"options": { "cwd": "${workspaceFolder}/pixel-sprite-maker" }, "options": {
"cwd": "${workspaceFolder}/pixel-sprite-maker"
},
"group": "build", "group": "build",
"problemMatcher": ["$go"] "problemMatcher": [
"$go"
]
}, },
{ {
"label": "build mesh-tool", "label": "build mesh-tool",
"type": "shell", "type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/mesht .", "command": "go build -trimpath -ldflags '-s -w' -o build/mesht .",
"options": { "cwd": "${workspaceFolder}/mesh-tool" }, "options": {
"cwd": "${workspaceFolder}/mesh-tool"
},
"group": "build", "group": "build",
"problemMatcher": ["$go"] "problemMatcher": [
"$go"
]
}, },
{ {
"label": "build bitmap-font-maker", "label": "build bitmap-font-maker",
"type": "shell", "type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/fontc .", "command": "go build -trimpath -ldflags '-s -w' -o build/fontc .",
"options": { "cwd": "${workspaceFolder}/bitmap-font-maker" }, "options": {
"cwd": "${workspaceFolder}/bitmap-font-maker"
},
"group": "build", "group": "build",
"problemMatcher": ["$go"] "problemMatcher": [
"$go"
]
}, },
{ {
"label": "test bitmap-font-maker", "label": "test bitmap-font-maker",
"type": "shell", "type": "shell",
"command": "go test ./...", "command": "go test ./...",
"options": { "cwd": "${workspaceFolder}/bitmap-font-maker" }, "options": {
"cwd": "${workspaceFolder}/bitmap-font-maker"
},
"group": "test", "group": "test",
"problemMatcher": ["$go"] "problemMatcher": [
"$go"
]
}, },
{ {
"label": "build sfx-maker", "label": "build sfx-maker",
"type": "shell", "type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/sfxc .", "command": "go build -trimpath -ldflags '-s -w' -o build/sfxc .",
"options": { "cwd": "${workspaceFolder}/sfx-maker" }, "options": {
"cwd": "${workspaceFolder}/sfx-maker"
},
"group": "build", "group": "build",
"problemMatcher": ["$go"] "problemMatcher": [
"$go"
]
}, },
{ {
"label": "test sfx-maker", "label": "test sfx-maker",
"type": "shell", "type": "shell",
"command": "go test ./...", "command": "go test ./...",
"options": { "cwd": "${workspaceFolder}/sfx-maker" }, "options": {
"cwd": "${workspaceFolder}/sfx-maker"
},
"group": "test", "group": "test",
"problemMatcher": ["$go"] "problemMatcher": [
"$go"
]
}, },
{ {
"label": "build hitbox-tool", "label": "build hitbox-tool",
"type": "shell", "type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/hitbox .", "command": "go build -trimpath -ldflags '-s -w' -o build/hitbox .",
"options": { "cwd": "${workspaceFolder}/hitbox-tool" }, "options": {
"cwd": "${workspaceFolder}/hitbox-tool"
},
"group": "build", "group": "build",
"problemMatcher": ["$go"] "problemMatcher": [
"$go"
]
}, },
{ {
"label": "test hitbox-tool", "label": "test hitbox-tool",
"type": "shell", "type": "shell",
"command": "go test ./...", "command": "go test ./...",
"options": { "cwd": "${workspaceFolder}/hitbox-tool" }, "options": {
"cwd": "${workspaceFolder}/hitbox-tool"
},
"group": "test", "group": "test",
"problemMatcher": ["$go"] "problemMatcher": [
"$go"
]
}, },
{ {
"label": "test pixel-sprite-maker", "label": "test pixel-sprite-maker",
"type": "shell", "type": "shell",
"command": "go test ./...", "command": "go test ./...",
"options": { "cwd": "${workspaceFolder}/pixel-sprite-maker" }, "options": {
"cwd": "${workspaceFolder}/pixel-sprite-maker"
},
"group": "test", "group": "test",
"problemMatcher": ["$go"] "problemMatcher": [
"$go"
]
}, },
{ {
"label": "test mesh-tool", "label": "test mesh-tool",
"type": "shell", "type": "shell",
"command": "go test ./...", "command": "go test ./...",
"options": { "cwd": "${workspaceFolder}/mesh-tool" }, "options": {
"cwd": "${workspaceFolder}/mesh-tool"
},
"group": "test", "group": "test",
"problemMatcher": ["$go"] "problemMatcher": [
"$go"
]
},
{
"label": "build notifyr",
"type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/notifyr .",
"options": {
"cwd": "${workspaceFolder}/notifyr"
},
"group": "build",
"problemMatcher": [
"$go"
]
},
{
"label": "test notifyr",
"type": "shell",
"command": "go test ./...",
"options": {
"cwd": "${workspaceFolder}/notifyr"
},
"group": "test",
"problemMatcher": [
"$go"
]
},
{
"label": "build svg-maker",
"type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/svgc .",
"options": {
"cwd": "${workspaceFolder}/svg-maker"
},
"group": "build",
"problemMatcher": [
"$go"
]
},
{
"label": "test svg-maker",
"type": "shell",
"command": "go test ./...",
"options": {
"cwd": "${workspaceFolder}/svg-maker"
},
"group": "test",
"problemMatcher": [
"$go"
]
},
{
"label": "build waitfor",
"type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/waitfor .",
"options": {
"cwd": "${workspaceFolder}/waitfor"
},
"group": "build",
"problemMatcher": [
"$go"
]
},
{
"label": "test waitfor",
"type": "shell",
"command": "go test ./...",
"options": {
"cwd": "${workspaceFolder}/waitfor"
},
"group": "test",
"problemMatcher": [
"$go"
]
},
{
"label": "build cronr",
"type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/cronr .",
"options": {
"cwd": "${workspaceFolder}/cronr"
},
"group": "build",
"problemMatcher": [
"$go"
]
},
{
"label": "test cronr",
"type": "shell",
"command": "go test ./...",
"options": {
"cwd": "${workspaceFolder}/cronr"
},
"group": "test",
"problemMatcher": [
"$go"
]
},
{
"label": "build giteactl",
"type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/giteactl .",
"options": {
"cwd": "${workspaceFolder}/giteactl"
},
"group": "build",
"problemMatcher": [
"$go"
]
},
{
"label": "test giteactl",
"type": "shell",
"command": "go test ./...",
"options": {
"cwd": "${workspaceFolder}/giteactl"
},
"group": "test",
"problemMatcher": [
"$go"
]
}, },
{ {
"label": "build", "label": "build",
"dependsOn": ["build pixel-sprite-maker", "build mesh-tool", "build bitmap-font-maker", "build sfx-maker", "build hitbox-tool"], "dependsOn": [
"build pixel-sprite-maker",
"build mesh-tool",
"build bitmap-font-maker",
"build sfx-maker",
"build hitbox-tool",
"build notifyr",
"build svg-maker",
"build waitfor",
"build cronr",
"build giteactl"
],
"dependsOrder": "parallel", "dependsOrder": "parallel",
"group": { "kind": "build", "isDefault": true }, "group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": [] "problemMatcher": []
} }
] ]

View File

@@ -13,18 +13,25 @@ agent understands the result without opening an image viewer.
| [`bitmap-font-maker/`](bitmap-font-maker/) | `fontc` | Turns `.font` text files (pixel glyph grids, proportional widths) into font atlases (PNG + JSON metrics) and renders text strings to PNG or the terminal. | | [`bitmap-font-maker/`](bitmap-font-maker/) | `fontc` | Turns `.font` text files (pixel glyph grids, proportional widths) into font atlases (PNG + JSON metrics) and renders text strings to PNG or the terminal. |
| [`sfx-maker/`](sfx-maker/) | `sfxc` | Synthesizes retro game sound effects (sfxr-style) from `.sfx` text presets to 16-bit WAV: waves, envelope, pitch slides, vibrato, arpeggio, filters. Deterministic, with built-in presets (jump, coin, laser…). | | [`sfx-maker/`](sfx-maker/) | `sfxc` | Synthesizes retro game sound effects (sfxr-style) from `.sfx` text presets to 16-bit WAV: waves, envelope, pitch slides, vibrato, arpeggio, filters. Deterministic, with built-in presets (jump, coin, laser…). |
| [`hitbox-tool/`](hitbox-tool/) | `hitbox` | Scans sprite sheet PNGs and writes per-frame collision boxes as JSON from the alpha channel. Understands the spritec sheet naming convention including upscaled sheets. | | [`hitbox-tool/`](hitbox-tool/) | `hitbox` | Scans sprite sheet PNGs and writes per-frame collision boxes as JSON from the alpha channel. Understands the spritec sheet naming convention including upscaled sheets. |
| [`notifyr/`](notifyr/) | `notifyr` | Sends **and reads** notifications on the homelab ntfy bus — alert a human, or check what the infra has been complaining about. |
| [`svg-maker/`](svg-maker/) | `svgc` | Builds SVG graphics from `.svgd` text descriptions (shapes, text, groups, color vars) with terminal preview, measurements and out-of-canvas warnings. Display via agent-helm: `helmd share out.svg`. |
| [`waitfor/`](waitfor/) | `waitfor` | Blocks until a shell condition holds (exit 0 + optional regex match) — replaces hand-rolled poll loops. `--then` hook composes with notifyr. |
| [`cronr/`](cronr/) | `cronr` | Recurring/one-shot jobs as systemd user timers that survive session exit and reboot. add/list/run/logs/rm, schedules validated by systemd-analyze. |
| [`giteactl/`](giteactl/) | `giteactl` | Gitea Actions runs, job logs (API/web/ssh+zstd fallback), `wait` (block until green) and `wait-quiet` (the "serialize heavy builds" rule), release assets. |
Each tool has its own folder, its own README with the full format/CLI Each tool has its own folder, its own README with the full format/CLI
reference, its own tests and its own dev branch (`dev/<tool>`). reference, its own tests and its own dev branch (`dev/<tool>`).
## Planned: agent-capability & homelab-admin tools ## Agent-capability & homelab-admin tools
[`doc/tool-parity.md`](doc/tool-parity.md) compares Gemini CLI's [`doc/tool-parity.md`](doc/tool-parity.md) compares the Google agent's
built-in tools with Claude Code's, and specs the CLI tools that close built-in tools with Claude Code's, and specs the CLI tools that close
the gaps (`notifyr`, `giteactl`, `waitfor`, `cronr`, `fleet`, the gaps so any agent gets the same capabilities via its shell tool.
`envaudit`, `reghelper`, `pagepub`, `nbcell`, `wtreectl`, `fanout`) so Built so far: `notifyr`, `waitfor`, `cronr`, `giteactl`, `svgc`
any agent gets the same capabilities via `run_shell_command`. Build (2026-08-07). Still on the list: `fleet`, `envaudit`, `reghelper`,
order and rationale live there and in [`doc/plan.md`](doc/plan.md). `pagepub`, `nbcell`, `wtreectl`, `fanout`. Build order and rationale
live there and in [`doc/plan.md`](doc/plan.md). Agy finds the tools
through the `agent-tools` skill in `~/.gemini/config/skills/`.
## Building ## Building

View File

@@ -27,6 +27,12 @@ master/main. Only tools whose folders changed get rebuilt.
multi-view rendering, measurements and watertightness checks. multi-view rendering, measurements and watertightness checks.
3. ✅ Per-tool VS Code build tasks → `<tool>/build/`. 3. ✅ Per-tool VS Code build tasks → `<tool>/build/`.
4. ✅ CI: changed-tool detection + per-tool rolling releases. 4. ✅ CI: changed-tool detection + per-tool rolling releases.
5. ✅ 2026-08-07: agent-capability batch 1 — `notifyr` (ntfy
send/read), `waitfor` (block on condition), `cronr` (systemd user
timers), `giteactl` (CI runs/logs/wait/wait-quiet/releases) and
`svgc` (`svg-maker/`: .svgd → SVG for agent-helm, Björns direkta
önskemål). Alla smoke-testade mot riktig infra; agy-integration
via `agent-tools`-skill + AGENTS.md i `~/.gemini/config/`.
## Roadmap / expansion ideas (not agreed yet) ## Roadmap / expansion ideas (not agreed yet)

View File

@@ -289,9 +289,14 @@ binaries), same philosophy — don't duplicate those here.
## 5. Suggested build order ## 5. Suggested build order
1. `notifyr` — smallest, everything else composes with it, ntfy already runs. 1. `notifyr` — smallest, everything else composes with it, ntfy already runs. *(built 2026-08-07)*
2. `giteactl` — removes the biggest daily friction (CI logs + build serialization). 2. `giteactl` — removes the biggest daily friction (CI logs + build serialization). *(built 2026-08-07; private repos need an API token in the config)*
3. `waitfor` + `cronr` — turns both agents into unattended operators. 3. `waitfor` + `cronr` — turns both agents into unattended operators. *(built 2026-08-07)*
4. `fleet` — replaces the hand-rolled health loops in every runbook. 4. `fleet` — replaces the hand-rolled health loops in every runbook.
5. `envaudit`, `reghelper`, `pagepub`, `nbcell`, `wtreectl` — as needed. 5. `envaudit`, `reghelper`, `pagepub`, `nbcell`, `wtreectl` — as needed.
6. `fanout` — only if a concrete multi-agent need shows up. 6. `fanout` — only if a concrete multi-agent need shows up.
Also built 2026-08-07 (outside this list, Björns direct request):
`svgc` (`svg-maker/`) — SVG graphics from text, displayed in
agent-helm via `helmd share`. Agy integration: the `agent-tools`
skill + a tools section in `~/.gemini/config/AGENTS.md`.

60
fleet/README.md Normal file
View File

@@ -0,0 +1,60 @@
# fleet — one-shot homelab health snapshot
Replaces the hand-rolled `ssh pi5-claude sudo claude-docker ps …`
loops from every runbook with a single command that checks **all
hosts**: container states, disk fill and failed systemd units.
Read-only by construction — it only runs `ps`/`df`/`systemctl` through
the read-only claude-docker wrapper, so it needs no new permissions.
From [`doc/tool-parity.md`](../doc/tool-parity.md) §4.2.
## Usage
```
fleet status [--host NAME] # all hosts, or one
```
```
== pi5 ==
containers: 54/58 healthy
disk / 81% used, 82G free
disk /srv/storage1 58% used, 1.2T free
failed units: 0 system, 0 user
...
19 finding(s):
pi5: container dozzle: exited (Exited (0) 5 months ago)
brasse-linux01: failed unit: cpupower-gui.service
```
| Exit | Meaning |
|------|---------|
| 0 | everything healthy |
| 2 | findings printed (unhealthy/exited containers, disk ≥ warn %, failed units) |
| 1 | error (unknown host, config problem) |
Output is a stable text table — an agent can diff it between runs.
Unreachable hosts become findings, not crashes. Gitea Actions'
transient job containers are skipped.
## Config
`~/.config/fleet/config.json`, created on first run (`FLEET_CONFIG`
overrides):
```json
{
"disk_warn_pct": 85,
"hosts": [
{"name": "pi5", "ssh": "pi5-claude", "docker_cmd": "sudo claude-docker", "df_targets": "/ /srv/storage1", "user_units": false},
{"name": "brasse-linux01", "ssh": "", "docker_cmd": "docker", "df_targets": "/ /home", "user_units": true}
]
}
```
`ssh: ""` means run locally. Add hosts by appending entries.
## Build & test
```bash
go test ./...
go build -o build/fleet .
```

118
fleet/check/check.go Normal file
View File

@@ -0,0 +1,118 @@
// Package check parses the health data fleet collects (docker ps,
// df, systemctl --failed) into stable rows and findings an agent can
// diff between runs.
package check
import (
"fmt"
"strconv"
"strings"
)
// Container is one row of docker ps output.
type Container struct {
Name string
State string // running, exited, restarting, ...
Status string // "Up 2 hours (healthy)", "Exited (1) 3 days ago"
Image string
}
// Healthy reports whether the container looks fine: running, and not
// flagged unhealthy.
func (c Container) Healthy() bool {
return c.State == "running" && !strings.Contains(c.Status, "unhealthy")
}
// ParseDockerPS parses `docker ps -a --format
// "{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Image}}"`. CI job
// containers (Gitea Actions workers) are transient and skipped.
func ParseDockerPS(out string) []Container {
var rows []Container
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, "\t")
if len(parts) < 4 {
continue
}
if strings.HasPrefix(parts[0], "GITEA-ACTIONS-TASK-") {
continue
}
rows = append(rows, Container{Name: parts[0], State: parts[1], Status: parts[2], Image: parts[3]})
}
return rows
}
// Mount is one row of df output.
type Mount struct {
Target string
UsedPct int
Avail string
}
// ParseDF parses `df -h --output=target,pcent,avail`.
func ParseDF(out string) []Mount {
var rows []Mount
for i, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if i == 0 || len(fields) < 3 { // header
continue
}
pct, err := strconv.Atoi(strings.TrimSuffix(fields[1], "%"))
if err != nil {
continue
}
rows = append(rows, Mount{Target: fields[0], UsedPct: pct, Avail: fields[2]})
}
return rows
}
// ParseFailedUnits parses `systemctl --failed --no-legend --plain`
// into unit names.
func ParseFailedUnits(out string) []string {
var units []string
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), "● "))
if len(fields) == 0 {
continue
}
u := fields[0]
if strings.Contains(u, ".") {
units = append(units, u)
}
}
return units
}
// Finding is one thing worth attention.
type Finding struct {
Host string
Text string
}
func (f Finding) String() string { return f.Host + ": " + f.Text }
// Evaluate turns parsed data into findings. diskWarnPct is the fill
// grade that counts as a problem.
func Evaluate(host string, containers []Container, mounts []Mount, failedSys, failedUser []string, diskWarnPct int) []Finding {
var out []Finding
for _, c := range containers {
if !c.Healthy() {
out = append(out, Finding{host, fmt.Sprintf("container %s: %s (%s)", c.Name, c.State, c.Status)})
}
}
for _, m := range mounts {
if m.UsedPct >= diskWarnPct {
out = append(out, Finding{host, fmt.Sprintf("disk %s at %d%% (%s left)", m.Target, m.UsedPct, m.Avail)})
}
}
for _, u := range failedSys {
out = append(out, Finding{host, "failed unit: " + u})
}
for _, u := range failedUser {
out = append(out, Finding{host, "failed user unit: " + u})
}
return out
}

78
fleet/check/check_test.go Normal file
View File

@@ -0,0 +1,78 @@
package check
import (
"strings"
"testing"
)
const psOut = `GITEA-ACTIONS-TASK-120-WORKFLOW-x running Up 2 minutes catthehacker/ubuntu:act-latest
helm-hub running Up 58 minutes localhost:5000/helmd:latest
gitea-d running Up 27 hours (healthy) gitea/gitea:1.24
deluge exited Exited (1) 3 days ago linuxserver/deluge
kuma running Up 4 days (unhealthy) louislam/uptime-kuma:1`
func TestParseDockerPS(t *testing.T) {
rows := ParseDockerPS(psOut)
if len(rows) != 4 {
t.Fatalf("got %d rows, want 4 (CI worker skipped)", len(rows))
}
if rows[0].Name != "helm-hub" || !rows[0].Healthy() {
t.Errorf("helm-hub should be healthy: %+v", rows[0])
}
for _, r := range rows {
switch r.Name {
case "deluge":
if r.Healthy() {
t.Error("exited container marked healthy")
}
case "kuma":
if r.Healthy() {
t.Error("unhealthy container marked healthy")
}
}
}
}
const dfOut = `Mounted on Use% Avail
/ 81% 82G
/srv/storage1 58% 1.2T`
func TestParseDF(t *testing.T) {
rows := ParseDF(dfOut)
if len(rows) != 2 || rows[0].Target != "/" || rows[0].UsedPct != 81 || rows[1].Avail != "1.2T" {
t.Errorf("df parse wrong: %+v", rows)
}
}
func TestParseFailedUnits(t *testing.T) {
units := ParseFailedUnits("● backup.service loaded failed failed Nightly backup\nfoo.timer loaded failed failed X\n\n")
if len(units) != 2 || units[0] != "backup.service" || units[1] != "foo.timer" {
t.Errorf("failed units: %v", units)
}
if len(ParseFailedUnits("")) != 0 {
t.Error("empty output should give no units")
}
}
func TestEvaluate(t *testing.T) {
findings := Evaluate("pi5",
ParseDockerPS(psOut),
ParseDF(dfOut),
[]string{"backup.service"}, nil, 80)
var text []string
for _, f := range findings {
text = append(text, f.String())
}
joined := strings.Join(text, "\n")
for _, want := range []string{"deluge", "kuma", "disk / at 81%", "backup.service"} {
if !strings.Contains(joined, want) {
t.Errorf("findings missing %q:\n%s", want, joined)
}
}
if strings.Contains(joined, "storage1") {
t.Error("58% disk should not be a finding at threshold 80")
}
if len(findings) != 4 {
t.Errorf("got %d findings, want 4", len(findings))
}
}

3
fleet/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-tools/fleet
go 1.24

191
fleet/main.go Normal file
View File

@@ -0,0 +1,191 @@
// fleet takes a one-shot health snapshot of the homelab: container
// states, disk fill and failed systemd units per host — the loop every
// runbook used to hand-roll, done once and properly. Read-only by
// construction. See doc/tool-parity.md §4.2.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gitea.brasse-pc.eu/brasse/agent-tools/fleet/check"
)
var version = "dev"
const usage = `fleet - one-shot homelab health snapshot
Usage:
fleet status [--host NAME] [--all] containers, disks, failed units
fleet version
Exit codes: 0 = everything healthy, 2 = findings, 1 = error.
Hosts come from ~/.config/fleet/config.json (created on first run;
FLEET_CONFIG overrides). Default: the Pi5 through the read-only
claude-docker wrapper, and this workstation locally. All commands are
read-only (ps/df/systemctl status) — mutations stay in the supervised
tmux flow.
`
type host struct {
Name string `json:"name"`
SSH string `json:"ssh"` // ssh host alias, "" = run locally
DockerCmd string `json:"docker_cmd"` // e.g. "sudo claude-docker" or "docker"
DFTargets string `json:"df_targets"` // space-separated mount points
UserUnits bool `json:"user_units"` // also check systemctl --user --failed
}
type config struct {
DiskWarnPct int `json:"disk_warn_pct"`
Hosts []host `json:"hosts"`
}
func defaultConfig() *config {
return &config{
DiskWarnPct: 85,
Hosts: []host{
{Name: "pi5", SSH: "pi5-claude", DockerCmd: "sudo claude-docker", DFTargets: "/ /srv/storage1", UserUnits: false},
{Name: "brasse-linux01", SSH: "", DockerCmd: "docker", DFTargets: "/ /home", UserUnits: true},
},
}
}
func configPath() string {
if p := os.Getenv("FLEET_CONFIG"); p != "" {
return p
}
dir, err := os.UserConfigDir()
if err != nil {
dir = "."
}
return filepath.Join(dir, "fleet", "config.json")
}
func loadConfig() *config {
cfg := defaultConfig()
path := configPath()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err == nil {
out, _ := json.MarshalIndent(cfg, "", " ")
os.WriteFile(path, append(out, '\n'), 0o600)
fmt.Fprintf(os.Stderr, "fleet: created %s\n", path)
}
return cfg
}
if err == nil {
json.Unmarshal(data, cfg)
}
return cfg
}
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "status":
cmdStatus(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("fleet", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'fleet help'", os.Args[1])
}
}
// run executes a read-only command locally or over ssh, with a
// timeout so one dead host cannot hang the snapshot.
func run(h host, cmdline string) (string, error) {
var c *exec.Cmd
if h.SSH != "" {
c = exec.Command("ssh", "-o", "ConnectTimeout=10", h.SSH, cmdline)
} else {
c = exec.Command("sh", "-c", cmdline)
}
done := make(chan struct{})
var out []byte
var err error
go func() { out, err = c.CombinedOutput(); close(done) }()
select {
case <-done:
case <-time.After(45 * time.Second):
c.Process.Kill()
return "", fmt.Errorf("timeout")
}
return string(out), err
}
func cmdStatus(args []string) {
fs := flag.NewFlagSet("status", flag.ExitOnError)
hostFilter := fs.String("host", "", "only this host (default: all)")
fs.Parse(args)
cfg := loadConfig()
var allFindings []check.Finding
checked := 0
for _, h := range cfg.Hosts {
if *hostFilter != "" && h.Name != *hostFilter {
continue
}
checked++
fmt.Printf("== %s ==\n", h.Name)
psOut, err := run(h, h.DockerCmd+` ps -a --format '{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Image}}'`)
if err != nil {
fmt.Printf(" docker: UNREACHABLE (%v)\n", err)
allFindings = append(allFindings, check.Finding{Host: h.Name, Text: "docker unreachable: " + strings.TrimSpace(psOut)})
}
containers := check.ParseDockerPS(psOut)
up := 0
for _, c := range containers {
if c.Healthy() {
up++
}
}
fmt.Printf(" containers: %d/%d healthy\n", up, len(containers))
dfOut, _ := run(h, "df -h --output=target,pcent,avail "+h.DFTargets)
mounts := check.ParseDF(dfOut)
for _, m := range mounts {
fmt.Printf(" disk %-16s %3d%% used, %s free\n", m.Target, m.UsedPct, m.Avail)
}
sysOut, _ := run(h, "systemctl --failed --no-legend --plain")
failedSys := check.ParseFailedUnits(sysOut)
var failedUser []string
if h.UserUnits {
userOut, _ := run(h, "systemctl --user --failed --no-legend --plain")
failedUser = check.ParseFailedUnits(userOut)
}
fmt.Printf(" failed units: %d system, %d user\n", len(failedSys), len(failedUser))
allFindings = append(allFindings, check.Evaluate(h.Name, containers, mounts, failedSys, failedUser, cfg.DiskWarnPct)...)
}
if checked == 0 {
die("no host named %q in the config", *hostFilter)
}
if len(allFindings) == 0 {
fmt.Println("\nall healthy")
return
}
fmt.Printf("\n%d finding(s):\n", len(allFindings))
for _, f := range allFindings {
fmt.Println(" " + f.String())
}
os.Exit(2)
}
func die(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "fleet: "+format+"\n", args...)
os.Exit(1)
}

70
giteactl/README.md Normal file
View File

@@ -0,0 +1,70 @@
# giteactl — Gitea Actions, CI logs and releases for agents
Removes the biggest daily friction from
[`doc/tool-parity.md`](../doc/tool-parity.md) §4.1: CI status was
polled ad hoc, logs lived as zst files on the Pi5, and the
hard-learned rule *"more than 2 heavy builds take the Pi5 down"* had
no tooling. One stable command prefix for all of it.
## Usage
```
giteactl runs <repo> [--limit 10] runs: status, branch, duration, title
giteactl log <repo> <run> [--job N] print a job log
giteactl wait <repo> [--timeout 30m] block until newest run finishes
giteactl wait-quiet [--max-active 1] block until the runner is quiet
giteactl release <repo> [<tag>] release assets + download URLs
```
| Exit | wait | wait-quiet |
|------|------|------------|
| 0 | run finished **green** | runner quiet — safe to push |
| 2 | run finished red | — |
| 3 | timeout, still running | timeout, still busy |
The serialization rule as one line:
```bash
giteactl wait-quiet && git push
```
## Log fetching
`giteactl log` tries three sources in order:
1. **API** `…/actions/jobs/{id}/logs` — needs `token` in the config
2. **Public web route** — works anonymously for public repos
3. **ssh + zstd** — reads Gitea's file storage
(`actions_log/<owner>/<repo>/<hex(id%256)>/<id>.log.zst`) through
the read-only `pi5-claude` ssh account and decompresses locally
So public-repo logs work with zero setup; private repos need a token
(or fall back to ssh).
## Config
`~/.config/giteactl/config.json`, created on first run
(`GITEACTL_CONFIG` overrides):
```json
{
"url": "https://gitea.brasse-pc.eu",
"owner": "brasse",
"token": "",
"log_ssh_host": "pi5-claude",
"log_dir": "/srv/storage1/gitea/actions_log",
"heavy_repos": ["agent-helm", "agent-tools", "FitnessDroid", "brasse-pc.eu-v2", "Archivum", "lyssnarr", "Npm-cli"]
}
```
`heavy_repos` is what `wait-quiet` watches. Private repos it cannot
read become **warnings, not failures** — add a token to remove the
blind spots. Create one in Gitea: Settings → Applications →
Generate token (read-only scopes suffice).
## Build & test
```bash
go test ./...
go build -o build/giteactl .
```

280
giteactl/gitea/gitea.go Normal file
View File

@@ -0,0 +1,280 @@
// Package gitea wraps the slice of Gitea's REST API that agents need
// daily: Actions runs, job logs and releases — plus the wait logic
// that encodes the homelab's "serialize heavy builds" rule.
package gitea
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
// Task is one Actions job as returned by /actions/tasks. Gitea calls
// these tasks; each run can hold several.
type Task struct {
ID int64 `json:"id"`
Name string `json:"name"`
HeadBranch string `json:"head_branch"`
RunNumber int64 `json:"run_number"`
Status string `json:"status"`
DisplayTitle string `json:"display_title"`
WorkflowID string `json:"workflow_id"`
URL string `json:"url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
RunStartedAt time.Time `json:"run_started_at"`
}
// Active reports whether the task still occupies (or will occupy) the
// runner.
func (t Task) Active() bool {
switch t.Status {
case "running", "waiting", "blocked":
return true
}
return false
}
// OK reports whether the task ended well (skipped counts as ok).
func (t Task) OK() bool {
return t.Status == "success" || t.Status == "skipped"
}
func (t Task) Duration() time.Duration {
if t.RunStartedAt.IsZero() || t.UpdatedAt.IsZero() || t.Active() {
return 0
}
d := t.UpdatedAt.Sub(t.RunStartedAt)
if d < 0 {
return 0
}
return d
}
// Release is a Gitea release with its downloadable assets.
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt time.Time `json:"published_at"`
Assets []struct {
Name string `json:"name"`
Size int64 `json:"size"`
DownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
type Client struct {
BaseURL string // e.g. https://gitea.brasse-pc.eu
Owner string
Token string // optional; required for private repos
HTTP *http.Client
}
func New(baseURL, owner, token string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Owner: owner,
Token: token,
HTTP: &http.Client{Timeout: 60 * time.Second},
}
}
func (c *Client) get(path string) (*http.Response, error) {
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
if err != nil {
return nil, err
}
if c.Token != "" {
req.Header.Set("Authorization", "token "+c.Token)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == 404 {
resp.Body.Close()
return nil, fmt.Errorf("not found (private repo without token in the config?)")
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
resp.Body.Close()
return nil, fmt.Errorf("gitea answered %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
return resp, nil
}
// Tasks lists a repo's Actions jobs, newest first.
func (c *Client) Tasks(repo string, limit int) ([]Task, error) {
resp, err := c.get(fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks?limit=%d",
url.PathEscape(c.Owner), url.PathEscape(repo), limit))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out struct {
WorkflowRuns []Task `json:"workflow_runs"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
sort.Slice(out.WorkflowRuns, func(i, j int) bool {
return out.WorkflowRuns[i].ID > out.WorkflowRuns[j].ID
})
if limit > 0 && len(out.WorkflowRuns) > limit {
out.WorkflowRuns = out.WorkflowRuns[:limit]
}
return out.WorkflowRuns, nil
}
// LogAPI fetches a job log through the token-authenticated API.
func (c *Client) LogAPI(repo string, taskID int64) (string, error) {
resp, err := c.get(fmt.Sprintf("/api/v1/repos/%s/%s/actions/jobs/%d/logs",
url.PathEscape(c.Owner), url.PathEscape(repo), taskID))
if err != nil {
return "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
return string(data), err
}
// LogWeb fetches a job log through the web route, which works
// anonymously for public repos (job = index within the run, not id).
func (c *Client) LogWeb(repo string, runNumber int64, jobIndex int) (string, error) {
resp, err := c.get(fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs",
url.PathEscape(c.Owner), url.PathEscape(repo), runNumber, jobIndex))
if err != nil {
return "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return "", err
}
if strings.HasPrefix(strings.TrimSpace(string(data)), "<") {
return "", fmt.Errorf("got HTML instead of a log (login page? private repo needs a token)")
}
return string(data), nil
}
// ZstPath is where Gitea's file storage keeps a task's compressed log
// on the server: <dir>/<owner>/<repo>/<hex(taskID%256)>/<taskID>.log.zst.
// Used by the ssh fallback for logs the HTTP routes no longer serve.
func ZstPath(dir, owner, repo string, taskID int64) string {
return fmt.Sprintf("%s/%s/%s/%02x/%d.log.zst", dir, owner, repo, taskID%256, taskID)
}
// Releases lists a repo's releases, newest first.
func (c *Client) Releases(repo string, limit int) ([]Release, error) {
resp, err := c.get(fmt.Sprintf("/api/v1/repos/%s/%s/releases?limit=%d",
url.PathEscape(c.Owner), url.PathEscape(repo), limit))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out []Release
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out, nil
}
// --- wait logic (injectable fetch so it is testable) ---
type TaskFetcher func(repo string) ([]Task, error)
type WaitResult struct {
Done bool // latest run finished within the timeout
AllOK bool
Tasks []Task // the latest run's tasks (or last seen)
Attempts int
}
// WaitRun polls until every task of repo's newest run has finished.
func WaitRun(repo string, fetch TaskFetcher, interval, timeout time.Duration, log func(string)) (WaitResult, error) {
start := time.Now()
res := WaitResult{}
for {
res.Attempts++
tasks, err := fetch(repo)
if err != nil {
return res, err
}
if len(tasks) == 0 {
return res, fmt.Errorf("repo has no Actions runs")
}
latest := tasks[0].RunNumber
var runTasks []Task
active := false
allOK := true
for _, t := range tasks {
if t.RunNumber != latest {
continue
}
runTasks = append(runTasks, t)
if t.Active() {
active = true
} else if !t.OK() {
allOK = false
}
}
res.Tasks = runTasks
if !active {
res.Done, res.AllOK = true, allOK
return res, nil
}
if log != nil {
log(fmt.Sprintf("run #%d still active (%d jobs), waiting...", latest, len(runTasks)))
}
if time.Since(start)+interval > timeout {
return res, nil
}
time.Sleep(interval)
}
}
// CountActive counts active jobs across the given repos.
func CountActive(repos []string, fetch TaskFetcher) (int, []string, error) {
count := 0
var busy []string
for _, r := range repos {
tasks, err := fetch(r)
if err != nil {
return 0, nil, fmt.Errorf("%s: %w", r, err)
}
for _, t := range tasks {
if t.Active() {
count++
busy = append(busy, fmt.Sprintf("%s#%d(%s)", r, t.RunNumber, t.Status))
}
}
}
return count, busy, nil
}
// WaitQuiet blocks until at most maxActive jobs run across repos —
// the "serialize pushes, >2 heavy builds take the Pi5 down" rule.
func WaitQuiet(repos []string, maxActive int, fetch TaskFetcher, interval, timeout time.Duration, log func(string)) (bool, error) {
start := time.Now()
for {
n, busy, err := CountActive(repos, fetch)
if err != nil {
return false, err
}
if n <= maxActive {
return true, nil
}
if log != nil {
log(fmt.Sprintf("%d active builds (max %d): %s", n, maxActive, strings.Join(busy, " ")))
}
if time.Since(start)+interval > timeout {
return false, nil
}
time.Sleep(interval)
}
}

View File

@@ -0,0 +1,149 @@
package gitea
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestTasksParsesAndSorts(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/repos/brasse/agent-tools/actions/tasks") {
t.Errorf("unexpected path %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "token tok" {
t.Errorf("token header missing")
}
fmt.Fprint(w, `{"workflow_runs":[
{"id":110,"name":"build","run_number":5,"status":"success"},
{"id":111,"name":"build-release","run_number":6,"status":"running"}]}`)
}))
defer srv.Close()
tasks, err := New(srv.URL, "brasse", "tok").Tasks("agent-tools", 10)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 2 || tasks[0].ID != 111 {
t.Errorf("tasks not sorted newest first: %+v", tasks)
}
if !tasks[0].Active() || tasks[1].Active() {
t.Error("Active() wrong")
}
}
func TestPrivateRepoHint(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
defer srv.Close()
_, err := New(srv.URL, "brasse", "").Tasks("infra-Doc", 5)
if err == nil || !strings.Contains(err.Error(), "token") {
t.Errorf("404 should hint about tokens, got %v", err)
}
}
func TestLogWebRejectsHTML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "<!DOCTYPE html><html>login page</html>")
}))
defer srv.Close()
_, err := New(srv.URL, "brasse", "").LogWeb("x", 1, 0)
if err == nil || !strings.Contains(err.Error(), "HTML") {
t.Errorf("HTML response should error, got %v", err)
}
}
func TestZstPathHexBucket(t *testing.T) {
// verified against the real Pi5 layout: task 53 -> 35/53.log.zst
cases := map[int64]string{
53: "/logs/brasse/FitnessDroid/35/53.log.zst",
52: "/logs/brasse/FitnessDroid/34/52.log.zst",
86: "/logs/brasse/FitnessDroid/56/86.log.zst",
2: "/logs/brasse/FitnessDroid/02/2.log.zst",
258: "/logs/brasse/FitnessDroid/02/258.log.zst",
}
for id, want := range cases {
if got := ZstPath("/logs", "brasse", "FitnessDroid", id); got != want {
t.Errorf("ZstPath(%d) = %s, want %s", id, got, want)
}
}
}
func TestWaitRunFinishes(t *testing.T) {
calls := 0
fetch := func(repo string) ([]Task, error) {
calls++
status := "running"
if calls >= 3 {
status = "success"
}
return []Task{
{ID: 2, RunNumber: 7, Status: status},
{ID: 1, RunNumber: 6, Status: "failure"}, // older run must not matter
}, nil
}
res, err := WaitRun("x", fetch, time.Millisecond, time.Second, nil)
if err != nil {
t.Fatal(err)
}
if !res.Done || !res.AllOK || res.Attempts != 3 {
t.Errorf("unexpected: %+v", res)
}
}
func TestWaitRunRedFailure(t *testing.T) {
fetch := func(repo string) ([]Task, error) {
return []Task{{ID: 2, RunNumber: 7, Status: "failure"}}, nil
}
res, err := WaitRun("x", fetch, time.Millisecond, time.Second, nil)
if err != nil {
t.Fatal(err)
}
if !res.Done || res.AllOK {
t.Errorf("failure must give Done && !AllOK: %+v", res)
}
}
func TestWaitRunTimeout(t *testing.T) {
fetch := func(repo string) ([]Task, error) {
return []Task{{ID: 2, RunNumber: 7, Status: "running"}}, nil
}
res, err := WaitRun("x", fetch, 5*time.Millisecond, 15*time.Millisecond, nil)
if err != nil {
t.Fatal(err)
}
if res.Done {
t.Errorf("should have timed out: %+v", res)
}
}
func TestWaitQuietCountsAcrossRepos(t *testing.T) {
step := 0
fetch := func(repo string) ([]Task, error) {
// step 0: both repos busy; step >= 1: only one
if repo == "a" && step > 0 {
return []Task{{ID: 1, Status: "success"}}, nil
}
return []Task{{ID: 2, Status: "running"}}, nil
}
log := func(string) { step++ }
ok, err := WaitQuiet([]string{"a", "b"}, 1, fetch, time.Millisecond, time.Second, log)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("should reach quiet state")
}
}
func TestTaskDuration(t *testing.T) {
start := time.Now().Add(-90 * time.Second)
tk := Task{Status: "success", RunStartedAt: start, UpdatedAt: start.Add(75 * time.Second)}
if d := tk.Duration(); d != 75*time.Second {
t.Errorf("duration = %s", d)
}
if (Task{Status: "running", RunStartedAt: start}).Duration() != 0 {
t.Error("active task should have zero duration")
}
}

3
giteactl/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-tools/giteactl
go 1.24

330
giteactl/main.go Normal file
View File

@@ -0,0 +1,330 @@
// giteactl gives agents first-class access to Gitea Actions runs, job
// logs and releases — including the hard-learned homelab rule
// "serialize heavy builds" (wait-quiet). See doc/tool-parity.md §4.1.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"gitea.brasse-pc.eu/brasse/agent-tools/giteactl/gitea"
)
var version = "dev"
const usage = `giteactl - Gitea Actions runs, CI logs and releases for agents
Usage:
giteactl runs <repo> [--limit 10] list runs: status, branch, duration
giteactl log <repo> <run> [--job N] print a run's job log
giteactl wait <repo> [--timeout 30m] [--interval 15s]
block until the newest run finishes
exit 0 = green, 2 = red, 3 = timeout
giteactl wait-quiet [--max-active 1] [--timeout 30m]
block until <=N builds are active
across the heavy_repos in the config
giteactl release <repo> [<tag>] release assets + download URLs
giteactl version
Config: ~/.config/giteactl/config.json (created on first run;
GITEACTL_CONFIG overrides). token is needed for private repos; public
repos work without. Log fetching tries the API (token), then the
public web route, then ssh+zstd against the server's log storage.
The Pi5 rule: more than 2 heavy builds take every service down.
Always 'giteactl wait-quiet && git push' when pushing build-triggering
repos.
`
type config struct {
URL string `json:"url"`
Owner string `json:"owner"`
Token string `json:"token"`
LogSSHHost string `json:"log_ssh_host"`
LogDir string `json:"log_dir"`
HeavyRepos []string `json:"heavy_repos"`
}
func defaultConfig() *config {
return &config{
URL: "https://gitea.brasse-pc.eu",
Owner: "brasse",
Token: "",
LogSSHHost: "pi5-claude",
LogDir: "/srv/storage1/gitea/actions_log",
HeavyRepos: []string{"agent-helm", "agent-tools", "FitnessDroid", "brasse-pc.eu-v2", "Archivum", "lyssnarr", "Npm-cli"},
}
}
func configPath() string {
if p := os.Getenv("GITEACTL_CONFIG"); p != "" {
return p
}
dir, err := os.UserConfigDir()
if err != nil {
dir = "."
}
return filepath.Join(dir, "giteactl", "config.json")
}
func loadConfig() *config {
cfg := defaultConfig()
path := configPath()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err == nil {
out, _ := json.MarshalIndent(cfg, "", " ")
os.WriteFile(path, append(out, '\n'), 0o600)
fmt.Fprintf(os.Stderr, "giteactl: created %s\n", path)
}
return cfg
}
if err == nil {
json.Unmarshal(data, cfg)
}
return cfg
}
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
cfg := loadConfig()
client := gitea.New(cfg.URL, cfg.Owner, cfg.Token)
switch os.Args[1] {
case "runs":
cmdRuns(client, os.Args[2:])
case "log":
cmdLog(client, cfg, os.Args[2:])
case "wait":
cmdWait(client, os.Args[2:])
case "wait-quiet":
cmdWaitQuiet(client, cfg, os.Args[2:])
case "release":
cmdRelease(client, os.Args[2:])
case "version", "--version", "-v":
fmt.Println("giteactl", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'giteactl help'", os.Args[1])
}
}
func cmdRuns(client *gitea.Client, args []string) {
fs := flag.NewFlagSet("runs", flag.ExitOnError)
limit := fs.Int("limit", 10, "max jobs to list")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("runs needs exactly one repo name")
}
tasks, err := client.Tasks(pos[0], *limit)
if err != nil {
die("%v", err)
}
if len(tasks) == 0 {
fmt.Println("no Actions runs")
return
}
fmt.Printf("%-6s %-18s %-9s %-14s %-9s %s\n", "RUN", "JOB", "STATUS", "BRANCH", "TOOK", "TITLE")
for _, t := range tasks {
took := ""
if d := t.Duration(); d > 0 {
took = d.Round(time.Second).String()
}
fmt.Printf("%-6d %-18s %-9s %-14s %-9s %s\n",
t.RunNumber, trunc(t.Name, 18), t.Status, trunc(t.HeadBranch, 14), took, trunc(t.DisplayTitle, 46))
}
}
func cmdLog(client *gitea.Client, cfg *config, args []string) {
fs := flag.NewFlagSet("log", flag.ExitOnError)
job := fs.Int("job", 0, "job index within the run (when a run has several)")
pos := parseInterspersed(fs, args)
if len(pos) != 2 {
die("log needs: giteactl log <repo> <run-number>")
}
repo := pos[0]
runNo, err := strconv.ParseInt(pos[1], 10, 64)
if err != nil {
die("%q is not a run number", pos[1])
}
tasks, err := client.Tasks(repo, 100)
if err != nil {
die("%v", err)
}
var runTasks []gitea.Task
for _, t := range tasks {
if t.RunNumber == runNo {
runTasks = append(runTasks, t)
}
}
if len(runTasks) == 0 {
die("run %d not found among the latest 100 jobs", runNo)
}
if *job >= len(runTasks) {
die("run %d has %d jobs (0..%d)", runNo, len(runTasks), len(runTasks)-1)
}
// tasks are newest-first; job index counts from the run's start
task := runTasks[len(runTasks)-1-*job]
// 1) API (needs token), 2) public web route, 3) ssh + zstd
if cfg.Token != "" {
if log, err := client.LogAPI(repo, task.ID); err == nil {
fmt.Print(log)
return
}
}
if log, err := client.LogWeb(repo, runNo, *job); err == nil {
fmt.Print(log)
return
}
path := gitea.ZstPath(cfg.LogDir, cfg.Owner, repo, task.ID)
fmt.Fprintf(os.Stderr, "giteactl: HTTP routes failed, trying ssh %s cat %s\n", cfg.LogSSHHost, path)
ssh := exec.Command("sh", "-c",
fmt.Sprintf("ssh %s cat %q | zstd -dc", cfg.LogSSHHost, path))
ssh.Stdout, ssh.Stderr = os.Stdout, os.Stderr
if err := ssh.Run(); err != nil {
die("all log sources failed (api/web/ssh): %v", err)
}
}
func cmdWait(client *gitea.Client, args []string) {
fs := flag.NewFlagSet("wait", flag.ExitOnError)
timeout := fs.Duration("timeout", 30*time.Minute, "total time budget")
interval := fs.Duration("interval", 15*time.Second, "poll interval")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("wait needs exactly one repo name")
}
res, err := gitea.WaitRun(pos[0], func(r string) ([]gitea.Task, error) { return client.Tasks(r, 30) },
*interval, *timeout, func(s string) { fmt.Fprintln(os.Stderr, "giteactl: "+s) })
if err != nil {
die("%v", err)
}
for _, t := range res.Tasks {
fmt.Printf("run %d %-18s %s\n", t.RunNumber, t.Name, t.Status)
}
switch {
case !res.Done:
fmt.Fprintln(os.Stderr, "giteactl: timeout — run still active")
os.Exit(3)
case !res.AllOK:
fmt.Fprintln(os.Stderr, "giteactl: run finished RED")
os.Exit(2)
}
fmt.Fprintln(os.Stderr, "giteactl: run finished green")
}
func cmdWaitQuiet(client *gitea.Client, cfg *config, args []string) {
fs := flag.NewFlagSet("wait-quiet", flag.ExitOnError)
maxActive := fs.Int("max-active", 1, "max simultaneously active builds")
timeout := fs.Duration("timeout", 30*time.Minute, "total time budget")
interval := fs.Duration("interval", 20*time.Second, "poll interval")
parseInterspersed(fs, args)
// private repos without a token become blind spots, not failures —
// warn once per repo and count the ones we can see
warned := map[string]bool{}
fetch := func(r string) ([]gitea.Task, error) {
tasks, err := client.Tasks(r, 10)
if err != nil {
if !warned[r] {
warned[r] = true
fmt.Fprintf(os.Stderr, "giteactl: warning: cannot check %s (%v)\n", r, err)
}
return nil, nil
}
return tasks, nil
}
ok, err := gitea.WaitQuiet(cfg.HeavyRepos, *maxActive, fetch,
*interval, *timeout, func(s string) { fmt.Fprintln(os.Stderr, "giteactl: "+s) })
if err != nil {
die("%v", err)
}
if !ok {
fmt.Fprintln(os.Stderr, "giteactl: timeout — runner still busy")
os.Exit(3)
}
fmt.Println("runner quiet — safe to push")
}
func cmdRelease(client *gitea.Client, args []string) {
if len(args) < 1 {
die("release needs: giteactl release <repo> [<tag>]")
}
repo := args[0]
releases, err := client.Releases(repo, 30)
if err != nil {
die("%v", err)
}
if len(releases) == 0 {
fmt.Println("no releases")
return
}
rel := releases[0]
if len(args) > 1 {
found := false
for _, r := range releases {
if r.TagName == args[1] {
rel, found = r, true
break
}
}
if !found {
die("no release with tag %q", args[1])
}
}
fmt.Printf("%s (%s, published %s)\n", rel.TagName, rel.Name, rel.PublishedAt.Local().Format("2006-01-02 15:04"))
for _, a := range rel.Assets {
fmt.Printf(" %-28s %8.1f KiB %s\n", a.Name, float64(a.Size)/1024, a.DownloadURL)
}
}
func trunc(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) []string {
var flags, pos []string
for i := 0; i < len(args); i++ {
a := args[i]
if len(a) > 1 && a[0] == '-' {
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if eq := strings.Index(name, "="); eq >= 0 {
continue
}
if f := fs.Lookup(name); f != nil {
if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
continue
}
}
if i+1 < len(args) {
i++
flags = append(flags, args[i])
}
} else {
pos = append(pos, a)
}
}
fs.Parse(flags)
return pos
}
func die(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "giteactl: "+format+"\n", args...)
os.Exit(1)
}