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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
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>
- 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>