Commit Graph

33 Commits

Author SHA1 Message Date
Paul Payne
1767a0a304 Adds custom domains. 2026-07-12 17:29:34 -07:00
Paul Payne
a3d01ca1b3 Tools schema generation and ux tweaks. 2026-07-07 17:48:52 -07:00
Paul Payne
ea59d846bc app: cross-link entity pages along graph edges
- service/static/tool/job detail pages now link back to their program;
  backfill the program ref on registry-only static+job details (the
  registry Deployment carries no program ref).
- new RelatedDeployments card surfaces a deployment's requires edges both
  directions (Depends on / Required by) as links; references render inert.
- replace the service detail Subdomain/Served-at rows with a launch icon
  in the header that opens the gateway URL in a new tab.
2026-07-07 13:27:46 -07:00
Paul Payne
e438f45b54 feat: deployment-scoped requires + UI editors, gateway nav, services filter
Model:
- Drop `requires` from ProgramSpec; requires is now deployment-only.
  Collapse Requirement to {ref, bind} with kind defaulting to "deployment"
  (drop the unused `version`). System-package preconditions stay on the
  program as `system_dependencies`, synthesized into {kind: system} for the
  functional check. relations.py/deploy.py read only the deployment's requires.
- create.py seeds a stack's substrate dependency onto the deployment, not the
  program. Docs + tests updated.

UI:
- Add a `requires` editor (useRequires) on service, job, and static detail
  pages — edit service→service deps (ref + optional bind env var).
- Restore the gateway route table "Open" column: a kind-aware in-app link to
  each route's deployment.
- Reformat the reach control: name baked into each option
  (localhost:PORT (local) / <name>.<domain> (internal) / <name>.<pubdomain> (public)).
- Services page: sort statics in with systemd services by name, add a
  search + kind-filter bar mirroring the programs page.
2026-07-06 04:00:24 -07:00
Paul Payne
20bf78caf1 api+ui: kind-scoped save/delete endpoints and links
API: /config/{services,jobs,tools,static}/{name} now pin the twin they
target — _save_deployment/_delete_deployment take an explicit kind so a
patch to a 'backup' service can't bleed into a 'backup' job/tool. Add
/tools and /static endpoints; keep /deployments/{name} kind-agnostic.
New test_kind_twins proves per-kind save/delete isolation on disk.

UI: ConfigPanel and CreateDeploymentForm write to the kind-scoped
resource; GatewayPanel/NodeDetail/DeploymentsSection link via a shared
detailPath(name, kind) helper instead of the ambiguous /deployment/:name.

Includes incidental ruff-format reflow of untouched api files.
2026-07-06 02:51:08 -07:00
Paul Payne
07c02aa151 fix(api): editable-spec detail + PATCH-merge saves + shared gateway parser
Deployment/program edits could silently drop spec fields:

- GET /deployments/{name} served the runtime view (no reach/program/root/expose)
  for deployed deployments, so the dashboard round-tripped a lossy manifest and
  stripped fields on save (broke astro). Now serves the editable castle.yaml spec
  when the deployment is in config.
- _save_deployment and save_program replaced the whole object with client input.
  Now shallow-merge over the existing spec (omit = preserve, null = clear), so a
  partial/lossy save can't drop untouched fields.
- save_yaml rebuilt GatewayConfig from `port` only, wiping tls/domain/tunnel/
  cert_hook on a whole-file save. Now uses a shared parse_gateway() (also used by
  load_config) so gateway fields can't drift between the two.

Dashboard forms (ServiceFields/StaticFields) send explicit null to clear, per the
merge contract; adds exposure host-label helpers. Coverage: detail-serves-spec,
save round-trip, partial-patch-preserves, null-clears, program partial-patch,
parse_gateway, and config save/load round-trip.
2026-07-05 23:23:59 -07:00
Paul Payne
add356dcf2 feat: relationship model (requires/repos/predicates) + git sync
A derived, mostly-computed model of how programs, deployments, and repos relate,
plus the git-sync surfaces that motivated it. See docs/relationships.md.

Core:
- `requires: [{kind, ref, version?, bind?}]` on programs + deployments — one
  precondition relation; `system_dependencies` is its `{kind: system}` alias.
  kind fixes meaning + check (system=installed, deployment=exists).
