Files
compute/object/machine_cloud.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

222 lines
6.2 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 object
import (
"context"
"fmt"
"github.com/hanzoai/visor/service"
)
// GetKubernetesNodesCloud returns DOKS worker nodes — as service.Machines — for
// every active BYOC DigitalOcean provider that names a cluster (Provider.ClusterID).
// This is the Provider-record cluster→org association; the house-account tag
// association lives in service.ListOrgKubernetesNodes and the controller unions
// both, so a cluster discovered by either path surfaces its nodes (deduped by
// droplet id) exactly once. The DO provider selection mirrors SyncNodePoolsCloud
// (Type=="DigitalOcean" && ClusterID!=""), so nodes come from the same clusters
// visor already reconciles pools for.
func GetKubernetesNodesCloud(owner string) ([]*service.Machine, error) {
providers, err := getActiveCloudProviders(owner)
if err != nil {
return nil, err
}
var machines []*service.Machine
for _, provider := range providers {
if provider.Type != "DigitalOcean" || provider.ClusterID == "" {
continue
}
token := provider.ClientSecret
if token == "" {
token = provider.ClientId
}
client, err := service.NewDOKSClient(token, provider.ClusterID)
if err != nil {
return nil, err
}
nodes, err := client.NodeMachines(context.Background())
if err != nil {
return nil, err
}
machines = append(machines, nodes...)
}
return machines, nil
}
func getMachineFromService(owner string, provider string, clientMachine *service.Machine) *Machine {
return &Machine{
Owner: owner,
Name: clientMachine.Name,
Id: clientMachine.Id,
Provider: provider,
CreatedTime: clientMachine.CreatedTime,
UpdatedTime: clientMachine.UpdatedTime,
ExpireTime: clientMachine.ExpireTime,
DisplayName: clientMachine.DisplayName,
Region: clientMachine.Region,
Zone: clientMachine.Zone,
Category: clientMachine.Category,
Type: clientMachine.Type,
Size: clientMachine.Size,
Tag: clientMachine.Tag,
State: clientMachine.State,
Image: clientMachine.Image,
Os: clientMachine.Os,
PublicIp: clientMachine.PublicIp,
PrivateIp: clientMachine.PrivateIp,
CpuSize: clientMachine.CpuSize,
MemSize: clientMachine.MemSize,
}
}
func getMachinesCloud(owner string) ([]*Machine, error) {
machines := []*Machine{}
providers, err := getActiveCloudProviders(owner)
if err != nil {
return nil, err
}
for _, provider := range providers {
client, err2 := service.NewMachineClient(provider.Type, provider.ClientId, provider.ClientSecret, provider.Region)
if err2 != nil {
return nil, err2
}
clientMachines, err2 := client.GetMachines()
if err2 != nil {
if provider.Type != "VMware" {
return nil, err2
}
}
for _, clientMachine := range clientMachines {
machine := getMachineFromService(owner, provider.Name, clientMachine)
machines = append(machines, machine)
}
}
return machines, nil
}
func SyncMachinesCloud(owner string) (bool, error) {
machines, err := getMachinesCloud(owner)
if err != nil {
return false, err
}
dbMachines, err := GetMachines(owner)
if err != nil {
return false, err
}
dbMachineMap := map[string]*Machine{}
for _, dbMachine := range dbMachines {
dbMachineMap[dbMachine.GetId()] = dbMachine
}
for _, machine := range machines {
if dbMachine, ok := dbMachineMap[machine.GetId()]; ok {
machine.RemoteProtocol = dbMachine.RemoteProtocol
machine.RemotePort = dbMachine.RemotePort
machine.RemoteUsername = dbMachine.RemoteUsername
machine.RemotePassword = dbMachine.RemotePassword
}
}
_, err = deleteMachines(owner)
if err != nil {
return false, err
}
if len(machines) == 0 {
return false, nil
}
affected, err := addMachines(owner, machines)
return affected, err
}
// CreateMachineCloud launches a new VM via the cloud provider and registers it in the DB.
func CreateMachineCloud(owner string, providerName string, spec *service.CreateMachineSpec) (*Machine, error) {
provider, err := getProvider(owner, providerName)
if err != nil {
return nil, err
}
if provider == nil {
return nil, fmt.Errorf("provider %q not found for owner %q", providerName, owner)
}
client, err := service.NewMachineClient(provider.Type, provider.ClientId, provider.ClientSecret, provider.Region)
if err != nil {
return nil, fmt.Errorf("failed to create machine client: %w", err)
}
clientMachine, err := client.CreateMachine(spec)
if err != nil {
return nil, fmt.Errorf("failed to create cloud machine: %w", err)
}
machine := getMachineFromService(owner, providerName, clientMachine)
machine.Os = spec.OS
// Set remote access defaults based on OS
switch spec.OS {
case "windows":
machine.RemoteProtocol = "RDP"
machine.RemotePort = 3389
case "macos":
machine.RemoteProtocol = "VNC"
machine.RemotePort = 5900
default:
machine.RemoteProtocol = "SSH"
machine.RemotePort = 22
}
// Persist to DB
_, err = AddMachine(machine)
if err != nil {
return nil, fmt.Errorf("machine created in cloud but DB insert failed: %w", err)
}
return machine, nil
}
func updateMachineCloud(oldMachine *Machine, machine *Machine) (bool, error) {
provider, err := getProvider(oldMachine.Owner, oldMachine.Provider)
if err != nil {
return false, err
}
if provider == nil {
return false, fmt.Errorf("The provider: %s does not exist", machine.Provider)
}
client, err := service.NewMachineClient(provider.Type, provider.ClientId, provider.ClientSecret, provider.Region)
if err != nil {
return false, err
}
if oldMachine.State != machine.State {
affected, _, err := client.UpdateMachineState(oldMachine.Name, machine.State)
if err != nil {
return false, err
}
return affected, nil
}
return false, nil
}