chore(workload): close the workload-first arc — apps i18n + codemap + tests
Build / build (push) Successful in 10m36s
Build / build (push) Successful in 10m36s
Closes the workload-first refactor by landing the Priority 3 polish items and the Priority 4 test gap. Net: ~2,400 lines added, ~350 lines modified across 13 files. Priority 3 — polish - apps.* i18n namespace: 276 new keys across apps.list.* (27), apps.new.* (91, sibling of existing apps.new.triggers.*), and apps.detail.* (158, sibling of existing apps.detail.bindings.*). EN+RU at 1314 keys each, perfectly in sync. /apps, /apps/new, /apps/[id] now render entirely from i18n. - New codemap docs/CODEMAPS/workload-plugin.md (238 lines): Source × Trigger contract, dispatch seam, webhook fan-out path, recipes for adding a new Source or Trigger kind. Plus docs/CODEMAPS/INDEX.md gateway. Priority 4 — tests - internal/api/workloads_test.go (new, ~30 subtests): /api/workloads CRUD + deploy + delete + env + volumes + chain + promote-from + triggers list/inline-bind + auth gating + standalone /api/triggers CRUD (create / dup-409 / kind filter / delete). Uses real POST handlers via httptest.NewServer + a fake plugin source registered under "testfakesource". - internal/deployer/dispatch_test.go (new, 11 tests): DispatchPlugin / DispatchTeardown / DispatchReconcile happy + unknown-kind + propagated-error each; PluginDeps wiring; a real 2s-bounded RWMutex deadlock probe on PluginDeps vs SetDNSProvider. - internal/workload/plugin/source/compose/compose_test.go (new, ~26 subtests): composeProjectName sanitization, writeYAML / writeYAMLIfChanged hash short-circuit, Validate happy + bad inputs, Kind / SchemaSample. 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 at 87-95% from the trigger-split work. Production fix surfaced by the tests - store.CreateWorkload now self-references RefID = ID when caller leaves RefID empty (the typical plugin-native path). The api layer's broken backfill loop (called UpdateWorkload, which deliberately omits ref_id) is gone. Multiple sibling plugin workloads can now coexist under the UNIQUE(kind, ref_id) constraint. Review fixes addressed before commit - CRITICAL: deadlock-detect test gained a real 2s time.After (was selecting on context.Background().Done() which never fires). - HIGH: happy-path test now hard-asserts RefID = ID (was a t.Logf that would silently pass after a production fix). - HIGH: standalone /api/triggers CRUD coverage added (was bypassed by the workload-side bind flow). - HIGH: seedWorkload bypass deleted; tests now go through the real POST /api/workloads handler. - MEDIUM: withTempDir restore is a no-op (t.Setenv auto-restores); dead `old := os.Getenv(...)` capture removed. - MEDIUM: list-workloads test now asserts ID membership, not just count. Doc - WORKLOAD_REFACTOR_TODO: all three Priority 1 items, Priority 3 polish, and Priority 4 tests marked DONE. The workload-first arc is closed.
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 <code>.md</code> 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 <code>.md</code> 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 <code>parent_workload_id</code> 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",
|
||||
|
||||
@@ -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": "— автоматически отдавать <code>.md</code> файлы как 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": "— автоматически отдавать <code>.md</code> как 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": "Задайте <code>parent_workload_id</code> у нагрузки, чтобы построить цепочку. Дочерние 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 триггер",
|
||||
|
||||
@@ -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<Workload[]>([]);
|
||||
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 @@
|
||||
|
||||
<div class="forge">
|
||||
{#snippet appsToolbar()}
|
||||
<button class="forge-btn-icon" onclick={load} aria-label="Refresh">
|
||||
<button class="forge-btn-icon" onclick={load} aria-label={$t('apps.list.refresh')}>
|
||||
<IconRefresh size={16} />
|
||||
</button>
|
||||
<a href="/apps/new" class="forge-btn">
|
||||
<IconPlus size={14} />
|
||||
<span>New App</span>
|
||||
<span>{$t('apps.list.newApp')}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
{#snippet appsStats()}
|
||||
<div>
|
||||
<dt>TOTAL</dt>
|
||||
<dt>{$t('apps.list.statTotal')}</dt>
|
||||
<dd>{loading ? '—' : String(pluginRows.length).padStart(2, '0')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>IMAGE</dt>
|
||||
<dt>{$t('apps.list.statImage')}</dt>
|
||||
<dd>{loading ? '—' : String(countBy('image')).padStart(2, '0')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>COMPOSE</dt>
|
||||
<dt>{$t('apps.list.statCompose')}</dt>
|
||||
<dd>{loading ? '—' : String(countBy('compose')).padStart(2, '0')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>STATIC</dt>
|
||||
<dt>{$t('apps.list.statStatic')}</dt>
|
||||
<dd class="accent">{loading ? '—' : String(countBy('static')).padStart(2, '0')}</dd>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet appsLede()}
|
||||
Plugin-native deployables — <em>image</em>, <em>compose</em>, or <em>static</em>, with
|
||||
pluggable redeploy triggers. Legacy projects, stacks, and sites continue to live under their
|
||||
own sections during the cutover.
|
||||
{$t('apps.list.ledePrefix')} <em>{$t('apps.list.ledeKindImage')}</em>, <em>{$t('apps.list.ledeKindCompose')}</em>{$t('apps.list.ledeMiddle')}
|
||||
<em>{$t('apps.list.ledeKindStatic')}</em>{$t('apps.list.ledeSuffix')}
|
||||
{/snippet}
|
||||
|
||||
<ForgeHero
|
||||
eyebrowSuffix="APPS"
|
||||
title="Apps"
|
||||
eyebrowSuffix={$t('apps.list.eyebrowSuffix')}
|
||||
title={$t('apps.list.title')}
|
||||
size="lg"
|
||||
toolbar={appsToolbar}
|
||||
lede_html={appsLede}
|
||||
@@ -104,11 +104,11 @@
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
<div class="alert"><span class="alert-tag">{$t('apps.list.alertTag')}</span><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
{#if !loading && pluginRows.length > 0}
|
||||
<div class="filter-row" role="tablist" aria-label="Filter by source plugin">
|
||||
<div class="filter-row" role="tablist" aria-label={$t('apps.list.filterAriaLabel')}>
|
||||
<button
|
||||
class="chip"
|
||||
class:active={filter === 'all'}
|
||||
@@ -116,7 +116,7 @@
|
||||
aria-selected={filter === 'all'}
|
||||
onclick={() => (filter = 'all')}
|
||||
>
|
||||
<span class="chip-label">ALL</span>
|
||||
<span class="chip-label">{$t('apps.list.filterAll')}</span>
|
||||
<span class="chip-count">{String(pluginRows.length).padStart(2, '0')}</span>
|
||||
</button>
|
||||
{#each sourceKinds as kind}
|
||||
@@ -145,13 +145,10 @@
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">
|
||||
<div class="empty-mark"><span></span><span></span><span></span></div>
|
||||
<h2>No apps yet</h2>
|
||||
<p>
|
||||
Apps unify image, compose, and static deployables behind a single plugin-driven
|
||||
surface. Forge your first one to see it light up here.
|
||||
</p>
|
||||
<h2>{$t('apps.list.emptyTitle')}</h2>
|
||||
<p>{$t('apps.list.emptyBody')}</p>
|
||||
<a href="/apps/new" class="btn-primary">
|
||||
<IconPlus size={14} /><span>Forge the first app</span>
|
||||
<IconPlus size={14} /><span>{$t('apps.list.emptyCta')}</span>
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -159,11 +156,11 @@
|
||||
<table class="forge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Source</th>
|
||||
<th>Trigger</th>
|
||||
<th>Created</th>
|
||||
<th class="t-right">Actions</th>
|
||||
<th>{$t('apps.list.colName')}</th>
|
||||
<th>{$t('apps.list.colSource')}</th>
|
||||
<th>{$t('apps.list.colTrigger')}</th>
|
||||
<th>{$t('apps.list.colCreated')}</th>
|
||||
<th class="t-right">{$t('apps.list.colActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -186,7 +183,7 @@
|
||||
<td class="muted mono">{w.created_at}</td>
|
||||
<td class="actions-cell">
|
||||
<a class="row-action" href={`/apps/${w.id}`}>
|
||||
Open <span class="arrow" aria-hidden="true">→</span>
|
||||
{$t('apps.list.rowOpen')} <span class="arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{workload?.name ?? 'App'} · Tinyforge</title>
|
||||
<title>{workload?.name ?? $t('apps.detail.pageTitleFallback')} · Tinyforge</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="forge">
|
||||
{#if loading && !workload}
|
||||
<div class="loading-line">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>Loading workload…</span>
|
||||
<span>{$t('apps.detail.loading')}</span>
|
||||
</div>
|
||||
{:else if error && !workload}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
<div class="alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{error}</span></div>
|
||||
{:else if workload}
|
||||
{#snippet detailToolbar()}
|
||||
<button class="forge-btn-icon" onclick={load} aria-label="Refresh">
|
||||
<button class="forge-btn-icon" onclick={load} aria-label={$t('apps.detail.refreshLabel')}>
|
||||
<IconRefresh size={16} />
|
||||
</button>
|
||||
{#if !editing}
|
||||
<button class="forge-btn-ghost" onclick={startEdit}>
|
||||
<IconEdit size={13} />
|
||||
<span>Edit</span>
|
||||
<span>{$t('apps.detail.editButton')}</span>
|
||||
</button>
|
||||
<button class="forge-btn-ghost danger" onclick={() => (confirmDelete = true)}>
|
||||
<IconTrash size={13} />
|
||||
<span>Delete</span>
|
||||
<span>{$t('apps.detail.deleteButton')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -1003,24 +1006,24 @@
|
||||
</span>
|
||||
<span class="lede-sep">·</span>
|
||||
<span class="lede-meta">
|
||||
created <code>{workload!.created_at}</code>
|
||||
{$t('apps.detail.createdAt')} <code>{workload!.created_at}</code>
|
||||
</span>
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<ForgeHero
|
||||
backHref="/apps"
|
||||
backLabel="Back to apps"
|
||||
eyebrowSuffix="APP"
|
||||
backLabel={$t('apps.detail.backLabel')}
|
||||
eyebrowSuffix={$t('apps.detail.eyebrowSuffix')}
|
||||
title={workload.name}
|
||||
kicker={`id: ${workload.id}`}
|
||||
kicker={$t('apps.detail.kickerId', { id: workload.id })}
|
||||
size="lg"
|
||||
toolbar={detailToolbar}
|
||||
lede_html={detailLede}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
<div class="alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
{#if editing}
|
||||
@@ -1032,17 +1035,16 @@
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Edit configuration<span class="title-accent">.</span></h2>
|
||||
<h2 class="panel-title">{$t('apps.detail.editTitle')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub">
|
||||
Source <code>{workload.source_kind}</code> · triggers managed in the
|
||||
Triggers panel below
|
||||
{$t('apps.detail.editSubPrefix')} <code>{workload.source_kind}</code> {$t('apps.detail.editSubSuffix')}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="field">
|
||||
<label for="edit-name" class="field-label">
|
||||
<span class="num">01</span>
|
||||
<span class="lbl">Name</span>
|
||||
<span class="lbl">{$t('apps.detail.editFieldName')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="edit-name"
|
||||
@@ -1057,15 +1059,15 @@
|
||||
<div class="field">
|
||||
<label for="edit-parent" class="field-label">
|
||||
<span class="num">02</span>
|
||||
<span class="lbl">Parent workload</span>
|
||||
<span class="opt">OPTIONAL</span>
|
||||
<span class="lbl">{$t('apps.detail.editFieldParent')}</span>
|
||||
<span class="opt">{$t('apps.detail.editFieldOptional')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="edit-parent"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={editParentID}
|
||||
placeholder="workload UUID (blank for root)"
|
||||
placeholder={$t('apps.detail.editFieldParentPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -1074,28 +1076,28 @@
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">03</span>
|
||||
<span class="lbl">Source config</span>
|
||||
<span class="lbl">{$t('apps.detail.editSourceConfig')}</span>
|
||||
<span class="req">
|
||||
{useEditComposeForm
|
||||
? 'YAML'
|
||||
? $t('apps.detail.editConfigYaml')
|
||||
: useEditImageForm || useEditStaticForm
|
||||
? 'FORM'
|
||||
: 'JSON'}
|
||||
? $t('apps.detail.editConfigForm')
|
||||
: $t('apps.detail.editConfigJson')}
|
||||
</span>
|
||||
</div>
|
||||
{#if useEditComposeForm}
|
||||
<div class="editor">
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">compose.yaml</span>
|
||||
<span class="editor-title">{$t('apps.detail.editComposeHeader')}</span>
|
||||
<span class="spacer"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleEditAdvancedJSON}
|
||||
title="Switch to the raw JSON editor"
|
||||
title={$t('apps.detail.switchToJsonTitle')}
|
||||
>
|
||||
Advanced JSON
|
||||
{$t('apps.detail.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
@@ -1104,24 +1106,24 @@
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
placeholder={'services:\n web:\n image: nginx:alpine'}
|
||||
aria-label="Compose YAML"
|
||||
placeholder={$t('apps.detail.editComposePlaceholder')}
|
||||
aria-label={$t('apps.detail.editComposeAria')}
|
||||
></textarea>
|
||||
<div class="editor-foot">
|
||||
<span class="foot-status">
|
||||
<span class="foot-dot" aria-hidden="true"></span>
|
||||
YAML
|
||||
{$t('apps.detail.editConfigYaml')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="sub" for="edit-compose-project">
|
||||
<span class="sub-label">Compose project name (optional)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editComposeProject')}</span>
|
||||
<input
|
||||
id="edit-compose-project"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={editComposeProjectName}
|
||||
placeholder="(defaults to sanitized workload name)"
|
||||
placeholder={$t('apps.detail.editComposeProjectPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -1129,24 +1131,24 @@
|
||||
{:else if useEditImageForm}
|
||||
<div class="image-form">
|
||||
<div class="image-form-head">
|
||||
<span class="editor-title">image source · runtime knobs</span>
|
||||
<span class="editor-title">{$t('apps.detail.editImageHeader')}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleEditAdvancedJSON}
|
||||
title="Switch to the raw JSON editor"
|
||||
title={$t('apps.detail.switchToJsonTitle')}
|
||||
>
|
||||
Advanced JSON
|
||||
{$t('apps.detail.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<label class="sub" for="edit-image-ref">
|
||||
<span class="sub-label">Image (registry path)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageRef')}</span>
|
||||
<input
|
||||
id="edit-image-ref"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={editImageRef}
|
||||
placeholder="registry.example.com/owner/app"
|
||||
placeholder={$t('apps.detail.editImageRefPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
@@ -1154,7 +1156,7 @@
|
||||
</label>
|
||||
<div class="row three">
|
||||
<label class="sub" for="edit-image-port">
|
||||
<span class="sub-label">Port</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImagePort')}</span>
|
||||
<input
|
||||
id="edit-image-port"
|
||||
type="number"
|
||||
@@ -1165,7 +1167,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="edit-image-healthcheck">
|
||||
<span class="sub-label">Healthcheck path</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageHealthcheck')}</span>
|
||||
<input
|
||||
id="edit-image-healthcheck"
|
||||
type="text"
|
||||
@@ -1177,7 +1179,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="edit-image-default-tag">
|
||||
<span class="sub-label">Default tag</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageDefaultTag')}</span>
|
||||
<input
|
||||
id="edit-image-default-tag"
|
||||
type="text"
|
||||
@@ -1190,14 +1192,14 @@
|
||||
</label>
|
||||
</div>
|
||||
<label class="sub" for="edit-image-registry">
|
||||
<span class="sub-label">Registry (for private pulls)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageRegistry')}</span>
|
||||
{#if editRegistries.length > 0}
|
||||
<select
|
||||
id="edit-image-registry"
|
||||
class="input"
|
||||
bind:value={editImageRegistryName}
|
||||
>
|
||||
<option value="">(public — no auth)</option>
|
||||
<option value="">{$t('apps.detail.editImageRegistryPublic')}</option>
|
||||
{#each editRegistries as r}
|
||||
<option value={r.name}>{r.name} — {r.url}</option>
|
||||
{/each}
|
||||
@@ -1208,14 +1210,14 @@
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={editImageRegistryName}
|
||||
placeholder="(public — no auth)"
|
||||
placeholder={$t('apps.detail.editImageRegistryPublic')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{/if}
|
||||
</label>
|
||||
<div class="row three">
|
||||
<label class="sub" for="edit-image-cpu">
|
||||
<span class="sub-label">CPU limit (cores, 0 = ∞)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageCpu')}</span>
|
||||
<input
|
||||
id="edit-image-cpu"
|
||||
type="number"
|
||||
@@ -1226,7 +1228,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="edit-image-memory">
|
||||
<span class="sub-label">Memory limit (MB, 0 = ∞)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageMemory')}</span>
|
||||
<input
|
||||
id="edit-image-memory"
|
||||
type="number"
|
||||
@@ -1236,7 +1238,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="edit-image-max">
|
||||
<span class="sub-label">Max instances</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageMax')}</span>
|
||||
<input
|
||||
id="edit-image-max"
|
||||
type="number"
|
||||
@@ -1246,27 +1248,24 @@
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint image-form-foot">
|
||||
Env vars and volume mounts use their own panels below — saving here
|
||||
preserves them.
|
||||
</p>
|
||||
<p class="hint image-form-foot">{$t('apps.detail.editImageFoot')}</p>
|
||||
</div>
|
||||
{:else if useEditStaticForm}
|
||||
<div class="image-form">
|
||||
<div class="image-form-head">
|
||||
<span class="editor-title">static source · pages from a repo</span>
|
||||
<span class="editor-title">{$t('apps.detail.editStaticHeader')}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleEditAdvancedJSON}
|
||||
title="Switch to the raw JSON editor"
|
||||
title={$t('apps.detail.switchToJsonTitle')}
|
||||
>
|
||||
Advanced JSON
|
||||
{$t('apps.detail.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="edit-static-provider">
|
||||
<span class="sub-label">Provider</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticProvider')}</span>
|
||||
<select
|
||||
id="edit-static-provider"
|
||||
class="input"
|
||||
@@ -1278,13 +1277,13 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="sub" for="edit-static-base-url">
|
||||
<span class="sub-label">Base URL</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticBaseUrl')}</span>
|
||||
<input
|
||||
id="edit-static-base-url"
|
||||
type="url"
|
||||
class="input mono"
|
||||
bind:value={editStaticBaseURL}
|
||||
placeholder="https://git.example.com"
|
||||
placeholder={$t('apps.detail.editStaticBaseUrlPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -1292,7 +1291,7 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="edit-static-owner">
|
||||
<span class="sub-label">Repo owner</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticRepoOwner')}</span>
|
||||
<input
|
||||
id="edit-static-owner"
|
||||
type="text"
|
||||
@@ -1304,7 +1303,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="edit-static-name">
|
||||
<span class="sub-label">Repo name</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticRepoName')}</span>
|
||||
<input
|
||||
id="edit-static-name"
|
||||
type="text"
|
||||
@@ -1318,7 +1317,7 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="edit-static-branch">
|
||||
<span class="sub-label">Branch</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticBranch')}</span>
|
||||
<input
|
||||
id="edit-static-branch"
|
||||
type="text"
|
||||
@@ -1329,31 +1328,31 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="edit-static-folder">
|
||||
<span class="sub-label">Folder path (optional)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticFolder')}</span>
|
||||
<input
|
||||
id="edit-static-folder"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={editStaticFolderPath}
|
||||
placeholder="(repo root)"
|
||||
placeholder={$t('apps.detail.editStaticFolderPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="sub" for="edit-static-token">
|
||||
<span class="sub-label">Access token (private repos)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticToken')}</span>
|
||||
<input
|
||||
id="edit-static-token"
|
||||
type="password"
|
||||
class="input"
|
||||
bind:value={editStaticAccessToken}
|
||||
placeholder="(leave blank for public repos)"
|
||||
placeholder={$t('apps.detail.editStaticTokenPlaceholder')}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<fieldset class="static-mode">
|
||||
<legend class="sub-label">Mode</legend>
|
||||
<legend class="sub-label">{$t('apps.detail.editStaticMode')}</legend>
|
||||
<label class="radio">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -1361,7 +1360,7 @@
|
||||
value="static"
|
||||
bind:group={editStaticMode}
|
||||
/>
|
||||
<span><strong>static</strong> — serve files via nginx.</span>
|
||||
<span><strong>static</strong> {$t('apps.detail.editStaticModeStaticDesc')}</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input
|
||||
@@ -1370,16 +1369,16 @@
|
||||
value="deno"
|
||||
bind:group={editStaticMode}
|
||||
/>
|
||||
<span><strong>deno</strong> — Deno runtime with dynamic routing.</span>
|
||||
<span><strong>deno</strong> {$t('apps.detail.editStaticModeDenoDesc')}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<label class="checkbox-row">
|
||||
<ToggleSwitch
|
||||
bind:checked={editStaticRenderMarkdown}
|
||||
label="Render markdown"
|
||||
label={$t('apps.detail.editStaticRenderMarkdown')}
|
||||
/>
|
||||
<span>
|
||||
<strong>Render markdown</strong> — auto-render <code>.md</code> as HTML.
|
||||
<strong>{$t('apps.detail.editStaticRenderMarkdown')}</strong> {@html $t('apps.detail.editStaticRenderMarkdownDesc')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1387,16 +1386,16 @@
|
||||
<div class="editor">
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">source_config.json</span>
|
||||
<span class="editor-title">{$t('apps.detail.editSourceJsonHeader')}</span>
|
||||
<span class="spacer"></span>
|
||||
{#if (workload?.source_kind ?? '') === 'compose' || (workload?.source_kind ?? '') === 'image' || (workload?.source_kind ?? '') === 'static'}
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleEditAdvancedJSON}
|
||||
title="Switch back to the form"
|
||||
title={$t('apps.detail.switchToFormTitle')}
|
||||
>
|
||||
Back to form
|
||||
{$t('apps.detail.backToForm')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1406,12 +1405,12 @@
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
aria-label="Source plugin configuration (JSON)"
|
||||
aria-label={$t('apps.detail.editSourceJsonAria')}
|
||||
></textarea>
|
||||
<div class="editor-foot">
|
||||
<span class="foot-status" class:bad={!sourceValid}>
|
||||
<span class="foot-dot" aria-hidden="true"></span>
|
||||
{sourceValid ? 'JSON OK' : 'JSON INVALID'}
|
||||
{sourceValid ? $t('apps.detail.jsonOk') : $t('apps.detail.jsonInvalid')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1421,13 +1420,13 @@
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">04</span>
|
||||
<span class="lbl">Public faces</span>
|
||||
<span class="opt">JSON ARRAY</span>
|
||||
<span class="lbl">{$t('apps.detail.editPublicFaces')}</span>
|
||||
<span class="opt">{$t('apps.detail.editPublicFacesTag')}</span>
|
||||
</div>
|
||||
<div class="editor">
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">public_faces.json</span>
|
||||
<span class="editor-title">{$t('apps.detail.editPublicFacesHeader')}</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="edit-faces"
|
||||
@@ -1435,19 +1434,19 @@
|
||||
rows="7"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
aria-label="Public faces configuration (JSON array)"
|
||||
aria-label={$t('apps.detail.editPublicFacesAria')}
|
||||
></textarea>
|
||||
<div class="editor-foot">
|
||||
<span class="foot-status" class:bad={!facesValid}>
|
||||
<span class="foot-dot" aria-hidden="true"></span>
|
||||
{facesValid ? 'JSON OK' : 'JSON INVALID'}
|
||||
{facesValid ? $t('apps.detail.jsonOk') : $t('apps.detail.jsonInvalid')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="forge-btn-ghost" onclick={cancelEdit} disabled={saving}>Cancel</button>
|
||||
<button class="forge-btn-ghost" onclick={cancelEdit} disabled={saving}>{$t('apps.detail.editCancel')}</button>
|
||||
<button
|
||||
class="forge-btn"
|
||||
onclick={saveEdit}
|
||||
@@ -1458,7 +1457,7 @@
|
||||
(useEditStaticForm && (!editStaticRepoOwner.trim() || !editStaticRepoName.trim())) ||
|
||||
!facesValid}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
{saving ? $t('apps.detail.editSaving') : $t('apps.detail.editSave')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1467,38 +1466,35 @@
|
||||
<!-- ── Manual deploy ────────────────────────────── -->
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Manual deploy<span class="title-accent">.</span></h2>
|
||||
<h2 class="panel-title">{$t('apps.detail.manualDeployTitle')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub">{$t('apps.detail.manualDeploySub')}</span>
|
||||
</header>
|
||||
|
||||
{#if lastDeployMsg}
|
||||
<div class="success">
|
||||
<span class="success-tag">OK</span>
|
||||
<span class="success-tag">{$t('apps.detail.manualDeployOk')}</span>
|
||||
<span>{lastDeployMsg}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="deploy-row">
|
||||
<label class="deploy-input" for="deploy-ref">
|
||||
<span class="sr-only">Deploy reference</span>
|
||||
<span class="sr-only">{$t('apps.detail.manualDeployRefAria')}</span>
|
||||
<input
|
||||
id="deploy-ref"
|
||||
type="text"
|
||||
bind:value={deployRef}
|
||||
placeholder="reference (image tag, git sha, blank for default)"
|
||||
placeholder={$t('apps.detail.manualDeployRefPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<button class="forge-btn" onclick={deploy} disabled={deploying}>
|
||||
<IconDeploy size={14} />
|
||||
<span>{deploying ? 'Dispatching…' : 'Deploy'}</span>
|
||||
<span>{deploying ? $t('apps.detail.manualDeployDispatching') : $t('apps.detail.manualDeployButton')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
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.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.detail.manualDeployHint')}</p>
|
||||
</section>
|
||||
|
||||
<!-- ── Triggers (bindings) ─────────────────────────
|
||||
@@ -1787,15 +1783,17 @@
|
||||
<!-- ── Containers ───────────────────────────────── -->
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Containers<span class="title-accent">.</span></h2>
|
||||
<h2 class="panel-title">{$t('apps.detail.containersTitle')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub">
|
||||
{containers.length === 0 ? 'No containers yet' : `${containers.length} reconciled`}
|
||||
{containers.length === 0
|
||||
? $t('apps.detail.containersEmpty')
|
||||
: $t('apps.detail.containersCount', { count: String(containers.length) })}
|
||||
</span>
|
||||
</header>
|
||||
{#if containers.length === 0}
|
||||
<div class="empty-inline">
|
||||
<span class="empty-mark" aria-hidden="true"></span>
|
||||
<span>No containers yet — deploy to spin one up.</span>
|
||||
<span>{$t('apps.detail.containersEmptyInline')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#if logContainerRowID}
|
||||
@@ -1814,12 +1812,12 @@
|
||||
<table class="forge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role</th>
|
||||
<th>State</th>
|
||||
<th>Image</th>
|
||||
<th>Subdomain</th>
|
||||
<th>Last seen</th>
|
||||
<th class="t-right">Actions</th>
|
||||
<th>{$t('apps.detail.containersColRole')}</th>
|
||||
<th>{$t('apps.detail.containersColState')}</th>
|
||||
<th>{$t('apps.detail.containersColImage')}</th>
|
||||
<th>{$t('apps.detail.containersColSubdomain')}</th>
|
||||
<th>{$t('apps.detail.containersColLastSeen')}</th>
|
||||
<th class="t-right">{$t('apps.detail.containersColActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1840,10 +1838,10 @@
|
||||
<button
|
||||
class="forge-btn-ghost"
|
||||
onclick={() => (logContainerRowID = c.id)}
|
||||
aria-label={`View logs for ${c.role || c.id}`}
|
||||
aria-label={`${$t('apps.detail.containersLogsAction')}: ${c.role || c.id}`}
|
||||
>
|
||||
<IconServer size={13} />
|
||||
<span>Logs</span>
|
||||
<span>{$t('apps.detail.containersLogsAction')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="muted mono">—</span>
|
||||
@@ -1861,20 +1859,20 @@
|
||||
{#if !editing && chain && (chain.parent || chain.children.length > 0)}
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Chain<span class="title-accent">.</span></h2>
|
||||
<h2 class="panel-title">{$t('apps.detail.chainTitle')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub">
|
||||
{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')}
|
||||
</span>
|
||||
</header>
|
||||
{#if chainError}
|
||||
<div class="alert inline-alert"><span class="alert-tag">ERR</span><span>{chainError}</span></div>
|
||||
<div class="alert inline-alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{chainError}</span></div>
|
||||
{/if}
|
||||
|
||||
{#if chain.parent}
|
||||
<div class="chain-row">
|
||||
<span class="chain-label">Parent</span>
|
||||
<span class="chain-label">{$t('apps.detail.chainParentLabel')}</span>
|
||||
<a class="chain-card" href={`/apps/${chain.parent.id}`}>
|
||||
<span class="chain-name">{chain.parent.name}</span>
|
||||
<span class="mono muted">{chain.parent.source_kind}</span>
|
||||
@@ -1885,14 +1883,14 @@
|
||||
disabled={promoting !== null}
|
||||
onclick={() => promoteFrom(chain!.parent!.id)}
|
||||
>
|
||||
{promoting === chain.parent.id ? 'Promoting…' : 'Promote from parent'}
|
||||
{promoting === chain.parent.id ? $t('apps.detail.chainPromoting') : $t('apps.detail.chainPromoteButton')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="chain-row">
|
||||
<span class="chain-label">This</span>
|
||||
<span class="chain-label">{$t('apps.detail.chainSelfLabel')}</span>
|
||||
<div class="chain-card chain-self">
|
||||
<span class="chain-name">{workload?.name ?? '—'}</span>
|
||||
<span class="mono muted">
|
||||
@@ -1909,7 +1907,7 @@
|
||||
|
||||
{#if chain.children.length > 0}
|
||||
<div class="chain-row chain-children">
|
||||
<span class="chain-label">Children</span>
|
||||
<span class="chain-label">{$t('apps.detail.chainChildrenLabel')}</span>
|
||||
<div class="chain-children-list">
|
||||
{#each chain.children as child (child.id)}
|
||||
<a class="chain-card" href={`/apps/${child.id}`}>
|
||||
@@ -1920,10 +1918,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="hint">
|
||||
Set <code>parent_workload_id</code> on a workload to build a chain. Image-source children
|
||||
can promote the parent's currently-running tag with one click.
|
||||
</p>
|
||||
<p class="hint">{@html $t('apps.detail.chainHint')}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -2024,26 +2019,28 @@
|
||||
{#if !editing}
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Volumes<span class="title-accent">.</span></h2>
|
||||
<h2 class="panel-title">{$t('apps.detail.volumesTitle')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub">
|
||||
{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) })}
|
||||
</span>
|
||||
</header>
|
||||
{#if volumeError}
|
||||
<div class="alert inline-alert"><span class="alert-tag">ERR</span><span>{volumeError}</span></div>
|
||||
<div class="alert inline-alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{volumeError}</span></div>
|
||||
{/if}
|
||||
{#if volumeRows.length > 0}
|
||||
<div class="table-wrap">
|
||||
<table class="forge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Target</th>
|
||||
<th>Source</th>
|
||||
<th>Scope</th>
|
||||
<th class="t-right">Updated</th>
|
||||
<th class="t-right">Actions</th>
|
||||
<th>{$t('apps.detail.volumesColTarget')}</th>
|
||||
<th>{$t('apps.detail.volumesColSource')}</th>
|
||||
<th>{$t('apps.detail.volumesColScope')}</th>
|
||||
<th class="t-right">{$t('apps.detail.volumesColUpdated')}</th>
|
||||
<th class="t-right">{$t('apps.detail.volumesColActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -2059,7 +2056,7 @@
|
||||
<button
|
||||
class="forge-btn-ghost danger"
|
||||
onclick={() => removeVolume(v.id)}
|
||||
aria-label={`Delete mount ${v.target}`}
|
||||
aria-label={`${$t('apps.detail.volumesColActions')}: ${v.target}`}
|
||||
>
|
||||
<IconTrash size={13} />
|
||||
</button>
|
||||
@@ -2078,24 +2075,24 @@
|
||||
}}
|
||||
>
|
||||
<label class="env-field">
|
||||
<span>Source (host)</span>
|
||||
<span>{$t('apps.detail.volumeSource')}</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newVolSource}
|
||||
placeholder="/srv/data/myapp"
|
||||
placeholder={$t('apps.detail.volumeSourcePlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<label class="env-field">
|
||||
<span>Target (container)</span>
|
||||
<span>{$t('apps.detail.volumeTarget')}</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newVolTarget}
|
||||
placeholder="/data"
|
||||
placeholder={$t('apps.detail.volumeTargetPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="env-field">
|
||||
<span>Scope</span>
|
||||
<span>{$t('apps.detail.volumeScope')}</span>
|
||||
<select bind:value={newVolScope}>
|
||||
<option value="absolute">absolute</option>
|
||||
<option value="named">named</option>
|
||||
@@ -2104,13 +2101,10 @@
|
||||
</select>
|
||||
</label>
|
||||
<button class="forge-btn" type="submit" disabled={volumeSaving || !newVolTarget.trim()}>
|
||||
{volumeSaving ? 'Saving…' : 'Add / Replace'}
|
||||
{volumeSaving ? $t('apps.detail.volumeSaving') : $t('apps.detail.volumeAddButton')}
|
||||
</button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
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.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.detail.volumeHint')}</p>
|
||||
</section>
|
||||
|
||||
{/if}
|
||||
@@ -2119,25 +2113,27 @@
|
||||
{#if !editing}
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Env<span class="title-accent">.</span></h2>
|
||||
<h2 class="panel-title">{$t('apps.detail.envTitle')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub">
|
||||
{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) })}
|
||||
</span>
|
||||
</header>
|
||||
{#if envError}
|
||||
<div class="alert inline-alert"><span class="alert-tag">ERR</span><span>{envError}</span></div>
|
||||
<div class="alert inline-alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{envError}</span></div>
|
||||
{/if}
|
||||
{#if envRows.length > 0}
|
||||
<div class="table-wrap">
|
||||
<table class="forge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th class="t-right">Updated</th>
|
||||
<th class="t-right">Actions</th>
|
||||
<th>{$t('apps.detail.envColKey')}</th>
|
||||
<th>{$t('apps.detail.envColValue')}</th>
|
||||
<th class="t-right">{$t('apps.detail.envColUpdated')}</th>
|
||||
<th class="t-right">{$t('apps.detail.envColActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -2148,7 +2144,7 @@
|
||||
{#if e.encrypted}
|
||||
<span class="state-pill st-encrypted">
|
||||
<span class="pulse" aria-hidden="true"></span>
|
||||
ENCRYPTED
|
||||
{$t('apps.detail.envEncrypted')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="mono">{e.value || '—'}</span>
|
||||
@@ -2159,7 +2155,7 @@
|
||||
<button
|
||||
class="forge-btn-ghost danger"
|
||||
onclick={() => removeEnv(e.id)}
|
||||
aria-label={`Delete ${e.key}`}
|
||||
aria-label={`${$t('apps.detail.envColActions')}: ${e.key}`}
|
||||
>
|
||||
<IconTrash size={13} />
|
||||
</button>
|
||||
@@ -2178,31 +2174,28 @@
|
||||
}}
|
||||
>
|
||||
<label class="env-field">
|
||||
<span>Key</span>
|
||||
<span>{$t('apps.detail.envKey')}</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newEnvKey}
|
||||
placeholder="DATABASE_URL"
|
||||
placeholder={$t('apps.detail.envKeyPlaceholder')}
|
||||
pattern="[A-Za-z_][A-Za-z0-9_]*"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="env-field">
|
||||
<span>Value</span>
|
||||
<input type="text" bind:value={newEnvValue} placeholder="(empty to unset)" />
|
||||
<span>{$t('apps.detail.envValue')}</span>
|
||||
<input type="text" bind:value={newEnvValue} placeholder={$t('apps.detail.envValuePlaceholder')} />
|
||||
</label>
|
||||
<label class="env-toggle">
|
||||
<ToggleSwitch bind:checked={newEnvEncrypted} label="Encrypt at rest" />
|
||||
<span>Encrypt at rest</span>
|
||||
<ToggleSwitch bind:checked={newEnvEncrypted} label={$t('apps.detail.envEncryptLabel')} />
|
||||
<span>{$t('apps.detail.envEncryptLabel')}</span>
|
||||
</label>
|
||||
<button class="forge-btn" type="submit" disabled={envSaving || !newEnvKey.trim()}>
|
||||
{envSaving ? 'Saving…' : 'Add / Replace'}
|
||||
{envSaving ? $t('apps.detail.envSaving') : $t('apps.detail.envAddButton')}
|
||||
</button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
Encrypted values are write-only after store — the API redacts them on read. Rotate by
|
||||
setting a new value.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.detail.envHint')}</p>
|
||||
</section>
|
||||
|
||||
<!-- Webhook URL panel removed — inbound webhooks live on
|
||||
@@ -2224,21 +2217,21 @@
|
||||
<span class="chev" class:rot={!openSource} aria-hidden="true">
|
||||
<IconChevronDown size={16} />
|
||||
</span>
|
||||
<h2 class="panel-title">Source config<span class="title-accent">.</span></h2>
|
||||
<h2 class="panel-title">{$t('apps.detail.sourceConfigTitle')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub mono">{workload.source_kind}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
onclick={() => copyToClipboard('source', prettyJson(workload!.source_config))}
|
||||
aria-label="Copy source config"
|
||||
aria-label={$t('apps.detail.sourceConfigCopyAria')}
|
||||
>
|
||||
{#if copied.source}
|
||||
<IconCheck size={13} />
|
||||
{:else}
|
||||
<IconCopy size={13} />
|
||||
{/if}
|
||||
<span>{copied.source ? 'Copied' : 'Copy'}</span>
|
||||
<span>{copied.source ? $t('apps.detail.sourceConfigCopied') : $t('apps.detail.sourceConfigCopy')}</span>
|
||||
</button>
|
||||
</header>
|
||||
{#if openSource}
|
||||
@@ -2261,20 +2254,20 @@
|
||||
<span class="chev" class:rot={!openFaces} aria-hidden="true">
|
||||
<IconChevronDown size={16} />
|
||||
</span>
|
||||
<h2 class="panel-title">Public faces<span class="title-accent">.</span></h2>
|
||||
<h2 class="panel-title">{$t('apps.detail.publicFacesTitle')}<span class="title-accent">.</span></h2>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
onclick={() => copyToClipboard('faces', prettyJson(workload!.public_faces))}
|
||||
aria-label="Copy public faces"
|
||||
aria-label={$t('apps.detail.publicFacesCopyAria')}
|
||||
>
|
||||
{#if copied.faces}
|
||||
<IconCheck size={13} />
|
||||
{:else}
|
||||
<IconCopy size={13} />
|
||||
{/if}
|
||||
<span>{copied.faces ? 'Copied' : 'Copy'}</span>
|
||||
<span>{copied.faces ? $t('apps.detail.sourceConfigCopied') : $t('apps.detail.sourceConfigCopy')}</span>
|
||||
</button>
|
||||
</header>
|
||||
{#if openFaces}
|
||||
@@ -2290,9 +2283,11 @@
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="Delete this app?"
|
||||
message={`Tears down all containers and proxy routes owned by "${workload?.name ?? 'this workload'}", then removes the row. This cannot be undone.`}
|
||||
confirmLabel={deleting ? 'Deleting…' : 'Yes, delete'}
|
||||
title={$t('apps.detail.deleteConfirmTitle')}
|
||||
message={$t('apps.detail.deleteConfirmMessage', {
|
||||
name: workload?.name ?? $t('apps.detail.deleteConfirmFallbackName')
|
||||
})}
|
||||
confirmLabel={deleting ? $t('apps.detail.deleteConfirmDeleting') : $t('apps.detail.deleteConfirmYes')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => {
|
||||
|
||||
@@ -325,7 +325,7 @@
|
||||
existingTriggers = [];
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load plugin kinds';
|
||||
error = e instanceof Error ? e.message : $t('apps.new.loadingKinds');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -533,20 +533,20 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>New App · Tinyforge</title>
|
||||
<title>{$t('apps.new.pageTitle')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="forge">
|
||||
{#snippet newLede()}
|
||||
Create a plugin-native workload. <em>Source</em> = how it deploys (image, compose, static).
|
||||
Pick or create a <em>trigger</em> below — when one fires, the source plugin redeploys.
|
||||
{$t('apps.new.ledePrefix')} <em>{$t('apps.new.ledeSourceLabel')}</em>
|
||||
{$t('apps.new.ledeSourceMid')} <em>{$t('apps.new.ledeTriggerLabel')}</em> {$t('apps.new.ledeSuffix')}
|
||||
{/snippet}
|
||||
|
||||
<ForgeHero
|
||||
backHref="/apps"
|
||||
backLabel="Back to apps"
|
||||
eyebrowSuffix="NEW APP"
|
||||
title="Forge a new app"
|
||||
backLabel={$t('apps.new.backLabel')}
|
||||
eyebrowSuffix={$t('apps.new.eyebrowSuffix')}
|
||||
title={$t('apps.new.title')}
|
||||
size="lg"
|
||||
lede_html={newLede}
|
||||
/>
|
||||
@@ -554,7 +554,7 @@
|
||||
{#if loading}
|
||||
<div class="loading-line">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>Loading available plugin kinds…</span>
|
||||
<span>{$t('apps.new.loadingKinds')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={submit} class="form" novalidate>
|
||||
@@ -564,36 +564,36 @@
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
{#if error}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
<div class="alert"><span class="alert-tag">{$t('apps.new.alertTag')}</span><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="app-name" class="field-label">
|
||||
<span class="num">01</span>
|
||||
<span class="lbl">Name</span>
|
||||
<span class="req">REQUIRED</span>
|
||||
<span class="lbl">{$t('apps.new.fieldName')}</span>
|
||||
<span class="req">{$t('apps.new.fieldNameRequired')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="app-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder="my-app"
|
||||
placeholder={$t('apps.new.fieldNamePlaceholder')}
|
||||
class="input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<p class="hint">Lowercase, no spaces. Becomes part of container names and subdomains.</p>
|
||||
<p class="hint">{$t('apps.new.fieldNameHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">02</span>
|
||||
<span class="lbl">Source plugin</span>
|
||||
<span class="opt">REQUIRED</span>
|
||||
<span class="lbl">{$t('apps.new.fieldSourcePlugin')}</span>
|
||||
<span class="opt">{$t('apps.new.fieldNameRequired')}</span>
|
||||
</div>
|
||||
<label class="sub" for="app-source">
|
||||
<span class="sub-label">Source</span>
|
||||
<span class="sub-label">{$t('apps.new.fieldSourceLabel')}</span>
|
||||
<select
|
||||
id="app-source"
|
||||
class="input"
|
||||
@@ -605,37 +605,34 @@
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<p class="hint">
|
||||
Populated from the running daemon — only plugins compiled in show up. Triggers
|
||||
(registry / git / manual) are configured below as standalone records.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.new.fieldSourceHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">03</span>
|
||||
<span class="lbl">Source config</span>
|
||||
<span class="lbl">{$t('apps.new.fieldSourceConfig')}</span>
|
||||
<span class="req">
|
||||
{useComposeForm
|
||||
? 'YAML'
|
||||
? $t('apps.new.fieldConfigYaml')
|
||||
: useImageForm || useStaticForm
|
||||
? 'FORM'
|
||||
: 'JSON'}
|
||||
? $t('apps.new.fieldConfigForm')
|
||||
: $t('apps.new.fieldConfigJson')}
|
||||
</span>
|
||||
</div>
|
||||
{#if useComposeForm}
|
||||
<div class="editor">
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">compose.yaml · compose</span>
|
||||
<span class="editor-title">{$t('apps.new.composeHeader')}</span>
|
||||
<span class="spacer"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleAdvancedJSON}
|
||||
title="Switch to the raw JSON editor"
|
||||
title={$t('apps.new.switchToJsonTitle')}
|
||||
>
|
||||
Advanced JSON
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
@@ -644,26 +641,26 @@
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"'}
|
||||
aria-label="Compose YAML"
|
||||
placeholder={$t('apps.new.composePlaceholder')}
|
||||
aria-label={$t('apps.new.composeAriaLabel')}
|
||||
></textarea>
|
||||
<div class="editor-foot">
|
||||
<span class="foot-status">
|
||||
<span class="foot-dot" aria-hidden="true"></span>
|
||||
YAML
|
||||
{$t('apps.new.fieldConfigYaml')}
|
||||
</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{composeYaml.split('\n').length} lines</span>
|
||||
<span>{composeYaml.split('\n').length} {$t('apps.new.linesUnit')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="sub" for="app-compose-project">
|
||||
<span class="sub-label">Compose project name (optional)</span>
|
||||
<span class="sub-label">{$t('apps.new.composeProjectLabel')}</span>
|
||||
<input
|
||||
id="app-compose-project"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={composeProjectName}
|
||||
placeholder="(defaults to sanitized workload name)"
|
||||
placeholder={$t('apps.new.composeProjectPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -675,36 +672,33 @@
|
||||
to be set at create time. -->
|
||||
<div class="image-form">
|
||||
<div class="image-form-head">
|
||||
<span class="editor-title">image source · runtime knobs</span>
|
||||
<span class="editor-title">{$t('apps.new.imageHeader')}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleAdvancedJSON}
|
||||
title="Switch to the raw JSON editor"
|
||||
title={$t('apps.new.switchToJsonTitle')}
|
||||
>
|
||||
Advanced JSON
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<label class="sub" for="app-image-ref">
|
||||
<span class="sub-label">Image (registry path)</span>
|
||||
<span class="sub-label">{$t('apps.new.imageRefLabel')}</span>
|
||||
<input
|
||||
id="app-image-ref"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={imageRef}
|
||||
placeholder="registry.example.com/owner/app"
|
||||
placeholder={$t('apps.new.imageRefPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<p class="hint">
|
||||
Fully-qualified reference; the tag is set per-deploy via the trigger or
|
||||
the Default tag field below.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.new.imageRefHint')}</p>
|
||||
</label>
|
||||
<div class="row three">
|
||||
<label class="sub" for="app-image-port">
|
||||
<span class="sub-label">Port</span>
|
||||
<span class="sub-label">{$t('apps.new.imagePort')}</span>
|
||||
<input
|
||||
id="app-image-port"
|
||||
type="number"
|
||||
@@ -715,7 +709,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-image-healthcheck">
|
||||
<span class="sub-label">Healthcheck path</span>
|
||||
<span class="sub-label">{$t('apps.new.imageHealthcheck')}</span>
|
||||
<input
|
||||
id="app-image-healthcheck"
|
||||
type="text"
|
||||
@@ -727,7 +721,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-image-default-tag">
|
||||
<span class="sub-label">Default tag</span>
|
||||
<span class="sub-label">{$t('apps.new.imageDefaultTag')}</span>
|
||||
<input
|
||||
id="app-image-default-tag"
|
||||
type="text"
|
||||
@@ -740,14 +734,14 @@
|
||||
</label>
|
||||
</div>
|
||||
<label class="sub" for="app-image-registry">
|
||||
<span class="sub-label">Registry (for private pulls)</span>
|
||||
<span class="sub-label">{$t('apps.new.imageRegistryLabel')}</span>
|
||||
{#if registries.length > 0}
|
||||
<select
|
||||
id="app-image-registry"
|
||||
class="input"
|
||||
bind:value={imageRegistryName}
|
||||
>
|
||||
<option value="">(public — no auth)</option>
|
||||
<option value="">{$t('apps.new.imageRegistryPublic')}</option>
|
||||
{#each registries as r}
|
||||
<option value={r.name}>{r.name} — {r.url}</option>
|
||||
{/each}
|
||||
@@ -758,18 +752,15 @@
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={imageRegistryName}
|
||||
placeholder="(public — no auth)"
|
||||
placeholder={$t('apps.new.imageRegistryPublic')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{/if}
|
||||
<p class="hint">
|
||||
Match the name from the Registries settings page. Leave empty for
|
||||
public images.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.new.imageRegistryHint')}</p>
|
||||
</label>
|
||||
<div class="row three">
|
||||
<label class="sub" for="app-image-cpu">
|
||||
<span class="sub-label">CPU limit (cores, 0 = ∞)</span>
|
||||
<span class="sub-label">{$t('apps.new.imageCpu')}</span>
|
||||
<input
|
||||
id="app-image-cpu"
|
||||
type="number"
|
||||
@@ -780,7 +771,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-image-memory">
|
||||
<span class="sub-label">Memory limit (MB, 0 = ∞)</span>
|
||||
<span class="sub-label">{$t('apps.new.imageMemory')}</span>
|
||||
<input
|
||||
id="app-image-memory"
|
||||
type="number"
|
||||
@@ -790,7 +781,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-image-max">
|
||||
<span class="sub-label">Max instances</span>
|
||||
<span class="sub-label">{$t('apps.new.imageMax')}</span>
|
||||
<input
|
||||
id="app-image-max"
|
||||
type="number"
|
||||
@@ -798,13 +789,10 @@
|
||||
class="input"
|
||||
bind:value={imageMaxInstances}
|
||||
/>
|
||||
<p class="hint">1 = strict blue-green.</p>
|
||||
<p class="hint">{$t('apps.new.imageMaxHint')}</p>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint image-form-foot">
|
||||
Env vars and volume mounts live in their own panels on the workload
|
||||
detail page after creation.
|
||||
</p>
|
||||
<p class="hint image-form-foot">{$t('apps.new.imageFoot')}</p>
|
||||
</div>
|
||||
{:else if useStaticForm}
|
||||
<!-- Static source form. Provider + repo + mode in
|
||||
@@ -812,19 +800,19 @@
|
||||
password input. -->
|
||||
<div class="image-form">
|
||||
<div class="image-form-head">
|
||||
<span class="editor-title">static source · pages from a repo</span>
|
||||
<span class="editor-title">{$t('apps.new.staticHeader')}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleAdvancedJSON}
|
||||
title="Switch to the raw JSON editor"
|
||||
title={$t('apps.new.switchToJsonTitle')}
|
||||
>
|
||||
Advanced JSON
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="app-static-provider">
|
||||
<span class="sub-label">Provider</span>
|
||||
<span class="sub-label">{$t('apps.new.staticProvider')}</span>
|
||||
<select
|
||||
id="app-static-provider"
|
||||
class="input"
|
||||
@@ -836,13 +824,13 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="sub" for="app-static-base-url">
|
||||
<span class="sub-label">Base URL</span>
|
||||
<span class="sub-label">{$t('apps.new.staticBaseUrl')}</span>
|
||||
<input
|
||||
id="app-static-base-url"
|
||||
type="url"
|
||||
class="input mono"
|
||||
bind:value={staticBaseURL}
|
||||
placeholder="https://git.example.com"
|
||||
placeholder={$t('apps.new.staticBaseUrlPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -850,26 +838,26 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="app-static-owner">
|
||||
<span class="sub-label">Repo owner</span>
|
||||
<span class="sub-label">{$t('apps.new.staticRepoOwner')}</span>
|
||||
<input
|
||||
id="app-static-owner"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={staticRepoOwner}
|
||||
placeholder="owner"
|
||||
placeholder={$t('apps.new.staticRepoOwnerPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-static-name">
|
||||
<span class="sub-label">Repo name</span>
|
||||
<span class="sub-label">{$t('apps.new.staticRepoName')}</span>
|
||||
<input
|
||||
id="app-static-name"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={staticRepoName}
|
||||
placeholder="pages"
|
||||
placeholder={$t('apps.new.staticRepoNamePlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
@@ -878,46 +866,44 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="app-static-branch">
|
||||
<span class="sub-label">Branch</span>
|
||||
<span class="sub-label">{$t('apps.new.staticBranch')}</span>
|
||||
<input
|
||||
id="app-static-branch"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={staticBranch}
|
||||
placeholder="main"
|
||||
placeholder={$t('apps.new.staticBranchPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-static-folder">
|
||||
<span class="sub-label">Folder path (optional)</span>
|
||||
<span class="sub-label">{$t('apps.new.staticFolder')}</span>
|
||||
<input
|
||||
id="app-static-folder"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={staticFolderPath}
|
||||
placeholder="(repo root)"
|
||||
placeholder={$t('apps.new.staticFolderPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="sub" for="app-static-token">
|
||||
<span class="sub-label">Access token (private repos)</span>
|
||||
<span class="sub-label">{$t('apps.new.staticToken')}</span>
|
||||
<input
|
||||
id="app-static-token"
|
||||
type="password"
|
||||
class="input"
|
||||
bind:value={staticAccessToken}
|
||||
placeholder="(leave blank for public repos)"
|
||||
placeholder={$t('apps.new.staticTokenPlaceholder')}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p class="hint">
|
||||
Encrypted at rest. Required only when the repo is private.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.new.staticTokenHint')}</p>
|
||||
</label>
|
||||
<fieldset class="static-mode">
|
||||
<legend class="sub-label">Mode</legend>
|
||||
<legend class="sub-label">{$t('apps.new.staticMode')}</legend>
|
||||
<label class="radio">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -926,8 +912,7 @@
|
||||
bind:group={staticMode}
|
||||
/>
|
||||
<span>
|
||||
<strong>static</strong> — serve files via nginx; zero runtime
|
||||
overhead.
|
||||
<strong>static</strong> {$t('apps.new.staticModeStaticDesc')}
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
@@ -938,40 +923,35 @@
|
||||
bind:group={staticMode}
|
||||
/>
|
||||
<span>
|
||||
<strong>deno</strong> — Deno runtime container with optional
|
||||
dynamic routing.
|
||||
<strong>deno</strong> {$t('apps.new.staticModeDenoDesc')}
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<label class="toggle-row">
|
||||
<ToggleSwitch
|
||||
bind:checked={staticRenderMarkdown}
|
||||
label="Render markdown"
|
||||
label={$t('apps.new.staticRenderMarkdown')}
|
||||
/>
|
||||
<span>
|
||||
<strong>Render markdown</strong> — auto-render <code>.md</code>
|
||||
files as HTML pages.
|
||||
<strong>{$t('apps.new.staticRenderMarkdown')}</strong> {@html $t('apps.new.staticRenderMarkdownDesc')}
|
||||
</span>
|
||||
</label>
|
||||
<p class="hint image-form-foot">
|
||||
The webhook secret for git push triggers lives on the workload's
|
||||
Webhook panel after creation.
|
||||
</p>
|
||||
<p class="hint image-form-foot">{$t('apps.new.staticFoot')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="editor">
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">source_config.json · {sourceKind}</span>
|
||||
<span class="editor-title">{$t('apps.new.sourceConfigJsonTitle', { kind: sourceKind })}</span>
|
||||
<span class="spacer"></span>
|
||||
{#if sourceKind === 'compose' || sourceKind === 'image' || sourceKind === 'static'}
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleAdvancedJSON}
|
||||
title="Switch back to the form"
|
||||
title={$t('apps.new.switchToFormTitle')}
|
||||
>
|
||||
Back to form
|
||||
{$t('apps.new.backToForm')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
@@ -979,7 +959,7 @@
|
||||
class="editor-chip"
|
||||
onclick={() => (sourceConfig = sourceConfigSample(sourceKind))}
|
||||
>
|
||||
Reset sample
|
||||
{$t('apps.new.resetSample')}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
@@ -988,15 +968,15 @@
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
aria-label="Source plugin configuration (JSON)"
|
||||
aria-label={$t('apps.new.sourceConfigJsonAria')}
|
||||
></textarea>
|
||||
<div class="editor-foot">
|
||||
<span class="foot-status" class:bad={!sourceValid}>
|
||||
<span class="foot-dot" aria-hidden="true"></span>
|
||||
{sourceValid ? 'JSON OK' : 'JSON INVALID'}
|
||||
{sourceValid ? $t('apps.new.jsonOk') : $t('apps.new.jsonInvalid')}
|
||||
</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{sourceLines} lines</span>
|
||||
<span>{sourceLines} {$t('apps.new.linesUnit')}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{sourceBytes} B</span>
|
||||
</div>
|
||||
@@ -1008,7 +988,7 @@
|
||||
<legend class="field-label as-legend">
|
||||
<span class="num">04</span>
|
||||
<span class="lbl">{$t('apps.new.triggers.section')}</span>
|
||||
<span class="opt">OPTIONAL</span>
|
||||
<span class="opt">{$t('apps.new.triggerNumOptional')}</span>
|
||||
</legend>
|
||||
<p class="hint">{$t('apps.new.triggers.sectionSub')}</p>
|
||||
|
||||
@@ -1029,7 +1009,7 @@
|
||||
class:active={triggerMode === 'inline'}
|
||||
onclick={() => (triggerMode = 'inline')}
|
||||
>
|
||||
<span class="trig-mode-tag mono">NEW</span>
|
||||
<span class="trig-mode-tag mono">{$t('apps.new.triggerNewTag')}</span>
|
||||
<span class="trig-mode-name">{$t('apps.new.triggers.modeInline')}</span>
|
||||
<span class="trig-mode-hint">{$t('apps.new.triggers.modeInlineHint')}</span>
|
||||
</button>
|
||||
@@ -1041,7 +1021,7 @@
|
||||
class:active={triggerMode === 'pick'}
|
||||
onclick={() => (triggerMode = 'pick')}
|
||||
>
|
||||
<span class="trig-mode-tag mono">PICK</span>
|
||||
<span class="trig-mode-tag mono">{$t('apps.new.triggerPickTag')}</span>
|
||||
<span class="trig-mode-name">{$t('apps.new.triggers.modePick')}</span>
|
||||
<span class="trig-mode-hint">{$t('apps.new.triggers.modePickHint')}</span>
|
||||
</button>
|
||||
@@ -1053,7 +1033,7 @@
|
||||
class:active={triggerMode === 'skip'}
|
||||
onclick={() => (triggerMode = 'skip')}
|
||||
>
|
||||
<span class="trig-mode-tag mono">SKIP</span>
|
||||
<span class="trig-mode-tag mono">{$t('apps.new.triggerSkipTag')}</span>
|
||||
<span class="trig-mode-name">{$t('apps.new.triggers.modeSkip')}</span>
|
||||
<span class="trig-mode-hint">{$t('apps.new.triggers.modeSkipHint')}</span>
|
||||
</button>
|
||||
@@ -1073,7 +1053,7 @@
|
||||
<div class="trig-sub">
|
||||
{#if existingTriggers.length === 0}
|
||||
<div class="note muted-note">
|
||||
<span class="note-tag">∅</span>
|
||||
<span class="note-tag">{$t('apps.new.noteEmptyTag')}</span>
|
||||
<p>{$t('apps.new.triggers.pickEmpty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -1100,7 +1080,7 @@
|
||||
{:else}
|
||||
<div class="trig-sub">
|
||||
<div class="note muted-note">
|
||||
<span class="note-tag">SKIP</span>
|
||||
<span class="note-tag">{$t('apps.new.noteSkipTag')}</span>
|
||||
<p>{$t('apps.new.triggers.skippedNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1110,34 +1090,34 @@
|
||||
<fieldset class="field group">
|
||||
<legend class="field-label as-legend">
|
||||
<span class="num">05</span>
|
||||
<span class="lbl">Public face</span>
|
||||
<span class="opt">OPTIONAL</span>
|
||||
<span class="lbl">{$t('apps.new.faceLabel')}</span>
|
||||
<span class="opt">{$t('apps.new.faceOptional')}</span>
|
||||
</legend>
|
||||
<div class="row three">
|
||||
<label class="sub" for="app-public-subdomain">
|
||||
<span class="sub-label">Subdomain</span>
|
||||
<span class="sub-label">{$t('apps.new.faceSubdomain')}</span>
|
||||
<input
|
||||
id="app-public-subdomain"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={publicSubdomain}
|
||||
placeholder="myapp"
|
||||
placeholder={$t('apps.new.faceSubdomainPlaceholder')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-public-domain">
|
||||
<span class="sub-label">Domain</span>
|
||||
<span class="sub-label">{$t('apps.new.faceDomain')}</span>
|
||||
<input
|
||||
id="app-public-domain"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={publicDomain}
|
||||
placeholder="(inherit from settings)"
|
||||
placeholder={$t('apps.new.faceDomainPlaceholder')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-public-port">
|
||||
<span class="sub-label">Target port</span>
|
||||
<span class="sub-label">{$t('apps.new.facePort')}</span>
|
||||
<input
|
||||
id="app-public-port"
|
||||
type="number"
|
||||
@@ -1148,20 +1128,17 @@
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Leave blank to skip provisioning a proxy route. Filling any field creates a single
|
||||
face row attached to this workload.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.new.faceHint')}</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/apps" class="forge-btn-ghost">Cancel</a>
|
||||
<a href="/apps" class="forge-btn-ghost">{$t('apps.new.cancel')}</a>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="submit"
|
||||
disabled={submitting || !name.trim() || (!useComposeForm && !useImageForm && !useStaticForm && !sourceValid) || (useImageForm && !imageRef.trim()) || (useStaticForm && (!staticRepoOwner.trim() || !staticRepoName.trim())) || !triggerStepValid}
|
||||
>
|
||||
<span>{submitting ? 'Forging…' : 'Forge app'}</span>
|
||||
<span>{submitting ? $t('apps.new.submitting') : $t('apps.new.submit')}</span>
|
||||
<span class="arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user