- relations.py: derives repos (git toplevel / monorepo), fan-in, and the
  predicates functional?/fresh?/deployed? — nothing stored.
- env is generated FROM a `{kind: deployment, bind}` requirement (target URL →
  consumer env), never scraped back into one; explicit defaults.env still wins.
- git.py: working-copy status/pull, repo toplevel + remote url.

Surfaces:
- `castle graph` + GET /graph — the relationship diagnostic.
- GET /repos, /repos/{key}/git|sync — repo-scoped sync (a repo is the sync unit;
  a monorepo backs several programs). GET /programs/{name}/git|sync + repo context.
- Dashboard: Graph screen, program-page git status + repo-aware Sync, and a
  monorepo banner on Programs.

Governing principle: predicates are derived; encode only the non-derivable, as a
node or edge property. Pull-only sync — converge stays a separate step.
2026-07-05 10:55:42 -07:00
Paul Payne
3c566540aa feat: raw-TCP exposure with castle-managed TLS + reach model
Expose raw-TCP services (postgres) by name at <name>.<domain>:<port> and
cut the gateway's ACME wildcard cert onto them so they present a trusted
cert. Replaces the proxy/public booleans with a single `reach` enum
(off|internal|public); legacy input still parses via derived accessors.

Core:
- expose.tcp{port,tls} + TlsSpec(material: pair|combined|off, reload)
- tls.py: materialize cert files from Caddy's wildcard, reconcile on
  renewal; `castle tls` CLI; optional cert_obtained events-exec hook
  (gateway.cert_hook, gated so a plugin-less Caddy still parses)
- apply waits (bounded) for the wildcard to issue before materializing so
  a fresh-node TLS service starts with its cert in place, then scopes
  materialization to the deployments being applied
- reach: internal|public requires an expose block (no silent no-op);
  public raw-TCP guarded until tunnel support lands
- chain.pem (${tls_ca}) is the issuer chain (leaf stripped), a real CA
  bundle distinct from cert.pem
- one shared ${...} resolver (resolve_placeholders) for env and container
  run fields; run.env now expands like volumes/args; $$ escapes a literal
- validate the generated Caddyfile before reloading the gateway so an
  invalid config never degrades routing

Docs: docs/tcp-exposure.md. Tests cover reach/expose validation,
placeholder expansion + escape, issuer-chain material, TLS materialize.
2026-07-04 23:04:26 -07:00
Paul Payne
f422c1879d ui: Apply everywhere — power toggles, plan preview, no start/stop/install
Rework the dashboard around convergence:
- ServiceControls / ServiceCard / JobCard: replace start/restart/stop
  with a Power toggle (set enabled → apply, i.e. activate/deactivate)
  plus Restart (the one imperative bounce).
- ToolDetail: install/uninstall becomes an Enable/Disable toggle;
  `installed` stays the live state, `enabled` the desired one.
- ConfigPanel Apply and CreateDeploymentForm now POST /apply (one
  converge that renders + restarts only what changed) instead of
  /deploy + a separate start/restart.
- New ConvergePanel on Overview: a terraform-style Preview (POST /apply
  plan=true) showing would-activate/restart/deactivate, then Apply.
- useApply + useSetEnabled hooks; `enabled` added to Service/Job/
  Deployment types; both handle the self-restart connection drop.

Backend: PUT /config/deployments/{name}/enabled sets desired on/off
(edit config; caller runs apply) — the declarative toggle the UI uses.

Dashboard builds clean (tsc + vite); 59 API tests pass; live /apply and
/services?enabled verified after an api restart.
2026-07-02 11:49:54 -07:00
Paul Payne
6929bd89d3 Detail pages: rename the 'Runs' field to 'Launch' (matches the launcher concept)
The field showing a deployment's launcher + target is now labelled 'Launch'
(consistent with launcher/manager vocabulary) on ServiceDetail and the Service/
Job/Create field editors.
2026-07-01 13:35:02 -07:00
Paul Payne
86d4552fdc Dashboard: Deployments nav group, tool detail page, deployment links, static-aware detail
- Nav: Services/Scheduled/Tools nested under a 'Deployments' group (expandable;
  flattened to icons when the sidebar is collapsed). Programs stays top-level.
