The Frappe Control-Plane & Data Model
This page is the map of what Frappe stores for Doppio Box / Bench Space. It is the data-model reference; the behaviour that reads and writes these doctypes lives in pages 08 (billing), 09 (auth/GitHub) and 12 (Ansible provisioning). Everything here was read off the actual .json schemas and .py controllers under benchspace/bench_space/doctype/ — architecture.md is stale, this is not.
1. Frappe as the single source of truth
Doppio Box is split into two halves:
- Control-plane — this Frappe app (
benchspace). It owns intent and record-keeping: who owns which Dev Box, what plan they're on, what the wallet balance is, which template to build, what the agent should do next. - Data-plane — the per-server Go agent (
/opt/devbox/agent) that actually creates Firecracker/Docker VMs, plus the Caddy proxy. The agent has its own SQLite DB but treats Frappe as authoritative.
The two never share a database. They communicate through two narrow seams, both expressed as doctypes:
Agent Job— Frappe writes a row ("create this slug"), the agent polls Frappe every ~10s, executes it, and reports status back. This is the command queue.- The agent user + API key (
Server.agent_user/agent_api_secret) — credentials the agent uses to call back into Frappe's REST API to push container status, stats, and build results.
So a "Dev Box" is really two things kept in sync: a Dev Box document (the truth) and a container/VM on a server (the reality). Scheduler jobs (sync_all_stats, status polls) continuously reconcile reality back onto the document. When in doubt, the document wins.
2. Doctype catalog by domain
Module: Bench Space (benchspace/modules.txt). Permissions revolve around two roles: System Manager (full admin) and Dev Box User (the end customer; auto-assigned to every new User — see §4).
2.1 Core — Dev Box, Server, Agent Job
Dev Box
Purpose: one developer workspace (a VM/container with a Frappe bench + code-server). The central entity.
autoname: field:slug — the document name is the slug.
| field | type | meaning |
|---|---|---|
title |
Data | User-facing label (editable via change_title) |
server |
Link → Server | Host it runs on. reqd, set_only_once |
slug |
Data | 8-char random [a-z0-9] id; unique, read-only; drives all URLs |
status |
Select | pending / creating / starting / active / stopped / resetting / error / deleted |
owner_user |
Link → User | Who owns it; null while in pool |
is_pool |
Check | True = pre-warmed, unclaimed box |
devbox_template |
Link → DevBox Template | Which image/stack it was built from |
runtime_backend |
Data | docker or firecracker (copied from Server) |
cpu_usage / memory_usage / disk_usage |
Data | Live stats, refreshed by scheduler |
subscription_end_date |
Datetime | Access gate — start/reset blocked past this |
site_url / code_url |
Data | https://{slug}.site.{domain} / .code. |
error_message |
Small Text | Last failure detail |
Controller logic (dev_box.py):
before_insert→ generate unique slug, compute URLs from the Server's domain, defaultowner_userto session user (unless pool).after_insert→ enqueue anAgent Jobwith actioncreate, set statuscreating.- Whitelisted lifecycle actions, each guarded by a status check (and a
subscription_end_dategate for non-admins):start_box,stop_box,reset_box(blocked for pool boxes),delete_box,send_to_pool(admin-only; expires linked subscriptions, returns box to pool),destroy(admin-only force teardown),claim_for_user(pool → owned, used by billing). sync_status/fetch_statscall the agent over HTTP (/containers,/containers/{slug}/stats)._send_agent_task(action)is the helper that writes theAgent Job; forcreate/resetit injects the owner'sGITHUB_TOKENintoenv_vars.- Module-level
STATUS_MAPtranslates agent container states (running→active,exited→stopped…)._apply_statusdeliberately skips boxes inresettingso polls don't clobber the transient state. - Module functions
replenish_pool,sync_all_dev_boxes,sync_all_statsare the scheduler entry points (see §3). has_permission/get_permission_query_conditions: non-admins see only rows whereowner_user == user.
Server
Purpose: a physical/virtual host that runs the agent and hosts Dev Boxes.
autoname: prompt (operator types the name, e.g. fc-01).
| field | type | meaning |
|---|---|---|
ip_address |
Data | reqd, set_only_once |
domain |
Data | Base domain for box URLs, reqd, set_only_once |
agent_port |
Int | Default 8090 |
agent_token |
Password | Bearer token the control-plane uses to call the agent |
runtime_backend |
Select | docker / firecracker, set_only_once |
status |
Select | Pending / Preparing / Active / Error |
agent_user |
Link → User | Dedicated API user the agent authenticates back as |
agent_api_secret |
Password | That user's API secret |
error_message |
Small Text | Last provisioning error |
Controller logic (server.py):
ping— SSHecho ponghealth check.prepare_server— generatesagent_token, creates the agent User (_create_agent_user, a System Manager API user namedagent-{name}@benchspace.dev), then enqueuesrun_prepare_server(long queue, runs Ansibleplaybook.yml→ see page 12).deploy_agent— enqueuesrun_deploy_agent(Ansibledeploy-agent.yml)._agent_variablesbuilds the Ansible var dict (Frappe URL, API key/secret, token, backend) so the agent is wired to call home.- Links (dashboard):
Dev BoxandAnsible Playboth link back viaserver.
Agent Job
Purpose: the command queue between control-plane and agent. One row = one action for the agent to execute.
autoname: hash. Controller is an empty pass — all behaviour lives on the agent side (it polls, executes, and PATCHes status back).
| field | type | meaning |
|---|---|---|
dev_box |
Link → Dev Box | Target box (null for image builds) |
server |
Link → Server | Where to run, reqd |
action |
Select | create / start / stop / delete / reset / build_image / build_golden |
status |
Select | Pending / Running / Success / Failure |
slug |
Data | Box slug |
devbox_template |
Link → DevBox Template | Template for create/build |
env_vars |
Code (JSON) | e.g. GITHUB_TOKEN, or build args (PYTHON_VERSION, APPS…) |
error_message |
Small Text | Failure detail |
2.2 Templates — DevBox Template, DevBox Template App, App Source, Server Template Image
DevBox Template
Purpose: a reusable definition of a bench stack (versions + apps) that gets baked into a rootfs image and pool.
autoname: field:title.
| field | type | meaning |
|---|---|---|
title |
Data | unique, reqd |
slug |
Data | filesystem-safe, auto from title, unique, read-only |
description |
Small Text | |
enabled |
Check | default 1; only enabled templates get pooled |
is_default |
Check | the one used when no template chosen; only one allowed |
pool_size |
Int | default 3; how many warm boxes to keep |
python_version / node_version / mariadb_version / frappe_branch |
Data | stack versions, reqd |
apps |
Table → DevBox Template App | apps to clone/install |
build_status |
Select | Not Built / Building / Built / Failed |
last_built_at |
Datetime | |
s3_url / checksum |
Data | built rootfs artifact location + integrity |
build_error |
Small Text |
Controller logic (devbox_template.py):
before_insert→ deriveslugvia_make_slug(lowercase, non-alnum →-).after_insert→_create_server_template_images: auto-create aServer Template Imagerow for every Active server except the builder server (the builder only builds rootfs, never hosts boxes).validate→_validate_single_default: throws if another template is alreadyis_default.build_image(admin) → assembles anAPPSJSON from eachApp Source, writes abuild_imageAgent Jobto the configuredbuilder_server, sets statusBuilding.
DevBox Template App (child table, istable: 1)
Purpose: one app entry inside a template.
| field | type | meaning |
|---|---|---|
app_source |
Link → App Source | reqd; which repo/branch |
install_on_site |
Check | install on the bench site vs. just clone into bench |
App Source
Purpose: a reusable Git source for a Frappe app (so templates reference it instead of repeating URLs).
autoname: field:title.
| field | type | meaning |
|---|---|---|
title |
Data | unique label, reqd |
app_name |
Data | Frappe app dir name (e.g. erpnext, lms), reqd |
repository_url |
Data | reqd |
branch |
Data | reqd |
Controller: empty pass.
Server Template Image
Purpose: join record tracking the build state of one template's image on one server (each host needs its own golden snapshot per template).
autoname: hash.
| field | type | meaning |
|---|---|---|
server |
Link → Server | reqd |
devbox_template |
Link → DevBox Template | reqd |
status |
Select | Not Built / Building / Built / Failed |
built_at |
Datetime | |
error |
Small Text |
Controller: empty pass (status driven by the agent). Has a "Built" green state.
2.3 Billing — Dev Box Plan, Dev Box Subscription, Wallet Transaction, Doppio Customer
(Behaviour & money flow: see page 08. Here = the data model only.)
Dev Box Plan
Purpose: a purchasable plan (duration + price), or a trial marker.
autoname: field:plan_name. Controller: empty pass.
| field | type | meaning |
|---|---|---|
plan_name |
Data | unique, reqd |
frequency |
Select | 1 Day / 3 Days / 7 Days / 1 Month / 3 Months / 6 Months (hidden/optional when trial) |
enabled |
Check | default 1 |
is_trial |
Check | trial plan; duration comes from Settings, not here |
price_usd / price_inr |
Currency | dual-currency pricing (mandatory unless trial) |
Dev Box Subscription
Purpose: a user's purchase of a plan that backs one Dev Box for a time window.
autoname: SUB-.#####.
| field | type | meaning |
|---|---|---|
user |
Link → User | defaults to session user, reqd, set_only_once |
customer |
Link → Doppio Customer | billing identity |
plan |
Link → Dev Box Plan | reqd |
amount |
Currency | charged amount, reqd |
currency |
Data | |
is_trial |
Check | |
status |
Select | Pending / Paid / Provisioning / Active / Expired / Extended / Failed |
payment_method |
Select | Razorpay / Wallet, set_only_once |
razorpay_order |
Data | gateway order id |
dev_box |
Link → Dev Box | the box this subscription funds |
devbox_template |
Link → DevBox Template | requested template |
billing_name / country / billing_address / tax_id |
Data/Link/Text | invoice details (mandatory unless trial) |
start_date / end_date |
Datetime | active window; end_date mirrors onto Dev Box.subscription_end_date |
expiry_warning_sent |
Check (hidden) | de-dupe flag for warning emails |
Controller logic (dev_box_subscription.py): before_insert defaults user to session user. Owner-scoped permissions (read-only for Dev Box User; the lifecycle is driven by api/billing.py).
Wallet Transaction (is_submittable: 1)
Purpose: an immutable ledger entry for a customer's prepaid wallet. Running balance is stored, not computed on the fly.
autoname: WTXN-.#####.
| field | type | meaning |
|---|---|---|
customer |
Link → Doppio Customer | reqd, set_only_once |
user |
Link → User | fetched from customer.user |
type |
Select | Top-up / Plan Purchase / Refund / Adjustment |
source |
Select | / Razorpay / Refund / Manual |
amount |
Currency | + credit, − debit, reqd, set_only_once |
currency |
Data | fetched from customer |
ending_balance |
Currency | running balance after this row (read-only) |
razorpay_order / razorpay_payment_id |
Data | gateway refs (payment_id unique) |
reference_doctype / reference_name |
Link / Dynamic Link | what this txn relates to (e.g. a Subscription) |
description, posted_at, amended_from |
Controller logic (wallet_transaction.py):
validate→ reject zero amount.before_submit→ending_balance = prev_balance + amount; throws on negative balance (insufficient funds guard). This is the core ledger invariant.on_submit→publish_realtime("wallet_updated", …)to the user; send a top-up confirmation email forTop-up.on_cancel→ throws — ledger entries can never be cancelled (only amended). Owner-scoped read for Dev Box User.
Doppio Customer
Purpose: the billing identity for a user (one per user).
autoname: field:user (so name == user email).
| field | type | meaning |
|---|---|---|
user |
Link → User | unique, reqd, set_only_once |
billing_name |
Data | reqd |
country |
Link → Country | reqd, set_only_once |
currency |
Select | INR / USD, read-only |
tax_id |
Data | |
razorpay_customer_id |
Data | gateway customer id |
billing_address |
Small Text | reqd |
Controller logic (doppio_customer.py): before_insert sets currency = INR if country == "India" else USD. get_balance() reads the latest submitted Wallet Transaction.ending_balance. Owner-scoped permissions.
2.4 Auth / Integration — Login Request, GitHub Token
(Flow & API: see page 09.)
Login Request
Purpose: holds an email OTP for passwordless login.
autoname: hash. Controller: empty pass.
| field | type | meaning |
|---|---|---|
email |
Data (Email) | unique, reqd |
otp |
Data | the one-time code |
otp_generated_at |
Datetime | for expiry checks |
GitHub Token
Purpose: stores a user's GitHub OAuth access token (so Dev Boxes can clone private repos / push).
autoname: field:user, allow_rename: 0 (so name == user; that's why Dev Box looks it up by owner_user).
| field | type | meaning |
|---|---|---|
user |
Link → User | unique, reqd |
access_token |
Password | OAuth token |
github_username |
Data | read-only |
authorized_at |
Datetime | read-only |
Controller: empty pass. Permission: owner can read their own (if_owner for role All).
2.5 Provisioning — Ansible Play, Ansible Task
(Runner internals: see page 12.)
Ansible Play
Purpose: a record of one Ansible playbook run against a server (the parent of its tasks).
autoname: autoincrement.
| field | type | meaning |
|---|---|---|
status |
Select | Pending / Running / Success / Failure |
play / playbook |
Data | play name / playbook file |
server |
Link → Server | target, reqd |
start / end / duration |
Datetime/Time | timing |
variables |
Code (JSON) | vars passed in |
ok / failures / changed / unreachable / skipped / rescued / ignored |
Int | recap stats |
error |
Code | failure detail |
Controller logic (ansible_play.py): on_trash cascade-deletes its Ansible Task rows. Dashboard link: Ansible Task via play.
Ansible Task
Purpose: one task within a play, with its output/result.
autoname: autoincrement.
| field | type | meaning |
|---|---|---|
task / role |
Data | task & role name, reqd |
play |
Link → Ansible Play | parent, reqd |
status |
Select | Pending / Running / Success / Failure / Skipped / Unreachable |
start / end / duration |
timing | |
output / error / exception |
Code | logs |
result |
Code (JSON) | raw task result |
Controller logic (ansible_task.py): on_update → publish_realtime("ansible_play_update", {"play": …}) so the Desk form streams progress live.
2.6 Settings — Bench Space Settings
Bench Space Settings (issingle: 1)
Purpose: the one global config document for the whole app.
| field | type | meaning |
|---|---|---|
self_hosting_price_usd |
Currency | price of the self-hosting offer |
default_server |
Link → Server | where new/pool boxes land, reqd |
default_runtime |
Select | docker / firecracker |
pool_size |
Int | global pool default |
builder_server |
Link → Server | host that builds rootfs images |
rebuild_frequency |
Select | Never / Weekly / Bi-Weekly / Monthly |
ssh_keys_status |
Data | computed at load (disk vs. site-config vs. none) |
github_app_id / github_app_client_id / github_app_client_secret / github_app_public_link |
Data/Password | GitHub App credentials |
cf_api_token |
Password | Cloudflare API token |
r2_account_id / r2_access_key_id / r2_secret_access_key / r2_bucket_name / r2_public_url |
Data/Password | Cloudflare R2 storage for image artifacts |
trial_duration |
Duration | default 7200s (2h) free-trial length |
Controller logic (bench_space_settings.py): load_from_db computes ssh_keys_status; whitelisted restore_ssh_keys (re-writes keys from site config to disk — see setup.py) and get_github_app_manifest (returns the GitHub App creation manifest with redirect/callback URLs).
Stale-types note:
server.jsondefinesruntime_backend,agent_user, andagent_api_secret, but the auto-generatedTYPE_CHECKINGblock inserver.py(lines 16-21) omits all three. The JSON fields are authoritative; the annotation block is out of date.
3. Relationships
The links, in plain English:
- Dev Box →
Server(where it runs),User(owner),DevBox Template(its stack). - Agent Job →
Dev Box,Server,DevBox Template(a command targeting them). - Server ← back-referenced by
Dev Box,Ansible Play; →User(its agent user). - DevBox Template ⇒ owns child rows DevBox Template App ⇒ each → App Source.
- Server Template Image →
Server+DevBox Template(per-host build state; one auto-created per Active non-builder server when a template is added). - Dev Box Subscription →
User,Doppio Customer,Dev Box Plan,Dev Box,DevBox Template. - Doppio Customer →
User,Country; one per user. - Wallet Transaction →
Doppio Customer(+ fetchedUser), plus a dynamicreference_doctype/reference_name(often a Subscription). - GitHub Token / Login Request → keyed by user email (token) / email (login).
- Ansible Play ⇒ owns Ansible Task rows; play →
Server. - Bench Space Settings (Single) →
default_server,builder_server.
3.1 ER diagram
3.2 Domains at a glance
4. hooks.py — how the data model comes alive
App metadata (benchspace/hooks.py:1): app_name="benchspace", app_title="Bench Space", publisher BWH, license agpl-3.0. Version 0.0.1 (__init__.py).
Document events (doc_events)
| doctype | event | handler |
|---|---|---|
User |
after_insert |
benchspace.api.auth.assign_dev_box_role — every new user becomes a Dev Box User |
(Note: the rest of the per-doctype behaviour is implemented as controller methods on the doctypes themselves, not via doc_events.)
Scheduler events (scheduler_events)
| cadence | method | what it does |
|---|---|---|
all (~every few min) |
dev_box.sync_all_stats |
refresh CPU/mem/disk for active boxes |
all |
dev_box.replenish_pool |
top each enabled template's warm pool back to pool_size |
all |
api.billing.check_expired_subscriptions |
expire lapsed subscriptions |
all |
api.billing.send_expiry_warnings |
warn before expiry |
all |
api.billing.send_trial_expiry_emails |
trial-ending emails |
all |
api.billing.delete_expired_trials |
tear down dead trials |
daily |
api.devbox.cleanup_old_workspace_downloads |
GC old workspace exports |
Note:
dev_box.pyalso definessync_all_dev_boxes(), but it is not wired intoscheduler_events(confirmed inhooks.py:151-163) — onlysync_all_statsandreplenish_poolare. Box-status sync is driven by agent callbacks (update_task) and the on-demandsync_statusmethod instead.
Permissions
hooks.py registers permission_query_conditions + has_permission for Dev Box, Doppio Customer, Wallet Transaction, Dev Box Subscription — all four enforce the same rule: System Managers see everything; everyone else sees only their own rows (owner_user/user/customer.user).
Other hooks
after_migrate = ["benchspace.setup.after_migrate"]— restores SSH keys from site config to disk (skipped in developer mode), since Frappe Cloud filesystems aren't persistent.jinja.methodsexposesget_landing_css_hash(cache-busting for the landing CSS).website_route_rulesmaps/dashboard/<path>→ thedashboardSPA.export_python_type_annotations = True,require_type_annotated_api_methods = True.- No
fixtureshook is defined — plans, templates, app sources, and settings are seeded operationally, not shipped as fixtures.
5. Mental model for a newcomer
- A customer signs up → gets the Dev Box User role → creates a Doppio Customer (billing identity, currency from country).
- They buy a Dev Box Plan → a Dev Box Subscription is created; payment is either Razorpay or Wallet Transaction debit (page 08).
- The subscription either claims a pooled Dev Box (
claim_for_user) or triggers a fresh create. Either way the box'ssubscription_end_dateis set from the subscription'send_date. - The Dev Box writes an Agent Job; the agent on its Server executes it and reports back, flipping
statustowardactive. - Templates (DevBox Template + DevBox Template App + App Source) define what stack gets baked; Server Template Image tracks the per-host build; the warm pool is kept full by
replenish_pool. - Server bring-up runs Ansible, logged as Ansible Play / Ansible Task.
- Bench Space Settings (Single) holds the global wiring: default/builder servers, pool size, GitHub App, Cloudflare R2, trial duration.