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>
Completes #38: the WordPress frontend now belongs to the extension too, not just
the backend. The tab-group constraint (contributed routes are flat — there's no
way to contribute a shared TabGroupLayout parent inside the dashboard) is solved
by contributing a SINGLE splat route `wordpress/*` whose component
(WordPressExtension) self-renders the whole WordPress sub-router — reproducing the
exact structure core App.jsx used to own:
- TabGroupLayout + WORDPRESS_TABS over the 3 list pages (sites / plugin library /
pipelines),
- full-bleed site + pipeline detail routes as siblings,
- the legacy /wordpress/projects → /wordpress/pipelines deprecation redirects.
The 5 page components stay in core pages/ and are re-used (Pattern A, same as
serverkit-git/email/workflows); only the routing + nav/palette/title ownership
moves into the manifest.
- builtin-extensions/serverkit-wordpress/frontend/index.jsx (source) → synced to
frontend/src/plugins/serverkit-wordpress/ (pre-bundled, D5) via
scripts/sync-builtin-frontends.mjs (drift check green).
- plugin.json contributions: nav (WordPress), routes ([{path:"wordpress/*",
component:"WordPressExtension"}]), page_titles, command_palette.
- Core App.jsx: removed the WordPress imports, the TabGroupLayout WORDPRESS_TABS
route block, the detail routes, LegacyWpPipelineRedirect, and the now-unused
ModuleRoute + WORDPRESS_TABS imports (kept the dynamic /wordpress/ title
fallbacks). Removed the sidebar nav item and the two command-palette entries.
WordPress now disappears cleanly (nav+routes+API) when the extension is
uninstalled.
Verified: npm run lint (0 errors) + npm run build clean; the screenshots skill
renders /wordpress/42 correctly (visually confirmed — full detail page, Overview
tab, sidebar item active); backend flagship still seeds/loads (has_frontend now
true) with tests green.
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>
Marketplace was in ADVANCED_ITEM_IDS, so every preset except "Full" (including
the default "Recommended" and everything the Setup wizard's use-case picks map
to) hid it — users had to switch presets/customize to find it. It's the front
door to extensions, so mark it alwaysVisible (like Dashboard) and drop it from
ADVANCED_ITEM_IDS. Now shown regardless of the onboarding choice or a custom
config that predates this.
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>
Grounded coupling maps + cut lists ("stays core" vs "moves") that gate the
actual extractions (#32/#38):
- EXTRACTION_EMAIL_AUDIT.md: mail-server stack cleanly extractable; only pre-req
is relocating DNSProviderConfig + lazy postfix import; notifications SMTP is
independent.
- EXTRACTION_WORDPRESS_AUDIT.md: extractable but 7 core hooks to invert first
(eager import, backup-engine branch, Fail2ban WP filter, DatabaseSnapshot/SyncJob
relocation, event catalog, lazy cross-imports). Ships bundled default-installed
per D4; run after Email proves the machinery.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Registry as a Browse source (getRegistryExtensions, "Registry" label,
checksum-verified install via installRegistryExtension)
- Update badges + one-click Update on the ServerKit Plugins tab
(getPluginUpdates / updatePlugin), disabled when panel too old
- API methods for registry + updates
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>
With several base domains registered, the create modal now lets you choose
which one the site publishes under. A "Publish under" dropdown lists the
available bases (default first, marked, showing which have HTTPS) and previews
the exact live host — <slug>.<base> — as you type the name. The choice rides
through as baseDomain in the existing createSite payload. When no base domain
is configured the field explains the site will stay on localhost until one is
set. The Custom Domain field is clarified as an override of the base choice.
wordpressApi.listBaseDomains() reads GET /api/v1/domains/base-domains; the base
list loads when the modal opens and preselects the returned default.
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>
After creating a WordPress site the post-create banner showed only the admin
credentials, so users had no signal that the site was already reachable at
<slug>.<base> — the exact "preview URL before DNS" gap roadmap #21 tracked,
now unblocked by the base-domain + Phase-5 wildcard-cert stack.
The banner now surfaces the live link on every successful create: a clickable
<slug>.<base> (noting HTTPS when the wildcard cert is set up), or, when the
site only came up on localhost, a nudge to set a managed-sites base domain in
Settings to publish it publicly. Shown even when the automated install did not
finalize (no captured credentials).
Mark #21 done in the roadmap: the wildcard base subdomain is the working,
TLS-valid preview a user relies on while pointing a custom domain, so the
separate *.preview.<panel-domain> scheme is unnecessary.
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>
Add a 'Related Projects' section linking Faro (desktop SFTP/SSH/S3 client) to the main README and translated docs (Spanish, Portuguese, Chinese). Explains Faro's features and relationship to ServerKit, and adds a link to the latest releases. This provides users a recommended desktop companion for file transfers and agent bridge functionality.
The panel frontend is now served straight from nginx/sites-available/*
(static SPA at location /, plus /api + /socket.io proxy). A broken
directive there 502s the whole panel on a real box, and nothing parsed
these configs in CI.
Add scripts/test/test_nginx_conf.sh: stages each vhost (secure +
insecure) against a real nginx, recreating the absolute paths they
reference (a stand-in frontend/dist bundle, the serverkit-locations /
conf.d include dirs, certbot webroot) and a throwaway self-signed cert
for the TLS vhost, then runs `nginx -t`. Skips cleanly (exit 0) where
nginx/openssl/root aren't available, so it's safe on a dev laptop.
Wire it into the Scripts CI `shell` job after the unit suites (installs
nginx + openssl so the step actually runs). shellcheck-clean at warning
severity; bash -n clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turns the zero-downtime promise into an actual check. The updater now
snapshots which managed-app upstreams are reachable through nginx
*before* the blue/green switch, re-probes after the graceful reload, and
reports any app that was serving and has since gone dark.
- discover_app_upstreams scans /etc/nginx/serverkit-locations/*.conf for
the per-app proxy_pass upstreams (host:port).
- probe_app_upstreams / snapshot_app_reachability record up/down (any
HTTP status = up; a refused/timed-out connection = down).
- report_app_uptime_regressions diffs before-vs-after and warns for any
up->down app, ignoring apps that were already down. Best-effort: it
never fails an otherwise-healthy update, just surfaces the regression.
- New "Verifying App Uptime" phase wired into the main flow; baseline
captured before the backend stop.
Tests: T16 (upstream discovery), T17 (regression detection both ways).
19 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The panel is now fully off Docker: the React SPA is served as static
files directly by host nginx from frontend/dist, eliminating the
serverkit-frontend container and the double nginx hop (host nginx ->
container nginx -> dist). Docker is now used ONLY for managed app
workloads.
- nginx sites (serverkit.conf + serverkit-insecure.conf): `location /`
now roots at /opt/serverkit/frontend/dist with a try_files SPA
fallback and a long-cache /assets/ block, instead of proxying
127.0.0.1:3847. Root tracks the /opt/serverkit symlink so blue/green
updates swap assets atomically.
- install.sh: launch_services no longer builds/starts a frontend
container; adds apply_frontend_root (rewrites the dist root for a
custom SERVERKIT_DIR) and selinux_label_frontend_dist (httpd_sys_-
content_t so SELinux hosts can read the bundle from /opt).
- update.sh: refresh_config carries the same root rewrite across
upgrades; selinux_label_dist relabels the switched-in slot; dropped
the `docker compose up -d --force-recreate frontend` from both the
forward and rollback paths.
- docker-compose.yml: frontend service moved behind a `legacy-frontend`
profile (opt-in escape hatch); default `up -d` starts nothing.
- docs/DEPLOYMENT.md: documents the off-Docker panel model.
Tests: T14 (refresh_config rewrites dist root for custom install dir),
T15 (shipped nginx configs serve static, never proxy :3847). 16 passed.
Note: live nginx -t / SELinux file-serving not validated on a RHEL box
from this dev host; logic is best-effort guarded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Host nginx is the front door for every managed app (it reverse-proxies
their containers via /etc/nginx/serverkit-locations/*.conf). The updater
used to `systemctl stop nginx` for the whole blue/green switch window,
which blacked out every unrelated hosted site for 10-60s during a panel
update. The slot switch only ever changes nginx config, never anything
nginx serves from the slot, so a stop was never required.
Add reload_nginx_graceful(): validate config (nginx -t) then
`systemctl reload nginx` - keeps listening sockets and in-flight
connections alive, so apps never blink. Starts nginx if it happens to be
down. Wired into both the forward update flow and rollback(); only the
panel backend is now cycled. Dropped the stray frontend-only
`docker compose up -d backend` line.
Tests: T11 reloads-not-stops a running nginx, T12 starts a stopped
nginx, T13 guards against `systemctl stop nginx` reappearing. 13 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>