a7c6a6e09c
Lands the operator → server → agent → restic → server roundtrip for
on-demand backups. The flow:
POST /api/hosts/{id}/jobs {kind:"backup",args:["/path"]}
→ server creates a queued Job row
→ server emits command.run over WS to the host's agent
→ agent dispatcher spawns runner.RunBackup in a goroutine
→ runner spawns `restic backup --json`, parses each line
→ forwards: job.started, log.stream (every line), job.progress
(throttled to 1/sec), job.finished (with summary stats blob)
→ server WS handler persists those into jobs / job_logs
P1-16 internal/restic: thin Locate + Env wrapper that runs `restic
backup --json`, scans stdout/stderr, parses BackupStatus +
BackupSummary, calls back into a LineHandler so the agent can fan
out to log.stream + job.progress. Treats exit code 3 as
"succeeded with issues" (matches restic's contract).
P1-18 store: jobs accessors (CreateJob, MarkJobStarted,
MarkJobFinished, AppendJobLog, GetJob).
P1-19 server: POST /api/hosts/{id}/jobs creates the Job row,
validates kind, dispatches via Hub.Send, audit-logs the action.
P1-20 agent runner: wraps restic.RunBackup with throttled progress
emission. Sender abstraction was added to wsclient.Handler so
background goroutines can keep replying after dispatch returns.
P1-21 server WS: dispatchAgentMessage now persists job.started,
job.finished, log.stream into the database. Browser fan-out for
live tailing lands with the UI work.
Agent gets repo_url + repo_password from agent.yaml in plaintext
for now (mode 0600, owned by service user); spec.md §7.3's keyring
storage moves there in P2. config.update over WS overrides the
in-memory copy (does not persist).
Build clean; all tests pass. End-to-end with a real restic still
needs a host that has restic installed — wire shape verified by
the existing hello/heartbeat round-trip test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
120 lines
3.7 KiB
Go
120 lines
3.7 KiB
Go
// Package config loads the agent's persistent configuration. After
|
|
// enrollment, the file holds the bearer token + server URL; it is
|
|
// only ever written via Save (which replaces atomically).
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Config is the on-disk shape of the agent's config file.
|
|
type Config struct {
|
|
// ServerURL is the base URL of the control plane, e.g.
|
|
// https://restic.lab.example. The agent appends /ws/agent and
|
|
// /api/agents/enroll.
|
|
ServerURL string `yaml:"server_url"`
|
|
|
|
// AgentToken is the bearer credential issued at enrollment.
|
|
// Empty means "not yet enrolled."
|
|
AgentToken string `yaml:"agent_token"`
|
|
|
|
// HostID is what the server thinks this host is.
|
|
HostID string `yaml:"host_id"`
|
|
|
|
// CertPinSHA256 (optional) is the SHA-256 of the server's TLS
|
|
// cert. When set, the agent refuses to connect to a server
|
|
// whose cert hash doesn't match.
|
|
CertPinSHA256 string `yaml:"cert_pin_sha256,omitempty"`
|
|
|
|
// ResticPath overrides the auto-detected restic binary path.
|
|
ResticPath string `yaml:"restic_path,omitempty"`
|
|
|
|
// RepoURL + RepoPassword are the credentials this host uses to
|
|
// reach its restic repository. Phase 1 keeps these in plaintext
|
|
// in agent.yaml (mode 0600 owned by the agent service user); the
|
|
// server-pushed config.update message can override them in
|
|
// memory. Phase 2 moves them into the OS keyring (DPAPI on
|
|
// Windows, Secret Service on Linux).
|
|
RepoURL string `yaml:"repo_url,omitempty"`
|
|
RepoPassword string `yaml:"repo_password,omitempty"`
|
|
|
|
// path is the file we loaded from. Used by Save.
|
|
path string `yaml:"-"`
|
|
}
|
|
|
|
// DefaultPath returns the canonical config path for the current OS.
|
|
// Phase 1 ships Linux only; Windows path lives in the spec for P2.
|
|
func DefaultPath() string {
|
|
return "/etc/restic-manager/agent.yaml"
|
|
}
|
|
|
|
// Load reads and parses the config file at path. A missing file is
|
|
// returned as an empty Config (not an error) — first-run agents
|
|
// haven't been enrolled yet.
|
|
func Load(path string) (*Config, error) {
|
|
c := &Config{path: path}
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return c, nil
|
|
}
|
|
return nil, fmt.Errorf("agent config: read %q: %w", path, err)
|
|
}
|
|
if err := yaml.Unmarshal(body, c); err != nil {
|
|
return nil, fmt.Errorf("agent config: parse %q: %w", path, err)
|
|
}
|
|
c.path = path
|
|
return c, nil
|
|
}
|
|
|
|
// Save writes the config back atomically: write to <path>.tmp, fsync,
|
|
// rename. A crash mid-write either leaves the old file or the new one,
|
|
// never a half-written one.
|
|
func (c *Config) Save() error {
|
|
if c.path == "" {
|
|
return fmt.Errorf("agent config: no path set")
|
|
}
|
|
dir := filepath.Dir(c.path)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return fmt.Errorf("agent config: mkdir %q: %w", dir, err)
|
|
}
|
|
body, err := yaml.Marshal(c)
|
|
if err != nil {
|
|
return fmt.Errorf("agent config: marshal: %w", err)
|
|
}
|
|
|
|
tmp := c.path + ".tmp"
|
|
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
|
if err != nil {
|
|
return fmt.Errorf("agent config: create tmp: %w", err)
|
|
}
|
|
if _, err := f.Write(body); err != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("agent config: write tmp: %w", err)
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("agent config: fsync tmp: %w", err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("agent config: close tmp: %w", err)
|
|
}
|
|
if err := os.Rename(tmp, c.path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("agent config: rename: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Enrolled reports whether the agent has finished enrollment.
|
|
func (c *Config) Enrolled() bool {
|
|
return c.AgentToken != "" && c.HostID != "" && c.ServerURL != ""
|
|
}
|