feat(discovery+runtime): restore static-site wizard discovery + close /sites/[id] feature parity
Build / build (push) Successful in 10m43s

Two-stage feature arc closing the gaps left by the hard legacy cutover.
The static-site creation wizard regains its auto-discovery + connection-test
flow; /apps/[id] grows the runtime/storage/lifecycle surface the legacy
/sites/[id] page used to expose.

Backend (Go)
- internal/api/discovery.go: six admin-gated endpoints wrapping
  staticsite.GitProvider — POST /api/discovery/git/{detect-provider,
  test-connection,repos,branches,tree} + GET /api/discovery/image/conflicts.
  Identifier validation (validateGitIdent / validateGitBranch) at the
  boundary so provider URL interpolation cannot be hijacked via `..`.
  Upstream errors scrubbed: detailed slog on the server, generic 502 to
  the client (mitigates token-reflection-in-error-page).
- internal/api/workload_runtime.go: four endpoints —
  GET /api/workloads/{id}/runtime-state decodes containers.extra_json for
  static workloads; GET /api/workloads/{id}/storage execs `du -sb /app/data`
  with a 30s in-process cache (storageProbeCache) so polling can't turn
  into per-request execs; POST /api/workloads/{id}/{stop,start} iterate
  ListContainersByWorkload and call docker.StopContainer / StartContainer,
  returning 200 / 409 (nothing to act on) / 502 (all failed).
- internal/staticsite/safehttp.go: NewSafeHTTPClient + ValidateBaseURL +
  blockReason. DialContext re-resolves hostnames and refuses loopback /
  link-local / multicast / unspecified addresses. RFC1918 + ULA explicitly
  allowed (self-hosted Gitea on LAN is the dominant deployment).
  Replaced four raw &http.Client{} constructions in the provider files.
- internal/staticsite/gitlab_provider.go: url.PathEscape each segment in
  the raw-file URL builder for parity with projectPath().
- Test coverage: 26 cases in discovery_test.go (image-tag stripping,
  source-config decoding, conflict scenarios, validator boundaries,
  scheme rejection), 14 in workload_runtime_test.go (404 / 409 / nil-docker
  / probe-cache), 16 in safehttp_test.go (URL validation + block-reason
  policy matrix + live dial against loopback + AWS metadata literals).

Frontend (Svelte 5 + runes)
- web/src/lib/api.ts: typed wrappers for every endpoint, AbortSignal
  threaded through post(); ApiError exported so callers can narrow on
  e.status; new DetectedGitProvider narrow union.
- web/src/routes/apps/new/+page.svelte: static-form discovery controls
  (auto-detect provider, test connection, repo / branch / folder
  EntityPickers, Deno auto-detect); image-form conflict panel with
  debounced lookup + double-click submit guard ("Forge anyway") + Inspect
  button that pre-fills port/healthcheck; English error fallbacks routed
  through apps.new.errors.* (en + ru).
- web/src/routes/apps/[id]/+page.svelte: runtime-state panel + storage
  panel + Stop / Start / Open-site toolbar; universal live-state badge
  in the hero lede for image/compose/static (RUNNING / TRANSITIONING /
  STOPPED / NOT DEPLOYED / MIXED · n/m RUNNING); ContainerStats panel
  per row (auto-collapsing native <details> when N > 2); read-only
  webhook bindings summary card; responsive toolbar overflow with native
  <details> at <640px (z-index 100 above sticky nav).
- web/src/app.css: project-wide .forge-btn-ghost:focus-visible outline.

Hardening from go-reviewer + security-reviewer + typescript-reviewer +
frontend-design UI/UX subagents (0 CRITICAL, all HIGH/BLOCKER addressed
inline, IMPORTANT applied before commit):
- AbortController + per-call sequence tokens on every long-running
  fetch (loadRuntimeState / loadStorage / loadTriggerMeta / inspectImage /
  listImageConflicts) plus onDestroy cleanup so late resolves cannot
  mutate dead component state.
- doStop / doStart snapshot and restore `error` across the finally-block
  reload so a load()-cleared message doesn't hide a real failure.
- triggersById refreshed after inline trigger creation so the webhook
  card doesn't silently exclude the just-created trigger.
- Live-state badge wraps in role=status / aria-live=polite (no redundant
  aria-label).
