pytest-split falls back to splitting by test COUNT when no durations file
exists, and these tests range from 0.01s to 10s+. That produced visibly lopsided
shards on the first sharded CI run — 6m17s for the fastest against 10m46s for
the slowest, and the slowest shard is the whole job's wall clock:
pytest (4/4) 6m 17s
pytest (2/4) 8m 42s
pytest (1/4) 10m 01s
pytest (3/4) 10m 46s <- Backend CI was 11m06s because of this one
With real durations, pytest-split packs the shards to equal TIME instead, which
should pull the slowest shard down toward the ~9m average.
Generated from a full local run: 3175 entries, 20 failed / 3108 passed /
47 skipped in 11m53s. All 20 failures are pre-existing and unrelated (a
serverkit-wordpress checkout absent locally, plus Linux-only fail2ban tests) —
verified by re-running those same files against the pre-plan-64 conftest.
Regenerating this file was only affordable because plan 64 Phase 1 landed
first; before it, a full run was slow enough that Phase 0 abandoned the attempt.
Regenerate it whenever suite timings shift materially:
cd backend && python -m pytest tests --store-durations
Plan 64 Phase 3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing catches a revert of `_flask_app` to scope='function'. The suite would
still pass — just ~3x slower — and CI would drift back to a 20-minute wait,
which is how it got that way in the first place. Same idea as the
collected-test-count ratchet: turn a silent regression into a red test.
Also asserts the inverse for `app`, which must stay function-scoped: it is what
resets the database between tests, and session-scoping it would leak each
test's rows into the next and fail while pointing at the wrong test.
Verified both directions — flipping _flask_app back to scope='function' fails
the guard with the explanation, and restoring it passes.
Ratchet 3173 -> 3175 for the two new tests.
Plan 64 Phase 4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `app` fixture called create_app('testing') for every one of 3173 tests,
registering 90+ blueprints each time. Measured on a fixed 199-test sample
(pytest --splits 16 --group 5 --durations=0):
setup 144.9s 89.5%
call 8.8s 5.4%
teardown 8.2s 5.0%
95% of the suite's time was preparing to test; 5% was testing. The app is now
built once per process and shared.
Same 794-test slice, before and after — identical results, 2.66x faster:
before 4 failed, 773 passed, 17 skipped 593.30s
after 4 failed, 773 passed, 17 skipped 223.11s
(the 4 are pre-existing locally: serverkit-wordpress isn't checked out here.)
Sharing one app surfaces three things a per-test app hid, all handled in the
fixture rather than patched test by test:
1. create_app() is not a pure constructor — it SEEDS the bundled flagship
extension rows as a boot side effect (test_cloudflare_extraction asserts
they are "seeded on boot"). The per-test drop_all wipes them, and a shared
app never re-boots, so every test after the first saw no flagship and the
extension routes answered 503. seed_flagship_extensions() is now re-run
after each create_all. The session fixture also boots twice on purpose:
once to create the schema, once to seed into a schema that exists.
2. Config mutation now leaks where it used to die with the app (e.g.
test_demo_deploys sets DEMO_DEPLOYS_ENABLED=False and never restores it),
so config is snapshotted and restored around every test.
3. Structure mutation cannot be undone at all — Flask has no
unregister_blueprint, and refuses setup methods once an app has served a
request. Those tests opt out via `pytestmark = pytest.mark.fresh_app` and
get a private app. Three groups needed it: 13 modules calling
register_blueprint/add_url_rule directly, 11 that install plugins (
plugin_service hot-loads their blueprints onto the live app), and
test_audit_log which adds routes with @app.route.
Tests using the `wp_extension` fixture are detected from the fixture graph
instead of being marked, since that fixture mounts blueprints on whatever app
it is handed — miss one marker and the failure lands in an unrelated test much
later in the run.
Plan 64 Phase 1. Phase 2 (savepoint rollback instead of create_all/drop_all)
is NOT included: it currently takes the slice from 4 failures to 100 failures
and 201 errors, and needs its own round.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 from the template repo/sync suite added alongside the DEFAULT_REPOS fix;
the rest accumulated since the last ratchet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DEFAULT_REPOS pointed at raw.githubusercontent.com/serverkit/templates --
the org `serverkit` does not exist (the registry is jhd3197/serverkit-templates,
and its default branch is master, not main). So it has 404'd for its entire
existence: no panel has ever fetched a template remotely, and every /templates
page has been showing only the 118 bundled backend/templates/*.yaml.
Now points at https://serverkit.ai/templates, which proxies the registry and
was built for this consumer -- it serves <repo_url>/index.json and the
<repo_url>/templates/<id>.yaml path this class derives, behind a TTL cache
with last-good fallback. Using the product domain also means a branch rename
upstream cannot silently empty every panel's catalog, which is the exact
failure this is fixing.
Fixing the default alone would only help fresh installs, because get_config
returns whatever a saved templates.json holds. Known-dead URLs are therefore
healed on read. Nothing is lost by rewriting a URL that never once resolved.
The checksum half is here rather than in a follow-up because this commit
ACTIVATES a download path that has never run: sync_templates wrote whatever
came back straight to disk, ignoring the sha256 the index pins for every one
of the 106 official entries. Turning on an unverified fetch days after the
panel learned to verify ed25519 signatures on extensions would be shipping a
known downgrade. Mismatch is now a hard refusal that never reaches disk;
absent hash is allowed and counted, mirroring unsigned-vs-invalid for
extensions. Content is written as bytes so the file on disk is exactly what
was hashed.
Note get_template's in-memory remote read (it parses without saving) is not
covered -- it has no index entry on hand and would need an extra fetch.
DEPLOY ORDER: serverkit.ai must ship the master-branch fix before this helps.
Until then the proxy 502s and sync finds nothing, same as today.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Runtime-ESM extensions (starting with serverkit-wordpress) need slot
mounting and error containment from the host; additive, versioned in
lockstep frontend/backend.
Create/update validation fails loudly naming the missing extension
instead of orphaning monitors; health sweep no-ops with zero WP sites
(plan 52 phase 3 task 17). StatusComponent.wordpress_site_id stays core
per D1.
The wordpress template kind + wordpress-external-db preflight move behind
a template-provider hook the extension supplies; the backend filters the
catalog so the WP card simply does not appear when WP is absent — no
frontend change needed (plan 52 phase 3 task 16).
Extensions register event types at load; wordpress.* types now come from
the WP extension instead of a hardcoded core catalog (plan 52 phase 3
task 15). emit_wp stays core as a dumb emitter helper.
VALID_TARGET_TYPES is now provider-driven; the wordpress_site branch
moves behind a provider the WP extension registers. ProtectionPanel /
RestoreDrawer read available types from the API; policies whose provider
is absent surface a clear missing-provider state instead of crashing
(plan 52 phase 3 task 13).
Extensions can register providers into core engines at load (backup
targets, event types, template kinds) via a core_hooks module; idempotent,
process-lifetime like blueprint registration. WP extension supplies its
entries through builtin-extensions/serverkit-wordpress/backend/core_hooks.py
(plan 52 phase 3, D4).
The four minecraft test files now live in serverkit-minecraft/tests/
(panel-hosted via SERVERKIT_MINECRAFT_DIR loader). Ratchet floor
3280 -> 3197 (-83 minecraft tests; collected 3222, floor intact).
Proactive hardening from an internal audit of the same broken-authorization
class as GHSA-6w78-q5vm-rfmh (authentication checked, app/workspace-level
authorization skipped):
- deployment_jobs: job detail/logs/retry resolve the job and gate on
can_access_app / can_operate_app (app-less jobs: requester or admin);
global job listing is admin-only
- secrets_webhooks: every vault/secret/webhook-endpoint/delivery by-id
route resolves to its workspace and 404-seals non-members; secret
reveal and signing-secret regeneration require workspace owner/admin;
dead local admin_required placeholder removed
- shared_resources: variable-group CRUD and attach/detach gate on the
resolved scope (workspace role / can_edit_app / can_operate_app),
closing deploy-time env injection into foreign apps
- queue_bus: message receive/complete/fail/list on system-owned groups
are admin-only; user-owned groups restricted to their owner
- docker: raw container inspect (exposes env vars) and container logs
are admin-only, matching Decision 7; per-app logs stay app-gated
- databases: raw SQL query endpoints, host-level metadata reads, and
process lists are admin-only; per-app managed database flows unchanged
Adds 4 persona-matrix regression suites (74 tests) and bumps the
test-count ratchet.
GHSA-6w78-q5vm-rfmh: four git deployment/webhook read endpoints checked
authentication but skipped app-level authorization, letting any active
user (even a foreign viewer) read cross-tenant deployment history and
logs that can contain secrets from pre/post-deploy scripts.
- git.deployments/<id>: resolve deployment -> app, 404/403 via
app_access_tier before returning any log bytes
- git.deployments/webhook/<id>: gate on _webhook_visible_to (app-linked
webhooks follow app visibility; unlinked stay panel-wide, plan 29 #11)
- deploy.history & deploy.webhook-logs: app_id queries require
app_access_tier; global (no app_id) queries are panel-admin only
Adds backend/tests/test_git_deployment_authz.py (9 persona-matrix
regression tests) and bumps the test-count ratchet.
Co-authored-by: Tyler Boykin <25166954+CaptBoykin@users.noreply.github.com>
Copilot review on the GHSA-rm3m-9mvw-68fh patch caught two issues:
- _BACKEND_DIR/_INSTALL_DIR were one level too shallow (backend/app and
backend/ instead of backend/ and the install root), which left
<install>/.env and frontend/dist reachable on the deployed layout —
the exact PoC path from the advisory. _BACKEND_DIR stays in
PROTECTED_ROOTS so flat layouts (code at /app) are covered too.
- The ALLOWED_ROOTS check used a bare startswith, so e.g. /optfoo
matched the /opt root. Both checks now require an exact match or a
path-boundary prefix.
New tests pin the level math (the existing cases computed the expected
layout independently and passed on Windows for the wrong reason) and
simulate the deployed layout with the install dir inside an allowed root.
New builtin job security-feed (daily) pulls the normalized advisory feed
from serverkit.ai/api/security-feed.json (a cached proxy of the repo's
published GitHub Security Advisories) and:
- notifies admins (security.alert, critical) when the running panel
version falls inside an advisory's affected range, once per advisory;
- fires a one-time post-fix reminder after an upgrade crosses a fix
boundary when the advisory declares a "## Post-upgrade actions" section
(e.g. rotate JWT_SECRET_KEY after GHSA-rm3m-9mvw-68fh).
Dedupe keys, the last-good feed copy, and the previously-seen version live
in SystemSettings, so restarts never re-notify and outbound failures serve
the cached feed. Opt out via the security_feed.enabled setting. Privacy
page on serverkit.ai updated to disclose the daily call.
The 1.7.68 RBAC fix enforced files.read on read endpoints, but the default
viewer role legitimately has files.read=true, and on the documented
install.sh layout the panel install dir (/opt/serverkit) sits under the
allowed root /opt. A viewer could read the backend .env, steal
JWT_SECRET_KEY / SERVERKIT_ENCRYPTION_KEY, and forge admin sessions.
FileService now carries PROTECTED_ROOTS (the panel install dir and
SERVERKIT_CONFIG_DIR), checked inside is_path_allowed before the
ALLOWED_ROOTS test, so every file-manager operation (read, write, browse,
download, upload, ...) refuses panel-internal paths for every role.
Regression tests: protected paths rejected by is_path_allowed; viewer
read/download of the backend .env returns 403; allowed roots keep working.
Co-authored-by: CaptBoykin <CaptBoykin@users.noreply.github.com>
- install_profile_service: record what install.sh put on the box
(minimal/standard/full), recommended from detected hardware and
overridable; thresholds mirrored by recommend_profile in install.sh
- setup wizard: replace the hard ResourceGate/SetupStepTier with
SetupStepCapacity + SetupStepSecurity
- ResourceAdvisory: inline capacity warning at the point of action
(WordPress/Docker/Services/Deployments), backed by headroom.fits
- RequiresDocker component for docker-dependent surfaces
- install.sh/update.sh: profile detection + tests
(test_install.sh, test_update.sh, test_server_capacity.py)
Walks the AST of monitor_service and the monitors blueprint to assert neither
imports the plugin loader nor reaches for StatusPageService, and that the routes
register without the extension. This is the property the promotion exists to
provide — watching a site must keep working on a lean panel with
serverkit-status uninstalled — so it should fail loudly if it regresses.
AST rather than a source grep: the modules' own comments discuss the extension,
which a text match would trip over.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_maybe_attach_certificate throttled on last_check_at, which is the probe clock.
An active monitor is checked every 30s, so that condition was never older than
the 6h refresh window: the certificate would be read once and then never
refreshed, leaving a stale expiry date on the detail page forever.
Adds cert_checked_at (migration 081) and throttles on that instead. The stamp
is written even when the read fails, so an unreachable TLS endpoint is retried
on the same cadence rather than on every single probe.
Also serves the Monitors list sparkline from one bulk query per page instead of
one per row, bounded by a time window so a busy monitor's history cannot crowd
out a quiet one's the way a single global LIMIT would.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A StatusComponent was already a full synthetic check (http/tcp/dns/smtp/ping,
target, interval, timeout, HealthCheck history, uptime windows, auto-incidents)
— it was just framed as "a component of a status page" and its engine lived
inside the serverkit-status extension, so watching a site required publishing a
status page. Promote it:
- migration 081 makes status_components.page_id and status_incidents.page_id
nullable, and adds the columns backing the monitor UI (is_paused,
check_method, expected_status, keyword, follow_redirects, verify_tls, retries,
consecutive_failures, cert_issuer, cert_expires_at)
- new core app/services/monitor_service.py owns the check engine; the extension
keeps page publishing and delegates
- new /api/v1/monitors (CRUD, check, pause, history, uptime buckets, incidents)
- new builtin.monitor_check sweep on a 30s tick — until now checks only ran when
someone pressed "Check now", so a configured monitor never noticed an outage
Check-engine defects fixed while moving it:
- ping ignored the process return code and could only ever report 'up'
- ping used Linux-only flags with no guard, breaking the Windows dev server
- http ignored its own method/expected_status/follow_redirects/verify_tls
- smtp was documented on the column but unimplemented; keyword did not exist
- run_check never recomputed uptime and never opened or resolved an incident —
only the WordPress health-sync path did, so network monitors never paged
- TLS issuer/expiry are now captured (throttled to one read per 6h)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page kept a shape the rest of the app moved off: a four-card KPI
strip, its own in-body search input, a hand-rolled table, four icon
buttons per row, and an edit modal on row click.
It now matches /domains: the segmented filter carries the counts (no
KPI strip), SearchField lives in the top bar, the list is the shared
DataTable, and a row opens the DS Drawer. The top bar keeps its title
— /cron has no tab group, and the reference mock shows a titled bar
there; the body was what was stale.
Last run / Next run / Status columns need data list_jobs() never
returned: it skipped the schedule_human / next_run / run-tracking joins
that jobs_for_application() has done since plan 34, so the admin list
could only ever show enabled-vs-disabled. Apply the same enrichment,
with tests covering the schedule join, the last-run join, and the
no-history case.
The drawer's run history uses GET /cron/jobs/<id>/runs, which already
existed and only ScheduledTasksCard consumed. It's admin-only and
empty until a job carries the tracking shim, so both cases are stated
in the drawer (with an enable-tracking action) rather than rendering a
blank table.
Also fixes styles that never applied: .run-output-*, .spinner-inline
and the modal field overrides were nested under .cron-page, but Radix
Dialog and Sheet portal to <body>. Hoisted to top level.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- log_service: grep the service/unit filter with -F so names like
'nginx.service' are matched literally instead of as a regex.
- log_service: clamp the in-Python tail like 'tail -n' — lines=0 sliced
as [-0:] == all lines, negatives kept nearly everything.
- test_files_rbac: add files/upload and files/s3/upload to WRITE_CASES,
and disk-usage/disk-mounts/analyze/type-breakdown to READ_CASES, so the
permission gates on the full /api/v1/files surface are covered.
- create_app(): skip background daemons (queue consumers, metrics, job
system, analytics flush, linked-panel client) when loaded by a Flask CLI
one-shot such as 'flask db upgrade', or when SERVERKIT_SKIP_BACKGROUND=1.
They query the DB before migrations run, so any error there (corrupt DB,
pre-migration schema) aborted the migration and sank the whole update.
- update.sh: set SERVERKIT_SKIP_BACKGROUND=1 for both flask db upgrade
invocations as an explicit contract.
- update.sh: fail fast with an actionable message when the SQLite slot copy
fails PRAGMA integrity_check, instead of dying mid-boot with 'database
disk image is malformed'.
- update.sh: rebuild the release tarball's prebuilt venv when its baked
VIRTUAL_ENV path resolves to the old slot (it is baked as
/opt/serverkit/venv), which previously ran new code on the old slot's
dependencies during migration. Resolved-path comparison keeps the fast
prebuilt path for fresh installs.
Follow-ups from the GHSA-4wqh-7f4f-5qmx / GHSA-mc93-rc3x-fpgq review:
- File Manager read endpoints (browse/info/read/search/disk-*/analyze/
type-breakdown/download + s3/browse, s3/read, s3/download-url) only
required a valid JWT, so a user whose files.read permission is revoked
via custom permissions could still read and download files. They now
require permission_required('files', 'read'), matching the write side.
- LogService._read_syslog() interpolated the unit name into a bash -c
string using subprocess.list2cmdline(), which is cmd.exe-style quoting
and does not stop $(...)/backtick expansion under bash. grep now runs
as a plain argv list (with --) and the tail happens in Python.
Reachable only on non-systemd hosts by admins, but the same bug class
as the Postfix advisory.
Regression tests: revoked files.read 403s every read endpoint; the
syslog filter never invokes a shell and passes payloads verbatim.
Mutating File Manager endpoints (write/create/mkdir/delete/rename/copy/
move/chmod/upload and the S3 equivalents) only required a valid JWT, so a
viewer account (files.write=false) could write and delete files. They now
require the files.write permission via permission_required(). Read
endpoints are unchanged. Adds regression tests covering both advisories.
Co-authored-by: AruvasgaChithan <AruvasgaChithan@users.noreply.github.com>
The hostname parameter was concatenated into a bash -c string executed as
root via run_privileged(). Validate the hostname against a strict charset
and pipe debconf selections via stdin so no user input ever reaches a
shell command line.
Co-authored-by: tonghuaroot <tonghuaroot@gmail.com>
Six new sections cover the pages rebuilt this round — the dashboard as it ships,
its edit mode, the widget library, the widget editor, the databases explorer and
the engine catalog drawer. 31/31 sections render.
The capture is a render smoke test, not just a picture, and it earned that here
by surfacing two real bugs that no test could see:
`SearchField` merges its `className` onto its OWN root, so `.skw-lib__search`
IS the `.sk-searchfield` element rather than a wrapper around one. The rule
styling it as a parent matched nothing, leaving the shared `flex: 1 1 240px`
in force — and inside a column-direction filter bar that 240px basis is a
HEIGHT. The widget library's search box was rendering 240px tall.
`GET /auth/me` answers `{user: {...}}`, but Databases read `.role` straight off
the envelope, so `isAdmin` was always false. Every admin got the read-only
console — and with the DDL builder now shipped, would never have seen the button
that runs a CREATE TABLE.
Also carries `postgresql.yaml`, which I left out of the extensions commit.
Without `engine.extensions: true` stock PostgreSQL is not a host for pgvector at
all; the extension tests passed only because the working tree had the change the
commit did not.
Fixture note for whoever edits these next: `/metrics/history` points are NESTED
(`cpu.percent`, `load['1m']`) and carry no network data, `/servers/available` is
a bare array, and `DeployPill` queries `/deployment-jobs` twice and sums — a
single unfiltered payload makes it claim eight running deploys.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`GET /databases/engines/<app_id>/extensions` carried only `@jwt_required()`, so
any signed-in user could enumerate any instance on the panel — the image it
runs, its databases, and what it can load. All facts about someone else's
application.
It gates on `can_access_app` now, mirroring the per-app docker DB read next to
it: owner, admin, or an explicit grant. Read access rather than admin-only is
deliberate — a developer who can already see an app should be able to see what
that app can load. The POST was already admin-gated and is unchanged.
The file also declares its `# Bucket: PER-APP` header (plan 29 #9), which is
what the structural sweeps read to know a per-app route file has been thought
about at all.
Found by the repo's own authz sweeps in a full-suite run — four failures, one
cause. Worth noting they fired on a route that has no feature test capable of
catching this: every functional test authenticates as someone entitled to the
data. That is precisely what a structural sweep is for.
Four tests pin the behaviour: denied without access, still visible to the owner,
404 rather than 403 for an app that does not exist (conflating them would make
the endpoint useless to its own caller), and 401 anonymous.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The third deferred item. An extension installs INTO a running engine rather than
getting its own container, so it lives in its own namespace:
`backend/templates/extensions/<id>.yaml` with a top-level `extension:` block,
discovered by the same operator-wins two-directory scan the app templates use.
Dropping a YAML in makes it available with no code change, and a test pins that.
Not nested inside the engine template, because pgvector applies to any
PostgreSQL-speaking engine rather than to `postgresql.yaml` — nesting would force
duplication per host. Not a third `kind:` in the app catalog either: an
extension has no compose, port or volume and produces no Application, so every
consumer of `list_all_templates` would have had to learn to skip it.
The hard part is not the SQL. `CREATE EXTENSION vector` is valid and fails on
stock `postgres:16` with "could not open extension control file", because that
image does not ship the library. Two layers, and the statement is never sent
speculatively: an extension declares which images provide it, so a listing marks
an instance unsupported BEFORE anything is clicked; then at install
`pg_available_extensions` is queried first and a negative answer is a 409 with a
remedy, not an attempt. The live probe overrules the image name in both
directions — a private `registry.internal/ops/pg-with-vector` is allowed, and a
`pgvector/pgvector` tag that does not actually carry the library is still
refused.
ServerKit does not recreate a container behind the operator's back. The remedy
instead names an engine template that IS capable, found by matching the
extension's images against each engine template's compose — hence
`postgresql-pgvector.yaml`. TimescaleDB deliberately ships no bundled host and
says so rather than implying one exists.
Compatibility also needed correcting mid-build: matching on wire protocol alone
claimed CockroachDB could host pgvector. It answers on pgwire and cannot load a
single extension. Engines opt in with `engine.extensions: true`, which excludes
it without any Python naming it.
Two fixes fell out. `DatabaseService` had no psql twin of `docker_mysql_execute`
— `managed_db_user_service` carried a private copy with a comment saying as
much; there is now one implementation and it delegates. And that twin sets
`ON_ERROR_STOP=1`, without which psql reports SUCCESS for a statement the server
rejected.
57 tests, covering the zero-code-change property, compatibility, the image
problem in both probe directions, admin gating, and refusals that must not
execute anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The engine templates were written from upstream docs and reviewed carefully, but
only ever read. Every one has now been brought up for real, authenticated
against, and torn down.
CouchDB was broken. A fresh node never created its system databases: `/_all_dbs`
answered `[]`, `_users` did not exist so no non-admin account could ever be
created, and it error-looped on "Missing system database _users". The image
reads only COUCHDB_USER/PASSWORD/SECRET/ERLANG_COOKIE — both
COUCHDB_COUCHDB__SINGLE_NODE and ERL_FLAGS are rejected as unknown_config_value
— so `single_node = true` ships as a local.d config file instead.
Meilisearch and Valkey advertised versions they do not install (1.0 vs the 1.12
that boots; 8.0 vs 8.1.9), caught by comparing the catalog header against what
actually reported for itself.
The method mattered more than the count. Probing auth from INSIDE the container
is not a test and would have passed two engines that were never checked:
postgres initdb writes `host all all 127.0.0.1/32 trust`, so a deliberately
wrong password succeeds on loopback, and the ClickHouse client reads
CLICKHOUSE_PASSWORD out of the container's own environment. Re-run from a remote
source address, both correctly refuse. Every auth assertion here was made from
off-host.
Nine tests cover what can be checked without Docker, led by the one that matters
most: the generated admin password must actually REACH the image. Mongo,
ClickHouse and CouchDB all start happily with authentication disabled when the
password is generated but never passed — silent, and indistinguishable from
working. Also: every ${VAR} resolves, no port binds all interfaces, the catalog
port matches the published one, the declared version is the tag installed, and a
config file can never be rendered over the data volume's mount point (which
would delete persistence without a word).
Verified by reverting each fix and confirming the tests fail with the right
message, then restoring. 67 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MariaDB 11 dropped the `mysql` symlink in favour of `mariadb`. Every place that
execs a client inside a database container hardcoded `mysql`, so introspecting
any modern MariaDB image failed outright:
OCI runtime exec failed: exec: "mysql": executable file not found in $PATH
Which is not a hypothetical image. `backend/templates/mariadb.yaml` — the engine
template shipped earlier in this same plan — pins `mariadb:11.4`. Installing
MariaDB through ServerKit produced a database ServerKit could not open, and the
failure surfaced in the tree as a generic "couldn't load".
The client is now resolved per container by probing `--version`, preferring
`mariadb` and falling back to `mysql`, memoised because the answer cannot change
while a container runs. When neither answers it keeps returning `mysql`, so an
unrelated failure still reports the error operators already recognise.
Found by running a real mariadb:11.4 container against the service rather than
trusting the code path; verified end to end (SHOW DATABASES, list databases,
list tables with row counts) before writing the fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The board carried two headers: a host identity strip (hostname, live dot, IP,
kernel, uptime, clock) and, under it, the board tabs and controls. Two rows of
chrome before any actual dashboard, and the strip was fixed — you could not
move it, size it, or point it at a different machine.
It is one row now: board tabs left, then the server picker, range, refresh,
TV and Edit. The server switcher moved into that row as a labelled `server`
pill, and still collapses to a plain readout when there is only one host.
Nothing was lost, it just became placeable. IP, kernel and uptime are the Host
details widget — which can be resized and pointed at any server, unlike the
strip. And the time is a new Clock widget: add it if you want it, put it on a
second timezone if that is what you need, leave it off if you do not. It ticks
locally rather than polling, because re-fetching every second to print a number
the browser already knows would be silly.
Also fixes a regression from the previous commit: making the spark row flexible
let it compete with the value for height, and centring then pushed the number up
out of the tile. The reading is the point of a stat widget, so it is pinned
first and the chart takes what is left.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The widget editor was a cramped fixed panel wedged against the right edge, with
inputs that rendered as bare white boxes because the field class sat on a
wrapper div while the stylesheet styled it as though it were the input. It is
now a wide drawer: settings on the right, a LIVE PREVIEW of the actual widget
on the left, sized from its real grid geometry so the size steppers visibly
move it. Stacks below lg. Built on the shared Drawer, which also makes the
floating AI bubble hide itself — it already hides for any open dialog, and the
old bare <aside> was exactly why it did not.
Every tile read "Stat". Seeded widgets carry no title, so the frame fell back
to the widget TYPE and a board of four different stats said "Stat" four times.
Titles are now derived at render time from what the widget points at — CPU
usage, Memory, Applications, "CPU usage & Memory" for a two-series chart —
falling back to the type name only when nothing better is known. Deliberately
derived rather than written into stored config: a title the user never set has
no business appearing in their saved board.
Boards already in the database are repaired on read. Fixing the shipped
defaults only ever helped users who had never opened the dashboard, because
seeding never runs again for anyone else — so `metrics` becomes `series`,
`actions` becomes `items`, dead shortcut keys are aliased or dropped, and an
empty shortcut list falls back to the shipped four instead of rendering "No
actions selected". The repair works on a deep copy: a JSON column mutated in
place looks unchanged to SQLAlchemy, so the commit would expire the instance
and silently reload the originals.
The log widget stopped guessing. With nothing configured it grabbed whatever
the log listing returned first — typically a system file the panel cannot read
— so a fresh dashboard greeted people with "sudo: a password is required". It
now asks you to pick a source, and phrases a permission failure as a setup
fact rather than quoting the shell.
Also deletes ~300 lines of stylesheet for the inspector that no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thirty-five of the fifty-two class names the widget renderers emit had no rule
anywhere. The renderers kept the design mock's flat names (`skw-stat-v`,
`skw-feed-item`, `skw-bar-row`) while the stylesheet was written in BEM
(`skw-stat__value`, `skw-topn__row`). Both files compiled, linted and built
clean the whole time — the styles simply never reached the markup.
So the activity feed rendered as raw text, host details ran their labels into
their values ("HostnameDESKTOP-8ELA366"), stat values wrapped mid-tile, and the
time-series chart lost its height constraint and drew as stray vertical spikes
against an empty box. Renamed the JSX to match, and wrote the blocks that were
missing outright: the feed, quick-action rows, alert body, and distinct
loading-vs-error empty states.
The seeded boards were wrong in the same way, one layer down. `timeseries`
seeded `metrics` while the renderer reads `series`; `actions` seeded `actions`
while the renderer reads `items`, with shortcut keys (`new-app`, `deploy`) that
no route resolves. Both fail silently — the widget just renders empty, which is
what every new user saw on their first dashboard. `logs` and `status` were
seeding keys nothing reads either.
Two tests now pin that coupling: seeded configs must use keys the renderers
actually read, and every seeded shortcut must resolve to a real route. The
table in the test is an explicit mirror of renderers.jsx, and says so.
Also: seeded widgets carry real titles again — "CPU usage", "Applications",
"Hardware specs", following the old dashboard's panel names — instead of every
tile falling back to its widget type and reading "Stat". And "Local (this
server)" is now "This server", which fits a 3-column tile on one line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`MigrationService.get_status()` had no tests, and it had just grown a third
state: a database stamped at a revision this build does not contain. That state
previously rendered as "80 pending updates" — every revision in history — behind
a Continue button that could only fail.
Covers up-to-date, genuinely-behind, and orphaned, plus the two edges worth
nailing down: the orphan flag has to survive the HTTP response because the
screen keys its warning off it, and a database with no revision at all is a
fresh install rather than a stranded one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An install a long way behind head listed all 80 pending migrations at full
size, so reaching the Continue button meant scrolling past a page of revisions
nobody reads. Show the newest four — the ones that say what you're actually
getting — with the rest behind "Show all N", and let the expanded list scroll
inside itself so the button stays put no matter how far behind you are. Panel
and row padding tightened along the way.
The 80 was also a lie, and that turned out to be a real bug. Pending migrations
are computed by walking history backwards and breaking when the walk reaches the
database's current revision. If that revision isn't in the chain at all — a
branch that added a migration and was then reverted, leaving the database
stamped at a file that no longer exists — the walk never breaks and reports
every revision as pending. `upgrade` would then fail with "Can't locate
revision" anyway.
So detect it: an unknown current revision is reported as `orphaned_revision`
instead of a fabricated backlog, the screen explains what happened and prints
the `flask db stamp` that fixes it, and Continue is disabled because there is
nothing it could successfully do.
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>