diff --git a/docs/CODEMAPS/INDEX.md b/docs/CODEMAPS/INDEX.md index 435a50c..2843492 100644 --- a/docs/CODEMAPS/INDEX.md +++ b/docs/CODEMAPS/INDEX.md @@ -6,9 +6,8 @@ This directory contains architectural maps of key Tinyforge subsystems. Each cod ## Codemaps -| Area | File | Focus | -|------|------|-------| -| **Workload Plugin** | [`workload-plugin.md`](./workload-plugin.md) | Source × Trigger plugin contracts; registry lookups; webhook fan-out; how to add new kinds | +- **[Workload Plugin](./workload-plugin.md)** — Source × Trigger plugin contracts; registry lookups; webhook fan-out; how to add new kinds. +- **[Discovery & Runtime API](./discovery-and-runtime.md)** — `/api/discovery/*` helpers (Git provider probe, repo/branch/tree pickers, image conflicts); `/api/workloads/{id}/runtime-state` + `/storage` + `/stop` + `/start`; SSRF-safe HTTP client in `internal/staticsite`. ## Cross-References diff --git a/docs/CODEMAPS/discovery-and-runtime.md b/docs/CODEMAPS/discovery-and-runtime.md new file mode 100644 index 0000000..f93d3cf --- /dev/null +++ b/docs/CODEMAPS/discovery-and-runtime.md @@ -0,0 +1,88 @@ +# Discovery & Runtime API — Codemap + +**Last Updated:** 2026-05-16 + +Surfaces added during the static-site discovery restoration + workload runtime panel work. All endpoints sit inside the existing `/api` group (auth-middleware enforced); admin-gated routes are noted per-endpoint. + +## Files + +### Backend + +- [`internal/api/discovery.go`](../../internal/api/discovery.go) — six admin-gated handlers wrapping `staticsite.GitProvider` + an image-source conflict scanner. +- [`internal/api/workload_runtime.go`](../../internal/api/workload_runtime.go) — runtime-state read, storage-usage probe (with 30s in-memory cache), and stop/start mutation handlers. +- [`internal/staticsite/safehttp.go`](../../internal/staticsite/safehttp.go) — `NewSafeHTTPClient` + `ValidateBaseURL` + `blockReason` (loopback / link-local / multicast / unspecified blocked at dial time; RFC1918 / ULA explicitly allowed). +- [`internal/api/discovery_test.go`](../../internal/api/discovery_test.go) — 26 table cases (image-tag stripping, source-config decoding, conflict scenarios, validator boundaries, scheme rejection). +- [`internal/api/workload_runtime_test.go`](../../internal/api/workload_runtime_test.go) — 14 cases (404, source-kind branching, never-deployed path, malformed extra-json, nil-docker-client 503, probe cache short-circuit). +- [`internal/staticsite/safehttp_test.go`](../../internal/staticsite/safehttp_test.go) — 16 cases (URL validation matrix, block-reason policy matrix, live dial against loopback + AWS metadata literals). + +### Frontend + +- [`web/src/lib/api.ts`](../../web/src/lib/api.ts) — typed wrappers for every endpoint, signal-aware (`AbortSignal` threaded through `post()`); `ApiError` exported so callers can narrow on `e.status`. +- [`web/src/routes/apps/new/+page.svelte`](../../web/src/routes/apps/new/+page.svelte) — static-form discovery controls (auto-detect provider, test connection, repo / branch / folder pickers, Deno auto-detect); image-form conflict panel + Inspect button. +- [`web/src/routes/apps/[id]/+page.svelte`](../../web/src/routes/apps/[id]/+page.svelte) — runtime-state panel, storage panel, Stop / Start / Open-site toolbar; live-state badge in hero; ContainerStats panel; webhook bindings card; responsive toolbar overflow. + +## Endpoint reference + +### Discovery (admin-only) + +| Method | Path | Returns | +| ------ | ------------------------------------------ | -------------------------------- | +| POST | `/api/discovery/git/detect-provider` | `{provider: DetectedGitProvider}`| +| POST | `/api/discovery/git/test-connection` | `{status: "ok"}` or 502 | +| POST | `/api/discovery/git/repos` | `RepoInfo[]` | +| POST | `/api/discovery/git/branches` | `string[]` | +| POST | `/api/discovery/git/tree` | `FolderEntry[]` | +| GET | `/api/discovery/image/conflicts?image=...` | `ImageConflict[]` | + +All Git endpoints accept the shared `gitProviderRequest` shape: `{provider, base_url, access_token, repo_owner, repo_name, branch, query}`. Token is plaintext over HTTPS and never persisted server-side. `provider` may be empty to trigger `staticsite.DetectProviderWithProbe`. + +### Workload runtime + +| Method | Path | Auth | Returns | +| ------ | ------------------------------------- | ------------ | ------------------------------- | +| GET | `/api/workloads/{id}/runtime-state` | Any auth | `WorkloadRuntimeState` | +| GET | `/api/workloads/{id}/storage` | Any auth | `WorkloadStorageUsage` | +| POST | `/api/workloads/{id}/stop` | Admin | `{touched, failed}` / 409 / 502 | +| POST | `/api/workloads/{id}/start` | Admin | `{touched, failed}` / 409 / 502 | + +`runtime-state` decodes `containers.extra_json` for `:site` (the deterministic container row the static plugin maintains). Returns `{source_kind, has_state: false}` for non-static workloads or never-deployed static workloads. + +`storage` returns `{enabled: false}` for non-static or storage-disabled workloads. When enabled, execs `du -sb /app/data` (15s budget) via `docker.InspectSiteStorageUsage`. Results memoized for 30s in the `storageProbeCache` package-level map. + +`stop` / `start` iterate `store.ListContainersByWorkload` and call `docker.StopContainer(ctx, id, 10)` / `StartContainer`. Returns 409 when no container row exists ("nothing to act on"), 502 when every container failed, 200 with `{touched, failed}` counts otherwise. + +## Security posture + +- **SSRF defense** — every outbound HTTP call from `staticsite/{gitea,github,gitlab}_provider.go` and the discovery probe uses `NewSafeHTTPClient`. The `DialContext` re-resolves the host and refuses loopback / link-local / multicast / unspecified addresses. RFC1918 + ULA are intentionally allowed (self-hosted Gitea on LAN is the dominant deployment pattern). +- **Identifier validation** — `validateGitIdent` (regex `^[A-Za-z0-9][A-Za-z0-9._-]*$`) and `validateGitBranch` (allows `/`, rejects `..`) run at the API boundary so provider URL interpolation cannot be hijacked. +- **Error scrubbing** — upstream Git provider errors are never echoed verbatim. `upstreamError(w, op, err)` logs the detail server-side and returns a generic 502 to the client (mitigates token-reflection-in-error-page). +- **Token handling** — tokens are plaintext in request bodies (HTTPS assumed) and never persisted. Discovery endpoints accept them per-call; nothing is stored. +- **Auth model** — read endpoints (`runtime-state`, `storage`) are open to any authenticated user; mutation endpoints (`stop`, `start`, every `/discovery/*` POST/GET) are admin-only. + +## Frontend integration patterns + +- All long-running requests accept an optional `AbortSignal` and are cancelled on `onDestroy` via per-call AbortController plus a sequence token (`reqSeq`) so a slow earlier response cannot overwrite a faster later one. Mirror this pattern when adding new probes — see `loadRuntimeState` / `loadStorage` / `inspectImageRef` for the canonical shape. +- The wizard's English error fallbacks live under `apps.new.errors.*` in en + ru. Parity is maintained at 1413 keys; verify with the inline `node -e ...` script in the repo root (or `npm run check`). +- `ApiError` narrowing (`e instanceof api.ApiError && e.status === N`) replaces the older regex-over-`Error.message` pattern. + +## Recipes + +### Add a new probe endpoint +1. Handler in `internal/api/workload_runtime.go` following the established 404-vs-409-vs-502 pattern. Log detail server-side, return generic messages. +2. Route registration in [`internal/api/router.go`](../../internal/api/router.go) under the `/workloads/{id}` group. +3. Typed wrapper in `web/src/lib/api.ts` with `signal?: AbortSignal` parameter. +4. UI consumer mirrors the `loadRuntimeState` pattern: per-call seq token + AbortController stored in module scope + cancelled in `onDestroy`. +5. Tests: table-driven with `newAPITestEnv` from [`internal/api/workloads_test.go`](../../internal/api/workloads_test.go). + +### Extend Git discovery to a new provider +1. Add a new `staticsite.GitProvider` implementation (see `gitea_content.go` for the smallest reference). Use `NewSafeHTTPClient(60 * time.Second)` for outbound calls — do not introduce a raw `&http.Client{}`. +2. Register in `staticsite.NewGitProvider` switch. +3. Add `URL.PathEscape` on every interpolated `{owner}/{repo}/{branch}` segment in URL construction. +4. Update `DetectProviderWithProbe` if the new provider has a known API signature worth probing for unknown hosts. +5. Update `DetectedGitProvider` union in `web/src/lib/api.ts`. + +## Cross-references + +- **Memory** — Project memory under `[[project_discovery_restoration]]` tracks what shipped vs deferred. +- **Workload Plugin** — [`workload-plugin.md`](./workload-plugin.md) — Source × Trigger contracts that the runtime endpoints read from. +- **Webhook Documentation** — [`docs/webhooks.md`](../webhooks.md) — Outgoing webhook events the static plugin fires (`site_sync_success`, `site_sync_failure`). diff --git a/internal/api/discovery.go b/internal/api/discovery.go new file mode 100644 index 0000000..35de994 --- /dev/null +++ b/internal/api/discovery.go @@ -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=. +// +// 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) +} + diff --git a/internal/api/discovery_test.go b/internal/api/discovery_test.go new file mode 100644 index 0000000..2405f3f --- /dev/null +++ b/internal/api/discovery_test.go @@ -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) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index bf8dc2e..a43cd68 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/api/workload_runtime.go b/internal/api/workload_runtime.go new file mode 100644 index 0000000..3511eb7 --- /dev/null +++ b/internal/api/workload_runtime.go @@ -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 `: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 :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 :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) +} diff --git a/internal/api/workload_runtime_test.go b/internal/api/workload_runtime_test.go new file mode 100644 index 0000000..bcdd49e --- /dev/null +++ b/internal/api/workload_runtime_test.go @@ -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() +} diff --git a/internal/staticsite/gitea_content.go b/internal/staticsite/gitea_content.go index c1072e9..3134ef1 100644 --- a/internal/staticsite/gitea_content.go +++ b/internal/staticsite/gitea_content.go @@ -54,11 +54,9 @@ type GiteaContentFetcher struct { // token may be empty for public repositories. func NewGiteaContentFetcher(baseURL, token string) *GiteaContentFetcher { return &GiteaContentFetcher{ - baseURL: strings.TrimRight(baseURL, "/"), - token: token, - httpClient: &http.Client{ - Timeout: 60 * time.Second, - }, + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + httpClient: NewSafeHTTPClient(60 * time.Second), } } diff --git a/internal/staticsite/github_provider.go b/internal/staticsite/github_provider.go index 6bed971..1a0fe91 100644 --- a/internal/staticsite/github_provider.go +++ b/internal/staticsite/github_provider.go @@ -30,11 +30,9 @@ func NewGitHubProvider(baseURL, token string) *GitHubProvider { } return &GitHubProvider{ - apiBase: apiBase, - token: token, - httpClient: &http.Client{ - Timeout: 60 * time.Second, - }, + apiBase: apiBase, + token: token, + httpClient: NewSafeHTTPClient(60 * time.Second), } } diff --git a/internal/staticsite/gitlab_provider.go b/internal/staticsite/gitlab_provider.go index ad0308e..d35fe59 100644 --- a/internal/staticsite/gitlab_provider.go +++ b/internal/staticsite/gitlab_provider.go @@ -26,12 +26,10 @@ type GitLabProvider struct { func NewGitLabProvider(baseURL, token string) *GitLabProvider { base := strings.TrimRight(baseURL, "/") return &GitLabProvider{ - apiBase: base + "/api/v4", - rawBase: base, - token: token, - httpClient: &http.Client{ - Timeout: 60 * time.Second, - }, + apiBase: base + "/api/v4", + rawBase: base, + token: token, + httpClient: NewSafeHTTPClient(60 * time.Second), } } @@ -219,8 +217,14 @@ func (g *GitLabProvider) DownloadFolder(ctx context.Context, owner, repo, branch } // GitLab raw file URL: {base}/{owner}/{repo}/-/raw/{branch}/{path} + // Each segment is path-escaped to match projectPath()'s shape and + // to refuse traversal sequences supplied via the request. fileURL := fmt.Sprintf("%s/%s/%s/-/raw/%s/%s", - g.rawBase, owner, repo, branch, entry.Path) + g.rawBase, + url.PathEscape(owner), + url.PathEscape(repo), + url.PathEscape(branch), + entry.Path) if err := downloadFileHTTP(ctx, g.httpClient, fileURL, localPath, g.setAuth); err != nil { return fmt.Errorf("download %s: %w", relativePath, err) diff --git a/internal/staticsite/provider.go b/internal/staticsite/provider.go index 9ba24d8..10a0ad6 100644 --- a/internal/staticsite/provider.go +++ b/internal/staticsite/provider.go @@ -101,8 +101,10 @@ func DetectProviderWithProbe(ctx context.Context, baseURL string) ProviderType { return urlBased } - // For unknown hosts, probe for Gitea/GitLab API signatures. - client := &http.Client{Timeout: 5 * time.Second} + // For unknown hosts, probe for Gitea/GitLab API signatures using the + // SSRF-safe client so a probe URL cannot be used to reach loopback + // or cloud-metadata addresses. + client := NewSafeHTTPClient(5 * time.Second) base := strings.TrimRight(baseURL, "/") // Try Gitea/Forgejo API. diff --git a/internal/staticsite/safehttp.go b/internal/staticsite/safehttp.go new file mode 100644 index 0000000..50b9da7 --- /dev/null +++ b/internal/staticsite/safehttp.go @@ -0,0 +1,108 @@ +package staticsite + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// ErrBlockedAddress is returned when the dialer refuses to connect +// to a reserved IP (loopback / link-local / unspecified / multicast). +// RFC1918 private ranges are intentionally allowed — self-hosted Gitea +// on a LAN is the dominant deployment pattern. +var ErrBlockedAddress = errors.New("connection to reserved address blocked") + +// ValidateBaseURL enforces scheme + host shape on a user-supplied +// provider base URL. Connect-time IP filtering happens later in the +// safe-HTTP transport so DNS rebinding cannot bypass this check. +func ValidateBaseURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return errors.New("base_url is required") + } + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid base_url: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("unsupported scheme %q (must be http or https)", u.Scheme) + } + if u.Host == "" { + return errors.New("base_url is missing host") + } + return nil +} + +// NewSafeHTTPClient returns an http.Client whose DialContext rejects +// loopback, link-local, multicast, and unspecified addresses at connect +// time. The dialer re-resolves and connects to the resolved IP so a +// rebind between resolution and connect cannot slip through. +// +// RFC1918 / ULA private ranges are NOT blocked — operators routinely +// point Tinyforge at self-hosted Gitea instances on private networks. +// The threat model here is cloud-metadata exfiltration and loopback +// service probing, not "any private IP is suspect". +func NewSafeHTTPClient(timeout time.Duration) *http.Client { + dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + // If the caller passed a literal IP, skip the DNS round-trip. + if literal := net.ParseIP(host); literal != nil { + if reason := blockReason(literal); reason != "" { + return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, literal, reason) + } + return dialer.DialContext(ctx, network, addr) + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + if len(ips) == 0 { + return nil, fmt.Errorf("no addresses for %s", host) + } + for _, ip := range ips { + if reason := blockReason(ip.IP); reason != "" { + return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, ip.IP, reason) + } + } + // Bind to the first resolved IP so a rebind between resolution + // and connect cannot redirect the request to a blocked address. + return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port)) + }, + MaxIdleConns: 16, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + } + return &http.Client{Timeout: timeout, Transport: transport} +} + +// blockReason returns a human label for why an IP is rejected, or "" +// if the IP is allowed. Centralized so all callers share the same +// policy. +func blockReason(ip net.IP) string { + if ip == nil { + return "nil address" + } + switch { + case ip.IsLoopback(): + return "loopback" + case ip.IsUnspecified(): + return "unspecified" + case ip.IsLinkLocalUnicast(): + return "link-local" + case ip.IsLinkLocalMulticast(): + return "link-local multicast" + case ip.IsMulticast(): + return "multicast" + } + return "" +} diff --git a/internal/staticsite/safehttp_test.go b/internal/staticsite/safehttp_test.go new file mode 100644 index 0000000..5e1727d --- /dev/null +++ b/internal/staticsite/safehttp_test.go @@ -0,0 +1,116 @@ +package staticsite + +import ( + "context" + "errors" + "net" + "net/http" + "strings" + "testing" + "time" +) + +func TestValidateBaseURL(t *testing.T) { + cases := []struct { + name string + input string + wantError bool + }{ + {"https", "https://git.example.com", false}, + {"http", "http://git.example.com", false}, + {"trailing_slash", "https://git.example.com/", false}, + {"with_path", "https://git.example.com/sub", false}, + {"with_port", "https://git.example.com:8080", false}, + {"empty", "", true}, + {"whitespace_only", " ", true}, + {"ftp_scheme", "ftp://git.example.com", true}, + {"file_scheme", "file:///etc/passwd", true}, + {"no_scheme", "git.example.com", true}, + {"scheme_no_host", "https://", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateBaseURL(tc.input) + if tc.wantError && err == nil { + t.Errorf("ValidateBaseURL(%q) = nil, want error", tc.input) + } + if !tc.wantError && err != nil { + t.Errorf("ValidateBaseURL(%q) = %v, want nil", tc.input, err) + } + }) + } +} + +func TestBlockReason_PolicyMatrix(t *testing.T) { + cases := []struct { + name string + ip string + wantBlocked bool + }{ + // Allowed. + {"public_v4", "8.8.8.8", false}, + {"rfc1918_10", "10.0.0.1", false}, + {"rfc1918_172_16", "172.16.0.1", false}, + {"rfc1918_192_168", "192.168.1.1", false}, + {"public_v6", "2606:4700:4700::1111", false}, + {"ula_v6", "fd00::1", false}, // ULA private — allowed, mirrors RFC1918 + + // Blocked. + {"loopback_v4", "127.0.0.1", true}, + {"loopback_v6", "::1", true}, + {"unspecified_v4", "0.0.0.0", true}, + {"unspecified_v6", "::", true}, + {"link_local_v4_metadata", "169.254.169.254", true}, // AWS/GCP metadata + {"link_local_v6", "fe80::1", true}, + {"multicast_v4", "224.0.0.1", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ip := net.ParseIP(tc.ip) + if ip == nil { + t.Fatalf("parse %q", tc.ip) + } + got := blockReason(ip) + blocked := got != "" + if blocked != tc.wantBlocked { + t.Errorf("blockReason(%s) = %q (blocked=%v), want blocked=%v", + tc.ip, got, blocked, tc.wantBlocked) + } + }) + } +} + +// TestSafeHTTPClient_RejectsLoopbackLiteral exercises the actual dial +// path: a request to a loopback literal must fail before any TCP work +// happens, with ErrBlockedAddress in the chain. +func TestSafeHTTPClient_RejectsLoopbackLiteral(t *testing.T) { + client := NewSafeHTTPClient(2 * time.Second) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://127.0.0.1:1/", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + _, err = client.Do(req) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, ErrBlockedAddress) && !strings.Contains(err.Error(), "blocked") { + t.Errorf("err = %v, expected ErrBlockedAddress in chain or 'blocked' in message", err) + } +} + +// TestSafeHTTPClient_RejectsAWSMetadataLiteral mirrors the loopback +// case but for the AWS/GCP cloud metadata IP (link-local). +func TestSafeHTTPClient_RejectsAWSMetadataLiteral(t *testing.T) { + client := NewSafeHTTPClient(2 * time.Second) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://169.254.169.254/latest/meta-data/", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + _, err = client.Do(req) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, ErrBlockedAddress) && !strings.Contains(err.Error(), "blocked") { + t.Errorf("err = %v, expected ErrBlockedAddress in chain or 'blocked' in message", err) + } +} diff --git a/web/src/app.css b/web/src/app.css index 6e4df99..4e56bd3 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -248,6 +248,10 @@ input[type="number"] { color: var(--text-primary); border-color: var(--color-brand-300); } +.forge-btn-ghost:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; +} .forge-btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; } .forge-btn-icon { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index df74e4a..4c54c96 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -32,7 +32,7 @@ import type { // ── Helpers ───────────────────────────────────────────────────────── -class ApiError extends Error { +export class ApiError extends Error { constructor( message: string, public readonly status: number @@ -141,11 +141,13 @@ function get(path: string, signal?: AbortSignal): Promise { return request(path, signal ? { signal } : undefined); } -function post(path: string, body?: unknown): Promise { - return request(path, { +function post(path: string, body?: unknown, signal?: AbortSignal): Promise { + const init: RequestInit = { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined - }); + }; + if (signal) init.signal = signal; + return request(path, init); } function put(path: string, body: unknown): Promise { @@ -171,8 +173,146 @@ function patch(path: string, body: unknown): Promise { // image port/healthcheck. `quickDeploy` (POST /api/deploy/quick) is gone: // it created a legacy Project + Stage in the now-dead path. -export function inspectImage(image: string): Promise { - return post('/api/deploy/inspect', { image }); +export function inspectImage(image: string, signal?: AbortSignal): Promise { + return post('/api/deploy/inspect', { image }, signal); +} + +// ── Discovery (/apps/new wizard helpers) ─────────────────────────── +// These endpoints back the auto-discovery + connection-test flow that +// the static-site creation wizard used in the legacy /sites/new page. +// They are admin-gated; the token is plaintext over HTTPS and is not +// persisted server-side. + +// GitProviderKind is the union the *frontend* sends. The empty string +// means "auto-detect server-side" (DetectProviderWithProbe runs). +export type GitProviderKind = '' | 'gitea' | 'github' | 'gitlab'; + +// DetectedGitProvider is the narrower union the backend's detect +// endpoint actually returns — `staticsite.DetectProviderWithProbe` +// always resolves to one of the three concrete kinds (it falls back to +// `gitea` for unknown hosts). Kept distinct from GitProviderKind so a +// successful detection cannot ever set the dropdown back to "". +export type DetectedGitProvider = 'gitea' | 'github' | 'gitlab'; + +export interface RepoInfo { + owner: string; + name: string; + full_name: string; + description: string; + private: boolean; + html_url: string; +} + +export interface FolderEntry { + path: string; + is_dir: boolean; +} + +export interface DiscoveryGitRequest { + provider?: GitProviderKind; + base_url: string; + access_token?: string; + repo_owner?: string; + repo_name?: string; + branch?: string; + query?: string; +} + +export interface ImageConflict { + id: string; + name: string; + image: string; + app_id?: string; +} + +export function detectGitProvider( + baseURL: string, + signal?: AbortSignal +): Promise<{ provider: DetectedGitProvider }> { + return post<{ provider: DetectedGitProvider }>( + '/api/discovery/git/detect-provider', + { base_url: baseURL }, + signal + ); +} + +export function testGitConnection( + req: DiscoveryGitRequest, + signal?: AbortSignal +): Promise<{ status: string }> { + return post<{ status: string }>('/api/discovery/git/test-connection', req, signal); +} + +export function listGitRepos(req: DiscoveryGitRequest, signal?: AbortSignal): Promise { + return post('/api/discovery/git/repos', req, signal); +} + +export function listGitBranches( + req: DiscoveryGitRequest, + signal?: AbortSignal +): Promise { + return post('/api/discovery/git/branches', req, signal); +} + +export function listGitTree(req: DiscoveryGitRequest, signal?: AbortSignal): Promise { + return post('/api/discovery/git/tree', req, signal); +} + +export function listImageConflicts(image: string, signal?: AbortSignal): Promise { + return get( + `/api/discovery/image/conflicts?image=${encodeURIComponent(image)}`, + signal + ); +} + +// ── Workload runtime view (runtime-state, storage, stop, start) ──── +// Backed by internal/api/workload_runtime.go. The shapes mirror the +// Go side exactly so the UI can render without further normalization. + +export interface WorkloadRuntimeState { + source_kind: string; + has_state: boolean; + container_id?: string; + state?: string; + status?: string; + last_commit_sha?: string; + last_sync_at?: string; + last_error?: string; +} + +export interface WorkloadStorageUsage { + source_kind: string; + enabled: boolean; + used_bytes: number; + limit_mb?: number; + probe_error?: string; +} + +export interface StopStartResult { + touched: number; + failed: number; +} + +export function getWorkloadRuntimeState( + id: string, + signal?: AbortSignal +): Promise { + return get(`/api/workloads/${id}/runtime-state`, signal); +} + +export function getWorkloadStorage( + id: string, + signal?: AbortSignal +): Promise { + return get(`/api/workloads/${id}/storage`, signal); +} + +export function stopWorkload(id: string): Promise { + return post(`/api/workloads/${id}/stop`); +} + +export function startWorkload(id: string): Promise { + return post(`/api/workloads/${id}/start`); } // ── Registries ────────────────────────────────────────────────────── @@ -1055,4 +1195,3 @@ export function getLogScanStats(signal?: AbortSignal): Promise { return get('/api/log-scan-rules/stats', signal); } -export { ApiError }; diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 4e35d82..595bac3 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1253,6 +1253,29 @@ "staticRenderMarkdown": "Render markdown", "staticRenderMarkdownDesc": "— auto-render .md files as HTML pages.", "staticFoot": "The webhook secret for git push triggers lives on the workload's Webhook panel after creation.", + "staticDetectProvider": "Detect", + "staticDetectedOk": "Detected: {provider}", + "staticDetectedFailed": "Detection failed: {error}", + "staticTestConnection": "Test connection", + "staticConnectionOk": "Connected", + "staticConnectionFailed": "Connection failed: {error}", + "staticBrowseRepos": "Browse repositories", + "staticBrowseBranches": "Browse branches", + "staticBrowseFolders": "Browse folders", + "staticPickerRepoTitle": "Select repository", + "staticPickerRepoPlaceholder": "Filter repositories…", + "staticPickerBranchTitle": "Select branch", + "staticPickerBranchPlaceholder": "Filter branches…", + "staticFolderRoot": "/ (root)", + "staticFolderSelectedPrefix": "Selected folder:", + "staticTreeLoading": "Loading folder tree…", + "staticTreeEmpty": "No folders found in this branch.", + "staticDenoAutoDetected": "Auto-detected an api/ folder — switched to Deno mode.", + "imageConflictTag": "IMAGE IN USE", + "imageConflictHeading": "{count} workload(s) already use this image:", + "imageConflictOpenBtn": "Open", + "imageConflictAcknowledgeNote": "If this is intentional (for example a separate stage), continue to create a new workload.", + "imageConflictBlockedSubmit": "Conflicts detected for this image — review the list above, then click Create again to proceed.", "sourceConfigJsonTitle": "source_config.json · {kind}", "sourceConfigJsonAria": "Source plugin configuration (JSON)", "triggerNumLabel": "Trigger", @@ -1273,6 +1296,22 @@ "cancel": "Cancel", "submit": "Forge app", "submitting": "Forging…", + "submitAnyway": "Forge anyway", + "errors": { + "detectionFailed": "Provider detection failed.", + "connectionFailed": "Connection failed.", + "reposFailed": "Failed to load repositories.", + "branchesFailed": "Failed to load branches.", + "treeFailed": "Failed to load folder tree.", + "sourceConfigInvalid": "Source config is not valid JSON.", + "triggerBindUnknown": "unknown error", + "createFailed": "Workload create failed.", + "inspectFailed": "Image inspect failed." + }, + "imageInspect": "Inspect", + "imageInspectHint": "Pulls port + healthcheck from the image so you don't have to type them.", + "imageInspectOk": "Inspected — port + healthcheck filled.", + "imageInspectError": "Inspect failed: {error}", "triggers": { "section": "Trigger", "sectionSub": "Optional. Pick how this app gets a redeploy signal — registry watcher, git event, or manual button.", @@ -1304,6 +1343,60 @@ "deployError": "Deploy failed", "saveError": "Save failed", "deleteError": "Delete failed", + "runtimeState": { + "title": "Sync status", + "sub": "Last successful sync of the source repo and the current container state.", + "status": "Status", + "lastCommit": "Last commit", + "lastSync": "Last sync", + "container": "Container", + "neverDeployed": "Never deployed. Click Deploy to publish for the first time.", + "loading": "Loading runtime state…" + }, + "storage": { + "title": "Persistent storage", + "sub": "Mounted at /app/data inside the container.", + "used": "Used", + "limit": "Limit", + "unlimited": "unlimited", + "unavailable": "Usage probe unavailable (container may be stopped).", + "loading": "Computing usage…" + }, + "toolbar": { + "stop": "Stop", + "start": "Start", + "openSite": "Open", + "more": "More" + }, + "liveBadge": { + "running": "RUNNING", + "transitioning": "TRANSITIONING", + "stopped": "STOPPED", + "notDeployed": "NOT DEPLOYED", + "mixed": "MIXED · {running}/{total} RUNNING" + }, + "stats": { + "title": "Resource usage", + "sub": "CPU and memory of the running container.", + "subMany": "CPU and memory of each of the {count} containers." + }, + "webhooks": { + "title": "Webhook bindings", + "sub": "Triggers wired to this app — manage URLs and signing on the trigger detail page.", + "openTrigger": "Open trigger", + "disabled": "disabled", + "empty": "No triggers bound to this app." + }, + "errors": { + "stopFailed": "Stop failed.", + "stopNothing": "Nothing to stop — no running container.", + "stopAllFailed": "Stop failed — all containers refused to stop.", + "startFailed": "Start failed.", + "startNothing": "Nothing to start — deploy first to create a container.", + "startAllFailed": "Start failed — all containers refused to start.", + "runtimeStateFailed": "Failed to load runtime state.", + "storageFailed": "Failed to load storage usage." + }, "alertTag": "ERR", "createdAt": "created", "refreshLabel": "Refresh", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index c8828fb..89c0ab8 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -1253,6 +1253,29 @@ "staticRenderMarkdown": "Рендерить markdown", "staticRenderMarkdownDesc": "— автоматически отдавать .md файлы как HTML-страницы.", "staticFoot": "Секрет вебхука для git push-триггеров появляется в панели вебхука нагрузки после создания.", + "staticDetectProvider": "Определить", + "staticDetectedOk": "Определено: {provider}", + "staticDetectedFailed": "Не удалось определить: {error}", + "staticTestConnection": "Проверить соединение", + "staticConnectionOk": "Соединение установлено", + "staticConnectionFailed": "Ошибка соединения: {error}", + "staticBrowseRepos": "Выбрать репозиторий", + "staticBrowseBranches": "Выбрать ветку", + "staticBrowseFolders": "Выбрать папку", + "staticPickerRepoTitle": "Выбор репозитория", + "staticPickerRepoPlaceholder": "Фильтр репозиториев…", + "staticPickerBranchTitle": "Выбор ветки", + "staticPickerBranchPlaceholder": "Фильтр веток…", + "staticFolderRoot": "/ (корень)", + "staticFolderSelectedPrefix": "Выбранная папка:", + "staticTreeLoading": "Загрузка дерева папок…", + "staticTreeEmpty": "В этой ветке нет папок.", + "staticDenoAutoDetected": "Обнаружена папка api/ — режим автоматически переключён на Deno.", + "imageConflictTag": "ОБРАЗ УЖЕ ИСПОЛЬЗУЕТСЯ", + "imageConflictHeading": "Этот образ уже используется в {count} нагрузке(ах):", + "imageConflictOpenBtn": "Открыть", + "imageConflictAcknowledgeNote": "Если это намеренно (например, отдельный этап), нажмите «Создать» ещё раз для продолжения.", + "imageConflictBlockedSubmit": "Обнаружены конфликты по этому образу — изучите список выше и нажмите «Создать» повторно для продолжения.", "sourceConfigJsonTitle": "source_config.json · {kind}", "sourceConfigJsonAria": "Конфигурация source-плагина (JSON)", "triggerNumLabel": "Триггер", @@ -1273,6 +1296,22 @@ "cancel": "Отмена", "submit": "Создать приложение", "submitting": "Создание…", + "submitAnyway": "Всё равно создать", + "errors": { + "detectionFailed": "Не удалось определить провайдера.", + "connectionFailed": "Ошибка соединения.", + "reposFailed": "Не удалось загрузить репозитории.", + "branchesFailed": "Не удалось загрузить ветки.", + "treeFailed": "Не удалось загрузить дерево папок.", + "sourceConfigInvalid": "source_config не является корректным JSON.", + "triggerBindUnknown": "неизвестная ошибка", + "createFailed": "Не удалось создать нагрузку.", + "inspectFailed": "Не удалось проинспектировать образ." + }, + "imageInspect": "Инспектировать", + "imageInspectHint": "Подставляет порт и healthcheck из образа, чтобы не вводить вручную.", + "imageInspectOk": "Готово — порт и healthcheck подставлены.", + "imageInspectError": "Ошибка инспекции: {error}", "triggers": { "section": "Триггер", "sectionSub": "Необязательно. Выберите, откуда придёт сигнал передеплоя — слежение за реестром, git-событие или ручная кнопка.", @@ -1304,6 +1343,60 @@ "deployError": "Деплой не удался", "saveError": "Сохранение не удалось", "deleteError": "Удаление не удалось", + "runtimeState": { + "title": "Статус синхронизации", + "sub": "Последняя успешная синхронизация репозитория и текущее состояние контейнера.", + "status": "Статус", + "lastCommit": "Последний коммит", + "lastSync": "Последняя синхронизация", + "container": "Контейнер", + "neverDeployed": "Ещё не разворачивалось. Нажмите «Деплой», чтобы опубликовать впервые.", + "loading": "Загрузка состояния…" + }, + "storage": { + "title": "Постоянное хранилище", + "sub": "Смонтировано в /app/data внутри контейнера.", + "used": "Использовано", + "limit": "Лимит", + "unlimited": "без лимита", + "unavailable": "Не удалось получить размер (контейнер мог быть остановлен).", + "loading": "Вычисление размера…" + }, + "toolbar": { + "stop": "Стоп", + "start": "Старт", + "openSite": "Открыть", + "more": "Ещё" + }, + "liveBadge": { + "running": "РАБОТАЕТ", + "transitioning": "ПЕРЕХОД", + "stopped": "ОСТАНОВЛЕНО", + "notDeployed": "НЕ РАЗВЁРНУТО", + "mixed": "СМЕШАННО · {running}/{total} РАБОТАЕТ" + }, + "stats": { + "title": "Ресурсы", + "sub": "CPU и память запущенного контейнера.", + "subMany": "CPU и память по каждому из {count} контейнеров." + }, + "webhooks": { + "title": "Привязки вебхуков", + "sub": "Триггеры, привязанные к приложению — управление URL и подписями на странице триггера.", + "openTrigger": "Открыть триггер", + "disabled": "отключён", + "empty": "К приложению не привязан ни один триггер." + }, + "errors": { + "stopFailed": "Не удалось остановить.", + "stopNothing": "Останавливать нечего — нет запущенного контейнера.", + "stopAllFailed": "Остановка не удалась — все контейнеры отклонили запрос.", + "startFailed": "Не удалось запустить.", + "startNothing": "Запускать нечего — сначала выполните Деплой, чтобы создать контейнер.", + "startAllFailed": "Запуск не удался — все контейнеры отклонили запрос.", + "runtimeStateFailed": "Не удалось загрузить состояние.", + "storageFailed": "Не удалось загрузить размер хранилища." + }, "alertTag": "ОШ", "createdAt": "создано", "refreshLabel": "Обновить", diff --git a/web/src/routes/apps/[id]/+page.svelte b/web/src/routes/apps/[id]/+page.svelte index 663cbf9..52a3a06 100644 --- a/web/src/routes/apps/[id]/+page.svelte +++ b/web/src/routes/apps/[id]/+page.svelte @@ -1,5 +1,5 @@ @@ -976,23 +1295,120 @@
{$t('apps.detail.alertTag')}{error}
{:else if workload} {#snippet detailToolbar()} - + {/if} + {#if !editing && canStart} + + {/if} + {#if !editing} - - + + +
+ + {$t('apps.detail.toolbar.more')} + + + +
+ {#if firstFaceFqdn} + + + {$t('apps.detail.toolbar.openSite')} + + + {/if} + + + +
+
{/if} {/snippet} {#snippet detailLede()} + + + + {liveBadge.count + ? $t(liveBadge.labelKey, { + running: String(liveBadge.count.running), + total: String(liveBadge.count.total) + }) + : $t(liveBadge.labelKey)} + {workload!.source_kind} @@ -1463,6 +1879,254 @@ {/if} + + {#if !editing && workload.source_kind === 'static' && (runtimeState !== null || runtimeError || storageEnabledOnSource)} +
+
+ + + + + +
+

+ + {$t('apps.detail.runtimeState.title')}. +

+ {$t('apps.detail.runtimeState.sub')} +
+ + {#if runtimeError} + + {:else if runtimeState && !runtimeState.has_state} +

{$t('apps.detail.runtimeState.neverDeployed')}

+ {:else if runtimeState} +
+
{$t('apps.detail.runtimeState.status')}
+
+ + + {runtimeState.status || runtimeState.state || '—'} + +
+ +
{$t('apps.detail.runtimeState.lastCommit')}
+
+ {#if runtimeState.last_commit_sha} + {runtimeState.last_commit_sha.slice(0, 8)} + {:else} + + {/if} +
+ +
{$t('apps.detail.runtimeState.lastSync')}
+
+ {#if runtimeState.last_sync_at} + {$fmt.dateTime(runtimeState.last_sync_at)} + {:else} + + {/if} +
+ + {#if runtimeState.container_id} +
{$t('apps.detail.runtimeState.container')}
+
{runtimeState.container_id.slice(0, 12)}
+ {/if} +
+ + {#if runtimeState.last_error} + + {/if} + {:else} +

{$t('apps.detail.runtimeState.loading')}

+ {/if} +
+ + {#if storageEnabledOnSource} +
+ + + + + +
+

+ + {$t('apps.detail.storage.title')}. +

+ {$t('apps.detail.storage.sub')} +
+ + {#if storageError} + + {:else if storage} +
+
{$t('apps.detail.storage.used')}
+
{formatBytes(storage.used_bytes)}
+ +
{$t('apps.detail.storage.limit')}
+
+ {#if storage.limit_mb && storage.limit_mb > 0} + {storage.limit_mb} MB + {:else} + {$t('apps.detail.storage.unlimited')} + {/if} +
+
+ + {#if storage.probe_error} +

{$t('apps.detail.storage.unavailable')}

+ {:else if storage.limit_mb && storage.limit_mb > 0} +
+
+
+

+ {Math.round(storageUsedPct * 100)}% +

+ {/if} + {:else} +

{$t('apps.detail.storage.loading')}

+ {/if} +
+ {/if} +
+ {/if} + + + {#if !editing && containers.length > 0} +
+ + + + + +
+

+ {$t('apps.detail.stats.title')}. +

+ + {containers.length === 1 + ? $t('apps.detail.stats.sub') + : $t('apps.detail.stats.subMany', { count: String(containers.length) })} + +
+ +
    + {#each containers as c (c.id)} + {@const collapseByDefault = containers.length > 2} +
  • + {#if collapseByDefault} + +
    + + + {c.role || c.image_ref || c.id.slice(0, 8)} + {#if c.container_id} + {c.container_id.slice(0, 12)} + {/if} + + +
    + {:else} +
    + {c.role || c.image_ref || c.id.slice(0, 8)} + {#if c.container_id} + {c.container_id.slice(0, 12)} + {/if} +
    + + {/if} +
  • + {/each} +
+
+ {/if} + + + + {#if !editing && webhookBindings.length > 0} +
+ + + + + +
+

+ + {$t('apps.detail.webhooks.title')}. +

+ {$t('apps.detail.webhooks.sub')} +
+ +
    + {#each webhookBindings as b (b.id)} +
  • + + + + {b.trigger_name || b.trigger_id} + + + {$t(`redeployTriggers.kindShort.${b.trigger_kind}`) === + `redeployTriggers.kindShort.${b.trigger_kind}` + ? b.trigger_kind.toUpperCase() + : $t(`redeployTriggers.kindShort.${b.trigger_kind}`)} + + {#if !b.enabled} + {$t('apps.detail.webhooks.disabled')} + {/if} + + + {$t('apps.detail.webhooks.openTrigger')} + +
  • + {/each} +
+
+ {/if} +
@@ -2554,6 +3218,179 @@ color: var(--text-secondary); } + /* ── Runtime / storage panels ──────────────────── + Two narrow operational-status cards displayed in a 2-up grid + above the Manual deploy panel. Collapses to one column under + 720px so the labels never get clipped by the registration + corners. */ + .rt-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1rem; + } + @media (max-width: 720px) { + .rt-grid { + grid-template-columns: 1fr; + } + } + .rt-card .panel-title :global(svg) { + opacity: 0.7; + margin-right: 0.35rem; + vertical-align: -2px; + } + /* External-link affordance on the "Open" toolbar button — the + only ghost button that navigates away from the app, so it + gets the ↗ glyph to differentiate it from in-app actions. */ + .open-ext .open-ext-glyph { + margin-left: 0.15rem; + font-size: 0.78em; + opacity: 0.65; + transition: transform 120ms ease, opacity 120ms ease; + } + .open-ext:hover .open-ext-glyph { + transform: translate(1px, -1px); + opacity: 1; + } + + .kv-grid { + display: grid; + grid-template-columns: minmax(7rem, max-content) 1fr; + row-gap: 0.55rem; + column-gap: 1rem; + margin: 0.7rem 0 0; + font-size: 0.875rem; + } + .kv-grid dt { + font-family: var(--forge-mono); + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-tertiary); + align-self: center; + } + .kv-grid dd { + margin: 0; + color: var(--text-secondary); + align-self: center; + } + .mono-sha, + .mono-time { + font-family: var(--forge-mono); + font-size: 0.78rem; + color: var(--text-secondary); + } + .muted { + color: var(--text-tertiary); + } + + .rt-empty { + font-family: var(--forge-mono); + font-size: 0.75rem; + letter-spacing: 0.08em; + color: var(--text-tertiary); + margin: 0.7rem 0 0; + } + + .rt-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.18rem 0.55rem; + border-radius: var(--radius-full); + font-family: var(--forge-mono); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + border: 1px solid var(--border-primary); + background: var(--surface-card-hover); + color: var(--text-secondary); + } + .rt-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + } + .rt-badge.rt-ok { + /* Darken the foreground beyond -dark to clear 4.5:1 against the + 12% tint on light backgrounds — color-mix the brand token with + black instead of relying on -dark alone. */ + background: color-mix(in srgb, var(--color-success) 16%, transparent); + color: color-mix(in srgb, var(--color-success-dark) 80%, #000); + border-color: color-mix(in srgb, var(--color-success) 45%, transparent); + } + :global([data-theme='dark']) .rt-badge.rt-ok { + color: #6ee7b7; + } + .rt-badge.rt-busy { + background: color-mix(in srgb, var(--color-warning) 12%, transparent); + color: var(--color-warning-dark); + border-color: color-mix(in srgb, var(--color-warning) 35%, transparent); + } + .rt-badge.rt-busy .rt-dot { + animation: rt-pulse 1.4s ease-in-out infinite; + } + @keyframes rt-pulse { + 0%, 100% { opacity: 0.35; transform: scale(0.85); } + 50% { opacity: 1; transform: scale(1.15); } + } + :global([data-theme='dark']) .rt-badge.rt-busy { + color: #fcd34d; + } + .rt-badge.rt-bad { + background: color-mix(in srgb, var(--color-danger) 12%, transparent); + color: var(--color-danger-dark); + border-color: color-mix(in srgb, var(--color-danger) 35%, transparent); + } + :global([data-theme='dark']) .rt-badge.rt-bad { + color: #fca5a5; + } + .rt-badge.rt-idle { + color: var(--text-tertiary); + } + + .rt-error { + margin-top: 0.8rem; + } + .rt-probe-note { + margin: 0.7rem 0 0; + font-size: 0.78rem; + color: var(--text-tertiary); + } + + .usage-bar { + margin-top: 0.85rem; + height: 6px; + width: 100%; + background: var(--surface-card-hover); + border-radius: var(--radius-full); + overflow: hidden; + } + .usage-fill { + height: 100%; + transition: width 200ms ease; + border-radius: var(--radius-full); + } + .usage-fill.bar-green { + background: var(--color-success); + } + .usage-fill.bar-amber { + background: var(--color-warning); + } + .usage-fill.bar-red { + background: var(--color-danger); + } + .usage-caption { + margin: 0.35rem 0 0; + font-family: var(--forge-mono); + font-size: 0.7rem; + letter-spacing: 0.08em; + color: var(--text-tertiary); + text-align: right; + } + /* ── Alert / Success ───────────────────────────── */ .alert { display: flex; @@ -3825,4 +4662,231 @@ .add-modal .alert.inline-alert { margin: 0.55rem 1.25rem 0; } + + /* ── Live-state pill (hero lede) ────────────────── + Reuses the existing .rt-badge palette tokens (rt-ok / rt-busy / + rt-bad / rt-idle) so the live container-state chip matches the + static runtime-status badge stylistically. .live-badge is just + a sizing tweak so it sits comfortably next to .badge chips. */ + .live-badge { + padding: 0.16rem 0.55rem; + /* Matches the bumped .rt-badge size (0.68rem) so the most + important status pill on the page is the most readable. */ + font-size: 0.66rem; + letter-spacing: 0.14em; + } + + /* ── Resource usage panel ─────────────────────── */ + .stats-panel { + margin-top: 1rem; + } + .stats-list { + list-style: none; + margin: 0.7rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.9rem; + } + .stats-row { + padding: 0.7rem 0.85rem 0.85rem; + background: var(--surface-card-hover); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + } + .stats-row-head { + display: inline-flex; + align-items: baseline; + gap: 0.6rem; + flex-wrap: wrap; + /* Breathing room between header and chart, matches .panel-head. */ + margin-bottom: 0.5rem; + } + .stats-role { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-secondary); + } + .stats-cid { + font-size: 0.68rem; + color: var(--text-tertiary); + } + /* Per-row collapse when the workload has ≥3 containers — a compose + stack with N services would otherwise stack a wall of charts. */ + .stats-collapse > summary { + cursor: pointer; + list-style: none; + padding: 0.1rem 0; + } + .stats-collapse > summary::-webkit-details-marker { + display: none; + } + .stats-collapse-glyph { + display: inline-block; + margin-right: 0.15rem; + font-family: var(--forge-mono); + color: var(--text-tertiary); + transition: transform 120ms ease; + } + .stats-collapse[open] .stats-collapse-glyph { + transform: rotate(90deg); + } + + /* ── Webhook bindings (read-only summary) ─────── */ + .wh-panel { + margin-top: 1rem; + } + .wh-panel .panel-title :global(svg) { + opacity: 0.7; + margin-right: 0.35rem; + vertical-align: -2px; + } + .wh-list { + list-style: none; + margin: 0.7rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; + } + .wh-row { + display: inline-flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + padding: 0.55rem 0.7rem; + background: var(--surface-card-hover); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + } + .wh-row.disabled { + opacity: 0.68; + } + .wh-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: var(--radius-sm); + background: var(--surface-card); + color: var(--text-tertiary); + border: 1px solid var(--border-primary); + flex: 0 0 auto; + } + .wh-name { + font-weight: 600; + color: var(--text-primary); + font-size: 0.85rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .wh-kind { + display: inline-flex; + padding: 0.16rem 0.45rem; + background: var(--text-primary); + color: var(--surface-card); + font-family: var(--forge-mono); + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.18em; + border-radius: var(--radius-sm); + line-height: 1; + flex: 0 0 auto; + } + .wh-muted { + font-size: 0.6rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-tertiary); + } + .wh-open { + margin-left: auto; + } + + /* ── Responsive toolbar overflow ────────────────── + At ≥640px the overflow
stays hidden and every action + renders inline. Under 640px the .tb-wide buttons collapse into a + "More ⋯" menu. Both copies live in the DOM so keyboard/aria + semantics remain native — CSS only flips visibility. */ + .tb-overflow { + position: relative; + display: none; + } + @media (max-width: 639px) { + .tb-wide { + display: none !important; + } + .tb-overflow { + display: inline-block; + } + } + .tb-more-summary { + list-style: none; + cursor: pointer; + user-select: none; + } + .tb-more-summary::-webkit-details-marker { + display: none; + } + .tb-more-glyph { + margin-left: 0.15rem; + font-size: 0.95em; + line-height: 1; + opacity: 0.7; + } + .tb-menu { + position: absolute; + right: 0; + top: calc(100% + 0.35rem); + min-width: 11rem; + padding: 0.35rem; + display: flex; + flex-direction: column; + gap: 0.15rem; + /* Sticky nav in this app uses z-index ≥40; bump well past so + the overflow menu always sits above page chrome. */ + z-index: 100; + background: var(--surface-card); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + box-shadow: 0 12px 28px -10px rgba(15, 23, 42, 0.35); + } + :global([data-theme='dark']) .tb-menu { + box-shadow: 0 12px 28px -10px rgba(0, 0, 0, 0.55); + } + .tb-menu-item { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.65rem; + background: transparent; + border: 0; + border-radius: var(--radius-md); + color: var(--text-secondary); + font: inherit; + font-size: 0.82rem; + text-align: left; + text-decoration: none; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; + } + .tb-menu-item:hover, + .tb-menu-item:focus-visible { + background: var(--surface-card-hover); + color: var(--text-primary); + outline: none; + } + .tb-menu-item.danger { + color: var(--color-danger); + } + .tb-menu-item.danger:hover, + .tb-menu-item.danger:focus-visible { + background: var(--color-danger-light); + color: var(--color-danger-dark); + } diff --git a/web/src/routes/apps/new/+page.svelte b/web/src/routes/apps/new/+page.svelte index 93e19d6..808867a 100644 --- a/web/src/routes/apps/new/+page.svelte +++ b/web/src/routes/apps/new/+page.svelte @@ -1,8 +1,14 @@