to select ↑↓ to navigate
Doppiobox

Doppiobox

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

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:

  1. 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.
  2. 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, default owner_user to session user (unless pool).
  • after_insert → enqueue an Agent Job with action create, set status creating.
  • Whitelisted lifecycle actions, each guarded by a status check (and a subscription_end_date gate 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_stats call the agent over HTTP (/containers, /containers/{slug}/stats). _send_agent_task(action) is the helper that writes the Agent Job; for create/reset it injects the owner's GITHUB_TOKEN into env_vars.
  • Module-level STATUS_MAP translates agent container states (runningactive, exitedstopped…). _apply_status deliberately skips boxes in resetting so polls don't clobber the transient state.
  • Module functions replenish_pool, sync_all_dev_boxes, sync_all_stats are the scheduler entry points (see §3).
  • has_permission / get_permission_query_conditions: non-admins see only rows where owner_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 — SSH echo pong health check.
  • prepare_server — generates agent_token, creates the agent User (_create_agent_user, a System Manager API user named agent-{name}@benchspace.dev), then enqueues run_prepare_server (long queue, runs Ansible playbook.yml → see page 12).
  • deploy_agent — enqueues run_deploy_agent (Ansible deploy-agent.yml).
  • _agent_variables builds the Ansible var dict (Frappe URL, API key/secret, token, backend) so the agent is wired to call home.
  • Links (dashboard): Dev Box and Ansible Play both link back via server.

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 → derive slug via _make_slug (lowercase, non-alnum → -).
  • after_insert_create_server_template_images: auto-create a Server Template Image row 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 already is_default.
  • build_image (admin) → assembles an APPS JSON from each App Source, writes a build_image Agent Job to the configured builder_server, sets status Building.

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_submitending_balance = prev_balance + amount; throws on negative balance (insufficient funds guard). This is the core ledger invariant.
  • on_submitpublish_realtime("wallet_updated", …) to the user; send a top-up confirmation email for Top-up.
  • on_cancelthrows — 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_updatepublish_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.json defines runtime_backend, agent_user, and agent_api_secret, but the auto-generated TYPE_CHECKING block in server.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 BoxServer (where it runs), User (owner), DevBox Template (its stack).
  • Agent JobDev 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 ImageServer + DevBox Template (per-host build state; one auto-created per Active non-builder server when a template is added).
  • Dev Box SubscriptionUser, Doppio Customer, Dev Box Plan, Dev Box, DevBox Template.
  • Doppio CustomerUser, Country; one per user.
  • Wallet TransactionDoppio Customer (+ fetched User), plus a dynamic reference_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

diagram

3.2 Domains at a glance

diagram


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.py also defines sync_all_dev_boxes(), but it is not wired into scheduler_events (confirmed in hooks.py:151-163) — only sync_all_stats and replenish_pool are. Box-status sync is driven by agent callbacks (update_task) and the on-demand sync_status method 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.methods exposes get_landing_css_hash (cache-busting for the landing CSS).
  • website_route_rules maps /dashboard/<path> → the dashboard SPA.
  • export_python_type_annotations = True, require_type_annotated_api_methods = True.
  • No fixtures hook is defined — plans, templates, app sources, and settings are seeded operationally, not shipped as fixtures.

5. Mental model for a newcomer

  1. A customer signs up → gets the Dev Box User role → creates a Doppio Customer (billing identity, currency from country).
  2. They buy a Dev Box Plan → a Dev Box Subscription is created; payment is either Razorpay or Wallet Transaction debit (page 08).
  3. The subscription either claims a pooled Dev Box (claim_for_user) or triggers a fresh create. Either way the box's subscription_end_date is set from the subscription's end_date.
  4. The Dev Box writes an Agent Job; the agent on its Server executes it and reports back, flipping status toward active.
  5. 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.
  6. Server bring-up runs Ansible, logged as Ansible Play / Ansible Task.
  7. Bench Space Settings (Single) holds the global wiring: default/builder servers, pool size, GitHub App, Cloudflare R2, trial duration.
Last updated 1 month ago
Was this helpful?
Thanks!