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 5ee58979fa
commit 7cc17813a9
11 changed files with 752 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:
+167
View File
@@ -0,0 +1,167 @@
package http
import (
"context"
"io"
stdhttp "net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// newAssetsTestServer is a minimal scaffold for the /agent/binary and
// /install/* handlers. Two roots: one acts as DataDir, the other as
// the image-baked BundledAssetsDir. Either or both may be empty.
func newAssetsTestServer(t *testing.T, populate func(dataDir, bundleDir string)) string {
t.Helper()
root := t.TempDir()
dataDir := filepath.Join(root, "data")
bundleDir := filepath.Join(root, "dist")
for _, d := range []string{
filepath.Join(dataDir, "agent-binaries"),
filepath.Join(dataDir, "install"),
filepath.Join(bundleDir, "agent-binaries"),
filepath.Join(bundleDir, "install"),
} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
}
if populate != nil {
populate(dataDir, bundleDir)
}
st, err := store.Open(context.Background(), filepath.Join(root, "rm.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
keyPath := filepath.Join(root, "secret.key")
_ = crypto.GenerateKeyFile(keyPath)
key, _ := crypto.LoadKeyFromFile(keyPath)
aead, _ := crypto.NewAEAD(key)
deps := Deps{
Cfg: config.Config{
Listen: ":0",
DataDir: dataDir,
SecretKeyFile: keyPath,
BundledAssetsDir: bundleDir,
},
Store: st,
AEAD: aead,
Hub: ws.NewHub(),
BootstrapToken: "test-token",
}
s := New(deps)
ts := httptest.NewServer(s.srv.Handler)
t.Cleanup(ts.Close)
return ts.URL
}
func writeFile(t *testing.T, path string, body []byte) {
t.Helper()
if err := os.WriteFile(path, body, 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func get(t *testing.T, url string) (int, []byte) {
t.Helper()
res, err := stdhttp.Get(url)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
return res.StatusCode, body
}
func TestAgentBinary_DataDirHit(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, func(dataDir, _ string) {
writeFile(t, filepath.Join(dataDir, "agent-binaries", "restic-manager-agent-linux-amd64"),
[]byte("from-datadir"))
})
code, body := get(t, url+"/agent/binary?os=linux&arch=amd64")
if code != 200 || string(body) != "from-datadir" {
t.Fatalf("got %d %q", code, string(body))
}
}
func TestAgentBinary_BundleFallback(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, func(_, bundleDir string) {
writeFile(t, filepath.Join(bundleDir, "agent-binaries", "restic-manager-agent-linux-amd64"),
[]byte("from-bundle"))
})
code, body := get(t, url+"/agent/binary?os=linux&arch=amd64")
if code != 200 || string(body) != "from-bundle" {
t.Fatalf("got %d %q", code, string(body))
}
}
func TestAgentBinary_DataDirShadowsBundle(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, func(dataDir, bundleDir string) {
writeFile(t, filepath.Join(dataDir, "agent-binaries", "restic-manager-agent-linux-amd64"),
[]byte("from-datadir"))
writeFile(t, filepath.Join(bundleDir, "agent-binaries", "restic-manager-agent-linux-amd64"),
[]byte("from-bundle"))
})
code, body := get(t, url+"/agent/binary?os=linux&arch=amd64")
if code != 200 || string(body) != "from-datadir" {
t.Fatalf("operator override should win: got %d %q", code, string(body))
}
}
func TestAgentBinary_BothMiss(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, nil)
code, _ := get(t, url+"/agent/binary?os=linux&arch=amd64")
if code != 404 {
t.Fatalf("expected 404, got %d", code)
}
}
func TestAgentBinary_WindowsNameHasExe(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, func(_, bundleDir string) {
writeFile(t, filepath.Join(bundleDir, "agent-binaries", "restic-manager-agent-windows-amd64.exe"),
[]byte("win"))
})
code, body := get(t, url+"/agent/binary?os=windows&arch=amd64")
if code != 200 || string(body) != "win" {
t.Fatalf("got %d %q", code, string(body))
}
}
func TestInstallAsset_BundleFallback(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, func(_, bundleDir string) {
writeFile(t, filepath.Join(bundleDir, "install", "install.sh"), []byte("#!/bin/sh\n"))
})
code, body := get(t, url+"/install/install.sh")
if code != 200 || string(body) != "#!/bin/sh\n" {
t.Fatalf("got %d %q", code, string(body))
}
}
func TestInstallAsset_PathTraversalRejected(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, nil)
// chi will normalise some traversal attempts, but the handler
// also rejects any rel containing a slash or backslash. The
// path component of the URL after /install/ is the rel.
code, _ := get(t, url+"/install/..%2fpasswd")
if code == 200 {
t.Fatalf("traversal should not return 200")
}
}