- Overview: Tools tile (Wrench, count of tool programs).
- New /tools/:name tool detail page (useDeployment): Install/Uninstall lifecycle,
  a link up to its program, and an editable ToolFields (description + env).
- ServiceDetail is static-aware: a caddy deployment shows served-by-gateway +
  URL + root, no start/stop, and edits via a new StaticFields (root + public + env)
  so a launcher is never injected into a caddy deployment.
- Program detail's Deployment section is now a clean list of links — every
  deployment (tool→/tools, service/static→/services, job→/jobs) links to its
  detail page; lifecycle controls moved onto those pages.

clean pnpm build + tsc.
2026-07-01 12:57:25 -07:00
Paul Payne
01505328ad Dashboard: programs list deployments; Tools page; statics on Services; editable launcher
- Programs page: each card lists the program's deployments (name · kind) instead
  of a single kind badge; kind-filter chips removed (nav is the kind lens now).
  protonmail honestly shows tool + job.
- New Tools nav page (Wrench) — programs with a tool deployment (/programs?kind=tool).
- Services page now shows statics too: /services returns service + static, and a
  static renders as a caddy-served card (KindBadge, served URL, no start/stop).
- Program detail: DeploymentsSection + deleteConfirm rebuilt over program.deployments;
  header drops the (now nonexistent) program kind.
- ServiceFields / JobFields: launcher is now an editable select (python|command|
  container|compose|node), persisted into run.launcher via the normal save.

clean pnpm build + tsc --noEmit.
2026-07-01 12:32:09 -07:00
Paul Payne
876b424722 Dashboard: kind filters on Programs, tools off Services page, in-app confirm modals
- /services was returning every non-job deployment (tools, statics, remotes);
  filter it to kind==service so the Services page shows only services.
- Programs page gains kind-filter chips (All / Service / Job / Tool / Static /
  Reference) with per-kind counts, colored to match KindBadge.
- Replace window.confirm with a reusable ConfirmModal (styled, danger variant,
  Esc/Enter, preserves bulleted bodies); FormFooter's delete uses it, so program/
  service/job deletion now confirms in-app with the enumerated cascade list.

castle-api 58 passed; /services live drops from 37 to 15 (services only).
2026-07-01 11:59:14 -07:00
Paul Payne
38074806c1 Delete-program confirm enumerates each action
The confirm dialog (window.confirm in FormFooter) now spells out exactly what
cascade will do — uninstall the tool from PATH / drop the static route / stop,
disable and remove named service+job deployments, and remove the catalog entry —
each as its own bullet, ending with the reassurance that source on disk is kept.
2026-07-01 11:52:56 -07:00
Paul Payne
fa8890ac45 Delete program cascades to its deployments (no more dead-end gate)
A program and its 1:1 tool/static deployment are one thing to the user, so
'Delete program' now just works instead of refusing. DELETE /config/programs/
{name}?cascade=true tears down each referencing deployment (manager-aware:
uninstall a tool from PATH, stop+disable a service/job, drop a static route),
removes them and the program, then converges the runtime (prune orphan units,
regenerate the Caddyfile, reload the gateway). Without cascade it still 409s
(safe API default). The dashboard drops the 'can't remove while deployed' block,
labels the action 'Delete program', and the confirm names exactly what will go.

Verified live: throwaway tool → cascade delete removes program + path deployment,
real config untouched; plain delete of a referenced program still 409s.
castle-api 57 passed.
2026-07-01 11:42:48 -07:00
Paul Payne
6ee6d4c850 Dashboard: lifecycle is a deployment concern, not a program action
Install/Uninstall no longer live in the program's action cluster (they were a
tool's PATH-deployment lifecycle masquerading as a program action). ProgramActions
is now dev-verbs-only (build/test/lint/type-check/check). The program detail's
Deployment section owns lifecycle, keyed on kind: a tool shows Installed-on-PATH
with an Install/Uninstall toggle, a static shows served-by-gateway + its URL,
services/jobs link to their own pages. Program list cards drop the quick toggle
for a plain active dot. 'Remove program' stays as catalog deletion — no longer a
dead-end now that the blocking deployment is visible in the Deployment section.
2026-07-01 11:32:25 -07:00
Paul Payne
317232ca6a Manager-first deployment model: split runner, merge service/job, frontend→static
Replace the conflated `runner` axis with two orthogonal ones: `manager`
(systemd|caddy|path|none) — who supervises/realizes a deployment — and, for
systemd only, a nested `launcher` (python|command|container|compose|node) — how
the process starts. ServiceSpec and JobSpec collapse into one manager-
discriminated DeploymentSpec union (Systemd/Caddy/Path/Remote); the services/
and jobs/ config dirs collapse into one deployments/ dir. The human "kind"
(service|job|tool|static|reference) is fully derived (kind_for), never stored —
the frontend kind is renamed static. behavior is gone.

