Files
restic-manager/internal/agent/config/config.go
T
steve a7c6a6e09c phase 1: run-now backup — restic wrapper, job lifecycle, end-to-end
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>
2026-05-01 00:45:04 +01:00

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 != ""
}