mirror of
https://github.com/hanzoai/k8s.git
synced 2026-10-02 15:40:05 +00:00
The tree was staged but never committed — this is the repo's first commit, so it carries both the existing content and the zip v1.18.8 -> v1.20.0 migration. Migration: Prepare() is replaced by Build() error, and both test harnesses assert the verdict rather than discarding it. main.go:59's Use call needed no change — the three middleware constructors already return zip.Handler, which satisfies the closed Component interface. The committed projections (k8s.plugin.json, openapi.json, ops/zipdoc_gen.go) are regenerated: v1.20.0 splits an op's prose into summary (first line) + description and zipdoc now emits per-field docs, so all three artifacts move with the bump. Co-authored-by: Hanzo Dev <[email protected]>
333 lines
14 KiB
Go
333 lines
14 KiB
Go
// Package ops is the /v1/k8s surface: one named, typed operation per thing this
|
|
// deployment may do to a Kubernetes cluster, and nothing else.
|
|
//
|
|
// # Every op names its cluster, and Reach is the only door
|
|
//
|
|
// Not one handler here constructs a Kubernetes client. Each one calls
|
|
// registry.Registry.Reach with the cluster the caller named, and Reach is the
|
|
// module's only client constructor — so "which cluster" cannot be omitted, cannot
|
|
// be defaulted, and cannot be inherited from the pod this process happens to run
|
|
// in. hanzo-k8s, lux-k8s, zoo-k8s and a customer's own cluster are four rows in a
|
|
// registry and one code path.
|
|
//
|
|
// # There is no generic passthrough, and that is the point
|
|
//
|
|
// Every operation is NAMED and TYPED, including the custom-resource quartet, whose
|
|
// kinds come from the closed table in resources.go. An "apply this YAML" op would
|
|
// have been shorter to write and would have relocated the credential problem
|
|
// behind an RPC instead of solving it: with one, the union of privilege this app
|
|
// needs cannot be written down, so nothing can be least-privileged and no reviewer
|
|
// can bound a blast radius. With this set, the union RBAC is a finite list and it
|
|
// is in the repo's LLM.md.
|
|
//
|
|
// # Every op is registered twice, from one handler
|
|
//
|
|
// Once on the app — the world's HTTP surface, which projects OpenAPI, an MCP tool
|
|
// and a CLI command — and once on app.Peer(), the fleet's socket, where a peer
|
|
// invokes it by op token through zip.Ask. One handler, one token, two transports.
|
|
// The peer app listens only on this app's 0600 socket, so an internal op is never
|
|
// served on the edge; that separation is structural rather than a check that could
|
|
// be forgotten.
|
|
//
|
|
// The registrations are written out as direct zip.Get / zip.Post calls with literal
|
|
// paths, deliberately and not by a helper: zipdoc reads the AST for exactly that
|
|
// shape, so a registration wrapped in a loop or a generic helper is a registration
|
|
// whose doc comment reaches NEITHER the OpenAPI description NOR the MCP tool
|
|
// description. The prose below each handler is the product surface an agent reads
|
|
// when deciding whether to call the op, so the shape that carries it wins over the
|
|
// shape that saves lines.
|
|
package ops
|
|
|
|
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/k8s/plane"
|
|
"github.com/hanzoai/k8s/registry"
|
|
)
|
|
|
|
// Ops is the service: the registry, and nothing else. Every fact an op needs
|
|
// beyond its argument comes through Reach, so there is no second source of
|
|
// cluster state to keep in step.
|
|
type Ops struct {
|
|
Reg *registry.Registry
|
|
}
|
|
|
|
// Mount registers every operation on both surfaces.
|
|
//
|
|
// The order is the order the API documentation reads in, not a routing order:
|
|
// zip's router decides by specificity, so mount sequence carries no meaning and a
|
|
// reader is free to find an op where they expect it.
|
|
func (o Ops) Mount(app *zip.App) {
|
|
p := app.Peer()
|
|
|
|
// ---- clusters — the registry ------------------------------------------
|
|
zip.Get(app, "/v1/k8s/clusters", o.listClusters,
|
|
zip.WithOperationID(plane.ClustersList), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/clusters/list", o.listClusters,
|
|
zip.WithOperationID(plane.ClustersList))
|
|
|
|
zip.Post(app, "/v1/k8s/clusters", o.registerCluster,
|
|
zip.WithOperationID(plane.ClustersRegister), zip.WithTags("k8s"), zip.WithStatus(201))
|
|
zip.Post(p, "/k8s/clusters/register", o.registerCluster,
|
|
zip.WithOperationID(plane.ClustersRegister))
|
|
|
|
zip.Get(app, "/v1/k8s/clusters/:cluster", o.getCluster,
|
|
zip.WithOperationID(plane.ClustersGet), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/clusters/get", o.getCluster,
|
|
zip.WithOperationID(plane.ClustersGet))
|
|
|
|
zip.Delete(app, "/v1/k8s/clusters/:cluster", o.deregisterCluster,
|
|
zip.WithOperationID(plane.ClustersDeregister), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/clusters/deregister", o.deregisterCluster,
|
|
zip.WithOperationID(plane.ClustersDeregister))
|
|
|
|
zip.Get(app, "/v1/k8s/clusters/:cluster/status", o.clusterStatus,
|
|
zip.WithOperationID(plane.ClustersStatus), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/clusters/status", o.clusterStatus,
|
|
zip.WithOperationID(plane.ClustersStatus))
|
|
|
|
// ---- nodes -------------------------------------------------------------
|
|
zip.Get(app, "/v1/k8s/nodes", o.listNodes,
|
|
zip.WithOperationID(plane.NodesList), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/nodes/list", o.listNodes,
|
|
zip.WithOperationID(plane.NodesList))
|
|
|
|
zip.Get(app, "/v1/k8s/nodes/volume-stats", o.nodeVolumeStats,
|
|
zip.WithOperationID(plane.NodesVolumeStats), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/nodes/volume-stats", o.nodeVolumeStats,
|
|
zip.WithOperationID(plane.NodesVolumeStats))
|
|
|
|
zip.Post(app, "/v1/k8s/nodes/:node/cordon", o.cordonNode,
|
|
zip.WithOperationID(plane.NodesCordon), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/nodes/cordon", o.cordonNode,
|
|
zip.WithOperationID(plane.NodesCordon))
|
|
|
|
// ---- namespaces --------------------------------------------------------
|
|
zip.Get(app, "/v1/k8s/namespaces", o.listNamespaces,
|
|
zip.WithOperationID(plane.NamespacesList), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/namespaces/list", o.listNamespaces,
|
|
zip.WithOperationID(plane.NamespacesList))
|
|
|
|
zip.Post(app, "/v1/k8s/namespaces", o.ensureNamespace,
|
|
zip.WithOperationID(plane.NamespacesEnsure), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/namespaces/ensure", o.ensureNamespace,
|
|
zip.WithOperationID(plane.NamespacesEnsure))
|
|
|
|
zip.Get(app, "/v1/k8s/namespaces/:namespace", o.getNamespace,
|
|
zip.WithOperationID(plane.NamespacesGet), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/namespaces/get", o.getNamespace,
|
|
zip.WithOperationID(plane.NamespacesGet))
|
|
|
|
zip.Delete(app, "/v1/k8s/namespaces/:namespace", o.deleteNamespace,
|
|
zip.WithOperationID(plane.NamespacesDelete), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/namespaces/delete", o.deleteNamespace,
|
|
zip.WithOperationID(plane.NamespacesDelete))
|
|
|
|
// ---- pods --------------------------------------------------------------
|
|
zip.Get(app, "/v1/k8s/pods", o.listPods,
|
|
zip.WithOperationID(plane.PodsList), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/pods/list", o.listPods,
|
|
zip.WithOperationID(plane.PodsList))
|
|
|
|
zip.Get(app, "/v1/k8s/pods/:namespace/:pod/logs", o.podLogs,
|
|
zip.WithOperationID(plane.PodsLogs), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/pods/logs", o.podLogs,
|
|
zip.WithOperationID(plane.PodsLogs))
|
|
|
|
// ---- volumes -----------------------------------------------------------
|
|
zip.Get(app, "/v1/k8s/volumes/claims", o.listClaims,
|
|
zip.WithOperationID(plane.VolumesListClaims), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/volumes/list-claims", o.listClaims,
|
|
zip.WithOperationID(plane.VolumesListClaims))
|
|
|
|
zip.Get(app, "/v1/k8s/volumes/persistent", o.listPersistentVolumes,
|
|
zip.WithOperationID(plane.VolumesListPersistent), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/volumes/list-persistent", o.listPersistentVolumes,
|
|
zip.WithOperationID(plane.VolumesListPersistent))
|
|
|
|
zip.Post(app, "/v1/k8s/volumes/claims", o.ensureClaim,
|
|
zip.WithOperationID(plane.VolumesEnsureClaim), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/volumes/ensure-claim", o.ensureClaim,
|
|
zip.WithOperationID(plane.VolumesEnsureClaim))
|
|
|
|
zip.Post(app, "/v1/k8s/volumes/claims/:namespace/:name/expand", o.expandClaim,
|
|
zip.WithOperationID(plane.VolumesExpandClaim), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/volumes/expand-claim", o.expandClaim,
|
|
zip.WithOperationID(plane.VolumesExpandClaim))
|
|
|
|
// ---- workloads ---------------------------------------------------------
|
|
zip.Get(app, "/v1/k8s/workloads/deployments", o.listDeployments,
|
|
zip.WithOperationID(plane.WorkloadsListDeployments), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/workloads/list-deployments", o.listDeployments,
|
|
zip.WithOperationID(plane.WorkloadsListDeployments))
|
|
|
|
zip.Get(app, "/v1/k8s/workloads/deployments/:namespace/:name", o.getDeployment,
|
|
zip.WithOperationID(plane.WorkloadsGetDeployment), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/workloads/get-deployment", o.getDeployment,
|
|
zip.WithOperationID(plane.WorkloadsGetDeployment))
|
|
|
|
zip.Get(app, "/v1/k8s/workloads/replicasets", o.listReplicaSets,
|
|
zip.WithOperationID(plane.WorkloadsListReplicaSets), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/workloads/list-replicasets", o.listReplicaSets,
|
|
zip.WithOperationID(plane.WorkloadsListReplicaSets))
|
|
|
|
zip.Get(app, "/v1/k8s/workloads/statefulsets", o.listStatefulSets,
|
|
zip.WithOperationID(plane.WorkloadsListStatefulSets), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/workloads/list-statefulsets", o.listStatefulSets,
|
|
zip.WithOperationID(plane.WorkloadsListStatefulSets))
|
|
|
|
zip.Get(app, "/v1/k8s/workloads/services", o.listServices,
|
|
zip.WithOperationID(plane.WorkloadsListServices), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/workloads/list-services", o.listServices,
|
|
zip.WithOperationID(plane.WorkloadsListServices))
|
|
|
|
zip.Get(app, "/v1/k8s/workloads/ingresses", o.listIngresses,
|
|
zip.WithOperationID(plane.WorkloadsListIngresses), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/workloads/list-ingresses", o.listIngresses,
|
|
zip.WithOperationID(plane.WorkloadsListIngresses))
|
|
|
|
zip.Get(app, "/v1/k8s/workloads/events", o.listEvents,
|
|
zip.WithOperationID(plane.WorkloadsListEvents), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/workloads/list-events", o.listEvents,
|
|
zip.WithOperationID(plane.WorkloadsListEvents))
|
|
|
|
zip.Get(app, "/v1/k8s/workloads/configmaps", o.listConfigMaps,
|
|
zip.WithOperationID(plane.WorkloadsListConfigMaps), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/workloads/list-configmaps", o.listConfigMaps,
|
|
zip.WithOperationID(plane.WorkloadsListConfigMaps))
|
|
|
|
// ---- jobs --------------------------------------------------------------
|
|
zip.Post(app, "/v1/k8s/jobs", o.createJob,
|
|
zip.WithOperationID(plane.JobsCreate), zip.WithTags("k8s"), zip.WithStatus(201))
|
|
zip.Post(p, "/k8s/jobs/create", o.createJob,
|
|
zip.WithOperationID(plane.JobsCreate))
|
|
|
|
zip.Get(app, "/v1/k8s/jobs", o.listJobs,
|
|
zip.WithOperationID(plane.JobsList), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/jobs/list", o.listJobs,
|
|
zip.WithOperationID(plane.JobsList))
|
|
|
|
zip.Get(app, "/v1/k8s/jobs/:namespace/:name", o.getJob,
|
|
zip.WithOperationID(plane.JobsGet), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/jobs/get", o.getJob,
|
|
zip.WithOperationID(plane.JobsGet))
|
|
|
|
// ---- resources — the closed kind set -----------------------------------
|
|
zip.Get(app, "/v1/k8s/resources", o.listResources,
|
|
zip.WithOperationID(plane.ResourcesList), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/resources/list", o.listResources,
|
|
zip.WithOperationID(plane.ResourcesList))
|
|
|
|
zip.Get(app, "/v1/k8s/resources/watch", o.watchResources,
|
|
zip.WithOperationID(plane.ResourcesWatch), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/resources/watch", o.watchResources,
|
|
zip.WithOperationID(plane.ResourcesWatch))
|
|
|
|
zip.Put(app, "/v1/k8s/resources", o.applyResource,
|
|
zip.WithOperationID(plane.ResourcesApply), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/resources/apply", o.applyResource,
|
|
zip.WithOperationID(plane.ResourcesApply))
|
|
|
|
zip.Get(app, "/v1/k8s/resources/:kind/:namespace/:name", o.getResource,
|
|
zip.WithOperationID(plane.ResourcesGet), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/resources/get", o.getResource,
|
|
zip.WithOperationID(plane.ResourcesGet))
|
|
|
|
zip.Delete(app, "/v1/k8s/resources/:kind/:namespace/:name", o.deleteResource,
|
|
zip.WithOperationID(plane.ResourcesDelete), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/resources/delete", o.deleteResource,
|
|
zip.WithOperationID(plane.ResourcesDelete))
|
|
|
|
// ---- access ------------------------------------------------------------
|
|
zip.Post(app, "/v1/k8s/access/can", o.can,
|
|
zip.WithOperationID(plane.AccessCan), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/access/can", o.can,
|
|
zip.WithOperationID(plane.AccessCan))
|
|
|
|
// ---- metrics -----------------------------------------------------------
|
|
zip.Get(app, "/v1/k8s/metrics/pods", o.podMetrics,
|
|
zip.WithOperationID(plane.MetricsPods), zip.WithTags("k8s"))
|
|
zip.Post(p, "/k8s/metrics/pods", o.podMetrics,
|
|
zip.WithOperationID(plane.MetricsPods))
|
|
}
|
|
|
|
// reach resolves a cluster for the caller and maps a refusal onto its HTTP
|
|
// status. It is the one line every handler starts with, and the one place a
|
|
// registry refusal becomes a wire answer.
|
|
func (o Ops) reach(ctx context.Context, cluster string) (*registry.Bound, error) {
|
|
b, err := o.Reg.Reach(ctx, cluster)
|
|
if err != nil {
|
|
return nil, fail(err)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// fail maps an error onto the status a caller should see.
|
|
//
|
|
// The mapping is deliberate about ONE thing above all: a cluster another org
|
|
// registered answers 404, never 403. A "forbidden" would confirm the name exists
|
|
// and turn the refusal into an oracle for enumerating other tenants' clusters.
|
|
// A namespace outside a registration's bound DOES answer 403, because the caller
|
|
// already knows that namespace name — it typed it — so there is nothing to leak
|
|
// and everything to explain.
|
|
func fail(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var noTenant registry.ErrNoTenant
|
|
if errors.As(err, &noTenant) {
|
|
return zip.ErrForbidden(err.Error())
|
|
}
|
|
var noCluster registry.ErrNoCluster
|
|
if errors.As(err, &noCluster) {
|
|
return zip.ErrNotFound(err.Error())
|
|
}
|
|
var badNS registry.ErrNamespace
|
|
if errors.As(err, &badNS) {
|
|
return zip.ErrForbidden(err.Error())
|
|
}
|
|
var badScope registry.ErrScope
|
|
if errors.As(err, &badScope) {
|
|
return zip.ErrForbidden(err.Error())
|
|
}
|
|
var badName registry.ErrName
|
|
if errors.As(err, &badName) {
|
|
return zip.ErrBadRequest(err.Error())
|
|
}
|
|
// An apiserver's own verdict is surfaced with its own status and its own
|
|
// message. A RBAC refusal read as an internal error would send an operator
|
|
// hunting a bug in this app instead of granting the verb it named.
|
|
switch {
|
|
case apierrors.IsNotFound(err):
|
|
return zip.ErrNotFound(err.Error())
|
|
case apierrors.IsForbidden(err), apierrors.IsUnauthorized(err):
|
|
return zip.ErrForbidden(err.Error())
|
|
case apierrors.IsConflict(err), apierrors.IsAlreadyExists(err):
|
|
return zip.ErrConflict(err.Error())
|
|
case apierrors.IsInvalid(err), apierrors.IsBadRequest(err):
|
|
return zip.ErrBadRequest(err.Error())
|
|
}
|
|
var he *zip.HTTPError
|
|
if errors.As(err, &he) {
|
|
return he
|
|
}
|
|
return zip.ErrInternal(err.Error())
|
|
}
|
|
|
|
// need refuses an empty required argument. zip's `validate:"required"` covers the
|
|
// HTTP body; a peer calling over ZAP hands the struct straight in, so the check
|
|
// belongs in the handler as well as in the tag.
|
|
func need(field, value string) error {
|
|
if value == "" {
|
|
return zip.ErrBadRequest(fmt.Sprintf("%q is required", field))
|
|
}
|
|
return nil
|
|
}
|