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:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user