370 Commits
Author SHA1 Message Date
Juan DenisandClaude Fable 5 0ec55accbe feat(extensions): settings.section slot + manifest shape lint (#52)
Extensions can now contribute Settings sections: a settings.section
PluginSlot renders below the active Settings tab with the tab id as
context. _validate_manifest grew shape checks for everything the
platform consumes declaratively — module:attr refs (entry_point,
socket_entry, models, lifecycle hooks, job handlers), jobs/schedules
shapes, and per-kind required contribution keys (nav/routes/tabs/
palette/widgets/layouts) — so a malformed block fails the install with
a message naming each problem instead of being silently dropped at
runtime. Unknown contribution kinds only warn (forward compat). The
scaffolder gained --validate <path> mirroring the same rules for
authors, wired into the conversion recipe in docs/EXTENSIONS.md.

3 new lint tests incl. one asserting every shipped builtin manifest
passes; full backend suite 1227 green; lint 0 errors; build clean;
--validate smoke-tested on a good and a bad manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:36:46 -04:00
Juan DenisandClaude Fable 5 136f8b195a refactor(marketplace): retire the legacy Extension catalog (#51)
The DB-seeded Extension/ExtensionInstall catalog was dead weight:
nothing ever populated it on a real panel, so it fed Browse an empty
third lane redundant with the real sources (builtin folder scan, remote
registry, InstalledPlugin state), and its orphaned per-user config
plumbing connected to nothing a plugin could read (superseded by #49).

Removed: the two models, marketplace_service.py, all legacy /marketplace
CRUD/install/rate endpoints (the blueprint keeps only the registry
endpoints), the frontend published lane (catalog mappers, install/
uninstall handlers, rating stars, published stats) and its API methods.
Migration 046 drops the orphaned tables.

Pipeline + registry suites 29 green; lint 0 errors; build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:21:45 -04:00
Juan DenisandClaude Fable 5 046ae0eacc feat(extensions): daily scheduled update check + admin notification (#50)
New builtin.extension_updates ScheduledJob (daily) runs
check_for_updates() against the registry and notifies admins via the
Notifications Bus (new extensions.updates_available catalog event,
to=admins). Fingerprint marker in settings de-dups: one notification
per new release set, not one per day; the Marketplace badge stays the
live surface. Dogfoods both the Jobs and Notify extension-facing SDKs.

3 new tests (notify-once-per-release, quiet when none, schedule
seeded); jobs suite builtin count updated 9 -> 10; pipeline+jobs 51
green, notification bus 20 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:15:03 -04:00
Juan DenisandClaude Fable 5 4ca07fb71c feat(extensions): per-plugin config store + Configure form (#49)
Completes the deferred half of #30. InstalledPlugin gains config_json
(migration 045); admin-gated GET/PUT /api/v1/plugins/<id>/config
persists values; plugins read them via the new plugins_sdk.config(slug)
accessor. The Marketplace Installed tab shows a Configure button for
plugins declaring a config_schema and renders a real form
(string/number/integer/boolean/enum/secret fields, defaults, hints).
Values may hold secrets so they are excluded from to_dict() and only
served by the admin endpoint. Documented in docs/EXTENSIONS.md.

2 new tests (config round-trip + SDK accessor + secret non-leak;
non-object rejection); pipeline suite 18 green; lint 0 errors; build
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:09:39 -04:00
Juan DenisandClaude Fable 5 1c911b50df feat(extensions): installed plugins survive panel updates (#48)
A panel update deploys a fresh clone/tarball preserving only .env and
the DB, wiping app/plugins/<slug> + src/plugins/<slug> for any
URL/registry/upload-installed extension — the surviving InstalledPlugin
row then flipped to error at next boot. Two-layer fix:

- update.sh: preserve_installed_plugins carries user-installed plugin
  dirs from the old tree into the new one (skipping anything the new
  tree already ships and __pycache__), before the frontend build so
  carried UIs recompile. Covered by harness test T18.
- boot repair pass (plugin_service.repair_missing_plugins, runs before
  load_all_plugins): builtin slugs reinstall from builtin-extensions/,
  url installs re-download from source_url (force + no hot-load so the
  loader registers once), sourceless uploads get a clear re-upload
  error. install_from_url/path grew force/hot_load plumbing.

4 new repair tests (16 total in the pipeline suite); update.sh harness
20 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:03:33 -04:00
Juan DenisandClaude Fable 5 36b6a92900 feat(extensions): convert Cloud Provisioning, Remote Access and Status Pages to builtins (#45-#47)
Three more tab-group conversions on the #43 primitive, completing the
plan''s Phase 1 seed list:

- serverkit-cloud-provision — "Cloud Servers" tab in the Servers group
  (order 4 keeps its original strip position)
- serverkit-remote-access — "Remote Access" tab in the Servers group
  (order 5), plus the command-palette entry the core page never had;
  pages/RemoteAccess.jsx stays core (the server detail page embeds it
  with serverId, independent of install state)
- serverkit-status — "Status Pages" tab in the Observability group; the
  public /status/:slug route and PublicStatusPage stay core

Backends stay core per D2 (two-speed). All three added to
CONVERTED_BUILTIN_SLUGS so upgraded panels auto-install them once;
fresh installs see them in the Marketplace (10 builtins total).

Full backend suite 1215 passed; lint 0 errors; build clean; builtin
frontend sync --check green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:54:21 -04:00
Juan DenisandClaude Fable 5 50166b8f78 feat(extensions): tab-group contributions (#43) + FTP as serverkit-ftp (#44)
Extensions can now contribute a real tab to a core-owned tab group:
a new `tabs` contribution kind ({group, to, label, icon?, end?, order?},
group id == sidebar item id) is merged into the strip by TabGroupLayout
(new groupId prop; core wins `to` collisions), and route contributions
gained a `group` field so ExtensionRoutes collects them per group and
App.jsx mounts them INSIDE that group''s TabGroupLayout — the shared
PageTopbar chrome stays and the Sidebar keeps the group item lit via
contributed matchPrefixes. Wired for files/servers/monitoring.

First consumer: the FTP Server page moves to the serverkit-ftp builtin
extension (tab in the Files group; backend /api/v1/ftp stays core per
D2). Added to CONVERTED_BUILTIN_SLUGS so upgraded panels auto-install
it once; fresh installs find it in the Marketplace.

Proven by the extended contributions-envelope test (26 plugin/platform
tests green; full backend suite 1215 passed), lint 0 errors, build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:39:37 -04:00
Juan DenisandClaude Opus 4.8 8dba586b0e feat(extensions): Phase 5 — extract Cloudflare zone-ops into serverkit-cloudflare-ops
Moves the Cloudflare per-zone control panel (zone settings, cache purge, WAF,
Workers, Tunnels, R2/KV/D1 storage) out of core into the bundled, default-installed
`serverkit-cloudflare-ops` extension (#36). Much cleaner than WordPress:

- Backend: only 2 files move — `api/cloudflare.py` + `cloudflare_service.py` →
  `builtin-extensions/serverkit-cloudflare-ops/backend/` (blueprint keeps
  /api/v1/cloudflare, D9; the one intra-extension import made relative). ZERO core
  callers (the service's only importer was its own blueprint), so no bridge
  accessors are needed for core — `cloudflare_ops_bridge.py` exists only so the
  moved test can reach the service by a dash-safe importlib path.
- No duplicate Cloudflare client (the hard constraint): the moved CloudflareService
  still borrows the single core CloudflareClient via DNSZoneService._resolve_credential.
  The whole DNS layer (dns/cloudflare.py, dns_zone_service, dns_provider_service,
  DNSProviderConfig) and the CloudflareWorker/CloudflareTunnel models (FK into
  dns_provider_configs) STAY core.
- Flagship (default-installed, uninstallable): added to FLAGSHIP_SLUGS — zero nav
  footprint, and the core Domains "Open in Cloudflare" button depends on the route.
- Frontend: a single plain route `cloudflare/zones/:zoneId` contributed via the
  manifest + a re-export shim; the page/panels/api-client/SCSS stay in core
  (Pattern A). Core App.jsx import + route removed.
- Retired the core blueprint registration; the plugin status guard is the gate.

Verified: 80 tests green (new tests/test_cloudflare_extraction.py + the moved
test_cloudflare_zone_settings.py + the 4 DNS-layer tests that stay core and still
exercise the shared client); npm run lint + build clean; the backend serves the
`cloudflare/zones/:zoneId` route contribution from the seeded flagship row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 01:04:03 -04:00
Juan DenisandClaude Opus 4.8 a55e50bc16 feat(extensions): Phase 5 — extract WordPress into serverkit-wordpress (backend)
The largest single move in the plan (#38). The entire WordPress backend leaves
core for the bundled, default-installed serverkit-wordpress extension (D4).

Moved (12 files → builtin-extensions/serverkit-wordpress/backend/, intra-extension
imports made relative, core imports stay absolute):
  api/wordpress.py, api/wordpress_sites.py, api/environment_pipeline.py,
  wordpress_service.py (2676 lines), git_wordpress_service, wordpress_env_service,
  wordpress_plugin_library_service, wp_analytics/reports/security/update/vulnerability.

Wiring:
- Blueprints keep /api/v1/wordpress, /wordpress/projects and the /wordpress/pipelines
  alias (D9) via new manifest `extra_blueprints` support (3 blueprints / 4 mounts).
- Flagship (D4): seeded active on every panel (fresh + upgrade), uninstallable,
  never deletes tracked builtin/pre-bundled files; the `wordpress` module toggle is
  retired (MODULES now empty) — the plugin status guard is the gate.
- Loadability: the dashed package app.plugins.serverkit-wordpress is registered
  in-place via spec_from_file_location (no copy) — uniform across prod/dev/test.
- The 11 WP test files re-pointed through app.services.wordpress_bridge; the 6 lazy
  core callers were re-pointed in the prior commit.
- _regenerate_frontend_manifest() is a no-op under testing so the flagship boot
  seed can't clobber the checked-in plugins-manifest.json.

Deviations (documented, matching the Email precedent):
- WordPress *models* stay core — status_page/environment_activity/promotion_job hold
  FKs into wordpress_sites, so relocation would be a breaking data migration.
- Event catalog + Fail2ban WP filter stay core as two-speed items (extension calls
  them via core APIs); inverting adds risk without blocking the move.
- Frontend UI stays core this session (it lives in a TabGroupLayout — the tab-grouped
  conversion Phase 1 deliberately deferred); the extension is backend-only, so there
  is no route duplication. Frontend contribution is the scoped follow-up.

Verified: clean collection across all 1223 tests; 199 passed / 2 skipped / 0 failed
across the WP + plugin/extension + module + backup + core-caller parity set, incl.
the new tests/test_wordpress_extraction.py proving the move.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:15:10 -04:00
Juan DenisandClaude Opus 4.8 eee21d8a14 refactor(plugins): add WordPress bridge + flagship/multi-blueprint plumbing
Pre-reqs for extracting WordPress into a bundled default-installed extension
(#38). No behavior change yet — WordPress still ships as core; this only inverts
the couplings so the physical move is mechanical:

- app/services/wordpress_bridge.py: importlib bridge to the (future) dashed
  package app.plugins.serverkit-wordpress, with a two-speed fallback to the
  legacy core location so callers work before AND after the move.
- plugin_service: _ensure_builtin_backend_importable() registers a dashed builtin
  backend in-place via spec_from_file_location (no copy) — solves dev/test/prod
  loadability of a default-installed dashed package; extra_blueprints manifest
  support (WP needs 3 blueprints / 4 mounts incl. the /pipelines alias);
  FLAGSHIP_SLUGS + seed_flagship_extensions() + flagship-aware install/uninstall
  (D4: default-installed, uninstallable, never deletes tracked files).
- create_app: seed flagships before load_all_plugins (inert until the folder exists).
- Re-point the 6 lazy WP-service callers (preview/webhook/env-pipeline/apps/
  builtin_handlers/backup_policy) through the bridge; drop the eager
  WordPressService import from app/services/__init__.py. Models stay core.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:45:22 -04:00
Juan DenisandClaude Opus 4.8 e3249fbd52 fix(system): report IANA timezone_id; guard chart formatting against bad zones
SystemService.get_server_time fell through to Python's time.tzname on hosts
without /etc/timezone or timedatectl (e.g. Windows dev), yielding a display
name like "Eastern Daylight Time" — not a valid IANA/Intl key. The dashboard
MetricsGraph then crashed with RangeError: Invalid time zone specified.

- Backend: add a cross-platform tzlocal fallback + an IANA-validity guard so
  timezone_id is always an IANA key or null (display name stays in
  timezone_name). Declare tzlocal in requirements.
- Frontend: MetricsGraph validates the zone and falls back to local time
  instead of throwing (defense in depth).
- Test: test_server_time_timezone.py.

ServerKit's timezone feature (get/list/set via /api/v1/system/timezone) is
unchanged — this only fixes the value reported on non-Linux hosts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:55:11 -04:00
Juan DenisandClaude Opus 4.8 b59a57430e feat(extensions): Phase 4 — extract Email into serverkit-email extension
Moves the mail-server stack out of core into builtin-extensions/serverkit-email/:
- api/email.py + email_service/dovecot/dkim/spamassassin/roundcube → extension
  backend (relative intra-extension imports; core imports absolute); blueprint
  keeps /api/v1/email (D9). Core __init__ no longer registers email.
- Stays core (deviations from the audit, documented): postfix_service (the
  outbound relay actively uses it) + all email models (no external importers,
  so no DNSProviderConfig relocation / data migration needed).
- Frontend: serverkit-email manifest contributes nav/route/title/palette; core
  App.jsx/sidebar/palette entries removed; Email page re-exported (D2 pattern).
- #34 auto-install gated on real mail usage (EmailDomain/EmailAccount rows);
  the core Email module toggle is retired in favor of the extension.
- Tests: test_email_extraction.py proves core has no /api/v1/email, install
  registers+responds (exercises relative-import rewiring), uninstall clean.
  Full backend suite: 1200 passed. Frontend lint 0 errors + build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:39:18 -04:00
Juan DenisandClaude Opus 4.8 cdcfba5980 feat(extensions): Phase 3 — platform primitives (models, jobs, sockets, perms)
Backend:
- Plugin data models (manifest `models` → ext_<slug>_ tables; upgrade hook;
  purge on uninstall) via extension_lifecycle.py
- Declarative jobs/schedules wired into the Jobs SDK; pause on disable,
  resume on enable, removed on uninstall
- Socket.IO extension point (plugins_sdk.sockets → status-guarded /ext/<slug>)
- Permission capability gate (plugins_sdk.permissions.require_permission)
- Version compat gates enforced at every install source + panel_version() SDK;
  app/utils/version.py
- Keep-data vs purge on uninstall (lifecycle.uninstall(plugin, purge=))
- Plugin-asset serving route (delivery foundation, ADR 0001); manifest-spec
  documents all new fields

Frontend:
- Generic PluginSlot; mounted dashboard.top / service.detail.tab /
  domain.drawer.panel
- Uninstall keep/purge dialog; permission consent list + config_schema preview
  on the extension detail modal

Docs: ADR 0001 (frontend delivery), ADR 0002 (sandboxing note), EXTENSIONS.md
updates; scripts/new-extension.mjs scaffolder (#40).
Tests: test_extension_platform.py (11). Full backend suite: 1197 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:11:09 -04:00
Juan DenisandClaude Opus 4.8 6f5f2b1047 feat(extensions): Phase 2 backend — remote registry, checksum install, updates
- RegistryService: configurable SERVERKIT_REGISTRY_URL, TTL cache, offline
  fallback (last-good → bundled app/data/registry_index.json); read-only, never
  auto-installs. GET /api/v1/marketplace/registry + registry install endpoint.
- Checksum-verified install: install_from_url(expected_sha256) hard-fails on
  mismatch; force= path enables in-place updates.
- Update flow v1: check_for_updates() + GET /plugins/updates + POST
  /plugins/<id>/update; respects min/max_panel_version via new
  app/utils/version.py (get_panel_version + semver compare).
- serverkit-gui listed as the first registry entry (bundled index).
- Docs: EXTENSIONS_REGISTRY.md (index format + publisher/review guide).
- Tests: test_registry.py (fetch/cache, checksum, compat gate, update flow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:41:50 -04:00
Juan DenisandClaude Opus 4.8 3e373511b7 feat(extensions): Phase 1 — seed marketplace (GPU + Workflow), module toggles
- One-shot auto-install migration (extension_migration.py): converted builtins
  auto-install on upgrade, marketplace-only on fresh install, user uninstall
  respected
- Convert GPU Monitor → serverkit-gpu and Workflow Builder → serverkit-workflows
  (manifest-driven nav/route/title/palette; pages + backend stay core, D2)
- Module toggles (#14): module_service + /api/v1/modules + 503 guards on
  email/wordpress; frontend useModules hook, sidebar gating, ModuleRoute guard,
  Settings → Modules card
- Marketplace curation polish (#15): by-ServerKit badge, real category chips,
  detail modal with icon + screenshots
- Tests: test_extension_migration.py, test_module_toggles.py (backend suite green)

Tab-grouped conversions (#8/#9/#10/#12/#13) deferred to after the Phase 3
tab-slot primitive (#27) to avoid a grouped-UX regression — see plan doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:34:54 -04:00
Juan DenisandClaude Opus 4.8 74c5e0a885 feat(extensions): Phase 0 hygiene — author guide, sync gate, truth pass, tests
- docs/EXTENSIONS.md: honest author guide (manifest, contributions, SDK,
  lifecycle, install sources, production frontend constraint, conversion recipe)
- Retire the serverkit-git compat shim in contributions.js (manifest is canonical)
- scripts/sync-builtin-frontends.mjs + extensions-ci.yml: builtin-frontend
  drift gate (single source of truth per D5)
- Marketplace UI truth pass: relabel bundled entries "Built-in"
- backend/tests/test_plugins_pipeline.py: first coverage for the install
  pipeline (builtin install, contributions envelope, disable 503, reinstall
  refresh, zip-slip)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:13:04 -04:00
Juan DenisandClaude Fable 5 7b45504568 docs: adopt numbered filenames for docs/plans and update references
Plan docs in docs/plans/ (git-ignored) now carry a NN_ prefix in
creation order so new plans sort last. Updated the tracked references
(test docstrings, AGENTS.md, serverkit_installer, _utilities.scss) and
dropped two stale per-file .gitignore lines already covered by the
/docs/plans rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 20:49:41 -04:00
Juan DenisandClaude Opus 4.8 58e80b2e18 feat(databases): track managed databases as first-class resources
Persist the databases ServerKit provisions (and let you adopt existing ones) so
backups, connection strings, and the UI have durable state -- beside, not
replacing, the live-introspection explorer. Not a DBaaS (no pooling/replicas).

- ManagedDatabase model (engine/name/host/port/origin/owner; admin secret
  Fernet-encrypted, unique per engine+host+name) + migration 044.
- managed_database_service: record_provisioned (additive on the create routes),
  adopt (idempotent), build_connection_uri (real URI, masked by default),
  sync_state (drift detection), delete (optional drop), protect.
- Backup FK fix (the headline): a managed-DB BackupPolicy uses
  target_id = managed_databases.id plus a `managed` marker, and _resolve_target
  resolves the descriptor (incl. decrypted secret) from the row. Legacy
  JSON-descriptor policies keep resolving unchanged (backward compatible).
- API under /api/v1/databases/managed (list/create/get+sync/delete?drop=/adopt/
  connection-uri/protect); the connection-uri reveal is admin-only and audited.
- Frontend: a "Managed" panel on the Database Explorer (connection-string
  reveal/copy, one-click Protect, Untrack/Drop).
- Tests: provisioning persists a row, adopt idempotent, connection URI per
  engine + masking, sync_state drift, protect resolves via the real FK, legacy
  policy still resolves, API masking/admin-gating/audited reveal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:44:48 -04:00
Juan DenisandClaude Opus 4.8 cbbd410bc8 feat(services): first-class managed volumes for apps
Give a service tracked, named persistent storage that survives redeploys,
replacing fragile relative bind mounts (./mysql-data:/var/lib/mysql).

- AppVolume model (namespaced serverkit-app-{id}-{slug}, unique per mount path)
  + migration 043.
- volume_service: create (docker volume + row, dup-path refusal), list_for_app
  (rows + live inspect), delete (row-only by default; wipe only when the app is
  stopped and no running container uses it), measure, convert_bind_mount,
  run_args, compose_fragment, host_paths_for_app.
- Wired into the single-container deploy (_deploy_docker passes -v specs to
  docker run) and new compose stacks (create_docker_app gains named_volumes ->
  top-level volumes: block).
- API under /api/v1/apps/<id>/volumes (list/attach/detach?wipe=/convert), ACL
  via ResourceGrantService.
- Frontend: Storage panel in Service detail Settings (attach/detach with an
  off-by-default "also delete data" checkbox, disabled while running).
- Tests: create + namespaced volume, dup-path refusal, wipe safety, single-
  container -v proving test, compose top-level named-volume generation, convert,
  API CRUD.

Scoping: volumes are backup-addressable (mountpoints exposed; protect via the
files target) rather than force-folded into the app's single-parent incremental
tar chain, which would break restore. Live compose files (esp. the WP multidev
stack) are not rewritten -- convert_bind_mount is the opt-in migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:57:07 -04:00
Juan DenisandClaude Opus 4.8 298c556ec7 feat(connections): authenticate private container registry pulls
Store per-registry credentials (Fernet-encrypted) and run `docker login`
before pulling a private image — GHCR, Docker Hub, GitLab, ECR, or any
generic registry. Public/anonymous pulls are unchanged.

- ContainerRegistry model (secret encrypted at rest; to_dict never returns it)
  + nullable applications.registry_id; migration 042.
- container_registry_service: CRUD + login/logout/test_connection (secret piped
  via stdin, never on argv) + lazy ECR token exchange.
- DockerService.pull_image gains a registry= arg (login -> pull -> logout in a
  try/finally). Wired into the deploy path (_deploy_docker) and the compose
  path (start_app / apply_image_update): a docker login authenticates both
  `docker pull` and `docker compose pull`.
- API under /api/v1/connections/registries (list=auth, writes=admin); registries
  join the unified /connections list.
- Frontend: Connections hub "Container registry" card (add/test/remove with a
  provider selector) + a per-app registry picker in Container Ops.
- Tests: encrypt/decrypt, stdin-not-argv, login->pull->logout ordering incl.
  finally-on-failure, deploy + compose auth-before-pull, API CRUD/masking/admin.

Deviation from the plan's "single injection point": docker apps run via compose,
so login was wired into both the compose and run_container paths; remote-agent
compose is a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:35:44 -04:00
Juan DenisandClaude Opus 4.8 6b35bb996d feat(wordpress): global plugin library for operator-owned plugins
Register WordPress plugins from GitHub repos or local paths in a global
library, then install or update them across any managed WP site from one
place. The per-site Plugins tab gains a "managed" badge and an
"update from library" action.

- models: WordPressCustomPlugin (library) + WordPressSitePlugin (installs)
- service: WordPressPluginLibraryService - sync, install, update, scan,
  with a per-slug cache dir under paths.SERVERKIT_CACHE_DIR
- api: /plugins/library CRUD + sync/install/bulk-update on wordpress_bp
- migration 041_wordpress_plugin_library
- frontend: WordPressPluginLibrary page + "Plugin Library" tab; managed
  badges and library-update action in PluginsTab
- tests: test_wp_plugin_library

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:08:42 -04:00
Juan DenisandClaude Opus 4.8 432e65179b feat(services): pick a base domain when giving an app a subdomain
Bring the multi-base picker to the generic app/service publish flow, matching
the WordPress create flow. The service Settings → "Give it a subdomain" modal
now loads the registered base domains and, when more than one exists, shows a
"Base domain" dropdown (default marked, HTTPS flagged); the live suffix and the
wildcard/per-site hint follow the selection, and the choice is passed through to
give-subdomain so the app publishes at <label>.<chosen-base>. A single-base
install is unchanged (no dropdown).

api.suggestSubdomain / giveSubdomain take an optional base; new
api.getSiteBaseDomains() reads GET /domains/base-domains.

Proven: suggest-subdomain honours ?base (test_site_base_domains.py); frontend
lints clean (0 errors) and builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:31:22 -04:00
Juan DenisandClaude Opus 4.8 2f11f450d9 feat(settings): manage the base-domain registry from Settings → Site
Turn the single-base "Managed Sites Domain & HTTPS" card into a registry
manager. Operators can now register several base domains, mark a default,
remove one, flip each between wildcard/per-site DNS, and set up per-base
wildcard HTTPS with a chosen DNS provider — all from the UI.

- Each registered base renders as a row with Default / HTTPS badges, a DNS-mode
  select, a "Set up / Renew HTTPS" action (using the selected provider, base
  passed through), plus Make default / Remove.
- Back-compat: an install with no registry rows shows its single legacy base as
  an editable in-place row (saves sites_base_domain); adding a second domain
  seeds that legacy value into a real row server-side, and the table takes over.
  A fresh install with no base shows an empty state + the Add form.
- Server public IP and DNS provider are shared across bases.
- New admin PATCH /admin/sites-https/base-domains/<domain> for DNS-mode edits;
  api service gains add/remove/setDefault/update helpers and setupSitesHttps now
  takes a base.

Proven: test_site_base_domains.py adds a PATCH-dns-mode API test (13 pass);
frontend lints clean (0 errors) and builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:27:30 -04:00
Juan DenisandClaude Opus 4.8 e718a95f43 feat(sites): per-base wildcard HTTPS + base-domain registry API
Wire the base-domain registry into HTTP + the cert flow:

- SitesHttpsService.setup(provider_id, email, base) now issues the wildcard cert
  for a specific base domain and persists HTTPS state to THAT base's registry row
  (recording the provider used), so example.com on Cloudflare and toto.com on
  Route53 each get their own cert; falls back to the legacy sites_* settings for
  a single-base install. status() now also returns the full `bases` registry.
- Admin API: POST/DELETE /admin/sites-https/base-domains[/<domain>] and
  .../<domain>/default to register, remove, and set the default base; setup
  accepts an optional `base`.
- GET /api/v1/domains/base-domains lists the bases a new site can use (registry,
  or the single legacy base) with the pre-selected default — the create-flow
  picker's data source. give-subdomain + suggest-subdomain accept a `base`.
- WordPress create accepts baseDomain end to end (API -> service -> subdomain_for),
  and picks the covering base's HTTPS state for the baked-in site URL.

Proven in test_site_base_domains.py: the list endpoint (registry + legacy),
admin add/set-default/remove, and per-base HTTPS setup persisting to one row
while the other base stays HTTP. WordPress suite (66) still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:11:18 -04:00
Juan DenisandClaude Opus 4.8 f76da49ab4 feat(sites): base-domain registry so sites can publish under multiple domains
Managed sites had exactly one base domain (the sites_base_domain setting), so
every site was <slug>.<that-one>. Add a registry (site_base_domains) letting an
operator register several — example.com, toto.com — each with its own DNS mode,
HTTPS state, and DNS provider connection, so a new site can be published under
any of them (a.example.com or a.toto.com), defaulting to the one marked default.

- New SiteBaseDomain model + migration 040 (own dns_provider_config_id + cert
  state per base; exactly one is_default).
- SiteBaseDomainService: add/remove/set_default/update, and ensure_seeded which
  materialises the legacy single sites_base_domain setting into the default row
  the first time a second domain is registered — so old and new never disagree.
- SiteDomainService is now base-aware and back-compatible: subdomain_for(name,
  base), https_enabled(base), dns_mode(base), wildcard_cert_paths(base) and
  give_subdomain(app, base) take an optional base; covering_base(host) picks the
  base (longest match) whose wildcard cert covers a host so vhosts get the right
  per-base cert; base_domain() returns the default and still falls back to the
  legacy setting / SITES_BASE_DOMAIN config for un-migrated & dev installs.

Existing single-base installs are untouched (zero rows -> setting fallback);
45 existing site-domain tests still green. New coverage in
test_site_base_domains.py: seeding, default promotion/demotion, per-base
https/dns-mode/cert, longest-match cover, and give_subdomain under a chosen base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:07:00 -04:00
Juan DenisandClaude Opus 4.8 cfe8317ea3 feat(sites): warn when the site base domain overlaps the panel domain
A managed-sites base domain that contains the panel's own hostname is a
wildcard footgun: *.<base> also matches the panel host, so it keeps resolving
only while the panel's explicit DNS record out-specifies the wildcard (or, when
base == panel domain, the wildcard setup's apex record repoints the panel
itself). Add a fourth publishing gap that detects this and nudges admins.

base_domain_overlaps_panel() flags panel host == base and panel host as a
direct single-label child of base; a deeper descendant (a.b.<base>) is safe
because a wildcard is single-label. It reads the panel's genuinely-configured
domain (canonical_domain / PUBLIC_URL), not panel_origin's base-domain
fallback — that fallback would equate panel with base and both mask real
overlaps and break the subdomain-vs-apex suggestion.

Proven in test_sites_publishing_gaps.py: equal-domain and direct-child overlap,
deep-descendant and scoped-label (apps.<domain>) non-overlap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:48:28 -04:00
Juan DenisandClaude Opus 4.8 9d49457dcd feat(sites): nudge admins when site-publishing config is half set up
Creating a site while the managed-sites config is only partly done (base
domain set but not the DNS/HTTPS half, or nothing set at all) left the site
quietly on localhost with no signal to finish setup. Now each create checks
readiness and drops an in-app nudge to admins for any open gap:

- no base domain -> site is localhost-only; suggests one to set
- base domain, HTTPS off -> HTTP-only (gentle/optional, HTTPS stays best-effort)
- per-site DNS mode, no server IP -> A records can't auto-create

The suggested base domain is derived from the panel's OWN domain, so it fits
the install shape: an apex install (example.com) suggests apps.example.com,
while a subdomain install (panel.example.com) suggests a sibling
apps.example.com that won't collide with the panel's record. Nudges are
deduped against an already-open unread notice, so a gap is raised once, not on
every create. Wired into WordPress create and the generic give_subdomain
publish path; best-effort so a notification failure never affects the create.

Proven in test_sites_publishing_gaps.py (each gap, the apex-vs-subdomain
suggestion, and the dedup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:44:08 -04:00
Juan DenisandClaude Opus 4.8 3d31f4e8ea fix(sites): route every managed app type on give-subdomain, not just docker
give_subdomain only wrote an nginx vhost when app_type=='docker', so managed
PHP/static/python/flask/django apps got a Domain row but no route and stayed
reachable only at localhost:<port>. NginxService.create_site already had the
right templates for all of them.

Add a shared SiteDomainService.write_app_vhost(app, force_type=None) +
_vhost_create_kwargs mapper (docker/wordpress -> docker proxy,
flask/django/python -> proxy+static, php/static -> filesystem root; same
wildcard-cert selection as before) and route give_subdomain through it.
Collapse WordPressService._write_app_vhost into a thin delegate
(force_type='docker'), removing the duplicate with no behavior change.

Proven in test_dns_give_subdomain.py: php/static route from root, python
routes by port, and an unroutable app surfaces a warning instead of
silently faking success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:31:57 -04:00
Juan DenisandClaude Opus 4.8 31330a09be fix(deploy): make docker-compose.yml frontend-only (backend runs on host)
ServerKit's backend manages the host it runs on -- it creates app dirs under
/var/serverkit/apps, drives the host Docker daemon (one container per app),
and reloads nginx. The default compose containerized the backend, so it ran
as a non-root user into a root-owned /var/serverkit/apps with no host
docker.sock: WordPress/app creation failed with
`Permission denied: /var/serverkit/apps/...`. It also contradicted install.sh,
which runs the backend on the host via systemd and expects compose to bring
up only the frontend.

- docker-compose.yml: frontend-only; the frontend reaches the host backend via
  extra_hosts ["backend:host-gateway"].
- backend/Dockerfile: chown /var/serverkit to appuser (latent EACCES on the
  apps dir for dev/demo container use) + header noting it cannot manage host.
- frontend/nginx.conf: document the backend:host-gateway upstream.
- docs/DEPLOYMENT.md: add a Deployment model note; fix build/start + data paths.
- scripts/test/test_install.sh: T12 guards against re-adding a backend service.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:20:32 -04:00
Juan DenisandClaude Opus 4.8 db16100a42 feat(installer): Phase 5 — additive Python bootstrap foundation
Hardening plan §8. A stdlib-only, import-safe, unit-tested Python package that
ports the stabilized bash install logic into testable orchestration. Per the
plan's migration path this is ADDITIVE — install.sh stays canonical and this is
NOT wired into it yet; it is the validated foundation a future thin-bootstrap
install.sh can call.

backend/serverkit_installer/:
  - _proc.py    — sudo-aware subprocess helpers, mirroring app/utils/system.py
                  WITHOUT importing app (the bootstrap runs before the venv
                  exists, so it must not pull in Flask).
  - distro.py   — OS family mapping (a faithful port of install.sh's
                  os_family_from, rhel-before-fedora) + container/WSL/systemd
                  predicates (mirroring scripts/lib/env.sh).
  - deps.py     — loads scripts/deps/manifest.yaml (its primary consumer) and
                  resolves base/python/node/docker/pkg-manager per family.
  - firewall.py — detect + pure open/close command builders (port of
                  scripts/lib/firewall.sh).
  - service.py  — init detect + pure start/stop/enable/reload builders (port of
                  scripts/lib/init.sh).
  - main.py     — composes a READ-ONLY install "plan" + `python -m
                  serverkit_installer plan|detect` CLI. No host mutation.

Tests: backend/tests/test_serverkit_installer.py — 46 pure-logic cases
(family mapping vs os_family_from, real-manifest resolution for all six
families, firewall/service command builders, env predicates, composed plan).
46 passed; py_compile + pyflakes clean. The CLI proves the manifest is genuinely
consumed (Debian -> apt + python3.11 + base packages), not dead config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 01:06:44 -04:00
Juan DenisandClaude Opus 4.8 b00fff7759 refactor(unify): §4 — one Observability namespace + tab group
Backend:
- The monitoring / metrics / telemetry(events) / uptime / fleet / status-page
  read surfaces are re-mounted under /api/v1/observability/<domain> as true
  aliases (same blueprints, distinct names). Original prefixes stay; the public
  status page route /api/v1/status/public/<slug> is untouched.

Frontend:
- The Monitoring tab group becomes "Observability": Monitoring · Events
  (the SystemEvent stream, formerly the standalone "Telemetry" page) · Status
  Pages, sharing one PageTopbar. /observability lands on Monitoring.
- The standalone Telemetry route + sidebar item are folded into the group
  (sidebar "Monitoring" → "Observability"; Events covered by matchPrefixes;
  removed from ADVANCED_ITEM_IDS). Command palette updated.
- Fleet Monitor stays under Servers (server-scoped) and GPU stays its own
  hardware page — intentional, per the plan.

Deferred (plan §4 deep merge, documented): the unified events/metrics/checks/
alerts/thresholds tables + collector migration into the job system. The
read-surface namespace + IA consolidation ship now; the schema merge is a large
data migration best done against a live metrics store.

Verified: 3 backend namespace tests + telemetry suite pass (22); frontend build
+ eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:58:55 -04:00
Juan DenisandClaude Opus 4.8 afd042896b refactor(unify): §3 — canonical DeploymentJob links + unified /deployments surface
Additive foundation + one read namespace for the three parallel deploy records
(versioned build Deployment, webhook GitDeployment, DeploymentJob executions).

Backend:
- DeploymentJob gains deployment_id / git_deployment_id / webhook_id FKs +
  commit_hash / image_tag / container_id, so it can be the canonical execution
  record pointing at the release ledgers it produces (surfaced in to_dict).
  Migration 039 (idempotent, additive).
- New /api/v1/deployments surface:
    GET /apps/:id/history?source=build|webhook|all  → federated, source-tagged
    GET /:id                                         → build Deployment detail
  and the DeploymentJob surface re-mounted at /api/v1/deployments/jobs (alias of
  /deployment-jobs). Per-system blueprints (/builds, /deploy, /git) unchanged.

Deliberately NOT done (plan §3 Phase 1/2 deep merge, with rationale): folding
GitDeployment into Deployment+DeploymentJob and async-ifying the currently
synchronous build/webhook deploys. Recon confirmed those paths are synchronous
with distinct failure/timeout semantics and GitDeployment carries rollback state
(snapshot_data, rolled_back_to_id); flipping them to the queue can't be verified
without a live build/runner, so it's deferred behind the additive links above.

Verified: 6 new tests + previews/config-snapshot regression pass (35).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:52:04 -04:00
Juan DenisandClaude Opus 4.8 92c435d436 refactor(unify): §7 — WordPress reachable through the generic /apps surface
Additive API consolidation (plan §7 Phases 3–4) that exposes a WordPress site
through its Application id, treating WordPressSite as the 1:1 extension table it
already is:
- GET  /apps/:id/wordpress            → {is_wordpress, site_id, env_type, ...}
- GET  /apps/:id/db-snapshots         → WP DB snapshots (was §8-deferred)
- POST /apps/:id/db-snapshots         → create
- DELETE /apps/:id/db-snapshots/:id   → delete
All grant-aware via the apps.py access seam (ResourceGrantService).

Deliberately NOT done (plan §7 Phase 1/2, with rationale): blanket-setting
app_type='wordpress' on WordPressSite-linked apps. Recon showed many WP sites are
app_type='docker', and 14+ docker-compose/nginx/scale/sleep code paths key on
app_type=='docker' — changing it would silently break start/stop/restart/logs/
status/domain config for containerized WP. The codebase already treats the
WordPressSite record (not app_type) as the source of truth (wordpress.py
_is_wp_app), which is the correct architecture; flipping app_type would regress
it. Frontend WordPress→Services merge likewise deferred (the /wordpress/:id route
is site-id-keyed).

Verified: 6 new surface tests + WP/resource-grant regression suites pass (23).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:46:14 -04:00
Juan DenisandClaude Opus 4.8 15fce59543 refactor(unify): §5 — single SSL surface (basic + advanced)
Unifies "SSL Certificates" and "Advanced SSL" into one lifecycle surface.

Backend:
- The advanced cert operations (wildcard DNS-01, SAN, custom upload, TLS
  profiles, health, expiry alerts) now mount under /api/v1/ssl alongside the
  basic certbot routes — no path collisions. /api/v1/ssl/advanced kept as a
  deprecated alias (same blueprint, second registration).

Frontend:
- api client advanced methods repointed to the unified /ssl/* paths.
- SSLCertificates.jsx (already the single SSL page) gains the two cert-creation
  paths that were previously UI-inaccessible: a "Wildcard (DNS-01)" mode in the
  New Certificate modal (Cloudflare token / Route 53 keys) and an "Upload"
  action + modal for bring-your-own PEM cert/key/chain.

Deferred (documented follow-up): a unified Certificate registry model + disk
scan, and surfacing health/profiles panels — the API is unified and the client
methods exist; these are read-side enhancements.

Verified: 3 backend route-unification tests + existing SSL suites pass (19);
frontend build + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:40:08 -04:00
Juan DenisandClaude Opus 4.8 2cbbb61dbf refactor(unify): §6 Phase C6b — Postfix relay backed by EmailProviderConnection
Completes the email unification: the outbound relay is now one smtp
EmailProviderConnection flagged uses_relay, so a single table drives both the
Notification Bus and the Postfix relay. The legacy single-row email_relay_config
is folded in on first boot and kept only as a read fallback.

- EmailRelayService rewritten over the unified table: get/save/disable/test all
  operate on the uses_relay smtp connection; password stays Fernet-encrypted in
  credentials_json; masked round-trips preserve the stored secret. Postfix apply
  unchanged (no-op when Postfix absent).
- migrate_legacy_config(): idempotent fold of email_relay_config → connection
  (password copied verbatim — same Fernet key); wired into the boot-time legacy
  migration block.
- API: PUT /notifications/admin/email-providers/:id toggles usage flags
  (uses_notifications/uses_relay/relay_priority); uses_relay honored for SMTP only.

Verified: 6 new relay tests + usage-flag + notification-bus suites pass (30 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:36:08 -04:00
Juan DenisandClaude Opus 4.8 45e01f8731 refactor(unify): §6 Phase C6a — email provider usage flags
Foundation for folding the single-row email_relay_config into the multi-row
EmailProviderConnection so one table drives notifications and the Postfix relay.

- EmailProviderConnection gains uses_notifications (default true), uses_relay
  (default false), relay_priority (default 0); surfaced in to_dict.
- Notification selection (default_provider) now restricted to uses_notifications
  rows, so relay-only connections are never used to send notifications.
- New relay_provider() selects the active SMTP connection by relay_priority.
- add_provider() accepts the flags; uses_relay honored only for provider=smtp
  (API providers can't be a Postfix smarthost).
- Migration 038 (idempotent additive columns).

Additive — existing providers default to uses_notifications=true / no relay.
Verified: 4 new tests + notification-bus suite pass (24 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:32:41 -04:00
Juan DenisandClaude Opus 4.8 45e83e9a09 refactor(unify): §8 Phase B5 — generic backup-policy CRUD for every target
Completes the §8 API consolidation: one admin surface drives a BackupPolicy for
ANY target — application, wordpress_site, database, files, server — by
(target_type, target_id):

  GET/PUT  /api/v1/backups/policies/<type>/<id>          (PUT accepts
           target_subtype + target_meta to describe database/files targets)
  POST     /api/v1/backups/policies/<type>/<id>/run
  GET      /api/v1/backups/policies/<type>/<id>/runs
  POST     .../runs/<run_id>/restore  |  /verify
  DELETE   .../runs/<run_id>

This is the only way to drive the database/files/server targets (which have no
per-resource page). The grant-aware per-resource mounts
(/apps/:id/backup-policy, /wordpress/sites/:id/backup-policy) are unchanged.

Deferred to §7 (WordPress-as-app): surfacing WP DatabaseSnapshot under
/apps/:id/db-snapshots, and the frontend Backups.jsx rewrite, both of which
depend on the app↔site unification.

Verified: 5 new tests; full backup/snapshot suite green (63 passed, 2 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:27:08 -04:00
Juan DenisandClaude Opus 4.8 d87e69b44f refactor(unify): §8 Phase B4 — surface DeploymentSnapshot as "Config Checkpoint"
Recorded §8 decision: a deployment config snapshot is shown as a "Config
Checkpoint" in the UI to end the overloaded "snapshot" wording. Distinct from
data-protection backups and from WordPress DB snapshots, which are untouched.

Backend:
- snapshots blueprint serves every route under /apps/:id/config-checkpoints
  in addition to /apps/:id/snapshots (dual @route — both resolve to one view).

Frontend:
- DeployTab section "Deployment Timeline" → "Config Checkpoints" with reworded
  hint; ConfigDiffModal + DeploymentTimeline empty/peer copy use "checkpoint".
- api/snapshots.js gains getConfigCheckpoint(s)/Diff/restore aliases.
- ProtectionStatusCard: "Snapshots run on the configured schedule" → "Backups"
  (that card describes backups, not config checkpoints).

Verified: 2 alias tests + existing config-snapshot suite pass; frontend build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:24:13 -04:00
Juan DenisandClaude Opus 4.8 b90f6669d2 refactor(unify): §8 Phase B3 — global backup policy/run listing API
Adds the cross-target queries the unified Backups page needs:
- GET /api/v1/backups/policies  (filters: target_type, enabled) — replaces the
  legacy filesystem schedule list with one view of every BackupPolicy.
- GET /api/v1/backups/runs (filters: target_type, status, policy_id, limit≤500)
  — every BackupRun across all policies, each augmented with its target's
  type/id/subtype so the UI filters without a second lookup.

Both admin-guarded like the rest of /backups.
Verified: 6 new API tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:20:43 -04:00
Juan DenisandClaude Opus 4.8 f357742d80 refactor(unify): §8 Phase B2 — database + files executors in policy service
Folds the legacy BackupService database/files capabilities into the unified
BackupPolicyService so one policy/run system performs every backup kind.

- _resolve_target() now handles database (connection descriptor from
  target_meta + target_subtype engine), files (path list), and rejects
  server (not yet implemented) with a clear message.
- _execute_backup() routes database → BackupService.backup_database and
  files → BackupService.backup_files, building consistent run metadata
  (kind=full, includes, primary_archive).
- Restore: _restore_database (symmetric with restore_database) and
  _restore_files (safe extract into a restores/ staging dir, never blind
  overwrite of a basename-only path-list archive); run_restore_job dispatches
  by target_type.

Note: remote-copy upload still applies to dir-based runs (app/WP); single-file
database/files runs are local-only for now (documented follow-up).

Verified: 8 new executor tests (BackupService mocked) + B1 + existing
backup-protection suite pass (26 passed, 2 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:19:32 -04:00
Juan DenisandClaude Opus 4.8 5d606a6030 refactor(unify): §8 Phase B1 — generalize BackupPolicy to all target types
First slice of the backups/snapshots unification. Makes the policy-driven
BackupPolicy/BackupRun system able to describe every data-protection target,
not just application/wordpress_site — the foundation for folding the legacy
BackupService (database/files) into one system.

- BackupPolicy gains target_subtype + target_meta_json (+ get/set helpers,
  surfaced in to_dict). VALID_TARGET_TYPES = application, wordpress_site,
  database, files, server.
- BackupPolicyService.validate_target_type(); get_or_create_policy() accepts
  and (idempotently) refreshes target_subtype/target_meta for non-resource
  targets like database/files.
- Migration 037 (idempotent, additive columns).

Additive only — no behavior change for existing app/WP policies.
Verified: 6 new tests + existing backup-protection suite pass (18 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:16:45 -04:00
Juan DenisandClaude Opus 4.8 8c57636455 refactor(unify): §2 — rename WordPress "Projects" → "Pipelines"
Feature-unification plan §2. Ends the naming collision between generic
/projects (workspace organization) and WordPress "Projects" (environment
pipelines). Label + URL change only; deeper WP/app unification is §7.

Frontend:
- Pipeline list moves to /wordpress/pipelines; detail to
  /wordpress/pipelines/:id[/:tab]. WORDPRESS_TABS, command palette, page
  titles, breadcrumbs, empty-state copy, and the WordPressProject tab base
  all updated to "Pipelines".
- Legacy /wordpress/projects[...] URLs redirect to the pipelines space via
  LegacyWpPipelineRedirect (deep links preserved).
- wordpressApi.getPipelines() added; getProjects() kept as back-compat alias.

Backend:
- environment_pipeline_bp mounted a second time under
  /api/v1/wordpress/pipelines as a true alias of /wordpress/projects.
- Test: /pipelines resolves identically to /projects.

Verified: frontend build clean, 5 alias tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:09:35 -04:00
Juan DenisandClaude Opus 4.8 3f18a0ebd3 refactor(unify): §1 — single Services detail surface; /apps→/services
Feature-unification plan §1 (Services UI = Applications backend). Ends the
"Applications vs Services" terminology split by collapsing the two detail
pages into one canonical surface and aliasing the backend.

Frontend:
- Merge ApplicationDetail's exclusive tabs (Container Ops, WAF, Build, Deploy,
  Previews) into the canonical ServiceDetail (PageTopbar chrome). Type-gating
  preserved exactly: ops=Docker, waf=Docker+Python, build/deploy/previews=all.
- serviceTypes.js tab lists extended for the merged tabs.
- /apps/:id and /apps/:id/:tab now redirect to /services/:id[/:tab] via
  LegacyAppRedirect (deep links preserved); ApplicationDetail.jsx deleted.
- Drop the /apps page-title entry and "Application Details" dynamic title.
- api.getServices()/getService() aliases over getApps()/getApp().

Backend:
- Mount apps_bp a second time under /api/v1/services as a true alias of
  /api/v1/apps (Flask blueprint re-registration with name='services').
- Test: /api/v1/services resolves identically to /api/v1/apps.

Verified: frontend build clean, eslint 0 errors, 3 alias tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:06:05 -04:00
Juan Denis ed2c9c3426 Protect ServerKit containers from lifecycle actions
Block stopping/restarting/removing ServerKit's own Docker containers and surface that in the UI. Backend: add DockerService helpers (is_protected_name / is_protected_container), route guard (_reject_if_protected) to reject destructive actions, annotate list_containers with a protected flag, and update cleanup logic. Add tests (backend/tests/test_docker_protection.py). Frontend: hide/replace lifecycle controls for protected containers (lock badge + styles), add Organization tab group (ORG_TABS) and migrate Projects/Shared Variables/Workspaces into a TabGroupLayout with topbar action hooks; update related styles and sidebar item matching.
2026-06-24 01:43:29 -04:00
Juan DenisandClaude Opus 4.8 807ca7b05e feat: per-service targeting for the compose env overlay
A variable can now land on one compose service instead of all of them. Both
EnvironmentVariable and SharedVariable gain an optional target_service
(NULL = all services), threaded through the shared resolver as an inclusion
filter (a var applies to a service when it targets all OR that service).

EnvService.get_effective_env_for_services(app, services) computes each service's
merged env (all-services vars + that-service vars, local overriding shared), and
ComposeEnvService builds the override's per-service environment from it — so a
var targeted at "db" only appears under the db service, untargeted vars appear
everywhere. The single-container docker-run path is unchanged (one container
gets the full effective env regardless of target).

API: env-var create/update and shared-variable create/update accept
target_service; GET /apps/<id>/compose-services lists a compose app's services.
UI: the Environment tab shows an "Applies to" selector (gated to compose apps via
the service list) with a per-row target chip; the shared variable editor gains a
target-service field.

Migration 036 adds the two columns; single Alembic head; chain applies from
empty. Proving tests cover per-service distribution + the resolver helper. Full
backend suite: 949 passed, 14 skipped; frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:19:41 -04:00
Juan DenisandClaude Opus 4.8 574de6af8a feat: inject shared variables into compose apps too
Single-container apps already get the effective env via docker run -e. Compose
apps get container env from the compose file's environment:, so this wires them
the same way through a managed override compose file.

ComposeEnvService.refresh_for_project writes docker-compose.serverkit.yml next to
a managed app's base compose, adding an environment: overlay to every service
built from EnvService.get_effective_env (shared variable groups under the app's
own local env vars). Compose merges the override on top of the base, so the
effective env wins on collisions — consistent with the docker-run path. Dollar
signs are escaped ($ -> $$) so values survive compose interpolation intact.

DockerService.compose_up now includes the override as a second -f (via
_compose_cmd_with_overlay); `up -d` recreates changed containers so updated env
takes effect on the next deploy. It's non-destructive (ServerKit only owns the
override; the base compose is never edited), best-effort/guarded (a build failure
falls back to the plain base command and never blocks a deploy), regenerated each
up, and removed when the app has no effective env. Non-app dirs (e.g. proxy
stacks) and env-less apps are left untouched.

Proving test test_compose_env_overlay.py (6 cases): overlay injects shared+local
into every service with local winning collisions, $ escaping, stale-override
cleanup, non-app dirs untouched, and compose_up running with -f base -f override.
Full backend suite: 947 passed, 14 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:34:49 -04:00
Juan DenisandClaude Opus 4.8 f015ecb7f8 feat: inject shared variables into the deploy env (under local precedence)
Shared variable groups were resolved/displayed but never reached the container.
EnvService.get_effective_env(app) now merges them in: shared groups
(workspace < project < environment < direct) form the base layer and the app's
own local env vars override them, so a key set in both yields the local value —
matching the "Set locally — local value applies" UI hint. Secrets are decrypted
for injection; shared resolution is best-effort and never blocks a deploy.

The docker deploy path (_deploy_docker) now injects this merged env via
run_container, which also fixes a latent bug there (it called .get('success')
on get_env_vars()'s list result, so env was effectively never applied). Updated
the shared-variables panel note to say shared vars now apply at deploy with
local taking precedence.

Proving test test_effective_env.py (6 cases) covers the merge precedence, the
best-effort fallback, and that _deploy_docker hands run_container the merged env.
Full backend suite: 941 passed, 14 skipped; frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:33:53 -04:00
Juan DenisandClaude Opus 4.8 e2438db9d1 feat: UX hardening for the new organization/proxy/snapshot surfaces
Tightens the seams the recent features introduced:

- Secret precedence/conflict: the embedded shared-variables panel now resolves
  via the hierarchical endpoint, shows a per-variable source-scope badge +
  precedence legend, and flags keys that are also set locally with a
  "Set locally — local value applies" badge (shared groups are a facade and are
  not injected into the container; the app's own env var is authoritative).
- Split inventory: apps now expose project_name/environment_name; the Services
  list shows a project/environment badge (or "Unassigned") and a "Move to
  project" bulk action (POST /apps/move-to-project, workspace-validated,
  null-unassigns).
- Fleet proxy actionability: fleet_overview rows carry a {level,text}
  recommendation (mismatch warning / idle stack / empty host / aligned),
  rendered as a per-row pill.
- Onboarding wizard: "Step X of 5" + progress bar + rough ETA, and a
  collapsible step-history trail so returning users get a clean summary.
- Config diff: a plain-language summary ("3 environment variables and the image
  tag (app:1.2.1 -> app:1.1.9) changed") above the technical diff and echoed in
  the restore confirmation.
- Container status: an always-visible "N issues" / "healthy/total" badge so a
  degraded sub-container isn't hidden behind hover.
- Sidebar: Projects, Shared Variables and Workspaces collapse under a single
  "Organization" group.

No schema change (project/environment names are derived). Backend suite: 935
passed, 14 skipped; frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:15:16 -04:00
Juan DenisandClaude Opus 4.8 0e71ab346a feat: make the ingress-plane boundary explicit (host Nginx vs proxy stack)
Both reverse proxies bind 80/443, so pointing the same domain at host Nginx and
a Dockerized proxy stack is an operational footgun. This tags each app with the
plane it expects and surfaces disagreements instead of leaving them implicit.

- Each app carries an `ingress_plane` ('nginx' | 'proxy_stack'); NULL reads as
  the host-Nginx default. New pure helpers in app/utils/ingress.py: only
  container-based services are proxy-stack eligible, and a requested proxy_stack
  is forced back to nginx for PHP/WordPress/static/Python apps. to_dict() also
  exposes `ingress_proxy_eligible`.
- ProxyStackService.ingress_audit(server) reports which of a server's apps
  disagree with its active proxy mode; fleet_overview now includes per-server
  app_count + mismatch_count. New GET /servers/<id>/proxy/ingress-audit.
- Create wizard offers the proxy stack only for Docker/Compose services and
  keeps the default on Nginx; app detail shows an Ingress badge; the fleet proxy
  dashboard flags mismatch counts; the server proxy panel warns and lists the
  specific mismatched apps with reasons.

Migration 035 adds applications.ingress_plane. Backend suite green (incl. 8 new
ingress tests); frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:57:40 -04:00
Juan DenisandClaude Opus 4.8 c4f3b4fdac feat: Phase 4 polish & fleet — onboarding completion, fleet proxy, shared-resource depth, docs
- Server onboarding completion (C8): a pure pre-flight compatibility check
  (arch/distro/kernel/cpu/memory) gating validate; real-but-guarded
  distro-aware install_prerequisites/install_docker/pair_agent steps that
  no-op safely on dev/Windows and never raise; audit + best-effort
  notifications on each transition; and a live auto-scrolling log trail +
  elapsed timer in the onboarding wizard.

- Fleet-wide proxy dashboard (C6): ProxyStackService.fleet_overview aggregates
  every server's proxy posture (configured stack vs host Nginx default) behind
  GET /api/v1/servers/proxy/overview, surfaced as a new Fleet Proxy page in the
  Servers tab group with summary tiles + click-through to each server's proxy.

- Polymorphic shared resources depth (C9): hierarchical variable resolution
  (workspace < project < environment < direct attachment < resource) with
  source provenance and {{group.KEY}} interpolation, an audit trail on every
  tag/group/variable/attachment mutation, GET /api/v1/shared/resolved/
  hierarchical, and the reusable Tags/Environment-Variables panels embedded on
  the application and server detail pages.

- Docs & coverage: docs/ENHANCEMENTS.md (brand-neutral guide to all ten
  capabilities + verified endpoints), README/CHANGELOG updates, and a
  cross-cutting integration test asserting the new endpoints are wired.

Full backend suite: 924 passed, 14 skipped; frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:04:04 -04:00