serverkit-extensions#3 published the signed first-party catalog and
restored serverkit-redis (re-pinned to the rebuilt v1.0.1 asset). The
bundled mirror already carried the signatures; this picks up the
serverkit-redis entry so test_matches_published_index agrees with main
again. Generated by scripts/sync-registry-index.sh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rollback guard only caught ValueError from field validation, but
db.session.commit() itself can raise SQLAlchemy errors (IntegrityError,
OperationalError), leaving callers a dirty session despite the comment's
promise. Catch everything, roll back, re-raise. (Copilot review on #120)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The backend stores an unset service_name as null; spreading the API
response over the defaults pushed that null into a controlled <Input>,
flipping it uncontrolled. normalizeScalePolicy now coerces strings and
integer fields back to form-safe values. (Copilot review on #120)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
586f556b/025fa0be deliberately moved template icons off inline data URIs
onto https://serverkit.ai/imgs/template-icons/ (proxied from the
serverkit-templates repo), but test_roadmap_template_conventions still
pinned the old offline-inline contract, failing all 41 hosted roadmap
templates in CI. The contract now accepts the registry-hosted icon URL,
keeping the inline data-URI form only for templates with no public logo
(prompture-hub).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Harness: bootstrap find/awk in distro-compatibility.py (AL2023,
Tumbleweed, SLES BCI minimal images lack them; the source-level
suites legitimately use both)
- Python gate 3.11-3.14: Arch/Manjaro rolling ship only python 3.14
with no versioned fallback packages (same trap as Debian 13/#99);
align scripts/update.sh, which had drifted to a 3.11-3.12 gate
- Gentoo: add emerge as a supported installer package-manager path
(install.sh, scripts/lib/pkg.sh, distro.py family, deps manifest);
provision via slotted dev-lang/python:3.13 -> 3.12
- Tests: move out-of-range stubs above the new ceiling, shadow all
locate_python candidate names (real python3.14/3.13 in Manjaro/
Gentoo images were winning over fixtures), cover the 7th manager
- Catalog: Gentoo is no longer a documented expected failure
Verified: test_lib 32/32, test_install 124/124, test_update 76/76,
pytest test_serverkit_installer 46/46, and all 6 previously failing
images now pass both quick and provision container probes (12/12).
The seven /connections/providers operations shipped in 939c77b6 without
the surface pin being regenerated; the diff is exactly those routes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_wordpress_ha_foundations constructed Application(...) by hand, which
the seed-one-door ratchet refuses for new files. The factory also seeds
the owning admin, so the hand-rolled User goes too.
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>
The drawer showed '<name>.<base> — Published automatically once the deploy
finishes' whenever a managed base domain existed, but publishing was gated
on a per-template auto_domain flag the operator can't see. For most of the
catalog the install landed on host:port instead — a link a default firewall
doesn't even expose — and the domain had to be redone by hand in the
service's Domain settings.
The promise on screen is now the contract: when the drawer displays a
domain it sends auto_domain: true, which rides the install plan to the
finalizer and overrides the template's silence (the forced flag also feeds
the SERVICE_FQDN magic context, so tokens and the published host agree).
Absent flag = the template YAML still decides, as before.
The drawer also stops promising what can't be delivered: the domain field
only renders for compose templates targeting the local server — the panel's
nginx can't proxy a remote container, and the repo pipeline has no
auto-publish step.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The template fit check measured ONE filesystem (the panel data path) and
declared "This server doesn't have room for it" in red while an attached
volume sat with plenty of free space — more disk than the check admitted
the box had. Local headroom now also reports the other real mounted
filesystems (host_inventory_service.enumerate_filesystems), and when the
shortfall is on disk and another writable filesystem could hold the whole
footprint, the verdict downgrades to tight and names the volume so the
operator can point the app's data there. Memory shortfalls stay red — a
volume holds files, not processes.
Also: "more disk than it has" -> "more disk than it has free", since the
number shown is free space, not the box's total.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The VS Code-style bottom statusbar (z 1130) was added above the old modal
stack (40/50/60), so any full-height drawer or dialog reaching the bottom
edge had its footer buttons painted over — e.g. Deploy/Cancel in the
template deploy drawer. Modals own the screen: raise backdrop/modal/tooltip
to 1140/1150/1160, keeping their internal order and leaving toasts and the
app loader on top.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to 025fa0be: keep serverkit.ai as the stable icon host rather
than jsDelivr. The registry repo remains the canonical home of the logo
files; serverkit.ai proxies /imgs/template-icons/ from its raw tree, so
these URLs now resolve for all 210 logos instead of only the legacy
subset the website bundles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The offline template bundle carried the same generic stroke-icon data
URIs the registry had, and even the templates with real icons pointed at
serverkit.ai, coupling icon availability to website deploys. Icons now
live in the serverkit-templates repo itself, so reference them through
its jsDelivr mirror. Also fixes pgvector/timescaledb extensions, which
referenced a postgresql.svg that never existed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The es/pt/zh-CN READMEs had drifted from the English one: neither carried
the Uneed POTD3 badge, and none had the new Docker Hub pulls badge. Adds
both in the same positions, leaving the intentionally translated demo
label and the ../LICENSE relative path untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GasJpPFj8JmAV7QCJhCG52
The stats row showed GitHub release downloads (1.9k) but nothing for the
Docker image, which is the larger distribution channel (5.4k pulls on
jhd3197/serverkit). Adds a shields.io docker/pulls badge next to
Downloads, linked to the Hub page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GasJpPFj8JmAV7QCJhCG52
flask-limiter picks RATELIMIT_STORAGE_URI up from app.config at init_app,
so surfacing the env var in config (only when non-empty) lets production
deployments share limit state across processes and silences the in-memory
storage warning. Unset keeps in-memory storage, matching the deliberate
single-worker gunicorn design. Documented in .env.example with probe and
mechanism tests.
- run wildcard/SAN certbot privileged and fail on non-zero exit instead
of reporting unissued cert paths as success
- cloudflare DNS-01 credentials: unpredictable mkstemp file, 0600,
always deleted (was predictable /tmp path, never cleaned up)
- route53 credentials via sudo env A=B (sudo env_reset strips env= kwarg)
- validate domains before they reach certbot argv or cert file paths
- validate reverse-proxy builder input (domains, upstreams, headers,
locations, rate-limit/cache values) against nginx config injection
- write_vhost: refuse non-plain-filename site names and roll back the
vhost when the reload itself fails, not only when nginx -t fails
- create_proxy API returns 400 on service errors instead of always 201
Pin the Compose template's full-tree wordpress_html named volume as the
supported same-host shared-state contract (uploads, plugins, themes, disk
page cache), and make WordPress vhost publishing install shared
wp-login.php/xmlrpc.php limit_req zones keyed by server name + client IP.
Generic Docker vhosts are unaffected; drift repair regenerates existing
WordPress vhosts with the policy. Spec and roadmap rows move from Partial
to Shipped with their real-host boundaries recorded.
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.
test_upgrade_head_matches_models failed in full CI runs with
"table ext_testext_thing: in models but never created by any migration".
Collecting test_extension_platform.py registers its synthetic plugin
model on the shared db.metadata for the whole process, and the builtin
serverkit-analytics extension leaves the same footprint when imported.
Extension tables (ext_<slug>_* per extension_lifecycle) are created at
plugin install and dropped on uninstall --purge - core Alembic
migrations never touch them - so the drift comparison now ignores the
whole ext_ namespace on both sides instead of chasing individual names.
Verified: test_extension_platform.py + test_migration_schema_drift.py
in one process (the failing CI condition) - 12 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PReuKGW6G4bac931Xxn2q
The plan 82 SE real-binaries leg failed in CI because the wrapper conf
did not stand in for what a real deployment provides: nginx -t opens
every access_log for writing (/var/log/nginx is not writable by the CI
user), `include fastcgi_params;` resolves against the conf prefix (the
tmp dir under -c), the micro-cache vhost references http-level zones
that ship as a conf.d snippet, and this nginx build's -t also binds the
listen sockets, so privileged ports fail next.
The harness now localizes /var/log/nginx, /var/cache/nginx and the
listen ports into tmp_path, writes the system fastcgi_params (or a
stub), and includes the real MICROCACHE_ZONE_SNIPPET at http level with
its cache-path parent pre-created (nginx mkdirs only the leaf). Only
path strings and port numbers are swapped - every directive still goes
through the real parser, and the garbage-rejection canary still fails.
Verified in WSL as uid 1001 against nginx/1.24.0: 8 passed under
-m real_binaries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PReuKGW6G4bac931Xxn2q
OpenAPIService.generate_spec() existed but nothing snapshotted it, so a
route rename/removal/method change shipped invisibly to API-key
consumers, the CLI, and agents. docs/API_SURFACE.md now pins the sorted
METHOD /path inventory (1418 operations) with the same
regenerate-and-review contract as docs/MIGRATION_INVENTORY.md: an
intentional surface change regenerates the doc in the same commit
(SERVERKIT_UPDATE_API_SURFACE=1 pytest
tests/test_api_surface_inventory.py) so the diff is the review.
Method+path only on purpose — summaries and schemas churn without
breaking anyone. Named *_inventory so preflight's globs run it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The panel's crontab/nginx code only ever met the fake_subprocess stub —
the 8-distro matrix runs stubs and the container legs run 12 detection
tests, so a syntax the real parser rejects failed on a user's box, not
in CI (the #117 class).
- new `real_binaries` marker (registered in conftest); the tests also
gate on SERVERKIT_REAL_BINARIES=1 so ordinary suite runs never mutate
a machine's crontab by surprise. Local loop (WSL works):
SERVERKIT_REAL_BINARIES=1 pytest tests -m real_binaries
- test_real_crontab_roundtrip: add -> list -> toggle -> remove through
the real `crontab -`/`crontab -l`, operator lines preserved, name
injection blocked; user crontab saved/restored around each test
(verified green on WSL against the real binary)
- test_real_nginx_vhost_syntax: every render_site_config flavor through
a real `nginx -t` in a tmp wrapper conf, plus a garbage probe so the
harness can't go vacuous
- backend-ci gains a real-binaries job (ubuntu runner, apt nginx+cron)
- scripts-ci gains the probe-matrix job: scripts/test/probe-matrix.sh
(real ufw/nginx/firewalld in 4 container shapes) existed since plan
75 but was wired into NO workflow; the ubuntu-root-sbinless leg was
re-verified green locally before wiring
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
version and pool_name flow from the URL straight into a filesystem path
handed to privileged write/rm — a traversal pool_name could delete an
arbitrary *.conf via run_privileged(['rm', ...]), and a hostile version
escaped the pool.d directory the same way. Both are now validated at
the service door (create/delete/get share the helpers).
_parse_pool_config returned {} on any read error, so an unreadable
pool listed as "user: www-data, pm: dynamic" — fabricated defaults.
It now returns None and the listing reports the honest unknown
(user/listen/pm null + unreadable: true), per the probe-honesty rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The jail filename derives from the site name at enable time, so a site
renamed afterwards orphans its jail: disable_jail derives a path that
no longer exists (honestly reports removed: False) while the stale
jail keeps banning against a log nginx no longer writes — invisible
to every name-derived lookup.
- list_jails() scans JAIL_DIR for serverkit-*.conf and parses the site
label + logpath out of the rendered header, the only view that still
sees orphans
- cleanup_orphans(active_keys) removes ServerKit jails no live key
derives any more (operator files never touched), reloading once
- the _sanitize collision (my.site / "my site" -> one shared jail) is
pinned as documented behavior: changing the derivation would orphan
every existing install's jail files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drift byte-compared the live vhost against the bare template render,
but an app with an enforcing WAF policy carries the post-render
include — so every WAF'd app read as permanently drifted, and one
"repair" click regenerated the vhost without the include: silently
unprotected while the DB still said enforcing.
- the injector is now a text-level function (inject_include_text) so
the live wiring and drift's expected render share one implementation
- _nginx_render_expected injects the include for enforcing policies,
making a correctly-wired vhost compare clean
- _nginx_repair re-runs WafService.apply after regenerating, so a
genuine repair re-wires protection instead of stripping it
Basic auth (WordPress-site-scoped) and the analytics tracker
(plugin-scoped) have the same erase-on-regen exposure and are tracked
in plan 82 §H — their state lives outside the app row this check walks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tests/subprocess_stub.py is the shared stubbing kit — hard-failing
UnscriptedCommand, sudo/basename normalization, sbin-guard still in
force — but adoption stalled half-way: 26 sites across 9 test files
still monkeypatch subprocess by hand, execs the runtime guard never
sees. Every other convergence here has a census + ceiling; this one
did not. Same contract as the rest: AST-counted over tracked files,
ceiling 26, may only go down; patching a service door
(run_privileged etc.) is the blessed pattern and not counted.
Preflight picks both files up by its existing globs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test suite's schema comes from db.create_all() (see conftest), so
none of the 93 alembic revisions had ever been executed by a test — a
model column with no migration keeps every test green while every real
upgrade dies with "no such column". New test runs `flask db upgrade`
against a throwaway sqlite file in a subprocess and diffs the migrated
schema against db.metadata in both directions.
First run caught real drift: migrations 059/065/068 create columns
(events_json, management_mode, allow_agent_update_observed) that the
models had lost in the recovery rebuild — restored in the previous
commit, which also brought two 500ing survey routes back to life.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migrations 065/068 have always created servers.management_mode and
servers.allow_agent_update_observed, but the model fields,
MANAGEMENT_MODES, is_managed, and agent_registry.observed_blocked_count
were lost in the 2026-07 data loss — POST /management-mode and
GET /observed-status raised AttributeError, and a mode flip that got
past validation would land on a transient Python attribute and
evaporate. test_survey_api carried skip-stubs documenting the hole.
Restored: the two columns (specs mirror the migrations), the mode
constant and is_managed property, to_dict emission, and a minimal
observed-block counter on the registry. The four skip-stubs are now
real tests, including one proving the flip persists across sessions.
The dispatch-level Observe guard remains hollow and is tracked in
plan 82 §H. events_json on notification_preferences is likewise
re-mapped (column carried by migration 059; its resolution surface is
still lost) so pre-loss override data survives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nothing verified that the ~1200 URL literals in
frontend/src/services/api/*.js resolve to rules in the backend url map,
so a backend route rename surfaced as a dead button found by a user.
New test statically extracts every this.request() literal (mirror of
test_socket_contract.py's cross-language pattern) and asserts each
matches an /api/v1 rule from the session app's route_rules; paths under
a builtin extension's plugin.json url_prefix are excused only while
that manifest still declares the prefix, and a second test keeps the
allowlist from rotting.
First run caught 78 unresolved paths. Extension-owned families
(email/ftp/cloud/status/tunnels) were excused; the rest were dead
frontend code calling routes that no longer exist, removed here:
- 11 workflow wrappers in apps.js (the workflow engine left the
backend in v1.7.33; zero callers remained)
- notification catalog/org-defaults wrappers (routes never existed;
zero callers)
- 6 WordPress standalone lifecycle wrappers (no routes, no callers;
the live status probe stays, allowlisted as extension-provided)
Frontend suite still green after the deletions (141/141, lint clean).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The frontend's 141 node --test assertions were invocable but invoked by
no workflow — written insurance never cashed in. frontend-ci now runs
npm test after lint (verified green locally: 141/141 in ~2s).
release.yml's ci-gate covered backend-ci only, so a release could ship
with a red frontend or drifted builtin extensions. The gate now also
requires frontend-ci and extensions-ci (both fast, both already
workflow_call-able). scripts-ci stays ungated on purpose — its
update-e2e leg takes ~45 min — with a comment showing how to add it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Locks in the round-trip suites added for the #117-class fixes (ssh
keys, env export/import, waf vhost injection, ufw parser parity,
postfix maps) plus tests accumulated since the floor was last set.
Measured with SERVERKIT_CLEAN_COLLECT=1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
add_domain deduped with a raw substring test, so with sub.example.com
already mapped, adding example.com was a no-op that still reported
success and mail for the new domain never routed. remove_domain
filtered alias lines with a bare `@domain in line` test, which also
deleted example.com.br entries. Appends went through tee -a with no
trailing-newline guard, so a truncated map glued the new entry onto
the last line, corrupting both and making them unremovable.
- add_domain dedupes on the line's domain field
- remove_domain drops only lines whose address tokens are at exactly
that domain (com.br superstrings and sub.example.com both survive)
- map appends heal a missing trailing newline first
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_get_ufw_rules and _ufw_numbered_rules each parsed `ufw status numbered`
with their own regex, and the listing's omitted LIMIT — a `ufw limit
22/tcp` rule was invisible in the panel's rule table while the
SSH-removal guard still counted it, so delete-by-number targeted rows
the user could not see. Both paths now match with the shared module
_UFW_NUMBERED_RE, so the table and the guard always agree row for row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_inject_include inserted after the first "server {" in the vhost. On an
SSL site the first block is the HTTP->HTTPS redirect from
SSL_REDIRECT_TEMPLATE, so ModSecurity was wired into a server that only
issues 301s while the real HTTPS server stayed unprotected — and
apply() still reported wired: True. The micro-cache injector already
guards this exact trap; the WAF include now does too: redirect-only
blocks (a 301 return with nothing served) are skipped and the include
lands in the first block that actually serves the app.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
export_to_env_format escaped backslashes and quotes that parse_env_file
never unescaped, so every export -> import cycle grew literal
backslashes in the value; newline values were written as raw multiline
quotes the parser mangled; and the user-supplied description landed as
a raw comment line, so a newline in a description injected brand-new
KEY=value pairs on re-import.
- exported values now escape \, " and newline onto a single line, and
the double-quoted import path decodes exactly those three sequences
(unknown sequences like C:\path stay as written)
- descriptions are whitespace-collapsed before being written as a
comment, the same hardening the cron #117 marker got
- a None value exports as empty instead of raising TypeError
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>