Files
zeekayandHanzo Dev 03fcdb55d6 Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers; omitzero is not applied, since it changes what the JSON says.


Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 19:27:30 -07:00

714 lines
24 KiB
Go

// Copyright 2025 Hanzo Industries Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// compute.go is the canonical /v1 resell compute surface: the cached
// DigitalOcean catalog (regions/sizes/GPUs, resale-priced) and per-org machine
// operations backed by the configured cloud account. Every machine endpoint is
// scoped to the caller's org, which is derived from the authenticated IAM
// identity (never trusted from a client-supplied field). Beneath org, an OPTIONAL
// app > project scope (from the gateway-threaded tenant context, or the launch
// body as a fallback) sharpens the analytics rollup without ever gating a launch.
// Launch is metered through commerce (per-org debit); a dryRun returns a price
// quote and spends nothing.
//
// A "fleet" is not a separate entity — it is just N machines launched in one
// batch (count>1) named "<name>-000", "<name>-001", … and grouped by the
// ?name= (prefix) / ?kind= / ?project= list filters. There is exactly ONE way a
// machine is launched, billed and destroyed (launchMetered), whether alone or as
// one of a batch.
package controllers
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/compute/object"
"github.com/hanzoai/compute/service"
)
// resolveComputeOrg returns the org that owns this request, IAM-native and
// spoof-proof:
// 1. an authenticated user's org (Owner claim) is authoritative and a
// client-supplied owner is ignored for real users; or
// 2. for a trusted service call (authenticated as the visor app via ApiFilter,
// e.g. the console proxy), the explicit owner query param is honored — the
// same model the PaaS proxy uses (PAAS_ORG_ID). Only a caller holding the
// KMS-held visor client secret reaches this branch.
//
// Empty result means "no org context" and the caller fails closed.
//
// The rule itself lives in principal (agent_binding.go) and is read from there,
// not restated here: a typed op declares the Bearer and the ?owner as INPUTS and
// so cannot reach a request to ask, while these untyped handlers fetch both off
// the Ctx. Two ways to obtain the same two strings is fine; two answers to
// "whose org is this" would not be.
func (c *ApiController) resolveComputeOrg() string {
// The address first, for the same reason everything else reads it first: a
// resource names its owner in the path, and the authorization seam — which
// runs as middleware and cannot see route parameters — reads that same
// segment. Owner in the query and owner in the path must not be able to
// disagree, so there is one place that decides which is which.
owner := c.Ctx.Param("owner")
if owner == "" {
owner = c.Ctx.Query("owner")
}
_, org := principal(c.Ctx.Header("Authorization"), owner)
return org
}
// resolveComputeApp / resolveComputeProject return the OPTIONAL app / project
// scope beneath the owning org, resolved the same ONE way: the gateway-threaded
// tenant context (X-App-ID / X-Project-ID, populated by routers.TenantContextFilter
// and read back through object's getters) is authoritative; a direct API caller
// that sends no header may pass the value in the launch body as a fallback. Empty
// means "no such scope" — unlike org, app and project are optional finer
// attribution and NEVER gate a launch. The billing key stays org; app/project only
// sharpen the analytics rollup (org > app > project).
func (c *ApiController) resolveComputeApp(fallback string) string {
if a := object.GetTenantAppID(c.Ctx); a != "" {
return a
}
return strings.TrimSpace(fallback)
}
func (c *ApiController) resolveComputeProject(fallback string) string {
if p := object.GetTenantProjectID(c.Ctx); p != "" {
return p
}
return strings.TrimSpace(fallback)
}
// ---- Catalog (cached, resale-priced) ----
// GetComputeRegions
// @Title GetComputeRegions
// @Tag Compute API
// @Description list DigitalOcean regions (cached)
// @Success 200 {object} controllers.Response
// @router /regions [get]
func (c *ApiController) GetComputeRegions() {
if !service.ComputeConfigured() {
c.ResponseError(refuseNoCompute)
return
}
regions, err := service.ListRegions()
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(regions)
}
// GetComputeSizes
// @Title GetComputeSizes
// @Tag Compute API
// @Description list resale-priced sizes (cached)
// @Success 200 {object} controllers.Response
// @router /sizes [get]
func (c *ApiController) GetComputeSizes() {
if !service.ComputeConfigured() {
c.ResponseError(refuseNoCompute)
return
}
sizes, err := service.ListSizes()
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(sizes)
}
// GetComputeGPUs
// @Title GetComputeGPUs
// @Tag Compute API
// @Description list resale-priced GPU sizes (cached)
// @Success 200 {object} controllers.Response
// @router /gpus [get]
func (c *ApiController) GetComputeGPUs() {
if !service.ComputeConfigured() {
c.ResponseError(refuseNoCompute)
return
}
gpus, err := service.ListGPUSizes()
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(gpus)
}
// ---- Machines (per-org, configured cloud account) ----
// filterMachines narrows a machine list by an optional kind (exact, matched on
// the machine's canonical kind), an optional name prefix (matched on the droplet
// DisplayName), and an optional project (exact, matched on the machine's
// hanzo-project scope tag). Empty filters pass everything — so a batch launched as
// "<name>-000", "<name>-001", … is listable and groupable purely by its ?name=
// prefix (and ?kind= / ?project=). This is the ONLY grouping; there is no fleet
// entity.
func filterMachines(machines []*service.Machine, kind, namePrefix, project string) []*service.Machine {
kind = strings.TrimSpace(kind)
namePrefix = strings.TrimSpace(namePrefix)
project = strings.TrimSpace(project)
if kind == "" && namePrefix == "" && project == "" {
return machines
}
want := service.CanonicalKind(kind)
out := make([]*service.Machine, 0, len(machines))
for _, m := range machines {
if namePrefix != "" && !strings.HasPrefix(m.DisplayName, namePrefix) {
continue
}
if kind != "" && service.MachineKind(m) != want {
continue
}
if project != "" && service.MachineProject(m) != project {
continue
}
out = append(out, m)
}
return out
}
// ListComputeMachines
// @Title ListComputeMachines
// @Tag Compute API
// @Description list the caller org's machines, optionally filtered by ?kind=, ?name= (prefix) and ?project=
// @Success 200 {object} controllers.Response
// @router /machines [get]
func (c *ApiController) ListComputeMachines() {
org := c.resolveComputeOrg()
if org == "" {
c.ResponseError(refuseNoOrg)
return
}
if !service.ComputeConfigured() {
c.ResponseError(refuseNoCompute)
return
}
machines, err := service.ListOrgMachines(org, c.resolveComputeProject(""))
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(filterMachines(machines, c.Ctx.Query("kind"), c.Ctx.Query("name"), c.Ctx.Query("project")))
}
// unionMachines merges machine lists from independent sources into ONE deduped
// slice: a machine is claimed by provider id OR name, and the FIRST source to
// carry it wins (later sources contribute only what is not already present). This
// is the visor-side analogue of the cloud fleet dedup — a DOKS node whose droplet
// is also in the droplet list appears once (deduped by droplet id). Ordered
// sources let the caller pick the winner.
func unionMachines(sources ...[]*service.Machine) []*service.Machine {
out := []*service.Machine{}
seen := map[string]struct{}{}
claimed := func(m *service.Machine) bool {
if m.Id != "" {
if _, ok := seen["id:"+m.Id]; ok {
return true
}
}
if m.Name != "" {
if _, ok := seen["name:"+m.Name]; ok {
return true
}
}
return false
}
claim := func(m *service.Machine) {
if m.Id != "" {
seen["id:"+m.Id] = struct{}{}
}
if m.Name != "" {
seen["name:"+m.Name] = struct{}{}
}
}
for _, src := range sources {
for _, m := range src {
if claimed(m) {
continue
}
out = append(out, m)
claim(m)
}
}
return out
}
// Nodes is an org's managed-Kubernetes worker inventory, one row per node.
//
// The field is ALWAYS an array on the wire, never null and never absent: a reader
// tells "this service does not serve this op" from "this org has no nodes" by
// whether the field arrived at all, and that distinction is the only thing between
// a version skew and a fleet that silently reads as empty.
type Nodes struct {
Nodes []*service.Machine `json:"nodes"`
}
// ListNodes lists the caller org's DOKS worker nodes as machines: the deduped
// union of the configured cloud account (clusters carrying the hanzo-org tag) and BYOC
// providers (clusters named by Provider.ClusterID). A DOKS node's droplet carries
// k8s tags rather than a hanzo-org droplet tag, so it appears on no other list —
// this op is how a cluster's workers are visible as machines at all.
//
// The two sources are independent, and the platform one needs the platform DO token: an
// unconfigured compute deployment skips it rather than failing the whole read and
// hiding the BYOC nodes behind an error.
func ListNodes(_ context.Context, in *Scope) (*Nodes, error) {
_, org := principal(in.Authorization, in.Owner)
if org == "" {
return nil, zip.ErrForbidden("no org context")
}
var platform []*service.Machine
if service.ComputeConfigured() {
var err error
platform, err = service.ListOrgKubernetesNodes(org)
if err != nil {
return nil, zip.Errorf(http.StatusBadGateway, "%s", err.Error())
}
}
byoc, err := object.GetKubernetesNodesCloud(org)
if err != nil {
return nil, zip.Errorf(http.StatusBadGateway, "%s", err.Error())
}
return &Nodes{Nodes: unionMachines(platform, byoc)}, nil
}
// ---- k8s clusters (platform-account DOKS lifecycle, org-scoped) ----
//
// The unified /v1/k8s noun: list clusters, one cluster's detail (pools + worker
// nodes) and DEPLOY (create) / delete DOKS clusters. Every handler is org-scoped by
// resolveComputeOrg (fail-closed on no org context) — the SAME tenant model the rest
// of the resell compute surface uses. Per-org isolation lives in the service layer
// (the hanzo-org cluster tag): a tenant can only ever see or mutate its OWN clusters.
// ListComputeKubernetesClusters
// @Title ListComputeKubernetesClusters
// @Tag Compute API
// @Description list the caller org's DOKS clusters (configured cloud account, hanzo-org tag)
// @Success 200 {object} controllers.Response
// @router /k8s/clusters [get]
func (c *ApiController) ListComputeKubernetesClusters() {
org := c.resolveComputeOrg()
if org == "" {
c.ResponseError(refuseNoOrg)
return
}
if !service.KubernetesConfigured() {
c.ResponseError(refuseNoProvider)
return
}
clusters, err := service.ListOrgKubernetesClusters(org)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(clusters)
}
// GetComputeKubernetesCluster
// @Title GetComputeKubernetesCluster
// @Tag Compute API
// @Description get one of the caller org's DOKS clusters by id — detail incl. node pools and worker nodes
// @router /k8s/clusters/:id [get]
func (c *ApiController) GetComputeKubernetesCluster() {
org := c.resolveComputeOrg()
if org == "" {
c.ResponseError(refuseNoOrg)
return
}
if !service.KubernetesConfigured() {
c.ResponseError(refuseNoProvider)
return
}
id := c.Ctx.Param("id")
detail, err := service.GetOrgKubernetesCluster(org, id)
if err != nil {
c.ResponseError(err.Error())
return
}
if detail == nil {
c.ResponseError("cluster not found")
return
}
c.ResponseOk(detail)
}
// CreateComputeKubernetesCluster
// @Title CreateComputeKubernetesCluster
// @Tag Compute API
// @Description provision a DOKS cluster for the caller org (body: name, region, version, nodePool{size,count})
// @router /k8s/clusters [post]
func (c *ApiController) CreateComputeKubernetesCluster() {
org := c.resolveComputeOrg()
if org == "" {
c.ResponseError(refuseNoOrg)
return
}
if !service.KubernetesConfigured() {
c.ResponseError(refuseNoProvider)
return
}
// The request body IS the service spec (one shape, no re-mapping).
var spec service.CreateClusterSpec
if err := json.Unmarshal(c.Ctx.Body(), &spec); err != nil {
c.ResponseError(err.Error())
return
}
spec.Name = strings.TrimSpace(spec.Name)
spec.Region = strings.TrimSpace(spec.Region)
spec.NodePool.Size = strings.TrimSpace(spec.NodePool.Size)
if spec.Name == "" {
c.ResponseError("name is required")
return
}
if spec.Region == "" {
c.ResponseError("region is required")
return
}
if spec.NodePool.Size == "" {
c.ResponseError("nodePool.size is required")
return
}
if spec.NodePool.Count < 1 {
c.ResponseError("nodePool.count must be at least 1")
return
}
cluster, err := service.CreateOrgKubernetesCluster(org, c.resolveComputeProject(""), &spec, object.RecordSeedPool)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(cluster)
}
// DeleteComputeKubernetesCluster
// @Title DeleteComputeKubernetesCluster
// @Tag Compute API
// @Description delete one of the caller org's DOKS clusters by id
// @router /k8s/clusters/:id [delete]
func (c *ApiController) DeleteComputeKubernetesCluster() {
org := c.resolveComputeOrg()
if org == "" {
c.ResponseError(refuseNoOrg)
return
}
if !service.KubernetesConfigured() {
c.ResponseError(refuseNoProvider)
return
}
id := c.Ctx.Param("id")
if err := service.DeleteOrgKubernetesCluster(org, id, object.ForgetClusterPools); err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk("deleted")
}
// GetComputeMachine
// @Title GetComputeMachine
// @Tag Compute API
// @Description get one of the caller org's machines by id
// @router /machines/:id [get]
func (c *ApiController) GetComputeMachine() {
org := c.resolveComputeOrg()
if org == "" {
c.ResponseError(refuseNoOrg)
return
}
id := c.Ctx.Param("id")
machine, err := service.GetOrgMachine(org, id)
if err != nil {
c.ResponseError(err.Error())
return
}
if machine == nil {
c.ResponseError("machine not found")
return
}
c.ResponseOk(machine)
}
// DeleteComputeMachine
// @Title DeleteComputeMachine
// @Tag Compute API
// @Description delete one of the caller org's machines by id
// @router /machines/:id [delete]
func (c *ApiController) DeleteComputeMachine() {
org := c.resolveComputeOrg()
if org == "" {
c.ResponseError(refuseNoOrg)
return
}
id := c.Ctx.Param("id")
if err := service.DeleteOrgMachine(org, id); err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk("deleted")
}
// launchComputeRequest is the body for POST /v1/machines. It embeds the
// provider spec and adds a size alias, a kind, an optional app/project scope, a
// dryRun flag (quote only, no spend) and a batch launch: count>1 launches N
// machines named "<name>-000", "<name>-001", … (a "fleet" is just this batch,
// grouped by the ?name= prefix). App/Project are a body-level FALLBACK for a
// direct API caller — the gateway-threaded X-App-ID / X-Project-ID header wins
// when present (resolveComputeApp / resolveComputeProject).
type launchComputeRequest struct {
service.CreateMachineSpec
Size string `json:"size"`
Kind string `json:"kind"`
App string `json:"app"` // optional org>app>project scope; header X-App-ID wins
Project string `json:"project"` // optional; header X-Project-ID wins
Count int `json:"count"` // >1 launches a batch; 0/1 is a single machine
Name string `json:"name"` // machine name; batch members are <name>-NNN
DryRun bool `json:"dryRun"`
}
// LaunchQuote is the resale price quote for a size in a region (Hanzo price
// only — wholesale/provider never surfaced).
type LaunchQuote struct {
Org string `json:"org"`
Size string `json:"size"`
Region string `json:"region"`
Currency string `json:"currency"`
PriceHourly float64 `json:"priceHourly"`
PriceMonthly float64 `json:"priceMonthly"`
GPU *service.GPUSpec `json:"gpu,omitempty"`
}
// batchMemberName is the canonical name of batch member i: "<name>-NNN". A batch
// launched with count N is just N machines sharing this name prefix — there is
// no separate fleet entity; the ?name= list filter re-groups them.
func batchMemberName(name string, i int) string {
return fmt.Sprintf("%s-%03d", name, i)
}
// mintMachineName names a machine whose caller did not name it.
//
// A droplet MUST have a name — DigitalOcean answers 422 "Droplet must have a
// name" without one. The BATCH path has always refused an empty name; the single
// path passed it straight through to the provider, so every launch that omitted
// one failed at the far end with a provider error rather than here with ours.
// Tabs is exactly that caller: it opens a scratch terminal and has no name to
// give, so its button failed on every click.
//
// Naming a throwaway is not the caller's job, so the server does it. The kind
// says what it is, and four random bytes keep two clicks in the same second
// apart. Lowercased, and anything a hostname will not carry becomes a dash —
// which is the intersection of what a droplet name and a hostname both allow.
func mintMachineName(kind string) string {
k := strings.ToLower(strings.TrimSpace(kind))
if k == "" {
k = "machine"
}
k = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
return r
}
return '-'
}, k)
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
// Never fail a launch over a name. The clock is unique enough for the
// only case this can happen in, which is the entropy pool being unusable.
return fmt.Sprintf("%s-%d", k, time.Now().UnixNano())
}
return fmt.Sprintf("%s-%x", k, b)
}
// launchMetered is the ONE metered launch shared by the single and batch launch
// paths: authorize the org for the first hour of the size, provision + bootstrap
// via LaunchOrgMachine, then debit that launch hour. Fail-closed — an unknown or
// insufficient balance launches nothing and spends nothing. The caller sets the
// spec's kind (service.SetKind) before calling; launchMetered is kind-agnostic.
// Every SUBSEQUENT running hour is debited by service.MeterRunningMachines (the
// hourly ticker) on this SAME commerce path; the launch owns the launch hour and
// the sweep skips a machine created in the current clock hour, so the hour is
// never double-billed.
//
// It composes the same three primitives every other provision path uses
// (service.HourlyCents → AuthorizeCompute → RecordCompute), so a machine, a node
// pool and a cluster are priced by one rule and gated by one rule.
//
// A metering WRITE failure does not fail the launch — the machine exists, and
// refusing to return it would leave the customer paying for something they were
// told they did not get. It is logged loudly instead: nothing reconciles it.
func launchMetered(ctx context.Context, org, project string, spec *service.CreateMachineSpec, si *service.SizeInfo) (*service.Machine, error) {
firstHourCents, err := service.RateOf(si)
if err != nil {
return nil, err
}
var machine *service.Machine
// A droplet has one fixed size and does not grow on its own, so the ceiling
// the org is authorized for and the hour it is charged are the same number.
err = service.Provision(ctx, org, project, firstHourCents, firstHourCents, spec.InstanceType, func() (string, error) {
m, err := service.LaunchOrgMachine(org, project, spec)
if err != nil {
return "", err
}
machine = m
// Roll a launched event into the analytics datastore (best-effort; never
// blocks or fails the launch) — the analytical mirror of the commerce debit.
service.EmitCompute(org, service.ComputeLaunched, m, firstHourCents)
return m.Id, nil
})
if err != nil {
return nil, err
}
return machine, nil
}
// LaunchComputeMachine
// @Title LaunchComputeMachine
// @Tag Compute API
// @Description quote (dryRun) or launch a metered, per-org machine; count>1 launches a batch of <name>-NNN
// @router /machines [post]
func (c *ApiController) LaunchComputeMachine() {
org := c.resolveComputeOrg()
if org == "" {
c.ResponseError(refuseNoOrg)
return
}
var req launchComputeRequest
if err := json.Unmarshal(c.Ctx.Body(), &req); err != nil {
c.ResponseError(err.Error())
return
}
size := strings.TrimSpace(req.Size)
if size == "" {
size = strings.TrimSpace(req.InstanceType)
}
if size == "" {
// A launcher with no size picker — the tabs "New cloud machine" button, a
// bare CLI launch — still gets a machine. This is the same default the DO
// service falls back to (service/digitalocean.go), so the quote the handler
// computes and the droplet the service creates name one size, not two.
size = service.DefaultLaunchSize
}
if size == "" {
c.ResponseError("size is required")
return
}
si, err := service.SizeBySlug(size)
if err != nil {
c.ResponseError(err.Error())
return
}
if si == nil {
c.ResponseError("unknown size: " + size)
return
}
quote := LaunchQuote{
Org: org,
Size: size,
Region: req.Region,
Currency: si.Currency,
PriceHourly: si.PriceHourly,
PriceMonthly: si.PriceMonthly,
GPU: si.GPU,
}
// Dry run: prove resale pricing without provisioning or billing.
if req.DryRun {
c.ResponseOk(quote)
return
}
// The batch launch budgets ~60s per member; a single launch keeps 30s.
timeout := 30 * time.Second
if req.Count > 1 {
timeout = time.Duration(60*req.Count) * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// One base spec — size, kind and the optional app>project scope set once,
// shared by the single and every batch member. SetKind default is machine
// (kind=bot bootstraps the @hanzo/bot agent); SetScope injects the resolved
// app/project as hanzo-app/hanzo-project tags so the droplet self-describes its
// org>app>project scope, exactly as org is injected in LaunchOrgMachine. Both
// launch surfaces (single + batch) flow through this SAME base, so scope is set
// exactly one way.
base := req.CreateMachineSpec
base.InstanceType = size
service.SetKind(&base, req.Kind)
project := c.resolveComputeProject(req.Project)
service.SetScope(&base, c.resolveComputeApp(req.App), project)
name := strings.TrimSpace(req.Name)
// Batch: count>1 launches N members named "<name>-NNN" through the SAME
// metered primitive. A per-member failure returns what launched plus the error.
if req.Count > 1 {
if name == "" {
c.ResponseError("name is required to launch a batch")
return
}
machines := make([]*service.Machine, 0, req.Count)
for i := 0; i < req.Count; i++ {
spec := base
spec.Name = batchMemberName(name, i)
spec.DisplayName = spec.Name
machine, err := launchMetered(ctx, org, project, &spec, si)
if err != nil {
c.ResponseOk(map[string]any{"machines": machines, "quote": quote, "error": err.Error()})
return
}
machines = append(machines, machine)
}
c.ResponseOk(map[string]any{"machines": machines, "quote": quote})
return
}
// Single: count<=1 keeps today's shape. The outer Name shadows the embedded
// spec's Name in JSON, so set it explicitly.
spec := base
if name == "" {
name = mintMachineName(req.Kind)
}
spec.Name = name
machine, err := launchMetered(ctx, org, project, &spec, si)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(map[string]any{"machine": machine, "quote": quote})
}
// ListComputeKubernetesProviders
// @Title ListComputeKubernetesProviders
// @Tag Compute API
// @Description every configured kubernetes cloud and whether it answers right now
// @router /k8s/providers [get]
func (c *ApiController) ListComputeKubernetesProviders() {
if c.resolveComputeOrg() == "" {
c.ResponseError(refuseNoOrg)
return
}
c.ResponseOk(service.KubernetesProviderStatus(context.Background()))
}