# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

NodeMaster is a beego v2 REST API (Go module `nodemaster`) for managing a single Docker/Podman host ("the node"), a registry of remote NodeMaster nodes, and the compose-based services running on the local node — including a scheduled/on-demand backup system for both the node and individual services.

## Commands

```bash
go build ./...              # build
go vet ./...                # vet
gofmt -l .                  # list files needing formatting (gofmt -w to fix)
go test ./...                # run all tests
go test ./tests/ -run TestGetNode   # run a single test
go run . scan [folder] [--json]     # CLI: scan compose folders, don't start the HTTP server
go run . info                       # CLI: print node summary, don't start the HTTP server
go run .                            # start the HTTP API (beego.Run(), port from conf/app.conf)
bee run -gendoc=true -downdoc=true  # dev server: rebuild-on-change + regenerate swagger/swagger.json on every save
bee generate routers                # regenerate routers/commentsRouter.go from @router annotations (run after adding/changing any controller method)
```

Swagger UI is served at `/swagger` when `runmode = dev` (see `conf/app.conf`). The spec files `swagger/swagger.json` / `swagger/swagger.yml` and `routers/commentsRouter.go` are all **generated** — never hand-edit them. Run `bee run -gendoc=true -downdoc=true` for day-to-day dev (it rebuilds and regenerates the swagger spec on every file change, and downloads the swagger UI static assets via `-downdoc` if `swagger/index.html` is missing); after adding, removing, or changing any controller method's `@router` annotation, also run `bee generate routers` to regenerate `routers/commentsRouter.go` (see routing note below). Two pre-existing `@Success`/`@Param` annotations reference types `bee generate docs` can't resolve (a package-local unexported struct `nodeServices` in `controllers/nodes.go`, and the pseudo-type `object` in `controllers/node.go`'s scan endpoint) — this prints a harmless `Cannot find the object: ...` warning and generates a placeholder empty-object schema for those two entries; it does not affect generation success or the routes themselves.

There is no README and no lint config beyond `go vet`/`gofmt`.

## Architecture

**Config persistence (`models/store.go`):** the entire application state — local node info, remote node registry, and services — is one `AppConfig` struct, serialized as JSON to `/etc/nodemaster/nodemaster.conf` (falls back to `~/.nodemaster.conf` if that path isn't writable). A single package-level `sync.RWMutex` (`mu`) guards the in-memory `cfg *AppConfig`. Every mutating function calls `persist()` to write the JSON back out. Because the config can hold a restic repository password (see below), `persist()` writes/chmods the file `0600`.

**API namespaces (all under `/v1`, wired in `routers/router.go`):**
- `/node` — the local host: info, update history, backup config/targets/run, compose scan
- `/nodes` — remote node registry, plus `GET /nodes/aggregated` which calls each registered node's own `/v1/services` over HTTP and merges the results
- `/services` — compose-based services on the local node: CRUD, start/stop, backup config/targets/run

**Critical routing gotcha:** beego's `NSInclude` resolves controller routes from `beego.GlobalControllerRouter`, which is populated by `routers/commentsRouter.go` — a **generated** file, rebuilt by `bee generate routers` (with no flags: this project's `ctrlDir`/`routersFile` match bee v2's defaults, `controllers` and `routers/commentsRouter.go`). If you add, remove, or change a controller method's `@router` annotation, you must run `bee generate routers` afterwards, or the route will silently 404 (it won't error at startup — it just won't be registered). Never hand-edit `commentsRouter.go`; a plain rerun of `bee generate routers` overwrites it in place from the current `@router` comments. (Historically this file was hand-maintained under the name `commentsRouter_controllers.go` after `bee generate routers` seemed unreliable — the actual issue was just that the default bee output path is `routers/commentsRouter.go`, not `commentsRouter_controllers.go`; running the generator with that stale filename created a second, duplicate router file instead of updating the existing one. Regenerating at the correct default path resolved it.)

**Backup execution (`models/backup.go`):** three methods — `MethodRsync`, `MethodRestic`, `MethodExternal` (no `rclone`; it was removed in favor of restic). `BackupTarget.Remote` is overloaded by method: an rsync destination (`user@host:/path`) or a restic repository string (`/mnt/backup/repo`, `s3:...`, `sftp:...`, etc.); `BackupTarget.Params` becomes extra `--key=value` CLI flags for whichever tool runs. `MethodExternal` is a no-op (the operator manages that backup outside the API). Restic's repository password is **node-global, not per-target**: `NodeInfo.ResticPassword`, read via `models.GetResticPassword()`, set through `PUT /v1/node`'s `restic_password` field (never echoed back — `GET /v1/node` only exposes `restic_password_set: bool`). It's passed to the `restic` binary via `RESTIC_PASSWORD`/`RESTIC_REPOSITORY` env vars, never argv. `runTarget` calls `ensureResticRepo()` (runs `restic init`, tolerates "already initialized") before every restic backup rather than tracking init state separately.

**Backup targets are individually CRUD-able**, not just a whole-`BackupConfig` replace: `/v1/node/backup/targets[...]` and `/v1/services/:id/backup/targets[...]`, each with `:tid` sub-routes for get/put/delete/run/progress, plus a `.../backup/progress` aggregate view. `BackupTarget.Schedule` is a 5-field standard cron expression (empty = manual-only). Validated centrally in `validateBackupTarget` (`models/backup.go`) — this is the single place that knows the set of valid `BackupMethod` values and schedule syntax.

**Scheduling (`models/scheduler.go`):** an in-process `robfig/cron/v3` scheduler. `StartScheduler()` is called once from `main.go`, only on the normal HTTP-server path (not on the `scan`/`info` CLI paths). `SyncSchedule()` fully tears down and rebuilds every cron entry from the current target definitions in `cfg`, and must be called after *any* mutation that can change `Targets` — not just the per-target CRUD endpoints, but also whole-object replace paths (`UpdateNodeBackupConfig`, `AddService`, `UpdateService`, `DeleteService`), since those can silently add/remove/alter schedules too. Any new way of mutating backup targets needs a `SyncSchedule()` call after `persist()`.

**Progress tracking (`models/progress.go`):** an in-memory-only `RunState` map (its own mutex, deliberately kept out of `cfg`/`persist()` — a crash/restart must never leave a stale "running" flag on disk). Populated by `models/backup.go`'s `progressWriter`, wired as both `cmd.Stdout` and `cmd.Stderr`: rsync via `--info=progress2` (regex on the `NN%` token), restic via `backup --json` (parses `message_type` lines: `status` for percent/ETA, `summary` for the final snapshot id, `error` for failures). Parsing is best-effort — an unparsed line just leaves the last known percent/message in place rather than failing the backup.

**Shared run helpers:** `models.RunNodeBackup(targetIDs []string)` and `models.RunServiceBackup(id string, targetIDs []string)` are the single code path used by manual-trigger controllers, the scheduler, and the "run all targets" endpoints. Don't duplicate stop/start/backup/persist logic in a controller — route new triggers through these.

**Compose support:** `docker compose` (default), `docker-compose`, `podman-compose`, selected per-service via `Service.ComposeType` (`models/backup.go`'s `composeCmd`). Services can be configured to stop before backup (`StopOnBackup=true`) and restart after; all backup runs are asynchronous (goroutine) and append a `BackupExecution` record to history.

**Compose scan (`models/scan.go`, `POST /v1/node/scan` or `nodemaster scan [folder] [--json]`):**
- Scans `/opt/compose`, `/root/compose`, `/home/*/compose` by default, plus an optional extra folder
- Parses compose YAML to deduce a backup folder (prefers `/opt/data/containers/<name>`, falls back to the common path prefix across volume mounts)
- Probes live status via `docker ps --filter label=com.docker.compose.project=<name>`
- Falls back to a text-based parser for compose files with duplicate YAML keys (which `gopkg.in/yaml.v3` rejects)

## Key files

- `models/types.go` — every persisted struct (`AppConfig`, `NodeInfo`, `RemoteNode`, `Service`, `BackupConfig`, `BackupTarget`, `BackupExecution`, `ScanResult`, etc.)
- `models/store.go` — the persistence layer (`cfg`, `mu`, `persist()`, config path resolution)
- `models/backup.go` — `RunBackup`/`runTarget`, `progressWriter`, `validateBackupTarget`, `StartService`/`StopService`/`composeCmd`
- `models/progress.go` — in-memory `RunState` tracking (not persisted)
- `models/scheduler.go` — cron wiring: `StartScheduler`/`SyncSchedule`
- `models/scan.go` — `ScanAll`, compose YAML parsing, backup-folder deduction
- `models/node.go` / `models/nodes.go` / `models/services.go` — CRUD for the local node, remote node registry, and services respectively
- `controllers/*.go` — beego controllers with `@router` annotations (source of truth for API surface)
- `routers/commentsRouter.go` — generated beego route registration, rebuilt via `bee generate routers` (see routing gotcha above) — never hand-edit
- `routers/router.go` — namespace wiring + dev-mode CORS filter
- `main.go` — entrypoint; also implements the `scan`/`info` CLI subcommands ahead of the normal `beego.Run()` path
