feat(discovery+runtime): restore static-site wizard discovery + close /sites/[id] feature parity
Build / build (push) Successful in 10m43s
Build / build (push) Successful in 10m43s
Two-stage feature arc closing the gaps left by the hard legacy cutover.
The static-site creation wizard regains its auto-discovery + connection-test
flow; /apps/[id] grows the runtime/storage/lifecycle surface the legacy
/sites/[id] page used to expose.
Backend (Go)
- internal/api/discovery.go: six admin-gated endpoints wrapping
staticsite.GitProvider — POST /api/discovery/git/{detect-provider,
test-connection,repos,branches,tree} + GET /api/discovery/image/conflicts.
Identifier validation (validateGitIdent / validateGitBranch) at the
boundary so provider URL interpolation cannot be hijacked via `..`.
Upstream errors scrubbed: detailed slog on the server, generic 502 to
the client (mitigates token-reflection-in-error-page).
- internal/api/workload_runtime.go: four endpoints —
GET /api/workloads/{id}/runtime-state decodes containers.extra_json for
static workloads; GET /api/workloads/{id}/storage execs `du -sb /app/data`
with a 30s in-process cache (storageProbeCache) so polling can't turn
into per-request execs; POST /api/workloads/{id}/{stop,start} iterate
ListContainersByWorkload and call docker.StopContainer / StartContainer,
returning 200 / 409 (nothing to act on) / 502 (all failed).
- internal/staticsite/safehttp.go: NewSafeHTTPClient + ValidateBaseURL +
blockReason. DialContext re-resolves hostnames and refuses loopback /
link-local / multicast / unspecified addresses. RFC1918 + ULA explicitly
allowed (self-hosted Gitea on LAN is the dominant deployment).
Replaced four raw &http.Client{} constructions in the provider files.
- internal/staticsite/gitlab_provider.go: url.PathEscape each segment in
the raw-file URL builder for parity with projectPath().
- Test coverage: 26 cases in discovery_test.go (image-tag stripping,
source-config decoding, conflict scenarios, validator boundaries,
scheme rejection), 14 in workload_runtime_test.go (404 / 409 / nil-docker
/ probe-cache), 16 in safehttp_test.go (URL validation + block-reason
policy matrix + live dial against loopback + AWS metadata literals).
Frontend (Svelte 5 + runes)
- web/src/lib/api.ts: typed wrappers for every endpoint, AbortSignal
threaded through post(); ApiError exported so callers can narrow on
e.status; new DetectedGitProvider narrow union.
- web/src/routes/apps/new/+page.svelte: static-form discovery controls
(auto-detect provider, test connection, repo / branch / folder
EntityPickers, Deno auto-detect); image-form conflict panel with
debounced lookup + double-click submit guard ("Forge anyway") + Inspect
button that pre-fills port/healthcheck; English error fallbacks routed
through apps.new.errors.* (en + ru).
- web/src/routes/apps/[id]/+page.svelte: runtime-state panel + storage
panel + Stop / Start / Open-site toolbar; universal live-state badge
in the hero lede for image/compose/static (RUNNING / TRANSITIONING /
STOPPED / NOT DEPLOYED / MIXED · n/m RUNNING); ContainerStats panel
per row (auto-collapsing native <details> when N > 2); read-only
webhook bindings summary card; responsive toolbar overflow with native
<details> at <640px (z-index 100 above sticky nav).
- web/src/app.css: project-wide .forge-btn-ghost:focus-visible outline.
Hardening from go-reviewer + security-reviewer + typescript-reviewer +
frontend-design UI/UX subagents (0 CRITICAL, all HIGH/BLOCKER addressed
inline, IMPORTANT applied before commit):
- AbortController + per-call sequence tokens on every long-running
fetch (loadRuntimeState / loadStorage / loadTriggerMeta / inspectImage /
listImageConflicts) plus onDestroy cleanup so late resolves cannot
mutate dead component state.
- doStop / doStart snapshot and restore `error` across the finally-block
reload so a load()-cleared message doesn't hide a real failure.
- triggersById refreshed after inline trigger creation so the webhook
card doesn't silently exclude the just-created trigger.
- Live-state badge wraps in role=status / aria-live=polite (no redundant
aria-label).
- Webhook row has a single click target (was two pointing at the same URL).
- Empty webhook section hides entirely.
- Dropped role=menu / role=menuitem from the overflow menu (they would
promise arrow-key nav we don't wire; native Tab + ESC carry it).
Doc
- docs/CODEMAPS/INDEX.md + new docs/CODEMAPS/discovery-and-runtime.md
map the endpoint surface, security posture, frontend integration
patterns, and an "add a new probe" recipe.
Verification
- svelte-check: 0 errors, 3 pre-existing a11y warnings.
- go build + go vet + go test ./...: all green.
- i18n parity: en + ru at 1413 keys each.
- Live smoke against :8090: 404 / 409 / 502 envelopes correct, discovery
sanity passes, ProbeError surfaces on no-container path.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 `<workloadID>: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`).
|
||||
@@ -0,0 +1,403 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/staticsite"
|
||||
)
|
||||
|
||||
// Discovery endpoints feed the /apps/new wizard's auto-discovery and
|
||||
// connection-test flow. They wrap staticsite.GitProvider so the form
|
||||
// can validate a repo + token before the workload is created, browse
|
||||
// repos/branches/folders without leaving the page, and warn the operator
|
||||
// when an image is already in use by another workload.
|
||||
//
|
||||
// The endpoints are workload-agnostic on purpose — they are scoped under
|
||||
// /api/discovery rather than tied to the static_sites table the cutover
|
||||
// dropped. Any future Git-driven source plugin can reuse them.
|
||||
|
||||
// Per-request budget for outbound calls. Short enough that a malicious
|
||||
// or stuck upstream cannot pin a worker for long; long enough for slow
|
||||
// self-hosted Gitea instances to respond.
|
||||
const discoveryTimeout = 15 * time.Second
|
||||
|
||||
// gitProviderRequest is the shared request body for the four Git
|
||||
// discovery endpoints. Token is plaintext over HTTPS — the wizard has
|
||||
// not yet persisted it, so there is nothing to decrypt server-side.
|
||||
// Empty Provider triggers DetectProviderWithProbe.
|
||||
type gitProviderRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
Branch string `json:"branch"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
// gitIdentRe accepts Git owner / repo identifiers as the major hosts
|
||||
// (GitHub, GitLab, Gitea/Forgejo) accept them: alphanumeric plus dot,
|
||||
// underscore, hyphen. Rejecting other characters at the API boundary
|
||||
// prevents `..` traversal and URL injection in the provider code that
|
||||
// interpolates these segments into request paths.
|
||||
var gitIdentRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
|
||||
// gitBranchRe is more permissive than gitIdentRe: branches may contain
|
||||
// `/` (e.g. `feature/foo`) but still cannot contain `..` or control
|
||||
// characters. The check below pairs this regex with an explicit `..`
|
||||
// reject so a `feature/../admin` value cannot slip through.
|
||||
var gitBranchRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
|
||||
|
||||
// validateGitIdent guards owner / repo path segments at the boundary
|
||||
// so the provider code can interpolate them with fmt.Sprintf without
|
||||
// risking traversal. Empty input is reported with the supplied field
|
||||
// name so the error message is actionable.
|
||||
func validateGitIdent(field, value string) error {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return &apiError{msg: field + " is required"}
|
||||
}
|
||||
if !gitIdentRe.MatchString(v) {
|
||||
return &apiError{msg: field + " contains invalid characters"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateGitBranch is the branch-shaped variant of validateGitIdent.
|
||||
// Branches legitimately contain `/`; the extra `..` reject covers the
|
||||
// one traversal vector the regex still admits.
|
||||
func validateGitBranch(value string) error {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return &apiError{msg: "branch is required"}
|
||||
}
|
||||
if strings.Contains(v, "..") {
|
||||
return &apiError{msg: "branch contains invalid sequence '..'"}
|
||||
}
|
||||
if !gitBranchRe.MatchString(v) {
|
||||
return &apiError{msg: "branch contains invalid characters"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// apiError is a small typed error so handlers can distinguish a
|
||||
// validation failure (→ 400) from any other error (→ 500/502). The
|
||||
// type lives in this file because nothing outside discovery uses it
|
||||
// yet — promote to response.go if other handlers need the same shape.
|
||||
type apiError struct{ msg string }
|
||||
|
||||
func (e *apiError) Error() string { return e.msg }
|
||||
|
||||
// providerType normalizes the provider string into the typed enum used
|
||||
// by staticsite.NewGitProvider. Empty input falls through to provider
|
||||
// auto-detection inside NewGitProvider.
|
||||
func (req gitProviderRequest) providerType() staticsite.ProviderType {
|
||||
switch strings.ToLower(strings.TrimSpace(req.Provider)) {
|
||||
case "github":
|
||||
return staticsite.ProviderGitHub
|
||||
case "gitlab":
|
||||
return staticsite.ProviderGitLab
|
||||
case "gitea":
|
||||
return staticsite.ProviderGitea
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// newProvider constructs the GitProvider for the request, or writes a
|
||||
// 400 to w and returns nil if the inputs are invalid. BaseURL is fully
|
||||
// validated here (scheme + host shape); connect-time IP filtering is
|
||||
// enforced inside the safe-HTTP transport the provider receives.
|
||||
func (req gitProviderRequest) newProvider(w http.ResponseWriter) staticsite.GitProvider {
|
||||
if err := staticsite.ValidateBaseURL(req.BaseURL); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return nil
|
||||
}
|
||||
provider, err := staticsite.NewGitProvider(req.providerType(), req.BaseURL, req.AccessToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return nil
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
// upstreamError logs the detailed upstream failure server-side and
|
||||
// writes a generic 502 to the client. Echoing the raw error string
|
||||
// would leak any access token reflected by a misconfigured or
|
||||
// attacker-controlled upstream into the response body.
|
||||
func upstreamError(w http.ResponseWriter, op string, err error) {
|
||||
slog.Warn("discovery upstream call failed", "op", op, "error", err)
|
||||
respondError(w, http.StatusBadGateway, "upstream git provider returned an error")
|
||||
}
|
||||
|
||||
// detectGitProviderRequest is the body for POST /api/discovery/git/detect-provider.
|
||||
type detectGitProviderRequest struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
|
||||
// detectGitProvider probes the base URL for known Git provider API
|
||||
// signatures so the wizard can auto-fill the provider dropdown.
|
||||
// POST /api/discovery/git/detect-provider.
|
||||
func (s *Server) detectGitProvider(w http.ResponseWriter, r *http.Request) {
|
||||
var req detectGitProviderRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := staticsite.ValidateBaseURL(req.BaseURL); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
|
||||
defer cancel()
|
||||
|
||||
provider := staticsite.DetectProviderWithProbe(ctx, req.BaseURL)
|
||||
respondJSON(w, http.StatusOK, map[string]string{"provider": string(provider)})
|
||||
}
|
||||
|
||||
// testGitConnection verifies the configured base URL + token + repo
|
||||
// reach the provider successfully so the wizard can fail fast.
|
||||
// POST /api/discovery/git/test-connection.
|
||||
func (s *Server) testGitConnection(w http.ResponseWriter, r *http.Request) {
|
||||
var req gitProviderRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := validateGitIdent("repo_owner", req.RepoOwner); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateGitIdent("repo_name", req.RepoName); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
provider := req.newProvider(w)
|
||||
if provider == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := provider.TestConnection(ctx, req.RepoOwner, req.RepoName); err != nil {
|
||||
upstreamError(w, "test_connection", err)
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// listGitRepos returns repositories accessible with the supplied token,
|
||||
// optionally filtered by a name query.
|
||||
// POST /api/discovery/git/repos.
|
||||
func (s *Server) listGitRepos(w http.ResponseWriter, r *http.Request) {
|
||||
var req gitProviderRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
provider := req.newProvider(w)
|
||||
if provider == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
|
||||
defer cancel()
|
||||
|
||||
repos, err := provider.ListRepos(ctx, req.Query)
|
||||
if err != nil {
|
||||
upstreamError(w, "list_repos", err)
|
||||
return
|
||||
}
|
||||
if repos == nil {
|
||||
repos = []staticsite.RepoInfo{}
|
||||
}
|
||||
respondJSON(w, http.StatusOK, repos)
|
||||
}
|
||||
|
||||
// listGitBranches returns the branch list for a repo.
|
||||
// POST /api/discovery/git/branches.
|
||||
func (s *Server) listGitBranches(w http.ResponseWriter, r *http.Request) {
|
||||
var req gitProviderRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := validateGitIdent("repo_owner", req.RepoOwner); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateGitIdent("repo_name", req.RepoName); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
provider := req.newProvider(w)
|
||||
if provider == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
|
||||
defer cancel()
|
||||
|
||||
branches, err := provider.ListBranches(ctx, req.RepoOwner, req.RepoName)
|
||||
if err != nil {
|
||||
upstreamError(w, "list_branches", err)
|
||||
return
|
||||
}
|
||||
if branches == nil {
|
||||
branches = []string{}
|
||||
}
|
||||
respondJSON(w, http.StatusOK, branches)
|
||||
}
|
||||
|
||||
// listGitTree returns the full directory tree for a branch so the
|
||||
// wizard can render the folder picker.
|
||||
// POST /api/discovery/git/tree.
|
||||
func (s *Server) listGitTree(w http.ResponseWriter, r *http.Request) {
|
||||
var req gitProviderRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := validateGitIdent("repo_owner", req.RepoOwner); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateGitIdent("repo_name", req.RepoName); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateGitBranch(req.Branch); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
provider := req.newProvider(w)
|
||||
if provider == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
|
||||
defer cancel()
|
||||
|
||||
tree, err := provider.ListTree(ctx, req.RepoOwner, req.RepoName, req.Branch)
|
||||
if err != nil {
|
||||
upstreamError(w, "list_tree", err)
|
||||
return
|
||||
}
|
||||
if tree == nil {
|
||||
tree = []staticsite.FolderEntry{}
|
||||
}
|
||||
respondJSON(w, http.StatusOK, tree)
|
||||
}
|
||||
|
||||
// imageConflict is a slim projection of Workload, scoped to what the
|
||||
// /apps/new conflict dialog needs to render.
|
||||
type imageConflict struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
}
|
||||
|
||||
// listImageConflicts finds existing image-source workloads whose
|
||||
// configured image matches the supplied ref, with or without tag.
|
||||
// GET /api/discovery/image/conflicts?image=<ref>.
|
||||
//
|
||||
// Matching mirrors the legacy quickDeploy behavior: collide on
|
||||
// repository-without-tag so nginx:1.25 surfaces nginx, nginx:latest,
|
||||
// and nginx:1.26 as conflicts. This is intentionally permissive — the
|
||||
// wizard surfaces matches but lets the operator decide.
|
||||
func (s *Server) listImageConflicts(w http.ResponseWriter, r *http.Request) {
|
||||
image := strings.TrimSpace(r.URL.Query().Get("image"))
|
||||
if image == "" {
|
||||
respondError(w, http.StatusBadRequest, "image query parameter is required")
|
||||
return
|
||||
}
|
||||
target := stripImageTag(image)
|
||||
if target == "" {
|
||||
respondError(w, http.StatusBadRequest, "image is empty after tag strip")
|
||||
return
|
||||
}
|
||||
|
||||
workloads, err := s.store.ListWorkloads("")
|
||||
if err != nil {
|
||||
slog.Error("list workloads for conflict check", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
conflicts := []imageConflict{}
|
||||
for _, wl := range workloads {
|
||||
if wl.SourceKind != "image" {
|
||||
continue
|
||||
}
|
||||
ref := imageRefFromSourceConfig(wl.SourceConfig)
|
||||
if ref == "" {
|
||||
continue
|
||||
}
|
||||
if stripImageTag(ref) != target {
|
||||
continue
|
||||
}
|
||||
conflicts = append(conflicts, imageConflict{
|
||||
ID: wl.ID,
|
||||
Name: wl.Name,
|
||||
Image: ref,
|
||||
AppID: wl.AppID,
|
||||
})
|
||||
}
|
||||
respondJSON(w, http.StatusOK, conflicts)
|
||||
}
|
||||
|
||||
// stripImageTag returns the image reference with the trailing :tag
|
||||
// removed, taking care to leave a registry port (e.g. registry:5000/foo)
|
||||
// intact. Digest references (image@sha256:...) are returned unchanged.
|
||||
func stripImageTag(ref string) string {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return ""
|
||||
}
|
||||
// Digest reference: keep as-is so two pinned-by-digest workloads do
|
||||
// not collide with each other or with tag-based refs unless the
|
||||
// caller asks for exact-match (we currently don't).
|
||||
if at := strings.Index(ref, "@"); at >= 0 {
|
||||
return ref[:at]
|
||||
}
|
||||
// Strip a :tag suffix only when the colon is in the final path
|
||||
// segment — earlier colons belong to a registry port.
|
||||
lastSlash := strings.LastIndex(ref, "/")
|
||||
tail := ref
|
||||
if lastSlash >= 0 {
|
||||
tail = ref[lastSlash+1:]
|
||||
}
|
||||
if colon := strings.LastIndex(tail, ":"); colon >= 0 {
|
||||
// Only strip if the tag part looks like a tag (no slashes,
|
||||
// non-empty). Otherwise leave alone. When lastSlash is -1 the
|
||||
// arithmetic still yields the right cut point (-1 + 1 + colon
|
||||
// == colon), so no special case is needed.
|
||||
tag := tail[colon+1:]
|
||||
if tag != "" && !strings.ContainsAny(tag, "/") {
|
||||
return ref[:lastSlash+1+colon]
|
||||
}
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// imageRefFromSourceConfig extracts the "image" field from a workload's
|
||||
// source_config JSON. Returns "" when the blob is missing, malformed,
|
||||
// or has no image field — those workloads simply do not contribute to
|
||||
// conflict detection.
|
||||
func imageRefFromSourceConfig(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
var cfg struct {
|
||||
Image string `json:"image"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cfg.Image)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// storageProbeCache memoizes the `du` result per workload for a short
|
||||
// window so a tight polling loop on /storage cannot turn into one
|
||||
// `docker exec du` per request. The TTL is intentionally short — the
|
||||
// panel is a coarse usage indicator, not a real-time meter.
|
||||
var (
|
||||
storageProbeCacheTTL = 30 * time.Second
|
||||
storageProbeMu sync.Mutex
|
||||
storageProbeCache = map[string]storageProbeEntry{}
|
||||
)
|
||||
|
||||
type storageProbeEntry struct {
|
||||
at time.Time
|
||||
usage int64
|
||||
probeOk bool
|
||||
}
|
||||
|
||||
// Runtime endpoints surface what the legacy /api/sites/* surface used
|
||||
// to expose on the static-site detail page: the last commit SHA / last
|
||||
// sync timestamp / status persisted by the static plugin in
|
||||
// containers.extra_json, the data-volume disk usage, and stop / start
|
||||
// controls that don't require a full re-deploy.
|
||||
//
|
||||
// The handlers are deliberately decoupled from the plugin interface so
|
||||
// they work uniformly across source kinds: stop/start operate on the
|
||||
// Docker container IDs stored in the containers index regardless of
|
||||
// kind; runtime-state reads what the source persisted (currently only
|
||||
// "static" writes a structured blob); storage usage is static-only
|
||||
// today but the endpoint shape allows future sources to opt in.
|
||||
|
||||
// runtimeStatePayload is the JSON shape returned by
|
||||
// GET /api/workloads/{id}/runtime-state.
|
||||
//
|
||||
// SourceKind is always present so the UI can decide whether to render
|
||||
// the static-specific fields (last_commit_sha, last_sync_at, ...). The
|
||||
// container-row fields (ContainerID, State) come from the canonical
|
||||
// containers row that the static plugin maintains under the
|
||||
// deterministic ID `<workloadID>:site`.
|
||||
type runtimeStatePayload struct {
|
||||
SourceKind string `json:"source_kind"`
|
||||
HasState bool `json:"has_state"`
|
||||
ContainerID string `json:"container_id,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
LastCommitSHA string `json:"last_commit_sha,omitempty"`
|
||||
LastSyncAt string `json:"last_sync_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
// getWorkloadRuntimeState handles GET /api/workloads/{id}/runtime-state.
|
||||
// Reads the typed state the static plugin writes into containers.extra_json
|
||||
// (see internal/workload/plugin/source/static/state.go). Non-static
|
||||
// source kinds return SourceKind + HasState=false; the panel hides
|
||||
// itself rather than the endpoint 404ing.
|
||||
func (s *Server) getWorkloadRuntimeState(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
workload, err := s.store.GetWorkloadByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "workload")
|
||||
return
|
||||
}
|
||||
slog.Error("get workload for runtime-state", "workload", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
payload := runtimeStatePayload{SourceKind: workload.SourceKind}
|
||||
|
||||
if workload.SourceKind != "static" {
|
||||
respondJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
|
||||
// The static plugin owns one container row per workload at the
|
||||
// deterministic ID <workloadID>:site. A missing row means the
|
||||
// workload has never been deployed — return HasState=false so the
|
||||
// UI can prompt the operator to deploy.
|
||||
row, err := s.store.GetContainerByID(id + ":site")
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
slog.Error("get container row for runtime-state", "workload", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
payload.HasState = true
|
||||
payload.ContainerID = row.ContainerID
|
||||
payload.State = row.State
|
||||
|
||||
// extra_json is the source of truth for the typed runtime fields.
|
||||
// A decode failure is non-fatal: we still report container_id /
|
||||
// state so the UI is useful, just without the sync history.
|
||||
//
|
||||
// No mutex here even though the writer (state.go saveState) holds
|
||||
// a per-workload mutex on read-modify-write — SQLite returns the
|
||||
// ExtraJSON column as a fully-materialized string from a single
|
||||
// SELECT, so the reader sees either the pre- or post-write snapshot
|
||||
// atomically. There is no torn read to defend against.
|
||||
if row.ExtraJSON != "" && row.ExtraJSON != "{}" {
|
||||
var st struct {
|
||||
Status string `json:"status"`
|
||||
LastCommitSHA string `json:"last_commit_sha"`
|
||||
LastSyncAt string `json:"last_sync_at"`
|
||||
LastError string `json:"last_error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(row.ExtraJSON), &st); err != nil {
|
||||
slog.Debug("decode extra_json for runtime-state", "workload", id, "error", err)
|
||||
} else {
|
||||
payload.Status = st.Status
|
||||
payload.LastCommitSHA = st.LastCommitSHA
|
||||
payload.LastSyncAt = st.LastSyncAt
|
||||
payload.LastError = st.LastError
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// storageUsagePayload is the JSON shape returned by
|
||||
// GET /api/workloads/{id}/storage. ProbeError surfaces a non-fatal
|
||||
// failure to compute used_bytes (du timed out, exec returned non-zero,
|
||||
// etc.) so the UI can render "usage unavailable" instead of an
|
||||
// always-zero number.
|
||||
type storageUsagePayload struct {
|
||||
SourceKind string `json:"source_kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
LimitMB int `json:"limit_mb,omitempty"`
|
||||
ProbeError string `json:"probe_error,omitempty"`
|
||||
}
|
||||
|
||||
// getWorkloadStorage handles GET /api/workloads/{id}/storage.
|
||||
//
|
||||
// For static workloads with storage enabled, execs `du -sb /app/data`
|
||||
// inside the running container to compute the data volume's footprint.
|
||||
// For workloads without storage (or non-static source kinds), returns
|
||||
// Enabled=false and zero usage so the UI can hide the panel.
|
||||
func (s *Server) getWorkloadStorage(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
workload, err := s.store.GetWorkloadByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "workload")
|
||||
return
|
||||
}
|
||||
slog.Error("get workload for storage", "workload", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
payload := storageUsagePayload{SourceKind: workload.SourceKind}
|
||||
|
||||
if workload.SourceKind != "static" {
|
||||
respondJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode storage knobs from source_config. Missing / malformed
|
||||
// blobs are treated as storage-disabled rather than erroring; the
|
||||
// validator that runs on workload create already rejects invalid
|
||||
// configs at the source.
|
||||
var cfg struct {
|
||||
StorageEnabled bool `json:"storage_enabled"`
|
||||
StorageLimitMB int `json:"storage_limit_mb"`
|
||||
}
|
||||
if workload.SourceConfig != "" {
|
||||
if err := json.Unmarshal([]byte(workload.SourceConfig), &cfg); err != nil {
|
||||
// Validator catches malformed configs at create-time, so
|
||||
// this is unexpected — log so a drifted row is traceable.
|
||||
slog.Debug("decode source_config for storage", "workload", id, "error", err)
|
||||
}
|
||||
}
|
||||
payload.Enabled = cfg.StorageEnabled
|
||||
payload.LimitMB = cfg.StorageLimitMB
|
||||
|
||||
if !cfg.StorageEnabled || s.docker == nil {
|
||||
respondJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
|
||||
// Cache hit short-circuits the docker exec entirely so a polling
|
||||
// frontend cannot turn this into a per-request `du`.
|
||||
storageProbeMu.Lock()
|
||||
if cached, ok := storageProbeCache[id]; ok && time.Since(cached.at) < storageProbeCacheTTL {
|
||||
storageProbeMu.Unlock()
|
||||
payload.UsedBytes = cached.usage
|
||||
if !cached.probeOk {
|
||||
payload.ProbeError = "storage probe unavailable"
|
||||
}
|
||||
respondJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
storageProbeMu.Unlock()
|
||||
|
||||
// Find the running container. The static plugin's canonical row is
|
||||
// at <id>:site; we also tolerate workloads whose plugin produced
|
||||
// multiple containers by scanning the index.
|
||||
containers, err := s.store.ListContainersByWorkload(id)
|
||||
if err != nil {
|
||||
slog.Error("list containers for storage", "workload", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
probeOk := false
|
||||
for _, c := range containers {
|
||||
if c.ContainerID == "" {
|
||||
continue
|
||||
}
|
||||
// 15s budget — `du` on a Hugo-style `public/` with tens of
|
||||
// thousands of files and a cold page cache can run several
|
||||
// seconds. The cache above keeps the amortized cost small.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
usage, err := s.docker.InspectSiteStorageUsage(ctx, c.ContainerID)
|
||||
cancel()
|
||||
if err != nil {
|
||||
slog.Debug("storage usage probe failed", "workload", id, "container", c.ContainerID, "error", err)
|
||||
continue
|
||||
}
|
||||
payload.UsedBytes = usage.UsedBytes
|
||||
probeOk = true
|
||||
break
|
||||
}
|
||||
if !probeOk {
|
||||
payload.ProbeError = "storage probe unavailable"
|
||||
}
|
||||
|
||||
storageProbeMu.Lock()
|
||||
storageProbeCache[id] = storageProbeEntry{at: time.Now(), usage: payload.UsedBytes, probeOk: probeOk}
|
||||
storageProbeMu.Unlock()
|
||||
|
||||
respondJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// stopStartResult is the JSON shape returned by both stop and start
|
||||
// handlers — counts so the UI can show "1 of 2 containers stopped".
|
||||
type stopStartResult struct {
|
||||
Touched int `json:"touched"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
// stopPluginWorkload handles POST /api/workloads/{id}/stop.
|
||||
//
|
||||
// Stops every container row belonging to the workload via Docker. Does
|
||||
// not remove containers or update runtime state — the reconciler
|
||||
// (internal/workload/plugin/source/static/reconcile.go) flips state to
|
||||
// "stopped"/"failed" on its next pass, and the user can immediately see
|
||||
// the new Docker state via /api/workloads/{id}/containers.
|
||||
//
|
||||
// Returning 200 with a `{touched, failed}` envelope even on partial
|
||||
// failures so the UI can surface "2 of 3 stopped" rather than treating
|
||||
// the whole call as red.
|
||||
func (s *Server) stopPluginWorkload(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetWorkloadByID(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "workload")
|
||||
return
|
||||
}
|
||||
slog.Error("get workload for stop", "workload", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
if s.docker == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "docker client unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
containers, err := s.store.ListContainersByWorkload(id)
|
||||
if err != nil {
|
||||
slog.Error("list containers for stop", "workload", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
result := stopStartResult{}
|
||||
for _, c := range containers {
|
||||
if c.ContainerID == "" {
|
||||
continue
|
||||
}
|
||||
// 30s per-container ctx budget; the third arg to StopContainer
|
||||
// is the in-container SIGTERM grace period before SIGKILL.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
if err := s.docker.StopContainer(ctx, c.ContainerID, 10); err != nil {
|
||||
slog.Warn("stop container failed", "workload", id, "container", c.ContainerID, "error", err)
|
||||
result.Failed++
|
||||
} else {
|
||||
result.Touched++
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
if result.Touched == 0 && result.Failed == 0 {
|
||||
// No live container row to act on — distinguish from a successful
|
||||
// stop of zero containers so the UI can show "nothing to stop"
|
||||
// rather than a misleading green toast.
|
||||
respondError(w, http.StatusConflict, "no running container to stop")
|
||||
return
|
||||
}
|
||||
if result.Touched == 0 && result.Failed > 0 {
|
||||
respondError(w, http.StatusBadGateway, "all containers failed to stop")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// startPluginWorkload handles POST /api/workloads/{id}/start.
|
||||
//
|
||||
// Calls `docker start` on every container row belonging to the
|
||||
// workload. Does not redeploy or recreate; if the container has been
|
||||
// removed externally, start returns an error and the operator should
|
||||
// click Deploy. Same partial-failure envelope as stop.
|
||||
func (s *Server) startPluginWorkload(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetWorkloadByID(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "workload")
|
||||
return
|
||||
}
|
||||
slog.Error("get workload for start", "workload", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
if s.docker == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "docker client unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
containers, err := s.store.ListContainersByWorkload(id)
|
||||
if err != nil {
|
||||
slog.Error("list containers for start", "workload", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
result := stopStartResult{}
|
||||
for _, c := range containers {
|
||||
if c.ContainerID == "" {
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
if err := s.docker.StartContainer(ctx, c.ContainerID); err != nil {
|
||||
slog.Warn("start container failed", "workload", id, "container", c.ContainerID, "error", err)
|
||||
result.Failed++
|
||||
} else {
|
||||
result.Touched++
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
if result.Touched == 0 && result.Failed == 0 {
|
||||
// No persisted container — deploy first to materialize one.
|
||||
respondError(w, http.StatusConflict, "no container to start; deploy first")
|
||||
return
|
||||
}
|
||||
if result.Touched == 0 && result.Failed > 0 {
|
||||
respondError(w, http.StatusBadGateway, "all containers failed to start")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, result)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+146
-7
@@ -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<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
return request<T>(path, signal ? { signal } : undefined);
|
||||
}
|
||||
|
||||
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
function post<T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
|
||||
const init: RequestInit = {
|
||||
method: 'POST',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined
|
||||
});
|
||||
};
|
||||
if (signal) init.signal = signal;
|
||||
return request<T>(path, init);
|
||||
}
|
||||
|
||||
function put<T>(path: string, body: unknown): Promise<T> {
|
||||
@@ -171,8 +173,146 @@ function patch<T>(path: string, body: unknown): Promise<T> {
|
||||
// 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<InspectResult> {
|
||||
return post<InspectResult>('/api/deploy/inspect', { image });
|
||||
export function inspectImage(image: string, signal?: AbortSignal): Promise<InspectResult> {
|
||||
return post<InspectResult>('/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<RepoInfo[]> {
|
||||
return post<RepoInfo[]>('/api/discovery/git/repos', req, signal);
|
||||
}
|
||||
|
||||
export function listGitBranches(
|
||||
req: DiscoveryGitRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<string[]> {
|
||||
return post<string[]>('/api/discovery/git/branches', req, signal);
|
||||
}
|
||||
|
||||
export function listGitTree(req: DiscoveryGitRequest, signal?: AbortSignal): Promise<FolderEntry[]> {
|
||||
return post<FolderEntry[]>('/api/discovery/git/tree', req, signal);
|
||||
}
|
||||
|
||||
export function listImageConflicts(image: string, signal?: AbortSignal): Promise<ImageConflict[]> {
|
||||
return get<ImageConflict[]>(
|
||||
`/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<WorkloadRuntimeState> {
|
||||
return get<WorkloadRuntimeState>(`/api/workloads/${id}/runtime-state`, signal);
|
||||
}
|
||||
|
||||
export function getWorkloadStorage(
|
||||
id: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkloadStorageUsage> {
|
||||
return get<WorkloadStorageUsage>(`/api/workloads/${id}/storage`, signal);
|
||||
}
|
||||
|
||||
export function stopWorkload(id: string): Promise<StopStartResult> {
|
||||
return post<StopStartResult>(`/api/workloads/${id}/stop`);
|
||||
}
|
||||
|
||||
export function startWorkload(id: string): Promise<StopStartResult> {
|
||||
return post<StopStartResult>(`/api/workloads/${id}/start`);
|
||||
}
|
||||
|
||||
// ── Registries ──────────────────────────────────────────────────────
|
||||
@@ -1055,4 +1195,3 @@ export function getLogScanStats(signal?: AbortSignal): Promise<LogScanStats> {
|
||||
return get<LogScanStats>('/api/log-scan-rules/stats', signal);
|
||||
}
|
||||
|
||||
export { ApiError };
|
||||
|
||||
@@ -1253,6 +1253,29 @@
|
||||
"staticRenderMarkdown": "Render markdown",
|
||||
"staticRenderMarkdownDesc": "— auto-render <code>.md</code> 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 <code>api/</code> 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",
|
||||
|
||||
@@ -1253,6 +1253,29 @@
|
||||
"staticRenderMarkdown": "Рендерить markdown",
|
||||
"staticRenderMarkdownDesc": "— автоматически отдавать <code>.md</code> файлы как 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": "Обнаружена папка <code>api/</code> — режим автоматически переключён на 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": "Обновить",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user