Skip to content

Running the Control Plane

The control plane is a standard Flask application. All commands below assume your working directory is CTF_Saas_CTRL_Pane/ inside the repo root.


Prerequisites

  • Python 3.12+
  • uv (used for all Python tooling in this repo)
  • The repo root must be reachable two directories above CTF_Saas_CTRL_Pane/ (i.e. the standard checkout layout). The app's __init__.py inserts the repo root onto sys.path at boot so that import provisioner resolves — see the note below.

Development server

cd CTF_Saas_CTRL_Pane

# Option A: Flask development server (auto-reload, debug mode)
uv run flask --app ctrlapp run --debug

# Option B: gunicorn (matches production behaviour)
uv run gunicorn wsgi:app

wsgi.py is a one-liner: it imports create_app from ctrlapp and calls it. Both entrypoints reach the same create_app factory.


Environment variables

Copy these into a .env file (never commit it) and load it with set -a; source .env; set +a (or a tool like direnv).

Env-file layout: test vs production

The repo convention separates environments by file, and only .env.example is tracked (a local .gitignore ignores .env and .env.*):

File Stripe mode Loaded where Notes
.env.example never (template) the tracked placeholder; copy it to start
.env TEST (sk_test_…) local dev / CI what source .env loads; safe for scripts/live_smoke.py
.env.production LIVE (sk_live_… / rk_live_…) production host only do not load locally or bootstrap against it

Run the app on a restricted key (rk_…, least privilege); only the one-time scripts/stripe_bootstrap.py run needs the full sk_… secret key. On the production host, load .env.production via systemd EnvironmentFile= or a secrets manager rather than baking values into an image.

# Flask
SECRET_KEY=change-me-in-production
BASE_DOMAIN=ctfhive.us          # apex domain for tenant FQDNs

# Public site origin (canonical links, sitemap, Stripe success/cancel defaults)
PUBLIC_BASE_URL=https://ctfhive.us

# Stripe — fill in the keys; run scripts/stripe_bootstrap.py for the rest.
# See Control Plane → Billing & Stripe webhooks for the full setup walkthrough.
STRIPE_SECRET_KEY=sk_live_…
STRIPE_PUBLISHABLE_KEY=pk_live_…
STRIPE_WEBHOOK_SECRET=whsec_…       # from stripe_bootstrap.py
STRIPE_PRICE_SOLO=price_1…          # from stripe_bootstrap.py
STRIPE_PRICE_STANDARD=price_1…      # from stripe_bootstrap.py
STRIPE_PRICE_PRO=price_1…           # from stripe_bootstrap.py

# Provisioning audit chain
PROVISION_AUDIT_SECRET=change-me-in-production

# Linode (leave empty to use FakeExecutor / dry-run mode)
LINODE_API_TOKEN=
LINODE_REGION=us-east           # default
LINODE_PLAN=g6-standard-2       # default
LINODE_IMAGE=linode/debian12    # default

Safe defaults: Every variable has a development-safe fallback in ctrlapp/config.py. The app boots without any environment set; Stripe checkout returns checkout_not_configured and Linode provisioning uses the in-memory FakeExecutor (no real server created).

Never use the defaults in production

SECRET_KEY defaults to "dev-insecure-change-me". PROVISION_AUDIT_SECRET defaults to "audit-dev-secret". Both must be set to strong random values before exposing the app to the internet.


Marketing-site SEO

The marketing templates render SEO metadata from PUBLIC_BASE_URL, so setting that variable correctly is the only required step:

  • Per-page meta — every template sets a unique {% block title %} and {% block description %}; base.html emits the description, a canonical link, Open Graph + Twitter Card tags, theme-color, an Organization/SoftwareApplication JSON-LD block (with the three paid plan offers), and a favicon link.
  • GET /robots.txt — allow-all, disallows /login and /register, and points crawlers at the sitemap.
  • GET /sitemap.xml — lists the public pages (/, /pricing, /about, /contact, /docs/) with absolute URLs built from PUBLIC_BASE_URL.
  • /login and /register send robots: noindex, nofollow.