- Webhook row has a single click target (was two pointing at the same URL).
- Empty webhook section hides entirely.
- Dropped role=menu / role=menuitem from the overflow menu (they would
  promise arrow-key nav we don't wire; native Tab + ESC carry it).

Doc
- docs/CODEMAPS/INDEX.md + new docs/CODEMAPS/discovery-and-runtime.md
  map the endpoint surface, security posture, frontend integration
  patterns, and an "add a new probe" recipe.

Verification
- svelte-check: 0 errors, 3 pre-existing a11y warnings.
- go build + go vet + go test ./...: all green.
- i18n parity: en + ru at 1413 keys each.
- Live smoke against :8090: 404 / 409 / 502 envelopes correct, discovery
  sanity passes, ProbeError surfaces on no-container path.
This commit is contained in:
2026-05-16 21:35:51 +03:00
parent ef62a41fc0
commit ea55d31177
19 changed files with 4333 additions and 81 deletions
+403
View File
@@ -0,0 +1,403 @@
package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"regexp"
"strings"
"time"
"github.com/alexei/tinyforge/internal/staticsite"
)
// Discovery endpoints feed the /apps/new wizard's auto-discovery and
// connection-test flow. They wrap staticsite.GitProvider so the form
// can validate a repo + token before the workload is created, browse
// repos/branches/folders without leaving the page, and warn the operator
// when an image is already in use by another workload.
//
// The endpoints are workload-agnostic on purpose — they are scoped under
// /api/discovery rather than tied to the static_sites table the cutover
// dropped. Any future Git-driven source plugin can reuse them.
// Per-request budget for outbound calls. Short enough that a malicious
// or stuck upstream cannot pin a worker for long; long enough for slow
// self-hosted Gitea instances to respond.
const discoveryTimeout = 15 * time.Second
// gitProviderRequest is the shared request body for the four Git
// discovery endpoints. Token is plaintext over HTTPS — the wizard has
// not yet persisted it, so there is nothing to decrypt server-side.
// Empty Provider triggers DetectProviderWithProbe.
type gitProviderRequest struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
AccessToken string `json:"access_token"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
Query string `json:"query"`
}
// gitIdentRe accepts Git owner / repo identifiers as the major hosts
// (GitHub, GitLab, Gitea/Forgejo) accept them: alphanumeric plus dot,
// underscore, hyphen. Rejecting other characters at the API boundary
// prevents `..` traversal and URL injection in the provider code that
// interpolates these segments into request paths.
var gitIdentRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
// gitBranchRe is more permissive than gitIdentRe: branches may contain
// `/` (e.g. `feature/foo`) but still cannot contain `..` or control
// characters. The check below pairs this regex with an explicit `..`
// reject so a `feature/../admin` value cannot slip through.
var gitBranchRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
// validateGitIdent guards owner / repo path segments at the boundary
// so the provider code can interpolate them with fmt.Sprintf without
// risking traversal. Empty input is reported with the supplied field
// name so the error message is actionable.
func validateGitIdent(field, value string) error {
v := strings.TrimSpace(value)
if v == "" {
return &apiError{msg: field + " is required"}
}
if !gitIdentRe.MatchString(v) {
return &apiError{msg: field + " contains invalid characters"}
}
return nil
}
// validateGitBranch is the branch-shaped variant of validateGitIdent.
// Branches legitimately contain `/`; the extra `..` reject covers the
// one traversal vector the regex still admits.
func validateGitBranch(value string) error {
v := strings.TrimSpace(value)
if v == "" {
return &apiError{msg: "branch is required"}
}
if strings.Contains(v, "..") {
return &apiError{msg: "branch contains invalid sequence '..'"}
}
if !gitBranchRe.MatchString(v) {
return &apiError{msg: "branch contains invalid characters"}
}
return nil
}
// apiError is a small typed error so handlers can distinguish a
// validation failure (→ 400) from any other error (→ 500/502). The
// type lives in this file because nothing outside discovery uses it
// yet — promote to response.go if other handlers need the same shape.
type apiError struct{ msg string }
func (e *apiError) Error() string { return e.msg }
// providerType normalizes the provider string into the typed enum used
// by staticsite.NewGitProvider. Empty input falls through to provider
// auto-detection inside NewGitProvider.
func (req gitProviderRequest) providerType() staticsite.ProviderType {
switch strings.ToLower(strings.TrimSpace(req.Provider)) {
case "github":
return staticsite.ProviderGitHub
case "gitlab":
return staticsite.ProviderGitLab
case "gitea":
return staticsite.ProviderGitea
default:
return ""
}
}
// newProvider constructs the GitProvider for the request, or writes a
// 400 to w and returns nil if the inputs are invalid. BaseURL is fully
// validated here (scheme + host shape); connect-time IP filtering is
// enforced inside the safe-HTTP transport the provider receives.
func (req gitProviderRequest) newProvider(w http.ResponseWriter) staticsite.GitProvider {
if err := staticsite.ValidateBaseURL(req.BaseURL); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return nil
}
provider, err := staticsite.NewGitProvider(req.providerType(), req.BaseURL, req.AccessToken)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return nil
}
return provider
}
// upstreamError logs the detailed upstream failure server-side and
// writes a generic 502 to the client. Echoing the raw error string
// would leak any access token reflected by a misconfigured or
// attacker-controlled upstream into the response body.
func upstreamError(w http.ResponseWriter, op string, err error) {
slog.Warn("discovery upstream call failed", "op", op, "error", err)
respondError(w, http.StatusBadGateway, "upstream git provider returned an error")
}
// detectGitProviderRequest is the body for POST /api/discovery/git/detect-provider.
type detectGitProviderRequest struct {
BaseURL string `json:"base_url"`
}
// detectGitProvider probes the base URL for known Git provider API
// signatures so the wizard can auto-fill the provider dropdown.
// POST /api/discovery/git/detect-provider.
func (s *Server) detectGitProvider(w http.ResponseWriter, r *http.Request) {
var req detectGitProviderRequest
if !decodeJSON(w, r, &req) {
return
}
if err := staticsite.ValidateBaseURL(req.BaseURL); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
defer cancel()
provider := staticsite.DetectProviderWithProbe(ctx, req.BaseURL)
respondJSON(w, http.StatusOK, map[string]string{"provider": string(provider)})
}
// testGitConnection verifies the configured base URL + token + repo
// reach the provider successfully so the wizard can fail fast.
// POST /api/discovery/git/test-connection.
func (s *Server) testGitConnection(w http.ResponseWriter, r *http.Request) {
var req gitProviderRequest
if !decodeJSON(w, r, &req) {
return
}
if err := validateGitIdent("repo_owner", req.RepoOwner); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateGitIdent("repo_name", req.RepoName); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
provider := req.newProvider(w)
if provider == nil {
return
}
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
defer cancel()
if err := provider.TestConnection(ctx, req.RepoOwner, req.RepoName); err != nil {
upstreamError(w, "test_connection", err)
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// listGitRepos returns repositories accessible with the supplied token,
// optionally filtered by a name query.
// POST /api/discovery/git/repos.
func (s *Server) listGitRepos(w http.ResponseWriter, r *http.Request) {
var req gitProviderRequest
if !decodeJSON(w, r, &req) {
return
}
provider := req.newProvider(w)
if provider == nil {
return
}
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
defer cancel()
repos, err := provider.ListRepos(ctx, req.Query)
if err != nil {
upstreamError(w, "list_repos", err)
return
}
if repos == nil {
repos = []staticsite.RepoInfo{}
}
respondJSON(w, http.StatusOK, repos)
}
// listGitBranches returns the branch list for a repo.
// POST /api/discovery/git/branches.
func (s *Server) listGitBranches(w http.ResponseWriter, r *http.Request) {
var req gitProviderRequest
if !decodeJSON(w, r, &req) {
return
}
if err := validateGitIdent("repo_owner", req.RepoOwner); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateGitIdent("repo_name", req.RepoName); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
provider := req.newProvider(w)
if provider == nil {
return
}
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
defer cancel()
branches, err := provider.ListBranches(ctx, req.RepoOwner, req.RepoName)
if err != nil {
upstreamError(w, "list_branches", err)
return
}
if branches == nil {
branches = []string{}
}
respondJSON(w, http.StatusOK, branches)
}
// listGitTree returns the full directory tree for a branch so the
// wizard can render the folder picker.
// POST /api/discovery/git/tree.
func (s *Server) listGitTree(w http.ResponseWriter, r *http.Request) {
var req gitProviderRequest
if !decodeJSON(w, r, &req) {
return
}
if err := validateGitIdent("repo_owner", req.RepoOwner); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateGitIdent("repo_name", req.RepoName); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateGitBranch(req.Branch); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
provider := req.newProvider(w)
if provider == nil {
return
}
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
defer cancel()
tree, err := provider.ListTree(ctx, req.RepoOwner, req.RepoName, req.Branch)
if err != nil {
upstreamError(w, "list_tree", err)
return
}
if tree == nil {
tree = []staticsite.FolderEntry{}
}
respondJSON(w, http.StatusOK, tree)
}
// imageConflict is a slim projection of Workload, scoped to what the
// /apps/new conflict dialog needs to render.
type imageConflict struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
AppID string `json:"app_id,omitempty"`
}
// listImageConflicts finds existing image-source workloads whose
// configured image matches the supplied ref, with or without tag.
// GET /api/discovery/image/conflicts?image=<ref>.
//
// Matching mirrors the legacy quickDeploy behavior: collide on
// repository-without-tag so nginx:1.25 surfaces nginx, nginx:latest,
// and nginx:1.26 as conflicts. This is intentionally permissive — the
// wizard surfaces matches but lets the operator decide.
func (s *Server) listImageConflicts(w http.ResponseWriter, r *http.Request) {
image := strings.TrimSpace(r.URL.Query().Get("image"))
if image == "" {
respondError(w, http.StatusBadRequest, "image query parameter is required")
return
}
target := stripImageTag(image)
if target == "" {
respondError(w, http.StatusBadRequest, "image is empty after tag strip")
return
}
workloads, err := s.store.ListWorkloads("")
if err != nil {
slog.Error("list workloads for conflict check", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
conflicts := []imageConflict{}
for _, wl := range workloads {
if wl.SourceKind != "image" {
continue
}
ref := imageRefFromSourceConfig(wl.SourceConfig)
if ref == "" {
continue
}
if stripImageTag(ref) != target {
continue
}
conflicts = append(conflicts, imageConflict{
ID: wl.ID,
Name: wl.Name,
Image: ref,
AppID: wl.AppID,
})
}
respondJSON(w, http.StatusOK, conflicts)
}
// stripImageTag returns the image reference with the trailing :tag
// removed, taking care to leave a registry port (e.g. registry:5000/foo)
// intact. Digest references (image@sha256:...) are returned unchanged.
func stripImageTag(ref string) string {
ref = strings.TrimSpace(ref)
if ref == "" {
return ""
}
// Digest reference: keep as-is so two pinned-by-digest workloads do
// not collide with each other or with tag-based refs unless the
// caller asks for exact-match (we currently don't).
if at := strings.Index(ref, "@"); at >= 0 {
return ref[:at]
}
// Strip a :tag suffix only when the colon is in the final path
// segment — earlier colons belong to a registry port.
lastSlash := strings.LastIndex(ref, "/")
tail := ref
if lastSlash >= 0 {
tail = ref[lastSlash+1:]
}
if colon := strings.LastIndex(tail, ":"); colon >= 0 {
// Only strip if the tag part looks like a tag (no slashes,
// non-empty). Otherwise leave alone. When lastSlash is -1 the
// arithmetic still yields the right cut point (-1 + 1 + colon
// == colon), so no special case is needed.
tag := tail[colon+1:]
if tag != "" && !strings.ContainsAny(tag, "/") {
return ref[:lastSlash+1+colon]
}
}
return ref
}
// imageRefFromSourceConfig extracts the "image" field from a workload's
// source_config JSON. Returns "" when the blob is missing, malformed,
// or has no image field — those workloads simply do not contribute to
// conflict detection.
func imageRefFromSourceConfig(raw string) string {
if raw == "" {
return ""
}
var cfg struct {
Image string `json:"image"`
}
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return ""
}
return strings.TrimSpace(cfg.Image)
}
+355
View File
@@ -0,0 +1,355 @@
package api
import (
"encoding/json"
"net/http"
"testing"
"github.com/alexei/tinyforge/internal/store"
)
// =============================================================================
// stripImageTag — pure helper, no fixtures needed
// =============================================================================
func TestStripImageTag(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"bare", "nginx", "nginx"},
{"tagged", "nginx:1.25", "nginx"},
{"latest", "nginx:latest", "nginx"},
{"owner_tagged", "library/nginx:1.25", "library/nginx"},
{"registry_tagged", "registry.example.com/owner/app:v1", "registry.example.com/owner/app"},
{"registry_port_no_tag", "registry.example.com:5000/owner/app", "registry.example.com:5000/owner/app"},
{"registry_port_with_tag", "registry.example.com:5000/owner/app:v1", "registry.example.com:5000/owner/app"},
{"digest", "nginx@sha256:abcd", "nginx"},
{"digest_with_owner", "library/nginx@sha256:abcd", "library/nginx"},
{"trailing_whitespace", " nginx:1.25 ", "nginx"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := stripImageTag(tc.in)
if got != tc.want {
t.Errorf("stripImageTag(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// =============================================================================
// imageRefFromSourceConfig — pure helper
// =============================================================================
func TestImageRefFromSourceConfig(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{"empty", "", ""},
{"malformed", "{not json", ""},
{"no_image_field", `{"port":8080}`, ""},
{"basic", `{"image":"nginx:1.25"}`, "nginx:1.25"},
{"whitespace_trim", `{"image":" nginx:1.25 "}`, "nginx:1.25"},
{"with_extras", `{"image":"nginx","port":8080,"env":{"K":"v"}}`, "nginx"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := imageRefFromSourceConfig(tc.raw)
if got != tc.want {
t.Errorf("imageRefFromSourceConfig(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
// =============================================================================
// GET /api/discovery/image/conflicts
// =============================================================================
// seedImageWorkload inserts a plugin-shaped image workload via the store
// directly. We bypass the API here so each test case starts with a
// known fixture independent of /api/workloads create-path behaviour.
func seedImageWorkload(t *testing.T, st *store.Store, name, imageRef string) {
t.Helper()
cfg, err := json.Marshal(map[string]any{"image": imageRef, "port": 8080})
if err != nil {
t.Fatalf("marshal source_config: %v", err)
}
if _, err := st.CreateWorkload(store.Workload{
Kind: string(store.WorkloadKindProject),
Name: name,
SourceKind: "image",
SourceConfig: string(cfg),
}); err != nil {
t.Fatalf("seed workload %q: %v", name, err)
}
}
func TestListImageConflicts_NoMatches_ReturnsEmpty(t *testing.T) {
e := newAPITestEnv(t)
seedImageWorkload(t, e.store, "alpha", "nginx:1.25")
seedImageWorkload(t, e.store, "beta", "registry.example.com/owner/web:v2")
resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts?image=postgres:16", nil)
if resp.StatusCode != http.StatusOK {
_ = decodeEnvelope(t, resp, nil)
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got []imageConflict
if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
t.Fatalf("envelope error: %q", errMsg)
}
if len(got) != 0 {
t.Errorf("expected 0 conflicts, got %d: %+v", len(got), got)
}
}
func TestListImageConflicts_TagMismatch_StillCollides(t *testing.T) {
// The legacy quickDeploy collided on repo without tag so nginx:1.25
// surfaces nginx:1.26 — this preserves that behaviour.
e := newAPITestEnv(t)
seedImageWorkload(t, e.store, "nginx-prod", "nginx:1.25")
seedImageWorkload(t, e.store, "nginx-latest", "nginx:latest")
resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts?image=nginx:1.26", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got []imageConflict
if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
t.Fatalf("envelope error: %q", errMsg)
}
if len(got) != 2 {
t.Fatalf("expected 2 conflicts, got %d: %+v", len(got), got)
}
names := map[string]bool{}
for _, c := range got {
names[c.Name] = true
}
if !names["nginx-prod"] || !names["nginx-latest"] {
t.Errorf("expected both nginx-prod and nginx-latest in conflicts, got %+v", got)
}
}
func TestListImageConflicts_RegistryPortPreserved(t *testing.T) {
// Make sure stripImageTag preserves a registry port in the host
// segment — registry.example.com:5000/owner/app:v1 must collide
// only with refs whose repo is registry.example.com:5000/owner/app.
e := newAPITestEnv(t)
seedImageWorkload(t, e.store, "with-port", "registry.example.com:5000/owner/app:v1")
seedImageWorkload(t, e.store, "no-port", "owner/app:v1")
resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts?image=registry.example.com:5000/owner/app:v2", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got []imageConflict
if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
t.Fatalf("envelope error: %q", errMsg)
}
if len(got) != 1 || got[0].Name != "with-port" {
t.Errorf("expected sole conflict on with-port, got %+v", got)
}
}
func TestListImageConflicts_NonImageSourceIgnored(t *testing.T) {
// Static-source workloads must never appear in image conflicts even
// if their JSON happens to contain a stray "image" key — guard
// against source_kind != "image" rows.
e := newAPITestEnv(t)
if _, err := e.store.CreateWorkload(store.Workload{
Kind: string(store.WorkloadKindProject),
Name: "static-with-image-key",
SourceKind: "static",
SourceConfig: `{"image":"nginx:1.25","provider":"gitea"}`,
}); err != nil {
t.Fatalf("seed static workload: %v", err)
}
resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts?image=nginx:1.25", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got []imageConflict
if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
t.Fatalf("envelope error: %q", errMsg)
}
if len(got) != 0 {
t.Errorf("expected 0 conflicts (static source filtered out), got %+v", got)
}
}
func TestListImageConflicts_MissingImageParam_400(t *testing.T) {
e := newAPITestEnv(t)
resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts", nil)
if resp.StatusCode != http.StatusBadRequest {
_ = decodeEnvelope(t, resp, nil)
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
// =============================================================================
// POST /api/discovery/git/* — input validation
// =============================================================================
//
// These tests only assert request-shape validation. The provider
// implementations themselves are exercised by their own tests in
// internal/staticsite; we don't reach upstream Git in unit tests.
func TestDetectGitProvider_MissingBaseURL_400(t *testing.T) {
e := newAPITestEnv(t)
resp := e.do(t, http.MethodPost, "/api/discovery/git/detect-provider", map[string]string{})
if resp.StatusCode != http.StatusBadRequest {
_ = decodeEnvelope(t, resp, nil)
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestTestGitConnection_MissingRepo_400(t *testing.T) {
e := newAPITestEnv(t)
resp := e.do(t, http.MethodPost, "/api/discovery/git/test-connection", map[string]string{
"base_url": "https://git.example.com",
})
if resp.StatusCode != http.StatusBadRequest {
_ = decodeEnvelope(t, resp, nil)
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestListGitBranches_MissingRepo_400(t *testing.T) {
e := newAPITestEnv(t)
resp := e.do(t, http.MethodPost, "/api/discovery/git/branches", map[string]string{
"base_url": "https://git.example.com",
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestListGitTree_MissingBranch_400(t *testing.T) {
e := newAPITestEnv(t)
resp := e.do(t, http.MethodPost, "/api/discovery/git/tree", map[string]string{
"base_url": "https://git.example.com",
"repo_owner": "owner",
"repo_name": "repo",
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestListGitRepos_MissingBaseURL_400(t *testing.T) {
e := newAPITestEnv(t)
resp := e.do(t, http.MethodPost, "/api/discovery/git/repos", map[string]string{})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
// =============================================================================
// Validators added during security hardening — boundary checks the
// providers depend on for safe URL interpolation.
// =============================================================================
func TestValidateGitIdent(t *testing.T) {
cases := []struct {
name string
input string
wantError bool
}{
{"ok_simple", "owner", false},
{"ok_with_dash", "my-org", false},
{"ok_with_dot", "user.name", false},
{"ok_with_underscore", "my_repo", false},
{"empty", "", true},
{"whitespace_only", " ", true},
{"leading_dot", ".hidden", true},
{"leading_dash", "-flag", true},
{"slash", "owner/repo", true},
{"traversal", "..", true},
{"path_traversal", "../admin", true},
{"with_space", "my org", true},
{"with_special", "owner;rm", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateGitIdent("test", tc.input)
if tc.wantError && err == nil {
t.Errorf("validateGitIdent(%q) = nil, want error", tc.input)
}
if !tc.wantError && err != nil {
t.Errorf("validateGitIdent(%q) = %v, want nil", tc.input, err)
}
})
}
}
func TestValidateGitBranch(t *testing.T) {
cases := []struct {
name string
input string
wantError bool
}{
{"ok_main", "main", false},
{"ok_master", "master", false},
{"ok_with_slash", "feature/foo", false},
{"ok_release_tag", "release/v1.2.3", false},
{"empty", "", true},
{"traversal", "feature/..", true},
{"hidden_traversal", "feature/../admin", true},
{"leading_dash", "-flag", true},
{"with_space", "feature/my branch", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateGitBranch(tc.input)
if tc.wantError && err == nil {
t.Errorf("validateGitBranch(%q) = nil, want error", tc.input)
}
if !tc.wantError && err != nil {
t.Errorf("validateGitBranch(%q) = %v, want nil", tc.input, err)
}
})
}
}
func TestTestGitConnection_InvalidOwner_400(t *testing.T) {
e := newAPITestEnv(t)
resp := e.do(t, http.MethodPost, "/api/discovery/git/test-connection", map[string]string{
"base_url": "https://git.example.com",
"repo_owner": "../admin",
"repo_name": "repo",
})
if resp.StatusCode != http.StatusBadRequest {
_ = decodeEnvelope(t, resp, nil)
t.Fatalf("status = %d, want 400 (traversal rejected)", resp.StatusCode)
}
}
func TestListGitTree_InvalidBranch_400(t *testing.T) {
e := newAPITestEnv(t)
resp := e.do(t, http.MethodPost, "/api/discovery/git/tree", map[string]string{
"base_url": "https://git.example.com",
"repo_owner": "owner",
"repo_name": "repo",
"branch": "feature/../admin",
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (branch traversal rejected)", resp.StatusCode)
}
}
func TestDetectGitProvider_InvalidScheme_400(t *testing.T) {
e := newAPITestEnv(t)
resp := e.do(t, http.MethodPost, "/api/discovery/git/detect-provider", map[string]string{
"base_url": "ftp://git.example.com",
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (non-http scheme rejected)", resp.StatusCode)
}
}
+22
View File
@@ -219,6 +219,21 @@ func (s *Server) Router() chi.Router {
r.Get("/hooks/kinds/{kind}/schema", s.getHookKindSchema)
r.With(auth.AdminOnly).Post("/hooks/generic", s.dispatchGeneric)
// Workload-creation discovery helpers: provider probe,
// connection test, repo / branch / tree browsers, and
// image-source conflict detection. Admin-gated because
// they accept an access token + can enumerate other
// workloads' images.
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
r.Post("/discovery/git/detect-provider", s.detectGitProvider)
r.Post("/discovery/git/test-connection", s.testGitConnection)
r.Post("/discovery/git/repos", s.listGitRepos)
r.Post("/discovery/git/branches", s.listGitBranches)
r.Post("/discovery/git/tree", s.listGitTree)
r.Get("/discovery/image/conflicts", s.listImageConflicts)
})
// Read-only endpoints (any authenticated user).
r.Get("/health", s.getHealth)
r.Get("/auth/me", s.currentUser)
@@ -263,8 +278,15 @@ func (s *Server) Router() chi.Router {
r.With(auth.AdminOnly).Patch("/app", s.updateWorkloadAppID)
r.With(auth.AdminOnly).Put("/plugin", s.updatePluginWorkload)
r.With(auth.AdminOnly).Post("/deploy", s.deployPluginWorkload)
r.With(auth.AdminOnly).Post("/stop", s.stopPluginWorkload)
r.With(auth.AdminOnly).Post("/start", s.startPluginWorkload)
r.With(auth.AdminOnly).Delete("/", s.deletePluginWorkload)
// Runtime view: per-source persisted state + storage usage.
// Read-only; safe for any authenticated user.
r.Get("/runtime-state", s.getWorkloadRuntimeState)
r.Get("/storage", s.getWorkloadStorage)
// Per-workload env vars. Listing open to authenticated readers;
// mutations admin-gated. Encrypted values are write-only after store.
r.Get("/env", s.listWorkloadEnv)
+377
View File
@@ -0,0 +1,377 @@
package api
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
)
// storageProbeCache memoizes the `du` result per workload for a short
// window so a tight polling loop on /storage cannot turn into one
// `docker exec du` per request. The TTL is intentionally short — the
// panel is a coarse usage indicator, not a real-time meter.
var (
storageProbeCacheTTL = 30 * time.Second
storageProbeMu sync.Mutex
storageProbeCache = map[string]storageProbeEntry{}
)
type storageProbeEntry struct {
at time.Time
usage int64
probeOk bool
}
// Runtime endpoints surface what the legacy /api/sites/* surface used
// to expose on the static-site detail page: the last commit SHA / last
// sync timestamp / status persisted by the static plugin in
// containers.extra_json, the data-volume disk usage, and stop / start
// controls that don't require a full re-deploy.
//
// The handlers are deliberately decoupled from the plugin interface so
// they work uniformly across source kinds: stop/start operate on the
// Docker container IDs stored in the containers index regardless of
// kind; runtime-state reads what the source persisted (currently only
// "static" writes a structured blob); storage usage is static-only
// today but the endpoint shape allows future sources to opt in.
// runtimeStatePayload is the JSON shape returned by
// GET /api/workloads/{id}/runtime-state.
//
// SourceKind is always present so the UI can decide whether to render
// the static-specific fields (last_commit_sha, last_sync_at, ...). The
// container-row fields (ContainerID, State) come from the canonical
// containers row that the static plugin maintains under the
// deterministic ID `<workloadID>:site`.
type runtimeStatePayload struct {
SourceKind string `json:"source_kind"`
HasState bool `json:"has_state"`
ContainerID string `json:"container_id,omitempty"`
State string `json:"state,omitempty"`
Status string `json:"status,omitempty"`
LastCommitSHA string `json:"last_commit_sha,omitempty"`
LastSyncAt string `json:"last_sync_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
// getWorkloadRuntimeState handles GET /api/workloads/{id}/runtime-state.
// Reads the typed state the static plugin writes into containers.extra_json
// (see internal/workload/plugin/source/static/state.go). Non-static
// source kinds return SourceKind + HasState=false; the panel hides
// itself rather than the endpoint 404ing.
func (s *Server) getWorkloadRuntimeState(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workload, err := s.store.GetWorkloadByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
slog.Error("get workload for runtime-state", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
payload := runtimeStatePayload{SourceKind: workload.SourceKind}
if workload.SourceKind != "static" {
respondJSON(w, http.StatusOK, payload)
return
}
// The static plugin owns one container row per workload at the
// deterministic ID <workloadID>:site. A missing row means the
// workload has never been deployed — return HasState=false so the
// UI can prompt the operator to deploy.
row, err := s.store.GetContainerByID(id + ":site")
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondJSON(w, http.StatusOK, payload)
return
}
slog.Error("get container row for runtime-state", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
payload.HasState = true
payload.ContainerID = row.ContainerID
payload.State = row.State
// extra_json is the source of truth for the typed runtime fields.
// A decode failure is non-fatal: we still report container_id /
// state so the UI is useful, just without the sync history.
//
// No mutex here even though the writer (state.go saveState) holds
// a per-workload mutex on read-modify-write — SQLite returns the
// ExtraJSON column as a fully-materialized string from a single
// SELECT, so the reader sees either the pre- or post-write snapshot
// atomically. There is no torn read to defend against.
if row.ExtraJSON != "" && row.ExtraJSON != "{}" {
var st struct {
Status string `json:"status"`
LastCommitSHA string `json:"last_commit_sha"`
LastSyncAt string `json:"last_sync_at"`
LastError string `json:"last_error"`
}
if err := json.Unmarshal([]byte(row.ExtraJSON), &st); err != nil {
slog.Debug("decode extra_json for runtime-state", "workload", id, "error", err)
} else {
payload.Status = st.Status
payload.LastCommitSHA = st.LastCommitSHA
payload.LastSyncAt = st.LastSyncAt
payload.LastError = st.LastError
}
}
respondJSON(w, http.StatusOK, payload)
}
// storageUsagePayload is the JSON shape returned by
// GET /api/workloads/{id}/storage. ProbeError surfaces a non-fatal
// failure to compute used_bytes (du timed out, exec returned non-zero,
// etc.) so the UI can render "usage unavailable" instead of an
// always-zero number.
type storageUsagePayload struct {
SourceKind string `json:"source_kind"`
Enabled bool `json:"enabled"`
UsedBytes int64 `json:"used_bytes"`
LimitMB int `json:"limit_mb,omitempty"`
ProbeError string `json:"probe_error,omitempty"`
}
// getWorkloadStorage handles GET /api/workloads/{id}/storage.
//
// For static workloads with storage enabled, execs `du -sb /app/data`
// inside the running container to compute the data volume's footprint.
// For workloads without storage (or non-static source kinds), returns
// Enabled=false and zero usage so the UI can hide the panel.
func (s *Server) getWorkloadStorage(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workload, err := s.store.GetWorkloadByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
slog.Error("get workload for storage", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
payload := storageUsagePayload{SourceKind: workload.SourceKind}
if workload.SourceKind != "static" {
respondJSON(w, http.StatusOK, payload)
return
}
// Decode storage knobs from source_config. Missing / malformed
// blobs are treated as storage-disabled rather than erroring; the
// validator that runs on workload create already rejects invalid
// configs at the source.
var cfg struct {
StorageEnabled bool `json:"storage_enabled"`
StorageLimitMB int `json:"storage_limit_mb"`
}
if workload.SourceConfig != "" {
if err := json.Unmarshal([]byte(workload.SourceConfig), &cfg); err != nil {
// Validator catches malformed configs at create-time, so
// this is unexpected — log so a drifted row is traceable.
slog.Debug("decode source_config for storage", "workload", id, "error", err)
}
}
payload.Enabled = cfg.StorageEnabled
payload.LimitMB = cfg.StorageLimitMB
if !cfg.StorageEnabled || s.docker == nil {
respondJSON(w, http.StatusOK, payload)
return
}
// Cache hit short-circuits the docker exec entirely so a polling
// frontend cannot turn this into a per-request `du`.
storageProbeMu.Lock()
if cached, ok := storageProbeCache[id]; ok && time.Since(cached.at) < storageProbeCacheTTL {
storageProbeMu.Unlock()
payload.UsedBytes = cached.usage
if !cached.probeOk {
payload.ProbeError = "storage probe unavailable"
}
respondJSON(w, http.StatusOK, payload)
return
}
storageProbeMu.Unlock()
// Find the running container. The static plugin's canonical row is
// at <id>:site; we also tolerate workloads whose plugin produced
// multiple containers by scanning the index.
containers, err := s.store.ListContainersByWorkload(id)
if err != nil {
slog.Error("list containers for storage", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
probeOk := false
for _, c := range containers {
if c.ContainerID == "" {
continue
}
// 15s budget — `du` on a Hugo-style `public/` with tens of
// thousands of files and a cold page cache can run several
// seconds. The cache above keeps the amortized cost small.
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
usage, err := s.docker.InspectSiteStorageUsage(ctx, c.ContainerID)
cancel()
if err != nil {
slog.Debug("storage usage probe failed", "workload", id, "container", c.ContainerID, "error", err)
continue
}
payload.UsedBytes = usage.UsedBytes
probeOk = true
break
}
if !probeOk {
payload.ProbeError = "storage probe unavailable"
}
storageProbeMu.Lock()
storageProbeCache[id] = storageProbeEntry{at: time.Now(), usage: payload.UsedBytes, probeOk: probeOk}
storageProbeMu.Unlock()
respondJSON(w, http.StatusOK, payload)
}
// stopStartResult is the JSON shape returned by both stop and start
// handlers — counts so the UI can show "1 of 2 containers stopped".
type stopStartResult struct {
Touched int `json:"touched"`
Failed int `json:"failed"`
}
// stopPluginWorkload handles POST /api/workloads/{id}/stop.
//
// Stops every container row belonging to the workload via Docker. Does
// not remove containers or update runtime state — the reconciler
// (internal/workload/plugin/source/static/reconcile.go) flips state to
// "stopped"/"failed" on its next pass, and the user can immediately see
// the new Docker state via /api/workloads/{id}/containers.
//
// Returning 200 with a `{touched, failed}` envelope even on partial
// failures so the UI can surface "2 of 3 stopped" rather than treating
// the whole call as red.
func (s *Server) stopPluginWorkload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
slog.Error("get workload for stop", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if s.docker == nil {
respondError(w, http.StatusServiceUnavailable, "docker client unavailable")
return
}
containers, err := s.store.ListContainersByWorkload(id)
if err != nil {
slog.Error("list containers for stop", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
result := stopStartResult{}
for _, c := range containers {
if c.ContainerID == "" {
continue
}
// 30s per-container ctx budget; the third arg to StopContainer
// is the in-container SIGTERM grace period before SIGKILL.
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
if err := s.docker.StopContainer(ctx, c.ContainerID, 10); err != nil {
slog.Warn("stop container failed", "workload", id, "container", c.ContainerID, "error", err)
result.Failed++
} else {
result.Touched++
}
cancel()
}
if result.Touched == 0 && result.Failed == 0 {
// No live container row to act on — distinguish from a successful
// stop of zero containers so the UI can show "nothing to stop"
// rather than a misleading green toast.
respondError(w, http.StatusConflict, "no running container to stop")
return
}
if result.Touched == 0 && result.Failed > 0 {
respondError(w, http.StatusBadGateway, "all containers failed to stop")
return
}
respondJSON(w, http.StatusOK, result)
}
// startPluginWorkload handles POST /api/workloads/{id}/start.
//
// Calls `docker start` on every container row belonging to the
// workload. Does not redeploy or recreate; if the container has been
// removed externally, start returns an error and the operator should
// click Deploy. Same partial-failure envelope as stop.
func (s *Server) startPluginWorkload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
slog.Error("get workload for start", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if s.docker == nil {
respondError(w, http.StatusServiceUnavailable, "docker client unavailable")
return
}
containers, err := s.store.ListContainersByWorkload(id)
if err != nil {
slog.Error("list containers for start", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
result := stopStartResult{}
for _, c := range containers {
if c.ContainerID == "" {
continue
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
if err := s.docker.StartContainer(ctx, c.ContainerID); err != nil {
slog.Warn("start container failed", "workload", id, "container", c.ContainerID, "error", err)
result.Failed++
} else {
result.Touched++
}
cancel()
}
if result.Touched == 0 && result.Failed == 0 {
// No persisted container — deploy first to materialize one.
respondError(w, http.StatusConflict, "no container to start; deploy first")
return
}
if result.Touched == 0 && result.Failed > 0 {
respondError(w, http.StatusBadGateway, "all containers failed to start")
return
}
respondJSON(w, http.StatusOK, result)
}
+295
View File
@@ -0,0 +1,295 @@
package api
import (
"encoding/json"
"net/http"
"testing"
"github.com/alexei/tinyforge/internal/store"
)
// =============================================================================
// GET /api/workloads/{id}/runtime-state
// =============================================================================
func TestGetWorkloadRuntimeState_NotFound_404(t *testing.T) {
e := newAPITestEnv(t)
resp := e.do(t, http.MethodGet, "/api/workloads/does-not-exist/runtime-state", nil)
if resp.StatusCode != http.StatusNotFound {
_ = decodeEnvelope(t, resp, nil)
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
func TestGetWorkloadRuntimeState_NonStaticSource_ReturnsBareKind(t *testing.T) {
e := newAPITestEnv(t)
wl, err := e.store.CreateWorkload(store.Workload{
Kind: string(store.WorkloadKindProject),
Name: "img-app",
SourceKind: "image",
SourceConfig: `{"image":"nginx:1.25"}`,
})
if err != nil {
t.Fatalf("seed: %v", err)
}
resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got runtimeStatePayload
if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
t.Fatalf("envelope error: %q", errMsg)
}
if got.SourceKind != "image" {
t.Errorf("SourceKind = %q, want image", got.SourceKind)
}
if got.HasState {
t.Errorf("HasState = true, want false for non-static source")
}
}
func TestGetWorkloadRuntimeState_StaticSourceNeverDeployed_HasStateFalse(t *testing.T) {
e := newAPITestEnv(t)
wl, err := e.store.CreateWorkload(store.Workload{
Kind: string(store.WorkloadKindSite),
Name: "pages",
SourceKind: "static",
SourceConfig: `{"provider":"gitea"}`,
})
if err != nil {
t.Fatalf("seed: %v", err)
}
resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got runtimeStatePayload
if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
t.Fatalf("envelope error: %q", errMsg)
}
if got.HasState {
t.Errorf("HasState = true, want false (never deployed)")
}
}
func TestGetWorkloadRuntimeState_StaticSourceDeployed_DecodesExtraJSON(t *testing.T) {
e := newAPITestEnv(t)
wl, err := e.store.CreateWorkload(store.Workload{
Kind: string(store.WorkloadKindSite),
Name: "pages",
SourceKind: "static",
SourceConfig: `{"provider":"gitea"}`,
})
if err != nil {
t.Fatalf("seed workload: %v", err)
}
extra, _ := json.Marshal(map[string]any{
"status": "deployed",
"last_commit_sha": "abc1234",
"last_sync_at": "2026-05-16T10:00:00Z",
"last_error": "",
// An unknown key — confirms decoding is lenient.
"unknown_future_field": "ignored",
})
if err := e.store.UpsertContainer(store.Container{
ID: wl.ID + ":site",
WorkloadID: wl.ID,
WorkloadKind: string(store.WorkloadKindSite),
Host: "local",
ContainerID: "abcdef1234",
State: "running",
ExtraJSON: string(extra),
}); err != nil {
t.Fatalf("seed container: %v", err)
}
resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got runtimeStatePayload
if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
t.Fatalf("envelope error: %q", errMsg)
}
if !got.HasState {
t.Fatalf("HasState = false, want true")
}
if got.ContainerID != "abcdef1234" || got.State != "running" {
t.Errorf("container fields = (%q,%q), want (abcdef1234, running)", got.ContainerID, got.State)
}
if got.Status != "deployed" || got.LastCommitSHA != "abc1234" || got.LastSyncAt == "" {
t.Errorf("runtime fields = %+v, want deployed/abc1234/non-empty", got)
}
}
func TestGetWorkloadRuntimeState_MalformedExtraJSON_ReturnsContainerFieldsOnly(t *testing.T) {
e := newAPITestEnv(t)
wl, _ := e.store.CreateWorkload(store.Workload{
Kind: string(store.WorkloadKindSite),
Name: "pages",
SourceKind: "static",
SourceConfig: `{"provider":"gitea"}`,
})
if err := e.store.UpsertContainer(store.Container{
ID: wl.ID + ":site",
WorkloadID: wl.ID,
WorkloadKind: string(store.WorkloadKindSite),
Host: "local",
ContainerID: "abc",
State: "running",
ExtraJSON: `{this is not json`,
}); err != nil {
t.Fatalf("seed: %v", err)
}
resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (decode is non-fatal)", resp.StatusCode)
}
var got runtimeStatePayload
_ = decodeEnvelope(t, resp, &got)
if !got.HasState || got.ContainerID != "abc" {
t.Errorf("expected HasState + container id present, got %+v", got)
}
if got.Status != "" || got.LastCommitSHA != "" {
t.Errorf("expected typed fields empty on decode failure, got %+v", got)
}
}
// =============================================================================
// GET /api/workloads/{id}/storage
// =============================================================================
func TestGetWorkloadStorage_NonStaticSource_EmptyPayload(t *testing.T) {
e := newAPITestEnv(t)
wl, _ := e.store.CreateWorkload(store.Workload{
Kind: string(store.WorkloadKindProject),
Name: "img-app",
SourceKind: "image",
SourceConfig: `{"image":"nginx"}`,
})
resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got storageUsagePayload
_ = decodeEnvelope(t, resp, &got)
if got.Enabled || got.UsedBytes != 0 {
t.Errorf("expected empty payload for non-static, got %+v", got)
}
}
func TestGetWorkloadStorage_StaticDisabled_ReturnsLimitButNoUsage(t *testing.T) {
e := newAPITestEnv(t)
wl, _ := e.store.CreateWorkload(store.Workload{
Kind: string(store.WorkloadKindSite),
Name: "pages",
SourceKind: "static",
SourceConfig: `{"provider":"gitea","storage_enabled":false,"storage_limit_mb":0}`,
})
resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got storageUsagePayload
_ = decodeEnvelope(t, resp, &got)
if got.Enabled {
t.Errorf("Enabled = true, want false")
}
}
func TestGetWorkloadStorage_StaticEnabledNoDockerClient_ReturnsZeroUsage(t *testing.T) {
// docker is nil in the test env — the handler must still return
// a valid payload (enabled + limit) without panicking.
e := newAPITestEnv(t)
wl, _ := e.store.CreateWorkload(store.Workload{
Kind: string(store.WorkloadKindSite),
Name: "pages",
SourceKind: "static",
SourceConfig: `{"provider":"gitea","storage_enabled":true,"storage_limit_mb":512}`,
})
resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got storageUsagePayload
_ = decodeEnvelope(t, resp, &got)
if !got.Enabled || got.LimitMB != 512 {
t.Errorf("got %+v, want enabled=true limit=512", got)
}
if got.UsedBytes != 0 {
t.Errorf("UsedBytes = %d, want 0 (no docker client)", got.UsedBytes)
}
}
// =============================================================================
// POST /api/workloads/{id}/{stop,start}
// =============================================================================
func TestStopPluginWorkload_NotFound_404(t *testing.T) {
e := newAPITestEnv(t)
resp := e.do(t, http.MethodPost, "/api/workloads/missing/stop", nil)
if resp.StatusCode != http.StatusNotFound {
_ = decodeEnvelope(t, resp, nil)
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
func TestStopPluginWorkload_NoDockerClient_503(t *testing.T) {
// The test env passes a nil dockerClient. The handler must refuse
// with 503 rather than panicking on a nil deref.
e := newAPITestEnv(t)
wl, _ := e.store.CreateWorkload(store.Workload{
Kind: string(store.WorkloadKindSite), Name: "x", SourceKind: "static",
})
resp := e.do(t, http.MethodPost, "/api/workloads/"+wl.ID+"/stop", nil)
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", resp.StatusCode)
}
}
func TestStartPluginWorkload_NoDockerClient_503(t *testing.T) {
e := newAPITestEnv(t)
wl, _ := e.store.CreateWorkload(store.Workload{
Kind: string(store.WorkloadKindSite), Name: "x", SourceKind: "static",
})
resp := e.do(t, http.MethodPost, "/api/workloads/"+wl.ID+"/start", nil)
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", resp.StatusCode)
}
}
// =============================================================================
// stripImageTag-style behaviour assertions for the storage probe cache —
// memoization wins on the second call within the TTL window.
// =============================================================================
func TestStorageProbeCache_SecondCallSkipsProbe(t *testing.T) {
// Clear the cache so a different test order doesn't pre-warm.
storageProbeMu.Lock()
storageProbeCache = map[string]storageProbeEntry{}
storageProbeMu.Unlock()
e := newAPITestEnv(t)
wl, _ := e.store.CreateWorkload(store.Workload{
Kind: string(store.WorkloadKindSite),
Name: "pages",
SourceKind: "static",
SourceConfig: `{"provider":"gitea","storage_enabled":true,"storage_limit_mb":256}`,
})
// First call populates the cache (docker is nil, so it short-circuits
// before the probe and never writes a cache entry — this test is
// asserting that the no-docker path is well-behaved).
resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("first call status = %d, want 200", resp.StatusCode)
}
resp.Body.Close()
// Second call should also return 200 — the path is idempotent.
resp = e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("second call status = %d, want 200", resp.StatusCode)
}
resp.Body.Close()
}