initial commit

This commit is contained in:
2026-07-27 14:41:04 +02:00
commit 5c01fbdd5f
37 changed files with 6082 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
# 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
View File
+10
View File
@@ -0,0 +1,10 @@
appname = nodemaster
httpport = 8080
runmode = dev
autorender = false
copyrequestbody = true
EnableDocs = true
sqlconn =
; comma-separated list of origins allowed to call this API from a browser
; (dev mode only for now). Supports "*" wildcards, e.g. "http://localhost:*".
corsalloworigins = http://localhost:*
+352
View File
@@ -0,0 +1,352 @@
package controllers
import (
"encoding/json"
"nodemaster/models"
beego "github.com/beego/beego/v2/server/web"
)
// NodeController manages the current host node.
type NodeController struct {
beego.Controller
}
// @Title GetNode
// @Description get current node info including runtime config_file path
// @Success 200 {object} models.NodeInfo
// @router / [get]
func (c *NodeController) Get() {
n := models.GetLocalNode()
c.Data["json"] = map[string]interface{}{
"hostname": n.Hostname,
"description": n.Description,
"update_history": n.UpdateHistory,
"backup": n.Backup,
"config_file": models.ConfigPath(),
"restic_password_set": n.ResticPassword != "",
}
c.ServeJSON()
}
// @Title UpdateNode
// @Description update current node hostname, description, or restic_password (the repository password used to encrypt/decrypt all restic backup targets on this node)
// @Param body body models.NodeInfo true "fields to update"
// @Success 200 {string} update success
// @Failure 400 invalid body
// @router / [put]
func (c *NodeController) Put() {
var n models.NodeInfo
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &n); err != nil {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
if err := models.UpdateLocalNode(n); err != nil {
c.Ctx.Output.SetStatus(500)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = map[string]string{"message": "update success"}
c.ServeJSON()
}
// @Title GetUpdates
// @Description list update history
// @Success 200 {object} []models.UpdateRecord
// @router /updates [get]
func (c *NodeController) GetUpdates() {
c.Data["json"] = models.GetUpdateHistory()
c.ServeJSON()
}
// @Title GetUpdate
// @Description get a single update record
// @Param id path string true "update record id"
// @Success 200 {object} models.UpdateRecord
// @Failure 404 not found
// @router /updates/:id [get]
func (c *NodeController) GetUpdate() {
id := c.Ctx.Input.Param(":id")
r, err := models.GetUpdateRecord(id)
if err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = r
c.ServeJSON()
}
// @Title AddUpdate
// @Description record a new update entry
// @Param body body models.UpdateRecord true "update record"
// @Success 201 {object} models.UpdateRecord
// @Failure 400 invalid body
// @router /updates [post]
func (c *NodeController) AddUpdate() {
var r models.UpdateRecord
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &r); err != nil {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
record, err := models.AddUpdateRecord(r)
if err != nil {
c.Ctx.Output.SetStatus(500)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Ctx.Output.SetStatus(201)
c.Data["json"] = record
c.ServeJSON()
}
// @Title DeleteUpdate
// @Description delete an update record
// @Param id path string true "update record id"
// @Success 200 {string} delete success
// @Failure 404 not found
// @router /updates/:id [delete]
func (c *NodeController) DeleteUpdate() {
id := c.Ctx.Input.Param(":id")
if err := models.DeleteUpdateRecord(id); err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = map[string]string{"message": "delete success"}
c.ServeJSON()
}
// @Title GetBackup
// @Description get node backup configuration
// @Success 200 {object} models.BackupConfig
// @router /backup [get]
func (c *NodeController) GetBackup() {
c.Data["json"] = models.GetNodeBackupConfig()
c.ServeJSON()
}
// @Title UpdateBackup
// @Description update node backup configuration
// @Param body body models.BackupConfig true "backup config"
// @Success 200 {string} update success
// @Failure 400 invalid body
// @router /backup [put]
func (c *NodeController) PutBackup() {
var bc models.BackupConfig
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &bc); err != nil {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
if err := models.UpdateNodeBackupConfig(bc); err != nil {
c.Ctx.Output.SetStatus(500)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = map[string]string{"message": "update success"}
c.ServeJSON()
}
// @Title Scan
// @Description scan compose folders and index discovered services; accepts optional {"folder":"/path"} body
// @Param body body object false "optional folder override"
// @Success 200 {object} models.ScanResult
// @router /scan [post]
func (c *NodeController) Scan() {
var req struct {
Folder string `json:"folder"`
}
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &req)
result := models.ScanAll(req.Folder)
c.Data["json"] = result
c.ServeJSON()
}
// @Title RunBackup
// @Description trigger an immediate node backup across all targets (asynchronous)
// @Success 202 {string} backup started
// @Failure 400 no targets configured
// @router /backup/run [post]
func (c *NodeController) RunBackup() {
bc := models.GetNodeBackupConfig()
if len(bc.Targets) == 0 {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": "no backup targets configured"}
c.ServeJSON()
return
}
c.Ctx.Output.SetStatus(202)
c.Data["json"] = map[string]string{"message": "backup started"}
c.ServeJSON()
go func() {
_ = models.RunNodeBackup(nil)
}()
}
// @Title GetBackupTargets
// @Description list node backup target definitions
// @Success 200 {object} []models.BackupTarget
// @router /backup/targets [get]
func (c *NodeController) GetBackupTargets() {
c.Data["json"] = models.GetNodeBackupTargets()
c.ServeJSON()
}
// @Title AddBackupTarget
// @Description define a new node backup target
// @Param body body models.BackupTarget true "backup target definition"
// @Success 201 {object} models.BackupTarget
// @Failure 400 invalid body
// @router /backup/targets [post]
func (c *NodeController) AddBackupTarget() {
var t models.BackupTarget
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &t); err != nil {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
created, err := models.AddNodeBackupTarget(t)
if err != nil {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Ctx.Output.SetStatus(201)
c.Data["json"] = created
c.ServeJSON()
}
// @Title GetBackupTarget
// @Description get a node backup target by id
// @Param tid path string true "backup target id"
// @Success 200 {object} models.BackupTarget
// @Failure 404 not found
// @router /backup/targets/:tid [get]
func (c *NodeController) GetBackupTarget() {
tid := c.Ctx.Input.Param(":tid")
t, err := models.GetNodeBackupTarget(tid)
if err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = t
c.ServeJSON()
}
// @Title PutBackupTarget
// @Description update a node backup target definition
// @Param tid path string true "backup target id"
// @Param body body models.BackupTarget true "updated backup target"
// @Success 200 {object} models.BackupTarget
// @Failure 400 invalid body
// @Failure 404 not found
// @router /backup/targets/:tid [put]
func (c *NodeController) PutBackupTarget() {
tid := c.Ctx.Input.Param(":tid")
var t models.BackupTarget
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &t); err != nil {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
updated, err := models.UpdateNodeBackupTarget(tid, t)
if err != nil {
status := 404
if err.Error() != "backup target not found" {
status = 400
}
c.Ctx.Output.SetStatus(status)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = updated
c.ServeJSON()
}
// @Title DeleteBackupTarget
// @Description delete a node backup target definition
// @Param tid path string true "backup target id"
// @Success 200 {string} delete success
// @Failure 404 not found
// @router /backup/targets/:tid [delete]
func (c *NodeController) DeleteBackupTarget() {
tid := c.Ctx.Input.Param(":tid")
if err := models.DeleteNodeBackupTarget(tid); err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = map[string]string{"message": "delete success"}
c.ServeJSON()
}
// @Title RunBackupTarget
// @Description trigger an immediate backup for a single node target (asynchronous)
// @Param tid path string true "backup target id"
// @Success 202 {string} backup started
// @Failure 404 not found
// @router /backup/targets/:tid/run [post]
func (c *NodeController) RunBackupTarget() {
tid := c.Ctx.Input.Param(":tid")
if _, err := models.GetNodeBackupTarget(tid); err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Ctx.Output.SetStatus(202)
c.Data["json"] = map[string]string{"message": "backup started"}
c.ServeJSON()
go func() {
_ = models.RunNodeBackup([]string{tid})
}()
}
// @Title GetBackupTargetProgress
// @Description get the current/last run state for a node backup target
// @Param tid path string true "backup target id"
// @Success 200 {object} models.RunState
// @Failure 404 not found
// @router /backup/targets/:tid/progress [get]
func (c *NodeController) GetBackupTargetProgress() {
tid := c.Ctx.Input.Param(":tid")
if _, err := models.GetNodeBackupTarget(tid); err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
state, ok := models.GetProgress("node", tid)
if !ok {
state = models.RunState{TargetID: tid}
}
c.Data["json"] = state
c.ServeJSON()
}
// @Title GetBackupProgress
// @Description get the current/last run state for every node backup target
// @Success 200 {object} []models.RunState
// @router /backup/progress [get]
func (c *NodeController) GetBackupProgress() {
c.Data["json"] = models.GetProgressForScope("node")
c.ServeJSON()
}
+159
View File
@@ -0,0 +1,159 @@
package controllers
import (
"encoding/json"
"io"
"net/http"
"nodemaster/models"
"time"
beego "github.com/beego/beego/v2/server/web"
)
// NodesController manages the registry of remote nodes.
type NodesController struct {
beego.Controller
}
// @Title GetAllNodes
// @Description list all registered remote nodes
// @Success 200 {object} []models.RemoteNode
// @router / [get]
func (c *NodesController) GetAll() {
c.Data["json"] = models.GetAllRemoteNodes()
c.ServeJSON()
}
// @Title AddNode
// @Description register a remote node
// @Param body body models.RemoteNode true "node to register"
// @Success 201 {object} models.RemoteNode
// @Failure 400 invalid body
// @router / [post]
func (c *NodesController) Post() {
var n models.RemoteNode
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &n); err != nil {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
created, err := models.AddRemoteNode(n)
if err != nil {
c.Ctx.Output.SetStatus(500)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Ctx.Output.SetStatus(201)
c.Data["json"] = created
c.ServeJSON()
}
// @Title GetNode
// @Description get a remote node by id
// @Param id path string true "node id"
// @Success 200 {object} models.RemoteNode
// @Failure 404 not found
// @router /:id [get]
func (c *NodesController) Get() {
id := c.Ctx.Input.Param(":id")
n, err := models.GetRemoteNode(id)
if err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = n
c.ServeJSON()
}
// @Title UpdateNode
// @Description update a remote node
// @Param id path string true "node id"
// @Param body body models.RemoteNode true "updated node data"
// @Success 200 {object} models.RemoteNode
// @Failure 400 invalid body
// @Failure 404 not found
// @router /:id [put]
func (c *NodesController) Put() {
id := c.Ctx.Input.Param(":id")
var n models.RemoteNode
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &n); err != nil {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
updated, err := models.UpdateRemoteNode(id, n)
if err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = updated
c.ServeJSON()
}
// @Title DeleteNode
// @Description delete a remote node
// @Param id path string true "node id"
// @Success 200 {string} delete success
// @Failure 404 not found
// @router /:id [delete]
func (c *NodesController) Delete() {
id := c.Ctx.Input.Param(":id")
if err := models.DeleteRemoteNode(id); err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = map[string]string{"message": "delete success"}
c.ServeJSON()
}
type nodeServices struct {
Node models.RemoteNode `json:"node"`
Services []models.Service `json:"services"`
Error string `json:"error,omitempty"`
}
// @Title GetAggregated
// @Description query all registered nodes for their services and return an aggregated view
// @Success 200 {object} []nodeServices
// @router /aggregated [get]
func (c *NodesController) GetAggregated() {
nodes := models.GetAllRemoteNodes()
client := &http.Client{Timeout: 10 * time.Second}
results := make([]nodeServices, 0, len(nodes))
for _, node := range nodes {
entry := nodeServices{Node: node}
resp, err := client.Get(node.URL + "/v1/services")
if err != nil {
entry.Error = err.Error()
results = append(results, entry)
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
entry.Error = err.Error()
results = append(results, entry)
continue
}
var svcs []models.Service
if err := json.Unmarshal(body, &svcs); err != nil {
entry.Error = "failed to parse services: " + err.Error()
} else {
entry.Services = svcs
}
results = append(results, entry)
}
c.Data["json"] = results
c.ServeJSON()
}
+397
View File
@@ -0,0 +1,397 @@
package controllers
import (
"encoding/json"
"fmt"
"nodemaster/models"
"strings"
"time"
beego "github.com/beego/beego/v2/server/web"
)
// ServicesController manages services on this host.
type ServicesController struct {
beego.Controller
}
// @Title GetAllServices
// @Description list all services
// @Success 200 {object} []models.Service
// @router / [get]
func (c *ServicesController) GetAll() {
c.Data["json"] = models.GetAllServices()
c.ServeJSON()
}
// @Title AddService
// @Description register a new service
// @Param body body models.Service true "service definition"
// @Success 201 {object} models.Service
// @Failure 400 invalid body
// @router / [post]
func (c *ServicesController) Post() {
var s models.Service
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &s); err != nil {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
created, err := models.AddService(s)
if err != nil {
c.Ctx.Output.SetStatus(500)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Ctx.Output.SetStatus(201)
c.Data["json"] = created
c.ServeJSON()
}
// @Title GetService
// @Description get a service by id
// @Param id path string true "service id"
// @Success 200 {object} models.Service
// @Failure 404 not found
// @router /:id [get]
func (c *ServicesController) Get() {
id := c.Ctx.Input.Param(":id")
s, err := models.GetService(id)
if err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = s
c.ServeJSON()
}
// @Title UpdateService
// @Description update a service definition
// @Param id path string true "service id"
// @Param body body models.Service true "updated service data"
// @Success 200 {object} models.Service
// @Failure 400 invalid body
// @Failure 404 not found
// @router /:id [put]
func (c *ServicesController) Put() {
id := c.Ctx.Input.Param(":id")
var s models.Service
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &s); err != nil {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
updated, err := models.UpdateService(id, s)
if err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = updated
c.ServeJSON()
}
// @Title DeleteService
// @Description delete a service
// @Param id path string true "service id"
// @Success 200 {string} delete success
// @Failure 404 not found
// @router /:id [delete]
func (c *ServicesController) Delete() {
id := c.Ctx.Input.Param(":id")
if err := models.DeleteService(id); err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = map[string]string{"message": "delete success"}
c.ServeJSON()
}
// @Title StartService
// @Description start a service via its compose file
// @Param id path string true "service id"
// @Success 200 {string} started
// @Failure 404 not found
// @router /:id/start [post]
func (c *ServicesController) Start() {
id := c.Ctx.Input.Param(":id")
svc, err := models.GetService(id)
if err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
if err := models.StartService(svc); err != nil {
c.Ctx.Output.SetStatus(500)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
_ = models.UpdateServiceStatus(id, "up")
c.Data["json"] = map[string]string{"message": "service started"}
c.ServeJSON()
}
// @Title StopService
// @Description stop a service via its compose file
// @Param id path string true "service id"
// @Success 200 {string} stopped
// @Failure 404 not found
// @router /:id/stop [post]
func (c *ServicesController) Stop() {
id := c.Ctx.Input.Param(":id")
svc, err := models.GetService(id)
if err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
if err := models.StopService(svc); err != nil {
_ = models.UpdateServiceStatus(id, "down")
c.Ctx.Output.SetStatus(500)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
_ = models.UpdateServiceStatus(id, "down")
c.Data["json"] = map[string]string{"message": "service stopped"}
c.ServeJSON()
}
// @Title BackupService
// @Description trigger an immediate backup for a service across all targets (asynchronous)
// @Param id path string true "service id"
// @Success 202 {string} backup started
// @Failure 400 no backup configured
// @Failure 404 not found
// @router /:id/backup [post]
func (c *ServicesController) Backup() {
id := c.Ctx.Input.Param(":id")
svc, err := models.GetService(id)
if err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
if svc.Backup == nil || len(svc.Backup.Targets) == 0 {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": "no backup targets configured for this service"}
c.ServeJSON()
return
}
c.Ctx.Output.SetStatus(202)
c.Data["json"] = map[string]string{
"message": "backup started",
"started": fmt.Sprintf("%s", time.Now().UTC().Format(time.RFC3339)),
}
c.ServeJSON()
go func() {
_ = models.RunServiceBackup(id, nil)
}()
}
// @Title GetServiceBackupTargets
// @Description list a service's backup target definitions
// @Param id path string true "service id"
// @Success 200 {object} []models.BackupTarget
// @Failure 404 not found
// @router /:id/backup/targets [get]
func (c *ServicesController) GetBackupTargets() {
id := c.Ctx.Input.Param(":id")
targets, err := models.GetServiceBackupTargets(id)
if err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = targets
c.ServeJSON()
}
// @Title AddServiceBackupTarget
// @Description define a new backup target for a service
// @Param id path string true "service id"
// @Param body body models.BackupTarget true "backup target definition"
// @Success 201 {object} models.BackupTarget
// @Failure 400 invalid body
// @Failure 404 not found
// @router /:id/backup/targets [post]
func (c *ServicesController) AddBackupTarget() {
id := c.Ctx.Input.Param(":id")
var t models.BackupTarget
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &t); err != nil {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
created, err := models.AddServiceBackupTarget(id, t)
if err != nil {
status := 400
if err.Error() == "service not found" {
status = 404
}
c.Ctx.Output.SetStatus(status)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Ctx.Output.SetStatus(201)
c.Data["json"] = created
c.ServeJSON()
}
// @Title GetServiceBackupTarget
// @Description get a service backup target by id
// @Param id path string true "service id"
// @Param tid path string true "backup target id"
// @Success 200 {object} models.BackupTarget
// @Failure 404 not found
// @router /:id/backup/targets/:tid [get]
func (c *ServicesController) GetBackupTarget() {
id := c.Ctx.Input.Param(":id")
tid := c.Ctx.Input.Param(":tid")
t, err := models.GetServiceBackupTarget(id, tid)
if err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = t
c.ServeJSON()
}
// @Title PutServiceBackupTarget
// @Description update a service backup target definition
// @Param id path string true "service id"
// @Param tid path string true "backup target id"
// @Param body body models.BackupTarget true "updated backup target"
// @Success 200 {object} models.BackupTarget
// @Failure 400 invalid body
// @Failure 404 not found
// @router /:id/backup/targets/:tid [put]
func (c *ServicesController) PutBackupTarget() {
id := c.Ctx.Input.Param(":id")
tid := c.Ctx.Input.Param(":tid")
var t models.BackupTarget
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &t); err != nil {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
updated, err := models.UpdateServiceBackupTarget(id, tid, t)
if err != nil {
status := 400
if strings.Contains(err.Error(), "not found") {
status = 404
}
c.Ctx.Output.SetStatus(status)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = updated
c.ServeJSON()
}
// @Title DeleteServiceBackupTarget
// @Description delete a service backup target definition
// @Param id path string true "service id"
// @Param tid path string true "backup target id"
// @Success 200 {string} delete success
// @Failure 404 not found
// @router /:id/backup/targets/:tid [delete]
func (c *ServicesController) DeleteBackupTarget() {
id := c.Ctx.Input.Param(":id")
tid := c.Ctx.Input.Param(":tid")
if err := models.DeleteServiceBackupTarget(id, tid); err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = map[string]string{"message": "delete success"}
c.ServeJSON()
}
// @Title RunServiceBackupTarget
// @Description trigger an immediate backup for a single service target (asynchronous)
// @Param id path string true "service id"
// @Param tid path string true "backup target id"
// @Success 202 {string} backup started
// @Failure 404 not found
// @router /:id/backup/targets/:tid/run [post]
func (c *ServicesController) RunBackupTarget() {
id := c.Ctx.Input.Param(":id")
tid := c.Ctx.Input.Param(":tid")
if _, err := models.GetServiceBackupTarget(id, tid); err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Ctx.Output.SetStatus(202)
c.Data["json"] = map[string]string{"message": "backup started"}
c.ServeJSON()
go func() {
_ = models.RunServiceBackup(id, []string{tid})
}()
}
// @Title GetServiceBackupTargetProgress
// @Description get the current/last run state for a service backup target
// @Param id path string true "service id"
// @Param tid path string true "backup target id"
// @Success 200 {object} models.RunState
// @Failure 404 not found
// @router /:id/backup/targets/:tid/progress [get]
func (c *ServicesController) GetBackupTargetProgress() {
id := c.Ctx.Input.Param(":id")
tid := c.Ctx.Input.Param(":tid")
if _, err := models.GetServiceBackupTarget(id, tid); err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
state, ok := models.GetProgress("svc:"+id, tid)
if !ok {
state = models.RunState{TargetID: tid}
}
c.Data["json"] = state
c.ServeJSON()
}
// @Title GetServiceBackupProgress
// @Description get the current/last run state for every backup target of a service
// @Param id path string true "service id"
// @Success 200 {object} []models.RunState
// @Failure 404 not found
// @router /:id/backup/progress [get]
func (c *ServicesController) GetBackupProgress() {
id := c.Ctx.Input.Param(":id")
if _, err := models.GetService(id); err != nil {
c.Ctx.Output.SetStatus(404)
c.Data["json"] = map[string]string{"error": err.Error()}
c.ServeJSON()
return
}
c.Data["json"] = models.GetProgressForScope("svc:" + id)
c.ServeJSON()
}
+35
View File
@@ -0,0 +1,35 @@
module nodemaster
go 1.24
require github.com/beego/beego/v2 v2.1.0
require (
github.com/robfig/cron/v3 v3.0.1
github.com/smartystreets/goconvey v1.6.4
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/kr/text v0.2.0 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.15.1 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.42.0 // indirect
github.com/prometheus/procfs v0.9.0 // indirect
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 // indirect
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect
golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/text v0.7.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
)
+81
View File
@@ -0,0 +1,81 @@
github.com/beego/beego/v2 v2.1.0 h1:Lk0FtQGvDQCx5V5yEu4XwDsIgt+QOlNjt5emUa3/ZmA=
github.com/beego/beego/v2 v2.1.0/go.mod h1:6h36ISpaxNrrpJ27siTpXBG8d/Icjzsc7pU1bWpp0EE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw=
github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 h1:DAYUYH5869yV94zvCES9F51oYtN5oGlwjxJJz7ZCnik=
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18/go.mod h1:nkxAfR/5quYxwPZhyDxgasBMnRtBZd0FCEpawpjMUFg=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd h1:XcWmESyNjXJMLahc3mqVQJcgSTDxFxhETVlfk9uGc38=
golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"encoding/json"
"fmt"
"nodemaster/models"
"os"
"strings"
_ "nodemaster/routers"
beego "github.com/beego/beego/v2/server/web"
)
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "scan":
runScanCLI(os.Args[2:])
return
case "info":
runInfoCLI()
return
}
}
if beego.BConfig.RunMode == "dev" {
beego.BConfig.WebConfig.DirectoryIndex = true
beego.BConfig.WebConfig.StaticDir["/swagger"] = "swagger"
}
models.StartScheduler()
beego.Run()
}
func runInfoCLI() {
n := models.GetLocalNode()
svcs := models.GetAllServices()
nodes := models.GetAllRemoteNodes()
fmt.Println("NodeMaster info")
fmt.Println(strings.Repeat("─", 60))
fmt.Printf("Config file : %s\n", models.ConfigPath())
fmt.Printf("Hostname : %s\n", n.Hostname)
if n.Description != "" {
fmt.Printf("Description : %s\n", n.Description)
}
fmt.Printf("Services : %d registered\n", len(svcs))
fmt.Printf("Remote nodes: %d registered\n", len(nodes))
fmt.Printf("Updates : %d records\n", len(n.UpdateHistory))
}
func runScanCLI(args []string) {
var folder string
jsonOutput := false
for _, a := range args {
switch {
case a == "--json":
jsonOutput = true
case !strings.HasPrefix(a, "-"):
folder = a
}
}
result := models.ScanAll(folder)
if jsonOutput {
out, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(out))
if len(result.Errors) > 0 {
os.Exit(1)
}
return
}
fmt.Println("NodeMaster scan")
fmt.Println(strings.Repeat("─", 60))
fmt.Printf("Config file : %s\n\n", models.ConfigPath())
if len(result.ScannedPaths) == 0 {
fmt.Println("No scan paths found (no /opt/compose, no ~/compose dirs)")
} else {
fmt.Println("Paths scanned:")
for _, p := range result.ScannedPaths {
fmt.Printf(" %s\n", p)
}
}
fmt.Printf("\nCompose files found: %d\n", result.Found)
if len(result.Created) > 0 {
fmt.Printf("\nCreated (%d):\n", len(result.Created))
for _, s := range result.Created {
printScanEntry(s)
}
}
if len(result.Updated) > 0 {
fmt.Printf("\nUpdated (%d):\n", len(result.Updated))
for _, s := range result.Updated {
printScanEntry(s)
}
}
if len(result.Created) == 0 && len(result.Updated) == 0 {
fmt.Println("\nNo changes (all services already up to date)")
}
if len(result.Errors) > 0 {
fmt.Printf("\nErrors:\n")
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, " ! %s\n", e)
}
os.Exit(1)
}
}
func printScanEntry(s models.ServiceScanInfo) {
status := s.Status
if status == "" {
status = "unknown"
}
backup := s.BackupFolder
if backup == "" {
backup = "(none deduced)"
}
fmt.Printf(" %-24s [%-7s] backup: %s\n", s.Name, status, backup)
}
+298
View File
@@ -0,0 +1,298 @@
package models
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
"github.com/robfig/cron/v3"
)
// validateBackupTarget checks that a backup target is well-formed enough to
// store: a known method, and (if set) a parseable cron schedule.
func validateBackupTarget(t BackupTarget) error {
switch t.Method {
case MethodRsync, MethodRestic, MethodExternal:
default:
return fmt.Errorf("unknown backup method: %s", t.Method)
}
if t.Schedule != "" {
if _, err := cron.ParseStandard(t.Schedule); err != nil {
return fmt.Errorf("invalid schedule %q: %w", t.Schedule, err)
}
}
return nil
}
// RunBackup executes backup for all targets against sourcePath and returns one
// execution record per target. scope identifies where progress is published
// ("node" or "svc:<serviceID>").
func RunBackup(scope string, targets []BackupTarget, sourcePath string) []BackupExecution {
results := make([]BackupExecution, 0, len(targets))
for _, t := range targets {
results = append(results, runTarget(scope, t, sourcePath))
}
return results
}
func runTarget(scope string, t BackupTarget, sourcePath string) BackupExecution {
start := time.Now()
ex := BackupExecution{
ID: fmt.Sprintf("bex_%d", start.UnixNano()),
Timestamp: start.UTC(),
TargetID: t.ID,
}
key := progressKey(scope, t.ID)
startedAt := start.UTC()
setProgress(key, RunState{Running: true, TargetID: t.ID, Method: t.Method, StartedAt: &startedAt})
if t.Method == MethodExternal {
ex.Success = true
ex.Message = "external backup managed externally; no action taken by API"
setProgress(key, RunState{Running: false, TargetID: t.ID, Method: t.Method, Percent: 100, Message: ex.Message, StartedAt: &startedAt})
return ex
}
var cmd *exec.Cmd
switch t.Method {
case MethodRsync:
args := []string{"-avz", "--info=progress2"}
for k, v := range t.Params {
args = append(args, "--"+k+"="+v)
}
args = append(args, sourcePath, t.Remote)
cmd = exec.Command("rsync", args...)
case MethodRestic:
password := GetResticPassword()
if password == "" {
ex.Success = false
ex.Message = "no restic repository password configured on this node; set restic_password via PUT /v1/node"
setProgress(key, RunState{Running: false, TargetID: t.ID, Method: t.Method, Message: ex.Message, StartedAt: &startedAt})
return ex
}
env := append(os.Environ(), "RESTIC_REPOSITORY="+t.Remote, "RESTIC_PASSWORD="+password)
if err := ensureResticRepo(env); err != nil {
ex.Success = false
ex.Message = fmt.Sprintf("restic init failed: %v", err)
setProgress(key, RunState{Running: false, TargetID: t.ID, Method: t.Method, Message: ex.Message, StartedAt: &startedAt})
return ex
}
args := []string{"backup", sourcePath, "--json"}
for k, v := range t.Params {
args = append(args, "--"+k+"="+v)
}
cmd = exec.Command("restic", args...)
cmd.Env = env
default:
ex.Success = false
ex.Message = fmt.Sprintf("unknown backup method: %s", t.Method)
setProgress(key, RunState{Running: false, TargetID: t.ID, Method: t.Method, Message: ex.Message, StartedAt: &startedAt})
return ex
}
pw := &progressWriter{key: key, targetID: t.ID, method: t.Method, startedAt: startedAt}
cmd.Stdout = pw
cmd.Stderr = pw
err := cmd.Run()
ex.Duration = int64(time.Since(start).Seconds())
out := strings.TrimSpace(pw.buf.String())
if err != nil {
ex.Success = false
ex.Message = fmt.Sprintf("%v | %s", err, out)
} else {
ex.Success = true
if pw.lastMessage != "" {
ex.Message = pw.lastMessage
} else {
ex.Message = out
}
}
final := pw.state()
final.Running = false
if ex.Success {
final.Percent = 100
}
final.Message = ex.Message
setProgress(key, final)
return ex
}
// ensureResticRepo initializes the restic repository named by env's
// RESTIC_REPOSITORY on first use. Running "init" against a repository that
// already exists fails with a message containing "already"; that case is
// treated as success so callers don't need to track init state themselves.
func ensureResticRepo(env []string) error {
cmd := exec.Command("restic", "init")
cmd.Env = env
out, err := cmd.CombinedOutput()
if err == nil {
return nil
}
if strings.Contains(strings.ToLower(string(out)), "already") {
return nil
}
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
}
// progressWriter is assigned as both Stdout and Stderr of a backup command. It
// buffers the full output (for the final BackupExecution.Message) while also
// scanning completed lines for progress info to publish via setProgress.
// Best-effort only: a line that doesn't parse just leaves the last known
// percentage/ETA in place rather than failing the backup.
type progressWriter struct {
buf bytes.Buffer
pending []byte
key string
targetID string
method BackupMethod
startedAt time.Time
percent float64
eta string
lastMessage string
}
func (w *progressWriter) Write(p []byte) (int, error) {
w.buf.Write(p)
w.pending = append(w.pending, p...)
for {
idx := bytes.IndexAny(w.pending, "\r\n")
if idx < 0 {
break
}
line := bytes.TrimSpace(w.pending[:idx])
w.pending = w.pending[idx+1:]
if len(line) > 0 {
w.handleLine(line)
}
}
return len(p), nil
}
func (w *progressWriter) handleLine(line []byte) {
switch w.method {
case MethodRsync:
w.handleRsyncLine(line)
case MethodRestic:
w.handleResticLine(line)
}
setProgress(w.key, w.state())
}
var rsyncPercentRe = regexp.MustCompile(`(\d+)%`)
func (w *progressWriter) handleRsyncLine(line []byte) {
w.lastMessage = string(line)
m := rsyncPercentRe.FindSubmatch(line)
if m == nil {
return
}
var pct float64
if _, err := fmt.Sscanf(string(m[1]), "%f", &pct); err == nil {
w.percent = pct
}
}
// resticStatusLine models the subset of restic's `backup --json` line kinds
// (message_type "status", "summary", "error") that are useful for progress
// reporting; unrecognized/unparseable lines are ignored.
type resticStatusLine struct {
MessageType string `json:"message_type"`
PercentDone float64 `json:"percent_done"`
SecondsRemaining float64 `json:"seconds_remaining"`
SnapshotID string `json:"snapshot_id"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
func (w *progressWriter) handleResticLine(line []byte) {
var sl resticStatusLine
if err := json.Unmarshal(line, &sl); err != nil {
return
}
switch sl.MessageType {
case "status":
w.percent = sl.PercentDone * 100
if sl.SecondsRemaining > 0 {
w.eta = fmt.Sprintf("%.0fs", sl.SecondsRemaining)
}
case "summary":
w.percent = 100
if sl.SnapshotID != "" {
w.lastMessage = fmt.Sprintf("snapshot %s created", sl.SnapshotID)
}
case "error":
if sl.Error != nil && sl.Error.Message != "" {
w.lastMessage = sl.Error.Message
}
}
}
func (w *progressWriter) state() RunState {
startedAt := w.startedAt
return RunState{
Running: true,
TargetID: w.targetID,
Method: w.method,
Percent: w.percent,
ETA: w.eta,
Message: w.lastMessage,
StartedAt: &startedAt,
}
}
// StartService runs the compose start command for the given service.
func StartService(svc Service) error {
cmd := composeCmd(svc, "start")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// StopService runs the compose stop command for the given service.
func StopService(svc Service) error {
cmd := composeCmd(svc, "stop")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func composeCmd(svc Service, action string) *exec.Cmd {
switch svc.ComposeType {
case "docker-compose":
return exec.Command("docker-compose", "-f", svc.ComposeFile, action)
case "podman-compose":
return exec.Command("podman-compose", "-f", svc.ComposeFile, action)
default:
return exec.Command("docker", "compose", "-f", svc.ComposeFile, action)
}
}
// filterTargets returns the subset of targets whose ID is in ids. A nil or
// empty ids means "all targets".
func filterTargets(targets []BackupTarget, ids []string) []BackupTarget {
if len(ids) == 0 {
return targets
}
want := make(map[string]bool, len(ids))
for _, id := range ids {
want[id] = true
}
result := make([]BackupTarget, 0, len(ids))
for _, t := range targets {
if want[t.ID] {
result = append(result, t)
}
}
return result
}
+221
View File
@@ -0,0 +1,221 @@
package models
import (
"errors"
"fmt"
"time"
)
func GetLocalNode() NodeInfo {
mu.RLock()
defer mu.RUnlock()
return cfg.Node
}
func UpdateLocalNode(n NodeInfo) error {
mu.Lock()
defer mu.Unlock()
if n.Hostname != "" {
cfg.Node.Hostname = n.Hostname
}
if n.Description != "" {
cfg.Node.Description = n.Description
}
if n.ResticPassword != "" {
cfg.Node.ResticPassword = n.ResticPassword
}
return persist()
}
// GetResticPassword returns the node-wide restic repository password used to
// encrypt/decrypt every restic backup target on this node.
func GetResticPassword() string {
mu.RLock()
defer mu.RUnlock()
return cfg.Node.ResticPassword
}
func GetUpdateHistory() []UpdateRecord {
mu.RLock()
defer mu.RUnlock()
result := make([]UpdateRecord, len(cfg.Node.UpdateHistory))
copy(result, cfg.Node.UpdateHistory)
return result
}
func GetUpdateRecord(id string) (UpdateRecord, error) {
mu.RLock()
defer mu.RUnlock()
for _, r := range cfg.Node.UpdateHistory {
if r.ID == id {
return r, nil
}
}
return UpdateRecord{}, errors.New("update record not found")
}
func AddUpdateRecord(r UpdateRecord) (UpdateRecord, error) {
mu.Lock()
defer mu.Unlock()
r.ID = fmt.Sprintf("upd_%d", time.Now().UnixNano())
r.Timestamp = time.Now().UTC()
cfg.Node.UpdateHistory = append(cfg.Node.UpdateHistory, r)
return r, persist()
}
func DeleteUpdateRecord(id string) error {
mu.Lock()
defer mu.Unlock()
for i, r := range cfg.Node.UpdateHistory {
if r.ID == id {
cfg.Node.UpdateHistory = append(cfg.Node.UpdateHistory[:i], cfg.Node.UpdateHistory[i+1:]...)
return persist()
}
}
return errors.New("update record not found")
}
func GetNodeBackupConfig() BackupConfig {
mu.RLock()
defer mu.RUnlock()
return cfg.Node.Backup
}
func UpdateNodeBackupConfig(bc BackupConfig) error {
mu.Lock()
history := cfg.Node.Backup.History
cfg.Node.Backup = bc
cfg.Node.Backup.History = history
err := persist()
mu.Unlock()
if err != nil {
return err
}
SyncSchedule()
return nil
}
func AppendNodeBackupExecution(ex BackupExecution) error {
mu.Lock()
defer mu.Unlock()
cfg.Node.Backup.History = append(cfg.Node.Backup.History, ex)
now := time.Now().UTC()
cfg.Node.Backup.LastRun = &now
for i, t := range cfg.Node.Backup.Targets {
if t.ID == ex.TargetID {
cfg.Node.Backup.Targets[i].LastRun = &now
break
}
}
return persist()
}
// GetNodeBackupTargets lists the node's individual backup target definitions.
func GetNodeBackupTargets() []BackupTarget {
mu.RLock()
defer mu.RUnlock()
result := make([]BackupTarget, len(cfg.Node.Backup.Targets))
copy(result, cfg.Node.Backup.Targets)
return result
}
// GetNodeBackupTarget returns a single node backup target by ID.
func GetNodeBackupTarget(tid string) (BackupTarget, error) {
mu.RLock()
defer mu.RUnlock()
for _, t := range cfg.Node.Backup.Targets {
if t.ID == tid {
return t, nil
}
}
return BackupTarget{}, errors.New("backup target not found")
}
// AddNodeBackupTarget creates a new backup target definition for the node.
func AddNodeBackupTarget(t BackupTarget) (BackupTarget, error) {
if err := validateBackupTarget(t); err != nil {
return BackupTarget{}, err
}
mu.Lock()
t.ID = fmt.Sprintf("bt_%d", time.Now().UnixNano())
cfg.Node.Backup.Targets = append(cfg.Node.Backup.Targets, t)
err := persist()
mu.Unlock()
if err != nil {
return BackupTarget{}, err
}
SyncSchedule()
return t, nil
}
// UpdateNodeBackupTarget replaces an existing node backup target definition.
func UpdateNodeBackupTarget(tid string, t BackupTarget) (BackupTarget, error) {
if err := validateBackupTarget(t); err != nil {
return BackupTarget{}, err
}
mu.Lock()
found := false
for i, existing := range cfg.Node.Backup.Targets {
if existing.ID == tid {
t.ID = tid
t.LastRun = existing.LastRun
cfg.Node.Backup.Targets[i] = t
found = true
break
}
}
if !found {
mu.Unlock()
return BackupTarget{}, errors.New("backup target not found")
}
err := persist()
mu.Unlock()
if err != nil {
return BackupTarget{}, err
}
SyncSchedule()
return t, nil
}
// DeleteNodeBackupTarget removes a node backup target definition.
func DeleteNodeBackupTarget(tid string) error {
mu.Lock()
found := false
for i, t := range cfg.Node.Backup.Targets {
if t.ID == tid {
cfg.Node.Backup.Targets = append(cfg.Node.Backup.Targets[:i], cfg.Node.Backup.Targets[i+1:]...)
found = true
break
}
}
if !found {
mu.Unlock()
return errors.New("backup target not found")
}
err := persist()
mu.Unlock()
if err != nil {
return err
}
SyncSchedule()
return nil
}
// RunNodeBackup runs the node's backup for the given target IDs (nil/empty
// means all targets), appending an execution record per target.
func RunNodeBackup(targetIDs []string) error {
bc := GetNodeBackupConfig()
targets := filterTargets(bc.Targets, targetIDs)
if len(targets) == 0 {
return errors.New("no matching backup targets")
}
sourcePath := bc.SourcePath
if sourcePath == "" {
sourcePath = ConfigPath()
}
results := RunBackup("node", targets, sourcePath)
for _, ex := range results {
_ = AppendNodeBackupExecution(ex)
}
return nil
}
+59
View File
@@ -0,0 +1,59 @@
package models
import (
"errors"
"fmt"
"time"
)
func GetAllRemoteNodes() []RemoteNode {
mu.RLock()
defer mu.RUnlock()
result := make([]RemoteNode, len(cfg.Nodes))
copy(result, cfg.Nodes)
return result
}
func GetRemoteNode(id string) (RemoteNode, error) {
mu.RLock()
defer mu.RUnlock()
for _, n := range cfg.Nodes {
if n.ID == id {
return n, nil
}
}
return RemoteNode{}, errors.New("node not found")
}
func AddRemoteNode(n RemoteNode) (RemoteNode, error) {
mu.Lock()
defer mu.Unlock()
n.ID = fmt.Sprintf("node_%d", time.Now().UnixNano())
cfg.Nodes = append(cfg.Nodes, n)
return n, persist()
}
func UpdateRemoteNode(id string, n RemoteNode) (RemoteNode, error) {
mu.Lock()
defer mu.Unlock()
for i, node := range cfg.Nodes {
if node.ID == id {
n.ID = id
cfg.Nodes[i] = n
return n, persist()
}
}
return RemoteNode{}, errors.New("node not found")
}
func DeleteRemoteNode(id string) error {
mu.Lock()
defer mu.Unlock()
for i, n := range cfg.Nodes {
if n.ID == id {
cfg.Nodes = append(cfg.Nodes[:i], cfg.Nodes[i+1:]...)
return persist()
}
}
return errors.New("node not found")
}
+59
View File
@@ -0,0 +1,59 @@
package models
import (
"strings"
"sync"
"time"
)
// RunState is the in-memory, non-persisted status of a backup target's current
// or most recent run. It intentionally lives outside cfg/persist(): a crash or
// restart should never leave a stale "still running" flag behind.
type RunState struct {
Running bool `json:"running"`
TargetID string `json:"target_id"`
Method BackupMethod `json:"method,omitempty"`
Percent float64 `json:"percent"`
ETA string `json:"eta,omitempty"`
Message string `json:"message,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
}
var (
progressMu sync.RWMutex
progress = map[string]RunState{}
)
// progressKey builds the scoped key a target's run state is stored under.
// scope is "node" or "svc:<serviceID>".
func progressKey(scope, targetID string) string {
return scope + ":" + targetID
}
func setProgress(key string, s RunState) {
progressMu.Lock()
defer progressMu.Unlock()
progress[key] = s
}
// GetProgress returns the current run state for a single target, if any is known.
func GetProgress(scope, targetID string) (RunState, bool) {
progressMu.RLock()
defer progressMu.RUnlock()
s, ok := progress[progressKey(scope, targetID)]
return s, ok
}
// GetProgressForScope returns all known run states for a scope ("node" or "svc:<id>").
func GetProgressForScope(scope string) []RunState {
prefix := scope + ":"
progressMu.RLock()
defer progressMu.RUnlock()
result := make([]RunState, 0)
for k, s := range progress {
if strings.HasPrefix(k, prefix) {
result = append(result, s)
}
}
return result
}
+366
View File
@@ -0,0 +1,366 @@
package models
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
var composeFileNames = []string{
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
}
// Host paths excluded from backup folder deduction.
var excludedPrefixes = []string{"/var/run/", "/proc/", "/sys/", "/dev/", "/run/", "/etc/"}
var excludedExact = []string{
"/var/run/docker.sock",
"/etc/timezone", "/etc/localtime", "/etc/hosts", "/etc/hostname", "/etc/resolv.conf",
}
// ScanAll discovers compose-based services under all default paths plus an optional
// extra folder. Existing services matched by compose file path are updated (status,
// and backup folder if previously unset). New services are created.
func ScanAll(extraFolder string) ScanResult {
paths := resolveScanPaths(extraFolder)
result := ScanResult{
ScannedPaths: paths,
Created: []ServiceScanInfo{},
Updated: []ServiceScanInfo{},
Errors: []string{},
}
for _, p := range paths {
r := scanFolder(p)
result.Found += r.Found
result.Created = append(result.Created, r.Created...)
result.Updated = append(result.Updated, r.Updated...)
result.Errors = append(result.Errors, r.Errors...)
}
return result
}
// resolveScanPaths returns the directories to scan: /opt/compose, /root/compose,
// /home/*/compose, and any explicitly supplied extra path.
func resolveScanPaths(extra string) []string {
var paths []string
for _, candidate := range []string{"/opt/compose", "/root/compose"} {
if isAccessibleDir(candidate) {
paths = append(paths, candidate)
}
}
if entries, err := os.ReadDir("/home"); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
p := filepath.Join("/home", e.Name(), "compose")
if isAccessibleDir(p) {
paths = append(paths, p)
}
}
}
if extra != "" && isAccessibleDir(extra) {
paths = append(paths, extra)
}
return paths
}
func isAccessibleDir(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
// scanFolder scans the immediate subdirectories of folder for compose files.
func scanFolder(folder string) ScanResult {
result := ScanResult{Created: []ServiceScanInfo{}, Updated: []ServiceScanInfo{}}
entries, err := os.ReadDir(folder)
if err != nil {
result.Errors = []string{fmt.Sprintf("cannot read %s: %v", folder, err)}
return result
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
serviceDir := filepath.Join(folder, entry.Name())
composeFile := findComposeFile(serviceDir)
if composeFile == "" {
continue
}
result.Found++
info, err := processComposeFile(entry.Name(), composeFile)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", composeFile, err))
continue
}
created, isNew := upsertService(info)
if created {
if isNew {
result.Created = append(result.Created, info)
} else {
result.Updated = append(result.Updated, info)
}
}
}
return result
}
func findComposeFile(dir string) string {
for _, name := range composeFileNames {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
// processComposeFile parses a compose file and produces a ServiceScanInfo.
func processComposeFile(name, composeFile string) (ServiceScanInfo, error) {
hostPaths, err := extractHostPaths(composeFile)
if err != nil {
return ServiceScanInfo{}, err
}
return ServiceScanInfo{
Name: name,
ComposeFile: composeFile,
BackupFolder: deduceBackupFolder(hostPaths, name),
Status: probeServiceStatus(composeFile),
}, nil
}
// extractHostPaths parses all volume bind-mount host paths from a compose YAML file,
// filtering out system/runtime paths that should not be backed up.
// If yaml.v3 strict parsing fails (e.g. duplicate mapping keys in real-world compose
// files), it falls back to a lenient text-based extraction.
func extractHostPaths(composeFile string) ([]string, error) {
data, err := os.ReadFile(composeFile)
if err != nil {
return nil, err
}
var doc map[string]interface{}
if err := yaml.Unmarshal(data, &doc); err != nil {
// Malformed YAML (duplicate keys etc.) — fall back to text scan.
return extractHostPathsFallback(data), nil
}
servicesRaw, ok := doc["services"]
if !ok {
return nil, nil
}
services, _ := servicesRaw.(map[string]interface{})
seen := map[string]bool{}
var paths []string
for _, svcRaw := range services {
svc, ok := svcRaw.(map[string]interface{})
if !ok {
continue
}
volumes, _ := svc["volumes"].([]interface{})
for _, v := range volumes {
var hostPath string
switch vv := v.(type) {
case string:
hostPath = hostPathFromShortForm(vv)
case map[string]interface{}:
if t, _ := vv["type"].(string); t == "bind" {
hostPath, _ = vv["source"].(string)
}
}
hostPath = filepath.Clean(hostPath)
if hostPath != "" && hostPath != "." && isBackupablePath(hostPath) && !seen[hostPath] {
seen[hostPath] = true
paths = append(paths, hostPath)
}
}
}
return paths, nil
}
// extractHostPathsFallback uses simple line scanning to extract volume bind-mounts
// from compose files that fail strict YAML parsing (duplicate keys, etc.).
// It looks for list items that contain an absolute host path followed by a colon.
func extractHostPathsFallback(data []byte) []string {
seen := map[string]bool{}
var paths []string
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
// Volume list entries: "- /abs/path:/container/path" (with or without quotes)
if !strings.HasPrefix(trimmed, "- ") {
continue
}
spec := strings.Trim(strings.TrimPrefix(trimmed, "- "), "\"' ")
if !strings.HasPrefix(spec, "/") {
continue
}
hostPath := hostPathFromShortForm(spec)
if hostPath == "" {
continue
}
hostPath = filepath.Clean(hostPath)
if isBackupablePath(hostPath) && !seen[hostPath] {
seen[hostPath] = true
paths = append(paths, hostPath)
}
}
return paths
}
// hostPathFromShortForm extracts the host path from "host:container[:opts]" volume spec.
func hostPathFromShortForm(spec string) string {
parts := strings.SplitN(spec, ":", 3)
if len(parts) < 2 {
return ""
}
src := parts[0]
if !filepath.IsAbs(src) {
return "" // named volume or relative path
}
return src
}
// isBackupablePath returns false for system/runtime paths that should not be backed up.
func isBackupablePath(path string) bool {
for _, exact := range excludedExact {
if path == exact {
return false
}
}
for _, prefix := range excludedPrefixes {
if strings.HasPrefix(path, prefix) {
return false
}
}
return true
}
// deduceBackupFolder finds the most appropriate backup root for a service.
//
// Priority:
// 1. /opt/data/containers/<serviceName> — standard layout in the examples
// 2. Longest common directory prefix across all collected host paths
// 3. First collected path as-is
func deduceBackupFolder(hostPaths []string, serviceName string) string {
if len(hostPaths) == 0 {
return ""
}
// 1. Standard layout
standard := "/opt/data/containers/" + serviceName
for _, p := range hostPaths {
if strings.HasPrefix(p, standard) {
return standard
}
}
// 2. Common prefix
prefix := commonPathPrefix(hostPaths)
if prefix != "" && prefix != "/" {
return prefix
}
// 3. Fallback
return hostPaths[0]
}
// commonPathPrefix returns the longest common directory prefix shared by all paths.
func commonPathPrefix(paths []string) string {
if len(paths) == 0 {
return ""
}
if len(paths) == 1 {
return paths[0]
}
sep := string(filepath.Separator)
parts := strings.Split(paths[0], sep)
for _, p := range paths[1:] {
other := strings.Split(p, sep)
limit := len(parts)
if len(other) < limit {
limit = len(other)
}
i := 0
for i < limit && parts[i] == other[i] {
i++
}
parts = parts[:i]
}
if len(parts) <= 1 {
return "/"
}
return strings.Join(parts, sep)
}
// probeServiceStatus checks whether the service has any running containers.
// Uses the docker project label set by compose, then falls back to compose CLI.
func probeServiceStatus(composeFile string) string {
projectName := strings.ToLower(filepath.Base(filepath.Dir(composeFile)))
// Method 1: docker ps with project label (no compose binary required)
cmd := exec.Command("docker", "ps", "-q",
"--filter", "label=com.docker.compose.project="+projectName)
if out, err := cmd.Output(); err == nil {
if strings.TrimSpace(string(out)) != "" {
return "up"
}
return "down"
}
// Method 2: docker compose ps
cmd = exec.Command("docker", "compose", "-f", composeFile, "ps", "-q")
if out, err := cmd.Output(); err == nil {
if strings.TrimSpace(string(out)) != "" {
return "up"
}
return "down"
}
// Method 3: legacy docker-compose
cmd = exec.Command("docker-compose", "-f", composeFile, "ps", "-q")
if out, err := cmd.Output(); err == nil {
if strings.TrimSpace(string(out)) != "" {
return "up"
}
return "down"
}
return "unknown"
}
// upsertService creates or updates a service entry indexed by compose file path.
// Returns (true, true) = created; (true, false) = updated; (false, _) = no change.
func upsertService(info ServiceScanInfo) (bool, bool) {
services := GetAllServices()
for _, svc := range services {
if svc.ComposeFile != info.ComposeFile {
continue
}
// Found — update status and backup folder if previously unset
changed := svc.Status != info.Status
if svc.BackupFolder == "" && info.BackupFolder != "" {
svc.BackupFolder = info.BackupFolder
changed = true
}
svc.Status = info.Status
if changed {
_, _ = UpdateService(svc.ID, svc)
return true, false
}
return false, false
}
// Not found — create
_, _ = AddService(Service{
Name: info.Name,
ComposeFile: info.ComposeFile,
BackupFolder: info.BackupFolder,
Status: info.Status,
ComposeType: "docker compose",
})
return true, true
}
+148
View File
@@ -0,0 +1,148 @@
package models
import (
"log"
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
)
var (
schedMu sync.Mutex
schedCron *cron.Cron
entryTarget = map[cron.EntryID]targetRef{}
)
type targetRef struct {
scope string // "node" or "svc:<serviceID>"
targetID string
}
// StartScheduler starts the in-process cron scheduler and performs an initial
// sync from the current configuration. Call once from main.go before
// beego.Run() — not from the scan/info CLI paths.
func StartScheduler() {
schedMu.Lock()
schedCron = cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
schedCron.Start()
schedMu.Unlock()
SyncSchedule()
}
// SyncSchedule rebuilds all cron entries from the current backup target
// definitions (node + every service). Called once at startup and after any
// target add/update/delete so the running scheduler reflects the latest
// definitions.
func SyncSchedule() {
schedMu.Lock()
defer schedMu.Unlock()
if schedCron == nil {
return
}
for _, e := range schedCron.Entries() {
schedCron.Remove(e.ID)
}
entryTarget = map[cron.EntryID]targetRef{}
mu.RLock()
nodeTargets := make([]BackupTarget, len(cfg.Node.Backup.Targets))
copy(nodeTargets, cfg.Node.Backup.Targets)
services := make([]Service, len(cfg.Services))
copy(services, cfg.Services)
mu.RUnlock()
for _, t := range nodeTargets {
registerJob("node", t)
}
for _, s := range services {
if s.Backup == nil {
continue
}
for _, t := range s.Backup.Targets {
registerJob("svc:"+s.ID, t)
}
}
refreshNextRunLocked()
}
// registerJob schedules a single target's cron job. Caller must hold schedMu.
func registerJob(scope string, t BackupTarget) {
if t.Schedule == "" {
return
}
schedule, err := cron.ParseStandard(t.Schedule)
if err != nil {
// Already validated at CRUD time; defensively skip a bad schedule
// rather than let it wedge the whole sync.
return
}
targetID := t.ID
job := cron.NewChain(cron.SkipIfStillRunning(cron.DefaultLogger)).Then(cron.FuncJob(func() {
runScheduledTarget(scope, targetID)
}))
id := schedCron.Schedule(schedule, job)
entryTarget[id] = targetRef{scope: scope, targetID: targetID}
}
func runScheduledTarget(scope, targetID string) {
var err error
switch {
case scope == "node":
err = RunNodeBackup([]string{targetID})
case strings.HasPrefix(scope, "svc:"):
err = RunServiceBackup(strings.TrimPrefix(scope, "svc:"), []string{targetID})
}
if err != nil {
log.Printf("nodemaster: scheduled backup %s/%s failed: %v", scope, targetID, err)
}
schedMu.Lock()
refreshNextRunLocked()
schedMu.Unlock()
}
// refreshNextRunLocked writes each scheduled target's NextRun from the cron
// entries' computed next-fire time. Caller must hold schedMu.
func refreshNextRunLocked() {
if schedCron == nil {
return
}
updates := map[targetRef]time.Time{}
for _, e := range schedCron.Entries() {
if ref, ok := entryTarget[e.ID]; ok {
updates[ref] = e.Next
}
}
if len(updates) == 0 {
return
}
mu.Lock()
for ref, next := range updates {
n := next
if ref.scope == "node" {
for i, t := range cfg.Node.Backup.Targets {
if t.ID == ref.targetID {
cfg.Node.Backup.Targets[i].NextRun = &n
break
}
}
continue
}
svcID := strings.TrimPrefix(ref.scope, "svc:")
for i, s := range cfg.Services {
if s.ID != svcID || s.Backup == nil {
continue
}
for j, t := range s.Backup.Targets {
if t.ID == ref.targetID {
cfg.Services[i].Backup.Targets[j].NextRun = &n
break
}
}
break
}
}
_ = persist()
mu.Unlock()
}
+316
View File
@@ -0,0 +1,316 @@
package models
import (
"errors"
"fmt"
"time"
)
func GetAllServices() []Service {
mu.RLock()
defer mu.RUnlock()
result := make([]Service, len(cfg.Services))
copy(result, cfg.Services)
return result
}
func GetService(id string) (Service, error) {
mu.RLock()
defer mu.RUnlock()
for _, s := range cfg.Services {
if s.ID == id {
return s, nil
}
}
return Service{}, errors.New("service not found")
}
func AddService(s Service) (Service, error) {
mu.Lock()
s.ID = fmt.Sprintf("svc_%d", time.Now().UnixNano())
if s.Backup != nil {
if s.Backup.Targets == nil {
s.Backup.Targets = []BackupTarget{}
}
if s.Backup.History == nil {
s.Backup.History = []BackupExecution{}
}
}
cfg.Services = append(cfg.Services, s)
err := persist()
mu.Unlock()
if err != nil {
return Service{}, err
}
SyncSchedule()
return s, nil
}
func UpdateService(id string, s Service) (Service, error) {
mu.Lock()
found := false
for i, svc := range cfg.Services {
if svc.ID == id {
s.ID = id
if s.Backup != nil && svc.Backup != nil {
s.Backup.History = svc.Backup.History
}
cfg.Services[i] = s
found = true
break
}
}
if !found {
mu.Unlock()
return Service{}, errors.New("service not found")
}
err := persist()
mu.Unlock()
if err != nil {
return Service{}, err
}
SyncSchedule()
return s, nil
}
func DeleteService(id string) error {
mu.Lock()
found := false
for i, s := range cfg.Services {
if s.ID == id {
cfg.Services = append(cfg.Services[:i], cfg.Services[i+1:]...)
found = true
break
}
}
if !found {
mu.Unlock()
return errors.New("service not found")
}
err := persist()
mu.Unlock()
if err != nil {
return err
}
SyncSchedule()
return nil
}
func UpdateServiceStatus(id, status string) error {
mu.Lock()
defer mu.Unlock()
for i, s := range cfg.Services {
if s.ID == id {
cfg.Services[i].Status = status
return persist()
}
}
return errors.New("service not found")
}
func AppendServiceBackupExecution(id string, ex BackupExecution) error {
mu.Lock()
defer mu.Unlock()
for i, s := range cfg.Services {
if s.ID == id {
if cfg.Services[i].Backup == nil {
cfg.Services[i].Backup = &BackupConfig{
Targets: []BackupTarget{},
History: []BackupExecution{},
}
}
cfg.Services[i].Backup.History = append(cfg.Services[i].Backup.History, ex)
now := time.Now().UTC()
cfg.Services[i].Backup.LastRun = &now
for j, t := range cfg.Services[i].Backup.Targets {
if t.ID == ex.TargetID {
cfg.Services[i].Backup.Targets[j].LastRun = &now
break
}
}
return persist()
}
}
return errors.New("service not found")
}
// GetServiceBackupTargets lists a service's individual backup target definitions.
func GetServiceBackupTargets(id string) ([]BackupTarget, error) {
mu.RLock()
defer mu.RUnlock()
for _, s := range cfg.Services {
if s.ID == id {
if s.Backup == nil {
return []BackupTarget{}, nil
}
result := make([]BackupTarget, len(s.Backup.Targets))
copy(result, s.Backup.Targets)
return result, nil
}
}
return nil, errors.New("service not found")
}
// GetServiceBackupTarget returns a single backup target by ID for a service.
func GetServiceBackupTarget(id, tid string) (BackupTarget, error) {
mu.RLock()
defer mu.RUnlock()
for _, s := range cfg.Services {
if s.ID == id {
if s.Backup == nil {
return BackupTarget{}, errors.New("backup target not found")
}
for _, t := range s.Backup.Targets {
if t.ID == tid {
return t, nil
}
}
return BackupTarget{}, errors.New("backup target not found")
}
}
return BackupTarget{}, errors.New("service not found")
}
// AddServiceBackupTarget creates a new backup target definition for a service.
func AddServiceBackupTarget(id string, t BackupTarget) (BackupTarget, error) {
if err := validateBackupTarget(t); err != nil {
return BackupTarget{}, err
}
mu.Lock()
idx := -1
for i, s := range cfg.Services {
if s.ID == id {
idx = i
break
}
}
if idx == -1 {
mu.Unlock()
return BackupTarget{}, errors.New("service not found")
}
if cfg.Services[idx].Backup == nil {
cfg.Services[idx].Backup = &BackupConfig{
Targets: []BackupTarget{},
History: []BackupExecution{},
}
}
t.ID = fmt.Sprintf("bt_%d", time.Now().UnixNano())
cfg.Services[idx].Backup.Targets = append(cfg.Services[idx].Backup.Targets, t)
err := persist()
mu.Unlock()
if err != nil {
return BackupTarget{}, err
}
SyncSchedule()
return t, nil
}
// UpdateServiceBackupTarget replaces an existing backup target definition for a service.
func UpdateServiceBackupTarget(id, tid string, t BackupTarget) (BackupTarget, error) {
if err := validateBackupTarget(t); err != nil {
return BackupTarget{}, err
}
mu.Lock()
svcIdx := -1
for i, s := range cfg.Services {
if s.ID == id {
svcIdx = i
break
}
}
if svcIdx == -1 || cfg.Services[svcIdx].Backup == nil {
mu.Unlock()
return BackupTarget{}, errors.New("service not found")
}
found := false
for i, existing := range cfg.Services[svcIdx].Backup.Targets {
if existing.ID == tid {
t.ID = tid
t.LastRun = existing.LastRun
cfg.Services[svcIdx].Backup.Targets[i] = t
found = true
break
}
}
if !found {
mu.Unlock()
return BackupTarget{}, errors.New("backup target not found")
}
err := persist()
mu.Unlock()
if err != nil {
return BackupTarget{}, err
}
SyncSchedule()
return t, nil
}
// DeleteServiceBackupTarget removes a backup target definition from a service.
func DeleteServiceBackupTarget(id, tid string) error {
mu.Lock()
svcIdx := -1
for i, s := range cfg.Services {
if s.ID == id {
svcIdx = i
break
}
}
if svcIdx == -1 || cfg.Services[svcIdx].Backup == nil {
mu.Unlock()
return errors.New("service not found")
}
found := false
for i, t := range cfg.Services[svcIdx].Backup.Targets {
if t.ID == tid {
cfg.Services[svcIdx].Backup.Targets = append(
cfg.Services[svcIdx].Backup.Targets[:i],
cfg.Services[svcIdx].Backup.Targets[i+1:]...,
)
found = true
break
}
}
if !found {
mu.Unlock()
return errors.New("backup target not found")
}
err := persist()
mu.Unlock()
if err != nil {
return err
}
SyncSchedule()
return nil
}
// RunServiceBackup runs a service's backup for the given target IDs (nil/empty
// means all targets), honoring StopOnBackup, and appends an execution record
// per target.
func RunServiceBackup(id string, targetIDs []string) error {
svc, err := GetService(id)
if err != nil {
return err
}
if svc.Backup == nil || len(svc.Backup.Targets) == 0 {
return errors.New("no backup targets configured for this service")
}
targets := filterTargets(svc.Backup.Targets, targetIDs)
if len(targets) == 0 {
return errors.New("no matching backup targets")
}
sourcePath := svc.BackupFolder
if svc.Backup.SourcePath != "" {
sourcePath = svc.Backup.SourcePath
}
if svc.StopOnBackup {
_ = StopService(svc)
}
results := RunBackup("svc:"+id, targets, sourcePath)
for _, ex := range results {
_ = AppendServiceBackupExecution(id, ex)
}
if svc.StopOnBackup {
_ = StartService(svc)
}
return nil
}
+93
View File
@@ -0,0 +1,93 @@
package models
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
var (
mu sync.RWMutex
cfg *AppConfig
cfgPath string
)
func init() {
cfgPath = resolveCfgPath()
hostname, _ := os.Hostname()
cfg = &AppConfig{
Node: NodeInfo{
Hostname: hostname,
UpdateHistory: []UpdateRecord{},
Backup: BackupConfig{
Targets: []BackupTarget{},
History: []BackupExecution{},
},
},
Nodes: []RemoteNode{},
Services: []Service{},
}
if data, err := os.ReadFile(cfgPath); err == nil {
_ = json.Unmarshal(data, cfg)
}
ensureSlices()
}
func ensureSlices() {
if cfg.Node.UpdateHistory == nil {
cfg.Node.UpdateHistory = []UpdateRecord{}
}
if cfg.Node.Backup.Targets == nil {
cfg.Node.Backup.Targets = []BackupTarget{}
}
if cfg.Node.Backup.History == nil {
cfg.Node.Backup.History = []BackupExecution{}
}
if cfg.Nodes == nil {
cfg.Nodes = []RemoteNode{}
}
if cfg.Services == nil {
cfg.Services = []Service{}
}
}
func resolveCfgPath() string {
const etcPath = "/etc/nodemaster/nodemaster.conf"
if err := os.MkdirAll(filepath.Dir(etcPath), 0755); err == nil {
if f, err := os.OpenFile(etcPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600); err == nil {
f.Close()
return etcPath
}
}
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
return filepath.Join(home, ".nodemaster.conf")
}
func persist() error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(cfgPath), 0755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
// 0600: the config now embeds the restic repository password, so it must
// not be group/world-readable. Chmod covers pre-existing files that were
// created before this field existed (WriteFile's mode only applies on
// create, not to files that already exist with looser permissions).
if err := os.WriteFile(cfgPath, data, 0600); err != nil {
return err
}
_ = os.Chmod(cfgPath, 0600)
return nil
}
// ConfigPath returns the resolved config file path.
func ConfigPath() string {
return cfgPath
}
+99
View File
@@ -0,0 +1,99 @@
package models
import "time"
type BackupMethod string
const (
MethodRsync BackupMethod = "rsync"
MethodRestic BackupMethod = "restic"
MethodExternal BackupMethod = "external"
)
type BackupTarget struct {
ID string `json:"id"`
Name string `json:"name"`
Method BackupMethod `json:"method"`
Remote string `json:"remote"` // destination: rsync target path (e.g. "user@host:/path") or restic repository string (e.g. "/mnt/backup/repo", "s3:s3.amazonaws.com/bucket/path", "sftp:user@host:/path")
Params map[string]string `json:"params,omitempty"`
Schedule string `json:"schedule,omitempty"` // cron expression (5-field, standard). Empty = manual-only.
LastRun *time.Time `json:"last_run,omitempty"`
NextRun *time.Time `json:"next_run,omitempty"`
}
type BackupExecution struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Success bool `json:"success"`
Duration int64 `json:"duration_seconds"`
Message string `json:"message"`
TargetID string `json:"target_id,omitempty"`
}
type BackupConfig struct {
SourcePath string `json:"source_path,omitempty"`
Targets []BackupTarget `json:"targets"`
NextRun *time.Time `json:"next_run,omitempty"` // aggregate: earliest NextRun across Targets
LastRun *time.Time `json:"last_run,omitempty"` // aggregate: latest LastRun across Targets
History []BackupExecution `json:"history"`
}
type UpdateRecord struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Success bool `json:"success"`
Packages []string `json:"packages,omitempty"`
Message string `json:"message"`
}
type NodeInfo struct {
Hostname string `json:"hostname"`
Description string `json:"description,omitempty"`
UpdateHistory []UpdateRecord `json:"update_history"`
Backup BackupConfig `json:"backup"`
ResticPassword string `json:"restic_password,omitempty"` // repository password used to encrypt/decrypt all restic backup targets on this node
}
type RemoteNode struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Status string `json:"status"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
LastSeen *time.Time `json:"last_seen,omitempty"`
}
type Service struct {
ID string `json:"id"`
Name string `json:"name"`
ComposeFile string `json:"compose_file"`
ComposeType string `json:"compose_type,omitempty"`
BackupFolder string `json:"backup_folder,omitempty"`
Status string `json:"status,omitempty"` // "up", "down", "unknown"
StopOnBackup bool `json:"stop_on_backup,omitempty"`
ExternalBackupStopMinutes int `json:"external_backup_stop_minutes,omitempty"`
ExternalBackupStartMinutes int `json:"external_backup_start_minutes,omitempty"`
Backup *BackupConfig `json:"backup,omitempty"`
}
type ScanResult struct {
ScannedPaths []string `json:"scanned_paths"`
Found int `json:"found"`
Created []ServiceScanInfo `json:"created"`
Updated []ServiceScanInfo `json:"updated"`
Errors []string `json:"errors,omitempty"`
}
type ServiceScanInfo struct {
Name string `json:"name"`
ComposeFile string `json:"compose_file"`
BackupFolder string `json:"backup_folder,omitempty"`
Status string `json:"status"`
}
type AppConfig struct {
Node NodeInfo `json:"node"`
Nodes []RemoteNode `json:"nodes"`
Services []Service `json:"services"`
}
+370
View File
@@ -0,0 +1,370 @@
package routers
import (
beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/server/web/context/param"
)
func init() {
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "Put",
Router: `/`,
AllowHTTPMethods: []string{"put"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "Get",
Router: `/`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "GetBackup",
Router: `/backup`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "PutBackup",
Router: `/backup`,
AllowHTTPMethods: []string{"put"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "GetBackupProgress",
Router: `/backup/progress`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "RunBackup",
Router: `/backup/run`,
AllowHTTPMethods: []string{"post"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "GetBackupTargets",
Router: `/backup/targets`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "AddBackupTarget",
Router: `/backup/targets`,
AllowHTTPMethods: []string{"post"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "DeleteBackupTarget",
Router: `/backup/targets/:tid`,
AllowHTTPMethods: []string{"delete"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "PutBackupTarget",
Router: `/backup/targets/:tid`,
AllowHTTPMethods: []string{"put"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "GetBackupTarget",
Router: `/backup/targets/:tid`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "GetBackupTargetProgress",
Router: `/backup/targets/:tid/progress`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "RunBackupTarget",
Router: `/backup/targets/:tid/run`,
AllowHTTPMethods: []string{"post"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "Scan",
Router: `/scan`,
AllowHTTPMethods: []string{"post"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "AddUpdate",
Router: `/updates`,
AllowHTTPMethods: []string{"post"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "GetUpdates",
Router: `/updates`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "DeleteUpdate",
Router: `/updates/:id`,
AllowHTTPMethods: []string{"delete"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodeController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodeController"],
beego.ControllerComments{
Method: "GetUpdate",
Router: `/updates/:id`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodesController"],
beego.ControllerComments{
Method: "GetAll",
Router: `/`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodesController"],
beego.ControllerComments{
Method: "Post",
Router: `/`,
AllowHTTPMethods: []string{"post"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodesController"],
beego.ControllerComments{
Method: "Get",
Router: `/:id`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodesController"],
beego.ControllerComments{
Method: "Put",
Router: `/:id`,
AllowHTTPMethods: []string{"put"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodesController"],
beego.ControllerComments{
Method: "Delete",
Router: `/:id`,
AllowHTTPMethods: []string{"delete"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:NodesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:NodesController"],
beego.ControllerComments{
Method: "GetAggregated",
Router: `/aggregated`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "Post",
Router: `/`,
AllowHTTPMethods: []string{"post"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "GetAll",
Router: `/`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "Get",
Router: `/:id`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "Put",
Router: `/:id`,
AllowHTTPMethods: []string{"put"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "Delete",
Router: `/:id`,
AllowHTTPMethods: []string{"delete"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "Backup",
Router: `/:id/backup`,
AllowHTTPMethods: []string{"post"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "GetBackupProgress",
Router: `/:id/backup/progress`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "GetBackupTargets",
Router: `/:id/backup/targets`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "AddBackupTarget",
Router: `/:id/backup/targets`,
AllowHTTPMethods: []string{"post"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "GetBackupTarget",
Router: `/:id/backup/targets/:tid`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "PutBackupTarget",
Router: `/:id/backup/targets/:tid`,
AllowHTTPMethods: []string{"put"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "DeleteBackupTarget",
Router: `/:id/backup/targets/:tid`,
AllowHTTPMethods: []string{"delete"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "GetBackupTargetProgress",
Router: `/:id/backup/targets/:tid/progress`,
AllowHTTPMethods: []string{"get"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "RunBackupTarget",
Router: `/:id/backup/targets/:tid/run`,
AllowHTTPMethods: []string{"post"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "Start",
Router: `/:id/start`,
AllowHTTPMethods: []string{"post"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"] = append(beego.GlobalControllerRouter["nodemaster/controllers:ServicesController"],
beego.ControllerComments{
Method: "Stop",
Router: `/:id/stop`,
AllowHTTPMethods: []string{"post"},
MethodParams: param.Make(),
Filters: nil,
Params: nil})
}
+51
View File
@@ -0,0 +1,51 @@
// @APIVersion 1.0.0
// @Title NodeMaster API
// @Description API for managing a node, its services, and a cluster of remote nodes.
// @Contact yves.cerezal@irt-saintexupery.com
// @License Apache 2.0
// @LicenseUrl http://www.apache.org/licenses/LICENSE-2.0.html
package routers
import (
"nodemaster/controllers"
beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/server/web/filter/cors"
)
func init() {
// The API has no authentication of its own, so CORS is intentionally
// scoped to explicit origins (never "*") and only enabled in dev mode
// for now — enabling it for other run modes, with an operator-supplied
// origin list, is tracked as a follow-up hardening task.
if beego.BConfig.RunMode == "dev" {
origins, err := beego.AppConfig.Strings("corsalloworigins")
if err != nil || len(origins) == 0 {
origins = []string{"http://localhost:*"}
}
beego.InsertFilter("*", beego.BeforeRouter, cors.Allow(&cors.Options{
AllowOrigins: origins,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowHeaders: []string{"Origin", "Content-Type"},
}))
}
ns := beego.NewNamespace("/v1",
beego.NSNamespace("/node",
beego.NSInclude(
&controllers.NodeController{},
),
),
beego.NSNamespace("/nodes",
beego.NSInclude(
&controllers.NodesController{},
),
),
beego.NSNamespace("/services",
beego.NSInclude(
&controllers.ServicesController{},
),
),
)
beego.AddNamespace(ns)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

+60
View File
@@ -0,0 +1,60 @@
<!-- HTML for static distribution bundle build -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
<style>
html
{
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after
{
box-sizing: inherit;
}
body
{
margin:0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "swagger.json",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
});
// End Swagger UI call region
window.ui = ui;
};
</script>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="en-US">
<head>
<title>Swagger UI: OAuth2 Redirect</title>
</head>
<body>
<script>
'use strict';
function run () {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;
if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1);
} else {
qp = location.search.substring(1);
}
arr = qp.split("&");
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value);
}
) : {};
isValid = qp.state === sentState;
if ((
oauth2.auth.schema.get("flow") === "accessCode" ||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
oauth2.auth.schema.get("flow") === "authorization_code"
) && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
});
}
if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
} else {
let oauthErrorMsg;
if (qp.error) {
oauthErrorMsg = "["+qp.error+"]: " +
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: "+qp.error_uri : "");
}
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
});
}
} else {
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
}
window.close();
}
if (document.readyState !== 'loading') {
run();
} else {
document.addEventListener('DOMContentLoaded', function () {
run();
});
}
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1462
View File
File diff suppressed because it is too large Load Diff
+1058
View File
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
package test
import (
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"testing"
_ "nodemaster/routers"
beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/core/logs"
. "github.com/smartystreets/goconvey/convey"
)
func init() {
_, file, _, _ := runtime.Caller(0)
apppath, _ := filepath.Abs(filepath.Dir(filepath.Join(file, ".."+string(filepath.Separator))))
beego.TestBeegoInit(apppath)
}
func TestGetNode(t *testing.T) {
r, _ := http.NewRequest("GET", "/v1/node", nil)
w := httptest.NewRecorder()
beego.BeeApp.Handlers.ServeHTTP(w, r)
logs.Info("testing", "TestGetNode", "Code[%d]\n%s", w.Code, w.Body.String())
Convey("GET /v1/node", t, func() {
Convey("Status Code Should Be 200", func() {
So(w.Code, ShouldEqual, 200)
})
Convey("The Result Should Not Be Empty", func() {
So(w.Body.Len(), ShouldBeGreaterThan, 0)
})
})
}
func TestGetNodes(t *testing.T) {
r, _ := http.NewRequest("GET", "/v1/nodes", nil)
w := httptest.NewRecorder()
beego.BeeApp.Handlers.ServeHTTP(w, r)
logs.Info("testing", "TestGetNodes", "Code[%d]\n%s", w.Code, w.Body.String())
Convey("GET /v1/nodes", t, func() {
Convey("Status Code Should Be 200", func() {
So(w.Code, ShouldEqual, 200)
})
})
}
func TestGetServices(t *testing.T) {
r, _ := http.NewRequest("GET", "/v1/services", nil)
w := httptest.NewRecorder()
beego.BeeApp.Handlers.ServeHTTP(w, r)
logs.Info("testing", "TestGetServices", "Code[%d]\n%s", w.Code, w.Body.String())
Convey("GET /v1/services", t, func() {
Convey("Status Code Should Be 200", func() {
So(w.Code, ShouldEqual, 200)
})
})
}
func TestGetNodeBackupTargets(t *testing.T) {
r, _ := http.NewRequest("GET", "/v1/node/backup/targets", nil)
w := httptest.NewRecorder()
beego.BeeApp.Handlers.ServeHTTP(w, r)
logs.Info("testing", "TestGetNodeBackupTargets", "Code[%d]\n%s", w.Code, w.Body.String())
Convey("GET /v1/node/backup/targets", t, func() {
Convey("Status Code Should Be 200", func() {
So(w.Code, ShouldEqual, 200)
})
})
}
func TestGetNodeBackupProgress(t *testing.T) {
r, _ := http.NewRequest("GET", "/v1/node/backup/progress", nil)
w := httptest.NewRecorder()
beego.BeeApp.Handlers.ServeHTTP(w, r)
logs.Info("testing", "TestGetNodeBackupProgress", "Code[%d]\n%s", w.Code, w.Body.String())
Convey("GET /v1/node/backup/progress", t, func() {
Convey("Status Code Should Be 200", func() {
So(w.Code, ShouldEqual, 200)
})
})
}