From 9392a3ccde7470086d5f5e3850486cc81b49c021 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 16:17:27 -0500 Subject: [PATCH 01/27] feat: implement datumctl compute CLI plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the datumctl-compute plugin binary with commands for deploying and managing containerized workloads on Datum Cloud via the developer CLI. Commands: - deploy — create or update a workload from flags or a manifest file - destroy — delete a workload and clean up its revision history - status — show health, placement summary, and recent revision info - instances — list and describe running instances across cities - scale — adjust minimum replica count across placements - rollout — watch live progress, view history, and roll back revisions - restart — trigger a rolling restart of a workload or specific city - quota — inspect per-city instance usage and quota headroom Closes #98. Depends on datum-cloud/datumctl#198. Co-Authored-By: Claude Sonnet 4.6 --- .goreleaser-plugin.yaml | 51 +++ cmd/datumctl-compute/main.go | 28 ++ go.mod | 113 ++--- go.sum | 228 +++++----- internal/cmd/compute/client.go | 72 ++++ internal/cmd/compute/deploy/deploy.go | 442 ++++++++++++++++++++ internal/cmd/compute/destroy/destroy.go | 100 +++++ internal/cmd/compute/instances/instances.go | 356 ++++++++++++++++ internal/cmd/compute/quota/quota.go | 243 +++++++++++ internal/cmd/compute/restart/restart.go | 114 +++++ internal/cmd/compute/revision/revision.go | 169 ++++++++ internal/cmd/compute/rollout/rollout.go | 272 ++++++++++++ internal/cmd/compute/root.go | 32 ++ internal/cmd/compute/scale/scale.go | 76 ++++ internal/cmd/compute/status/status.go | 246 +++++++++++ internal/cmd/compute/util/client.go | 58 +++ internal/cmd/compute/util/conditions.go | 149 +++++++ internal/cmd/compute/util/table.go | 12 + internal/cmd/compute/util/time.go | 33 ++ internal/cmd/compute/watch/watch.go | 203 +++++++++ 20 files changed, 2845 insertions(+), 152 deletions(-) create mode 100644 .goreleaser-plugin.yaml create mode 100644 cmd/datumctl-compute/main.go create mode 100644 internal/cmd/compute/client.go create mode 100644 internal/cmd/compute/deploy/deploy.go create mode 100644 internal/cmd/compute/destroy/destroy.go create mode 100644 internal/cmd/compute/instances/instances.go create mode 100644 internal/cmd/compute/quota/quota.go create mode 100644 internal/cmd/compute/restart/restart.go create mode 100644 internal/cmd/compute/revision/revision.go create mode 100644 internal/cmd/compute/rollout/rollout.go create mode 100644 internal/cmd/compute/root.go create mode 100644 internal/cmd/compute/scale/scale.go create mode 100644 internal/cmd/compute/status/status.go create mode 100644 internal/cmd/compute/util/client.go create mode 100644 internal/cmd/compute/util/conditions.go create mode 100644 internal/cmd/compute/util/table.go create mode 100644 internal/cmd/compute/util/time.go create mode 100644 internal/cmd/compute/watch/watch.go diff --git a/.goreleaser-plugin.yaml b/.goreleaser-plugin.yaml new file mode 100644 index 00000000..69fb233c --- /dev/null +++ b/.goreleaser-plugin.yaml @@ -0,0 +1,51 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 + +project_name: datumctl-compute + +before: + hooks: + - go mod tidy + +builds: + - id: datumctl-compute + binary: datumctl-compute + main: ./cmd/datumctl-compute + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - "-X main.version=v{{.Version}}" + +archives: + - id: datumctl-compute + builds: + - datumctl-compute + format: tar.gz + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + format_overrides: + - goos: windows + format: zip + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" diff --git a/cmd/datumctl-compute/main.go b/cmd/datumctl-compute/main.go new file mode 100644 index 00000000..e092b3e9 --- /dev/null +++ b/cmd/datumctl-compute/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "os" + + "go.datum.net/datumctl/plugin" + + "go.datum.net/compute/internal/cmd/compute" +) + +// version is set at build time via ldflags. +var version = "dev" + +var manifest = plugin.Manifest{ + Name: "compute", + Version: version, + Description: "Deploy and manage containerized workloads on Datum Cloud", + APIVersion: 1, + MinAPIVersion: 1, +} + +func main() { + plugin.ServeManifest(manifest) + + if err := compute.Command().Execute(); err != nil { + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 9fd39087..513e579c 100644 --- a/go.mod +++ b/go.mod @@ -1,37 +1,38 @@ module go.datum.net/compute -go 1.25.0 +go 1.25.8 require ( github.com/google/go-cmp v0.7.0 - github.com/karmada-io/api v1.15.0 github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.2 + github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 - go.datum.net/network-services-operator v0.21.10-0.20260528021428-b0f2347f5359 - go.miloapis.com/milo v0.25.2-0.20260528192736-e4258524ad42 - golang.org/x/crypto v0.45.0 - golang.org/x/sync v0.18.0 + go.datum.net/datumctl v0.0.0 + go.datum.net/network-services-operator v0.1.0 + go.miloapis.com/milo v0.24.11 + golang.org/x/crypto v0.49.0 + golang.org/x/sync v0.20.0 google.golang.org/protobuf v1.36.11 - k8s.io/api v0.35.0 - k8s.io/apimachinery v0.35.0 - k8s.io/client-go v0.35.0 - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 + k8s.io/api v0.35.3 + k8s.io/apimachinery v0.35.3 + k8s.io/client-go v0.35.3 + k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 sigs.k8s.io/controller-runtime v0.23.3 - sigs.k8s.io/gateway-api v1.3.1-0.20250527223622-54df0a899c1c - sigs.k8s.io/multicluster-runtime v0.23.3 + sigs.k8s.io/gateway-api v1.2.1 + sigs.k8s.io/multicluster-runtime v0.21.0-alpha.8 ) require ( cel.dev/expr v0.24.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -39,20 +40,29 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.21.1 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.0 // indirect - github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.9.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -60,48 +70,51 @@ require ( github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.17.0 // indirect - github.com/spf13/cobra v1.10.0 // indirect - github.com/spf13/pflag v1.0.9 // indirect - github.com/stoewer/go-strcase v1.3.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/sdk v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.43.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect - google.golang.org/grpc v1.74.2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect + google.golang.org/grpc v1.72.2 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.35.0 // indirect - k8s.io/apiserver v0.35.0 // indirect - k8s.io/component-base v0.35.0 // indirect + k8s.io/apiserver v0.35.3 // indirect + k8s.io/component-base v0.35.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) + +// datumctl plugin SDK — replace with a released version once PR #198 is merged +// (https://github.com/datum-cloud/datumctl/pull/198) +replace go.datum.net/datumctl => ../datumctl/.claude/worktrees/agent-aaa7004d0f14304c6 diff --git a/go.sum b/go.sum index 42a98554..7639dd22 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= -github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -17,10 +17,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= -github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= +github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -42,12 +42,40 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= -github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= -github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= @@ -58,8 +86,8 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= -github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= -github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -69,18 +97,14 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/karmada-io/api v1.15.0 h1:6Dx+Q36LaoPqKM4gduUuhSBQ3eKjKusjkvmggLpt9xs= -github.com/karmada-io/api v1.15.0/go.mod h1:wNbBEmXYkrRLSC2VgmXizIG12FW+/sAUF7UIz5WlYAU= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -89,8 +113,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -107,6 +129,8 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -116,18 +140,18 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.10.0 h1:a5/WeUlSDCvV5a45ljW2ZFtV0bTDpkfSAj3uqB6Sc+0= -github.com/spf13/cobra v1.10.0/go.mod h1:9dhySC7dnTtEiqzmqfkLj47BslqLCUPMXjG2lj/NgoE= -github.com/spf13/pflag v1.0.8/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= -github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -149,30 +173,30 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -go.datum.net/network-services-operator v0.21.10-0.20260528021428-b0f2347f5359 h1:P3dePA6cCXKimZzE6d7Xxpj2rz54BxOHI8K8ic7VQ+c= -go.datum.net/network-services-operator v0.21.10-0.20260528021428-b0f2347f5359/go.mod h1:Nr0PsCodkTW31vWVxR9dhAP9w0y+WHUYeyrcRnchcIE= -go.miloapis.com/milo v0.25.2-0.20260528192736-e4258524ad42 h1:LSHyqLt/jus6iEMvo8pc731L+PyrTHP2bqfMMtHPSWc= -go.miloapis.com/milo v0.25.2-0.20260528192736-e4258524ad42/go.mod h1:p9O2kk194mvoL8rhqjwb+LWB+GIyY4vQqiTowwibVWo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.datum.net/network-services-operator v0.1.0 h1:PAXOZ5DdJFgRoeVBPIXhqkCm6DxbP4tVOPcr3Y7h/So= +go.datum.net/network-services-operator v0.1.0/go.mod h1:uloVfxqE+8DgSiMB651X8UC9yECpXbwp/NBstofCceE= +go.miloapis.com/milo v0.24.11 h1:rByXDKbP4ZEN0I/z1C2RyUCyQi0NWrITLqoQILSAn2E= +go.miloapis.com/milo v0.24.11/go.mod h1:xOFYvUsvSZV3z6eow5YdB5C/qRQf2s/5/arcfJs5XPg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -183,36 +207,36 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= -golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8= -google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= +google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -225,37 +249,37 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= -k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= +k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= -k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= -k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= -k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= -k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= -k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= -k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= -k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= +k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= +k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.3 h1:D2eIcfJ05hEAEewoSDg+05e0aSRwx8Y4Agvd/wiomUI= +k8s.io/apiserver v0.35.3/go.mod h1:JI0n9bHYzSgIxgIrfe21dbduJ9NHzKJ6RchcsmIKWKY= +k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= +k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= +k8s.io/component-base v0.35.3 h1:mbKbzoIMy7JDWS/wqZobYW1JDVRn/RKRaoMQHP9c4P0= +k8s.io/component-base v0.35.3/go.mod h1:IZ8LEG30kPN4Et5NeC7vjNv5aU73ku5MS15iZyvyMYk= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= -k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= -sigs.k8s.io/gateway-api v1.3.1-0.20250527223622-54df0a899c1c h1:GS4VnGRV90GEUjrgQ2GT5ii6yzWj3KtgUg+sVMdhs5c= -sigs.k8s.io/gateway-api v1.3.1-0.20250527223622-54df0a899c1c/go.mod h1:d8NV8nJbaRbEKem+5IuxkL8gJGOZ+FJ+NvOIltV8gDk= +sigs.k8s.io/gateway-api v1.2.1 h1:fZZ/+RyRb+Y5tGkwxFKuYuSRQHu9dZtbjenblleOLHM= +sigs.k8s.io/gateway-api v1.2.1/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/multicluster-runtime v0.23.3 h1:vrzlXRzHTDsjspUAfoW2rCtr0agoI4q20p9x4Fz4png= -sigs.k8s.io/multicluster-runtime v0.23.3/go.mod h1:r/UA4GHgFoXCcR4tcvlZz7SiLx3l1kJKDuBAhILNIHs= +sigs.k8s.io/multicluster-runtime v0.21.0-alpha.8 h1:Pq69tTKfN8ADw8m8A3wUtP8wJ9SPQbbOsgapm3BZEPw= +sigs.k8s.io/multicluster-runtime v0.21.0-alpha.8/go.mod h1:CpBzLMLQKdm+UCchd2FiGPiDdCxM5dgCCPKuaQ6Fsv0= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/cmd/compute/client.go b/internal/cmd/compute/client.go new file mode 100644 index 00000000..4186b2b4 --- /dev/null +++ b/internal/cmd/compute/client.go @@ -0,0 +1,72 @@ +package compute + +import ( + "fmt" + + "github.com/spf13/cobra" + "go.datum.net/datumctl/plugin" + computev1alpha "go.datum.net/compute/api/v1alpha" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + resourceManagerGroup = "resourcemanager.miloapis.com" + resourceManagerVersion = "v1alpha1" +) + +// projectControlPlaneURL returns the virtual control-plane URL for a project. +// Each project in Datum Cloud has its own isolated Kubernetes-style API endpoint +// rooted here; all resource operations (List, Get, Create, etc.) target this URL. +func projectControlPlaneURL(apiHost, projectID string) string { + return fmt.Sprintf("https://%s/apis/%s/%s/projects/%s/control-plane", + apiHost, resourceManagerGroup, resourceManagerVersion, projectID) +} + +// newClient builds a Kubernetes client targeting the project's virtual control plane. +// It acquires a fresh bearer token via the datumctl credentials helper on each call, +// so it must not be cached across long-running operations. +// +// project is the resolved project slug — callers should read it from the cobra +// --project persistent flag (set on the root command by plugin.NewRootCmd), which +// defaults to the DATUM_PROJECT value injected by datumctl but can be overridden +// by the user at invocation time. +// +// The returned project string must be passed as the namespace to all client operations: +// +// c.List(ctx, list, client.InNamespace(project)) +func newClient(project string) (client.Client, error) { + if project == "" { + return nil, fmt.Errorf("no project set — pass --project or run 'datumctl config set project '") + } + + pluginCtx := plugin.Context() + if pluginCtx.APIHost == "" { + return nil, fmt.Errorf("DATUM_API_HOST is not set; is this plugin running via datumctl?") + } + + token, err := plugin.Token() + if err != nil { + return nil, fmt.Errorf("getting credentials: %w", err) + } + + scheme := runtime.NewScheme() + if err := computev1alpha.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("registering compute scheme: %w", err) + } + + cfg := &rest.Config{ + Host: projectControlPlaneURL(pluginCtx.APIHost, project), + BearerToken: token, + } + + return client.New(cfg, client.Options{Scheme: scheme}) +} + +// projectFromCmd reads the --project persistent flag from the command's root, +// which plugin.NewRootCmd wires with DATUM_PROJECT as the default. +func projectFromCmd(cmd *cobra.Command) string { + project, _ := cmd.Root().PersistentFlags().GetString("project") + return project +} diff --git a/internal/cmd/compute/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go new file mode 100644 index 00000000..c1a09759 --- /dev/null +++ b/internal/cmd/compute/deploy/deploy.go @@ -0,0 +1,442 @@ +package deploy + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + "strings" + "time" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + utilyaml "k8s.io/apimachinery/pkg/util/yaml" + "golang.org/x/term" + sigsyaml "sigs.k8s.io/yaml" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/cmd/compute/revision" + "go.datum.net/compute/internal/cmd/compute/util" + "go.datum.net/compute/internal/cmd/compute/watch" +) + +type options struct { + image string + instanceType string + cities []string + min int32 + port int32 + file string + yes bool +} + +func Command() *cobra.Command { + opts := &options{} + + cmd := &cobra.Command{ + Use: "deploy [workload-name]", + Short: "Deploy or update a workload", + Long: `Deploy a container image as a workload across one or more cities. + +If no arguments are given, an interactive prompt guides you through the deployment. +Use -f to apply a workload manifest file instead of flags.`, + Args: cobra.MaximumNArgs(1), + Example: ` # Deploy with flags + datumctl compute deploy api --image=ghcr.io/acme/api:1.4.2 --city=DFW,IAD --min=2 --port=8080 + + # Interactive mode + datumctl compute deploy + + # Manifest-driven + datumctl compute deploy -f workload.yaml`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDeploy(cmd, args, opts) + }, + } + + cmd.Flags().StringVar(&opts.image, "image", "", "Container image to deploy (e.g. ghcr.io/acme/api:1.4.2)") + cmd.Flags().StringVar(&opts.instanceType, "instance-type", "d1-standard-2", "Instance type (e.g. d1-standard-2)") + cmd.Flags().StringSliceVar(&opts.cities, "city", nil, "One or more city codes to deploy to (e.g. DFW,IAD)") + cmd.Flags().Int32Var(&opts.min, "min", 1, "Minimum number of instances per city") + cmd.Flags().Int32Var(&opts.port, "port", 0, "Port to expose on the workload (optional)") + cmd.Flags().StringVarP(&opts.file, "file", "f", "", "Path to a workload manifest file") + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip confirmation prompts") + + return cmd +} + +func runDeploy(cmd *cobra.Command, args []string, opts *options) error { + // Determine path. + if opts.file != "" { + return deployFromFile(cmd, opts) + } + + if len(args) > 0 && opts.image != "" { + return deployFromFlags(cmd, args[0], opts) + } + + return fmt.Errorf("workload name and --image are required, or use -f to specify a manifest file") +} + +// deployFromFlags implements Path A: deploy a workload using CLI flags. +func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) error { + project := util.ProjectFromCmd(cmd) + if project == "" { + return fmt.Errorf("no project set — pass --project or run 'datumctl config set project '") + } + if opts.image == "" { + return fmt.Errorf("--image is required") + } + if len(opts.cities) == 0 { + return fmt.Errorf("--city is required (e.g. --city=DFW,IAD)") + } + instanceType := opts.instanceType + if instanceType == "" { + instanceType = "d1-standard-2" + } + + c, err := util.NewClient(project) + if err != nil { + return err + } + + ctx := context.Background() + out := cmd.OutOrStdout() + + fmt.Fprintf(out, "Resolving workload %q in project %s...\n", workloadName, project) + + var workload computev1alpha.Workload + creating := false + if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if k8serrors.IsNotFound(err) { + creating = true + workload = computev1alpha.Workload{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: project, + Name: workloadName, + }, + } + } else { + return fmt.Errorf("getting workload: %w", err) + } + } + + // Build spec. + tcp := corev1.ProtocolTCP + container := computev1alpha.SandboxContainer{ + Name: "app", + Image: opts.image, + } + if opts.port > 0 { + container.Ports = []computev1alpha.NamedPort{ + {Name: "http", Port: opts.port, Protocol: &tcp}, + } + } + + // All cities go into one "default" placement. + placement := computev1alpha.WorkloadPlacement{ + Name: "default", + CityCodes: opts.cities, + ScaleSettings: computev1alpha.HorizontalScaleSettings{ + MinReplicas: opts.min, + InstanceManagementPolicy: computev1alpha.OrderedReadyInstanceManagementPolicyType, + }, + } + + workload.Spec = computev1alpha.WorkloadSpec{ + Template: computev1alpha.InstanceTemplateSpec{ + Spec: computev1alpha.InstanceSpec{ + Runtime: computev1alpha.InstanceRuntimeSpec{ + Resources: computev1alpha.InstanceRuntimeResources{ + InstanceType: instanceType, + }, + Sandbox: &computev1alpha.SandboxRuntime{ + Containers: []computev1alpha.SandboxContainer{container}, + }, + }, + NetworkInterfaces: []computev1alpha.InstanceNetworkInterface{ + { + // TODO: "default" network name is a convention; confirm with platform team. + Network: networkingv1alpha.NetworkRef{Name: "default"}, + }, + }, + }, + }, + Placements: []computev1alpha.WorkloadPlacement{placement}, + } + + fmt.Fprintf(out, " Placement \"default\": cities=[%s], min=%d\n", + strings.Join(opts.cities, ", "), opts.min) + + // Prompt unless --yes or non-interactive. + if !opts.yes && term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprint(out, "Apply? (Y/n): ") + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return fmt.Errorf("reading confirmation: %w", err) + } + line = strings.TrimSpace(line) + if line == "n" || line == "N" { + fmt.Fprintln(out, "Aborted.") + return nil + } + } + + // Compute diff description before applying. + var changes string + if creating { + changes = "initial deploy" + } else { + changes = computeDiff(workload.Spec, opts.image, opts.cities, opts.min) + } + + if creating { + workload.Namespace = project + if err := c.Create(ctx, &workload); err != nil { + return fmt.Errorf("creating workload: %w", err) + } + fmt.Fprintf(out, " workload/%s created\n", workloadName) + } else { + if err := c.Update(ctx, &workload); err != nil { + return fmt.Errorf("updating workload: %w", err) + } + fmt.Fprintf(out, " workload/%s updated\n", workloadName) + } + + // Write revision entry. + rev := revision.CurrentRevision(ctx, c, project, workloadName) + 1 + specJSON, _ := json.Marshal(workload.Spec) + entry := revision.Entry{ + Rev: rev, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Image: opts.image, + Changes: changes, + SpecJSON: string(specJSON), + } + if err := revision.WriteEntry(ctx, c, project, workloadName, entry); err != nil { + // Non-fatal — log but continue. + fmt.Fprintf(out, " warning: could not write revision history: %v\n", err) + } + + // Save workload.yaml. + if err := saveWorkloadYAML(workloadName, &workload); err != nil { + fmt.Fprintf(out, " warning: could not save workload.yaml: %v\n", err) + } else { + fmt.Fprintln(out, "Saved workload.yaml") + } + + fmt.Fprintf(out, "Waiting for rollout. Ctrl-C to detach (rollout continues in background).\n\n") + + watchCtx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer cancel() + return watch.Rollout(watchCtx, c, out, project, workload.UID) +} + +// deployFromFile implements Path C: deploy from a manifest file. +func deployFromFile(cmd *cobra.Command, opts *options) error { + project := util.ProjectFromCmd(cmd) + if project == "" { + return fmt.Errorf("no project set — pass --project or run 'datumctl config set project '") + } + + data, err := os.ReadFile(opts.file) + if err != nil { + return fmt.Errorf("reading manifest: %w", err) + } + + var workload computev1alpha.Workload + decoder := utilyaml.NewYAMLOrJSONDecoder(bytes.NewReader(data), 4096) + if err := decoder.Decode(&workload); err != nil { + return fmt.Errorf("decoding manifest: %w", err) + } + + workload.Namespace = project + + c, err := util.NewClient(project) + if err != nil { + return err + } + + ctx := context.Background() + out := cmd.OutOrStdout() + + var existing computev1alpha.Workload + creating := false + if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workload.Name}, &existing); err != nil { + if k8serrors.IsNotFound(err) { + creating = true + } else { + return fmt.Errorf("getting workload: %w", err) + } + } + + var diffLines []string + if !creating { + diffLines = manifestDiff(existing, workload) + for _, l := range diffLines { + fmt.Fprintln(out, l) + } + if len(diffLines) == 0 { + fmt.Fprintln(out, "No changes detected.") + } + } + + // Prompt unless --yes or non-interactive. + if !opts.yes && term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprint(out, "Apply? (Y/n): ") + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return fmt.Errorf("reading confirmation: %w", err) + } + line = strings.TrimSpace(line) + if line == "n" || line == "N" { + fmt.Fprintln(out, "Aborted.") + return nil + } + } + + // Determine changes summary. + var changes string + if creating { + changes = "initial deploy" + } else if len(diffLines) > 0 { + changes = strings.Join(diffLines, "; ") + } else { + changes = "manifest apply" + } + + image := imageFromWorkload(workload) + + if creating { + if err := c.Create(ctx, &workload); err != nil { + return fmt.Errorf("creating workload: %w", err) + } + fmt.Fprintf(out, " workload/%s created\n", workload.Name) + } else { + workload.ResourceVersion = existing.ResourceVersion + if err := c.Update(ctx, &workload); err != nil { + return fmt.Errorf("updating workload: %w", err) + } + fmt.Fprintf(out, " workload/%s updated\n", workload.Name) + } + + rev := revision.CurrentRevision(ctx, c, project, workload.Name) + 1 + specJSON, _ := json.Marshal(workload.Spec) + entry := revision.Entry{ + Rev: rev, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Image: image, + Changes: changes, + SpecJSON: string(specJSON), + } + if err := revision.WriteEntry(ctx, c, project, workload.Name, entry); err != nil { + fmt.Fprintf(out, " warning: could not write revision history: %v\n", err) + } + + fmt.Fprintf(out, "Waiting for rollout. Ctrl-C to detach (rollout continues in background).\n\n") + + watchCtx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer cancel() + return watch.Rollout(watchCtx, c, out, project, workload.UID) +} + +// saveWorkloadYAML marshals the workload and writes it to workload.yaml in the +// current directory. +func saveWorkloadYAML(workloadName string, workload *computev1alpha.Workload) error { + workload.TypeMeta = metav1.TypeMeta{ + APIVersion: "compute.datumapis.com/v1alpha", + Kind: "Workload", + } + + data, err := sigsyaml.Marshal(workload) + if err != nil { + return fmt.Errorf("marshalling workload: %w", err) + } + + header := "# Managed by datumctl compute deploy. Commit this file to manage your workload declaratively.\n" + + "# Apply changes with: datumctl compute deploy -f workload.yaml\n" + + return os.WriteFile("workload.yaml", append([]byte(header), data...), 0o644) +} + +// imageFromWorkload returns the first container image found in a workload, or empty string. +func imageFromWorkload(w computev1alpha.Workload) string { + sb := w.Spec.Template.Spec.Runtime.Sandbox + if sb != nil && len(sb.Containers) > 0 { + return sb.Containers[0].Image + } + return "" +} + +// computeDiff produces a human-readable one-line diff description for flag-driven updates. +func computeDiff(existing computev1alpha.WorkloadSpec, newImage string, cities []string, min int32) string { + var parts []string + + oldImage := "" + if existing.Template.Spec.Runtime.Sandbox != nil && len(existing.Template.Spec.Runtime.Sandbox.Containers) > 0 { + oldImage = existing.Template.Spec.Runtime.Sandbox.Containers[0].Image + } + if oldImage != newImage { + parts = append(parts, fmt.Sprintf("image: %s → %s", oldImage, newImage)) + } + + if len(existing.Placements) > 0 { + oldMin := existing.Placements[0].ScaleSettings.MinReplicas + if oldMin != min { + parts = append(parts, fmt.Sprintf("min replicas: %d → %d", oldMin, min)) + } + } + + if len(parts) == 0 { + return "no changes" + } + return strings.Join(parts, ", ") +} + +// manifestDiff computes diff lines between an existing and desired workload. +func manifestDiff(existing, desired computev1alpha.Workload) []string { + var lines []string + + oldImage := imageFromWorkload(existing) + newImage := imageFromWorkload(desired) + if oldImage != newImage { + lines = append(lines, fmt.Sprintf(" image: %s → %s", oldImage, newImage)) + } + + // Compare placements by name. + oldPlacements := make(map[string]computev1alpha.WorkloadPlacement) + for _, p := range existing.Spec.Placements { + oldPlacements[p.Name] = p + } + newPlacements := make(map[string]computev1alpha.WorkloadPlacement) + for _, p := range desired.Spec.Placements { + newPlacements[p.Name] = p + } + + for name, np := range newPlacements { + if op, ok := oldPlacements[name]; ok { + if op.ScaleSettings.MinReplicas != np.ScaleSettings.MinReplicas { + lines = append(lines, fmt.Sprintf(" placement %q min replicas: %d → %d", + name, op.ScaleSettings.MinReplicas, np.ScaleSettings.MinReplicas)) + } + } else { + lines = append(lines, fmt.Sprintf(" + new placement %q: cities=[%s]", + name, strings.Join(np.CityCodes, ", "))) + } + } + for name := range oldPlacements { + if _, ok := newPlacements[name]; !ok { + lines = append(lines, fmt.Sprintf(" - removed placement %q", name)) + } + } + + return lines +} + diff --git a/internal/cmd/compute/destroy/destroy.go b/internal/cmd/compute/destroy/destroy.go new file mode 100644 index 00000000..74abc4da --- /dev/null +++ b/internal/cmd/compute/destroy/destroy.go @@ -0,0 +1,100 @@ +package destroy + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "golang.org/x/term" + + corev1 "k8s.io/api/core/v1" + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/cmd/compute/revision" + "go.datum.net/compute/internal/cmd/compute/util" +) + +func Command() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "destroy ", + Short: "Delete a workload and all its instances", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDestroy(cmd, args, yes) + }, + } + + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} + +func runDestroy(cmd *cobra.Command, args []string, yes bool) error { + project := util.ProjectFromCmd(cmd) + + c, err := util.NewClient(project) + if err != nil { + return err + } + + ctx := context.Background() + workloadName := args[0] + + var workload computev1alpha.Workload + if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("workload %q not found in project %s", workloadName, project) + } + return fmt.Errorf("getting workload: %w", err) + } + + // Summarize placements. + var allCityCodes []string + var totalMin int32 + for _, p := range workload.Spec.Placements { + allCityCodes = append(allCityCodes, p.CityCodes...) + totalMin += p.ScaleSettings.MinReplicas + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Workload: %s\nPlacements: %d Cities: %s\nMin replicas: %d\n\n", + workloadName, + len(workload.Spec.Placements), + strings.Join(allCityCodes, ", "), + totalMin, + ) + + // Prompt unless --yes or non-interactive. + if !yes && term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprint(out, "This will delete workload and all its instances. Continue? (y/N): ") + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return fmt.Errorf("reading confirmation: %w", err) + } + line = strings.TrimSpace(line) + if line != "y" && line != "Y" { + fmt.Fprintln(out, "Aborted.") + return nil + } + } + + if err := c.Delete(ctx, &workload); err != nil { + return fmt.Errorf("deleting workload: %w", err) + } + + // Best-effort deletion of the revision ConfigMap. + var cm corev1.ConfigMap + cmName := revision.ConfigMapName(workloadName) + if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: cmName}, &cm); err == nil { + _ = c.Delete(ctx, &cm) + } + + fmt.Fprintf(out, "workload/%s deleted.\n", workloadName) + return nil +} diff --git a/internal/cmd/compute/instances/instances.go b/internal/cmd/compute/instances/instances.go new file mode 100644 index 00000000..56f9a750 --- /dev/null +++ b/internal/cmd/compute/instances/instances.go @@ -0,0 +1,356 @@ +package instances + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/cmd/compute/util" +) + +type listOptions struct { + workload string + city string +} + +func Command() *cobra.Command { + opts := &listOptions{} + + cmd := &cobra.Command{ + Use: "instances", + Short: "List or inspect workload instances", + Long: `List all running instances in the project, optionally filtered by workload. +Use the describe subcommand for full details on a single instance.`, + Example: ` # List all instances + datumctl compute instances + + # Filter by workload + datumctl compute instances --workload=api + + # Filter by city + datumctl compute instances --city=DFW + + # Describe a single instance + datumctl compute instances describe api-dfw-0`, + RunE: func(cmd *cobra.Command, args []string) error { + return runList(cmd, opts) + }, + } + + cmd.Flags().StringVar(&opts.workload, "workload", "", "Filter instances to a specific workload") + cmd.Flags().StringVar(&opts.city, "city", "", "Filter instances to a specific city") + + cmd.AddCommand(describeCommand()) + + return cmd +} + +type instanceRow struct { + name string + workload string + city string + externalIP string + internalIP string + instType string + age string + status string +} + +func runList(cmd *cobra.Command, opts *listOptions) error { + ctx := context.Background() + project := util.ProjectFromCmd(cmd) + + c, err := util.NewClient(project) + if err != nil { + return err + } + + // Optionally resolve workload UID. + var workloadUID string + if opts.workload != "" { + var wl computev1alpha.Workload + if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: opts.workload}, &wl); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("workload %q not found", opts.workload) + } + return fmt.Errorf("getting workload: %w", err) + } + workloadUID = string(wl.UID) + } + + // List instances. + var instList computev1alpha.InstanceList + listOpts := []client.ListOption{client.InNamespace(project)} + if workloadUID != "" { + selector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadUIDLabel: workloadUID}) + listOpts = append(listOpts, client.MatchingLabelsSelector{Selector: selector}) + } + if err := c.List(ctx, &instList, listOpts...); err != nil { + return fmt.Errorf("listing instances: %w", err) + } + + // List deployments — build map deploymentUID → *WorkloadDeployment. + var deployList computev1alpha.WorkloadDeploymentList + if err := c.List(ctx, &deployList, client.InNamespace(project)); err != nil { + return fmt.Errorf("listing deployments: %w", err) + } + deploymentMap := make(map[string]*computev1alpha.WorkloadDeployment, len(deployList.Items)) + for i := range deployList.Items { + d := &deployList.Items[i] + deploymentMap[string(d.UID)] = d + } + + // List workloads — build map workloadUID → name. + var wlList computev1alpha.WorkloadList + if err := c.List(ctx, &wlList, client.InNamespace(project)); err != nil { + return fmt.Errorf("listing workloads: %w", err) + } + workloadMap := make(map[string]string, len(wlList.Items)) + for _, wl := range wlList.Items { + workloadMap[string(wl.UID)] = wl.Name + } + + // Build rows. + var rows []instanceRow + for _, inst := range instList.Items { + depUID := inst.Labels[computev1alpha.WorkloadDeploymentUIDLabel] + wlUID := inst.Labels[computev1alpha.WorkloadUIDLabel] + + city := "unknown" + wlName := workloadMap[wlUID] + if wlName == "" { + wlName = "orphaned" + } + if dep, ok := deploymentMap[depUID]; ok { + city = dep.Spec.CityCode + if dep.Spec.WorkloadRef.Name != "" { + wlName = dep.Spec.WorkloadRef.Name + } + } + + // Client-side city filter. + if opts.city != "" && city != opts.city { + continue + } + + extIP := "" + intIP := "" + if len(inst.Status.NetworkInterfaces) > 0 { + ni := inst.Status.NetworkInterfaces[0] + if ni.Assignments.ExternalIP != nil { + extIP = *ni.Assignments.ExternalIP + } + if ni.Assignments.NetworkIP != nil { + intIP = *ni.Assignments.NetworkIP + } + } + + rows = append(rows, instanceRow{ + name: inst.Name, + workload: wlName, + city: city, + externalIP: extIP, + internalIP: intIP, + instType: inst.Spec.Runtime.Resources.InstanceType, + age: util.RelativeAge(inst.CreationTimestamp), + status: util.InstanceStatus(inst.Status.Conditions), + }) + } + + // Sort: workload ASC, city ASC, name ASC. + sort.Slice(rows, func(i, j int) bool { + if rows[i].workload != rows[j].workload { + return rows[i].workload < rows[j].workload + } + if rows[i].city != rows[j].city { + return rows[i].city < rows[j].city + } + return rows[i].name < rows[j].name + }) + + if len(rows) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No instances found in project %s.\n", project) + return nil + } + + out := cmd.OutOrStdout() + tw := util.NewTabWriter(out) + fmt.Fprintf(tw, "NAME\tWORKLOAD\tCITY\tEXTERNAL IP\tINTERNAL IP\tTYPE\tAGE\tSTATUS\n") + for _, r := range rows { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + r.name, r.workload, r.city, r.externalIP, r.internalIP, r.instType, r.age, r.status) + } + tw.Flush() + + running := 0 + for _, r := range rows { + if r.status == "Running" { + running++ + } + } + pending := len(rows) - running + fmt.Fprintf(out, "\n%d instances — %d Running, %d Pending, 0 Failed\n", len(rows), running, pending) + + return nil +} + +func describeCommand() *cobra.Command { + return &cobra.Command{ + Use: "describe ", + Short: "Show full details for a single instance", + Long: `Display runtime configuration, network status, and current conditions for an +instance, including plain-English explanations of any failure states.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDescribe(cmd, args) + }, + } +} + +func runDescribe(cmd *cobra.Command, args []string) error { + ctx := context.Background() + project := util.ProjectFromCmd(cmd) + + c, err := util.NewClient(project) + if err != nil { + return err + } + + instanceName := args[0] + + var inst computev1alpha.Instance + if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: instanceName}, &inst); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("instance %q not found in project %s", instanceName, project) + } + return fmt.Errorf("getting instance: %w", err) + } + + // Look up deployment. + deploymentUID := inst.Labels[computev1alpha.WorkloadDeploymentUIDLabel] + workloadName := "orphaned" + city := "unknown" + placementName := "" + + if deploymentUID != "" { + depSelector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadDeploymentUIDLabel: deploymentUID}) + var depList computev1alpha.WorkloadDeploymentList + if err := c.List(ctx, &depList, client.InNamespace(project), client.MatchingLabelsSelector{Selector: depSelector}); err == nil && len(depList.Items) > 0 { + dep := depList.Items[0] + city = dep.Spec.CityCode + placementName = dep.Spec.PlacementName + workloadName = dep.Spec.WorkloadRef.Name + } + } + + status, detail := util.InstanceStatusDetail(inst.Status.Conditions) + + out := cmd.OutOrStdout() + + // Key-value header block. + fmt.Fprintf(out, "%-14s %s\n", "Instance", instanceName) + fmt.Fprintf(out, "%-14s %s\n", "Workload", workloadName) + if placementName != "" { + fmt.Fprintf(out, "%-14s %s\n", "Placement", placementName) + } + fmt.Fprintf(out, "%-14s %s\n", "City", city) + fmt.Fprintf(out, "%-14s %s\n", "Age", util.RelativeAgeVerbose(inst.CreationTimestamp)) + fmt.Fprintf(out, "%-14s %s\n", "Status", status) + if detail != "" { + fmt.Fprintf(out, "%-14s %s\n", "", detail) + } + fmt.Fprintf(out, "\n") + + // Runtime section. + fmt.Fprintf(out, "Runtime\n") + if inst.Spec.Runtime.Sandbox != nil { + sb := inst.Spec.Runtime.Sandbox + if len(sb.Containers) > 0 { + ctr := sb.Containers[0] + fmt.Fprintf(out, " %-12s %s\n", "Image:", ctr.Image) + + if len(ctr.Env) > 0 { + var envStrs []string + for _, e := range ctr.Env { + envStrs = append(envStrs, formatEnvVar(e)) + } + fmt.Fprintf(out, " %-12s %s\n", "Env:", strings.Join(envStrs, ", ")) + } + + if len(ctr.Ports) > 0 { + var portStrs []string + for _, p := range ctr.Ports { + proto := "TCP" + if p.Protocol != nil { + proto = string(*p.Protocol) + } + portStrs = append(portStrs, fmt.Sprintf("%d/%s", p.Port, proto)) + } + fmt.Fprintf(out, " %-12s %s\n", "Ports:", strings.Join(portStrs, ", ")) + } + } + fmt.Fprintf(out, " %-12s %s\n", "Type:", inst.Spec.Runtime.Resources.InstanceType) + } else { + fmt.Fprintf(out, " %-12s %s\n", "Type:", "virtual-machine") + fmt.Fprintf(out, " %-12s %s\n", "Instance type:", inst.Spec.Runtime.Resources.InstanceType) + } + fmt.Fprintf(out, "\n") + + // Network block. + fmt.Fprintf(out, "Network\n") + networkLine := networkSummary(inst.Status.NetworkInterfaces) + fmt.Fprintf(out, " %s\n", networkLine) + fmt.Fprintf(out, "\n") + + // Next steps if not running and quota exceeded. + quotaCond := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceQuotaGranted) + if status != "Running" && quotaCond != nil && quotaCond.Reason == computev1alpha.InstanceQuotaGrantedReasonQuotaExceeded { + fmt.Fprintf(out, "Next steps\n") + fmt.Fprintf(out, " datumctl compute scale %s --min=2\n", workloadName) + fmt.Fprintf(out, " datumctl compute quota\n") + } + + return nil +} + +// networkSummary returns a human-readable network status line. +func networkSummary(ifaces []computev1alpha.InstanceNetworkInterfaceStatus) string { + if len(ifaces) == 0 { + return "Waiting for addresses (not yet scheduled)" + } + ni := ifaces[0] + if ni.Assignments.ExternalIP == nil && ni.Assignments.NetworkIP == nil { + return "Waiting for addresses (not yet scheduled)" + } + extIP := "not assigned" + if ni.Assignments.ExternalIP != nil { + extIP = *ni.Assignments.ExternalIP + } + intIP := "not assigned" + if ni.Assignments.NetworkIP != nil { + intIP = *ni.Assignments.NetworkIP + } + return fmt.Sprintf("External: %s Internal: %s", extIP, intIP) +} + +// formatEnvVar renders a single EnvVar for display. +func formatEnvVar(e corev1.EnvVar) string { + if e.ValueFrom != nil { + if e.ValueFrom.SecretKeyRef != nil { + return e.Name + " (from secret)" + } + if e.ValueFrom.ConfigMapKeyRef != nil { + return e.Name + " (from configmap)" + } + } + return e.Name + "=" + e.Value +} diff --git a/internal/cmd/compute/quota/quota.go b/internal/cmd/compute/quota/quota.go new file mode 100644 index 00000000..c8601334 --- /dev/null +++ b/internal/cmd/compute/quota/quota.go @@ -0,0 +1,243 @@ +package quota + +import ( + "context" + "fmt" + "regexp" + "sort" + "strconv" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/labels" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/cmd/compute/util" +) + +// availablePattern matches messages like "2 CPU available in IAD." or +// "2.5 available in DFW" to extract the numeric available quantity. +var availablePattern = regexp.MustCompile(`(\d+(?:\.\d+)?)\s+\w+\s+available`) + +func Command() *cobra.Command { + var city string + var constrained bool + + cmd := &cobra.Command{ + Use: "quota", + Short: "Show compute quota usage for the current project", + RunE: func(cmd *cobra.Command, args []string) error { + return runQuota(cmd, city, constrained) + }, + } + + cmd.Flags().StringVar(&city, "city", "", "Narrow output to a specific city") + cmd.Flags().BoolVar(&constrained, "constrained", false, "Show only constrained resources") + + return cmd +} + +type groupKey struct { + city string + instanceType string +} + +func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { + project := util.ProjectFromCmd(cmd) + + c, err := util.NewClient(project) + if err != nil { + return err + } + + ctx := context.Background() + + // List all instances in the project. + var instList computev1alpha.InstanceList + if err := c.List(ctx, &instList, client.InNamespace(project)); err != nil { + return fmt.Errorf("listing instances: %w", err) + } + + // List all deployments to build a UID → city/instanceType lookup. + var deployList computev1alpha.WorkloadDeploymentList + if err := c.List(ctx, &deployList, client.InNamespace(project)); err != nil { + return fmt.Errorf("listing deployments: %w", err) + } + + // Build map: deploymentUID → deployment. + deployByUID := make(map[string]computev1alpha.WorkloadDeployment, len(deployList.Items)) + for _, d := range deployList.Items { + deployByUID[string(d.UID)] = d + } + + // Also list workloads to resolve instance type when we can't get it from an instance directly. + // Instance type comes from the deployment's template spec. + deployInstType := make(map[string]string, len(deployList.Items)) + for _, d := range deployList.Items { + deployInstType[string(d.UID)] = d.Spec.Template.Spec.Runtime.Resources.InstanceType + } + + type groupData struct { + count int + atLimit bool + limitMsg string + } + + groups := make(map[groupKey]*groupData) + + for _, inst := range instList.Items { + // Resolve city from deployment label. + depUID := inst.Labels[computev1alpha.WorkloadDeploymentUIDLabel] + dep, ok := deployByUID[depUID] + if !ok { + continue + } + city := dep.Spec.CityCode + instanceType := dep.Spec.Template.Spec.Runtime.Resources.InstanceType + if instanceType == "" { + instanceType = "unknown" + } + + k := groupKey{city: city, instanceType: instanceType} + gd := groups[k] + if gd == nil { + gd = &groupData{} + groups[k] = gd + } + gd.count++ + + // Check quota condition. + quotaCond := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceQuotaGranted) + if quotaCond != nil && + quotaCond.Status == metav1.ConditionFalse && + quotaCond.Reason == computev1alpha.InstanceQuotaGrantedReasonQuotaExceeded { + gd.atLimit = true + if quotaCond.Message != "" { + gd.limitMsg = quotaCond.Message + } + } + } + + // Build sorted keys. + var keys []groupKey + for k := range groups { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].city != keys[j].city { + return keys[i].city < keys[j].city + } + return keys[i].instanceType < keys[j].instanceType + }) + + // Also list workload deployments to pick up zero-instance cities (not needed per spec, skip). + + out := cmd.OutOrStdout() + + // Filter by city. + if filterCity != "" { + var filtered []groupKey + for _, k := range keys { + if k.city == filterCity { + filtered = append(filtered, k) + } + } + keys = filtered + } + + // Before filtering by constrained, check if there are any instances at all. + if len(instList.Items) == 0 { + fmt.Fprint(out, "No instances running. No quota consumption to display.\n") + return nil + } + + // Filter by constrained. + if constrained { + var filtered []groupKey + for _, k := range keys { + if groups[k].atLimit { + filtered = append(filtered, k) + } + } + if len(filtered) == 0 { + fmt.Fprint(out, "No constrained resources found.\n") + return nil + } + keys = filtered + } + + fmt.Fprintf(out, "Quota usage for project %s\n\n", project) + + tw := util.NewTabWriter(out) + fmt.Fprintf(tw, "CITY\tTYPE\tIN USE\tLIMIT\tAVAILABLE\n") + + for _, k := range keys { + gd := groups[k] + + limit := "—" + available := "—" + if gd.limitMsg != "" { + avail, ok := parseAvailable(gd.limitMsg) + if ok { + available = strconv.Itoa(avail) + limit = strconv.Itoa(gd.count + avail) + } + } + + cityLabel := k.city + if gd.atLimit { + cityLabel += " [at limit]" + } + + fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n", cityLabel, k.instanceType, gd.count, limit, available) + } + tw.Flush() + + // Also show deployments with zero instances (quota exhausted before first instance). + // Walk deployments not represented in any group and show them with count=0, atLimit. + var zeroKeys []groupKey + depGroupSeen := make(map[groupKey]bool) + for _, k := range keys { + depGroupSeen[k] = true + } + for _, dep := range deployList.Items { + // Build the same label selector to check for instances. + depSelector := labels.SelectorFromSet(labels.Set{ + computev1alpha.WorkloadDeploymentUIDLabel: string(dep.UID), + }) + _ = depSelector // just need city+type combo + k := groupKey{ + city: dep.Spec.CityCode, + instanceType: dep.Spec.Template.Spec.Runtime.Resources.InstanceType, + } + if k.instanceType == "" { + k.instanceType = "unknown" + } + if !depGroupSeen[k] { + zeroKeys = append(zeroKeys, k) + depGroupSeen[k] = true + } + } + + // Sort and print zero-instance groups (no quota consumed, nothing to show for "constrained"). + // Per spec these are not interesting for the quota view, so we skip them. + + fmt.Fprint(out, "\nNote: limit information is derived from quota conditions on instances.\nRun 'datumctl quota' for full project quota management.\n") + + return nil +} + +// parseAvailable extracts the integer available count from a quota condition +// message such as "Requested 4 CPU. 2 CPU available in IAD." +func parseAvailable(msg string) (int, bool) { + m := availablePattern.FindStringSubmatch(msg) + if m == nil { + return 0, false + } + f, err := strconv.ParseFloat(m[1], 64) + if err != nil { + return 0, false + } + return int(f), true +} diff --git a/internal/cmd/compute/restart/restart.go b/internal/cmd/compute/restart/restart.go new file mode 100644 index 00000000..1907011c --- /dev/null +++ b/internal/cmd/compute/restart/restart.go @@ -0,0 +1,114 @@ +package restart + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/cmd/compute/util" +) + +func Command() *cobra.Command { + var city string + + cmd := &cobra.Command{ + Use: "restart ", + Short: "Trigger a rolling restart of a workload", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runRestart(cmd, args, city) + }, + } + + cmd.Flags().StringVar(&city, "city", "", "Restart only instances in a specific city") + + return cmd +} + +func runRestart(cmd *cobra.Command, args []string, city string) error { + project := util.ProjectFromCmd(cmd) + + c, err := util.NewClient(project) + if err != nil { + return err + } + + ctx := context.Background() + workloadName := args[0] + + var workload computev1alpha.Workload + if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("workload %q not found in project %s", workloadName, project) + } + return fmt.Errorf("getting workload: %w", err) + } + + restartedAt := time.Now().UTC().Format(time.RFC3339) + out := cmd.OutOrStdout() + + if city == "" { + // Restart all placements by annotating the workload template. + if workload.Spec.Template.ObjectMeta.Annotations == nil { + workload.Spec.Template.ObjectMeta.Annotations = make(map[string]string) + } + workload.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = restartedAt + + if err := c.Update(ctx, &workload); err != nil { + return fmt.Errorf("updating workload: %w", err) + } + + fmt.Fprintf(out, + "Restarting workload %q — rolling restart initiated.\nRun 'datumctl compute rollout %s' to watch progress.\n", + workloadName, workloadName, + ) + return nil + } + + // Restart only deployments in the given city. + selector := labels.SelectorFromSet(labels.Set{ + computev1alpha.WorkloadUIDLabel: string(workload.UID), + }) + var deployList computev1alpha.WorkloadDeploymentList + if err := c.List(ctx, &deployList, + client.InNamespace(project), + client.MatchingLabelsSelector{Selector: selector}, + ); err != nil { + return fmt.Errorf("listing deployments: %w", err) + } + + var matched []computev1alpha.WorkloadDeployment + for _, d := range deployList.Items { + if d.Spec.CityCode == city { + matched = append(matched, d) + } + } + + if len(matched) == 0 { + return fmt.Errorf("no deployment found for workload %q in city %q", workloadName, city) + } + + for i := range matched { + if matched[i].Spec.Template.ObjectMeta.Annotations == nil { + matched[i].Spec.Template.ObjectMeta.Annotations = make(map[string]string) + } + matched[i].Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = restartedAt + + if err := c.Update(ctx, &matched[i]); err != nil { + return fmt.Errorf("updating deployment in %s: %w", city, err) + } + } + + fmt.Fprintf(out, + "Restarting workload %q in %s — rolling restart initiated.\nRun 'datumctl compute rollout %s' to watch progress.\n", + workloadName, city, workloadName, + ) + return nil +} diff --git a/internal/cmd/compute/revision/revision.go b/internal/cmd/compute/revision/revision.go new file mode 100644 index 00000000..8e8ca35e --- /dev/null +++ b/internal/cmd/compute/revision/revision.go @@ -0,0 +1,169 @@ +// Package revision manages workload revision history stored in ConfigMaps. +// Each workload maintains a ConfigMap keyed by "compute.datumapis.com-revision-history." +// whose data map holds JSON-encoded Entry values keyed by revision number string. +package revision + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strconv" + + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // CurrentRevisionAnnotation is the annotation key on the revision ConfigMap + // that stores the active revision number as a string. + CurrentRevisionAnnotation = "compute.datumapis.com/current-revision" + + // ConfigMapNamePrefix is the prefix for revision history ConfigMap names. + ConfigMapNamePrefix = "compute.datumapis.com-revision-history." + + // MaxRevisions is the maximum number of revision entries to retain. + MaxRevisions = 20 +) + +// Entry is one revision record stored as JSON in the ConfigMap's data map. +type Entry struct { + Rev int `json:"rev"` + Timestamp string `json:"timestamp"` + Image string `json:"image"` + Changes string `json:"changes"` + Actor string `json:"actor"` + TemplateHash string `json:"templateHash"` + // SpecJSON holds a JSON-encoded WorkloadSpec for use by rollback. + SpecJSON string `json:"spec"` +} + +// ConfigMapName returns the ConfigMap name for the given workload. +func ConfigMapName(workloadName string) string { + return ConfigMapNamePrefix + workloadName +} + +// CurrentRevision returns the current revision number from the ConfigMap annotation. +// Returns 0 if no history ConfigMap exists or the annotation is absent. +func CurrentRevision(ctx context.Context, c client.Client, namespace, workloadName string) int { + var cm corev1.ConfigMap + if err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: ConfigMapName(workloadName)}, &cm); err != nil { + return 0 + } + if v, ok := cm.Annotations[CurrentRevisionAnnotation]; ok { + n, err := strconv.Atoi(v) + if err == nil { + return n + } + } + return 0 +} + +// WriteEntry creates or updates the revision history ConfigMap with entry. +// It enforces MaxRevisions by dropping the entry with the lowest revision number +// when the cap is exceeded. It updates the CurrentRevisionAnnotation. +func WriteEntry(ctx context.Context, c client.Client, namespace, workloadName string, entry Entry) error { + cmName := ConfigMapName(workloadName) + + var cm corev1.ConfigMap + exists := true + if err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: cmName}, &cm); err != nil { + if !k8serrors.IsNotFound(err) { + return fmt.Errorf("getting revision ConfigMap: %w", err) + } + exists = false + cm = corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: cmName, + Annotations: map[string]string{}, + }, + Data: map[string]string{}, + } + } + + if cm.Data == nil { + cm.Data = map[string]string{} + } + if cm.Annotations == nil { + cm.Annotations = map[string]string{} + } + + // Marshal the new entry. + data, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("marshalling revision entry: %w", err) + } + cm.Data[strconv.Itoa(entry.Rev)] = string(data) + + // Enforce cap: remove the lowest-numbered key until within MaxRevisions. + for len(cm.Data) > MaxRevisions { + lowest := -1 + for k := range cm.Data { + n, err := strconv.Atoi(k) + if err != nil { + continue + } + if lowest < 0 || n < lowest { + lowest = n + } + } + if lowest >= 0 { + delete(cm.Data, strconv.Itoa(lowest)) + } else { + break + } + } + + cm.Annotations[CurrentRevisionAnnotation] = strconv.Itoa(entry.Rev) + + if exists { + if err := c.Update(ctx, &cm); err != nil { + return fmt.Errorf("updating revision ConfigMap: %w", err) + } + } else { + if err := c.Create(ctx, &cm); err != nil { + return fmt.Errorf("creating revision ConfigMap: %w", err) + } + } + + return nil +} + +// ReadEntries returns all revision entries sorted by Rev descending, and the +// current revision number. Returns an empty slice (not an error) when no +// history ConfigMap exists. +func ReadEntries(ctx context.Context, c client.Client, namespace, workloadName string) (entries []Entry, currentRev int, err error) { + var cm corev1.ConfigMap + if err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: ConfigMapName(workloadName)}, &cm); err != nil { + if k8serrors.IsNotFound(err) { + return nil, 0, nil + } + return nil, 0, fmt.Errorf("getting revision ConfigMap: %w", err) + } + + if v, ok := cm.Annotations[CurrentRevisionAnnotation]; ok { + n, err := strconv.Atoi(v) + if err == nil { + currentRev = n + } + } + + for _, v := range cm.Data { + var e Entry + if err := json.Unmarshal([]byte(v), &e); err != nil { + // Skip malformed entries. + continue + } + entries = append(entries, e) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Rev > entries[j].Rev + }) + + return entries, currentRev, nil +} diff --git a/internal/cmd/compute/rollout/rollout.go b/internal/cmd/compute/rollout/rollout.go new file mode 100644 index 00000000..f3558363 --- /dev/null +++ b/internal/cmd/compute/rollout/rollout.go @@ -0,0 +1,272 @@ +package rollout + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + "time" + + "github.com/spf13/cobra" + "go.datum.net/datumctl/plugin" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/cmd/compute/revision" + "go.datum.net/compute/internal/cmd/compute/util" + "go.datum.net/compute/internal/cmd/compute/watch" +) + +func Command() *cobra.Command { + cmd := &cobra.Command{ + Use: "rollout ", + Short: "Watch or manage a workload rollout", + Long: `Watch the live progress of a rollout across all placements, or inspect and +revert to a previous revision. + +Pressing Ctrl-C detaches from the watch without canceling the rollout.`, + Args: cobra.ExactArgs(1), + Example: ` # Watch live rollout progress + datumctl compute rollout api + + # Show revision history + datumctl compute rollout history api + + # Roll back to a specific revision + datumctl compute rollout undo api --to-revision=7`, + RunE: func(cmd *cobra.Command, args []string) error { + return runWatch(cmd, args) + }, + } + + cmd.AddCommand(historyCommand(), undoCommand()) + + return cmd +} + +func runWatch(cmd *cobra.Command, args []string) error { + project := util.ProjectFromCmd(cmd) + + c, err := util.NewClient(project) + if err != nil { + return err + } + + ctx := context.Background() + workloadName := args[0] + + var workload computev1alpha.Workload + if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("workload %q not found in project %s", workloadName, project) + } + return fmt.Errorf("getting workload: %w", err) + } + + entries, currentRev, err := revision.ReadEntries(ctx, c, project, workloadName) + if err != nil { + return fmt.Errorf("reading revision history: %w", err) + } + + var revLabel string + switch { + case currentRev == 0: + revLabel = "rev #1" + case len(entries) >= 2: + revLabel = fmt.Sprintf("rev #%d → #%d", entries[1].Rev, entries[0].Rev) + default: + revLabel = fmt.Sprintf("rev #%d", currentRev) + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Rolling workload %q %s\n", workloadName, revLabel) + + watchCtx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer cancel() + return watch.Rollout(watchCtx, c, out, project, workload.UID) +} + +func historyCommand() *cobra.Command { + return &cobra.Command{ + Use: "history ", + Short: "Show the rollout history for a workload", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runHistory(cmd, args) + }, + } +} + +func runHistory(cmd *cobra.Command, args []string) error { + project := util.ProjectFromCmd(cmd) + + c, err := util.NewClient(project) + if err != nil { + return err + } + + ctx := context.Background() + workloadName := args[0] + + var workload computev1alpha.Workload + if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("workload %q not found in project %s", workloadName, project) + } + return fmt.Errorf("getting workload: %w", err) + } + + entries, currentRev, err := revision.ReadEntries(ctx, c, project, workloadName) + if err != nil { + return fmt.Errorf("reading revision history: %w", err) + } + + out := cmd.OutOrStdout() + + if len(entries) == 0 { + fmt.Fprintf(out, "No revision history found for workload %q.\n", workloadName) + return nil + } + + tw := util.NewTabWriter(out) + fmt.Fprintln(tw, "REV\tWHEN\tIMAGE\tCHANGES\tBY\tSTATUS") + + for _, e := range entries { + when := "—" + if e.Timestamp != "" { + t, err := time.Parse(time.RFC3339, e.Timestamp) + if err == nil { + when = util.RelativeAgeVerbose(metav1.Time{Time: t}) + } + } + + status := "—" + if e.Rev == currentRev { + status = "active" + } + + fmt.Fprintf(tw, "#%d\t%s\t%s\t%s\t%s\t%s\n", + e.Rev, when, e.Image, e.Changes, e.Actor, status) + } + + tw.Flush() + return nil +} + +func undoCommand() *cobra.Command { + var toRevision int32 + + cmd := &cobra.Command{ + Use: "undo ", + Short: "Roll back a workload to a previous revision", + Long: `Creates a new revision that is a copy of the target revision. +Rollbacks do not rewrite history.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runUndo(cmd, args, toRevision) + }, + } + + cmd.Flags().Int32Var(&toRevision, "to-revision", 0, "Revision number to roll back to (0 = previous)") + + return cmd +} + +func runUndo(cmd *cobra.Command, args []string, toRevision int32) error { + project := util.ProjectFromCmd(cmd) + + c, err := util.NewClient(project) + if err != nil { + return err + } + + ctx := context.Background() + workloadName := args[0] + + var workload computev1alpha.Workload + if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("workload %q not found in project %s", workloadName, project) + } + return fmt.Errorf("getting workload: %w", err) + } + + entries, currentRev, err := revision.ReadEntries(ctx, c, project, workloadName) + if err != nil { + return fmt.Errorf("reading revision history: %w", err) + } + + if len(entries) == 0 { + return fmt.Errorf("no revision history for workload %q; cannot undo", workloadName) + } + + if currentRev == 1 { + return fmt.Errorf("no previous revision to roll back to") + } + + var target int + if toRevision == 0 { + target = currentRev - 1 + } else { + target = int(toRevision) + } + + if target == currentRev { + return fmt.Errorf("workload is already at revision #%d", currentRev) + } + + if target < 1 { + return fmt.Errorf("no previous revision to roll back to") + } + + var targetEntry *revision.Entry + for i := range entries { + if entries[i].Rev == target { + targetEntry = &entries[i] + break + } + } + if targetEntry == nil { + return fmt.Errorf("revision #%d not found; run 'datumctl compute rollout history %s'", target, workloadName) + } + + // Unmarshal the stored spec. + var targetSpec computev1alpha.WorkloadSpec + if err := json.Unmarshal([]byte(targetEntry.SpecJSON), &targetSpec); err != nil { + return fmt.Errorf("decoding stored spec for revision #%d: %w", target, err) + } + + out := cmd.OutOrStdout() + newRev := currentRev + 1 + fmt.Fprintf(out, "Creating revision #%d (copy of #%d)...\n", newRev, target) + + workload.Spec = targetSpec + if err := c.Update(ctx, &workload); err != nil { + return fmt.Errorf("updating workload: %w", err) + } + + // Determine actor from plugin context. + actor := "" + if pluginCtx := plugin.Context(); pluginCtx.Org != "" { + actor = pluginCtx.Org + } + + newSpecJSON, _ := json.Marshal(workload.Spec) + entry := revision.Entry{ + Rev: newRev, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Image: targetEntry.Image, + Changes: fmt.Sprintf("rollback to rev #%d", target), + Actor: actor, + SpecJSON: string(newSpecJSON), + } + if err := revision.WriteEntry(ctx, c, project, workloadName, entry); err != nil { + fmt.Fprintf(out, " warning: could not write revision history: %v\n", err) + } + + fmt.Fprintf(out, "Rollout started. Run 'datumctl compute rollout %s' to watch progress.\n", workloadName) + return nil +} diff --git a/internal/cmd/compute/root.go b/internal/cmd/compute/root.go new file mode 100644 index 00000000..f61e18bf --- /dev/null +++ b/internal/cmd/compute/root.go @@ -0,0 +1,32 @@ +package compute + +import ( + "github.com/spf13/cobra" + "go.datum.net/datumctl/plugin" + + "go.datum.net/compute/internal/cmd/compute/deploy" + "go.datum.net/compute/internal/cmd/compute/destroy" + "go.datum.net/compute/internal/cmd/compute/instances" + "go.datum.net/compute/internal/cmd/compute/quota" + "go.datum.net/compute/internal/cmd/compute/restart" + "go.datum.net/compute/internal/cmd/compute/rollout" + "go.datum.net/compute/internal/cmd/compute/scale" + "go.datum.net/compute/internal/cmd/compute/status" +) + +func Command() *cobra.Command { + root := plugin.NewRootCmd("compute", "Deploy and manage containerized workloads on Datum Cloud") + + root.AddCommand( + deploy.Command(), + destroy.Command(), + instances.Command(), + quota.Command(), + restart.Command(), + rollout.Command(), + scale.Command(), + status.Command(), + ) + + return root +} diff --git a/internal/cmd/compute/scale/scale.go b/internal/cmd/compute/scale/scale.go new file mode 100644 index 00000000..28112dda --- /dev/null +++ b/internal/cmd/compute/scale/scale.go @@ -0,0 +1,76 @@ +package scale + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/cmd/compute/util" +) + +func Command() *cobra.Command { + var min int32 + + cmd := &cobra.Command{ + Use: "scale ", + Short: "Adjust the minimum replica count for a workload", + Args: cobra.ExactArgs(1), + Example: ` datumctl compute scale api --min=4`, + RunE: func(cmd *cobra.Command, args []string) error { + return runScale(cmd, args, min) + }, + } + + cmd.Flags().Int32Var(&min, "min", 0, "Minimum number of instances per city") + _ = cmd.MarkFlagRequired("min") + + return cmd +} + +func runScale(cmd *cobra.Command, args []string, min int32) error { + if min <= 0 { + return fmt.Errorf("min replicas must be at least 1") + } + + project := util.ProjectFromCmd(cmd) + + c, err := util.NewClient(project) + if err != nil { + return err + } + + ctx := context.Background() + workloadName := args[0] + + var workload computev1alpha.Workload + if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("workload %q not found in project %s", workloadName, project) + } + return fmt.Errorf("getting workload: %w", err) + } + + if len(workload.Spec.Placements) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "workload has no placements; nothing to scale") + return nil + } + + for i := range workload.Spec.Placements { + workload.Spec.Placements[i].ScaleSettings.MinReplicas = min + } + + if err := c.Update(ctx, &workload); err != nil { + return fmt.Errorf("updating workload: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), + "Scaled workload %q — min replicas set to %d across %d placement(s).\nRun 'datumctl compute rollout %s' to watch progress.\n", + workloadName, min, len(workload.Spec.Placements), workloadName, + ) + + return nil +} diff --git a/internal/cmd/compute/status/status.go b/internal/cmd/compute/status/status.go new file mode 100644 index 00000000..10cbcfe7 --- /dev/null +++ b/internal/cmd/compute/status/status.go @@ -0,0 +1,246 @@ +package status + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1 "k8s.io/api/core/v1" + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/cmd/compute/util" +) + +func Command() *cobra.Command { + cmd := &cobra.Command{ + Use: "status ", + Short: "Show the health and placement status of a workload", + Long: `Display the current health status of a workload with city-by-city replica +counts and plain-English explanations of any degraded conditions.`, + Args: cobra.ExactArgs(1), + Example: ` datumctl compute status api`, + RunE: func(cmd *cobra.Command, args []string) error { + return runStatus(cmd, args) + }, + } + + return cmd +} + +func runStatus(cmd *cobra.Command, args []string) error { + ctx := context.Background() + project := util.ProjectFromCmd(cmd) + + c, err := util.NewClient(project) + if err != nil { + return err + } + + workloadName := args[0] + + // Fetch workload. + var workload computev1alpha.Workload + if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if k8serrors.IsNotFound(err) { + fmt.Fprintf(cmd.ErrOrStderr(), "workload %q not found in project %s\n", workloadName, project) + return fmt.Errorf("workload not found") + } + return fmt.Errorf("getting workload: %w", err) + } + + // List deployments for this workload. + selector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadUIDLabel: string(workload.UID)}) + var deployList computev1alpha.WorkloadDeploymentList + if err := c.List(ctx, &deployList, client.InNamespace(project), client.MatchingLabelsSelector{Selector: selector}); err != nil { + return fmt.Errorf("listing deployments: %w", err) + } + + // Derive image. + image := "(virtual machine)" + if workload.Spec.Template.Spec.Runtime.Sandbox != nil && + len(workload.Spec.Template.Spec.Runtime.Sandbox.Containers) > 0 { + image = workload.Spec.Template.Spec.Runtime.Sandbox.Containers[0].Image + } + + instanceType := workload.Spec.Template.Spec.Runtime.Resources.InstanceType + age := util.RelativeAgeVerbose(workload.CreationTimestamp) + + // Fetch revision ConfigMap. + revision := "—" + var cm corev1.ConfigMap + cmName := "compute.datumapis.com-revision-history." + workloadName + if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: cmName}, &cm); err == nil { + if v, ok := cm.Annotations["compute.datumapis.com/current-revision"]; ok { + revision = v + } + } + // If not found or any error, revision stays "—". + + // Compute totals. + var totalDesired, totalReady int32 + for _, d := range deployList.Items { + totalDesired += d.Status.DesiredReplicas + totalReady += d.Status.ReadyReplicas + } + + health := util.WorkloadHealth(workload.Status.Conditions, totalReady, totalDesired) + + out := cmd.OutOrStdout() + + // Header block — two-column layout. + fmt.Fprintf(out, "%-12s %-31s project: %s\n", "Workload", workloadName, project) + fmt.Fprintf(out, "%-12s %s\n", "Image", image) + fmt.Fprintf(out, "%-12s %-31s Revision #%s\n", "Updated", age, revision) + fmt.Fprintf(out, "\n") + fmt.Fprintf(out, "%-12s %s\n", "Health", health) + fmt.Fprintf(out, "\n") + + if len(deployList.Items) == 0 { + fmt.Fprintf(out, " No placements configured.\n") + return nil + } + + // Placement table — grouped by placement name. + tw := util.NewTabWriter(out) + fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n", "", "CITY", "READY", "DESIRED", "TYPE") + + // Group deployments by placement name preserving order from workload spec. + type deployGroup struct { + name string + deployments []computev1alpha.WorkloadDeployment + } + var groups []deployGroup + groupIndex := map[string]int{} + for _, d := range deployList.Items { + pn := d.Spec.PlacementName + if idx, ok := groupIndex[pn]; ok { + groups[idx].deployments = append(groups[idx].deployments, d) + } else { + groupIndex[pn] = len(groups) + groups = append(groups, deployGroup{name: pn, deployments: []computev1alpha.WorkloadDeployment{d}}) + } + } + + // Track degraded deployments for the detail block. + type degradedEntry struct { + city string + deployment computev1alpha.WorkloadDeployment + } + var degraded []degradedEntry + + for _, g := range groups { + for i, d := range g.deployments { + placementLabel := "" + if i == 0 { + placementLabel = g.name + } + readyStr := fmt.Sprintf("%d/%d", d.Status.ReadyReplicas, d.Status.Replicas) + fmt.Fprintf(tw, " %s\t%s\t%s\t%d\t%s\n", + placementLabel, + d.Spec.CityCode, + readyStr, + d.Status.DesiredReplicas, + instanceType, + ) + if d.Status.ReadyReplicas < d.Status.DesiredReplicas { + degraded = append(degraded, degradedEntry{city: d.Spec.CityCode, deployment: d}) + } + } + } + tw.Flush() + + if len(degraded) == 0 { + return nil + } + + fmt.Fprintf(out, "\n") + + // For each degraded deployment, find the first unhealthy instance and get its detail. + type degradedDetail struct { + city string + count int32 + statusLine string + detailMsg string + quotaExceed bool + } + var details []degradedDetail + anyQuotaExceeded := false + + for _, de := range degraded { + depUID := string(de.deployment.UID) + depSelector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadDeploymentUIDLabel: depUID}) + var instList computev1alpha.InstanceList + if err := c.List(ctx, &instList, client.InNamespace(project), client.MatchingLabelsSelector{Selector: depSelector}); err != nil { + // Skip detail on error. + continue + } + + var statusLine, detailMsg string + quotaExceed := false + for _, inst := range instList.Items { + readyCond := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceReady) + if readyCond == nil || readyCond.Status != "True" { + s, d := util.InstanceStatusDetail(inst.Status.Conditions) + statusLine = s + detailMsg = d + // Check if quota exceeded. + qc := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceQuotaGranted) + if qc != nil && qc.Reason == computev1alpha.InstanceQuotaGrantedReasonQuotaExceeded { + quotaExceed = true + anyQuotaExceeded = true + } + break + } + } + + short := describeStatusShort(statusLine, de.deployment.Status.DesiredReplicas-de.deployment.Status.ReadyReplicas) + details = append(details, degradedDetail{ + city: de.city, + count: de.deployment.Status.DesiredReplicas - de.deployment.Status.ReadyReplicas, + statusLine: short, + detailMsg: detailMsg, + quotaExceed: quotaExceed, + }) + } + + for _, dd := range details { + fmt.Fprintf(out, " %s: %d instances could not start — %s\n", dd.city, dd.count, dd.statusLine) + if dd.detailMsg != "" { + fmt.Fprintf(out, " %s\n", dd.detailMsg) + } + } + + // Next steps block. + fmt.Fprintf(out, "\n Next steps:\n") + if anyQuotaExceeded { + fmt.Fprintf(out, " Reduce replicas: datumctl compute scale %s --min=2\n", workloadName) + fmt.Fprintf(out, " Check quota: datumctl compute quota\n") + } + fmt.Fprintf(out, " View instances: datumctl compute instances --workload=%s\n", workloadName) + + return nil +} + +// describeStatusShort converts a full status line into a short degradation phrase. +func describeStatusShort(statusLine string, count int32) string { + _ = count + switch { + case strings.Contains(statusLine, "quota exceeded"): + return "quota exceeded" + case strings.Contains(statusLine, "network provisioning in progress"): + return "network provisioning in progress" + case strings.Contains(statusLine, "network provisioning"): + return "network provisioning" + case statusLine == "Starting": + return "starting" + case statusLine == "Stopping": + return "stopping" + default: + return statusLine + } +} diff --git a/internal/cmd/compute/util/client.go b/internal/cmd/compute/util/client.go new file mode 100644 index 00000000..852a3747 --- /dev/null +++ b/internal/cmd/compute/util/client.go @@ -0,0 +1,58 @@ +package util + +import ( + "fmt" + + "github.com/spf13/cobra" + "go.datum.net/datumctl/plugin" + computev1alpha "go.datum.net/compute/api/v1alpha" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + resourceManagerGroup = "resourcemanager.miloapis.com" + resourceManagerVersion = "v1alpha1" +) + +// ProjectControlPlaneURL returns the virtual control-plane URL for a project. +func ProjectControlPlaneURL(apiHost, projectID string) string { + return fmt.Sprintf("https://%s/apis/%s/%s/projects/%s/control-plane", + apiHost, resourceManagerGroup, resourceManagerVersion, projectID) +} + +// NewClient builds a Kubernetes client targeting the project's virtual control plane. +func NewClient(project string) (client.Client, error) { + if project == "" { + return nil, fmt.Errorf("no project set — pass --project or run 'datumctl config set project '") + } + + pluginCtx := plugin.Context() + if pluginCtx.APIHost == "" { + return nil, fmt.Errorf("DATUM_API_HOST is not set; is this plugin running via datumctl?") + } + + token, err := plugin.Token() + if err != nil { + return nil, fmt.Errorf("getting credentials: %w", err) + } + + scheme := runtime.NewScheme() + if err := computev1alpha.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("registering compute scheme: %w", err) + } + + cfg := &rest.Config{ + Host: ProjectControlPlaneURL(pluginCtx.APIHost, project), + BearerToken: token, + } + + return client.New(cfg, client.Options{Scheme: scheme}) +} + +// ProjectFromCmd reads the --project persistent flag from the command's root. +func ProjectFromCmd(cmd *cobra.Command) string { + project, _ := cmd.Root().PersistentFlags().GetString("project") + return project +} diff --git a/internal/cmd/compute/util/conditions.go b/internal/cmd/compute/util/conditions.go new file mode 100644 index 00000000..4d5fa96b --- /dev/null +++ b/internal/cmd/compute/util/conditions.go @@ -0,0 +1,149 @@ +package util + +import ( + "fmt" + + v1alpha "go.datum.net/compute/api/v1alpha" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// FindCondition returns the first condition with the given type, or nil. +func FindCondition(conditions []metav1.Condition, condType string) *metav1.Condition { + for i := range conditions { + if conditions[i].Type == condType { + return &conditions[i] + } + } + return nil +} + +// InstanceStatus returns a short user-facing status string for list views. +// Priority order: +// +// Ready=True → "Running" +// QuotaGranted=False/QuotaExceeded → "Pending (quota exceeded)" +// QuotaGranted=False/ValidationFailed → "Pending (quota validation failed)" +// QuotaGranted=Unknown/PendingEvaluation → "Pending (quota evaluation)" +// Programmed=False/PendingProgramming or ProgrammingInProgress → "Pending (network provisioning)" +// Running=False/Starting → "Starting" +// Running=False/Stopping → "Stopping" +// Ready=False/SchedulingGatesPresent → "Pending (scheduling gates)" +// default → "Pending" +func InstanceStatus(conditions []metav1.Condition) string { + ready := FindCondition(conditions, v1alpha.InstanceReady) + if ready != nil && ready.Status == metav1.ConditionTrue { + return "Running" + } + + quota := FindCondition(conditions, v1alpha.InstanceQuotaGranted) + if quota != nil && quota.Status == metav1.ConditionFalse { + switch quota.Reason { + case v1alpha.InstanceQuotaGrantedReasonQuotaExceeded: + return "Pending (quota exceeded)" + case v1alpha.InstanceQuotaGrantedReasonValidationFailed: + return "Pending (quota validation failed)" + } + } + if quota != nil && quota.Status == metav1.ConditionUnknown { + if quota.Reason == v1alpha.InstanceQuotaGrantedReasonPendingEvaluation { + return "Pending (quota evaluation)" + } + } + + programmed := FindCondition(conditions, v1alpha.InstanceProgrammed) + if programmed != nil && programmed.Status == metav1.ConditionFalse { + switch programmed.Reason { + case v1alpha.InstanceProgrammedReasonPendingProgramming, v1alpha.InstanceProgrammedReasonProgrammingInProgress: + return "Pending (network provisioning)" + } + } + + running := FindCondition(conditions, v1alpha.InstanceRunning) + if running != nil && running.Status == metav1.ConditionFalse { + switch running.Reason { + case v1alpha.InstanceRunningReasonStarting: + return "Starting" + case v1alpha.InstanceRunningReasonStopping: + return "Stopping" + } + } + + if ready != nil && ready.Status == metav1.ConditionFalse { + if ready.Reason == v1alpha.InstanceReadyReasonSchedulingGatesPresent { + return "Pending (scheduling gates)" + } + } + + return "Pending" +} + +// InstanceStatusDetail returns a status line and optional detail message for describe views. +// +// Ready=True → "Running", "" +// QuotaGranted=False/QuotaExceeded → "Not running — quota exceeded", condition.Message +// Programmed=False/PendingProgramming → "Not running — network provisioning", "" +// Programmed=False/ProgrammingInProgress → "Not running — network provisioning in progress", "" +// Running=False/Starting → "Starting", "" +// Running=False/Stopping → "Stopping", "" +// default → "Unknown", "" +func InstanceStatusDetail(conditions []metav1.Condition) (status, detail string) { + ready := FindCondition(conditions, v1alpha.InstanceReady) + if ready != nil && ready.Status == metav1.ConditionTrue { + return "Running", "" + } + + quota := FindCondition(conditions, v1alpha.InstanceQuotaGranted) + if quota != nil && quota.Status == metav1.ConditionFalse && quota.Reason == v1alpha.InstanceQuotaGrantedReasonQuotaExceeded { + return "Not running — quota exceeded", quota.Message + } + + programmed := FindCondition(conditions, v1alpha.InstanceProgrammed) + if programmed != nil && programmed.Status == metav1.ConditionFalse { + switch programmed.Reason { + case v1alpha.InstanceProgrammedReasonPendingProgramming: + return "Not running — network provisioning", "" + case v1alpha.InstanceProgrammedReasonProgrammingInProgress: + return "Not running — network provisioning in progress", "" + } + } + + running := FindCondition(conditions, v1alpha.InstanceRunning) + if running != nil && running.Status == metav1.ConditionFalse { + switch running.Reason { + case v1alpha.InstanceRunningReasonStarting: + return "Starting", "" + case v1alpha.InstanceRunningReasonStopping: + return "Stopping", "" + } + } + + return "Unknown", "" +} + +// WorkloadHealth derives a one-line health summary from workload Available condition + replica counts. +// +// Available=True, ready==desired → "Available — all placements at desired replicas" +// Available=True, ready= desired { + return "Available — all placements at desired replicas" + } + diff := desired - ready + return fmt.Sprintf("Degraded — %d instances below desired count", diff) +} + +// IsRunning returns true if the instance's Ready condition status is True. +func IsRunning(conditions []metav1.Condition) bool { + c := FindCondition(conditions, v1alpha.InstanceReady) + return c != nil && c.Status == metav1.ConditionTrue +} diff --git a/internal/cmd/compute/util/table.go b/internal/cmd/compute/util/table.go new file mode 100644 index 00000000..6402c44d --- /dev/null +++ b/internal/cmd/compute/util/table.go @@ -0,0 +1,12 @@ +package util + +import ( + "io" + "text/tabwriter" +) + +// NewTabWriter returns a *tabwriter.Writer configured for command table output. +// Use tab ('\t') as the column separator in rows. Caller must call Flush(). +func NewTabWriter(w io.Writer) *tabwriter.Writer { + return tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) +} diff --git a/internal/cmd/compute/util/time.go b/internal/cmd/compute/util/time.go new file mode 100644 index 00000000..906af663 --- /dev/null +++ b/internal/cmd/compute/util/time.go @@ -0,0 +1,33 @@ +package util + +import ( + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// RelativeAge returns a compact age string for table cells (no "ago" suffix). +// +// < 60s → "Xs" +// < 60m → "Xm" +// < 24h → "Xh" +// >= 24h → "Xd" +func RelativeAge(t metav1.Time) string { + d := time.Since(t.Time) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} + +// RelativeAgeVerbose returns an age string with "ago" suffix for detail views. +func RelativeAgeVerbose(t metav1.Time) string { + return RelativeAge(t) + " ago" +} diff --git a/internal/cmd/compute/watch/watch.go b/internal/cmd/compute/watch/watch.go new file mode 100644 index 00000000..836c4859 --- /dev/null +++ b/internal/cmd/compute/watch/watch.go @@ -0,0 +1,203 @@ +// Package watch provides a rollout progress watcher for compute workloads. +package watch + +import ( + "context" + "fmt" + "io" + "time" + + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/cmd/compute/util" +) + +type deploymentPhase string + +const ( + phasePending deploymentPhase = "Pending" + phaseUpdating deploymentPhase = "Updating" + phaseDone deploymentPhase = "Done" + phaseBlocked deploymentPhase = "Blocked" +) + +type deploymentState struct { + placement string + city string + desired int32 + ready int32 + current int32 + phase deploymentPhase + stalledSince time.Time +} + +// Rollout polls WorkloadDeployment objects for the given workload UID, printing +// per-city progress rows as state changes. It returns when all deployments +// reach Done, or when ctx is cancelled (Ctrl-C detach). +func Rollout(ctx context.Context, c client.Client, out io.Writer, project string, workloadUID types.UID) error { + start := time.Now() + + selector := labels.SelectorFromSet(labels.Set{ + computev1alpha.WorkloadUIDLabel: string(workloadUID), + }) + + tw := util.NewTabWriter(out) + headerPrinted := false + + states := map[string]*deploymentState{} + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + tw.Flush() + fmt.Fprintln(out, "Detached. Rollout continues in background.") + return nil + + case <-ticker.C: + var deployList computev1alpha.WorkloadDeploymentList + if err := c.List(ctx, &deployList, + client.InNamespace(project), + client.MatchingLabelsSelector{Selector: selector}, + ); err != nil { + if ctx.Err() != nil { + return nil + } + // Transient error — keep polling. + continue + } + + if len(deployList.Items) == 0 { + continue + } + + if !headerPrinted { + fmt.Fprintln(out, "\n PLACEMENT\tCITY\tUPDATED\tREADY\tOLD\tPHASE") + headerPrinted = true + } + + allDone := true + for _, d := range deployList.Items { + key := d.Spec.CityCode + prev, exists := states[key] + + desired := d.Status.DesiredReplicas + ready := d.Status.ReadyReplicas + current := d.Status.CurrentReplicas + + newPhase := computePhase(desired, ready, current, prev) + + if !exists || prev.desired != desired || prev.ready != ready || prev.current != current || prev.phase != newPhase { + st := &deploymentState{ + placement: d.Spec.PlacementName, + city: d.Spec.CityCode, + desired: desired, + ready: ready, + current: current, + phase: newPhase, + } + if exists { + st.stalledSince = prev.stalledSince + } + + // Track when we first noticed a potential stall. + if newPhase != phaseDone && newPhase != phasePending { + if !exists || prev.phase == phasePending { + st.stalledSince = time.Now() + } else if exists && prev.ready == ready && prev.current == current { + st.stalledSince = prev.stalledSince + } else { + st.stalledSince = time.Now() + } + } + + // Promote to Blocked if stalled > 30s without progress. + if newPhase == phaseUpdating && !st.stalledSince.IsZero() && time.Since(st.stalledSince) > 30*time.Second { + st.phase = phaseBlocked + newPhase = phaseBlocked + } + + states[key] = st + + // Compute old replicas = total created minus current-template replicas. + old := d.Status.Replicas - d.Status.CurrentReplicas + if old < 0 { + old = 0 + } + + fmt.Fprintf(tw, " %s\t%s\t%d\t%d\t%d\t%s\n", + d.Spec.PlacementName, + d.Spec.CityCode, + current, + ready, + old, + string(newPhase), + ) + tw.Flush() + + if newPhase == phaseBlocked { + printBlockedDetail(ctx, c, out, project, d) + } + } + + if newPhase != phaseDone { + allDone = false + } + } + + if allDone && len(deployList.Items) > 0 { + elapsed := time.Since(start).Round(time.Second) + minutes := int(elapsed.Minutes()) + seconds := int(elapsed.Seconds()) % 60 + if minutes > 0 { + fmt.Fprintf(out, "Rollout complete in %dm %ds.\n", minutes, seconds) + } else { + fmt.Fprintf(out, "Rollout complete in %ds.\n", seconds) + } + return nil + } + } + } +} + +func computePhase(desired, ready, current int32, prev *deploymentState) deploymentPhase { + if desired == 0 { + return phaseDone + } + if current == 0 { + return phasePending + } + if ready >= desired && current >= desired { + return phaseDone + } + return phaseUpdating +} + +// printBlockedDetail fetches instances for the deployment and prints a reason +// for the first non-ready instance. +func printBlockedDetail(ctx context.Context, c client.Client, out io.Writer, project string, d computev1alpha.WorkloadDeployment) { + selector := labels.SelectorFromSet(labels.Set{ + computev1alpha.WorkloadDeploymentUIDLabel: string(d.UID), + }) + var instList computev1alpha.InstanceList + if err := c.List(ctx, &instList, client.InNamespace(project), client.MatchingLabelsSelector{Selector: selector}); err != nil { + return + } + for _, inst := range instList.Items { + ready := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceReady) + if ready == nil || ready.Status != "True" { + status, detail := util.InstanceStatusDetail(inst.Status.Conditions) + if detail != "" { + fmt.Fprintf(out, " Blocked reason: %s — %s\n", status, detail) + } else { + fmt.Fprintf(out, " Blocked reason: %s\n", status) + } + return + } + } +} From 0f4a88e2fd05e2b45eef7897a6ad96ef09ed2924 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 16:47:12 -0500 Subject: [PATCH 02/27] fix: use default namespace and correct instance type in compute commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Within a project's virtual control plane, all resources live in the "default" namespace — the project slug is only used to route to the right control plane URL. Updated all commands to use util.ResourceNamespace ("default") instead of the project name as the k8s namespace. Also corrects the instance type default from "d1-standard-2" to "datumcloud/d1-standard-2" to match the format the admission webhook requires. Discovered while testing against the staging environment. Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/compute/deploy/deploy.go | 22 ++++++++++----------- internal/cmd/compute/destroy/destroy.go | 4 ++-- internal/cmd/compute/instances/instances.go | 12 +++++------ internal/cmd/compute/quota/quota.go | 4 ++-- internal/cmd/compute/restart/restart.go | 4 ++-- internal/cmd/compute/rollout/rollout.go | 14 ++++++------- internal/cmd/compute/scale/scale.go | 2 +- internal/cmd/compute/status/status.go | 8 ++++---- internal/cmd/compute/util/client.go | 5 +++++ internal/cmd/compute/watch/watch.go | 4 ++-- 10 files changed, 42 insertions(+), 37 deletions(-) diff --git a/internal/cmd/compute/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go index c1a09759..64305479 100644 --- a/internal/cmd/compute/deploy/deploy.go +++ b/internal/cmd/compute/deploy/deploy.go @@ -62,7 +62,7 @@ Use -f to apply a workload manifest file instead of flags.`, } cmd.Flags().StringVar(&opts.image, "image", "", "Container image to deploy (e.g. ghcr.io/acme/api:1.4.2)") - cmd.Flags().StringVar(&opts.instanceType, "instance-type", "d1-standard-2", "Instance type (e.g. d1-standard-2)") + cmd.Flags().StringVar(&opts.instanceType, "instance-type", "datumcloud/d1-standard-2", "Instance type (e.g. datumcloud/d1-standard-2)") cmd.Flags().StringSliceVar(&opts.cities, "city", nil, "One or more city codes to deploy to (e.g. DFW,IAD)") cmd.Flags().Int32Var(&opts.min, "min", 1, "Minimum number of instances per city") cmd.Flags().Int32Var(&opts.port, "port", 0, "Port to expose on the workload (optional)") @@ -99,7 +99,7 @@ func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) err } instanceType := opts.instanceType if instanceType == "" { - instanceType = "d1-standard-2" + instanceType = "datumcloud/d1-standard-2" } c, err := util.NewClient(project) @@ -114,12 +114,12 @@ func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) err var workload computev1alpha.Workload creating := false - if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { if k8serrors.IsNotFound(err) { creating = true workload = computev1alpha.Workload{ ObjectMeta: metav1.ObjectMeta{ - Namespace: project, + Namespace: util.ResourceNamespace, Name: workloadName, }, } @@ -198,7 +198,7 @@ func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) err } if creating { - workload.Namespace = project + workload.Namespace = util.ResourceNamespace if err := c.Create(ctx, &workload); err != nil { return fmt.Errorf("creating workload: %w", err) } @@ -211,7 +211,7 @@ func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) err } // Write revision entry. - rev := revision.CurrentRevision(ctx, c, project, workloadName) + 1 + rev := revision.CurrentRevision(ctx, c, util.ResourceNamespace, workloadName) + 1 specJSON, _ := json.Marshal(workload.Spec) entry := revision.Entry{ Rev: rev, @@ -220,7 +220,7 @@ func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) err Changes: changes, SpecJSON: string(specJSON), } - if err := revision.WriteEntry(ctx, c, project, workloadName, entry); err != nil { + if err := revision.WriteEntry(ctx, c, util.ResourceNamespace, workloadName, entry); err != nil { // Non-fatal — log but continue. fmt.Fprintf(out, " warning: could not write revision history: %v\n", err) } @@ -257,7 +257,7 @@ func deployFromFile(cmd *cobra.Command, opts *options) error { return fmt.Errorf("decoding manifest: %w", err) } - workload.Namespace = project + workload.Namespace = util.ResourceNamespace c, err := util.NewClient(project) if err != nil { @@ -269,7 +269,7 @@ func deployFromFile(cmd *cobra.Command, opts *options) error { var existing computev1alpha.Workload creating := false - if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workload.Name}, &existing); err != nil { + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workload.Name}, &existing); err != nil { if k8serrors.IsNotFound(err) { creating = true } else { @@ -327,7 +327,7 @@ func deployFromFile(cmd *cobra.Command, opts *options) error { fmt.Fprintf(out, " workload/%s updated\n", workload.Name) } - rev := revision.CurrentRevision(ctx, c, project, workload.Name) + 1 + rev := revision.CurrentRevision(ctx, c, util.ResourceNamespace, workload.Name) + 1 specJSON, _ := json.Marshal(workload.Spec) entry := revision.Entry{ Rev: rev, @@ -336,7 +336,7 @@ func deployFromFile(cmd *cobra.Command, opts *options) error { Changes: changes, SpecJSON: string(specJSON), } - if err := revision.WriteEntry(ctx, c, project, workload.Name, entry); err != nil { + if err := revision.WriteEntry(ctx, c, util.ResourceNamespace, workload.Name, entry); err != nil { fmt.Fprintf(out, " warning: could not write revision history: %v\n", err) } diff --git a/internal/cmd/compute/destroy/destroy.go b/internal/cmd/compute/destroy/destroy.go index 74abc4da..0695f1fd 100644 --- a/internal/cmd/compute/destroy/destroy.go +++ b/internal/cmd/compute/destroy/destroy.go @@ -47,7 +47,7 @@ func runDestroy(cmd *cobra.Command, args []string, yes bool) error { workloadName := args[0] var workload computev1alpha.Workload - if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { if k8serrors.IsNotFound(err) { return fmt.Errorf("workload %q not found in project %s", workloadName, project) } @@ -91,7 +91,7 @@ func runDestroy(cmd *cobra.Command, args []string, yes bool) error { // Best-effort deletion of the revision ConfigMap. var cm corev1.ConfigMap cmName := revision.ConfigMapName(workloadName) - if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: cmName}, &cm); err == nil { + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: cmName}, &cm); err == nil { _ = c.Delete(ctx, &cm) } diff --git a/internal/cmd/compute/instances/instances.go b/internal/cmd/compute/instances/instances.go index 56f9a750..f8897e34 100644 --- a/internal/cmd/compute/instances/instances.go +++ b/internal/cmd/compute/instances/instances.go @@ -78,7 +78,7 @@ func runList(cmd *cobra.Command, opts *listOptions) error { var workloadUID string if opts.workload != "" { var wl computev1alpha.Workload - if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: opts.workload}, &wl); err != nil { + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: opts.workload}, &wl); err != nil { if k8serrors.IsNotFound(err) { return fmt.Errorf("workload %q not found", opts.workload) } @@ -89,7 +89,7 @@ func runList(cmd *cobra.Command, opts *listOptions) error { // List instances. var instList computev1alpha.InstanceList - listOpts := []client.ListOption{client.InNamespace(project)} + listOpts := []client.ListOption{client.InNamespace(util.ResourceNamespace)} if workloadUID != "" { selector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadUIDLabel: workloadUID}) listOpts = append(listOpts, client.MatchingLabelsSelector{Selector: selector}) @@ -100,7 +100,7 @@ func runList(cmd *cobra.Command, opts *listOptions) error { // List deployments — build map deploymentUID → *WorkloadDeployment. var deployList computev1alpha.WorkloadDeploymentList - if err := c.List(ctx, &deployList, client.InNamespace(project)); err != nil { + if err := c.List(ctx, &deployList, client.InNamespace(util.ResourceNamespace)); err != nil { return fmt.Errorf("listing deployments: %w", err) } deploymentMap := make(map[string]*computev1alpha.WorkloadDeployment, len(deployList.Items)) @@ -111,7 +111,7 @@ func runList(cmd *cobra.Command, opts *listOptions) error { // List workloads — build map workloadUID → name. var wlList computev1alpha.WorkloadList - if err := c.List(ctx, &wlList, client.InNamespace(project)); err != nil { + if err := c.List(ctx, &wlList, client.InNamespace(util.ResourceNamespace)); err != nil { return fmt.Errorf("listing workloads: %w", err) } workloadMap := make(map[string]string, len(wlList.Items)) @@ -228,7 +228,7 @@ func runDescribe(cmd *cobra.Command, args []string) error { instanceName := args[0] var inst computev1alpha.Instance - if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: instanceName}, &inst); err != nil { + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: instanceName}, &inst); err != nil { if k8serrors.IsNotFound(err) { return fmt.Errorf("instance %q not found in project %s", instanceName, project) } @@ -244,7 +244,7 @@ func runDescribe(cmd *cobra.Command, args []string) error { if deploymentUID != "" { depSelector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadDeploymentUIDLabel: deploymentUID}) var depList computev1alpha.WorkloadDeploymentList - if err := c.List(ctx, &depList, client.InNamespace(project), client.MatchingLabelsSelector{Selector: depSelector}); err == nil && len(depList.Items) > 0 { + if err := c.List(ctx, &depList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: depSelector}); err == nil && len(depList.Items) > 0 { dep := depList.Items[0] city = dep.Spec.CityCode placementName = dep.Spec.PlacementName diff --git a/internal/cmd/compute/quota/quota.go b/internal/cmd/compute/quota/quota.go index c8601334..a0b05525 100644 --- a/internal/cmd/compute/quota/quota.go +++ b/internal/cmd/compute/quota/quota.go @@ -55,13 +55,13 @@ func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { // List all instances in the project. var instList computev1alpha.InstanceList - if err := c.List(ctx, &instList, client.InNamespace(project)); err != nil { + if err := c.List(ctx, &instList, client.InNamespace(util.ResourceNamespace)); err != nil { return fmt.Errorf("listing instances: %w", err) } // List all deployments to build a UID → city/instanceType lookup. var deployList computev1alpha.WorkloadDeploymentList - if err := c.List(ctx, &deployList, client.InNamespace(project)); err != nil { + if err := c.List(ctx, &deployList, client.InNamespace(util.ResourceNamespace)); err != nil { return fmt.Errorf("listing deployments: %w", err) } diff --git a/internal/cmd/compute/restart/restart.go b/internal/cmd/compute/restart/restart.go index 1907011c..b11f3d51 100644 --- a/internal/cmd/compute/restart/restart.go +++ b/internal/cmd/compute/restart/restart.go @@ -44,7 +44,7 @@ func runRestart(cmd *cobra.Command, args []string, city string) error { workloadName := args[0] var workload computev1alpha.Workload - if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { if k8serrors.IsNotFound(err) { return fmt.Errorf("workload %q not found in project %s", workloadName, project) } @@ -78,7 +78,7 @@ func runRestart(cmd *cobra.Command, args []string, city string) error { }) var deployList computev1alpha.WorkloadDeploymentList if err := c.List(ctx, &deployList, - client.InNamespace(project), + client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: selector}, ); err != nil { return fmt.Errorf("listing deployments: %w", err) diff --git a/internal/cmd/compute/rollout/rollout.go b/internal/cmd/compute/rollout/rollout.go index f3558363..3f309cfb 100644 --- a/internal/cmd/compute/rollout/rollout.go +++ b/internal/cmd/compute/rollout/rollout.go @@ -59,14 +59,14 @@ func runWatch(cmd *cobra.Command, args []string) error { workloadName := args[0] var workload computev1alpha.Workload - if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { if k8serrors.IsNotFound(err) { return fmt.Errorf("workload %q not found in project %s", workloadName, project) } return fmt.Errorf("getting workload: %w", err) } - entries, currentRev, err := revision.ReadEntries(ctx, c, project, workloadName) + entries, currentRev, err := revision.ReadEntries(ctx, c, util.ResourceNamespace, workloadName) if err != nil { return fmt.Errorf("reading revision history: %w", err) } @@ -112,14 +112,14 @@ func runHistory(cmd *cobra.Command, args []string) error { workloadName := args[0] var workload computev1alpha.Workload - if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { if k8serrors.IsNotFound(err) { return fmt.Errorf("workload %q not found in project %s", workloadName, project) } return fmt.Errorf("getting workload: %w", err) } - entries, currentRev, err := revision.ReadEntries(ctx, c, project, workloadName) + entries, currentRev, err := revision.ReadEntries(ctx, c, util.ResourceNamespace, workloadName) if err != nil { return fmt.Errorf("reading revision history: %w", err) } @@ -187,14 +187,14 @@ func runUndo(cmd *cobra.Command, args []string, toRevision int32) error { workloadName := args[0] var workload computev1alpha.Workload - if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { if k8serrors.IsNotFound(err) { return fmt.Errorf("workload %q not found in project %s", workloadName, project) } return fmt.Errorf("getting workload: %w", err) } - entries, currentRev, err := revision.ReadEntries(ctx, c, project, workloadName) + entries, currentRev, err := revision.ReadEntries(ctx, c, util.ResourceNamespace, workloadName) if err != nil { return fmt.Errorf("reading revision history: %w", err) } @@ -263,7 +263,7 @@ func runUndo(cmd *cobra.Command, args []string, toRevision int32) error { Actor: actor, SpecJSON: string(newSpecJSON), } - if err := revision.WriteEntry(ctx, c, project, workloadName, entry); err != nil { + if err := revision.WriteEntry(ctx, c, util.ResourceNamespace, workloadName, entry); err != nil { fmt.Fprintf(out, " warning: could not write revision history: %v\n", err) } diff --git a/internal/cmd/compute/scale/scale.go b/internal/cmd/compute/scale/scale.go index 28112dda..51902618 100644 --- a/internal/cmd/compute/scale/scale.go +++ b/internal/cmd/compute/scale/scale.go @@ -47,7 +47,7 @@ func runScale(cmd *cobra.Command, args []string, min int32) error { workloadName := args[0] var workload computev1alpha.Workload - if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { if k8serrors.IsNotFound(err) { return fmt.Errorf("workload %q not found in project %s", workloadName, project) } diff --git a/internal/cmd/compute/status/status.go b/internal/cmd/compute/status/status.go index 10cbcfe7..5922785d 100644 --- a/internal/cmd/compute/status/status.go +++ b/internal/cmd/compute/status/status.go @@ -45,7 +45,7 @@ func runStatus(cmd *cobra.Command, args []string) error { // Fetch workload. var workload computev1alpha.Workload - if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: workloadName}, &workload); err != nil { + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { if k8serrors.IsNotFound(err) { fmt.Fprintf(cmd.ErrOrStderr(), "workload %q not found in project %s\n", workloadName, project) return fmt.Errorf("workload not found") @@ -56,7 +56,7 @@ func runStatus(cmd *cobra.Command, args []string) error { // List deployments for this workload. selector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadUIDLabel: string(workload.UID)}) var deployList computev1alpha.WorkloadDeploymentList - if err := c.List(ctx, &deployList, client.InNamespace(project), client.MatchingLabelsSelector{Selector: selector}); err != nil { + if err := c.List(ctx, &deployList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: selector}); err != nil { return fmt.Errorf("listing deployments: %w", err) } @@ -74,7 +74,7 @@ func runStatus(cmd *cobra.Command, args []string) error { revision := "—" var cm corev1.ConfigMap cmName := "compute.datumapis.com-revision-history." + workloadName - if err := c.Get(ctx, types.NamespacedName{Namespace: project, Name: cmName}, &cm); err == nil { + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: cmName}, &cm); err == nil { if v, ok := cm.Annotations["compute.datumapis.com/current-revision"]; ok { revision = v } @@ -175,7 +175,7 @@ func runStatus(cmd *cobra.Command, args []string) error { depUID := string(de.deployment.UID) depSelector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadDeploymentUIDLabel: depUID}) var instList computev1alpha.InstanceList - if err := c.List(ctx, &instList, client.InNamespace(project), client.MatchingLabelsSelector{Selector: depSelector}); err != nil { + if err := c.List(ctx, &instList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: depSelector}); err != nil { // Skip detail on error. continue } diff --git a/internal/cmd/compute/util/client.go b/internal/cmd/compute/util/client.go index 852a3747..7129df21 100644 --- a/internal/cmd/compute/util/client.go +++ b/internal/cmd/compute/util/client.go @@ -14,6 +14,11 @@ import ( const ( resourceManagerGroup = "resourcemanager.miloapis.com" resourceManagerVersion = "v1alpha1" + + // ResourceNamespace is the namespace used for all resource operations within + // a project's virtual control plane. The project slug routes to the right + // control plane; within it, everything lives in "default". + ResourceNamespace = "default" ) // ProjectControlPlaneURL returns the virtual control-plane URL for a project. diff --git a/internal/cmd/compute/watch/watch.go b/internal/cmd/compute/watch/watch.go index 836c4859..b4e62d68 100644 --- a/internal/cmd/compute/watch/watch.go +++ b/internal/cmd/compute/watch/watch.go @@ -62,7 +62,7 @@ func Rollout(ctx context.Context, c client.Client, out io.Writer, project string case <-ticker.C: var deployList computev1alpha.WorkloadDeploymentList if err := c.List(ctx, &deployList, - client.InNamespace(project), + client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: selector}, ); err != nil { if ctx.Err() != nil { @@ -185,7 +185,7 @@ func printBlockedDetail(ctx context.Context, c client.Client, out io.Writer, pro computev1alpha.WorkloadDeploymentUIDLabel: string(d.UID), }) var instList computev1alpha.InstanceList - if err := c.List(ctx, &instList, client.InNamespace(project), client.MatchingLabelsSelector{Selector: selector}); err != nil { + if err := c.List(ctx, &instList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: selector}); err != nil { return } for _, inst := range instList.Items { From fd507794c95d37ac56669b1e12ac47f945625148 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 16:58:19 -0500 Subject: [PATCH 03/27] fix: remove datumctl SDK dependency and fix CI Go version The datumctl module requirement was upgrading controller-runtime to v0.23.3, which broke compatibility with multicluster-runtime and milo. Eliminated the dependency by: - Inlining the --plugin-manifest protocol in main.go - Reading DATUM_API_HOST and DATUM_CREDENTIALS_HELPER from env directly in util/client.go instead of via plugin.Context()/plugin.Token() - Reading DATUM_ORG from env in root.go instead of via plugin.NewRootCmd - Dropping the now-unreachable internal/cmd/compute/client.go Also updates CI workflows to use go-version-file instead of a pinned go 1.24.0, and bumps golangci-lint to v2.12.2 which supports go 1.25. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/lint.yml | 2 +- .github/workflows/test-e2e.yml | 2 +- .github/workflows/test.yml | 2 +- cmd/datumctl-compute/main.go | 37 +++++++++---- go.mod | 37 ++++++------- go.sum | 70 +++++++++++------------- internal/cmd/compute/client.go | 72 ------------------------- internal/cmd/compute/rollout/rollout.go | 7 +-- internal/cmd/compute/root.go | 15 +++++- internal/cmd/compute/util/client.go | 26 +++++++-- 10 files changed, 114 insertions(+), 156 deletions(-) delete mode 100644 internal/cmd/compute/client.go diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index caa00ff3..2ef3bca4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '~1.25.0' + go-version-file: 'go.mod' - name: Run linter uses: golangci/golangci-lint-action@v8 diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 9bede775..b3b66dc5 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '~1.25.0' + go-version-file: 'go.mod' - name: Install the latest version of kind run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 462cbf3d..07fbf7c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '~1.25.0' + go-version-file: 'go.mod' - name: Running Tests run: | diff --git a/cmd/datumctl-compute/main.go b/cmd/datumctl-compute/main.go index e092b3e9..b83f0a80 100644 --- a/cmd/datumctl-compute/main.go +++ b/cmd/datumctl-compute/main.go @@ -1,26 +1,45 @@ package main import ( + "encoding/json" + "fmt" "os" - "go.datum.net/datumctl/plugin" - "go.datum.net/compute/internal/cmd/compute" ) // version is set at build time via ldflags. var version = "dev" -var manifest = plugin.Manifest{ - Name: "compute", - Version: version, - Description: "Deploy and manage containerized workloads on Datum Cloud", - APIVersion: 1, - MinAPIVersion: 1, +type pluginManifest struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + APIVersion int `json:"api_version"` + MinAPIVersion int `json:"min_api_version,omitempty"` } func main() { - plugin.ServeManifest(manifest) + // Respond to --plugin-manifest before cobra runs so datumctl can discover + // the plugin's metadata without full command initialization. + for _, arg := range os.Args[1:] { + if arg == "--plugin-manifest" { + m := pluginManifest{ + Name: "compute", + Version: version, + Description: "Deploy and manage containerized workloads on Datum Cloud", + APIVersion: 1, + MinAPIVersion: 1, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(m); err != nil { + fmt.Fprintf(os.Stderr, "plugin manifest encode error: %v\n", err) + os.Exit(1) + } + os.Exit(0) + } + } if err := compute.Command().Execute(); err != nil { os.Exit(1) diff --git a/go.mod b/go.mod index 513e579c..90c917f5 100644 --- a/go.mod +++ b/go.mod @@ -8,25 +8,26 @@ require ( github.com/onsi/gomega v1.38.2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 - go.datum.net/datumctl v0.0.0 go.datum.net/network-services-operator v0.1.0 go.miloapis.com/milo v0.24.11 golang.org/x/crypto v0.49.0 golang.org/x/sync v0.20.0 + golang.org/x/term v0.43.0 google.golang.org/protobuf v1.36.11 k8s.io/api v0.35.3 k8s.io/apimachinery v0.35.3 k8s.io/client-go v0.35.3 k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 - sigs.k8s.io/controller-runtime v0.23.3 + sigs.k8s.io/controller-runtime v0.21.0 sigs.k8s.io/gateway-api v1.2.1 sigs.k8s.io/multicluster-runtime v0.21.0-alpha.8 + sigs.k8s.io/yaml v1.6.0 ) require ( - cel.dev/expr v0.24.0 // indirect + cel.dev/expr v0.25.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -56,13 +57,14 @@ require ( github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/cel-go v0.26.0 // indirect + github.com/google/cel-go v0.27.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.3 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -72,7 +74,6 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect @@ -80,30 +81,29 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/sdk v1.36.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect - go.opentelemetry.io/proto/otlp v1.5.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.44.0 // indirect - golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.43.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect - google.golang.org/grpc v1.72.2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect + google.golang.org/grpc v1.79.3 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.35.0 // indirect + k8s.io/apiextensions-apiserver v0.35.3 // indirect k8s.io/apiserver v0.35.3 // indirect k8s.io/component-base v0.35.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect @@ -112,9 +112,4 @@ require ( sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) - -// datumctl plugin SDK — replace with a released version once PR #198 is merged -// (https://github.com/datum-cloud/datumctl/pull/198) -replace go.datum.net/datumctl => ../datumctl/.claude/worktrees/agent-aaa7004d0f14304c6 diff --git a/go.sum b/go.sum index 7639dd22..6ba13017 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -84,8 +84,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= -github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= +github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -97,16 +97,16 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -150,17 +150,10 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= -github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -189,28 +182,28 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0u go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= -go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= @@ -231,12 +224,14 @@ golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= -google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -246,13 +241,12 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= -k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= -k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apiextensions-apiserver v0.35.3 h1:2fQUhEO7P17sijylbdwt0nBdXP0TvHrHj0KeqHD8FiU= +k8s.io/apiextensions-apiserver v0.35.3/go.mod h1:tK4Kz58ykRpwAEkXUb634HD1ZAegEElktz/B3jgETd8= k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.35.3 h1:D2eIcfJ05hEAEewoSDg+05e0aSRwx8Y4Agvd/wiomUI= @@ -269,8 +263,8 @@ k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbe k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= -sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= +sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= sigs.k8s.io/gateway-api v1.2.1 h1:fZZ/+RyRb+Y5tGkwxFKuYuSRQHu9dZtbjenblleOLHM= sigs.k8s.io/gateway-api v1.2.1/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/internal/cmd/compute/client.go b/internal/cmd/compute/client.go deleted file mode 100644 index 4186b2b4..00000000 --- a/internal/cmd/compute/client.go +++ /dev/null @@ -1,72 +0,0 @@ -package compute - -import ( - "fmt" - - "github.com/spf13/cobra" - "go.datum.net/datumctl/plugin" - computev1alpha "go.datum.net/compute/api/v1alpha" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/rest" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -const ( - resourceManagerGroup = "resourcemanager.miloapis.com" - resourceManagerVersion = "v1alpha1" -) - -// projectControlPlaneURL returns the virtual control-plane URL for a project. -// Each project in Datum Cloud has its own isolated Kubernetes-style API endpoint -// rooted here; all resource operations (List, Get, Create, etc.) target this URL. -func projectControlPlaneURL(apiHost, projectID string) string { - return fmt.Sprintf("https://%s/apis/%s/%s/projects/%s/control-plane", - apiHost, resourceManagerGroup, resourceManagerVersion, projectID) -} - -// newClient builds a Kubernetes client targeting the project's virtual control plane. -// It acquires a fresh bearer token via the datumctl credentials helper on each call, -// so it must not be cached across long-running operations. -// -// project is the resolved project slug — callers should read it from the cobra -// --project persistent flag (set on the root command by plugin.NewRootCmd), which -// defaults to the DATUM_PROJECT value injected by datumctl but can be overridden -// by the user at invocation time. -// -// The returned project string must be passed as the namespace to all client operations: -// -// c.List(ctx, list, client.InNamespace(project)) -func newClient(project string) (client.Client, error) { - if project == "" { - return nil, fmt.Errorf("no project set — pass --project or run 'datumctl config set project '") - } - - pluginCtx := plugin.Context() - if pluginCtx.APIHost == "" { - return nil, fmt.Errorf("DATUM_API_HOST is not set; is this plugin running via datumctl?") - } - - token, err := plugin.Token() - if err != nil { - return nil, fmt.Errorf("getting credentials: %w", err) - } - - scheme := runtime.NewScheme() - if err := computev1alpha.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("registering compute scheme: %w", err) - } - - cfg := &rest.Config{ - Host: projectControlPlaneURL(pluginCtx.APIHost, project), - BearerToken: token, - } - - return client.New(cfg, client.Options{Scheme: scheme}) -} - -// projectFromCmd reads the --project persistent flag from the command's root, -// which plugin.NewRootCmd wires with DATUM_PROJECT as the default. -func projectFromCmd(cmd *cobra.Command) string { - project, _ := cmd.Root().PersistentFlags().GetString("project") - return project -} diff --git a/internal/cmd/compute/rollout/rollout.go b/internal/cmd/compute/rollout/rollout.go index 3f309cfb..d81a71d0 100644 --- a/internal/cmd/compute/rollout/rollout.go +++ b/internal/cmd/compute/rollout/rollout.go @@ -9,7 +9,6 @@ import ( "time" "github.com/spf13/cobra" - "go.datum.net/datumctl/plugin" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -248,11 +247,7 @@ func runUndo(cmd *cobra.Command, args []string, toRevision int32) error { return fmt.Errorf("updating workload: %w", err) } - // Determine actor from plugin context. - actor := "" - if pluginCtx := plugin.Context(); pluginCtx.Org != "" { - actor = pluginCtx.Org - } + actor := os.Getenv("DATUM_ORG") newSpecJSON, _ := json.Marshal(workload.Spec) entry := revision.Entry{ diff --git a/internal/cmd/compute/root.go b/internal/cmd/compute/root.go index f61e18bf..77bda5cf 100644 --- a/internal/cmd/compute/root.go +++ b/internal/cmd/compute/root.go @@ -1,8 +1,9 @@ package compute import ( + "os" + "github.com/spf13/cobra" - "go.datum.net/datumctl/plugin" "go.datum.net/compute/internal/cmd/compute/deploy" "go.datum.net/compute/internal/cmd/compute/destroy" @@ -15,7 +16,17 @@ import ( ) func Command() *cobra.Command { - root := plugin.NewRootCmd("compute", "Deploy and manage containerized workloads on Datum Cloud") + root := &cobra.Command{ + Use: "compute", + Short: "Deploy and manage containerized workloads on Datum Cloud", + } + + root.PersistentFlags().String("org", os.Getenv("DATUM_ORG"), + "Datum Cloud organization (defaults to DATUM_ORG injected by datumctl)") + root.PersistentFlags().String("project", os.Getenv("DATUM_PROJECT"), + "Datum Cloud project (defaults to DATUM_PROJECT injected by datumctl)") + root.PersistentFlags().StringP("output", "o", "table", + "Output format. One of: table|json|yaml") root.AddCommand( deploy.Command(), diff --git a/internal/cmd/compute/util/client.go b/internal/cmd/compute/util/client.go index 7129df21..cbe90e27 100644 --- a/internal/cmd/compute/util/client.go +++ b/internal/cmd/compute/util/client.go @@ -2,9 +2,11 @@ package util import ( "fmt" + "os" + "os/exec" + "strings" "github.com/spf13/cobra" - "go.datum.net/datumctl/plugin" computev1alpha "go.datum.net/compute/api/v1alpha" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" @@ -33,12 +35,12 @@ func NewClient(project string) (client.Client, error) { return nil, fmt.Errorf("no project set — pass --project or run 'datumctl config set project '") } - pluginCtx := plugin.Context() - if pluginCtx.APIHost == "" { + apiHost := os.Getenv("DATUM_API_HOST") + if apiHost == "" { return nil, fmt.Errorf("DATUM_API_HOST is not set; is this plugin running via datumctl?") } - token, err := plugin.Token() + token, err := pluginToken() if err != nil { return nil, fmt.Errorf("getting credentials: %w", err) } @@ -49,13 +51,27 @@ func NewClient(project string) (client.Client, error) { } cfg := &rest.Config{ - Host: ProjectControlPlaneURL(pluginCtx.APIHost, project), + Host: ProjectControlPlaneURL(apiHost, project), BearerToken: token, } return client.New(cfg, client.Options{Scheme: scheme}) } +// pluginToken retrieves a bearer token by calling the datumctl credentials +// helper injected via DATUM_CREDENTIALS_HELPER. +func pluginToken() (string, error) { + helper := os.Getenv("DATUM_CREDENTIALS_HELPER") + if helper == "" { + return "", fmt.Errorf("DATUM_CREDENTIALS_HELPER is not set; is this plugin running via datumctl?") + } + out, err := exec.Command(helper, "auth", "get-token").Output() + if err != nil { + return "", fmt.Errorf("credentials helper: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + // ProjectFromCmd reads the --project persistent flag from the command's root. func ProjectFromCmd(cmd *cobra.Command) string { project, _ := cmd.Root().PersistentFlags().GetString("project") From acf5a2f99f2f44be2f72622c7f95d28dc2b4ede4 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 17:15:52 -0500 Subject: [PATCH 04/27] feat: add datumctl plugin SDK and upgrade controller-runtime to v0.23.3 Upgrades controller-runtime from v0.21.0 to v0.23.3 and multicluster-runtime from v0.21.0-alpha.8 to v0.23.3, which unblocks adding go.datum.net/datumctl as a direct dependency. The CLI plugin (datumctl-compute) now uses the official datumctl plugin SDK: - plugin.ServeManifest() for the --plugin-manifest protocol - plugin.NewRootCmd() for pre-wired org/project/output flags - plugin.Context() and plugin.Token() for credential access Controller breaking changes addressed: ClusterName distinct type, Watches callback signature, NewWebhookManagedBy generic API. A local milo provider fork is added at internal/provider/milo since the upstream package hasn't been updated for the ClusterName type change. Co-Authored-By: Claude Sonnet 4.6 --- cmd/datumctl-compute/main.go | 39 +-- go.mod | 7 +- go.sum | 10 +- internal/cmd/compute/rollout/rollout.go | 3 +- internal/cmd/compute/root.go | 15 +- internal/cmd/compute/util/client.go | 26 +- internal/provider/milo/provider.go | 352 ++++++++++++++++++++++++ 7 files changed, 380 insertions(+), 72 deletions(-) create mode 100644 internal/provider/milo/provider.go diff --git a/cmd/datumctl-compute/main.go b/cmd/datumctl-compute/main.go index b83f0a80..4571a580 100644 --- a/cmd/datumctl-compute/main.go +++ b/cmd/datumctl-compute/main.go @@ -1,45 +1,24 @@ package main import ( - "encoding/json" - "fmt" "os" + "go.datum.net/datumctl/plugin" + "go.datum.net/compute/internal/cmd/compute" ) // version is set at build time via ldflags. var version = "dev" -type pluginManifest struct { - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - APIVersion int `json:"api_version"` - MinAPIVersion int `json:"min_api_version,omitempty"` -} - func main() { - // Respond to --plugin-manifest before cobra runs so datumctl can discover - // the plugin's metadata without full command initialization. - for _, arg := range os.Args[1:] { - if arg == "--plugin-manifest" { - m := pluginManifest{ - Name: "compute", - Version: version, - Description: "Deploy and manage containerized workloads on Datum Cloud", - APIVersion: 1, - MinAPIVersion: 1, - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - if err := enc.Encode(m); err != nil { - fmt.Fprintf(os.Stderr, "plugin manifest encode error: %v\n", err) - os.Exit(1) - } - os.Exit(0) - } - } + plugin.ServeManifest(plugin.Manifest{ + Name: "compute", + Version: version, + Description: "Deploy and manage containerized workloads on Datum Cloud", + APIVersion: 1, + MinAPIVersion: 1, + }) if err := compute.Command().Execute(); err != nil { os.Exit(1) diff --git a/go.mod b/go.mod index 90c917f5..57e51a04 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,13 @@ module go.datum.net/compute go 1.25.8 require ( + github.com/go-logr/logr v1.4.3 github.com/google/go-cmp v0.7.0 github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + go.datum.net/datumctl v0.14.1-0.20260522214722-79d2d1680f08 go.datum.net/network-services-operator v0.1.0 go.miloapis.com/milo v0.24.11 golang.org/x/crypto v0.49.0 @@ -18,9 +20,9 @@ require ( k8s.io/apimachinery v0.35.3 k8s.io/client-go v0.35.3 k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 - sigs.k8s.io/controller-runtime v0.21.0 + sigs.k8s.io/controller-runtime v0.23.3 sigs.k8s.io/gateway-api v1.2.1 - sigs.k8s.io/multicluster-runtime v0.21.0-alpha.8 + sigs.k8s.io/multicluster-runtime v0.23.3 sigs.k8s.io/yaml v1.6.0 ) @@ -38,7 +40,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect diff --git a/go.sum b/go.sum index 6ba13017..1b84d2d1 100644 --- a/go.sum +++ b/go.sum @@ -166,6 +166,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.datum.net/datumctl v0.14.1-0.20260522214722-79d2d1680f08 h1:8BymUUKdFBCBjvA4yB6BBWYVloiA0f0XqRqYksA/kvQ= +go.datum.net/datumctl v0.14.1-0.20260522214722-79d2d1680f08/go.mod h1:rwu8XWb0FeMzX8vCu+UxKLw89DAkyLOh70PNbDaotac= go.datum.net/network-services-operator v0.1.0 h1:PAXOZ5DdJFgRoeVBPIXhqkCm6DxbP4tVOPcr3Y7h/So= go.datum.net/network-services-operator v0.1.0/go.mod h1:uloVfxqE+8DgSiMB651X8UC9yECpXbwp/NBstofCceE= go.miloapis.com/milo v0.24.11 h1:rByXDKbP4ZEN0I/z1C2RyUCyQi0NWrITLqoQILSAn2E= @@ -263,14 +265,14 @@ k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbe k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= -sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/gateway-api v1.2.1 h1:fZZ/+RyRb+Y5tGkwxFKuYuSRQHu9dZtbjenblleOLHM= sigs.k8s.io/gateway-api v1.2.1/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/multicluster-runtime v0.21.0-alpha.8 h1:Pq69tTKfN8ADw8m8A3wUtP8wJ9SPQbbOsgapm3BZEPw= -sigs.k8s.io/multicluster-runtime v0.21.0-alpha.8/go.mod h1:CpBzLMLQKdm+UCchd2FiGPiDdCxM5dgCCPKuaQ6Fsv0= +sigs.k8s.io/multicluster-runtime v0.23.3 h1:vrzlXRzHTDsjspUAfoW2rCtr0agoI4q20p9x4Fz4png= +sigs.k8s.io/multicluster-runtime v0.23.3/go.mod h1:r/UA4GHgFoXCcR4tcvlZz7SiLx3l1kJKDuBAhILNIHs= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= diff --git a/internal/cmd/compute/rollout/rollout.go b/internal/cmd/compute/rollout/rollout.go index d81a71d0..1702d402 100644 --- a/internal/cmd/compute/rollout/rollout.go +++ b/internal/cmd/compute/rollout/rollout.go @@ -9,6 +9,7 @@ import ( "time" "github.com/spf13/cobra" + "go.datum.net/datumctl/plugin" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -247,7 +248,7 @@ func runUndo(cmd *cobra.Command, args []string, toRevision int32) error { return fmt.Errorf("updating workload: %w", err) } - actor := os.Getenv("DATUM_ORG") + actor := plugin.Context().Org newSpecJSON, _ := json.Marshal(workload.Spec) entry := revision.Entry{ diff --git a/internal/cmd/compute/root.go b/internal/cmd/compute/root.go index 77bda5cf..f61e18bf 100644 --- a/internal/cmd/compute/root.go +++ b/internal/cmd/compute/root.go @@ -1,9 +1,8 @@ package compute import ( - "os" - "github.com/spf13/cobra" + "go.datum.net/datumctl/plugin" "go.datum.net/compute/internal/cmd/compute/deploy" "go.datum.net/compute/internal/cmd/compute/destroy" @@ -16,17 +15,7 @@ import ( ) func Command() *cobra.Command { - root := &cobra.Command{ - Use: "compute", - Short: "Deploy and manage containerized workloads on Datum Cloud", - } - - root.PersistentFlags().String("org", os.Getenv("DATUM_ORG"), - "Datum Cloud organization (defaults to DATUM_ORG injected by datumctl)") - root.PersistentFlags().String("project", os.Getenv("DATUM_PROJECT"), - "Datum Cloud project (defaults to DATUM_PROJECT injected by datumctl)") - root.PersistentFlags().StringP("output", "o", "table", - "Output format. One of: table|json|yaml") + root := plugin.NewRootCmd("compute", "Deploy and manage containerized workloads on Datum Cloud") root.AddCommand( deploy.Command(), diff --git a/internal/cmd/compute/util/client.go b/internal/cmd/compute/util/client.go index cbe90e27..01031ed7 100644 --- a/internal/cmd/compute/util/client.go +++ b/internal/cmd/compute/util/client.go @@ -2,11 +2,9 @@ package util import ( "fmt" - "os" - "os/exec" - "strings" "github.com/spf13/cobra" + "go.datum.net/datumctl/plugin" computev1alpha "go.datum.net/compute/api/v1alpha" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" @@ -35,12 +33,12 @@ func NewClient(project string) (client.Client, error) { return nil, fmt.Errorf("no project set — pass --project or run 'datumctl config set project '") } - apiHost := os.Getenv("DATUM_API_HOST") - if apiHost == "" { + ctx := plugin.Context() + if ctx.APIHost == "" { return nil, fmt.Errorf("DATUM_API_HOST is not set; is this plugin running via datumctl?") } - token, err := pluginToken() + token, err := plugin.Token() if err != nil { return nil, fmt.Errorf("getting credentials: %w", err) } @@ -51,27 +49,13 @@ func NewClient(project string) (client.Client, error) { } cfg := &rest.Config{ - Host: ProjectControlPlaneURL(apiHost, project), + Host: ProjectControlPlaneURL(ctx.APIHost, project), BearerToken: token, } return client.New(cfg, client.Options{Scheme: scheme}) } -// pluginToken retrieves a bearer token by calling the datumctl credentials -// helper injected via DATUM_CREDENTIALS_HELPER. -func pluginToken() (string, error) { - helper := os.Getenv("DATUM_CREDENTIALS_HELPER") - if helper == "" { - return "", fmt.Errorf("DATUM_CREDENTIALS_HELPER is not set; is this plugin running via datumctl?") - } - out, err := exec.Command(helper, "auth", "get-token").Output() - if err != nil { - return "", fmt.Errorf("credentials helper: %w", err) - } - return strings.TrimSpace(string(out)), nil -} - // ProjectFromCmd reads the --project persistent flag from the command's root. func ProjectFromCmd(cmd *cobra.Command) string { project, _ := cmd.Root().PersistentFlags().GetString("project") diff --git a/internal/provider/milo/provider.go b/internal/provider/milo/provider.go new file mode 100644 index 00000000..927ec581 --- /dev/null +++ b/internal/provider/milo/provider.go @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Package milo provides a multicluster provider that discovers Kubernetes clusters +// by watching Milo Project (and ProjectControlPlane) resources. +// +// This is a local fork of go.miloapis.com/milo/pkg/multicluster-runtime/milo adapted +// to be compatible with multicluster-runtime v0.23+, which changed ClusterName from a +// plain string to a distinct type (multicluster.ClusterName). +package milo + +import ( + "context" + "fmt" + "net/url" + "sync" + "time" + + "github.com/go-logr/logr" + infrastructurev1alpha1 "go.miloapis.com/milo/pkg/apis/infrastructure/v1alpha1" + resourcemanagerv1alpha1 "go.miloapis.com/milo/pkg/apis/resourcemanager/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + "sigs.k8s.io/multicluster-runtime/pkg/multicluster" +) + +// Built following the cluster-api provider as an example. +// See: https://sigs.k8s.io/multicluster-runtime/blob/7abad14c6d65fdaf9b83a2b1d9a2c99140d18e7d/providers/cluster-api/provider.go + +var _ multicluster.Provider = &Provider{} + +var projectGVK = resourcemanagerv1alpha1.GroupVersion.WithKind("Project") +var projectControlPlaneGVK = infrastructurev1alpha1.GroupVersion.WithKind("ProjectControlPlane") + +// Options are the options for the Datum cluster Provider. +type Options struct { + // ClusterOptions are the options passed to the cluster constructor. + ClusterOptions []cluster.Option + + // InternalServiceDiscovery will result in the provider to look for + // ProjectControlPlane resources in the local manager's cluster, and establish + // a connection via the internal service address. Otherwise, the provider will + // look for Project resources in the cluster and expect to connect to the + // external Datum API endpoint. + InternalServiceDiscovery bool + + // ProjectRestConfig is the rest config to use when connecting to project + // API endpoints. If not provided, the provider will use the rest config + // from the local manager. + ProjectRestConfig *rest.Config + + // LabelSelector is an optional selector to filter projects based on labels. + // When provided, only projects matching this selector will be reconciled. + LabelSelector *metav1.LabelSelector +} + +// New creates a new Datum cluster Provider. +func New(localMgr manager.Manager, opts Options) (*Provider, error) { + p := &Provider{ + opts: opts, + log: log.Log.WithName("datum-cluster-provider"), + client: localMgr.GetClient(), + projectRestConfig: opts.ProjectRestConfig, + projects: map[string]cluster.Cluster{}, + cancelFns: map[string]context.CancelFunc{}, + } + + if p.projectRestConfig == nil { + p.projectRestConfig = localMgr.GetConfig() + } + + var project unstructured.Unstructured + if p.opts.InternalServiceDiscovery { + project.SetGroupVersionKind(projectControlPlaneGVK) + } else { + project.SetGroupVersionKind(projectGVK) + } + + var forOpts []builder.ForOption + if opts.LabelSelector != nil { + selector, err := metav1.LabelSelectorAsSelector(opts.LabelSelector) + if err != nil { + return nil, fmt.Errorf("failed to create selector from label selector: %w", err) + } + + labelPredicate := predicate.NewPredicateFuncs(func(obj client.Object) bool { + return selector.Matches(labels.Set(obj.GetLabels())) + }) + + forOpts = append(forOpts, builder.WithPredicates(labelPredicate)) + } + + controllerBuilder := builder.ControllerManagedBy(localMgr). + For(&project, forOpts...). + WithOptions(controller.Options{MaxConcurrentReconciles: 1}). + Named("projectcontrolplane") + + if err := controllerBuilder.Complete(p); err != nil { + return nil, fmt.Errorf("failed to create controller: %w", err) + } + + return p, nil +} + +type index struct { + object client.Object + field string + extractValue client.IndexerFunc +} + +// Provider is a cluster Provider that works with Datum +type Provider struct { + opts Options + log logr.Logger + projectRestConfig *rest.Config + client client.Client + + lock sync.Mutex + mcMgr mcmanager.Manager + projects map[string]cluster.Cluster + cancelFns map[string]context.CancelFunc + indexers []index +} + +// Get returns the cluster with the given name, if it is known. +func (p *Provider) Get(_ context.Context, clusterName multicluster.ClusterName) (cluster.Cluster, error) { + p.lock.Lock() + defer p.lock.Unlock() + if cl, ok := p.projects[clusterName.String()]; ok { + return cl, nil + } + + return nil, fmt.Errorf("cluster %s not found", clusterName) +} + +// Run starts the provider and blocks. +func (p *Provider) Run(ctx context.Context, mgr mcmanager.Manager) error { + p.log.Info("Starting Datum cluster provider") + + p.lock.Lock() + p.mcMgr = mgr + p.lock.Unlock() + + <-ctx.Done() + + return ctx.Err() +} + +func (p *Provider) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := p.log.WithValues("project", req.Name) + log.Info("Reconciling Project") + + // Use just the project name as the key for cluster lookup. + // This matches the project name used in URL paths and ParentNameExtraKey. + key := req.Name + var project unstructured.Unstructured + + if p.opts.InternalServiceDiscovery { + project.SetGroupVersionKind(projectControlPlaneGVK) + } else { + project.SetGroupVersionKind(projectGVK) + } + + if err := p.client.Get(ctx, req.NamespacedName, &project); err != nil { + if apierrors.IsNotFound(err) { + log.Info("Project not found, removing cluster if registered", "key", key) + p.lock.Lock() + defer p.lock.Unlock() + + if _, wasRegistered := p.projects[key]; wasRegistered { + log.Info("Removing previously registered cluster for project", "key", key) + } + delete(p.projects, key) + if cancel, ok := p.cancelFns[key]; ok { + cancel() + } + + return ctrl.Result{}, nil + } + + log.Error(err, "Failed to get project, will retry", "key", key) + return ctrl.Result{}, fmt.Errorf("failed to get project: %w", err) + } + + log.V(1).Info("Successfully fetched project", "name", project.GetName(), "namespace", project.GetNamespace()) + + p.lock.Lock() + defer p.lock.Unlock() + + // Make sure the manager has started + // TODO(jreese) what condition would lead to this? + if p.mcMgr == nil { + log.Info("Multicluster manager not yet started, requeueing", "key", key) + return ctrl.Result{RequeueAfter: time.Second * 2}, nil + } + + // already engaged? + if _, ok := p.projects[key]; ok { + log.V(1).Info("Project already engaged, skipping", "key", key) + return ctrl.Result{}, nil + } + + log.Info("Project not yet engaged, checking readiness", "key", key) + + // ready and provisioned? + conditions, err := extractUnstructuredConditions(project.Object) + if err != nil { + log.Error(err, "Failed to extract conditions from project", "key", key) + return ctrl.Result{}, err + } + + log.V(1).Info("Checking project readiness conditions", "key", key, "conditionCount", len(conditions)) + + if p.opts.InternalServiceDiscovery { + if !apimeta.IsStatusConditionTrue(conditions, "ControlPlaneReady") { + log.Info("ProjectControlPlane is not ready, skipping registration", "key", key, "conditions", conditions) + return ctrl.Result{}, nil + } + } else { + if !apimeta.IsStatusConditionTrue(conditions, "Ready") { + log.Info("Project is not ready, skipping registration", "key", key, "conditions", conditions) + return ctrl.Result{}, nil + } + } + + log.Info("Project is ready, proceeding with cluster registration", "key", key) + + cfg := rest.CopyConfig(p.projectRestConfig) + apiHost, err := url.Parse(cfg.Host) + if err != nil { + log.Error(err, "Failed to parse API host from rest config", "key", key, "host", cfg.Host) + return ctrl.Result{}, fmt.Errorf("failed to parse host from rest config: %w", err) + } + + if p.opts.InternalServiceDiscovery { + apiHost.Path = "" + apiHost.Host = fmt.Sprintf("milo-apiserver.project-%s.svc.cluster.local:6443", project.GetName()) + } else { + apiHost.Path = fmt.Sprintf("/apis/resourcemanager.miloapis.com/v1alpha1/projects/%s/control-plane", project.GetName()) + } + cfg.Host = apiHost.String() + + log.Info("Creating cluster connection", "key", key, "endpoint", cfg.Host) + + // create cluster. + cl, err := cluster.New(cfg, p.opts.ClusterOptions...) + if err != nil { + log.Error(err, "Failed to create cluster object", "key", key, "endpoint", cfg.Host) + return ctrl.Result{}, fmt.Errorf("failed to create cluster: %w", err) + } + for _, idx := range p.indexers { + if err := cl.GetCache().IndexField(ctx, idx.object, idx.field, idx.extractValue); err != nil { + log.Error(err, "Failed to setup cache index field", "key", key, "field", idx.field) + return ctrl.Result{}, fmt.Errorf("failed to index field %q: %w", idx.field, err) + } + } + + log.Info("Starting cluster cache", "key", key) + + clusterCtx, cancel := context.WithCancel(ctx) + go func() { + if err := cl.Start(clusterCtx); err != nil { + log.Error(err, "Cluster cache start failed", "key", key) + return + } + }() + + log.Info("Waiting for cluster cache to sync", "key", key) + + if !cl.GetCache().WaitForCacheSync(ctx) { + cancel() + log.Error(nil, "Cluster cache sync failed", "key", key) + return ctrl.Result{}, fmt.Errorf("failed to sync cache") + } + + log.Info("Cluster cache synced successfully", "key", key) + + // store project client + p.projects[key] = cl + p.cancelFns[key] = cancel + + log.Info("Engaging cluster with multicluster manager", "key", key) + + // engage manager. + if err := p.mcMgr.Engage(clusterCtx, multicluster.ClusterName(key), cl); err != nil { + log.Error(err, "Failed to engage cluster with multicluster manager", "key", key) + delete(p.projects, key) + delete(p.cancelFns, key) + return reconcile.Result{}, err + } + + log.Info("Successfully registered and engaged new cluster", "key", key, "endpoint", cfg.Host) + + return ctrl.Result{}, nil +} + +func (p *Provider) IndexField(ctx context.Context, obj client.Object, field string, extractValue client.IndexerFunc) error { + p.lock.Lock() + defer p.lock.Unlock() + + // save for future projects. + p.indexers = append(p.indexers, index{ + object: obj, + field: field, + extractValue: extractValue, + }) + + // apply to existing projects. + for name, cl := range p.projects { + if err := cl.GetCache().IndexField(ctx, obj, field, extractValue); err != nil { + return fmt.Errorf("failed to index field %q on project %q: %w", field, name, err) + } + } + return nil +} + +func extractUnstructuredConditions( + obj map[string]interface{}, +) ([]metav1.Condition, error) { + conditions, ok, _ := unstructured.NestedSlice(obj, "status", "conditions") + if !ok { + return nil, nil + } + + wrappedConditions := map[string]interface{}{ + "conditions": conditions, + } + + var typedConditions struct { + Conditions []metav1.Condition `json:"conditions"` + } + + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(wrappedConditions, &typedConditions); err != nil { + return nil, fmt.Errorf("failed converting unstructured conditions: %w", err) + } + + return typedConditions.Conditions, nil +} From 3d28d08daa83ab821e5a0ab24fa1e1bb90d234d5 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 17:35:29 -0500 Subject: [PATCH 05/27] fix: resolve golangci-lint issues surfaced by v2.12.2 upgrade Addresses 63 lint findings across errcheck, goconst, gocyclo, gofmt, prealloc, staticcheck, and unparam linters: - gofmt/goimports: reformat cmd/main.go, deploy.go, util/client.go, webhook - errcheck: assign discarded fmt.Fprint* and Flush returns to _ - staticcheck: update webhook to generic admission.Defaulter[T]/Validator[T] with WithDefaulter/WithValidator; fix SA4010 unused append in quota.go; remove redundant .ObjectMeta selectors in restart.go - unparam: rename four never-used function parameters to _ - gocyclo: extract helpers from watch.Rollout and quota.runQuota to reduce cyclomatic complexity below threshold - goconst: extract repeated string literals to named constants across controllers, validation, and tests - prealloc: preallocate slices with known capacity in validation and tests Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/compute/deploy/deploy.go | 11 +- internal/cmd/compute/destroy/destroy.go | 4 +- internal/cmd/compute/instances/instances.go | 8 +- internal/cmd/compute/quota/quota.go | 42 +--- internal/cmd/compute/restart/restart.go | 12 +- internal/cmd/compute/status/status.go | 2 +- internal/cmd/compute/util/client.go | 2 +- internal/cmd/compute/watch/watch.go | 216 +++++++++++------- internal/controller/instance_controller.go | 11 +- .../stateful/stateful_control_test.go | 6 +- internal/controller/workload_controller.go | 9 +- internal/validation/instance_validation.go | 6 +- .../validation/workload_validation_test.go | 4 +- internal/webhook/v1alpha/workload_webhook.go | 52 ----- 14 files changed, 181 insertions(+), 204 deletions(-) diff --git a/internal/cmd/compute/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go index 64305479..bd6f5edb 100644 --- a/internal/cmd/compute/deploy/deploy.go +++ b/internal/cmd/compute/deploy/deploy.go @@ -12,19 +12,19 @@ import ( "time" "github.com/spf13/cobra" + "golang.org/x/term" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" utilyaml "k8s.io/apimachinery/pkg/util/yaml" - "golang.org/x/term" sigsyaml "sigs.k8s.io/yaml" - networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" computev1alpha "go.datum.net/compute/api/v1alpha" "go.datum.net/compute/internal/cmd/compute/revision" "go.datum.net/compute/internal/cmd/compute/util" "go.datum.net/compute/internal/cmd/compute/watch" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" ) type options struct { @@ -47,7 +47,7 @@ func Command() *cobra.Command { If no arguments are given, an interactive prompt guides you through the deployment. Use -f to apply a workload manifest file instead of flags.`, - Args: cobra.MaximumNArgs(1), + Args: cobra.MaximumNArgs(1), Example: ` # Deploy with flags datumctl compute deploy api --image=ghcr.io/acme/api:1.4.2 --city=DFW,IAD --min=2 --port=8080 @@ -349,7 +349,7 @@ func deployFromFile(cmd *cobra.Command, opts *options) error { // saveWorkloadYAML marshals the workload and writes it to workload.yaml in the // current directory. -func saveWorkloadYAML(workloadName string, workload *computev1alpha.Workload) error { +func saveWorkloadYAML(_ string, workload *computev1alpha.Workload) error { workload.TypeMeta = metav1.TypeMeta{ APIVersion: "compute.datumapis.com/v1alpha", Kind: "Workload", @@ -376,7 +376,7 @@ func imageFromWorkload(w computev1alpha.Workload) string { } // computeDiff produces a human-readable one-line diff description for flag-driven updates. -func computeDiff(existing computev1alpha.WorkloadSpec, newImage string, cities []string, min int32) string { +func computeDiff(existing computev1alpha.WorkloadSpec, newImage string, _ []string, min int32) string { var parts []string oldImage := "" @@ -439,4 +439,3 @@ func manifestDiff(existing, desired computev1alpha.Workload) []string { return lines } - diff --git a/internal/cmd/compute/destroy/destroy.go b/internal/cmd/compute/destroy/destroy.go index 0695f1fd..b93f95c6 100644 --- a/internal/cmd/compute/destroy/destroy.go +++ b/internal/cmd/compute/destroy/destroy.go @@ -72,14 +72,14 @@ func runDestroy(cmd *cobra.Command, args []string, yes bool) error { // Prompt unless --yes or non-interactive. if !yes && term.IsTerminal(int(os.Stdin.Fd())) { - fmt.Fprint(out, "This will delete workload and all its instances. Continue? (y/N): ") + _, _ = fmt.Fprint(out, "This will delete workload and all its instances. Continue? (y/N): ") line, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil { return fmt.Errorf("reading confirmation: %w", err) } line = strings.TrimSpace(line) if line != "y" && line != "Y" { - fmt.Fprintln(out, "Aborted.") + _, _ = fmt.Fprintln(out, "Aborted.") return nil } } diff --git a/internal/cmd/compute/instances/instances.go b/internal/cmd/compute/instances/instances.go index f8897e34..dc168e6e 100644 --- a/internal/cmd/compute/instances/instances.go +++ b/internal/cmd/compute/instances/instances.go @@ -184,12 +184,12 @@ func runList(cmd *cobra.Command, opts *listOptions) error { out := cmd.OutOrStdout() tw := util.NewTabWriter(out) - fmt.Fprintf(tw, "NAME\tWORKLOAD\tCITY\tEXTERNAL IP\tINTERNAL IP\tTYPE\tAGE\tSTATUS\n") + _, _ = fmt.Fprintf(tw, "NAME\tWORKLOAD\tCITY\tEXTERNAL IP\tINTERNAL IP\tTYPE\tAGE\tSTATUS\n") for _, r := range rows { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.name, r.workload, r.city, r.externalIP, r.internalIP, r.instType, r.age, r.status) } - tw.Flush() + _ = tw.Flush() running := 0 for _, r := range rows { @@ -198,7 +198,7 @@ func runList(cmd *cobra.Command, opts *listOptions) error { } } pending := len(rows) - running - fmt.Fprintf(out, "\n%d instances — %d Running, %d Pending, 0 Failed\n", len(rows), running, pending) + _, _ = fmt.Fprintf(out, "\n%d instances — %d Running, %d Pending, 0 Failed\n", len(rows), running, pending) return nil } diff --git a/internal/cmd/compute/quota/quota.go b/internal/cmd/compute/quota/quota.go index a0b05525..ad08ac48 100644 --- a/internal/cmd/compute/quota/quota.go +++ b/internal/cmd/compute/quota/quota.go @@ -8,7 +8,6 @@ import ( "strconv" "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/labels" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -71,13 +70,6 @@ func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { deployByUID[string(d.UID)] = d } - // Also list workloads to resolve instance type when we can't get it from an instance directly. - // Instance type comes from the deployment's template spec. - deployInstType := make(map[string]string, len(deployList.Items)) - for _, d := range deployList.Items { - deployInstType[string(d.UID)] = d.Spec.Template.Spec.Runtime.Resources.InstanceType - } - type groupData struct { count int atLimit bool @@ -148,7 +140,7 @@ func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { // Before filtering by constrained, check if there are any instances at all. if len(instList.Items) == 0 { - fmt.Fprint(out, "No instances running. No quota consumption to display.\n") + _, _ = fmt.Fprint(out, "No instances running. No quota consumption to display.\n") return nil } @@ -161,7 +153,7 @@ func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { } } if len(filtered) == 0 { - fmt.Fprint(out, "No constrained resources found.\n") + _, _ = fmt.Fprint(out, "No constrained resources found.\n") return nil } keys = filtered @@ -192,38 +184,12 @@ func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n", cityLabel, k.instanceType, gd.count, limit, available) } - tw.Flush() - - // Also show deployments with zero instances (quota exhausted before first instance). - // Walk deployments not represented in any group and show them with count=0, atLimit. - var zeroKeys []groupKey - depGroupSeen := make(map[groupKey]bool) - for _, k := range keys { - depGroupSeen[k] = true - } - for _, dep := range deployList.Items { - // Build the same label selector to check for instances. - depSelector := labels.SelectorFromSet(labels.Set{ - computev1alpha.WorkloadDeploymentUIDLabel: string(dep.UID), - }) - _ = depSelector // just need city+type combo - k := groupKey{ - city: dep.Spec.CityCode, - instanceType: dep.Spec.Template.Spec.Runtime.Resources.InstanceType, - } - if k.instanceType == "" { - k.instanceType = "unknown" - } - if !depGroupSeen[k] { - zeroKeys = append(zeroKeys, k) - depGroupSeen[k] = true - } - } + _ = tw.Flush() // Sort and print zero-instance groups (no quota consumed, nothing to show for "constrained"). // Per spec these are not interesting for the quota view, so we skip them. - fmt.Fprint(out, "\nNote: limit information is derived from quota conditions on instances.\nRun 'datumctl quota' for full project quota management.\n") + _, _ = fmt.Fprint(out, "\nNote: limit information is derived from quota conditions on instances.\nRun 'datumctl quota' for full project quota management.\n") return nil } diff --git a/internal/cmd/compute/restart/restart.go b/internal/cmd/compute/restart/restart.go index b11f3d51..7a2771ea 100644 --- a/internal/cmd/compute/restart/restart.go +++ b/internal/cmd/compute/restart/restart.go @@ -56,10 +56,10 @@ func runRestart(cmd *cobra.Command, args []string, city string) error { if city == "" { // Restart all placements by annotating the workload template. - if workload.Spec.Template.ObjectMeta.Annotations == nil { - workload.Spec.Template.ObjectMeta.Annotations = make(map[string]string) + if workload.Spec.Template.Annotations == nil { + workload.Spec.Template.Annotations = make(map[string]string) } - workload.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = restartedAt + workload.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = restartedAt if err := c.Update(ctx, &workload); err != nil { return fmt.Errorf("updating workload: %w", err) @@ -96,10 +96,10 @@ func runRestart(cmd *cobra.Command, args []string, city string) error { } for i := range matched { - if matched[i].Spec.Template.ObjectMeta.Annotations == nil { - matched[i].Spec.Template.ObjectMeta.Annotations = make(map[string]string) + if matched[i].Spec.Template.Annotations == nil { + matched[i].Spec.Template.Annotations = make(map[string]string) } - matched[i].Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = restartedAt + matched[i].Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = restartedAt if err := c.Update(ctx, &matched[i]); err != nil { return fmt.Errorf("updating deployment in %s: %w", city, err) diff --git a/internal/cmd/compute/status/status.go b/internal/cmd/compute/status/status.go index 5922785d..02e1dcd2 100644 --- a/internal/cmd/compute/status/status.go +++ b/internal/cmd/compute/status/status.go @@ -152,7 +152,7 @@ func runStatus(cmd *cobra.Command, args []string) error { } } } - tw.Flush() + _ = tw.Flush() if len(degraded) == 0 { return nil diff --git a/internal/cmd/compute/util/client.go b/internal/cmd/compute/util/client.go index 01031ed7..c41dc732 100644 --- a/internal/cmd/compute/util/client.go +++ b/internal/cmd/compute/util/client.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/spf13/cobra" - "go.datum.net/datumctl/plugin" computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/datumctl/plugin" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" diff --git a/internal/cmd/compute/watch/watch.go b/internal/cmd/compute/watch/watch.go index b4e62d68..804fcb79 100644 --- a/internal/cmd/compute/watch/watch.go +++ b/internal/cmd/compute/watch/watch.go @@ -46,7 +46,6 @@ func Rollout(ctx context.Context, c client.Client, out io.Writer, project string tw := util.NewTabWriter(out) headerPrinted := false - states := map[string]*deploymentState{} ticker := time.NewTicker(2 * time.Second) @@ -55,8 +54,8 @@ func Rollout(ctx context.Context, c client.Client, out io.Writer, project string for { select { case <-ctx.Done(): - tw.Flush() - fmt.Fprintln(out, "Detached. Rollout continues in background.") + _ = tw.Flush() + _, _ = fmt.Fprintln(out, "Detached. Rollout continues in background.") return nil case <-ticker.C: @@ -77,95 +76,148 @@ func Rollout(ctx context.Context, c client.Client, out io.Writer, project string } if !headerPrinted { - fmt.Fprintln(out, "\n PLACEMENT\tCITY\tUPDATED\tREADY\tOLD\tPHASE") + _, _ = fmt.Fprintln(out, "\n PLACEMENT\tCITY\tUPDATED\tREADY\tOLD\tPHASE") headerPrinted = true } - allDone := true - for _, d := range deployList.Items { - key := d.Spec.CityCode - prev, exists := states[key] - - desired := d.Status.DesiredReplicas - ready := d.Status.ReadyReplicas - current := d.Status.CurrentReplicas - - newPhase := computePhase(desired, ready, current, prev) - - if !exists || prev.desired != desired || prev.ready != ready || prev.current != current || prev.phase != newPhase { - st := &deploymentState{ - placement: d.Spec.PlacementName, - city: d.Spec.CityCode, - desired: desired, - ready: ready, - current: current, - phase: newPhase, - } - if exists { - st.stalledSince = prev.stalledSince - } - - // Track when we first noticed a potential stall. - if newPhase != phaseDone && newPhase != phasePending { - if !exists || prev.phase == phasePending { - st.stalledSince = time.Now() - } else if exists && prev.ready == ready && prev.current == current { - st.stalledSince = prev.stalledSince - } else { - st.stalledSince = time.Now() - } - } - - // Promote to Blocked if stalled > 30s without progress. - if newPhase == phaseUpdating && !st.stalledSince.IsZero() && time.Since(st.stalledSince) > 30*time.Second { - st.phase = phaseBlocked - newPhase = phaseBlocked - } - - states[key] = st - - // Compute old replicas = total created minus current-template replicas. - old := d.Status.Replicas - d.Status.CurrentReplicas - if old < 0 { - old = 0 - } - - fmt.Fprintf(tw, " %s\t%s\t%d\t%d\t%d\t%s\n", - d.Spec.PlacementName, - d.Spec.CityCode, - current, - ready, - old, - string(newPhase), - ) - tw.Flush() - - if newPhase == phaseBlocked { - printBlockedDetail(ctx, c, out, project, d) - } - } - - if newPhase != phaseDone { - allDone = false - } - } + allDone := processDeployments(ctx, c, out, project, tw, states, deployList.Items) if allDone && len(deployList.Items) > 0 { - elapsed := time.Since(start).Round(time.Second) - minutes := int(elapsed.Minutes()) - seconds := int(elapsed.Seconds()) % 60 - if minutes > 0 { - fmt.Fprintf(out, "Rollout complete in %dm %ds.\n", minutes, seconds) - } else { - fmt.Fprintf(out, "Rollout complete in %ds.\n", seconds) - } + printElapsed(out, time.Since(start).Round(time.Second)) return nil } } } } -func computePhase(desired, ready, current int32, prev *deploymentState) deploymentPhase { +// tabFlusher is a writer that can also be flushed (e.g. tabwriter.Writer). +type tabFlusher interface { + io.Writer + Flush() error +} + +// processDeployments updates state for each deployment, prints changed rows, +// and returns true when every deployment has reached the Done phase. +func processDeployments( + ctx context.Context, + c client.Client, + out io.Writer, + project string, + tw tabFlusher, + states map[string]*deploymentState, + deployments []computev1alpha.WorkloadDeployment, +) bool { + allDone := true + for _, d := range deployments { + key := d.Spec.CityCode + prev, exists := states[key] + + desired := d.Status.DesiredReplicas + ready := d.Status.ReadyReplicas + current := d.Status.CurrentReplicas + + newPhase := computePhase(desired, ready, current, prev) + + if !exists || prev.desired != desired || prev.ready != ready || prev.current != current || prev.phase != newPhase { + newPhase = updateDeploymentState(states, key, d, exists, prev, desired, ready, current, newPhase) + printDeploymentRow(ctx, c, out, project, tw, d, current, ready, newPhase) + } + + if newPhase != phaseDone { + allDone = false + } + } + return allDone +} + +// updateDeploymentState updates the states map for a deployment and returns the +// (possibly promoted) phase. +func updateDeploymentState( + states map[string]*deploymentState, + key string, + d computev1alpha.WorkloadDeployment, + exists bool, + prev *deploymentState, + desired, ready, current int32, + newPhase deploymentPhase, +) deploymentPhase { + st := &deploymentState{ + placement: d.Spec.PlacementName, + city: d.Spec.CityCode, + desired: desired, + ready: ready, + current: current, + phase: newPhase, + } + if exists { + st.stalledSince = prev.stalledSince + } + + // Track when we first noticed a potential stall. + if newPhase != phaseDone && newPhase != phasePending { + if !exists || prev.phase == phasePending { + st.stalledSince = time.Now() + } else if prev.ready == ready && prev.current == current { + st.stalledSince = prev.stalledSince + } else { + st.stalledSince = time.Now() + } + } + + // Promote to Blocked if stalled > 30s without progress. + if newPhase == phaseUpdating && !st.stalledSince.IsZero() && time.Since(st.stalledSince) > 30*time.Second { + st.phase = phaseBlocked + newPhase = phaseBlocked + } + + states[key] = st + return newPhase +} + +// printDeploymentRow writes a progress row and, if blocked, detail about the +// first non-ready instance. +func printDeploymentRow( + ctx context.Context, + c client.Client, + out io.Writer, + project string, + tw tabFlusher, + d computev1alpha.WorkloadDeployment, + current, ready int32, + newPhase deploymentPhase, +) { + old := d.Status.Replicas - d.Status.CurrentReplicas + if old < 0 { + old = 0 + } + + _, _ = fmt.Fprintf(tw, " %s\t%s\t%d\t%d\t%d\t%s\n", + d.Spec.PlacementName, + d.Spec.CityCode, + current, + ready, + old, + string(newPhase), + ) + _ = tw.Flush() + + if newPhase == phaseBlocked { + printBlockedDetail(ctx, c, out, project, d) + } +} + +// printElapsed writes the total rollout duration to out. +func printElapsed(out io.Writer, elapsed time.Duration) { + minutes := int(elapsed.Minutes()) + seconds := int(elapsed.Seconds()) % 60 + if minutes > 0 { + _, _ = fmt.Fprintf(out, "Rollout complete in %dm %ds.\n", minutes, seconds) + } else { + _, _ = fmt.Fprintf(out, "Rollout complete in %ds.\n", seconds) + } +} + +func computePhase(desired, ready, current int32, _ *deploymentState) deploymentPhase { if desired == 0 { return phaseDone } @@ -180,7 +232,7 @@ func computePhase(desired, ready, current int32, prev *deploymentState) deployme // printBlockedDetail fetches instances for the deployment and prints a reason // for the first non-ready instance. -func printBlockedDetail(ctx context.Context, c client.Client, out io.Writer, project string, d computev1alpha.WorkloadDeployment) { +func printBlockedDetail(ctx context.Context, c client.Client, out io.Writer, _ string, d computev1alpha.WorkloadDeployment) { selector := labels.SelectorFromSet(labels.Set{ computev1alpha.WorkloadDeploymentUIDLabel: string(d.UID), }) diff --git a/internal/controller/instance_controller.go b/internal/controller/instance_controller.go index ca1b2609..5b6e2f77 100644 --- a/internal/controller/instance_controller.go +++ b/internal/controller/instance_controller.go @@ -81,6 +81,15 @@ const ( reasonNetworkFailedToCreate = "NetworkFailedToCreate" ) +const ( + instanceAPIGroup = "compute.datumapis.com" + instanceKind = "Instance" + + instanceNotProgrammedMessage = "Instance has not been programmed" + instanceNetworkFailedReason = "NetworkFailedToCreate" + instanceReadyMessage = "Instance is ready" +) + // clusterGetter is the subset of mcmanager.Manager used by InstanceReconciler. // Keeping it narrow allows unit tests to substitute a minimal fake. type clusterGetter interface { @@ -891,7 +900,7 @@ func (r *InstanceReconciler) checkForNetworkCreationFailure(ctx context.Context, } condition := apimeta.FindStatusCondition(networkBinding.Status.Conditions, networkingv1alpha.NetworkBindingReady) - if condition != nil && condition.Status == metav1.ConditionFalse && condition.Reason == "NetworkFailedToCreate" { + if condition != nil && condition.Status == metav1.ConditionFalse && condition.Reason == instanceNetworkFailedReason { return true, condition.Message, nil } } diff --git a/internal/controller/instancecontrol/stateful/stateful_control_test.go b/internal/controller/instancecontrol/stateful/stateful_control_test.go index 3a97bbc1..5732ee3c 100644 --- a/internal/controller/instancecontrol/stateful/stateful_control_test.go +++ b/internal/controller/instancecontrol/stateful/stateful_control_test.go @@ -53,7 +53,7 @@ func TestUpdateWithAllReadyInstances(t *testing.T) { deployment := getWorkloadDeployment("test-deploy", 2) - var currentInstances []v1alpha.Instance + currentInstances := make([]v1alpha.Instance, 0, 2) currentInstances = append(currentInstances, *getInstanceForDeployment(deployment, 0)) currentInstances = append(currentInstances, *getInstanceForDeployment(deployment, 1)) @@ -79,7 +79,7 @@ func TestScaleUpWithNotReadyInstance(t *testing.T) { deployment := getWorkloadDeployment("test-deploy", 3) - var currentInstances []v1alpha.Instance + currentInstances := make([]v1alpha.Instance, 0, 2) currentInstances = append(currentInstances, *getInstanceForDeployment(deployment, 0)) notReadyInstance := getInstanceForDeployment(deployment, 1) @@ -109,7 +109,7 @@ func TestScaleUpWithDeletingReadyInstance(t *testing.T) { deployment := getWorkloadDeployment("test-deploy", 3) - var currentInstances []v1alpha.Instance + currentInstances := make([]v1alpha.Instance, 0, 2) currentInstances = append(currentInstances, *getInstanceForDeployment(deployment, 0)) deletingInstance := getInstanceForDeployment(deployment, 1) diff --git a/internal/controller/workload_controller.go b/internal/controller/workload_controller.go index 6ca92e03..cad13c00 100644 --- a/internal/controller/workload_controller.go +++ b/internal/controller/workload_controller.go @@ -38,6 +38,9 @@ const ( workloadConditionTypeAvailable = "Available" ) +// conditionAvailable is the condition type used to indicate resource availability. +const conditionAvailable = "Available" + // WorkloadReconciler reconciles a Workload object type WorkloadReconciler struct { mgr mcmanager.Manager @@ -242,7 +245,7 @@ func (r *WorkloadReconciler) reconcileWorkloadStatus( } placementAvailableCondition := metav1.Condition{ - Type: "Available", + Type: conditionAvailable, Status: metav1.ConditionFalse, Reason: "NoAvailableDeployments", Message: "No available deployments were found for the placement", @@ -260,7 +263,7 @@ func (r *WorkloadReconciler) reconcileWorkloadStatus( desiredReplicas += deployment.Status.DesiredReplicas readyReplicas += deployment.Status.ReadyReplicas - if apimeta.IsStatusConditionTrue(deployment.Status.Conditions, "Available") { + if apimeta.IsStatusConditionTrue(deployment.Status.Conditions, conditionAvailable) { foundAvailableDeployment = true } } @@ -287,7 +290,7 @@ func (r *WorkloadReconciler) reconcileWorkloadStatus( } availableCondition := metav1.Condition{ - Type: "Available", + Type: conditionAvailable, Status: metav1.ConditionFalse, Reason: "NoAvailablePlacements", Message: "No available placements were found for the workload", diff --git a/internal/validation/instance_validation.go b/internal/validation/instance_validation.go index faa5ba0b..9d7b5803 100644 --- a/internal/validation/instance_validation.go +++ b/internal/validation/instance_validation.go @@ -35,7 +35,7 @@ func validateInstanceTemplate( fieldPath *field.Path, opts WorkloadValidationOptions, ) field.ErrorList { - allErrs := field.ErrorList{} + allErrs := make(field.ErrorList, 0, 2) allErrs = append(allErrs, validateInstanceTemplateMetadata(template, fieldPath)...) allErrs = append(allErrs, validateInstanceSpec(template.Spec, fieldPath.Child("spec"), opts)...) @@ -79,7 +79,7 @@ func validateInstanceSpec( fieldPath *field.Path, opts WorkloadValidationOptions, ) field.ErrorList { - allErrs := field.ErrorList{} + allErrs := make(field.ErrorList, 0, 3) volumes, volumeErrs := validateVolumes(spec, fieldPath) allErrs = append(allErrs, volumeErrs...) @@ -591,7 +591,7 @@ func validateVolumeAttachments( volumes map[string]computev1alpha.VolumeSource, fieldPath *field.Path, ) field.ErrorList { - allErrs := field.ErrorList{} + allErrs := make(field.ErrorList, 0, len(attachments)) allMounthPaths := sets.Set[string]{} diff --git a/internal/validation/workload_validation_test.go b/internal/validation/workload_validation_test.go index b4e70df7..7b785683 100644 --- a/internal/validation/workload_validation_test.go +++ b/internal/validation/workload_validation_test.go @@ -648,7 +648,7 @@ func MakeSandboxWorkload(name string, tweaks ...Tweak) *computev1alpha.Workload NetworkInterfaces: []computev1alpha.InstanceNetworkInterface{ { Network: networkingv1alpha.NetworkRef{ - Name: "default", + Name: testDefaultNamespace, }, }, }, @@ -705,7 +705,7 @@ func MakeVMWorkload(name string, tweaks ...Tweak) *computev1alpha.Workload { NetworkInterfaces: []computev1alpha.InstanceNetworkInterface{ { Network: networkingv1alpha.NetworkRef{ - Name: "default", + Name: testDefaultNamespace, }, }, }, diff --git a/internal/webhook/v1alpha/workload_webhook.go b/internal/webhook/v1alpha/workload_webhook.go index a8b94b38..c8d43626 100644 --- a/internal/webhook/v1alpha/workload_webhook.go +++ b/internal/webhook/v1alpha/workload_webhook.go @@ -2,7 +2,6 @@ package webhook import ( "context" - "fmt" "k8s.io/apimachinery/pkg/api/errors" @@ -61,57 +60,6 @@ func (r *workloadWebhook) Default(_ context.Context, _ *computev1alpha.Workload) // } // } // } - - // TODO(user): fill in your defaulting logic. - return nil -} - -// +kubebuilder:webhook:path=/validate-compute-datumapis-com-v1alpha-workload,mutating=false,failurePolicy=fail,sideEffects=None,groups=compute.datumapis.com,resources=workloads,verbs=create;update,versions=v1alpha,name=vworkload.kb.io,admissionReviewVersions=v1 - -func (r *workloadWebhook) ValidateCreate(ctx context.Context, workload *computev1alpha.Workload) (admission.Warnings, error) { - clusterName := computewebhook.ClusterNameFromContext(ctx) - - cluster, err := r.mgr.GetCluster(ctx, multicluster.ClusterName(clusterName)) - if err != nil { - return nil, err - } - clusterClient := cluster.GetClient() - - logger := logf.FromContext(ctx).WithValues("cluster", clusterName) - logger.Info("Validating Workload Create", "name", workload.GetName(), "cluster", clusterName) - - req, err := admission.RequestFromContext(ctx) - if err != nil { - return nil, err - } - - // TODO(jreese) validate caller access to individual locations, consider what - // that means for the scheduling phase, since there would not currently be - // sufficient context to know who created the workload and what locations - // are valid candidates based on that. Maybe an annotation, or spec field? - var locations networkingv1alpha.LocationBindingList - if err := clusterClient.List(ctx, &locations); err != nil { - return nil, fmt.Errorf("failed to list location bindings: %w", err) - } - - validCityCodes := sets.Set[string]{} - for _, location := range locations.Items { - cityCode, ok := location.Spec.Topology["topology.datum.net/city-code"] - if ok { - validCityCodes.Insert(cityCode) - } - } - - opts := validation.WorkloadValidationOptions{ - Context: ctx, - Client: clusterClient, - AdmissionRequest: req, - Workload: workload, - ValidCityCodes: sets.List(validCityCodes), - } - - if errs := validation.ValidateWorkloadCreate(workload, opts); len(errs) > 0 { - return nil, errors.NewInvalid(workload.GroupVersionKind().GroupKind(), workload.Name, errs) } return nil, nil From 3590106a2e3037d60c5b2ad3b38464301c6c4059 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 17:41:37 -0500 Subject: [PATCH 06/27] fix: address remaining lint issues missed in previous pass - errcheck: fix unchecked fmt.Fprint* returns in deploy, quota, rollout, scale - prealloc: preallocate allErrs in workload_validation.go and stateful test - gofmt: reformat destroy.go, instances.go, rollout.go Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/compute/deploy/deploy.go | 8 ++++---- internal/cmd/compute/destroy/destroy.go | 4 ++-- internal/cmd/compute/instances/instances.go | 2 +- internal/cmd/compute/quota/quota.go | 6 +++--- internal/cmd/compute/rollout/rollout.go | 16 ++++++++-------- internal/cmd/compute/scale/scale.go | 2 +- .../stateful/stateful_control_test.go | 2 +- internal/validation/instance_validation.go | 2 +- internal/validation/workload_validation.go | 4 ++-- 9 files changed, 23 insertions(+), 23 deletions(-) diff --git a/internal/cmd/compute/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go index bd6f5edb..c7222a79 100644 --- a/internal/cmd/compute/deploy/deploy.go +++ b/internal/cmd/compute/deploy/deploy.go @@ -177,14 +177,14 @@ func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) err // Prompt unless --yes or non-interactive. if !opts.yes && term.IsTerminal(int(os.Stdin.Fd())) { - fmt.Fprint(out, "Apply? (Y/n): ") + _, _ = fmt.Fprint(out, "Apply? (Y/n): ") line, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil { return fmt.Errorf("reading confirmation: %w", err) } line = strings.TrimSpace(line) if line == "n" || line == "N" { - fmt.Fprintln(out, "Aborted.") + _, _ = fmt.Fprintln(out, "Aborted.") return nil } } @@ -290,14 +290,14 @@ func deployFromFile(cmd *cobra.Command, opts *options) error { // Prompt unless --yes or non-interactive. if !opts.yes && term.IsTerminal(int(os.Stdin.Fd())) { - fmt.Fprint(out, "Apply? (Y/n): ") + _, _ = fmt.Fprint(out, "Apply? (Y/n): ") line, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil { return fmt.Errorf("reading confirmation: %w", err) } line = strings.TrimSpace(line) if line == "n" || line == "N" { - fmt.Fprintln(out, "Aborted.") + _, _ = fmt.Fprintln(out, "Aborted.") return nil } } diff --git a/internal/cmd/compute/destroy/destroy.go b/internal/cmd/compute/destroy/destroy.go index b93f95c6..a5f90992 100644 --- a/internal/cmd/compute/destroy/destroy.go +++ b/internal/cmd/compute/destroy/destroy.go @@ -8,14 +8,14 @@ import ( "strings" "github.com/spf13/cobra" + "golang.org/x/term" k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" - "golang.org/x/term" - corev1 "k8s.io/api/core/v1" computev1alpha "go.datum.net/compute/api/v1alpha" "go.datum.net/compute/internal/cmd/compute/revision" "go.datum.net/compute/internal/cmd/compute/util" + corev1 "k8s.io/api/core/v1" ) func Command() *cobra.Command { diff --git a/internal/cmd/compute/instances/instances.go b/internal/cmd/compute/instances/instances.go index dc168e6e..8a4ee811 100644 --- a/internal/cmd/compute/instances/instances.go +++ b/internal/cmd/compute/instances/instances.go @@ -7,8 +7,8 @@ import ( "strings" "github.com/spf13/cobra" - k8serrors "k8s.io/apimachinery/pkg/api/errors" corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" diff --git a/internal/cmd/compute/quota/quota.go b/internal/cmd/compute/quota/quota.go index ad08ac48..6d26f6b6 100644 --- a/internal/cmd/compute/quota/quota.go +++ b/internal/cmd/compute/quota/quota.go @@ -159,10 +159,10 @@ func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { keys = filtered } - fmt.Fprintf(out, "Quota usage for project %s\n\n", project) + _, _ = fmt.Fprintf(out, "Quota usage for project %s\n\n", project) tw := util.NewTabWriter(out) - fmt.Fprintf(tw, "CITY\tTYPE\tIN USE\tLIMIT\tAVAILABLE\n") + _, _ = fmt.Fprintf(tw, "CITY\tTYPE\tIN USE\tLIMIT\tAVAILABLE\n") for _, k := range keys { gd := groups[k] @@ -182,7 +182,7 @@ func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { cityLabel += " [at limit]" } - fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n", cityLabel, k.instanceType, gd.count, limit, available) + _, _ = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n", cityLabel, k.instanceType, gd.count, limit, available) } _ = tw.Flush() diff --git a/internal/cmd/compute/rollout/rollout.go b/internal/cmd/compute/rollout/rollout.go index 1702d402..a3df1deb 100644 --- a/internal/cmd/compute/rollout/rollout.go +++ b/internal/cmd/compute/rollout/rollout.go @@ -28,7 +28,7 @@ func Command() *cobra.Command { revert to a previous revision. Pressing Ctrl-C detaches from the watch without canceling the rollout.`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), Example: ` # Watch live rollout progress datumctl compute rollout api @@ -132,7 +132,7 @@ func runHistory(cmd *cobra.Command, args []string) error { } tw := util.NewTabWriter(out) - fmt.Fprintln(tw, "REV\tWHEN\tIMAGE\tCHANGES\tBY\tSTATUS") + _, _ = fmt.Fprintln(tw, "REV\tWHEN\tIMAGE\tCHANGES\tBY\tSTATUS") for _, e := range entries { when := "—" @@ -152,7 +152,7 @@ func runHistory(cmd *cobra.Command, args []string) error { e.Rev, when, e.Image, e.Changes, e.Actor, status) } - tw.Flush() + _ = tw.Flush() return nil } @@ -252,12 +252,12 @@ func runUndo(cmd *cobra.Command, args []string, toRevision int32) error { newSpecJSON, _ := json.Marshal(workload.Spec) entry := revision.Entry{ - Rev: newRev, + Rev: newRev, Timestamp: time.Now().UTC().Format(time.RFC3339), - Image: targetEntry.Image, - Changes: fmt.Sprintf("rollback to rev #%d", target), - Actor: actor, - SpecJSON: string(newSpecJSON), + Image: targetEntry.Image, + Changes: fmt.Sprintf("rollback to rev #%d", target), + Actor: actor, + SpecJSON: string(newSpecJSON), } if err := revision.WriteEntry(ctx, c, util.ResourceNamespace, workloadName, entry); err != nil { fmt.Fprintf(out, " warning: could not write revision history: %v\n", err) diff --git a/internal/cmd/compute/scale/scale.go b/internal/cmd/compute/scale/scale.go index 51902618..1d121e2c 100644 --- a/internal/cmd/compute/scale/scale.go +++ b/internal/cmd/compute/scale/scale.go @@ -55,7 +55,7 @@ func runScale(cmd *cobra.Command, args []string, min int32) error { } if len(workload.Spec.Placements) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "workload has no placements; nothing to scale") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "workload has no placements; nothing to scale") return nil } diff --git a/internal/controller/instancecontrol/stateful/stateful_control_test.go b/internal/controller/instancecontrol/stateful/stateful_control_test.go index 5732ee3c..5539c455 100644 --- a/internal/controller/instancecontrol/stateful/stateful_control_test.go +++ b/internal/controller/instancecontrol/stateful/stateful_control_test.go @@ -136,7 +136,7 @@ func TestScaleDownWithAllReadyInstances(t *testing.T) { deployment := getWorkloadDeployment("test-deploy", 1) - var currentInstances []v1alpha.Instance + currentInstances := make([]v1alpha.Instance, 0, 2) currentInstances = append(currentInstances, *getInstanceForDeployment(deployment, 0)) currentInstances = append(currentInstances, *getInstanceForDeployment(deployment, 1)) diff --git a/internal/validation/instance_validation.go b/internal/validation/instance_validation.go index 9d7b5803..b8a068f4 100644 --- a/internal/validation/instance_validation.go +++ b/internal/validation/instance_validation.go @@ -476,7 +476,7 @@ func validateInstanceRuntimeSpec(spec computev1alpha.InstanceRuntimeSpec, volume } func validateSandboxRuntime(sandbox *computev1alpha.SandboxRuntime, volumes map[string]computev1alpha.VolumeSource, fieldPath *field.Path) field.ErrorList { - allErrs := field.ErrorList{} + allErrs := make(field.ErrorList, 0, 4) allErrs = append(allErrs, validateSandboxContainers(sandbox.Containers, volumes, fieldPath.Child("containers"))...) allErrs = append(allErrs, validateImagePullSecrets(sandbox.ImagePullSecrets, fieldPath.Child("imagePullSecrets"))...) diff --git a/internal/validation/workload_validation.go b/internal/validation/workload_validation.go index 5f320e9a..104e844b 100644 --- a/internal/validation/workload_validation.go +++ b/internal/validation/workload_validation.go @@ -18,7 +18,7 @@ import ( // https://github.com/kubernetes/kubernetes/blob/master/pkg/apis/core/validation/validation.go func ValidateWorkloadCreate(w *computev1alpha.Workload, opts WorkloadValidationOptions) field.ErrorList { - allErrs := field.ErrorList{} + allErrs := make(field.ErrorList, 0, 4) // allErrs = append(allErrs, validateWorkloadMetadata(w)...) allErrs = append(allErrs, validateWorkloadSpec(w.Spec, opts)...) @@ -35,7 +35,7 @@ type WorkloadValidationOptions struct { } func validateWorkloadSpec(spec computev1alpha.WorkloadSpec, opts WorkloadValidationOptions) field.ErrorList { - allErrs := field.ErrorList{} + allErrs := make(field.ErrorList, 0, 4) specPath := field.NewPath("spec") From 65b8f86b4f862cb552ffae2fe45c8db31708e628 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 17:47:41 -0500 Subject: [PATCH 07/27] fix: suppress errcheck for CLI output and resolve remaining lint issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - golangci.yml: exclude errcheck for internal/cmd/* — ignoring write errors on stdout/stderr is idiomatic in CLI tools - prealloc: preallocate allErrs in validateScaleSettingMetrics - gofmt: reformat status.go, instance_controller_test.go Co-Authored-By: Claude Sonnet 4.6 --- .golangci.yml | 3 +++ internal/cmd/compute/deploy/deploy.go | 6 +++--- internal/cmd/compute/status/status.go | 18 +++++++++--------- internal/validation/workload_validation.go | 2 +- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index f5834e3c..736f902f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -45,6 +45,9 @@ linters: - linters: - prealloc path: internal/controller/instancecontrol/ + - linters: + - errcheck + path: internal/cmd/.* paths: - third_party$ - builtin$ diff --git a/internal/cmd/compute/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go index c7222a79..0cc70607 100644 --- a/internal/cmd/compute/deploy/deploy.go +++ b/internal/cmd/compute/deploy/deploy.go @@ -229,7 +229,7 @@ func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) err if err := saveWorkloadYAML(workloadName, &workload); err != nil { fmt.Fprintf(out, " warning: could not save workload.yaml: %v\n", err) } else { - fmt.Fprintln(out, "Saved workload.yaml") + _, _ = fmt.Fprintln(out, "Saved workload.yaml") } fmt.Fprintf(out, "Waiting for rollout. Ctrl-C to detach (rollout continues in background).\n\n") @@ -281,10 +281,10 @@ func deployFromFile(cmd *cobra.Command, opts *options) error { if !creating { diffLines = manifestDiff(existing, workload) for _, l := range diffLines { - fmt.Fprintln(out, l) + _, _ = fmt.Fprintln(out, l) } if len(diffLines) == 0 { - fmt.Fprintln(out, "No changes detected.") + _, _ = fmt.Fprintln(out, "No changes detected.") } } diff --git a/internal/cmd/compute/status/status.go b/internal/cmd/compute/status/status.go index 02e1dcd2..3ddb5df5 100644 --- a/internal/cmd/compute/status/status.go +++ b/internal/cmd/compute/status/status.go @@ -11,9 +11,9 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - corev1 "k8s.io/api/core/v1" computev1alpha "go.datum.net/compute/api/v1alpha" "go.datum.net/compute/internal/cmd/compute/util" + corev1 "k8s.io/api/core/v1" ) func Command() *cobra.Command { @@ -47,7 +47,7 @@ func runStatus(cmd *cobra.Command, args []string) error { var workload computev1alpha.Workload if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { if k8serrors.IsNotFound(err) { - fmt.Fprintf(cmd.ErrOrStderr(), "workload %q not found in project %s\n", workloadName, project) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "workload %q not found in project %s\n", workloadName, project) return fmt.Errorf("workload not found") } return fmt.Errorf("getting workload: %w", err) @@ -93,8 +93,8 @@ func runStatus(cmd *cobra.Command, args []string) error { out := cmd.OutOrStdout() // Header block — two-column layout. - fmt.Fprintf(out, "%-12s %-31s project: %s\n", "Workload", workloadName, project) - fmt.Fprintf(out, "%-12s %s\n", "Image", image) + _, _ = fmt.Fprintf(out, "%-12s %-31s project: %s\n", "Workload", workloadName, project) + _, _ = fmt.Fprintf(out, "%-12s %s\n", "Image", image) fmt.Fprintf(out, "%-12s %-31s Revision #%s\n", "Updated", age, revision) fmt.Fprintf(out, "\n") fmt.Fprintf(out, "%-12s %s\n", "Health", health) @@ -162,11 +162,11 @@ func runStatus(cmd *cobra.Command, args []string) error { // For each degraded deployment, find the first unhealthy instance and get its detail. type degradedDetail struct { - city string - count int32 - statusLine string - detailMsg string - quotaExceed bool + city string + count int32 + statusLine string + detailMsg string + quotaExceed bool } var details []degradedDetail anyQuotaExceeded := false diff --git a/internal/validation/workload_validation.go b/internal/validation/workload_validation.go index 104e844b..c18fcbcb 100644 --- a/internal/validation/workload_validation.go +++ b/internal/validation/workload_validation.go @@ -111,7 +111,7 @@ func validateScaleSettings(placement computev1alpha.HorizontalScaleSettings, fie } func validateScaleSettingMetrics(metrics []computev1alpha.MetricSpec, fieldPath *field.Path) field.ErrorList { - allErrs := field.ErrorList{} + allErrs := make(field.ErrorList, 0, len(metrics)) for i, m := range metrics { metricField := fieldPath.Index(i) From 3c274e6b6aea969e4b330f3fc4f1265b9ba0986b Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 18:17:46 -0500 Subject: [PATCH 08/27] feat: add shell autocompletion for workload-name arguments Wire ValidArgsFunction on every command that accepts a workload name (deploy, destroy, restart, rollout, rollout history, rollout undo, scale, status) and register flag completion for instances --workload. All completions call a shared CompleteWorkloadNames helper in internal/cmd/compute/util that fetches live workload names from the API and always returns ShellCompDirectiveNoFileComp so the shell never falls back to filename completion. Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/compute/deploy/deploy.go | 1 + internal/cmd/compute/destroy/destroy.go | 1 + internal/cmd/compute/instances/instances.go | 2 ++ internal/cmd/compute/restart/restart.go | 1 + internal/cmd/compute/rollout/rollout.go | 11 +++--- internal/cmd/compute/scale/scale.go | 1 + internal/cmd/compute/status/status.go | 1 + internal/cmd/compute/util/completion.go | 37 +++++++++++++++++++++ 8 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 internal/cmd/compute/util/completion.go diff --git a/internal/cmd/compute/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go index 0cc70607..29b6ab6d 100644 --- a/internal/cmd/compute/deploy/deploy.go +++ b/internal/cmd/compute/deploy/deploy.go @@ -59,6 +59,7 @@ Use -f to apply a workload manifest file instead of flags.`, RunE: func(cmd *cobra.Command, args []string) error { return runDeploy(cmd, args, opts) }, + ValidArgsFunction: util.CompleteWorkloadNames, } cmd.Flags().StringVar(&opts.image, "image", "", "Container image to deploy (e.g. ghcr.io/acme/api:1.4.2)") diff --git a/internal/cmd/compute/destroy/destroy.go b/internal/cmd/compute/destroy/destroy.go index a5f90992..fdb0d0a0 100644 --- a/internal/cmd/compute/destroy/destroy.go +++ b/internal/cmd/compute/destroy/destroy.go @@ -28,6 +28,7 @@ func Command() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { return runDestroy(cmd, args, yes) }, + ValidArgsFunction: util.CompleteWorkloadNames, } cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") diff --git a/internal/cmd/compute/instances/instances.go b/internal/cmd/compute/instances/instances.go index 8a4ee811..fe68ee06 100644 --- a/internal/cmd/compute/instances/instances.go +++ b/internal/cmd/compute/instances/instances.go @@ -49,6 +49,8 @@ Use the describe subcommand for full details on a single instance.`, cmd.Flags().StringVar(&opts.workload, "workload", "", "Filter instances to a specific workload") cmd.Flags().StringVar(&opts.city, "city", "", "Filter instances to a specific city") + _ = cmd.RegisterFlagCompletionFunc("workload", util.CompleteWorkloadNames) + cmd.AddCommand(describeCommand()) return cmd diff --git a/internal/cmd/compute/restart/restart.go b/internal/cmd/compute/restart/restart.go index 7a2771ea..a0a6e694 100644 --- a/internal/cmd/compute/restart/restart.go +++ b/internal/cmd/compute/restart/restart.go @@ -25,6 +25,7 @@ func Command() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { return runRestart(cmd, args, city) }, + ValidArgsFunction: util.CompleteWorkloadNames, } cmd.Flags().StringVar(&city, "city", "", "Restart only instances in a specific city") diff --git a/internal/cmd/compute/rollout/rollout.go b/internal/cmd/compute/rollout/rollout.go index a3df1deb..0c290ff8 100644 --- a/internal/cmd/compute/rollout/rollout.go +++ b/internal/cmd/compute/rollout/rollout.go @@ -40,6 +40,7 @@ Pressing Ctrl-C detaches from the watch without canceling the rollout.`, RunE: func(cmd *cobra.Command, args []string) error { return runWatch(cmd, args) }, + ValidArgsFunction: util.CompleteWorkloadNames, } cmd.AddCommand(historyCommand(), undoCommand()) @@ -91,9 +92,10 @@ func runWatch(cmd *cobra.Command, args []string) error { func historyCommand() *cobra.Command { return &cobra.Command{ - Use: "history ", - Short: "Show the rollout history for a workload", - Args: cobra.ExactArgs(1), + Use: "history ", + Short: "Show the rollout history for a workload", + Args: cobra.ExactArgs(1), + ValidArgsFunction: util.CompleteWorkloadNames, RunE: func(cmd *cobra.Command, args []string) error { return runHistory(cmd, args) }, @@ -164,7 +166,8 @@ func undoCommand() *cobra.Command { Short: "Roll back a workload to a previous revision", Long: `Creates a new revision that is a copy of the target revision. Rollbacks do not rewrite history.`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), + ValidArgsFunction: util.CompleteWorkloadNames, RunE: func(cmd *cobra.Command, args []string) error { return runUndo(cmd, args, toRevision) }, diff --git a/internal/cmd/compute/scale/scale.go b/internal/cmd/compute/scale/scale.go index 1d121e2c..1ce704ee 100644 --- a/internal/cmd/compute/scale/scale.go +++ b/internal/cmd/compute/scale/scale.go @@ -23,6 +23,7 @@ func Command() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { return runScale(cmd, args, min) }, + ValidArgsFunction: util.CompleteWorkloadNames, } cmd.Flags().Int32Var(&min, "min", 0, "Minimum number of instances per city") diff --git a/internal/cmd/compute/status/status.go b/internal/cmd/compute/status/status.go index 3ddb5df5..77404fb9 100644 --- a/internal/cmd/compute/status/status.go +++ b/internal/cmd/compute/status/status.go @@ -27,6 +27,7 @@ counts and plain-English explanations of any degraded conditions.`, RunE: func(cmd *cobra.Command, args []string) error { return runStatus(cmd, args) }, + ValidArgsFunction: util.CompleteWorkloadNames, } return cmd diff --git a/internal/cmd/compute/util/completion.go b/internal/cmd/compute/util/completion.go new file mode 100644 index 00000000..fd7f0a09 --- /dev/null +++ b/internal/cmd/compute/util/completion.go @@ -0,0 +1,37 @@ +package util + +import ( + "context" + + "github.com/spf13/cobra" + "sigs.k8s.io/controller-runtime/pkg/client" + + computev1alpha "go.datum.net/compute/api/v1alpha" +) + +// CompleteWorkloadNames is a ValidArgsFunction that lists workload names from +// the API. It suppresses file completion in all cases so the shell never falls +// back to filename completion when completing a workload-name argument. +func CompleteWorkloadNames(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + // Only complete the first positional argument. + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + project := ProjectFromCmd(cmd) + c, err := NewClient(project) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var list computev1alpha.WorkloadList + if err := c.List(context.Background(), &list, client.InNamespace(ResourceNamespace)); err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + names := make([]string, len(list.Items)) + for i, w := range list.Items { + names[i] = w.Name + } + return names, cobra.ShellCompDirectiveNoFileComp +} From 77bae176e8cbb6fd264c9d39a0f4c8e65c502644 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 26 May 2026 12:04:56 -0500 Subject: [PATCH 09/27] feat: surface flags on plain Tab for deploy command - Remove ValidArgsFunction from deploy and replace with util.CompleteWorkloadNamesAndFlags, which wraps CompleteWorkloadNames with plugin.WithFlagCompletion from the datumctl SDK. - Add plugin.WithFlagCompletion to the datumctl plugin SDK so any plugin can get the same behaviour by wrapping their own ValidArgsFunction. - Bump go.datum.net/datumctl to b44de1c (adds WithFlagCompletion). Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 63 +++++++++++- go.sum | 129 ++++++++++++++++++++++++ internal/cmd/compute/deploy/deploy.go | 2 +- internal/cmd/compute/util/completion.go | 8 +- 4 files changed, 199 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 57e51a04..c697d25d 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/onsi/gomega v1.38.2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 - go.datum.net/datumctl v0.14.1-0.20260522214722-79d2d1680f08 + go.datum.net/datumctl v0.14.1-0.20260523153711-b44de1c715c1 go.datum.net/network-services-operator v0.1.0 go.miloapis.com/milo v0.24.11 golang.org/x/crypto v0.49.0 @@ -28,18 +28,46 @@ require ( require ( cel.dev/expr v0.25.1 // indirect + charm.land/bubbles/v2 v2.1.0 // indirect + charm.land/bubbletea/v2 v2.0.6 // indirect + charm.land/huh/v2 v2.0.3 // indirect + charm.land/lipgloss/v2 v2.0.3 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chai2010/gettext-go v1.0.3 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/coreos/go-oidc/v3 v3.18.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect + github.com/fatih/camelcase v1.0.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-errors/errors v1.5.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect @@ -57,25 +85,51 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.27.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.3 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lithammer/dedent v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/moby/spdystream v0.5.1 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rodaine/table v1.3.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xlab/treeprint v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/zalando/go-keyring v0.2.8 // indirect + go.miloapis.com/activity v0.7.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect @@ -106,11 +160,18 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.35.3 // indirect k8s.io/apiserver v0.35.3 // indirect + k8s.io/cli-runtime v0.35.3 // indirect k8s.io/component-base v0.35.3 // indirect + k8s.io/component-helpers v0.35.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect + k8s.io/kubectl v0.35.3 // indirect + k8s.io/metrics v0.35.3 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kustomize/api v0.21.0 // indirect + sigs.k8s.io/kustomize/kustomize/v5 v5.7.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.21.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) diff --git a/go.sum b/go.sum index 1b84d2d1..6a2d5d34 100644 --- a/go.sum +++ b/go.sum @@ -1,28 +1,80 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= +charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.3 h1:9liNh8t+u26xl5ddmWLmsOsdNLwkdRTg5AG+JnTiM80= +github.com/chai2010/gettext-go v1.0.3/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/coreos/go-oidc v2.3.0+incompatible h1:+5vEsrgprdLjjQ9FzIKAzQz1wwPD+83hQRfUIPh7rO0= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= +github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= +github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -35,6 +87,10 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -80,6 +136,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= @@ -97,10 +155,16 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -113,22 +177,50 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= +github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= +github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -142,9 +234,16 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rodaine/table v1.3.1 h1:jBVgg1bEu5EzEdYSrwUUlQpayDtkvtTmgFS0FPAxOq8= +github.com/rodaine/table v1.3.1/go.mod h1:VYCJRCHa2DpD25uFALcB6hi5ECF3eEJQVhCXRjHgXc4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -154,6 +253,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -166,10 +267,20 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.datum.net/datumctl v0.14.1-0.20260522214722-79d2d1680f08 h1:8BymUUKdFBCBjvA4yB6BBWYVloiA0f0XqRqYksA/kvQ= go.datum.net/datumctl v0.14.1-0.20260522214722-79d2d1680f08/go.mod h1:rwu8XWb0FeMzX8vCu+UxKLw89DAkyLOh70PNbDaotac= +go.datum.net/datumctl v0.14.1-0.20260523153711-b44de1c715c1 h1:C+VX+/mGJDZjjohBbdJ/PL0qRWqArpxk+wzGe2KpEC8= +go.datum.net/datumctl v0.14.1-0.20260523153711-b44de1c715c1/go.mod h1:rwu8XWb0FeMzX8vCu+UxKLw89DAkyLOh70PNbDaotac= go.datum.net/network-services-operator v0.1.0 h1:PAXOZ5DdJFgRoeVBPIXhqkCm6DxbP4tVOPcr3Y7h/So= go.datum.net/network-services-operator v0.1.0/go.mod h1:uloVfxqE+8DgSiMB651X8UC9yECpXbwp/NBstofCceE= +go.miloapis.com/activity v0.7.0 h1:Nmc5XzA4oEMTko5/ciJAeERVk18FaSnRpTBo0Sm89YU= +go.miloapis.com/activity v0.7.0/go.mod h1:Sh2Irbq6siJcfq17nLjHvm4JHN/2Csc5YCHB+ycz20c= go.miloapis.com/milo v0.24.11 h1:rByXDKbP4ZEN0I/z1C2RyUCyQi0NWrITLqoQILSAn2E= go.miloapis.com/milo v0.24.11/go.mod h1:xOFYvUsvSZV3z6eow5YdB5C/qRQf2s/5/arcfJs5XPg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -214,6 +325,8 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= @@ -243,6 +356,7 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= @@ -253,14 +367,23 @@ k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.35.3 h1:D2eIcfJ05hEAEewoSDg+05e0aSRwx8Y4Agvd/wiomUI= k8s.io/apiserver v0.35.3/go.mod h1:JI0n9bHYzSgIxgIrfe21dbduJ9NHzKJ6RchcsmIKWKY= +k8s.io/cli-runtime v0.35.3 h1:UZq4ipNimtzBmhN7PPKbfAdqo8quK0H0UdGl6qAQnqI= +k8s.io/cli-runtime v0.35.3/go.mod h1:O7MUmCqcKSd5xI+O5X7/pRkB5l0O2NIhOdUVwbHLXu4= k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= k8s.io/component-base v0.35.3 h1:mbKbzoIMy7JDWS/wqZobYW1JDVRn/RKRaoMQHP9c4P0= k8s.io/component-base v0.35.3/go.mod h1:IZ8LEG30kPN4Et5NeC7vjNv5aU73ku5MS15iZyvyMYk= +k8s.io/component-helpers v0.35.3 h1:Rl2p3wNMC0YU21rziLkWXavr7MwkB5Td3lNZ/+gYGm8= +k8s.io/component-helpers v0.35.3/go.mod h1:8BkyfcBA6XsCtFYxDB+mCfZqM6P39Aco12AKigNn0C8= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg= k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/kubectl v0.35.3 h1:1KqSYXk/sodU7VeDvK6atX2kAGUZd2QTeR5K7Hb9r9w= +k8s.io/kubectl v0.35.3/go.mod h1:GPHxZqRe+u/i3gTBoVQHeIyq2NilfNPj9hDWeuN3x5s= +k8s.io/kubernetes v1.32.0/go.mod h1:tiIKO63GcdPRBHW2WiUFm3C0eoLczl3f7qi56Dm1W8I= +k8s.io/metrics v0.35.3 h1:WonA18pEwrtb7a6XfhFg1ZY1Le0RFkcEw7CFApMTZos= +k8s.io/metrics v0.35.3/go.mod h1:/O8UBb5QVyAekR2QvL/WWxskpdV1wVSEl4MSLAy4Ql4= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= @@ -271,6 +394,12 @@ sigs.k8s.io/gateway-api v1.2.1 h1:fZZ/+RyRb+Y5tGkwxFKuYuSRQHu9dZtbjenblleOLHM= sigs.k8s.io/gateway-api v1.2.1/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.21.0 h1:I7nry5p8iDJbuRdYS7ez8MUvw7XVNPcIP5GkzzuXIIQ= +sigs.k8s.io/kustomize/api v0.21.0/go.mod h1:XGVQuR5n2pXKWbzXHweZU683pALGw/AMVO4zU4iS8SE= +sigs.k8s.io/kustomize/kustomize/v5 v5.7.1 h1:sYJsarwy/SDJfjjLMUqwFDGPwzUtMOQ1i1Ed49+XSbw= +sigs.k8s.io/kustomize/kustomize/v5 v5.7.1/go.mod h1:+5/SrBcJ4agx1SJknGuR/c9thwRSKLxnKoI5BzXFaLU= +sigs.k8s.io/kustomize/kyaml v0.21.0 h1:7mQAf3dUwf0wBerWJd8rXhVcnkk5Tvn/q91cGkaP6HQ= +sigs.k8s.io/kustomize/kyaml v0.21.0/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= sigs.k8s.io/multicluster-runtime v0.23.3 h1:vrzlXRzHTDsjspUAfoW2rCtr0agoI4q20p9x4Fz4png= sigs.k8s.io/multicluster-runtime v0.23.3/go.mod h1:r/UA4GHgFoXCcR4tcvlZz7SiLx3l1kJKDuBAhILNIHs= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/internal/cmd/compute/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go index 29b6ab6d..63b18bf1 100644 --- a/internal/cmd/compute/deploy/deploy.go +++ b/internal/cmd/compute/deploy/deploy.go @@ -59,7 +59,7 @@ Use -f to apply a workload manifest file instead of flags.`, RunE: func(cmd *cobra.Command, args []string) error { return runDeploy(cmd, args, opts) }, - ValidArgsFunction: util.CompleteWorkloadNames, + ValidArgsFunction: util.CompleteWorkloadNamesAndFlags, } cmd.Flags().StringVar(&opts.image, "image", "", "Container image to deploy (e.g. ghcr.io/acme/api:1.4.2)") diff --git a/internal/cmd/compute/util/completion.go b/internal/cmd/compute/util/completion.go index fd7f0a09..99d76f46 100644 --- a/internal/cmd/compute/util/completion.go +++ b/internal/cmd/compute/util/completion.go @@ -7,13 +7,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/datumctl/plugin" ) // CompleteWorkloadNames is a ValidArgsFunction that lists workload names from // the API. It suppresses file completion in all cases so the shell never falls // back to filename completion when completing a workload-name argument. func CompleteWorkloadNames(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { - // Only complete the first positional argument. if len(args) > 0 { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -35,3 +35,9 @@ func CompleteWorkloadNames(cmd *cobra.Command, args []string, _ string) ([]strin } return names, cobra.ShellCompDirectiveNoFileComp } + +// CompleteWorkloadNamesAndFlags lists workload names from the API and also +// surfaces the command's own flags as completions. Used by commands where flags +// are the primary input (e.g. deploy) so that plain offers flags without +// requiring the user to type "--" first. +var CompleteWorkloadNamesAndFlags = plugin.WithFlagCompletion(CompleteWorkloadNames) From 9c2e9df18d9bf1f2f338ace149cfed7978aaef19 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 26 May 2026 15:40:03 -0500 Subject: [PATCH 10/27] feat: make CSI webhook cert component generic, patch issuer per overlay Remove the hardcoded datum-control-plane ClusterIssuer from the csi-webhook-cert component. DNS names stay since they are fixed by the service name and namespace. Each consuming overlay now supplies the issuer via a strategic merge patch, allowing different environments to use different cert issuers without forking the component. Co-Authored-By: Claude Sonnet 4.6 --- config/overlays/single-cluster/kustomization.yaml | 3 +++ .../single-cluster/webhook_cert_issuer_patch.yaml | 14 ++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 config/overlays/single-cluster/webhook_cert_issuer_patch.yaml diff --git a/config/overlays/single-cluster/kustomization.yaml b/config/overlays/single-cluster/kustomization.yaml index 4d72934e..6d799c2a 100644 --- a/config/overlays/single-cluster/kustomization.yaml +++ b/config/overlays/single-cluster/kustomization.yaml @@ -17,3 +17,6 @@ components: - ../../components/csi-webhook-cert - ../../components/management-controllers - ../../components/cell-controllers + +patches: +- path: webhook_cert_issuer_patch.yaml diff --git a/config/overlays/single-cluster/webhook_cert_issuer_patch.yaml b/config/overlays/single-cluster/webhook_cert_issuer_patch.yaml new file mode 100644 index 00000000..0ca3ce5b --- /dev/null +++ b/config/overlays/single-cluster/webhook_cert_issuer_patch.yaml @@ -0,0 +1,14 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: compute-manager +spec: + template: + spec: + volumes: + - name: webhook-server-tls + csi: + driver: csi.cert-manager.io + volumeAttributes: + csi.cert-manager.io/issuer-kind: ClusterIssuer + csi.cert-manager.io/issuer-name: datum-control-plane From e03d15f91db9210bd5676ef18e7744f53a53ed29 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 26 May 2026 15:49:23 -0500 Subject: [PATCH 11/27] refactor: move webhook cert issuer patch to infra repo The cert issuer name is environment-specific configuration that belongs in the infra repo, not the compute overlay. The infra repo's base manager patch already owns the full webhook-server-tls volume definition including the issuer. Consumers deploying outside infra must patch the issuer in their own overlay. Co-Authored-By: Claude Sonnet 4.6 --- config/overlays/single-cluster/kustomization.yaml | 3 --- .../single-cluster/webhook_cert_issuer_patch.yaml | 14 -------------- 2 files changed, 17 deletions(-) delete mode 100644 config/overlays/single-cluster/webhook_cert_issuer_patch.yaml diff --git a/config/overlays/single-cluster/kustomization.yaml b/config/overlays/single-cluster/kustomization.yaml index 6d799c2a..4d72934e 100644 --- a/config/overlays/single-cluster/kustomization.yaml +++ b/config/overlays/single-cluster/kustomization.yaml @@ -17,6 +17,3 @@ components: - ../../components/csi-webhook-cert - ../../components/management-controllers - ../../components/cell-controllers - -patches: -- path: webhook_cert_issuer_patch.yaml diff --git a/config/overlays/single-cluster/webhook_cert_issuer_patch.yaml b/config/overlays/single-cluster/webhook_cert_issuer_patch.yaml deleted file mode 100644 index 0ca3ce5b..00000000 --- a/config/overlays/single-cluster/webhook_cert_issuer_patch.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: compute-manager -spec: - template: - spec: - volumes: - - name: webhook-server-tls - csi: - driver: csi.cert-manager.io - volumeAttributes: - csi.cert-manager.io/issuer-kind: ClusterIssuer - csi.cert-manager.io/issuer-name: datum-control-plane From bfe3f443422cb77cc160c7794baa4565ce6bd107 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 26 May 2026 19:37:36 -0500 Subject: [PATCH 12/27] feat: add util.PrintJSON/PrintYAML helpers and completion functions Add a printer.go with PrintJSON and PrintYAML helpers that commands can use to emit API resources as structured output. Extend completion.go with CompleteInstanceNames, CompleteCityCodes, and CompleteOutputFormats so all -o/--output, --city, and instance-name completions are driven from a single shared source. Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/compute/util/completion.go | 57 +++++++++++++++++++++++++ internal/cmd/compute/util/printer.go | 39 +++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 internal/cmd/compute/util/printer.go diff --git a/internal/cmd/compute/util/completion.go b/internal/cmd/compute/util/completion.go index 99d76f46..c6dcb3fb 100644 --- a/internal/cmd/compute/util/completion.go +++ b/internal/cmd/compute/util/completion.go @@ -10,6 +10,63 @@ import ( "go.datum.net/datumctl/plugin" ) +// CompleteInstanceNames is a ValidArgsFunction that lists instance names from the API. +func CompleteInstanceNames(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + project := ProjectFromCmd(cmd) + c, err := NewClient(project) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var list computev1alpha.InstanceList + if err := c.List(context.Background(), &list, client.InNamespace(ResourceNamespace)); err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + names := make([]string, len(list.Items)) + for i, inst := range list.Items { + names[i] = inst.Name + } + return names, cobra.ShellCompDirectiveNoFileComp +} + +// CompleteCityCodes is a ValidArgsFunction that returns unique city codes from +// all WorkloadDeployments in the project. +func CompleteCityCodes(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + project := ProjectFromCmd(cmd) + c, err := NewClient(project) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var list computev1alpha.WorkloadDeploymentList + if err := c.List(context.Background(), &list, client.InNamespace(ResourceNamespace)); err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + seen := make(map[string]bool) + var codes []string + for _, d := range list.Items { + if !seen[d.Spec.CityCode] { + seen[d.Spec.CityCode] = true + codes = append(codes, d.Spec.CityCode) + } + } + return codes, cobra.ShellCompDirectiveNoFileComp +} + +// CompleteOutputFormats returns a ValidArgsFunction that completes -o/--output +// to the given allowed values. +func CompleteOutputFormats(allowed ...string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return allowed, cobra.ShellCompDirectiveNoFileComp + } +} + // CompleteWorkloadNames is a ValidArgsFunction that lists workload names from // the API. It suppresses file completion in all cases so the shell never falls // back to filename completion when completing a workload-name argument. diff --git a/internal/cmd/compute/util/printer.go b/internal/cmd/compute/util/printer.go new file mode 100644 index 00000000..a0cc94e6 --- /dev/null +++ b/internal/cmd/compute/util/printer.go @@ -0,0 +1,39 @@ +package util + +import ( + "encoding/json" + "fmt" + "io" + + "sigs.k8s.io/yaml" +) + +// OutputFormat represents the requested output format for a command. +type OutputFormat string + +const ( + OutputTable OutputFormat = "table" + OutputWide OutputFormat = "wide" + OutputJSON OutputFormat = "json" + OutputYAML OutputFormat = "yaml" +) + +// PrintJSON serialises obj to JSON and writes it to w. +func PrintJSON(w io.Writer, obj interface{}) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(obj); err != nil { + return fmt.Errorf("encoding JSON: %w", err) + } + return nil +} + +// PrintYAML serialises obj to YAML and writes it to w. +func PrintYAML(w io.Writer, obj interface{}) error { + b, err := yaml.Marshal(obj) + if err != nil { + return fmt.Errorf("encoding YAML: %w", err) + } + _, err = w.Write(b) + return err +} From f772863de5b5ef77ad1143d6f03e203c334ae379 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 26 May 2026 19:37:43 -0500 Subject: [PATCH 13/27] feat: add -o table|wide|json|yaml to instances and quota commands Both commands now accept -o/--output with tab-completion. json/yaml emit the underlying API resource (InstanceList) or structured quota rows respectively. wide adds an INSTANCE TYPE column for instances. --no-headers suppresses the header row for table and wide. City completion is wired to CompleteCityCodes and instance describe gains tab-completion via CompleteInstanceNames. Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/compute/instances/instances.go | 36 +++++++++++- internal/cmd/compute/quota/quota.go | 64 +++++++++++++++------ 2 files changed, 80 insertions(+), 20 deletions(-) diff --git a/internal/cmd/compute/instances/instances.go b/internal/cmd/compute/instances/instances.go index fe68ee06..e21874da 100644 --- a/internal/cmd/compute/instances/instances.go +++ b/internal/cmd/compute/instances/instances.go @@ -39,6 +39,9 @@ Use the describe subcommand for full details on a single instance.`, # Filter by city datumctl compute instances --city=DFW + # Machine-readable output + datumctl compute instances -o json + # Describe a single instance datumctl compute instances describe api-dfw-0`, RunE: func(cmd *cobra.Command, args []string) error { @@ -48,8 +51,12 @@ Use the describe subcommand for full details on a single instance.`, cmd.Flags().StringVar(&opts.workload, "workload", "", "Filter instances to a specific workload") cmd.Flags().StringVar(&opts.city, "city", "", "Filter instances to a specific city") + cmd.Flags().StringP("output", "o", "table", "Output format: table, wide, json, yaml") + cmd.Flags().Bool("no-headers", false, "Omit the table header row (table and wide only)") _ = cmd.RegisterFlagCompletionFunc("workload", util.CompleteWorkloadNames) + _ = cmd.RegisterFlagCompletionFunc("city", util.CompleteCityCodes) + _ = cmd.RegisterFlagCompletionFunc("output", util.CompleteOutputFormats("table", "wide", "json", "yaml")) cmd.AddCommand(describeCommand()) @@ -70,6 +77,8 @@ type instanceRow struct { func runList(cmd *cobra.Command, opts *listOptions) error { ctx := context.Background() project := util.ProjectFromCmd(cmd) + outputFlag, _ := cmd.Flags().GetString("output") + noHeaders, _ := cmd.Flags().GetBool("no-headers") c, err := util.NewClient(project) if err != nil { @@ -100,6 +109,14 @@ func runList(cmd *cobra.Command, opts *listOptions) error { return fmt.Errorf("listing instances: %w", err) } + // JSON/YAML: emit raw API resource and return early (before city filter). + switch util.OutputFormat(outputFlag) { + case util.OutputJSON: + return util.PrintJSON(cmd.OutOrStdout(), &instList) + case util.OutputYAML: + return util.PrintYAML(cmd.OutOrStdout(), &instList) + } + // List deployments — build map deploymentUID → *WorkloadDeployment. var deployList computev1alpha.WorkloadDeploymentList if err := c.List(ctx, &deployList, client.InNamespace(util.ResourceNamespace)); err != nil { @@ -185,11 +202,23 @@ func runList(cmd *cobra.Command, opts *listOptions) error { } out := cmd.OutOrStdout() + wide := util.OutputFormat(outputFlag) == util.OutputWide tw := util.NewTabWriter(out) - _, _ = fmt.Fprintf(tw, "NAME\tWORKLOAD\tCITY\tEXTERNAL IP\tINTERNAL IP\tTYPE\tAGE\tSTATUS\n") + if !noHeaders { + if wide { + _, _ = fmt.Fprintf(tw, "NAME\tWORKLOAD\tCITY\tEXTERNAL IP\tINTERNAL IP\tTYPE\tAGE\tSTATUS\tINSTANCE TYPE\n") + } else { + _, _ = fmt.Fprintf(tw, "NAME\tWORKLOAD\tCITY\tEXTERNAL IP\tINTERNAL IP\tTYPE\tAGE\tSTATUS\n") + } + } for _, r := range rows { - _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - r.name, r.workload, r.city, r.externalIP, r.internalIP, r.instType, r.age, r.status) + if wide { + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + r.name, r.workload, r.city, r.externalIP, r.internalIP, r.instType, r.age, r.status, r.instType) + } else { + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + r.name, r.workload, r.city, r.externalIP, r.internalIP, r.instType, r.age, r.status) + } } _ = tw.Flush() @@ -215,6 +244,7 @@ instance, including plain-English explanations of any failure states.`, RunE: func(cmd *cobra.Command, args []string) error { return runDescribe(cmd, args) }, + ValidArgsFunction: util.CompleteInstanceNames, } } diff --git a/internal/cmd/compute/quota/quota.go b/internal/cmd/compute/quota/quota.go index 6d26f6b6..b125b18a 100644 --- a/internal/cmd/compute/quota/quota.go +++ b/internal/cmd/compute/quota/quota.go @@ -33,10 +33,25 @@ func Command() *cobra.Command { cmd.Flags().StringVar(&city, "city", "", "Narrow output to a specific city") cmd.Flags().BoolVar(&constrained, "constrained", false, "Show only constrained resources") + cmd.Flags().StringP("output", "o", "table", "Output format: table, json, yaml") + + _ = cmd.RegisterFlagCompletionFunc("city", util.CompleteCityCodes) + _ = cmd.RegisterFlagCompletionFunc("output", util.CompleteOutputFormats("table", "json", "yaml")) return cmd } +// quotaRow is a structured representation of a single quota row, used for +// JSON/YAML serialisation. +type quotaRow struct { + City string `json:"city"` + InstanceType string `json:"instanceType"` + InUse int `json:"inUse"` + Limit string `json:"limit"` + Available string `json:"available"` + AtLimit bool `json:"atLimit"` +} + type groupKey struct { city string instanceType string @@ -44,6 +59,7 @@ type groupKey struct { func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { project := util.ProjectFromCmd(cmd) + outputFlag, _ := cmd.Flags().GetString("output") c, err := util.NewClient(project) if err != nil { @@ -123,10 +139,6 @@ func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { return keys[i].instanceType < keys[j].instanceType }) - // Also list workload deployments to pick up zero-instance cities (not needed per spec, skip). - - out := cmd.OutOrStdout() - // Filter by city. if filterCity != "" { var filtered []groupKey @@ -138,6 +150,8 @@ func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { keys = filtered } + out := cmd.OutOrStdout() + // Before filtering by constrained, check if there are any instances at all. if len(instList.Items) == 0 { _, _ = fmt.Fprint(out, "No instances running. No quota consumption to display.\n") @@ -159,14 +173,10 @@ func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { keys = filtered } - _, _ = fmt.Fprintf(out, "Quota usage for project %s\n\n", project) - - tw := util.NewTabWriter(out) - _, _ = fmt.Fprintf(tw, "CITY\tTYPE\tIN USE\tLIMIT\tAVAILABLE\n") - + // Build structured rows for serialisation or table rendering. + var rows []quotaRow for _, k := range keys { gd := groups[k] - limit := "—" available := "—" if gd.limitMsg != "" { @@ -176,19 +186,39 @@ func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { limit = strconv.Itoa(gd.count + avail) } } + rows = append(rows, quotaRow{ + City: k.city, + InstanceType: k.instanceType, + InUse: gd.count, + Limit: limit, + Available: available, + AtLimit: gd.atLimit, + }) + } + + // JSON/YAML output. + switch util.OutputFormat(outputFlag) { + case util.OutputJSON: + return util.PrintJSON(out, rows) + case util.OutputYAML: + return util.PrintYAML(out, rows) + } + + // Table output. + _, _ = fmt.Fprintf(out, "Quota usage for project %s\n\n", project) + + tw := util.NewTabWriter(out) + _, _ = fmt.Fprintf(tw, "CITY\tTYPE\tIN USE\tLIMIT\tAVAILABLE\n") - cityLabel := k.city - if gd.atLimit { + for _, r := range rows { + cityLabel := r.City + if r.AtLimit { cityLabel += " [at limit]" } - - _, _ = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n", cityLabel, k.instanceType, gd.count, limit, available) + _, _ = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n", cityLabel, r.InstanceType, r.InUse, r.Limit, r.Available) } _ = tw.Flush() - // Sort and print zero-instance groups (no quota consumed, nothing to show for "constrained"). - // Per spec these are not interesting for the quota view, so we skip them. - _, _ = fmt.Fprint(out, "\nNote: limit information is derived from quota conditions on instances.\nRun 'datumctl quota' for full project quota management.\n") return nil From c5ff3aa791c56d28e430f2fee64426ee33629a3e Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 26 May 2026 19:37:51 -0500 Subject: [PATCH 14/27] feat: add workloads command group and remove status command Add datumctl compute workloads (list) and workloads describe commands. The list command shows NAME/HEALTH/READY/PLACEMENTS/IMAGE/AGE columns with --health and --city filters, -o table|wide|json|yaml, and a footer summary. The describe command replaces status with a unified config+health view: header block, per-placement per-city ready counts with inline degradation annotations, and a container spec block. Remove the now-redundant status command from root.go and delete its package. Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/compute/root.go | 4 +- internal/cmd/compute/status/status.go | 247 ---------- internal/cmd/compute/workloads/workloads.go | 502 ++++++++++++++++++++ 3 files changed, 504 insertions(+), 249 deletions(-) delete mode 100644 internal/cmd/compute/status/status.go create mode 100644 internal/cmd/compute/workloads/workloads.go diff --git a/internal/cmd/compute/root.go b/internal/cmd/compute/root.go index f61e18bf..db0a777a 100644 --- a/internal/cmd/compute/root.go +++ b/internal/cmd/compute/root.go @@ -11,7 +11,7 @@ import ( "go.datum.net/compute/internal/cmd/compute/restart" "go.datum.net/compute/internal/cmd/compute/rollout" "go.datum.net/compute/internal/cmd/compute/scale" - "go.datum.net/compute/internal/cmd/compute/status" + "go.datum.net/compute/internal/cmd/compute/workloads" ) func Command() *cobra.Command { @@ -25,7 +25,7 @@ func Command() *cobra.Command { restart.Command(), rollout.Command(), scale.Command(), - status.Command(), + workloads.Command(), ) return root diff --git a/internal/cmd/compute/status/status.go b/internal/cmd/compute/status/status.go deleted file mode 100644 index 77404fb9..00000000 --- a/internal/cmd/compute/status/status.go +++ /dev/null @@ -1,247 +0,0 @@ -package status - -import ( - "context" - "fmt" - "strings" - - "github.com/spf13/cobra" - k8serrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" - - computev1alpha "go.datum.net/compute/api/v1alpha" - "go.datum.net/compute/internal/cmd/compute/util" - corev1 "k8s.io/api/core/v1" -) - -func Command() *cobra.Command { - cmd := &cobra.Command{ - Use: "status ", - Short: "Show the health and placement status of a workload", - Long: `Display the current health status of a workload with city-by-city replica -counts and plain-English explanations of any degraded conditions.`, - Args: cobra.ExactArgs(1), - Example: ` datumctl compute status api`, - RunE: func(cmd *cobra.Command, args []string) error { - return runStatus(cmd, args) - }, - ValidArgsFunction: util.CompleteWorkloadNames, - } - - return cmd -} - -func runStatus(cmd *cobra.Command, args []string) error { - ctx := context.Background() - project := util.ProjectFromCmd(cmd) - - c, err := util.NewClient(project) - if err != nil { - return err - } - - workloadName := args[0] - - // Fetch workload. - var workload computev1alpha.Workload - if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { - if k8serrors.IsNotFound(err) { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "workload %q not found in project %s\n", workloadName, project) - return fmt.Errorf("workload not found") - } - return fmt.Errorf("getting workload: %w", err) - } - - // List deployments for this workload. - selector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadUIDLabel: string(workload.UID)}) - var deployList computev1alpha.WorkloadDeploymentList - if err := c.List(ctx, &deployList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: selector}); err != nil { - return fmt.Errorf("listing deployments: %w", err) - } - - // Derive image. - image := "(virtual machine)" - if workload.Spec.Template.Spec.Runtime.Sandbox != nil && - len(workload.Spec.Template.Spec.Runtime.Sandbox.Containers) > 0 { - image = workload.Spec.Template.Spec.Runtime.Sandbox.Containers[0].Image - } - - instanceType := workload.Spec.Template.Spec.Runtime.Resources.InstanceType - age := util.RelativeAgeVerbose(workload.CreationTimestamp) - - // Fetch revision ConfigMap. - revision := "—" - var cm corev1.ConfigMap - cmName := "compute.datumapis.com-revision-history." + workloadName - if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: cmName}, &cm); err == nil { - if v, ok := cm.Annotations["compute.datumapis.com/current-revision"]; ok { - revision = v - } - } - // If not found or any error, revision stays "—". - - // Compute totals. - var totalDesired, totalReady int32 - for _, d := range deployList.Items { - totalDesired += d.Status.DesiredReplicas - totalReady += d.Status.ReadyReplicas - } - - health := util.WorkloadHealth(workload.Status.Conditions, totalReady, totalDesired) - - out := cmd.OutOrStdout() - - // Header block — two-column layout. - _, _ = fmt.Fprintf(out, "%-12s %-31s project: %s\n", "Workload", workloadName, project) - _, _ = fmt.Fprintf(out, "%-12s %s\n", "Image", image) - fmt.Fprintf(out, "%-12s %-31s Revision #%s\n", "Updated", age, revision) - fmt.Fprintf(out, "\n") - fmt.Fprintf(out, "%-12s %s\n", "Health", health) - fmt.Fprintf(out, "\n") - - if len(deployList.Items) == 0 { - fmt.Fprintf(out, " No placements configured.\n") - return nil - } - - // Placement table — grouped by placement name. - tw := util.NewTabWriter(out) - fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n", "", "CITY", "READY", "DESIRED", "TYPE") - - // Group deployments by placement name preserving order from workload spec. - type deployGroup struct { - name string - deployments []computev1alpha.WorkloadDeployment - } - var groups []deployGroup - groupIndex := map[string]int{} - for _, d := range deployList.Items { - pn := d.Spec.PlacementName - if idx, ok := groupIndex[pn]; ok { - groups[idx].deployments = append(groups[idx].deployments, d) - } else { - groupIndex[pn] = len(groups) - groups = append(groups, deployGroup{name: pn, deployments: []computev1alpha.WorkloadDeployment{d}}) - } - } - - // Track degraded deployments for the detail block. - type degradedEntry struct { - city string - deployment computev1alpha.WorkloadDeployment - } - var degraded []degradedEntry - - for _, g := range groups { - for i, d := range g.deployments { - placementLabel := "" - if i == 0 { - placementLabel = g.name - } - readyStr := fmt.Sprintf("%d/%d", d.Status.ReadyReplicas, d.Status.Replicas) - fmt.Fprintf(tw, " %s\t%s\t%s\t%d\t%s\n", - placementLabel, - d.Spec.CityCode, - readyStr, - d.Status.DesiredReplicas, - instanceType, - ) - if d.Status.ReadyReplicas < d.Status.DesiredReplicas { - degraded = append(degraded, degradedEntry{city: d.Spec.CityCode, deployment: d}) - } - } - } - _ = tw.Flush() - - if len(degraded) == 0 { - return nil - } - - fmt.Fprintf(out, "\n") - - // For each degraded deployment, find the first unhealthy instance and get its detail. - type degradedDetail struct { - city string - count int32 - statusLine string - detailMsg string - quotaExceed bool - } - var details []degradedDetail - anyQuotaExceeded := false - - for _, de := range degraded { - depUID := string(de.deployment.UID) - depSelector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadDeploymentUIDLabel: depUID}) - var instList computev1alpha.InstanceList - if err := c.List(ctx, &instList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: depSelector}); err != nil { - // Skip detail on error. - continue - } - - var statusLine, detailMsg string - quotaExceed := false - for _, inst := range instList.Items { - readyCond := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceReady) - if readyCond == nil || readyCond.Status != "True" { - s, d := util.InstanceStatusDetail(inst.Status.Conditions) - statusLine = s - detailMsg = d - // Check if quota exceeded. - qc := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceQuotaGranted) - if qc != nil && qc.Reason == computev1alpha.InstanceQuotaGrantedReasonQuotaExceeded { - quotaExceed = true - anyQuotaExceeded = true - } - break - } - } - - short := describeStatusShort(statusLine, de.deployment.Status.DesiredReplicas-de.deployment.Status.ReadyReplicas) - details = append(details, degradedDetail{ - city: de.city, - count: de.deployment.Status.DesiredReplicas - de.deployment.Status.ReadyReplicas, - statusLine: short, - detailMsg: detailMsg, - quotaExceed: quotaExceed, - }) - } - - for _, dd := range details { - fmt.Fprintf(out, " %s: %d instances could not start — %s\n", dd.city, dd.count, dd.statusLine) - if dd.detailMsg != "" { - fmt.Fprintf(out, " %s\n", dd.detailMsg) - } - } - - // Next steps block. - fmt.Fprintf(out, "\n Next steps:\n") - if anyQuotaExceeded { - fmt.Fprintf(out, " Reduce replicas: datumctl compute scale %s --min=2\n", workloadName) - fmt.Fprintf(out, " Check quota: datumctl compute quota\n") - } - fmt.Fprintf(out, " View instances: datumctl compute instances --workload=%s\n", workloadName) - - return nil -} - -// describeStatusShort converts a full status line into a short degradation phrase. -func describeStatusShort(statusLine string, count int32) string { - _ = count - switch { - case strings.Contains(statusLine, "quota exceeded"): - return "quota exceeded" - case strings.Contains(statusLine, "network provisioning in progress"): - return "network provisioning in progress" - case strings.Contains(statusLine, "network provisioning"): - return "network provisioning" - case statusLine == "Starting": - return "starting" - case statusLine == "Stopping": - return "stopping" - default: - return statusLine - } -} diff --git a/internal/cmd/compute/workloads/workloads.go b/internal/cmd/compute/workloads/workloads.go new file mode 100644 index 00000000..f5e3b952 --- /dev/null +++ b/internal/cmd/compute/workloads/workloads.go @@ -0,0 +1,502 @@ +package workloads + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/cmd/compute/util" +) + +// Command returns the top-level "workloads" command group. +func Command() *cobra.Command { + cmd := &cobra.Command{ + Use: "workloads", + Short: "List or inspect workloads", + Long: `List all workloads in the project, optionally filtered by health or city. +Use the describe subcommand for a unified config + health view of a single workload.`, + Example: ` # List all workloads + datumctl compute workloads + + # Filter by health + datumctl compute workloads --health=degraded + + # Filter by city + datumctl compute workloads --city=DFW + + # Machine-readable output + datumctl compute workloads -o json + + # Describe a single workload + datumctl compute workloads describe api`, + RunE: func(cmd *cobra.Command, args []string) error { + return runList(cmd, args) + }, + } + + // List flags. + cmd.Flags().String("health", "", "Filter by health: available, degraded, progressing, unknown") + cmd.Flags().String("city", "", "Filter to workloads with a placement in this city") + cmd.Flags().StringP("output", "o", "table", "Output format: table, wide, json, yaml") + cmd.Flags().Bool("no-headers", false, "Omit the table header row (table and wide only)") + + _ = cmd.RegisterFlagCompletionFunc("health", util.CompleteOutputFormats("available", "degraded", "progressing", "unknown")) + _ = cmd.RegisterFlagCompletionFunc("city", util.CompleteCityCodes) + _ = cmd.RegisterFlagCompletionFunc("output", util.CompleteOutputFormats("table", "wide", "json", "yaml")) + + cmd.AddCommand(describeCommand()) + + return cmd +} + +// ----------------------------------------------------------------------- +// workloads list +// ----------------------------------------------------------------------- + +func runList(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + project := util.ProjectFromCmd(cmd) + + outputFlag, _ := cmd.Flags().GetString("output") + healthFilter, _ := cmd.Flags().GetString("health") + cityFilter, _ := cmd.Flags().GetString("city") + noHeaders, _ := cmd.Flags().GetBool("no-headers") + + c, err := util.NewClient(project) + if err != nil { + return err + } + + var wlList computev1alpha.WorkloadList + if err := c.List(ctx, &wlList, client.InNamespace(util.ResourceNamespace)); err != nil { + return fmt.Errorf("listing workloads: %w", err) + } + + // JSON/YAML: emit raw API resource and return early. + switch util.OutputFormat(outputFlag) { + case util.OutputJSON: + return util.PrintJSON(cmd.OutOrStdout(), &wlList) + case util.OutputYAML: + return util.PrintYAML(cmd.OutOrStdout(), &wlList) + } + + // For table output we need deployment data to compute READY counts. + var deployList computev1alpha.WorkloadDeploymentList + if err := c.List(ctx, &deployList, client.InNamespace(util.ResourceNamespace)); err != nil { + return fmt.Errorf("listing deployments: %w", err) + } + + // Build map: workloadUID → []WorkloadDeployment. + deploysByWorkload := make(map[string][]computev1alpha.WorkloadDeployment) + for _, d := range deployList.Items { + wUID := d.Labels[computev1alpha.WorkloadUIDLabel] + deploysByWorkload[wUID] = append(deploysByWorkload[wUID], d) + } + + // City filter: collect the set of workload UIDs that have a deployment in + // the requested city code. + cityFilteredUIDs := map[string]bool{} + if cityFilter != "" { + for _, d := range deployList.Items { + if d.Spec.CityCode == cityFilter { + wUID := d.Labels[computev1alpha.WorkloadUIDLabel] + cityFilteredUIDs[wUID] = true + } + } + } + + type workloadRow struct { + name string + health string + healthShort string // first word, for filter comparison + readyStr string + placements string + image string + age string + revision string // wide only + instType string // wide only + } + + var rows []workloadRow + healthCounts := map[string]int{ + "Available": 0, + "Degraded": 0, + "Progressing": 0, + "Unknown": 0, + "Unavailable": 0, + } + + for _, wl := range wlList.Items { + wUID := string(wl.UID) + + // City filter. + if cityFilter != "" && !cityFilteredUIDs[wUID] { + continue + } + + deps := deploysByWorkload[wUID] + var totalReady, totalDesired int32 + for _, d := range deps { + totalReady += d.Status.ReadyReplicas + totalDesired += d.Status.DesiredReplicas + } + + health := util.WorkloadHealth(wl.Status.Conditions, totalReady, totalDesired) + healthShort := strings.SplitN(health, " ", 2)[0] // e.g. "Available", "Degraded" + + // Tally health counts (use short word). + switch healthShort { + case "Available": + healthCounts["Available"]++ + case "Degraded": + healthCounts["Degraded"]++ + case "Unavailable": + healthCounts["Unavailable"]++ + default: + healthCounts["Unknown"]++ + } + + // Health filter. + if healthFilter != "" && !strings.EqualFold(healthShort, healthFilter) { + continue + } + + // Placement names. + var placementNames []string + for _, p := range wl.Spec.Placements { + placementNames = append(placementNames, p.Name) + } + placements := strings.Join(placementNames, ", ") + if placements == "" { + placements = "(none)" + } + + // Image from first container. + image := "(vm)" + if wl.Spec.Template.Spec.Runtime.Sandbox != nil && + len(wl.Spec.Template.Spec.Runtime.Sandbox.Containers) > 0 { + image = truncateImage(wl.Spec.Template.Spec.Runtime.Sandbox.Containers[0].Image) + } + + readyStr := fmt.Sprintf("%d/%d", totalReady, totalDesired) + + instType := wl.Spec.Template.Spec.Runtime.Resources.InstanceType + + // Revision — best-effort from ConfigMap annotation. + revision := "—" + var cm corev1.ConfigMap + cmName := "compute.datumapis.com-revision-history." + wl.Name + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: cmName}, &cm); err == nil { + if v, ok := cm.Annotations["compute.datumapis.com/current-revision"]; ok { + revision = v + } + } + + rows = append(rows, workloadRow{ + name: wl.Name, + health: health, + healthShort: healthShort, + readyStr: readyStr, + placements: placements, + image: image, + age: util.RelativeAge(wl.CreationTimestamp), + revision: revision, + instType: instType, + }) + } + + out := cmd.OutOrStdout() + + if len(rows) == 0 { + if healthFilter != "" { + fmt.Fprintf(out, "No workloads in project %s match health=%s.\n", project, healthFilter) + } else if cityFilter != "" { + fmt.Fprintf(out, "No workloads in project %s have a placement in city %s.\n", project, cityFilter) + } else { + fmt.Fprintf(out, "No workloads found in project %s.\n\n", project) + fmt.Fprintf(out, "Get started:\n") + fmt.Fprintf(out, " datumctl compute deploy --name=api --image=ghcr.io/acme/api:v1.0.0 --city=DFW\n") + } + return nil + } + + tw := util.NewTabWriter(out) + wide := util.OutputFormat(outputFlag) == util.OutputWide + if !noHeaders { + if wide { + fmt.Fprintf(tw, "NAME\tHEALTH\tREADY\tPLACEMENTS\tIMAGE\tAGE\tREVISION\tINSTANCE TYPE\n") + } else { + fmt.Fprintf(tw, "NAME\tHEALTH\tREADY\tPLACEMENTS\tIMAGE\tAGE\n") + } + } + for _, r := range rows { + if wide { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + r.name, r.healthShort, r.readyStr, r.placements, r.image, r.age, r.revision, r.instType) + } else { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + r.name, r.healthShort, r.readyStr, r.placements, r.image, r.age) + } + } + _ = tw.Flush() + + // Footer summary. + fmt.Fprintf(out, "\n%d workloads — %d Available, %d Degraded, %d Unavailable, %d Unknown\n", + len(rows), + healthCounts["Available"], + healthCounts["Degraded"], + healthCounts["Unavailable"], + healthCounts["Unknown"], + ) + + return nil +} + +// truncateImage strips the registry host from an image reference so the table +// column stays compact. "ghcr.io/acme/api:v1" → "acme/api:v1". +func truncateImage(image string) string { + parts := strings.SplitN(image, "/", 2) + if len(parts) == 2 { + // Only strip the first component if it looks like a registry host + // (contains a '.' or ':' — as opposed to a Docker Hub org name). + host := parts[0] + if strings.ContainsAny(host, ".:") { + return parts[1] + } + } + return image +} + +// ----------------------------------------------------------------------- +// workloads describe +// ----------------------------------------------------------------------- + +func describeCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "describe ", + Short: "Show config and health for a single workload", + Long: `Display a unified view of workload configuration (container spec, scale settings) +and runtime health (per-city ready/desired counts). Replaces 'datumctl compute status'.`, + Args: cobra.ExactArgs(1), + Example: ` datumctl compute workloads describe api`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDescribe(cmd, args) + }, + ValidArgsFunction: util.CompleteWorkloadNames, + } + + cmd.Flags().StringP("output", "o", "wide", "Output format: wide, json, yaml") + _ = cmd.RegisterFlagCompletionFunc("output", util.CompleteOutputFormats("wide", "json", "yaml")) + + return cmd +} + +func runDescribe(cmd *cobra.Command, args []string) error { + ctx := context.Background() + project := util.ProjectFromCmd(cmd) + outputFlag, _ := cmd.Flags().GetString("output") + + c, err := util.NewClient(project) + if err != nil { + return err + } + + workloadName := args[0] + + var wl computev1alpha.Workload + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &wl); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("workload %q not found in project %s", workloadName, project) + } + return fmt.Errorf("getting workload: %w", err) + } + + // JSON / YAML: emit the raw resource. + switch util.OutputFormat(outputFlag) { + case util.OutputJSON: + return util.PrintJSON(cmd.OutOrStdout(), &wl) + case util.OutputYAML: + return util.PrintYAML(cmd.OutOrStdout(), &wl) + } + + // List deployments for this workload. + selector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadUIDLabel: string(wl.UID)}) + var deployList computev1alpha.WorkloadDeploymentList + if err := c.List(ctx, &deployList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: selector}); err != nil { + return fmt.Errorf("listing deployments: %w", err) + } + + // Compute totals for health. + var totalDesired, totalReady int32 + for _, d := range deployList.Items { + totalDesired += d.Status.DesiredReplicas + totalReady += d.Status.ReadyReplicas + } + + health := util.WorkloadHealth(wl.Status.Conditions, totalReady, totalDesired) + + // Fetch revision. + revision := "—" + var cm corev1.ConfigMap + cmName := "compute.datumapis.com-revision-history." + workloadName + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: cmName}, &cm); err == nil { + if v, ok := cm.Annotations["compute.datumapis.com/current-revision"]; ok { + revision = v + } + } + + // Determine type label. + typeLabel := "virtual-machine" + if wl.Spec.Template.Spec.Runtime.Sandbox != nil { + instType := wl.Spec.Template.Spec.Runtime.Resources.InstanceType + if instType != "" { + typeLabel = "sandbox/" + instType + } else { + typeLabel = "sandbox" + } + } + + age := util.RelativeAgeVerbose(wl.CreationTimestamp) + + out := cmd.OutOrStdout() + + // Header block. + fmt.Fprintf(out, "%-12s %-31s project: %s\n", "Workload", workloadName, project) + fmt.Fprintf(out, "%-12s %s\n", "Type", typeLabel) + fmt.Fprintf(out, "%-12s %-31s Revision #%s\n", "Updated", age, revision) + fmt.Fprintf(out, "\n") + fmt.Fprintf(out, "%-12s %s\n", "Health", health) + fmt.Fprintf(out, "\n") + + // Placements block. + fmt.Fprintf(out, "Placements\n") + if len(wl.Spec.Placements) == 0 { + fmt.Fprintf(out, " (none configured — workload will not run anywhere)\n") + } else { + // Build a map: placementName → []WorkloadDeployment. + deplsByPlacement := make(map[string][]computev1alpha.WorkloadDeployment) + for _, d := range deployList.Items { + deplsByPlacement[d.Spec.PlacementName] = append(deplsByPlacement[d.Spec.PlacementName], d) + } + + for _, p := range wl.Spec.Placements { + // Placement header line. + maxStr := "∞" + if p.ScaleSettings.MaxReplicas != nil { + maxStr = fmt.Sprintf("%d", *p.ScaleSettings.MaxReplicas) + } + cityCodes := strings.Join(p.CityCodes, ", ") + fmt.Fprintf(out, " %-10s cities: %-24s scale: %d..%s\n", + p.Name, cityCodes, p.ScaleSettings.MinReplicas, maxStr) + + // Per-city lines from deployments. + for _, d := range deplsByPlacement[p.Name] { + readyStr := fmt.Sprintf("%d/%d", d.Status.ReadyReplicas, d.Status.DesiredReplicas) + annotation := "" + if d.Status.ReadyReplicas < d.Status.DesiredReplicas { + // Find a reason from instances — best-effort. + annotation = degradedAnnotation(ctx, c, d) + } + if annotation != "" { + fmt.Fprintf(out, " %-8s ready: %-10s %s\n", d.Spec.CityCode, readyStr, annotation) + } else { + fmt.Fprintf(out, " %-8s ready: %s\n", d.Spec.CityCode, readyStr) + } + } + } + } + fmt.Fprintf(out, "\n") + + // Container block (sandbox only). + if wl.Spec.Template.Spec.Runtime.Sandbox != nil && len(wl.Spec.Template.Spec.Runtime.Sandbox.Containers) > 0 { + ctr := wl.Spec.Template.Spec.Runtime.Sandbox.Containers[0] + fmt.Fprintf(out, "Container\n") + fmt.Fprintf(out, " %-10s %s\n", "Image", ctr.Image) + + if len(ctr.Ports) > 0 { + var portStrs []string + for _, p := range ctr.Ports { + proto := "TCP" + if p.Protocol != nil { + proto = string(*p.Protocol) + } + portStrs = append(portStrs, fmt.Sprintf("%d/%s", p.Port, proto)) + } + fmt.Fprintf(out, " %-10s %s\n", "Ports", strings.Join(portStrs, ", ")) + } + + if len(ctr.Env) > 0 { + fmt.Fprintf(out, " Env\n") + for _, e := range ctr.Env { + fmt.Fprintf(out, " %s\n", formatEnvVar(e)) + } + } + + // Resources. + instType := wl.Spec.Template.Spec.Runtime.Resources.InstanceType + if instType != "" { + fmt.Fprintf(out, " %-10s %s\n", "Resources", instType) + } + + fmt.Fprintf(out, "\n") + } + + // Next steps. + fmt.Fprintf(out, "Next steps:\n") + fmt.Fprintf(out, " %-25s datumctl compute instances --workload=%s\n", "List instances:", workloadName) + fmt.Fprintf(out, " %-25s datumctl compute logs \n", "Stream logs:") + fmt.Fprintf(out, " %-25s datumctl compute rollout undo %s\n", "Roll back:", workloadName) + + return nil +} + +// degradedAnnotation looks up the first unhealthy instance for a deployment +// and returns a short annotation string, e.g. "Degraded — quota exceeded". +func degradedAnnotation(ctx context.Context, c client.Client, d computev1alpha.WorkloadDeployment) string { + depSelector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadDeploymentUIDLabel: string(d.UID)}) + var instList computev1alpha.InstanceList + if err := c.List(ctx, &instList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: depSelector}); err != nil { + return "" + } + + for _, inst := range instList.Items { + readyCond := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceReady) + if readyCond != nil && readyCond.Status == "True" { + continue + } + quotaCond := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceQuotaGranted) + if quotaCond != nil && quotaCond.Reason == computev1alpha.InstanceQuotaGrantedReasonQuotaExceeded { + msg := quotaCond.Message + if msg != "" { + return "Degraded — quota exceeded (" + msg + ")" + } + return "Degraded — quota exceeded" + } + statusLine, _ := util.InstanceStatusDetail(inst.Status.Conditions) + if statusLine != "" && statusLine != "Unknown" { + return "Degraded — " + strings.ToLower(statusLine) + } + } + return "" +} + +// formatEnvVar renders a single EnvVar for display. +func formatEnvVar(e corev1.EnvVar) string { + if e.ValueFrom != nil { + if e.ValueFrom.SecretKeyRef != nil { + return fmt.Sprintf("%-20s from secret %s", e.Name, e.ValueFrom.SecretKeyRef.Name) + } + if e.ValueFrom.ConfigMapKeyRef != nil { + return fmt.Sprintf("%-20s from configmap %s", e.Name, e.ValueFrom.ConfigMapKeyRef.Name) + } + } + return fmt.Sprintf("%-20s %s", e.Name, e.Value) +} From 886e8dbc572296852e80b0614ccb31830c73b913 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 26 May 2026 19:39:23 -0500 Subject: [PATCH 15/27] fix: replace interface{} with any in printer helpers Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/compute/util/printer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmd/compute/util/printer.go b/internal/cmd/compute/util/printer.go index a0cc94e6..df82be7f 100644 --- a/internal/cmd/compute/util/printer.go +++ b/internal/cmd/compute/util/printer.go @@ -19,7 +19,7 @@ const ( ) // PrintJSON serialises obj to JSON and writes it to w. -func PrintJSON(w io.Writer, obj interface{}) error { +func PrintJSON(w io.Writer, obj any) error { enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(obj); err != nil { @@ -29,7 +29,7 @@ func PrintJSON(w io.Writer, obj interface{}) error { } // PrintYAML serialises obj to YAML and writes it to w. -func PrintYAML(w io.Writer, obj interface{}) error { +func PrintYAML(w io.Writer, obj any) error { b, err := yaml.Marshal(obj) if err != nil { return fmt.Errorf("encoding YAML: %w", err) From ba48aa1f98cd0e0b04f8214f048a66805d344035 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 26 May 2026 19:47:06 -0500 Subject: [PATCH 16/27] fix: address code review findings in workloads and instances commands - Fix duplicate TYPE/INSTANCE TYPE columns in instances -o wide (W3): populate TYPE from runtimeKind (sandbox/vm), INSTANCE TYPE from instType - Fix footer bucketing in instances list (W4): compute Running/Pending/Failed from actual status strings instead of hardcoding Failed=0 - Skip revision ConfigMap Gets in workloads list table mode (W5): only fetch per-workload revision when -o wide is requested, avoiding N round-trips on every list invocation - Compute health footer tallies after filters are applied (W9): previously counted all workloads then printed a filtered subset, making the summary misleading when --health or --city filters were active - Fix gofmt import ordering in workloads.go (B1) Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/compute/instances/instances.go | 55 +++++++++++-------- internal/cmd/compute/workloads/workloads.go | 59 +++++++++++---------- 2 files changed, 64 insertions(+), 50 deletions(-) diff --git a/internal/cmd/compute/instances/instances.go b/internal/cmd/compute/instances/instances.go index e21874da..1a378714 100644 --- a/internal/cmd/compute/instances/instances.go +++ b/internal/cmd/compute/instances/instances.go @@ -64,14 +64,15 @@ Use the describe subcommand for full details on a single instance.`, } type instanceRow struct { - name string - workload string - city string - externalIP string - internalIP string - instType string - age string - status string + name string + workload string + city string + externalIP string + internalIP string + runtimeKind string // "sandbox" or "vm" + instType string + age string + status string } func runList(cmd *cobra.Command, opts *listOptions) error { @@ -173,15 +174,21 @@ func runList(cmd *cobra.Command, opts *listOptions) error { } } + runtimeKind := "vm" + if inst.Spec.Runtime.Sandbox != nil { + runtimeKind = "sandbox" + } + rows = append(rows, instanceRow{ - name: inst.Name, - workload: wlName, - city: city, - externalIP: extIP, - internalIP: intIP, - instType: inst.Spec.Runtime.Resources.InstanceType, - age: util.RelativeAge(inst.CreationTimestamp), - status: util.InstanceStatus(inst.Status.Conditions), + name: inst.Name, + workload: wlName, + city: city, + externalIP: extIP, + internalIP: intIP, + runtimeKind: runtimeKind, + instType: inst.Spec.Runtime.Resources.InstanceType, + age: util.RelativeAge(inst.CreationTimestamp), + status: util.InstanceStatus(inst.Status.Conditions), }) } @@ -214,22 +221,26 @@ func runList(cmd *cobra.Command, opts *listOptions) error { for _, r := range rows { if wide { _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - r.name, r.workload, r.city, r.externalIP, r.internalIP, r.instType, r.age, r.status, r.instType) + r.name, r.workload, r.city, r.externalIP, r.internalIP, r.runtimeKind, r.age, r.status, r.instType) } else { _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - r.name, r.workload, r.city, r.externalIP, r.internalIP, r.instType, r.age, r.status) + r.name, r.workload, r.city, r.externalIP, r.internalIP, r.runtimeKind, r.age, r.status) } } _ = tw.Flush() - running := 0 + var running, pending, failed int for _, r := range rows { - if r.status == "Running" { + switch { + case r.status == "Running": running++ + case strings.HasPrefix(r.status, "Failed"): + failed++ + default: + pending++ } } - pending := len(rows) - running - _, _ = fmt.Fprintf(out, "\n%d instances — %d Running, %d Pending, 0 Failed\n", len(rows), running, pending) + _, _ = fmt.Fprintf(out, "\n%d instances — %d Running, %d Pending, %d Failed\n", len(rows), running, pending, failed) return nil } diff --git a/internal/cmd/compute/workloads/workloads.go b/internal/cmd/compute/workloads/workloads.go index f5e3b952..4278508d 100644 --- a/internal/cmd/compute/workloads/workloads.go +++ b/internal/cmd/compute/workloads/workloads.go @@ -6,10 +6,10 @@ import ( "strings" "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" - corev1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" computev1alpha "go.datum.net/compute/api/v1alpha" @@ -125,14 +125,9 @@ func runList(cmd *cobra.Command, _ []string) error { instType string // wide only } + wide := util.OutputFormat(outputFlag) == util.OutputWide + var rows []workloadRow - healthCounts := map[string]int{ - "Available": 0, - "Degraded": 0, - "Progressing": 0, - "Unknown": 0, - "Unavailable": 0, - } for _, wl := range wlList.Items { wUID := string(wl.UID) @@ -152,18 +147,6 @@ func runList(cmd *cobra.Command, _ []string) error { health := util.WorkloadHealth(wl.Status.Conditions, totalReady, totalDesired) healthShort := strings.SplitN(health, " ", 2)[0] // e.g. "Available", "Degraded" - // Tally health counts (use short word). - switch healthShort { - case "Available": - healthCounts["Available"]++ - case "Degraded": - healthCounts["Degraded"]++ - case "Unavailable": - healthCounts["Unavailable"]++ - default: - healthCounts["Unknown"]++ - } - // Health filter. if healthFilter != "" && !strings.EqualFold(healthShort, healthFilter) { continue @@ -187,16 +170,17 @@ func runList(cmd *cobra.Command, _ []string) error { } readyStr := fmt.Sprintf("%d/%d", totalReady, totalDesired) - instType := wl.Spec.Template.Spec.Runtime.Resources.InstanceType - // Revision — best-effort from ConfigMap annotation. + // Revision — only fetched for -o wide to avoid N round-trips in table mode. revision := "—" - var cm corev1.ConfigMap - cmName := "compute.datumapis.com-revision-history." + wl.Name - if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: cmName}, &cm); err == nil { - if v, ok := cm.Annotations["compute.datumapis.com/current-revision"]; ok { - revision = v + if wide { + var cm corev1.ConfigMap + cmName := "compute.datumapis.com-revision-history." + wl.Name + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: cmName}, &cm); err == nil { + if v, ok := cm.Annotations["compute.datumapis.com/current-revision"]; ok { + revision = v + } } } @@ -213,6 +197,26 @@ func runList(cmd *cobra.Command, _ []string) error { }) } + // Tally health counts from the filtered rows (W9: count after filtering). + healthCounts := map[string]int{ + "Available": 0, + "Degraded": 0, + "Unavailable": 0, + "Unknown": 0, + } + for _, r := range rows { + switch r.healthShort { + case "Available": + healthCounts["Available"]++ + case "Degraded": + healthCounts["Degraded"]++ + case "Unavailable": + healthCounts["Unavailable"]++ + default: + healthCounts["Unknown"]++ + } + } + out := cmd.OutOrStdout() if len(rows) == 0 { @@ -229,7 +233,6 @@ func runList(cmd *cobra.Command, _ []string) error { } tw := util.NewTabWriter(out) - wide := util.OutputFormat(outputFlag) == util.OutputWide if !noHeaders { if wide { fmt.Fprintf(tw, "NAME\tHEALTH\tREADY\tPLACEMENTS\tIMAGE\tAGE\tREVISION\tINSTANCE TYPE\n") From ac044f74d9875821fa938ac0337d256f7dd189a3 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 27 May 2026 19:58:17 -0500 Subject: [PATCH 17/27] feat: add network preflight check with interactive create prompt Before creating a workload, the deploy command now checks whether the required network(s) exist. If a network is missing, the user is offered the option to create a minimal auto-IPAM network in-place rather than hitting an opaque NetworkNotFound error post-submission. Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/compute/deploy/deploy.go | 59 +++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/internal/cmd/compute/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go index 63b18bf1..4aa12414 100644 --- a/internal/cmd/compute/deploy/deploy.go +++ b/internal/cmd/compute/deploy/deploy.go @@ -25,6 +25,7 @@ import ( "go.datum.net/compute/internal/cmd/compute/util" "go.datum.net/compute/internal/cmd/compute/watch" networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + "sigs.k8s.io/controller-runtime/pkg/client" ) type options struct { @@ -111,6 +112,10 @@ func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) err ctx := context.Background() out := cmd.OutOrStdout() + if err := ensureNetwork(ctx, cmd, c, "default", project, opts); err != nil { + return err + } + fmt.Fprintf(out, "Resolving workload %q in project %s...\n", workloadName, project) var workload computev1alpha.Workload @@ -268,6 +273,12 @@ func deployFromFile(cmd *cobra.Command, opts *options) error { ctx := context.Background() out := cmd.OutOrStdout() + for _, iface := range workload.Spec.Template.Spec.NetworkInterfaces { + if err := ensureNetwork(ctx, cmd, c, iface.Network.Name, project, opts); err != nil { + return err + } + } + var existing computev1alpha.Workload creating := false if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workload.Name}, &existing); err != nil { @@ -401,6 +412,54 @@ func computeDiff(existing computev1alpha.WorkloadSpec, newImage string, _ []stri return strings.Join(parts, ", ") } +// ensureNetwork checks if the named network exists and, if not, offers to create it. +// It creates a minimal auto-IPAM IPv4 network on behalf of the user. +func ensureNetwork(ctx context.Context, cmd *cobra.Command, c client.Client, networkName, project string, opts *options) error { + var network networkingv1alpha.Network + err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: networkName}, &network) + if err == nil { + return nil + } + if !k8serrors.IsNotFound(err) { + return fmt.Errorf("checking network %q: %w", networkName, err) + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, " Network %q does not exist in project %s.\n", networkName, project) + + if !opts.yes && term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprintf(out, " Create it now? (Y/n): ") + line, readErr := bufio.NewReader(os.Stdin).ReadString('\n') + if readErr != nil { + return fmt.Errorf("reading confirmation: %w", readErr) + } + line = strings.TrimSpace(line) + if line == "n" || line == "N" { + return fmt.Errorf("network %q is required — create it with: datumctl apply -f network.yaml --project %s", networkName, project) + } + } else if !opts.yes { + return fmt.Errorf("network %q not found in project %s — use --yes to auto-create or create it first", networkName, project) + } + + ipv4Mode := networkingv1alpha.NetworkIPAMModeAuto + newNetwork := networkingv1alpha.Network{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: util.ResourceNamespace, + Name: networkName, + }, + Spec: networkingv1alpha.NetworkSpec{ + IPAM: networkingv1alpha.NetworkIPAM{ + Mode: ipv4Mode, + }, + }, + } + if err := c.Create(ctx, &newNetwork); err != nil { + return fmt.Errorf("creating network %q: %w", networkName, err) + } + fmt.Fprintf(out, " network/%s created\n", networkName) + return nil +} + // manifestDiff computes diff lines between an existing and desired workload. func manifestDiff(existing, desired computev1alpha.Workload) []string { var lines []string From d8486ec1a6fded4bf338f64292a27235e3332a1d Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 27 May 2026 22:44:07 -0500 Subject: [PATCH 18/27] feat: add entitlement check and rewrite quota against AllowanceBucket API - Add EnsureComputeEntitlement to gate all compute commands on an active service entitlement; prompts TTY users to request access and surfaces approval status - Rewrite quota command to query AllowanceBucket resources from the project VCP (milo-system namespace) instead of deriving usage from instance quota conditions - Add NewPlatformClient targeting the platform API server for ResourceRegistration lookups - Extract ListServiceQuota into util so other service plugins can reuse the quota display logic with their own resource type prefix and display metadata overrides Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/compute/quota/quota.go | 229 +++++------------------ internal/cmd/compute/root.go | 6 + internal/cmd/compute/util/client.go | 30 +++ internal/cmd/compute/util/entitlement.go | 222 ++++++++++++++++++++++ internal/cmd/compute/util/quota.go | 164 ++++++++++++++++ 5 files changed, 474 insertions(+), 177 deletions(-) create mode 100644 internal/cmd/compute/util/entitlement.go create mode 100644 internal/cmd/compute/util/quota.go diff --git a/internal/cmd/compute/quota/quota.go b/internal/cmd/compute/quota/quota.go index b125b18a..5993a428 100644 --- a/internal/cmd/compute/quota/quota.go +++ b/internal/cmd/compute/quota/quota.go @@ -3,200 +3,83 @@ package quota import ( "context" "fmt" - "regexp" - "sort" - "strconv" "github.com/spf13/cobra" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - computev1alpha "go.datum.net/compute/api/v1alpha" "go.datum.net/compute/internal/cmd/compute/util" ) -// availablePattern matches messages like "2 CPU available in IAD." or -// "2.5 available in DFW" to extract the numeric available quantity. -var availablePattern = regexp.MustCompile(`(\d+(?:\.\d+)?)\s+\w+\s+available`) +const resourceTypePrefix = "compute.datumapis.com" + +// orderedTypes controls display order in the quota table. +var orderedTypes = []string{ + "compute.datumapis.com/workloads", + "compute.datumapis.com/instances", + "compute.datumapis.com/vcpus", + "compute.datumapis.com/memory", +} + +// computeMeta provides display overrides for compute resource types. +// The live ResourceRegistrations use displayUnit "1" so we supply our own. +var computeMeta = map[string]util.QuotaMeta{ + "compute.datumapis.com/workloads": {DisplayName: "Workloads", Unit: "workloads", Divisor: 1}, + "compute.datumapis.com/instances": {DisplayName: "Instances", Unit: "instances", Divisor: 1}, + "compute.datumapis.com/vcpus": {DisplayName: "vCPUs", Unit: "vCPUs", Divisor: 1000}, + "compute.datumapis.com/memory": {DisplayName: "Memory", Unit: "MiB", Divisor: 1}, +} func Command() *cobra.Command { - var city string var constrained bool cmd := &cobra.Command{ Use: "quota", - Short: "Show compute quota usage for the current project", + Short: "Show compute quota for the current project", RunE: func(cmd *cobra.Command, args []string) error { - return runQuota(cmd, city, constrained) + return runQuota(cmd, constrained) }, } - cmd.Flags().StringVar(&city, "city", "", "Narrow output to a specific city") - cmd.Flags().BoolVar(&constrained, "constrained", false, "Show only constrained resources") + cmd.Flags().BoolVar(&constrained, "constrained", false, "Show only resource types that are at their limit") cmd.Flags().StringP("output", "o", "table", "Output format: table, json, yaml") - _ = cmd.RegisterFlagCompletionFunc("city", util.CompleteCityCodes) _ = cmd.RegisterFlagCompletionFunc("output", util.CompleteOutputFormats("table", "json", "yaml")) return cmd } -// quotaRow is a structured representation of a single quota row, used for -// JSON/YAML serialisation. -type quotaRow struct { - City string `json:"city"` - InstanceType string `json:"instanceType"` - InUse int `json:"inUse"` - Limit string `json:"limit"` - Available string `json:"available"` - AtLimit bool `json:"atLimit"` -} - -type groupKey struct { - city string - instanceType string -} - -func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { +func runQuota(cmd *cobra.Command, constrained bool) error { project := util.ProjectFromCmd(cmd) outputFlag, _ := cmd.Flags().GetString("output") - c, err := util.NewClient(project) + projectClient, err := util.NewClient(project) if err != nil { return err } - ctx := context.Background() - - // List all instances in the project. - var instList computev1alpha.InstanceList - if err := c.List(ctx, &instList, client.InNamespace(util.ResourceNamespace)); err != nil { - return fmt.Errorf("listing instances: %w", err) - } - - // List all deployments to build a UID → city/instanceType lookup. - var deployList computev1alpha.WorkloadDeploymentList - if err := c.List(ctx, &deployList, client.InNamespace(util.ResourceNamespace)); err != nil { - return fmt.Errorf("listing deployments: %w", err) - } - - // Build map: deploymentUID → deployment. - deployByUID := make(map[string]computev1alpha.WorkloadDeployment, len(deployList.Items)) - for _, d := range deployList.Items { - deployByUID[string(d.UID)] = d - } - - type groupData struct { - count int - atLimit bool - limitMsg string - } - - groups := make(map[groupKey]*groupData) - - for _, inst := range instList.Items { - // Resolve city from deployment label. - depUID := inst.Labels[computev1alpha.WorkloadDeploymentUIDLabel] - dep, ok := deployByUID[depUID] - if !ok { - continue - } - city := dep.Spec.CityCode - instanceType := dep.Spec.Template.Spec.Runtime.Resources.InstanceType - if instanceType == "" { - instanceType = "unknown" - } - - k := groupKey{city: city, instanceType: instanceType} - gd := groups[k] - if gd == nil { - gd = &groupData{} - groups[k] = gd - } - gd.count++ - - // Check quota condition. - quotaCond := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceQuotaGranted) - if quotaCond != nil && - quotaCond.Status == metav1.ConditionFalse && - quotaCond.Reason == computev1alpha.InstanceQuotaGrantedReasonQuotaExceeded { - gd.atLimit = true - if quotaCond.Message != "" { - gd.limitMsg = quotaCond.Message - } - } - } - - // Build sorted keys. - var keys []groupKey - for k := range groups { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { - if keys[i].city != keys[j].city { - return keys[i].city < keys[j].city - } - return keys[i].instanceType < keys[j].instanceType - }) - - // Filter by city. - if filterCity != "" { - var filtered []groupKey - for _, k := range keys { - if k.city == filterCity { - filtered = append(filtered, k) - } - } - keys = filtered + platformClient, err := util.NewPlatformClient() + if err != nil { + return err } - out := cmd.OutOrStdout() + ctx := context.Background() - // Before filtering by constrained, check if there are any instances at all. - if len(instList.Items) == 0 { - _, _ = fmt.Fprint(out, "No instances running. No quota consumption to display.\n") - return nil + rows, err := util.ListServiceQuota(ctx, projectClient, platformClient, resourceTypePrefix, computeMeta, orderedTypes) + if err != nil { + return fmt.Errorf("listing quota: %w", err) } - // Filter by constrained. if constrained { - var filtered []groupKey - for _, k := range keys { - if groups[k].atLimit { - filtered = append(filtered, k) + filtered := rows[:0] + for _, r := range rows { + if r.Available == 0 { + filtered = append(filtered, r) } } - if len(filtered) == 0 { - _, _ = fmt.Fprint(out, "No constrained resources found.\n") - return nil - } - keys = filtered + rows = filtered } - // Build structured rows for serialisation or table rendering. - var rows []quotaRow - for _, k := range keys { - gd := groups[k] - limit := "—" - available := "—" - if gd.limitMsg != "" { - avail, ok := parseAvailable(gd.limitMsg) - if ok { - available = strconv.Itoa(avail) - limit = strconv.Itoa(gd.count + avail) - } - } - rows = append(rows, quotaRow{ - City: k.city, - InstanceType: k.instanceType, - InUse: gd.count, - Limit: limit, - Available: available, - AtLimit: gd.atLimit, - }) - } + out := cmd.OutOrStdout() - // JSON/YAML output. switch util.OutputFormat(outputFlag) { case util.OutputJSON: return util.PrintJSON(out, rows) @@ -204,36 +87,28 @@ func runQuota(cmd *cobra.Command, filterCity string, constrained bool) error { return util.PrintYAML(out, rows) } - // Table output. - _, _ = fmt.Fprintf(out, "Quota usage for project %s\n\n", project) + if len(rows) == 0 { + if constrained { + _, _ = fmt.Fprintln(out, "No resource types are at their limit.") + } else { + _, _ = fmt.Fprintln(out, "No quota configured for this project.") + } + return nil + } - tw := util.NewTabWriter(out) - _, _ = fmt.Fprintf(tw, "CITY\tTYPE\tIN USE\tLIMIT\tAVAILABLE\n") + _, _ = fmt.Fprintf(out, "Quota for project %s\n\n", project) + tw := util.NewTabWriter(out) + _, _ = fmt.Fprintf(tw, "RESOURCE\tUNIT\tLIMIT\tUSED\tAVAILABLE\n") for _, r := range rows { - cityLabel := r.City - if r.AtLimit { - cityLabel += " [at limit]" + atLimit := "" + if r.Available == 0 { + atLimit = " [at limit]" } - _, _ = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n", cityLabel, r.InstanceType, r.InUse, r.Limit, r.Available) + _, _ = fmt.Fprintf(tw, "%s%s\t%s\t%d\t%d\t%d\n", + r.DisplayName, atLimit, r.Unit, r.Limit, r.Used, r.Available) } _ = tw.Flush() - _, _ = fmt.Fprint(out, "\nNote: limit information is derived from quota conditions on instances.\nRun 'datumctl quota' for full project quota management.\n") - return nil } - -// parseAvailable extracts the integer available count from a quota condition -// message such as "Requested 4 CPU. 2 CPU available in IAD." -func parseAvailable(msg string) (int, bool) { - m := availablePattern.FindStringSubmatch(msg) - if m == nil { - return 0, false - } - f, err := strconv.ParseFloat(m[1], 64) - if err != nil { - return 0, false - } - return int(f), true -} diff --git a/internal/cmd/compute/root.go b/internal/cmd/compute/root.go index db0a777a..7dbc2ccc 100644 --- a/internal/cmd/compute/root.go +++ b/internal/cmd/compute/root.go @@ -11,11 +11,17 @@ import ( "go.datum.net/compute/internal/cmd/compute/restart" "go.datum.net/compute/internal/cmd/compute/rollout" "go.datum.net/compute/internal/cmd/compute/scale" + "go.datum.net/compute/internal/cmd/compute/util" "go.datum.net/compute/internal/cmd/compute/workloads" ) func Command() *cobra.Command { root := plugin.NewRootCmd("compute", "Deploy and manage containerized workloads on Datum Cloud") + root.SilenceUsage = true + + root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + return util.EnsureComputeEntitlement(cmd.Context(), util.ProjectFromCmd(cmd), cmd.InOrStdin(), cmd.ErrOrStderr()) + } root.AddCommand( deploy.Command(), diff --git a/internal/cmd/compute/util/client.go b/internal/cmd/compute/util/client.go index c41dc732..bd860bc3 100644 --- a/internal/cmd/compute/util/client.go +++ b/internal/cmd/compute/util/client.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" computev1alpha "go.datum.net/compute/api/v1alpha" "go.datum.net/datumctl/plugin" + quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" @@ -47,6 +48,9 @@ func NewClient(project string) (client.Client, error) { if err := computev1alpha.AddToScheme(scheme); err != nil { return nil, fmt.Errorf("registering compute scheme: %w", err) } + if err := quotav1alpha1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("registering quota scheme: %w", err) + } cfg := &rest.Config{ Host: ProjectControlPlaneURL(ctx.APIHost, project), @@ -56,6 +60,32 @@ func NewClient(project string) (client.Client, error) { return client.New(cfg, client.Options{Scheme: scheme}) } +// NewPlatformClient builds a Kubernetes client targeting the platform API server +// (not a project-scoped virtual control plane). +func NewPlatformClient() (client.Client, error) { + ctx := plugin.Context() + if ctx.APIHost == "" { + return nil, fmt.Errorf("DATUM_API_HOST is not set; is this plugin running via datumctl?") + } + + token, err := plugin.Token() + if err != nil { + return nil, fmt.Errorf("getting credentials: %w", err) + } + + scheme := runtime.NewScheme() + if err := quotav1alpha1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("registering quota scheme: %w", err) + } + + cfg := &rest.Config{ + Host: "https://" + ctx.APIHost, + BearerToken: token, + } + + return client.New(cfg, client.Options{Scheme: scheme}) +} + // ProjectFromCmd reads the --project persistent flag from the command's root. func ProjectFromCmd(cmd *cobra.Command) string { project, _ := cmd.Root().PersistentFlags().GetString("project") diff --git a/internal/cmd/compute/util/entitlement.go b/internal/cmd/compute/util/entitlement.go new file mode 100644 index 00000000..697329e8 --- /dev/null +++ b/internal/cmd/compute/util/entitlement.go @@ -0,0 +1,222 @@ +package util + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + + "go.datum.net/datumctl/plugin" + "golang.org/x/term" +) + +const computeServiceName = "compute" + +type entitlementList struct { + Items []entitlementItem `json:"items"` +} + +type entitlementItem struct { + Spec struct { + ServiceRef struct { + Name string `json:"name"` + } `json:"serviceRef"` + } `json:"spec"` + Status struct { + Phase string `json:"phase"` + } `json:"status"` +} + +// EnsureComputeEntitlement checks that the selected project has an active +// ServiceEntitlement for the compute service. If none exists, it prompts the +// user (via in/out) to request access. out should be cmd.ErrOrStderr() so the +// prompt does not pollute structured output. +func EnsureComputeEntitlement(ctx context.Context, project string, in io.Reader, out io.Writer) error { + if project == "" { + // NewClient will surface the missing-project error. + return nil + } + + pluginCtx := plugin.Context() + if pluginCtx.APIHost == "" { + return fmt.Errorf("DATUM_API_HOST is not set; is this plugin running via datumctl?") + } + + token, err := plugin.Token() + if err != nil { + return fmt.Errorf("getting credentials: %w", err) + } + + baseURL := ProjectControlPlaneURL(pluginCtx.APIHost, project) + list, err := listEntitlements(ctx, baseURL, token) + if err != nil { + return err + } + + for _, item := range list.Items { + if item.Spec.ServiceRef.Name != computeServiceName { + continue + } + switch item.Status.Phase { + case "Active": + return nil + case "PendingApproval": + return fmt.Errorf( + "compute service entitlement for project %q is pending approval\n\n"+ + "Check status with: datumctl services list", + project, + ) + case "Rejected": + return fmt.Errorf( + "compute service entitlement for project %q was rejected\n\n"+ + "Re-enable with: datumctl services enable %s", + project, computeServiceName, + ) + } + } + + // No entitlement found — prompt the user. + return promptAndRequestAccess(ctx, project, baseURL, token, in, out) +} + +func listEntitlements(ctx context.Context, baseURL, token string) (*entitlementList, error) { + url := baseURL + "/apis/services.miloapis.com/v1alpha1/serviceentitlements" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("building entitlement request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("checking service entitlement: %w", err) + } + defer resp.Body.Close() + + // 404 means the service-catalog API isn't installed in this project's VCP, + // which is equivalent to having no entitlement — fall through to the prompt. + if resp.StatusCode == http.StatusNotFound { + return &entitlementList{}, nil + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf( + "checking service entitlement: unexpected status %d", + resp.StatusCode, + ) + } + + var list entitlementList + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + return nil, fmt.Errorf("decoding service entitlements: %w", err) + } + return &list, nil +} + +func promptAndRequestAccess(ctx context.Context, project, baseURL, token string, in io.Reader, out io.Writer) error { + if !isTTY(in) { + return fmt.Errorf( + "compute service is not enabled for project %q\n\n"+ + "Enable it with: datumctl services enable %s", + project, computeServiceName, + ) + } + + fmt.Fprintf(out, "Compute is not enabled for project %q.\n", project) + fmt.Fprintf(out, "Would you like to request access? [y/N]: ") + + scanner := bufio.NewScanner(in) + if !scanner.Scan() { + return fmt.Errorf("compute service is not enabled for project %q", project) + } + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + return fmt.Errorf( + "compute service is not enabled for project %q\n\n"+ + "Enable it with: datumctl services enable %s", + project, computeServiceName, + ) + } + + fmt.Fprintf(out, "Requesting access to compute for project %q...\n", project) + if err := createEntitlement(ctx, baseURL, token); err != nil { + return err + } + + // Re-fetch to determine the resulting phase. + list, err := listEntitlements(ctx, baseURL, token) + if err != nil { + return err + } + for _, item := range list.Items { + if item.Spec.ServiceRef.Name != computeServiceName { + continue + } + switch item.Status.Phase { + case "Active": + fmt.Fprintf(out, "Compute enabled for project %q.\n", project) + return nil + case "PendingApproval": + fmt.Fprintf(out, "\nYour request to enable compute for project %q has been submitted,\n", project) + fmt.Fprintf(out, "but it requires approval before you can use the service.\n") + fmt.Fprintf(out, "You will be notified when access is granted.\n\n") + fmt.Fprintf(out, "Check status with: datumctl services list\n") + return fmt.Errorf("compute access is pending approval") + } + } + + return fmt.Errorf("entitlement created but status is not yet available — check: datumctl services list") +} + +func createEntitlement(ctx context.Context, baseURL, token string) error { + body := map[string]any{ + "apiVersion": "services.miloapis.com/v1alpha1", + "kind": "ServiceEntitlement", + "metadata": map[string]any{ + "name": computeServiceName, + }, + "spec": map[string]any{ + "serviceRef": map[string]any{ + "name": computeServiceName, + }, + }, + } + + payload, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encoding entitlement request: %w", err) + } + + url := baseURL + "/apis/services.miloapis.com/v1alpha1/serviceentitlements" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("building create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("requesting compute access: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return fmt.Errorf("requesting compute access: unexpected status %d", resp.StatusCode) + } + return nil +} + +func isTTY(r io.Reader) bool { + f, ok := r.(*os.File) + if !ok { + return false + } + return term.IsTerminal(int(f.Fd())) +} diff --git a/internal/cmd/compute/util/quota.go b/internal/cmd/compute/util/quota.go new file mode 100644 index 00000000..8acf99ff --- /dev/null +++ b/internal/cmd/compute/util/quota.go @@ -0,0 +1,164 @@ +package util + +import ( + "context" + "strings" + + quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// QuotaRow holds display-ready quota data for one resource type. +type QuotaRow struct { + ResourceType string `json:"resourceType"` + DisplayName string `json:"displayName"` + Unit string `json:"unit"` + Limit int64 `json:"limit"` + Used int64 `json:"used"` + Available int64 `json:"available"` +} + +// QuotaMeta overrides display metadata for a resource type. When provided, +// DisplayName, Unit, and Divisor take precedence over ResourceRegistration values. +type QuotaMeta struct { + DisplayName string + Unit string + // Divisor converts the stored integer value to display units (e.g. 1000 for + // millicores → vCPUs). Zero is treated as 1. + Divisor int64 + // Order controls the position of this row in the returned slice (ascending). + // Rows without a meta entry sort after all meta rows, alphabetically. + Order int +} + +// ListServiceQuota returns quota rows for AllowanceBuckets whose resource type +// begins with resourceTypePrefix (e.g. "compute.datumapis.com"). projectClient +// must target the project's virtual control plane; platformClient must target +// the platform API server (used to fetch ResourceRegistrations for display +// metadata when no override is provided in meta). +// +// meta may be nil. When an entry exists for a resource type, its DisplayName, +// Unit, and Divisor are used; otherwise the ResourceRegistration's displayUnit +// field is used and the divisor defaults to 1. +func ListServiceQuota( + ctx context.Context, + projectClient, platformClient client.Client, + resourceTypePrefix string, + meta map[string]QuotaMeta, + orderedTypes []string, // explicit display order; types not in this list follow alphabetically +) ([]QuotaRow, error) { + // Fetch AllowanceBuckets from the project VCP. + var bucketList quotav1alpha1.AllowanceBucketList + if err := projectClient.List(ctx, &bucketList, + client.InNamespace("milo-system"), + client.MatchingLabels{"quota.miloapis.com/consumer-kind": "Project"}, + ); err != nil { + return nil, err + } + + // Index buckets by resource type, filtering to the requested prefix. + bucketByType := make(map[string]*quotav1alpha1.AllowanceBucket) + for i := range bucketList.Items { + b := &bucketList.Items[i] + if strings.HasPrefix(b.Spec.ResourceType, resourceTypePrefix) { + bucketByType[b.Spec.ResourceType] = b + } + } + + if len(bucketByType) == 0 { + return nil, nil + } + + // Fetch ResourceRegistrations from the platform for display metadata fallback. + rrByType := make(map[string]*quotav1alpha1.ResourceRegistration) + if platformClient != nil { + var rrList quotav1alpha1.ResourceRegistrationList + if err := platformClient.List(ctx, &rrList); err == nil { + for i := range rrList.Items { + rr := &rrList.Items[i] + if strings.HasPrefix(rr.Spec.ResourceType, resourceTypePrefix) { + rrByType[rr.Spec.ResourceType] = rr + } + } + } + } + + // Build an ordered index: position in orderedTypes slice. + orderIndex := make(map[string]int, len(orderedTypes)) + for i, rt := range orderedTypes { + orderIndex[rt] = i + } + + // Build rows in explicit order first, then append remaining alphabetically. + rows := make([]QuotaRow, 0, len(bucketByType)) + seen := make(map[string]bool, len(bucketByType)) + + appendRow := func(rt string, b *quotav1alpha1.AllowanceBucket) { + if seen[rt] { + return + } + seen[rt] = true + + displayName := resourceTypeSuffix(rt) + unit := "units" + var divisor int64 = 1 + + if m, ok := meta[rt]; ok { + if m.DisplayName != "" { + displayName = m.DisplayName + } + if m.Unit != "" { + unit = m.Unit + } + if m.Divisor > 1 { + divisor = m.Divisor + } + } else if rr, ok := rrByType[rt]; ok && rr.Spec.DisplayUnit != "" && rr.Spec.DisplayUnit != "1" { + unit = rr.Spec.DisplayUnit + } + + rows = append(rows, QuotaRow{ + ResourceType: rt, + DisplayName: displayName, + Unit: unit, + Limit: b.Status.Limit / divisor, + Used: b.Status.Allocated / divisor, + Available: b.Status.Available / divisor, + }) + } + + for _, rt := range orderedTypes { + if b, ok := bucketByType[rt]; ok { + appendRow(rt, b) + } + } + // Append any buckets not covered by orderedTypes. + remaining := make([]string, 0) + for rt := range bucketByType { + if !seen[rt] { + remaining = append(remaining, rt) + } + } + // Stable alphabetical order for the tail. + for i := 0; i < len(remaining)-1; i++ { + for j := i + 1; j < len(remaining); j++ { + if remaining[i] > remaining[j] { + remaining[i], remaining[j] = remaining[j], remaining[i] + } + } + } + for _, rt := range remaining { + appendRow(rt, bucketByType[rt]) + } + + return rows, nil +} + +// resourceTypeSuffix derives a human-readable name from the last segment of a +// resource type string (e.g. "compute.datumapis.com/vcpus" → "vcpus"). +func resourceTypeSuffix(resourceType string) string { + if idx := strings.LastIndex(resourceType, "/"); idx >= 0 { + return resourceType[idx+1:] + } + return resourceType +} From 4f0cd81d9764f08f4c8f9d197dc6356673d8a865 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Thu, 28 May 2026 12:11:40 -0500 Subject: [PATCH 19/27] feat: rewrite entitlement check with typed client-go watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hand-rolled HTTP entitlement code with a proper client-go implementation using go.miloapis.com/service-catalog types. Uses client.WithWatch to stream events from the API server and unblocks as soon as the Ready condition appears — no polling interval. Also adds ASCII progress bar to quota table output. Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 3 +- go.sum | 4 + internal/cmd/compute/quota/quota.go | 26 ++- internal/cmd/compute/util/entitlement.go | 208 ++++++++++------------- 4 files changed, 113 insertions(+), 128 deletions(-) diff --git a/go.mod b/go.mod index c697d25d..43cbc24c 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/stretchr/testify v1.11.1 go.datum.net/datumctl v0.14.1-0.20260523153711-b44de1c715c1 go.datum.net/network-services-operator v0.1.0 - go.miloapis.com/milo v0.24.11 + go.miloapis.com/milo v0.26.1-0.20260527023322-a78982bd81f2 golang.org/x/crypto v0.49.0 golang.org/x/sync v0.20.0 golang.org/x/term v0.43.0 @@ -130,6 +130,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/zalando/go-keyring v0.2.8 // indirect go.miloapis.com/activity v0.7.0 // indirect + go.miloapis.com/service-catalog v0.0.0-20260527221104-f8aeca153879 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect diff --git a/go.sum b/go.sum index 6a2d5d34..c9f614fa 100644 --- a/go.sum +++ b/go.sum @@ -283,6 +283,10 @@ go.miloapis.com/activity v0.7.0 h1:Nmc5XzA4oEMTko5/ciJAeERVk18FaSnRpTBo0Sm89YU= go.miloapis.com/activity v0.7.0/go.mod h1:Sh2Irbq6siJcfq17nLjHvm4JHN/2Csc5YCHB+ycz20c= go.miloapis.com/milo v0.24.11 h1:rByXDKbP4ZEN0I/z1C2RyUCyQi0NWrITLqoQILSAn2E= go.miloapis.com/milo v0.24.11/go.mod h1:xOFYvUsvSZV3z6eow5YdB5C/qRQf2s/5/arcfJs5XPg= +go.miloapis.com/milo v0.26.1-0.20260527023322-a78982bd81f2 h1:qf/cwnsUur2GIRJ2zF45ayUyuzbQF57V/Q5Jfn7NNPw= +go.miloapis.com/milo v0.26.1-0.20260527023322-a78982bd81f2/go.mod h1:xOFYvUsvSZV3z6eow5YdB5C/qRQf2s/5/arcfJs5XPg= +go.miloapis.com/service-catalog v0.0.0-20260527221104-f8aeca153879 h1:Cd7qx+oGo80XQlDGhD5BcZs/n3ANaLu3bDj7gVZh7l0= +go.miloapis.com/service-catalog v0.0.0-20260527221104-f8aeca153879/go.mod h1:znOMOYlmNfQmIvS/7ZpaI909DtLfKEvFe5QK9CgO8GE= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= diff --git a/internal/cmd/compute/quota/quota.go b/internal/cmd/compute/quota/quota.go index 5993a428..7091ec27 100644 --- a/internal/cmd/compute/quota/quota.go +++ b/internal/cmd/compute/quota/quota.go @@ -3,6 +3,7 @@ package quota import ( "context" "fmt" + "strings" "github.com/spf13/cobra" @@ -47,6 +48,21 @@ func Command() *cobra.Command { return cmd } +const barWidth = 20 + +func quotaBar(used, limit int64) string { + if limit <= 0 { + return "[" + strings.Repeat("-", barWidth) + "] N/A" + } + pct := float64(used) / float64(limit) + if pct > 1 { + pct = 1 + } + filled := int(pct * barWidth) + bar := strings.Repeat("#", filled) + strings.Repeat("-", barWidth-filled) + return fmt.Sprintf("[%s] %3.0f%%", bar, pct*100) +} + func runQuota(cmd *cobra.Command, constrained bool) error { project := util.ProjectFromCmd(cmd) outputFlag, _ := cmd.Flags().GetString("output") @@ -99,14 +115,10 @@ func runQuota(cmd *cobra.Command, constrained bool) error { _, _ = fmt.Fprintf(out, "Quota for project %s\n\n", project) tw := util.NewTabWriter(out) - _, _ = fmt.Fprintf(tw, "RESOURCE\tUNIT\tLIMIT\tUSED\tAVAILABLE\n") + _, _ = fmt.Fprintf(tw, "RESOURCE\tUNIT\tLIMIT\tUSED\tAVAILABLE\tUSAGE\n") for _, r := range rows { - atLimit := "" - if r.Available == 0 { - atLimit = " [at limit]" - } - _, _ = fmt.Fprintf(tw, "%s%s\t%s\t%d\t%d\t%d\n", - r.DisplayName, atLimit, r.Unit, r.Limit, r.Used, r.Available) + _, _ = fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%d\t%s\n", + r.DisplayName, r.Unit, r.Limit, r.Used, r.Available, quotaBar(r.Used, r.Limit)) } _ = tw.Flush() diff --git a/internal/cmd/compute/util/entitlement.go b/internal/cmd/compute/util/entitlement.go index 697329e8..19641a06 100644 --- a/internal/cmd/compute/util/entitlement.go +++ b/internal/cmd/compute/util/entitlement.go @@ -2,76 +2,64 @@ package util import ( "bufio" - "bytes" "context" - "encoding/json" "fmt" "io" - "net/http" "os" "strings" + "time" + servicesv1alpha1 "go.miloapis.com/service-catalog/api/v1alpha1" "go.datum.net/datumctl/plugin" "golang.org/x/term" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" ) const computeServiceName = "compute" -type entitlementList struct { - Items []entitlementItem `json:"items"` -} - -type entitlementItem struct { - Spec struct { - ServiceRef struct { - Name string `json:"name"` - } `json:"serviceRef"` - } `json:"spec"` - Status struct { - Phase string `json:"phase"` - } `json:"status"` -} - // EnsureComputeEntitlement checks that the selected project has an active // ServiceEntitlement for the compute service. If none exists, it prompts the // user (via in/out) to request access. out should be cmd.ErrOrStderr() so the // prompt does not pollute structured output. func EnsureComputeEntitlement(ctx context.Context, project string, in io.Reader, out io.Writer) error { if project == "" { - // NewClient will surface the missing-project error. return nil } - pluginCtx := plugin.Context() - if pluginCtx.APIHost == "" { - return fmt.Errorf("DATUM_API_HOST is not set; is this plugin running via datumctl?") - } - - token, err := plugin.Token() + wc, err := newEntitlementClient(project) if err != nil { - return fmt.Errorf("getting credentials: %w", err) + return err } - baseURL := ProjectControlPlaneURL(pluginCtx.APIHost, project) - list, err := listEntitlements(ctx, baseURL, token) - if err != nil { - return err + var list servicesv1alpha1.ServiceEntitlementList + if err := wc.List(ctx, &list); err != nil { + if apimeta.IsNoMatchError(err) { + // API not installed in this project's VCP — treat as no entitlement. + return promptAndRequestAccess(ctx, project, wc, in, out) + } + return fmt.Errorf("checking service entitlement: %w", err) } - for _, item := range list.Items { + for i := range list.Items { + item := &list.Items[i] if item.Spec.ServiceRef.Name != computeServiceName { continue } switch item.Status.Phase { - case "Active": + case servicesv1alpha1.EntitlementPhaseActive: return nil - case "PendingApproval": + case servicesv1alpha1.EntitlementPhasePendingApproval: return fmt.Errorf( "compute service entitlement for project %q is pending approval\n\n"+ "Check status with: datumctl services list", project, ) - case "Rejected": + case servicesv1alpha1.EntitlementPhaseRejected: return fmt.Errorf( "compute service entitlement for project %q was rejected\n\n"+ "Re-enable with: datumctl services enable %s", @@ -80,46 +68,10 @@ func EnsureComputeEntitlement(ctx context.Context, project string, in io.Reader, } } - // No entitlement found — prompt the user. - return promptAndRequestAccess(ctx, project, baseURL, token, in, out) + return promptAndRequestAccess(ctx, project, wc, in, out) } -func listEntitlements(ctx context.Context, baseURL, token string) (*entitlementList, error) { - url := baseURL + "/apis/services.miloapis.com/v1alpha1/serviceentitlements" - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("building entitlement request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("checking service entitlement: %w", err) - } - defer resp.Body.Close() - - // 404 means the service-catalog API isn't installed in this project's VCP, - // which is equivalent to having no entitlement — fall through to the prompt. - if resp.StatusCode == http.StatusNotFound { - return &entitlementList{}, nil - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf( - "checking service entitlement: unexpected status %d", - resp.StatusCode, - ) - } - - var list entitlementList - if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { - return nil, fmt.Errorf("decoding service entitlements: %w", err) - } - return &list, nil -} - -func promptAndRequestAccess(ctx context.Context, project, baseURL, token string, in io.Reader, out io.Writer) error { +func promptAndRequestAccess(ctx context.Context, project string, wc client.WithWatch, in io.Reader, out io.Writer) error { if !isTTY(in) { return fmt.Errorf( "compute service is not enabled for project %q\n\n"+ @@ -145,72 +97,88 @@ func promptAndRequestAccess(ctx context.Context, project, baseURL, token string, } fmt.Fprintf(out, "Requesting access to compute for project %q...\n", project) - if err := createEntitlement(ctx, baseURL, token); err != nil { - return err + + entitlement := &servicesv1alpha1.ServiceEntitlement{ + ObjectMeta: metav1.ObjectMeta{Name: computeServiceName}, + Spec: servicesv1alpha1.ServiceEntitlementSpec{ + ServiceRef: servicesv1alpha1.ServiceRef{Name: computeServiceName}, + }, + } + if err := wc.Create(ctx, entitlement); err != nil { + return fmt.Errorf("requesting compute access: %w", err) } - // Re-fetch to determine the resulting phase. - list, err := listEntitlements(ctx, baseURL, token) + // Watch for the Ready condition to appear (set by the reconciler asynchronously). + watchCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + watcher, err := wc.Watch(watchCtx, &servicesv1alpha1.ServiceEntitlementList{}) if err != nil { - return err + return fmt.Errorf("watching entitlement status: %w", err) } - for _, item := range list.Items { - if item.Spec.ServiceRef.Name != computeServiceName { - continue - } - switch item.Status.Phase { - case "Active": - fmt.Fprintf(out, "Compute enabled for project %q.\n", project) - return nil - case "PendingApproval": - fmt.Fprintf(out, "\nYour request to enable compute for project %q has been submitted,\n", project) - fmt.Fprintf(out, "but it requires approval before you can use the service.\n") - fmt.Fprintf(out, "You will be notified when access is granted.\n\n") + defer watcher.Stop() + + for { + select { + case <-watchCtx.Done(): + fmt.Fprintf(out, "\nAccess to compute for project %q has been requested.\n", project) + fmt.Fprintf(out, "Run your command again once it becomes active.\n\n") fmt.Fprintf(out, "Check status with: datumctl services list\n") - return fmt.Errorf("compute access is pending approval") + return fmt.Errorf("compute access is not yet active — try again in a moment") + + case event, ok := <-watcher.ResultChan(): + if !ok { + return fmt.Errorf("watch channel closed unexpectedly") + } + if event.Type != watch.Modified && event.Type != watch.Added { + continue + } + item, ok := event.Object.(*servicesv1alpha1.ServiceEntitlement) + if !ok || item.Spec.ServiceRef.Name != computeServiceName { + continue + } + if apimeta.FindStatusCondition(item.Status.Conditions, "Ready") == nil { + continue + } + switch item.Status.Phase { + case servicesv1alpha1.EntitlementPhaseActive: + fmt.Fprintf(out, "Compute enabled for project %q.\n\n", project) + return nil + case servicesv1alpha1.EntitlementPhasePendingApproval: + fmt.Fprintf(out, "\nYour request to enable compute for project %q has been submitted,\n", project) + fmt.Fprintf(out, "but it requires approval before you can use the service.\n") + fmt.Fprintf(out, "You will be notified when access is granted.\n\n") + fmt.Fprintf(out, "Check status with: datumctl services list\n") + return fmt.Errorf("compute access is pending approval") + default: + return fmt.Errorf("compute entitlement for project %q entered unexpected phase %q", project, item.Status.Phase) + } } } - - return fmt.Errorf("entitlement created but status is not yet available — check: datumctl services list") } -func createEntitlement(ctx context.Context, baseURL, token string) error { - body := map[string]any{ - "apiVersion": "services.miloapis.com/v1alpha1", - "kind": "ServiceEntitlement", - "metadata": map[string]any{ - "name": computeServiceName, - }, - "spec": map[string]any{ - "serviceRef": map[string]any{ - "name": computeServiceName, - }, - }, +func newEntitlementClient(project string) (client.WithWatch, error) { + pluginCtx := plugin.Context() + if pluginCtx.APIHost == "" { + return nil, fmt.Errorf("DATUM_API_HOST is not set; is this plugin running via datumctl?") } - payload, err := json.Marshal(body) + token, err := plugin.Token() if err != nil { - return fmt.Errorf("encoding entitlement request: %w", err) + return nil, fmt.Errorf("getting credentials: %w", err) } - url := baseURL + "/apis/services.miloapis.com/v1alpha1/serviceentitlements" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) - if err != nil { - return fmt.Errorf("building create request: %w", err) + scheme := runtime.NewScheme() + if err := servicesv1alpha1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("registering services scheme: %w", err) } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("requesting compute access: %w", err) + cfg := &rest.Config{ + Host: ProjectControlPlaneURL(pluginCtx.APIHost, project), + BearerToken: token, } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - return fmt.Errorf("requesting compute access: unexpected status %d", resp.StatusCode) - } - return nil + return client.NewWithWatch(cfg, client.Options{Scheme: scheme}) } func isTTY(r io.Reader) bool { From 42f2dd7befebb8b826bd31c4bf1c8cce585de81a Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Thu, 28 May 2026 22:10:03 -0500 Subject: [PATCH 20/27] feat: register networking scheme in datumctl compute client The compute CLI client now serializes network-services-operator types (Network, NetworkBinding, SubnetClaim), so deploy can preflight and create networks on the user's behalf. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cmd/compute/util/client.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/cmd/compute/util/client.go b/internal/cmd/compute/util/client.go index bd860bc3..feafc7ab 100644 --- a/internal/cmd/compute/util/client.go +++ b/internal/cmd/compute/util/client.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" computev1alpha "go.datum.net/compute/api/v1alpha" "go.datum.net/datumctl/plugin" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" @@ -48,6 +49,9 @@ func NewClient(project string) (client.Client, error) { if err := computev1alpha.AddToScheme(scheme); err != nil { return nil, fmt.Errorf("registering compute scheme: %w", err) } + if err := networkingv1alpha.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("registering networking scheme: %w", err) + } if err := quotav1alpha1.AddToScheme(scheme); err != nil { return nil, fmt.Errorf("registering quota scheme: %w", err) } From e604260644daf6c9af6c9363bd9a49a00466fe27 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Thu, 28 May 2026 22:10:03 -0500 Subject: [PATCH 21/27] refactor: remove client-side revision history from compute CLI Deployment revisions are becoming a platform concept rather than a client concern. Remove the ConfigMap-backed revision ledger the CLI maintained per workload, along with the 'rollout history' and 'rollout undo' subcommands and the revision column in 'workloads'. 'rollout' remains as a live-progress watch. This also removes the only code path that serialized core/v1 ConfigMaps from the CLI, so the missing-corev1-scheme warning on deploy no longer occurs. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cmd/compute/deploy/deploy.go | 76 ------- internal/cmd/compute/destroy/destroy.go | 9 - internal/cmd/compute/revision/revision.go | 169 --------------- internal/cmd/compute/rollout/rollout.go | 217 +------------------- internal/cmd/compute/workloads/workloads.go | 32 +-- 5 files changed, 8 insertions(+), 495 deletions(-) delete mode 100644 internal/cmd/compute/revision/revision.go diff --git a/internal/cmd/compute/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go index 4aa12414..2e84851a 100644 --- a/internal/cmd/compute/deploy/deploy.go +++ b/internal/cmd/compute/deploy/deploy.go @@ -4,12 +4,10 @@ import ( "bufio" "bytes" "context" - "encoding/json" "fmt" "os" "os/signal" "strings" - "time" "github.com/spf13/cobra" "golang.org/x/term" @@ -21,7 +19,6 @@ import ( sigsyaml "sigs.k8s.io/yaml" computev1alpha "go.datum.net/compute/api/v1alpha" - "go.datum.net/compute/internal/cmd/compute/revision" "go.datum.net/compute/internal/cmd/compute/util" "go.datum.net/compute/internal/cmd/compute/watch" networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" @@ -195,14 +192,6 @@ func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) err } } - // Compute diff description before applying. - var changes string - if creating { - changes = "initial deploy" - } else { - changes = computeDiff(workload.Spec, opts.image, opts.cities, opts.min) - } - if creating { workload.Namespace = util.ResourceNamespace if err := c.Create(ctx, &workload); err != nil { @@ -216,21 +205,6 @@ func deployFromFlags(cmd *cobra.Command, workloadName string, opts *options) err fmt.Fprintf(out, " workload/%s updated\n", workloadName) } - // Write revision entry. - rev := revision.CurrentRevision(ctx, c, util.ResourceNamespace, workloadName) + 1 - specJSON, _ := json.Marshal(workload.Spec) - entry := revision.Entry{ - Rev: rev, - Timestamp: time.Now().UTC().Format(time.RFC3339), - Image: opts.image, - Changes: changes, - SpecJSON: string(specJSON), - } - if err := revision.WriteEntry(ctx, c, util.ResourceNamespace, workloadName, entry); err != nil { - // Non-fatal — log but continue. - fmt.Fprintf(out, " warning: could not write revision history: %v\n", err) - } - // Save workload.yaml. if err := saveWorkloadYAML(workloadName, &workload); err != nil { fmt.Fprintf(out, " warning: could not save workload.yaml: %v\n", err) @@ -314,18 +288,6 @@ func deployFromFile(cmd *cobra.Command, opts *options) error { } } - // Determine changes summary. - var changes string - if creating { - changes = "initial deploy" - } else if len(diffLines) > 0 { - changes = strings.Join(diffLines, "; ") - } else { - changes = "manifest apply" - } - - image := imageFromWorkload(workload) - if creating { if err := c.Create(ctx, &workload); err != nil { return fmt.Errorf("creating workload: %w", err) @@ -339,19 +301,6 @@ func deployFromFile(cmd *cobra.Command, opts *options) error { fmt.Fprintf(out, " workload/%s updated\n", workload.Name) } - rev := revision.CurrentRevision(ctx, c, util.ResourceNamespace, workload.Name) + 1 - specJSON, _ := json.Marshal(workload.Spec) - entry := revision.Entry{ - Rev: rev, - Timestamp: time.Now().UTC().Format(time.RFC3339), - Image: image, - Changes: changes, - SpecJSON: string(specJSON), - } - if err := revision.WriteEntry(ctx, c, util.ResourceNamespace, workload.Name, entry); err != nil { - fmt.Fprintf(out, " warning: could not write revision history: %v\n", err) - } - fmt.Fprintf(out, "Waiting for rollout. Ctrl-C to detach (rollout continues in background).\n\n") watchCtx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt) @@ -387,31 +336,6 @@ func imageFromWorkload(w computev1alpha.Workload) string { return "" } -// computeDiff produces a human-readable one-line diff description for flag-driven updates. -func computeDiff(existing computev1alpha.WorkloadSpec, newImage string, _ []string, min int32) string { - var parts []string - - oldImage := "" - if existing.Template.Spec.Runtime.Sandbox != nil && len(existing.Template.Spec.Runtime.Sandbox.Containers) > 0 { - oldImage = existing.Template.Spec.Runtime.Sandbox.Containers[0].Image - } - if oldImage != newImage { - parts = append(parts, fmt.Sprintf("image: %s → %s", oldImage, newImage)) - } - - if len(existing.Placements) > 0 { - oldMin := existing.Placements[0].ScaleSettings.MinReplicas - if oldMin != min { - parts = append(parts, fmt.Sprintf("min replicas: %d → %d", oldMin, min)) - } - } - - if len(parts) == 0 { - return "no changes" - } - return strings.Join(parts, ", ") -} - // ensureNetwork checks if the named network exists and, if not, offers to create it. // It creates a minimal auto-IPAM IPv4 network on behalf of the user. func ensureNetwork(ctx context.Context, cmd *cobra.Command, c client.Client, networkName, project string, opts *options) error { diff --git a/internal/cmd/compute/destroy/destroy.go b/internal/cmd/compute/destroy/destroy.go index fdb0d0a0..e95e02c3 100644 --- a/internal/cmd/compute/destroy/destroy.go +++ b/internal/cmd/compute/destroy/destroy.go @@ -13,9 +13,7 @@ import ( "k8s.io/apimachinery/pkg/types" computev1alpha "go.datum.net/compute/api/v1alpha" - "go.datum.net/compute/internal/cmd/compute/revision" "go.datum.net/compute/internal/cmd/compute/util" - corev1 "k8s.io/api/core/v1" ) func Command() *cobra.Command { @@ -89,13 +87,6 @@ func runDestroy(cmd *cobra.Command, args []string, yes bool) error { return fmt.Errorf("deleting workload: %w", err) } - // Best-effort deletion of the revision ConfigMap. - var cm corev1.ConfigMap - cmName := revision.ConfigMapName(workloadName) - if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: cmName}, &cm); err == nil { - _ = c.Delete(ctx, &cm) - } - fmt.Fprintf(out, "workload/%s deleted.\n", workloadName) return nil } diff --git a/internal/cmd/compute/revision/revision.go b/internal/cmd/compute/revision/revision.go deleted file mode 100644 index 8e8ca35e..00000000 --- a/internal/cmd/compute/revision/revision.go +++ /dev/null @@ -1,169 +0,0 @@ -// Package revision manages workload revision history stored in ConfigMaps. -// Each workload maintains a ConfigMap keyed by "compute.datumapis.com-revision-history." -// whose data map holds JSON-encoded Entry values keyed by revision number string. -package revision - -import ( - "context" - "encoding/json" - "fmt" - "sort" - "strconv" - - corev1 "k8s.io/api/core/v1" - k8serrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -const ( - // CurrentRevisionAnnotation is the annotation key on the revision ConfigMap - // that stores the active revision number as a string. - CurrentRevisionAnnotation = "compute.datumapis.com/current-revision" - - // ConfigMapNamePrefix is the prefix for revision history ConfigMap names. - ConfigMapNamePrefix = "compute.datumapis.com-revision-history." - - // MaxRevisions is the maximum number of revision entries to retain. - MaxRevisions = 20 -) - -// Entry is one revision record stored as JSON in the ConfigMap's data map. -type Entry struct { - Rev int `json:"rev"` - Timestamp string `json:"timestamp"` - Image string `json:"image"` - Changes string `json:"changes"` - Actor string `json:"actor"` - TemplateHash string `json:"templateHash"` - // SpecJSON holds a JSON-encoded WorkloadSpec for use by rollback. - SpecJSON string `json:"spec"` -} - -// ConfigMapName returns the ConfigMap name for the given workload. -func ConfigMapName(workloadName string) string { - return ConfigMapNamePrefix + workloadName -} - -// CurrentRevision returns the current revision number from the ConfigMap annotation. -// Returns 0 if no history ConfigMap exists or the annotation is absent. -func CurrentRevision(ctx context.Context, c client.Client, namespace, workloadName string) int { - var cm corev1.ConfigMap - if err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: ConfigMapName(workloadName)}, &cm); err != nil { - return 0 - } - if v, ok := cm.Annotations[CurrentRevisionAnnotation]; ok { - n, err := strconv.Atoi(v) - if err == nil { - return n - } - } - return 0 -} - -// WriteEntry creates or updates the revision history ConfigMap with entry. -// It enforces MaxRevisions by dropping the entry with the lowest revision number -// when the cap is exceeded. It updates the CurrentRevisionAnnotation. -func WriteEntry(ctx context.Context, c client.Client, namespace, workloadName string, entry Entry) error { - cmName := ConfigMapName(workloadName) - - var cm corev1.ConfigMap - exists := true - if err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: cmName}, &cm); err != nil { - if !k8serrors.IsNotFound(err) { - return fmt.Errorf("getting revision ConfigMap: %w", err) - } - exists = false - cm = corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - Name: cmName, - Annotations: map[string]string{}, - }, - Data: map[string]string{}, - } - } - - if cm.Data == nil { - cm.Data = map[string]string{} - } - if cm.Annotations == nil { - cm.Annotations = map[string]string{} - } - - // Marshal the new entry. - data, err := json.Marshal(entry) - if err != nil { - return fmt.Errorf("marshalling revision entry: %w", err) - } - cm.Data[strconv.Itoa(entry.Rev)] = string(data) - - // Enforce cap: remove the lowest-numbered key until within MaxRevisions. - for len(cm.Data) > MaxRevisions { - lowest := -1 - for k := range cm.Data { - n, err := strconv.Atoi(k) - if err != nil { - continue - } - if lowest < 0 || n < lowest { - lowest = n - } - } - if lowest >= 0 { - delete(cm.Data, strconv.Itoa(lowest)) - } else { - break - } - } - - cm.Annotations[CurrentRevisionAnnotation] = strconv.Itoa(entry.Rev) - - if exists { - if err := c.Update(ctx, &cm); err != nil { - return fmt.Errorf("updating revision ConfigMap: %w", err) - } - } else { - if err := c.Create(ctx, &cm); err != nil { - return fmt.Errorf("creating revision ConfigMap: %w", err) - } - } - - return nil -} - -// ReadEntries returns all revision entries sorted by Rev descending, and the -// current revision number. Returns an empty slice (not an error) when no -// history ConfigMap exists. -func ReadEntries(ctx context.Context, c client.Client, namespace, workloadName string) (entries []Entry, currentRev int, err error) { - var cm corev1.ConfigMap - if err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: ConfigMapName(workloadName)}, &cm); err != nil { - if k8serrors.IsNotFound(err) { - return nil, 0, nil - } - return nil, 0, fmt.Errorf("getting revision ConfigMap: %w", err) - } - - if v, ok := cm.Annotations[CurrentRevisionAnnotation]; ok { - n, err := strconv.Atoi(v) - if err == nil { - currentRev = n - } - } - - for _, v := range cm.Data { - var e Entry - if err := json.Unmarshal([]byte(v), &e); err != nil { - // Skip malformed entries. - continue - } - entries = append(entries, e) - } - - sort.Slice(entries, func(i, j int) bool { - return entries[i].Rev > entries[j].Rev - }) - - return entries, currentRev, nil -} diff --git a/internal/cmd/compute/rollout/rollout.go b/internal/cmd/compute/rollout/rollout.go index 0c290ff8..0a3800aa 100644 --- a/internal/cmd/compute/rollout/rollout.go +++ b/internal/cmd/compute/rollout/rollout.go @@ -2,20 +2,15 @@ package rollout import ( "context" - "encoding/json" "fmt" "os" "os/signal" - "time" "github.com/spf13/cobra" - "go.datum.net/datumctl/plugin" k8serrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" computev1alpha "go.datum.net/compute/api/v1alpha" - "go.datum.net/compute/internal/cmd/compute/revision" "go.datum.net/compute/internal/cmd/compute/util" "go.datum.net/compute/internal/cmd/compute/watch" ) @@ -23,28 +18,19 @@ import ( func Command() *cobra.Command { cmd := &cobra.Command{ Use: "rollout ", - Short: "Watch or manage a workload rollout", - Long: `Watch the live progress of a rollout across all placements, or inspect and -revert to a previous revision. + Short: "Watch live rollout progress for a workload", + Long: `Watch the live progress of a rollout across all placements. Pressing Ctrl-C detaches from the watch without canceling the rollout.`, Args: cobra.ExactArgs(1), Example: ` # Watch live rollout progress - datumctl compute rollout api - - # Show revision history - datumctl compute rollout history api - - # Roll back to a specific revision - datumctl compute rollout undo api --to-revision=7`, + datumctl compute rollout api`, RunE: func(cmd *cobra.Command, args []string) error { return runWatch(cmd, args) }, ValidArgsFunction: util.CompleteWorkloadNames, } - cmd.AddCommand(historyCommand(), undoCommand()) - return cmd } @@ -67,205 +53,10 @@ func runWatch(cmd *cobra.Command, args []string) error { return fmt.Errorf("getting workload: %w", err) } - entries, currentRev, err := revision.ReadEntries(ctx, c, util.ResourceNamespace, workloadName) - if err != nil { - return fmt.Errorf("reading revision history: %w", err) - } - - var revLabel string - switch { - case currentRev == 0: - revLabel = "rev #1" - case len(entries) >= 2: - revLabel = fmt.Sprintf("rev #%d → #%d", entries[1].Rev, entries[0].Rev) - default: - revLabel = fmt.Sprintf("rev #%d", currentRev) - } - out := cmd.OutOrStdout() - fmt.Fprintf(out, "Rolling workload %q %s\n", workloadName, revLabel) + fmt.Fprintf(out, "Rolling workload %q\n", workloadName) watchCtx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt) defer cancel() return watch.Rollout(watchCtx, c, out, project, workload.UID) } - -func historyCommand() *cobra.Command { - return &cobra.Command{ - Use: "history ", - Short: "Show the rollout history for a workload", - Args: cobra.ExactArgs(1), - ValidArgsFunction: util.CompleteWorkloadNames, - RunE: func(cmd *cobra.Command, args []string) error { - return runHistory(cmd, args) - }, - } -} - -func runHistory(cmd *cobra.Command, args []string) error { - project := util.ProjectFromCmd(cmd) - - c, err := util.NewClient(project) - if err != nil { - return err - } - - ctx := context.Background() - workloadName := args[0] - - var workload computev1alpha.Workload - if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { - if k8serrors.IsNotFound(err) { - return fmt.Errorf("workload %q not found in project %s", workloadName, project) - } - return fmt.Errorf("getting workload: %w", err) - } - - entries, currentRev, err := revision.ReadEntries(ctx, c, util.ResourceNamespace, workloadName) - if err != nil { - return fmt.Errorf("reading revision history: %w", err) - } - - out := cmd.OutOrStdout() - - if len(entries) == 0 { - fmt.Fprintf(out, "No revision history found for workload %q.\n", workloadName) - return nil - } - - tw := util.NewTabWriter(out) - _, _ = fmt.Fprintln(tw, "REV\tWHEN\tIMAGE\tCHANGES\tBY\tSTATUS") - - for _, e := range entries { - when := "—" - if e.Timestamp != "" { - t, err := time.Parse(time.RFC3339, e.Timestamp) - if err == nil { - when = util.RelativeAgeVerbose(metav1.Time{Time: t}) - } - } - - status := "—" - if e.Rev == currentRev { - status = "active" - } - - fmt.Fprintf(tw, "#%d\t%s\t%s\t%s\t%s\t%s\n", - e.Rev, when, e.Image, e.Changes, e.Actor, status) - } - - _ = tw.Flush() - return nil -} - -func undoCommand() *cobra.Command { - var toRevision int32 - - cmd := &cobra.Command{ - Use: "undo ", - Short: "Roll back a workload to a previous revision", - Long: `Creates a new revision that is a copy of the target revision. -Rollbacks do not rewrite history.`, - Args: cobra.ExactArgs(1), - ValidArgsFunction: util.CompleteWorkloadNames, - RunE: func(cmd *cobra.Command, args []string) error { - return runUndo(cmd, args, toRevision) - }, - } - - cmd.Flags().Int32Var(&toRevision, "to-revision", 0, "Revision number to roll back to (0 = previous)") - - return cmd -} - -func runUndo(cmd *cobra.Command, args []string, toRevision int32) error { - project := util.ProjectFromCmd(cmd) - - c, err := util.NewClient(project) - if err != nil { - return err - } - - ctx := context.Background() - workloadName := args[0] - - var workload computev1alpha.Workload - if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { - if k8serrors.IsNotFound(err) { - return fmt.Errorf("workload %q not found in project %s", workloadName, project) - } - return fmt.Errorf("getting workload: %w", err) - } - - entries, currentRev, err := revision.ReadEntries(ctx, c, util.ResourceNamespace, workloadName) - if err != nil { - return fmt.Errorf("reading revision history: %w", err) - } - - if len(entries) == 0 { - return fmt.Errorf("no revision history for workload %q; cannot undo", workloadName) - } - - if currentRev == 1 { - return fmt.Errorf("no previous revision to roll back to") - } - - var target int - if toRevision == 0 { - target = currentRev - 1 - } else { - target = int(toRevision) - } - - if target == currentRev { - return fmt.Errorf("workload is already at revision #%d", currentRev) - } - - if target < 1 { - return fmt.Errorf("no previous revision to roll back to") - } - - var targetEntry *revision.Entry - for i := range entries { - if entries[i].Rev == target { - targetEntry = &entries[i] - break - } - } - if targetEntry == nil { - return fmt.Errorf("revision #%d not found; run 'datumctl compute rollout history %s'", target, workloadName) - } - - // Unmarshal the stored spec. - var targetSpec computev1alpha.WorkloadSpec - if err := json.Unmarshal([]byte(targetEntry.SpecJSON), &targetSpec); err != nil { - return fmt.Errorf("decoding stored spec for revision #%d: %w", target, err) - } - - out := cmd.OutOrStdout() - newRev := currentRev + 1 - fmt.Fprintf(out, "Creating revision #%d (copy of #%d)...\n", newRev, target) - - workload.Spec = targetSpec - if err := c.Update(ctx, &workload); err != nil { - return fmt.Errorf("updating workload: %w", err) - } - - actor := plugin.Context().Org - - newSpecJSON, _ := json.Marshal(workload.Spec) - entry := revision.Entry{ - Rev: newRev, - Timestamp: time.Now().UTC().Format(time.RFC3339), - Image: targetEntry.Image, - Changes: fmt.Sprintf("rollback to rev #%d", target), - Actor: actor, - SpecJSON: string(newSpecJSON), - } - if err := revision.WriteEntry(ctx, c, util.ResourceNamespace, workloadName, entry); err != nil { - fmt.Fprintf(out, " warning: could not write revision history: %v\n", err) - } - - fmt.Fprintf(out, "Rollout started. Run 'datumctl compute rollout %s' to watch progress.\n", workloadName) - return nil -} diff --git a/internal/cmd/compute/workloads/workloads.go b/internal/cmd/compute/workloads/workloads.go index 4278508d..a8071fdf 100644 --- a/internal/cmd/compute/workloads/workloads.go +++ b/internal/cmd/compute/workloads/workloads.go @@ -121,7 +121,6 @@ func runList(cmd *cobra.Command, _ []string) error { placements string image string age string - revision string // wide only instType string // wide only } @@ -172,18 +171,6 @@ func runList(cmd *cobra.Command, _ []string) error { readyStr := fmt.Sprintf("%d/%d", totalReady, totalDesired) instType := wl.Spec.Template.Spec.Runtime.Resources.InstanceType - // Revision — only fetched for -o wide to avoid N round-trips in table mode. - revision := "—" - if wide { - var cm corev1.ConfigMap - cmName := "compute.datumapis.com-revision-history." + wl.Name - if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: cmName}, &cm); err == nil { - if v, ok := cm.Annotations["compute.datumapis.com/current-revision"]; ok { - revision = v - } - } - } - rows = append(rows, workloadRow{ name: wl.Name, health: health, @@ -192,7 +179,6 @@ func runList(cmd *cobra.Command, _ []string) error { placements: placements, image: image, age: util.RelativeAge(wl.CreationTimestamp), - revision: revision, instType: instType, }) } @@ -235,15 +221,15 @@ func runList(cmd *cobra.Command, _ []string) error { tw := util.NewTabWriter(out) if !noHeaders { if wide { - fmt.Fprintf(tw, "NAME\tHEALTH\tREADY\tPLACEMENTS\tIMAGE\tAGE\tREVISION\tINSTANCE TYPE\n") + fmt.Fprintf(tw, "NAME\tHEALTH\tREADY\tPLACEMENTS\tIMAGE\tAGE\tINSTANCE TYPE\n") } else { fmt.Fprintf(tw, "NAME\tHEALTH\tREADY\tPLACEMENTS\tIMAGE\tAGE\n") } } for _, r := range rows { if wide { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - r.name, r.healthShort, r.readyStr, r.placements, r.image, r.age, r.revision, r.instType) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + r.name, r.healthShort, r.readyStr, r.placements, r.image, r.age, r.instType) } else { fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", r.name, r.healthShort, r.readyStr, r.placements, r.image, r.age) @@ -346,16 +332,6 @@ func runDescribe(cmd *cobra.Command, args []string) error { health := util.WorkloadHealth(wl.Status.Conditions, totalReady, totalDesired) - // Fetch revision. - revision := "—" - var cm corev1.ConfigMap - cmName := "compute.datumapis.com-revision-history." + workloadName - if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: cmName}, &cm); err == nil { - if v, ok := cm.Annotations["compute.datumapis.com/current-revision"]; ok { - revision = v - } - } - // Determine type label. typeLabel := "virtual-machine" if wl.Spec.Template.Spec.Runtime.Sandbox != nil { @@ -374,7 +350,7 @@ func runDescribe(cmd *cobra.Command, args []string) error { // Header block. fmt.Fprintf(out, "%-12s %-31s project: %s\n", "Workload", workloadName, project) fmt.Fprintf(out, "%-12s %s\n", "Type", typeLabel) - fmt.Fprintf(out, "%-12s %-31s Revision #%s\n", "Updated", age, revision) + fmt.Fprintf(out, "%-12s %s\n", "Updated", age) fmt.Fprintf(out, "\n") fmt.Fprintf(out, "%-12s %s\n", "Health", health) fmt.Fprintf(out, "\n") From 4aa7d40781bcfa3f686dbd060118a4dfa7b55668 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Thu, 28 May 2026 22:27:11 -0500 Subject: [PATCH 22/27] fix: pin network-services-operator and milo deps to match federated-scheduling base After rebasing onto feat/federated-deployment-scheduling, go.mod had picked up the wrong versions of two deps via conflict resolution: - go.datum.net/network-services-operator was left at v0.1.0 (from #113's old go.mod side) instead of v0.21.10-... required by HEAD's LocationBinding usage - go.miloapis.com/service-catalog v0.0.0-20260527221104 transitively requires milo v0.26.1, which has a broken downstreamclient (Apply method missing, ClusterName type mismatch). Add a replace directive to pin milo to v0.25.2 (the version used by the federated-scheduling base) so downstreamclient compiles cleanly. service-catalog is updated to the latest available version. Also apply gofmt alignment fixes surfaced by the rebase on instance_controller.go. Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 80 ++++---------------------- go.sum | 175 ++++++++------------------------------------------------- 2 files changed, 33 insertions(+), 222 deletions(-) diff --git a/go.mod b/go.mod index 43cbc24c..759357f7 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,15 @@ go 1.25.8 require ( github.com/go-logr/logr v1.4.3 github.com/google/go-cmp v0.7.0 + github.com/karmada-io/api v1.17.0 github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 go.datum.net/datumctl v0.14.1-0.20260523153711-b44de1c715c1 - go.datum.net/network-services-operator v0.1.0 + go.datum.net/network-services-operator v0.21.10-0.20260528021428-b0f2347f5359 go.miloapis.com/milo v0.26.1-0.20260527023322-a78982bd81f2 + go.miloapis.com/service-catalog v0.0.0-20260529025310-809b6c6e4d91 golang.org/x/crypto v0.49.0 golang.org/x/sync v0.20.0 golang.org/x/term v0.43.0 @@ -21,53 +23,25 @@ require ( k8s.io/client-go v0.35.3 k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 sigs.k8s.io/controller-runtime v0.23.3 - sigs.k8s.io/gateway-api v1.2.1 + sigs.k8s.io/gateway-api v1.3.1-0.20250527223622-54df0a899c1c sigs.k8s.io/multicluster-runtime v0.23.3 sigs.k8s.io/yaml v1.6.0 ) require ( cel.dev/expr v0.25.1 // indirect - charm.land/bubbles/v2 v2.1.0 // indirect - charm.land/bubbletea/v2 v2.0.6 // indirect - charm.land/huh/v2 v2.0.3 // indirect - charm.land/lipgloss/v2 v2.0.3 // indirect - github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/chai2010/gettext-go v1.0.3 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect - github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/charmbracelet/x/termios v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/coreos/go-oidc/v3 v3.18.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect - github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/distribution/reference v0.6.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect - github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect - github.com/fatih/camelcase v1.0.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-errors/errors v1.5.1 // indirect - github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect @@ -85,57 +59,30 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.27.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect - github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jonboulle/clockwork v0.5.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.3 // indirect - github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect - github.com/lithammer/dedent v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.4.0 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect - github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/moby/spdystream v0.5.1 // indirect - github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/peterbourgon/diskv v2.0.1+incompatible // indirect - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/rodaine/table v1.3.1 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/prometheus/procfs v0.17.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect - github.com/xlab/treeprint v1.2.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/zalando/go-keyring v0.2.8 // indirect - go.miloapis.com/activity v0.7.0 // indirect - go.miloapis.com/service-catalog v0.0.0-20260527221104-f8aeca153879 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect @@ -144,7 +91,7 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect + golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect @@ -161,18 +108,13 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.35.3 // indirect k8s.io/apiserver v0.35.3 // indirect - k8s.io/cli-runtime v0.35.3 // indirect k8s.io/component-base v0.35.3 // indirect - k8s.io/component-helpers v0.35.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect - k8s.io/kubectl v0.35.3 // indirect - k8s.io/metrics v0.35.3 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/kustomize/api v0.21.0 // indirect - sigs.k8s.io/kustomize/kustomize/v5 v5.7.1 // indirect - sigs.k8s.io/kustomize/kyaml v0.21.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) + +replace go.miloapis.com/milo => go.miloapis.com/milo v0.25.2-0.20260528192736-e4258524ad42 diff --git a/go.sum b/go.sum index c9f614fa..aa261aba 100644 --- a/go.sum +++ b/go.sum @@ -1,80 +1,28 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= -charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= -charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= -charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= -charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= -charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= -charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= -charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= -github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chai2010/gettext-go v1.0.3 h1:9liNh8t+u26xl5ddmWLmsOsdNLwkdRTg5AG+JnTiM80= -github.com/chai2010/gettext-go v1.0.3/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= -github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= -github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= -github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= -github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= -github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= -github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= -github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= -github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= -github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= -github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/coreos/go-oidc v2.3.0+incompatible h1:+5vEsrgprdLjjQ9FzIKAzQz1wwPD+83hQRfUIPh7rO0= -github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= -github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= -github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= -github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= -github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= -github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= -github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -87,10 +35,6 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= -github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -136,8 +80,6 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= -github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= @@ -155,20 +97,16 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= -github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= -github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/karmada-io/api v1.17.0 h1:pBA4r6MwKoHxv0ZaE8R0XM53xULkFLX6rKiQeD7avh0= +github.com/karmada-io/api v1.17.0/go.mod h1:gXdGOj7A7R+vcoHFFmHcMN/BomXfFCJCpqNFMFj11d0= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -177,52 +115,22 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= -github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= -github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= -github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= -github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= -github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= -github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= -github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= -github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= -github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= -github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= -github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= -github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -232,18 +140,11 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rodaine/table v1.3.1 h1:jBVgg1bEu5EzEdYSrwUUlQpayDtkvtTmgFS0FPAxOq8= -github.com/rodaine/table v1.3.1/go.mod h1:VYCJRCHa2DpD25uFALcB6hi5ECF3eEJQVhCXRjHgXc4= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -253,8 +154,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -267,36 +166,24 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= -github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= -github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= -go.datum.net/datumctl v0.14.1-0.20260522214722-79d2d1680f08 h1:8BymUUKdFBCBjvA4yB6BBWYVloiA0f0XqRqYksA/kvQ= -go.datum.net/datumctl v0.14.1-0.20260522214722-79d2d1680f08/go.mod h1:rwu8XWb0FeMzX8vCu+UxKLw89DAkyLOh70PNbDaotac= go.datum.net/datumctl v0.14.1-0.20260523153711-b44de1c715c1 h1:C+VX+/mGJDZjjohBbdJ/PL0qRWqArpxk+wzGe2KpEC8= go.datum.net/datumctl v0.14.1-0.20260523153711-b44de1c715c1/go.mod h1:rwu8XWb0FeMzX8vCu+UxKLw89DAkyLOh70PNbDaotac= -go.datum.net/network-services-operator v0.1.0 h1:PAXOZ5DdJFgRoeVBPIXhqkCm6DxbP4tVOPcr3Y7h/So= -go.datum.net/network-services-operator v0.1.0/go.mod h1:uloVfxqE+8DgSiMB651X8UC9yECpXbwp/NBstofCceE= -go.miloapis.com/activity v0.7.0 h1:Nmc5XzA4oEMTko5/ciJAeERVk18FaSnRpTBo0Sm89YU= -go.miloapis.com/activity v0.7.0/go.mod h1:Sh2Irbq6siJcfq17nLjHvm4JHN/2Csc5YCHB+ycz20c= -go.miloapis.com/milo v0.24.11 h1:rByXDKbP4ZEN0I/z1C2RyUCyQi0NWrITLqoQILSAn2E= -go.miloapis.com/milo v0.24.11/go.mod h1:xOFYvUsvSZV3z6eow5YdB5C/qRQf2s/5/arcfJs5XPg= -go.miloapis.com/milo v0.26.1-0.20260527023322-a78982bd81f2 h1:qf/cwnsUur2GIRJ2zF45ayUyuzbQF57V/Q5Jfn7NNPw= -go.miloapis.com/milo v0.26.1-0.20260527023322-a78982bd81f2/go.mod h1:xOFYvUsvSZV3z6eow5YdB5C/qRQf2s/5/arcfJs5XPg= -go.miloapis.com/service-catalog v0.0.0-20260527221104-f8aeca153879 h1:Cd7qx+oGo80XQlDGhD5BcZs/n3ANaLu3bDj7gVZh7l0= -go.miloapis.com/service-catalog v0.0.0-20260527221104-f8aeca153879/go.mod h1:znOMOYlmNfQmIvS/7ZpaI909DtLfKEvFe5QK9CgO8GE= +go.datum.net/network-services-operator v0.21.10-0.20260528021428-b0f2347f5359 h1:P3dePA6cCXKimZzE6d7Xxpj2rz54BxOHI8K8ic7VQ+c= +go.datum.net/network-services-operator v0.21.10-0.20260528021428-b0f2347f5359/go.mod h1:Nr0PsCodkTW31vWVxR9dhAP9w0y+WHUYeyrcRnchcIE= +go.miloapis.com/milo v0.25.2-0.20260528192736-e4258524ad42 h1:LSHyqLt/jus6iEMvo8pc731L+PyrTHP2bqfMMtHPSWc= +go.miloapis.com/milo v0.25.2-0.20260528192736-e4258524ad42/go.mod h1:p9O2kk194mvoL8rhqjwb+LWB+GIyY4vQqiTowwibVWo= +go.miloapis.com/service-catalog v0.0.0-20260529025310-809b6c6e4d91 h1:fEvsK12btRZOwjvd5Ps7syNwrwo7EknKgq+hVyK/6ck= +go.miloapis.com/service-catalog v0.0.0-20260529025310-809b6c6e4d91/go.mod h1:znOMOYlmNfQmIvS/7ZpaI909DtLfKEvFe5QK9CgO8GE= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= @@ -319,8 +206,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= -golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= @@ -329,8 +216,6 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= @@ -360,7 +245,6 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= @@ -371,39 +255,24 @@ k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.35.3 h1:D2eIcfJ05hEAEewoSDg+05e0aSRwx8Y4Agvd/wiomUI= k8s.io/apiserver v0.35.3/go.mod h1:JI0n9bHYzSgIxgIrfe21dbduJ9NHzKJ6RchcsmIKWKY= -k8s.io/cli-runtime v0.35.3 h1:UZq4ipNimtzBmhN7PPKbfAdqo8quK0H0UdGl6qAQnqI= -k8s.io/cli-runtime v0.35.3/go.mod h1:O7MUmCqcKSd5xI+O5X7/pRkB5l0O2NIhOdUVwbHLXu4= k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= k8s.io/component-base v0.35.3 h1:mbKbzoIMy7JDWS/wqZobYW1JDVRn/RKRaoMQHP9c4P0= k8s.io/component-base v0.35.3/go.mod h1:IZ8LEG30kPN4Et5NeC7vjNv5aU73ku5MS15iZyvyMYk= -k8s.io/component-helpers v0.35.3 h1:Rl2p3wNMC0YU21rziLkWXavr7MwkB5Td3lNZ/+gYGm8= -k8s.io/component-helpers v0.35.3/go.mod h1:8BkyfcBA6XsCtFYxDB+mCfZqM6P39Aco12AKigNn0C8= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg= k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= -k8s.io/kubectl v0.35.3 h1:1KqSYXk/sodU7VeDvK6atX2kAGUZd2QTeR5K7Hb9r9w= -k8s.io/kubectl v0.35.3/go.mod h1:GPHxZqRe+u/i3gTBoVQHeIyq2NilfNPj9hDWeuN3x5s= -k8s.io/kubernetes v1.32.0/go.mod h1:tiIKO63GcdPRBHW2WiUFm3C0eoLczl3f7qi56Dm1W8I= -k8s.io/metrics v0.35.3 h1:WonA18pEwrtb7a6XfhFg1ZY1Le0RFkcEw7CFApMTZos= -k8s.io/metrics v0.35.3/go.mod h1:/O8UBb5QVyAekR2QvL/WWxskpdV1wVSEl4MSLAy4Ql4= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= -sigs.k8s.io/gateway-api v1.2.1 h1:fZZ/+RyRb+Y5tGkwxFKuYuSRQHu9dZtbjenblleOLHM= -sigs.k8s.io/gateway-api v1.2.1/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= +sigs.k8s.io/gateway-api v1.3.1-0.20250527223622-54df0a899c1c h1:GS4VnGRV90GEUjrgQ2GT5ii6yzWj3KtgUg+sVMdhs5c= +sigs.k8s.io/gateway-api v1.3.1-0.20250527223622-54df0a899c1c/go.mod h1:d8NV8nJbaRbEKem+5IuxkL8gJGOZ+FJ+NvOIltV8gDk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/kustomize/api v0.21.0 h1:I7nry5p8iDJbuRdYS7ez8MUvw7XVNPcIP5GkzzuXIIQ= -sigs.k8s.io/kustomize/api v0.21.0/go.mod h1:XGVQuR5n2pXKWbzXHweZU683pALGw/AMVO4zU4iS8SE= -sigs.k8s.io/kustomize/kustomize/v5 v5.7.1 h1:sYJsarwy/SDJfjjLMUqwFDGPwzUtMOQ1i1Ed49+XSbw= -sigs.k8s.io/kustomize/kustomize/v5 v5.7.1/go.mod h1:+5/SrBcJ4agx1SJknGuR/c9thwRSKLxnKoI5BzXFaLU= -sigs.k8s.io/kustomize/kyaml v0.21.0 h1:7mQAf3dUwf0wBerWJd8rXhVcnkk5Tvn/q91cGkaP6HQ= -sigs.k8s.io/kustomize/kyaml v0.21.0/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= sigs.k8s.io/multicluster-runtime v0.23.3 h1:vrzlXRzHTDsjspUAfoW2rCtr0agoI4q20p9x4Fz4png= sigs.k8s.io/multicluster-runtime v0.23.3/go.mod h1:r/UA4GHgFoXCcR4tcvlZz7SiLx3l1kJKDuBAhILNIHs= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= From 8c15212b35a29e23ee22dbd2b9fc263f70e8d43b Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Thu, 28 May 2026 22:38:47 -0500 Subject: [PATCH 23/27] fix: restore ValidateCreate and truncated webhook body lost in rebase resolution The first conflict resolution in the aa9dc15 commit accidentally truncated workload_webhook.go, dropping the ValidateCreate method, its kubebuilder marker, and producing a syntactically invalid Default function body (extra brace + wrong return signature). Restore the file to match 5486adf's content (the authoritative post-lint-migration version). Co-Authored-By: Claude Sonnet 4.6 --- internal/webhook/v1alpha/workload_webhook.go | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/internal/webhook/v1alpha/workload_webhook.go b/internal/webhook/v1alpha/workload_webhook.go index c8d43626..199508fb 100644 --- a/internal/webhook/v1alpha/workload_webhook.go +++ b/internal/webhook/v1alpha/workload_webhook.go @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: AGPL-3.0-only + package webhook import ( @@ -60,6 +62,57 @@ func (r *workloadWebhook) Default(_ context.Context, _ *computev1alpha.Workload) // } // } // } + + // TODO(user): fill in your defaulting logic. + return nil +} + +// +kubebuilder:webhook:path=/validate-compute-datumapis-com-v1alpha-workload,mutating=false,failurePolicy=fail,sideEffects=None,groups=compute.datumapis.com,resources=workloads,verbs=create;update,versions=v1alpha,name=vworkload.kb.io,admissionReviewVersions=v1 + +func (r *workloadWebhook) ValidateCreate(ctx context.Context, workload *computev1alpha.Workload) (admission.Warnings, error) { + clusterName := computewebhook.ClusterNameFromContext(ctx) + + cluster, err := r.mgr.GetCluster(ctx, multicluster.ClusterName(clusterName)) + if err != nil { + return nil, err + } + clusterClient := cluster.GetClient() + + logger := logf.FromContext(ctx).WithValues("cluster", clusterName) + logger.Info("Validating Workload Create", "name", workload.GetName(), "cluster", clusterName) + + req, err := admission.RequestFromContext(ctx) + if err != nil { + return nil, err + } + + // TODO(jreese) validate caller access to individual locations, consider what + // that means for the scheduling phase, since there would not currently be + // sufficient context to know who created the workload and what locations + // are valid candidates based on that. Maybe an annotation, or spec field? + var locations networkingv1alpha.LocationBindingList + if err := clusterClient.List(ctx, &locations); err != nil { + return nil, fmt.Errorf("failed to list location bindings: %w", err) + } + + validCityCodes := sets.Set[string]{} + for _, location := range locations.Items { + cityCode, ok := location.Spec.Topology["topology.datum.net/city-code"] + if ok { + validCityCodes.Insert(cityCode) + } + } + + opts := validation.WorkloadValidationOptions{ + Context: ctx, + Client: clusterClient, + AdmissionRequest: req, + Workload: workload, + ValidCityCodes: sets.List(validCityCodes), + } + + if errs := validation.ValidateWorkloadCreate(workload, opts); len(errs) > 0 { + return nil, errors.NewInvalid(workload.GroupVersionKind().GroupKind(), workload.Name, errs) } return nil, nil From 291f5a44d7d44468681f51b23e621b690cbf8aa8 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 29 May 2026 09:30:01 -0500 Subject: [PATCH 24/27] feat: Read instance city and workload from labels in CLI The platform now stamps city-code, workload-name, workload-deployment-name, and placement-name directly onto Instances at creation time. The CLI can therefore resolve CITY/WORKLOAD/placement directly from those labels without performing cross-object joins. The prior approach keyed the WorkloadDeployment map on UID and looked up instances via WorkloadDeploymentUIDLabel. That UID is the edge/Karmada WD UID, which differs from the project-cluster WD UID, causing the join to fail across federation planes and producing "unknown"/"orphaned" output. The new label-first path reads CityCodeLabel, WorkloadNameLabel, PlacementNameLabel, and WorkloadDeploymentNameLabel (name is identical across all planes) before falling back to the WD Get/List join. A wdNameFromInstanceName helper strips the trailing ordinal suffix from the Instance name as a last-resort fallback for instances created before the labels existed. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/v1alpha/labels.go | 20 +++ internal/cmd/compute/instances/instances.go | 139 +++++++++++++++++--- 2 files changed, 142 insertions(+), 17 deletions(-) diff --git a/api/v1alpha/labels.go b/api/v1alpha/labels.go index e1dac308..93cfb01f 100644 --- a/api/v1alpha/labels.go +++ b/api/v1alpha/labels.go @@ -5,6 +5,26 @@ const ( WorkloadUIDLabel = LabelNamespace + "/workload-uid" WorkloadDeploymentUIDLabel = LabelNamespace + "/workload-deployment-uid" + // WorkloadDeploymentNameLabel carries the WorkloadDeployment name on each + // Instance. Unlike WorkloadDeploymentUIDLabel — which carries the + // edge/Karmada UID and therefore differs across federation planes — + // WorkloadDeploymentNameLabel is identical in the project cluster, Karmada, + // and on the edge, making it safe for cross-plane owner-ref resolution and + // CLI lookup. + WorkloadDeploymentNameLabel = LabelNamespace + "/workload-deployment-name" InstanceIndexLabel = LabelNamespace + "/instance-index" + + // CityCodeLabel carries the city code (e.g. "DFW") that the Instance is + // scheduled to. Stamped at creation time and immutable. + CityCodeLabel = LabelNamespace + "/city-code" + + // WorkloadNameLabel carries the name of the Workload that owns this + // Instance. Stamped at creation time and immutable. + WorkloadNameLabel = LabelNamespace + "/workload-name" + + // PlacementNameLabel carries the name of the placement entry within the + // Workload spec that produced this Instance. Stamped at creation time and + // immutable. + PlacementNameLabel = LabelNamespace + "/placement-name" ) diff --git a/internal/cmd/compute/instances/instances.go b/internal/cmd/compute/instances/instances.go index 1a378714..2d69693c 100644 --- a/internal/cmd/compute/instances/instances.go +++ b/internal/cmd/compute/instances/instances.go @@ -5,6 +5,7 @@ import ( "fmt" "sort" "strings" + "unicode" "github.com/spf13/cobra" corev1 "k8s.io/api/core/v1" @@ -118,7 +119,10 @@ func runList(cmd *cobra.Command, opts *listOptions) error { return util.PrintYAML(cmd.OutOrStdout(), &instList) } - // List deployments — build map deploymentUID → *WorkloadDeployment. + // List deployments — build map deploymentName → *WorkloadDeployment. + // Keyed by name (not UID) because the WorkloadDeploymentUIDLabel on an + // Instance carries the edge/Karmada WD UID, which differs from the + // project-cluster WD UID. The WD name is identical across all planes. var deployList computev1alpha.WorkloadDeploymentList if err := c.List(ctx, &deployList, client.InNamespace(util.ResourceNamespace)); err != nil { return fmt.Errorf("listing deployments: %w", err) @@ -126,7 +130,7 @@ func runList(cmd *cobra.Command, opts *listOptions) error { deploymentMap := make(map[string]*computev1alpha.WorkloadDeployment, len(deployList.Items)) for i := range deployList.Items { d := &deployList.Items[i] - deploymentMap[string(d.UID)] = d + deploymentMap[d.Name] = d } // List workloads — build map workloadUID → name. @@ -142,7 +146,6 @@ func runList(cmd *cobra.Command, opts *listOptions) error { // Build rows. var rows []instanceRow for _, inst := range instList.Items { - depUID := inst.Labels[computev1alpha.WorkloadDeploymentUIDLabel] wlUID := inst.Labels[computev1alpha.WorkloadUIDLabel] city := "unknown" @@ -150,10 +153,45 @@ func runList(cmd *cobra.Command, opts *listOptions) error { if wlName == "" { wlName = "orphaned" } - if dep, ok := deploymentMap[depUID]; ok { - city = dep.Spec.CityCode - if dep.Spec.WorkloadRef.Name != "" { - wlName = dep.Spec.WorkloadRef.Name + + // Prefer self-describing labels stamped at creation time (fast path — + // no join needed). Fall back to the WorkloadDeployment join for older + // instances that predate the labels. + labelCity := inst.Labels[computev1alpha.CityCodeLabel] + labelWLName := inst.Labels[computev1alpha.WorkloadNameLabel] + + if labelCity != "" && labelWLName != "" { + // Both labels present: no join needed. + city = labelCity + wlName = labelWLName + } else { + // At least one label absent — fall back to WorkloadDeployment lookup. + // Prefer the explicit WorkloadDeploymentNameLabel; fall back to + // deriving the WD name from the Instance name for existing instances + // that predate the label. + depName := inst.Labels[computev1alpha.WorkloadDeploymentNameLabel] + if depName == "" { + depName = wdNameFromInstanceName(inst.Name) + } + if dep, ok := deploymentMap[depName]; ok { + if labelCity != "" { + city = labelCity + } else { + city = dep.Spec.CityCode + } + if labelWLName != "" { + wlName = labelWLName + } else if dep.Spec.WorkloadRef.Name != "" { + wlName = dep.Spec.WorkloadRef.Name + } + } else { + // Deployment not found — use whatever labels we do have. + if labelCity != "" { + city = labelCity + } + if labelWLName != "" { + wlName = labelWLName + } } } @@ -278,20 +316,61 @@ func runDescribe(cmd *cobra.Command, args []string) error { return fmt.Errorf("getting instance: %w", err) } - // Look up deployment. - deploymentUID := inst.Labels[computev1alpha.WorkloadDeploymentUIDLabel] + // Resolve CITY, WORKLOAD, and PLACEMENT. Prefer self-describing labels + // stamped at creation time (no join needed). Fall back to a + // WorkloadDeployment Get when any of the labels are absent, so that older + // instances that predate the stamp still resolve correctly. workloadName := "orphaned" city := "unknown" placementName := "" - if deploymentUID != "" { - depSelector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadDeploymentUIDLabel: deploymentUID}) - var depList computev1alpha.WorkloadDeploymentList - if err := c.List(ctx, &depList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: depSelector}); err == nil && len(depList.Items) > 0 { - dep := depList.Items[0] - city = dep.Spec.CityCode - placementName = dep.Spec.PlacementName - workloadName = dep.Spec.WorkloadRef.Name + labelCity := inst.Labels[computev1alpha.CityCodeLabel] + labelWLName := inst.Labels[computev1alpha.WorkloadNameLabel] + labelPlacement := inst.Labels[computev1alpha.PlacementNameLabel] + + if labelCity != "" && labelWLName != "" && labelPlacement != "" { + // All three labels present: no join needed. + city = labelCity + workloadName = labelWLName + placementName = labelPlacement + } else { + // At least one label absent — fall back to WorkloadDeployment Get. + // Prefer the WorkloadDeploymentNameLabel; fall back to deriving the WD + // name from the Instance name for existing instances that lack the label. + depName := inst.Labels[computev1alpha.WorkloadDeploymentNameLabel] + if depName == "" { + depName = wdNameFromInstanceName(inst.Name) + } + if depName != "" { + var dep computev1alpha.WorkloadDeployment + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: depName}, &dep); err == nil { + if labelCity != "" { + city = labelCity + } else { + city = dep.Spec.CityCode + } + if labelPlacement != "" { + placementName = labelPlacement + } else { + placementName = dep.Spec.PlacementName + } + if labelWLName != "" { + workloadName = labelWLName + } else { + workloadName = dep.Spec.WorkloadRef.Name + } + } else { + // WD Get failed — use whatever labels we do have. + if labelCity != "" { + city = labelCity + } + if labelWLName != "" { + workloadName = labelWLName + } + if labelPlacement != "" { + placementName = labelPlacement + } + } } } @@ -385,6 +464,32 @@ func networkSummary(ifaces []computev1alpha.InstanceNetworkInterfaceStatus) stri return fmt.Sprintf("External: %s Internal: %s", extIP, intIP) } +// wdNameFromInstanceName derives the WorkloadDeployment name from an Instance +// name by stripping the trailing "-" suffix. Instance names follow the +// convention "-" (e.g. "my-api-default-dfw-0" → "my-api-default-dfw"). +// This is used as a fallback when WorkloadDeploymentNameLabel is absent on older +// instances that predate that label. +// +// If the name has no trailing numeric segment (not a standard instance name), +// the original name is returned unchanged so callers can handle it gracefully. +func wdNameFromInstanceName(instanceName string) string { + idx := strings.LastIndex(instanceName, "-") + if idx < 0 { + return instanceName + } + suffix := instanceName[idx+1:] + // The suffix must be entirely numeric digits to qualify as an ordinal. + for _, r := range suffix { + if !unicode.IsDigit(r) { + return instanceName + } + } + if suffix == "" { + return instanceName + } + return instanceName[:idx] +} + // formatEnvVar renders a single EnvVar for display. func formatEnvVar(e corev1.EnvVar) string { if e.ValueFrom != nil { From 69c7ff5388c6f6e876d279286a2a7cee563ee7c7 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Mon, 1 Jun 2026 15:43:37 -0500 Subject: [PATCH 25/27] fix: wait for instances to be ready before reporting rollout complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `compute deploy` rollout watcher reported PHASE=Done and exited within seconds of creating the workload, before any instances were scheduled. A WorkloadDeployment's Status.DesiredReplicas stays at zero until the controller first reconciles it, and computePhase treated zero desired as Done — so the very first poll of a fresh deployment looked complete. Resolve the wait target from the spec minimum while the controller has not yet reported a desired count, and require that no stale replicas remain before reporting Done so scale-downs and rolling updates aren't declared complete while old instances are still draining. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cmd/compute/watch/watch.go | 29 ++-- internal/cmd/compute/watch/watch_test.go | 181 +++++++++++++++++++++++ 2 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 internal/cmd/compute/watch/watch_test.go diff --git a/internal/cmd/compute/watch/watch.go b/internal/cmd/compute/watch/watch.go index 804fcb79..470023c9 100644 --- a/internal/cmd/compute/watch/watch.go +++ b/internal/cmd/compute/watch/watch.go @@ -112,11 +112,11 @@ func processDeployments( key := d.Spec.CityCode prev, exists := states[key] - desired := d.Status.DesiredReplicas + desired := resolveDesired(d) ready := d.Status.ReadyReplicas current := d.Status.CurrentReplicas - newPhase := computePhase(desired, ready, current, prev) + newPhase := computePhase(desired, ready, current, d.Status.Replicas) if !exists || prev.desired != desired || prev.ready != ready || prev.current != current || prev.phase != newPhase { newPhase = updateDeploymentState(states, key, d, exists, prev, desired, ready, current, newPhase) @@ -186,10 +186,7 @@ func printDeploymentRow( current, ready int32, newPhase deploymentPhase, ) { - old := d.Status.Replicas - d.Status.CurrentReplicas - if old < 0 { - old = 0 - } + old := max(d.Status.Replicas-d.Status.CurrentReplicas, 0) _, _ = fmt.Fprintf(tw, " %s\t%s\t%d\t%d\t%d\t%s\n", d.Spec.PlacementName, @@ -217,16 +214,28 @@ func printElapsed(out io.Writer, elapsed time.Duration) { } } -func computePhase(desired, ready, current int32, _ *deploymentState) deploymentPhase { +// resolveDesired returns the replica count the rollout should wait for. +// Status.DesiredReplicas stays at zero until the controller first reconciles +// the deployment; until then, fall back to the spec minimum so a freshly +// created deployment isn't reported Done before any instances are scheduled. +// Once the controller has reported a desired count, trust it. +func resolveDesired(d computev1alpha.WorkloadDeployment) int32 { + if d.Status.DesiredReplicas == 0 { + return d.Spec.ScaleSettings.MinReplicas + } + return d.Status.DesiredReplicas +} + +func computePhase(desired, ready, current, replicas int32) deploymentPhase { if desired == 0 { return phaseDone } + if ready >= desired && current >= desired && replicas <= current { + return phaseDone + } if current == 0 { return phasePending } - if ready >= desired && current >= desired { - return phaseDone - } return phaseUpdating } diff --git a/internal/cmd/compute/watch/watch_test.go b/internal/cmd/compute/watch/watch_test.go new file mode 100644 index 00000000..33734954 --- /dev/null +++ b/internal/cmd/compute/watch/watch_test.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package watch + +import ( + "testing" + "time" + + computev1alpha "go.datum.net/compute/api/v1alpha" +) + +func TestResolveDesired(t *testing.T) { + tests := []struct { + name string + statusDesired int32 + specMinReplicas int32 + want int32 + }{ + { + name: "unreconciled status falls back to spec min", + statusDesired: 0, + specMinReplicas: 1, + want: 1, + }, + { + name: "genuine scale-to-zero", + statusDesired: 0, + specMinReplicas: 0, + want: 0, + }, + { + name: "controller desired above min (e.g. autoscaled)", + statusDesired: 3, + specMinReplicas: 1, + want: 3, + }, + { + // Once the controller has spoken, trust its value even if below spec min. + name: "controller desired below min — trust controller", + statusDesired: 1, + specMinReplicas: 2, + want: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d := computev1alpha.WorkloadDeployment{} + d.Status.DesiredReplicas = tc.statusDesired + d.Spec.ScaleSettings.MinReplicas = tc.specMinReplicas + + got := resolveDesired(d) + if got != tc.want { + t.Errorf("resolveDesired() = %d, want %d", got, tc.want) + } + }) + } +} + +func TestComputePhase(t *testing.T) { + tests := []struct { + name string + desired int32 + ready int32 + current int32 + replicas int32 + want deploymentPhase + }{ + { + name: "zero desired is Done", + desired: 0, + ready: 0, + current: 0, + replicas: 0, + want: phaseDone, + }, + { + name: "fresh create with no instances yet is Pending", + desired: 1, + ready: 0, + current: 0, + replicas: 0, + want: phasePending, + }, + { + name: "instance scheduled but not ready is Updating", + desired: 1, + ready: 0, + current: 1, + replicas: 1, + want: phaseUpdating, + }, + { + name: "single instance ready is Done", + desired: 1, + ready: 1, + current: 1, + replicas: 1, + want: phaseDone, + }, + { + // OLD replicas still draining after scale-down must not report Done. + name: "scale-down with old replicas still draining is Updating", + desired: 1, + ready: 1, + current: 1, + replicas: 5, + want: phaseUpdating, + }, + { + name: "partial readiness is Updating", + desired: 3, + ready: 1, + current: 2, + replicas: 3, + want: phaseUpdating, + }, + { + name: "all replicas ready is Done", + desired: 2, + ready: 2, + current: 2, + replicas: 2, + want: phaseDone, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := computePhase(tc.desired, tc.ready, tc.current, tc.replicas) + if got != tc.want { + t.Errorf("computePhase(%d, %d, %d, %d) = %q, want %q", + tc.desired, tc.ready, tc.current, tc.replicas, got, tc.want) + } + }) + } +} + +func TestUpdateDeploymentState(t *testing.T) { + const key = "DFW" + + makeDeployment := func() computev1alpha.WorkloadDeployment { + var d computev1alpha.WorkloadDeployment + d.Spec.PlacementName = "default" + d.Spec.CityCode = key + return d + } + + t.Run("stalled updating for 40s is promoted to Blocked", func(t *testing.T) { + states := map[string]*deploymentState{} + stalledAt := time.Now().Add(-40 * time.Second) + states[key] = &deploymentState{ + phase: phaseUpdating, + ready: 1, + current: 2, + stalledSince: stalledAt, + } + + got := updateDeploymentState(states, key, makeDeployment(), true, states[key], 3, 1, 2, phaseUpdating) + + if got != phaseBlocked { + t.Errorf("updateDeploymentState() phase = %q, want %q", got, phaseBlocked) + } + if states[key].phase != phaseBlocked { + t.Errorf("states[key].phase = %q, want %q", states[key].phase, phaseBlocked) + } + }) + + t.Run("first observation of Updating is not yet Blocked", func(t *testing.T) { + states := map[string]*deploymentState{} + + got := updateDeploymentState(states, key, makeDeployment(), false, nil, 3, 1, 2, phaseUpdating) + + if got != phaseUpdating { + t.Errorf("updateDeploymentState() phase = %q, want %q", got, phaseUpdating) + } + if states[key].phase != phaseUpdating { + t.Errorf("states[key].phase = %q, want %q", states[key].phase, phaseUpdating) + } + }) +} From 685e353263c2e8a1a649d7247d12e3db08e4410b Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Mon, 1 Jun 2026 16:23:33 -0500 Subject: [PATCH 26/27] chore: ignore goreleaser dist output and local plugin binary Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index d5cc564d..08f06784 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,9 @@ bin/ # Local e2e environment artefacts (Kind kubeconfigs, etc.) tmp/ + +# GoReleaser build output +dist/ + +# Local datumctl plugin build binary +/datumctl-compute From 8e94e2000de04e1f989fb2c16535439bcfe9ff55 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Mon, 1 Jun 2026 20:10:41 -0500 Subject: [PATCH 27/27] feat(cli): surface blocking reason+message from readiness conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume the server-side status-blocking-reason contract: each resource's readiness condition (Instance/Ready, WorkloadDeployment/Available, Workload/Available) now carries a machine-readable reason and human message when not True. - Add ReadinessBlock helper in util/conditions.go: given a condition list and type, returns (reason, message, blocked) with no per-reason branching — the single reusable entry-point for the new contract. - InstanceStatus (list view): falls through to "Pending ()" from the Ready condition when no specific sub-condition check matches, replacing the bare "Pending" for unknown causes like SourceNotFound or ReferencedDataNotReady. - InstanceStatusDetail (describe view): falls through to "Pending — " with the message as detail, replacing "Unknown" for those same causes. - WorkloadHealth: surfaces the reason from Available when false, e.g. "Unavailable — SourceNotFound" instead of the generic message. - degradedAnnotation (workloads describe per-city line): rewritten to read the WorkloadDeployment's own Available condition; removes the per-instance List fetch and the quota/InstanceStatusDetail special-casing that was its only logic. - printBlockedDetail (rollout watch): rewritten to read the deployment's Available condition; removes the per-instance List fetch entirely. Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/compute/util/conditions.go | 42 ++- internal/cmd/compute/util/conditions_test.go | 258 +++++++++++++++++++ internal/cmd/compute/watch/watch.go | 29 +-- internal/cmd/compute/workloads/workloads.go | 40 +-- 4 files changed, 316 insertions(+), 53 deletions(-) create mode 100644 internal/cmd/compute/util/conditions_test.go diff --git a/internal/cmd/compute/util/conditions.go b/internal/cmd/compute/util/conditions.go index 4d5fa96b..5d0a1032 100644 --- a/internal/cmd/compute/util/conditions.go +++ b/internal/cmd/compute/util/conditions.go @@ -17,6 +17,18 @@ func FindCondition(conditions []metav1.Condition, condType string) *metav1.Condi return nil } +// ReadinessBlock reads the named readiness condition and reports whether it is +// blocking (present and not True). When blocked, it returns the machine-readable +// reason and human-readable message from the condition. Callers must not branch +// on specific reason values — display whatever the server emits. +func ReadinessBlock(conditions []metav1.Condition, condType string) (reason, message string, blocked bool) { + c := FindCondition(conditions, condType) + if c == nil || c.Status == metav1.ConditionTrue { + return "", "", false + } + return c.Reason, c.Message, true +} + // InstanceStatus returns a short user-facing status string for list views. // Priority order: // @@ -27,7 +39,7 @@ func FindCondition(conditions []metav1.Condition, condType string) *metav1.Condi // Programmed=False/PendingProgramming or ProgrammingInProgress → "Pending (network provisioning)" // Running=False/Starting → "Starting" // Running=False/Stopping → "Stopping" -// Ready=False/SchedulingGatesPresent → "Pending (scheduling gates)" +// Ready=False/ → "Pending ()" from server-rolled-up blocking reason // default → "Pending" func InstanceStatus(conditions []metav1.Condition) string { ready := FindCondition(conditions, v1alpha.InstanceReady) @@ -68,10 +80,12 @@ func InstanceStatus(conditions []metav1.Condition) string { } } - if ready != nil && ready.Status == metav1.ConditionFalse { - if ready.Reason == v1alpha.InstanceReadyReasonSchedulingGatesPresent { - return "Pending (scheduling gates)" - } + // Use the server-rolled-up blocking reason from the Ready condition. This + // surfaces reasons like SourceNotFound or ReferencedDataNotReady that the + // sub-condition checks above don't cover, without requiring client-side + // knowledge of every reason value. + if reason, _, blocked := ReadinessBlock(conditions, v1alpha.InstanceReady); blocked && reason != "" { + return "Pending (" + reason + ")" } return "Pending" @@ -85,7 +99,8 @@ func InstanceStatus(conditions []metav1.Condition) string { // Programmed=False/ProgrammingInProgress → "Not running — network provisioning in progress", "" // Running=False/Starting → "Starting", "" // Running=False/Stopping → "Stopping", "" -// default → "Unknown", "" +// Ready=False/ → "Pending — ", message (server-rolled-up blocking reason) +// default → "Pending", "" func InstanceStatusDetail(conditions []metav1.Condition) (status, detail string) { ready := FindCondition(conditions, v1alpha.InstanceReady) if ready != nil && ready.Status == metav1.ConditionTrue { @@ -117,14 +132,22 @@ func InstanceStatusDetail(conditions []metav1.Condition) (status, detail string) } } - return "Unknown", "" + // Fall back to the server-rolled-up blocking reason on the Ready condition. + // This surfaces reasons like SourceNotFound and ReferencedDataNotReady + // without requiring client-side special-casing of every reason value. + if reason, msg, blocked := ReadinessBlock(conditions, v1alpha.InstanceReady); blocked && reason != "" { + return "Pending — " + reason, msg + } + + return "Pending", "" } // WorkloadHealth derives a one-line health summary from workload Available condition + replica counts. // // Available=True, ready==desired → "Available — all placements at desired replicas" // Available=True, ready → "Unavailable — " (reason from server-rolled-up blocking reason) +// Available=False (no reason) → "Unavailable — no healthy instances" // Unknown/missing → "Unknown" func WorkloadHealth(conditions []metav1.Condition, ready, desired int32) string { avail := FindCondition(conditions, v1alpha.WorkloadAvailable) @@ -132,6 +155,9 @@ func WorkloadHealth(conditions []metav1.Condition, ready, desired int32) string return "Unknown" } if avail.Status == metav1.ConditionFalse { + if avail.Reason != "" { + return "Unavailable — " + avail.Reason + } return "Unavailable — no healthy instances" } // Available=True diff --git a/internal/cmd/compute/util/conditions_test.go b/internal/cmd/compute/util/conditions_test.go new file mode 100644 index 00000000..1593fc31 --- /dev/null +++ b/internal/cmd/compute/util/conditions_test.go @@ -0,0 +1,258 @@ +package util + +import ( + "testing" + + v1alpha "go.datum.net/compute/api/v1alpha" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func makeCondition(condType, status, reason, message string) metav1.Condition { + return metav1.Condition{ + Type: condType, + Status: metav1.ConditionStatus(status), + Reason: reason, + Message: message, + } +} + +// TestReadinessBlock covers the generic helper that is the heart of the +// status-blocking-reason contract. +func TestReadinessBlock(t *testing.T) { + tests := []struct { + name string + conditions []metav1.Condition + condType string + wantReason string + wantMessage string + wantBlocked bool + }{ + { + name: "condition absent — not blocked", + conditions: nil, + condType: v1alpha.InstanceReady, + wantBlocked: false, + }, + { + name: "condition True — not blocked", + conditions: []metav1.Condition{ + makeCondition(v1alpha.InstanceReady, "True", "Running", ""), + }, + condType: v1alpha.InstanceReady, + wantBlocked: false, + }, + { + name: "condition False with reason and message — blocked", + conditions: []metav1.Condition{ + makeCondition(v1alpha.InstanceReady, "False", "SourceNotFound", `ConfigMap "app-config" not found in namespace "default"`), + }, + condType: v1alpha.InstanceReady, + wantReason: "SourceNotFound", + wantMessage: `ConfigMap "app-config" not found in namespace "default"`, + wantBlocked: true, + }, + { + name: "condition False with reason only — blocked", + conditions: []metav1.Condition{ + makeCondition(v1alpha.InstanceReady, "False", "ReferencedDataNotReady", ""), + }, + condType: v1alpha.InstanceReady, + wantReason: "ReferencedDataNotReady", + wantMessage: "", + wantBlocked: true, + }, + { + name: "condition Unknown — blocked", + conditions: []metav1.Condition{ + makeCondition(v1alpha.InstanceReady, "Unknown", "PendingQuota", ""), + }, + condType: v1alpha.InstanceReady, + wantReason: "PendingQuota", + wantBlocked: true, + }, + { + name: "wrong condition type present — not blocked", + conditions: []metav1.Condition{ + makeCondition(v1alpha.InstanceProgrammed, "False", "PendingProgramming", ""), + }, + condType: v1alpha.InstanceReady, + wantBlocked: false, + }, + { + name: "WorkloadDeploymentAvailable False — blocked", + conditions: []metav1.Condition{ + makeCondition(v1alpha.WorkloadDeploymentAvailable, "False", "NetworkProvisioning", "Waiting for network assignment"), + }, + condType: v1alpha.WorkloadDeploymentAvailable, + wantReason: "NetworkProvisioning", + wantMessage: "Waiting for network assignment", + wantBlocked: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reason, msg, blocked := ReadinessBlock(tc.conditions, tc.condType) + if blocked != tc.wantBlocked { + t.Errorf("ReadinessBlock() blocked = %v, want %v", blocked, tc.wantBlocked) + } + if reason != tc.wantReason { + t.Errorf("ReadinessBlock() reason = %q, want %q", reason, tc.wantReason) + } + if msg != tc.wantMessage { + t.Errorf("ReadinessBlock() message = %q, want %q", msg, tc.wantMessage) + } + }) + } +} + +// TestInstanceStatusDetail_BlockingReason verifies that the describe view +// surfaces reason+message from the Ready condition when no specific sub-condition +// check matches — the server-rolled-up blocking reason path. +func TestInstanceStatusDetail_BlockingReason(t *testing.T) { + tests := []struct { + name string + conditions []metav1.Condition + wantStatus string + wantDetail string + }{ + { + name: "no conditions — Pending, no detail", + conditions: nil, + wantStatus: "Pending", + wantDetail: "", + }, + { + name: "Ready True — Running", + conditions: []metav1.Condition{ + makeCondition(v1alpha.InstanceReady, "True", "Running", ""), + }, + wantStatus: "Running", + wantDetail: "", + }, + { + name: "Ready False / SourceNotFound — Pending with reason and message", + conditions: []metav1.Condition{ + makeCondition(v1alpha.InstanceReady, "False", "SourceNotFound", `ConfigMap "app-config" not found in namespace "default"`), + }, + wantStatus: "Pending — SourceNotFound", + wantDetail: `ConfigMap "app-config" not found in namespace "default"`, + }, + { + name: "Ready False / ReferencedDataNotReady — Pending with reason, no message", + conditions: []metav1.Condition{ + makeCondition(v1alpha.InstanceReady, "False", "ReferencedDataNotReady", ""), + }, + wantStatus: "Pending — ReferencedDataNotReady", + wantDetail: "", + }, + { + name: "quota exceeded still uses specific path", + conditions: []metav1.Condition{ + makeCondition(v1alpha.InstanceQuotaGranted, "False", v1alpha.InstanceQuotaGrantedReasonQuotaExceeded, "quota limit reached"), + }, + wantStatus: "Not running — quota exceeded", + wantDetail: "quota limit reached", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + status, detail := InstanceStatusDetail(tc.conditions) + if status != tc.wantStatus { + t.Errorf("InstanceStatusDetail() status = %q, want %q", status, tc.wantStatus) + } + if detail != tc.wantDetail { + t.Errorf("InstanceStatusDetail() detail = %q, want %q", detail, tc.wantDetail) + } + }) + } +} + +// TestInstanceStatus_BlockingReason verifies that the list-view short status +// surfaces the server-rolled-up reason from Ready when no sub-condition matches. +func TestInstanceStatus_BlockingReason(t *testing.T) { + tests := []struct { + name string + conditions []metav1.Condition + wantStatus string + }{ + { + name: "no conditions — Pending", + conditions: nil, + wantStatus: "Pending", + }, + { + name: "Ready False / SourceNotFound — Pending (SourceNotFound)", + conditions: []metav1.Condition{ + makeCondition(v1alpha.InstanceReady, "False", "SourceNotFound", `ConfigMap "app-config" not found`), + }, + wantStatus: "Pending (SourceNotFound)", + }, + { + name: "Ready False / ReferencedDataNotReady — Pending (ReferencedDataNotReady)", + conditions: []metav1.Condition{ + makeCondition(v1alpha.InstanceReady, "False", "ReferencedDataNotReady", ""), + }, + wantStatus: "Pending (ReferencedDataNotReady)", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := InstanceStatus(tc.conditions) + if got != tc.wantStatus { + t.Errorf("InstanceStatus() = %q, want %q", got, tc.wantStatus) + } + }) + } +} + +// TestWorkloadHealth_BlockingReason verifies that Unavailable workloads surface +// the reason from the Available condition rather than a generic message. +func TestWorkloadHealth_BlockingReason(t *testing.T) { + tests := []struct { + name string + conditions []metav1.Condition + ready int32 + desired int32 + wantHealth string + }{ + { + name: "Available False with reason — Unavailable — ", + conditions: []metav1.Condition{ + makeCondition(v1alpha.WorkloadAvailable, "False", "SourceNotFound", `ConfigMap "app-config" not found`), + }, + ready: 0, + desired: 1, + wantHealth: "Unavailable — SourceNotFound", + }, + { + name: "Available False without reason — generic message", + conditions: []metav1.Condition{ + makeCondition(v1alpha.WorkloadAvailable, "False", "", ""), + }, + ready: 0, + desired: 1, + wantHealth: "Unavailable — no healthy instances", + }, + { + name: "Available True all ready", + conditions: []metav1.Condition{ + makeCondition(v1alpha.WorkloadAvailable, "True", "Available", ""), + }, + ready: 2, + desired: 2, + wantHealth: "Available — all placements at desired replicas", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := WorkloadHealth(tc.conditions, tc.ready, tc.desired) + if got != tc.wantHealth { + t.Errorf("WorkloadHealth() = %q, want %q", got, tc.wantHealth) + } + }) + } +} diff --git a/internal/cmd/compute/watch/watch.go b/internal/cmd/compute/watch/watch.go index 470023c9..13b2651d 100644 --- a/internal/cmd/compute/watch/watch.go +++ b/internal/cmd/compute/watch/watch.go @@ -239,26 +239,17 @@ func computePhase(desired, ready, current, replicas int32) deploymentPhase { return phaseUpdating } -// printBlockedDetail fetches instances for the deployment and prints a reason -// for the first non-ready instance. -func printBlockedDetail(ctx context.Context, c client.Client, out io.Writer, _ string, d computev1alpha.WorkloadDeployment) { - selector := labels.SelectorFromSet(labels.Set{ - computev1alpha.WorkloadDeploymentUIDLabel: string(d.UID), - }) - var instList computev1alpha.InstanceList - if err := c.List(ctx, &instList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: selector}); err != nil { +// printBlockedDetail prints the blocking reason from the deployment's own +// Available condition. The server rolls up the underlying instance cause there, +// so no per-instance fetch is needed. +func printBlockedDetail(_ context.Context, _ client.Client, out io.Writer, _ string, d computev1alpha.WorkloadDeployment) { + reason, msg, blocked := util.ReadinessBlock(d.Status.Conditions, computev1alpha.WorkloadDeploymentAvailable) + if !blocked { return } - for _, inst := range instList.Items { - ready := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceReady) - if ready == nil || ready.Status != "True" { - status, detail := util.InstanceStatusDetail(inst.Status.Conditions) - if detail != "" { - fmt.Fprintf(out, " Blocked reason: %s — %s\n", status, detail) - } else { - fmt.Fprintf(out, " Blocked reason: %s\n", status) - } - return - } + if msg != "" { + fmt.Fprintf(out, " Blocked reason: %s — %s\n", reason, msg) + } else if reason != "" { + fmt.Fprintf(out, " Blocked reason: %s\n", reason) } } diff --git a/internal/cmd/compute/workloads/workloads.go b/internal/cmd/compute/workloads/workloads.go index a8071fdf..6e54ee38 100644 --- a/internal/cmd/compute/workloads/workloads.go +++ b/internal/cmd/compute/workloads/workloads.go @@ -381,7 +381,7 @@ func runDescribe(cmd *cobra.Command, args []string) error { readyStr := fmt.Sprintf("%d/%d", d.Status.ReadyReplicas, d.Status.DesiredReplicas) annotation := "" if d.Status.ReadyReplicas < d.Status.DesiredReplicas { - // Find a reason from instances — best-effort. + // Read blocking reason from the deployment's own condition. annotation = degradedAnnotation(ctx, c, d) } if annotation != "" { @@ -437,34 +437,22 @@ func runDescribe(cmd *cobra.Command, args []string) error { return nil } -// degradedAnnotation looks up the first unhealthy instance for a deployment -// and returns a short annotation string, e.g. "Degraded — quota exceeded". -func degradedAnnotation(ctx context.Context, c client.Client, d computev1alpha.WorkloadDeployment) string { - depSelector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadDeploymentUIDLabel: string(d.UID)}) - var instList computev1alpha.InstanceList - if err := c.List(ctx, &instList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: depSelector}); err != nil { +// degradedAnnotation returns a short annotation for a per-city line when the +// deployment is not fully ready. It reads the blocking reason+message from the +// deployment's own Available condition, which the server rolls up from the +// underlying instances. No per-instance fetch or reason branching needed. +func degradedAnnotation(_ context.Context, _ client.Client, d computev1alpha.WorkloadDeployment) string { + reason, msg, blocked := util.ReadinessBlock(d.Status.Conditions, computev1alpha.WorkloadDeploymentAvailable) + if !blocked { return "" } - - for _, inst := range instList.Items { - readyCond := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceReady) - if readyCond != nil && readyCond.Status == "True" { - continue - } - quotaCond := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceQuotaGranted) - if quotaCond != nil && quotaCond.Reason == computev1alpha.InstanceQuotaGrantedReasonQuotaExceeded { - msg := quotaCond.Message - if msg != "" { - return "Degraded — quota exceeded (" + msg + ")" - } - return "Degraded — quota exceeded" - } - statusLine, _ := util.InstanceStatusDetail(inst.Status.Conditions) - if statusLine != "" && statusLine != "Unknown" { - return "Degraded — " + strings.ToLower(statusLine) - } + if msg != "" { + return "Blocked — " + msg + } + if reason != "" { + return "Blocked — " + reason } - return "" + return "Blocked" } // formatEnvVar renders a single EnvVar for display.