feat: master/agent phase 4 — registry polling and version history (#9) (#23)
Docker Build / docker (push) Successful in 47s
Docker Build / docker (push) Successful in 47s
This commit was merged in pull request #23.
This commit is contained in:
@@ -26,12 +26,18 @@ var assets embed.FS
|
||||
// serviceJobsLimit is how many jobs the service page lists.
|
||||
const serviceJobsLimit = 20
|
||||
|
||||
// versionsLimit is how many image versions the service page lists.
|
||||
const versionsLimit = 10
|
||||
|
||||
var agentNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// Store is the storage the UI reads and writes. *store.Store satisfies it.
|
||||
type Store interface {
|
||||
ListServices(ctx context.Context) ([]model.Service, error)
|
||||
GetService(ctx context.Context, id int64) (model.Service, error)
|
||||
// RecentVersions returns up to limit versions of repo the master has
|
||||
// seen, most recently first seen first.
|
||||
RecentVersions(ctx context.Context, repo string, limit int) ([]model.ImageVersion, error)
|
||||
// SetUpdateMode returns model.ErrNotFound for an unknown or removed service.
|
||||
SetUpdateMode(ctx context.Context, id int64, mode string) error
|
||||
GetJob(ctx context.Context, id int64) (model.Job, error)
|
||||
@@ -54,6 +60,9 @@ type Jobs interface {
|
||||
Updatable(svc model.Service) error
|
||||
AutoAllowed(svc model.Service) error
|
||||
Trigger(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error)
|
||||
// TriggerVersion updates to exactly the image version versionID of the
|
||||
// service's repo; model.ErrNotFound for an unknown service or version.
|
||||
TriggerVersion(ctx context.Context, serviceID, versionID int64, requestedBy string) (model.Job, error)
|
||||
}
|
||||
|
||||
// Broker delivers change signals to open streams and accepts signals from
|
||||
@@ -63,11 +72,12 @@ type Broker interface {
|
||||
Publish(t events.Topic)
|
||||
}
|
||||
|
||||
// Config holds values shown in the "add agent" snippet.
|
||||
// Config holds values shown in the "add agent" snippet and on the service page.
|
||||
type Config struct {
|
||||
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
|
||||
MasterKey string // the master's fingerprint
|
||||
AgentImage string
|
||||
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
|
||||
MasterKey string // the master's fingerprint
|
||||
AgentImage string
|
||||
PollInterval time.Duration // shown as "every <interval>" on the service page
|
||||
}
|
||||
|
||||
// Server renders the UI.
|
||||
@@ -85,7 +95,7 @@ type Server struct {
|
||||
// New parses the templates. Each page is its own template set so every page
|
||||
// can define its own "content" block.
|
||||
func New(store Store, hub Hub, jobs Jobs, broker Broker, cfg Config, log *slog.Logger) (*Server, error) {
|
||||
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration}
|
||||
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, page := range []string{"overview.html", "agents.html", "agent_created.html", "service.html", "jobs.html", "job.html"} {
|
||||
t, err := template.New(page).Funcs(funcs).ParseFS(assets, "templates/layout.html", "templates/"+page)
|
||||
@@ -132,6 +142,9 @@ type pageData struct {
|
||||
ConfigFiles []string
|
||||
Reason string // why Service cannot be updated; "" when it can
|
||||
AutoBlocked string // why Service cannot be set to auto; "" when it can
|
||||
Versions []versionRow
|
||||
Polling string // what the service page says about registry polling
|
||||
PollFailed bool // Polling is the last registry error
|
||||
Jobs []model.Job
|
||||
Job model.Job
|
||||
}
|
||||
@@ -222,6 +235,12 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) {
|
||||
return data, err
|
||||
}
|
||||
data.AutoBlocked = s.autoBlocked(svc)
|
||||
versions, err := s.store.RecentVersions(r.Context(), svc.ImageRepo, versionsLimit)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Versions = versionRows(versions, svc.ActualDigest)
|
||||
data.Polling, data.PollFailed = pollingStatus(svc, s.cfg.PollInterval)
|
||||
data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: serviceJobsLimit})
|
||||
return data, err
|
||||
}
|
||||
@@ -260,7 +279,18 @@ func (s *Server) updateService(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
sess, _ := webauth.SessionFrom(r.Context())
|
||||
job, err := s.jobs.Trigger(r.Context(), id, sess.Subject)
|
||||
var job model.Job
|
||||
var err error
|
||||
if v := r.PostFormValue("version"); v == "" {
|
||||
job, err = s.jobs.Trigger(r.Context(), id, sess.Subject)
|
||||
} else {
|
||||
versionID, perr := strconv.ParseInt(v, 10, 64)
|
||||
if perr != nil {
|
||||
http.Error(w, "invalid version", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
job, err = s.jobs.TriggerVersion(r.Context(), id, versionID, sess.Subject)
|
||||
}
|
||||
var nu *model.NotUpdatableError
|
||||
switch {
|
||||
case err == nil || errors.Is(err, model.ErrJobActive):
|
||||
|
||||
@@ -34,6 +34,17 @@ type fakeStore struct {
|
||||
created map[string]string // name → token hash
|
||||
revokedID int64
|
||||
revokeFailed []int64 // job IDs RevokeAgent reports as failed
|
||||
versions []model.ImageVersion
|
||||
}
|
||||
|
||||
func (f *fakeStore) RecentVersions(_ context.Context, repo string, limit int) ([]model.ImageVersion, error) {
|
||||
var out []model.ImageVersion
|
||||
for _, v := range f.versions {
|
||||
if v.Repo == repo && len(out) < limit {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListServices(context.Context) ([]model.Service, error) { return f.services, nil }
|
||||
@@ -128,6 +139,9 @@ type fakeJobs struct {
|
||||
|
||||
mu sync.Mutex
|
||||
requestedBy string
|
||||
triggered []int64 // version IDs passed to TriggerVersion
|
||||
triggerSvc []int64 // service IDs passed to TriggerVersion
|
||||
plainCalls int // Trigger calls
|
||||
}
|
||||
|
||||
func (f *fakeJobs) Updatable(model.Service) error { return f.updatable }
|
||||
@@ -138,6 +152,16 @@ func (f *fakeJobs) Trigger(_ context.Context, _ int64, by string) (model.Job, er
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.requestedBy = by
|
||||
f.plainCalls++
|
||||
return f.job, f.err
|
||||
}
|
||||
|
||||
func (f *fakeJobs) TriggerVersion(_ context.Context, serviceID, versionID int64, by string) (model.Job, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.requestedBy = by
|
||||
f.triggerSvc = append(f.triggerSvc, serviceID)
|
||||
f.triggered = append(f.triggered, versionID)
|
||||
return f.job, f.err
|
||||
}
|
||||
|
||||
@@ -177,7 +201,15 @@ func newTestServer(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *event
|
||||
return srv, store, hub, broker
|
||||
}
|
||||
|
||||
// newTestEnv starts the UI on fake storage, with polling off.
|
||||
func newTestEnv(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
|
||||
t.Helper()
|
||||
return newTestEnvPolling(t, 0)
|
||||
}
|
||||
|
||||
// newTestEnvPolling is newTestEnv with pollInterval as the polling interval
|
||||
// the service page shows.
|
||||
func newTestEnvPolling(t *testing.T, pollInterval time.Duration) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
|
||||
t.Helper()
|
||||
agentID := int64(2)
|
||||
store := &fakeStore{
|
||||
@@ -201,6 +233,7 @@ func newTestEnv(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.B
|
||||
jobs := &fakeJobs{}
|
||||
s, err := New(store, hub, jobs, broker, Config{
|
||||
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
|
||||
PollInterval: pollInterval,
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
require.NoError(t, err)
|
||||
mux := http.NewServeMux()
|
||||
|
||||
@@ -29,5 +29,6 @@ table.details { width: auto; }
|
||||
table.details th { padding-right: 24px; }
|
||||
pre.log { white-space: pre-wrap; }
|
||||
.badge-update { color: var(--warn); border-color: var(--warn); }
|
||||
.badge-error { color: var(--bad); border-color: var(--bad); }
|
||||
form.mode { display: inline; margin-left: 8px; }
|
||||
form.mode button { font-size: 12px; padding: 0 6px; }
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<tbody>
|
||||
{{range .Services}}
|
||||
<tr>
|
||||
<td><a href="/services/{{.ID}}">{{.DisplayName}}</a>{{if .Self}} <span class="badge">managed outside</span>{{end}}{{if .AvailableRef}} <span class="badge badge-update" title="{{.AvailableRef}}">update available</span>{{end}}</td>
|
||||
<td><a href="/services/{{.ID}}">{{.DisplayName}}</a>{{if .Self}} <span class="badge">managed outside</span>{{end}}{{if .AvailableRef}} <span class="badge badge-update" title="{{.AvailableRef}}">update available</span>{{end}}{{if followsNoTag .Service}} <span class="badge">follows no tag</span>{{end}}{{if .LastPollError}} <span class="badge badge-error" title="{{.LastPollError}}">poll failed</span>{{end}}</td>
|
||||
<td><code>{{shortRef .ActualRef}}</code></td>
|
||||
<td class="status status-{{.Status}}">{{.Status}}</td>
|
||||
<td>{{.UpdateMode}}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{{if .Service.DesiredRef}}<tr><th>Desired image</th><td><code>{{shortRef .Service.DesiredRef}}</code></td></tr>{{end}}
|
||||
{{if .Service.AvailableRef}}<tr><th>Available</th><td><code>{{.Service.AvailableRef}}</code> <span class="muted">via {{.Service.AvailableSource}}{{with .Service.AvailableAt}}, {{.Format "2006-01-02 15:04"}}{{end}}</span></td></tr>{{end}}
|
||||
<tr><th>Mode</th><td>{{.Service.UpdateMode}}</td></tr>
|
||||
<tr><th>Polling</th><td{{if .PollFailed}} class="error"{{end}}>{{.Polling}}</td></tr>
|
||||
{{if .ConfigFiles}}<tr><th>Config files</th><td>{{range .ConfigFiles}}<code>{{.}}</code><br>{{end}}</td></tr>{{end}}
|
||||
</table>
|
||||
{{if .Reason}}
|
||||
@@ -31,6 +32,29 @@
|
||||
<p class="muted">{{if .AutoBlocked}}Auto unavailable: {{.AutoBlocked}}.{{else}}The webhook will update this service as soon as CI pushes a new image.{{end}}</p>
|
||||
{{end}}
|
||||
</form>
|
||||
<h2>Versions</h2>
|
||||
{{if eq .Service.UpdateMode "auto"}}<p class="warning">This service is in auto mode: a version picked here is replaced again when a newer version is announced or found by polling.</p>{{end}}
|
||||
<table>
|
||||
<thead><tr><th>Version</th><th>Seen via</th><th>First seen</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Versions}}
|
||||
<tr>
|
||||
<td><code title="{{.Ref}}">{{shortRef .Ref}}</code>{{if .Running}} <span class="badge">running</span>{{end}}{{if not .Tag}} <span class="muted">the service will follow no tag afterwards</span>{{end}}</td>
|
||||
<td>{{.Source}}</td>
|
||||
<td>{{.FirstSeenAt.Format "2006-01-02 15:04"}}</td>
|
||||
{{/* AutoBlocked is the same check that decides whether the service can be updated with an explicit ref, which is what a pick is. */}}
|
||||
<td>{{if not $.AutoBlocked}}
|
||||
<form method="post" action="/services/{{$.Service.ID}}/update">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="version" value="{{.ID}}">
|
||||
<button type="submit">Deploy</button>
|
||||
</form>{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="4">No versions seen yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>Update history</h2>
|
||||
<div id="history" hx-get="/fragments/services/{{.Service.ID}}/jobs" hx-trigger="sse:jobs" hx-swap="innerHTML">
|
||||
{{template "history" .}}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
)
|
||||
|
||||
const sluskRepo = "gitea.shcizo.se/shcizo/slusk"
|
||||
|
||||
func TestServicePage_VersionPicker(t *testing.T) {
|
||||
srv, store, _, _, _ := newTestEnv(t)
|
||||
store.services[0].ImageRepo = sluskRepo
|
||||
store.services[0].ActualDigest = "sha256:cur"
|
||||
first := time.Date(2026, 10, 12, 9, 5, 0, 0, time.UTC)
|
||||
store.versions = []model.ImageVersion{
|
||||
{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Digest: "sha256:" + strings.Repeat("a", 40), Source: "poll", FirstSeenAt: first},
|
||||
{ID: 32, Repo: sluskRepo, Tag: "1.4.2", Digest: "sha256:cur", Source: "running", FirstSeenAt: first},
|
||||
{ID: 33, Repo: sluskRepo, Digest: "sha256:" + strings.Repeat("b", 40), Source: "running", FirstSeenAt: first},
|
||||
{ID: 34, Repo: sluskRepo, Tag: "main", Source: "webhook", FirstSeenAt: first},
|
||||
{ID: 40, Repo: "other/repo", Tag: "x", Source: "poll", FirstSeenAt: first},
|
||||
}
|
||||
|
||||
_, body := get(t, srv, "/services/1")
|
||||
|
||||
for _, id := range []string{"31", "32", "33", "34"} {
|
||||
assert.Contains(t, body, `<input type="hidden" name="version" value="`+id+`">`, id)
|
||||
}
|
||||
assert.NotContains(t, body, `name="version" value="40"`, "versions of other repos are not listed")
|
||||
assert.Equal(t, 4, strings.Count(body, `name="version"`))
|
||||
assert.Contains(t, body, `name="csrf_token" value="csrf"`)
|
||||
assert.Contains(t, body, `title="`+sluskRepo+`:1.5.0@sha256:`+strings.Repeat("a", 40)+`"`, "full ref as title")
|
||||
assert.Contains(t, body, sluskRepo+":1.5.0@sha256:"+strings.Repeat("a", 12), "short ref as text")
|
||||
assert.Equal(t, 1, strings.Count(body, `<span class="badge">running</span>`), "only the running digest is marked")
|
||||
assert.Equal(t, 1, strings.Count(body, "the service will follow no tag afterwards"), "only the tagless row")
|
||||
assert.Contains(t, body, "2026-10-12 09:05")
|
||||
assert.NotContains(t, body, "auto mode", "no auto warning for a manual service")
|
||||
}
|
||||
|
||||
func TestServicePage_VersionPickerShowsAtMostTenRows(t *testing.T) {
|
||||
srv, store, _, _, _ := newTestEnv(t)
|
||||
store.services[0].ImageRepo = sluskRepo
|
||||
for i := int64(1); i <= 12; i++ {
|
||||
store.versions = append(store.versions, model.ImageVersion{ID: i, Repo: sluskRepo, Tag: "t", Digest: "sha256:d", Source: "poll"})
|
||||
}
|
||||
|
||||
_, body := get(t, srv, "/services/1")
|
||||
|
||||
assert.Equal(t, 10, strings.Count(body, `name="version"`))
|
||||
}
|
||||
|
||||
func TestServicePage_VersionPickerWarnsForAutoServices(t *testing.T) {
|
||||
srv, store, _, _, _ := newTestEnv(t)
|
||||
store.services[0].UpdateMode = "auto"
|
||||
|
||||
_, body := get(t, srv, "/services/1")
|
||||
|
||||
assert.Contains(t, body, "This service is in auto mode")
|
||||
}
|
||||
|
||||
func TestServicePage_VersionPickerHiddenWhenServiceCannotBeUpdated(t *testing.T) {
|
||||
for _, reason := range []string{model.ReasonSelf, model.ReasonContainer, model.ReasonRemoved, model.ReasonBuilt} {
|
||||
t.Run(reason, func(t *testing.T) {
|
||||
srv, store, _, _, jobs := newTestEnv(t)
|
||||
store.services[0].ImageRepo = sluskRepo
|
||||
store.versions = []model.ImageVersion{{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Source: "poll"}}
|
||||
jobs.autoErr = &model.NotUpdatableError{Reason: reason}
|
||||
|
||||
_, body := get(t, srv, "/services/1")
|
||||
|
||||
assert.Contains(t, body, "<h2>Versions</h2>")
|
||||
assert.Contains(t, body, "1.5.0", "the history is still shown")
|
||||
assert.NotContains(t, body, `name="version"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePage_VersionPickerOffersVersionsToAServiceThatFollowsNoTag(t *testing.T) {
|
||||
srv, store, _, _, jobs := newTestEnv(t)
|
||||
store.services[0].ImageRepo = sluskRepo
|
||||
store.versions = []model.ImageVersion{{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Digest: "sha256:a", Source: "poll"}}
|
||||
jobs.updatable = &model.NotUpdatableError{Reason: model.ReasonNoTag} // autoErr stays nil: an explicit ref is fine
|
||||
|
||||
_, body := get(t, srv, "/services/1")
|
||||
|
||||
assert.Contains(t, body, `name="version" value="31"`)
|
||||
assert.NotContains(t, body, `<button type="submit">Update</button>`, "the plain Update button stays hidden")
|
||||
}
|
||||
|
||||
func TestServicePage_PollingRow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
edit func(*model.Service)
|
||||
want string
|
||||
failed bool
|
||||
}{
|
||||
{"polled", func(s *model.Service) { s.ActualDigest = "sha256:cur" }, "every 6h", false},
|
||||
{"poll error", func(s *model.Service) { s.ActualDigest = "sha256:cur"; s.LastPollError = "head x: 401 <b>" }, "last check failed: head x: 401 <b>", true},
|
||||
{"built", func(s *model.Service) { s.ActualDigest = "sha256:cur"; s.Built = true }, "not polled: built from source", false},
|
||||
{"no digest", func(s *model.Service) {}, "not polled: no registry digest", false},
|
||||
{"no tag", func(s *model.Service) { s.ActualRef = "r@sha256:a"; s.ActualDigest = "sha256:a" }, "not polled: follows no tag", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv, store, _, _, _ := newTestEnvPolling(t, 6*time.Hour)
|
||||
tt.edit(&store.services[0])
|
||||
|
||||
_, body := get(t, srv, "/services/1")
|
||||
|
||||
assert.Contains(t, body, "<th>Polling</th><td")
|
||||
assert.Contains(t, body, tt.want)
|
||||
assert.Equal(t, tt.failed, strings.Contains(body, `<td class="error">`+tt.want))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{6 * time.Hour, "6h"},
|
||||
{90 * time.Minute, "1h30m"},
|
||||
{30 * time.Minute, "30m"},
|
||||
{90 * time.Second, "1m30s"},
|
||||
{45 * time.Second, "45s"},
|
||||
{6*time.Hour + 5*time.Second, "6h0m5s"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, shortDuration(tt.d))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func postVersion(t *testing.T, srv *httptest.Server, version string) (*http.Response, string) {
|
||||
t.Helper()
|
||||
resp, err := noRedirectClient().PostForm(srv.URL+"/services/1/update", url.Values{"csrf_token": {"csrf"}, "version": {version}})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return resp, string(body)
|
||||
}
|
||||
|
||||
func TestUpdateService_VersionStartsThatVersion(t *testing.T) {
|
||||
srv, _, _, _, jobs := newTestEnv(t)
|
||||
jobs.job = model.Job{ID: 42}
|
||||
|
||||
resp, _ := postVersion(t, srv, "31")
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
assert.Equal(t, "/jobs/42", resp.Header.Get("Location"))
|
||||
assert.Equal(t, []int64{31}, jobs.triggered)
|
||||
assert.Equal(t, []int64{1}, jobs.triggerSvc)
|
||||
assert.Equal(t, "alice", jobs.triggeredBy())
|
||||
assert.Zero(t, jobs.plainCalls)
|
||||
}
|
||||
|
||||
func TestUpdateService_WithoutVersionTriggersTheFollowedTag(t *testing.T) {
|
||||
srv, _, _, _, jobs := newTestEnv(t)
|
||||
jobs.job = model.Job{ID: 42}
|
||||
|
||||
resp, _ := postVersion(t, srv, "")
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
assert.Equal(t, 1, jobs.plainCalls)
|
||||
assert.Empty(t, jobs.triggered)
|
||||
}
|
||||
|
||||
func TestUpdateService_VersionErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
err error
|
||||
wantCode int
|
||||
wantBody string
|
||||
wantJob string // Location header
|
||||
}{
|
||||
{"not a number", "abc", nil, http.StatusBadRequest, "invalid version", ""},
|
||||
{"a ref instead of an id", "repo:evil@sha256:x", nil, http.StatusBadRequest, "invalid version", ""},
|
||||
{"unknown version or other repo", "77", model.ErrNotFound, http.StatusNotFound, "not found", ""},
|
||||
{"not updatable", "31", &model.NotUpdatableError{Reason: model.ReasonBuilt}, http.StatusConflict, "Cannot update: " + model.ReasonBuilt + ".", ""},
|
||||
{"job already active", "31", model.ErrJobActive, http.StatusSeeOther, "", "/jobs/41"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv, _, _, _, jobs := newTestEnv(t)
|
||||
jobs.err, jobs.job = tt.err, model.Job{ID: 41}
|
||||
|
||||
resp, body := postVersion(t, srv, tt.version)
|
||||
|
||||
assert.Equal(t, tt.wantCode, resp.StatusCode)
|
||||
assert.Contains(t, body, tt.wantBody)
|
||||
assert.Equal(t, tt.wantJob, resp.Header.Get("Location"))
|
||||
if tt.wantCode == http.StatusBadRequest {
|
||||
assert.Empty(t, jobs.triggered, "no job for a malformed version")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverview_ShowsFollowsNoTagAndPollFailedBadges(t *testing.T) {
|
||||
srv, store, _, _, _ := newTestEnv(t)
|
||||
store.services[0].LastPollError = `head x: "boom" <script>`
|
||||
|
||||
for _, path := range []string{"/", "/fragments/services"} {
|
||||
_, body := get(t, srv, path)
|
||||
assert.Equal(t, 1, strings.Count(body, "follows no tag"), "only the digest-only Swarm service: "+path)
|
||||
assert.Contains(t, body, `class="badge badge-error" title="head x: "boom" <script>">poll failed</span>`, path)
|
||||
assert.Equal(t, 1, strings.Count(body, "poll failed"), path)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/imageref"
|
||||
"github.com/shcizo/package-updater/internal/master/jobs"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/master/poller"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
|
||||
@@ -54,6 +56,51 @@ func shortRef(ref string) string {
|
||||
return r.String()
|
||||
}
|
||||
|
||||
// followsNoTag says the service's image reference has a digest but no tag,
|
||||
// so there is no tag to poll or update.
|
||||
func followsNoTag(svc model.Service) bool {
|
||||
return jobs.FollowedRef(svc.ActualRef) == ""
|
||||
}
|
||||
|
||||
// versionRow is an image version as shown on the service page.
|
||||
type versionRow struct {
|
||||
model.ImageVersion
|
||||
Running bool // the service runs exactly this digest
|
||||
}
|
||||
|
||||
// versionRows marks the version the service runs now, by digest.
|
||||
func versionRows(versions []model.ImageVersion, actualDigest string) []versionRow {
|
||||
rows := make([]versionRow, len(versions))
|
||||
for i, v := range versions {
|
||||
rows[i] = versionRow{ImageVersion: v, Running: v.Digest != "" && v.Digest == actualDigest}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// pollingStatus is the text of the "Polling" row on the service page and
|
||||
// whether it is a registry error.
|
||||
func pollingStatus(svc model.Service, interval time.Duration) (text string, failed bool) {
|
||||
switch {
|
||||
case poller.SkipReason(svc) != "":
|
||||
return "not polled: " + poller.SkipReason(svc), false
|
||||
case svc.LastPollError != "":
|
||||
return "last check failed: " + svc.LastPollError, true
|
||||
}
|
||||
return "every " + shortDuration(interval), false
|
||||
}
|
||||
|
||||
// shortDuration formats d without trailing zero units: "6h", "1h30m", "90s".
|
||||
func shortDuration(d time.Duration) string {
|
||||
s := d.String()
|
||||
if strings.HasSuffix(s, "m0s") {
|
||||
s = s[:len(s)-2]
|
||||
}
|
||||
if strings.HasSuffix(s, "h0m") {
|
||||
s = s[:len(s)-2]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type agentRow struct {
|
||||
model.Agent
|
||||
Online bool
|
||||
|
||||
Reference in New Issue
Block a user