| index e7befef93..ee8bfde86 100644 |
| --- a/internal/console/apisystem.go |
| +++ b/internal/console/apisystem.go |
| @@ -194,13 +194,34 @@ func (s *Server) systemFeed() publicrelease.Feed { |
| // running is better served by the boxes that did answer plus an honest |
| // "partial" flag than by a blank page. The second return is that flag: true |
| // means some fleet's boxes are missing from the slice. |
| +// |
| +// NEITHER answer applies to a console with NO REGISTRY. cfg.Registry == "" |
| +// and a registry file that has not been created yet are legal, ordinary |
| +// shapes — a console before its first box, or one that serves no route |
| +// registry at all — and they mean this deployment has no fleets, full stop. |
| +// Treating them as "unreadable" would refuse every promote such a console |
| +// could ever make and pin its status at partial forever, which is what the |
| +// first cut of this function did. Only a registry that EXISTS and still |
| +// cannot be opened, or one whose fleets cannot be listed, is a fleet set this |
| +// console cannot see. |
| func (s *Server) systemBoxes(ctx context.Context, strict bool) ([]status.Box, bool, error) { |
| boxes, err := status.LoadBoxes(ctx, s.cfg.Releases) |
| if err != nil { |
| return nil, false, err |
| } |
| + if s.cfg.Registry == "" { |
| + return boxes, false, nil // this console serves no route registry: no fleets |
| + } |
| + // openPassRegistry reports the not-yet-created case by wrapping os.Stat's |
| + // error, so errors.Is reaches it; the unconfigured case is the guard |
| + // above, since that one is a plain message. Distinguished here rather |
| + // than in openPassRegistry so its other callers keep the answers they |
| + // already expect. |
| reg, err := s.openPassRegistry() |
| - if err != nil { |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + return boxes, false, nil // no registry file yet: no fleets |
| + case err != nil: |
| return s.systemFleetsUnreadable(boxes, strict, err) |
| } |
| fleets, err := reg.ListFleets() |
| @@ -289,7 +310,7 @@ func (s *Server) handleAPISystemUpdate(w http.ResponseWriter, r *http.Request) { |
| }) |
| return |
| } |
| - go s.runSystemUpdate(context.WithoutCancel(r.Context()), job, addr, in, spread) |
| + go s.runSystemUpdate(context.WithoutCancel(r.Context()), id, job, addr, in, spread) |
| writeJSON(w, http.StatusOK, map[string]string{"job": id}) |
| } |
| |
| @@ -334,19 +355,27 @@ func (s *Server) startSystemJob(component, channel string) (id string, job *syst |
| return id, job, "" |
| } |
| |
| -// releaseSystemChannel hands the slot back. It is called BEFORE the job is |
| -// marked done, so an operator who polls a job to completion and immediately |
| -// promotes again is never told the channel is busy by the job they just |
| -// watched finish. |
| -func (s *Server) releaseSystemChannel(component, channel string) { |
| +// releaseSystemChannel hands the slot back, and only if this job is still the |
| +// one holding it: a compare-and-delete on the id, so a late or repeated |
| +// release can never free a LATER promote's slot and let a third run beside |
| +// it. That makes it idempotent, which is what lets runSystemUpdate both defer |
| +// it (the panic path) and call it explicitly at the point the ordering |
| +// matters. |
| +func (s *Server) releaseSystemChannel(component, channel, id string) { |
| + key := component + "/" + channel |
| s.systemMu.Lock() |
| - delete(s.systemRunning, component+"/"+channel) |
| + if s.systemRunning[key] == id { |
| + delete(s.systemRunning, key) |
| + } |
| s.systemMu.Unlock() |
| } |
| |
| // runSystemUpdate is the promote: resolve, ingest (or take the upload), |
| // mirror, sign, bump. Every step is idempotent, so a lost job is re-run. |
| -func (s *Server) runSystemUpdate(ctx context.Context, job *systemJob, actor string, in systemUpdateRequest, spread time.Duration) { |
| +func (s *Server) runSystemUpdate(ctx context.Context, id string, job *systemJob, actor string, in systemUpdateRequest, spread time.Duration) { |
| + // Every exit hands the channel back, a panic included — without this a |
| + // crashed promote would wedge its channel for the life of the process. |
| + defer s.releaseSystemChannel(in.Component, in.Channel, id) |
| err := func() error { |
| var rel release.SystemRelease |
| if in.Upload != "" { |
| @@ -409,9 +438,11 @@ func (s *Server) runSystemUpdate(ctx context.Context, job *systemJob, actor stri |
| job.step("serial bumped: boxes on %s start their spread", in.Channel) |
| return nil |
| }() |
| - // Hand the channel back BEFORE marking the job done — see |
| - // releaseSystemChannel. |
| - s.releaseSystemChannel(in.Component, in.Channel) |
| + // And hand it back BEFORE marking the job done, so an operator who polls |
| + // this job to completion and immediately promotes again is never told the |
| + // channel is busy by the job they just watched finish. The deferred |
| + // release above then finds nothing to do; it is there for the panic path. |
| + s.releaseSystemChannel(in.Component, in.Channel, id) |
| job.finish(err) |
| } |
| |