Docker Build / docker (push) Successful in 38s
## Summary
- `POST /update` on the master (outside OIDC, bearer `UPDATER_API_KEY`; unset → 503). Matches `image` against `image_repo` on all agents, marks `available_*` on every match, starts webhook-triggered jobs for `auto` services targeting `<image_repo>:<tag>` (followed tag when no tag is given), and waits for them up to `UPDATE_TIMEOUT` (default 5m). 404 on no match, 500 if any auto job failed or timed out, otherwise 200.
- Manual/auto toggle per service on the overview and service page (`POST /services/{id}/mode`, CSRF); auto is blocked for self, `build:` and container services with the reason shown. "Update available" badge and an "Available" row.
- `FinishJob` clears `available_*` only when the successful job was created for that exact ref, after it was reported. Migration `0003_webhook.sql`, phase-3 plan doc, README / gitea-action README / CLAUDE.md / deploy example updated. `action.yml` unchanged.
Closes #8
## Behaviour changes for CI
- Mixed results now return 500 (the old 207 is gone); no match returns 404 instead of 200.
- With a `tag`, Compose services get pinned to `<image_repo>:<tag>` via `compose.override.yaml`; the tag must be a pushed tag.
- `busy` (job already active) and `refused` (not updatable) are reported but return 200.
- The reverse proxy read timeout must exceed `UPDATE_TIMEOUT`.
## Test plan
- [x] `go test ./...` and `go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/`
- [x] `go test -tags integration ./internal/master/store/` against throwaway Postgres
- [x] UI verified in browser (mode toggle, disabled reason, badge via SSE, Available row)
- [ ] Manual: webhook with manual service → `marked` + badge
- [ ] Manual: webhook with auto service → waits, `succeeded`, badge cleared, override pinned
- [ ] Manual: wrong token 401, unknown image 404, offline agent 500
- [ ] Gitea action pointed at the master's `/update` in a real CI run
Reviewed-on: #22
89 lines
2.3 KiB
Go
89 lines
2.3 KiB
Go
package config
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
var required = map[string]string{
|
|
"DATABASE_URL": "postgres://u:p@db/pu",
|
|
"PUBLIC_URL": "https://updater.example.com",
|
|
"AGENT_URL": "wss://10.0.0.5:8090/agent",
|
|
"OIDC_ISSUER": "https://auth.example.com/application/o/pu/",
|
|
"OIDC_CLIENT_ID": "id",
|
|
"OIDC_CLIENT_SECRET": "secret",
|
|
"ALLOWED_GROUP": "updater-admins",
|
|
}
|
|
|
|
func setAll(t *testing.T) {
|
|
for k, v := range required {
|
|
t.Setenv(k, v)
|
|
}
|
|
for _, k := range []string{"PORT", "AGENT_PORT", "AGENT_IMAGE", "LOG_LEVEL", "UPDATER_API_KEY", "UPDATE_TIMEOUT"} {
|
|
t.Setenv(k, "")
|
|
}
|
|
}
|
|
|
|
func TestLoad_Defaults(t *testing.T) {
|
|
setAll(t)
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "8080", cfg.Port)
|
|
assert.Equal(t, "8090", cfg.AgentPort)
|
|
assert.Equal(t, "gitea.shcizo.se/shcizo/package-updater:latest", cfg.AgentImage)
|
|
assert.Equal(t, "info", cfg.LogLevel)
|
|
assert.Equal(t, "updater-admins", cfg.AllowedGroup)
|
|
assert.Empty(t, cfg.UpdaterAPIKey)
|
|
assert.Equal(t, 5*time.Minute, cfg.UpdateTimeout)
|
|
}
|
|
|
|
func TestLoad_WebhookSettings(t *testing.T) {
|
|
setAll(t)
|
|
t.Setenv("UPDATER_API_KEY", "s3cret")
|
|
t.Setenv("UPDATE_TIMEOUT", "90s")
|
|
cfg, err := Load()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "s3cret", cfg.UpdaterAPIKey)
|
|
assert.Equal(t, 90*time.Second, cfg.UpdateTimeout)
|
|
}
|
|
|
|
func TestLoad_InvalidUpdateTimeout(t *testing.T) {
|
|
for _, v := range []string{"abc", "0s", "-5m", "30"} {
|
|
t.Run(v, func(t *testing.T) {
|
|
setAll(t)
|
|
t.Setenv("UPDATE_TIMEOUT", v)
|
|
_, err := Load()
|
|
assert.ErrorContains(t, err, "UPDATE_TIMEOUT must be a positive duration")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoad_EachRequiredVariable(t *testing.T) {
|
|
for key := range required {
|
|
t.Run(key, func(t *testing.T) {
|
|
setAll(t)
|
|
t.Setenv(key, "")
|
|
_, err := Load()
|
|
assert.ErrorContains(t, err, key+" is required")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoad_URLFormats(t *testing.T) {
|
|
cases := []struct{ key, value, wantErr string }{
|
|
{"PUBLIC_URL", "updater.example.com", "PUBLIC_URL must start with http:// or https://"},
|
|
{"AGENT_URL", "ws://10.0.0.5:8090/agent", "AGENT_URL must start with wss://"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.key, func(t *testing.T) {
|
|
setAll(t)
|
|
t.Setenv(c.key, c.value)
|
|
_, err := Load()
|
|
assert.ErrorContains(t, err, c.wantErr)
|
|
})
|
|
}
|
|
}
|