5 Commits
Author SHA1 Message Date
Juan Denis 6296887bad Remove internal plan citations from app code
Comments, docstrings and a few strings cited plan numbers ("plan 29
#9", "plan 86 §B2") from a git-ignored folder, so they pointed at
nothing; one reached users in a bottleneck hint. Code and frontend
strings are unchanged apart from those citations (checked by AST and
token comparison). A vhost still carrying the old asset comment reads
as undrifted, and a guard test keeps new citations out.
2026-10-10 15:50:45 -04:00
Juan DenisandClaude Opus 5 5f8a3ef204 feat(recycle-bin): applications are soft-deletable, and a delete stops keeping data hostage
Plan 70. Domain went through this and TEN read paths were wrong, four with
effects outside the panel. Application has 211 call sites across 68 files and
drives more -- container lifecycles, nginx, backups, registry pulls, the OS
crontab -- so it was audited in full first (docs/plans/70_...md). 180 sites are
now query_active(); the 35 left unfiltered are each commented in place.

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

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

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

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

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

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

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

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

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

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

The ones that reached the outside world:

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

And the ones that broke the feature itself:

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

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

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

Seven tests cover the leaks that had real consequences.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 09:27:05 -04:00
Juan Denis 6e32519f7e restore: remaining reconstructed July 5-7 changes (clean recovered files not attributable to one plan) 2026-07-07 20:29:31 -04:00
Juan DenisandClaude Fable 5 c3f356b6fb feat(cli): API-backed operator verbs and diagnostic support bundle
serverkit status / services / apps / doctor [--repair] / repair /
update / login-url / support-bundle. Break-glass auth mints a 10-minute
admin JWT in-process (audit-logged) — root on the box already implies
full control. The support bundle exports a secret-scrubbed zip of
versions, service states, setting shapes and recent job failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:21:22 -04:00