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/.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/.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..4571a58 --- /dev/null +++ b/cmd/datumctl-compute/main.go @@ -0,0 +1,26 @@ +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" + +func main() { + 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..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.miloapis.com/milo/pkg/multicluster-runtime/milo" // +kubebuilder:scaffold:imports ) diff --git a/go.mod b/go.mod index 19fc010..57e51a0 100644 --- a/go.mod +++ b/go.mod @@ -1,108 +1,116 @@ module go.datum.net/compute -go 1.24.0 - -toolchain go1.24.2 +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.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.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.39.0 - golang.org/x/sync v0.16.0 + 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.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 + sigs.k8s.io/multicluster-runtime v0.23.3 + sigs.k8s.io/yaml v1.6.0 ) require ( - cel.dev/expr v0.19.1 // indirect - github.com/antlr4-go/antlr/v4 v4.13.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.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 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/go-logr/logr v1.4.3 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // 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.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/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/klauspost/compress v1.18.3 // 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/stoewer/go-strcase v1.3.0 // indirect + github.com/spf13/pflag v1.0.10 // 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.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.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.yaml.in/yaml/v2 v2.4.2 // 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 + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect + golang.org/x/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/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/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.33.1 // indirect - k8s.io/apiserver v0.33.1 // indirect - k8s.io/component-base v0.33.1 // 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 - 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 ) diff --git a/go.sum b/go.sum index c472bd8..1b84d2d 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,9 @@ -cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= -cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= -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= +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.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= @@ -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.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= 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,227 +97,185 @@ 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/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/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/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= 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/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/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/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.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/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.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.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.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.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= -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= +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-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= +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= +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= 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.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= +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/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/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/multicluster-runtime v0.23.3 h1:vrzlXRzHTDsjspUAfoW2rCtr0agoI4q20p9x4Fz4png= +sigs.k8s.io/multicluster-runtime v0.23.3/go.mod h1:r/UA4GHgFoXCcR4tcvlZz7SiLx3l1kJKDuBAhILNIHs= sigs.k8s.io/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/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go new file mode 100644 index 0000000..29b6ab6 --- /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" + "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" + sigsyaml "sigs.k8s.io/yaml" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/cmd/compute/revision" + "go.datum.net/compute/internal/cmd/compute/util" + "go.datum.net/compute/internal/cmd/compute/watch" + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" +) + +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) + }, + ValidArgsFunction: util.CompleteWorkloadNames, + } + + 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", "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)") + 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 = "datumcloud/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: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { + if k8serrors.IsNotFound(err) { + creating = true + workload = computev1alpha.Workload{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: util.ResourceNamespace, + 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 = util.ResourceNamespace + 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, util.ResourceNamespace, workloadName) + 1 + specJSON, _ := json.Marshal(workload.Spec) + entry := revision.Entry{ + Rev: rev, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Image: opts.image, + Changes: changes, + SpecJSON: string(specJSON), + } + if err := revision.WriteEntry(ctx, c, util.ResourceNamespace, workloadName, entry); err != nil { + // Non-fatal — log but continue. + fmt.Fprintf(out, " warning: could not write revision history: %v\n", err) + } + + // Save workload.yaml. + if err := saveWorkloadYAML(workloadName, &workload); err != nil { + fmt.Fprintf(out, " warning: could not save workload.yaml: %v\n", err) + } 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 = util.ResourceNamespace + + 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: util.ResourceNamespace, 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, util.ResourceNamespace, workload.Name) + 1 + specJSON, _ := json.Marshal(workload.Spec) + entry := revision.Entry{ + Rev: rev, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Image: image, + Changes: changes, + SpecJSON: string(specJSON), + } + if err := revision.WriteEntry(ctx, c, util.ResourceNamespace, workload.Name, entry); err != nil { + fmt.Fprintf(out, " warning: could not write revision history: %v\n", err) + } + + fmt.Fprintf(out, "Waiting for rollout. Ctrl-C to detach (rollout continues in background).\n\n") + + watchCtx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt) + 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(_ 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, _ []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..fdb0d0a --- /dev/null +++ b/internal/cmd/compute/destroy/destroy.go @@ -0,0 +1,101 @@ +package destroy + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + 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/revision" + "go.datum.net/compute/internal/cmd/compute/util" + corev1 "k8s.io/api/core/v1" +) + +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) + }, + ValidArgsFunction: util.CompleteWorkloadNames, + } + + 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: 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) + } + + // 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: util.ResourceNamespace, Name: cmName}, &cm); err == nil { + _ = c.Delete(ctx, &cm) + } + + fmt.Fprintf(out, "workload/%s deleted.\n", workloadName) + return nil +} diff --git a/internal/cmd/compute/instances/instances.go b/internal/cmd/compute/instances/instances.go new file mode 100644 index 0000000..fe68ee0 --- /dev/null +++ b/internal/cmd/compute/instances/instances.go @@ -0,0 +1,358 @@ +package instances + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "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.RegisterFlagCompletionFunc("workload", util.CompleteWorkloadNames) + + 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: util.ResourceNamespace, 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(util.ResourceNamespace)} + 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(util.ResourceNamespace)); 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(util.ResourceNamespace)); 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: util.ResourceNamespace, 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(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: depSelector}); err == nil && len(depList.Items) > 0 { + dep := depList.Items[0] + city = dep.Spec.CityCode + placementName = dep.Spec.PlacementName + workloadName = dep.Spec.WorkloadRef.Name + } + } + + 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..6d26f6b --- /dev/null +++ b/internal/cmd/compute/quota/quota.go @@ -0,0 +1,209 @@ +package quota + +import ( + "context" + "fmt" + "regexp" + "sort" + "strconv" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/cmd/compute/util" +) + +// availablePattern matches messages like "2 CPU available in IAD." or +// "2.5 available in DFW" to extract the numeric available quantity. +var availablePattern = regexp.MustCompile(`(\d+(?:\.\d+)?)\s+\w+\s+available`) + +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(util.ResourceNamespace)); err != nil { + return fmt.Errorf("listing instances: %w", err) + } + + // List all deployments to build a UID → city/instanceType lookup. + var deployList computev1alpha.WorkloadDeploymentList + if err := c.List(ctx, &deployList, client.InNamespace(util.ResourceNamespace)); err != nil { + return fmt.Errorf("listing deployments: %w", err) + } + + // Build map: deploymentUID → deployment. + deployByUID := make(map[string]computev1alpha.WorkloadDeployment, len(deployList.Items)) + for _, d := range deployList.Items { + deployByUID[string(d.UID)] = d + } + + type groupData struct { + count int + atLimit bool + limitMsg string + } + + groups := make(map[groupKey]*groupData) + + for _, inst := range instList.Items { + // Resolve city from deployment label. + depUID := inst.Labels[computev1alpha.WorkloadDeploymentUIDLabel] + dep, ok := deployByUID[depUID] + if !ok { + continue + } + city := dep.Spec.CityCode + instanceType := dep.Spec.Template.Spec.Runtime.Resources.InstanceType + if instanceType == "" { + instanceType = "unknown" + } + + k := groupKey{city: city, instanceType: instanceType} + gd := groups[k] + if gd == nil { + gd = &groupData{} + groups[k] = gd + } + gd.count++ + + // Check quota condition. + quotaCond := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceQuotaGranted) + if quotaCond != nil && + quotaCond.Status == metav1.ConditionFalse && + quotaCond.Reason == computev1alpha.InstanceQuotaGrantedReasonQuotaExceeded { + gd.atLimit = true + if quotaCond.Message != "" { + gd.limitMsg = quotaCond.Message + } + } + } + + // Build sorted keys. + var keys []groupKey + for k := range groups { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].city != keys[j].city { + return keys[i].city < keys[j].city + } + return keys[i].instanceType < keys[j].instanceType + }) + + // 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() + + // 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..a0a6e69 --- /dev/null +++ b/internal/cmd/compute/restart/restart.go @@ -0,0 +1,115 @@ +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) + }, + ValidArgsFunction: util.CompleteWorkloadNames, + } + + 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: 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) + } + + restartedAt := time.Now().UTC().Format(time.RFC3339) + out := cmd.OutOrStdout() + + if city == "" { + // Restart all placements by annotating the workload template. + if workload.Spec.Template.Annotations == nil { + workload.Spec.Template.Annotations = make(map[string]string) + } + workload.Spec.Template.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(util.ResourceNamespace), + 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.Annotations == nil { + matched[i].Spec.Template.Annotations = make(map[string]string) + } + 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) + } + } + + 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..0c290ff --- /dev/null +++ b/internal/cmd/compute/rollout/rollout.go @@ -0,0 +1,271 @@ +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) + }, + ValidArgsFunction: util.CompleteWorkloadNames, + } + + 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: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("workload %q not found in project %s", workloadName, project) + } + return fmt.Errorf("getting workload: %w", err) + } + + entries, currentRev, err := revision.ReadEntries(ctx, c, util.ResourceNamespace, workloadName) + if err != nil { + return fmt.Errorf("reading revision history: %w", err) + } + + 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), + ValidArgsFunction: util.CompleteWorkloadNames, + RunE: func(cmd *cobra.Command, args []string) error { + return runHistory(cmd, args) + }, + } +} + +func runHistory(cmd *cobra.Command, args []string) error { + project := util.ProjectFromCmd(cmd) + + c, err := util.NewClient(project) + if err != nil { + return err + } + + ctx := context.Background() + workloadName := args[0] + + var workload computev1alpha.Workload + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("workload %q not found in project %s", workloadName, project) + } + return fmt.Errorf("getting workload: %w", err) + } + + entries, currentRev, err := revision.ReadEntries(ctx, c, util.ResourceNamespace, workloadName) + if err != nil { + return fmt.Errorf("reading revision history: %w", err) + } + + out := cmd.OutOrStdout() + + if len(entries) == 0 { + fmt.Fprintf(out, "No revision history found for workload %q.\n", workloadName) + return nil + } + + tw := util.NewTabWriter(out) + _, _ = fmt.Fprintln(tw, "REV\tWHEN\tIMAGE\tCHANGES\tBY\tSTATUS") + + for _, e := range entries { + when := "—" + if e.Timestamp != "" { + t, err := time.Parse(time.RFC3339, e.Timestamp) + if err == nil { + when = util.RelativeAgeVerbose(metav1.Time{Time: t}) + } + } + + status := "—" + if e.Rev == currentRev { + status = "active" + } + + fmt.Fprintf(tw, "#%d\t%s\t%s\t%s\t%s\t%s\n", + e.Rev, when, e.Image, e.Changes, e.Actor, status) + } + + _ = tw.Flush() + return nil +} + +func undoCommand() *cobra.Command { + var toRevision int32 + + cmd := &cobra.Command{ + Use: "undo ", + Short: "Roll back a workload to a previous revision", + Long: `Creates a new revision that is a copy of the target revision. +Rollbacks do not rewrite history.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: util.CompleteWorkloadNames, + RunE: func(cmd *cobra.Command, args []string) error { + return runUndo(cmd, args, toRevision) + }, + } + + cmd.Flags().Int32Var(&toRevision, "to-revision", 0, "Revision number to roll back to (0 = previous)") + + return cmd +} + +func runUndo(cmd *cobra.Command, args []string, toRevision int32) error { + project := util.ProjectFromCmd(cmd) + + c, err := util.NewClient(project) + if err != nil { + return err + } + + ctx := context.Background() + workloadName := args[0] + + var workload computev1alpha.Workload + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("workload %q not found in project %s", workloadName, project) + } + return fmt.Errorf("getting workload: %w", err) + } + + entries, currentRev, err := revision.ReadEntries(ctx, c, util.ResourceNamespace, workloadName) + if err != nil { + return fmt.Errorf("reading revision history: %w", err) + } + + if len(entries) == 0 { + return fmt.Errorf("no revision history for workload %q; cannot undo", workloadName) + } + + if currentRev == 1 { + return fmt.Errorf("no previous revision to roll back to") + } + + var target int + if toRevision == 0 { + target = currentRev - 1 + } else { + target = int(toRevision) + } + + if target == currentRev { + return fmt.Errorf("workload is already at revision #%d", currentRev) + } + + if target < 1 { + return fmt.Errorf("no previous revision to roll back to") + } + + var targetEntry *revision.Entry + for i := range entries { + if entries[i].Rev == target { + targetEntry = &entries[i] + break + } + } + if targetEntry == nil { + return fmt.Errorf("revision #%d not found; run 'datumctl compute rollout history %s'", target, workloadName) + } + + // Unmarshal the stored spec. + var targetSpec computev1alpha.WorkloadSpec + if err := json.Unmarshal([]byte(targetEntry.SpecJSON), &targetSpec); err != nil { + return fmt.Errorf("decoding stored spec for revision #%d: %w", target, err) + } + + out := cmd.OutOrStdout() + newRev := currentRev + 1 + fmt.Fprintf(out, "Creating revision #%d (copy of #%d)...\n", newRev, target) + + workload.Spec = targetSpec + if err := c.Update(ctx, &workload); err != nil { + return fmt.Errorf("updating workload: %w", err) + } + + actor := plugin.Context().Org + + newSpecJSON, _ := json.Marshal(workload.Spec) + entry := revision.Entry{ + Rev: newRev, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Image: targetEntry.Image, + Changes: fmt.Sprintf("rollback to rev #%d", target), + Actor: actor, + SpecJSON: string(newSpecJSON), + } + if err := revision.WriteEntry(ctx, c, util.ResourceNamespace, workloadName, entry); err != nil { + fmt.Fprintf(out, " warning: could not write revision history: %v\n", err) + } + + fmt.Fprintf(out, "Rollout started. Run 'datumctl compute rollout %s' to watch progress.\n", workloadName) + return nil +} diff --git a/internal/cmd/compute/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..1ce704e --- /dev/null +++ b/internal/cmd/compute/scale/scale.go @@ -0,0 +1,77 @@ +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) + }, + ValidArgsFunction: util.CompleteWorkloadNames, + } + + 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: 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) + } + + 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..77404fb --- /dev/null +++ b/internal/cmd/compute/status/status.go @@ -0,0 +1,247 @@ +package status + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + computev1alpha "go.datum.net/compute/api/v1alpha" + "go.datum.net/compute/internal/cmd/compute/util" + corev1 "k8s.io/api/core/v1" +) + +func Command() *cobra.Command { + cmd := &cobra.Command{ + Use: "status ", + Short: "Show the health and placement status of a workload", + Long: `Display the current health status of a workload with city-by-city replica +counts and plain-English explanations of any degraded conditions.`, + Args: cobra.ExactArgs(1), + Example: ` datumctl compute status api`, + RunE: func(cmd *cobra.Command, args []string) error { + return runStatus(cmd, args) + }, + ValidArgsFunction: util.CompleteWorkloadNames, + } + + return cmd +} + +func runStatus(cmd *cobra.Command, args []string) error { + ctx := context.Background() + project := util.ProjectFromCmd(cmd) + + c, err := util.NewClient(project) + if err != nil { + return err + } + + workloadName := args[0] + + // Fetch workload. + var workload computev1alpha.Workload + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: workloadName}, &workload); err != nil { + if k8serrors.IsNotFound(err) { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "workload %q not found in project %s\n", workloadName, project) + return fmt.Errorf("workload not found") + } + return fmt.Errorf("getting workload: %w", err) + } + + // List deployments for this workload. + selector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadUIDLabel: string(workload.UID)}) + var deployList computev1alpha.WorkloadDeploymentList + if err := c.List(ctx, &deployList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: selector}); err != nil { + return fmt.Errorf("listing deployments: %w", err) + } + + // Derive image. + image := "(virtual machine)" + if workload.Spec.Template.Spec.Runtime.Sandbox != nil && + len(workload.Spec.Template.Spec.Runtime.Sandbox.Containers) > 0 { + image = workload.Spec.Template.Spec.Runtime.Sandbox.Containers[0].Image + } + + instanceType := workload.Spec.Template.Spec.Runtime.Resources.InstanceType + age := util.RelativeAgeVerbose(workload.CreationTimestamp) + + // Fetch revision ConfigMap. + revision := "—" + var cm corev1.ConfigMap + cmName := "compute.datumapis.com-revision-history." + workloadName + if err := c.Get(ctx, types.NamespacedName{Namespace: util.ResourceNamespace, Name: cmName}, &cm); err == nil { + if v, ok := cm.Annotations["compute.datumapis.com/current-revision"]; ok { + revision = v + } + } + // If not found or any error, revision stays "—". + + // Compute totals. + var totalDesired, totalReady int32 + for _, d := range deployList.Items { + totalDesired += d.Status.DesiredReplicas + totalReady += d.Status.ReadyReplicas + } + + health := util.WorkloadHealth(workload.Status.Conditions, totalReady, totalDesired) + + out := cmd.OutOrStdout() + + // Header block — two-column layout. + _, _ = fmt.Fprintf(out, "%-12s %-31s project: %s\n", "Workload", workloadName, project) + _, _ = fmt.Fprintf(out, "%-12s %s\n", "Image", image) + fmt.Fprintf(out, "%-12s %-31s Revision #%s\n", "Updated", age, revision) + fmt.Fprintf(out, "\n") + fmt.Fprintf(out, "%-12s %s\n", "Health", health) + fmt.Fprintf(out, "\n") + + if len(deployList.Items) == 0 { + fmt.Fprintf(out, " No placements configured.\n") + return nil + } + + // Placement table — grouped by placement name. + tw := util.NewTabWriter(out) + fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n", "", "CITY", "READY", "DESIRED", "TYPE") + + // Group deployments by placement name preserving order from workload spec. + type deployGroup struct { + name string + deployments []computev1alpha.WorkloadDeployment + } + var groups []deployGroup + groupIndex := map[string]int{} + for _, d := range deployList.Items { + pn := d.Spec.PlacementName + if idx, ok := groupIndex[pn]; ok { + groups[idx].deployments = append(groups[idx].deployments, d) + } else { + groupIndex[pn] = len(groups) + groups = append(groups, deployGroup{name: pn, deployments: []computev1alpha.WorkloadDeployment{d}}) + } + } + + // Track degraded deployments for the detail block. + type degradedEntry struct { + city string + deployment computev1alpha.WorkloadDeployment + } + var degraded []degradedEntry + + for _, g := range groups { + for i, d := range g.deployments { + placementLabel := "" + if i == 0 { + placementLabel = g.name + } + readyStr := fmt.Sprintf("%d/%d", d.Status.ReadyReplicas, d.Status.Replicas) + fmt.Fprintf(tw, " %s\t%s\t%s\t%d\t%s\n", + placementLabel, + d.Spec.CityCode, + readyStr, + d.Status.DesiredReplicas, + instanceType, + ) + if d.Status.ReadyReplicas < d.Status.DesiredReplicas { + degraded = append(degraded, degradedEntry{city: d.Spec.CityCode, deployment: d}) + } + } + } + _ = tw.Flush() + + if len(degraded) == 0 { + return nil + } + + fmt.Fprintf(out, "\n") + + // For each degraded deployment, find the first unhealthy instance and get its detail. + type degradedDetail struct { + city string + count int32 + statusLine string + detailMsg string + quotaExceed bool + } + var details []degradedDetail + anyQuotaExceeded := false + + for _, de := range degraded { + depUID := string(de.deployment.UID) + depSelector := labels.SelectorFromSet(labels.Set{computev1alpha.WorkloadDeploymentUIDLabel: depUID}) + var instList computev1alpha.InstanceList + if err := c.List(ctx, &instList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: depSelector}); err != nil { + // Skip detail on error. + continue + } + + var statusLine, detailMsg string + quotaExceed := false + for _, inst := range instList.Items { + readyCond := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceReady) + if readyCond == nil || readyCond.Status != "True" { + s, d := util.InstanceStatusDetail(inst.Status.Conditions) + statusLine = s + detailMsg = d + // Check if quota exceeded. + qc := util.FindCondition(inst.Status.Conditions, computev1alpha.InstanceQuotaGranted) + if qc != nil && qc.Reason == computev1alpha.InstanceQuotaGrantedReasonQuotaExceeded { + quotaExceed = true + anyQuotaExceeded = true + } + break + } + } + + short := describeStatusShort(statusLine, de.deployment.Status.DesiredReplicas-de.deployment.Status.ReadyReplicas) + details = append(details, degradedDetail{ + city: de.city, + count: de.deployment.Status.DesiredReplicas - de.deployment.Status.ReadyReplicas, + statusLine: short, + detailMsg: detailMsg, + quotaExceed: quotaExceed, + }) + } + + for _, dd := range details { + fmt.Fprintf(out, " %s: %d instances could not start — %s\n", dd.city, dd.count, dd.statusLine) + if dd.detailMsg != "" { + fmt.Fprintf(out, " %s\n", dd.detailMsg) + } + } + + // Next steps block. + fmt.Fprintf(out, "\n Next steps:\n") + if anyQuotaExceeded { + fmt.Fprintf(out, " Reduce replicas: datumctl compute scale %s --min=2\n", workloadName) + fmt.Fprintf(out, " Check quota: datumctl compute quota\n") + } + fmt.Fprintf(out, " View instances: datumctl compute instances --workload=%s\n", workloadName) + + return nil +} + +// describeStatusShort converts a full status line into a short degradation phrase. +func describeStatusShort(statusLine string, count int32) string { + _ = count + switch { + case strings.Contains(statusLine, "quota exceeded"): + return "quota exceeded" + case strings.Contains(statusLine, "network provisioning in progress"): + return "network provisioning in progress" + case strings.Contains(statusLine, "network provisioning"): + return "network provisioning" + case statusLine == "Starting": + return "starting" + case statusLine == "Stopping": + return "stopping" + default: + return statusLine + } +} diff --git a/internal/cmd/compute/util/client.go b/internal/cmd/compute/util/client.go new file mode 100644 index 0000000..c41dc73 --- /dev/null +++ b/internal/cmd/compute/util/client.go @@ -0,0 +1,63 @@ +package util + +import ( + "fmt" + + "github.com/spf13/cobra" + 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" +) + +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. +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 '") + } + + ctx := plugin.Context() + if ctx.APIHost == "" { + return nil, fmt.Errorf("DATUM_API_HOST is not set; is this plugin running via datumctl?") + } + + token, err := plugin.Token() + if err != nil { + return nil, fmt.Errorf("getting credentials: %w", err) + } + + scheme := runtime.NewScheme() + if err := computev1alpha.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("registering compute scheme: %w", err) + } + + cfg := &rest.Config{ + Host: ProjectControlPlaneURL(ctx.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/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 +} 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..804fcb7 --- /dev/null +++ b/internal/cmd/compute/watch/watch.go @@ -0,0 +1,255 @@ +// 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(util.ResourceNamespace), + 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 := processDeployments(ctx, c, out, project, tw, states, deployList.Items) + + if allDone && len(deployList.Items) > 0 { + printElapsed(out, time.Since(start).Round(time.Second)) + return nil + } + } + } +} + +// 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 + } + 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, _ string, d computev1alpha.WorkloadDeployment) { + selector := labels.SelectorFromSet(labels.Set{ + computev1alpha.WorkloadDeploymentUIDLabel: string(d.UID), + }) + var instList computev1alpha.InstanceList + if err := c.List(ctx, &instList, client.InNamespace(util.ResourceNamespace), client.MatchingLabelsSelector{Selector: selector}); err != nil { + 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 + } + } +} diff --git a/internal/controller/instance_controller.go b/internal/controller/instance_controller.go index e5bc356..147f8c8 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" @@ -35,10 +36,19 @@ 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 { - 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 +112,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) } @@ -222,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, }, @@ -327,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() @@ -345,7 +355,7 @@ func (r *InstanceReconciler) reconcileInstanceReadyCondition( } if networkCreationFailure { - readyCondition.Reason = "NetworkFailedToCreate" + readyCondition.Reason = instanceNetworkFailedReason readyCondition.Message = networkCreationFailureMessage } else { readyCondition.Reason = computev1alpha.InstanceReadyReasonSchedulingGatesPresent @@ -365,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 } @@ -394,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 } @@ -428,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 } } @@ -449,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{ @@ -460,7 +470,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..91b22bb 100644 --- a/internal/controller/instance_controller_test.go +++ b/internal/controller/instance_controller_test.go @@ -14,12 +14,14 @@ 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" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/cluster" "sigs.k8s.io/controller-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" @@ -27,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 @@ -40,6 +54,7 @@ func (f *fakeCluster) GetScheme() *runtime.Scheme { return 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 } @@ -49,8 +64,8 @@ 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) } @@ -79,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, }, }, @@ -89,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, }, }, @@ -97,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{ @@ -114,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(), }, @@ -134,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{ @@ -153,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, }, @@ -162,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{ @@ -171,8 +186,8 @@ func TestReconcileInstanceReadyCondition(t *testing.T) { { Type: computev1alpha.InstanceProgrammed, Status: metav1.ConditionFalse, - Reason: "TestReason", - Message: "Test message", + Reason: reasonTestReason, + Message: reasonTestMessage, }, }, }, @@ -181,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, }, }, @@ -190,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{ @@ -200,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, }, }, }, @@ -215,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, }, }, @@ -224,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{ @@ -234,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, }, }, }, @@ -250,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, }, }, @@ -258,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{ @@ -268,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(), }, @@ -276,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, }, }, }, @@ -292,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, }, }, @@ -343,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{ @@ -360,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(), }, }, @@ -385,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{ @@ -402,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(), }, }, @@ -420,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{ @@ -448,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, }, }, } @@ -549,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..860f471 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) @@ -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/controller/workload_controller.go b/internal/controller/workload_controller.go index 6e907b6..38a06de 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" @@ -34,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 @@ -118,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), @@ -238,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", @@ -256,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 } } @@ -283,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", @@ -463,7 +467,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/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/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/validation/instance_validation.go b/internal/validation/instance_validation.go index 7f11282..c686459 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})) } } } @@ -457,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"))...) @@ -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.go b/internal/validation/workload_validation.go index 5f320e9..c18fcbc 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") @@ -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) 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 e3f3735..74303b1 100644 --- a/internal/webhook/v1alpha/workload_webhook.go +++ b/internal/webhook/v1alpha/workload_webhook.go @@ -2,16 +2,15 @@ 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" "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,8 +26,7 @@ func SetupWorkloadWebhookWithManager(mgr mcmanager.Manager) error { mgr: mgr, } - return ctrl.NewWebhookManagedBy(mgr.GetLocalManager()). - For(&computev1alpha.Workload{}). + return ctrl.NewWebhookManagedBy(mgr.GetLocalManager(), &computev1alpha.Workload{}). WithDefaulter(webhook). WithValidator(webhook). Complete() @@ -40,50 +38,21 @@ 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, clusterName) + cluster, err := r.mgr.GetCluster(ctx, multicluster.ClusterName(clusterName)) if err != nil { return nil, err } @@ -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 }