diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..f6b5a26 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,218 @@ +name: Release + +# Fires only on a merge into main (a push to the main branch). No per-commit +# or per-PR CI runs on other branches — this is the single pipeline that turns +# what lands on main into a release + container image. +# +# Docs-only merges are skipped: touching just README/docs/LICENSE does not +# produce a new build. +on: + # Allow an on-demand run from the Gitea Actions UI/API without a dummy commit. + workflow_dispatch: + push: + branches: [main] + paths-ignore: + - 'README.md' + - 'LICENSE' + - 'docs/**' + - '.gitignore' + - '.gitattributes' + +jobs: + release: + # Label must match a registered Linux runner that has Docker (used for the + # dockerized test gate and the image build/push). This is the same label the + # sibling repos use for their image jobs. + runs-on: ubuntu-host + + env: + # Optional EXTERNAL registry override. Leave these unset to publish to the + # Gitea instance's own built-in container registry (the default below). + REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }} + REGISTRY_USER: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASS: ${{ secrets.REGISTRY_PASSWORD }} + # Injected automatically by Gitea Actions; used for the built-in registry + # login and for creating the release. No manual secret needed. + BUILTIN_TOKEN: ${{ secrets.GITEA_TOKEN }} + SERVER_URL: ${{ github.server_url }} + ACTOR: ${{ github.actor }} + OWNER: ${{ github.repository_owner }} + + steps: + # OrchestrAD is a SHA-1 repo, so actions/checkout works as-is. fetch-depth + # 0 is required so the HEAD commit date is available for the version. + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Version = the UTC date of the HEAD commit, formatted yyyy.MM.dd.HHmm + # (the project's documented version scheme). Deriving it from the commit + # rather than "now" makes re-runs reproducible and keeps the image tag, + # the release tag, and the binary's embedded version identical. + - name: Compute version + id: ver + shell: bash + run: | + set -euo pipefail + VERSION="$(TZ=UTC git show -s --format=%cd --date=format-local:'%Y.%m.%d.%H%M' HEAD)" + GIT_COMMIT="$(git rev-parse HEAD)" + GIT_COMMIT_SHORT="$(git rev-parse --short HEAD)" + BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + { + echo "version=$VERSION" + echo "git_commit=$GIT_COMMIT" + echo "git_commit_short=$GIT_COMMIT_SHORT" + echo "build_time=$BUILD_TIME" + } >> "$GITHUB_OUTPUT" + echo "OrchestrAD version: $VERSION ($GIT_COMMIT_SHORT)" + + # Test gate: run the Go test suite inside the same toolchain image the + # build uses. Running it in a container means the runner needs only Docker + # (no host Go/gcc), and a failure here stops the release before anything + # is published. CGO is on because the SQLite driver requires it. + - name: Test + shell: bash + run: | + set -euo pipefail + docker run --rm -v "$PWD/backend:/src" -w /src \ + -e CGO_ENABLED=1 golang:1.24-alpine \ + sh -c "apk add --no-cache gcc musl-dev >/dev/null && go test ./..." + + # jq is used to JSON-encode the release body safely. Install it if the + # self-hosted runner does not already have it. + - name: Ensure tooling (jq) + shell: bash + run: | + set -euo pipefail + if ! command -v jq >/dev/null 2>&1; then + echo "Installing jq..." + sudo apt-get update -y && sudo apt-get install -y jq + fi + jq --version + + - name: Resolve registry target + id: reg + shell: bash + run: | + set -euo pipefail + if [ -n "${REGISTRY_HOST:-}" ]; then + HOST="$REGISTRY_HOST"; USER="$REGISTRY_USER"; PASS="$REGISTRY_PASS" + echo "Using external registry $HOST" + else + HOST="$(echo "$SERVER_URL" | sed -E 's#^https?://##; s#/$##')" + USER="$ACTOR"; PASS="$BUILTIN_TOKEN" + echo "Using the built-in Gitea registry at $HOST" + fi + OWNER_LC="$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')" + { + echo "host=$HOST" + echo "user=$USER" + echo "image=${HOST}/${OWNER_LC}/orchestrad" + } >> "$GITHUB_OUTPUT" + echo "::add-mask::$PASS" + echo "REGISTRY_LOGIN_PASSWORD=$PASS" >> "$GITHUB_ENV" + + - name: Registry login + run: echo "$REGISTRY_LOGIN_PASSWORD" | docker login "${{ steps.reg.outputs.host }}" -u "${{ steps.reg.outputs.user }}" --password-stdin + + # One multi-stage build compiles the Next.js UI, embeds it, and produces + # the Go binary. Tag both the immutable version and latest (main only). + - name: Build image + env: + IMAGE: ${{ steps.reg.outputs.image }} + VERSION: ${{ steps.ver.outputs.version }} + GIT_COMMIT: ${{ steps.ver.outputs.git_commit }} + BUILD_TIME: ${{ steps.ver.outputs.build_time }} + run: | + set -euo pipefail + docker build \ + --build-arg VERSION="$VERSION" \ + --build-arg GIT_COMMIT="$GIT_COMMIT" \ + --build-arg BUILD_TIME="$BUILD_TIME" \ + -t "${IMAGE}:${VERSION}" \ + -t "${IMAGE}:latest" \ + . + + - name: Push image + env: + IMAGE: ${{ steps.reg.outputs.image }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + docker push "${IMAGE}:${VERSION}" + docker push "${IMAGE}:latest" + echo "Published ${IMAGE}:${VERSION} and ${IMAGE}:latest" + + # Pull the linux/amd64 binary back out of the freshly built image so the + # release carries a ready-to-run artifact, not just an image reference. + - name: Extract release binary + env: + IMAGE: ${{ steps.reg.outputs.image }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + mkdir -p dist + CID="$(docker create "${IMAGE}:${VERSION}")" + docker cp "$CID:/app/orchestrad" "dist/orchestrad" + docker rm "$CID" >/dev/null + tar -C dist -czf "dist/orchestrad-${VERSION}-linux-amd64.tar.gz" orchestrad + ( cd dist && sha256sum "orchestrad-${VERSION}-linux-amd64.tar.gz" > "orchestrad-${VERSION}-linux-amd64.tar.gz.sha256" ) + ls -la dist + + # Create the Gitea release for this version and attach the binary. Uses + # the auto-injected token; no manual secret required. + - name: Create Gitea release + shell: bash + env: + API_URL: ${{ github.api_url }} + REPO: ${{ github.repository }} + TOKEN: ${{ secrets.GITEA_TOKEN }} + VERSION: ${{ steps.ver.outputs.version }} + GIT_COMMIT: ${{ steps.ver.outputs.git_commit }} + GIT_COMMIT_SHORT: ${{ steps.ver.outputs.git_commit_short }} + IMAGE: ${{ steps.reg.outputs.image }} + run: | + set -euo pipefail + + # Skip if a release for this tag already exists (e.g. a re-run). + code="$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: token ${TOKEN}" \ + "${API_URL}/repos/${REPO}/releases/tags/${VERSION}")" + if [ "$code" = "200" ]; then + echo "Release ${VERSION} already exists; skipping." + exit 0 + fi + + # Markdown release notes, JSON-encoded with jq so any characters are + # safely escaped. + body="$(printf '**OrchestrAD %s**\n\n| Field | Value |\n| --- | --- |\n| Version | `%s` |\n| Commit | [`%s`](%s/%s/commit/%s) |\n\n## Container image\n```\ndocker pull %s:%s\ndocker pull %s:latest\n```\n' \ + "$VERSION" "$VERSION" "$GIT_COMMIT_SHORT" "$SERVER_URL" "$REPO" "$GIT_COMMIT" "$IMAGE" "$VERSION" "$IMAGE")" + + payload="$(jq -n \ + --arg tag "$VERSION" \ + --arg sha "$GIT_COMMIT" \ + --arg name "OrchestrAD $VERSION" \ + --arg body "$body" \ + '{tag_name:$tag, target_commitish:$sha, name:$name, body:$body, draft:false, prerelease:false}')" + + rel="$(curl -sS -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "${API_URL}/repos/${REPO}/releases")" + rel_id="$(printf '%s' "$rel" | jq -r '.id')" + if [ -z "$rel_id" ] || [ "$rel_id" = "null" ]; then + echo "Failed to create release:"; echo "$rel"; exit 1 + fi + echo "Created release id=$rel_id" + + for asset in dist/orchestrad-${VERSION}-linux-amd64.tar.gz dist/orchestrad-${VERSION}-linux-amd64.tar.gz.sha256; do + name="$(basename "$asset")" + echo "Uploading $name" + curl -sS -X POST \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${asset}" \ + "${API_URL}/repos/${REPO}/releases/${rel_id}/assets?name=${name}" >/dev/null + done + echo "Release ${VERSION} published." diff --git a/.gitignore b/.gitignore index 87158d7..9006577 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,7 @@ coverage/ # Temporary files tmp/ temp/ + +# Local scratch space (design templates, experiments) - never committed +/Scratch/ +scratch/ diff --git a/Dockerfile b/Dockerfile index ffa3d28..9555873 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,45 @@ # OrchestrAD Docker Image -# Multi-stage build for minimal final image +# Three-stage build: (1) compile the Next.js static export, (2) embed it into +# the Go binary, (3) ship a minimal Alpine runtime. The single resulting binary +# serves the full product UI plus the API on one port. -# Build stage -FROM golang:1.22-alpine AS builder +# --------------------------------------------------------------------------- +# Stage 1: build the frontend (Next.js static export -> frontend/out) +# Debian base avoids musl/sharp native-module friction; this stage is discarded. +# --------------------------------------------------------------------------- +FROM node:22-bookworm-slim AS frontend +WORKDIR /frontend + +# Install deps first for better layer caching. .npmrc carries +# legacy-peer-deps=true, which npm ci needs to resolve the React 19 RC peer +# graph, so it must be present before the install runs. +COPY frontend/package.json frontend/package-lock.json frontend/.npmrc ./ +RUN npm ci --no-audit --no-fund + +COPY frontend/ ./ +RUN npm run build +# next.config.mjs sets output:"export", so the static site lands in ./out + +# --------------------------------------------------------------------------- +# Stage 2: build the Go binary with the UI embedded +# --------------------------------------------------------------------------- +FROM golang:1.24-alpine AS builder RUN apk add --no-cache git gcc musl-dev WORKDIR /build -# Copy go.mod first for better caching +# Cache modules first. COPY backend/go.mod backend/go.sum* ./ RUN go mod download -# Copy source +# Copy source, then drop the staged UI into the embed directory. webui.go does +# //go:embed all:dist, so the compiled binary carries the real UI instead of +# the placeholder page. COPY backend/ ./ +COPY --from=frontend /frontend/out/ ./internal/webui/dist/ -# Build with version info +# Build with version info injected via ldflags. ARG VERSION=dev ARG BUILD_TIME=unknown ARG GIT_COMMIT=unknown @@ -27,10 +51,12 @@ RUN CGO_ENABLED=1 go build \ -X github.com/Grace-Solutions/OrchestrAD/internal/version.GitCommit=${GIT_COMMIT}" \ -o /orchestrad ./cmd/orchestrad -# Final stage +# --------------------------------------------------------------------------- +# Stage 3: minimal runtime +# --------------------------------------------------------------------------- FROM alpine:3.19 -RUN apk add --no-cache ca-certificates tzdata +RUN apk add --no-cache ca-certificates tzdata wget # Create non-root user RUN addgroup -g 1000 orchestrad && \ diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..b979dee --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,100 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// TestLoadDefaults verifies that Load falls back to the documented defaults +// when no configuration environment variables are set. +func TestLoadDefaults(t *testing.T) { + dataDir := t.TempDir() + t.Setenv("ORCHESTRAD_DATA_PATH", dataDir) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Server.Host != "0.0.0.0" { + t.Errorf("Host = %q, want 0.0.0.0", cfg.Server.Host) + } + if cfg.Server.Port != 8080 { + t.Errorf("Port = %d, want 8080", cfg.Server.Port) + } + if cfg.Logging.Level != "info" { + t.Errorf("Log level = %q, want info", cfg.Logging.Level) + } + if !cfg.Database.WALMode { + t.Error("WALMode = false, want true") + } + // With no secret set, Load allows startup with an insecure dev key rather + // than failing, so local development works out of the box. + if len(cfg.SecretKey) == 0 { + t.Error("SecretKey is empty; expected an insecure dev fallback") + } + // Load must create the data directory if it does not exist. + if _, err := os.Stat(dataDir); err != nil { + t.Errorf("data dir not present after Load: %v", err) + } +} + +// TestLoadEnvOverrides verifies that environment variables override defaults +// and that CSV-valued settings are parsed into slices. +func TestLoadEnvOverrides(t *testing.T) { + t.Setenv("ORCHESTRAD_DATA_PATH", t.TempDir()) + t.Setenv("ORCHESTRAD_HOST", "127.0.0.1") + t.Setenv("ORCHESTRAD_PORT", "9090") + t.Setenv("ORCHESTRAD_LOG_LEVEL", "debug") + t.Setenv("ORCHESTRAD_SECRET_KEY", "a-real-secret-value") + t.Setenv("ORCHESTRAD_ALLOWED_ORIGINS", "https://a.example, https://b.example") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Server.Host != "127.0.0.1" { + t.Errorf("Host = %q, want 127.0.0.1", cfg.Server.Host) + } + if cfg.Server.Port != 9090 { + t.Errorf("Port = %d, want 9090", cfg.Server.Port) + } + if cfg.Logging.Level != "debug" { + t.Errorf("Log level = %q, want debug", cfg.Logging.Level) + } + if string(cfg.SecretKey) != "a-real-secret-value" { + t.Errorf("SecretKey = %q, want the value from the env", string(cfg.SecretKey)) + } + want := []string{"https://a.example", "https://b.example"} + if len(cfg.CORS.AllowedOrigins) != len(want) { + t.Fatalf("AllowedOrigins = %v, want %v", cfg.CORS.AllowedOrigins, want) + } + for i := range want { + if cfg.CORS.AllowedOrigins[i] != want[i] { + t.Errorf("AllowedOrigins[%d] = %q, want %q", i, cfg.CORS.AllowedOrigins[i], want[i]) + } + } +} + +// TestSecretKeyFromFile verifies that ORCHESTRAD_SECRET_KEY_FILE is read and +// that a trailing newline (as written by `echo` or a mounted secret) is +// stripped. +func TestSecretKeyFromFile(t *testing.T) { + t.Setenv("ORCHESTRAD_DATA_PATH", t.TempDir()) + + secretPath := filepath.Join(t.TempDir(), "secret.key") + if err := os.WriteFile(secretPath, []byte("file-secret\n"), 0o600); err != nil { + t.Fatalf("writing secret file: %v", err) + } + t.Setenv("ORCHESTRAD_SECRET_KEY_FILE", secretPath) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if string(cfg.SecretKey) != "file-secret" { + t.Errorf("SecretKey = %q, want %q", string(cfg.SecretKey), "file-secret") + } +} diff --git a/backend/internal/server/health_test.go b/backend/internal/server/health_test.go new file mode 100644 index 0000000..221a801 --- /dev/null +++ b/backend/internal/server/health_test.go @@ -0,0 +1,78 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Grace-Solutions/OrchestrAD/internal/version" +) + +// handleHealth and handleVersion are deliberately independent of the Server's +// wired dependencies (db, services, etc.), so a zero-value Server is enough to +// exercise them. These act as a smoke test that the liveness and version +// endpoints keep their documented contract, which the Docker HEALTHCHECK and +// the bootstrap flow both rely on. + +func TestHandleHealth(t *testing.T) { + s := &Server{} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + + s.handleHealth(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + + var body struct { + Status string `json:"status"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("response is not valid JSON: %v (body=%q)", err, rr.Body.String()) + } + if body.Status != "healthy" { + t.Errorf("status = %q, want %q", body.Status, "healthy") + } +} + +func TestHandleVersion(t *testing.T) { + // Pin the build-time vars so the assertion is deterministic regardless of + // whether the test binary was built with -ldflags. + version.Version = "2026.01.02.0304" + version.BuildTime = "2026-01-02T03:04:05Z" + version.GitCommit = "abcdef1" + + s := &Server{} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil) + + s.handleVersion(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + + var body struct { + Version string `json:"version"` + BuildTime string `json:"build_time"` + GitCommit string `json:"git_commit"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("response is not valid JSON: %v (body=%q)", err, rr.Body.String()) + } + if body.Version != version.Version { + t.Errorf("version = %q, want %q", body.Version, version.Version) + } + if body.GitCommit != version.GitCommit { + t.Errorf("git_commit = %q, want %q", body.GitCommit, version.GitCommit) + } + if !strings.Contains(rr.Body.String(), version.BuildTime) { + t.Errorf("body %q missing build_time %q", rr.Body.String(), version.BuildTime) + } +}