Merge pull request 'P5-03 + P5-07: docker-only release path & reference deployment' (#17) from p5-03-docker-release into main
Reviewed-on: #17
This commit was merged in pull request #17.
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
|
SERVER_BIN := $(BIN_DIR)/restic-manager-server
|
||||||
AGENT_BIN := $(BIN_DIR)/restic-manager-agent
|
AGENT_BIN := $(BIN_DIR)/restic-manager-agent
|
||||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
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
|
GOFLAGS := -trimpath
|
||||||
DOCKER_IMAGE ?= ghcr.io/dcglab/restic-manager
|
DOCKER_IMAGE ?= gitea.dcglab.co.uk/steve/restic-manager
|
||||||
DOCKER_TAG ?= dev
|
DOCKER_TAG ?= dev
|
||||||
|
|
||||||
# Tailwind standalone CLI — single binary, no Node toolchain.
|
# Tailwind standalone CLI — single binary, no Node toolchain.
|
||||||
@@ -84,7 +86,11 @@ run-agent: agent ## Build and run the agent
|
|||||||
$(AGENT_BIN)
|
$(AGENT_BIN)
|
||||||
|
|
||||||
docker: ## Build the server Docker image
|
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
|
release: ## Cross-compile for all supported platforms
|
||||||
@mkdir -p $(BIN_DIR)
|
@mkdir -p $(BIN_DIR)
|
||||||
|
|||||||
+6
-2
@@ -24,7 +24,11 @@ import (
|
|||||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/restic"
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/restic"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
var (
|
||||||
|
version = "dev"
|
||||||
|
commit = "none"
|
||||||
|
date = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
@@ -62,7 +66,7 @@ func run() error {
|
|||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *showVersion {
|
if *showVersion {
|
||||||
fmt.Println("restic-manager-agent", version)
|
fmt.Printf("restic-manager-agent %s (commit %s, built %s)\n", version, commit, date)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -25,7 +25,11 @@ import (
|
|||||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
var (
|
||||||
|
version = "dev"
|
||||||
|
commit = "none"
|
||||||
|
date = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
@@ -40,7 +44,7 @@ func run() error {
|
|||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *showVersion {
|
if *showVersion {
|
||||||
fmt.Println("restic-manager-server", version)
|
fmt.Printf("restic-manager-server %s (commit %s, built %s)\n", version, commit, date)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
# ---- Build stage --------------------------------------------------------
|
# ---- 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
|
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 \
|
ENV CGO_ENABLED=0 \
|
||||||
GOOS=linux \
|
|
||||||
GOFLAGS="-trimpath"
|
GOFLAGS="-trimpath"
|
||||||
|
|
||||||
# Cache module downloads in a separate layer.
|
# Cache module downloads in a separate layer.
|
||||||
@@ -18,9 +21,34 @@ RUN go mod download
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
RUN go build -ldflags="-s -w -X main.version=${VERSION}" \
|
ARG COMMIT=none
|
||||||
-o /out/restic-manager-server \
|
ARG DATE=unknown
|
||||||
./cmd/server
|
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 ------------------------------------------------------
|
# ---- Runtime stage ------------------------------------------------------
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
@@ -31,7 +59,18 @@ LABEL org.opencontainers.image.licenses="PolyForm-Noncommercial-1.0.0"
|
|||||||
USER nonroot:nonroot
|
USER nonroot:nonroot
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
|
# Server binary on PATH.
|
||||||
COPY --from=build /out/restic-manager-server /usr/local/bin/restic-manager-server
|
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
|
EXPOSE 8443
|
||||||
ENTRYPOINT ["/usr/local/bin/restic-manager-server"]
|
ENTRYPOINT ["/usr/local/bin/restic-manager-server"]
|
||||||
|
|||||||
@@ -1,21 +1,52 @@
|
|||||||
# Reference deployment for the restic-manager control plane.
|
# Reference deployment for the restic-manager control plane.
|
||||||
# Mirrors spec.md §10.1. Adjust image tag and RM_BASE_URL for your env.
|
# Mirrors spec.md §10.1 and the P5-07 reference deployment.
|
||||||
#
|
#
|
||||||
# The server speaks plain HTTP. Front it with a TLS-terminating
|
# Scope: this compose stands up the server only. TLS termination and
|
||||||
# reverse proxy (Caddy/Traefik/nginx). RM_TRUSTED_PROXY must contain
|
# the public hostname belong to a reverse proxy that lives outside
|
||||||
# the proxy's IP/CIDR so X-Forwarded-* headers are honoured.
|
# this stack (Caddy, Traefik, nginx, HAProxy, your existing edge —
|
||||||
|
# whatever you already operate). See `docs/reverse-proxy.md` for the
|
||||||
|
# headers + CIDRs that proxy needs to forward.
|
||||||
|
#
|
||||||
|
# Architecture:
|
||||||
|
# * The server speaks plain HTTP on :8080.
|
||||||
|
# * The agent binaries + install scripts ship inside the image under
|
||||||
|
# /opt/restic-manager/dist/, so /agent/binary and /install/*
|
||||||
|
# serve out of the box without first-run staging.
|
||||||
|
# * The named volume holds *only* operator state (sqlite,
|
||||||
|
# secrets.enc, audit log, the AEAD key). Image upgrades replace
|
||||||
|
# the agents/scripts; the volume is untouched.
|
||||||
|
# * Pre-1.0 releases never publish :latest — pin to an exact
|
||||||
|
# vX.Y.Z tag and bump deliberately.
|
||||||
|
#
|
||||||
|
# Before first start:
|
||||||
|
# 1. Pick a version: export RM_VERSION=vX.Y.Z (or substitute below).
|
||||||
|
# 2. Set RM_BASE_URL to the public HTTPS URL the external proxy
|
||||||
|
# serves on.
|
||||||
|
# 3. Set RM_TRUSTED_PROXY to the IP/CIDR the proxy connects from
|
||||||
|
# (the X-Forwarded-* headers are honoured only when the immediate
|
||||||
|
# peer matches one of these).
|
||||||
|
|
||||||
services:
|
services:
|
||||||
restic-manager:
|
restic-manager:
|
||||||
image: ghcr.io/dcglab/restic-manager:latest
|
image: gitea.dcglab.co.uk/steve/restic-manager:${RM_VERSION:?set RM_VERSION to a vX.Y.Z tag}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Bind to localhost only — the proxy is what the public reaches.
|
# Bind to localhost only — your reverse proxy reaches the server
|
||||||
|
# over loopback (or, if it runs in a separate compose / on
|
||||||
|
# another host, swap this for an internal docker network or a
|
||||||
|
# private LAN bind).
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- rm-data:/data
|
||||||
environment:
|
environment:
|
||||||
- RM_DATA_DIR=/data
|
- RM_DATA_DIR=/data
|
||||||
- RM_LISTEN=:8080
|
- RM_LISTEN=:8080
|
||||||
- RM_BASE_URL=https://restic.lab.example
|
- RM_BASE_URL=${RM_BASE_URL:?set RM_BASE_URL to the public https URL}
|
||||||
- RM_SECRET_KEY_FILE=/data/secret.key
|
- RM_SECRET_KEY_FILE=/data/secret.key
|
||||||
- RM_TRUSTED_PROXY=172.16.0.0/12
|
- RM_TRUSTED_PROXY=${RM_TRUSTED_PROXY:?set RM_TRUSTED_PROXY to the proxy CIDR}
|
||||||
|
# Cookies are Secure by default; keep that. Override only for
|
||||||
|
# local-HTTP smoke tests.
|
||||||
|
# - RM_COOKIE_SECURE=true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
rm-data:
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Running behind a reverse proxy
|
||||||
|
|
||||||
|
The restic-manager server is HTTP-only by design (see `spec.md` §11):
|
||||||
|
TLS termination, public hostname, ACME, HSTS, and edge-level rate
|
||||||
|
limiting all belong to a reverse proxy that you already operate
|
||||||
|
outside this project. The reference compose in `deploy/docker-compose.yml`
|
||||||
|
stands up *only* the server; this page covers what your proxy needs
|
||||||
|
to do to make the rest of it work.
|
||||||
|
|
||||||
|
## What the proxy must forward
|
||||||
|
|
||||||
|
The server reads four headers when (and only when) the immediate peer
|
||||||
|
matches `RM_TRUSTED_PROXY`:
|
||||||
|
|
||||||
|
| Header | Value | Why |
|
||||||
|
|---------------------|----------------------------------------------------------|-----|
|
||||||
|
| `X-Forwarded-For` | The original client IP (single value, or comma chain) | Rate-limit keys, audit log entries, and OIDC redirect-URI checks all use the real client IP. |
|
||||||
|
| `X-Forwarded-Proto` | `https` | The server emits absolute URLs (e.g. OIDC redirect URIs) using this. |
|
||||||
|
| `Host` | The public hostname clients use | Cookies are scoped to this; `RM_BASE_URL` must match. |
|
||||||
|
| `Connection`/`Upgrade` | Pass through unchanged | The agent connects on `/ws/agent` and the live-log viewer connects on `/api/jobs/{id}/stream` — both are WebSockets and need `Upgrade: websocket` to survive the hop. |
|
||||||
|
|
||||||
|
Set `RM_TRUSTED_PROXY` to the CIDR (or comma-separated list of CIDRs)
|
||||||
|
the proxy connects from. Anything outside that range has its
|
||||||
|
`X-Forwarded-*` headers ignored, so a stray request that bypasses the
|
||||||
|
proxy can't spoof the client IP.
|
||||||
|
|
||||||
|
## Example: Caddy
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
restic.example.com {
|
||||||
|
# Caddy's default reverse_proxy preserves Host, sets
|
||||||
|
# X-Forwarded-For/Proto, and passes Connection: upgrade through,
|
||||||
|
# so a single directive covers HTTP + WebSocket.
|
||||||
|
reverse_proxy 127.0.0.1:8080
|
||||||
|
|
||||||
|
encode zstd gzip
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`RM_TRUSTED_PROXY=127.0.0.1/32` if Caddy and the server share the
|
||||||
|
host; the docker-bridge CIDR (commonly `172.16.0.0/12`) if Caddy
|
||||||
|
runs in another container on the default bridge network.
|
||||||
|
|
||||||
|
## Example: nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name restic.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/restic.example.com.fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/restic.example.com.key.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# WebSocket support — agent + live-log endpoints need this.
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
# Trusted-proxy headers.
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
|
||||||
|
# Live job logs are long-running streams. Bump read timeouts
|
||||||
|
# so nginx doesn't drop them mid-backup.
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
proxy_send_timeout 1h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Standard websocket upgrade map (define once at the http {} level).
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`RM_TRUSTED_PROXY` for the same-host case: `127.0.0.1/32`.
|
||||||
|
|
||||||
|
## Example: Traefik (label-based)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.restic-manager.rule=Host(`restic.example.com`)"
|
||||||
|
- "traefik.http.routers.restic-manager.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.restic-manager.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.restic-manager.loadbalancer.server.port=8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
Traefik handles `X-Forwarded-*` and `Connection: upgrade` by default.
|
||||||
|
`RM_TRUSTED_PROXY` should be the docker network the Traefik container
|
||||||
|
shares with the server (commonly `172.16.0.0/12` for the default
|
||||||
|
bridge, or whatever your overlay network's CIDR is).
|
||||||
|
|
||||||
|
## Sanity-checking the wiring
|
||||||
|
|
||||||
|
After bringing the stack up:
|
||||||
|
|
||||||
|
1. `curl -fsS https://restic.example.com/healthz` — should return 200.
|
||||||
|
2. The login page should report HTTPS in the address bar; cookies
|
||||||
|
set after login should carry the `Secure` flag.
|
||||||
|
3. Check the server log for the `config resolved` line:
|
||||||
|
`trusted_proxies` must include the IP/CIDR your proxy actually
|
||||||
|
connects from.
|
||||||
|
4. Enrol a test agent — the WebSocket handshake hitting `/ws/agent`
|
||||||
|
confirms `Upgrade` is being forwarded correctly.
|
||||||
|
|
||||||
|
If any of those fail, the proxy is the first place to look — the
|
||||||
|
server itself is intentionally minimal.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# P5-03 implementation plan — Docker-only release
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-05-05-p5-03-docker-only-release.md`.
|
||||||
|
|
||||||
|
Branch: `p5-03-docker-release`. Do not auto-open a PR (see CLAUDE.md
|
||||||
|
memory: CI runs are expensive on the self-hosted cluster).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 1 — Server config + handler fallback
|
||||||
|
|
||||||
|
**Goal:** server can serve agent binaries / install scripts from a
|
||||||
|
read-only "bundled assets" path when `<DataDir>` doesn't have them.
|
||||||
|
|
||||||
|
1. `internal/server/config/config.go` (or wherever `Cfg` lives) gains
|
||||||
|
a `BundledAssetsDir string` field, defaulting to
|
||||||
|
`/opt/restic-manager/dist`. Wire from `RM_BUNDLED_ASSETS_DIR` env
|
||||||
|
var, mirroring the existing env-var conventions.
|
||||||
|
2. `internal/server/http/agent_assets.go`:
|
||||||
|
- `handleAgentBinary`: try `<DataDir>/agent-binaries/<name>`
|
||||||
|
first; on `os.Stat` ENOENT, try
|
||||||
|
`<BundledAssetsDir>/agent-binaries/<name>`; on second ENOENT,
|
||||||
|
existing 404.
|
||||||
|
- `handleInstallAsset`: same dual-path, with `install/` subpath.
|
||||||
|
3. Tests in `internal/server/http/agent_assets_test.go` (new file):
|
||||||
|
- DataDir hit serves DataDir bytes.
|
||||||
|
- DataDir miss + bundled hit serves bundled bytes.
|
||||||
|
- DataDir hit shadows bundled.
|
||||||
|
- Both miss → 404 + existing error envelope.
|
||||||
|
- Path-traversal still rejected for `install/*` (regression check).
|
||||||
|
|
||||||
|
**Verify:** `go vet ./...` + `go test ./internal/server/http/...`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 2 — Version ldflags on both binaries
|
||||||
|
|
||||||
|
1. `cmd/server/main.go`: keep `var version`, add
|
||||||
|
`var commit = "none"` and `var date = "unknown"`. Surface via
|
||||||
|
existing version-log line.
|
||||||
|
2. `cmd/agent/main.go`: same three vars. Agent already reports
|
||||||
|
`agent_version` in the WS hello — extend to include commit if
|
||||||
|
it's already plumbed through `internal/api`; otherwise leave the
|
||||||
|
commit out of the wire and just log it on startup.
|
||||||
|
3. `Makefile`: extend the `make build` `-ldflags` to set all three
|
||||||
|
from `git describe --tags --always` + `git rev-parse HEAD` +
|
||||||
|
UTC timestamp. Source-build users get real values, not "dev".
|
||||||
|
4. `deploy/Dockerfile.server`: add `ARG COMMIT=none` and
|
||||||
|
`ARG DATE=unknown`; pass through `-ldflags`.
|
||||||
|
|
||||||
|
**Verify:** `make build && ./bin/restic-manager-server -version`
|
||||||
|
(or whatever the existing flag is) prints non-`dev` values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 3 — Dockerfile bakes agents + install assets
|
||||||
|
|
||||||
|
1. Build stage cross-compiles three agents:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN go build -trimpath -ldflags="-s -w \
|
||||||
|
-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \
|
||||||
|
-o /out/agent/restic-manager-agent-linux-amd64 ./cmd/agent
|
||||||
|
ENV GOARCH=arm64
|
||||||
|
RUN go build ... -o /out/agent/restic-manager-agent-linux-arm64 ./cmd/agent
|
||||||
|
ENV GOOS=windows GOARCH=amd64
|
||||||
|
RUN go build ... -o /out/agent/restic-manager-agent-windows-amd64.exe ./cmd/agent
|
||||||
|
```
|
||||||
|
|
||||||
|
(Reset `GOOS`/`GOARCH` between layers via `ENV`. Server build
|
||||||
|
stays at `GOOS=linux GOARCH=$TARGETARCH`.)
|
||||||
|
|
||||||
|
2. Final stage `COPY --from=build`:
|
||||||
|
- `/out/restic-manager-server` → `/usr/local/bin/`
|
||||||
|
- `/out/agent/*` → `/opt/restic-manager/dist/agent-binaries/`
|
||||||
|
- `deploy/install/install.sh` →
|
||||||
|
`/opt/restic-manager/dist/install/install.sh`
|
||||||
|
- `deploy/install/install.ps1` →
|
||||||
|
`/opt/restic-manager/dist/install/install.ps1`
|
||||||
|
- `deploy/install/restic-manager-agent.service` →
|
||||||
|
`/opt/restic-manager/dist/install/restic-manager-agent.service`
|
||||||
|
|
||||||
|
3. Set `--chmod=0755` on the agent binaries and `install.sh`,
|
||||||
|
`--chmod=0644` on the unit file and `install.ps1`. Distroless
|
||||||
|
final stage runs as `nonroot`; bundled assets are readable by
|
||||||
|
anyone (mode `o+r`), so the user switch doesn't break reads.
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
```sh
|
||||||
|
docker build -f deploy/Dockerfile.server -t rm:dev .
|
||||||
|
docker run --rm -d -p 18080:8080 \
|
||||||
|
-e RM_LISTEN=:8080 -e RM_DATA_DIR=/data \
|
||||||
|
-e RM_BASE_URL=http://127.0.0.1:18080 \
|
||||||
|
-v rm-test:/data rm:dev
|
||||||
|
curl -fsSL "http://127.0.0.1:18080/agent/binary?os=linux&arch=amd64" | wc -c
|
||||||
|
curl -fsSL "http://127.0.0.1:18080/install/install.sh" | head -1
|
||||||
|
```
|
||||||
|
|
||||||
|
Both should succeed against a fresh volume (no operator staging).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 4 — Release workflow
|
||||||
|
|
||||||
|
`.gitea/workflows/release.yml` per the spec. Two jobs:
|
||||||
|
|
||||||
|
1. **`image`**: checkout → setup-qemu → setup-buildx → login → compute
|
||||||
|
tags → buildx build+push.
|
||||||
|
2. (Future) `release-notes`: stub left as a TODO comment for now.
|
||||||
|
Operator can hand-write release notes via the Gitea UI on first
|
||||||
|
cut.
|
||||||
|
|
||||||
|
The `compute tags` shell step is the only non-trivial bit; tested
|
||||||
|
inline by running the script with mocked `GITHUB_REF_TYPE` /
|
||||||
|
`GITHUB_REF_NAME` env vars before committing.
|
||||||
|
|
||||||
|
**Verify on first dispatch:** trigger `workflow_dispatch` from the
|
||||||
|
Gitea UI, check the runner produces `:snapshot-<sha>` and pushes
|
||||||
|
multi-arch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 5 — Tasks.md + commit + push
|
||||||
|
|
||||||
|
1. `tasks.md`: tick P5-03; add a one-line note that goreleaser was
|
||||||
|
dropped in favour of Docker-only after a 2026-05-05 design pass
|
||||||
|
(link the spec).
|
||||||
|
2. `git add -A && git commit -m "p5-03: docker-only release path"`
|
||||||
|
(no Co-Authored-By trailer — CLAUDE.md rule).
|
||||||
|
3. `git push -u origin p5-03-docker-release`.
|
||||||
|
4. **Stop.** Do not open a PR. Wait for operator review.
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# P5-03 — Docker-only release path
|
||||||
|
|
||||||
|
**Status:** approved 2026-05-05. Pivots P5-03 away from `goreleaser` +
|
||||||
|
binary archives toward a single Docker image as the only public
|
||||||
|
deliverable.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
One artifact per tag: the `restic-manager` server image, multi-arch
|
||||||
|
(linux amd64 + arm64), published to the Gitea container registry of
|
||||||
|
this self-hosted instance. The image bakes in cross-compiled agent
|
||||||
|
binaries (linux amd64, linux arm64, windows amd64), the install
|
||||||
|
scripts, and the systemd unit at a read-only image path. The running
|
||||||
|
server distributes those agents and scripts via its existing
|
||||||
|
`/agent/binary` and `/install/*` endpoints; operators on N hosts never
|
||||||
|
download a release artifact directly.
|
||||||
|
|
||||||
|
Source builds via `make build` remain a first-class path for anyone
|
||||||
|
who wants binaries.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Standalone binary archives (`.tar.gz`, `.zip`) on the release page.
|
||||||
|
- darwin / windows-arm64 agent targets — neither is service-tested.
|
||||||
|
- `goreleaser`. Not used.
|
||||||
|
- `cosign`, `SBOM`, `in-toto`, `minisign`. Re-promote when we ship
|
||||||
|
binaries outside an image (Phase 6 candidate).
|
||||||
|
- GHCR / GitHub mirror. Single source of truth = Gitea.
|
||||||
|
|
||||||
|
## Decisions captured (with one-line rationale)
|
||||||
|
|
||||||
|
| ID | Decision | Why |
|
||||||
|
|----|----------|-----|
|
||||||
|
| D1 | One artifact: server Docker image | Architecture already routes agent distribution through the server (`/agent/binary`); release surface should mirror that. |
|
||||||
|
| D2 | Trigger: `tag-push` (`v*.*.*`) **plus** `workflow_dispatch` | Tag for real cuts; dispatch for snapshot iteration without polluting tag history. |
|
||||||
|
| D3 | Build matrix: linux amd64+arm64 server image; agent cross-compiles for linux amd64+arm64+windows amd64 | Mirrors the existing CI build matrix; nothing ships that hasn't been service-tested. |
|
||||||
|
| D4 | Image-baked, separate path (`/opt/restic-manager/dist/`); HTTP handler reads `<DataDir>/...` first, falls back to `/opt/...` | Volume stays purely operator state; image content is immutable per tag; eliminates the smoke-env "stale agent" footgun in production. |
|
||||||
|
| D5 | Tag fan-out: `vX.Y.Z`, `X.Y`, `X`, `latest` — but `latest` is held back until `v1.0.0` | Standard rolling-minor pattern; pre-1.0 forces explicit pinning. |
|
||||||
|
| D6 | Snapshot tag: `:snapshot-<shortsha>`, never moves `latest` | Operator can never accidentally pull an unblessed build. |
|
||||||
|
| D7 | Version embedding via `-ldflags`: `main.version`, `main.commit`, `main.date` on both `cmd/server` and `cmd/agent` | Server already had `version`; add `commit`/`date` to both for parity and traceability. |
|
||||||
|
| D8 | Registry: Gitea container registry on this instance, under `<host>/<owner>/restic-manager` | One source of truth, no external creds. |
|
||||||
|
| D9 | Integrity: a `SHA256SUMS` file + the manifest digest in the release notes; nothing else | Image is the unit of trust; pull-by-digest is the verification primitive. |
|
||||||
|
| D10 | P1-31 (signed binaries) stays deferred | Re-promote the day we ship binaries outside an image. |
|
||||||
|
|
||||||
|
## Image layout
|
||||||
|
|
||||||
|
Multi-stage Dockerfile (extends today's `deploy/Dockerfile.server`):
|
||||||
|
|
||||||
|
```
|
||||||
|
build stage (golang:1.25-alpine):
|
||||||
|
cross-compile cmd/server for $TARGETARCH (linux)
|
||||||
|
cross-compile cmd/agent for linux/amd64
|
||||||
|
cross-compile cmd/agent for linux/arm64
|
||||||
|
cross-compile cmd/agent for windows/amd64
|
||||||
|
(CGO_ENABLED=0 throughout — pure-Go SQLite)
|
||||||
|
|
||||||
|
final stage (gcr.io/distroless/static-debian12:nonroot):
|
||||||
|
/usr/local/bin/restic-manager-server (matches image arch)
|
||||||
|
/opt/restic-manager/dist/agent-binaries/
|
||||||
|
restic-manager-agent-linux-amd64
|
||||||
|
restic-manager-agent-linux-arm64
|
||||||
|
restic-manager-agent-windows-amd64.exe
|
||||||
|
/opt/restic-manager/dist/install/
|
||||||
|
install.sh
|
||||||
|
install.ps1
|
||||||
|
restic-manager-agent.service
|
||||||
|
```
|
||||||
|
|
||||||
|
`/opt/restic-manager/dist/` is owned by `root:root`, mode `0755` for
|
||||||
|
directories, `0755` for `install.sh` (script must be executable when
|
||||||
|
the install path uses `curl ... | sh` semantics) and `0644` for the
|
||||||
|
unit file and `install.ps1`. The agent binaries are mode `0755`.
|
||||||
|
|
||||||
|
`<DataDir>` keeps holding only operator state: `restic-manager.db`,
|
||||||
|
`secret.key`, `secrets.enc`, `audit/`, `tls/`. Nothing the image
|
||||||
|
owns gets written into the volume.
|
||||||
|
|
||||||
|
## Server-side handler change
|
||||||
|
|
||||||
|
`internal/server/http/agent_assets.go` today reads from
|
||||||
|
`<DataDir>/agent-binaries/<name>` and `<DataDir>/install/<name>`.
|
||||||
|
|
||||||
|
Change: if the file isn't present in `<DataDir>`, fall back to
|
||||||
|
`/opt/restic-manager/dist/<subpath>/<name>`. The fallback path is a
|
||||||
|
new server-config field defaulted to `/opt/restic-manager/dist`,
|
||||||
|
overridable via `RM_BUNDLED_ASSETS_DIR` for tests and source-build
|
||||||
|
deployments. If neither path resolves, return 404 (existing
|
||||||
|
`binary_not_published` / `not_found` body unchanged).
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- A fresh container without any operator-staged overrides serves the
|
||||||
|
baked-in agents. No first-run setup needed.
|
||||||
|
- An operator can still drop a custom-built agent into
|
||||||
|
`<DataDir>/agent-binaries/` to override the image's copy (handy for
|
||||||
|
pre-release agent testing without rebuilding the server image).
|
||||||
|
- Source-build dev (`bin/restic-manager-server` running out of the
|
||||||
|
working tree) still works exactly as today — the fallback dir is
|
||||||
|
configurable, and the `<DataDir>` path remains the primary lookup.
|
||||||
|
|
||||||
|
Tests cover four cases: (a) DataDir hit, (b) fallback hit, (c) DataDir
|
||||||
|
hit shadows fallback, (d) neither — 404.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
Both binaries grow `commit` and `date` ldflag-targets next to the
|
||||||
|
existing `version`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var (
|
||||||
|
version = "dev"
|
||||||
|
commit = "none"
|
||||||
|
date = "unknown"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dockerfile gains `ARG VERSION`, `ARG COMMIT`, `ARG DATE`, all
|
||||||
|
`""`-defaulted; the `go build` line passes them via `-ldflags`. The
|
||||||
|
release workflow fills them from `${{ gitea.ref_name }}`,
|
||||||
|
`${{ gitea.sha }}`, and a UTC ISO-8601 timestamp.
|
||||||
|
|
||||||
|
Snapshot builds (workflow_dispatch) compute
|
||||||
|
`VERSION=0.0.0-snapshot-${SHORTSHA}` and tag the image as
|
||||||
|
`:snapshot-${SHORTSHA}` only. They never touch `latest` or any
|
||||||
|
`vX.Y.Z` tag.
|
||||||
|
|
||||||
|
## Workflow (`.gitea/workflows/release.yml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: gitea.dcglab.co.uk/${{ gitea.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-qemu-action@v3
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: gitea.dcglab.co.uk
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: compute tags
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
# tag-push → :vX.Y.Z, :X.Y, :X (only :latest if X >= 1)
|
||||||
|
# dispatch → :snapshot-<shortsha>
|
||||||
|
...
|
||||||
|
- 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 }}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `compute tags` step:
|
||||||
|
|
||||||
|
- For `push:tags`: extract `vMAJOR.MINOR.PATCH`. Always emit
|
||||||
|
`:vMAJOR.MINOR.PATCH`, `:MAJOR.MINOR`, `:MAJOR`. Emit `:latest`
|
||||||
|
only when `MAJOR >= 1`.
|
||||||
|
- For `workflow_dispatch`: emit `:snapshot-<shortsha>`. Nothing else.
|
||||||
|
|
||||||
|
No release-asset upload step yet — the GHCR-equivalent registry push
|
||||||
|
is the deliverable. A future iteration may attach a `SHA256SUMS` file
|
||||||
|
to a Gitea release object once `tea release create` is wired in;
|
||||||
|
that's not in scope for the first cut.
|
||||||
|
|
||||||
|
## Tests / verification
|
||||||
|
|
||||||
|
1. `go vet ./...` (CLAUDE.md rule, runs locally pre-commit).
|
||||||
|
2. `go test ./internal/server/http/...` covers the new fallback
|
||||||
|
logic.
|
||||||
|
3. Local manual smoke: `docker build -f deploy/Dockerfile.server .`
|
||||||
|
produces an image; `docker run --rm <image>` starts the server;
|
||||||
|
`curl http://127.0.0.1:8080/agent/binary?os=linux&arch=amd64`
|
||||||
|
serves bytes; `curl http://127.0.0.1:8080/install/install.sh`
|
||||||
|
serves the script.
|
||||||
|
4. Release workflow itself is exercised on first tag-push; until
|
||||||
|
then, `workflow_dispatch` is the smoke test.
|
||||||
|
|
||||||
|
## Operator-facing changes
|
||||||
|
|
||||||
|
- `README.md` install snippet becomes
|
||||||
|
`docker run -v rm-data:/var/lib/restic-manager ...
|
||||||
|
gitea.dcglab.co.uk/<owner>/restic-manager:vX.Y.Z`. Pre-1.0
|
||||||
|
releases are pinned by exact tag; no `:latest` is published.
|
||||||
|
- The CLAUDE.md "restage" block is dev-only (smoke env runs the
|
||||||
|
server out of `bin/`). Production users on the image never see
|
||||||
|
it.
|
||||||
|
- `RM_BUNDLED_ASSETS_DIR` is documented in the server config
|
||||||
|
reference (defaults to `/opt/restic-manager/dist`).
|
||||||
|
|
||||||
|
## Risks / footguns
|
||||||
|
|
||||||
|
- **Image size growth.** Three agent binaries (~15-20 MB each
|
||||||
|
stripped) add ~50 MB. Acceptable; we're already shipping a
|
||||||
|
distroless server. Watch the trajectory once Phase 4 alerting is
|
||||||
|
in.
|
||||||
|
- **Dockerfile cross-compile multiplies build time** on the runner.
|
||||||
|
Pure-Go means each leg is just a `go build`; total stage time
|
||||||
|
should stay under 60s on the self-hosted runner.
|
||||||
|
- **`ARG VERSION` leakage.** The current Dockerfile already accepts
|
||||||
|
`ARG VERSION=dev`; we're tightening, not loosening.
|
||||||
|
- **Operator overriding `<DataDir>/agent-binaries/<name>`** with a
|
||||||
|
stale binary will silently shadow the image's copy. Documented in
|
||||||
|
the server config reference; this is a feature (lets operators
|
||||||
|
hot-patch a pre-release agent) not a bug.
|
||||||
|
|
||||||
|
## Out of scope (tracked for follow-up)
|
||||||
|
|
||||||
|
- Cosign / SBOM / in-toto provenance — defer to Phase 6 with the rest
|
||||||
|
of the supply-chain hardening.
|
||||||
|
- GHCR mirror — defer until P5-01 docs site goes public.
|
||||||
|
- `tea release create` integration — pending until we have something
|
||||||
|
worth attaching beyond the image digest.
|
||||||
@@ -33,6 +33,14 @@ type Config struct {
|
|||||||
CookieSecure bool `yaml:"cookie_secure"`
|
CookieSecure bool `yaml:"cookie_secure"`
|
||||||
OIDCRaw *OIDCConfig `yaml:"oidc"`
|
OIDCRaw *OIDCConfig `yaml:"oidc"`
|
||||||
OIDC *OIDCConfig `yaml:"-"`
|
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:
|
// Load resolves config in this order:
|
||||||
@@ -44,9 +52,10 @@ type Config struct {
|
|||||||
// safe to start.
|
// safe to start.
|
||||||
func Load(yamlPath string) (Config, error) {
|
func Load(yamlPath string) (Config, error) {
|
||||||
c := Config{
|
c := Config{
|
||||||
Listen: ":8080",
|
Listen: ":8080",
|
||||||
DataDir: "/data",
|
DataDir: "/data",
|
||||||
CookieSecure: true,
|
CookieSecure: true,
|
||||||
|
BundledAssetsDir: "/opt/restic-manager/dist",
|
||||||
}
|
}
|
||||||
|
|
||||||
if yamlPath != "" {
|
if yamlPath != "" {
|
||||||
@@ -81,6 +90,9 @@ func Load(yamlPath string) (Config, error) {
|
|||||||
c.CookieSecure = true
|
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 {
|
if v, ok := os.LookupEnv("RM_TRUSTED_PROXY"); ok {
|
||||||
// Comma-separated CIDRs; allow whitespace for readability.
|
// Comma-separated CIDRs; allow whitespace for readability.
|
||||||
parts := strings.Split(v, ",")
|
parts := strings.Split(v, ",")
|
||||||
|
|||||||
@@ -11,19 +11,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// agent_assets.go serves the agent binary (one per OS/arch) and the
|
// agent_assets.go serves the agent binary (one per OS/arch) and the
|
||||||
// install scripts. The binaries live under <DataDir>/agent-binaries/,
|
// install scripts. Lookup is dual-path:
|
||||||
// laid down by the release pipeline (or copied by hand for now).
|
//
|
||||||
// The install scripts live in <DataDir>/install/ alongside the
|
// 1. <DataDir>/agent-binaries/<name> (or <DataDir>/install/<name>) —
|
||||||
// systemd unit.
|
// 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
|
// Both endpoints are intentionally unauthenticated: the install
|
||||||
// payload is unprivileged on its own — it's the one-time enrollment
|
// payload is unprivileged on its own — it's the one-time enrollment
|
||||||
// token that grants access. Anyone can pull the binary; only
|
// token that grants access. Anyone can pull the binary; only
|
||||||
// someone with a valid token can use it productively.
|
// someone with a valid token can use it productively.
|
||||||
//
|
//
|
||||||
// P1-31: signed-binary verification is deferred. Today we serve
|
// P1-31: signed-binary verification is deferred. The image is the
|
||||||
// whatever the operator dropped on disk. Future work bumps this to
|
// unit of trust; pull-by-digest is the verification primitive.
|
||||||
// minisign/cosign signed bundles.
|
// Future work bumps standalone-binary delivery to minisign/cosign.
|
||||||
|
|
||||||
// installAssetsRoutes adds /agent/binary and /install/* to r.
|
// installAssetsRoutes adds /agent/binary and /install/* to r.
|
||||||
func (s *Server) handleAgentBinary(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
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"
|
ext = ".exe"
|
||||||
}
|
}
|
||||||
name := fmt.Sprintf("restic-manager-agent-%s-%s%s", osTag, archTag, ext)
|
name := fmt.Sprintf("restic-manager-agent-%s-%s%s", osTag, archTag, ext)
|
||||||
path := filepath.Join(s.deps.Cfg.DataDir, "agent-binaries", name)
|
path, ok := s.resolveBundledAsset("agent-binaries", name)
|
||||||
if _, err := os.Stat(path); err != nil {
|
if !ok {
|
||||||
writeJSONError(w, stdhttp.StatusNotFound, "binary_not_published",
|
writeJSONError(w, stdhttp.StatusNotFound, "binary_not_published",
|
||||||
fmt.Sprintf("agent binary for %s/%s not published on this server", osTag, archTag))
|
fmt.Sprintf("agent binary for %s/%s not published on this server", osTag, archTag))
|
||||||
return
|
return
|
||||||
@@ -64,14 +68,34 @@ func (s *Server) handleInstallAsset(w stdhttp.ResponseWriter, r *stdhttp.Request
|
|||||||
writeJSONError(w, stdhttp.StatusBadRequest, "bad_path", "")
|
writeJSONError(w, stdhttp.StatusBadRequest, "bad_path", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
path := filepath.Join(s.deps.Cfg.DataDir, "install", rel)
|
path, ok := s.resolveBundledAsset("install", rel)
|
||||||
if _, err := os.Stat(path); err != nil {
|
if !ok {
|
||||||
writeJSONError(w, stdhttp.StatusNotFound, "not_found", "")
|
writeJSONError(w, stdhttp.StatusNotFound, "not_found", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stdhttp.ServeFile(w, r, path)
|
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 {
|
func validOS(s string) bool {
|
||||||
switch api.HostOS(s) {
|
switch api.HostOS(s) {
|
||||||
case api.OSLinux, api.OSWindows:
|
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,11 +328,11 @@ 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-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-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-04** (S) Demo screenshots / short Loom walkthrough in README
|
||||||
- [ ] **P5-05** (S) `SECURITY.md` with disclosure process
|
- [ ] **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)
|
- [ ] **P5-06** (M) End-to-end test suite in CI (Playwright vs. compose stack with sibling Linux agent)
|
||||||
- [ ] **P5-07** (S) Reference deployment: `docker-compose.yml` + Caddyfile snippet showing the TLS-terminating reverse proxy in front of the HTTP-only server (also demonstrates `RM_TRUSTED_PROXY`)
|
- [x] **P5-07** (S) Reference deployment landed alongside P5-03. `deploy/docker-compose.yml` stands up *only* the server (image-pinned via `RM_VERSION`, named volume for operator state, bound to localhost) — TLS termination is left to whichever reverse proxy the operator already runs. `docs/reverse-proxy.md` documents the headers + WebSocket pass-through the proxy must forward, the `RM_TRUSTED_PROXY` CIDR rule, and worked examples for Caddy, nginx, and Traefik.
|
||||||
|
|
||||||
### Phase 5 acceptance
|
### Phase 5 acceptance
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user