- core: DeploymentSpec union + LaunchSpec + kind_for; legacy-aware loader
  normalizes old runner shapes; CastleConfig.deployments with derived
  services/jobs/tools views; registry.Deployment carries manager/launcher/kind.
- cli: service/job/tool as filtered views + a deployment group; --behavior→--kind,
  create --runner→--launcher; lifecycle dispatches over config.deployments.
- castle-api: /deployments primary with /services,/jobs as views; summaries
  derive kind; PUT/DELETE /config/deployments/{name} (services/jobs aliased).
- app: KindBadge, frontend→static everywhere, pick-a-kind creation wizard,
  per-kind config editors.
- docs: single deployments/ layout, manager/launcher, static kind throughout.

Live migration verified byte-identical: regenerated Caddyfile and every unit
ExecStart line unchanged, so nothing restarted. Suites: core 124, cli 25,
castle-api 55; dashboard build + type-check clean.
2026-07-01 10:23:03 -07:00
Paul Payne
fa65ae3d1c Dashboard: public routes link to their public URL; drop editable behavior field
- Gateway route table: a public route's address now links to its public URL
  (green, with the tunnel icon) instead of the internal one.
- Program detail: remove the editable `behavior` selector — behavior is a derived
  label now and drives no logic, so editing it was meaningless (the stored value
  is preserved on save via the manifest spread).
2026-07-01 08:35:37 -07:00
Paul Payne
4371e46240 Add ${public_url} to the env placeholder hint in the UI 2026-06-30 23:16:19 -07:00
Paul Payne
14145c9f15 Populate the program stack select from castle-api
The dashboard's stack dropdown hardcoded its options (python-cli/fastapi/
react-vite), so a supabase program showed "(none)" — the value had no matching
option. Make the backend the single source of truth instead:

- core: available_stacks() returns sorted(HANDLERS) — the stacks castle has
  handlers for.
- castle-api: GET /stacks exposes it.
- cli: --stack choices derive from available_stacks() (drops the duplicated list).
- app: ProgramFields fetches /stacks (useStacks) and renders options from it,
  always including the current value so it never silently blanks; StackBadge
  gets a Supabase label.

A new backend stack now appears everywhere without a frontend edit.
2026-06-30 21:43:11 -07:00
Paul Payne
770574f575 Flatten proxy config to a bool (drop the caddy key)
CaddySpec had only `enable` left (path_prefix/host removed; extra_snippets was
unused), so `proxy: { caddy: {} }` was three levels of nesting for a checkbox —
and it invited drift (supabase silently reverted to proxy.caddy.host). Replace it
with a plain `ServiceSpec.proxy: bool`.

- Model: remove ProxySpec/CaddySpec; `proxy: bool = False`. Readers simplified to
  `bool(svc.proxy)` (generator, castle-api summaries, CLI info).
- CLI create / dashboard editor + create form: write `proxy: true` / a checkbox.
- Configs migrated to `proxy: true` (and supabase's stale proxy.caddy.host block
  removed).
- Docs (registry, dns-and-tls, design, stack guides) + tests/fixtures updated;
  also fixed lingering path-model staleness in the stack guides.
2026-06-30 21:23:14 -07:00
Paul Payne
15dc8ef6f7 Dashboard UI: subdomain checkbox, drop proxy path/host
Follow the backend to the subdomain-only model:
- Service editor + create form: replace the Proxy path / Proxy host text fields
  with a single "Expose" checkbox (writes proxy: { caddy: {} } or removes proxy).
