> For the complete documentation index, see [llms.txt](https://docs.layeronecloud.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.layeronecloud.com/platform/vps/managed-services.md).

# Managed services and the application canvas

The managed-services addon is a Subscription with kind=ADDON and a virtual\_machine FK (4.8), enabled and cancelled through enable\_managed\_service / cancel\_managed\_service from…

## 5.18 Managed services <a href="#id-518-managed-services" id="id-518-managed-services"></a>

The managed-services addon is a `Subscription` with `kind=ADDON` and a `virtual_machine` FK ([4.8](/platform/billing/ledger-and-metering.md#id-48-billing-records-subscription)), enabled and cancelled through `enable_managed_service` / `cancel_managed_service` from both the client and operator server pages. It accrues hourly like other billable resources and appears in `account_monthly_recharge_amount`, so it counts toward credit runway. **LayerOne Recommendations** on the instance Overview groups the activation/cancellation flow with backup storage ([5.20](/platform/vps/images-and-backups.md#id-520-backups-and-snapshots)).

## 5.18.1 Guest type: KVM or microvm, never both <a href="#id-5181-guest-type-kvm-or-microvm-never-both" id="id-5181-guest-type-kvm-or-microvm-never-both"></a>

Every Proxmox connection carries `virtualization`, and it is **one or the other**. `HypervisorVirtualization.KVM` is the VPS estate and the **default**, so adding the column changed no placement: every connection that predates it keeps carrying servers exactly as before. `MICROVM` is hardware running the hardened pve-microvm build, and it exists for the application platform in `apps/paas`.

They are exclusive because the host is built for one of them — kernel, storage layout and bridges all differ — not because supporting both would merely be more work.

**`apps/billing/virtualization.py` is the only place that rule is written**, and all three of its functions **fail closed**: an unrecognised workload matches no connection and blocks placement rather than falling through to "allow", so a surface added later that forgets to name its workload hides hardware instead of offering the wrong kind.

| Question                                   | Function                           |
| ------------------------------------------ | ---------------------------------- |
| Which connections can carry this workload? | `clusters_for_workload`            |
| Why can this connection not carry it?      | `virtualization_placement_blocker` |
| May this connection be re-typed?           | `switch_blocker`                   |

The two estates reach it through their own doors and **cannot see each other's hardware**:

* **VPS** — `development.clusters_for_account` intersects the development pin with KVM. Every VPS surface already funnelled through it (image pickers, the ISO library, private networks, the services directory), so none of them needed changing. Order intake asks `virtualization_placement_blocker` alongside the development blocker, so a stale deploy page, the client API and the console's create-a-service path get the same answer as the picker they bypassed.
* **Applications** — `paas.services.microvm_clusters`, plus `paas.services.placement_blocker`, which answers the prior question too: with no hardware marked as microvm it says so, rather than reporting a placement failure for hardware that was never there.

**A connection that already holds guests cannot be re-typed.** Doing so would strand them: the pickers stop offering it and placement refuses it, while the guests already on it keep running with nothing in the panel able to reach them. `ProxmoxClusterConfig.clean` refuses the change, so the console form and a shell get the same answer. A **destroyed** VM is history and holds nothing.

**The console field is optional on purpose.** Re-typing hardware is destructive, so omitting `virtualization` from a POST keeps whatever the connection already runs rather than silently moving it — it has to be something an operator chose, never something a partial form did on their behalf.

## 5.18.2 GitHub deploys <a href="#id-5182-github-deploys" id="id-5182-github-deploys"></a>

A tenant connects **one** GitHub account (`paas.GitHubConnection`, one per billing account, not per user — an application belongs to the tenant and a deploy has to keep working after the person who connected it leaves; `connected_by` records who authorized it). The OAuth user token is encrypted at rest with the shared Fernet helpers and **never rendered**; `__str__` names the GitHub login instead.

`SourceKind.GITHUB` is deliberately separate from `SourceKind.GIT`: a connected repository clones with a token and can build on a push, while a plain Git URL is anonymous and never does.

**This is an OAuth app, not a GitHub App.** The trade is worth knowing: an OAuth app is one redirect and one token, but the `repo` scope it needs for private clones and webhook creation covers **every repository the authorizing user can reach**. A GitHub App would scope access to the repositories the customer picks at install time, which is the better posture for a platform holding customer code; it costs JWT signing and installation-token refresh. Moving to one is the recommended next step and does not change any of the surfaces above it.

**The OAuth state is the whole CSRF defence** on the callback. It is session-bound and single use: without it, a link to the callback carrying an attacker's code would attach their repositories to whoever clicked it.

**Disconnecting stops deploys; it never destroys services.** The hooks we created come off the customer's repositories (best effort), `auto_deploy` and the hook ids are cleared, the applications keep their rows and their history, and only the connection is deleted. Turning "I revoked an integration" into "I destroyed my production services" is not an acceptable reading of that button.

**The push receiver at `/paas/github/push/` is the only unauthenticated endpoint in the feature**, and it is deliberately narrow:

* The `X-Hub-Signature-256` HMAC is verified with `compare_digest` **before the body is parsed**. A missing `PAAS_GITHUB_WEBHOOK_SECRET` means every delivery is refused, not that every delivery is believed — auto-deploy simply does not work until one is set.
* It builds only applications whose own configuration already says to: `source_kind=GITHUB`, matching repository **and** branch, `auto_deploy` on, and a connection still attached.
* A correctly signed push answers **202 whether or not it matched anything**. Reporting "no such application" would turn the endpoint into an oracle for which repositories are hosted here.

## 5.18.3 The application canvas <a href="#id-5183-the-application-canvas" id="id-5183-the-application-canvas"></a>

**`/client/apps/` is a map of the tenant's own system, not a list of rows.** Applications and databases are nodes, an attachment is a wire between them, and both can be dragged anywhere and managed without leaving the screen. The list is still there behind a **Canvas / List** switch, and below 768px it is the only view — a plane you pan with one finger is not how anyone manages a service on a phone.

**Positions belong to the record, not to the viewer.** `Application.canvas_x/y` and `Addon.canvas_x/y` are nullable, and null means *never placed*: `paas/canvas.py` lays those out itself, deterministically, databases in a column left of the applications they feed. A layout kept in one browser's storage would mean two people on the same account describing different pictures to each other on a call. The first drag is what writes a position down.

`POST canvas/layout/` takes a batch of moves and **re-checks ownership per node** — that filter is the only thing stopping one account nudging another's box. Unknown or unowned ids are **dropped, not refused**, because a stale id must not cost the customer the positions of everything else they just arranged, and coordinates are clamped to ±20000 so nothing is stranded where no amount of panning reaches it. Geometry is defined once in `paas/canvas.py` (node box, spacing, the wire curve) and read from there by the auto-layout, the stylesheet and the drag handler; `canvas.wire_path` and `wirePath` in `static/js/paas-canvas.js` **must stay identical**, or a wire jumps the moment a node is touched.

**Wires are drawn server-side** from stored positions, so the wiring is visible on first paint rather than after a script runs. The world is one large positive rectangle with the origin in the middle, because SVG does not draw what sits at a negative coordinate.

**The drawer renders the real pages.** Clicking a node fetches the same application tab with `?panel=1`, which swaps the console shell for `paas/_panel_base.html` and nothing else: same view, same template, same context. `_app_header.html` holds both the full-width and the panel chrome, so the tab set cannot exist in one mode and be missing from the other. Every redirect out of a write carries the flag forward (`_back()`), so a save made in the drawer comes back as a drawer. Two things leave on purpose and say so with `data-canvas-escape`: the live hostname, and opening a console — which mints a credential and lands on a terminal page that does not belong in a 30rem panel. Deleting the application or the database also leaves, because the node is gone.

Four details that are load-bearing and were each paid for once:

* **`data-l1-no-partial-navigation` on the canvas.** The console shell intercepts internal links in the *capture* phase ([12.6](/platform/design-system.md#id-126-navigation-route-naming-and-partial-navigation)); without the opt-out, clicking a node or a drawer tab navigated the whole pane away before any handler here ran.
* **No `data-l1-provisioning-poll` in panel mode.** That refresh reloads the whole content pane from the polled URL, which inside the drawer would replace the canvas with the page it is showing. The canvas polls `canvas/state/` instead, which repaints status chips and **never carries a position** — a poll that moved a node would fight the hand dragging it — and stops as soon as nothing is in flight.
* **Drawer forms post the CSRF header, not the body field.** Django reads the body before the header, and a drawer can sit open long after its form was rendered; the header is read from the cookie at the moment of the submit and cannot go stale. A write with neither is still refused.
* **The wire layer states its own size.** `app.css` carries a bare `svg { width: 16px; height: 16px }` for icon placeholders, which beats the element's width/height attributes and collapses the whole wire layer to a 16px square — present in the DOM, correct path data, drawing nothing.

**Dragging a database's port onto an application attaches it**, posting to `client-database-attach` with no variable name, which means the ordinary name for that engine. That is the one relationship on this canvas a customer can create by hand; everything else drawn there already exists. A database's own panel (`client-database-detail`) shows what uses it and under which name, and **never renders the password** — the point of an attachment is that nobody types one.

Nothing here is required for the screen to work. Every node is a real link to the page the drawer would have shown, the wires and the rows are server-rendered, and the script only adds dragging, panning and managing in place.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.layeronecloud.com/platform/vps/managed-services.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
