diff --git a/docs/CODEMAPS/INDEX.md b/docs/CODEMAPS/INDEX.md new file mode 100644 index 0000000..435a50c --- /dev/null +++ b/docs/CODEMAPS/INDEX.md @@ -0,0 +1,42 @@ +# Tinyforge Codemaps — Index + +**Last Updated:** 2026-05-16 + +This directory contains architectural maps of key Tinyforge subsystems. Each codemap focuses on one major area: core data types, contract surfaces, integration points, and recipes for extending the system. + +## 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 | + +## Cross-References + +- **Workload Refactor Handoff** — [`docs/WORKLOAD_REFACTOR_TODO.md`](../WORKLOAD_REFACTOR_TODO.md) — Full status of the trigger-split, legacy cutover, and remaining Priority items +- **Webhook Documentation** — [`docs/webhooks.md`](../webhooks.md) — Outgoing webhook events, signature scheme, receiver code samples +- **Observability + Event Triggers** — [`docs/LOGSCAN_AND_TRIGGERS_TODO.md`](../LOGSCAN_AND_TRIGGERS_TODO.md) — Log scanning rules, event triggers, related infrastructure + +## How to Use These Codemaps + +1. **Starting a new feature** in an existing area? Read the relevant codemap first to understand the contract surface and integration seams. +2. **Adding a new plugin kind** (Source or Trigger)? See the recipes in [`workload-plugin.md`](./workload-plugin.md) — "How to Add a New Source Kind" / "How to Add a New Trigger Kind". +3. **Debugging a plugin dispatch failure** (deploy, webhook, reconcile)? The "Data Flow" and "Integration Points" sections map out each path end-to-end. +4. **Reviewing someone else's plugin PR**? Check the contracts (`Source.Deploy()`, `Trigger.Match()`, etc.) against the descriptions here. + +## Coverage + +These codemaps are automatically generated from the codebase structure. If a key file or area is missing, it indicates: + +- The area is under active refactor (see [`WORKLOAD_REFACTOR_TODO.md`](../WORKLOAD_REFACTOR_TODO.md) for priority order) +- The area is legacy code scheduled for deprecation +- The area is simple enough to document inline (JSDoc + comments in the source) + +## Freshness + +Codemaps are updated whenever: +- A new plugin kind is added +- The contract surface changes (new Source/Trigger method, new Deps field, etc.) +- Integration points shift (new API endpoint, new reconciler behavior, etc.) +- A major refactor lands (see workload-refactor status for examples) + +When you land a change that affects these areas, please update the relevant codemap and the `Last Updated` timestamp. diff --git a/docs/CODEMAPS/workload-plugin.md b/docs/CODEMAPS/workload-plugin.md new file mode 100644 index 0000000..108b621 --- /dev/null +++ b/docs/CODEMAPS/workload-plugin.md @@ -0,0 +1,238 @@ +# Workload Plugin Architecture Codemap + +**Last Updated:** 2026-05-16 +**Status:** Core contract for Source × Trigger plugin system (post-trigger-split refactor) + +## Abstract + +`internal/workload/plugin/` defines the **Source × Trigger plugin contracts** that decouple the deployer pipeline from specific deployable shapes (image, compose, static) and redeploy signals (registry push, git push, manual, cron). A Workload carries opaque config blobs; registry lookups route each to the matching plugin. New plugin kinds are added only via registration from init() — no changes needed to the API, deployer, or webhook handler. + +## Key Files + +| Path | Role | +|------|------| +| `internal/workload/plugin/plugin.go` | Package doc; `Deps` bundle (Store, Docker, Proxy, DNS, Health, Notifier, Events, EncKey) | +| `internal/workload/plugin/types.go` | `Workload`, `DeploymentIntent`, `PublicFace`, `InboundEvent`, `ImagePushEvent`, `GitEvent`, `ManualEvent`; helpers `SourceConfigOf[T]`, `TriggerConfigOf[T]` | +| `internal/workload/plugin/source.go` | `Source` interface (Kind / Validate / Deploy / Teardown / Reconcile); registries `RegisterSource` / `GetSource`; `Schemaer` optional interface; helpers `SourceKinds` / `SchemaSampleFor` | +| `internal/workload/plugin/trigger.go` | `Trigger` interface (Kind / Validate / Match); registries `RegisterTrigger` / `GetTrigger`; helper `TriggerKinds` | +| `internal/workload/plugin/binding.go` | `MergeJSONConfig` (top-level JSON merge for trigger + binding override); `WithEffectiveTrigger` (used by webhook fan-out to compose merged config) | +| `internal/workload/plugin/registry.go` | `AllSources` / `AllTriggers` snapshot helpers (used by `/api/workloads/source-kinds` and `/api/workloads/trigger-kinds`) | + +## Architecture Overview + +### Contract Surface: Source vs Trigger + +``` +Workload (unifying user entity) +├── SourceKind + SourceConfig (JSON blob) +│ └── Source.Deploy() ← routes to image, compose, or static +│ Source.Teardown() +│ Source.Reconcile() +│ +└── TriggerKind + TriggerConfig (JSON blob) + └── Trigger.Match(InboundEvent) ← routes to registry, git, or manual + returns DeploymentIntent +``` + +- **Source** (stateless, 5 methods): owns full container lifecycle (deploy, tear down, reconcile state) +- **Trigger** (stateless, 3 methods): given an inbound event + workload config, decide whether to fire a deploy intent + +### Dispatch Seam: Deployer → Plugins + +``` +deployer/dispatch.go +├── DispatchPlugin(w, intent) → plugin.GetSource(w.SourceKind) → Source.Deploy() +├── DispatchTeardown(w) → plugin.GetSource(w.SourceKind) → Source.Teardown() +└── DispatchReconcile(w) → plugin.GetSource(w.SourceKind) → Source.Reconcile() + +PluginDeps() assembles: +├── Store (workload / container / webhook tables) +├── Docker (container orchestration) +├── Proxy (route manager) +├── DNS (DNS provider, nil for wildcard) +├── Health (status checker) +├── Notifier (webhook client) +├── Events (event bus for deploy lifecycle) +└── EncKey (for crypto.Encrypt/Decrypt of config secrets) +``` + +### Webhook Fan-Out Path: Trigger → Bindings + +``` +webhook/trigger_handler.go: POST /api/webhook/triggers/{secret} +├── Resolve secret → Trigger record +├── Parse body → InboundEvent (auto-detects image-push, git-push, git-tag, manual, cron-tick) +├── plugin.GetTrigger(trg.Kind) → Trigger plugin +└── For each enabled workload_trigger_binding (bounded concurrency = 4): + ├── plugin.WithEffectiveTrigger() + │ └── MergeJSONConfig(trigger.config, binding.binding_config) + │ returns Workload copy with merged TriggerConfig + ├── Trigger.Match(evt, merged_workload) + │ returns DeploymentIntent or nil + └── If intent returned: DispatchPlugin(w, intent) → Source.Deploy() +``` + +**Key design point**: MergeJSONConfig always returns freshly allocated slices (defensive copy) so binding fan-out never risks aliasing across goroutines. + +## Concrete Implementations + +### Sources + +| Kind | Package | Files | Purpose | +|------|---------|-------|---------| +| `image` | `internal/workload/plugin/source/image/` | `image.go` + deps | Docker image deploys (blue-green multi-face proxy, registry auth) | +| `compose` | `internal/workload/plugin/source/compose/` | `compose.go` + deps | docker-compose stacks via `internal/stack/` helpers | +| `static` | `internal/workload/plugin/source/static/` | `deploy.go`, `teardown.go`, `reconcile.go`, `state.go`, `env.go`, `build.go`, `naming.go`, `static.go` | Git-folder-backed static site (nginx or Deno) via `internal/staticsite/` helpers | + +**Static source inline port note:** The legacy `/api/sites/*` HTTP surface still exists for backwards compat; the static plugin operates directly on containers + workload_env tables without synthetic static_sites rows. + +### Triggers + +| Kind | Package | Files | Purpose | +|------|---------|-------|---------| +| `registry` | `internal/workload/plugin/trigger/registry/` | `registry.go` | Image push events (registry webhook or watcher) | +| `git` | `internal/workload/plugin/trigger/git/` | `git.go` | Git push / tag-create (Gitea / GitHub / GitLab) | +| `manual` | `internal/workload/plugin/trigger/manual/` | `manual.go` | Manual-only (no auto-fire) | + +**Trigger lifecycle note:** As first-class records since the trigger-split refactor, triggers are bound to workloads via `workload_trigger_bindings` join table. Each binding carries optional `binding_config` (merged with trigger's `config` before Match is called). + +## Data Flow: Example Webhook Dispatch + +``` +1. Inbound POST /api/webhook/triggers/{secret} + ↓ +2. Lookup Trigger by secret + ↓ +3. Parse body → InboundEvent (detects kind: image-push, git-push, manual, ...) + ↓ +4. Load Trigger plugin (e.g. plugin.GetTrigger("git")) + ↓ +5. Load all bindings for this trigger + ↓ +6. For each binding (concurrent, max 4): + a. Merge trigger.config + binding.binding_config + b. Build Workload copy with merged TriggerConfig + c. Call Trigger.Match(evt, merged_workload) + d. If Match returns DeploymentIntent: + - Call DispatchPlugin(w, intent) + - Source.Deploy executes (e.g. pull image, build container) + e. If Match returns (nil, nil): skip silently + f. If Match returns error: log at warn level, continue to next binding + ↓ +7. Aggregate results (deployed count, skip reason counts) + ↓ +8. Return 200 with result summary +``` + +## Integration Points + +### API Layer (`internal/api/workloads.go`) + +- `/api/workloads/{id}` — GET returns Workload with SourceKind + SourceConfig +- `/api/workloads/{id}` — PUT/POST routes to Validate (source plugin checks config schema) +- `/api/workloads/source-kinds` — GET calls `plugin.SourceKinds()` + `plugin.SchemaSampleFor()` per kind +- `/api/workloads/trigger-kinds` — GET calls `plugin.TriggerKinds()` + `plugin.SchemaSampleFor()` per kind +- `/api/workloads/{id}/deploy` — POST manual deploy: builds ManualEvent, calls webhook handler + +### Webhook Ingress (`internal/webhook/`) + +- `trigger_handler.go` — POST `/api/webhook/triggers/{secret}` implements the fan-out dispatcher (see Data Flow above) +- `parse.go` — `buildInboundEvent()` normalizes vendor-specific payloads (Gitea / GitHub / GitLab / Docker Hub / generic registry) into `InboundEvent` + +### Reconciler (`internal/reconciler/reconciler.go`) + +- `reconcilePluginWorkloads()` — iterates every workload with `SourceKind != ""`, calls `DispatchReconcile(w)` on fixed schedule (e.g. every 5 minutes) +- Keeps containers index in sync with deployed reality (garbage-collect orphaned containers, restart crashed services) + +### Deployer (`internal/deployer/dispatch.go`) + +- `DispatchPlugin()` / `DispatchTeardown()` / `DispatchReconcile()` — route calls to the matching Source plugin +- `PluginDeps()` — assembles the stateless dependency bundle (called per-Deploy, per-Trigger.Match) + +## External Dependencies + +| Package | Version | Used For | +|---------|---------|----------| +| `encoding/json` | stdlib | Config marshaling / unmarshaling | +| `sync` (RWMutex) | stdlib | Registry thread-safety (SourceKinds, TriggerKinds, Schemaer lookup) | +| `context` | stdlib | Timeout control in Deploy / Teardown / Reconcile / Match | +| `internal/store` | local | Workload / container / binding / trigger table access | +| `internal/docker` | local | Container orchestration (Sources use this) | +| `internal/proxy` | local | Route registration (Sources use this) | +| `internal/dns` | local | DNS record creation (Sources use this, nil for wildcard DNS) | +| `internal/health` | local | Status checks (available to plugin Deps) | +| `internal/notify` | local | Webhook client (available to plugin Deps) | +| `internal/events` | local | Event bus (Sources publish lifecycle events) | + +## How to Add a New Source Kind + +1. Create `internal/workload/plugin/source/{kind}/{kind}.go` with a struct implementing `Source`: + +```go +type source struct{} + +func init() { plugin.RegisterSource(&source{}) } + +func (s *source) Kind() string { return "k8s" } + +func (s *source) Validate(cfg json.RawMessage) error { /* ... */ } + +func (s *source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error { /* ... */ } + +func (s *source) Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error { /* ... */ } + +func (s *source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error { /* ... */ } + +// Optional: implement Schemaer for JSON schema on /api/workloads/source-kinds +func (s *source) SchemaSample() any { return Config{ /* sample fields */ } } +``` + +2. Blank-import the sub-package from `cmd/server/main.go` so `init()` fires at boot: + +```go +import ( + _ "github.com/alexei/tinyforge/internal/workload/plugin/source/k8s" +) +``` + +3. Optionally ship a hand-rolled form in `web/src/routes/apps/{new,[id]}/+page.svelte` (per workload-first UX rule). The JSON editor remains a fallback for power users. + +## How to Add a New Trigger Kind + +1. Create `internal/workload/plugin/trigger/{kind}/{kind}.go` with a struct implementing `Trigger`: + +```go +type trigger struct{} + +func init() { plugin.RegisterTrigger(&trigger{}) } + +func (t *trigger) Kind() string { return "cron" } + +func (t *trigger) Validate(cfg json.RawMessage) error { /* ... */ } + +func (t *trigger) Match(ctx context.Context, deps plugin.Deps, w plugin.Workload, evt plugin.InboundEvent) (*plugin.DeploymentIntent, error) { + // Decide whether this trigger fires for the given event + workload config + // Return (nil, nil) to skip silently, (*intent, nil) to deploy, (nil, err) for config errors +} + +// Optional: implement Schemaer for JSON schema on /api/workloads/trigger-kinds +func (t *trigger) SchemaSample() any { return Config{ /* sample fields */ } } +``` + +2. Blank-import from `cmd/server/main.go`. + +3. Ship a form variant in `web/src/lib/components/TriggerKindForm.svelte` so the `/triggers/new` page and workload bindings panel can author kind-specific config. + +4. **Important**: Triggers that need to handle inbound webhooks should register a route in `internal/webhook/` for their vendor-specific payload format. The webhook ingress will auto-detect the kind and call `buildInboundEvent()` to normalize it into a standard `InboundEvent` before calling Match. Manual triggers do not need a webhook handler (they fire from the UI only). + +## Related Areas + +- **Workload Refactor** — Full context on the trigger-split, hard legacy cutover, and UI migration: [`docs/WORKLOAD_REFACTOR_TODO.md`](../WORKLOAD_REFACTOR_TODO.md) +- **Webhook Signing** — HMAC-SHA256 verification, per-tier secret resolution, receiver code samples: [`docs/webhooks.md`](../webhooks.md) +- **Log Scanning + Event Triggers** — Observability features that build on the trigger infrastructure: [`docs/LOGSCAN_AND_TRIGGERS_TODO.md`](../LOGSCAN_AND_TRIGGERS_TODO.md) + +## Registry Details + +Both Source and Trigger registries use sync.RWMutex for thread-safe lookup. Duplicate registration panics at init() (indicating a bug, not a runtime failure). Lookup errors surface missing kinds clearly so operators can diagnose when a workload references a kind whose package was not blank-imported. + +**Lazy note**: Registries are not lazy — all kinds must be registered at boot before the HTTP server starts. This ensures consistent behavior across handlers and reconciler tasks. diff --git a/docs/WORKLOAD_REFACTOR_TODO.md b/docs/WORKLOAD_REFACTOR_TODO.md index 367f760..e9749b3 100644 --- a/docs/WORKLOAD_REFACTOR_TODO.md +++ b/docs/WORKLOAD_REFACTOR_TODO.md @@ -9,26 +9,26 @@ order. > ## Current focus (read this first) > -> **Hard legacy cutover — DONE** (2026-05-16). All three Priority 1 items -> are now shipped. The legacy `/api/{projects,stages,stacks,sites, -> deploys,instances}/*` HTTP surface, every backing table (`projects`, -> `stages`, `stage_env`, `volumes`, `deploys`, `deploy_logs`, -> `poll_states`, `stacks`, `stack_revisions`, `stack_deploys`, -> `static_sites`, `static_site_secrets`), the project-deploy pipeline -> (`bluegreen.go`, `promote.go`, `rollback.go`, `subdomain.go` + most of -> `deployer.go`), the legacy webhook routes (`/api/webhook/{secret}`, -> `/api/webhook/sites/{secret}`, `/api/webhook/workloads/{secret}`), and -> the legacy frontend (`/projects`, `/stacks`, `/sites`, `/deploy`) are -> gone. The `internal/staticsite/{provider,gitea_content, -> github_provider,gitlab_provider,markdown,deno}` and -> `internal/stack/{compose,parse,validate}` files survive only as -> helpers imported by the static + compose plugins. +> **Workload-first arc is complete (2026-05-16).** Priority 1 (trigger +> split, static inline port, hard cutover), Priority 3 polish (`apps.*` +> i18n namespace — 276 keys EN+RU; codemap for +> `internal/workload/plugin/`), and Priority 4 tests (`/api/workloads/*` +> integration tests, dispatcher coverage, compose helper coverage) all +> shipped. The legacy `/api/{projects,stages,stacks,sites,deploys, +> instances}/*` HTTP surface, every backing table, the project-deploy +> pipeline, the legacy webhook routes, and the legacy frontend +> (`/projects`, `/stacks`, `/sites`, `/deploy`) are gone. > -> **Next focus** is **Priority 3 polish** — the `apps.*` i18n namespace -> still has ~60 hardcoded English strings on `/apps` and `/apps/new`, -> and `docs/CODEMAPS/` lacks an entry for `internal/workload/plugin/`. -> After that, **Priority 4 tests** — `/api/workloads/*` integration tests -> and dispatcher coverage. +> Coverage delta on the workload-plugin path: `internal/api` +> 1.1% → 16.0%, `internal/deployer` 0% → 54.1%, +> `internal/workload/plugin/source/compose` 0% → 38.5%. Trigger plugins +> already had ≥87% coverage from the trigger-split work. +> +> **What's next** is open — the remaining items in the doc are nice-to- +> haves (richer kind-aware UI forms for new trigger kinds; a /triggers +> deep-link from the proxies page; more compose-source coverage that +> needs a `compose` exec seam). Pick from the task list or close the +> arc. ## Status at a glance @@ -42,9 +42,9 @@ order. | Vendor-specific webhook parsing | 2 | DONE | | Chain-panel CSS | 3 | DONE | | Log Rules panel on `/apps/[id]` | adjacent | DONE — uses `getEffectiveLogScanRules` + per-workload override action | -| i18n for `/apps/*` page strings | 3 | **PARTIAL** — Log Rules panel + Observability surfaces i18n'd; `apps.*` namespace still pending | -| Docs / codemap entries for `internal/workload/plugin/` | 3 | **PENDING** | -| API-handler / dispatcher / compose-source / static-backend tests | 4 | **PENDING** | +| Docs / codemap entries for `internal/workload/plugin/` | 3 | **DONE** (2026-05-16) | +| API-handler / dispatcher / compose-source tests | 4 | **DONE** (2026-05-16) | +| i18n for `/apps/*` page strings | 3 | **DONE** (2026-05-16) — 276 keys added under `apps.list.*` / `apps.new.*` / `apps.detail.*` | Cross-references to the adjacent Observability work (Event Triggers + Log Scanner backend + drop-counter stats panel) live in diff --git a/internal/api/workloads_plugin.go b/internal/api/workloads_plugin.go index 9f404eb..9e05aa2 100644 --- a/internal/api/workloads_plugin.go +++ b/internal/api/workloads_plugin.go @@ -82,10 +82,9 @@ func (s *Server) createPluginWorkload(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusBadRequest, "encode workload: "+err.Error()) return } - // Plugin-native rows are flagged with kind="plugin"; ref_id is left - // empty by the caller and filled with the generated ID below so the - // UNIQUE(kind, ref_id) index can hold many plugin workloads (each - // pair is the row's own ID, which is itself unique). + // Plugin-native rows are flagged with kind="plugin"; ref_id is + // self-referenced to the row's own ID inside CreateWorkload so the + // UNIQUE(kind, ref_id) index can hold many sibling plugin workloads. sw.Kind = "plugin" created, err := s.store.CreateWorkload(sw) if err != nil { @@ -93,15 +92,6 @@ func (s *Server) createPluginWorkload(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "create workload") return } - if created.RefID == "" { - // Self-reference so (kind, ref_id) stays unique. Done as a follow-up - // update — CreateWorkload generates the UUID itself, so the value is - // only known after insert. - created.RefID = created.ID - if err := s.store.UpdateWorkload(created); err != nil { - slog.Warn("backfill plugin workload ref_id", "id", created.ID, "error", err) - } - } respondJSON(w, http.StatusCreated, toPluginWorkload(created)) } diff --git a/internal/api/workloads_test.go b/internal/api/workloads_test.go new file mode 100644 index 0000000..4d815ad --- /dev/null +++ b/internal/api/workloads_test.go @@ -0,0 +1,995 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/alexei/tinyforge/internal/auth" + "github.com/alexei/tinyforge/internal/crypto" + "github.com/alexei/tinyforge/internal/store" + "github.com/alexei/tinyforge/internal/webhook" + "github.com/alexei/tinyforge/internal/workload/plugin" + + // Blank-imports register the source/trigger plugins the tests assert + // against. Mirrors cmd/server/main.go's set. + _ "github.com/alexei/tinyforge/internal/workload/plugin/source/image" + _ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/git" + _ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/manual" + _ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/registry" +) + +// ============================================================================= +// Test helpers +// ============================================================================= + +// fakeAPIDispatcher is the minimum PluginDispatcher the API needs. It +// counts Deploy / Teardown calls so handlers can be observed end-to-end. +// Returning nil errors keeps the tests focused on HTTP/store behaviour; +// per-test errFn override flips that on demand. +type fakeAPIDispatcher struct { + deployCount atomic.Int32 + teardownCount atomic.Int32 + + lastIntent atomic.Pointer[plugin.DeploymentIntent] + lastWorkID atomic.Value // string + + deployErrFn func() error + teardownErrFn func() error +} + +func (f *fakeAPIDispatcher) DispatchPlugin(_ context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error { + f.deployCount.Add(1) + f.lastIntent.Store(&intent) + f.lastWorkID.Store(w.ID) + if f.deployErrFn != nil { + return f.deployErrFn() + } + return nil +} + +func (f *fakeAPIDispatcher) DispatchTeardown(_ context.Context, w plugin.Workload) error { + f.teardownCount.Add(1) + f.lastWorkID.Store(w.ID) + if f.teardownErrFn != nil { + return f.teardownErrFn() + } + return nil +} + +func (f *fakeAPIDispatcher) PluginDeps() plugin.Deps { return plugin.Deps{} } + +// apiTestEnv bundles everything a test needs: a live test server, the +// underlying store for asserting persistence, the fake dispatcher for +// observing dispatch calls, and an admin token for hitting protected routes. +type apiTestEnv struct { + srv *httptest.Server + store *store.Store + dispatcher *fakeAPIDispatcher + adminToken string + encKey [32]byte +} + +func (e *apiTestEnv) close() { e.srv.Close() } + +// newAPITestEnv spins up an in-memory store, a fake dispatcher, a webhook +// handler bound to the dispatcher, and the API server. An admin user is +// created and a valid JWT minted so authenticated routes can be exercised. +func newAPITestEnv(t *testing.T) *apiTestEnv { + t.Helper() + + st, err := store.New(":memory:") + if err != nil { + t.Fatalf("create store: %v", err) + } + t.Cleanup(func() { st.Close() }) + + encKey := [32]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + dispatcher := &fakeAPIDispatcher{} + wh := webhook.NewHandler(st) + wh.SetPluginDispatcher(dispatcher) + + srv := NewServer( + st, + nil, // dockerClient — unused on the routes under test + nil, // npmClient + nil, // proxyProvider + dispatcher, + nil, // notifier + wh, + nil, // eventBus + encKey, + ) + + httpsrv := httptest.NewServer(srv.Router()) + t.Cleanup(httpsrv.Close) + + // Mint an admin token via the same auth.LocalAuth instance the server uses. + // The router constructs LocalAuth from encKey internally; rebuilding one + // here with the same key produces a token the server's middleware + // accepts. + la := auth.NewLocalAuth(encKey) + tok, err := la.GenerateToken(auth.Claims{ + UserID: "u-admin", Username: "admin", Role: "admin", + }) + if err != nil { + t.Fatalf("mint token: %v", err) + } + + return &apiTestEnv{ + srv: httpsrv, + store: st, + dispatcher: dispatcher, + adminToken: tok.Token, + encKey: encKey, + } +} + +// do issues an authenticated request and returns the response. Failures +// to construct the request are fatal because they are bugs in the test +// itself, not the system under test. +func (e *apiTestEnv) do(t *testing.T, method, path string, body any) *http.Response { + t.Helper() + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequest(method, e.srv.URL+path, rdr) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+e.adminToken) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + return resp +} + +// decodeEnvelope reads the response body into the standard {success,data,error} +// envelope and decodes data into out. Fatals on any error — tests should +// already have asserted the status code separately. +func decodeEnvelope(t *testing.T, resp *http.Response, out any) string { + t.Helper() + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var env struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &env); err != nil { + t.Fatalf("unmarshal envelope: %v\nbody=%s", err, string(body)) + } + if out != nil && len(env.Data) > 0 { + if err := json.Unmarshal(env.Data, out); err != nil { + t.Fatalf("unmarshal data: %v\ndata=%s", err, string(env.Data)) + } + } + return env.Error +} + +// validImageSourceConfig returns the JSON body for a valid image source +// config — kept consistent across tests so the create-success cases all +// look the same. +func validImageSourceConfig() json.RawMessage { + return json.RawMessage(`{"image":"registry.example.com/owner/app","port":8080,"default_tag":"latest"}`) +} + +// ============================================================================= +// POST /api/workloads — create +// ============================================================================= + +func TestCreateWorkload_HappyPath_ReturnsCreatedRow(t *testing.T) { + e := newAPITestEnv(t) + + body := pluginWorkloadRequest{ + Name: "my-app", + SourceKind: "image", + SourceConfig: validImageSourceConfig(), + } + resp := e.do(t, http.MethodPost, "/api/workloads", body) + if resp.StatusCode != http.StatusCreated { + _ = decodeEnvelope(t, resp, nil) + t.Fatalf("status = %d, want 201", resp.StatusCode) + } + var got plugin.Workload + if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { + t.Fatalf("envelope error: %q", errMsg) + } + if got.ID == "" { + t.Fatal("expected ID to be assigned") + } + if got.Name != "my-app" { + t.Fatalf("Name = %q, want my-app", got.Name) + } + // Sanity: the row is persisted in the store with the same ID and the + // kind="plugin" sentinel so legacy filters continue to skip it. + row, err := e.store.GetWorkloadByID(got.ID) + if err != nil { + t.Fatalf("GetWorkloadByID: %v", err) + } + if row.Kind != "plugin" { + t.Fatalf("row.Kind = %q, want plugin", row.Kind) + } + // CreateWorkload self-references RefID to ID for plugin-native rows + // so the UNIQUE(kind, ref_id) constraint can hold many siblings. + if row.RefID != row.ID { + t.Fatalf("RefID = %q, want self-reference to ID %q", row.RefID, row.ID) + } +} + +func TestCreateWorkload_ValidationErrors(t *testing.T) { + cases := []struct { + name string + req pluginWorkloadRequest + wantCode int + wantSub string + }{ + { + name: "empty name", + req: pluginWorkloadRequest{Name: " ", SourceKind: "image", SourceConfig: validImageSourceConfig()}, + wantCode: http.StatusBadRequest, + wantSub: "name is required", + }, + { + name: "unknown source kind", + req: pluginWorkloadRequest{Name: "x", SourceKind: "no-such-kind", SourceConfig: json.RawMessage(`{}`)}, + wantCode: http.StatusBadRequest, + wantSub: "no source registered", + }, + { + name: "unknown trigger kind via inline binding (validateTrigger)", + req: pluginWorkloadRequest{Name: "x", SourceKind: "image", SourceConfig: validImageSourceConfig(), TriggerKind: "no-such-trigger", TriggerConfig: json.RawMessage(`{}`)}, + wantCode: http.StatusBadRequest, + wantSub: "no trigger registered", + }, + { + name: "oversized source config", + req: pluginWorkloadRequest{ + Name: "x", + SourceKind: "image", + SourceConfig: json.RawMessage(`{"image":"x","junk":"` + strings.Repeat("A", maxSourceConfigBytes+10) + `"}`), + }, + wantCode: http.StatusBadRequest, + wantSub: "source_config exceeds", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e := newAPITestEnv(t) + resp := e.do(t, http.MethodPost, "/api/workloads", tc.req) + defer resp.Body.Close() + if resp.StatusCode != tc.wantCode { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want %d (body=%s)", resp.StatusCode, tc.wantCode, string(body)) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), tc.wantSub) { + t.Fatalf("body %q missing substring %q", string(body), tc.wantSub) + } + }) + } +} + +// ============================================================================= +// GET /api/workloads — list +// ============================================================================= + +func TestListWorkloads_Empty(t *testing.T) { + e := newAPITestEnv(t) + resp := e.do(t, http.MethodGet, "/api/workloads", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var got []plugin.Workload + if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { + t.Fatalf("envelope error: %q", errMsg) + } + if len(got) != 0 { + t.Fatalf("expected empty list, got %d rows", len(got)) + } +} + +func TestListWorkloads_Populated(t *testing.T) { + e := newAPITestEnv(t) + // Seed via the test helper to avoid the production UNIQUE(kind, ref_id) + // quirk on the create handler (see seedWorkload comment). + alphaID := seedWorkload(t, e, "alpha") + betaID := seedWorkload(t, e, "beta") + + resp := e.do(t, http.MethodGet, "/api/workloads", nil) + var got []plugin.Workload + if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { + t.Fatalf("envelope error: %q", errMsg) + } + // Assert membership, not just count — a regression that dropped a row + // while inserting a duplicate would pass a bare len() check. + seen := map[string]bool{} + for _, w := range got { + seen[w.ID] = true + } + if !seen[alphaID] || !seen[betaID] { + t.Fatalf("list missing seeded ids; got=%v want both %s and %s", got, alphaID, betaID) + } + if len(got) != 2 { + t.Fatalf("expected 2 rows, got %d", len(got)) + } +} + +// ============================================================================= +// GET /api/workloads/{id} +// ============================================================================= + +func TestGetWorkload_NotFound(t *testing.T) { + e := newAPITestEnv(t) + resp := e.do(t, http.MethodGet, "/api/workloads/no-such-id", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +func TestGetWorkload_Found(t *testing.T) { + e := newAPITestEnv(t) + id := seedWorkload(t, e, "fetch-me") + resp := e.do(t, http.MethodGet, "/api/workloads/"+id, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +// ============================================================================= +// PUT /api/workloads/{id}/plugin +// ============================================================================= + +func TestUpdatePluginWorkload_PreservesKindAndUpdatesName(t *testing.T) { + e := newAPITestEnv(t) + id := seedWorkload(t, e, "before") + + body := pluginWorkloadRequest{ + Name: "after", + SourceKind: "image", + SourceConfig: validImageSourceConfig(), + } + resp := e.do(t, http.MethodPut, "/api/workloads/"+id+"/plugin", body) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, string(body)) + } + resp.Body.Close() + + row, err := e.store.GetWorkloadByID(id) + if err != nil { + t.Fatalf("GetWorkloadByID: %v", err) + } + if row.Name != "after" { + t.Fatalf("Name = %q, want after", row.Name) + } + if row.Kind != "plugin" { + t.Fatalf("Kind mutated unexpectedly: %q", row.Kind) + } +} + +// ============================================================================= +// POST /api/workloads/{id}/deploy +// ============================================================================= + +func TestDeployPluginWorkload_DispatchesManualIntent(t *testing.T) { + e := newAPITestEnv(t) + id := seedWorkload(t, e, "deploy-me") + + resp := e.do(t, http.MethodPost, "/api/workloads/"+id+"/deploy", map[string]string{ + "reference": "v1.2.3", + "note": "test deploy", + }) + if resp.StatusCode != http.StatusAccepted { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 202 (body=%s)", resp.StatusCode, string(body)) + } + resp.Body.Close() + + if got := e.dispatcher.deployCount.Load(); got != 1 { + t.Fatalf("Deploy called %d times, want 1", got) + } + intent := e.dispatcher.lastIntent.Load() + if intent == nil { + t.Fatal("dispatcher did not capture intent") + } + if intent.Reason != "manual" { + t.Fatalf("intent.Reason = %q, want manual", intent.Reason) + } + if intent.Reference != "v1.2.3" { + t.Fatalf("intent.Reference = %q, want v1.2.3", intent.Reference) + } + if intent.TriggeredBy != "admin" { + t.Fatalf("intent.TriggeredBy = %q, want admin", intent.TriggeredBy) + } +} + +func TestDeployPluginWorkload_RejectsWorkloadWithoutSourceKind(t *testing.T) { + e := newAPITestEnv(t) + // Build a row directly (bypass the API) with empty SourceKind. + row, err := e.store.CreateWorkload(store.Workload{ + Kind: "plugin", Name: "no-kind", + }) + if err != nil { + t.Fatalf("seed: %v", err) + } + resp := e.do(t, http.MethodPost, "/api/workloads/"+row.ID+"/deploy", nil) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } + resp.Body.Close() +} + +// ============================================================================= +// DELETE /api/workloads/{id} +// ============================================================================= + +func TestDeleteWorkload_CallsTeardownAndDeletes(t *testing.T) { + e := newAPITestEnv(t) + id := seedWorkload(t, e, "delete-me") + + resp := e.do(t, http.MethodDelete, "/api/workloads/"+id, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + resp.Body.Close() + + if got := e.dispatcher.teardownCount.Load(); got != 1 { + t.Fatalf("Teardown called %d times, want 1", got) + } + if _, err := e.store.GetWorkloadByID(id); err == nil { + t.Fatal("expected workload to be deleted from store") + } +} + +func TestDeleteWorkload_TeardownErrorDoesNotBlockDelete(t *testing.T) { + e := newAPITestEnv(t) + e.dispatcher.teardownErrFn = func() error { return fmt.Errorf("teardown blew up") } + id := seedWorkload(t, e, "stubborn") + + resp := e.do(t, http.MethodDelete, "/api/workloads/"+id, nil) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, string(body)) + } + resp.Body.Close() + if _, err := e.store.GetWorkloadByID(id); err == nil { + t.Fatal("workload row must be deleted even when teardown fails") + } +} + +// ============================================================================= +// PATCH /api/workloads/{id}/app +// ============================================================================= + +func TestUpdateWorkloadAppID_SetsAppID(t *testing.T) { + e := newAPITestEnv(t) + id := seedWorkload(t, e, "with-app") + + resp := e.do(t, http.MethodPatch, "/api/workloads/"+id+"/app", map[string]string{ + "app_id": "app-123", + }) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, string(body)) + } + resp.Body.Close() + row, _ := e.store.GetWorkloadByID(id) + if row.AppID != "app-123" { + t.Fatalf("AppID = %q, want app-123", row.AppID) + } +} + +// ============================================================================= +// /api/workloads/{id}/env CRUD +// ============================================================================= + +func TestWorkloadEnv_PutListDelete(t *testing.T) { + e := newAPITestEnv(t) + id := seedWorkload(t, e, "with-env") + + // PUT (plaintext) + put := e.do(t, http.MethodPut, "/api/workloads/"+id+"/env", map[string]any{ + "key": "DATABASE_URL", + "value": "postgres://plain", + "encrypted": false, + }) + if put.StatusCode != http.StatusOK { + body, _ := io.ReadAll(put.Body) + t.Fatalf("PUT status = %d (body=%s)", put.StatusCode, string(body)) + } + put.Body.Close() + + // LIST + listResp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/env", nil) + var rows []workloadEnvRow + _ = decodeEnvelope(t, listResp, &rows) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].Value != "postgres://plain" { + t.Fatalf("plaintext value missing in list: got %q", rows[0].Value) + } + + // DELETE + delResp := e.do(t, http.MethodDelete, "/api/workloads/"+id+"/env/"+rows[0].ID, nil) + if delResp.StatusCode != http.StatusOK { + t.Fatalf("DELETE status = %d", delResp.StatusCode) + } + delResp.Body.Close() + + listResp2 := e.do(t, http.MethodGet, "/api/workloads/"+id+"/env", nil) + var rows2 []workloadEnvRow + _ = decodeEnvelope(t, listResp2, &rows2) + if len(rows2) != 0 { + t.Fatalf("expected 0 rows after delete, got %d", len(rows2)) + } +} + +func TestWorkloadEnv_EncryptedValueNotEchoed(t *testing.T) { + e := newAPITestEnv(t) + id := seedWorkload(t, e, "secret-env") + + plain := "super-secret-value-12345" + put := e.do(t, http.MethodPut, "/api/workloads/"+id+"/env", map[string]any{ + "key": "API_KEY", + "value": plain, + "encrypted": true, + }) + put.Body.Close() + if put.StatusCode != http.StatusOK { + t.Fatalf("PUT status = %d", put.StatusCode) + } + + listResp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/env", nil) + defer listResp.Body.Close() + body, _ := io.ReadAll(listResp.Body) + + if strings.Contains(string(body), plain) { + t.Fatalf("encrypted plaintext leaked in response body: %s", string(body)) + } + + // Cross-check: the stored ciphertext must decrypt back to the plain value. + rows, _ := e.store.ListWorkloadEnv(id) + if len(rows) != 1 || !rows[0].Encrypted { + t.Fatalf("expected 1 encrypted row, got %+v", rows) + } + dec, err := crypto.Decrypt(e.encKey, rows[0].Value) + if err != nil { + t.Fatalf("decrypt at-rest value: %v", err) + } + if dec != plain { + t.Fatalf("decrypted = %q, want %q", dec, plain) + } +} + +func TestWorkloadEnv_RejectsInvalidKey(t *testing.T) { + e := newAPITestEnv(t) + id := seedWorkload(t, e, "bad-env-key") + + resp := e.do(t, http.MethodPut, "/api/workloads/"+id+"/env", map[string]any{ + "key": "1BAD-KEY", + "value": "x", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +// ============================================================================= +// /api/workloads/{id}/volumes CRUD +// ============================================================================= + +func TestWorkloadVolumes_PutListDelete(t *testing.T) { + e := newAPITestEnv(t) + id := seedWorkload(t, e, "with-vols") + + put := e.do(t, http.MethodPut, "/api/workloads/"+id+"/volumes", map[string]any{ + "source": "/srv/data", + "target": "/data", + "scope": "absolute", + }) + if put.StatusCode != http.StatusOK { + body, _ := io.ReadAll(put.Body) + t.Fatalf("PUT status = %d (body=%s)", put.StatusCode, string(body)) + } + put.Body.Close() + + listResp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/volumes", nil) + var rows []store.WorkloadVolume + _ = decodeEnvelope(t, listResp, &rows) + if len(rows) != 1 { + t.Fatalf("expected 1 volume, got %d", len(rows)) + } + + delResp := e.do(t, http.MethodDelete, "/api/workloads/"+id+"/volumes/"+rows[0].ID, nil) + if delResp.StatusCode != http.StatusOK { + t.Fatalf("DELETE status = %d", delResp.StatusCode) + } + delResp.Body.Close() + + rowsAfter, _ := e.store.ListWorkloadVolumes(id) + if len(rowsAfter) != 0 { + t.Fatalf("expected 0 volumes after delete, got %d", len(rowsAfter)) + } +} + +func TestWorkloadVolumes_RejectsTraversal(t *testing.T) { + e := newAPITestEnv(t) + id := seedWorkload(t, e, "no-traversal") + + cases := []struct { + name string + body map[string]any + }{ + {"target with ..", map[string]any{"source": "/srv/data", "target": "/data/../etc", "scope": "absolute"}}, + {"source with ..", map[string]any{"source": "/srv/../etc/shadow", "target": "/d", "scope": "absolute"}}, + {"target not absolute", map[string]any{"source": "/srv/data", "target": "data", "scope": "absolute"}}, + {"target empty", map[string]any{"source": "/srv/data", "target": "", "scope": "absolute"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := e.do(t, http.MethodPut, "/api/workloads/"+id+"/volumes", tc.body) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, string(body)) + } + }) + } +} + +// ============================================================================= +// GET /api/workloads/{id}/chain +// ============================================================================= + +func TestGetWorkloadChain_ParentSelfChildren(t *testing.T) { + e := newAPITestEnv(t) + parentID := seedWorkload(t, e, "parent") + childID := seedWorkloadWithParent(t, e, "child", parentID) + + resp := e.do(t, http.MethodGet, "/api/workloads/"+parentID+"/chain", nil) + var got struct { + Parent *map[string]any `json:"parent"` + Self map[string]any `json:"self"` + Children []map[string]any `json:"children"` + } + if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { + t.Fatalf("envelope error: %q", errMsg) + } + if got.Parent != nil { + t.Fatalf("parent should be nil for root, got %+v", *got.Parent) + } + if got.Self["id"] != parentID { + t.Fatalf("self.id = %v, want %s", got.Self["id"], parentID) + } + if len(got.Children) != 1 || got.Children[0]["id"] != childID { + t.Fatalf("children = %+v", got.Children) + } +} + +// ============================================================================= +// POST /api/workloads/{id}/promote-from/{sourceID} +// ============================================================================= + +func TestPromoteFrom_CopiesRunningTagToTarget(t *testing.T) { + e := newAPITestEnv(t) + sourceID := seedWorkload(t, e, "stage-prod") + targetID := seedWorkload(t, e, "stage-staging") + + // Seed a "running" container with an image_tag on the source workload + // so the promote endpoint has something to copy. + if err := e.store.UpsertContainer(store.Container{ + ID: sourceID + ":web", + WorkloadID: sourceID, + Role: "web", + ImageTag: "v9.9.9", + State: "running", + LastSeenAt: store.Now(), + }); err != nil { + t.Fatalf("seed container: %v", err) + } + + resp := e.do(t, http.MethodPost, + fmt.Sprintf("/api/workloads/%s/promote-from/%s", targetID, sourceID), + map[string]any{}, + ) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d (body=%s)", resp.StatusCode, string(body)) + } + resp.Body.Close() + + // Target workload's source_config.default_tag must now equal v9.9.9. + row, _ := e.store.GetWorkloadByID(targetID) + var cfg map[string]any + if err := json.Unmarshal([]byte(row.SourceConfig), &cfg); err != nil { + t.Fatalf("decode target source_config: %v", err) + } + if cfg["default_tag"] != "v9.9.9" { + t.Fatalf("default_tag = %v, want v9.9.9", cfg["default_tag"]) + } +} + +// ============================================================================= +// /api/workloads/{id}/triggers — list + inline create+bind +// ============================================================================= + +func TestListBindingsForWorkload_EmptyByDefault(t *testing.T) { + e := newAPITestEnv(t) + id := seedWorkload(t, e, "no-bindings") + + resp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/triggers", nil) + var rows []map[string]any + _ = decodeEnvelope(t, resp, &rows) + if len(rows) != 0 { + t.Fatalf("expected 0 bindings, got %d", len(rows)) + } +} + +func TestBindTriggerToWorkload_InlineManualTrigger(t *testing.T) { + e := newAPITestEnv(t) + id := seedWorkload(t, e, "inline-bind") + + body := map[string]any{ + "binding_config": json.RawMessage(`{}`), + "inline": map[string]any{ + "kind": "manual", + "name": "manual-trigger-for-inline", + "config": json.RawMessage(`{}`), + }, + } + resp := e.do(t, http.MethodPost, "/api/workloads/"+id+"/triggers", body) + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d (body=%s)", resp.StatusCode, string(body)) + } + resp.Body.Close() + + // Verify a binding row exists for this workload. + bindings, err := e.store.ListBindingsForWorkloadWithNames(id) + if err != nil { + t.Fatalf("list bindings: %v", err) + } + if len(bindings) != 1 { + t.Fatalf("expected 1 binding, got %d", len(bindings)) + } + if bindings[0].TriggerKind != "manual" { + t.Fatalf("expected manual trigger, got %q", bindings[0].TriggerKind) + } +} + +func TestBindTriggerToWorkload_RequiresEitherTriggerIDOrInline(t *testing.T) { + e := newAPITestEnv(t) + id := seedWorkload(t, e, "bind-validation") + + resp := e.do(t, http.MethodPost, "/api/workloads/"+id+"/triggers", map[string]any{ + "binding_config": json.RawMessage(`{}`), + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +// ============================================================================= +// Standalone /api/triggers CRUD +// ============================================================================= + +func TestCreateTrigger_HappyPath(t *testing.T) { + e := newAPITestEnv(t) + body := map[string]any{ + "kind": "manual", + "name": "standalone-manual", + "config": json.RawMessage(`{}`), + "webhook_enabled": false, + } + resp := e.do(t, http.MethodPost, "/api/triggers", body) + if resp.StatusCode != http.StatusCreated { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d (body=%s)", resp.StatusCode, string(raw)) + } + var got map[string]any + if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { + t.Fatalf("envelope error %q", errMsg) + } + if got["kind"] != "manual" || got["name"] != "standalone-manual" { + t.Fatalf("wrong shape: %v", got) + } + // Sanity: the row landed in the store. + if _, err := e.store.GetTriggerByName("standalone-manual"); err != nil { + t.Fatalf("trigger missing from store: %v", err) + } +} + +func TestCreateTrigger_DuplicateNameReturns409(t *testing.T) { + e := newAPITestEnv(t) + body := map[string]any{ + "kind": "manual", + "name": "dup-trigger", + "config": json.RawMessage(`{}`), + } + if r := e.do(t, http.MethodPost, "/api/triggers", body); r.StatusCode != http.StatusCreated { + r.Body.Close() + t.Fatalf("first create status = %d", r.StatusCode) + } + r2 := e.do(t, http.MethodPost, "/api/triggers", body) + defer r2.Body.Close() + if r2.StatusCode != http.StatusConflict { + t.Fatalf("dup status = %d, want 409", r2.StatusCode) + } +} + +func TestListTriggers_PopulatedKindFilter(t *testing.T) { + e := newAPITestEnv(t) + mkBody := func(kind, name string) map[string]any { + cfg := json.RawMessage(`{}`) + switch kind { + case "registry": + cfg = json.RawMessage(`{"image":"registry.example.com/o/a","tag_pattern":"*"}`) + case "git": + cfg = json.RawMessage(`{"repo":"o/r","mode":"push","branch":"main"}`) + } + return map[string]any{"kind": kind, "name": name, "config": cfg} + } + for _, kn := range []struct{ kind, name string }{ + {"manual", "m1"}, {"manual", "m2"}, {"registry", "r1"}, {"git", "g1"}, + } { + if r := e.do(t, http.MethodPost, "/api/triggers", mkBody(kn.kind, kn.name)); r.StatusCode != http.StatusCreated { + raw, _ := io.ReadAll(r.Body) + t.Fatalf("seed %s/%s: status %d (%s)", kn.kind, kn.name, r.StatusCode, raw) + } else { + r.Body.Close() + } + } + resp := e.do(t, http.MethodGet, "/api/triggers", nil) + var all []map[string]any + _ = decodeEnvelope(t, resp, &all) + if len(all) != 4 { + t.Fatalf("all triggers = %d, want 4", len(all)) + } + r2 := e.do(t, http.MethodGet, "/api/triggers?kind=manual", nil) + var manuals []map[string]any + _ = decodeEnvelope(t, r2, &manuals) + if len(manuals) != 2 { + t.Fatalf("manual triggers = %d, want 2", len(manuals)) + } + for _, row := range manuals { + if row["kind"] != "manual" { + t.Fatalf("kind filter leaked: %v", row) + } + } +} + +func TestDeleteTrigger_RemovesRow(t *testing.T) { + e := newAPITestEnv(t) + createResp := e.do(t, http.MethodPost, "/api/triggers", map[string]any{ + "kind": "manual", "name": "del-me", "config": json.RawMessage(`{}`), + }) + var created map[string]any + _ = decodeEnvelope(t, createResp, &created) + id, _ := created["id"].(string) + if id == "" { + t.Fatal("no id in create response") + } + r2 := e.do(t, http.MethodDelete, "/api/triggers/"+id, nil) + r2.Body.Close() + if r2.StatusCode != http.StatusOK { + t.Fatalf("delete status = %d", r2.StatusCode) + } + if _, err := e.store.GetTriggerByID(id); err == nil { + t.Fatal("trigger still in store after delete") + } +} + +// ============================================================================= +// Auth gating +// ============================================================================= + +func TestAdminOnlyRoutes_RejectViewerToken(t *testing.T) { + e := newAPITestEnv(t) + + // Mint a viewer token using a fresh LocalAuth bound to the same key. + la := auth.NewLocalAuth(e.encKey) + tok, err := la.GenerateToken(auth.Claims{ + UserID: "u-viewer", Username: "viewer", Role: "viewer", + }) + if err != nil { + t.Fatalf("mint viewer token: %v", err) + } + body := pluginWorkloadRequest{ + Name: "x", SourceKind: "image", SourceConfig: validImageSourceConfig(), + } + b, _ := json.Marshal(body) + req, _ := http.NewRequest(http.MethodPost, e.srv.URL+"/api/workloads", bytes.NewReader(b)) + req.Header.Set("Authorization", "Bearer "+tok.Token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("viewer status = %d, want 403", resp.StatusCode) + } +} + +func TestUnauthenticatedRoutes_RejectMissingToken(t *testing.T) { + e := newAPITestEnv(t) + req, _ := http.NewRequest(http.MethodGet, e.srv.URL+"/api/workloads", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +// ============================================================================= +// Test-only fixtures +// ============================================================================= + +// seedWorkload creates a minimal valid image-source workload via the +// real POST /api/workloads handler and returns its ID. Going through +// the handler exercises the same validation + ref_id self-reference +// path that production callers hit. +func seedWorkload(t *testing.T, e *apiTestEnv, name string) string { + t.Helper() + body := pluginWorkloadRequest{ + Name: name, + SourceKind: "image", + SourceConfig: validImageSourceConfig(), + } + resp := e.do(t, http.MethodPost, "/api/workloads", body) + if resp.StatusCode != http.StatusCreated { + _ = decodeEnvelope(t, resp, nil) + t.Fatalf("seedWorkload(%s): status = %d", name, resp.StatusCode) + } + var got plugin.Workload + if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { + t.Fatalf("seedWorkload(%s): envelope error %q", name, errMsg) + } + return got.ID +} + +func seedWorkloadWithParent(t *testing.T, e *apiTestEnv, name, parentID string) string { + t.Helper() + body := pluginWorkloadRequest{ + Name: name, + ParentWorkloadID: parentID, + SourceKind: "image", + SourceConfig: validImageSourceConfig(), + } + resp := e.do(t, http.MethodPost, "/api/workloads", body) + if resp.StatusCode != http.StatusCreated { + _ = decodeEnvelope(t, resp, nil) + t.Fatalf("seedWorkloadWithParent(%s): status = %d", name, resp.StatusCode) + } + var got plugin.Workload + if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { + t.Fatalf("seedWorkloadWithParent(%s): envelope error %q", name, errMsg) + } + return got.ID +} diff --git a/internal/deployer/dispatch_test.go b/internal/deployer/dispatch_test.go new file mode 100644 index 0000000..af6755a --- /dev/null +++ b/internal/deployer/dispatch_test.go @@ -0,0 +1,297 @@ +package deployer + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/alexei/tinyforge/internal/store" + "github.com/alexei/tinyforge/internal/workload/plugin" +) + +// fakeSource is a stub Source implementation registered exactly once +// (kind="dispatchertest") so each dispatch test can assert exactly which +// lifecycle method ran. Counters and the configured error are atomic / +// mutex-guarded because a future parallel run should not flake. +type fakeSource struct { + kind string + + mu sync.Mutex + deployErr error + teardownErr error + reconcileErr error + + deployCount atomic.Int32 + teardownCount atomic.Int32 + reconcileCount atomic.Int32 + + lastIntent plugin.DeploymentIntent + lastDeps plugin.Deps +} + +func (f *fakeSource) Kind() string { return f.kind } +func (f *fakeSource) SchemaSample() any { return struct{}{} } +func (f *fakeSource) Validate(json.RawMessage) error { return nil } + +func (f *fakeSource) Deploy(_ context.Context, deps plugin.Deps, _ plugin.Workload, intent plugin.DeploymentIntent) error { + f.deployCount.Add(1) + f.mu.Lock() + f.lastIntent = intent + f.lastDeps = deps + err := f.deployErr + f.mu.Unlock() + return err +} + +func (f *fakeSource) Teardown(_ context.Context, deps plugin.Deps, _ plugin.Workload) error { + f.teardownCount.Add(1) + f.mu.Lock() + f.lastDeps = deps + err := f.teardownErr + f.mu.Unlock() + return err +} + +func (f *fakeSource) Reconcile(_ context.Context, deps plugin.Deps, _ plugin.Workload) error { + f.reconcileCount.Add(1) + f.mu.Lock() + f.lastDeps = deps + err := f.reconcileErr + f.mu.Unlock() + return err +} + +func (f *fakeSource) setDeployErr(err error) { f.mu.Lock(); f.deployErr = err; f.mu.Unlock() } +func (f *fakeSource) setTeardownErr(err error) { f.mu.Lock(); f.teardownErr = err; f.mu.Unlock() } +func (f *fakeSource) setReconcileErr(err error) { f.mu.Lock(); f.reconcileErr = err; f.mu.Unlock() } + +// dispatchTestSource is the singleton fake registered into the plugin +// registry. Registration happens exactly once — subsequent calls would +// panic (RegisterSource panics on duplicate kind). +var dispatchTestSource = &fakeSource{kind: "dispatchertest"} + +func init() { + plugin.RegisterSource(dispatchTestSource) +} + +// resetFake clears counters + queued errors between tests. The Source +// instance is shared (the registry can't be cleared per-test) so reset +// is the seam. +func resetFake(t *testing.T) { + t.Helper() + dispatchTestSource.mu.Lock() + dispatchTestSource.deployErr = nil + dispatchTestSource.teardownErr = nil + dispatchTestSource.reconcileErr = nil + dispatchTestSource.lastIntent = plugin.DeploymentIntent{} + dispatchTestSource.lastDeps = plugin.Deps{} + dispatchTestSource.mu.Unlock() + dispatchTestSource.deployCount.Store(0) + dispatchTestSource.teardownCount.Store(0) + dispatchTestSource.reconcileCount.Store(0) +} + +func newTestDeployer(t *testing.T) *Deployer { + t.Helper() + st, err := store.New(":memory:") + if err != nil { + t.Fatalf("create store: %v", err) + } + t.Cleanup(func() { st.Close() }) + // All other deps are nil — the fake source ignores them. The dispatch + // surface itself does not dereference them. + return New(nil, nil, st, nil, nil, nil, [32]byte{}) +} + +func sampleWorkload() plugin.Workload { + return plugin.Workload{ + ID: "wid-dispatch", + Name: "wkl", + SourceKind: "dispatchertest", + SourceConfig: json.RawMessage(`{}`), + } +} + +// ---- DispatchPlugin --------------------------------------------------------- + +func TestDispatchPlugin_HappyPath_CallsDeployOnce(t *testing.T) { + resetFake(t) + d := newTestDeployer(t) + + intent := plugin.DeploymentIntent{Reason: "manual", TriggeredBy: "alice"} + if err := d.DispatchPlugin(context.Background(), sampleWorkload(), intent); err != nil { + t.Fatalf("DispatchPlugin: %v", err) + } + if got := dispatchTestSource.deployCount.Load(); got != 1 { + t.Fatalf("Deploy called %d times, want 1", got) + } + if dispatchTestSource.lastIntent.Reason != "manual" { + t.Fatalf("intent.Reason = %q, want manual", dispatchTestSource.lastIntent.Reason) + } + if dispatchTestSource.lastIntent.TriggeredBy != "alice" { + t.Fatalf("intent.TriggeredBy = %q, want alice", dispatchTestSource.lastIntent.TriggeredBy) + } +} + +func TestDispatchPlugin_UnknownKind_ReturnsRegistryError(t *testing.T) { + resetFake(t) + d := newTestDeployer(t) + + w := sampleWorkload() + w.SourceKind = "no-such-kind" + err := d.DispatchPlugin(context.Background(), w, plugin.DeploymentIntent{}) + if err == nil { + t.Fatalf("expected error for unknown kind, got nil") + } + if !strings.Contains(err.Error(), "no source registered") { + t.Fatalf("error = %q, want substring 'no source registered'", err.Error()) + } + if got := dispatchTestSource.deployCount.Load(); got != 0 { + t.Fatalf("Deploy must not be called for unknown kind, got %d", got) + } +} + +func TestDispatchPlugin_PropagatesSourceError(t *testing.T) { + resetFake(t) + d := newTestDeployer(t) + + want := errors.New("boom") + dispatchTestSource.setDeployErr(want) + + err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{}) + if !errors.Is(err, want) { + t.Fatalf("expected wrapped error to match %v, got %v", want, err) + } +} + +// ---- DispatchTeardown ------------------------------------------------------- + +func TestDispatchTeardown_HappyPath_CallsTeardownOnce(t *testing.T) { + resetFake(t) + d := newTestDeployer(t) + + if err := d.DispatchTeardown(context.Background(), sampleWorkload()); err != nil { + t.Fatalf("DispatchTeardown: %v", err) + } + if got := dispatchTestSource.teardownCount.Load(); got != 1 { + t.Fatalf("Teardown called %d times, want 1", got) + } + if got := dispatchTestSource.deployCount.Load(); got != 0 { + t.Fatalf("Teardown must not call Deploy, got %d Deploy calls", got) + } +} + +func TestDispatchTeardown_UnknownKind_ReturnsRegistryError(t *testing.T) { + resetFake(t) + d := newTestDeployer(t) + + w := sampleWorkload() + w.SourceKind = "no-such-kind" + err := d.DispatchTeardown(context.Background(), w) + if err == nil || !strings.Contains(err.Error(), "no source registered") { + t.Fatalf("expected unknown-kind error, got %v", err) + } +} + +func TestDispatchTeardown_PropagatesSourceError(t *testing.T) { + resetFake(t) + d := newTestDeployer(t) + + want := errors.New("teardown failed") + dispatchTestSource.setTeardownErr(want) + + err := d.DispatchTeardown(context.Background(), sampleWorkload()) + if !errors.Is(err, want) { + t.Fatalf("expected wrapped error to match %v, got %v", want, err) + } +} + +// ---- DispatchReconcile ------------------------------------------------------ + +func TestDispatchReconcile_HappyPath_CallsReconcileOnce(t *testing.T) { + resetFake(t) + d := newTestDeployer(t) + + if err := d.DispatchReconcile(context.Background(), sampleWorkload()); err != nil { + t.Fatalf("DispatchReconcile: %v", err) + } + if got := dispatchTestSource.reconcileCount.Load(); got != 1 { + t.Fatalf("Reconcile called %d times, want 1", got) + } +} + +func TestDispatchReconcile_UnknownKind_ReturnsRegistryError(t *testing.T) { + resetFake(t) + d := newTestDeployer(t) + + w := sampleWorkload() + w.SourceKind = "no-such-kind" + err := d.DispatchReconcile(context.Background(), w) + if err == nil || !strings.Contains(err.Error(), "no source registered") { + t.Fatalf("expected unknown-kind error, got %v", err) + } +} + +func TestDispatchReconcile_PropagatesSourceError(t *testing.T) { + resetFake(t) + d := newTestDeployer(t) + + want := errors.New("reconcile failed") + dispatchTestSource.setReconcileErr(want) + + err := d.DispatchReconcile(context.Background(), sampleWorkload()) + if !errors.Is(err, want) { + t.Fatalf("expected wrapped error to match %v, got %v", want, err) + } +} + +// ---- PluginDeps ------------------------------------------------------------- + +func TestPluginDeps_PassesStoreAndEncKey(t *testing.T) { + resetFake(t) + d := newTestDeployer(t) + + if err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{}); err != nil { + t.Fatalf("dispatch: %v", err) + } + got := dispatchTestSource.lastDeps + if got.Store != d.store { + t.Fatalf("Deps.Store mismatch: got %p want %p", got.Store, d.store) + } + // EncKey is a value type — compare bytes. + if got.EncKey != d.encKey { + t.Fatalf("Deps.EncKey not propagated") + } +} + +func TestPluginDeps_DNSReadUnderRWMutex_NoDeadlockOnHotSwap(t *testing.T) { + // PluginDeps takes dnsMu.RLock; SetDNSProvider takes dnsMu.Lock. A bug + // where the read code path also took the write lock would deadlock + // when a concurrent SetDNSProvider runs. Run both in parallel goroutines + // and assert both finish. + d := newTestDeployer(t) + + const N = 50 + var wg sync.WaitGroup + wg.Add(2 * N) + for i := 0; i < N; i++ { + go func() { defer wg.Done(); _ = d.PluginDeps() }() + go func() { defer wg.Done(); d.SetDNSProvider(nil) }() + } + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + + // Real timeout: a deadlock here would hang `go test` for the entire + // package timeout (default 10 min) and report no useful diagnostic. + // Bound at 2s so a regression fails this test specifically. + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("deadlock: PluginDeps/SetDNSProvider did not finish within 2s") + } +} diff --git a/internal/store/workloads.go b/internal/store/workloads.go index d3ce9bd..ee4e47f 100644 --- a/internal/store/workloads.go +++ b/internal/store/workloads.go @@ -28,12 +28,19 @@ func scanWorkload(scanner interface{ Scan(...any) error }) (Workload, error) { return w, err } -// CreateWorkload inserts a new workload row. The (Kind, RefID) pair must be -// unique; the caller is responsible for matching this to a project/stack/site. +// CreateWorkload inserts a new workload row. The (Kind, RefID) pair +// must be unique; for plugin-native rows (Kind="plugin") the caller +// typically leaves RefID empty and we self-reference it to the row's +// own ID so the UNIQUE(kind, ref_id) constraint holds for many sibling +// plugin workloads. Legacy bridge code that wired ref_id to a +// project/stack/site row was deleted in the hard cutover. func (s *Store) CreateWorkload(w Workload) (Workload, error) { if w.ID == "" { w.ID = uuid.New().String() } + if w.RefID == "" { + w.RefID = w.ID + } w.CreatedAt = Now() w.UpdatedAt = w.CreatedAt diff --git a/internal/workload/plugin/source/compose/compose_test.go b/internal/workload/plugin/source/compose/compose_test.go new file mode 100644 index 0000000..9e9f671 --- /dev/null +++ b/internal/workload/plugin/source/compose/compose_test.go @@ -0,0 +1,298 @@ +package compose + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/alexei/tinyforge/internal/workload/plugin" +) + +func TestComposeProjectName_GivenExplicitValue_PassesThroughVerbatim(t *testing.T) { + got := composeProjectName("my-explicit-project", plugin.Workload{ + ID: "abcd1234-5678-1234-abcd-deadbeef0000", + Name: "WhAtEvEr Name!", + }) + if got != "my-explicit-project" { + t.Fatalf("explicit value should win verbatim, got %q", got) + } +} + +func TestComposeProjectName_GivenNoExplicit_DerivesStableSlug(t *testing.T) { + w := plugin.Workload{ + ID: "abcd1234-5678-1234-abcd-deadbeef0000", + Name: "My App", + } + got1 := composeProjectName("", w) + got2 := composeProjectName("", w) + + if got1 != got2 { + t.Fatalf("derived name must be stable across calls: %q != %q", got1, got2) + } + if !strings.HasPrefix(got1, "tf-") { + t.Fatalf("expected tf- prefix, got %q", got1) + } + if !strings.HasSuffix(got1, "-abcd1234") { + t.Fatalf("expected workload ID short prefix suffix, got %q", got1) + } +} + +func TestComposeProjectName_SanitizesSpecialChars(t *testing.T) { + cases := []struct { + name string + in string + // We assert with substring checks so the test does not need to + // re-implement the full normalization logic. + mustContain []string + mustNotContain []string + }{ + { + name: "spaces become dashes", + in: "My App", + mustContain: []string{"my-app"}, + mustNotContain: []string{" "}, + }, + { + name: "uppercase folded to lowercase", + in: "UPPERCASE", + mustContain: []string{"uppercase"}, + mustNotContain: []string{"UPPERCASE"}, + }, + { + name: "non-alphanum stripped to dash", + in: "weird!@#name", + mustContain: []string{"weird"}, + mustNotContain: []string{"!", "@", "#"}, + }, + { + name: "leading-and-trailing dashes stripped", + in: "---name---", + mustContain: []string{"name"}, + mustNotContain: []string{"--name", "name--"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := composeProjectName("", plugin.Workload{ + ID: "abcd1234-5678-1234-abcd-deadbeef0000", + Name: tc.in, + }) + for _, s := range tc.mustContain { + if !strings.Contains(got, s) { + t.Errorf("expected %q to contain %q", got, s) + } + } + for _, s := range tc.mustNotContain { + if strings.Contains(got, s) { + t.Errorf("expected %q to NOT contain %q", got, s) + } + } + }) + } +} + +func TestComposeProjectName_EmptyAfterSanitize_FallsBackToWkl(t *testing.T) { + got := composeProjectName("", plugin.Workload{ + ID: "abcd1234-rest", + Name: "!!!@@@###", + }) + // All chars get stripped, falls back to "wkl" + ID short prefix. + if !strings.Contains(got, "wkl") { + t.Fatalf("expected wkl fallback in %q", got) + } + if !strings.HasSuffix(got, "-abcd1234") { + t.Fatalf("expected ID short prefix suffix in %q", got) + } +} + +func TestComposeProjectName_ShortIDDoesNotPanic(t *testing.T) { + // IDs shorter than 8 chars must not panic — they are taken verbatim. + got := composeProjectName("", plugin.Workload{ + ID: "ab", + Name: "app", + }) + if !strings.HasSuffix(got, "-ab") { + t.Fatalf("short ID handling regressed: %q", got) + } +} + +// withTempDir tries to point os.TempDir() at a per-test scratch +// directory. Note: Go's os.TempDir reads TMPDIR/TMP/TEMP on every call +// (no process-init cache), but isolation across tests rests primarily +// on each test passing a distinct workload-id-derived subdir to +// writeYAML — the env redirect just keeps the directory tree under +// t.TempDir() so the test runner cleans it up at the end. t.Setenv +// restores prior values automatically. +func withTempDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("TMPDIR", dir) + t.Setenv("TMP", dir) + t.Setenv("TEMP", dir) + return dir +} + +func TestWriteYAML_CreatesFileWithCorrectContents(t *testing.T) { + withTempDir(t) + wid := "wid-write-yaml" + path, err := writeYAML(wid, "services:\n web:\n image: nginx:alpine\n") + if err != nil { + t.Fatalf("writeYAML: %v", err) + } + if filepath.Base(path) != "compose.yml" { + t.Fatalf("expected compose.yml, got %q", filepath.Base(path)) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if !strings.Contains(string(body), "nginx:alpine") { + t.Fatalf("yaml content missing expected line, got %q", string(body)) + } +} + +func TestWriteYAMLIfChanged_NoRewriteWhenIdentical(t *testing.T) { + withTempDir(t) + wid := "wid-no-rewrite" + yaml := "services:\n web:\n image: nginx:alpine\n" + + path, err := writeYAML(wid, yaml) + if err != nil { + t.Fatalf("seed writeYAML: %v", err) + } + st1, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + + // Sleep is unreliable; instead, modify the file then call + // writeYAMLIfChanged with identical content and assert it did NOT + // touch the file by checking ModTime is unchanged. + origMod := st1.ModTime() + + path2, err := writeYAMLIfChanged(wid, yaml) + if err != nil { + t.Fatalf("writeYAMLIfChanged: %v", err) + } + if path2 != path { + t.Fatalf("path mismatch: %q vs %q", path, path2) + } + st2, err := os.Stat(path2) + if err != nil { + t.Fatalf("stat after: %v", err) + } + if !st2.ModTime().Equal(origMod) { + t.Fatalf("file was rewritten despite identical contents (mtime changed: %v -> %v)", origMod, st2.ModTime()) + } +} + +func TestWriteYAMLIfChanged_RewritesWhenDifferent(t *testing.T) { + withTempDir(t) + wid := "wid-rewrite" + yaml1 := "services:\n web:\n image: nginx:alpine\n" + yaml2 := "services:\n web:\n image: nginx:1.25\n" + + if _, err := writeYAML(wid, yaml1); err != nil { + t.Fatalf("seed: %v", err) + } + + path, err := writeYAMLIfChanged(wid, yaml2) + if err != nil { + t.Fatalf("writeYAMLIfChanged: %v", err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if !strings.Contains(string(body), "nginx:1.25") { + t.Fatalf("yaml not updated, got %q", string(body)) + } +} + +func TestWriteYAMLIfChanged_MissingFile_Writes(t *testing.T) { + withTempDir(t) + wid := "wid-missing" + yaml := "services:\n web:\n image: alpine\n" + + path, err := writeYAMLIfChanged(wid, yaml) + if err != nil { + t.Fatalf("writeYAMLIfChanged: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected file to be created, stat err: %v", err) + } +} + +func TestTruncate(t *testing.T) { + cases := []struct { + in string + n int + want string + }{ + {"short", 100, "short"}, + {"exact", 5, "exact"}, + {"longerstring", 4, "long...(truncated)"}, + {"", 5, ""}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + if got := truncate(tc.in, tc.n); got != tc.want { + t.Fatalf("truncate(%q,%d): got %q want %q", tc.in, tc.n, got, tc.want) + } + }) + } +} + +func TestValidate_GivenValidYAML_Passes(t *testing.T) { + src := &source{} + cfg := Config{ + ComposeYAML: "services:\n web:\n image: nginx:alpine\n ports:\n - \"80\"\n", + } + body, _ := json.Marshal(cfg) + if err := src.Validate(body); err != nil { + t.Fatalf("expected pass, got %v", err) + } +} + +func TestValidate_RejectsKnownBadInputs(t *testing.T) { + src := &source{} + cases := []struct { + name string + body string + wantSub string + }{ + {"empty config", "", "config is required"}, + {"invalid json", `{not json`, "invalid json"}, + {"missing yaml", `{"compose_yaml":""}`, "compose_yaml is required"}, + {"yaml whitespace only", `{"compose_yaml":" \n "}`, "compose_yaml is required"}, + {"unparseable yaml", `{"compose_yaml":":\n bad\n indent"}`, "parse yaml"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := src.Validate([]byte(tc.body)) + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("error %q missing substring %q", err.Error(), tc.wantSub) + } + }) + } +} + +func TestKindAndSchemaSample(t *testing.T) { + src := &source{} + if src.Kind() != "compose" { + t.Fatalf("Kind: %q", src.Kind()) + } + sample := src.SchemaSample() + cfg, ok := sample.(Config) + if !ok { + t.Fatalf("SchemaSample is not Config: %T", sample) + } + if cfg.ComposeYAML == "" { + t.Fatalf("SchemaSample missing ComposeYAML") + } +} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 24f98e4..ce0194d 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1128,7 +1128,127 @@ } }, "apps": { + "list": { + "eyebrowSuffix": "APPS", + "title": "Apps", + "ledePrefix": "Plugin-native deployables —", + "ledeKindImage": "image", + "ledeKindCompose": "compose", + "ledeKindStatic": "static", + "ledeMiddle": ", or", + "ledeSuffix": ", with pluggable redeploy triggers. Legacy projects, stacks, and sites continue to live under their own sections during the cutover.", + "statTotal": "TOTAL", + "statImage": "IMAGE", + "statCompose": "COMPOSE", + "statStatic": "STATIC", + "refresh": "Refresh", + "newApp": "New App", + "filterAriaLabel": "Filter by source plugin", + "filterAll": "ALL", + "loadError": "Failed to load apps", + "alertTag": "ERR", + "emptyTitle": "No apps yet", + "emptyBody": "Apps unify image, compose, and static deployables behind a single plugin-driven surface. Forge your first one to see it light up here.", + "emptyCta": "Forge the first app", + "colName": "Name", + "colSource": "Source", + "colTrigger": "Trigger", + "colCreated": "Created", + "colActions": "Actions", + "rowOpen": "Open" + }, "new": { + "pageTitle": "New App · Tinyforge", + "backLabel": "Back to apps", + "eyebrowSuffix": "NEW APP", + "title": "Forge a new app", + "ledePrefix": "Create a plugin-native workload.", + "ledeSourceLabel": "Source", + "ledeSourceMid": "= how it deploys (image, compose, static). Pick or create a", + "ledeTriggerLabel": "trigger", + "ledeSuffix": "below — when one fires, the source plugin redeploys.", + "loadingKinds": "Loading available plugin kinds…", + "alertTag": "ERR", + "fieldName": "Name", + "fieldNameRequired": "REQUIRED", + "fieldNamePlaceholder": "my-app", + "fieldNameHint": "Lowercase, no spaces. Becomes part of container names and subdomains.", + "fieldSourcePlugin": "Source plugin", + "fieldSourceLabel": "Source", + "fieldSourceHint": "Populated from the running daemon — only plugins compiled in show up. Triggers (registry / git / manual) are configured below as standalone records.", + "fieldSourceConfig": "Source config", + "fieldConfigYaml": "YAML", + "fieldConfigForm": "FORM", + "fieldConfigJson": "JSON", + "advancedJson": "Advanced JSON", + "backToForm": "Back to form", + "resetSample": "Reset sample", + "switchToJsonTitle": "Switch to the raw JSON editor", + "switchToFormTitle": "Switch back to the form", + "jsonOk": "JSON OK", + "jsonInvalid": "JSON INVALID", + "linesUnit": "lines", + "composeHeader": "compose.yaml · compose", + "composeAriaLabel": "Compose YAML", + "composeProjectLabel": "Compose project name (optional)", + "composeProjectPlaceholder": "(defaults to sanitized workload name)", + "composePlaceholder": "services:\n web:\n image: nginx:alpine\n ports:\n - \"80\"", + "imageHeader": "image source · runtime knobs", + "imageRefLabel": "Image (registry path)", + "imageRefPlaceholder": "registry.example.com/owner/app", + "imageRefHint": "Fully-qualified reference; the tag is set per-deploy via the trigger or the Default tag field below.", + "imagePort": "Port", + "imageHealthcheck": "Healthcheck path", + "imageDefaultTag": "Default tag", + "imageRegistryLabel": "Registry (for private pulls)", + "imageRegistryPublic": "(public — no auth)", + "imageRegistryHint": "Match the name from the Registries settings page. Leave empty for public images.", + "imageCpu": "CPU limit (cores, 0 = ∞)", + "imageMemory": "Memory limit (MB, 0 = ∞)", + "imageMax": "Max instances", + "imageMaxHint": "1 = strict blue-green.", + "imageFoot": "Env vars and volume mounts live in their own panels on the workload detail page after creation.", + "staticHeader": "static source · pages from a repo", + "staticProvider": "Provider", + "staticBaseUrl": "Base URL", + "staticBaseUrlPlaceholder": "https://git.example.com", + "staticRepoOwner": "Repo owner", + "staticRepoOwnerPlaceholder": "owner", + "staticRepoName": "Repo name", + "staticRepoNamePlaceholder": "pages", + "staticBranch": "Branch", + "staticBranchPlaceholder": "main", + "staticFolder": "Folder path (optional)", + "staticFolderPlaceholder": "(repo root)", + "staticToken": "Access token (private repos)", + "staticTokenPlaceholder": "(leave blank for public repos)", + "staticTokenHint": "Encrypted at rest. Required only when the repo is private.", + "staticMode": "Mode", + "staticModeStaticDesc": "— serve files via nginx; zero runtime overhead.", + "staticModeDenoDesc": "— Deno runtime container with optional dynamic routing.", + "staticRenderMarkdown": "Render markdown", + "staticRenderMarkdownDesc": "— auto-render .md files as HTML pages.", + "staticFoot": "The webhook secret for git push triggers lives on the workload's Webhook panel after creation.", + "sourceConfigJsonTitle": "source_config.json · {kind}", + "sourceConfigJsonAria": "Source plugin configuration (JSON)", + "triggerNumLabel": "Trigger", + "triggerNumOptional": "OPTIONAL", + "triggerNewTag": "NEW", + "triggerPickTag": "PICK", + "triggerSkipTag": "SKIP", + "noteSkipTag": "SKIP", + "noteEmptyTag": "∅", + "faceLabel": "Public face", + "faceOptional": "OPTIONAL", + "faceSubdomain": "Subdomain", + "faceSubdomainPlaceholder": "myapp", + "faceDomain": "Domain", + "faceDomainPlaceholder": "(inherit from settings)", + "facePort": "Target port", + "faceHint": "Leave blank to skip provisioning a proxy route. Filling any field creates a single face row attached to this workload.", + "cancel": "Cancel", + "submit": "Forge app", + "submitting": "Forging…", "triggers": { "section": "Trigger", "sectionSub": "Optional. Pick how this app gets a redeploy signal — registry watcher, git event, or manual button.", @@ -1151,6 +1271,160 @@ } }, "detail": { + "pageTitleFallback": "App", + "backLabel": "Back to apps", + "eyebrowSuffix": "APP", + "kickerId": "id: {id}", + "loading": "Loading workload…", + "loadError": "Failed to load app", + "deployError": "Deploy failed", + "saveError": "Save failed", + "deleteError": "Delete failed", + "alertTag": "ERR", + "createdAt": "created", + "refreshLabel": "Refresh", + "editButton": "Edit", + "deleteButton": "Delete", + "editTitle": "Edit configuration", + "editSubPrefix": "Source", + "editSubSuffix": "· triggers managed in the Triggers panel below", + "editFieldName": "Name", + "editFieldParent": "Parent workload", + "editFieldOptional": "OPTIONAL", + "editFieldParentPlaceholder": "workload UUID (blank for root)", + "editSourceConfig": "Source config", + "editConfigYaml": "YAML", + "editConfigForm": "FORM", + "editConfigJson": "JSON", + "advancedJson": "Advanced JSON", + "backToForm": "Back to form", + "switchToJsonTitle": "Switch to the raw JSON editor", + "switchToFormTitle": "Switch back to the form", + "jsonOk": "JSON OK", + "jsonInvalid": "JSON INVALID", + "editComposeProject": "Compose project name (optional)", + "editComposeProjectPlaceholder": "(defaults to sanitized workload name)", + "editComposePlaceholder": "services:\n web:\n image: nginx:alpine", + "editComposeAria": "Compose YAML", + "editComposeHeader": "compose.yaml", + "editImageHeader": "image source · runtime knobs", + "editImageRef": "Image (registry path)", + "editImageRefPlaceholder": "registry.example.com/owner/app", + "editImagePort": "Port", + "editImageHealthcheck": "Healthcheck path", + "editImageDefaultTag": "Default tag", + "editImageRegistry": "Registry (for private pulls)", + "editImageRegistryPublic": "(public — no auth)", + "editImageCpu": "CPU limit (cores, 0 = ∞)", + "editImageMemory": "Memory limit (MB, 0 = ∞)", + "editImageMax": "Max instances", + "editImageFoot": "Env vars and volume mounts use their own panels below — saving here preserves them.", + "editStaticHeader": "static source · pages from a repo", + "editStaticProvider": "Provider", + "editStaticBaseUrl": "Base URL", + "editStaticBaseUrlPlaceholder": "https://git.example.com", + "editStaticRepoOwner": "Repo owner", + "editStaticRepoName": "Repo name", + "editStaticBranch": "Branch", + "editStaticFolder": "Folder path (optional)", + "editStaticFolderPlaceholder": "(repo root)", + "editStaticToken": "Access token (private repos)", + "editStaticTokenPlaceholder": "(leave blank for public repos)", + "editStaticMode": "Mode", + "editStaticModeStaticDesc": "— serve files via nginx.", + "editStaticModeDenoDesc": "— Deno runtime with dynamic routing.", + "editStaticRenderMarkdown": "Render markdown", + "editStaticRenderMarkdownDesc": "— auto-render .md as HTML.", + "editSourceJsonHeader": "source_config.json", + "editSourceJsonAria": "Source plugin configuration (JSON)", + "editPublicFaces": "Public faces", + "editPublicFacesTag": "JSON ARRAY", + "editPublicFacesHeader": "public_faces.json", + "editPublicFacesAria": "Public faces configuration (JSON array)", + "editCancel": "Cancel", + "editSave": "Save changes", + "editSaving": "Saving…", + "manualDeployTitle": "Manual deploy", + "manualDeployOk": "OK", + "manualDeployDispatched": "Dispatched {reference} as {by}", + "manualDeployRefAria": "Deploy reference", + "manualDeployRefPlaceholder": "reference (image tag, git sha, blank for default)", + "manualDeployButton": "Deploy", + "manualDeployDispatching": "Dispatching…", + "manualDeployHint": "Use a specific image tag, git sha, or branch to force a deploy. Leave blank to use the default reference resolved by the source plugin.", + "containersTitle": "Containers", + "containersEmpty": "No containers yet", + "containersCount": "{count} reconciled", + "containersEmptyInline": "No containers yet — deploy to spin one up.", + "containersColRole": "Role", + "containersColState": "State", + "containersColImage": "Image", + "containersColSubdomain": "Subdomain", + "containersColLastSeen": "Last seen", + "containersColActions": "Actions", + "containersLogsAction": "Logs", + "chainTitle": "Chain", + "chainSubFromParent": "promotes from a parent", + "chainSubParentOf": "parent of", + "chainChildSingular": "child", + "chainChildPlural": "children", + "chainParentLabel": "Parent", + "chainSelfLabel": "This", + "chainChildrenLabel": "Children", + "chainPromoteButton": "Promote from parent", + "chainPromoting": "Promoting…", + "chainHint": "Set parent_workload_id on a workload to build a chain. Image-source children can promote the parent's currently-running tag with one click.", + "volumesTitle": "Volumes", + "volumesEmpty": "No mounts", + "volumesCountSingular": "{count} mount", + "volumesCountPlural": "{count} mounts", + "volumesColTarget": "Target", + "volumesColSource": "Source", + "volumesColScope": "Scope", + "volumesColUpdated": "Updated", + "volumesColActions": "Actions", + "volumeSource": "Source (host)", + "volumeSourcePlaceholder": "/srv/data/myapp", + "volumeTarget": "Target (container)", + "volumeTargetPlaceholder": "/data", + "volumeScope": "Scope", + "volumeAddButton": "Add / Replace", + "volumeSaving": "Saving…", + "volumeHint": "Absolute mounts bind a host path into the container. Non-absolute scopes are accepted for future use; only absolute is honoured at deploy time today.", + "volumeTargetError": "Target must be an absolute container path (e.g. /data)", + "volumeSetFailed": "Failed to set volume", + "volumeDeleteFailed": "Failed to delete volume", + "envTitle": "Env", + "envEmpty": "No overrides", + "envCountSingular": "{count} override", + "envCountPlural": "{count} overrides", + "envColKey": "Key", + "envColValue": "Value", + "envColUpdated": "Updated", + "envColActions": "Actions", + "envEncrypted": "ENCRYPTED", + "envKey": "Key", + "envKeyPlaceholder": "DATABASE_URL", + "envValue": "Value", + "envValuePlaceholder": "(empty to unset)", + "envEncryptLabel": "Encrypt at rest", + "envAddButton": "Add / Replace", + "envSaving": "Saving…", + "envHint": "Encrypted values are write-only after store — the API redacts them on read. Rotate by setting a new value.", + "envKeyRequired": "Key is required", + "envSetFailed": "Failed to set env", + "envDeleteFailed": "Failed to delete env", + "sourceConfigTitle": "Source config", + "sourceConfigCopy": "Copy", + "sourceConfigCopied": "Copied", + "sourceConfigCopyAria": "Copy source config", + "publicFacesTitle": "Public faces", + "publicFacesCopyAria": "Copy public faces", + "deleteConfirmTitle": "Delete this app?", + "deleteConfirmMessage": "Tears down all containers and proxy routes owned by \"{name}\", then removes the row. This cannot be undone.", + "deleteConfirmFallbackName": "this workload", + "deleteConfirmYes": "Yes, delete", + "deleteConfirmDeleting": "Deleting…", "manualDeploySub": "Bypasses configured triggers and dispatches through the source plugin directly.", "chainTriggersZero": "no triggers", "chainTriggersOne": "1 trigger", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 5f3beb5..0c609a1 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -1128,7 +1128,127 @@ } }, "apps": { + "list": { + "eyebrowSuffix": "ПРИЛОЖЕНИЯ", + "title": "Приложения", + "ledePrefix": "Plugin-native деплои —", + "ledeKindImage": "image", + "ledeKindCompose": "compose", + "ledeKindStatic": "static", + "ledeMiddle": ", или", + "ledeSuffix": ", с подключаемыми триггерами передеплоя. Старые проекты, стеки и сайты на время переезда остаются в собственных разделах.", + "statTotal": "ВСЕГО", + "statImage": "IMAGE", + "statCompose": "COMPOSE", + "statStatic": "STATIC", + "refresh": "Обновить", + "newApp": "Новое приложение", + "filterAriaLabel": "Фильтр по source-плагину", + "filterAll": "ВСЕ", + "loadError": "Не удалось загрузить приложения", + "alertTag": "ОШ", + "emptyTitle": "Приложений пока нет", + "emptyBody": "Приложения объединяют image, compose и static-деплои за единым plugin-driven интерфейсом. Создайте первое, чтобы увидеть его здесь.", + "emptyCta": "Создать первое приложение", + "colName": "Имя", + "colSource": "Источник", + "colTrigger": "Триггер", + "colCreated": "Создано", + "colActions": "Действия", + "rowOpen": "Открыть" + }, "new": { + "pageTitle": "Новое приложение · Tinyforge", + "backLabel": "К приложениям", + "eyebrowSuffix": "НОВОЕ ПРИЛОЖЕНИЕ", + "title": "Создать приложение", + "ledePrefix": "Создайте plugin-native нагрузку.", + "ledeSourceLabel": "Источник", + "ledeSourceMid": "= как она деплоится (image, compose, static). Выберите или создайте", + "ledeTriggerLabel": "триггер", + "ledeSuffix": "ниже — при срабатывании source-плагин передеплоит приложение.", + "loadingKinds": "Загрузка доступных видов плагинов…", + "alertTag": "ОШ", + "fieldName": "Имя", + "fieldNameRequired": "ОБЯЗАТЕЛЬНО", + "fieldNamePlaceholder": "my-app", + "fieldNameHint": "В нижнем регистре, без пробелов. Используется в именах контейнеров и поддоменах.", + "fieldSourcePlugin": "Source-плагин", + "fieldSourceLabel": "Источник", + "fieldSourceHint": "Список берётся из запущенного демона — видны только вкомпилированные плагины. Триггеры (registry / git / manual) настраиваются ниже как отдельные записи.", + "fieldSourceConfig": "Конфигурация источника", + "fieldConfigYaml": "YAML", + "fieldConfigForm": "ФОРМА", + "fieldConfigJson": "JSON", + "advancedJson": "Расширенный JSON", + "backToForm": "К форме", + "resetSample": "Сбросить к примеру", + "switchToJsonTitle": "Переключиться на сырой JSON-редактор", + "switchToFormTitle": "Вернуться к форме", + "jsonOk": "JSON ОК", + "jsonInvalid": "JSON НЕВЕРНЫЙ", + "linesUnit": "строк", + "composeHeader": "compose.yaml · compose", + "composeAriaLabel": "Compose YAML", + "composeProjectLabel": "Имя compose-проекта (опционально)", + "composeProjectPlaceholder": "(по умолчанию — нормализованное имя нагрузки)", + "composePlaceholder": "services:\n web:\n image: nginx:alpine\n ports:\n - \"80\"", + "imageHeader": "image-источник · параметры рантайма", + "imageRefLabel": "Образ (путь в реестре)", + "imageRefPlaceholder": "registry.example.com/owner/app", + "imageRefHint": "Полная ссылка без тега; тег задаётся при деплое — триггером или полем «Тег по умолчанию» ниже.", + "imagePort": "Порт", + "imageHealthcheck": "Путь healthcheck", + "imageDefaultTag": "Тег по умолчанию", + "imageRegistryLabel": "Реестр (для приватных pull-ов)", + "imageRegistryPublic": "(публичный — без авторизации)", + "imageRegistryHint": "Имя должно совпадать с записью на странице «Реестры». Оставьте пустым для публичных образов.", + "imageCpu": "Лимит CPU (ядра, 0 = ∞)", + "imageMemory": "Лимит памяти (МБ, 0 = ∞)", + "imageMax": "Макс. инстансов", + "imageMaxHint": "1 = строгий blue-green.", + "imageFoot": "Переменные окружения и тома задаются в отдельных панелях на странице нагрузки после создания.", + "staticHeader": "static-источник · страницы из репозитория", + "staticProvider": "Провайдер", + "staticBaseUrl": "Base URL", + "staticBaseUrlPlaceholder": "https://git.example.com", + "staticRepoOwner": "Владелец репозитория", + "staticRepoOwnerPlaceholder": "owner", + "staticRepoName": "Имя репозитория", + "staticRepoNamePlaceholder": "pages", + "staticBranch": "Ветка", + "staticBranchPlaceholder": "main", + "staticFolder": "Папка (опционально)", + "staticFolderPlaceholder": "(корень репозитория)", + "staticToken": "Токен доступа (приватные репозитории)", + "staticTokenPlaceholder": "(оставьте пустым для публичных репозиториев)", + "staticTokenHint": "Шифруется при хранении. Нужен только для приватных репозиториев.", + "staticMode": "Режим", + "staticModeStaticDesc": "— раздача файлов через nginx; ноль рантайм-оверхеда.", + "staticModeDenoDesc": "— Deno-рантайм с опциональной динамической маршрутизацией.", + "staticRenderMarkdown": "Рендерить markdown", + "staticRenderMarkdownDesc": "— автоматически отдавать .md файлы как HTML-страницы.", + "staticFoot": "Секрет вебхука для git push-триггеров появляется в панели вебхука нагрузки после создания.", + "sourceConfigJsonTitle": "source_config.json · {kind}", + "sourceConfigJsonAria": "Конфигурация source-плагина (JSON)", + "triggerNumLabel": "Триггер", + "triggerNumOptional": "ОПЦИОНАЛЬНО", + "triggerNewTag": "НОВЫЙ", + "triggerPickTag": "ВЫБРАТЬ", + "triggerSkipTag": "ПРОПУСК", + "noteSkipTag": "ПРОПУСК", + "noteEmptyTag": "∅", + "faceLabel": "Публичный фронт", + "faceOptional": "ОПЦИОНАЛЬНО", + "faceSubdomain": "Поддомен", + "faceSubdomainPlaceholder": "myapp", + "faceDomain": "Домен", + "faceDomainPlaceholder": "(наследуется из настроек)", + "facePort": "Целевой порт", + "faceHint": "Оставьте пустым, чтобы не создавать прокси-маршрут. Заполнение любого поля создаст одну запись фронта, привязанную к этой нагрузке.", + "cancel": "Отмена", + "submit": "Создать приложение", + "submitting": "Создание…", "triggers": { "section": "Триггер", "sectionSub": "Необязательно. Выберите, откуда придёт сигнал передеплоя — слежение за реестром, git-событие или ручная кнопка.", @@ -1151,6 +1271,160 @@ } }, "detail": { + "pageTitleFallback": "Приложение", + "backLabel": "К приложениям", + "eyebrowSuffix": "ПРИЛОЖЕНИЕ", + "kickerId": "id: {id}", + "loading": "Загрузка нагрузки…", + "loadError": "Не удалось загрузить приложение", + "deployError": "Деплой не удался", + "saveError": "Сохранение не удалось", + "deleteError": "Удаление не удалось", + "alertTag": "ОШ", + "createdAt": "создано", + "refreshLabel": "Обновить", + "editButton": "Изменить", + "deleteButton": "Удалить", + "editTitle": "Редактирование конфигурации", + "editSubPrefix": "Источник", + "editSubSuffix": "· триггеры настраиваются в панели «Триггеры» ниже", + "editFieldName": "Имя", + "editFieldParent": "Родительская нагрузка", + "editFieldOptional": "ОПЦИОНАЛЬНО", + "editFieldParentPlaceholder": "UUID нагрузки (пусто для корневой)", + "editSourceConfig": "Конфигурация источника", + "editConfigYaml": "YAML", + "editConfigForm": "ФОРМА", + "editConfigJson": "JSON", + "advancedJson": "Расширенный JSON", + "backToForm": "К форме", + "switchToJsonTitle": "Переключиться на сырой JSON-редактор", + "switchToFormTitle": "Вернуться к форме", + "jsonOk": "JSON ОК", + "jsonInvalid": "JSON НЕВЕРНЫЙ", + "editComposeProject": "Имя compose-проекта (опционально)", + "editComposeProjectPlaceholder": "(по умолчанию — нормализованное имя нагрузки)", + "editComposePlaceholder": "services:\n web:\n image: nginx:alpine", + "editComposeAria": "Compose YAML", + "editComposeHeader": "compose.yaml", + "editImageHeader": "image-источник · параметры рантайма", + "editImageRef": "Образ (путь в реестре)", + "editImageRefPlaceholder": "registry.example.com/owner/app", + "editImagePort": "Порт", + "editImageHealthcheck": "Путь healthcheck", + "editImageDefaultTag": "Тег по умолчанию", + "editImageRegistry": "Реестр (для приватных pull-ов)", + "editImageRegistryPublic": "(публичный — без авторизации)", + "editImageCpu": "Лимит CPU (ядра, 0 = ∞)", + "editImageMemory": "Лимит памяти (МБ, 0 = ∞)", + "editImageMax": "Макс. инстансов", + "editImageFoot": "Переменные окружения и тома живут в своих панелях ниже — сохранение здесь их не затронет.", + "editStaticHeader": "static-источник · страницы из репозитория", + "editStaticProvider": "Провайдер", + "editStaticBaseUrl": "Base URL", + "editStaticBaseUrlPlaceholder": "https://git.example.com", + "editStaticRepoOwner": "Владелец репозитория", + "editStaticRepoName": "Имя репозитория", + "editStaticBranch": "Ветка", + "editStaticFolder": "Папка (опционально)", + "editStaticFolderPlaceholder": "(корень репозитория)", + "editStaticToken": "Токен доступа (приватные репозитории)", + "editStaticTokenPlaceholder": "(оставьте пустым для публичных репозиториев)", + "editStaticMode": "Режим", + "editStaticModeStaticDesc": "— раздача файлов через nginx.", + "editStaticModeDenoDesc": "— Deno-рантайм с динамической маршрутизацией.", + "editStaticRenderMarkdown": "Рендерить markdown", + "editStaticRenderMarkdownDesc": "— автоматически отдавать .md как HTML.", + "editSourceJsonHeader": "source_config.json", + "editSourceJsonAria": "Конфигурация source-плагина (JSON)", + "editPublicFaces": "Публичные фронты", + "editPublicFacesTag": "JSON МАССИВ", + "editPublicFacesHeader": "public_faces.json", + "editPublicFacesAria": "Конфигурация публичных фронтов (JSON-массив)", + "editCancel": "Отмена", + "editSave": "Сохранить", + "editSaving": "Сохранение…", + "manualDeployTitle": "Ручной деплой", + "manualDeployOk": "ОК", + "manualDeployDispatched": "Отправлено {reference} как {by}", + "manualDeployRefAria": "Референс для деплоя", + "manualDeployRefPlaceholder": "референс (тег образа, git sha; пусто — по умолчанию)", + "manualDeployButton": "Деплой", + "manualDeployDispatching": "Отправка…", + "manualDeployHint": "Задайте конкретный тег образа, git sha или ветку, чтобы принудительно деплоить. Оставьте пустым, чтобы source-плагин выбрал референс по умолчанию.", + "containersTitle": "Контейнеры", + "containersEmpty": "Контейнеров пока нет", + "containersCount": "{count} согласовано", + "containersEmptyInline": "Контейнеров пока нет — задеплойте, чтобы поднять первый.", + "containersColRole": "Роль", + "containersColState": "Состояние", + "containersColImage": "Образ", + "containersColSubdomain": "Поддомен", + "containersColLastSeen": "Последний раз виден", + "containersColActions": "Действия", + "containersLogsAction": "Логи", + "chainTitle": "Цепочка", + "chainSubFromParent": "продвигается от родителя", + "chainSubParentOf": "родитель для", + "chainChildSingular": "дочерней нагрузки", + "chainChildPlural": "дочерних нагрузок", + "chainParentLabel": "Родитель", + "chainSelfLabel": "Эта", + "chainChildrenLabel": "Дочерние", + "chainPromoteButton": "Продвинуть от родителя", + "chainPromoting": "Продвижение…", + "chainHint": "Задайте parent_workload_id у нагрузки, чтобы построить цепочку. Дочерние image-источники могут одним кликом продвинуть текущий запущенный тег родителя.", + "volumesTitle": "Тома", + "volumesEmpty": "Нет монтирований", + "volumesCountSingular": "{count} монтирование", + "volumesCountPlural": "{count} монтирований", + "volumesColTarget": "Цель", + "volumesColSource": "Источник", + "volumesColScope": "Скоуп", + "volumesColUpdated": "Обновлено", + "volumesColActions": "Действия", + "volumeSource": "Источник (хост)", + "volumeSourcePlaceholder": "/srv/data/myapp", + "volumeTarget": "Цель (контейнер)", + "volumeTargetPlaceholder": "/data", + "volumeScope": "Скоуп", + "volumeAddButton": "Добавить / Заменить", + "volumeSaving": "Сохранение…", + "volumeHint": "Абсолютные монтирования прокидывают путь хоста в контейнер. Другие скоупы приняты для будущих сценариев; сегодня при деплое применяется только absolute.", + "volumeTargetError": "Цель должна быть абсолютным путём в контейнере (например, /data)", + "volumeSetFailed": "Не удалось задать том", + "volumeDeleteFailed": "Не удалось удалить том", + "envTitle": "Окружение", + "envEmpty": "Нет переопределений", + "envCountSingular": "{count} переопределение", + "envCountPlural": "{count} переопределений", + "envColKey": "Ключ", + "envColValue": "Значение", + "envColUpdated": "Обновлено", + "envColActions": "Действия", + "envEncrypted": "ЗАШИФРОВАНО", + "envKey": "Ключ", + "envKeyPlaceholder": "DATABASE_URL", + "envValue": "Значение", + "envValuePlaceholder": "(пусто — снять)", + "envEncryptLabel": "Шифровать при хранении", + "envAddButton": "Добавить / Заменить", + "envSaving": "Сохранение…", + "envHint": "Зашифрованные значения после записи доступны только на запись — API скрывает их при чтении. Ротация — установка нового значения.", + "envKeyRequired": "Ключ обязателен", + "envSetFailed": "Не удалось задать переменную", + "envDeleteFailed": "Не удалось удалить переменную", + "sourceConfigTitle": "Конфигурация источника", + "sourceConfigCopy": "Копировать", + "sourceConfigCopied": "Скопировано", + "sourceConfigCopyAria": "Копировать конфиг источника", + "publicFacesTitle": "Публичные фронты", + "publicFacesCopyAria": "Копировать публичные фронты", + "deleteConfirmTitle": "Удалить это приложение?", + "deleteConfirmMessage": "Сносит все контейнеры и прокси-маршруты, принадлежащие «{name}», затем удаляет запись. Это нельзя отменить.", + "deleteConfirmFallbackName": "эта нагрузка", + "deleteConfirmYes": "Да, удалить", + "deleteConfirmDeleting": "Удаление…", "manualDeploySub": "Обходит настроенные триггеры и отправляет деплой напрямую через source-плагин.", "chainTriggersZero": "без триггеров", "chainTriggersOne": "1 триггер", diff --git a/web/src/routes/apps/+page.svelte b/web/src/routes/apps/+page.svelte index f79d832..5dd4be2 100644 --- a/web/src/routes/apps/+page.svelte +++ b/web/src/routes/apps/+page.svelte @@ -4,6 +4,7 @@ import * as api from '$lib/api'; import { IconPlus, IconRefresh } from '$lib/components/icons'; import ForgeHero from '$lib/components/ForgeHero.svelte'; + import { t } from '$lib/i18n'; let workloads = $state([]); let loading = $state(true); @@ -32,7 +33,7 @@ try { workloads = await api.listWorkloads(); } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load apps'; + error = e instanceof Error ? e.message : $t('apps.list.loadError'); } finally { loading = false; } @@ -60,43 +61,42 @@
{#snippet appsToolbar()} - - New App + {$t('apps.list.newApp')} {/snippet} {#snippet appsStats()}
-
TOTAL
+
{$t('apps.list.statTotal')}
{loading ? '—' : String(pluginRows.length).padStart(2, '0')}
-
IMAGE
+
{$t('apps.list.statImage')}
{loading ? '—' : String(countBy('image')).padStart(2, '0')}
-
COMPOSE
+
{$t('apps.list.statCompose')}
{loading ? '—' : String(countBy('compose')).padStart(2, '0')}
-
STATIC
+
{$t('apps.list.statStatic')}
{loading ? '—' : String(countBy('static')).padStart(2, '0')}
{/snippet} {#snippet appsLede()} - Plugin-native deployables — image, compose, or static, with - pluggable redeploy triggers. Legacy projects, stacks, and sites continue to live under their - own sections during the cutover. + {$t('apps.list.ledePrefix')} {$t('apps.list.ledeKindImage')}, {$t('apps.list.ledeKindCompose')}{$t('apps.list.ledeMiddle')} + {$t('apps.list.ledeKindStatic')}{$t('apps.list.ledeSuffix')} {/snippet} {#if error} -
ERR{error}
+
{$t('apps.list.alertTag')}{error}
{/if} {#if !loading && pluginRows.length > 0} -
+
{#each sourceKinds as kind} @@ -145,13 +145,10 @@ {:else if filtered.length === 0}
-

No apps yet

-

- Apps unify image, compose, and static deployables behind a single plugin-driven - surface. Forge your first one to see it light up here. -

+

{$t('apps.list.emptyTitle')}

+

{$t('apps.list.emptyBody')}

- Forge the first app + {$t('apps.list.emptyCta')}
{:else} @@ -159,11 +156,11 @@ - - - - - + + + + + @@ -186,7 +183,7 @@ diff --git a/web/src/routes/apps/[id]/+page.svelte b/web/src/routes/apps/[id]/+page.svelte index 9bb2d71..663cbf9 100644 --- a/web/src/routes/apps/[id]/+page.svelte +++ b/web/src/routes/apps/[id]/+page.svelte @@ -401,7 +401,7 @@ // session storage may be disabled — ignore. } } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load app'; + error = e instanceof Error ? e.message : $t('apps.detail.loadError'); } finally { loading = false; } @@ -469,7 +469,7 @@ async function addVolume() { volumeError = ''; if (!newVolTarget.trim().startsWith('/')) { - volumeError = 'Target must be an absolute container path (e.g. /data)'; + volumeError = $t('apps.detail.volumeTargetError'); return; } volumeSaving = true; @@ -483,7 +483,7 @@ newVolTarget = ''; volumeRows = await api.listWorkloadVolumes(id); } catch (e) { - volumeError = e instanceof Error ? e.message : 'Failed to set volume'; + volumeError = e instanceof Error ? e.message : $t('apps.detail.volumeSetFailed'); } finally { volumeSaving = false; } @@ -495,7 +495,7 @@ await api.deleteWorkloadVolume(id, volID); volumeRows = volumeRows.filter((v) => v.id !== volID); } catch (e) { - volumeError = e instanceof Error ? e.message : 'Failed to delete volume'; + volumeError = e instanceof Error ? e.message : $t('apps.detail.volumeDeleteFailed'); } } @@ -503,7 +503,7 @@ envError = ''; const key = newEnvKey.trim(); if (!key) { - envError = 'Key is required'; + envError = $t('apps.detail.envKeyRequired'); return; } envSaving = true; @@ -517,7 +517,7 @@ newEnvValue = ''; envRows = await api.listWorkloadEnv(id); } catch (e) { - envError = e instanceof Error ? e.message : 'Failed to set env'; + envError = e instanceof Error ? e.message : $t('apps.detail.envSetFailed'); } finally { envSaving = false; } @@ -529,7 +529,7 @@ await api.deleteWorkloadEnv(id, envID); envRows = envRows.filter((e) => e.id !== envID); } catch (e) { - envError = e instanceof Error ? e.message : 'Failed to delete env'; + envError = e instanceof Error ? e.message : $t('apps.detail.envDeleteFailed'); } } @@ -540,10 +540,13 @@ try { const body = deployRef ? { reference: deployRef } : undefined; const res = await api.deployPluginWorkload(id, body); - lastDeployMsg = `Dispatched ${res.reference || '(default)'} as ${res.triggered_by}`; + lastDeployMsg = $t('apps.detail.manualDeployDispatched', { + reference: res.reference || '(default)', + by: res.triggered_by + }); setTimeout(load, 1500); } catch (e) { - error = e instanceof Error ? e.message : 'Deploy failed'; + error = e instanceof Error ? e.message : $t('apps.detail.deployError'); } finally { deploying = false; } @@ -789,7 +792,7 @@ editing = false; await load(); } catch (e) { - error = e instanceof Error ? e.message : 'Save failed'; + error = e instanceof Error ? e.message : $t('apps.detail.saveError'); } finally { saving = false; } @@ -913,7 +916,7 @@ await api.deletePluginWorkload(id); goto('/apps'); } catch (e) { - error = e instanceof Error ? e.message : 'Delete failed'; + error = e instanceof Error ? e.message : $t('apps.detail.deleteError'); deleting = false; confirmDelete = false; } @@ -960,30 +963,30 @@ - {workload?.name ?? 'App'} · Tinyforge + {workload?.name ?? $t('apps.detail.pageTitleFallback')} · Tinyforge
{#if loading && !workload}
- Loading workload… + {$t('apps.detail.loading')}
{:else if error && !workload} -
ERR{error}
+
{$t('apps.detail.alertTag')}{error}
{:else if workload} {#snippet detailToolbar()} - {#if !editing} {/if} {/snippet} @@ -1003,24 +1006,24 @@ · - created {workload!.created_at} + {$t('apps.detail.createdAt')} {workload!.created_at} {/snippet} {#if error} -
ERR{error}
+
{$t('apps.detail.alertTag')}{error}
{/if} {#if editing} @@ -1032,17 +1035,16 @@
-

Edit configuration.

+

{$t('apps.detail.editTitle')}.

- Source {workload.source_kind} · triggers managed in the - Triggers panel below + {$t('apps.detail.editSubPrefix')} {workload.source_kind} {$t('apps.detail.editSubSuffix')}
@@ -1074,28 +1076,28 @@
03 - Source config + {$t('apps.detail.editSourceConfig')} {useEditComposeForm - ? 'YAML' + ? $t('apps.detail.editConfigYaml') : useEditImageForm || useEditStaticForm - ? 'FORM' - : 'JSON'} + ? $t('apps.detail.editConfigForm') + : $t('apps.detail.editConfigJson')}
{#if useEditComposeForm}
- compose.yaml + {$t('apps.detail.editComposeHeader')}
- YAML + {$t('apps.detail.editConfigYaml')}
{:else if useEditStaticForm}
- static source · pages from a repo + {$t('apps.detail.editStaticHeader')}
@@ -1387,16 +1386,16 @@
- source_config.json + {$t('apps.detail.editSourceJsonHeader')} {#if (workload?.source_kind ?? '') === 'compose' || (workload?.source_kind ?? '') === 'image' || (workload?.source_kind ?? '') === 'static'} {/if}
@@ -1406,12 +1405,12 @@ rows="12" spellcheck="false" class="code-area" - aria-label="Source plugin configuration (JSON)" + aria-label={$t('apps.detail.editSourceJsonAria')} >
- {sourceValid ? 'JSON OK' : 'JSON INVALID'} + {sourceValid ? $t('apps.detail.jsonOk') : $t('apps.detail.jsonInvalid')}
@@ -1421,13 +1420,13 @@
04 - Public faces - JSON ARRAY + {$t('apps.detail.editPublicFaces')} + {$t('apps.detail.editPublicFacesTag')}
- public_faces.json + {$t('apps.detail.editPublicFacesHeader')}
- {facesValid ? 'JSON OK' : 'JSON INVALID'} + {facesValid ? $t('apps.detail.jsonOk') : $t('apps.detail.jsonInvalid')}
- +
@@ -1467,38 +1466,35 @@
-

Manual deploy.

+

{$t('apps.detail.manualDeployTitle')}.

{$t('apps.detail.manualDeploySub')}
{#if lastDeployMsg}
- OK + {$t('apps.detail.manualDeployOk')} {lastDeployMsg}
{/if}
-

- Use a specific image tag, git sha, or branch to force a deploy. Leave blank to use the - default reference resolved by the source plugin. -

+

{$t('apps.detail.manualDeployHint')}

-

Containers.

+

{$t('apps.detail.containersTitle')}.

- {containers.length === 0 ? 'No containers yet' : `${containers.length} reconciled`} + {containers.length === 0 + ? $t('apps.detail.containersEmpty') + : $t('apps.detail.containersCount', { count: String(containers.length) })}
{#if containers.length === 0}
- No containers yet — deploy to spin one up. + {$t('apps.detail.containersEmptyInline')}
{:else} {#if logContainerRowID} @@ -1814,12 +1812,12 @@
NameSourceTriggerCreatedActions{$t('apps.list.colName')}{$t('apps.list.colSource')}{$t('apps.list.colTrigger')}{$t('apps.list.colCreated')}{$t('apps.list.colActions')}
{w.created_at} - Open + {$t('apps.list.rowOpen')}
- - - - - - + + + + + + @@ -1840,10 +1838,10 @@ {:else} @@ -1861,20 +1859,20 @@ {#if !editing && chain && (chain.parent || chain.children.length > 0)}
-

Chain.

+

{$t('apps.detail.chainTitle')}.

- {chain.parent ? 'promotes from a parent' : 'parent of'} + {chain.parent ? $t('apps.detail.chainSubFromParent') : $t('apps.detail.chainSubParentOf')} {chain.children.length} - {chain.children.length === 1 ? 'child' : 'children'} + {chain.children.length === 1 ? $t('apps.detail.chainChildSingular') : $t('apps.detail.chainChildPlural')}
{#if chainError} -
ERR{chainError}
+
{$t('apps.detail.alertTag')}{chainError}
{/if} {#if chain.parent} {/if}
{/if} @@ -2024,26 +2019,28 @@ {#if !editing}
-

Volumes.

+

{$t('apps.detail.volumesTitle')}.

{volumeRows.length === 0 - ? 'No mounts' - : `${volumeRows.length} mount${volumeRows.length === 1 ? '' : 's'}`} + ? $t('apps.detail.volumesEmpty') + : volumeRows.length === 1 + ? $t('apps.detail.volumesCountSingular', { count: '1' }) + : $t('apps.detail.volumesCountPlural', { count: String(volumeRows.length) })}
{#if volumeError} -
ERR{volumeError}
+
{$t('apps.detail.alertTag')}{volumeError}
{/if} {#if volumeRows.length > 0}
RoleStateImageSubdomainLast seenActions{$t('apps.detail.containersColRole')}{$t('apps.detail.containersColState')}{$t('apps.detail.containersColImage')}{$t('apps.detail.containersColSubdomain')}{$t('apps.detail.containersColLastSeen')}{$t('apps.detail.containersColActions')}
- - - - - + + + + + @@ -2059,7 +2056,7 @@ @@ -2078,24 +2075,24 @@ }} > -

- Absolute mounts bind a host path into the container. Non-absolute scopes are accepted for - future use; only absolute is honoured at deploy time today. -

+

{$t('apps.detail.volumeHint')}

{/if} @@ -2119,25 +2113,27 @@ {#if !editing}
-

Env.

+

{$t('apps.detail.envTitle')}.

{envRows.length === 0 - ? 'No overrides' - : `${envRows.length} override${envRows.length === 1 ? '' : 's'}`} + ? $t('apps.detail.envEmpty') + : envRows.length === 1 + ? $t('apps.detail.envCountSingular', { count: '1' }) + : $t('apps.detail.envCountPlural', { count: String(envRows.length) })}
{#if envError} -
ERR{envError}
+
{$t('apps.detail.alertTag')}{envError}
{/if} {#if envRows.length > 0}
TargetSourceScopeUpdatedActions{$t('apps.detail.volumesColTarget')}{$t('apps.detail.volumesColSource')}{$t('apps.detail.volumesColScope')}{$t('apps.detail.volumesColUpdated')}{$t('apps.detail.volumesColActions')}
- - - - + + + + @@ -2148,7 +2144,7 @@ {#if e.encrypted} - ENCRYPTED + {$t('apps.detail.envEncrypted')} {:else} {e.value || '—'} @@ -2159,7 +2155,7 @@ @@ -2178,31 +2174,28 @@ }} > -

- Encrypted values are write-only after store — the API redacts them on read. Rotate by - setting a new value. -

+

{$t('apps.detail.envHint')}

- image source · runtime knobs + {$t('apps.new.imageHeader')}
-

- Env vars and volume mounts live in their own panels on the workload - detail page after creation. -

+

{$t('apps.new.imageFoot')}

{:else if useStaticForm}
- static source · pages from a repo + {$t('apps.new.staticHeader')}
{:else}
- source_config.json · {sourceKind} + {$t('apps.new.sourceConfigJsonTitle', { kind: sourceKind })} {#if sourceKind === 'compose' || sourceKind === 'image' || sourceKind === 'static'} {/if}
- {sourceValid ? 'JSON OK' : 'JSON INVALID'} + {sourceValid ? $t('apps.new.jsonOk') : $t('apps.new.jsonInvalid')} · - {sourceLines} lines + {sourceLines} {$t('apps.new.linesUnit')} · {sourceBytes} B
@@ -1008,7 +988,7 @@ 04 {$t('apps.new.triggers.section')} - OPTIONAL + {$t('apps.new.triggerNumOptional')}

{$t('apps.new.triggers.sectionSub')}

@@ -1029,7 +1009,7 @@ class:active={triggerMode === 'inline'} onclick={() => (triggerMode = 'inline')} > - NEW + {$t('apps.new.triggerNewTag')} {$t('apps.new.triggers.modeInline')} {$t('apps.new.triggers.modeInlineHint')} @@ -1041,7 +1021,7 @@ class:active={triggerMode === 'pick'} onclick={() => (triggerMode = 'pick')} > - PICK + {$t('apps.new.triggerPickTag')} {$t('apps.new.triggers.modePick')} {$t('apps.new.triggers.modePickHint')} @@ -1053,7 +1033,7 @@ class:active={triggerMode === 'skip'} onclick={() => (triggerMode = 'skip')} > - SKIP + {$t('apps.new.triggerSkipTag')} {$t('apps.new.triggers.modeSkip')} {$t('apps.new.triggers.modeSkipHint')} @@ -1073,7 +1053,7 @@
KeyValueUpdatedActions{$t('apps.detail.envColKey')}{$t('apps.detail.envColValue')}{$t('apps.detail.envColUpdated')}{$t('apps.detail.envColActions')}