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:
@@ -0,0 +1,107 @@
|
||||
# Release workflow — P5-03 (docker-only release path).
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# What it does
|
||||
# * Triggered by either:
|
||||
# - tag push matching v[0-9]+.[0-9]+.[0-9]+ (real release), or
|
||||
# - workflow_dispatch (snapshot iteration without tagging).
|
||||
# * Cross-builds a multi-arch (linux/amd64,linux/arm64) image of the
|
||||
# server, with three agent binaries (linux amd64+arm64, windows amd64)
|
||||
# plus install.sh / install.ps1 / the systemd unit baked in under
|
||||
# /opt/restic-manager/dist (the read-only fallback path the server
|
||||
# handlers use when <DataDir>/... is empty).
|
||||
# * Pushes to this Gitea instance's container registry under
|
||||
# <gitea-host>/<owner>/restic-manager.
|
||||
#
|
||||
# Tag fan-out
|
||||
# * tag push: :vX.Y.Z, :X.Y, :X
|
||||
# * tag push and X >= 1: also :latest
|
||||
# * workflow_dispatch: only :snapshot-<shortsha>; nothing else moves.
|
||||
#
|
||||
# Why no goreleaser
|
||||
# The architecture already routes agent distribution through the
|
||||
# server's /agent/binary endpoint. The image is the only deliverable;
|
||||
# binary archives would just be a second source of truth.
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.dcglab.co.uk
|
||||
IMAGE_NAME: ${{ gitea.repository }}
|
||||
|
||||
jobs:
|
||||
image:
|
||||
name: Build + push image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute tags + version
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REG="${REGISTRY}/${IMAGE_NAME}"
|
||||
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME}" = "push" ] && [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
TAG="${GITHUB_REF_NAME}" # vX.Y.Z
|
||||
VER="${TAG#v}" # X.Y.Z
|
||||
MAJOR="${VER%%.*}"
|
||||
MINOR="${VER#${MAJOR}.}"; MINOR="${MINOR%%.*}"
|
||||
|
||||
TAGS="${REG}:${TAG}"
|
||||
TAGS="${TAGS},${REG}:${MAJOR}.${MINOR}"
|
||||
TAGS="${TAGS},${REG}:${MAJOR}"
|
||||
# Pre-1.0 holds back :latest by design; operators must
|
||||
# pin a version explicitly until v1.0.0.
|
||||
if [ "${MAJOR}" -ge 1 ]; then
|
||||
TAGS="${TAGS},${REG}:latest"
|
||||
fi
|
||||
VERSION="${TAG}"
|
||||
else
|
||||
TAGS="${REG}:snapshot-${SHORT_SHA}"
|
||||
VERSION="0.0.0-snapshot-${SHORT_SHA}"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "tags=${TAGS}"
|
||||
echo "version=${VERSION}"
|
||||
echo "date=${DATE}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Build + push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: deploy/Dockerfile.server
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
DATE=${{ steps.meta.outputs.date }}
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.created=${{ steps.meta.outputs.date }}
|
||||
@@ -5,9 +5,11 @@ BIN_DIR := bin
|
||||
SERVER_BIN := $(BIN_DIR)/restic-manager-server
|
||||
AGENT_BIN := $(BIN_DIR)/restic-manager-agent
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo none)
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
|
||||
GOFLAGS := -trimpath
|
||||
DOCKER_IMAGE ?= ghcr.io/dcglab/restic-manager
|
||||
DOCKER_IMAGE ?= gitea.dcglab.co.uk/steve/restic-manager
|
||||
DOCKER_TAG ?= dev
|
||||
|
||||
# Tailwind standalone CLI — single binary, no Node toolchain.
|
||||
@@ -84,7 +86,11 @@ run-agent: agent ## Build and run the agent
|
||||
$(AGENT_BIN)
|
||||
|
||||
docker: ## Build the server Docker image
|
||||
docker build -f deploy/Dockerfile.server --build-arg VERSION=$(VERSION) -t $(DOCKER_IMAGE):$(DOCKER_TAG) .
|
||||
docker build -f deploy/Dockerfile.server \
|
||||
--build-arg VERSION=$(VERSION) \
|
||||
--build-arg COMMIT=$(COMMIT) \
|
||||
--build-arg DATE=$(DATE) \
|
||||
-t $(DOCKER_IMAGE):$(DOCKER_TAG) .
|
||||
|
||||
release: ## Cross-compile for all supported platforms
|
||||
@mkdir -p $(BIN_DIR)
|
||||
|
||||
+6
-2
@@ -24,7 +24,11 @@ import (
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/restic"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
date = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
@@ -62,7 +66,7 @@ func run() error {
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println("restic-manager-agent", version)
|
||||
fmt.Printf("restic-manager-agent %s (commit %s, built %s)\n", version, commit, date)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -25,7 +25,11 @@ import (
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
date = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
@@ -40,7 +44,7 @@ func run() error {
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println("restic-manager-server", version)
|
||||
fmt.Printf("restic-manager-server %s (commit %s, built %s)\n", version, commit, date)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---- Build stage --------------------------------------------------------
|
||||
FROM golang:1.25-alpine AS build
|
||||
# Cross-compiles:
|
||||
# * the server binary for the image's TARGETARCH (linux/amd64 or arm64),
|
||||
# * three agent binaries (linux/amd64, linux/arm64, windows/amd64) that
|
||||
# the running server hands out via /agent/binary.
|
||||
# Pure-Go SQLite (modernc.org/sqlite) means CGO stays off; static binaries
|
||||
# run on distroless/static.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Pure-Go SQLite (modernc.org/sqlite) means we can keep CGO off and build a
|
||||
# fully static binary that runs on distroless/static.
|
||||
ENV CGO_ENABLED=0 \
|
||||
GOOS=linux \
|
||||
GOFLAGS="-trimpath"
|
||||
|
||||
# Cache module downloads in a separate layer.
|
||||
@@ -18,9 +21,34 @@ RUN go mod download
|
||||
COPY . .
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN go build -ldflags="-s -w -X main.version=${VERSION}" \
|
||||
-o /out/restic-manager-server \
|
||||
./cmd/server
|
||||
ARG COMMIT=none
|
||||
ARG DATE=unknown
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV LDFLAGS="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}"
|
||||
|
||||
# Server: built for the image's runtime arch.
|
||||
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -ldflags="${LDFLAGS}" \
|
||||
-o /out/restic-manager-server \
|
||||
./cmd/server
|
||||
|
||||
# Agents: identical across image arches — an arm64 server image still
|
||||
# ships an amd64 agent binary for amd64 endpoints to download.
|
||||
RUN mkdir -p /out/agent-binaries && \
|
||||
GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="${LDFLAGS}" \
|
||||
-o /out/agent-binaries/restic-manager-agent-linux-amd64 \
|
||||
./cmd/agent && \
|
||||
GOOS=linux GOARCH=arm64 \
|
||||
go build -ldflags="${LDFLAGS}" \
|
||||
-o /out/agent-binaries/restic-manager-agent-linux-arm64 \
|
||||
./cmd/agent && \
|
||||
GOOS=windows GOARCH=amd64 \
|
||||
go build -ldflags="${LDFLAGS}" \
|
||||
-o /out/agent-binaries/restic-manager-agent-windows-amd64.exe \
|
||||
./cmd/agent
|
||||
|
||||
# ---- Runtime stage ------------------------------------------------------
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
@@ -31,7 +59,18 @@ LABEL org.opencontainers.image.licenses="PolyForm-Noncommercial-1.0.0"
|
||||
USER nonroot:nonroot
|
||||
WORKDIR /
|
||||
|
||||
# Server binary on PATH.
|
||||
COPY --from=build /out/restic-manager-server /usr/local/bin/restic-manager-server
|
||||
|
||||
# Image-baked bundled assets (P5-03). Read-only; the /agent/binary and
|
||||
# /install/* handlers fall back here when <DataDir>/... is empty, so a
|
||||
# fresh container Just Works without first-run staging. Operators can
|
||||
# still drop a custom build under <DataDir>/agent-binaries/<name> to
|
||||
# override per-host.
|
||||
COPY --from=build --chmod=0755 /out/agent-binaries/ /opt/restic-manager/dist/agent-binaries/
|
||||
COPY --chmod=0755 deploy/install/install.sh /opt/restic-manager/dist/install/install.sh
|
||||
COPY --chmod=0644 deploy/install/install.ps1 /opt/restic-manager/dist/install/install.ps1
|
||||
COPY --chmod=0644 deploy/install/restic-manager-agent.service /opt/restic-manager/dist/install/restic-manager-agent.service
|
||||
|
||||
EXPOSE 8443
|
||||
ENTRYPOINT ["/usr/local/bin/restic-manager-server"]
|
||||
|
||||
@@ -33,6 +33,14 @@ type Config struct {
|
||||
CookieSecure bool `yaml:"cookie_secure"`
|
||||
OIDCRaw *OIDCConfig `yaml:"oidc"`
|
||||
OIDC *OIDCConfig `yaml:"-"`
|
||||
|
||||
// BundledAssetsDir is the read-only path inside the image that
|
||||
// holds agent binaries (under agent-binaries/) and install
|
||||
// scripts (under install/). The /agent/binary and /install/*
|
||||
// handlers fall back here when the file is not present in
|
||||
// DataDir. Source-build deployments can override via
|
||||
// RM_BUNDLED_ASSETS_DIR.
|
||||
BundledAssetsDir string `yaml:"bundled_assets_dir"`
|
||||
}
|
||||
|
||||
// Load resolves config in this order:
|
||||
@@ -44,9 +52,10 @@ type Config struct {
|
||||
// safe to start.
|
||||
func Load(yamlPath string) (Config, error) {
|
||||
c := Config{
|
||||
Listen: ":8080",
|
||||
DataDir: "/data",
|
||||
CookieSecure: true,
|
||||
Listen: ":8080",
|
||||
DataDir: "/data",
|
||||
CookieSecure: true,
|
||||
BundledAssetsDir: "/opt/restic-manager/dist",
|
||||
}
|
||||
|
||||
if yamlPath != "" {
|
||||
@@ -81,6 +90,9 @@ func Load(yamlPath string) (Config, error) {
|
||||
c.CookieSecure = true
|
||||
}
|
||||
}
|
||||
if v, ok := os.LookupEnv("RM_BUNDLED_ASSETS_DIR"); ok {
|
||||
c.BundledAssetsDir = v
|
||||
}
|
||||
if v, ok := os.LookupEnv("RM_TRUSTED_PROXY"); ok {
|
||||
// Comma-separated CIDRs; allow whitespace for readability.
|
||||
parts := strings.Split(v, ",")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -328,7 +328,7 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days.
|
||||
|
||||
- [ ] **P5-01** (M) Documentation site (mdBook or similar) with install, concepts, security model, screenshots
|
||||
- [ ] **P5-02** (S) `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, issue + PR templates
|
||||
- [ ] **P5-03** (S) Release automation: `goreleaser` for binaries + Docker image to GHCR
|
||||
- [x] **P5-03** (S) Release automation — **pivoted away from goreleaser/binary archives** on 2026-05-05 (spec: `docs/superpowers/specs/2026-05-05-p5-03-docker-only-release.md`). Single deliverable per tag: a multi-arch (linux amd64+arm64) server image, with cross-compiled agent binaries (linux amd64+arm64, windows amd64) + `install.sh` + `install.ps1` + the systemd unit baked under `/opt/restic-manager/dist/`. The `/agent/binary` and `/install/*` handlers fall back from `<DataDir>/...` to `<BundledAssetsDir>/...` so a fresh container Just Works. Workflow `.gitea/workflows/release.yml` triggers on `v*.*.*` tag-push (real release: fan-out `:vX.Y.Z`, `:X.Y`, `:X`, plus `:latest` once `MAJOR>=1`) and `workflow_dispatch` (snapshot: `:snapshot-<shortsha>` only). Pushed to the Gitea container registry on this instance — no external creds, no GHCR mirror. Cosign / SBOM / minisign / GHCR mirror deferred to Phase 6. Source builds via `make build` remain a first-class path.
|
||||
- [ ] **P5-04** (S) Demo screenshots / short Loom walkthrough in README
|
||||
- [ ] **P5-05** (S) `SECURITY.md` with disclosure process
|
||||
- [ ] **P5-06** (M) End-to-end test suite in CI (Playwright vs. compose stack with sibling Linux agent)
|
||||
|
||||
Reference in New Issue
Block a user