- ServiceSummary/DeploymentSummary type: proxy_path/proxy_host → subdomain.
- ServiceCard / ServiceDetail: show the subdomain, linking to
  <subdomain>.<domain> via a new subdomainUrl() helper (domain derived from the
  dashboard's own host; null on a bare host / off mode).
2026-06-30 20:55:38 -07:00
Paul Payne
a3a862b63f feat: Add type-check script and enhance UI for better text handling 2026-06-27 12:46:15 -07:00
Paul Payne
414168dc43 ui: Apply (deploy & restart) after a service/job config edit
Editing a service/job only persisted to castle.yaml; the unit isn't
regenerated and the process isn't restarted (the Restart button just runs
'systemctl restart' on the *existing* unit), so a change like a new port
appeared saved but never took effect.

The config panel now, after saving a service/job, shows an amber 'not live
yet' banner with an Apply button that runs POST /deploy {name} (regenerate
unit + Caddyfile) then, for services, POST /services/{name}/restart — so the
edit actually goes live. Programs are unaffected (no deploy/restart needed).

App builds clean.
2026-06-15 20:25:20 -07:00
Paul Payne
ba60019a52 ui: field help text + editable program commands
- Add a 'hint' to the shared Field/TextField; annotate the config forms with
  short, accurate descriptions of each field:
  - program: source (working copy castle builds from), behavior (tool/daemon/
    frontend), stack (optional toolchain template), repo/ref (for clone),
    system deps (listed for reference, NOT auto-installed), commands.
  - service: port (listened-on, health-checked, proxied; map via ${port}),
    health path, proxy path vs proxy host, runs.
  - job: schedule (cron → systemd timer), runs.
- ProgramFields: the Commands section is now editable (build/test/lint/
  type-check/run), so a program with no stack can declare its own dev verbs
  by hand — a declared command overrides the stack default. Verified the
  commands/build shape round-trips through the config API.

App builds clean.
2026-06-15 08:50:03 -07:00
Paul Payne
7314b5cddb refactor(env): explicit defaults.env with placeholders; drop auto-injection
A service/job's env is now exactly its defaults.env — castle injects no hidden
convention vars. Values support ${port}/${data_dir}/${name} placeholders
(resolved at deploy, alongside ${secret:…}), so a program's own env var names
map to castle's computed values without hardcoding.

Why: the auto-injected <PREFIX>_PORT/<PREFIX>_DATA_DIR were a guess at the
program's env names — right for castle-scaffolded services, dead weight for
adopted ones (lakehouse carried two dead vars; notification-bridge/backup jobs
too). They also weren't visible in the config editor (computed at deploy), which
was the source of the 'four env vars but the UI shows none' mystery.

- core: resolve_env_vars gains a context (${port}/${data_dir}/${name});
  deploy builds env from defaults.env only — no <PREFIX>_* injection, no
  port_env. Removed the port_env field and the dead _env_prefix helper.
- cli: 'service/job create' gains repeatable --env KEY=VALUE (replaces
  --port-env); 'program create' scaffolds <PREFIX>_PORT/_DATA_DIR: ${…} for new
  daemons.
- app: removed the 'Port env' field; the Environment editor (defaults.env) is
  the single place, with a placeholder hint.
- live migration: central-context/castle-api/power-graph/protonmail mapped their
  real vars explicitly; lakehouse → just LAKEHOUSED_DAEMON_PORT: ${port}, data
  stays in ~/.lakehoused. Verified all services healthy on their ports, dead
  vars gone, zero failed units.
- docs: registry.md/design.md/stack guides + findings updated to the explicit
  model.

core 94 / cli 24 / api 52 green; ruff + app build clean.
2026-06-14 17:06:46 -07:00
Paul Payne
82f12c9d61 Remove buttons: confirm + block program removal with active deployments
The Stage 2 editor split dropped the old confirm() — Remove program/service/job
deleted immediately on click. Restored confirmation on all three (FormFooter
window.confirm with a per-type message).

Removing a program that still has services/jobs referencing it would orphan
their program: ref. Now blocked:
- API: DELETE /config/programs/{name} → 409 'Programs with active jobs or
  services cannot be removed. Delete these first: …' (authoritative).
- App: ProgramFields shows the blocked reason instead of a Remove button when
  the program has deployments.
- CLI: castle delete refuses a program with referencing deployments not named
  the same (which would survive the delete dangling).

