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>
This commit is contained in:
@@ -149,18 +149,44 @@ func runAgentLoop(ctx context.Context, c *Conn, hostID string, deps HandlerDeps)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchAgentMessage routes a single envelope to its handler. Only
|
||||
// hello + heartbeat are wired up in Phase 1's first slice; the rest
|
||||
// land with P1-18+ (jobs) and P2 (schedules).
|
||||
// dispatchAgentMessage routes a single envelope to its handler.
|
||||
func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.Envelope, deps HandlerDeps) {
|
||||
switch env.Type {
|
||||
case api.MsgHeartbeat:
|
||||
_ = deps.Store.TouchHost(ctx, hostID, time.Now().UTC())
|
||||
|
||||
case api.MsgJobStarted, api.MsgJobProgress, api.MsgJobFinished,
|
||||
api.MsgLogStream, api.MsgSnapshotsRpt, api.MsgRepoStats,
|
||||
api.MsgScheduleAck, api.MsgCommandResult:
|
||||
// TODO(P1-18+): persist + fan out to subscribed browsers.
|
||||
case api.MsgJobStarted:
|
||||
var p api.JobStartedPayload
|
||||
_ = env.UnmarshalPayload(&p)
|
||||
if err := deps.Store.MarkJobStarted(ctx, p.JobID, p.StartedAt); err != nil {
|
||||
slog.Warn("ws: mark job started", "job_id", p.JobID, "err", err)
|
||||
}
|
||||
|
||||
case api.MsgJobProgress:
|
||||
// We don't persist every progress tick; the live UI subscribes
|
||||
// to a fan-out channel that lands with P1-21 / the UI work.
|
||||
// TODO: implement the ws fan-out hub for browsers.
|
||||
_ = env
|
||||
|
||||
case api.MsgJobFinished:
|
||||
var p api.JobFinishedPayload
|
||||
_ = env.UnmarshalPayload(&p)
|
||||
errMsg := p.Error
|
||||
if err := deps.Store.MarkJobFinished(ctx, p.JobID,
|
||||
string(p.Status), p.ExitCode, p.Stats, errMsg, p.FinishedAt); err != nil {
|
||||
slog.Warn("ws: mark job finished", "job_id", p.JobID, "err", err)
|
||||
}
|
||||
|
||||
case api.MsgLogStream:
|
||||
var p api.LogStreamLine
|
||||
_ = env.UnmarshalPayload(&p)
|
||||
if err := deps.Store.AppendJobLog(ctx, p.JobID, p.Seq, p.TS,
|
||||
string(p.Stream), p.Payload); err != nil {
|
||||
slog.Warn("ws: append job log", "job_id", p.JobID, "err", err)
|
||||
}
|
||||
|
||||
case api.MsgSnapshotsRpt, api.MsgRepoStats, api.MsgScheduleAck, api.MsgCommandResult:
|
||||
// TODO(P1-22 + P2): persist these projections.
|
||||
slog.Debug("ws msg not yet handled", "type", env.Type, "host_id", hostID)
|
||||
|
||||
case api.MsgError:
|
||||
|
||||
Reference in New Issue
Block a user