Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
57a4eed
feat(controller): clone a reference SandboxPool into each org namespa…
Jul 5, 2026
66ab501
Merge remote-tracking branch 'origin/main' into feat/org-default-pool
Jul 5, 2026
43f44cd
Merge branch 'main' into feat/org-default-pool
Jul 5, 2026
2d3d983
Merge branch 'main' into feat/org-default-pool
Jul 5, 2026
10fd8b5
Merge branch 'main' into feat/org-default-pool
Jul 6, 2026
5d1fe45
Merge branch 'main' into feat/org-default-pool
Jul 6, 2026
ac5f778
Merge branch 'main' into feat/org-default-pool
Jul 6, 2026
6538105
Merge branch 'main' into feat/org-default-pool
Jul 6, 2026
44c7786
Merge branch 'main' into feat/org-default-pool
Jul 6, 2026
5d0878f
Merge branch 'main' into feat/org-default-pool
Jul 6, 2026
1fa64f0
Merge branch 'main' into feat/org-default-pool
Jul 6, 2026
664aeda
Merge branch 'main' into feat/org-default-pool
Jul 6, 2026
4a5fd15
Merge branch 'main' into feat/org-default-pool
Jul 7, 2026
12b9d0c
Merge branch 'main' into feat/org-default-pool
Jul 7, 2026
4eafef1
Merge branch 'main' into feat/org-default-pool
Jul 7, 2026
7a2a0a6
Merge branch 'main' into feat/org-default-pool
Jul 7, 2026
c215840
Merge branch 'main' into feat/org-default-pool
Jul 7, 2026
83b7b66
Merge branch 'main' into feat/org-default-pool
Jul 7, 2026
4bae1cb
Merge branch 'main' into feat/org-default-pool
Jul 7, 2026
4e4ce16
Merge branch 'main' into feat/org-default-pool
Jul 7, 2026
1c812fa
Merge branch 'main' into feat/org-default-pool
Jul 7, 2026
8db83f2
Merge branch 'main' into feat/org-default-pool
Jul 7, 2026
71ed0c8
Merge branch 'main' into feat/org-default-pool
Jul 7, 2026
ca0afe6
Merge branch 'main' into feat/org-default-pool
Jul 7, 2026
0def786
Merge branch 'main' into feat/org-default-pool
Jul 7, 2026
617a2d7
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
e0c3ec8
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
c404a8a
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
2b5f2d4
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
8207fdd
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
266242d
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
1cbbe7e
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
288f0bd
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
6cf60c4
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
6e6734a
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
f438d2e
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
0a31c74
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
1c88ee2
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
0c00e5d
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
72f349b
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
dbf3712
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
227d7f0
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
95e9e99
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
eeb53fa
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
ae54d4e
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
bc4eb47
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
fb8ea76
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
4879050
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
fd058ed
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
2914039
Merge branch 'main' into feat/org-default-pool
Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ func main() {
var orgDefaultMaxSandboxes int
var orgDefaultCPU string
var orgDefaultMemory string
var orgPoolTemplateName string
var orgPoolTemplateNamespace string
var orgPoolName string
var orgPoolWarmMin int

flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
Expand Down Expand Up @@ -119,6 +123,10 @@ func main() {
flag.IntVar(&orgDefaultMaxSandboxes, "org-default-max-sandboxes", 50, "Default per-org sandbox/pod count ceiling applied to an Org's ResourceQuota when the Org sets no spec.quota override. The per-org abuse-control primitive: it bounds how much one tenant can schedule. Only used with --enable-org-tenancy.")
flag.StringVar(&orgDefaultCPU, "org-default-cpu", "32", "Default per-org aggregate CPU limit ceiling (a Kubernetes quantity, for example 32 or 32000m) applied to an Org's ResourceQuota when the Org sets no spec.quota override. Only used with --enable-org-tenancy.")
flag.StringVar(&orgDefaultMemory, "org-default-memory", "64Gi", "Default per-org aggregate memory limit ceiling (a Kubernetes quantity, for example 64Gi) applied to an Org's ResourceQuota when the Org sets no spec.quota override. Only used with --enable-org-tenancy.")
flag.StringVar(&orgPoolTemplateName, "org-pool-template-name", "", "Name of a reference SandboxPool (in --org-pool-template-namespace) whose full spec (image, resources, placement, snapshots, network) is cloned into every org namespace so a per-org create has a schedulable pool to fork from. Empty (the default) provisions NO per-org pool (bring-your-own-pool). Only used with --enable-org-tenancy.")
flag.StringVar(&orgPoolTemplateNamespace, "org-pool-template-namespace", "", "Namespace of the reference pool named by --org-pool-template-name. Empty defaults to the controller's own namespace. Only used with --enable-org-tenancy.")
flag.StringVar(&orgPoolName, "org-pool-name", "", "Name the cloned pool is created under in each org namespace; MUST match the pool name a create resolves (the SDK's image/pool argument). Empty defaults to --org-pool-template-name. Only used with --enable-org-tenancy.")
flag.IntVar(&orgPoolWarmMin, "org-pool-warm-min", 0, "warm.min override on the cloned per-org pool. Default 0: no idle warm husks, so N per-org pools cost nothing at rest; the first fork per org cold-starts and the pool warms on demand. Only used with --enable-org-tenancy.")
flag.Parse()

ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
Expand Down Expand Up @@ -390,19 +398,29 @@ func main() {
os.Exit(1)
}
orgReconciler := &controller.OrgReconciler{
Client: mgr.GetClient(),
PoolSecretsSubject: "mitos-controller",
PoolSecretsNamespace: poolControllerNamespace,
DefaultMaxSandboxes: int32(orgDefaultMaxSandboxes), //nolint:gosec // flag value, operator-controlled, bounded by ResourceQuota semantics
DefaultCPU: orgCPU,
DefaultMemory: orgMem,
Client: mgr.GetClient(),
PoolSecretsSubject: "mitos-controller",
PoolSecretsNamespace: poolControllerNamespace,
DefaultMaxSandboxes: int32(orgDefaultMaxSandboxes), //nolint:gosec // flag value, operator-controlled, bounded by ResourceQuota semantics
DefaultCPU: orgCPU,
DefaultMemory: orgMem,
PoolTemplateName: orgPoolTemplateName,
PoolTemplateNamespace: orgPoolTemplateNamespace,
OrgPoolName: orgPoolName,
OrgPoolWarmMin: int32(orgPoolWarmMin), //nolint:gosec // flag value, operator-controlled
}
if err := orgReconciler.SetupWithManager(mgr); err != nil {
logger.Error(err, "unable to create controller", "controller", "Org")
os.Exit(1)
}
logger.Info("org tenancy: ENABLED; provisioning per-org isolation namespaces (mitos-org-<id>) with PSA privileged labels, a ResourceQuota ceiling, a LimitRange, a default-deny NetworkPolicy + DNS egress, and the mitos-pool-secrets RoleBinding. Cross-org isolation is the separate namespace + default-deny NetworkPolicy + ResourceQuota + the microVM; a NetworkPolicy-enforcing CNI is required",
"default-max-sandboxes", orgDefaultMaxSandboxes, "default-cpu", orgDefaultCPU, "default-memory", orgDefaultMemory)
if orgPoolTemplateName != "" {
logger.Info("org tenancy: per-org pool ENABLED; cloning a reference pool spec into each org namespace with warm.min overridden so a per-org create has a schedulable pool to fork from",
"template-name", orgPoolTemplateName, "template-namespace", orgPoolTemplateNamespace, "org-pool-name", orgPoolName, "warm-min", orgPoolWarmMin)
} else {
logger.Info("org tenancy: per-org pool disabled (no --org-pool-template-name); each org namespace has NO pool until one is provisioned (bring-your-own-pool)")
}
} else {
logger.Info("org tenancy: disabled (default); self-host single-tenant. Pass --enable-org-tenancy for hosted multi-tenant per-org namespaces")
}
Expand Down
10 changes: 10 additions & 0 deletions deploy/charts/mitos/templates/controller-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ spec:
- --org-default-max-sandboxes={{ .Values.controller.orgTenancy.defaultMaxSandboxes }}
- --org-default-cpu={{ .Values.controller.orgTenancy.defaultCPU }}
- --org-default-memory={{ .Values.controller.orgTenancy.defaultMemory }}
{{- if .Values.controller.orgTenancy.poolTemplate.name }}
- --org-pool-template-name={{ .Values.controller.orgTenancy.poolTemplate.name }}
{{- if .Values.controller.orgTenancy.poolTemplate.namespace }}
- --org-pool-template-namespace={{ .Values.controller.orgTenancy.poolTemplate.namespace }}
{{- end }}
{{- if .Values.controller.orgTenancy.poolTemplate.orgPoolName }}
- --org-pool-name={{ .Values.controller.orgTenancy.poolTemplate.orgPoolName }}
{{- end }}
- --org-pool-warm-min={{ .Values.controller.orgTenancy.poolTemplate.warmMin }}
{{- end }}
{{- end }}
{{- /*
Usage metering (issue #602): the collector scrapes every forkd
Expand Down
23 changes: 23 additions & 0 deletions deploy/charts/mitos/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,29 @@
"defaultMemory": {
"description": "Default per-org aggregate memory limit ceiling, a Kubernetes quantity.",
"type": "string"
},
"poolTemplate": {
"description": "Per-org SandboxPool cloned from a reference pool: when name is set, clone that pool's full spec into every org namespace with warm.min overridden.",
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"description": "Reference SandboxPool to clone; empty disables per-org pools.",
"type": "string"
},
"namespace": {
"description": "Namespace of the reference pool; empty defaults to the release namespace.",
"type": "string"
},
"orgPoolName": {
"description": "Name the cloned pool takes per org; empty defaults to name.",
"type": "string"
},
"warmMin": {
"description": "warm.min override on the cloned pool.",
"type": "integer"
}
}
}
}
},
Expand Down
19 changes: 19 additions & 0 deletions deploy/charts/mitos/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,25 @@ controller:
defaultCPU: "32"
# Default per-org aggregate memory limit ceiling (a Kubernetes quantity).
defaultMemory: "64Gi"
# Per-org SandboxPool. When poolTemplate.name is set, the OrgReconciler clones
# that reference pool's FULL spec (image, resources, placement, snapshots,
# network) into every org namespace with warm.min overridden, so the per-org
# pool schedules exactly where the reference pool does (a bare image would not
# carry placement/resources). warmMin:0 means no idle warm husks, so N per-org
# pools cost nothing at rest (the snapshot is content-addressed and deduped per
# node); the first fork per org cold-starts and the pool warms on demand.
# orgPoolName MUST match the pool name a create resolves (the SDK image/pool
# argument). Empty name (the default) provisions no pool (bring-your-own-pool).
# Only used when enabled.
poolTemplate:
# Name of the reference SandboxPool to clone. Empty disables per-org pools.
name: ""
# Namespace of the reference pool. Empty defaults to the release namespace.
namespace: ""
# Name the cloned pool takes in each org namespace. Empty defaults to name.
orgPoolName: ""
# warm.min override on the cloned pool.
warmMin: 0
# Usage metering (issues #164/#211/#602): the live per-org metering scraper
# and the bearer-gated internal usage API the hosted console reads. OFF by
# default: a self-host install that does not want metering renders none of it.
Expand Down
14 changes: 14 additions & 0 deletions docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -1525,6 +1525,20 @@ deleted or drifted stack object enqueues the Org and is recreated; org deletion
cascades through owner references (the cluster-scoped Org owns the cluster-scoped
Namespace, a valid owner edge).

When `--org-pool-template-name` is set, the reconciler also clones a reference
SandboxPool's full spec (image, resources, placement, snapshots, network) into
each org namespace with `warm.min` overridden (default 0), named to match the
pool a create resolves. Cloning from one reference is what makes the per-org pool
schedule exactly where the reference does (a bare image would not carry the KVM
placement); `warm.min:0` keeps no idle warm husks, so N per-org pools cost nothing
at rest (the first fork per org cold-starts; the snapshot is content-addressed and
deduped per node). This does NOT move the isolation boundary: the pool and its
husks live inside the org's already-isolated namespace under the same default-deny
NetworkPolicy and ResourceQuota, so it grants no cross-tenant reach. It is the
enablement that lets the hosted deployment run per-org namespaces instead of one
shared namespace (which is what closes the shared-namespace referenced-object
class of issues in section 7b, the bare-name secret/workspace reference finding).

What isolates two orgs: **the separate namespace + the per-org default-deny
NetworkPolicy + the per-org ResourceQuota ceiling + the microVM.** No single one
of these is the whole boundary; the microVM remains the in-pod isolation
Expand Down
47 changes: 47 additions & 0 deletions internal/controller/org_builders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,53 @@ import (
"mitos.run/mitos/internal/tenant"
)

// TestBuildOrgPoolFromTemplate asserts the per-org pool clones the reference
// pool's full spec (image, resources, placement) and overrides ONLY warm.min to
// the configured floor, carries the org label and target name/namespace, so a
// per-org create in the org namespace has a schedulable pool to fork from (issue
// #288 multi-tenancy enablement).
func TestBuildOrgPoolFromTemplate(t *testing.T) {
tmpl := &v1.SandboxPool{
Spec: v1.SandboxPoolSpec{
Template: &v1.PoolTemplateSpec{
Image: "ghcr.io/mitos-run/mitos-python:v1.13.0",
Resources: v1.SandboxResources{CPU: resource.MustParse("2"), Memory: resource.MustParse("512Mi")},
},
Warm: &v1.PoolWarm{Min: 8},
Placement: &v1.PoolPlacement{
NodeSelector: map[string]string{"mitos.run/kvm": "true"},
},
},
}
org := &v1.Org{}
org.Name = "acme"
pool := buildOrgPoolFromTemplate(org, tenant.NamespaceForOrg("acme"), "python", tmpl, 0)

if pool.Name != "python" {
t.Fatalf("name = %q, want python", pool.Name)
}
if pool.Namespace != "mitos-org-acme" {
t.Fatalf("namespace = %q, want mitos-org-acme", pool.Namespace)
}
if got := pool.Labels[tenant.OrgLabelKey]; got != "acme" {
t.Fatalf("org label = %q, want acme", got)
}
if pool.Spec.Template == nil || pool.Spec.Template.Image != "ghcr.io/mitos-run/mitos-python:v1.13.0" {
t.Fatalf("template image not cloned: %+v", pool.Spec.Template)
}
if pool.Spec.Placement == nil || pool.Spec.Placement.NodeSelector["mitos.run/kvm"] != "true" {
t.Fatalf("placement not cloned: %+v", pool.Spec.Placement)
}
if pool.Spec.Warm == nil || pool.Spec.Warm.Min != 0 {
t.Fatalf("warm.min = %+v, want a non-nil Warm with Min 0 (overridden)", pool.Spec.Warm)
}
// The clone must be independent: mutating it must not touch the template.
pool.Spec.Placement.NodeSelector["mitos.run/kvm"] = "mutated"
if tmpl.Spec.Placement.NodeSelector["mitos.run/kvm"] != "true" {
t.Fatalf("clone shares the template's placement map (not a deep copy)")
}
}

// TestBuildOrgDefaultDenyPolicy asserts the per-org NetworkPolicy is
// default-deny in BOTH directions with a single DNS egress allow, applies to
// every pod in the namespace, and carries the org label.
Expand Down
98 changes: 97 additions & 1 deletion internal/controller/org_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,28 @@ type OrgReconciler struct {
// DefaultMemory is the per-org aggregate memory limit ceiling applied when an
// Org sets no Quota override.
DefaultMemory resource.Quantity

// PoolTemplateName is the name of a reference SandboxPool (in
// PoolTemplateNamespace) whose spec is cloned into every org namespace so a
// per-org create has a pool to fork from. Cloning the full spec (template,
// resources, placement, snapshots, network) from ONE source of truth is what
// makes the per-org pool schedulable on the same nodes as the reference pool,
// which a bare image cannot. Empty disables per-org pool provisioning (the
// self-host / bring-your-own-pool posture), so a single-tenant install is
// unaffected.
PoolTemplateName string
// PoolTemplateNamespace is where the reference pool lives. Empty defaults to
// the controller's own namespace (PoolSecretsNamespace, else "mitos").
PoolTemplateNamespace string
// OrgPoolName is the name the cloned pool is created under in each org
// namespace. It MUST match the pool name a create resolves (the SDK passes it
// as the image/pool argument). Empty defaults to PoolTemplateName.
OrgPoolName string
// OrgPoolWarmMin overrides warm.min on the cloned per-org pool. Default 0: no
// dormant warm husks are held, so N per-org pools cost nothing at rest (the
// snapshot is content-addressed and deduped per node); the first fork per org
// cold-starts and the pool warms on demand.
OrgPoolWarmMin int32
}

const (
Expand Down Expand Up @@ -128,9 +150,83 @@ func (r *OrgReconciler) ensureStack(ctx context.Context, org *v1.Org, ns string)
if err := r.ensurePoolSecretsRoleBinding(ctx, org, ns); err != nil {
return err
}
if err := r.ensureOrgPool(ctx, org, ns); err != nil {
return err
}
return nil
}

// ensureOrgPool clones the reference SandboxPool (PoolTemplateNamespace/
// PoolTemplateName) into the org namespace when a template is configured. It is a
// no-op otherwise (the self-host / bring-your-own-pool posture), so a
// single-tenant install is unaffected. Cloning the full spec carries placement,
// resources, snapshots, and network from one source of truth, so the per-org
// pool schedules exactly where the reference pool does. The pool is created ONLY
// (never updated): once it exists the reconciler leaves its spec alone, so a
// warm.min bumped by the autoscaler or an operator persists. Owner-referenced to
// the Org for cascade deletion.
func (r *OrgReconciler) ensureOrgPool(ctx context.Context, org *v1.Org, ns string) error {
if r.PoolTemplateName == "" {
return nil
}
tns := r.PoolTemplateNamespace
if tns == "" {
tns = r.PoolSecretsNamespace
}
if tns == "" {
tns = defaultControllerNamespace
}
name := r.OrgPoolName
if name == "" {
name = r.PoolTemplateName
}

// Create-once: if the per-org pool already exists, leave it alone.
existing := &v1.SandboxPool{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}}
if err := r.Get(ctx, client.ObjectKeyFromObject(existing), existing); err == nil {
return nil
} else if !apierrors.IsNotFound(err) {
return fmt.Errorf("get org pool %s/%s: %w", ns, name, err)
}

var tmpl v1.SandboxPool
if err := r.Get(ctx, client.ObjectKey{Namespace: tns, Name: r.PoolTemplateName}, &tmpl); err != nil {
// A missing template is an operator misconfiguration, not a transient: fail
// the reconcile so it surfaces on the Org status with actionable context.
return fmt.Errorf("get org pool template %s/%s: %w", tns, r.PoolTemplateName, err)
}

desired := buildOrgPoolFromTemplate(org, ns, name, &tmpl, r.OrgPoolWarmMin)
if err := r.setOwner(org, desired); err != nil {
return err
}
if err := r.Create(ctx, desired); err != nil && !apierrors.IsAlreadyExists(err) {
return fmt.Errorf("create org pool %s/%s: %w", ns, name, err)
}
return nil
}

// buildOrgPoolFromTemplate builds a per-org SandboxPool by deep-copying the
// reference pool's spec and overriding only warm.min. Placement, resources,
// snapshots, template image, and network are carried verbatim so the per-org
// pool schedules exactly like the reference. warmMin defaults the pool to no
// idle warm husks (0) so N per-org pools cost nothing at rest.
func buildOrgPoolFromTemplate(org *v1.Org, ns, name string, tmpl *v1.SandboxPool, warmMin int32) *v1.SandboxPool {
spec := *tmpl.Spec.DeepCopy()
if spec.Warm == nil {
spec.Warm = &v1.PoolWarm{}
}
spec.Warm.Min = warmMin
return &v1.SandboxPool{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: ns,
Labels: tenant.OrgLabels(org.Name),
},
Spec: spec,
}
}

// ensureNamespace creates or updates the org namespace with the org label and
// the PSA privileged labels.
//
Expand Down Expand Up @@ -213,7 +309,7 @@ func buildOrgResourceQuota(org *v1.Org, ns string, defMaxSandboxes int32, defCPU
}

hard := corev1.ResourceList{
corev1.ResourcePods: *resource.NewQuantity(int64(maxPods), resource.DecimalSI),
corev1.ResourcePods: *resource.NewQuantity(int64(maxPods), resource.DecimalSI),
"count/sandboxes.mitos.run": *resource.NewQuantity(int64(maxSandboxes), resource.DecimalSI),
corev1.ResourceLimitsCPU: cpu,
corev1.ResourceLimitsMemory: mem,
Expand Down
Loading
Loading