feat: self-update of master and agents (#15) (#32)

This commit was merged in pull request #32.
This commit is contained in:
2026-10-13 10:17:52 +00:00
parent ec28d1e027
commit 850a670446
39 changed files with 1421 additions and 130 deletions
+84 -16
View File
@@ -16,12 +16,25 @@ import (
// chatty job cannot grow a single job.progress message without bound.
const maxProgress = 32 << 10
// Job is a job as an Executor sees it: what the master asked for, plus the
// ID and start time the runner gave it. A self-update records both in the
// helper container, so the new agent can finish the job under the same ID.
type Job struct {
protocol.JobStart
ID string
Started time.Time
}
// Executor deploys one job and returns the "repo:tag@digest" it deployed.
// Everything it writes to out is streamed to the master as job output.
type Executor interface {
Execute(ctx context.Context, job protocol.JobStart, out io.Writer) (string, error)
Execute(ctx context.Context, job Job, out io.Writer) (string, error)
}
// ResumeFunc finishes a job that an earlier agent process began. It returns
// the "repo:tag@digest" that was deployed, like Executor.Execute.
type ResumeFunc func(ctx context.Context, out io.Writer) (string, error)
// Progress is output a running job produced since the previous TakeProgress.
type Progress struct{ ID, Output string }
@@ -52,6 +65,8 @@ func (b *tailBuffer) Reset() { b.buf, b.truncated = nil, false }
type jobState struct {
start protocol.JobStart
resume ResumeFunc // set for a job resumed after a restart; start is then empty
started time.Time // set with resume
running bool
result *protocol.JobResult
output tailBuffer // everything, capped at protocol.MaxJobOutput
@@ -117,6 +132,28 @@ func (r *Runner) Submit(id string, start protocol.JobStart) {
signal(r.wake)
}
// Resume registers a job that an earlier agent process began and runs fn for
// it, before every job that is already queued. The job is in Known at once,
// so the agent's next hello does not report it lost. The result carries
// started as its start time. A job whose ID the runner already holds is
// ignored.
func (r *Runner) Resume(id string, started time.Time, fn ResumeFunc) {
r.mu.Lock()
if _, ok := r.jobs[id]; ok {
r.mu.Unlock()
return
}
r.jobs[id] = &jobState{
resume: fn,
started: started,
output: tailBuffer{max: protocol.MaxJobOutput},
unsent: tailBuffer{max: maxProgress},
}
r.queue = append([]string{id}, r.queue...)
r.mu.Unlock()
signal(r.wake)
}
// Ack forgets a finished job. Acks for unknown, queued or running jobs are
// ignored.
func (r *Runner) Ack(id string) {
@@ -196,10 +233,12 @@ func (r *Runner) Pending() []Finished {
func (r *Runner) Changed() <-chan struct{} { return r.changed }
// Run works through the queue until ctx ends. Jobs still queued then are
// dropped; the master marks them lost on the agent's next hello.
// dropped; the master marks them lost on the agent's next hello. A job that
// ctx interrupts is dropped too, without a result: a self-updating agent is
// stopped that way, and its successor reports the outcome.
func (r *Runner) Run(ctx context.Context) {
for {
id, start, ok := r.next()
id, ok := r.next()
if !ok {
select {
case <-ctx.Done():
@@ -211,32 +250,62 @@ func (r *Runner) Run(ctx context.Context) {
if ctx.Err() != nil {
return
}
r.runOne(ctx, id, start)
r.runOne(ctx, id)
}
}
// next pops the next queued job and marks it running.
func (r *Runner) next() (string, protocol.JobStart, bool) {
// next pops the next job to run and marks it running. Jobs for the agent's
// own service run last: a master and an agent on the same host share an
// image, and the master's job must finish before the agent replaces itself.
func (r *Runner) next() (string, bool) {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.queue) == 0 {
return "", protocol.JobStart{}, false
return "", false
}
id := r.queue[0]
r.queue = r.queue[1:]
j := r.jobs[id]
j.running = true
pick := 0
for i, id := range r.queue {
if !r.jobs[id].start.Self {
pick = i
break
}
}
id := r.queue[pick]
r.queue = append(r.queue[:pick:pick], r.queue[pick+1:]...)
r.jobs[id].running = true
signal(r.changed)
return id, j.start, true
return id, true
}
func (r *Runner) runOne(ctx context.Context, id string, start protocol.JobStart) {
started := r.now()
func (r *Runner) runOne(ctx context.Context, id string) {
r.mu.Lock()
j := r.jobs[id]
start, resume, started := j.start, j.resume, j.started
r.mu.Unlock()
if resume == nil {
started = r.now()
}
jobCtx, cancel := context.WithTimeout(ctx, r.timeout)
ref, err := r.exec.Execute(jobCtx, start, &jobWriter{r: r, id: id})
var ref string
var err error
if resume != nil {
ref, err = resume(jobCtx, &jobWriter{r: r, id: id})
} else {
ref, err = r.exec.Execute(jobCtx, Job{JobStart: start, ID: id, Started: started}, &jobWriter{r: r, id: id})
}
timedOut := errors.Is(jobCtx.Err(), context.DeadlineExceeded)
cancel()
if err != nil && ctx.Err() != nil && !timedOut {
// The agent is stopping, not the job failing. Nothing is recorded, so
// no result can pre-empt the one a successor agent may report.
r.mu.Lock()
delete(r.jobs, id)
r.mu.Unlock()
return
}
res := protocol.JobResult{Status: protocol.ResultSucceeded, Ref: ref, StartedAt: started, FinishedAt: r.now()}
switch {
case timedOut:
@@ -246,7 +315,6 @@ func (r *Runner) runOne(ctx context.Context, id string, start protocol.JobStart)
}
r.mu.Lock()
j := r.jobs[id]
res.Output, res.OutputTruncated = j.output.String(), j.output.Truncated()
j.running, j.result, j.finished = false, &res, res.FinishedAt
j.unsent.Reset()