Verified live: DELETE program lakehouse → 409. api 52 / cli 24 green; build clean.
2026-06-14 15:52:57 -07:00
Paul Payne
2a073fc0b4 Stage 4: App — create service/job forms (UI twin of castle expose)
CreateDeploymentForm builds a service or job in castle.yaml (PUT /config/...),
then deploys it (POST /deploy {name} → unit + Caddyfile + reload) and, for a
service, starts it (POST /services/{name}/start), then navigates to the new
detail page.

Reachable two ways:
- Program page Deployments section: 'Create service' / 'Create job' buttons,
  prefilled from the program (name, program ref, run target, runner).
- Dashboard: standalone '+ Add service' / '+ Add job' (deployments can run
  anything, not just castle programs).

Service form covers port / port_env / health / proxy path / proxy host; job
form covers schedule. Name validated against existing services/jobs.

Verified: the configs the form builds validate (PUT 200) and round-trip clean.
App builds; api 52 / core 94 green from prior stages.
2026-06-14 15:41:13 -07:00
Paul Payne
dea585b15b Stage 3: App — program/deployment model & controls
- ProgramActions: install/uninstall (activate) shown only for tools and
  no-service frontends. Daemons activate via a service/job, so the program
  page no longer offers a misleading Install/Uninstall (which secretly
  enabled/disabled the service).
- Program page: new Deployments section listing the services/jobs that run
  the program, as links (a program → 0-N services/jobs).
- Service page: 'Runs' row from the run_target summary field (the old code
  dug into manifest.run, which was empty pre-Stage-1), a Host row for
  proxy_host, and a convenience link to the referenced program.
- lifecycle.activate on a daemon with no service now errors toward
  'castle expose' instead of silently installing its binary to PATH.

core 94 green; ruff + app build clean.
2026-06-14 15:37:20 -07:00
Paul Payne
73698fafe7 Stage 2: App — split the one editor into typed program/service/job forms
The single service-shaped DeploymentFields was reused for all three sections,
so the program page bled service fields (port/proxy/health) and couldn't edit
program config, and the job page couldn't edit its schedule. Replaced it with:

- ProgramFields  — description, source, stack, behavior, version, repo/ref,
  system deps (+ read-only commands). Edits the program catalog entry.
- ServiceFields  — run target, port, port_env, health, proxy path + host,
  env/secrets. Prefills correctly now (Stage 1) and round-trips through PUT.
- JobFields      — schedule, run target, env/secrets.

ConfigPanel selects the form by section. Shared Field/TextField/useEnvSecrets/
FormFooter helpers in detail/fields.tsx. Retired DeploymentFields. run editing
only touches python/command specs (container/node/remote run blocks preserved).

Verified: GET service manifest → PUT back validates 200. App type-checks/builds.
2026-06-14 15:34:07 -07:00
Paul Payne
0515faadfd refactor(app): rename Component* UI widgets to accurate domain names
The dashboard widgets were named after the old 'component' concept. Renamed
each to what it actually renders:

- ComponentCard  → ServiceCard      (ServiceSummary, /services/, service actions)
- ComponentTable → ProgramTable     (ProgramSummary[], the program catalog)
- ComponentEditor→ DeploymentEditor (DeploymentDetail, the unified type)
- ComponentFields→ DeploymentFields (AnyDetail)
- AddComponent   → AddDeployment

Also purged the domain term 'component' from the rest of the app: the
useComponent hook → useDeployment, ConfigPanel + the three detail pages'
`component` prop/var → `deployment`, and the gateway/node table headers
(Component → Program / Deployment). Props renamed to match (component →
service / program / deployment). The React UI directory app/src/components/
keeps its conventional name.

App type-checks and builds clean.
2026-06-14 12:13:41 -07:00
Paul Payne
0d36e4f72a Refactor component terminology to programs in config and manifest
- Updated the terminology from "components" to "programs" across the codebase, including in config loading, saving, and manifest specifications.
- Introduced a new `stacks.py` file to handle lifecycle actions for development stacks, implementing handlers for Python and React Vite stacks.
- Adjusted tests to reflect the new program structure and ensure proper functionality.
- Revised documentation to align with the new terminology and structure, ensuring clarity on the purpose and configuration of programs, services, and jobs.
2026-02-23 22:09:41 -08:00
Paul Payne
f559fba143 feat: Introduce job and service categorization for components, enhancing routing and display logic across the application 2026-02-23 17:02:55 -08:00