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