Files
compute/service/doks_test.go
zeekay ed86b0d80e feat(compute): DOKS worker nodes on the fleet — GET /v1/kubernetes-nodes
The fleet showed standalone droplets but never the WORKER NODES of a DOKS
cluster: ListNodePools already fetched each pool's Nodes (with real DropletID)
but SyncNodePoolsCloud discarded the per-node list, and visor never called
godo's Kubernetes.List/Get. So a cluster's nodes were invisible on
world.hanzo.ai even though each is a real droplet.

Source the nodes and surface them as fleet Machines:

- service/doks.go: ListClusters(ctx) (Kubernetes.List + per-cluster Get for
  authoritative region/status/pools), nodePoolMachines (the ONE node->Machine
  mapper: Id=DropletID so it dedups against the droplet list, Name=node.Name,
  Provider=DigitalOcean, Size=pool.Size, Region=cluster.RegionSlug,
  State=node.Status, Tag=doks-cluster:<name>, IPs honestly empty; a node still
  provisioning with no DropletID is skipped), and the two org associations that
  feed it.
- service/compute.go ListOrgKubernetesNodes: HOUSE account, cluster hanzo-org
  tag association (DOKS node droplets carry k8s tags, not a hanzo-org DROPLET
  tag, so they never surfaced via ListOrgMachines).
- object/machine_cloud.go GetKubernetesNodesCloud: BYOC providers, the
  Provider.ClusterID association (mirrors SyncNodePoolsCloud's DO selection).
- controllers/compute.go ListComputeKubernetesNodes: org-scoped, unions BOTH
  associations deduped by id-or-name (a cluster found via tag AND provider, or a
  node whose droplet is also listed, appears once) in the casibase envelope.
- routers/router.go: GET /v1/kubernetes-nodes.

Both associations implemented and unioned rather than guessing which is live —
strictly more correct. Tests: service TestNodePoolMachines (mapping + skip
unprovisioned + empty IPs) passes; controllers TestUnionMachinesDedup compiles
and pins the dedup contract. (The object/controllers test BINARIES currently
panic at init on origin/main — modernc.org/sqlite vs hanzoai/sqlite both
register driver "sqlite" via object -> hanzoai/vfs/replica -> modernc, a
pre-existing PR #10 migration artifact unrelated to this change; go build ./...
and go vet are green.)
2026-07-18 16:46:22 -07:00

78 lines
3.0 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.
package service
import "testing"
// TestNodePoolMachines pins the DOKS node -> fleet Machine mapping: one Machine
// per WORKER NODE (not per pool), keyed by the node's DropletID so it dedups
// against the live droplet list, sized by its pool, regioned + cluster-tagged by
// its cluster, and carrying the node's DO status. A node still provisioning (no
// DropletID) has no fleet identity and is skipped. IPs are honestly empty.
func TestNodePoolMachines(t *testing.T) {
cluster := &KubernetesCluster{ID: "cl-1", Name: "prod", RegionSlug: "sfo3"}
pools := []*NodePool{
{
Name: "default", Size: "s-4vcpu-8gb",
Nodes: []NodeInfo{
{ID: "n-1", Name: "prod-default-aaa", DropletID: "111", Status: "running"},
{ID: "n-2", Name: "prod-default-bbb", DropletID: "222", Status: "running"},
{ID: "n-3", Name: "prod-default-ccc", DropletID: "", Status: "provisioning"}, // skipped
},
},
{
Name: "gpu", Size: "gpu-h100x1-80gb",
Nodes: []NodeInfo{
{ID: "n-4", Name: "prod-gpu-ddd", DropletID: "333", Status: "running"},
},
},
}
got := nodePoolMachines(cluster, pools)
if len(got) != 3 {
t.Fatalf("want 3 machines (2 default + 1 gpu, provisioning node skipped), got %d: %+v", len(got), got)
}
byID := map[string]*Machine{}
for _, m := range got {
byID[m.Id] = m
}
for _, tc := range []struct{ id, name, size string }{
{"111", "prod-default-aaa", "s-4vcpu-8gb"},
{"222", "prod-default-bbb", "s-4vcpu-8gb"},
{"333", "prod-gpu-ddd", "gpu-h100x1-80gb"},
} {
m, ok := byID[tc.id]
if !ok {
t.Fatalf("node droplet %q missing from mapped machines: %+v", tc.id, got)
}
if m.Name != tc.name || m.Size != tc.size {
t.Errorf("droplet %q: got name=%q size=%q, want name=%q size=%q", tc.id, m.Name, m.Size, tc.name, tc.size)
}
if m.Provider != "DigitalOcean" || m.Region != "sfo3" || m.State != "running" || m.Tag != "doks-cluster:prod" {
t.Errorf("droplet %q: got provider=%q region=%q state=%q tag=%q, want DigitalOcean/sfo3/running/doks-cluster:prod",
tc.id, m.Provider, m.Region, m.State, m.Tag)
}
if m.PublicIp != "" || m.PrivateIp != "" {
t.Errorf("droplet %q: IPs must be empty (DOKS node API exposes none), got public=%q private=%q", tc.id, m.PublicIp, m.PrivateIp)
}
}
// A cluster with no pools maps to no machines (no panic, honest empty).
if got := nodePoolMachines(&KubernetesCluster{Name: "empty"}, nil); len(got) != 0 {
t.Fatalf("empty cluster want 0 machines, got %d", len(got))
}
}