Place real og-image.png and favicon.ico assets in CTF_Saas_CTRL_Pane/ctrlapp/static/ (referenced but not shipped — they 404 harmlessly until added).

The docs site (this MkDocs build) gets its own canonical/OG tags via overrides/main.html and site_url; MkDocs auto-generates its sitemap.xml.


Serving the documentation (/docs)

The documentation you are reading is authored in docs/ and compiled to the repo-root site/ directory:

uv run mkdocs build --strict     # outputs to ./site

The control plane serves that compiled site at /docs/ via the docs blueprint (ctrlapp/blueprints/docs/). It serves the static MkDocs output directly — including the Material left-sidebar navigation (collapsible "drop-down" sections, like docs.ctfd.io) and the right-hand table of contents — with a use_directory_urls fallback so clean URLs like /docs/control-plane/billing-stripe/ resolve to the right index.html.

  • GET /docs308 redirect to /docs/.
  • If site/ has not been built, the route returns a clear 503 ("run mkdocs build") instead of a 500, so the app and test suite work either way.
  • Override the build location with DOCS_SITE_DIR=/path/to/site (e.g. to serve a CI artifact) without code changes.

In production the same static tree is served by Caddy under /docs (see ARCHITECTURE.md / CLAUDE.md §10); the blueprint is the self-contained, single-process equivalent for dev and minimal deploys.


Running the test suite

Tests live in CTF_Saas_CTRL_Pane/tests/ and use TestingConfig (pins STRIPE_WEBHOOK_SECRET="whsec_test", TESTING=True).

cd CTF_Saas_CTRL_Pane
uv run pytest -q

The suite (55 tests) covers marketing routes, pricing helpers, the provision service, Stripe signature verification, the stdlib Stripe client, the bootstrap script, SEO, and the full subscription webhook lifecycle. No external services are required — FakeExecutor is used throughout and every Stripe interaction is faked.

If you invoke pytest from the repo root rather than from CTF_Saas_CTRL_Pane/, add the path explicitly:

uv run pytest CTF_Saas_CTRL_Pane/ -q

The provisioner import bootstrap

ctrlapp/__init__.py contains this block near the top:

_REPO_ROOT = os.path.dirname(
    os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
if _REPO_ROOT not in sys.path:
    sys.path.insert(0, _REPO_ROOT)

The provisioner package lives at the repo root, two levels above ctrlapp/. Without this insertion, import provisioner raises ModuleNotFoundError when gunicorn or flask run loads the app, because neither adds the repo root to sys.path automatically. The guard is idempotent — if conftest.py has already inserted the path (as it does for pytest), the second insert is a no-op.

Consequence for deployment: the control plane must run from a checkout that includes the repo root (i.e. with provisioner/ as a sibling of CTF_Saas_CTRL_Pane/). It is not a self-contained installable package.


Containerising (sketch — Dockerfiles are stubs)

Dockerfiles are empty placeholders

CTF_Saas_CTRL_Pane/Dockerfile and CTF_Saas_CTRL_Pane/Dockerfile.dev are 0-byte files. They are not usable as-is. The following is a sketch of what a minimal production image would look like, not a claim that it has been built or tested.

FROM python:3.12-slim

WORKDIR /app

# Copy the entire repo so `import provisioner` resolves (see above).
COPY . /app

RUN pip install uv && uv sync --project CTF_Saas_CTRL_Pane

WORKDIR /app/CTF_Saas_CTRL_Pane

ENV PYTHONPATH=/app

CMD ["uv", "run", "gunicorn", "wsgi:app", "--bind", "0.0.0.0:8000", "--workers", "2"]

Key points:

  • Copy the whole repo (not just CTF_Saas_CTRL_Pane/) because the provisioner package must be importable.
  • Set PYTHONPATH=/app as a belt-and-suspenders alternative to the sys.path bootstrap.
  • Pass all secrets via environment variables at runtime; bake none into the image.
  • The dev image would add --reload to the gunicorn command and mount the source tree as a volume.