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
114 lines
3.6 KiB
Go
114 lines
3.6 KiB
Go
package http
|
||
|
||
import (
|
||
"fmt"
|
||
stdhttp "net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||
)
|
||
|
||
// agent_assets.go serves the agent binary (one per OS/arch) and the
|
||
// 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. 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) {
|
||
osTag := r.URL.Query().Get("os")
|
||
archTag := r.URL.Query().Get("arch")
|
||
if osTag == "" || archTag == "" {
|
||
writeJSONError(w, stdhttp.StatusBadRequest, "missing_os_or_arch",
|
||
"query params os= and arch= are required")
|
||
return
|
||
}
|
||
if !validOS(osTag) || !validArch(archTag) {
|
||
writeJSONError(w, stdhttp.StatusBadRequest, "unsupported_target",
|
||
fmt.Sprintf("os=%q arch=%q not in {linux,windows} × {amd64,arm64}", osTag, archTag))
|
||
return
|
||
}
|
||
|
||
ext := ""
|
||
if osTag == "windows" {
|
||
ext = ".exe"
|
||
}
|
||
name := fmt.Sprintf("restic-manager-agent-%s-%s%s", osTag, archTag, ext)
|
||
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
|
||
}
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||
stdhttp.ServeFile(w, r, path)
|
||
}
|
||
|
||
func (s *Server) handleInstallAsset(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||
// chi's TrimPrefix-like behaviour: r.URL.Path is "/install/<file>".
|
||
rel := strings.TrimPrefix(r.URL.Path, "/install/")
|
||
// Reject any path traversal — must be a flat filename.
|
||
if rel == "" || strings.ContainsAny(rel, "/\\") {
|
||
writeJSONError(w, stdhttp.StatusBadRequest, "bad_path", "")
|
||
return
|
||
}
|
||
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:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func validArch(s string) bool {
|
||
switch api.HostArch(s) {
|
||
case api.ArchAmd64, api.ArchArm64:
|
||
return true
|
||
}
|
||
return false
|
||
}
|