p5-03: docker-only release path (drop goreleaser)

Single public deliverable per tag: a multi-arch server image, with
cross-compiled agent binaries + install scripts + the systemd unit
baked under /opt/restic-manager/dist/. The /agent/binary and
/install/* handlers fall back from <DataDir>/... to that read-only
path so a fresh container Just Works without first-run staging;
operators can still drop a custom build into <DataDir>/ to override
per-host.

Architecture rationale: agent distribution already routes through
the running server, so the release surface mirrors that — there's
no second source of truth to keep in sync.

Workflow .gitea/workflows/release.yml triggers on v*.*.* tag-push
(fan-out :vX.Y.Z / :X.Y / :X, plus :latest once MAJOR>=1) and
workflow_dispatch (snapshot tag only). Pushes to the Gitea
container registry on this instance.

Both binaries grow main.commit + main.date ldflag targets. Makefile
and Dockerfile fill them; release workflow forwards from gitea.sha
plus a UTC timestamp.

Spec : docs/superpowers/specs/2026-05-05-p5-03-docker-only-release.md
Plan : docs/superpowers/plans/2026-05-05-p5-03-docker-only-release.md
This commit is contained in:
2026-05-05 15:18:48 +01:00
parent 9abdedf40a
commit fb978ad10c
9 changed files with 392 additions and 29 deletions
+35 -11
View File
@@ -11,19 +11,23 @@ import (
)
// agent_assets.go serves the agent binary (one per OS/arch) and the
// install scripts. The binaries live under <DataDir>/agent-binaries/,
// laid down by the release pipeline (or copied by hand for now).
// The install scripts live in <DataDir>/install/ alongside the
// systemd unit.
// install scripts. Lookup is dual-path:
//
// 1. <DataDir>/agent-binaries/<name> (or <DataDir>/install/<name>) —
// operator-managed override; lets the operator hot-patch a
// pre-release agent without rebuilding the server image.
// 2. <BundledAssetsDir>/agent-binaries/<name> — read-only, baked
// into the server image at build time (P5-03). This is what
// makes a fresh container Just Work without first-run staging.
//
// Both endpoints are intentionally unauthenticated: the install
// payload is unprivileged on its own — it's the one-time enrollment
// token that grants access. Anyone can pull the binary; only
// someone with a valid token can use it productively.
//
// P1-31: signed-binary verification is deferred. Today we serve
// whatever the operator dropped on disk. Future work bumps this to
// minisign/cosign signed bundles.
// P1-31: signed-binary verification is deferred. The image is the
// unit of trust; pull-by-digest is the verification primitive.
// Future work bumps standalone-binary delivery to minisign/cosign.
// installAssetsRoutes adds /agent/binary and /install/* to r.
func (s *Server) handleAgentBinary(w stdhttp.ResponseWriter, r *stdhttp.Request) {
@@ -45,8 +49,8 @@ func (s *Server) handleAgentBinary(w stdhttp.ResponseWriter, r *stdhttp.Request)
ext = ".exe"
}
name := fmt.Sprintf("restic-manager-agent-%s-%s%s", osTag, archTag, ext)
path := filepath.Join(s.deps.Cfg.DataDir, "agent-binaries", name)
if _, err := os.Stat(path); err != nil {
path, ok := s.resolveBundledAsset("agent-binaries", name)
if !ok {
writeJSONError(w, stdhttp.StatusNotFound, "binary_not_published",
fmt.Sprintf("agent binary for %s/%s not published on this server", osTag, archTag))
return
@@ -64,14 +68,34 @@ func (s *Server) handleInstallAsset(w stdhttp.ResponseWriter, r *stdhttp.Request
writeJSONError(w, stdhttp.StatusBadRequest, "bad_path", "")
return
}
path := filepath.Join(s.deps.Cfg.DataDir, "install", rel)
if _, err := os.Stat(path); err != nil {
path, ok := s.resolveBundledAsset("install", rel)
if !ok {
writeJSONError(w, stdhttp.StatusNotFound, "not_found", "")
return
}
stdhttp.ServeFile(w, r, path)
}
// resolveBundledAsset looks up an asset by (subdir, name). DataDir
// wins so an operator can override the image-baked copy by dropping
// a file into <DataDir>/<subdir>/<name>. If neither path resolves,
// returns ("", false).
func (s *Server) resolveBundledAsset(subdir, name string) (string, bool) {
candidates := []string{
filepath.Join(s.deps.Cfg.DataDir, subdir, name),
}
if s.deps.Cfg.BundledAssetsDir != "" {
candidates = append(candidates,
filepath.Join(s.deps.Cfg.BundledAssetsDir, subdir, name))
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p, true
}
}
return "", false
}
func validOS(s string) bool {
switch api.HostOS(s) {
case api.OSLinux, api.OSWindows: