feat(sandbox): orchestrator listens tenant.sandbox_requested → Sandbox CR materialisation

PR #1633 wired CreateOrg to publish `tenant.sandbox_requested` when the
marketplace cart includes the sandbox product. Nobody was subscribing —
the event landed in NATS `catalyst.tenant.sandbox_requested` and aged
out unread, so no Sandbox CR (PR #1622) was ever minted and the
customer sat on a "Provisioning…" spinner forever.

This slice closes the loop. A new SandboxOrchestrator in tenant-service:

- Subscribes via events.MultiSubscriber (PR #1636) to the canonical
  NATS subject + legacy Kafka topic.
- Parses {tenant_id, org_slug, owner_id, owner_email, agents,
  sovereign, requested_at} and resolves the owner email (event field
  → store.GetMemberEmail → owner_id fallback).
- Materialises a Sandbox CR in catalyst-system (SANDBOX_NAMESPACE
  override) via a dynamic client, with spec per architecture §7:
  owner.email + owner.orgRef.slug, default quota (4 CPU / 8 Gi /
  50 Gi / 3 sessions), spec.agentCatalogue from the cart.
- Idempotent: Get-then-Create with AlreadyExists swallowed so NATS
  redeliveries + duplicate marketplace submits stay no-ops; the
  sandbox-controller remains SoR for spec mutations.

Wiring in main.go is best-effort — when no in-cluster config nor
KUBECONFIG is available (CI / dev loops) the orchestrator is skipped
with a Warn; the rest of the tenant service still boots.

Hard rules: no chart bump, no cluster writes outside of the Sandbox
Create call (sandbox-controller reconciles the rest), `go build ./...`
clean, `go test ./...` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Emrah Baysal 2026-05-18 09:08:33 +02:00
parent 22851d980d
commit 9dbffc9d5b
6 changed files with 1068 additions and 14 deletions

View File

@ -1,31 +1,61 @@
module github.com/openova-io/openova/core/services/tenant
go 1.22
go 1.26.0
require (
github.com/google/uuid v1.6.0
github.com/openova-io/openova/core/services/shared v0.0.0
go.mongodb.org/mongo-driver/v2 v2.1.0
k8s.io/apimachinery v0.31.1
k8s.io/client-go v0.31.1
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/imdario/mergo v0.3.6 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/nats-io/nats.go v1.37.0 // indirect
github.com/nats-io/nkeys v0.4.8 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/twmb/franz-go v1.18.0 // indirect
github.com/twmb/franz-go/pkg/kmsg v1.9.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
replace github.com/openova-io/openova/core/services/shared => ../shared

View File

@ -1,15 +1,65 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.37.0 h1:07rauXbVnnJvv1gfIyghFEo6lUcYRY0WXc3x7x0vUxE=
github.com/nats-io/nats.go v1.37.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8=
github.com/nats-io/nkeys v0.4.8 h1:+wee30071y3vCZAYRsnrmIPaOe47A/SkK/UBDPdIV70=
@ -18,10 +68,23 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twmb/franz-go v1.18.0 h1:25FjMZfdozBywVX+5xrWC2W+W76i0xykKjTdEeD2ejw=
github.com/twmb/franz-go v1.18.0/go.mod h1:zXCGy74M0p5FbXsLeASdyvfLFsBvTubVqctIaa5wQ+I=
github.com/twmb/franz-go/pkg/kmsg v1.9.0 h1:JojYUph2TKAau6SBtErXpXGC7E3gg4vGZMv9xFU/B6M=
github.com/twmb/franz-go/pkg/kmsg v1.9.0/go.mod h1:CMbfazviCyY6HM0SXuG5t9vOwYDHRCSrJJyBAe5paqg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
@ -30,37 +93,101 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.1.0 h1:/ELnVNjmfUKDsoBisXxuJL0noR9CfeUIrP7Yt3R+egg=
go.mongodb.org/mongo-driver/v2 v2.1.0/go.mod h1:AWiLRShSrk5RHQS3AEn3RL19rqOzVq49MCpWQ3x/huI=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU=
k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI=
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0=
k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

View File

@ -0,0 +1,392 @@
// Sandbox orchestrator (Wave 8 — session-orchestrator slice).
//
// Background. PR #1633 wired CreateOrg to publish a
// `tenant.sandbox_requested` event whenever the marketplace cart
// contains the "sandbox" product. Nobody was listening — so the event
// landed in NATS JetStream's `catalyst.tenant.sandbox_requested` subject
// and was simply retained until expiry. No Sandbox CR was ever minted,
// no namespace, no PVCs, no pty-server — the customer sat on a
// "Provisioning…" spinner forever.
//
// This file closes the loop. It subscribes to the canonical NATS
// subject (and the legacy Kafka topic during the migration window) via
// events.MultiSubscriber (PR #1636 — bridge_subscriber.go), parses the
// event payload, and materialises a Sandbox CR in `catalyst-system`
// (or whatever SANDBOX_NAMESPACE points at). The sandbox-controller
// (PR #1622 — sandbox.openova.io/v1) then reconciles the CR into the
// namespace + RBAC + PVCs + pty-server StatefulSet per Wave 1.
//
// Per products/sandbox/docs/architecture.md §7:
//
// spec:
// owner:
// email: <event.owner_email | event.owner_id>
// orgRef:
// slug: <event.org_slug>
// quota: # defaulted here — Wave 1
// cpu: "4"
// memory: "8Gi"
// storage: "50Gi"
// concurrentSessions: 3
// agentCatalogue: # from the cart picker
// - claude-code
// - cursor-agent
//
// Idempotency. The CR name is derived deterministically from the owner
// identity (email when available, owner_id otherwise) sanitised into a
// DNS-label-safe form, prefixed with `sandbox-`, truncated to 63 chars.
// A Get-then-Create dance handles "already exists" silently so that a
// NATS redelivery (or a duplicate marketplace submit) does not error.
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/openova-io/openova/core/services/shared/events"
)
// SandboxGVR is the namespace-scoped CR the sandbox-controller reconciles
// (core/controllers/sandbox/internal/sandboxapi/types.go). Defined here
// rather than imported from the controllers monorepo to keep
// tenant-service's go.mod free of the controller-runtime dependency
// tree — the orchestrator only needs the dynamic-client GVR shape.
var SandboxGVR = schema.GroupVersionResource{
Group: "sandbox.openova.io",
Version: "v1",
Resource: "sandboxes",
}
// DefaultSandboxNamespace is the namespace the orchestrator writes
// Sandbox CRs into when SANDBOX_NAMESPACE is unset. catalyst-system is
// the Sovereign-wide control-plane namespace (matches the UserAccess
// CR target in user_access_owner_seed.go) so the sandbox-controller's
// in-cluster cache watches a single namespace.
const DefaultSandboxNamespace = "catalyst-system"
// SandboxClient is the narrow Kubernetes surface the orchestrator uses
// to materialise a Sandbox CR. Implementations:
// - production: a thin wrapper around dynamic.Interface in main.go.
// - tests: a fake recording every Get/Create call.
//
// Keeping this surface interface-shaped lets us unit-test the consumer
// without standing up envtest or pulling in client-go test stubs.
type SandboxClient interface {
// Get returns the existing Sandbox in (namespace, name) as
// unstructured. apierrors.IsNotFound on err signals "create me".
Get(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error)
// Create writes a Sandbox CR. apierrors.IsAlreadyExists is treated
// as a benign idempotency no-op by the orchestrator.
Create(ctx context.Context, namespace string, obj *unstructured.Unstructured) error
}
// memberEmailLookup is the narrow store slice the orchestrator uses to
// enrich the event with the owner's email when the publisher did not
// inline it. Optional — the consumer falls back to owner_id when nil.
type memberEmailLookup interface {
GetMemberEmail(ctx context.Context, tenantID, userID string) (string, error)
}
// SandboxRequestedPayload mirrors the JSON the tenant service emits in
// handlers.go (PR #1633). owner_email is best-effort; the orchestrator
// falls back to a store lookup, then to owner_id, when it is empty.
type SandboxRequestedPayload struct {
TenantID string `json:"tenant_id"`
OrgSlug string `json:"org_slug"`
OwnerID string `json:"owner_id"`
OwnerEmail string `json:"owner_email"`
Agents []string `json:"agents"`
Sovereign string `json:"sovereign"`
RequestedAt string `json:"requested_at"`
}
// SandboxOrchestrator consumes tenant.sandbox_requested events and
// materialises Sandbox CRs. Field semantics:
//
// - Client — Kubernetes write surface. Required.
// - Namespace — target namespace (defaults to catalyst-system).
// - Members — optional store slice for owner-email enrichment.
// When nil the consumer skips the lookup and uses
// payload.OwnerEmail or payload.OwnerID directly.
// - DefaultQuota — quota stamped onto every new Sandbox. Per
// architecture §7 the Wave 1 defaults are 4 CPU / 8 Gi / 50 Gi /
// 3 sessions; overridden via main.go for sized plans.
type SandboxOrchestrator struct {
Client SandboxClient
Namespace string
Members memberEmailLookup
DefaultQuota SandboxQuota
}
// SandboxQuota is the small DTO the orchestrator stamps into spec.quota.
// Mirrors core/controllers/sandbox/internal/sandboxapi/types.go's
// SandboxQuota without forcing a dependency on the controllers module.
type SandboxQuota struct {
CPU string
Memory string
Storage string
ConcurrentSessions int
}
// DefaultSandboxQuota returns the Wave 1 quota baseline per
// architecture §7.
func DefaultSandboxQuota() SandboxQuota {
return SandboxQuota{
CPU: "4",
Memory: "8Gi",
Storage: "50Gi",
ConcurrentSessions: 3,
}
}
// Start subscribes to the supplied BrokerSubscriber and dispatches every
// tenant.sandbox_requested event into handleSandboxRequested. Non-match
// event types are ignored (the subscriber may multiplex via a single
// stream when the operator narrows the EventTypes filter).
func (o *SandboxOrchestrator) Start(ctx context.Context, sub events.BrokerSubscriber) error {
if o == nil {
return errors.New("sandbox-orchestrator: nil receiver")
}
if o.Client == nil {
return errors.New("sandbox-orchestrator: nil SandboxClient")
}
slog.Info("starting sandbox-orchestrator consumer",
"namespace", o.namespace(),
"default_quota_cpu", o.quota().CPU,
"default_quota_memory", o.quota().Memory,
)
return sub.Subscribe(ctx, func(event *events.Event) error {
if event.Type != "tenant.sandbox_requested" {
return nil
}
return o.handleSandboxRequested(ctx, event)
})
}
// handleSandboxRequested is the dispatcher. Returning a non-nil error
// triggers a Nak so the broker redelivers; malformed payloads are
// ack-skipped (log + nil) to avoid poison-pill hot loops.
func (o *SandboxOrchestrator) handleSandboxRequested(ctx context.Context, event *events.Event) error {
var payload SandboxRequestedPayload
if err := json.Unmarshal(event.Data, &payload); err != nil {
slog.Error("sandbox-orchestrator: malformed payload — ack to skip",
"event_id", event.ID, "error", err)
return nil
}
if payload.OwnerID == "" && payload.OwnerEmail == "" {
slog.Warn("sandbox-orchestrator: payload has no owner identity — skipping",
"event_id", event.ID, "tenant_id", payload.TenantID)
return nil
}
if payload.OrgSlug == "" {
slog.Warn("sandbox-orchestrator: payload missing org_slug — skipping",
"event_id", event.ID, "tenant_id", payload.TenantID)
return nil
}
email := payload.OwnerEmail
if email == "" && o.Members != nil && payload.TenantID != "" && payload.OwnerID != "" {
if v, err := o.Members.GetMemberEmail(ctx, payload.TenantID, payload.OwnerID); err == nil && v != "" {
email = v
}
}
// Final fallback: owner_id (UUID-shaped). Better than dropping the
// event when the JWT didn't carry an email and the member row was
// inserted without one.
if email == "" {
email = payload.OwnerID
}
name := sandboxCRName(email, payload.OwnerID)
ns := o.namespace()
// Idempotency: Get first. The sandbox-controller is the SoR for
// spec.* once the CR exists, so a re-emit of the same event MUST
// NOT overwrite a controller-tweaked CR.
if existing, err := o.Client.Get(ctx, ns, name); err == nil && existing != nil {
slog.Info("sandbox-orchestrator: Sandbox already exists — no-op",
"namespace", ns, "name", name,
"event_id", event.ID, "tenant_id", payload.TenantID)
return nil
} else if err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("get Sandbox %s/%s: %w", ns, name, err)
}
obj := o.buildSandbox(name, ns, email, payload)
if err := o.Client.Create(ctx, ns, obj); err != nil {
if apierrors.IsAlreadyExists(err) {
slog.Info("sandbox-orchestrator: Sandbox already exists (race) — no-op",
"namespace", ns, "name", name)
return nil
}
return fmt.Errorf("create Sandbox %s/%s: %w", ns, name, err)
}
slog.Info("sandbox-orchestrator: Sandbox CR materialised",
"namespace", ns, "name", name,
"event_id", event.ID, "tenant_id", payload.TenantID,
"org_slug", payload.OrgSlug, "agents", payload.Agents)
return nil
}
// buildSandbox composes the unstructured CR. Shape mirrors
// products/sandbox/docs/architecture.md §7 + the SandboxSpec Go type at
// core/controllers/sandbox/internal/sandboxapi/types.go.
func (o *SandboxOrchestrator) buildSandbox(name, ns, email string, p SandboxRequestedPayload) *unstructured.Unstructured {
obj := &unstructured.Unstructured{}
obj.SetGroupVersionKind(schema.GroupVersionKind{
Group: SandboxGVR.Group,
Version: SandboxGVR.Version,
Kind: "Sandbox",
})
obj.SetName(name)
obj.SetNamespace(ns)
labels := map[string]string{
"openova.io/organization": p.OrgSlug,
"openova.io/owner-id": p.OwnerID,
"openova.io/managed-by": "tenant-service",
}
if p.Sovereign != "" {
labels["openova.io/sovereign"] = p.Sovereign
}
obj.SetLabels(labels)
annotations := map[string]string{
"openova.io/source-event": "tenant.sandbox_requested",
"openova.io/source-tenant-id": p.TenantID,
"openova.io/source-requested-at": p.RequestedAt,
}
obj.SetAnnotations(annotations)
q := o.quota()
spec := map[string]any{
"owner": map[string]any{
"email": email,
"orgRef": map[string]any{
"slug": p.OrgSlug,
},
},
"quota": map[string]any{
"cpu": q.CPU,
"memory": q.Memory,
"storage": q.Storage,
"concurrentSessions": int64(q.ConcurrentSessions),
},
}
if len(p.Agents) > 0 {
// Copy into []any so json.Marshal renders a plain JSON list
// rather than a typed []string (unstructured accepts both but
// the controller's deepcopy expects []interface{}).
out := make([]any, 0, len(p.Agents))
for _, a := range p.Agents {
a = strings.TrimSpace(a)
if a == "" {
continue
}
out = append(out, a)
}
if len(out) > 0 {
spec["agentCatalogue"] = out
}
}
obj.Object["spec"] = spec
// Empty TypeMeta is set above; we also want CreationTimestamp empty
// so apiserver assigns it. SetCreationTimestamp(metav1.Time{}) is
// implicit for a fresh unstructured.
_ = metav1.Time{}
return obj
}
// namespace returns the target namespace, defaulting to
// DefaultSandboxNamespace.
func (o *SandboxOrchestrator) namespace() string {
if o == nil || strings.TrimSpace(o.Namespace) == "" {
return DefaultSandboxNamespace
}
return o.Namespace
}
// quota returns the configured default quota, defaulting to the Wave 1
// baseline.
func (o *SandboxOrchestrator) quota() SandboxQuota {
if o == nil {
return DefaultSandboxQuota()
}
if o.DefaultQuota.CPU == "" && o.DefaultQuota.Memory == "" {
return DefaultSandboxQuota()
}
q := o.DefaultQuota
if q.CPU == "" {
q.CPU = "4"
}
if q.Memory == "" {
q.Memory = "8Gi"
}
if q.Storage == "" {
q.Storage = "50Gi"
}
if q.ConcurrentSessions <= 0 {
q.ConcurrentSessions = 3
}
return q
}
// sandboxCRName builds the deterministic CR name. Prefers a sanitised
// email when available so operators reading `kubectl get sandbox -A`
// see human-scannable identities; falls back to owner_id (typically a
// UUID) when the publisher didn't carry one. The result is bounded at
// 63 chars (RFC 1123) and trimmed of trailing hyphens.
func sandboxCRName(email, ownerID string) string {
candidate := strings.TrimSpace(email)
if candidate == "" {
candidate = strings.TrimSpace(ownerID)
}
leaf := sanitizeSandboxLeaf(candidate)
if leaf == "" {
// Pathological — every char stripped. Use a stable literal so
// the consumer never returns an empty-name Create.
leaf = "user"
}
name := "sandbox-" + leaf
if len(name) > 63 {
name = name[:63]
}
name = strings.TrimRight(name, "-")
return name
}
// sanitizeSandboxLeaf is the same shape as
// organization_controller.sanitizeEmail but local to this package so
// the orchestrator does not pull in the controllers module.
func sanitizeSandboxLeaf(in string) string {
out := strings.ToLower(in)
out = strings.ReplaceAll(out, "@", "-at-")
out = strings.ReplaceAll(out, ".", "-")
out = strings.ReplaceAll(out, "+", "-plus-")
out = strings.ReplaceAll(out, "_", "-")
// Strip anything not [a-z0-9-].
var b strings.Builder
b.Grow(len(out))
for _, r := range out {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
out = b.String()
// Collapse runs of hyphens (-- → -).
for strings.Contains(out, "--") {
out = strings.ReplaceAll(out, "--", "-")
}
return strings.Trim(out, "-")
}

View File

@ -0,0 +1,358 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"testing"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/openova-io/openova/core/services/shared/events"
)
// fakeSandboxClient records every Get / Create the orchestrator makes
// and lets the test stage NotFound or AlreadyExists.
type fakeSandboxClient struct {
getReturn *unstructured.Unstructured
getErr error
createErr error
getCalls []string
createObjs []*unstructured.Unstructured
}
func (f *fakeSandboxClient) Get(_ context.Context, ns, name string) (*unstructured.Unstructured, error) {
f.getCalls = append(f.getCalls, ns+"/"+name)
return f.getReturn, f.getErr
}
func (f *fakeSandboxClient) Create(_ context.Context, _ string, obj *unstructured.Unstructured) error {
f.createObjs = append(f.createObjs, obj)
return f.createErr
}
// fakeMemberEmailLookup stands in for *store.Store for the email-lookup
// fallback path. nil error + empty result keeps the orchestrator on the
// owner_id fallback (which is the production CreateOrg path today).
type fakeMemberEmailLookup struct {
email string
err error
}
func (f *fakeMemberEmailLookup) GetMemberEmail(_ context.Context, _, _ string) (string, error) {
return f.email, f.err
}
// notFoundErr returns an apierrors.IsNotFound-compatible error so the
// orchestrator's Get-then-Create dance treats the fake as "no existing
// Sandbox, proceed to Create".
func notFoundErr() error {
return apierrors.NewNotFound(
schema.GroupResource{Group: "sandbox.openova.io", Resource: "sandboxes"},
"sandbox-x",
)
}
func mkSandboxRequestedEvent(t *testing.T, payload SandboxRequestedPayload) *events.Event {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
return &events.Event{
ID: "evt-sb-001",
Type: "tenant.sandbox_requested",
TenantID: payload.TenantID,
Data: body,
}
}
// TestSandboxHandleHappyPath_CreatesCR — primary contract test.
//
// A valid tenant.sandbox_requested event must:
// - call Get exactly once (idempotency check) and proceed past NotFound,
// - call Create exactly once with a Sandbox CR shaped per architecture §7
// (spec.owner.email + spec.owner.orgRef.slug + spec.quota defaults +
// spec.agentCatalogue from event.Agents),
// - return nil so the broker acks.
func TestSandboxHandleHappyPath_CreatesCR(t *testing.T) {
client := &fakeSandboxClient{getErr: notFoundErr()}
o := &SandboxOrchestrator{
Client: client,
Namespace: "catalyst-system",
}
evt := mkSandboxRequestedEvent(t, SandboxRequestedPayload{
TenantID: "tenant-1",
OrgSlug: "acme",
OwnerID: "user-uuid-1",
OwnerEmail: "emrah@acme.com",
Agents: []string{"claude-code", "cursor-agent"},
Sovereign: "rzk7.openova.io",
RequestedAt: "2026-05-18T10:00:00Z",
})
if err := o.handleSandboxRequested(context.Background(), evt); err != nil {
t.Fatalf("handleSandboxRequested: %v", err)
}
if len(client.getCalls) != 1 {
t.Fatalf("want exactly 1 Get call, got %v", client.getCalls)
}
if len(client.createObjs) != 1 {
t.Fatalf("want exactly 1 Create call, got %d", len(client.createObjs))
}
got := client.createObjs[0]
if got.GetKind() != "Sandbox" {
t.Fatalf("kind want Sandbox, got %q", got.GetKind())
}
if got.GetAPIVersion() != "sandbox.openova.io/v1" {
t.Fatalf("apiVersion want sandbox.openova.io/v1, got %q", got.GetAPIVersion())
}
if got.GetNamespace() != "catalyst-system" {
t.Fatalf("ns want catalyst-system, got %q", got.GetNamespace())
}
wantName := "sandbox-emrah-at-acme-com"
if got.GetName() != wantName {
t.Fatalf("name want %q, got %q", wantName, got.GetName())
}
spec, ok := got.Object["spec"].(map[string]any)
if !ok {
t.Fatalf("spec missing or wrong shape: %#v", got.Object["spec"])
}
owner, ok := spec["owner"].(map[string]any)
if !ok {
t.Fatalf("spec.owner missing: %#v", spec["owner"])
}
if owner["email"] != "emrah@acme.com" {
t.Fatalf("spec.owner.email want emrah@acme.com, got %v", owner["email"])
}
orgRef, _ := owner["orgRef"].(map[string]any)
if orgRef["slug"] != "acme" {
t.Fatalf("spec.owner.orgRef.slug want acme, got %v", orgRef["slug"])
}
quota, ok := spec["quota"].(map[string]any)
if !ok {
t.Fatalf("spec.quota missing: %#v", spec["quota"])
}
if quota["cpu"] != "4" || quota["memory"] != "8Gi" || quota["storage"] != "50Gi" {
t.Fatalf("default quota mismatch: %#v", quota)
}
if cs, _ := quota["concurrentSessions"].(int64); cs != 3 {
t.Fatalf("concurrentSessions want 3, got %v (%T)", quota["concurrentSessions"], quota["concurrentSessions"])
}
cat, ok := spec["agentCatalogue"].([]any)
if !ok || len(cat) != 2 || cat[0] != "claude-code" || cat[1] != "cursor-agent" {
t.Fatalf("agentCatalogue mismatch: %#v", spec["agentCatalogue"])
}
if labels := got.GetLabels(); labels["openova.io/organization"] != "acme" {
t.Fatalf("label openova.io/organization want acme, got %q", labels["openova.io/organization"])
}
if ann := got.GetAnnotations(); ann["openova.io/source-event"] != "tenant.sandbox_requested" {
t.Fatalf("annotation openova.io/source-event missing: %v", ann)
}
}
// TestSandboxHandle_IdempotentOnAlreadyExists — Get returns an existing
// CR ⇒ Create must NOT be called, return nil so the broker acks.
func TestSandboxHandle_IdempotentOnExisting(t *testing.T) {
existing := &unstructured.Unstructured{}
existing.SetGroupVersionKind(schema.GroupVersionKind{
Group: "sandbox.openova.io", Version: "v1", Kind: "Sandbox",
})
existing.SetName("sandbox-emrah-at-acme-com")
existing.SetNamespace("catalyst-system")
existing.SetCreationTimestamp(metav1.Now())
client := &fakeSandboxClient{getReturn: existing}
o := &SandboxOrchestrator{Client: client}
evt := mkSandboxRequestedEvent(t, SandboxRequestedPayload{
TenantID: "tenant-1",
OrgSlug: "acme",
OwnerID: "user-uuid-1",
OwnerEmail: "emrah@acme.com",
})
if err := o.handleSandboxRequested(context.Background(), evt); err != nil {
t.Fatalf("handleSandboxRequested: %v", err)
}
if len(client.createObjs) != 0 {
t.Fatalf("Create must not be called when Sandbox already exists, got %d calls", len(client.createObjs))
}
}
// TestSandboxHandle_AlreadyExistsRace — Get returns NotFound but Create
// races against another writer and returns AlreadyExists. Must be
// swallowed (return nil) so the broker acks and we don't re-loop.
func TestSandboxHandle_AlreadyExistsRace(t *testing.T) {
client := &fakeSandboxClient{
getErr: notFoundErr(),
createErr: apierrors.NewAlreadyExists(
schema.GroupResource{Group: "sandbox.openova.io", Resource: "sandboxes"},
"sandbox-emrah-at-acme-com",
),
}
o := &SandboxOrchestrator{Client: client}
evt := mkSandboxRequestedEvent(t, SandboxRequestedPayload{
TenantID: "tenant-1",
OrgSlug: "acme",
OwnerID: "user-uuid-1",
OwnerEmail: "emrah@acme.com",
})
if err := o.handleSandboxRequested(context.Background(), evt); err != nil {
t.Fatalf("AlreadyExists race must be swallowed, got: %v", err)
}
}
// TestSandboxHandle_FallsBackToOwnerIDWhenEmailMissing — the event
// shape from the current CreateOrg path does not include owner_email.
// The orchestrator must:
// - call Members.GetMemberEmail (when wired) — fake returns "" here,
// - fall through to using OwnerID as both name and spec.owner.email,
// - still build a valid CR.
func TestSandboxHandle_FallsBackToOwnerIDWhenEmailMissing(t *testing.T) {
client := &fakeSandboxClient{getErr: notFoundErr()}
lookup := &fakeMemberEmailLookup{} // returns ""
o := &SandboxOrchestrator{Client: client, Members: lookup}
evt := mkSandboxRequestedEvent(t, SandboxRequestedPayload{
TenantID: "tenant-1",
OrgSlug: "acme",
OwnerID: "user-uuid-7",
})
if err := o.handleSandboxRequested(context.Background(), evt); err != nil {
t.Fatalf("handleSandboxRequested: %v", err)
}
if len(client.createObjs) != 1 {
t.Fatalf("want one Create, got %d", len(client.createObjs))
}
got := client.createObjs[0]
wantName := "sandbox-user-uuid-7"
if got.GetName() != wantName {
t.Fatalf("name want %q, got %q", wantName, got.GetName())
}
spec := got.Object["spec"].(map[string]any)
owner := spec["owner"].(map[string]any)
if owner["email"] != "user-uuid-7" {
t.Fatalf("owner.email fallback should equal owner_id, got %v", owner["email"])
}
}
// TestSandboxHandle_MissingOwnerSkips — no owner anywhere ⇒ ack-skip.
func TestSandboxHandle_MissingOwnerSkips(t *testing.T) {
client := &fakeSandboxClient{getErr: notFoundErr()}
o := &SandboxOrchestrator{Client: client}
evt := mkSandboxRequestedEvent(t, SandboxRequestedPayload{
TenantID: "tenant-1",
OrgSlug: "acme",
})
if err := o.handleSandboxRequested(context.Background(), evt); err != nil {
t.Fatalf("missing owner must ack-skip, got: %v", err)
}
if len(client.createObjs) != 0 {
t.Fatalf("no Create should fire when owner is unknown, got %d", len(client.createObjs))
}
}
// TestSandboxHandle_MissingOrgSlugSkips — no slug ⇒ ack-skip.
func TestSandboxHandle_MissingOrgSlugSkips(t *testing.T) {
client := &fakeSandboxClient{getErr: notFoundErr()}
o := &SandboxOrchestrator{Client: client}
evt := mkSandboxRequestedEvent(t, SandboxRequestedPayload{
TenantID: "tenant-1",
OwnerID: "user-uuid-1",
OwnerEmail: "emrah@acme.com",
})
if err := o.handleSandboxRequested(context.Background(), evt); err != nil {
t.Fatalf("missing org_slug must ack-skip, got: %v", err)
}
if len(client.createObjs) != 0 {
t.Fatalf("no Create should fire when org_slug is missing, got %d", len(client.createObjs))
}
}
// TestSandboxHandle_MalformedPayloadAcks — poison pill ⇒ ack-skip, no
// Create. Matches the members_consumer convention.
func TestSandboxHandle_MalformedPayloadAcks(t *testing.T) {
client := &fakeSandboxClient{getErr: notFoundErr()}
o := &SandboxOrchestrator{Client: client}
evt := &events.Event{
ID: "evt-bad",
Type: "tenant.sandbox_requested",
TenantID: "tenant-1",
Data: json.RawMessage(`"not an object"`),
}
if err := o.handleSandboxRequested(context.Background(), evt); err != nil {
t.Fatalf("malformed payload must ack-skip, got: %v", err)
}
if len(client.createObjs) != 0 {
t.Fatalf("no Create on poison pill, got %d", len(client.createObjs))
}
}
// TestSandboxHandle_CreateErrorPropagates — a real Create error
// (anything other than AlreadyExists) must surface so the broker
// redelivers.
func TestSandboxHandle_CreateErrorPropagates(t *testing.T) {
client := &fakeSandboxClient{
getErr: notFoundErr(),
createErr: fmt.Errorf("apiserver: timeout"),
}
o := &SandboxOrchestrator{Client: client}
evt := mkSandboxRequestedEvent(t, SandboxRequestedPayload{
TenantID: "tenant-1",
OrgSlug: "acme",
OwnerID: "user-uuid-1",
OwnerEmail: "emrah@acme.com",
})
if err := o.handleSandboxRequested(context.Background(), evt); err == nil {
t.Fatal("expected Create error to propagate, got nil")
}
}
// TestSandboxHandle_GetErrorPropagates — a real Get error (anything
// other than NotFound) must surface so the broker redelivers.
func TestSandboxHandle_GetErrorPropagates(t *testing.T) {
client := &fakeSandboxClient{getErr: fmt.Errorf("apiserver: connection refused")}
o := &SandboxOrchestrator{Client: client}
evt := mkSandboxRequestedEvent(t, SandboxRequestedPayload{
TenantID: "tenant-1",
OrgSlug: "acme",
OwnerID: "user-uuid-1",
OwnerEmail: "emrah@acme.com",
})
if err := o.handleSandboxRequested(context.Background(), evt); err == nil {
t.Fatal("expected Get error to propagate, got nil")
}
if len(client.createObjs) != 0 {
t.Fatalf("no Create should fire when Get failed, got %d", len(client.createObjs))
}
}
// TestSandboxCRName_LongEmailTruncates — name must stay within RFC 1123
// (63 chars) and not end on a hyphen.
func TestSandboxCRName_LongEmailTruncates(t *testing.T) {
long := "supercalifragilisticexpialidocious-very-long-name@verylongtenant.openova.io"
got := sandboxCRName(long, "")
if len(got) > 63 {
t.Fatalf("name length %d > 63: %q", len(got), got)
}
if got[len(got)-1] == '-' {
t.Fatalf("name ends on hyphen: %q", got)
}
if got == "sandbox-" || got == "sandbox-user" {
t.Fatalf("name truncation lost all entropy: %q", got)
}
}
// TestSandboxCRName_PathologicalEmptyEmail — empty email + empty owner_id
// (caller's bug) collapses to the stable "sandbox-user" literal so the
// dynamic-client Create does not panic on an empty name.
func TestSandboxCRName_PathologicalEmpty(t *testing.T) {
if got := sandboxCRName("", ""); got != "sandbox-user" {
t.Fatalf("want sandbox-user, got %q", got)
}
}

View File

@ -11,6 +11,12 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"github.com/openova-io/openova/core/services/shared/events"
"github.com/openova-io/openova/core/services/shared/health"
"github.com/openova-io/openova/core/services/shared/middleware"
@ -203,6 +209,65 @@ func main() {
"kafka_enabled", membersKafkaConsumer != nil,
"group", "tenant-members-cleanup")
// Sandbox orchestrator — consumes tenant.sandbox_requested (PR #1633)
// and materialises a Sandbox CR in catalyst-system that the
// sandbox-controller (PR #1622) reconciles into namespace + RBAC +
// PVCs + pty-server. The Kubernetes write surface is optional: when
// no in-cluster config OR KUBECONFIG is available (CI / dev loops)
// we skip starting the orchestrator with a Warn so the rest of the
// tenant service still boots. On Sovereigns the ServiceAccount-bound
// in-cluster config is always present so the consumer is always on.
sandboxNamespace := getEnv("SANDBOX_NAMESPACE", handlers.DefaultSandboxNamespace)
dyn := buildDynamicClient()
if dyn == nil {
slog.Warn("sandbox-orchestrator: kubernetes client unavailable — orchestrator disabled",
"namespace", sandboxNamespace,
"reason", "no in-cluster config and KUBECONFIG unset (or build failed)")
} else {
var sandboxKafkaConsumer *events.Consumer
if redpandaBrokersRaw != "" {
kc, err := events.NewConsumer(
strings.Split(redpandaBrokersRaw, ","),
"sandbox-orchestrator",
[]string{"sme.tenant.events"},
)
if err != nil {
slog.Error("failed to create sandbox-orchestrator kafka consumer", "error", err)
os.Exit(1)
}
sandboxKafkaConsumer = kc
}
sandboxSub, err := events.NewMultiSubscriber(events.MultiSubscriberConfig{
NATS: natsConn,
Kafka: sandboxKafkaConsumer,
Group: "sandbox-orchestrator",
EventTypes: []string{"tenant.sandbox_requested"},
})
if err != nil {
slog.Error("failed to create sandbox-orchestrator subscriber", "error", err)
os.Exit(1)
}
defer sandboxSub.Close()
orchestrator := &handlers.SandboxOrchestrator{
Client: newDynamicSandboxClient(dyn),
Namespace: sandboxNamespace,
Members: tenantStore,
DefaultQuota: handlers.DefaultSandboxQuota(),
}
go func() {
if err := orchestrator.Start(context.Background(), sandboxSub); err != nil {
slog.Error("sandbox-orchestrator consumer stopped", "error", err)
}
}()
slog.Info("sandbox-orchestrator consumer started",
"nats_subject", "catalyst.tenant.sandbox_requested",
"kafka_topic", "sme.tenant.events",
"namespace", sandboxNamespace,
"nats_enabled", natsConn != nil,
"kafka_enabled", sandboxKafkaConsumer != nil,
"group", "sandbox-orchestrator")
}
// Build the main mux.
mux := http.NewServeMux()
@ -255,3 +320,60 @@ func getEnv(key, fallback string) string {
}
return fallback
}
// buildDynamicClient returns an in-cluster dynamic.Interface (production
// Sovereign path) or one built from $KUBECONFIG (dev), or nil when both
// fail. Returning nil is the explicit "orchestrator disabled" signal —
// see the caller's Warn log; the rest of the tenant service still boots.
func buildDynamicClient() dynamic.Interface {
if cfg, err := rest.InClusterConfig(); err == nil {
c, err := dynamic.NewForConfig(cfg)
if err != nil {
slog.Warn("sandbox-orchestrator: in-cluster dynamic client build failed",
"error", err)
return nil
}
return c
}
kc := os.Getenv("KUBECONFIG")
if kc == "" {
return nil
}
cfg, err := clientcmd.BuildConfigFromFlags("", kc)
if err != nil {
slog.Warn("sandbox-orchestrator: kubeconfig load failed",
"kubeconfig", kc, "error", err)
return nil
}
c, err := dynamic.NewForConfig(cfg)
if err != nil {
slog.Warn("sandbox-orchestrator: kubeconfig dynamic client build failed",
"error", err)
return nil
}
return c
}
// dynamicSandboxClient is the production adapter implementing
// handlers.SandboxClient on top of dynamic.Interface. Kept here (not
// in handlers/) so handlers/ stays free of client-go transitive deps
// and unit tests can run without a Kubernetes test rig.
type dynamicSandboxClient struct {
dyn dynamic.Interface
}
func newDynamicSandboxClient(d dynamic.Interface) *dynamicSandboxClient {
return &dynamicSandboxClient{dyn: d}
}
// Get fetches the Sandbox CR in (namespace, name).
func (c *dynamicSandboxClient) Get(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) {
return c.dyn.Resource(handlers.SandboxGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
}
// Create writes the Sandbox CR. Returns the underlying api error so
// callers can distinguish IsAlreadyExists.
func (c *dynamicSandboxClient) Create(ctx context.Context, namespace string, obj *unstructured.Unstructured) error {
_, err := c.dyn.Resource(handlers.SandboxGVR).Namespace(namespace).Create(ctx, obj, metav1.CreateOptions{})
return err
}

View File

@ -459,3 +459,28 @@ func (s *Store) GetMemberRole(ctx context.Context, tenantID, userID string) (str
}
return m.Role, nil
}
// GetMemberEmail returns the email recorded on the member row, or
// empty string when the member does not exist. Used by the sandbox
// orchestrator (handlers/sandbox_consumer.go) to enrich the
// tenant.sandbox_requested event with the owner's email when the
// publisher did not inline it. Members inserted via CreateOrg do not
// currently set Email (only UserID), so this returns "" for the
// owner-just-created path — the orchestrator then falls back to
// owner_id. The method exists for the eventual InviteMember path where
// Email is populated up-front.
func (s *Store) GetMemberEmail(ctx context.Context, tenantID, userID string) (string, error) {
filter := bson.D{
{Key: "tenant_id", Value: tenantID},
{Key: "user_id", Value: userID},
}
var m Member
err := s.members().FindOne(ctx, filter).Decode(&m)
if err != nil {
if err == mongo.ErrNoDocuments {
return "", nil
}
return "", fmt.Errorf("store: get member email: %w", err)
}
return m.Email, nil
}