From 2ff9ee0c2a49be83fd060f0f1c88c038ebebd664 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 16:17:27 -0500 Subject: [PATCH 1/8] 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 | 126 +++--- go.sum | 321 +++++++------- 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, 2884 insertions(+), 219 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 0000000..69fb233 --- /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 0000000..e092b3e --- /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 19fc010..513e579 100644 --- a/go.mod +++ b/go.mod @@ -1,108 +1,120 @@ module go.datum.net/compute -go 1.24.0 - -toolchain go1.24.2 +go 1.25.8 require ( github.com/google/go-cmp v0.7.0 - github.com/onsi/ginkgo/v2 v2.23.4 - github.com/onsi/gomega v1.37.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.0.0 go.datum.net/network-services-operator v0.1.0 go.miloapis.com/milo v0.24.11 - golang.org/x/crypto v0.39.0 - golang.org/x/sync v0.16.0 + 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.33.1 - k8s.io/apimachinery v0.33.2 - k8s.io/client-go v0.33.1 - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 - sigs.k8s.io/controller-runtime v0.21.0 + 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.2.1 sigs.k8s.io/multicluster-runtime v0.21.0-alpha.8 ) require ( - cel.dev/expr v0.19.1 // indirect + cel.dev/expr v0.24.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // 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 - github.com/fxamacker/cbor/v2 v2.8.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.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/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/cel-go v0.23.2 // indirect - github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/cel-go v0.26.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/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.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.64.0 // indirect + github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect - github.com/spf13/cobra v1.9.1 // indirect - github.com/spf13/pflag v1.0.7 // 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.58.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/sdk v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect - go.opentelemetry.io/proto/otlp v1.4.0 // indirect - go.uber.org/automaxprocs v1.6.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 + 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.2 // 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/net v0.41.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.26.0 // indirect - golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.33.0 // 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-20250303144028-a0af3efb3deb // indirect - google.golang.org/grpc v1.71.1 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // 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.33.1 // indirect - k8s.io/apiserver v0.33.1 // indirect - k8s.io/component-base v0.33.1 // indirect + k8s.io/apiextensions-apiserver 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-20250610211856-8b98d1ed966a // 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-20241014173422-cfa47c3a1cc8 // 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/v4 v4.7.0 // indirect - sigs.k8s.io/yaml v1.5.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 c472bd8..7639dd2 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ -cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= -cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +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.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= @@ -15,8 +17,8 @@ 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/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= @@ -25,8 +27,14 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= -github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +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-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= @@ -34,25 +42,52 @@ 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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +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/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.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4= -github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo= -github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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.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= @@ -62,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.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= 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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 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= @@ -82,42 +113,43 @@ 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= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= 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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 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/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/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= -github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= -github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= -github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +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= -github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= -github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= -github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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/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= @@ -129,160 +161,125 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +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/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 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.1.0 h1:AYFVz1lfta/NbWSFSSKPtnkCA2rN+iegxlfQrDgEvYY= -go.miloapis.com/milo v0.1.0/go.mod h1:X+DpWOchv/Vm63mwHnboW00KRGsODY2bUTS/bBbK1+E= 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.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.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= -go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= -go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +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= 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.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= -go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +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/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -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.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= 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-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= -google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +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= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +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.33.1 h1:tA6Cf3bHnLIrUK4IqEgb2v++/GYUtqiu9sRVk3iBXyw= -k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw= -k8s.io/apiextensions-apiserver v0.33.1 h1:N7ccbSlRN6I2QBcXevB73PixX2dQNIW0ZRuguEE91zI= -k8s.io/apiextensions-apiserver v0.33.1/go.mod h1:uNQ52z1A1Gu75QSa+pFK5bcXc4hq7lpOXbweZgi4dqA= -k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= -k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= -k8s.io/apiserver v0.33.1 h1:yLgLUPDVC6tHbNcw5uE9mo1T6ELhJj7B0geifra3Qdo= -k8s.io/apiserver v0.33.1/go.mod h1:VMbE4ArWYLO01omz+k8hFjAdYfc3GVAYPrhP2tTKccs= -k8s.io/client-go v0.33.1 h1:ZZV/Ks2g92cyxWkRRnfUDsnhNn28eFpt26aGc8KbXF4= -k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA= -k8s.io/component-base v0.33.1 h1:EoJ0xA+wr77T+G8p6T3l4efT2oNwbqBVKR71E0tBIaI= -k8s.io/component-base v0.33.1/go.mod h1:guT/w/6piyPfTgq7gfvgetyXMIh10zuXA6cRRm3rDuY= +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.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-20250610211856-8b98d1ed966a h1:ZV3Zr+/7s7aVbjNGICQt+ppKWsF1tehxggNfbM7XnG8= -k8s.io/kube-openapi v0.0.0-20250610211856-8b98d1ed966a/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/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.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-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +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/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 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/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= -sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= -sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= -sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= +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 0000000..4186b2b --- /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 0000000..c1a0975 --- /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 0000000..74abc4d --- /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 0000000..56f9a75 --- /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 0000000..c860133 --- /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 0000000..1907011 --- /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 0000000..8e8ca35 --- /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 0000000..f355836 --- /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 0000000..f61e18b --- /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 0000000..28112dd --- /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 0000000..10cbcfe --- /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 0000000..852a374 --- /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 0000000..4d5fa96 --- /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 0000000..6402c44 --- /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 0000000..906af66 --- /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 0000000..836c485 --- /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 79562f594b35b617e648aa9ee95f572d9546da76 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 16:47:12 -0500 Subject: [PATCH 2/8] 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 c1a0975..6430547 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 74abc4d..0695f1f 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 56f9a75..f8897e3 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 c860133..a0b0552 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 1907011..b11f3d5 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 f355836..3f309cf 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 28112dd..5190261 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 10cbcfe..5922785 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 852a374..7129df2 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 836c485..b4e62d6 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 6929f237266e6053ee37ddaa33f39a92cc746ae3 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 16:58:19 -0500 Subject: [PATCH 3/8] 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 | 4 +- .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, 115 insertions(+), 157 deletions(-) delete mode 100644 internal/cmd/compute/client.go diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ddfaa17..2ef3bca 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,9 +15,9 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '~1.24.0' + go-version-file: 'go.mod' - name: Run linter uses: golangci/golangci-lint-action@v8 with: - version: v2.1.5 + version: v2.12.2 diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 8429bf2..b3b66dc 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.24.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 834d33a..07fbf7c 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.24.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 e092b3e..b83f0a8 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 513e579..90c917f 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 7639dd2..6ba1301 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 4186b2b..0000000 --- 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 3f309cf..d81a71d 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 f61e18b..77bda5c 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 7129df2..cbe90e2 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 a6c55d42eb02df39a015659cce86388154f0b749 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 17:15:52 -0500 Subject: [PATCH 4/8] 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 +- cmd/main.go | 2 +- 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/controller/instance_controller.go | 7 +- .../controller/instance_controller_test.go | 27 +- internal/controller/workload_controller.go | 3 +- .../workloaddeployment_controller.go | 7 +- internal/provider/milo/provider.go | 352 ++++++++++++++++++ internal/webhook/v1alpha/workload_webhook.go | 10 +- 13 files changed, 411 insertions(+), 97 deletions(-) create mode 100644 internal/provider/milo/provider.go diff --git a/cmd/datumctl-compute/main.go b/cmd/datumctl-compute/main.go index b83f0a8..4571a58 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/cmd/main.go b/cmd/main.go index 3bb44bc..305b486 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -37,7 +37,7 @@ import ( networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" multiclusterproviders "go.miloapis.com/milo/pkg/multicluster-runtime" - milomulticluster "go.miloapis.com/milo/pkg/multicluster-runtime/milo" + milomulticluster "go.datum.net/compute/internal/provider/milo" // +kubebuilder:scaffold:imports ) diff --git a/go.mod b/go.mod index 90c917f..57e51a0 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 6ba1301..1b84d2d 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 d81a71d..1702d40 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 77bda5c..f61e18b 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 cbe90e2..01031ed 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/controller/instance_controller.go b/internal/controller/instance_controller.go index e5bc356..535de3b 100644 --- a/internal/controller/instance_controller.go +++ b/internal/controller/instance_controller.go @@ -24,6 +24,7 @@ import ( mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder" mccontext "sigs.k8s.io/multicluster-runtime/pkg/context" mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + "sigs.k8s.io/multicluster-runtime/pkg/multicluster" mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" computev1alpha "go.datum.net/compute/api/v1alpha" @@ -38,7 +39,7 @@ const instanceQuotaFinalizer = "quota.compute.datumapis.com/claim-cleanup" // clusterGetter is the subset of mcmanager.Manager used by InstanceReconciler. // Keeping it narrow allows unit tests to substitute a minimal fake. type clusterGetter interface { - GetCluster(ctx context.Context, clusterName string) (cluster.Cluster, error) + GetCluster(ctx context.Context, clusterName multicluster.ClusterName) (cluster.Cluster, error) } // InstanceReconciler reconciles an Instance object @@ -102,7 +103,7 @@ func (r *InstanceReconciler) Reconcile(ctx context.Context, req mcreconcile.Requ return ctrl.Result{}, nil } - grantedCondition, err := r.reconcileQuotaClaim(ctx, req.ClusterName, &instance) + grantedCondition, err := r.reconcileQuotaClaim(ctx, req.ClusterName.String(), &instance) if err != nil { return ctrl.Result{}, fmt.Errorf("failed reconciling quota claim: %w", err) } @@ -460,7 +461,7 @@ func (r *InstanceReconciler) SetupWithManager(mgr mcmanager.Manager, managementC Namespace: claim.Spec.ResourceRef.Namespace, }, }, - ClusterName: claim.Spec.ConsumerRef.Name, + ClusterName: multicluster.ClusterName(claim.Spec.ConsumerRef.Name), }, } }), diff --git a/internal/controller/instance_controller_test.go b/internal/controller/instance_controller_test.go index 1a15090..548f530 100644 --- a/internal/controller/instance_controller_test.go +++ b/internal/controller/instance_controller_test.go @@ -14,6 +14,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" + "k8s.io/client-go/tools/events" "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" @@ -21,6 +22,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/cluster" "sigs.k8s.io/controller-runtime/pkg/reconcile" mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + "sigs.k8s.io/multicluster-runtime/pkg/multicluster" computev1alpha "go.datum.net/compute/api/v1alpha" "go.datum.net/compute/internal/controller/instancecontrol" @@ -33,24 +35,25 @@ type fakeCluster struct { scheme *runtime.Scheme } -func (f *fakeCluster) GetHTTPClient() *http.Client { return nil } -func (f *fakeCluster) GetConfig() *rest.Config { return nil } -func (f *fakeCluster) GetCache() cache.Cache { return nil } -func (f *fakeCluster) GetScheme() *runtime.Scheme { return f.scheme } -func (f *fakeCluster) GetClient() client.Client { return f.client } -func (f *fakeCluster) GetFieldIndexer() client.FieldIndexer { return nil } -func (f *fakeCluster) GetEventRecorderFor(string) record.EventRecorder { return nil } -func (f *fakeCluster) GetRESTMapper() apimeta.RESTMapper { return nil } -func (f *fakeCluster) GetAPIReader() client.Reader { return f.client } -func (f *fakeCluster) Start(context.Context) error { return nil } +func (f *fakeCluster) GetHTTPClient() *http.Client { return nil } +func (f *fakeCluster) GetConfig() *rest.Config { return nil } +func (f *fakeCluster) GetCache() cache.Cache { return nil } +func (f *fakeCluster) GetScheme() *runtime.Scheme { return f.scheme } +func (f *fakeCluster) GetClient() client.Client { return f.client } +func (f *fakeCluster) GetFieldIndexer() client.FieldIndexer { return nil } +func (f *fakeCluster) GetEventRecorderFor(string) record.EventRecorder { return nil } +func (f *fakeCluster) GetEventRecorder(string) events.EventRecorder { return nil } +func (f *fakeCluster) GetRESTMapper() apimeta.RESTMapper { return nil } +func (f *fakeCluster) GetAPIReader() client.Reader { return f.client } +func (f *fakeCluster) Start(context.Context) error { return nil } // fakeMCManager is a minimal multicluster manager that returns a single cluster. type fakeMCManager struct { clusters map[string]cluster.Cluster } -func (m *fakeMCManager) GetCluster(ctx context.Context, clusterName string) (cluster.Cluster, error) { - cl, ok := m.clusters[clusterName] +func (m *fakeMCManager) GetCluster(ctx context.Context, clusterName multicluster.ClusterName) (cluster.Cluster, error) { + cl, ok := m.clusters[clusterName.String()] if !ok { return nil, fmt.Errorf("cluster %q not found", clusterName) } diff --git a/internal/controller/workload_controller.go b/internal/controller/workload_controller.go index 6e907b6..bc522c4 100644 --- a/internal/controller/workload_controller.go +++ b/internal/controller/workload_controller.go @@ -26,6 +26,7 @@ import ( mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder" mccontext "sigs.k8s.io/multicluster-runtime/pkg/context" mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + "sigs.k8s.io/multicluster-runtime/pkg/multicluster" mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" computev1alpha "go.datum.net/compute/api/v1alpha" @@ -463,7 +464,7 @@ func (r *WorkloadReconciler) SetupWithManager(mgr mcmanager.Manager) error { return mcbuilder.ControllerManagedBy(mgr). For(&computev1alpha.Workload{}, mcbuilder.WithEngageWithLocalCluster(false)). Owns(&computev1alpha.WorkloadDeployment{}, mcbuilder.WithEngageWithLocalCluster(false)). - Watches(&networkingv1alpha.Network{}, func(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] { + Watches(&networkingv1alpha.Network{}, func(clusterName multicluster.ClusterName, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] { return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, network client.Object) []mcreconcile.Request { logger := log.FromContext(ctx) diff --git a/internal/controller/workloaddeployment_controller.go b/internal/controller/workloaddeployment_controller.go index 50e21ef..a7e8a24 100644 --- a/internal/controller/workloaddeployment_controller.go +++ b/internal/controller/workloaddeployment_controller.go @@ -24,6 +24,7 @@ import ( mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder" mccontext "sigs.k8s.io/multicluster-runtime/pkg/context" mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + "sigs.k8s.io/multicluster-runtime/pkg/multicluster" mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" computev1alpha "go.datum.net/compute/api/v1alpha" @@ -472,13 +473,13 @@ func (r *WorkloadDeploymentReconciler) SetupWithManager(mgr mcmanager.Manager) e For(&computev1alpha.WorkloadDeployment{}, mcbuilder.WithEngageWithLocalCluster(false)). Owns(&computev1alpha.Instance{}). Owns(&networkingv1alpha.NetworkBinding{}). - Watches(&networkingv1alpha.SubnetClaim{}, func(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] { + Watches(&networkingv1alpha.SubnetClaim{}, func(clusterName multicluster.ClusterName, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] { return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []mcreconcile.Request { subnetClaim := o.(*networkingv1alpha.SubnetClaim) return enqueueWorkloadDeploymentByLocation(ctx, mgr, clusterName, subnetClaim.Spec.Location) }) }). - Watches(&networkingv1alpha.Subnet{}, func(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] { + Watches(&networkingv1alpha.Subnet{}, func(clusterName multicluster.ClusterName, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] { return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []mcreconcile.Request { subnet := o.(*networkingv1alpha.Subnet) return enqueueWorkloadDeploymentByLocation(ctx, mgr, clusterName, subnet.Spec.Location) @@ -487,7 +488,7 @@ func (r *WorkloadDeploymentReconciler) SetupWithManager(mgr mcmanager.Manager) e Complete(r) } -func enqueueWorkloadDeploymentByLocation(ctx context.Context, mgr mcmanager.Manager, clusterName string, locationRef networkingv1alpha.LocationReference) []mcreconcile.Request { +func enqueueWorkloadDeploymentByLocation(ctx context.Context, mgr mcmanager.Manager, clusterName multicluster.ClusterName, locationRef networkingv1alpha.LocationReference) []mcreconcile.Request { logger := log.FromContext(ctx) cluster, err := mgr.GetCluster(ctx, clusterName) diff --git a/internal/provider/milo/provider.go b/internal/provider/milo/provider.go new file mode 100644 index 0000000..927ec58 --- /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 +} diff --git a/internal/webhook/v1alpha/workload_webhook.go b/internal/webhook/v1alpha/workload_webhook.go index e3f3735..82f248a 100644 --- a/internal/webhook/v1alpha/workload_webhook.go +++ b/internal/webhook/v1alpha/workload_webhook.go @@ -12,6 +12,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + "sigs.k8s.io/multicluster-runtime/pkg/multicluster" computev1alpha "go.datum.net/compute/api/v1alpha" "go.datum.net/compute/internal/validation" @@ -27,10 +28,9 @@ func SetupWorkloadWebhookWithManager(mgr mcmanager.Manager) error { mgr: mgr, } - return ctrl.NewWebhookManagedBy(mgr.GetLocalManager()). - For(&computev1alpha.Workload{}). - WithDefaulter(webhook). - WithValidator(webhook). + return ctrl.NewWebhookManagedBy(mgr.GetLocalManager(), &computev1alpha.Workload{}). + WithCustomDefaulter(webhook). + WithCustomValidator(webhook). Complete() } @@ -83,7 +83,7 @@ func (r *workloadWebhook) ValidateCreate(ctx context.Context, obj runtime.Object clusterName := computewebhook.ClusterNameFromContext(ctx) - cluster, err := r.mgr.GetCluster(ctx, clusterName) + cluster, err := r.mgr.GetCluster(ctx, multicluster.ClusterName(clusterName)) if err != nil { return nil, err } From 13c8b3667d93f8363d2b31934d149f1e4b94ed31 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 17:35:29 -0500 Subject: [PATCH 5/8] 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 --- cmd/main.go | 2 +- 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 | 25 +- .../controller/instance_controller_test.go | 106 +++++---- .../stateful/stateful_control_test.go | 6 +- internal/controller/workload_controller.go | 11 +- .../workloaddeployment_scheduler.go | 6 +- internal/validation/instance_validation.go | 24 +- .../validation/workload_validation_test.go | 68 +++--- internal/webhook/v1alpha/workload_webhook.go | 71 +----- 17 files changed, 310 insertions(+), 306 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 305b486..d638119 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -32,12 +32,12 @@ import ( computev1alpha "go.datum.net/compute/api/v1alpha" "go.datum.net/compute/internal/config" "go.datum.net/compute/internal/controller" + milomulticluster "go.datum.net/compute/internal/provider/milo" computewebhook "go.datum.net/compute/internal/webhook" computev1alphawebhooks "go.datum.net/compute/internal/webhook/v1alpha" networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" multiclusterproviders "go.miloapis.com/milo/pkg/multicluster-runtime" - milomulticluster "go.datum.net/compute/internal/provider/milo" // +kubebuilder:scaffold:imports ) diff --git a/internal/cmd/compute/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go index 6430547..bd6f5ed 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 0695f1f..b93f95c 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 f8897e3..dc168e6 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 a0b0552..ad08ac4 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 b11f3d5..7a2771e 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 5922785..02e1dcd 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 01031ed..c41dc73 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 b4e62d6..804fcb7 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 535de3b..147f8c8 100644 --- a/internal/controller/instance_controller.go +++ b/internal/controller/instance_controller.go @@ -36,6 +36,15 @@ import ( const instanceQuotaFinalizer = "quota.compute.datumapis.com/claim-cleanup" +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 { @@ -223,8 +232,8 @@ func (r *InstanceReconciler) reconcileQuotaClaim(ctx context.Context, clusterNam Name: clusterName, }, ResourceRef: quotav1alpha1.UnversionedObjectReference{ - APIGroup: "compute.datumapis.com", - Kind: "Instance", + APIGroup: instanceAPIGroup, + Kind: instanceKind, Name: instance.Name, Namespace: instance.Namespace, }, @@ -328,7 +337,7 @@ func (r *InstanceReconciler) reconcileInstanceReadyCondition( Status: metav1.ConditionFalse, Reason: computev1alpha.InstanceProgrammedReasonPendingProgramming, ObservedGeneration: instance.Generation, - Message: "Instance has not been programmed", + Message: instanceNotProgrammedMessage, } } else { readyCondition = readyCondition.DeepCopy() @@ -346,7 +355,7 @@ func (r *InstanceReconciler) reconcileInstanceReadyCondition( } if networkCreationFailure { - readyCondition.Reason = "NetworkFailedToCreate" + readyCondition.Reason = instanceNetworkFailedReason readyCondition.Message = networkCreationFailureMessage } else { readyCondition.Reason = computev1alpha.InstanceReadyReasonSchedulingGatesPresent @@ -366,7 +375,7 @@ func (r *InstanceReconciler) reconcileInstanceReadyCondition( readyCondition.Reason = programmedCondition.Reason } - readyCondition.Message = "Instance has not been programmed" + readyCondition.Message = instanceNotProgrammedMessage if programmedCondition != nil && programmedCondition.Status != metav1.ConditionUnknown { readyCondition.Message = programmedCondition.Message } @@ -395,7 +404,7 @@ func (r *InstanceReconciler) reconcileInstanceReadyCondition( readyCondition.Status = metav1.ConditionTrue readyCondition.Reason = computev1alpha.InstanceReadyReasonRunning - readyCondition.Message = "Instance is ready" + readyCondition.Message = instanceReadyMessage return apimeta.SetStatusCondition(&instance.Status.Conditions, *readyCondition), nil } @@ -429,7 +438,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 } } @@ -450,7 +459,7 @@ func (r *InstanceReconciler) SetupWithManager(mgr mcmanager.Manager, managementC managementCluster.GetCache(), "av1alpha1.ResourceClaim{}, handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, claim *quotav1alpha1.ResourceClaim) []mcreconcile.Request { - if claim.Spec.ResourceRef.Kind != "Instance" || claim.Spec.ResourceRef.APIGroup != "compute.datumapis.com" { + if claim.Spec.ResourceRef.Kind != instanceKind || claim.Spec.ResourceRef.APIGroup != instanceAPIGroup { return nil } return []mcreconcile.Request{ diff --git a/internal/controller/instance_controller_test.go b/internal/controller/instance_controller_test.go index 548f530..1c02d2e 100644 --- a/internal/controller/instance_controller_test.go +++ b/internal/controller/instance_controller_test.go @@ -29,6 +29,18 @@ import ( quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" ) +const ( + testInstanceName = "test-instance" + testNamespace = "default" + + msgNotProgrammed = "Instance has not been programmed" + msgProgrammed = "Instance has been programmed" + msgRunning = "Instance is running" + msgReady = "Instance is ready" + reasonTestReason = "TestReason" + reasonTestMessage = "Test message" +) + // fakeCluster implements cluster.Cluster for testing using a fake client. type fakeCluster struct { client client.Client @@ -82,8 +94,8 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { name: "instance without ready condition should create default", instance: &computev1alpha.Instance{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "default", + Name: testInstanceName, + Namespace: testNamespace, Generation: 1, }, }, @@ -92,7 +104,7 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { Type: computev1alpha.InstanceReady, Status: metav1.ConditionFalse, Reason: computev1alpha.InstanceProgrammedReasonPendingProgramming, - Message: "Instance has not been programmed", + Message: msgNotProgrammed, ObservedGeneration: 1, }, }, @@ -100,8 +112,8 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { name: "instance with scheduling gates should set scheduling gates present", instance: &computev1alpha.Instance{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "default", + Name: testInstanceName, + Namespace: testNamespace, Generation: 1, }, Spec: computev1alpha.InstanceSpec{ @@ -117,7 +129,7 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { Type: computev1alpha.InstanceReady, Status: metav1.ConditionFalse, Reason: computev1alpha.InstanceProgrammedReasonPendingProgramming, - Message: "Instance has not been programmed", + Message: msgNotProgrammed, ObservedGeneration: 1, LastTransitionTime: metav1.Now(), }, @@ -137,8 +149,8 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { name: "instance with scheduling gates and network failure should set network failed", instance: &computev1alpha.Instance{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "default", + Name: testInstanceName, + Namespace: testNamespace, Generation: 1, }, Spec: computev1alpha.InstanceSpec{ @@ -156,7 +168,7 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { expectedCondition: &metav1.Condition{ Type: computev1alpha.InstanceReady, Status: metav1.ConditionFalse, - Reason: "NetworkFailedToCreate", + Reason: instanceNetworkFailedReason, Message: "Network creation failed: timeout", ObservedGeneration: 1, }, @@ -165,8 +177,8 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { name: "instance not programmed should set pending programming", instance: &computev1alpha.Instance{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "default", + Name: testInstanceName, + Namespace: testNamespace, Generation: 1, }, Status: computev1alpha.InstanceStatus{ @@ -174,8 +186,8 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { { Type: computev1alpha.InstanceProgrammed, Status: metav1.ConditionFalse, - Reason: "TestReason", - Message: "Test message", + Reason: reasonTestReason, + Message: reasonTestMessage, }, }, }, @@ -184,8 +196,8 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { expectedCondition: &metav1.Condition{ Type: computev1alpha.InstanceReady, Status: metav1.ConditionFalse, - Reason: "TestReason", - Message: "Test message", + Reason: reasonTestReason, + Message: reasonTestMessage, ObservedGeneration: 1, }, }, @@ -193,8 +205,8 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { name: "instance programmed but not running should wait for running", instance: &computev1alpha.Instance{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "default", + Name: testInstanceName, + Namespace: testNamespace, Generation: 1, }, Status: computev1alpha.InstanceStatus{ @@ -203,13 +215,13 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { Type: computev1alpha.InstanceProgrammed, Status: metav1.ConditionTrue, Reason: computev1alpha.InstanceProgrammedReasonProgrammed, - Message: "Instance has been programmed", + Message: msgProgrammed, }, { Type: computev1alpha.InstanceRunning, Status: metav1.ConditionFalse, - Reason: "TestReason", - Message: "Test message", + Reason: reasonTestReason, + Message: reasonTestMessage, }, }, }, @@ -218,8 +230,8 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { expectedCondition: &metav1.Condition{ Type: computev1alpha.InstanceReady, Status: metav1.ConditionFalse, - Reason: "TestReason", - Message: "Test message", + Reason: reasonTestReason, + Message: reasonTestMessage, ObservedGeneration: 1, }, }, @@ -227,8 +239,8 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { name: "instance fully ready should set ready condition", instance: &computev1alpha.Instance{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "default", + Name: testInstanceName, + Namespace: testNamespace, Generation: 1, }, Status: computev1alpha.InstanceStatus{ @@ -237,13 +249,13 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { Type: computev1alpha.InstanceProgrammed, Status: metav1.ConditionTrue, Reason: computev1alpha.InstanceProgrammedReasonProgrammed, - Message: "Instance has been programmed", + Message: msgProgrammed, }, { Type: computev1alpha.InstanceRunning, Status: metav1.ConditionTrue, Reason: computev1alpha.InstanceRunningReasonRunning, - Message: "Instance is running", + Message: msgRunning, }, }, }, @@ -253,7 +265,7 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { Type: computev1alpha.InstanceReady, Status: metav1.ConditionTrue, Reason: computev1alpha.InstanceReadyReasonRunning, - Message: "Instance is ready", + Message: msgReady, ObservedGeneration: 1, }, }, @@ -261,8 +273,8 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { name: "no change when condition already matches", instance: &computev1alpha.Instance{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "default", + Name: testInstanceName, + Namespace: testNamespace, Generation: 1, }, Status: computev1alpha.InstanceStatus{ @@ -271,7 +283,7 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { Type: computev1alpha.InstanceReady, Status: metav1.ConditionTrue, Reason: computev1alpha.InstanceReadyReasonRunning, - Message: "Instance is ready", + Message: msgReady, ObservedGeneration: 1, LastTransitionTime: metav1.Now(), }, @@ -279,13 +291,13 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { Type: computev1alpha.InstanceProgrammed, Status: metav1.ConditionTrue, Reason: computev1alpha.InstanceProgrammedReasonProgrammed, - Message: "Instance has been programmed", + Message: msgProgrammed, }, { Type: computev1alpha.InstanceRunning, Status: metav1.ConditionTrue, Reason: computev1alpha.InstanceRunningReasonRunning, - Message: "Instance is running", + Message: msgRunning, }, }, }, @@ -295,7 +307,7 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { Type: computev1alpha.InstanceReady, Status: metav1.ConditionTrue, Reason: computev1alpha.InstanceReadyReasonRunning, - Message: "Instance is ready", + Message: msgReady, ObservedGeneration: 1, }, }, @@ -346,8 +358,8 @@ func TestReconcileInstanceReadyConditionWithQuota(t *testing.T) { name: "quota denied blocks ready condition", instance: &computev1alpha.Instance{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "default", + Name: testInstanceName, + Namespace: testNamespace, Generation: 1, }, Status: computev1alpha.InstanceStatus{ @@ -363,14 +375,14 @@ func TestReconcileInstanceReadyConditionWithQuota(t *testing.T) { Type: computev1alpha.InstanceProgrammed, Status: metav1.ConditionTrue, Reason: computev1alpha.InstanceProgrammedReasonProgrammed, - Message: "Instance has been programmed", + Message: msgProgrammed, LastTransitionTime: metav1.Now(), }, { Type: computev1alpha.InstanceRunning, Status: metav1.ConditionTrue, Reason: computev1alpha.InstanceRunningReasonRunning, - Message: "Instance is running", + Message: msgRunning, LastTransitionTime: metav1.Now(), }, }, @@ -388,8 +400,8 @@ func TestReconcileInstanceReadyConditionWithQuota(t *testing.T) { name: "quota available does not block ready condition", instance: &computev1alpha.Instance{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "default", + Name: testInstanceName, + Namespace: testNamespace, Generation: 1, }, Status: computev1alpha.InstanceStatus{ @@ -405,14 +417,14 @@ func TestReconcileInstanceReadyConditionWithQuota(t *testing.T) { Type: computev1alpha.InstanceProgrammed, Status: metav1.ConditionTrue, Reason: computev1alpha.InstanceProgrammedReasonProgrammed, - Message: "Instance has been programmed", + Message: msgProgrammed, LastTransitionTime: metav1.Now(), }, { Type: computev1alpha.InstanceRunning, Status: metav1.ConditionTrue, Reason: computev1alpha.InstanceRunningReasonRunning, - Message: "Instance is running", + Message: msgRunning, LastTransitionTime: metav1.Now(), }, }, @@ -423,15 +435,15 @@ func TestReconcileInstanceReadyConditionWithQuota(t *testing.T) { Type: computev1alpha.InstanceReady, Status: metav1.ConditionTrue, Reason: computev1alpha.InstanceReadyReasonRunning, - Message: "Instance is ready", + Message: msgReady, }, }, { name: "quota pending unknown does not block ready condition", instance: &computev1alpha.Instance{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "default", + Name: testInstanceName, + Namespace: testNamespace, Generation: 1, }, Status: computev1alpha.InstanceStatus{ @@ -451,7 +463,7 @@ func TestReconcileInstanceReadyConditionWithQuota(t *testing.T) { Type: computev1alpha.InstanceReady, Status: metav1.ConditionFalse, Reason: computev1alpha.InstanceProgrammedReasonPendingProgramming, - Message: "Instance has not been programmed", + Message: msgNotProgrammed, }, }, } @@ -552,8 +564,8 @@ func TestReconcileQuota(t *testing.T) { Name: clusterName, }, ResourceRef: quotav1alpha1.UnversionedObjectReference{ - APIGroup: "compute.datumapis.com", - Kind: "Instance", + APIGroup: instanceAPIGroup, + Kind: instanceKind, Name: instanceName, Namespace: namespace, }, diff --git a/internal/controller/instancecontrol/stateful/stateful_control_test.go b/internal/controller/instancecontrol/stateful/stateful_control_test.go index d45b24b..20d265b 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 bc522c4..38a06de 100644 --- a/internal/controller/workload_controller.go +++ b/internal/controller/workload_controller.go @@ -35,6 +35,9 @@ import ( const workloadControllerFinalizer = "compute.datumapis.com/workload-controller" +// conditionAvailable is the condition type used to indicate resource availability. +const conditionAvailable = "Available" + // WorkloadReconciler reconciles a Workload object type WorkloadReconciler struct { mgr mcmanager.Manager @@ -119,7 +122,7 @@ func (r *WorkloadReconciler) Reconcile(ctx context.Context, req mcreconcile.Requ if len(notFoundNetworks) > 0 { missingNetworks := strings.Join(notFoundNetworks.UnsortedList(), ", ") changed := apimeta.SetStatusCondition(&workload.Status.Conditions, metav1.Condition{ - Type: "Available", + Type: conditionAvailable, Status: metav1.ConditionFalse, Reason: "NetworkNotFound", Message: fmt.Sprintf("Unable to find networks: %s", missingNetworks), @@ -239,7 +242,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", @@ -257,7 +260,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 } } @@ -284,7 +287,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/controller/workloaddeployment_scheduler.go b/internal/controller/workloaddeployment_scheduler.go index 041b0d6..c4d8fcb 100644 --- a/internal/controller/workloaddeployment_scheduler.go +++ b/internal/controller/workloaddeployment_scheduler.go @@ -69,7 +69,7 @@ func (r *WorkloadDeploymentScheduler) Reconcile(ctx context.Context, req mcrecon // prior to location registration. changed := apimeta.SetStatusCondition(&deployment.Status.Conditions, metav1.Condition{ - Type: "Available", + Type: conditionAvailable, Status: metav1.ConditionFalse, Reason: "NoLocations", ObservedGeneration: deployment.Generation, @@ -100,7 +100,7 @@ func (r *WorkloadDeploymentScheduler) Reconcile(ctx context.Context, req mcrecon if selectedLocation == nil { changed := apimeta.SetStatusCondition(&deployment.Status.Conditions, metav1.Condition{ - Type: "Available", + Type: conditionAvailable, Status: metav1.ConditionFalse, Reason: "NoCandidateLocations", ObservedGeneration: deployment.Generation, @@ -121,7 +121,7 @@ func (r *WorkloadDeploymentScheduler) Reconcile(ctx context.Context, req mcrecon // of the spec then status here. Just can't remember if it's an issue. apimeta.SetStatusCondition(&deployment.Status.Conditions, metav1.Condition{ - Type: "Available", + Type: conditionAvailable, Status: metav1.ConditionFalse, Reason: "LocationAssigned", ObservedGeneration: deployment.Generation, diff --git a/internal/validation/instance_validation.go b/internal/validation/instance_validation.go index 7f11282..3657e45 100644 --- a/internal/validation/instance_validation.go +++ b/internal/validation/instance_validation.go @@ -17,12 +17,18 @@ import ( networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" ) +const ( + supportedDiskType = "pd-standard" + supportedImageName = "datumcloud/ubuntu-2204-lts" + supportedInstanceType = "datumcloud/d1-standard-2" +) + func validateInstanceTemplate( template computev1alpha.InstanceTemplateSpec, 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)...) @@ -66,7 +72,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...) @@ -258,8 +264,8 @@ func validateDiskVolumeSource(diskSource *computev1alpha.DiskTemplateVolumeSourc diskTemplateSpecField := diskTemplateField.Child("spec") // TODO(jrese) look up valid disk types - if diskTemplate.Spec.Type != "pd-standard" { - allErrs = append(allErrs, field.NotSupported(diskTemplateSpecField.Child("type"), diskTemplate.Spec.Type, []string{"pd-standard"})) + if diskTemplate.Spec.Type != supportedDiskType { + allErrs = append(allErrs, field.NotSupported(diskTemplateSpecField.Child("type"), diskTemplate.Spec.Type, []string{supportedDiskType})) } populatorResourceRequests, errs := validateDiskPopulator(diskTemplate.Spec.Populator, diskTemplateField.Child("populator")) @@ -400,8 +406,8 @@ func validateDiskPopulator(populator *computev1alpha.DiskPopulator, fieldPath *f // TODO(jreese) look up image imagePopulator := populator.Image - if imagePopulator.Name != "datumcloud/ubuntu-2204-lts" { - allErrs = append(allErrs, field.NotSupported(imageField.Child("name"), imagePopulator.Name, []string{"datumcloud/ubuntu-2204-lts"})) + if imagePopulator.Name != supportedImageName { + allErrs = append(allErrs, field.NotSupported(imageField.Child("name"), imagePopulator.Name, []string{supportedImageName})) } } } @@ -572,7 +578,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]{} @@ -657,8 +663,8 @@ func validateInstanceRuntimeResources(resources computev1alpha.InstanceRuntimeRe allErrs := field.ErrorList{} // TODO(jreese) look up available instance types - if resources.InstanceType != "datumcloud/d1-standard-2" { - allErrs = append(allErrs, field.NotSupported(fieldPath, resources.InstanceType, []string{"datumcloud/d1-standard-2"})) + if resources.InstanceType != supportedInstanceType { + allErrs = append(allErrs, field.NotSupported(fieldPath, resources.InstanceType, []string{supportedInstanceType})) } if resources.Requests != nil { diff --git a/internal/validation/workload_validation_test.go b/internal/validation/workload_validation_test.go index f73e4c9..779cdbc 100644 --- a/internal/validation/workload_validation_test.go +++ b/internal/validation/workload_validation_test.go @@ -23,6 +23,14 @@ import ( networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" ) +const ( + testCPUMetricName = "cpu" + testVolumeName = "vol" + testDuplicateMountPath = "duplicate-mount-path" + testDefaultNamespace = "default" + testCityCodeDFW = "DFW" +) + func TestValidateWorkloads(t *testing.T) { scenarios := map[string]struct { workload *computev1alpha.Workload @@ -157,7 +165,7 @@ func TestValidateWorkloads(t *testing.T) { w.Spec.Placements[0].ScaleSettings.Metrics = []computev1alpha.MetricSpec{ { Resource: &computev1alpha.ResourceMetricSource{ - Name: "cpu", + Name: testCPUMetricName, Target: computev1alpha.MetricTarget{ Value: resource.NewQuantity(50, resource.DecimalSI), AverageValue: resource.NewQuantity(50, resource.DecimalSI), @@ -181,7 +189,7 @@ func TestValidateWorkloads(t *testing.T) { w.Spec.Placements[0].ScaleSettings.Metrics = []computev1alpha.MetricSpec{ { Resource: &computev1alpha.ResourceMetricSource{ - Name: "cpu", + Name: testCPUMetricName, Target: computev1alpha.MetricTarget{ Value: resource.NewQuantity(-1, resource.DecimalSI), }, @@ -202,7 +210,7 @@ func TestValidateWorkloads(t *testing.T) { w.Spec.Placements[0].ScaleSettings.Metrics = []computev1alpha.MetricSpec{ { Resource: &computev1alpha.ResourceMetricSource{ - Name: "cpu", + Name: testCPUMetricName, Target: computev1alpha.MetricTarget{ AverageValue: resource.NewQuantity(-1, resource.DecimalSI), }, @@ -223,7 +231,7 @@ func TestValidateWorkloads(t *testing.T) { w.Spec.Placements[0].ScaleSettings.Metrics = []computev1alpha.MetricSpec{ { Resource: &computev1alpha.ResourceMetricSource{ - Name: "cpu", + Name: testCPUMetricName, Target: computev1alpha.MetricTarget{ AverageUtilization: proto.Int32(0), }, @@ -336,16 +344,16 @@ func TestValidateWorkloads(t *testing.T) { w.Spec.Template.Spec.Runtime.VirtualMachine.VolumeAttachments = append( w.Spec.Template.Spec.Runtime.VirtualMachine.VolumeAttachments, computev1alpha.VolumeAttachment{ - Name: "vol", + Name: testVolumeName, }, ) w.Spec.Template.Spec.Volumes = append(w.Spec.Template.Spec.Volumes, computev1alpha.InstanceVolume{ - Name: "vol", + Name: testVolumeName, VolumeSource: computev1alpha.VolumeSource{ Disk: &computev1alpha.DiskTemplateVolumeSource{ Template: &computev1alpha.DiskTemplateVolumeSourceTemplate{ Spec: computev1alpha.DiskSpec{ - Type: "pd-standard", + Type: supportedDiskType, Resources: &computev1alpha.DiskResourceRequirements{ Requests: k8scorev1.ResourceList{ k8scorev1.ResourceStorage: resource.MustParse("1Gi"), @@ -369,16 +377,16 @@ func TestValidateWorkloads(t *testing.T) { w.Spec.Template.Spec.Runtime.VirtualMachine.VolumeAttachments = append( w.Spec.Template.Spec.Runtime.VirtualMachine.VolumeAttachments, computev1alpha.VolumeAttachment{ - Name: "vol", + Name: testVolumeName, }, ) w.Spec.Template.Spec.Volumes = append(w.Spec.Template.Spec.Volumes, computev1alpha.InstanceVolume{ - Name: "vol", + Name: testVolumeName, VolumeSource: computev1alpha.VolumeSource{ Disk: &computev1alpha.DiskTemplateVolumeSource{ Template: &computev1alpha.DiskTemplateVolumeSourceTemplate{ Spec: computev1alpha.DiskSpec{ - Type: "pd-standard", + Type: supportedDiskType, Resources: &computev1alpha.DiskResourceRequirements{ Requests: k8scorev1.ResourceList{ k8scorev1.ResourceStorage: resource.MustParse("1Pi"), @@ -402,16 +410,16 @@ func TestValidateWorkloads(t *testing.T) { w.Spec.Template.Spec.Runtime.VirtualMachine.VolumeAttachments = append( w.Spec.Template.Spec.Runtime.VirtualMachine.VolumeAttachments, computev1alpha.VolumeAttachment{ - Name: "vol", + Name: testVolumeName, }, ) w.Spec.Template.Spec.Volumes = append(w.Spec.Template.Spec.Volumes, computev1alpha.InstanceVolume{ - Name: "vol", + Name: testVolumeName, VolumeSource: computev1alpha.VolumeSource{ Disk: &computev1alpha.DiskTemplateVolumeSource{ Template: &computev1alpha.DiskTemplateVolumeSourceTemplate{ Spec: computev1alpha.DiskSpec{ - Type: "pd-standard", + Type: supportedDiskType, Resources: &computev1alpha.DiskResourceRequirements{ Requests: k8scorev1.ResourceList{ k8scorev1.ResourceStorage: resource.MustParse("10.5Gi"), @@ -436,7 +444,7 @@ func TestValidateWorkloads(t *testing.T) { Disk: &computev1alpha.DiskTemplateVolumeSource{ Template: &computev1alpha.DiskTemplateVolumeSourceTemplate{ Spec: computev1alpha.DiskSpec{ - Type: "pd-standard", + Type: supportedDiskType, Resources: &computev1alpha.DiskResourceRequirements{ Requests: k8scorev1.ResourceList{ k8scorev1.ResourceStorage: resource.MustParse("10Gi"), @@ -473,7 +481,7 @@ func TestValidateWorkloads(t *testing.T) { Disk: &computev1alpha.DiskTemplateVolumeSource{ Template: &computev1alpha.DiskTemplateVolumeSourceTemplate{ Spec: computev1alpha.DiskSpec{ - Type: "pd-standard", + Type: supportedDiskType, Resources: &computev1alpha.DiskResourceRequirements{ Requests: k8scorev1.ResourceList{ k8scorev1.ResourceStorage: resource.MustParse("10Gi"), @@ -490,11 +498,11 @@ func TestValidateWorkloads(t *testing.T) { } w.Spec.Template.Spec.Runtime.Sandbox.Containers[0].VolumeAttachments = []computev1alpha.VolumeAttachment{ { - Name: "duplicate-mount-path", + Name: testDuplicateMountPath, MountPath: proto.String("/mount1"), }, { - Name: "duplicate-mount-path", + Name: testDuplicateMountPath, MountPath: proto.String("/mount1"), }, { @@ -503,7 +511,7 @@ func TestValidateWorkloads(t *testing.T) { } w.Spec.Template.Spec.Volumes = []computev1alpha.InstanceVolume{ { - Name: "duplicate-mount-path", + Name: testDuplicateMountPath, VolumeSource: volumeSource, }, } @@ -540,7 +548,7 @@ func TestValidateWorkloads(t *testing.T) { interceptorFuncs: &interceptor.Funcs{ Create: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error { if sar, ok := obj.(*authorizationv1.SubjectAccessReview); ok { - if sar.Spec.ResourceAttributes.Name == "default" && + if sar.Spec.ResourceAttributes.Name == testDefaultNamespace && sar.Spec.ResourceAttributes.Group == networkingv1alpha.GroupVersion.Group && sar.Spec.ResourceAttributes.Version == networkingv1alpha.GroupVersion.Version && sar.Spec.ResourceAttributes.Resource == "networks" { @@ -559,8 +567,8 @@ func TestValidateWorkloads(t *testing.T) { initObjs := []client.Object{ &networkingv1alpha.Network{ ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "default", + Namespace: testDefaultNamespace, + Name: testDefaultNamespace, }, }, } @@ -606,7 +614,7 @@ func TestValidateWorkloads(t *testing.T) { ) if len(scenario.opts.ValidCityCodes) == 0 { - scenario.opts.ValidCityCodes = []string{"DFW"} + scenario.opts.ValidCityCodes = []string{testCityCodeDFW} } t.Run(name, func(t *testing.T) { @@ -639,13 +647,13 @@ func MakeSandboxWorkload(name string, tweaks ...Tweak) *computev1alpha.Workload NetworkInterfaces: []computev1alpha.InstanceNetworkInterface{ { Network: networkingv1alpha.NetworkRef{ - Name: "default", + Name: testDefaultNamespace, }, }, }, Runtime: computev1alpha.InstanceRuntimeSpec{ Resources: computev1alpha.InstanceRuntimeResources{ - InstanceType: "datumcloud/d1-standard-2", + InstanceType: supportedInstanceType, }, Sandbox: &computev1alpha.SandboxRuntime{ Containers: []computev1alpha.SandboxContainer{ @@ -661,7 +669,7 @@ func MakeSandboxWorkload(name string, tweaks ...Tweak) *computev1alpha.Workload Placements: []computev1alpha.WorkloadPlacement{ { Name: "placement1", - CityCodes: []string{"DFW"}, + CityCodes: []string{testCityCodeDFW}, ScaleSettings: computev1alpha.HorizontalScaleSettings{ MinReplicas: 1, }, @@ -696,13 +704,13 @@ func MakeVMWorkload(name string, tweaks ...Tweak) *computev1alpha.Workload { NetworkInterfaces: []computev1alpha.InstanceNetworkInterface{ { Network: networkingv1alpha.NetworkRef{ - Name: "default", + Name: testDefaultNamespace, }, }, }, Runtime: computev1alpha.InstanceRuntimeSpec{ Resources: computev1alpha.InstanceRuntimeResources{ - InstanceType: "datumcloud/d1-standard-2", + InstanceType: supportedInstanceType, }, VirtualMachine: &computev1alpha.VirtualMachineRuntime{ VolumeAttachments: []computev1alpha.VolumeAttachment{ @@ -719,10 +727,10 @@ func MakeVMWorkload(name string, tweaks ...Tweak) *computev1alpha.Workload { Disk: &computev1alpha.DiskTemplateVolumeSource{ Template: &computev1alpha.DiskTemplateVolumeSourceTemplate{ Spec: computev1alpha.DiskSpec{ - Type: "pd-standard", + Type: supportedDiskType, Populator: &computev1alpha.DiskPopulator{ Image: &computev1alpha.ImageDiskPopulator{ - Name: "datumcloud/ubuntu-2204-lts", + Name: supportedImageName, }, }, }, @@ -736,7 +744,7 @@ func MakeVMWorkload(name string, tweaks ...Tweak) *computev1alpha.Workload { Placements: []computev1alpha.WorkloadPlacement{ { Name: "placement1", - CityCodes: []string{"DFW"}, + CityCodes: []string{testCityCodeDFW}, ScaleSettings: computev1alpha.HorizontalScaleSettings{ MinReplicas: 1, }, diff --git a/internal/webhook/v1alpha/workload_webhook.go b/internal/webhook/v1alpha/workload_webhook.go index 82f248a..74303b1 100644 --- a/internal/webhook/v1alpha/workload_webhook.go +++ b/internal/webhook/v1alpha/workload_webhook.go @@ -2,11 +2,9 @@ package webhook import ( "context" - "fmt" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/sets" ctrl "sigs.k8s.io/controller-runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" @@ -29,8 +27,8 @@ func SetupWorkloadWebhookWithManager(mgr mcmanager.Manager) error { } return ctrl.NewWebhookManagedBy(mgr.GetLocalManager(), &computev1alpha.Workload{}). - WithCustomDefaulter(webhook). - WithCustomValidator(webhook). + WithDefaulter(webhook). + WithValidator(webhook). Complete() } @@ -40,47 +38,18 @@ type workloadWebhook struct { mgr mcmanager.Manager } -var _ admission.CustomDefaulter = &workloadWebhook{} -var _ admission.CustomValidator = &workloadWebhook{} - -// Default implements webhook.Defaulter so a webhook will be registered for the type -func (r *workloadWebhook) Default(ctx context.Context, obj runtime.Object) error { - workload, ok := obj.(*computev1alpha.Workload) - if !ok { - return fmt.Errorf("unexpected type %T", obj) - } - _ = workload - - // // TODO(jreese) review and test gateway defaulting / logic - // if gw := workload.Spec.Gateway; gw != nil { - // for i, tcpRoute := range gw.TCPRoutes { - // for j := range tcpRoute.ParentRefs { - // workload.Spec.Gateway.TCPRoutes[i].ParentRefs[j].Name = "workload-gateway" - // } - - // for j := range tcpRoute.Rules { - // for k := range tcpRoute.Rules[j].BackendRefs { - // // TODO(jreese) think about this Kind more - // kind := gatewayv1.Kind("NamedPort") - // workload.Spec.Gateway.TCPRoutes[i].Rules[j]. - // BackendRefs[k].Kind = &kind - // } - // } - // } - // } +var _ admission.Defaulter[*computev1alpha.Workload] = &workloadWebhook{} +var _ admission.Validator[*computev1alpha.Workload] = &workloadWebhook{} +// Default implements admission.Defaulter so a webhook will be registered for the type. +func (r *workloadWebhook) Default(_ context.Context, _ *computev1alpha.Workload) error { // 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, obj runtime.Object) (admission.Warnings, error) { - workload, ok := obj.(*computev1alpha.Workload) - if !ok { - return nil, fmt.Errorf("unexpected type %T", obj) - } - +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)) @@ -123,38 +92,18 @@ func (r *workloadWebhook) ValidateCreate(ctx context.Context, obj runtime.Object } if errs := validation.ValidateWorkloadCreate(workload, opts); len(errs) > 0 { - return nil, errors.NewInvalid(obj.GetObjectKind().GroupVersionKind().GroupKind(), workload.Name, errs) + return nil, errors.NewInvalid(workload.GetObjectKind().GroupVersionKind().GroupKind(), workload.Name, errs) } return nil, nil } -func (r *workloadWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { - oldworkload, ok := oldObj.(*computev1alpha.Workload) - if !ok { - return nil, fmt.Errorf("unexpected type %T", oldObj) - } - - _ = oldworkload - - newworkload, ok := newObj.(*computev1alpha.Workload) - if !ok { - return nil, fmt.Errorf("unexpected type %T", newObj) - } - - _ = newworkload - +func (r *workloadWebhook) ValidateUpdate(_ context.Context, _, _ *computev1alpha.Workload) (admission.Warnings, error) { // TODO(user): fill in your validation logic upon object update. return nil, nil } -func (r *workloadWebhook) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { - workload, ok := obj.(*computev1alpha.Workload) - if !ok { - return nil, fmt.Errorf("unexpected type %T", obj) - } - _ = workload - +func (r *workloadWebhook) ValidateDelete(_ context.Context, _ *computev1alpha.Workload) (admission.Warnings, error) { // TODO(user): fill in your validation logic upon object deletion. return nil, nil } From ca9f0003c9bf7f28bf923dc93245e7c8b9e57f57 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 17:41:37 -0500 Subject: [PATCH 6/8] 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 bd6f5ed..c7222a7 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 b93f95c..a5f9099 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 dc168e6..8a4ee81 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 ad08ac4..6d26f6b 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 1702d40..a3df1de 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 5190261..1d121e2 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 20d265b..860f471 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 3657e45..c686459 100644 --- a/internal/validation/instance_validation.go +++ b/internal/validation/instance_validation.go @@ -463,7 +463,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 5f320e9..104e844 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 dabde4181ee3cda19ea2e7e581a1e68b0247c4e2 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 17:47:41 -0500 Subject: [PATCH 7/8] 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 +++++++------- .../controller/instance_controller_test.go | 24 +++++++++---------- internal/validation/workload_validation.go | 2 +- 5 files changed, 28 insertions(+), 25 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index a7246fb..793c5c3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -35,6 +35,9 @@ linters: - dupl - lll path: internal/* + - 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 c7222a7..0cc7060 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 02e1dcd..3ddb5df 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/controller/instance_controller_test.go b/internal/controller/instance_controller_test.go index 1c02d2e..91b22bb 100644 --- a/internal/controller/instance_controller_test.go +++ b/internal/controller/instance_controller_test.go @@ -21,8 +21,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/cluster" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" "sigs.k8s.io/multicluster-runtime/pkg/multicluster" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" computev1alpha "go.datum.net/compute/api/v1alpha" "go.datum.net/compute/internal/controller/instancecontrol" @@ -47,17 +47,17 @@ type fakeCluster struct { scheme *runtime.Scheme } -func (f *fakeCluster) GetHTTPClient() *http.Client { return nil } -func (f *fakeCluster) GetConfig() *rest.Config { return nil } -func (f *fakeCluster) GetCache() cache.Cache { return nil } -func (f *fakeCluster) GetScheme() *runtime.Scheme { return f.scheme } -func (f *fakeCluster) GetClient() client.Client { return f.client } -func (f *fakeCluster) GetFieldIndexer() client.FieldIndexer { return nil } -func (f *fakeCluster) GetEventRecorderFor(string) record.EventRecorder { return nil } -func (f *fakeCluster) GetEventRecorder(string) events.EventRecorder { return nil } -func (f *fakeCluster) GetRESTMapper() apimeta.RESTMapper { return nil } -func (f *fakeCluster) GetAPIReader() client.Reader { return f.client } -func (f *fakeCluster) Start(context.Context) error { return nil } +func (f *fakeCluster) GetHTTPClient() *http.Client { return nil } +func (f *fakeCluster) GetConfig() *rest.Config { return nil } +func (f *fakeCluster) GetCache() cache.Cache { return nil } +func (f *fakeCluster) GetScheme() *runtime.Scheme { return f.scheme } +func (f *fakeCluster) GetClient() client.Client { return f.client } +func (f *fakeCluster) GetFieldIndexer() client.FieldIndexer { return nil } +func (f *fakeCluster) GetEventRecorderFor(string) record.EventRecorder { return nil } +func (f *fakeCluster) GetEventRecorder(string) events.EventRecorder { return nil } +func (f *fakeCluster) GetRESTMapper() apimeta.RESTMapper { return nil } +func (f *fakeCluster) GetAPIReader() client.Reader { return f.client } +func (f *fakeCluster) Start(context.Context) error { return nil } // fakeMCManager is a minimal multicluster manager that returns a single cluster. type fakeMCManager struct { diff --git a/internal/validation/workload_validation.go b/internal/validation/workload_validation.go index 104e844..c18fcbc 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 d47b5f0630548b994d1ba2bb75e0d53784943430 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 22 May 2026 18:17:46 -0500 Subject: [PATCH 8/8] 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 0cc7060..29b6ab6 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 a5f9099..fdb0d0a 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 8a4ee81..fe68ee0 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 7a2771e..a0a6e69 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 a3df1de..0c290ff 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 1d121e2..1ce704e 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 3ddb5df..77404fb 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 0000000..fd7f0a0 --- /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 +}