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.9 KiB
Go
89 lines
2.9 KiB
Go
// Package config loads the master's configuration from environment variables.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Config holds the master's runtime configuration.
|
|
type Config struct {
|
|
DatabaseURL string
|
|
PublicURL string // where people reach the UI, without trailing slash
|
|
AgentURL string // what agents dial, shown in the add-agent snippet
|
|
AgentImage string
|
|
OIDCIssuer string
|
|
OIDCClientID string
|
|
OIDCClientSecret string
|
|
AllowedGroup string
|
|
Port string
|
|
AgentPort string
|
|
LogLevel string
|
|
|
|
// UpdaterAPIKey is the bearer token for POST /update (UPDATER_API_KEY).
|
|
// Empty disables the webhook.
|
|
UpdaterAPIKey string
|
|
// UpdateTimeout bounds how long one webhook request waits for its auto
|
|
// jobs (UPDATE_TIMEOUT, default 5m).
|
|
UpdateTimeout time.Duration
|
|
}
|
|
|
|
// defaultUpdateTimeout is used when UPDATE_TIMEOUT is unset.
|
|
const defaultUpdateTimeout = 5 * time.Minute
|
|
|
|
// Load reads and validates the master configuration, failing fast on
|
|
// anything missing so a half-configured master never starts.
|
|
func Load() (*Config, error) {
|
|
cfg := &Config{
|
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
|
PublicURL: strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"),
|
|
AgentURL: os.Getenv("AGENT_URL"),
|
|
AgentImage: getenvDefault("AGENT_IMAGE", "gitea.shcizo.se/shcizo/package-updater:latest"),
|
|
OIDCIssuer: os.Getenv("OIDC_ISSUER"),
|
|
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
|
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
|
AllowedGroup: os.Getenv("ALLOWED_GROUP"),
|
|
Port: getenvDefault("PORT", "8080"),
|
|
AgentPort: getenvDefault("AGENT_PORT", "8090"),
|
|
LogLevel: getenvDefault("LOG_LEVEL", "info"),
|
|
UpdaterAPIKey: os.Getenv("UPDATER_API_KEY"),
|
|
UpdateTimeout: defaultUpdateTimeout,
|
|
}
|
|
for _, r := range []struct{ name, value string }{
|
|
{"DATABASE_URL", cfg.DatabaseURL},
|
|
{"PUBLIC_URL", cfg.PublicURL},
|
|
{"AGENT_URL", cfg.AgentURL},
|
|
{"OIDC_ISSUER", cfg.OIDCIssuer},
|
|
{"OIDC_CLIENT_ID", cfg.OIDCClientID},
|
|
{"OIDC_CLIENT_SECRET", cfg.OIDCClientSecret},
|
|
{"ALLOWED_GROUP", cfg.AllowedGroup},
|
|
} {
|
|
if r.value == "" {
|
|
return nil, fmt.Errorf("%s is required", r.name)
|
|
}
|
|
}
|
|
if !strings.HasPrefix(cfg.PublicURL, "http://") && !strings.HasPrefix(cfg.PublicURL, "https://") {
|
|
return nil, fmt.Errorf("PUBLIC_URL must start with http:// or https://")
|
|
}
|
|
if !strings.HasPrefix(cfg.AgentURL, "wss://") {
|
|
return nil, fmt.Errorf("AGENT_URL must start with wss://")
|
|
}
|
|
if v := os.Getenv("UPDATE_TIMEOUT"); v != "" {
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil || d <= 0 {
|
|
return nil, fmt.Errorf("UPDATE_TIMEOUT must be a positive duration such as 5m, got %q", v)
|
|
}
|
|
cfg.UpdateTimeout = d
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func getenvDefault(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|