mirror of
https://github.com/hanzoai/compute.git
synced 2026-10-30 14:50:41 +00:00
Cloud's side of this rename landed some time ago — the app is apps/compute, the env is COMPUTE_URL — and left a note saying the other half moves with the manifest that names it. This is the half that does not touch the cluster: the Go module is github.com/hanzoai/compute, the binary it builds is compute, and the image is ghcr.io/hanzoai/compute. GitHub already carries the new name. What is deliberately NOT here is the cutover. The Service universe declares is still visor, and so are visor-config, visor-kms-sync and visor-db-bucket. Pointing anything at compute.hanzo.svc before those move resolves to nothing, so they move together, in universe, not here.
255 lines
14 KiB
Go
255 lines
14 KiB
Go
// Copyright 2023 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.
|
|
|
|
package routers
|
|
|
|
import (
|
|
"github.com/zap-proto/zip"
|
|
"github.com/zap-proto/zip/middleware"
|
|
|
|
"github.com/hanzoai/compute/controllers"
|
|
)
|
|
|
|
// h adapts a controller method to a zip.Handler: it binds a fresh controller to
|
|
// the request context and invokes the method. This is the ONE bridge from the
|
|
// method-set controllers to the framework — every route goes through it.
|
|
func h(fn func(*controllers.ApiController)) zip.Handler {
|
|
return func(c *zip.Ctx) error {
|
|
fn(controllers.New(c))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// corsPolicy is what a browser is told it may send, and the header list is the
|
|
// load-bearing part of it. Visor authenticates with a Bearer token, so leaving
|
|
// Authorization out of the preflight answer does not merely reject the header —
|
|
// the browser refuses to send the request at all and reports it as a CORS
|
|
// failure, which reads like a network fault and hides that the API was reachable
|
|
// the whole time. The list came from upstream without it, and that went
|
|
// unnoticed for as long as every caller was another server.
|
|
var corsPolicy = middleware.CORSConfig{
|
|
AllowOrigins: []string{"*"},
|
|
AllowMethods: []string{"GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS"},
|
|
AllowHeaders: []string{"Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"},
|
|
ExposeHeaders: []string{"Content-Length"},
|
|
AllowCreds: true,
|
|
}
|
|
|
|
// Route mounts the whole visor HTTP surface on app: the ambient filter chain
|
|
// (recover, CORS, static, tenant, authz, record) followed by the /v1 API. The
|
|
// filter order is load-bearing — static short-circuits before the authz seam, so
|
|
// an asset is never gated; every /v1 route registered after the chain is
|
|
// tenant-scoped, authorized and audited.
|
|
func Route(app *zip.App) {
|
|
app.Use(middleware.Recover())
|
|
app.Use(middleware.CORS(corsPolicy))
|
|
|
|
// Health is registered AHEAD of the rest of the chain, and the position is
|
|
// the whole design. A probe carries no credentials, so ApiFilter would put it
|
|
// to the policy engine as "anonymous" — which means a mistake in an authz
|
|
// policy stops answering probes, kubelet reads that as every pod being sick,
|
|
// and an authorization bug becomes a total outage. RecordMessage is the other
|
|
// half: it writes an audit row per request, and two probes every ten seconds
|
|
// is seventeen thousand rows a day describing nothing.
|
|
//
|
|
// Static is passed too, which is what the old /api/health could not do: any
|
|
// path outside /v1/ falls to the SPA fallback and comes back 200, so the
|
|
// probe measured the file server rather than the service.
|
|
registerHealth(app)
|
|
registerGone(app)
|
|
|
|
app.Use(zip.H(TransparentStatic))
|
|
app.Use(zip.H(TenantContextFilter))
|
|
app.Use(zip.H(ApiFilter))
|
|
app.Use(zip.H(RecordMessage))
|
|
|
|
registerAPI(app)
|
|
}
|
|
|
|
// registerHealth declares the health op. It is a TYPED zip op — the In and Out
|
|
// are named types, so it projects into the OpenAPI document, the MCP tool
|
|
// surface, the CLI and the by-name call plane, and an in-process caller can
|
|
// reach it through zip.Here without a wire. An untyped route would appear in
|
|
// none of those.
|
|
func registerHealth(app *zip.App) {
|
|
zip.Get[Ping, Health](app, "/v1/health", health,
|
|
zip.WithSummary("Report whether visor can reach its store"),
|
|
zip.WithOperationID("health"),
|
|
zip.WithTags("Health"),
|
|
)
|
|
}
|
|
|
|
// registerAgent declares a machine's AGENT — the record that it runs the
|
|
// @hanzo/bot runtime for one cloud Agent (controllers/agent_binding.go). Four
|
|
// TYPED ops, so this noun is in the registry every projection reads (OpenAPI,
|
|
// MCP, CLI, the by-name call plane) rather than only on the wire.
|
|
//
|
|
// ONE noun, ONE address, and the METHOD carries the verb. PUT rather than POST
|
|
// because binding is idempotent: re-binding the same agent to the same machine
|
|
// is the state the caller asked for, not a second binding.
|
|
//
|
|
// It is called next to the other /v1/machines routes because that is where the
|
|
// noun lives, and NOT because the position decides anything: fiber prefers a
|
|
// static segment over a `:param` at the same position however the two were
|
|
// registered, so /v1/machines/agents beats /v1/machines/:owner/:name on specificity.
|
|
// Measured, not assumed — moving this call below the :id routes leaves the
|
|
// literal still winning. What is worth pinning is the OUTCOME rather than an
|
|
// ordering rule that turns out not to be one, so TestAgentsIsNotAMachineId
|
|
// asserts which handler answers.
|
|
func registerAgent(app *zip.App) {
|
|
zip.Get(app, "/v1/machines/agents", controllers.ListAgents,
|
|
zip.WithSummary("List the caller org's agent bindings"),
|
|
zip.WithOperationID("listAgents"),
|
|
zip.WithTags("AgentBinding"),
|
|
)
|
|
zip.Put(app, "/v1/machines/:owner/:name/agent", controllers.BindAgent,
|
|
zip.WithSummary("Bind a cloud Agent to a machine"),
|
|
zip.WithOperationID("bindAgent"),
|
|
zip.WithTags("AgentBinding"),
|
|
)
|
|
zip.Get(app, "/v1/machines/:owner/:name/agent", controllers.GetAgent,
|
|
zip.WithSummary("Read a machine's agent binding"),
|
|
zip.WithOperationID("getAgent"),
|
|
zip.WithTags("AgentBinding"),
|
|
)
|
|
zip.Delete(app, "/v1/machines/:owner/:name/agent", controllers.UnbindAgent,
|
|
zip.WithSummary("Unbind a machine's agent"),
|
|
zip.WithOperationID("unbindAgent"),
|
|
zip.WithTags("AgentBinding"),
|
|
)
|
|
}
|
|
|
|
// registerAPI registers the /v1 surface, one verb per line. The table is pinned
|
|
// by router_contract_test.go: a route added here without a contract line fails,
|
|
// and so does a contract line with no route.
|
|
func registerAPI(app *zip.App) {
|
|
app.Post("/v1/signin", h((*controllers.ApiController).Signin))
|
|
app.Post("/v1/signout", h((*controllers.ApiController).Signout))
|
|
app.Get("/v1/account", h((*controllers.ApiController).GetAccount))
|
|
|
|
app.Get("/v1/records", h((*controllers.ApiController).GetRecords))
|
|
app.Get("/v1/records/:owner/:name", h((*controllers.ApiController).GetRecord))
|
|
app.Put("/v1/records/:owner/:name", h((*controllers.ApiController).UpdateRecord))
|
|
app.Post("/v1/records", h((*controllers.ApiController).AddRecord))
|
|
app.Delete("/v1/records/:owner/:name", h((*controllers.ApiController).DeleteRecord))
|
|
|
|
// Committing a record writes its BLOCK on the chain (object.Record.Block);
|
|
// querying reads it back. One noun, written and read.
|
|
app.Put("/v1/records/:owner/:name/block", h((*controllers.ApiController).CommitRecord))
|
|
app.Get("/v1/records/:owner/:name/block", h((*controllers.ApiController).QueryRecord))
|
|
|
|
// An asset is a resource. The collection lists and creates; the item, named
|
|
// by its (owner, name) key, is read, replaced and removed.
|
|
app.Get("/v1/assets", h((*controllers.ApiController).GetAssets))
|
|
app.Post("/v1/assets", h((*controllers.ApiController).AddAsset))
|
|
app.Get("/v1/assets/:owner/:name", h((*controllers.ApiController).GetAsset))
|
|
app.Put("/v1/assets/:owner/:name", h((*controllers.ApiController).UpdateAsset))
|
|
app.Delete("/v1/assets/:owner/:name", h((*controllers.ApiController).DeleteAsset))
|
|
|
|
// A provider holds a cloud credential, so the authorization on each address
|
|
// is the one the verb spelling had: the seam reads the (owner, name) out of
|
|
// the path (see pathTarget) and compares it to the subject exactly as before.
|
|
app.Get("/v1/providers", h((*controllers.ApiController).GetProviders))
|
|
app.Post("/v1/providers", h((*controllers.ApiController).AddProvider))
|
|
app.Get("/v1/providers/:owner/:name", h((*controllers.ApiController).GetProvider))
|
|
app.Put("/v1/providers/:owner/:name", h((*controllers.ApiController).UpdateProvider))
|
|
app.Delete("/v1/providers/:owner/:name", h((*controllers.ApiController).DeleteProvider))
|
|
|
|
// Canonical /v1 resell compute surface — cached DigitalOcean catalog and
|
|
// per-org machines over the configured cloud account (controllers/compute.go).
|
|
app.Get("/v1/regions", h((*controllers.ApiController).GetComputeRegions))
|
|
app.Get("/v1/sizes", h((*controllers.ApiController).GetComputeSizes))
|
|
app.Get("/v1/gpus", h((*controllers.ApiController).GetComputeGPUs))
|
|
// ONE machines collection. It answers from the organization's own providers
|
|
// and from the house account, keyed the one way (owner, name), with Source
|
|
// saying which. There is no second address to join against.
|
|
app.Get("/v1/machines", h((*controllers.ApiController).ListMachines))
|
|
app.Post("/v1/machines", h((*controllers.ApiController).LaunchComputeMachine))
|
|
registerAgent(app)
|
|
app.Get("/v1/machines/:owner/:name", h((*controllers.ApiController).GetMachine))
|
|
app.Put("/v1/machines/:owner/:name", h((*controllers.ApiController).UpdateMachine))
|
|
app.Delete("/v1/machines/:owner/:name", h((*controllers.ApiController).DeleteMachine))
|
|
// Unified /v1/k8s noun — the ONE Kubernetes surface: DOKS cluster lifecycle
|
|
// (list / detail+nodes / create / delete) plus the worker NODES on the fleet.
|
|
app.Get("/v1/k8s/providers", h((*controllers.ApiController).ListComputeKubernetesProviders))
|
|
app.Get("/v1/k8s/clusters", h((*controllers.ApiController).ListComputeKubernetesClusters))
|
|
app.Post("/v1/k8s/clusters", h((*controllers.ApiController).CreateComputeKubernetesCluster))
|
|
app.Get("/v1/k8s/clusters/:id", h((*controllers.ApiController).GetComputeKubernetesCluster))
|
|
app.Delete("/v1/k8s/clusters/:id", h((*controllers.ApiController).DeleteComputeKubernetesCluster))
|
|
// The worker NODES are a TYPED op. Every other line in this table registers a
|
|
// handler and nothing else: the route exists on the wire and in none of the
|
|
// projections, so the OpenAPI document, the MCP tool list, the CLI and the
|
|
// generated SDKs do not know it is there. This one is declared with its In and
|
|
// Out, so it appears in all of them — and cloud, which folds these nodes into
|
|
// the fleet, can be generated against it instead of hand-written to match.
|
|
zip.Get[controllers.Scope, controllers.Nodes](app, "/v1/k8s/nodes", controllers.ListNodes,
|
|
zip.WithSummary("List the org's managed-Kubernetes worker nodes as machines"),
|
|
zip.WithOperationID("nodes"),
|
|
zip.WithTags("Compute"),
|
|
)
|
|
app.Get("/v1/images", h((*controllers.ApiController).ListImages))
|
|
app.Post("/v1/images", h((*controllers.ApiController).CreateImage))
|
|
|
|
app.Get("/v1/sessions", h((*controllers.ApiController).GetSessions))
|
|
app.Get("/v1/sessions/:owner/:name", h((*controllers.ApiController).GetConnSession))
|
|
app.Put("/v1/sessions/:owner/:name", h((*controllers.ApiController).UpdateSession))
|
|
app.Post("/v1/sessions", h((*controllers.ApiController).AddSession))
|
|
app.Delete("/v1/sessions/:owner/:name", h((*controllers.ApiController).DeleteSession))
|
|
// Starting and stopping both write the session's STATUS — connected, or
|
|
// disconnected. One address, because it is one property.
|
|
app.Put("/v1/sessions/:owner/:name/status", h((*controllers.ApiController).StartSession))
|
|
app.Delete("/v1/sessions/:owner/:name/status", h((*controllers.ApiController).StopSession))
|
|
|
|
// Neither of these is a tunnel on an asset, which is what they were called.
|
|
//
|
|
// The first CREATES A SESSION for an asset and returns it, so it is a POST to
|
|
// the asset's sessions. The second opens the live connection to a SESSION —
|
|
// it reads ?sessionId= and upgrades to a WebSocket — so it belongs to the
|
|
// session, not to the asset it happens to reach.
|
|
//
|
|
// Separating them puts each with the thing it is about: creating belongs to
|
|
// the collection that holds the created, and the connection belongs to the
|
|
// session it connects to.
|
|
app.Post("/v1/assets/:owner/:name/sessions", h((*controllers.ApiController).AddAssetTunnel))
|
|
app.Get("/v1/sessions/:owner/:name/connection", h((*controllers.ApiController).GetAssetTunnel))
|
|
|
|
app.Get("/v1/pools", h((*controllers.ApiController).GetNodePools))
|
|
app.Post("/v1/pools", h((*controllers.ApiController).CreateNodePool))
|
|
app.Get("/v1/pools/:owner/:name", h((*controllers.ApiController).GetNodePool))
|
|
app.Put("/v1/pools/:owner/:name", h((*controllers.ApiController).UpdateNodePool))
|
|
app.Delete("/v1/pools/:owner/:name", h((*controllers.ApiController).DeleteNodePool))
|
|
// How many nodes the pool runs is a property of the pool, so scaling is
|
|
// writing that property — not a verb of its own.
|
|
app.Put("/v1/pools/:owner/:name/size", h((*controllers.ApiController).ScaleNodePool))
|
|
|
|
app.Get("/v1/plans", h((*controllers.ApiController).GetPlans))
|
|
app.Get("/v1/plans/:owner/:name", h((*controllers.ApiController).GetPlan))
|
|
app.Post("/v1/plans", h((*controllers.ApiController).AddPlan))
|
|
app.Put("/v1/plans/:owner/:name", h((*controllers.ApiController).UpdatePlan))
|
|
app.Delete("/v1/plans/:owner/:name", h((*controllers.ApiController).DeletePlan))
|
|
|
|
app.Get("/v1/whitelabel", h((*controllers.ApiController).GetWhitelabel))
|
|
|
|
app.Get("/v1/volumes", h((*controllers.ApiController).GetVolumes))
|
|
app.Post("/v1/volumes", h((*controllers.ApiController).CreateVolume))
|
|
app.Get("/v1/volumes/:owner/:name", h((*controllers.ApiController).GetVolume))
|
|
app.Delete("/v1/volumes/:owner/:name", h((*controllers.ApiController).DeleteVolume))
|
|
// Which machine a volume is attached to is a RELATION, and a relation the
|
|
// volume has one of: writing it attaches, removing it detaches.
|
|
app.Put("/v1/volumes/:owner/:name/attachment", h((*controllers.ApiController).AttachVolume))
|
|
app.Delete("/v1/volumes/:owner/:name/attachment", h((*controllers.ApiController).DetachVolume))
|
|
// How large the volume is, written.
|
|
app.Put("/v1/volumes/:owner/:name/size", h((*controllers.ApiController).ResizeVolume))
|
|
}
|