fb978ad10c
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
168 lines
4.9 KiB
Go
168 lines
4.9 KiB
Go
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")
|
|
}
|
|
}
|