79 Commits
Author SHA1 Message Date
Juan Denis 99a19c2fb7 Read 40 more route bodies through json_object()
The first pass left routes that hand the body straight to a service
(create_zone(data), MonitorService.create(data), ...). Those services
read it as a dict, so a body of null, [] or a bare string still
crashed them into a 500. Agent long-poll, external webhook receivers
and the routes that already type-check their body are left alone.
2026-10-10 21:48:18 -04:00
Juan Denis 55d8fb1e44 Read route bodies through json_object()
328 more routes used a JSON body as a dict straight from
request.get_json(), so a body of null, [] or a bare string crashed them
into a 500. Bare reads become json_object(); the
'get_json(silent=True) or {}' reads of action routes become
json_object(optional=True), which still treats a missing body as {}.
Routes that take arrays or pass the body through untouched are left
alone. The full backend suite shows no new failures.

Also carries three files' bare section citations (apps, backups,
notifications) and the extended citation guard, which needed them.
2026-10-10 20:53:43 -04:00
Juan Denis 6068b099dc Replace nine per-app access helpers with one
Nine route files each loaded the live app and checked access, with
four return shapes, docstrings promising error tuples the code never
returned, and 41 dead 'if err: return err' branches. require_app()
backed by ResourceGrantService.get_app_for does it once; conceal=True
keeps the 404-for-denied answer the previews, WAF and slot routes
already gave, so no route's status changes. Nine query sites leave the
API layer.
2026-10-10 16:36:01 -04:00
Juan Denis 6296887bad Remove internal plan citations from app code
Comments, docstrings and a few strings cited plan numbers ("plan 29
#9", "plan 86 §B2") from a git-ignored folder, so they pointed at
nothing; one reached users in a bottleneck hint. Code and frontend
strings are unchanged apart from those citations (checked by AST and
token comparison). A vhost still carrying the old asset comment reads
as undrifted, and a guard test keeps new citations out.
2026-10-10 15:50:45 -04:00
Juan Denis 4e45ecb5f5 Move the new auth lookups behind the model/service
The server access helper and the app-name clash checks queried the DB
from the API layer, which the controller-boundary scan rejects. They
now go through WorkspaceService.get_accessible_server and
Application.name_clash_error; the baseline drops by three.
2026-10-10 09:56:44 -04:00
Juan Denis bd850adfb3 Raise typed errors from the new auth checks
The checks added to apps, servers and 2FA verify returned hand-shaped
{'error'} bodies, pushing that census past its ceiling. They now raise
the typed errors (a RateLimitedError joins them for 429). create_app
loads the user once through get_current_user(), which drops the
identity census to 68. Regenerates the migration inventory, stale since
the raw-subprocess ceiling fell to 23.
2026-10-10 09:47:11 -04:00
Juan Denis 12f6dff430 Keep host paths and site names out of user hands
POST/PUT /apps let any developer set root_path, compose_file,
systemd_unit and managed_by, and store a raw name. Root-run code later
runs docker compose from that root, renders the name into an nginx
vhost and joins it onto sites-available for rm -f. The host-binding
fields are now admin-only (as /manual already was), create slugs the
name and checks for clashes, and a rename must already be a slug.

The nginx site methods all share write_vhost's plain-filename check, so
enable/disable/delete can no longer be pointed outside the site dirs.
2026-10-10 06:46:18 -04:00
Juan Denis 97a96d963b Give common API errors stable codes and service wording
Toasts showed backend prose verbatim, with no code to translate
against and "application" where the UI says "service". Not-found,
missing-field, permission, conflict and agent-offline errors now come
from factories in app/exceptions.py that pair one fixed message with
a dotted code (not_found.<resource>, validation.required,
permission.denied, conflict.exists, agent.offline), so the client can
translate them.

The shared app-loading helpers and the apps, domains, servers,
databases and backups routes raise those errors instead of hand
shaping bodies, which drops the hand-shaped body count from 1132 to
850. Five routes picked an HTTP status by comparing error text; they
now key off a result code or a sentinel so rewording can't change a
status. Five broad excepts that echoed str(exc) now log the crash and
return the request id instead.
2026-10-04 18:59:25 -04:00
Juan Denis 9eafe6709e Point compose app actions at the live slot project
Plan 87 phase E, compose part. For a compose app on slots, the live
release is a slot project, not the project its own compose file names.
Status maps to the live project only (a stopped standby never reads as
a degraded app), start/stop/restart and sleep/wake act on it, container
listings show the live and standby projects, and an image update runs
as a queued slot deploy instead of recreating the stack in place.
2026-09-28 22:27:13 -04:00
Juan Denis 87be6be8fa Raise a typed error for bad deploy settings 2026-09-28 21:14:48 -04:00
Juan Denis ddd6772279 Validate deploy settings before touching the app 2026-09-28 21:13:59 -04:00
Juan Denis cecbb743fd Make redeploys fail safe before slot deploys
Plan 87 phase A. Fixes the deploy bugs that make a rollback or a
health check lie:

- builds get an immutable tag per deployment, so rolling back runs
  the image that actually ran instead of whatever :latest became;
  old tags are pruned so this is not a new disk leak
- the deployment records the commit after the pull, not before
- the health wait is a real gate: a 4xx fails, it takes three passes
  in a row, an exited container fails at once, and it raises
- a per-app deploy lock makes a second deploy wait for the first
- a compose rollback rebuilds and recreates its containers, and a
  rollback with no version finds the previous release again
- a failed template update says when restoring the old stack failed

Health path, timeout and the 4xx rule are editable in Settings.
2026-09-28 21:10:29 -04:00
Juan Denis acdf6b878d Remove auto-scaling and the load-balancer builder
Replica counts could be changed, but nginx only proxied to one port
and the auto-scale sweep was never scheduled, so neither the
Auto-scale section nor its five routes could work; the nginx
upstream builder had no UI at all. With one live copy per app they
are gone, and the scale-policy table is dropped. Running containers
are not touched. The scaling spec now says replicas are not
pursued.
2026-09-28 09:03:34 -04:00
Juan Denis d148ccc247 Make the micro-cache a real page cache
Each site now sets its own TTL (10s default, 300s cap), and serves
the last good page while the app errors or the entry refreshes, with
one upstream request per missing entry. The cache key carries the
host, so purge clears only this site's files instead of every site's
cache. The settings panel shows the 24h hit ratio.
2026-09-27 18:51:05 -04:00
Juan Denis 45bc3c70bd Guard first admin with a setup code, fix Git deploys
A fresh panel no longer lets whoever reaches it first become admin:
registering the first account needs a one-time code the installer
prints (`serverkit setup-code` repeats it). SERVERKIT_ADMIN_EMAIL and
SERVERKIT_ADMIN_PASSWORD create the admin unattended instead.

Git apps now deploy what users expect: a repository with a compose
file deploys every service it declares, "Deploy latest" builds the
branch's newest commit, and push webhooks rebuild the containers.
Also fixes branch switching on single-branch clones, uploads of a
lone file, compose v1 port preflight, and CLI commands that ignored
their flags.
2026-09-24 00:50:02 -04:00
Juan Denis d5577e8bda fix(logs): keep remote runtime lookups off the panel host 2026-09-07 22:46:16 -04:00
Juan Denisandjrbertt 4f361fa3d1 Merge PR #136: fix(observability): resolve container runtime logs and metrics
Co-authored-by: jrbertt <joaorebertt0219@gmail.com>
2026-09-07 20:08:16 -04:00
Juan Denis d67e5d07d0 fix: complete audit follow-ups and add regression coverage
Centralize backup schedule calculations and shared request/form flows, encode query values, and move repository finalization into its service. Add browser CI coverage, repair stale test fixtures, and provide opt-in request and build measurement tools.
2026-09-05 14:41:15 -04:00
jrbertt 81a56012c3 fix(observability): resolve container runtime logs and metrics 2026-09-05 08:44:39 -03:00
Juan Denis f490d75bee refactor: share backend lifecycle and fleet metrics operations 2026-09-05 05:06:41 -04:00
Juan DenisandClaude Opus 5 aa9a570f57 fix(apps): keep compose the default; divert only build-pack apps
The previous commit asked "does a compose file exist on disk?" and sent
everything else down the container path. That was too wide in both directions:

  - test_start_app_authenticates_registry_before_compose_up seeds a
    registry-bound app whose compose file has not been rendered yet, and
    rightly expects `docker login` -> `compose up` -> `docker logout`. The
    disk probe diverted it and the start 400'd.
  - it also silently changed behaviour for any compose app whose file was
    missing for an unrelated reason, turning a clear compose error into a
    confusing "not been deployed yet".

Invert it. `_is_single_container_app()` is a positive test: a local app, no
compose file recorded, none on disk, and `buildpack_type` set -- i.e. one the
build pack deployed as a single container. Compose stays the default for
everything else, so no existing path changes.

Also seed the new test through `make_application` rather than constructing
Application(...) by hand, which is what test_seed_one_door_ratchet enforces
for files added after 2026-08-19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 01:44:35 -04:00
Juan DenisandClaude Opus 5 503dedd95c fix(apps): start/stop/restart a docker app that has no compose project
`app_type == 'docker'` was being read as "compose-managed", but the build-pack
path deploys by building an image and running a single container
(DeploymentService._deploy_docker, named serverkit-app-<id>) and never writes
a compose file. start/stop/restart called `docker compose` anyway, which -- in
a directory with nothing to read -- fails with:

    no configuration file provided: not found

So every build-pack app returned 400 from the panel's start, stop and restart
buttons and could not be run at all. There was already a materialize-compose
guard for BYO-image apps, but it only fires when root_path is NULL; a
build-pack app has source on disk, so it fell straight through to compose.

Decide on the artifact rather than the type: `_has_local_compose_project()`
looks for a compose file on disk (the app's own `compose_file`, else the four
default names). When there is one, nothing changes. When there isn't, drive
the container the deploy actually created.

Stop treats an already-gone container as stopped -- reporting that as a
failure would strand the app in `running` forever. Start and restart explain
that the app has not been deployed yet rather than surfacing a docker error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 01:34:30 -04:00
Juan DenisandClaude Fable 5 1b9f0061c7 fix(apps): name-clash errors point at the Recycle Bin for tombstones
A deleted app deliberately keeps reserving its name (Plan 70: a restore
must never collide), but every clash site said only "already exists" —
pointing the operator at an app that is in no list (GH report: delete an
app, then a same-name deploy fails with no way forward).

app_name_clash_error() now distinguishes a tombstone from a live row and
names the release path (restore or purge from Settings -> Recycle Bin);
all five clash sites go through it. Live-row wording is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 03:20:34 -04:00
Juan DenisandClaude Fable 5 1316feaad3 refactor(scale): ContainerScaleService owns its rollback
set_policy assigned fields to the ORM row and then raised ValueError
mid-validation, leaving the API route to clean up the dirty session with a
direct db.session.rollback() — a new controller-boundary crossing the
inventory ratchet rightly refused. The service now wraps apply+commit and
rolls back before re-raising, so callers never inherit a dirty session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 22:20:33 -04:00
Juan Denis bb675f0d9a feat(apps): policy-driven replica scaling for local Compose apps
Add ContainerScaleService policies (min/max replicas, CPU thresholds,
cooldown) persisted via migration 093, with manual scale, single
evaluation, and an admin sweep endpoint. Container Ops panel exposes
policy editing; the configured minimum acts as a floor on the next
evaluation. Frontend node:test suites pin policy normalization and the
API contract.
2026-08-27 22:35:43 -04:00
Juan DenisandClaude Fable 5 f237d4f3b4 feat(databases): ManagedDatabase adopts SoftDelete into the Recycle Bin (plan 77 B5)
First of the regret-ranked adoptions: a plain delete now tombstones the
row (migration 088; no unique constraints, so no partial-index rewrite).
The database content and its managed BackupPolicy survive the bin window
— restore brings everything back intact; PURGE removes the policy but
NEVER drops database content; the explicit ?drop=true path stays
immediately destructive. Active read paths (list/get/adopt/manifest/env
references/app detail) switch to query_active(); the backup executor
deliberately stays tombstone-blind so bin-parked data keeps backing up.
Server (98 query sites, fleet enrollment + HMAC secrets) is the next
ranked adoption — deliberately its own pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 05:43:53 -04:00
Juan DenisandClaude Opus 5 107fdbdfde refactor(auth): remove the last inline role checks
Plan 76 milestone A, the third row. 23 handlers decided admin access inline,
all in two files, in two shapes.

18 were the mid-route gate this milestone already built a door for:

    current_user_id = get_jwt_identity()
    user = User.query.get(current_user_id)
    if user.role != 'admin':
        return jsonify({'error': 'Admin access required'}), 403

which is four lines to say require_admin_user(), and carried the two-line
identity lookup with it. Migrating them closes both rows at once: the identity
census drops 87 -> 69 and the controller-boundary baseline loses 18 persistence
crossings (528 -> 510), with no new entries.

It also fixes a latent 500: where the check omitted the `not user or` guard, a
token naming a deleted user hit `None.role` instead of answering 403.

The other 5 are `not user.is_admin and app.user_id != user.id` — admin OR
owner, which is a different question. Those keep their shape and only converge
the truth source from `role != 'admin'` to `is_admin`; routing them through
can_access_app would silently widen access, since that also honours resource
grants.

The migration initially left `current_user_id` referenced but unbound in four
places. Two were caught by tests; the other two (create_manual_app,
upload_app_archive) have no coverage and were caught by an AST scan for names
used-but-never-assigned. Worth remembering that a mechanical removal needs the
scan, not just the suite.

Gate: -k "system or apps or app_ or host_snapshot or manual_app or upload",
304 passed / 13 skipped; all seven ratchets green (52 passed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:06:35 -04:00
Juan DenisandClaude Opus 5 fe02ce8eeb fix(auth): resolve the caller through one identity door, not the raw JWT
Plan 76 milestone A, drift/bug row 2 — scheduled as a fix, not a refactor,
because it is a behaviour difference shipping today.

rbac.get_current_user() is the only lookup that knows about both auth
mechanisms: it returns g.api_key_user for an X-API-Key request and falls back
to the JWT. 73 call sites answered "who is calling" with
`User.query.get(get_jwt_identity())` instead, behind 19 near-identical private
helpers (12 named get_current_user(), shadowing the real door).

The audit that scheduled this row predicted those return None for API-key
callers. Measured, it is worse: auth_required() deliberately skips
verify_jwt_in_request() once the key middleware has authenticated, so there is
no JWT context and get_jwt_identity() *raises*. Nine handlers reached through
an API-key-capable policy decorator therefore returned 500 to every API-key
caller:

  bandwidth.get_app_bandwidth          @viewer_required
  servers.set_server_workspace         @developer_required
  servers.create_server                @developer_required
  sso.get_sso_config / update_provider_config / update_general_settings
  source_connections.update_{github,gitlab,bitbucket}_admin_config
  source_connections.complete_github_app_manifest
  databases.execute_{mysql,pg,sqlite,docker}_query   (see below)

Three service helpers had the same lookup inside a blanket `except Exception`,
which converted the raise into a silent user_id=None — the write landed and
the audit trail lost the actor (shared_resource_service, cf_ops_change_service,
secrets_webhooks).

The four databases.execute_*_query sites were a second admin check *inside* an
already-@admin_required route: dead code that always passed for JWT callers and
crashed for API-key ones. They now call require_admin_user() rather than
re-deriving the answer.

Also folds in a sixth _require_admin() found in the cloudflare-ops extension
source (29 gates, the same inverted contract as jobs.py), and fixes
serverkit-gui's blueprint at its sibling-repo source rather than in this repo's
live copy.

Ratchet (second-wave rule 1 — a migration without one regrows):
tests/identity_door_census.py counts both bypass shapes by AST, direct and the
two-line `uid = get_jwt_identity(); User.query.get(uid)` variant. The ceiling is
87, all of them on @jwt_required()-only routes where reading the JWT is correct
today; the ratchet's third assertion holds the API-key-capable population at
exactly zero, so migrating one of those routes to a policy decorator without
fixing its lookup now fails. Verified non-vacuous: an injected bypass fails both
assertions and passes again on revert.

The first wave's controller-boundary baseline is regenerated — this removes 74
persistence crossings (593 -> 519), a debt reduction with no new entries.

Gate: tests/test_api_key_identity.py (7, each red before the fix),
test_identity_door_ratchet.py (10), test_api_controller_boundaries.py (4), plus
the 65 test files covering every touched module: 1024 passed, 10 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 19:33:57 -04:00
Juan DenisandClaude Opus 5 5f8a3ef204 feat(recycle-bin): applications are soft-deletable, and a delete stops keeping data hostage
Plan 70. Domain went through this and TEN read paths were wrong, four with
effects outside the panel. Application has 211 call sites across 68 files and
drives more -- container lifecycles, nginx, backups, registry pulls, the OS
crontab -- so it was audited in full first (docs/plans/70_...md). 180 sites are
now query_active(); the 35 left unfiltered are each commented in place.

**The delete / purge split is what makes the tombstone honest.** delete_app used
to run compose_down(volumes=True) and rmtree(). Keeping a tombstone AND doing
that would put a Restore button next to a row whose data the delete had already
destroyed -- the one promise a recycle bin cannot break. So:

  DELETE   containers down (VOLUMES KEPT), vhost removed, firewall closed,
           cron suspended, row tombstoned. All reversible.
  RESTORE  vhost rewritten, firewall reopened, cron resumed. Containers left
           STOPPED -- "put it back" must not silently start processes or open
           ports, the same reasoning that stops domain restore re-issuing certs.
  PURGE    volumes removed, uploaded source deleted, cron detached.

register() gained on_purge for that, wired into BOTH purge paths. And
purge_expired had NO scheduler -- fine when a tombstone was just a row, not fine
when it holds data volumes and a source tree, so a daily builtin job now runs
it. Without that, "kept until purge" would have meant kept forever and delete
would have quietly stopped reclaiming disk.

A side effect worth naming: the old _remove_data_flag protection for database
engines is now the DEFAULT for every app, and the flag is carried into
?purge=true so an explicit skip-the-bin delete still honours it.

**Cron was the blocker with no mechanical answer.** before_delete never fires on
a soft delete, and clear_application is LOSSY -- it nulls the association
without recording it, so nothing could put it back. suspend_for_application
instead disables only the jobs that were CURRENTLY ENABLED and stamps each
suspended_by_app; resume re-enables exactly those. That flag is the whole point:
without it a restore switches back on jobs the user deliberately turned off.
clear_application survives as purge-only, where lossiness is correct.

**The indirect reach (audit 3.1) is the class that grep cannot find.**
ContainerSleepPolicy, BackupPolicy, DeploymentJob and WafPolicy are enumerated
on a SCHEDULE and resolve application_id afterwards, so they never appear in a
search for Application.query. Left alone, a tombstoned app's sleep policy is
swept forever and wake_app restarts the containers the delete tore down. Those
loops test Application.deleted_ids().

Deliberately NOT scoped: reservation checks (port, name, directory) -- a
tombstone must keep holding those or a new app takes them and the restore is
refused; api/git.py's webhook VISIBILITY gate, which falls through to
"panel-wide visible" so hiding a tombstone would WIDEN access; and cron
attribution, which only labels a row.

Verified: 15 new tests, the migration replayed on a fresh DB (a tombstone frees
its private_slug, two live rows still collide), and a full delete -> restore
round trip. The 20 remaining full-suite failures are the known
serverkit-wordpress-not-installed class and are identical on a clean tree.

Two traps caught by testing, both recorded in the plan: a bad import made the
ENTIRE purge cleanup silently no-op while purge() still reported success
(_run_purge_hook swallows by design, so assert on the warning, not just ok), and
private_slug had to lose unique=True at the MODEL level or a models-built
database burns the slug regardless of the migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:26:04 -04:00
Juan DenisandClaude Opus 5 139d5884fe perf(api): stop /status/apps spawning a subprocess per app, and roll up the activity summary
The two worst endpoints in the backend, both measured in plan 69 phase 6.

**/status/apps** ran one `docker compose ps` per app plus a `docker inspect`
per container -- roughly 30 apps x 3 containers = ~120 process spawns per
request -- and it is refilled on a timer, so the cost never amortised. It is
now ONE host-wide `docker ps` whose output is indexed by the compose
project/service/working-dir labels Docker already stamps on every container,
shared between callers through a 3-second in-memory snapshot.

The snapshot is deliberately module memory rather than CacheService: it must be
droppable synchronously, because a status that survives the action that changed
it reads as a broken panel. So `invalidate(app_id)` is called from start, image
apply, deploy and rollback. 3s is short enough that a missed hook self-corrects
within one poll.

**/admin/activity/summary** ran two 90-day loops issuing one COUNT per day =
180 sequential queries. Now one `GROUP BY func.date(...)` per series with the
gaps zero-filled in Python, so the response shape is byte-identical. `func.date`
is portable across the SQLite and PostgreSQL this app supports. A third N+1 fell
out on the way: the top-users block did one SELECT per user, now a single IN.

New tests assert the zero-fill specifically -- a day with no rows must still
appear and the series length must match the window, which is exactly what a
GROUP BY drops if you forget.

1140 backend tests pass. The 3 failures in that run (test_fail2ban_jail_service,
test_url_swap, test_site_routing) all reproduce on a clean tree -- the last is
the known "serverkit-wordpress extension not installed" class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:15:53 -04:00
Juan DenisandClaude Opus 5 cf7c20864f perf(api): $select/$filter/$orderby/$skip/$top, and opt /apps in
Field selection did not exist anywhere in the backend (`fields=`/`select=`: 0
hits across 90+ API files) and neither did eager loading (joinedload/
selectinload/subqueryload: 0 matches). /apps sends ~2,319 B per row where the
list UI reads ~411, over an unbounded `query.all()` -- for an admin with no
workspace header that is literally SELECT * FROM applications.

Ports the author's conuco_v2 query layer as app/api/_query.py, with two
deliberate differences:

- ORM, not Core. conuco resolves fields through `table.c`; here they resolve
  against the mapper's column set, and that set is the ALLOWLIST -- an unknown
  field is a 400, never a getattr into arbitrary model attributes.
- $select is not only a column narrowing. That is where the win actually is:
  Application.to_dict() costs ~3 extra queries per row, and two of them
  (image_scans, image_update_checks) are lazy='dynamic', so the attribute IS a
  Query and NO loader option can hoist them. Declining a field is the only way
  not to pay for it. to_dict(fields=...) now skips the loads it was not asked
  for, so $select=id,name costs zero extra queries per row instead of three.

/apps also eager-loads server/project/environment/linked_app -- but only when
the request actually asked for the names they resolve, since a joinedload for
a field nobody selected is a wider row for nothing.

Every parameter is optional and to_dict(fields=None) is the full legacy
payload, so adopting this changed no existing caller's response. Paging meta
appears only once a caller pages.

$view is deliberately not implemented: ServerKit's saved views are per-user
rows already resolved in the browser, so a second server-side copy of the same
concept would only be somewhere for the two to disagree.

19 new tests. The 2 failures in the wider run (test_fail2ban_jail_service,
test_url_swap) reproduce on a clean tree -- pre-existing, not from this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:11:39 -04:00
Juan DenisandClaude Opus 5 eee14e6a79 fix(domains): stop soft-delete tombstones leaking into every read path
Making Domain soft-deletable silently changed the meaning of every existing
`Domain.query`: before, it could only return live rows. An audit of all 16 call
sites found ten that were now wrong, several with side effects outside the
panel.

The ones that reached the outside world:

  setup_reconcile_service.py:267  the only is_primary row after deleting the
      primary IS a tombstone -- and it drives a WP-CLI search-replace across
      the whole WordPress database. It would have repointed a live site at a
      host nginx no longer serves.
  setup_reconcile_service.py:83   DNS backfill enumerated deleted domains and
      called ensure_a_record() -- creating a real record at Cloudflare/Route53
      for a domain the user removed.
  api/domains.py:367 (enable_ssl)  a real ACME order for a host that is in no
      server_name: the HTTP-01 challenge cannot be served, burning Let's
      Encrypt failed-validation limits for the whole registered domain, and on
      success installing a wrong-CN cert on the LIVE vhost.
  doctor_service.py:267           permanent red "does not resolve" rows that no
      action could clear, and the same set is the allowlist for one-click DNS
      repair -- so Repair created a provider record for a deleted domain.

And the ones that broke the feature itself:

  domain_attach_service.py:79     a tombstone counted as a name clash, so you
      could not move a hostname to another app after removing it. Migration 083
      made the unique index partial precisely to free the name; this re-imposed
      the burn in application code.
  domain_attach_service.py:71     attach found the tombstone, reported success,
      created no live row, published nothing -- and then mutated a recycle-bin
      record.
  site_domain_service.py:600      same for give_subdomain: success, a URL, a DNS
      record, and a site that was never published.
  api/domains.py:532              a second vhost renderer (admin Regenerate)
      writing tombstones back into server_name.

Application.domains still includes tombstones ON PURPOSE -- filtering it with a
primaryjoin would, with delete-orphan, make SQLAlchemy HARD-delete the row the
recycle bin exists to keep. `Application.live_domains` is the accessor for
"what this app actually serves", and to_dict() uses it so the /apps payload no
longer lists deleted domains.

Per-domain routes now 404 a tombstone instead of editing it.

Seven tests cover the leaks that had real consequences.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 09:27:05 -04:00
Juan Denis 7c6b0f3958 security: hook seam lifecycle + registrant binding
Audit remediation (extension seams):
- disable/uninstall tear down the extension's backup kinds, event
  types, and template providers (unregister_capabilities by slug);
  enable re-registers (idempotent)
- wordpress_bridge fails closed unless the extension row is ACTIVE;
  orphaned-site db snapshot route returns clean 503
- provider registrations bound to their registrant: cross-slug
  replace/hijack rejected for backup kinds, event types, template
  providers
2026-08-05 13:36:33 -04:00
Juan Denis 72eaba34f2 refactor(wordpress): decouple core from the in-tree extension
Bridge, blueprint registration, app/attach flows and builtin job
handlers no longer assume the WP builtin is present; WP install paths
gitignored like other extracted extensions. WP absent = feature absent
gracefully (plan 52 phase 5, D4).
2026-08-05 02:37:48 -04:00
Juan DenisandClaude Opus 5 3070353631 feat(databases): install any engine, because an engine is just a template
You could not install a database from ServerKit. The tree's four roots were
literals, only MySQL and PostgreSQL reported real state, and no install path
existed anywhere in the backend.

Rather than add one, this makes a database engine an ordinary app template that
declares a top-level `engine:` block — family, wire protocol, admin user, which
variable holds the password, what it calls a "table". Presence of that block is
the only thing that lists a template in the Databases catalog, and installing it
is the template pipeline that already existed: same deployment job, same
streaming console.

The consequence is the point. Adding CockroachDB is adding a YAML file. So is
adding something we have never heard of — author it locally or sync a template
repo — and the install form still works, because it is generated from the
template's own `variables[]` rather than hand-written per engine. There is no
engine model, no engine table, no engine install mechanism to keep in step.
`test_dropping_a_new_engine_yaml_lists_it` is the design; if it ever needs code
to pass, this went wrong.

Twelve engines ship: postgresql, mysql, mariadb, mongodb, redis, valkey,
cockroachdb, clickhouse, influxdb, neo4j, meilisearch, couchdb. Each pins its
image, binds to loopback, names a data volume, and was checked against the
upstream image's actual environment contract — a wrong variable name is a
container that boots with authentication silently disabled.

Two safety notes worth reading. Engines bind 127.0.0.1 unless explicitly
exposed, and the flag is applied after caller variables so a handcrafted payload
cannot quietly publish one. And CockroachDB single-node runs `--insecure`
because password auth needs a certificate-backed cluster: for engines with no
credentials at all the loopback bind is the only control, so public exposure is
refused outright rather than warned about. A test pins the passwordless list to
exactly cockroachdb, so a future template that drops its password variable
fails loudly instead of opening a hole.

Also surfaced at last: `ManagedDatabase.owner_application_id` has been accepted
by the create endpoints for ages with no UI to set it. Creating a database now
offers "Attach to application".

Deferred deliberately: new-table builder, SQL dump import, engine extensions
(pgvector, TimescaleDB) — each a data-mutation surface deserving its own round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:09:05 -04:00
Juan DenisandClaude Opus 5 ef3712f97a fix(services): real numbers in Resource Usage, and a Related card that finishes
Two more of the same fault as the logs: the frontend reading fields the backend
does not send at that level.

Resource Usage showed 0.0% CPU, 0.0% memory and N/A for everything else on a
healthy container. The stats route answers {stats: {...}} carrying docker's own
CLI keys, and the caller read the top level of the envelope — so every field was
undefined and fell through to its zero. It unwraps `stats` now, which is where
CPUPerc, MemUsage, NetIO and PIDs have been all along.

Related Resources sat on "Loading…" permanently because GET /apps/<id>/related
was never implemented. The client has always called it, the 404 was swallowed by
an empty catch, and the card reads null as "still loading". The endpoint now
exists — domains, managed databases, backup policy and deploy count, scoped by
the same access check as the rest of the app routes, each section gathered
independently so a missing table degrades that row instead of failing the card.
The catch also settles to an empty object now, so a future failure shows "no
related resources" rather than spinning forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:13:36 -04:00
Juan Denis 98ad0481a5 fix(deployments): stop stuck 0% installs, deploy repo apps on create, unify install UX
- deployment_job_logs.id: BigInteger -> with_variant(Integer, 'sqlite');
  BIGINT PK never autoincrements on SQLite, so the first log insert crashed
  every fresh install (jobs stuck 'running' at step 0 with no logs)
- migration 076 rebuilds the table on existing SQLite databases
- runner: first status/log/telemetry inside try, non-raising log(),
  rollback-safe failure marking — any failure now fails the job visibly
- service: guard enqueue, crash-proof run_job, fail jobs left 'running'
  across restarts at boot
- repo-based services (POST /apps/from-repository) now enqueue a real
  'app_deploy' DeploymentJob (build + start via DeploymentService.deploy)
  and return deploy_job_id
- compose override injects restart: unless-stopped for services without a
  policy, so apps come back after reboot
- GET /deployment-jobs gains an app_id filter
- frontend: shared DeploymentJobProgress component (timeout + visible poll
  errors) used by the Templates InstallModal and the service Logs tab;
  repo wizard navigates to the Logs tab to watch the deploy live; install
  success lands on the Logs tab; failure toasts surface real server errors
2026-07-21 22:57:33 -04:00
Juan DenisandClaude Opus 4.8 3954680c87 fix: apply login layout to the main login form + bind BYO-image legacy port to loopback
Two issues from PR review:

- Login: the AuthLayout wrapper only reached the 2FA and login-link states —
  the primary login form still had a hardcoded auth-container/auth-card, so the
  `login_layout` setting didn't affect the page most users see. Wrap the main
  form in AuthLayout too.
- apps.py: the legacy scalar-port fallback for a BYO-image app omitted `expose`,
  which AppPortService defaults to `public` (binds 0.0.0.0), publishing the port
  past nginx. Default it to `local` (loopback). (Workers set no port, so this
  was latent, but the generic path is now safe.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:55:15 -04:00
Juan DenisandClaude Opus 4.8 1428aa8f29 feat(apps): start local image apps that have no source on disk
A docker app created with a `docker_image` but no repository (root_path /
compose_file NULL — e.g. an `image:` manifest service with no git source)
previously skipped the launch branch in start_app entirely and was just
marked `running`, so nothing ever started.

On first start, materialize a one-service compose from the image and its
typed ports under APPS_DIR, persist root_path/compose_file, and let the
normal compose_up overlay inject the app's effective env (including
resolved vault secrets) — so nothing sensitive is written to the base
compose. No-op when a source already exists, there is no image, or the
app targets a remote server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:50:55 -04:00
Juan DenisandClaude Opus 4.8 06c3fcf28c feat(manifest): appliance-tier typed L4 ports + plan-time blockers rail (plan 35 ph1)
Rebuild of plan 35 Phase 1 against the surviving migrations (ports as a JSON
column on applications, not a separate table). Adds typed `ports[]` to the
serverkit.yaml schema/normalizer, AppPortService (persist/compose-emit/firewall),
open_port apply step + firewall open on apply / close on app delete, and the
blockers engine (port_conflict/remote_target/observed_server/firewall_undetected)
that refuses apply instead of half-deploying.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 02:57:23 -04:00
Juan DenisandClaude Opus 4.8 d9b04077d4 fix(recovery): restore per-app route authz gating (plan 29)
Many per-app API route files lost their plan-29 gating (the `# Bucket:` header +
require_app_member/admin_required decorators). Restored so the authz structural
sweep + hardening matrix pass again, using the recovered rbac primitives
(app_access_tier / require_app_member).

- env_vars: can_access_app (read) / can_edit_app (write) across handlers
- apps sleep/scale policy, container_status, snapshots, git deployments/webhooks
  (visibility-scoped), databases raw-docker routes (admin), status-page checks
- wordpress: sealed reads + P0 destructive sealed-write + convergence reads +
  standalone host-ops admin-gated
- bucket headers on the already-gated app_volumes/deploy/domains/private_urls/
  python/security/templates

test_authz_structural_sweep 2, test_api_authz_hardening 27, test_git_webhook_authz
5 — green; 430+ route regression tests still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 02:05:50 -04:00
Juan DenisandClaude Opus 4.8 ae0f823e90 feat(manifest): persist manifest + seed env + health-check gate (Phase 1)
Stop dropping what import already detects. Add Application.healthcheck_path
(migration 054) and an application_manifests table (one row per project) storing
the normalized manifest, hash and provenance. Import now seeds non-secret env
values and records the health-check path, and the zero-downtime restart gate
polls that path instead of a blind sleep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 01:16:29 -04:00
Juan DenisandClaude Fable 5 96d162e9a4 feat(hosting): per-site nginx micro-cache toggle
Opt-in 10s FastCGI/proxy micro-cache per site (migration 053) with safe
bypasses for logged-in/admin/cart cookies and paths, X-SK-Cache header
for verification, and a shared-zone purge. Flows through the drift-safe
vhost render seam so detection and writes always agree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:21:50 -04:00
Juan DenisandClaude Fable 5 9edeb68f85 feat(apps): first-class per-app CPU/memory limits
cpu_limit/memory_limit on Application (migration 048), emitted into the
generated compose and the ServerKit override for live re-apply, with a
Resource Limits panel (usage vs limit) in Service Settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:23:39 -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 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 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 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 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