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__.pyinserts the repo root ontosys.pathat boot so thatimport provisionerresolves — 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.htmlemits the description, acanonicallink, 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/loginand/register, and points crawlers at the sitemap.GET /sitemap.xml— lists the public pages (/,/pricing,/about,/contact,/docs/) with absolute URLs built fromPUBLIC_BASE_URL./loginand/registersendrobots: 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:
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 /docs→308redirect to/docs/.- If
site/has not been built, the route returns a clear503("runmkdocs 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).
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:
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 theprovisionerpackage must be importable. - Set
PYTHONPATH=/appas a belt-and-suspenders alternative to thesys.pathbootstrap. - Pass all secrets via environment variables at runtime; bake none into the image.
- The dev image would add
--reloadto the gunicorn command and mount the source tree as a volume.