9.6 KiB
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
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, plusGET /nodes/aggregatedwhich calls each registered node's own/v1/servicesover 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/*/composeby 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.v3rejects)
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/composeCmdmodels/progress.go— in-memoryRunStatetracking (not persisted)models/scheduler.go— cron wiring:StartScheduler/SyncSchedulemodels/scan.go—ScanAll, compose YAML parsing, backup-folder deductionmodels/node.go/models/nodes.go/models/services.go— CRUD for the local node, remote node registry, and services respectivelycontrollers/*.go— beego controllers with@routerannotations (source of truth for API surface)routers/commentsRouter.go— generated beego route registration, rebuilt viabee generate routers(see routing gotcha above) — never hand-editrouters/router.go— namespace wiring + dev-mode CORS filtermain.go— entrypoint; also implements thescan/infoCLI subcommands ahead of the normalbeego.Run()path