From 707236c4d441903cd1c3d1c90e099241d5f0be3e Mon Sep 17 00:00:00 2001 From: Drew Walczyk Date: Wed, 21 Oct 2026 08:36:55 -0500 Subject: [PATCH 1/8] feat(cli): add monitoring API selection to installation Resolve MONITORING_API_GROUP during installation and propagate GMP to the operator Deployment while preserving default CoreOS and RHOBS rendering. Reject managed environment overrides and GMP conflicts based on the RHOBS settings emitted into the operator Deployment, including normalized additional environment variables. Cover installer-only RHOBS settings separately from deployed RHOBS settings. Signed-off-by: Drew Walczyk Commit-Message-Assisted-by: Codex (via Codex) --- cmd/install/assets/hypershift_operator.go | 4 ++ .../assets/hypershift_operator_test.go | 30 ++++++++++ cmd/install/install.go | 27 +++++++++ cmd/install/monitoring_api_group_test.go | 59 +++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 cmd/install/monitoring_api_group_test.go diff --git a/cmd/install/assets/hypershift_operator.go b/cmd/install/assets/hypershift_operator.go index a0922b59caca..09c4d0fc697b 100644 --- a/cmd/install/assets/hypershift_operator.go +++ b/cmd/install/assets/hypershift_operator.go @@ -560,6 +560,7 @@ type HyperShiftOperatorDeployment struct { IncludeVersion bool UWMTelemetry bool RHOBSMonitoring bool + MonitoringAPIGroup metrics.MonitoringAPIGroup CVOPrometheusURL string MonitoringDashboards bool CertRotationScale time.Duration @@ -905,6 +906,9 @@ func (o HyperShiftOperatorDeployment) buildEnvVars() []corev1.EnvVar { if o.RHOBSMonitoring { envVars = append(envVars, corev1.EnvVar{Name: rhobsmonitoring.EnvironmentVariable, Value: "1"}) } + if o.MonitoringAPIGroup != "" && o.MonitoringAPIGroup != metrics.MonitoringAPIGroupCoreOS { + envVars = append(envVars, corev1.EnvVar{Name: metrics.MonitoringAPIGroupEnvVar, Value: string(o.MonitoringAPIGroup)}) + } if o.CVOPrometheusURL != "" { envVars = append(envVars, corev1.EnvVar{Name: config.CVOPrometheusURLEnvVar, Value: o.CVOPrometheusURL}) } diff --git a/cmd/install/assets/hypershift_operator_test.go b/cmd/install/assets/hypershift_operator_test.go index 8912253c16e6..5000b56a078d 100644 --- a/cmd/install/assets/hypershift_operator_test.go +++ b/cmd/install/assets/hypershift_operator_test.go @@ -10,6 +10,7 @@ import ( hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" controlplaneoperatoroverrides "github.com/openshift/hypershift/hypershift-operator/controlplaneoperator-overrides" "github.com/openshift/hypershift/support/config" + "github.com/openshift/hypershift/support/metrics" "github.com/openshift/hypershift/support/rhobsmonitoring" configv1 "github.com/openshift/api/config/v1" @@ -935,10 +936,38 @@ func TestBuildArgs(t *testing.T) { func TestBuildEnvVars(t *testing.T) { tests := []struct { name string + installerRHOBS string deployment HyperShiftOperatorDeployment expectContains []corev1.EnvVar expectAbsent []string }{ + { + name: "When GMP is selected, it should propagate the monitoring API group", + deployment: HyperShiftOperatorDeployment{MonitoringAPIGroup: metrics.MonitoringAPIGroupGMP}, + expectContains: []corev1.EnvVar{{Name: metrics.MonitoringAPIGroupEnvVar, Value: string(metrics.MonitoringAPIGroupGMP)}}, + }, + { + name: "When only the installer enables RHOBS, it should emit GMP without RHOBS", + installerRHOBS: "1", + deployment: HyperShiftOperatorDeployment{MonitoringAPIGroup: metrics.MonitoringAPIGroupGMP}, + expectContains: []corev1.EnvVar{{Name: metrics.MonitoringAPIGroupEnvVar, Value: string(metrics.MonitoringAPIGroupGMP)}}, + expectAbsent: []string{rhobsmonitoring.EnvironmentVariable}, + }, + { + name: "When CoreOS is explicit, it should retain the default environment", + deployment: HyperShiftOperatorDeployment{MonitoringAPIGroup: metrics.MonitoringAPIGroupCoreOS}, + expectAbsent: []string{metrics.MonitoringAPIGroupEnvVar}, + }, + { + name: "When monitoring selection is unset, it should retain the default environment", + expectAbsent: []string{metrics.MonitoringAPIGroupEnvVar}, + }, + { + name: "When RHOBS uses CoreOS selection, it should retain the RHOBS environment", + deployment: HyperShiftOperatorDeployment{MonitoringAPIGroup: metrics.MonitoringAPIGroupCoreOS, RHOBSMonitoring: true}, + expectContains: []corev1.EnvVar{{Name: rhobsmonitoring.EnvironmentVariable, Value: "1"}}, + expectAbsent: []string{metrics.MonitoringAPIGroupEnvVar}, + }, { name: "When TechPreviewNoUpgrade is enabled, it should include HYPERSHIFT_FEATURESET env var", deployment: HyperShiftOperatorDeployment{ @@ -1066,6 +1095,7 @@ func TestBuildEnvVars(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { g := NewGomegaWithT(t) + t.Setenv(rhobsmonitoring.EnvironmentVariable, tc.installerRHOBS) envVars := tc.deployment.buildEnvVars() for _, expected := range tc.expectContains { g.Expect(envVars).To(ContainElement(expected)) diff --git a/cmd/install/install.go b/cmd/install/install.go index 0f767cf405b1..66f853f770b2 100644 --- a/cmd/install/install.go +++ b/cmd/install/install.go @@ -142,6 +142,7 @@ type Options struct { WaitUntilAvailable bool WaitUntilEstablished bool RHOBSMonitoring bool + MonitoringAPIGroup metrics.MonitoringAPIGroup CVOPrometheusURL string SLOsAlerts bool MonitoringDashboards bool @@ -183,12 +184,37 @@ func (o *Options) Complete() error { o.ScaleFromZeroProvider = strings.ToLower(o.ScaleFromZeroProvider) } + // Resolve the monitoring API group from the environment when it is not set + // explicitly, defaulting to CoreOS. Validation happens in Validate. + if o.MonitoringAPIGroup == "" { + o.MonitoringAPIGroup = metrics.MonitoringAPIGroup(os.Getenv(metrics.MonitoringAPIGroupEnvVar)) + } + if o.MonitoringAPIGroup == "" { + o.MonitoringAPIGroup = metrics.MonitoringAPIGroupCoreOS + } + return nil } func (o *Options) Validate() error { var errs []error + // Match the operator Deployment builder's normalization when validating + // managed overrides and the GMP/RHOBS conflict. + rhobsMonitoring := o.RHOBSMonitoring + for key, value := range o.AdditionalOperatorEnvVars { + switch strings.TrimSpace(key) { + case metrics.MonitoringAPIGroupEnvVar: + errs = append(errs, fmt.Errorf("%s must not be set via --additional-operator-env-vars; it is managed from the monitoring API group selection", metrics.MonitoringAPIGroupEnvVar)) + case rhobsmonitoring.EnvironmentVariable: + rhobsMonitoring = rhobsMonitoring || strings.TrimSpace(value) == "1" + } + } + // The monitoring API group is resolved in Complete; validate it without mutating Options. + if _, err := metrics.ParseMonitoringAPIGroup(string(o.MonitoringAPIGroup), rhobsMonitoring); err != nil { + errs = append(errs, err) + } + if o.InstallScope != "" && !Outputs(o.InstallScope).IsValid() { errs = append(errs, fmt.Errorf("invalid --install-scope value %q: must be '%s', '%s', or '%s'", o.InstallScope, OutputAll, OutputCRDs, OutputResources)) } @@ -1405,6 +1431,7 @@ func setupOperatorResources(opts Options, userCABundleCM *corev1.ConfigMap, trus IncludeVersion: !opts.Template, UWMTelemetry: opts.EnableUWMTelemetryRemoteWrite, RHOBSMonitoring: opts.RHOBSMonitoring, + MonitoringAPIGroup: opts.MonitoringAPIGroup, CVOPrometheusURL: opts.CVOPrometheusURL, MonitoringDashboards: opts.MonitoringDashboards, CertRotationScale: opts.CertRotationScale, diff --git a/cmd/install/monitoring_api_group_test.go b/cmd/install/monitoring_api_group_test.go new file mode 100644 index 000000000000..965841dce334 --- /dev/null +++ b/cmd/install/monitoring_api_group_test.go @@ -0,0 +1,59 @@ +package install + +import ( + "testing" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/support/metrics" + "github.com/openshift/hypershift/support/rhobsmonitoring" + + "github.com/stretchr/testify/require" +) + +func TestValidate(t *testing.T) { + for _, tc := range []struct { + name string + option metrics.MonitoringAPIGroup + env, rhobsEnv string + rhobsFlag bool + additionalEnv map[string]string + want metrics.MonitoringAPIGroup + errorContains string + }{ + {name: "When monitoring selection is unset, it should default to CoreOS", want: metrics.MonitoringAPIGroupCoreOS}, + {name: "When CoreOS is pre-populated, it should accept CoreOS", option: metrics.MonitoringAPIGroupCoreOS, want: metrics.MonitoringAPIGroupCoreOS}, + {name: "When GMP is pre-populated, it should accept GMP", option: metrics.MonitoringAPIGroupGMP, want: metrics.MonitoringAPIGroupGMP}, + {name: "When the environment selects GMP, it should accept GMP", env: string(metrics.MonitoringAPIGroupGMP), want: metrics.MonitoringAPIGroupGMP}, + {name: "When a pre-populated group is invalid, it should reject it", option: "unsupported", errorContains: "unsupported MONITORING_API_GROUP value"}, + {name: "When the environment group is invalid, it should reject it", env: "unsupported", errorContains: "unsupported MONITORING_API_GROUP value"}, + {name: "When the explicit group overrides an invalid environment, it should validate the explicit group", option: metrics.MonitoringAPIGroupCoreOS, env: "unsupported", want: metrics.MonitoringAPIGroupCoreOS}, + {name: "When only the installer RHOBS environment is enabled with GMP, it should accept GMP", env: string(metrics.MonitoringAPIGroupGMP), rhobsEnv: "1", want: metrics.MonitoringAPIGroupGMP}, + {name: "When GMP and RHOBS environment are selected with the flag, it should reject the conflict", env: string(metrics.MonitoringAPIGroupGMP), rhobsEnv: "1", rhobsFlag: true, errorContains: "cannot be used with RHOBS monitoring"}, + {name: "When explicit GMP has only the installer RHOBS environment enabled, it should accept GMP", option: metrics.MonitoringAPIGroupGMP, rhobsEnv: "1", want: metrics.MonitoringAPIGroupGMP}, + {name: "When GMP conflicts with the RHOBS flag alone, it should reject the conflict", option: metrics.MonitoringAPIGroupGMP, rhobsFlag: true, errorContains: "cannot be used with RHOBS monitoring"}, + {name: "When RHOBS is enabled with the default group, it should preserve RHOBS validation", rhobsEnv: "1", rhobsFlag: true, want: metrics.MonitoringAPIGroupCoreOS}, + {name: "When the RHOBS flag is used without its environment, it should retain the existing error", rhobsFlag: true, errorContains: "RHOBS_MONITORING environment variable must be set"}, + {name: "When the managed group is set via additional operator env vars, it should reject the override", additionalEnv: map[string]string{metrics.MonitoringAPIGroupEnvVar: string(metrics.MonitoringAPIGroupGMP)}, errorContains: "must not be set via --additional-operator-env-vars"}, + {name: "When an additional managed group key contains whitespace, it should reject the override", additionalEnv: map[string]string{" \t" + metrics.MonitoringAPIGroupEnvVar + " \t": "unsupported"}, errorContains: "must not be set via --additional-operator-env-vars"}, + {name: "When GMP conflicts with additional RHOBS environment, it should reject the conflict", option: metrics.MonitoringAPIGroupGMP, additionalEnv: map[string]string{rhobsmonitoring.EnvironmentVariable: "1"}, errorContains: "cannot be used with RHOBS monitoring"}, + {name: "When additional RHOBS environment is disabled despite the installer environment, it should accept GMP", option: metrics.MonitoringAPIGroupGMP, rhobsEnv: "1", additionalEnv: map[string]string{rhobsmonitoring.EnvironmentVariable: "0"}, want: metrics.MonitoringAPIGroupGMP}, + {name: "When additional RHOBS keys and values contain whitespace with GMP, it should reject the conflict", option: metrics.MonitoringAPIGroupGMP, additionalEnv: map[string]string{" \t" + rhobsmonitoring.EnvironmentVariable + " \t": " \t1 \t"}, errorContains: "cannot be used with RHOBS monitoring"}, + {name: "When additional RHOBS environment is disabled with GMP, it should accept GMP", option: metrics.MonitoringAPIGroupGMP, additionalEnv: map[string]string{rhobsmonitoring.EnvironmentVariable: "0"}, want: metrics.MonitoringAPIGroupGMP}, + {name: "When additional RHOBS environment uses the default group, it should retain CoreOS selection", additionalEnv: map[string]string{rhobsmonitoring.EnvironmentVariable: "1"}, want: metrics.MonitoringAPIGroupCoreOS}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(metrics.MonitoringAPIGroupEnvVar, tc.env) + t.Setenv(rhobsmonitoring.EnvironmentVariable, tc.rhobsEnv) + opts := Options{PrivatePlatform: string(hyperv1.NonePlatform), MonitoringAPIGroup: tc.option, RHOBSMonitoring: tc.rhobsFlag, AdditionalOperatorEnvVars: tc.additionalEnv} + // Complete resolves the group from the flag or environment; Validate only checks it. + require.NoError(t, opts.Complete()) + err := opts.Validate() + if tc.errorContains != "" { + require.ErrorContains(t, err, tc.errorContains) + } else { + require.NoError(t, err) + require.Equal(t, tc.want, opts.MonitoringAPIGroup) + } + }) + } +} From a0f4e4e016368989f078216cabab1254fd1414a9 Mon Sep 17 00:00:00 2001 From: Drew Walczyk Date: Wed, 21 Oct 2026 08:37:06 -0500 Subject: [PATCH 2/8] feat(hypershift-operator): add GMP monitoring API foundation Add monitoring API selection types and carry the selection through component contexts. Register API-only GMP PodMonitoring and Rules types additively without creating resources, watches, or a new operator dependency. Provide a pinned extractor, provenance, upstream schema fixtures, generated deepcopies, and reproducible sync and verification commands. Document the maintenance workflow and route the new paths to GCP reviewers. Signed-off-by: Drew Walczyk Commit-Message-Assisted-by: Codex (via Codex) --- DEVELOPMENT.md | 4 + Makefile | 12 +- OWNERS | 5 + go.mod | 6 +- hack/gmp-api-gen/main.go | 235 ++++ hack/gmp-api-gen/main_test.go | 227 ++++ hack/gmp-api-gen/provenance.go | 50 + hack/gmp-api-gen/provenance_test.go | 113 ++ hack/gmp-api-source/go.mod | 6 + hack/gmp-api-source/go.sum | 2 + hack/update-gmp-api.sh | 104 ++ support/api/scheme.go | 5 +- .../controlplane-component.go | 4 + .../monitoring_api_group_test.go | 27 + support/gmpmonitoring/README.md | 101 ++ support/gmpmonitoring/doc.go | 8 + support/gmpmonitoring/scheme.go | 24 + .../testdata/podmonitorings.crd.yaml | 1131 +++++++++++++++++ support/gmpmonitoring/testdata/rules.crd.yaml | 289 +++++ support/gmpmonitoring/types.go | 487 +++++++ support/gmpmonitoring/types_test.go | 225 ++++ support/gmpmonitoring/upstream.sha256 | 21 + .../gmpmonitoring/zz_generated.deepcopy.go | 719 +++++++++++ support/metrics/monitoring_api_group.go | 42 + support/metrics/monitoring_api_group_test.go | 64 + 25 files changed, 3905 insertions(+), 6 deletions(-) create mode 100644 hack/gmp-api-gen/main.go create mode 100644 hack/gmp-api-gen/main_test.go create mode 100644 hack/gmp-api-gen/provenance.go create mode 100644 hack/gmp-api-gen/provenance_test.go create mode 100644 hack/gmp-api-source/go.mod create mode 100644 hack/gmp-api-source/go.sum create mode 100755 hack/update-gmp-api.sh create mode 100644 support/controlplane-component/monitoring_api_group_test.go create mode 100644 support/gmpmonitoring/README.md create mode 100644 support/gmpmonitoring/doc.go create mode 100644 support/gmpmonitoring/scheme.go create mode 100644 support/gmpmonitoring/testdata/podmonitorings.crd.yaml create mode 100644 support/gmpmonitoring/testdata/rules.crd.yaml create mode 100644 support/gmpmonitoring/types.go create mode 100644 support/gmpmonitoring/types_test.go create mode 100644 support/gmpmonitoring/upstream.sha256 create mode 100644 support/gmpmonitoring/zz_generated.deepcopy.go create mode 100644 support/metrics/monitoring_api_group.go create mode 100644 support/metrics/monitoring_api_group_test.go diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index fbf5cf1fec66..3290913ddfa8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -182,8 +182,12 @@ make update # Full update (api-deps, workspace-sync, capi-sync make capi-sync # Sync CAPI provider types (incremental) make capi-sync-force # Force re-sync all CAPI providers make verify-capi-sync # Verify CAPI sync is clean (CI target) +make gmp-sync # Regenerate the pinned GMP API snapshot +make verify-gmp-sync # Verify the GMP snapshot reproduces without changing tracked files ``` +For GMP API updates, see [the package maintenance guide](support/gmpmonitoring/README.md#maintenance). + ## Development Patterns ### Resource Management diff --git a/Makefile b/Makefile index af3905b76d35..e9e4c3c36662 100644 --- a/Makefile +++ b/Makefile @@ -249,9 +249,17 @@ $(CRD_SCHEMA_CHECK): $(TOOLS_DIR)/go.mod # Build crd-schema-check tool cd $(TOOLS_DIR); $(GO) build -o $(BIN_DIR)/crd-schema-check ./crd-schema-check .PHONY: generate -generate: $(MOCKGEN) +generate: $(MOCKGEN) $(CONTROLLER_GEN) $(GO) generate ./... +.PHONY: gmp-sync +gmp-sync: $(CONTROLLER_GEN) + ./hack/update-gmp-api.sh --sync + +.PHONY: verify-gmp-sync +verify-gmp-sync: $(CONTROLLER_GEN) + ./hack/update-gmp-api.sh --verify + # Compile all tests .PHONY: tests tests: generate @@ -811,7 +819,7 @@ test-commit-layout: $(PYTHON_VENV_STAMP) ## Test the restructure-commits layout .PHONY: verify-codespell verify-codespell: codespell ## Verify codespell. - @$(CODESPELL) --count --ignore-words=./.codespellignore --skip="./hack/tools/bin/codespell_dist,./docs/site/*,./vendor/*,./api/vendor/*,./hack/tools/vendor/*,./api/hypershift/v1alpha1/*,./support/thirdparty/*,./docs/content/reference/*,./hack/tools/bin/*,./cmd/install/assets/*,./go.sum,./api/go.sum,./hack/workspace/go.work.sum,./api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests,./hack/tools/go.mod,./hack/tools/go.sum,./karpenter-operator/controllers/karpenter/assets/*.yaml,./dev/*,./pkg/capi/*,./hack/capi-vendor/*" + @$(CODESPELL) --count --ignore-words=./.codespellignore --skip="./hack/tools/bin/codespell_dist,./docs/site/*,./support/gmpmonitoring/testdata/*,./vendor/*,./api/vendor/*,./hack/tools/vendor/*,./api/hypershift/v1alpha1/*,./support/thirdparty/*,./docs/content/reference/*,./hack/tools/bin/*,./cmd/install/assets/*,./go.sum,./api/go.sum,./hack/workspace/go.work.sum,./api/hypershift/v1beta1/zz_generated.featuregated-crd-manifests,./hack/tools/go.mod,./hack/tools/go.sum,./karpenter-operator/controllers/karpenter/assets/*.yaml,./dev/*,./pkg/capi/*,./hack/capi-vendor/*" .PHONY: verify-api-deps verify-api-deps: $(VERIFY_API_DEPS) ## Verify API dependency allowlist and shared requirements/replacements. diff --git a/OWNERS b/OWNERS index 4f3b306022f8..030fec995220 100644 --- a/OWNERS +++ b/OWNERS @@ -27,6 +27,11 @@ filters: - gcp-reviewers labels: - area/platform/gcp + "^(support/gmpmonitoring/|support/metrics/monitoring_api_group.*\\.go$|hack/gmp-api-gen/|hack/gmp-api-source/|hack/update-gmp-api\\.sh$)": + reviewers: + - gcp-reviewers + labels: + - area/platform/gcp "^(api|cmd|control-plane-operator|docs|hypershift-operator|product-cli|test)/.*ibmcloud.*": labels: - area/platform/ibmcloud diff --git a/go.mod b/go.mod index cf03e076c548..bf24f9490277 100644 --- a/go.mod +++ b/go.mod @@ -191,8 +191,8 @@ require ( github.com/go-openapi/jsonreference v1.0.0 // indirect github.com/go-openapi/loads v0.22.0 // indirect github.com/go-openapi/runtime v0.28.0 // indirect - github.com/go-openapi/spec v0.21.0 // indirect - github.com/go-openapi/strfmt v0.25.0 // indirect + github.com/go-openapi/spec v0.21.0 + github.com/go-openapi/strfmt v0.25.0 github.com/go-openapi/swag v0.27.1 // indirect github.com/go-openapi/swag/cmdutils v0.27.1 // indirect github.com/go-openapi/swag/conv v0.27.1 // indirect @@ -205,7 +205,7 @@ require ( github.com/go-openapi/swag/stringutils v0.27.1 // indirect github.com/go-openapi/swag/typeutils v0.27.1 // indirect github.com/go-openapi/swag/yamlutils v0.27.1 // indirect - github.com/go-openapi/validate v0.24.0 // indirect + github.com/go-openapi/validate v0.24.0 github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.28.0 // indirect diff --git a/hack/gmp-api-gen/main.go b/hack/gmp-api-gen/main.go new file mode 100644 index 000000000000..94bf427a40ca --- /dev/null +++ b/hack/gmp-api-gen/main.go @@ -0,0 +1,235 @@ +// gmp-api-gen extracts the data-type closure of the supported GMP resources. +// Runtime methods, interfaces and unrelated resources are deliberately excluded. +package main + +import ( + "bytes" + "flag" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +func main() { + upstream := flag.String("upstream", "", "pinned prometheus-engine source directory") + output := flag.String("output", "support/gmpmonitoring", "output directory") + sourceInfo := flag.String("source-info", "", "go mod download JSON for generated provenance") + flag.Parse() + if err := generate(*upstream, *output); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if *sourceInfo != "" { + if err := writeProvenance(*upstream, *sourceInfo, *output); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } +} + +func generate(upstream, output string) error { + files, err := apiSourceFiles(upstream) + if err != nil { + return err + } + fset := token.NewFileSet() + types := map[string]*ast.GenDecl{} + sources := map[*ast.GenDecl][]byte{} + imports := map[*ast.GenDecl]map[string]string{} + packageOffsets := map[*ast.GenDecl]int{} + var condition *ast.GenDecl + for _, name := range files { + path := filepath.Join(upstream, name) + source, err := os.ReadFile(path) + if err != nil { + return err + } + file, err := parser.ParseFile(fset, path, source, parser.ParseComments) + if err != nil { + return err + } + if file.Name.Name != "v1" { + return fmt.Errorf("unexpected upstream package %q in %s", file.Name.Name, name) + } + fileImports := map[string]string{} + for _, imp := range file.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + return err + } + alias := filepath.Base(path) + if imp.Name != nil { + alias = imp.Name.Name + } + fileImports[alias] = path + } + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || (gen.Tok != token.TYPE && gen.Tok != token.CONST) { + continue + } + sources[gen] = source + imports[gen] = fileImports + packageOffsets[gen] = fset.Position(file.Package).Offset + for _, spec := range gen.Specs { + if typ, ok := spec.(*ast.TypeSpec); ok { + types[typ.Name.Name] = gen + } + if val, ok := spec.(*ast.ValueSpec); ok && val.Names[0].Name == "ConfigurationCreateSuccess" { + condition = gen + } + } + } + } + names, err := selectTypes(types) + if err != nil { + return err + } + usedImports := map[string]string{} + var body bytes.Buffer + writeDecl := func(decl *ast.GenDecl) error { + if err := collectImports(decl, imports[decl], usedImports); err != nil { + return err + } + start := decl.Pos() + if decl.Doc != nil { + start = decl.Doc.Pos() + } + source := sources[decl] + body.Write(source[fset.Position(start).Offset:fset.Position(decl.End()).Offset]) + body.WriteString("\n\n") + return nil + } + if condition == nil { + return fmt.Errorf("missing upstream monitoring condition constant") + } + if err := writeDecl(condition); err != nil { + return err + } + for _, name := range names { + if err := writeDecl(types[name]); err != nil { + return err + } + } + var result bytes.Buffer + // Preserve the upstream license header and make provenance explicit. + licenseSource := sources[types["PodMonitoring"]] + result.Write(licenseSource[:packageOffsets[types["PodMonitoring"]]]) + result.WriteString("// Code generated by hack/update-gmp-api.sh; DO NOT EDIT.\n\npackage gmpmonitoring\n\nimport (\n") + aliases := make([]string, 0, len(usedImports)) + for alias := range usedImports { + aliases = append(aliases, alias) + } + sort.Strings(aliases) + for _, alias := range aliases { + path := usedImports[alias] + fmt.Fprintf(&result, "%s %q\n", alias, path) + } + result.WriteString(")\n\n") + result.Write(body.Bytes()) + formatted, err := format.Source(result.Bytes()) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(output, "types.go"), formatted, 0644) +} + +func collectImports(decl *ast.GenDecl, imports, usedImports map[string]string) error { + var importError error + ast.Inspect(decl, func(node ast.Node) bool { + if selector, ok := node.(*ast.SelectorExpr); ok { + if alias, ok := selector.X.(*ast.Ident); ok { + path := imports[alias.Name] + switch path { + case "github.com/prometheus/common/model", "k8s.io/api/core/v1", "k8s.io/apimachinery/pkg/apis/meta/v1", "k8s.io/apimachinery/pkg/util/intstr": + default: + importError = fmt.Errorf("unsupported API import %q for %s; review the upstream data dependency", path, alias.Name) + return false + } + if previous := usedImports[alias.Name]; previous != "" && previous != path { + importError = fmt.Errorf("conflicting upstream import alias %s: %s and %s", alias.Name, previous, path) + return false + } + usedImports[alias.Name] = path + } + } + return true + }) + return importError +} + +func selectTypes(types map[string]*ast.GenDecl) ([]string, error) { + selected := map[string]bool{} + var visit func(string) error + visit = func(name string) error { + if selected[name] { + return nil + } + decl, ok := types[name] + if !ok { + return fmt.Errorf("missing upstream type %s", name) + } + if len(decl.Specs) != 1 { + return fmt.Errorf("unsupported grouped upstream type declaration for %s", name) + } + spec := decl.Specs[0].(*ast.TypeSpec) + if _, ok := spec.Type.(*ast.InterfaceType); ok { + return fmt.Errorf("API data unexpectedly depends on interface %s", name) + } + selected[name] = true + var err error + var inspect func(ast.Node) bool + inspect = func(node ast.Node) bool { + switch node := node.(type) { + case *ast.Field: + ast.Inspect(node.Type, inspect) + return false // Field names are declarations, not type references. + case *ast.SelectorExpr: + return false // External types are imports, not part of the local closure. + case *ast.InterfaceType: + err = fmt.Errorf("API data unexpectedly depends on interface in %s", name) + return false + } + if id, ok := node.(*ast.Ident); ok && types[id.Name] != nil && err == nil { + err = visit(id.Name) + } + return err == nil + } + ast.Inspect(spec.Type, inspect) + return err + } + for _, name := range []string{"PodMonitoring", "PodMonitoringList", "Rules", "RulesList"} { + if err := visit(name); err != nil { + return nil, err + } + } + names := make([]string, 0, len(selected)) + for name := range selected { + names = append(names, name) + } + sort.Strings(names) + return names, nil +} + +func apiSourceFiles(upstream string) ([]string, error) { + const directory = "pkg/operator/apis/monitoring/v1" + entries, err := os.ReadDir(filepath.Join(upstream, directory)) + if err != nil { + return nil, err + } + var files []string + for _, entry := range entries { + name := entry.Name() + if !entry.IsDir() && strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") && !strings.HasPrefix(name, "zz_generated.") { + files = append(files, filepath.Join(directory, name)) + } + } + return files, nil +} diff --git a/hack/gmp-api-gen/main_test.go b/hack/gmp-api-gen/main_test.go new file mode 100644 index 000000000000..18212bf1424f --- /dev/null +++ b/hack/gmp-api-gen/main_test.go @@ -0,0 +1,227 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSelectTypes(t *testing.T) { + for _, tc := range []struct{ name, source, wantError string }{ + {name: "When API types contain recursive references, it should terminate and return a sorted closure", source: `package v1 +type PodMonitoring struct { Ref *PodMonitoring } +type PodMonitoringList struct { Items []PodMonitoring } +type Rules struct { Ref *PodMonitoring } +type RulesList struct { Items []Rules } +type Unrelated string`}, + {name: "When a field name matches an unrelated type, it should not add that type to the closure", source: `package v1 +type PodMonitoring struct { Authorization string } +type PodMonitoringList struct { Items []PodMonitoring } +type Rules struct{} +type RulesList struct { Items []Rules } +type Authorization interface { Reconcile() }`}, + {name: "When a required root type is missing, it should fail explicitly", source: "package v1", wantError: "missing upstream type PodMonitoring"}, + {name: "When a selected type uses a grouped declaration, it should require explicit generator support", source: `package v1 +type (PodMonitoring struct{}; PodMonitoringList struct{}) +type Rules struct{} +type RulesList struct{}`, wantError: "unsupported grouped upstream type declaration"}, + } { + t.Run(tc.name, func(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "types.go", tc.source, 0) + if err != nil { + t.Fatal(err) + } + types := map[string]*ast.GenDecl{} + for _, node := range file.Decls { + decl := node.(*ast.GenDecl) + for _, spec := range decl.Specs { + types[spec.(*ast.TypeSpec).Name.Name] = decl + } + } + names, err := selectTypes(types) + if tc.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("expected %q, got %v", tc.wantError, err) + } + return + } + if err != nil { + t.Fatal(err) + } + if strings.Join(names, ",") != "PodMonitoring,PodMonitoringList,Rules,RulesList" { + t.Fatalf("unexpected closure %v", names) + } + }) + } +} + +func TestCollectImports(t *testing.T) { + for _, tc := range []struct { + name, path, wantError string + }{ + {name: "When a selected declaration uses Kubernetes metadata, it should collect only that import", path: "k8s.io/apimachinery/pkg/apis/meta/v1"}, + {name: "When a selected declaration imports operator code, it should reject the dependency", path: "coupled/operator", wantError: "unsupported API import"}, + } { + t.Run(tc.name, func(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "types.go", "package v1\ntype Snapshot struct { Metadata meta.ObjectMeta }", 0) + if err != nil { + t.Fatal(err) + } + used := map[string]string{} + err = collectImports(file.Decls[0].(*ast.GenDecl), map[string]string{"meta": tc.path, "unused": "coupled/unused"}, used) + if tc.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("expected %q, got %v", tc.wantError, err) + } + return + } + if err != nil { + t.Fatal(err) + } + if len(used) != 1 || used["meta"] != tc.path { + t.Fatalf("unexpected imports: %v", used) + } + }) + } +} + +func TestAPISourceFiles(t *testing.T) { + for _, tc := range []struct { + name string + missing bool + }{ + {name: "When the source directory contains mixed inputs, it should select sorted non-generated Go sources"}, + {name: "When the upstream API directory is missing, it should report the error", missing: true}, + } { + t.Run(tc.name, func(t *testing.T) { + upstream := t.TempDir() + directory := filepath.Join(upstream, "pkg/operator/apis/monitoring/v1") + if !tc.missing { + if err := os.MkdirAll(directory, 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"z.go", "a.go", "a_test.go", "zz_generated.deepcopy.go", "README.md"} { + if err := os.WriteFile(filepath.Join(directory, name), nil, 0644); err != nil { + t.Fatal(err) + } + } + if err := os.Mkdir(filepath.Join(directory, "nested.go"), 0755); err != nil { + t.Fatal(err) + } + } + files, err := apiSourceFiles(upstream) + if tc.missing { + if !os.IsNotExist(err) { + t.Fatalf("expected a missing directory error, got %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + want := "pkg/operator/apis/monitoring/v1/a.go,pkg/operator/apis/monitoring/v1/z.go" + if strings.Join(files, ",") != want { + t.Fatalf("unexpected source selection: %v", files) + } + }) + } +} + +func TestGenerate(t *testing.T) { + for _, tc := range []struct { + name, nested, nestedImports, wantError string + extraFiles map[string]string + omitCondition bool + }{ + {name: "When nested data moves into another source file, it should copy only the data closure deterministically", nested: "struct { Labels map[string]string; Values []string }"}, + {name: "When API data depends on a runtime interface, it should reject the dependency", nested: "interface { Reconcile() }", wantError: "unexpectedly depends on interface"}, + {name: "When API data contains an anonymous runtime interface, it should reject the dependency", nested: "struct { Runtime interface { Reconcile() } }", wantError: "unexpectedly depends on interface"}, + {name: "When a selected type imports runtime code, it should reject the dependency", nested: "struct { Client operator.Client }", nestedImports: `import "coupled/operator"`, wantError: `unsupported API import "coupled/operator"`}, + {name: "When an unrelated file reuses an import alias, it should retain the selected type's import", nested: "struct { Label model.LabelName }", nestedImports: `import "github.com/prometheus/common/model"`, extraFiles: map[string]string{"unrelated.go": "package v1\nimport model \"coupled/operator\"\ntype Ignored struct { Client model.Client }"}}, + {name: "When selected declarations conflict on an import alias, it should report the conflict", nested: "struct { Ref shared.SecretReference }", nestedImports: `import shared "k8s.io/api/core/v1"`, extraFiles: map[string]string{"rules_types.go": "package v1\nimport shared \"k8s.io/apimachinery/pkg/apis/meta/v1\"\ntype Rules struct { Selector shared.LabelSelector }\ntype RulesList struct { Items []Rules }"}, wantError: "conflicting upstream import alias shared"}, + {name: "When the upstream condition constant is removed, it should report the missing declaration", nested: "string", omitCondition: true, wantError: "missing upstream monitoring condition constant"}, + {name: "When a source file belongs to another package, it should fail explicitly", nested: "string", extraFiles: map[string]string{"other.go": "package other"}, wantError: "unexpected upstream package"}, + } { + t.Run(tc.name, func(t *testing.T) { + upstream, output := t.TempDir(), t.TempDir() + input := filepath.Join(upstream, "pkg/operator/apis/monitoring/v1") + if err := os.MkdirAll(input, 0755); err != nil { + t.Fatal(err) + } + files := map[string]string{ + "pod_types.go": `// Copyright Google LLC +package v1 +import "coupled/operator" +// PodMonitoring carries monitoring data. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type PodMonitoring struct { Spec *Nested } +type PodMonitoringList struct { Items []PodMonitoring } +type ClusterPodMonitoring struct { Runtime operator.Client } +func (*PodMonitoring) Runtime() { operator.Reconcile() }`, + "transport_types.go": "package v1\n" + tc.nestedImports + "\ntype Nested " + tc.nested, + "rules_types.go": `package v1 +type Rules struct { State MonitoringConditionType } +type RulesList struct { Items []Rules } +type ClusterRules struct { Runtime string }`, + "http_types.go": `package v1 +type Unrelated struct { Runtime string }`, + "monitoring_types.go": `package v1 +type MonitoringConditionType string +const ( // Monitoring configuration condition. +ConfigurationCreateSuccess MonitoringConditionType = "ConfigurationCreateSuccess" +)`, + "ignored_test.go": "not valid Go source", + "zz_generated.deepcopy.go": "not valid Go source", + } + if tc.omitCondition { + files["monitoring_types.go"] = "package v1\ntype MonitoringConditionType string" + } + for name, source := range tc.extraFiles { + files[name] = source + } + for name, source := range files { + if err := os.WriteFile(filepath.Join(input, name), []byte(source), 0644); err != nil { + t.Fatal(err) + } + } + err := generate(upstream, output) + if tc.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("expected %q, got %v", tc.wantError, err) + } + return + } + if err != nil { + t.Fatal(err) + } + first, err := os.ReadFile(filepath.Join(output, "types.go")) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{"Copyright Google LLC", "type Nested", "type PodMonitoringList", "type RulesList", "ConfigurationCreateSuccess", "+k8s:deepcopy-gen"} { + if !strings.Contains(string(first), required) { + t.Fatalf("missing %q", required) + } + } + for _, forbidden := range []string{"coupled/operator", "func (", "ClusterPodMonitoring", "ClusterRules", "Unrelated"} { + if strings.Contains(string(first), forbidden) { + t.Fatalf("unexpected runtime or unrelated declaration %q", forbidden) + } + } + if err := generate(upstream, output); err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(filepath.Join(output, "types.go")) + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Fatal("generation changed on identical inputs") + } + }) + } +} diff --git a/hack/gmp-api-gen/provenance.go b/hack/gmp-api-gen/provenance.go new file mode 100644 index 000000000000..d2f62c924dfb --- /dev/null +++ b/hack/gmp-api-gen/provenance.go @@ -0,0 +1,50 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +func writeProvenance(upstream, sourceInfo, output string) error { + data, err := os.ReadFile(sourceInfo) + if err != nil { + return err + } + var info struct { + Path, Version, Sum, GoModSum string + Origin struct{ Hash string } + } + if err := json.Unmarshal(data, &info); err != nil { + return fmt.Errorf("decode upstream module information: %w", err) + } + if info.Path != "github.com/GoogleCloudPlatform/prometheus-engine" || info.Version == "" || info.Sum == "" || info.GoModSum == "" { + return fmt.Errorf("incomplete or unexpected upstream module information") + } + if decoded, err := hex.DecodeString(info.Origin.Hash); err != nil || len(decoded) != 20 { + return fmt.Errorf("upstream module information must include a full Git revision") + } + files, err := apiSourceFiles(upstream) + if err != nil { + return err + } + files = append(files, + "charts/operator/crds/monitoring.googleapis.com_podmonitorings.yaml", + "charts/operator/crds/monitoring.googleapis.com_rules.yaml") + sort.Strings(files) + var manifest bytes.Buffer + fmt.Fprintf(&manifest, "# Code generated by hack/update-gmp-api.sh; DO NOT EDIT.\n# Module: %s\n# Version: %s\n# Revision: %s\n# Module sum: %s\n# go.mod sum: %s\n", info.Path, info.Version, info.Origin.Hash, info.Sum, info.GoModSum) + for _, name := range files { + contents, err := os.ReadFile(filepath.Join(upstream, name)) + if err != nil { + return err + } + fmt.Fprintf(&manifest, "%x %s\n", sha256.Sum256(contents), filepath.ToSlash(name)) + } + return os.WriteFile(filepath.Join(output, "upstream.sha256"), manifest.Bytes(), 0644) +} diff --git a/hack/gmp-api-gen/provenance_test.go b/hack/gmp-api-gen/provenance_test.go new file mode 100644 index 000000000000..a161861c160d --- /dev/null +++ b/hack/gmp-api-gen/provenance_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteProvenance(t *testing.T) { + for _, tc := range []struct { + name, field, value, remove, wantError string + }{ + {name: "When the source is pinned, it should record deterministic provenance and detect changed input contents"}, + {name: "When the source module is unexpected, it should reject the metadata", field: "Path", value: "github.com/openshift/hypershift", wantError: "unexpected upstream module"}, + {name: "When the version is missing, it should reject incomplete metadata", field: "Version", wantError: "incomplete"}, + {name: "When the module checksum is missing, it should reject incomplete metadata", field: "Sum", wantError: "incomplete"}, + {name: "When the module-file checksum is missing, it should reject incomplete metadata", field: "GoModSum", wantError: "incomplete"}, + {name: "When the revision is missing, it should reject incomplete metadata", field: "Hash", wantError: "full Git revision"}, + {name: "When the revision is malformed, it should reject the metadata", field: "Hash", value: "not-a-git-revision", wantError: "full Git revision"}, + {name: "When a CRD fixture is missing, it should fail before writing provenance", remove: "charts/operator/crds/monitoring.googleapis.com_rules.yaml", wantError: "no such file"}, + } { + t.Run(tc.name, func(t *testing.T) { + upstream, output := t.TempDir(), t.TempDir() + for _, name := range []string{ + "pkg/operator/apis/monitoring/v1/pod_types.go", + "pkg/operator/apis/monitoring/v1/transport_types.go", + "pkg/operator/apis/monitoring/v1/ignored_test.go", + "charts/operator/crds/monitoring.googleapis.com_podmonitorings.yaml", + "charts/operator/crds/monitoring.googleapis.com_rules.yaml", + } { + if name == tc.remove { + continue + } + path := filepath.Join(upstream, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("source"), 0644); err != nil { + t.Fatal(err) + } + } + info := map[string]interface{}{ + "Path": "github.com/GoogleCloudPlatform/prometheus-engine", "Version": "v0.17.2", + "Sum": "h1:module", "GoModSum": "h1:module-file", + "Origin": map[string]string{"Hash": "058752ec791edefba5c42486eb9237562c3f0d29"}, + } + if tc.field == "Hash" { + info["Origin"].(map[string]string)["Hash"] = tc.value + } else if tc.field != "" { + info[tc.field] = tc.value + } + data, err := json.Marshal(info) + if err != nil { + t.Fatal(err) + } + sourceInfo := filepath.Join(t.TempDir(), "source.json") + if err := os.WriteFile(sourceInfo, data, 0644); err != nil { + t.Fatal(err) + } + err = writeProvenance(upstream, sourceInfo, output) + if tc.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("expected %q, got %v", tc.wantError, err) + } + if _, err := os.Stat(filepath.Join(output, "upstream.sha256")); !os.IsNotExist(err) { + t.Fatal("failed generation should not write provenance") + } + return + } + if err != nil { + t.Fatal(err) + } + manifest := filepath.Join(output, "upstream.sha256") + first, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{"# Version: v0.17.2", "# Revision: 058752ec791edefba5c42486eb9237562c3f0d29", "# Module sum: h1:module", "transport_types.go", "monitoring.googleapis.com_rules.yaml"} { + if !strings.Contains(string(first), required) { + t.Fatalf("missing provenance %q", required) + } + } + if strings.Contains(string(first), "ignored_test.go") { + t.Fatal("upstream tests are not extraction inputs") + } + if err := writeProvenance(upstream, sourceInfo, output); err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Fatal("provenance changed on identical inputs") + } + if err := os.WriteFile(filepath.Join(upstream, "pkg/operator/apis/monitoring/v1/transport_types.go"), []byte("changed"), 0644); err != nil { + t.Fatal(err) + } + if err := writeProvenance(upstream, sourceInfo, output); err != nil { + t.Fatal(err) + } + third, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + if string(first) == string(third) { + t.Fatal("changed input contents did not change the recorded checksum") + } + }) + } +} diff --git a/hack/gmp-api-source/go.mod b/hack/gmp-api-source/go.mod new file mode 100644 index 000000000000..7b379e2a9bd1 --- /dev/null +++ b/hack/gmp-api-source/go.mod @@ -0,0 +1,6 @@ +module github.com/openshift/hypershift/hack/gmp-api-source + +go 1.26.3 + +// Download-only input for GMP API generation. Do not run go mod tidy here. +require github.com/GoogleCloudPlatform/prometheus-engine v0.17.2 diff --git a/hack/gmp-api-source/go.sum b/hack/gmp-api-source/go.sum new file mode 100644 index 000000000000..49b4c2e254d6 --- /dev/null +++ b/hack/gmp-api-source/go.sum @@ -0,0 +1,2 @@ +github.com/GoogleCloudPlatform/prometheus-engine v0.17.2 h1:b4Sau/04Xn0wqGyGABUzLVOSlM7BxsZHbcYEXP3EDeg= +github.com/GoogleCloudPlatform/prometheus-engine v0.17.2/go.mod h1:Mfy+yPZh97JLpnp8/WADyS3PJ3xzK8tua/luLRYkwtU= diff --git a/hack/update-gmp-api.sh b/hack/update-gmp-api.sh new file mode 100755 index 000000000000..da8f69d5c734 --- /dev/null +++ b/hack/update-gmp-api.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:---sync}" +if [[ $# -gt 1 || ( "$mode" != --sync && "$mode" != --verify ) ]]; then + echo 'usage: hack/update-gmp-api.sh [--sync|--verify]' >&2 + exit 2 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source_module="$repo_root/hack/gmp-api-source" +target="$repo_root/support/gmpmonitoring" +scratch_dir="$(mktemp -d)" +candidate_dir="" +trap 'rm -rf "$scratch_dir"; if [[ -n "$candidate_dir" ]]; then rm -rf "$candidate_dir"; fi' EXIT + +# Download in a disposable module so verification cannot update tracked pins. +cp "$source_module/go.mod" "$scratch_dir/go.mod" +if [[ -f "$source_module/go.sum" ]]; then + cp "$source_module/go.sum" "$scratch_dir/go.sum" +fi +(cd "$scratch_dir" && GO111MODULE=on GOWORK=off GOFLAGS= go mod edit -json) > "$scratch_dir/pin.json" +upstream_version="$(python3 - "$scratch_dir/pin.json" <<'PY' +import json, sys +with open(sys.argv[1]) as source: + requirements = json.load(source).get('Require', []) +versions = [item['Version'] for item in requirements if item['Path'] == 'github.com/GoogleCloudPlatform/prometheus-engine'] +if len(versions) != 1: + raise SystemExit('source module must pin exactly one prometheus-engine version') +print(versions[0]) +PY +)" +if ! (cd "$scratch_dir" && GO111MODULE=on GOWORK=off GOFLAGS= go mod download -json "github.com/GoogleCloudPlatform/prometheus-engine@$upstream_version") > "$scratch_dir/source.json"; then + cat "$scratch_dir/source.json" >&2 + exit 1 +fi +upstream_dir="$(python3 - "$scratch_dir/source.json" <<'PY' +import json, pathlib, sys +with open(sys.argv[1]) as source: + info = json.load(source) +directory = info.get('Dir', '') +if info.get('Error') or not directory or not pathlib.Path(directory).is_dir(): + raise SystemExit('failed to resolve the pinned GMP source module') +print(directory) +PY +)" +if ! cmp -s "$source_module/go.mod" "$scratch_dir/go.mod"; then + echo 'The source module changed during download; review its Go version before syncing.' >&2 + exit 1 +fi + +# A hidden package uses root vendoring without entering recursive package scans. +candidate_dir="$(mktemp -d "$repo_root/.gmp-api-gen.XXXXXX")" +cp "$target/doc.go" "$target/scheme.go" "$candidate_dir/" +cd "$repo_root" +GO111MODULE=on GOWORK=off GOFLAGS=-mod=vendor go run ./hack/gmp-api-gen \ + --upstream "$upstream_dir" --output "$candidate_dir" --source-info "$scratch_dir/source.json" +mkdir "$candidate_dir/testdata" +for resource in podmonitorings rules; do + cp "$upstream_dir/charts/operator/crds/monitoring.googleapis.com_$resource.yaml" "$candidate_dir/testdata/$resource.crd.yaml" +done +make "$repo_root/hack/tools/bin/controller-gen" +GO111MODULE=on GOWORK=off GOFLAGS=-mod=vendor hack/tools/bin/controller-gen \ + object:headerFile=hack/boilerplate.go.txt paths="./$(basename "$candidate_dir")" + +python3 - "$mode" "$target" "$candidate_dir" "$source_module" "$scratch_dir" <<'PY' +import pathlib, shutil, sys + +mode = sys.argv[1] +target, candidate, source_module, scratch = map(pathlib.Path, sys.argv[2:]) +outputs = { + 'types.go', 'zz_generated.deepcopy.go', 'upstream.sha256', + 'testdata/podmonitorings.crd.yaml', 'testdata/rules.crd.yaml', +} +existing = { + str(path.relative_to(target)) + for path in (target / 'testdata').rglob('*') if path.is_file() +} +for path in target.glob('*.go'): + if path.name.startswith('zz_generated.') or b'// Code generated ' in path.read_bytes()[:2048]: + existing.add(path.name) +unexpected = sorted(existing - outputs) +if unexpected: + raise SystemExit('Unexpected generated files; ask gcp-reviewers to review: ' + ', '.join(unexpected)) +missing = sorted(name for name in outputs if not (candidate / name).is_file()) +if missing: + raise SystemExit('Generation did not produce expected files: ' + ', '.join(missing)) + +pairs = [(candidate / name, target / name) for name in sorted(outputs)] +if not (scratch / 'go.sum').is_file(): + raise SystemExit('Pinned source download did not produce go.sum; cannot record module checksums.') +pairs.append((scratch / 'go.sum', source_module / 'go.sum')) +if mode == '--verify': + differences = [str(dst) for src, dst in pairs if not dst.is_file() or src.read_bytes() != dst.read_bytes()] + if differences: + raise SystemExit('GMP API files do not match the pinned source:\n' + '\n'.join(differences) + + '\nRun make gmp-sync, review the generated changes, and commit them.') + print('GMP API snapshot matches the pinned source.') +else: + for src, dst in pairs: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src, dst) + print('GMP API snapshot synchronized. Run make verify-gmp-sync and the focused tests.') +PY diff --git a/support/api/scheme.go b/support/api/scheme.go index 2dcdf29e17d7..3826fe80fde4 100644 --- a/support/api/scheme.go +++ b/support/api/scheme.go @@ -12,6 +12,7 @@ import ( hyperv1beta1 "github.com/openshift/hypershift/api/hypershift/v1beta1" hyperkarpenterv1 "github.com/openshift/hypershift/api/karpenter/v1" schedulingv1alpha1 "github.com/openshift/hypershift/api/scheduling/v1alpha1" + "github.com/openshift/hypershift/support/gmpmonitoring" "github.com/openshift/hypershift/support/rhobsmonitoring" configv1 "github.com/openshift/api/config/v1" @@ -91,7 +92,7 @@ var ( json.SerializerOptions{Yaml: true, Pretty: true, Strict: false}, ) // AllMonitoringYamlSerializer allows decoding monitoring resources - // from either the default coreos group version or the rhobs group version + // from the CoreOS, RHOBS and GMP group versions AllMonitoringYamlSerializer = json.NewSerializerWithOptions( json.DefaultMetaFactory, AllMonitoringScheme, AllMonitoringScheme, json.SerializerOptions{Yaml: true, Pretty: true, Strict: true}, @@ -146,6 +147,8 @@ func init() { } else { _ = prometheusoperatorv1.AddToScheme(scheme) } + // Type registration is additive; runtime resource selection is separate. + _ = gmpmonitoring.AddToScheme(scheme) _ = snapshotv1.AddToScheme(scheme) _ = mcfgv1.AddToScheme(scheme) _ = cdiv1beta1.AddToScheme(scheme) diff --git a/support/controlplane-component/controlplane-component.go b/support/controlplane-component/controlplane-component.go index 14f61f4a1cb2..935865d2a64d 100644 --- a/support/controlplane-component/controlplane-component.go +++ b/support/controlplane-component/controlplane-component.go @@ -74,6 +74,8 @@ type ControlPlaneContext struct { EnableCIDebugOutput bool // MetricsSet specifies which metrics to use in the service/pod-monitors. MetricsSet metrics.MetricsSet + // MonitoringAPIGroup selects which monitoring API group to emit monitors for. + MonitoringAPIGroup metrics.MonitoringAPIGroup // OmitOwnerReference determines whether the HCP OwnerReference should be omitted from resources deployed by this component. // This is useful when the component is not managed by the same HostedControlPlane controller like capi and the CPO itself. OmitOwnerReference bool @@ -110,6 +112,7 @@ type WorkloadContext struct { DefaultSecurityContextUID int64 EnableCIDebugOutput bool MetricsSet metrics.MetricsSet + MonitoringAPIGroup metrics.MonitoringAPIGroup // skip generation of certificates for unit tests SkipCertificateSigning bool @@ -127,6 +130,7 @@ func (cp *ControlPlaneContext) workloadContext() WorkloadContext { DefaultSecurityContextUID: cp.DefaultSecurityContextUID, EnableCIDebugOutput: cp.EnableCIDebugOutput, MetricsSet: cp.MetricsSet, + MonitoringAPIGroup: cp.MonitoringAPIGroup, ImageMetadataProvider: cp.ImageMetadataProvider, SkipCertificateSigning: cp.SkipCertificateSigning, } diff --git a/support/controlplane-component/monitoring_api_group_test.go b/support/controlplane-component/monitoring_api_group_test.go new file mode 100644 index 000000000000..92eafcafae1c --- /dev/null +++ b/support/controlplane-component/monitoring_api_group_test.go @@ -0,0 +1,27 @@ +package controlplanecomponent + +import ( + "testing" + + "github.com/openshift/hypershift/support/metrics" + + "github.com/stretchr/testify/require" +) + +func TestWorkloadContext(t *testing.T) { + for _, tc := range []struct { + name string + group metrics.MonitoringAPIGroup + }{ + {name: "When monitoring selection is empty, it should preserve the empty context value"}, + {name: "When CoreOS is selected, it should thread CoreOS to the workload", group: metrics.MonitoringAPIGroupCoreOS}, + {name: "When GMP is selected, it should thread GMP to the workload", group: metrics.MonitoringAPIGroupGMP}, + } { + t.Run(tc.name, func(t *testing.T) { + cp := &ControlPlaneContext{Context: t.Context(), MetricsSet: metrics.MetricsSetSRE, MonitoringAPIGroup: tc.group} + workload := cp.workloadContext() + require.Equal(t, tc.group, workload.MonitoringAPIGroup) + require.Equal(t, cp.MetricsSet, workload.MetricsSet) + }) + } +} diff --git a/support/gmpmonitoring/README.md b/support/gmpmonitoring/README.md new file mode 100644 index 000000000000..38c8a17cb720 --- /dev/null +++ b/support/gmpmonitoring/README.md @@ -0,0 +1,101 @@ +# GMP API data types + +This package is a maintained API-only extraction from +[`GoogleCloudPlatform/prometheus-engine`](https://github.com/GoogleCloudPlatform/prometheus-engine). +It contains `PodMonitoring`, `Rules`, their lists and the complete closure of +nested data types, including +status, authentication and TLS fields. Upstream license headers, field comments, +JSON tags and generation markers are preserved. The generated `upstream.sha256` +records the resolved source version, upstream commit, module checksums and input +file checksums. + +Collection, rule evaluation, admission, Secret resolution and Prometheus config +construction are excluded. The package imports only Kubernetes API machinery and +the already selected `prometheus/common/model` package. It does not import the +Google operator or require its Prometheus fork. Existing dependency versions stay +unchanged. `ClusterPodMonitoring`, `ClusterRules` and `GlobalRules` are deferred; +management rules spanning HCP namespaces require a separate rule-scope decision. + +`AddToScheme` registers the four supported kinds. HyperShift adds them to both +`Scheme` and `AllMonitoringScheme` regardless of process environment. Existing +CoreOS/RHOBS registration remains unchanged. Registration creates no resources, +clients or watches and does not select a backend at reconciliation time. + +## Maintenance + +The GCP HCP team owns this API snapshot, its extraction tooling and compatibility +validation. Changes to the package and tooling are reviewed by `gcp-reviewers` +through the normal HyperShift review process. + +The source version is selected in +[`hack/gmp-api-source/go.mod`](../../hack/gmp-api-source/go.mod). This is a +download-only module; do not run `go mod tidy` there. HyperShift does not import +the upstream operator package. Do not edit generated types, deepcopies or +checksums by hand. + +How candidate updates are detected remains an open maintenance decision. The +regeneration tooling does not monitor upstream releases or establish compatibility +with deployed GKE/GMP components. + +### Regenerate the current snapshot + +From the repository root, with Go, Python 3 and Make available: + +```sh +make gmp-sync +make verify-gmp-sync +``` + +Regenerating an unchanged source pin should produce no differences. The updater +downloads the selected module in isolation, extracts the supported data types, +copies the upstream license and CRD fixtures, records provenance and generates +deepcopies with the repository's pinned `controller-gen`. It does not change +HyperShift's root module or vendor directory. Ordinary `make generate` continues +to regenerate deepcopies offline. + +`make verify-gmp-sync` verifies the recorded inputs and regenerates into temporary +storage. It fails on missing, different or unexpected generated files without +rewriting tracked files. It is a manual, maintainer-run check and is not wired +into `make verify-ci` or the CI verify workflow; run it after any GMP snapshot, +tooling or dependency change. + +### Update to a newer upstream version + +1. Select a source version appropriate for the supported GKE/GMP environments and + review its relevant API/schema changes with the GCP HCP team. +2. Update the `prometheus-engine` requirement in `hack/gmp-api-source/go.mod`. +3. Run `make gmp-sync`. +4. Run `make verify-gmp-sync` and the focused tests: + + ```sh + GO111MODULE=on GOWORK=off GOFLAGS=-mod=vendor go test -race \ + ./hack/gmp-api-gen ./support/gmpmonitoring + ``` + +5. Review the generated diff and include compatibility evidence in the PR. + +Expected changes are limited to the source pin/checksums, generated provenance, +API types, deepcopies, license and schema fixtures. Unexpected runtime dependencies +or extraction failures require review by the owning team; do not work around them +by editing generated files. The extractor follows the type closure plus the +monitoring condition constant group only. If an added type requires another +constant, extend the generator deliberately. + +## Compatibility checks + +The copied source version does not need to numerically match the deployed GMP +image version. Compatibility depends on the fields HyperShift emits and the +schemas and behavior supported by the deployed components. Record the GKE version, +GMP operator image and validation performed when assessing compatibility. + +Tests cover scheme registration in fresh CoreOS/GMP/RHOBS processes, actual +HyperShift YAML/JSON serializer round trips (objects and lists, including status), +deepcopy isolation and emitted objects against pinned upstream v1 OpenAPI schemas +with negative controls. Schema fixtures are test input only; HyperShift does not +install these CRDs. + +Offline schema validation checks OpenAPI structure and constraints. It does not +execute Kubernetes CEL validation or Google's admission/runtime checks. The pinned +source is a serialization reference, not a claim that every deployed GKE cluster +uses that schema. Live CRD compatibility, scrape behavior and rule evaluation require +later validation against the selected GKE/GMP versions. diff --git a/support/gmpmonitoring/doc.go b/support/gmpmonitoring/doc.go new file mode 100644 index 000000000000..cca642030f63 --- /dev/null +++ b/support/gmpmonitoring/doc.go @@ -0,0 +1,8 @@ +// Package gmpmonitoring contains upstream-derived monitoring.googleapis.com/v1 +// API data types only. Collection, admission and rule evaluation belong to GMP. +// +// +kubebuilder:object:generate=true +// +groupName=monitoring.googleapis.com +package gmpmonitoring + +//go:generate ../../hack/tools/bin/controller-gen object:headerFile=../../hack/boilerplate.go.txt paths=. diff --git a/support/gmpmonitoring/scheme.go b/support/gmpmonitoring/scheme.go new file mode 100644 index 000000000000..ff41b19c8f63 --- /dev/null +++ b/support/gmpmonitoring/scheme.go @@ -0,0 +1,24 @@ +package gmpmonitoring + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// SchemeGroupVersion is the API version of the copied GMP types. +var SchemeGroupVersion = schema.GroupVersion{Group: "monitoring.googleapis.com", Version: "v1"} + +// SchemeBuilder registers only the supported monitoring data objects. +var SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + +// AddToScheme registers GMP types without installing CRDs or starting watches. +var AddToScheme = SchemeBuilder.AddToScheme + +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &PodMonitoring{}, &PodMonitoringList{}, &Rules{}, &RulesList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/support/gmpmonitoring/testdata/podmonitorings.crd.yaml b/support/gmpmonitoring/testdata/podmonitorings.crd.yaml new file mode 100644 index 000000000000..647441e075f6 --- /dev/null +++ b/support/gmpmonitoring/testdata/podmonitorings.crd.yaml @@ -0,0 +1,1131 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.1-0.20250103184936-50893dee96da + name: podmonitorings.monitoring.googleapis.com +spec: + group: monitoring.googleapis.com + names: + kind: PodMonitoring + listKind: PodMonitoringList + plural: podmonitorings + singular: podmonitoring + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: |- + PodMonitoring defines monitoring for a set of pods, scoped to pods + within the PodMonitoring's namespace. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + Specification of desired Pod selection for target discovery by + Prometheus. + properties: + endpoints: + description: The endpoints to scrape on the selected pods. + items: + description: ScrapeEndpoint specifies a Prometheus metrics endpoint + to scrape. + properties: + authorization: + description: Authorization is the HTTP authorization credentials + for the targets. + properties: + credentials: + description: Credentials uses the secret as the credentials + (token) for the authentication header. + properties: + secret: + description: Secret represents reference to a given + key from certain Secret in a given namespace. + properties: + key: + description: Key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: Name of the secret to select from. + type: string + namespace: + description: |- + Namespace of the secret to select from. + If empty the parent resource namespace will be chosen. + type: string + required: + - key + - name + type: object + type: object + type: + description: |- + Type is the authentication type. Defaults to Bearer. + Basic will cause an error, as the BasicAuth object should be used instead. + type: string + x-kubernetes-validations: + - message: authorization type cannot be set to "basic", + use "basic_auth" instead + rule: self != 'Basic' + type: object + basicAuth: + description: BasicAuth is the HTTP basic authentication credentials + for the targets. + properties: + password: + description: Password uses the secret as the BasicAuth password. + properties: + secret: + description: Secret represents reference to a given + key from certain Secret in a given namespace. + properties: + key: + description: Key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: Name of the secret to select from. + type: string + namespace: + description: |- + Namespace of the secret to select from. + If empty the parent resource namespace will be chosen. + type: string + required: + - key + - name + type: object + type: object + username: + description: Username is the BasicAuth username. + type: string + type: object + interval: + description: Interval at which to scrape metrics. Must be a + valid Prometheus duration. + format: duration + type: string + metricRelabeling: + description: |- + Relabeling rules for metrics scraped from this endpoint. Relabeling rules that + override protected target labels (project_id, location, cluster, namespace, job, + instance, top_level_controller, top_level_controller_type, or __address__) are + not permitted. The labelmap action is not permitted in general. + items: + description: RelabelingRule defines a single Prometheus relabeling + rule. + properties: + action: + description: Action to perform based on regex matching. + Defaults to 'replace'. + enum: + - replace + - lowercase + - uppercase + - keep + - drop + - keepequal + - dropequal + - hashmod + - labeldrop + - labelkeep + type: string + modulus: + description: Modulus to take of the hash of the source + label values. + format: int64 + type: integer + regex: + description: Regular expression against which the extracted + value is matched. Defaults to '(.*)'. + maxLength: 10000 + type: string + replacement: + description: |- + Replacement value against which a regex replace is performed if the + regular expression matches. Regex capture groups are available. Defaults to '$1'. + type: string + separator: + description: Separator placed between concatenated source + label values. Defaults to ';'. + type: string + sourceLabels: + description: |- + The source labels select values from existing labels. Their content is concatenated + using the configured separator and matched against the configured regular expression + for the replace, keep, and drop actions. + items: + pattern: ^[a-zA-Z_][a-zA-Z0-9_]*$ + type: string + maxItems: 100 + type: array + targetLabel: + description: |- + Label to which the resulting value is written in a replace action. + It is mandatory for replace actions. Regex capture groups are available. + pattern: ^[a-zA-Z_][a-zA-Z0-9_]*$ + type: string + x-kubernetes-validations: + - messageExpression: '''cannot relabel onto protected + label "%s"''.format([self])' + rule: self != 'project_id' && self != 'location' && + self != 'cluster' && self != 'namespace' && self != + 'job' && self != 'instance' && self != 'top_level_controller' + && self != 'top_level_controller_type' && self != + '__address__' + type: object + x-kubernetes-validations: + - rule: '!has(self.action) || self.action != ''labeldrop'' + || has(self.regex)' + maxItems: 250 + type: array + oauth2: + description: OAuth2 is the OAuth2 client credentials used to + fetch a token for the targets. + properties: + clientID: + description: ClientID is the public identifier for the client. + type: string + clientSecret: + description: ClientSecret uses the secret as the client + secret token. + properties: + secret: + description: Secret represents reference to a given + key from certain Secret in a given namespace. + properties: + key: + description: Key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: Name of the secret to select from. + type: string + namespace: + description: |- + Namespace of the secret to select from. + If empty the parent resource namespace will be chosen. + type: string + required: + - key + - name + type: object + type: object + endpointParams: + additionalProperties: + type: string + description: EndpointParams are additional parameters to + append to the token URL. + type: object + proxyUrl: + description: |- + ProxyURL is the HTTP proxy server to use to connect to the targets. + + Encoded passwords are not supported. + maxLength: 2000 + type: string + x-kubernetes-validations: + - rule: isURL(self) && !self.matches('@') + scopes: + description: Scopes represents the scopes for the token + request. + items: + type: string + type: array + tlsConfig: + description: TLS configures the token request's TLS settings. + properties: + ca: + description: |- + SecretSelector references a secret from a secret provider e.g. Kubernetes Secret. Only one + provider can be used at a time. + properties: + secret: + description: Secret represents reference to a given + key from certain Secret in a given namespace. + properties: + key: + description: Key of the secret to select from. + Must be a valid secret key. + type: string + name: + description: Name of the secret to select from. + type: string + namespace: + description: |- + Namespace of the secret to select from. + If empty the parent resource namespace will be chosen. + type: string + required: + - key + - name + type: object + type: object + cert: + description: Cert uses the secret as the certificate + for client authentication to the server. + properties: + secret: + description: Secret represents reference to a given + key from certain Secret in a given namespace. + properties: + key: + description: Key of the secret to select from. + Must be a valid secret key. + type: string + name: + description: Name of the secret to select from. + type: string + namespace: + description: |- + Namespace of the secret to select from. + If empty the parent resource namespace will be chosen. + type: string + required: + - key + - name + type: object + type: object + insecureSkipVerify: + description: InsecureSkipVerify disables target certificate + validation. + type: boolean + key: + description: Key uses the secret as the private key + for client authentication to the server. + properties: + secret: + description: Secret represents reference to a given + key from certain Secret in a given namespace. + properties: + key: + description: Key of the secret to select from. + Must be a valid secret key. + type: string + name: + description: Name of the secret to select from. + type: string + namespace: + description: |- + Namespace of the secret to select from. + If empty the parent resource namespace will be chosen. + type: string + required: + - key + - name + type: object + type: object + maxVersion: + description: |- + MaxVersion is the maximum TLS version. Accepted values: TLS10 (TLS 1.0), TLS11 (TLS 1.1), + TLS12 (TLS 1.2), TLS13 (TLS 1.3). + + If unset, Prometheus will use Go default minimum version, which is TLS 1.2. + See MinVersion in https://pkg.go.dev/crypto/tls#Config. + enum: + - TLS10 + - TLS11 + - TLS12 + - TLS13 + type: string + minVersion: + description: |- + MinVersion is the minimum TLS version. Accepted values: TLS10 (TLS 1.0), TLS11 (TLS 1.1), + TLS12 (TLS 1.2), TLS13 (TLS 1.3). + + If unset, Prometheus will use Go default minimum version, which is TLS 1.2. + See MinVersion in https://pkg.go.dev/crypto/tls#Config. + enum: + - TLS10 + - TLS11 + - TLS12 + - TLS13 + type: string + serverName: + description: ServerName is used to verify the hostname + for the targets. + type: string + type: object + x-kubernetes-validations: + - message: client cert and client key must be provided together, + when either is provided + rule: has(self.cert) == has(self.key) + tokenURL: + description: TokenURL is the URL to fetch the token from. + type: string + type: object + params: + additionalProperties: + items: + type: string + type: array + description: HTTP GET params to use when scraping. + type: object + path: + description: HTTP path to scrape metrics from. Defaults to "/metrics". + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Name or number of the port to scrape. + The container metadata label is only populated if the port is referenced by name + because port numbers are not unique across containers. + maxLength: 253 + minLength: 1 + x-kubernetes-int-or-string: true + x-kubernetes-validations: + - message: Port is required + rule: self != 0 + proxyUrl: + description: |- + ProxyURL is the HTTP proxy server to use to connect to the targets. + + Encoded passwords are not supported. + maxLength: 2000 + type: string + x-kubernetes-validations: + - rule: isURL(self) && !self.matches('@') + scheme: + description: Protocol scheme to use to scrape. + enum: + - http + - https + type: string + timeout: + description: |- + Timeout for metrics scrapes. Must be a valid Prometheus duration. + Must not be larger than the scrape interval. + format: duration + type: string + tls: + description: TLS configures the scrape request's TLS settings. + properties: + ca: + description: |- + SecretSelector references a secret from a secret provider e.g. Kubernetes Secret. Only one + provider can be used at a time. + properties: + secret: + description: Secret represents reference to a given + key from certain Secret in a given namespace. + properties: + key: + description: Key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: Name of the secret to select from. + type: string + namespace: + description: |- + Namespace of the secret to select from. + If empty the parent resource namespace will be chosen. + type: string + required: + - key + - name + type: object + type: object + cert: + description: Cert uses the secret as the certificate for + client authentication to the server. + properties: + secret: + description: Secret represents reference to a given + key from certain Secret in a given namespace. + properties: + key: + description: Key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: Name of the secret to select from. + type: string + namespace: + description: |- + Namespace of the secret to select from. + If empty the parent resource namespace will be chosen. + type: string + required: + - key + - name + type: object + type: object + insecureSkipVerify: + description: InsecureSkipVerify disables target certificate + validation. + type: boolean + key: + description: Key uses the secret as the private key for + client authentication to the server. + properties: + secret: + description: Secret represents reference to a given + key from certain Secret in a given namespace. + properties: + key: + description: Key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: Name of the secret to select from. + type: string + namespace: + description: |- + Namespace of the secret to select from. + If empty the parent resource namespace will be chosen. + type: string + required: + - key + - name + type: object + type: object + maxVersion: + description: |- + MaxVersion is the maximum TLS version. Accepted values: TLS10 (TLS 1.0), TLS11 (TLS 1.1), + TLS12 (TLS 1.2), TLS13 (TLS 1.3). + + If unset, Prometheus will use Go default minimum version, which is TLS 1.2. + See MinVersion in https://pkg.go.dev/crypto/tls#Config. + enum: + - TLS10 + - TLS11 + - TLS12 + - TLS13 + type: string + minVersion: + description: |- + MinVersion is the minimum TLS version. Accepted values: TLS10 (TLS 1.0), TLS11 (TLS 1.1), + TLS12 (TLS 1.2), TLS13 (TLS 1.3). + + If unset, Prometheus will use Go default minimum version, which is TLS 1.2. + See MinVersion in https://pkg.go.dev/crypto/tls#Config. + enum: + - TLS10 + - TLS11 + - TLS12 + - TLS13 + type: string + serverName: + description: ServerName is used to verify the hostname for + the targets. + type: string + type: object + x-kubernetes-validations: + - message: client cert and client key must be provided together, + when either is provided + rule: has(self.cert) == has(self.key) + required: + - interval + - port + type: object + x-kubernetes-validations: + - messageExpression: '''scrape timeout (%s) must not be greater + than scrape interval (%s)''.format([self.timeout, self.interval])' + rule: '!has(self.timeout) || self.timeout <= self.interval' + - rule: '((has(self.authorization) ? 1 : 0) + (has(self.basicAuth) + ? 1 : 0) + (has(self.oauth2) ? 1 : 0)) <= 1' + maxItems: 10 + minItems: 1 + type: array + filterRunning: + description: |- + FilterRunning will drop any pods that are in the "Failed" or "Succeeded" + pod lifecycle. + See: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase + type: boolean + limits: + description: Limits to apply at scrape time. + properties: + labelNameLength: + description: |- + Maximum label name length. + Uses Prometheus default if left unspecified. + format: int64 + type: integer + labelValueLength: + description: |- + Maximum label value length. + Uses Prometheus default if left unspecified. + format: int64 + type: integer + labels: + description: |- + Maximum number of labels accepted for a single sample. + Uses Prometheus default if left unspecified. + format: int64 + type: integer + samples: + description: |- + Maximum number of samples accepted within a single scrape. + Uses Prometheus default if left unspecified. + format: int64 + type: integer + type: object + selector: + description: |- + Label selector that specifies which pods are selected for this monitoring + configuration. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + targetLabels: + description: |- + Labels to add to the Prometheus target for discovered endpoints. + The `instance` label is always set to `:` or `:` + if the scraped pod is controlled by a DaemonSet. + properties: + fromPod: + description: |- + Labels to transfer from the Kubernetes Pod to Prometheus target labels. + Mappings are applied in order. + items: + description: |- + LabelMapping specifies how to transfer a label from a Kubernetes resource + onto a Prometheus target. + properties: + from: + description: Kubernetes resource label to remap. + type: string + to: + description: |- + Remapped Prometheus target label. + Defaults to the same name as `From`. + pattern: ^[a-zA-Z_][a-zA-Z0-9_]*$ + type: string + x-kubernetes-validations: + - messageExpression: '''cannot relabel onto protected label + "%s"''.format([self])' + rule: self != 'project_id' && self != 'location' && self + != 'cluster' && self != 'namespace' && self != 'job' + && self != 'instance' && self != 'top_level_controller' + && self != 'top_level_controller_type' && self != '__address__' + required: + - from + type: object + maxItems: 100 + type: array + metadata: + default: + - container + - pod + - top_level_controller_name + - top_level_controller_type + description: |- + Pod metadata labels that are set on all scraped targets. + Permitted keys are `container`, `node`, `pod`, `top_level_controller_name`, + and `top_level_controller_type`. The `container` + label is only populated if the scrape port is referenced by name. + Defaults to [container, pod, top_level_controller_name, top_level_controller_type]. + If set to null, it will be interpreted as the empty list. + This is for backwards-compatibility only. + items: + enum: + - container + - node + - pod + - top_level_controller_name + - top_level_controller_type + type: string + type: array + x-kubernetes-list-type: set + type: object + required: + - endpoints + - selector + type: object + status: + description: Most recently observed status of the resource. + properties: + conditions: + description: Represents the latest available observations of a podmonitor's + current state. + items: + description: MonitoringCondition describes the condition of a PodMonitoring. + properties: + lastTransitionTime: + description: Last time the condition transitioned from one status + to another. + format: date-time + type: string + lastUpdateTime: + description: The last time this condition was updated. + format: date-time + type: string + message: + description: A human-readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: MonitoringConditionType is the type of MonitoringCondition. + type: string + required: + - status + - type + type: object + type: array + endpointStatuses: + description: Represents the latest available observations of target + state for each ScrapeEndpoint. + items: + properties: + activeTargets: + description: Total number of active targets. + format: int64 + type: integer + collectorsFraction: + description: |- + Fraction of collectors included in status, bounded [0,1]. + Ideally, this should always be 1. Anything less can + be considered a problem and should be investigated. + type: string + lastUpdateTime: + description: Last time this status was updated. + format: date-time + type: string + name: + description: The name of the ScrapeEndpoint. + type: string + sampleGroups: + description: A fixed sample of targets grouped by error type. + items: + properties: + count: + description: Total count of similar errors. + format: int32 + type: integer + sampleTargets: + description: Targets emitting the error message. + items: + properties: + health: + description: Health status. + type: string + labels: + additionalProperties: + description: A LabelValue is an associated value + for a LabelName. + type: string + description: The label set, keys and values, of + the target. + type: object + lastError: + description: Error message. + type: string + lastScrapeDurationSeconds: + description: Scrape duration in seconds. + type: string + type: object + type: array + type: object + type: array + unhealthyTargets: + description: Total number of active, unhealthy targets. + format: int64 + type: integer + required: + - name + type: object + type: array + observedGeneration: + description: The generation observed by the controller. + format: int64 + type: integer + type: object + required: + - spec + type: object + x-kubernetes-validations: + - message: Namespace not allowed on PodMonitoring secret references. + reason: FieldValueForbidden + rule: self.spec.endpoints.all(e, !has(e.authorization) || !has(e.authorization.credentials) + || !has(e.authorization.credentials.secret) || !has(e.authorization.credentials.secret.__namespace__)) + - message: Namespace not allowed on PodMonitoring secret references. + reason: FieldValueForbidden + rule: self.spec.endpoints.all(e, !has(e.basicAuth) || !has(e.basicAuth.password) + || !has(e.basicAuth.password.secret) || !has(e.basicAuth.password.secret.__namespace__)) + - message: Namespace not allowed on PodMonitoring secret references. + reason: FieldValueForbidden + rule: self.spec.endpoints.all(e, !has(e.tls) || !has(e.tls.ca) || !has(e.tls.ca.secret) + || !has(e.tls.ca.secret.__namespace__)) + - message: Namespace not allowed on PodMonitoring secret references. + reason: FieldValueForbidden + rule: self.spec.endpoints.all(e, !has(e.tls) || !has(e.tls.cert) || !has(e.tls.cert.secret) + || !has(e.tls.cert.secret.__namespace__)) + - message: Namespace not allowed on PodMonitoring secret references. + reason: FieldValueForbidden + rule: self.spec.endpoints.all(e, !has(e.tls) || !has(e.tls.key) || !has(e.tls.key.secret) + || !has(e.tls.key.secret.__namespace__)) + - message: Namespace not allowed on PodMonitoring secret references. + reason: FieldValueForbidden + rule: self.spec.endpoints.all(e, !has(e.oauth2) || !has(e.oauth2.clientSecret) + || !has(e.oauth2.clientSecret.secret) || !has(e.oauth2.clientSecret.secret.__namespace__)) + served: true + storage: true + subresources: + status: {} + - deprecated: true + name: v1alpha1 + schema: + openAPIV3Schema: + description: PodMonitoring defines monitoring for a set of pods. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + Specification of desired Pod selection for target discovery by + Prometheus. + properties: + endpoints: + description: The endpoints to scrape on the selected pods. + items: + description: ScrapeEndpoint specifies a Prometheus metrics endpoint + to scrape. + properties: + interval: + description: Interval at which to scrape metrics. Must be a + valid Prometheus duration. + type: string + metricRelabeling: + description: |- + Relabeling rules for metrics scraped from this endpoint. Relabeling rules that + override protected target labels (project_id, location, cluster, namespace, job, + instance, or __address__) are not permitted. The labelmap action is not permitted + in general. + items: + description: RelabelingRule defines a single Prometheus relabeling + rule. + properties: + action: + description: Action to perform based on regex matching. + Defaults to 'replace'. + type: string + modulus: + description: Modulus to take of the hash of the source + label values. + format: int64 + type: integer + regex: + description: Regular expression against which the extracted + value is matched. Defaults to '(.*)'. + type: string + replacement: + description: |- + Replacement value against which a regex replace is performed if the + regular expression matches. Regex capture groups are available. Defaults to '$1'. + type: string + separator: + description: Separator placed between concatenated source + label values. Defaults to ';'. + type: string + sourceLabels: + description: |- + The source labels select values from existing labels. Their content is concatenated + using the configured separator and matched against the configured regular expression + for the replace, keep, and drop actions. + items: + type: string + type: array + targetLabel: + description: |- + Label to which the resulting value is written in a replace action. + It is mandatory for replace actions. Regex capture groups are available. + type: string + type: object + type: array + params: + additionalProperties: + items: + type: string + type: array + description: HTTP GET params to use when scraping. + type: object + path: + description: HTTP path to scrape metrics from. Defaults to "/metrics". + type: string + port: + anyOf: + - type: integer + - type: string + description: Name or number of the port to scrape. + x-kubernetes-int-or-string: true + proxyUrl: + description: Proxy URL to scrape through. Encoded passwords + are not supported. + type: string + scheme: + description: Protocol scheme to use to scrape. + type: string + timeout: + description: |- + Timeout for metrics scrapes. Must be a valid Prometheus duration. + Must not be larger then the scrape interval. + type: string + required: + - port + type: object + type: array + limits: + description: Limits to apply at scrape time. + properties: + labelNameLength: + description: |- + Maximum label name length. + Uses Prometheus default if left unspecified. + format: int64 + type: integer + labelValueLength: + description: |- + Maximum label value length. + Uses Prometheus default if left unspecified. + format: int64 + type: integer + labels: + description: |- + Maximum number of labels accepted for a single sample. + Uses Prometheus default if left unspecified. + format: int64 + type: integer + samples: + description: |- + Maximum number of samples accepted within a single scrape. + Uses Prometheus default if left unspecified. + format: int64 + type: integer + type: object + selector: + description: |- + Label selector that specifies which pods are selected for this monitoring + configuration. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + targetLabels: + description: Labels to add to the Prometheus target for discovered + endpoints. + properties: + fromPod: + description: |- + Labels to transfer from the Kubernetes Pod to Prometheus target labels. + Mappings are applied in order. + items: + description: |- + LabelMapping specifies how to transfer a label from a Kubernetes resource + onto a Prometheus target. + properties: + from: + description: Kubenetes resource label to remap. + type: string + to: + description: |- + Remapped Prometheus target label. + Defaults to the same name as `From`. + type: string + required: + - from + type: object + type: array + metadata: + description: |- + Pod metadata labels that are set on all scraped targets. + Permitted keys are `pod`, `container`, and `node` for PodMonitoring and + `pod`, `container`, `node`, and `namespace` for ClusterPodMonitoring. + Defaults to [pod, container] for PodMonitoring and [namespace, pod, container] + for ClusterPodMonitoring. + If set to null, it will be interpreted as the empty list for PodMonitoring + and to [namespace] for ClusterPodMonitoring. This is for backwards-compatibility + only. + items: + type: string + type: array + type: object + required: + - endpoints + - selector + type: object + status: + description: Most recently observed status of the resource. + properties: + conditions: + description: Represents the latest available observations of a podmonitor's + current state. + items: + description: MonitoringCondition describes a condition of a PodMonitoring. + properties: + lastTransitionTime: + description: Last time the condition transitioned from one status + to another. + format: date-time + type: string + lastUpdateTime: + description: The last time this condition was updated. + format: date-time + type: string + message: + description: A human-readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: MonitoringConditionType is the type of MonitoringCondition. + type: string + required: + - status + - type + type: object + type: array + observedGeneration: + description: The generation observed by the controller. + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: false + subresources: + status: {} diff --git a/support/gmpmonitoring/testdata/rules.crd.yaml b/support/gmpmonitoring/testdata/rules.crd.yaml new file mode 100644 index 000000000000..f17697ed140c --- /dev/null +++ b/support/gmpmonitoring/testdata/rules.crd.yaml @@ -0,0 +1,289 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.1-0.20250103184936-50893dee96da + name: rules.monitoring.googleapis.com +spec: + group: monitoring.googleapis.com + names: + kind: Rules + listKind: RulesList + plural: rules + singular: rules + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: |- + Rules defines Prometheus alerting and recording rules that are scoped + to the namespace of the resource. Only metric data from this namespace is processed + and all rule results have their project_id, cluster, and namespace label preserved + for query processing. + If the location label is not preserved by the rule, it defaults to the cluster's location. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Specification of rules to record and alert on. + properties: + groups: + description: A list of Prometheus rule groups. + items: + description: |- + RuleGroup declares rules in the Prometheus format: + https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/ + properties: + interval: + default: 1m + description: The interval at which to evaluate the rules. Must + be a valid Prometheus duration. + format: duration + type: string + name: + description: The name of the rule group. + type: string + rules: + description: A list of rules that are executed sequentially + as part of this group. + items: + description: |- + Rule is a single rule in the Prometheus format: + https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/ + properties: + alert: + description: |- + Name of the alert to evaluate the expression as. + Only one of `record` and `alert` must be set. + type: string + annotations: + additionalProperties: + type: string + description: |- + A set of annotations to attach to alerts produced by the query expression. + Only valid if `alert` is set. + type: object + expr: + description: The PromQL expression to evaluate. + type: string + for: + description: |- + The duration to wait before a firing alert produced by this rule is sent to Alertmanager. + Only valid if `alert` is set. + format: duration + type: string + labels: + additionalProperties: + type: string + description: A set of labels to attach to the result of + the query expression. + type: object + record: + description: |- + Record the result of the expression to this metric name. + Only one of `record` and `alert` must be set. + pattern: ^[a-zA-Z_:][a-zA-Z0-9_:]*$ + type: string + required: + - expr + type: object + x-kubernetes-validations: + - message: Must set exactly one of Record or Alert + rule: '(has(self.record) ? 1 : 0) + (has(self.alert) ? 1 + : 0) == 1' + - message: Annotations are only allowed for alerting rules + rule: '!has(self.annotations) || has(self.alert)' + minItems: 1 + type: array + required: + - name + - rules + type: object + type: array + required: + - groups + type: object + status: + description: Most recently observed status of the resource. + properties: + conditions: + description: Represents the latest available observations of a podmonitor's + current state. + items: + description: MonitoringCondition describes the condition of a PodMonitoring. + properties: + lastTransitionTime: + description: Last time the condition transitioned from one status + to another. + format: date-time + type: string + lastUpdateTime: + description: The last time this condition was updated. + format: date-time + type: string + message: + description: A human-readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: MonitoringConditionType is the type of MonitoringCondition. + type: string + required: + - status + - type + type: object + type: array + observedGeneration: + description: The generation observed by the controller. + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} + - deprecated: true + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + Rules defines Prometheus alerting and recording rules that are scoped + to the namespace of the resource. Only metric data from this namespace is processed + and all rule results have their project_id, cluster, and namespace label preserved + for query processing. + If the location label is not preserved by the rule, it defaults to the cluster's location. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Specification of rules to record and alert on. + properties: + groups: + description: A list of Prometheus rule groups. + items: + description: |- + RuleGroup declares rules in the Prometheus format: + https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/ + properties: + interval: + description: The interval at which to evaluate the rules. Must + be a valid Prometheus duration. + type: string + name: + description: The name of the rule group. + type: string + rules: + description: A list of rules that are executed sequentially + as part of this group. + items: + description: |- + Rule is a single rule in the Prometheus format: + https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/ + properties: + alert: + description: |- + Name of the alert to evaluate the expression as. + Only one of `record` and `alert` must be set. + type: string + annotations: + additionalProperties: + type: string + description: |- + A set of annotations to attach to alerts produced by the query expression. + Only valid if `alert` is set. + type: object + expr: + description: The PromQL expression to evaluate. + type: string + for: + description: |- + The duration to wait before a firing alert produced by this rule is sent to Alertmanager. + Only valid if `alert` is set. + type: string + labels: + additionalProperties: + type: string + description: A set of labels to attach to the result of + the query expression. + type: object + record: + description: |- + Record the result of the expression to this metric name. + Only one of `record` and `alert` must be set. + type: string + required: + - expr + type: object + type: array + required: + - interval + - name + - rules + type: object + type: array + required: + - groups + type: object + status: + description: Most recently observed status of the resource. + type: object + required: + - spec + type: object + served: true + storage: false + subresources: + status: {} diff --git a/support/gmpmonitoring/types.go b/support/gmpmonitoring/types.go new file mode 100644 index 000000000000..7ac65931bc04 --- /dev/null +++ b/support/gmpmonitoring/types.go @@ -0,0 +1,487 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by hack/update-gmp-api.sh; DO NOT EDIT. + +package gmpmonitoring + +import ( + prommodel "github.com/prometheus/common/model" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + intstr "k8s.io/apimachinery/pkg/util/intstr" +) + +const ( + // ConfigurationCreateSuccess indicates that the config generated from the + // monitoring resource was created successfully. + ConfigurationCreateSuccess MonitoringConditionType = "ConfigurationCreateSuccess" +) + +// Auth sets the `Authorization` header on every HTTP request. +type Auth struct { + // Type is the authentication type. Defaults to Bearer. + // Basic will cause an error, as the BasicAuth object should be used instead. + // +kubebuilder:validation:XValidation:rule="self != 'Basic'",message="authorization type cannot be set to \"basic\", use \"basic_auth\" instead" + // +optional + Type string `json:"type,omitempty"` + // Credentials uses the secret as the credentials (token) for the authentication header. + // +optional + Credentials *SecretSelector `json:"credentials,omitempty"` +} + +// BasicAuth sets the `Authorization` header on every HTTP request with the configured username +// and password. +type BasicAuth struct { + // Username is the BasicAuth username. + // +optional + Username string `json:"username,omitempty"` + // Password uses the secret as the BasicAuth password. + // +optional + Password *SecretSelector `json:"password,omitempty"` +} + +// HTTPClientConfig stores HTTP-client configurations. +// +kubebuilder:validation:XValidation:rule="((has(self.authorization) ? 1 : 0) + (has(self.basicAuth) ? 1 : 0) + (has(self.oauth2) ? 1 : 0)) <= 1" +type HTTPClientConfig struct { + // Authorization is the HTTP authorization credentials for the targets. + // +optional + Authorization *Auth `json:"authorization,omitempty"` + // BasicAuth is the HTTP basic authentication credentials for the targets. + // +optional + BasicAuth *BasicAuth `json:"basicAuth,omitempty"` + // TLS configures the scrape request's TLS settings. + // +optional + TLS *TLS `json:"tls,omitempty"` + // OAuth2 is the OAuth2 client credentials used to fetch a token for the targets. + // +optional + OAuth2 *OAuth2 `json:"oauth2,omitempty"` + ProxyConfig `json:",inline"` +} + +// LabelMapping specifies how to transfer a label from a Kubernetes resource +// onto a Prometheus target. +type LabelMapping struct { + // Kubernetes resource label to remap. + From string `json:"from"` + // Remapped Prometheus target label. + // Defaults to the same name as `From`. + // +kubebuilder:validation:Pattern=^[a-zA-Z_][a-zA-Z0-9_]*$ + // +kubebuilder:validation:MaxLength:100 + // +kubebuilder:validation:XValidation:rule="self != 'project_id' && self != 'location' && self != 'cluster' && self != 'namespace' && self != 'job' && self != 'instance' && self != 'top_level_controller' && self != 'top_level_controller_type' && self != '__address__'",messageExpression="'cannot relabel onto protected label \"%s\"'.format([self])" + To string `json:"to,omitempty"` +} + +// MonitoringCondition describes the condition of a PodMonitoring. +type MonitoringCondition struct { + Type MonitoringConditionType `json:"type"` + // Status of the condition, one of True, False, Unknown. + Status corev1.ConditionStatus `json:"status"` + // The last time this condition was updated. + // +optional + LastUpdateTime metav1.Time `json:"lastUpdateTime,omitempty"` + // Last time the condition transitioned from one status to another. + // +optional + LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"` + // The reason for the condition's last transition. + // +optional + Reason string `json:"reason,omitempty"` + // A human-readable message indicating details about the transition. + // +optional + Message string `json:"message,omitempty"` +} + +// MonitoringConditionType is the type of MonitoringCondition. +type MonitoringConditionType string + +// MonitoringStatus holds status information of a monitoring resource. +type MonitoringStatus struct { + // The generation observed by the controller. + // +optional + ObservedGeneration int64 `json:"observedGeneration"` + // Represents the latest available observations of a podmonitor's current state. + Conditions []MonitoringCondition `json:"conditions,omitempty"` +} + +// OAuth2 is the OAuth2 client configuration. +type OAuth2 struct { + // ClientID is the public identifier for the client. + // +optional + ClientID string `json:"clientID"` + // ClientSecret uses the secret as the client secret token. + // +optional + ClientSecret *SecretSelector `json:"clientSecret,omitempty"` + // Scopes represents the scopes for the token request. + // +optional + Scopes []string `json:"scopes,omitempty"` + // TokenURL is the URL to fetch the token from. + // +optional + TokenURL string `json:"tokenURL"` + // EndpointParams are additional parameters to append to the token URL. + // +optional + EndpointParams map[string]string `json:"endpointParams,omitempty"` + // TLS configures the token request's TLS settings. + // +optional + TLS *TLS `json:"tlsConfig,omitempty"` + ProxyConfig `json:",inline"` +} + +// PodMonitoring defines monitoring for a set of pods, scoped to pods +// within the PodMonitoring's namespace. +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +// +kubebuilder:validation:XValidation:rule="self.spec.endpoints.all(e, !has(e.authorization) || !has(e.authorization.credentials) || !has(e.authorization.credentials.secret) || !has(e.authorization.credentials.secret.__namespace__))",message="Namespace not allowed on PodMonitoring secret references.",reason="FieldValueForbidden" +// +kubebuilder:validation:XValidation:rule="self.spec.endpoints.all(e, !has(e.basicAuth) || !has(e.basicAuth.password) || !has(e.basicAuth.password.secret) || !has(e.basicAuth.password.secret.__namespace__))",message="Namespace not allowed on PodMonitoring secret references.",reason="FieldValueForbidden" +// +kubebuilder:validation:XValidation:rule="self.spec.endpoints.all(e, !has(e.tls) || !has(e.tls.ca) || !has(e.tls.ca.secret) || !has(e.tls.ca.secret.__namespace__))",message="Namespace not allowed on PodMonitoring secret references.",reason="FieldValueForbidden" +// +kubebuilder:validation:XValidation:rule="self.spec.endpoints.all(e, !has(e.tls) || !has(e.tls.cert) || !has(e.tls.cert.secret) || !has(e.tls.cert.secret.__namespace__))",message="Namespace not allowed on PodMonitoring secret references.",reason="FieldValueForbidden" +// +kubebuilder:validation:XValidation:rule="self.spec.endpoints.all(e, !has(e.tls) || !has(e.tls.key) || !has(e.tls.key.secret) || !has(e.tls.key.secret.__namespace__))",message="Namespace not allowed on PodMonitoring secret references.",reason="FieldValueForbidden" +// +kubebuilder:validation:XValidation:rule="self.spec.endpoints.all(e, !has(e.oauth2) || !has(e.oauth2.clientSecret) || !has(e.oauth2.clientSecret.secret) || !has(e.oauth2.clientSecret.secret.__namespace__))",message="Namespace not allowed on PodMonitoring secret references.",reason="FieldValueForbidden" +type PodMonitoring struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + // Specification of desired Pod selection for target discovery by + // Prometheus. + Spec PodMonitoringSpec `json:"spec"` + // Most recently observed status of the resource. + // +optional + Status PodMonitoringStatus `json:"status"` +} + +// PodMonitoringList is a list of PodMonitorings. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type PodMonitoringList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []PodMonitoring `json:"items"` +} + +// PodMonitoringSpec contains specification parameters for PodMonitoring. +type PodMonitoringSpec struct { + // Label selector that specifies which pods are selected for this monitoring + // configuration. + Selector metav1.LabelSelector `json:"selector"` + // The endpoints to scrape on the selected pods. + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=10 + Endpoints []ScrapeEndpoint `json:"endpoints"` + // Labels to add to the Prometheus target for discovered endpoints. + // The `instance` label is always set to `:` or `:` + // if the scraped pod is controlled by a DaemonSet. + TargetLabels TargetLabels `json:"targetLabels,omitempty"` + // Limits to apply at scrape time. + Limits *ScrapeLimits `json:"limits,omitempty"` + // FilterRunning will drop any pods that are in the "Failed" or "Succeeded" + // pod lifecycle. + // See: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase + FilterRunning *bool `json:"filterRunning,omitempty"` +} + +// PodMonitoringStatus holds status information of a PodMonitoring resource. +type PodMonitoringStatus struct { + MonitoringStatus `json:",inline"` + // Represents the latest available observations of target state for each ScrapeEndpoint. + EndpointStatuses []ScrapeEndpointStatus `json:"endpointStatuses,omitempty"` +} + +// ProxyConfig specifies the proxy HTTP configuration. +type ProxyConfig struct { + // ProxyURL is the HTTP proxy server to use to connect to the targets. + // + // Encoded passwords are not supported. + // +optional + // +kubebuilder:validation:MaxLength=2000 + // +kubebuilder:validation:XValidation:rule="isURL(self) && !self.matches('@')" + ProxyURL string `json:"proxyUrl,omitempty"` + + // TODO(TheSpiritXIII): Consider adding further fields for Proxy configuration, similar to https://prometheus.io/docs/prometheus/latest/configuration/configuration/#oauth2 +} + +// RelabelingRule defines a single Prometheus relabeling rule. +// +kubebuilder:validation:XValidation:rule="!has(self.action) || self.action != 'labeldrop' || has(self.regex)" +type RelabelingRule struct { + // The source labels select values from existing labels. Their content is concatenated + // using the configured separator and matched against the configured regular expression + // for the replace, keep, and drop actions. + // +kubebuilder:validation:MaxItems=100 + // +kubebuilder:validation:items:Pattern=^[a-zA-Z_][a-zA-Z0-9_]*$ + SourceLabels []string `json:"sourceLabels,omitempty"` + // Separator placed between concatenated source label values. Defaults to ';'. + Separator string `json:"separator,omitempty"` + // Label to which the resulting value is written in a replace action. + // It is mandatory for replace actions. Regex capture groups are available. + // +kubebuilder:validation:Pattern=^[a-zA-Z_][a-zA-Z0-9_]*$ + // +kubebuilder:validation:MaxLength:100 + // +kubebuilder:validation:XValidation:rule="self != 'project_id' && self != 'location' && self != 'cluster' && self != 'namespace' && self != 'job' && self != 'instance' && self != 'top_level_controller' && self != 'top_level_controller_type' && self != '__address__'",messageExpression="'cannot relabel onto protected label \"%s\"'.format([self])" + TargetLabel string `json:"targetLabel,omitempty"` + // Regular expression against which the extracted value is matched. Defaults to '(.*)'. + // +kubebuilder:validation:MaxLength=10000 + Regex string `json:"regex,omitempty"` + // Modulus to take of the hash of the source label values. + Modulus uint64 `json:"modulus,omitempty"` + // Replacement value against which a regex replace is performed if the + // regular expression matches. Regex capture groups are available. Defaults to '$1'. + Replacement string `json:"replacement,omitempty"` + // Action to perform based on regex matching. Defaults to 'replace'. + // +kubebuilder:validation:Enum=replace;lowercase;uppercase;keep;drop;keepequal;dropequal;hashmod;labeldrop;labelkeep + Action string `json:"action,omitempty"` +} + +// Rule is a single rule in the Prometheus format: +// https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/ +// +kubebuilder:validation:XValidation:rule="(has(self.record) ? 1 : 0) + (has(self.alert) ? 1 : 0) == 1",message="Must set exactly one of Record or Alert" +// +kubebuilder:validation:XValidation:rule="!has(self.annotations) || has(self.alert)",message="Annotations are only allowed for alerting rules" +type Rule struct { + // Record the result of the expression to this metric name. + // Only one of `record` and `alert` must be set. + // +kubebuilder:validation:Pattern="^[a-zA-Z_:][a-zA-Z0-9_:]*$" + Record string `json:"record,omitempty"` + // Name of the alert to evaluate the expression as. + // Only one of `record` and `alert` must be set. + Alert string `json:"alert,omitempty"` + // The PromQL expression to evaluate. + Expr string `json:"expr"` + // The duration to wait before a firing alert produced by this rule is sent to Alertmanager. + // Only valid if `alert` is set. + // +kubebuilder:validation:Format=duration + For string `json:"for,omitempty"` + // A set of labels to attach to the result of the query expression. + Labels map[string]string `json:"labels,omitempty"` + // A set of annotations to attach to alerts produced by the query expression. + // Only valid if `alert` is set. + Annotations map[string]string `json:"annotations,omitempty"` +} + +// RuleGroup declares rules in the Prometheus format: +// https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/ +type RuleGroup struct { + // The name of the rule group. + Name string `json:"name"` + // The interval at which to evaluate the rules. Must be a valid Prometheus duration. + // +kubebuilder:validation:Format=duration + // +kubebuilder:default="1m" + Interval string `json:"interval,omitempty"` + // A list of rules that are executed sequentially as part of this group. + // +kubebuilder:validation:MinItems=1 + Rules []Rule `json:"rules"` +} + +// Rules defines Prometheus alerting and recording rules that are scoped +// to the namespace of the resource. Only metric data from this namespace is processed +// and all rule results have their project_id, cluster, and namespace label preserved +// for query processing. +// If the location label is not preserved by the rule, it defaults to the cluster's location. +// +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +type Rules struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + // Specification of rules to record and alert on. + Spec RulesSpec `json:"spec"` + // Most recently observed status of the resource. + // +optional + Status RulesStatus `json:"status"` +} + +// RulesList is a list of Rules. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type RulesList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Rules `json:"items"` +} + +// RulesSpec contains specification parameters for a Rules resource. +type RulesSpec struct { + // A list of Prometheus rule groups. + Groups []RuleGroup `json:"groups"` +} + +// RulesStatus contains status information for a Rules resource. +type RulesStatus struct { + MonitoringStatus `json:",inline"` +} + +type SampleGroup struct { + // Targets emitting the error message. + SampleTargets []SampleTarget `json:"sampleTargets,omitempty"` + // Total count of similar errors. + // +optional + Count *int32 `json:"count,omitempty"` +} + +type SampleTarget struct { + // The label set, keys and values, of the target. + Labels prommodel.LabelSet `json:"labels,omitempty"` + // Error message. + LastError *string `json:"lastError,omitempty"` + // Scrape duration in seconds. + LastScrapeDurationSeconds string `json:"lastScrapeDurationSeconds,omitempty"` + // Health status. + Health string `json:"health,omitempty"` +} + +// ScrapeEndpoint specifies a Prometheus metrics endpoint to scrape. +// +kubebuilder:validation:XValidation:rule="!has(self.timeout) || self.timeout <= self.interval",messageExpression="'scrape timeout (%s) must not be greater than scrape interval (%s)'.format([self.timeout, self.interval])" +type ScrapeEndpoint struct { + // Name or number of the port to scrape. + // The container metadata label is only populated if the port is referenced by name + // because port numbers are not unique across containers. + // +kubebuilder:validation:XIntOrString + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:XValidation:rule="self != 0",message="Port is required" + // +required + Port intstr.IntOrString `json:"port,omitempty"` + // Protocol scheme to use to scrape. + // +kubebuilder:validation:Enum=http;https + Scheme string `json:"scheme,omitempty"` + // HTTP path to scrape metrics from. Defaults to "/metrics". + Path string `json:"path,omitempty"` + // HTTP GET params to use when scraping. + Params map[string][]string `json:"params,omitempty"` + // Interval at which to scrape metrics. Must be a valid Prometheus duration. + // +kubebuilder:validation:Format=duration + // +required + Interval string `json:"interval"` + // Timeout for metrics scrapes. Must be a valid Prometheus duration. + // Must not be larger than the scrape interval. + // +kubebuilder:validation:Format=duration + Timeout string `json:"timeout,omitempty"` + // Relabeling rules for metrics scraped from this endpoint. Relabeling rules that + // override protected target labels (project_id, location, cluster, namespace, job, + // instance, top_level_controller, top_level_controller_type, or __address__) are + // not permitted. The labelmap action is not permitted in general. + // +kubebuilder:validation:MaxItems=250 + MetricRelabeling []RelabelingRule `json:"metricRelabeling,omitempty"` + // Prometheus HTTP client configuration. + HTTPClientConfig `json:",inline"` +} + +type ScrapeEndpointStatus struct { + // The name of the ScrapeEndpoint. + Name string `json:"name"` + // Total number of active targets. + ActiveTargets int64 `json:"activeTargets,omitempty"` + // Total number of active, unhealthy targets. + UnhealthyTargets int64 `json:"unhealthyTargets,omitempty"` + // Last time this status was updated. + LastUpdateTime metav1.Time `json:"lastUpdateTime,omitempty"` + // A fixed sample of targets grouped by error type. + SampleGroups []SampleGroup `json:"sampleGroups,omitempty"` + // Fraction of collectors included in status, bounded [0,1]. + // Ideally, this should always be 1. Anything less can + // be considered a problem and should be investigated. + CollectorsFraction string `json:"collectorsFraction,omitempty"` +} + +// ScrapeLimits limits applied to scraped targets. +type ScrapeLimits struct { + // Maximum number of samples accepted within a single scrape. + // Uses Prometheus default if left unspecified. + Samples uint64 `json:"samples,omitempty"` + // Maximum number of labels accepted for a single sample. + // Uses Prometheus default if left unspecified. + Labels uint64 `json:"labels,omitempty"` + // Maximum label name length. + // Uses Prometheus default if left unspecified. + LabelNameLength uint64 `json:"labelNameLength,omitempty"` + // Maximum label value length. + // Uses Prometheus default if left unspecified. + LabelValueLength uint64 `json:"labelValueLength,omitempty"` +} + +// SecretKeySelector represents selector for Kubernetes secret. +// It's similar to k8s.io/api/core/v1.SecretKeySelector, but allows cross namespace selections. +type SecretKeySelector struct { + // Name of the secret to select from. + Name string `json:"name"` + + // Key of the secret to select from. Must be a valid secret key. + Key string `json:"key"` + + // Namespace of the secret to select from. + // If empty the parent resource namespace will be chosen. + // +optional + Namespace string `json:"namespace,omitempty"` +} + +// SecretSelector references a secret from a secret provider e.g. Kubernetes Secret. Only one +// provider can be used at a time. +type SecretSelector struct { + // Secret represents reference to a given key from certain Secret in a given namespace. + // +optional + Secret *SecretKeySelector `json:"secret,omitempty"` +} + +// TLS specifies TLS configuration used for HTTP requests. +// +kubebuilder:validation:XValidation:rule=has(self.cert) == has(self.key),message="client cert and client key must be provided together, when either is provided" +type TLS struct { + // ServerName is used to verify the hostname for the targets. + // +optional + ServerName string `json:"serverName,omitempty"` + // InsecureSkipVerify disables target certificate validation. + // +optional + InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` + // MinVersion is the minimum TLS version. Accepted values: TLS10 (TLS 1.0), TLS11 (TLS 1.1), + // TLS12 (TLS 1.2), TLS13 (TLS 1.3). + // + // If unset, Prometheus will use Go default minimum version, which is TLS 1.2. + // See MinVersion in https://pkg.go.dev/crypto/tls#Config. + // +kubebuilder:validation:Enum=TLS10;TLS11;TLS12;TLS13 + // +optional + MinVersion string `json:"minVersion,omitempty"` + // MaxVersion is the maximum TLS version. Accepted values: TLS10 (TLS 1.0), TLS11 (TLS 1.1), + // TLS12 (TLS 1.2), TLS13 (TLS 1.3). + // + // If unset, Prometheus will use Go default minimum version, which is TLS 1.2. + // See MinVersion in https://pkg.go.dev/crypto/tls#Config. + // +kubebuilder:validation:Enum=TLS10;TLS11;TLS12;TLS13 + // +optional + MaxVersion string `json:"maxVersion,omitempty"` + + // CA uses the secret as the CA certificate to validate the server with. + // +optional + + CA *SecretSelector `json:"ca,omitempty"` + // Cert uses the secret as the certificate for client authentication to the server. + // +optional + Cert *SecretSelector `json:"cert,omitempty"` + // Key uses the secret as the private key for client authentication to the server. + // +optional + Key *SecretSelector `json:"key,omitempty"` +} + +// TargetLabels configures labels for the discovered Prometheus targets. +type TargetLabels struct { + // Pod metadata labels that are set on all scraped targets. + // Permitted keys are `container`, `node`, `pod`, `top_level_controller_name`, + // and `top_level_controller_type`. The `container` + // label is only populated if the scrape port is referenced by name. + // Defaults to [container, pod, top_level_controller_name, top_level_controller_type]. + // If set to null, it will be interpreted as the empty list. + // This is for backwards-compatibility only. + // +kubebuilder:validation:items:Enum=container;node;pod;top_level_controller_name;top_level_controller_type + // +listType=set + // +kubebuilder:default=container;pod;top_level_controller_name;top_level_controller_type + Metadata *[]string `json:"metadata,omitempty"` + // Labels to transfer from the Kubernetes Pod to Prometheus target labels. + // Mappings are applied in order. + // +kubebuilder:validation:MaxItems=100 + FromPod []LabelMapping `json:"fromPod,omitempty"` +} diff --git a/support/gmpmonitoring/types_test.go b/support/gmpmonitoring/types_test.go new file mode 100644 index 000000000000..cda0514fb110 --- /dev/null +++ b/support/gmpmonitoring/types_test.go @@ -0,0 +1,225 @@ +package gmpmonitoring_test + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + hyperapi "github.com/openshift/hypershift/support/api" + gmp "github.com/openshift/hypershift/support/gmpmonitoring" + "github.com/openshift/hypershift/support/metrics" + "github.com/openshift/hypershift/support/rhobsmonitoring" + + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/intstr" + + "sigs.k8s.io/yaml" + + "github.com/go-openapi/spec" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/validate" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" +) + +func monitoringFixtures() (*gmp.PodMonitoring, *gmp.Rules) { + metadata := []string{"pod", "container"} + running, count, lastError := true, int32(1), "connection refused" + secret := func(name, key string) *gmp.SecretSelector { + return &gmp.SecretSelector{Secret: &gmp.SecretKeySelector{Name: name, Key: key}} + } + timestamp := metav1.NewTime(time.Date(2026, time.October, 13, 12, 0, 0, 0, time.UTC)) + status := gmp.MonitoringStatus{ObservedGeneration: 2, Conditions: []gmp.MonitoringCondition{{Type: gmp.ConfigurationCreateSuccess, Status: corev1.ConditionTrue, Reason: "ValidConfig", LastUpdateTime: timestamp, LastTransitionTime: timestamp}}} + monitor := &gmp.PodMonitoring{ + TypeMeta: metav1.TypeMeta{APIVersion: gmp.SchemeGroupVersion.String(), Kind: "PodMonitoring"}, + ObjectMeta: metav1.ObjectMeta{Name: "etcd", Namespace: "clusters-gmp", Labels: map[string]string{"component": "etcd"}}, + Spec: gmp.PodMonitoringSpec{ + Selector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "etcd"}, MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "role", Operator: metav1.LabelSelectorOpIn, Values: []string{"control-plane"}}}}, + FilterRunning: &running, + Limits: &gmp.ScrapeLimits{Samples: 10000, Labels: 100, LabelNameLength: 100, LabelValueLength: 1000}, + TargetLabels: gmp.TargetLabels{Metadata: &metadata, FromPod: []gmp.LabelMapping{{From: "app", To: "component"}}}, + Endpoints: []gmp.ScrapeEndpoint{{ + Port: intstr.FromString("metrics"), Interval: "30s", Timeout: "10s", Scheme: "https", Path: "/metrics", Params: map[string][]string{"format": {"prometheus"}}, + MetricRelabeling: []gmp.RelabelingRule{{Action: "keep", SourceLabels: []string{"__name__"}, Regex: "etcd_.*"}}, + HTTPClientConfig: gmp.HTTPClientConfig{TLS: &gmp.TLS{ServerName: "etcd-client", MinVersion: "TLS12", CA: secret("gmp-etcd-ca", "ca.crt"), Cert: secret("etcd-metrics-client-tls", "etcd-client.crt"), Key: secret("etcd-metrics-client-tls", "etcd-client.key")}}, + }}, + }, + Status: gmp.PodMonitoringStatus{MonitoringStatus: status, EndpointStatuses: []gmp.ScrapeEndpointStatus{{Name: "metrics", LastUpdateTime: timestamp, ActiveTargets: 3, UnhealthyTargets: 1, CollectorsFraction: "1", SampleGroups: []gmp.SampleGroup{{Count: &count, SampleTargets: []gmp.SampleTarget{{Labels: model.LabelSet{"pod": "etcd-0"}, LastError: &lastError, LastScrapeDurationSeconds: "0.1", Health: "down"}}}}}}}, + } + rules := &gmp.Rules{ + TypeMeta: metav1.TypeMeta{APIVersion: gmp.SchemeGroupVersion.String(), Kind: "Rules"}, + ObjectMeta: metav1.ObjectMeta{Name: "etcd", Namespace: monitor.Namespace}, + Spec: gmp.RulesSpec{Groups: []gmp.RuleGroup{{Name: "etcd", Interval: "30s", Rules: []gmp.Rule{ + {Record: "etcd:database_size_bytes:sum", Expr: "sum(etcd_mvcc_db_total_size_in_bytes)", Labels: map[string]string{"component": "etcd"}}, + {Alert: "EtcdDatabaseLarge", Expr: "etcd_mvcc_db_total_size_in_bytes > 1e9", For: "5m", Labels: map[string]string{"severity": "warning"}, Annotations: map[string]string{"summary": "Database size is high"}}, + }}}}, + Status: gmp.RulesStatus{MonitoringStatus: status}, + } + return monitor, rules +} + +func TestAddToScheme(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, gmp.AddToScheme(scheme)) + for _, kind := range []string{"PodMonitoring", "PodMonitoringList", "Rules", "RulesList"} { + t.Run("When "+kind+" is registered, it should construct a Kubernetes object", func(t *testing.T) { + obj, err := scheme.New(gmp.SchemeGroupVersion.WithKind(kind)) + require.NoError(t, err) + require.NotNil(t, obj.DeepCopyObject()) + }) + } + require.False(t, scheme.Recognizes(gmp.SchemeGroupVersion.WithKind("ClusterPodMonitoring"))) + require.False(t, scheme.Recognizes(gmp.SchemeGroupVersion.WithKind("ClusterRules"))) +} + +func TestDeepCopy(t *testing.T) { + monitor, rules := monitoringFixtures() + originalMonitor, originalRules := monitor.DeepCopy(), rules.DeepCopy() + copy := monitor.DeepCopy() + copy.Labels["component"] = "changed" + copy.Spec.Selector.MatchLabels["app"] = "changed" + copy.Spec.Selector.MatchExpressions[0].Values[0] = "changed" + *copy.Spec.FilterRunning = false + copy.Spec.Limits.Samples = 1 + (*copy.Spec.TargetLabels.Metadata)[0] = "node" + copy.Spec.TargetLabels.FromPod[0].From = "changed" + copy.Spec.Endpoints[0].Params["format"][0] = "changed" + copy.Spec.Endpoints[0].TLS.CA.Secret.Name = "changed" + copy.Spec.Endpoints[0].TLS.Cert.Secret.Key = "changed" + copy.Spec.Endpoints[0].TLS.Key.Secret.Key = "changed" + copy.Spec.Endpoints[0].MetricRelabeling[0].SourceLabels[0] = "changed" + copy.Status.Conditions[0].Reason = "changed" + group := ©.Status.EndpointStatuses[0].SampleGroups[0] + *group.Count = 99 + group.SampleTargets[0].Labels["pod"] = "changed" + *group.SampleTargets[0].LastError = "changed" + require.Equal(t, originalMonitor, monitor) + copiedRules := rules.DeepCopy() + copiedRules.Spec.Groups[0].Rules[0].Labels["component"] = "changed" + copiedRules.Spec.Groups[0].Rules[1].Annotations["summary"] = "changed" + copiedRules.Status.Conditions[0].Reason = "changed" + require.Equal(t, originalRules, rules) + monitorList := &gmp.PodMonitoringList{Items: []gmp.PodMonitoring{*monitor}} + monitorList.DeepCopy().Items[0].Spec.Endpoints[0].TLS.CA.Secret.Name = "changed" + require.Equal(t, *originalMonitor, monitorList.Items[0]) + rulesList := &gmp.RulesList{Items: []gmp.Rules{*rules}} + rulesList.DeepCopy().Items[0].Spec.Groups[0].Rules[0].Labels["component"] = "changed" + require.Equal(t, *originalRules, rulesList.Items[0]) +} + +func TestCompatibleYAMLEncode(t *testing.T) { + monitor, rules := monitoringFixtures() + objects := []runtime.Object{monitor, rules, + &gmp.PodMonitoringList{TypeMeta: metav1.TypeMeta{APIVersion: gmp.SchemeGroupVersion.String(), Kind: "PodMonitoringList"}, Items: []gmp.PodMonitoring{*monitor}}, + &gmp.RulesList{TypeMeta: metav1.TypeMeta{APIVersion: gmp.SchemeGroupVersion.String(), Kind: "RulesList"}, Items: []gmp.Rules{*rules}}, + } + for _, obj := range objects { + t.Run("When "+obj.GetObjectKind().GroupVersionKind().Kind+" is serialized, it should round trip specs and status", func(t *testing.T) { + for _, serializer := range []*runtimeSerializer{{encode: func(obj runtime.Object) ([]byte, error) { + return hyperapi.CompatibleYAMLEncode(obj, hyperapi.YamlSerializer) + }, decode: hyperapi.YamlSerializer.Decode}, + {encode: func(obj runtime.Object) ([]byte, error) { + var buf bytes.Buffer + err := hyperapi.JsonSerializer.Encode(obj, &buf) + return buf.Bytes(), err + }, decode: hyperapi.JsonSerializer.Decode}} { + encoded, err := serializer.encode(obj.DeepCopyObject()) + require.NoError(t, err) + decoded, gvk, err := serializer.decode(encoded, nil, nil) + require.NoError(t, err) + require.Equal(t, gmp.SchemeGroupVersion, gvk.GroupVersion()) + require.True(t, apiequality.Semantic.DeepEqual(obj, decoded), "round trip changed %T", obj) + decodedAll, _, err := hyperapi.AllMonitoringYamlSerializer.Decode(encoded, nil, nil) + require.NoError(t, err) + require.True(t, apiequality.Semantic.DeepEqual(obj, decodedAll), "all-monitoring round trip changed %T", obj) + } + }) + } +} + +type runtimeSerializer struct { + encode func(runtime.Object) ([]byte, error) + decode func([]byte, *schema.GroupVersionKind, runtime.Object) (runtime.Object, *schema.GroupVersionKind, error) +} + +func TestValidateSchema(t *testing.T) { + monitor, rules := monitoringFixtures() + for _, tc := range []struct { + name, fixture string + object runtime.Object + invalid func(map[string]interface{}) + }{ + {name: "When a typed PodMonitoring is emitted, it should conform to the upstream schema", fixture: "podmonitorings", object: monitor, invalid: func(obj map[string]interface{}) { obj["spec"].(map[string]interface{})["endpoints"] = []interface{}{} }}, + {name: "When typed Rules are emitted, it should conform to the upstream schema", fixture: "rules", object: rules, invalid: func(obj map[string]interface{}) { delete(obj["spec"].(map[string]interface{}), "groups") }}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture, err := os.ReadFile(filepath.Join("testdata", tc.fixture+".crd.yaml")) + require.NoError(t, err) + var crd struct { + Spec struct { + Versions []struct { + Name string + Schema struct{ OpenAPIV3Schema spec.Schema } + } + } + } + require.NoError(t, yaml.Unmarshal(fixture, &crd)) + var schema *spec.Schema + for i := range crd.Spec.Versions { + if crd.Spec.Versions[i].Name == "v1" { + schema = &crd.Spec.Versions[i].Schema.OpenAPIV3Schema + } + } + require.NotNil(t, schema) + encoded, err := json.Marshal(tc.object) + require.NoError(t, err) + var obj map[string]interface{} + require.NoError(t, json.Unmarshal(encoded, &obj)) + require.NoError(t, validate.AgainstSchema(schema, obj, strfmt.Default)) + tc.invalid(obj) + require.Error(t, validate.AgainstSchema(schema, obj, strfmt.Default), "negative control must be rejected") + }) + } +} + +func TestSchemeInitialization(t *testing.T) { + if os.Getenv("GMP_SCHEME_TEST_CHILD") == "1" { + for _, scheme := range []*runtime.Scheme{hyperapi.Scheme, hyperapi.AllMonitoringScheme} { + require.True(t, scheme.Recognizes(gmp.SchemeGroupVersion.WithKind("PodMonitoring"))) + require.True(t, scheme.Recognizes(gmp.SchemeGroupVersion.WithKind("Rules"))) + } + coreos := schema.GroupVersion{Group: "monitoring.coreos.com", Version: "v1"}.WithKind("ServiceMonitor") + rhobs := rhobsmonitoring.SchemeGroupVersion.WithKind("ServiceMonitor") + require.Equal(t, os.Getenv(rhobsmonitoring.EnvironmentVariable) != "1", hyperapi.Scheme.Recognizes(coreos)) + require.Equal(t, os.Getenv(rhobsmonitoring.EnvironmentVariable) == "1", hyperapi.Scheme.Recognizes(rhobs)) + require.True(t, hyperapi.AllMonitoringScheme.Recognizes(coreos)) + require.Equal(t, os.Getenv(rhobsmonitoring.EnvironmentVariable) == "1", hyperapi.AllMonitoringScheme.Recognizes(rhobs)) + return + } + for _, tc := range []struct{ name, group, rhobs string }{ + {name: "When the default process initializes, it should retain CoreOS registration"}, + {name: "When a GMP process initializes, it should register GMP additively", group: string(metrics.MonitoringAPIGroupGMP)}, + {name: "When a RHOBS process initializes, it should retain RHOBS registration", rhobs: "1"}, + } { + t.Run(tc.name, func(t *testing.T) { + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestSchemeInitialization$") + for _, env := range os.Environ() { + if !strings.HasPrefix(env, metrics.MonitoringAPIGroupEnvVar+"=") && !strings.HasPrefix(env, rhobsmonitoring.EnvironmentVariable+"=") && !strings.HasPrefix(env, "GMP_SCHEME_TEST_CHILD=") { + cmd.Env = append(cmd.Env, env) + } + } + cmd.Env = append(cmd.Env, "GMP_SCHEME_TEST_CHILD=1", metrics.MonitoringAPIGroupEnvVar+"="+tc.group, rhobsmonitoring.EnvironmentVariable+"="+tc.rhobs) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "%s", output) + }) + } +} diff --git a/support/gmpmonitoring/upstream.sha256 b/support/gmpmonitoring/upstream.sha256 new file mode 100644 index 000000000000..28a13776724e --- /dev/null +++ b/support/gmpmonitoring/upstream.sha256 @@ -0,0 +1,21 @@ +# Code generated by hack/update-gmp-api.sh; DO NOT EDIT. +# Module: github.com/GoogleCloudPlatform/prometheus-engine +# Version: v0.17.2 +# Revision: 058752ec791edefba5c42486eb9237562c3f0d29 +# Module sum: h1:b4Sau/04Xn0wqGyGABUzLVOSlM7BxsZHbcYEXP3EDeg= +# go.mod sum: h1:Mfy+yPZh97JLpnp8/WADyS3PJ3xzK8tua/luLRYkwtU= +d344029bc7754e0254d7c253db9d6400b344d5837e121b42790946757bc79f1f charts/operator/crds/monitoring.googleapis.com_podmonitorings.yaml +49c2cfea8d228a96c466ac49c09f4c19f6cb51ebdd258011cb364a97cb24afee charts/operator/crds/monitoring.googleapis.com_rules.yaml +925ec984ab400a99bf29a74e51fc7270effd8f315b6b0030d385f2f96f6d565c pkg/operator/apis/monitoring/v1/common_types.go +89dda6427bca1516f1159cfd4d3e1db9853c2c70d10f1096fce84298ac65f053 pkg/operator/apis/monitoring/v1/doc.go +4e5b470f3ba58e0a0e3b53a5148ce12da77560021ff42c122c9b23403f14af34 pkg/operator/apis/monitoring/v1/http_types.go +08aeb27a9e087227a35c9bc21875e0c2f843c63d3904f39ed61506aaac0a1c27 pkg/operator/apis/monitoring/v1/monitoring_types.go +7b3f74fd22d15645339deda5a10d0dc8b925e95a972d299f9a679a7442139fe9 pkg/operator/apis/monitoring/v1/node_types.go +f0003dfdf67c669fe44c330654dc9af4e1eeeae9a6eb462b0aaeb4211fdc4047 pkg/operator/apis/monitoring/v1/operator_admission.go +f1d7b67ce21ddfa040c63e182e1f1f7f7a2242ca24bced495827babc3c45d5b1 pkg/operator/apis/monitoring/v1/operator_config.go +4c285b93b4f9b15d0cabad098c79b93875b8c6f0fb3fc17cd8c385df5a4f3ef4 pkg/operator/apis/monitoring/v1/operator_types.go +36a4f137f330994a10e58f9664e85b82027e4e4ee4e35157838d6345a750478a pkg/operator/apis/monitoring/v1/pod_config.go +0dac0ce3c09f8f3f3381c95bb970b147c18077896b2d3dcec510d5e3e5b4abc5 pkg/operator/apis/monitoring/v1/pod_types.go +9c6eec0b2ca8fe2380796f2754e26f68bf56427749ffbedccda01e057f4413d9 pkg/operator/apis/monitoring/v1/register.go +d6bb86f0b4b1fa157044dba696a96419adc4187b16dfb2a4cedadfc07baf55a2 pkg/operator/apis/monitoring/v1/rules_config.go +182e88bf9d879e7b50383198f1b0f45bf09f7d7d9b43e23f14ab5596280dee96 pkg/operator/apis/monitoring/v1/rules_types.go diff --git a/support/gmpmonitoring/zz_generated.deepcopy.go b/support/gmpmonitoring/zz_generated.deepcopy.go new file mode 100644 index 000000000000..dff2e0c8742b --- /dev/null +++ b/support/gmpmonitoring/zz_generated.deepcopy.go @@ -0,0 +1,719 @@ +//go:build !ignore_autogenerated + +/* + + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package gmpmonitoring + +import ( + "github.com/prometheus/common/model" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Auth) DeepCopyInto(out *Auth) { + *out = *in + if in.Credentials != nil { + in, out := &in.Credentials, &out.Credentials + *out = new(SecretSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Auth. +func (in *Auth) DeepCopy() *Auth { + if in == nil { + return nil + } + out := new(Auth) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BasicAuth) DeepCopyInto(out *BasicAuth) { + *out = *in + if in.Password != nil { + in, out := &in.Password, &out.Password + *out = new(SecretSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BasicAuth. +func (in *BasicAuth) DeepCopy() *BasicAuth { + if in == nil { + return nil + } + out := new(BasicAuth) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HTTPClientConfig) DeepCopyInto(out *HTTPClientConfig) { + *out = *in + if in.Authorization != nil { + in, out := &in.Authorization, &out.Authorization + *out = new(Auth) + (*in).DeepCopyInto(*out) + } + if in.BasicAuth != nil { + in, out := &in.BasicAuth, &out.BasicAuth + *out = new(BasicAuth) + (*in).DeepCopyInto(*out) + } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLS) + (*in).DeepCopyInto(*out) + } + if in.OAuth2 != nil { + in, out := &in.OAuth2, &out.OAuth2 + *out = new(OAuth2) + (*in).DeepCopyInto(*out) + } + out.ProxyConfig = in.ProxyConfig +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPClientConfig. +func (in *HTTPClientConfig) DeepCopy() *HTTPClientConfig { + if in == nil { + return nil + } + out := new(HTTPClientConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LabelMapping) DeepCopyInto(out *LabelMapping) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LabelMapping. +func (in *LabelMapping) DeepCopy() *LabelMapping { + if in == nil { + return nil + } + out := new(LabelMapping) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MonitoringCondition) DeepCopyInto(out *MonitoringCondition) { + *out = *in + in.LastUpdateTime.DeepCopyInto(&out.LastUpdateTime) + in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MonitoringCondition. +func (in *MonitoringCondition) DeepCopy() *MonitoringCondition { + if in == nil { + return nil + } + out := new(MonitoringCondition) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MonitoringStatus) DeepCopyInto(out *MonitoringStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]MonitoringCondition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MonitoringStatus. +func (in *MonitoringStatus) DeepCopy() *MonitoringStatus { + if in == nil { + return nil + } + out := new(MonitoringStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OAuth2) DeepCopyInto(out *OAuth2) { + *out = *in + if in.ClientSecret != nil { + in, out := &in.ClientSecret, &out.ClientSecret + *out = new(SecretSelector) + (*in).DeepCopyInto(*out) + } + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.EndpointParams != nil { + in, out := &in.EndpointParams, &out.EndpointParams + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLS) + (*in).DeepCopyInto(*out) + } + out.ProxyConfig = in.ProxyConfig +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OAuth2. +func (in *OAuth2) DeepCopy() *OAuth2 { + if in == nil { + return nil + } + out := new(OAuth2) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodMonitoring) DeepCopyInto(out *PodMonitoring) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodMonitoring. +func (in *PodMonitoring) DeepCopy() *PodMonitoring { + if in == nil { + return nil + } + out := new(PodMonitoring) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PodMonitoring) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodMonitoringList) DeepCopyInto(out *PodMonitoringList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PodMonitoring, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodMonitoringList. +func (in *PodMonitoringList) DeepCopy() *PodMonitoringList { + if in == nil { + return nil + } + out := new(PodMonitoringList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PodMonitoringList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodMonitoringSpec) DeepCopyInto(out *PodMonitoringSpec) { + *out = *in + in.Selector.DeepCopyInto(&out.Selector) + if in.Endpoints != nil { + in, out := &in.Endpoints, &out.Endpoints + *out = make([]ScrapeEndpoint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.TargetLabels.DeepCopyInto(&out.TargetLabels) + if in.Limits != nil { + in, out := &in.Limits, &out.Limits + *out = new(ScrapeLimits) + **out = **in + } + if in.FilterRunning != nil { + in, out := &in.FilterRunning, &out.FilterRunning + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodMonitoringSpec. +func (in *PodMonitoringSpec) DeepCopy() *PodMonitoringSpec { + if in == nil { + return nil + } + out := new(PodMonitoringSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodMonitoringStatus) DeepCopyInto(out *PodMonitoringStatus) { + *out = *in + in.MonitoringStatus.DeepCopyInto(&out.MonitoringStatus) + if in.EndpointStatuses != nil { + in, out := &in.EndpointStatuses, &out.EndpointStatuses + *out = make([]ScrapeEndpointStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodMonitoringStatus. +func (in *PodMonitoringStatus) DeepCopy() *PodMonitoringStatus { + if in == nil { + return nil + } + out := new(PodMonitoringStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProxyConfig) DeepCopyInto(out *ProxyConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyConfig. +func (in *ProxyConfig) DeepCopy() *ProxyConfig { + if in == nil { + return nil + } + out := new(ProxyConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RelabelingRule) DeepCopyInto(out *RelabelingRule) { + *out = *in + if in.SourceLabels != nil { + in, out := &in.SourceLabels, &out.SourceLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RelabelingRule. +func (in *RelabelingRule) DeepCopy() *RelabelingRule { + if in == nil { + return nil + } + out := new(RelabelingRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Rule) DeepCopyInto(out *Rule) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Rule. +func (in *Rule) DeepCopy() *Rule { + if in == nil { + return nil + } + out := new(Rule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuleGroup) DeepCopyInto(out *RuleGroup) { + *out = *in + if in.Rules != nil { + in, out := &in.Rules, &out.Rules + *out = make([]Rule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuleGroup. +func (in *RuleGroup) DeepCopy() *RuleGroup { + if in == nil { + return nil + } + out := new(RuleGroup) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Rules) DeepCopyInto(out *Rules) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Rules. +func (in *Rules) DeepCopy() *Rules { + if in == nil { + return nil + } + out := new(Rules) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Rules) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RulesList) DeepCopyInto(out *RulesList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Rules, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RulesList. +func (in *RulesList) DeepCopy() *RulesList { + if in == nil { + return nil + } + out := new(RulesList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RulesList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RulesSpec) DeepCopyInto(out *RulesSpec) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]RuleGroup, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RulesSpec. +func (in *RulesSpec) DeepCopy() *RulesSpec { + if in == nil { + return nil + } + out := new(RulesSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RulesStatus) DeepCopyInto(out *RulesStatus) { + *out = *in + in.MonitoringStatus.DeepCopyInto(&out.MonitoringStatus) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RulesStatus. +func (in *RulesStatus) DeepCopy() *RulesStatus { + if in == nil { + return nil + } + out := new(RulesStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SampleGroup) DeepCopyInto(out *SampleGroup) { + *out = *in + if in.SampleTargets != nil { + in, out := &in.SampleTargets, &out.SampleTargets + *out = make([]SampleTarget, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Count != nil { + in, out := &in.Count, &out.Count + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SampleGroup. +func (in *SampleGroup) DeepCopy() *SampleGroup { + if in == nil { + return nil + } + out := new(SampleGroup) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SampleTarget) DeepCopyInto(out *SampleTarget) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(model.LabelSet, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.LastError != nil { + in, out := &in.LastError, &out.LastError + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SampleTarget. +func (in *SampleTarget) DeepCopy() *SampleTarget { + if in == nil { + return nil + } + out := new(SampleTarget) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScrapeEndpoint) DeepCopyInto(out *ScrapeEndpoint) { + *out = *in + out.Port = in.Port + if in.Params != nil { + in, out := &in.Params, &out.Params + *out = make(map[string][]string, len(*in)) + for key, val := range *in { + var outVal []string + if val == nil { + (*out)[key] = nil + } else { + inVal := (*in)[key] + in, out := &inVal, &outVal + *out = make([]string, len(*in)) + copy(*out, *in) + } + (*out)[key] = outVal + } + } + if in.MetricRelabeling != nil { + in, out := &in.MetricRelabeling, &out.MetricRelabeling + *out = make([]RelabelingRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.HTTPClientConfig.DeepCopyInto(&out.HTTPClientConfig) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScrapeEndpoint. +func (in *ScrapeEndpoint) DeepCopy() *ScrapeEndpoint { + if in == nil { + return nil + } + out := new(ScrapeEndpoint) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScrapeEndpointStatus) DeepCopyInto(out *ScrapeEndpointStatus) { + *out = *in + in.LastUpdateTime.DeepCopyInto(&out.LastUpdateTime) + if in.SampleGroups != nil { + in, out := &in.SampleGroups, &out.SampleGroups + *out = make([]SampleGroup, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScrapeEndpointStatus. +func (in *ScrapeEndpointStatus) DeepCopy() *ScrapeEndpointStatus { + if in == nil { + return nil + } + out := new(ScrapeEndpointStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScrapeLimits) DeepCopyInto(out *ScrapeLimits) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScrapeLimits. +func (in *ScrapeLimits) DeepCopy() *ScrapeLimits { + if in == nil { + return nil + } + out := new(ScrapeLimits) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretKeySelector) DeepCopyInto(out *SecretKeySelector) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeySelector. +func (in *SecretKeySelector) DeepCopy() *SecretKeySelector { + if in == nil { + return nil + } + out := new(SecretKeySelector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretSelector) DeepCopyInto(out *SecretSelector) { + *out = *in + if in.Secret != nil { + in, out := &in.Secret, &out.Secret + *out = new(SecretKeySelector) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretSelector. +func (in *SecretSelector) DeepCopy() *SecretSelector { + if in == nil { + return nil + } + out := new(SecretSelector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLS) DeepCopyInto(out *TLS) { + *out = *in + if in.CA != nil { + in, out := &in.CA, &out.CA + *out = new(SecretSelector) + (*in).DeepCopyInto(*out) + } + if in.Cert != nil { + in, out := &in.Cert, &out.Cert + *out = new(SecretSelector) + (*in).DeepCopyInto(*out) + } + if in.Key != nil { + in, out := &in.Key, &out.Key + *out = new(SecretSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLS. +func (in *TLS) DeepCopy() *TLS { + if in == nil { + return nil + } + out := new(TLS) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TargetLabels) DeepCopyInto(out *TargetLabels) { + *out = *in + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = new([]string) + if **in != nil { + in, out := *in, *out + *out = make([]string, len(*in)) + copy(*out, *in) + } + } + if in.FromPod != nil { + in, out := &in.FromPod, &out.FromPod + *out = make([]LabelMapping, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TargetLabels. +func (in *TargetLabels) DeepCopy() *TargetLabels { + if in == nil { + return nil + } + out := new(TargetLabels) + in.DeepCopyInto(out) + return out +} diff --git a/support/metrics/monitoring_api_group.go b/support/metrics/monitoring_api_group.go new file mode 100644 index 000000000000..8ff7c4f8017a --- /dev/null +++ b/support/metrics/monitoring_api_group.go @@ -0,0 +1,42 @@ +package metrics + +import ( + "fmt" + "os" + + "github.com/openshift/hypershift/support/rhobsmonitoring" +) + +// MonitoringAPIGroup selects which monitoring API group to use for emitting monitors. +type MonitoringAPIGroup string + +const ( + MonitoringAPIGroupCoreOS MonitoringAPIGroup = "monitoring.coreos.com" + MonitoringAPIGroupGMP MonitoringAPIGroup = "monitoring.googleapis.com" + MonitoringAPIGroupEnvVar = "MONITORING_API_GROUP" +) + +// MonitoringAPIGroupFromEnv reads MONITORING_API_GROUP from the environment, defaulting to +// monitoring.coreos.com when unset. +func MonitoringAPIGroupFromEnv() (MonitoringAPIGroup, error) { + return ParseMonitoringAPIGroup(os.Getenv(MonitoringAPIGroupEnvVar), os.Getenv(rhobsmonitoring.EnvironmentVariable) == "1") +} + +// ParseMonitoringAPIGroup defaults an empty value to CoreOS and rejects unsupported +// groups and GMP combined with RHOBS monitoring. +func ParseMonitoringAPIGroup(val string, rhobsMonitoring bool) (MonitoringAPIGroup, error) { + if val == "" { + return MonitoringAPIGroupCoreOS, nil + } + switch MonitoringAPIGroup(val) { + case MonitoringAPIGroupCoreOS: + return MonitoringAPIGroupCoreOS, nil + case MonitoringAPIGroupGMP: + if rhobsMonitoring { + return "", fmt.Errorf("%s=%s cannot be used with RHOBS monitoring; unset %s and disable --rhobs-monitoring to use GMP", MonitoringAPIGroupEnvVar, MonitoringAPIGroupGMP, rhobsmonitoring.EnvironmentVariable) + } + return MonitoringAPIGroup(val), nil + default: + return "", fmt.Errorf("unsupported %s value %q: must be %q or %q", MonitoringAPIGroupEnvVar, val, MonitoringAPIGroupCoreOS, MonitoringAPIGroupGMP) + } +} diff --git a/support/metrics/monitoring_api_group_test.go b/support/metrics/monitoring_api_group_test.go new file mode 100644 index 000000000000..68bfc602887a --- /dev/null +++ b/support/metrics/monitoring_api_group_test.go @@ -0,0 +1,64 @@ +package metrics + +import ( + "testing" + + "github.com/openshift/hypershift/support/rhobsmonitoring" + + "github.com/stretchr/testify/require" +) + +func TestParseMonitoringAPIGroup(t *testing.T) { + for _, tc := range []struct { + name string + value string + rhobs bool + want MonitoringAPIGroup + errorContains string + }{ + {name: "When the value is empty, it should default to CoreOS", want: MonitoringAPIGroupCoreOS}, + {name: "When CoreOS is selected, it should accept CoreOS", value: string(MonitoringAPIGroupCoreOS), want: MonitoringAPIGroupCoreOS}, + {name: "When GMP is selected, it should accept GMP", value: string(MonitoringAPIGroupGMP), want: MonitoringAPIGroupGMP}, + {name: "When an unsupported group is selected, it should reject it", value: "monitoring.rhobs", errorContains: "unsupported MONITORING_API_GROUP value"}, + {name: "When the group contains whitespace, it should reject it", value: " monitoring.googleapis.com", errorContains: "unsupported MONITORING_API_GROUP value"}, + {name: "When RHOBS uses the default, it should retain CoreOS selection", rhobs: true, want: MonitoringAPIGroupCoreOS}, + {name: "When RHOBS uses explicit CoreOS, it should accept it", value: string(MonitoringAPIGroupCoreOS), rhobs: true, want: MonitoringAPIGroupCoreOS}, + {name: "When RHOBS and GMP are selected, it should reject the conflict", value: string(MonitoringAPIGroupGMP), rhobs: true, errorContains: "cannot be used with RHOBS monitoring"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseMonitoringAPIGroup(tc.value, tc.rhobs) + if tc.errorContains != "" { + require.ErrorContains(t, err, tc.errorContains) + } else { + require.NoError(t, err) + } + require.Equal(t, tc.want, got) + }) + } +} + +func TestMonitoringAPIGroupFromEnv(t *testing.T) { + for _, tc := range []struct { + name, value, rhobs string + want MonitoringAPIGroup + errorContains string + }{ + {name: "When the environment is empty, it should default to CoreOS", want: MonitoringAPIGroupCoreOS}, + {name: "When the environment selects GMP, it should return GMP", value: string(MonitoringAPIGroupGMP), want: MonitoringAPIGroupGMP}, + {name: "When the environment selects an invalid group, it should fail", value: "unsupported", errorContains: "unsupported MONITORING_API_GROUP value"}, + {name: "When RHOBS is enabled with GMP, it should fail", value: string(MonitoringAPIGroupGMP), rhobs: "1", errorContains: "cannot be used with RHOBS monitoring"}, + {name: "When RHOBS has an inactive value with GMP, it should accept GMP", value: string(MonitoringAPIGroupGMP), rhobs: "true", want: MonitoringAPIGroupGMP}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(MonitoringAPIGroupEnvVar, tc.value) + t.Setenv(rhobsmonitoring.EnvironmentVariable, tc.rhobs) + got, err := MonitoringAPIGroupFromEnv() + if tc.errorContains != "" { + require.ErrorContains(t, err, tc.errorContains) + } else { + require.NoError(t, err) + } + require.Equal(t, tc.want, got) + }) + } +} From 533961d98cb292742ebe0e494252e0018366f4bb Mon Sep 17 00:00:00 2001 From: Drew Walczyk Date: Wed, 21 Oct 2026 08:37:10 -0500 Subject: [PATCH 3/8] feat(control-plane-operator): propagate monitoring API selection Validate the monitoring API selection before creating CPO Deployments and at CPO startup. Carry the parsed selection into control plane component contexts while preserving the default CoreOS and RHOBS environments. This prepares configuration propagation without reconciling GMP resources. Signed-off-by: Drew Walczyk Commit-Message-Assisted-by: Codex (via Codex) --- .../hostedcontrolplane_controller.go | 2 + .../v2/controlplaneoperator/deployment.go | 13 ++++++ .../controlplaneoperator/deployment_test.go | 43 +++++++++++++++++++ control-plane-operator/main.go | 8 ++++ 4 files changed, 66 insertions(+) diff --git a/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go b/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go index 5b85fbdce490..ddfe199f505e 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go +++ b/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go @@ -199,6 +199,7 @@ type HostedControlPlaneReconciler struct { OperateOnReleaseImage string DefaultIngressDomain string MetricsSet metrics.MetricsSet + MonitoringAPIGroup metrics.MonitoringAPIGroup KASHealthMetrics *kas.KASHealthMetrics SREConfigHash string ec2Client awsapi.EC2API @@ -1428,6 +1429,7 @@ func (r *HostedControlPlaneReconciler) reconcileCPOV2(ctx context.Context, hcp * SetDefaultSecurityContext: r.SetDefaultSecurityContext, DefaultSecurityContextUID: r.DefaultSecurityContextUID, MetricsSet: r.MetricsSet, + MonitoringAPIGroup: r.MonitoringAPIGroup, EnableCIDebugOutput: r.EnableCIDebugOutput, ImageMetadataProvider: r.ImageMetadataProvider, NativeSidecarContainersEnabled: r.ManagementClusterCapabilities.Has(capabilities.CapabilityNativeSidecarContainers), diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment.go b/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment.go index 52da743072ea..2006c9b153c7 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment.go +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment.go @@ -30,6 +30,13 @@ const ( func (cpo *ControlPlaneOperatorOptions) adaptDeployment(cpContext component.WorkloadContext, deployment *appsv1.Deployment) error { hcp := cpContext.HCP + // Validate the monitoring API group before propagating it so an invalid value or + // GMP/RHOBS conflict fails here rather than crashlooping the CPO it is stamped onto. + monitoringAPIGroup, err := metrics.MonitoringAPIGroupFromEnv() + if err != nil { + return err + } + podspec.UpdateContainer(ComponentName, deployment.Spec.Template.Spec.Containers, func(c *corev1.Container) { c.Image = cpo.Image @@ -122,6 +129,12 @@ func (cpo *ControlPlaneOperatorOptions) adaptDeployment(cpContext component.Work }, ) } + if monitoringAPIGroup != metrics.MonitoringAPIGroupCoreOS { + c.Env = append(c.Env, corev1.EnvVar{ + Name: metrics.MonitoringAPIGroupEnvVar, + Value: string(monitoringAPIGroup), + }) + } if os.Getenv("ENABLE_SIZE_TAGGING") == "1" { c.Env = append(c.Env, corev1.EnvVar{ diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment_test.go index e5c482662ba7..3b78b6554008 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment_test.go +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment_test.go @@ -8,6 +8,8 @@ import ( hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" controlplanecomponent "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/metrics" + "github.com/openshift/hypershift/support/rhobsmonitoring" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -160,3 +162,44 @@ func TestAdaptDeploymentWatchListClientEnv(t *testing.T) { }) } } + +func TestAdaptDeployment(t *testing.T) { + for _, tc := range []struct { + name, group, rhobs string + wantGroup bool + wantErr bool + }{ + {name: "When GMP is selected, it should propagate the monitoring API group", group: string(metrics.MonitoringAPIGroupGMP), wantGroup: true}, + {name: "When monitoring selection is unset, it should preserve the default environment"}, + {name: "When CoreOS is explicit, it should preserve the default environment", group: string(metrics.MonitoringAPIGroupCoreOS)}, + {name: "When RHOBS uses CoreOS selection, it should preserve the RHOBS environment", group: string(metrics.MonitoringAPIGroupCoreOS), rhobs: "1"}, + {name: "When GMP conflicts with RHOBS, it should fail before mutating the deployment", group: string(metrics.MonitoringAPIGroupGMP), rhobs: "1", wantErr: true}, + {name: "When the monitoring API group is unsupported, it should fail before mutating the deployment", group: "unsupported", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + g := NewGomegaWithT(t) + t.Setenv(metrics.MonitoringAPIGroupEnvVar, tc.group) + t.Setenv(rhobsmonitoring.EnvironmentVariable, tc.rhobs) + cpo := &ControlPlaneOperatorOptions{HostedCluster: &hyperv1.HostedCluster{}, Image: "quay.io/openshift-release-dev/ocp-v4.0-art-dev:control-plane-operator"} + cpContext := controlplanecomponent.WorkloadContext{Context: t.Context(), HCP: &hyperv1.HostedControlPlane{}} + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).NotTo(HaveOccurred()) + if tc.wantErr { + g.Expect(cpo.adaptDeployment(cpContext, deployment)).NotTo(Succeed()) + // The invalid selection must fail fast without stamping the group onto the deployment. + g.Expect(deployment.Spec.Template.Spec.Containers[0].Env).NotTo(ContainElement(HaveField("Name", metrics.MonitoringAPIGroupEnvVar))) + return + } + g.Expect(cpo.adaptDeployment(cpContext, deployment)).To(Succeed()) + env := deployment.Spec.Template.Spec.Containers[0].Env + if tc.wantGroup { + g.Expect(env).To(ContainElement(corev1.EnvVar{Name: metrics.MonitoringAPIGroupEnvVar, Value: tc.group})) + } else { + g.Expect(env).NotTo(ContainElement(HaveField("Name", metrics.MonitoringAPIGroupEnvVar))) + } + if tc.rhobs == "1" { + g.Expect(env).To(ContainElement(corev1.EnvVar{Name: rhobsmonitoring.EnvironmentVariable, Value: "1"})) + } + }) + } +} diff --git a/control-plane-operator/main.go b/control-plane-operator/main.go index 1dee4413d062..699a463855be 100644 --- a/control-plane-operator/main.go +++ b/control-plane-operator/main.go @@ -521,6 +521,13 @@ func NewStartCommand() *cobra.Command { } setupLog.Info("Using metrics set", "set", metricsSet.String()) + monitoringAPIGroup, err := metrics.MonitoringAPIGroupFromEnv() + if err != nil { + setupLog.Error(err, "invalid monitoring API group") + os.Exit(1) + } + setupLog.Info("Using monitoring API group", "group", monitoringAPIGroup) + enableCVOManagementClusterMetricsAccess := (os.Getenv(config.EnableCVOManagementClusterMetricsAccessEnvVar) == "1") if err := (&hostedcontrolplane.HostedControlPlaneReconciler{ @@ -533,6 +540,7 @@ func NewStartCommand() *cobra.Command { OperateOnReleaseImage: os.Getenv("OPERATE_ON_RELEASE_IMAGE"), DefaultIngressDomain: defaultIngressDomain, MetricsSet: metricsSet, + MonitoringAPIGroup: monitoringAPIGroup, KASHealthMetrics: kas.NewKASHealthMetrics(crmetrics.Registry), CertRotationScale: certRotationScale, EnableCVOManagementClusterMetricsAccess: enableCVOManagementClusterMetricsAccess, From b61ec491ea01869b5a4565b57c0b5e43c63cb154 Mon Sep 17 00:00:00 2001 From: Drew Walczyk Date: Tue, 27 Oct 2026 15:49:22 -0500 Subject: [PATCH 4/8] refactor: replace MONITORING_API_GROUP with --enable-gmp-monitoring flag Select GMP monitoring with an explicit --enable-gmp-monitoring flag on the installer, HyperShift operator and control plane operator instead of the MONITORING_API_GROUP environment variable. The HyperShift operator only enables GMP for a hosted control plane, and passes the flag to the CPO, when the CPO image declares the io.openshift.hypershift.control-plane-operator-supports-gmp-monitoring label, since older CPO images reject unknown flags. Default and RHOBS renders are unchanged. Reject GMP combined with RHOBS at install time (including RHOBS set via additional operator env vars), at HyperShift operator startup and at CPO startup. Harden GMP API snapshot generation: reject Go directives such as go:generate or build constraints in copied upstream source, always verify the pinned download against sum.golang.org regardless of GOPRIVATE/GONOSUMDB settings, add an offline unit test that fails when upstream.sha256 drifts from the hack/gmp-api-source pin, and disable Renovate updates for that pin so version bumps stay deliberate via make gmp-sync. Signed-off-by: Drew Walczyk Assisted-by: LLM --- OWNERS | 2 +- cmd/install/assets/hypershift_operator.go | 8 +- .../assets/hypershift_operator_test.go | 45 +++----- cmd/install/install.go | 41 +++---- cmd/install/install_test.go | 103 +++++++++++++++++- cmd/install/monitoring_api_group_test.go | 59 ---------- .../hostedcontrolplane_controller.go | 4 +- .../v2/controlplaneoperator/deployment.go | 19 +--- .../controlplaneoperator/deployment_test.go | 72 ++++++------ control-plane-operator/main.go | 12 +- hack/gmp-api-gen/main.go | 28 ++++- hack/gmp-api-gen/main_test.go | 36 +++++- hack/gmp-api-gen/provenance_test.go | 38 +++++++ hack/update-gmp-api.sh | 5 +- .../hostedcluster/hostedcluster_controller.go | 28 ++++- .../hostedcluster_controller_test.go | 47 ++++++++ .../hostedcluster/reconcile_legacy.go | 2 + hypershift-operator/main.go | 8 ++ hypershift-operator/main_test.go | 31 ++++++ renovate.json | 10 ++ .../controlplane-component.go | 8 +- .../controlplane-component_test.go | 18 +++ .../monitoring_api_group_test.go | 27 ----- support/gmpmonitoring/README.md | 18 ++- support/gmpmonitoring/types_test.go | 81 +++++++------- support/metrics/gmp_monitoring.go | 14 +++ support/metrics/gmp_monitoring_test.go | 30 +++++ support/metrics/monitoring_api_group.go | 42 ------- support/metrics/monitoring_api_group_test.go | 64 ----------- 29 files changed, 532 insertions(+), 368 deletions(-) delete mode 100644 cmd/install/monitoring_api_group_test.go delete mode 100644 support/controlplane-component/monitoring_api_group_test.go create mode 100644 support/metrics/gmp_monitoring.go create mode 100644 support/metrics/gmp_monitoring_test.go delete mode 100644 support/metrics/monitoring_api_group.go delete mode 100644 support/metrics/monitoring_api_group_test.go diff --git a/OWNERS b/OWNERS index 030fec995220..fb35062ff8fe 100644 --- a/OWNERS +++ b/OWNERS @@ -27,7 +27,7 @@ filters: - gcp-reviewers labels: - area/platform/gcp - "^(support/gmpmonitoring/|support/metrics/monitoring_api_group.*\\.go$|hack/gmp-api-gen/|hack/gmp-api-source/|hack/update-gmp-api\\.sh$)": + "^(support/gmpmonitoring/|support/metrics/gmp_monitoring.*\\.go$|hack/gmp-api-gen/|hack/gmp-api-source/|hack/update-gmp-api\\.sh$)": reviewers: - gcp-reviewers labels: diff --git a/cmd/install/assets/hypershift_operator.go b/cmd/install/assets/hypershift_operator.go index 09c4d0fc697b..a6e5d92285e0 100644 --- a/cmd/install/assets/hypershift_operator.go +++ b/cmd/install/assets/hypershift_operator.go @@ -560,7 +560,7 @@ type HyperShiftOperatorDeployment struct { IncludeVersion bool UWMTelemetry bool RHOBSMonitoring bool - MonitoringAPIGroup metrics.MonitoringAPIGroup + EnableGMPMonitoring bool CVOPrometheusURL string MonitoringDashboards bool CertRotationScale time.Duration @@ -823,6 +823,9 @@ func (o HyperShiftOperatorDeployment) buildArgs() []string { if o.RegistryOverrides != "" { args = append(args, fmt.Sprintf("--registry-overrides=%s", o.RegistryOverrides)) } + if o.EnableGMPMonitoring { + args = append(args, "--enable-gmp-monitoring") + } return args } @@ -906,9 +909,6 @@ func (o HyperShiftOperatorDeployment) buildEnvVars() []corev1.EnvVar { if o.RHOBSMonitoring { envVars = append(envVars, corev1.EnvVar{Name: rhobsmonitoring.EnvironmentVariable, Value: "1"}) } - if o.MonitoringAPIGroup != "" && o.MonitoringAPIGroup != metrics.MonitoringAPIGroupCoreOS { - envVars = append(envVars, corev1.EnvVar{Name: metrics.MonitoringAPIGroupEnvVar, Value: string(o.MonitoringAPIGroup)}) - } if o.CVOPrometheusURL != "" { envVars = append(envVars, corev1.EnvVar{Name: config.CVOPrometheusURLEnvVar, Value: o.CVOPrometheusURL}) } diff --git a/cmd/install/assets/hypershift_operator_test.go b/cmd/install/assets/hypershift_operator_test.go index 5000b56a078d..1c06986f9932 100644 --- a/cmd/install/assets/hypershift_operator_test.go +++ b/cmd/install/assets/hypershift_operator_test.go @@ -10,7 +10,6 @@ import ( hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" controlplaneoperatoroverrides "github.com/openshift/hypershift/hypershift-operator/controlplaneoperator-overrides" "github.com/openshift/hypershift/support/config" - "github.com/openshift/hypershift/support/metrics" "github.com/openshift/hypershift/support/rhobsmonitoring" configv1 "github.com/openshift/api/config/v1" @@ -878,6 +877,21 @@ func TestBuildArgs(t *testing.T) { }, expectNotContains: []string{"--registry-overrides"}, }, + { + name: "When GMP monitoring is enabled, it should include the flag in args", + deployment: HyperShiftOperatorDeployment{ + PrivatePlatform: string(hyperv1.NonePlatform), + EnableGMPMonitoring: true, + }, + expectContains: []string{"--enable-gmp-monitoring"}, + }, + { + name: "When GMP monitoring is disabled, it should not include the flag", + deployment: HyperShiftOperatorDeployment{ + PrivatePlatform: string(hyperv1.NonePlatform), + }, + expectNotContains: []string{"--enable-gmp-monitoring"}, + }, { name: "When all standard options are set, it should include them in args", deployment: HyperShiftOperatorDeployment{ @@ -936,38 +950,10 @@ func TestBuildArgs(t *testing.T) { func TestBuildEnvVars(t *testing.T) { tests := []struct { name string - installerRHOBS string deployment HyperShiftOperatorDeployment expectContains []corev1.EnvVar expectAbsent []string }{ - { - name: "When GMP is selected, it should propagate the monitoring API group", - deployment: HyperShiftOperatorDeployment{MonitoringAPIGroup: metrics.MonitoringAPIGroupGMP}, - expectContains: []corev1.EnvVar{{Name: metrics.MonitoringAPIGroupEnvVar, Value: string(metrics.MonitoringAPIGroupGMP)}}, - }, - { - name: "When only the installer enables RHOBS, it should emit GMP without RHOBS", - installerRHOBS: "1", - deployment: HyperShiftOperatorDeployment{MonitoringAPIGroup: metrics.MonitoringAPIGroupGMP}, - expectContains: []corev1.EnvVar{{Name: metrics.MonitoringAPIGroupEnvVar, Value: string(metrics.MonitoringAPIGroupGMP)}}, - expectAbsent: []string{rhobsmonitoring.EnvironmentVariable}, - }, - { - name: "When CoreOS is explicit, it should retain the default environment", - deployment: HyperShiftOperatorDeployment{MonitoringAPIGroup: metrics.MonitoringAPIGroupCoreOS}, - expectAbsent: []string{metrics.MonitoringAPIGroupEnvVar}, - }, - { - name: "When monitoring selection is unset, it should retain the default environment", - expectAbsent: []string{metrics.MonitoringAPIGroupEnvVar}, - }, - { - name: "When RHOBS uses CoreOS selection, it should retain the RHOBS environment", - deployment: HyperShiftOperatorDeployment{MonitoringAPIGroup: metrics.MonitoringAPIGroupCoreOS, RHOBSMonitoring: true}, - expectContains: []corev1.EnvVar{{Name: rhobsmonitoring.EnvironmentVariable, Value: "1"}}, - expectAbsent: []string{metrics.MonitoringAPIGroupEnvVar}, - }, { name: "When TechPreviewNoUpgrade is enabled, it should include HYPERSHIFT_FEATURESET env var", deployment: HyperShiftOperatorDeployment{ @@ -1095,7 +1081,6 @@ func TestBuildEnvVars(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { g := NewGomegaWithT(t) - t.Setenv(rhobsmonitoring.EnvironmentVariable, tc.installerRHOBS) envVars := tc.deployment.buildEnvVars() for _, expected := range tc.expectContains { g.Expect(envVars).To(ContainElement(expected)) diff --git a/cmd/install/install.go b/cmd/install/install.go index 66f853f770b2..01688b228285 100644 --- a/cmd/install/install.go +++ b/cmd/install/install.go @@ -142,7 +142,7 @@ type Options struct { WaitUntilAvailable bool WaitUntilEstablished bool RHOBSMonitoring bool - MonitoringAPIGroup metrics.MonitoringAPIGroup + EnableGMPMonitoring bool CVOPrometheusURL string SLOsAlerts bool MonitoringDashboards bool @@ -184,37 +184,12 @@ func (o *Options) Complete() error { o.ScaleFromZeroProvider = strings.ToLower(o.ScaleFromZeroProvider) } - // Resolve the monitoring API group from the environment when it is not set - // explicitly, defaulting to CoreOS. Validation happens in Validate. - if o.MonitoringAPIGroup == "" { - o.MonitoringAPIGroup = metrics.MonitoringAPIGroup(os.Getenv(metrics.MonitoringAPIGroupEnvVar)) - } - if o.MonitoringAPIGroup == "" { - o.MonitoringAPIGroup = metrics.MonitoringAPIGroupCoreOS - } - return nil } func (o *Options) Validate() error { var errs []error - // Match the operator Deployment builder's normalization when validating - // managed overrides and the GMP/RHOBS conflict. - rhobsMonitoring := o.RHOBSMonitoring - for key, value := range o.AdditionalOperatorEnvVars { - switch strings.TrimSpace(key) { - case metrics.MonitoringAPIGroupEnvVar: - errs = append(errs, fmt.Errorf("%s must not be set via --additional-operator-env-vars; it is managed from the monitoring API group selection", metrics.MonitoringAPIGroupEnvVar)) - case rhobsmonitoring.EnvironmentVariable: - rhobsMonitoring = rhobsMonitoring || strings.TrimSpace(value) == "1" - } - } - // The monitoring API group is resolved in Complete; validate it without mutating Options. - if _, err := metrics.ParseMonitoringAPIGroup(string(o.MonitoringAPIGroup), rhobsMonitoring); err != nil { - errs = append(errs, err) - } - if o.InstallScope != "" && !Outputs(o.InstallScope).IsValid() { errs = append(errs, fmt.Errorf("invalid --install-scope value %q: must be '%s', '%s', or '%s'", o.InstallScope, OutputAll, OutputCRDs, OutputResources)) } @@ -414,6 +389,17 @@ func (o *Options) validateMonitoringConfig() []error { if len(o.CVOPrometheusURL) > 0 && !o.RHOBSMonitoring && !o.EnableCVOManagementClusterMetricsAccess { errs = append(errs, fmt.Errorf("--cvo-prometheus-url requires either --rhobs-monitoring or --enable-cvo-management-cluster-metrics-access to be enabled")) } + // RHOBS can be enabled through the flag or through additional operator env vars; + // match the operator Deployment builder's normalization when checking the GMP conflict. + rhobsMonitoring := o.RHOBSMonitoring + for key, value := range o.AdditionalOperatorEnvVars { + if strings.TrimSpace(key) == rhobsmonitoring.EnvironmentVariable { + rhobsMonitoring = rhobsMonitoring || strings.TrimSpace(value) == "1" + } + } + if err := metrics.ValidateGMPMonitoring(o.EnableGMPMonitoring, rhobsMonitoring); err != nil { + errs = append(errs, fmt.Errorf("--enable-gmp-monitoring: %w", err)) + } return errs } @@ -522,6 +508,7 @@ func NewCommand(clientProviders ...*util.ClientProvider) *cobra.Command { cmd.Flags().BoolVar(&opts.WaitUntilAvailable, "wait-until-available", opts.WaitUntilAvailable, "If true, pauses installation until hypershift operator has been rolled out and its webhook service is available (if installing the webhook)") cmd.Flags().BoolVar(&opts.WaitUntilEstablished, "wait-until-crds-established", opts.WaitUntilEstablished, "If true, pauses installation until all custom resource definitions are established before applying other manifests.") cmd.PersistentFlags().BoolVar(&opts.RHOBSMonitoring, "rhobs-monitoring", opts.RHOBSMonitoring, "If true, HyperShift will generate and use the RHOBS version of monitoring resources (ServiceMonitors, PodMonitors, etc). For ROSA HCP, this also enables the Cluster Version Operator to query the RHOBS Prometheus for conditional update evaluation. Use --cvo-prometheus-url to override the default Prometheus endpoint.") + cmd.PersistentFlags().BoolVar(&opts.EnableGMPMonitoring, "enable-gmp-monitoring", opts.EnableGMPMonitoring, "If true, HyperShift will use Google Managed Prometheus (monitoring.googleapis.com) monitoring resources instead of the default monitoring.coreos.com resources. Cannot be combined with --rhobs-monitoring.") cmd.PersistentFlags().StringVar(&opts.CVOPrometheusURL, "cvo-prometheus-url", opts.CVOPrometheusURL, "Prometheus URL for the Cluster Version Operator to query metrics for conditional update risk evaluation. Only effective when --rhobs-monitoring or --enable-cvo-management-cluster-metrics-access is enabled. If not specified, defaults to the RHOBS monitoring stack for ROSA HCP, or the Thanos querier for self-managed HyperShift.") cmd.PersistentFlags().BoolVar(&opts.SLOsAlerts, "slos-alerts", opts.SLOsAlerts, "If true, HyperShift will generate and use the prometheus alerts for monitoring HostedCluster and NodePools") cmd.PersistentFlags().BoolVar(&opts.MonitoringDashboards, "monitoring-dashboards", opts.MonitoringDashboards, "If true, HyperShift will generate a monitoring dashboard for every HostedCluster that it creates") @@ -1431,7 +1418,7 @@ func setupOperatorResources(opts Options, userCABundleCM *corev1.ConfigMap, trus IncludeVersion: !opts.Template, UWMTelemetry: opts.EnableUWMTelemetryRemoteWrite, RHOBSMonitoring: opts.RHOBSMonitoring, - MonitoringAPIGroup: opts.MonitoringAPIGroup, + EnableGMPMonitoring: opts.EnableGMPMonitoring, CVOPrometheusURL: opts.CVOPrometheusURL, MonitoringDashboards: opts.MonitoringDashboards, CertRotationScale: opts.CertRotationScale, diff --git a/cmd/install/install_test.go b/cmd/install/install_test.go index 0608b01e934b..4b2620d6fd6e 100644 --- a/cmd/install/install_test.go +++ b/cmd/install/install_test.go @@ -24,6 +24,7 @@ import ( "github.com/openshift/hypershift/hypershift-operator/controllers/sharedingress" hyperapi "github.com/openshift/hypershift/support/api" "github.com/openshift/hypershift/support/metrics" + "github.com/openshift/hypershift/support/rhobsmonitoring" configv1 "github.com/openshift/api/config/v1" operatorv1alpha1 "github.com/openshift/api/operator/v1alpha1" @@ -1922,9 +1923,10 @@ func TestValidateExternalDNSConfig(t *testing.T) { func TestValidateMonitoringConfig(t *testing.T) { tests := []struct { - name string - opts Options - expectError bool + name string + opts Options + expectError bool + errorContains string }{ { name: "When no monitoring options are set, it should pass", @@ -1954,13 +1956,59 @@ func TestValidateMonitoringConfig(t *testing.T) { }, expectError: false, }, + { + name: "When GMP monitoring is enabled alone, it should pass", + opts: Options{EnableGMPMonitoring: true}, + expectError: false, + }, + { + name: "When GMP monitoring is enabled with the RHOBS flag, it should fail", + opts: Options{EnableGMPMonitoring: true, RHOBSMonitoring: true}, + expectError: true, + errorContains: "--enable-gmp-monitoring", + }, + { + name: "When GMP monitoring is enabled with RHOBS via additional operator env vars, it should fail", + opts: Options{EnableGMPMonitoring: true, AdditionalOperatorEnvVars: map[string]string{" " + rhobsmonitoring.EnvironmentVariable + " ": " 1 "}}, + expectError: true, + errorContains: "--enable-gmp-monitoring", + }, + { + name: "When GMP monitoring is enabled with RHOBS disabled via additional operator env vars, it should pass", + opts: Options{EnableGMPMonitoring: true, AdditionalOperatorEnvVars: map[string]string{rhobsmonitoring.EnvironmentVariable: "0"}}, + expectError: false, + }, + { + name: "When GMP monitoring is enabled with the RHOBS flag and RHOBS disabled via additional operator env vars, it should fail", + opts: Options{EnableGMPMonitoring: true, RHOBSMonitoring: true, AdditionalOperatorEnvVars: map[string]string{rhobsmonitoring.EnvironmentVariable: "0"}}, + expectError: true, + errorContains: "--enable-gmp-monitoring", + }, + { + name: "When GMP monitoring is enabled with a blank RHOBS additional operator env var, it should pass", + opts: Options{EnableGMPMonitoring: true, AdditionalOperatorEnvVars: map[string]string{rhobsmonitoring.EnvironmentVariable: " "}}, + expectError: false, + }, + { + name: "When GMP monitoring is enabled with a non-1 RHOBS additional operator env var, it should pass", + opts: Options{EnableGMPMonitoring: true, AdditionalOperatorEnvVars: map[string]string{rhobsmonitoring.EnvironmentVariable: "true"}}, + expectError: false, + }, + { + name: "When GMP monitoring is enabled with duplicate RHOBS additional operator env vars after trimming, it should fail", + opts: Options{EnableGMPMonitoring: true, AdditionalOperatorEnvVars: map[string]string{rhobsmonitoring.EnvironmentVariable: "0", " " + rhobsmonitoring.EnvironmentVariable: "1"}}, + expectError: true, + errorContains: "--enable-gmp-monitoring", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { g := NewGomegaWithT(t) errs := tc.opts.validateMonitoringConfig() - if tc.expectError { + if tc.errorContains != "" { + g.Expect(errs).To(ContainElement(MatchError(ContainSubstring(tc.errorContains)))) + } else if tc.expectError { g.Expect(errs).NotTo(BeEmpty()) } else { g.Expect(errs).To(BeEmpty()) @@ -2723,6 +2771,53 @@ func TestHyperShiftOperatorManifests_WebhookFlags(t *testing.T) { } } +func TestHyperShiftOperatorManifests_GMPMonitoringFlag(t *testing.T) { + tests := []struct { + name string + enableGMPMonitoring bool + }{ + { + name: "When GMP monitoring is enabled, it should pass the flag to the operator deployment", + enableGMPMonitoring: true, + }, + { + name: "When GMP monitoring is disabled, it should not pass the flag to the operator deployment", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewGomegaWithT(t) + opts := Options{ + PrivatePlatform: string(hyperv1.NonePlatform), + HyperShiftImage: "test-image", + Namespace: "hypershift", + RenderNamespace: true, + EnableGMPMonitoring: tc.enableGMPMonitoring, + } + + _, objects, err := hyperShiftOperatorManifests(t.Context(), nil, opts) + g.Expect(err).NotTo(HaveOccurred()) + + var deployment *appsv1.Deployment + for _, obj := range objects { + if d, ok := obj.(*appsv1.Deployment); ok { + deployment = d + break + } + } + g.Expect(deployment).NotTo(BeNil(), "should find operator deployment") + + args := deployment.Spec.Template.Spec.Containers[0].Args + if tc.enableGMPMonitoring { + g.Expect(args).To(ContainElement("--enable-gmp-monitoring")) + } else { + g.Expect(args).NotTo(ContainElement("--enable-gmp-monitoring")) + } + }) + } +} + func TestAwsRoleCredentialFileContent(t *testing.T) { tests := map[string]struct { roleARN string diff --git a/cmd/install/monitoring_api_group_test.go b/cmd/install/monitoring_api_group_test.go deleted file mode 100644 index 965841dce334..000000000000 --- a/cmd/install/monitoring_api_group_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package install - -import ( - "testing" - - hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" - "github.com/openshift/hypershift/support/metrics" - "github.com/openshift/hypershift/support/rhobsmonitoring" - - "github.com/stretchr/testify/require" -) - -func TestValidate(t *testing.T) { - for _, tc := range []struct { - name string - option metrics.MonitoringAPIGroup - env, rhobsEnv string - rhobsFlag bool - additionalEnv map[string]string - want metrics.MonitoringAPIGroup - errorContains string - }{ - {name: "When monitoring selection is unset, it should default to CoreOS", want: metrics.MonitoringAPIGroupCoreOS}, - {name: "When CoreOS is pre-populated, it should accept CoreOS", option: metrics.MonitoringAPIGroupCoreOS, want: metrics.MonitoringAPIGroupCoreOS}, - {name: "When GMP is pre-populated, it should accept GMP", option: metrics.MonitoringAPIGroupGMP, want: metrics.MonitoringAPIGroupGMP}, - {name: "When the environment selects GMP, it should accept GMP", env: string(metrics.MonitoringAPIGroupGMP), want: metrics.MonitoringAPIGroupGMP}, - {name: "When a pre-populated group is invalid, it should reject it", option: "unsupported", errorContains: "unsupported MONITORING_API_GROUP value"}, - {name: "When the environment group is invalid, it should reject it", env: "unsupported", errorContains: "unsupported MONITORING_API_GROUP value"}, - {name: "When the explicit group overrides an invalid environment, it should validate the explicit group", option: metrics.MonitoringAPIGroupCoreOS, env: "unsupported", want: metrics.MonitoringAPIGroupCoreOS}, - {name: "When only the installer RHOBS environment is enabled with GMP, it should accept GMP", env: string(metrics.MonitoringAPIGroupGMP), rhobsEnv: "1", want: metrics.MonitoringAPIGroupGMP}, - {name: "When GMP and RHOBS environment are selected with the flag, it should reject the conflict", env: string(metrics.MonitoringAPIGroupGMP), rhobsEnv: "1", rhobsFlag: true, errorContains: "cannot be used with RHOBS monitoring"}, - {name: "When explicit GMP has only the installer RHOBS environment enabled, it should accept GMP", option: metrics.MonitoringAPIGroupGMP, rhobsEnv: "1", want: metrics.MonitoringAPIGroupGMP}, - {name: "When GMP conflicts with the RHOBS flag alone, it should reject the conflict", option: metrics.MonitoringAPIGroupGMP, rhobsFlag: true, errorContains: "cannot be used with RHOBS monitoring"}, - {name: "When RHOBS is enabled with the default group, it should preserve RHOBS validation", rhobsEnv: "1", rhobsFlag: true, want: metrics.MonitoringAPIGroupCoreOS}, - {name: "When the RHOBS flag is used without its environment, it should retain the existing error", rhobsFlag: true, errorContains: "RHOBS_MONITORING environment variable must be set"}, - {name: "When the managed group is set via additional operator env vars, it should reject the override", additionalEnv: map[string]string{metrics.MonitoringAPIGroupEnvVar: string(metrics.MonitoringAPIGroupGMP)}, errorContains: "must not be set via --additional-operator-env-vars"}, - {name: "When an additional managed group key contains whitespace, it should reject the override", additionalEnv: map[string]string{" \t" + metrics.MonitoringAPIGroupEnvVar + " \t": "unsupported"}, errorContains: "must not be set via --additional-operator-env-vars"}, - {name: "When GMP conflicts with additional RHOBS environment, it should reject the conflict", option: metrics.MonitoringAPIGroupGMP, additionalEnv: map[string]string{rhobsmonitoring.EnvironmentVariable: "1"}, errorContains: "cannot be used with RHOBS monitoring"}, - {name: "When additional RHOBS environment is disabled despite the installer environment, it should accept GMP", option: metrics.MonitoringAPIGroupGMP, rhobsEnv: "1", additionalEnv: map[string]string{rhobsmonitoring.EnvironmentVariable: "0"}, want: metrics.MonitoringAPIGroupGMP}, - {name: "When additional RHOBS keys and values contain whitespace with GMP, it should reject the conflict", option: metrics.MonitoringAPIGroupGMP, additionalEnv: map[string]string{" \t" + rhobsmonitoring.EnvironmentVariable + " \t": " \t1 \t"}, errorContains: "cannot be used with RHOBS monitoring"}, - {name: "When additional RHOBS environment is disabled with GMP, it should accept GMP", option: metrics.MonitoringAPIGroupGMP, additionalEnv: map[string]string{rhobsmonitoring.EnvironmentVariable: "0"}, want: metrics.MonitoringAPIGroupGMP}, - {name: "When additional RHOBS environment uses the default group, it should retain CoreOS selection", additionalEnv: map[string]string{rhobsmonitoring.EnvironmentVariable: "1"}, want: metrics.MonitoringAPIGroupCoreOS}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Setenv(metrics.MonitoringAPIGroupEnvVar, tc.env) - t.Setenv(rhobsmonitoring.EnvironmentVariable, tc.rhobsEnv) - opts := Options{PrivatePlatform: string(hyperv1.NonePlatform), MonitoringAPIGroup: tc.option, RHOBSMonitoring: tc.rhobsFlag, AdditionalOperatorEnvVars: tc.additionalEnv} - // Complete resolves the group from the flag or environment; Validate only checks it. - require.NoError(t, opts.Complete()) - err := opts.Validate() - if tc.errorContains != "" { - require.ErrorContains(t, err, tc.errorContains) - } else { - require.NoError(t, err) - require.Equal(t, tc.want, opts.MonitoringAPIGroup) - } - }) - } -} diff --git a/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go b/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go index ddfe199f505e..170cdce5bb57 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go +++ b/control-plane-operator/controllers/hostedcontrolplane/hostedcontrolplane_controller.go @@ -199,7 +199,7 @@ type HostedControlPlaneReconciler struct { OperateOnReleaseImage string DefaultIngressDomain string MetricsSet metrics.MetricsSet - MonitoringAPIGroup metrics.MonitoringAPIGroup + EnableGMPMonitoring bool KASHealthMetrics *kas.KASHealthMetrics SREConfigHash string ec2Client awsapi.EC2API @@ -1429,7 +1429,7 @@ func (r *HostedControlPlaneReconciler) reconcileCPOV2(ctx context.Context, hcp * SetDefaultSecurityContext: r.SetDefaultSecurityContext, DefaultSecurityContextUID: r.DefaultSecurityContextUID, MetricsSet: r.MetricsSet, - MonitoringAPIGroup: r.MonitoringAPIGroup, + EnableGMPMonitoring: r.EnableGMPMonitoring, EnableCIDebugOutput: r.EnableCIDebugOutput, ImageMetadataProvider: r.ImageMetadataProvider, NativeSidecarContainersEnabled: r.ManagementClusterCapabilities.Has(capabilities.CapabilityNativeSidecarContainers), diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment.go b/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment.go index 2006c9b153c7..71119ea5b8ed 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment.go +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment.go @@ -30,13 +30,6 @@ const ( func (cpo *ControlPlaneOperatorOptions) adaptDeployment(cpContext component.WorkloadContext, deployment *appsv1.Deployment) error { hcp := cpContext.HCP - // Validate the monitoring API group before propagating it so an invalid value or - // GMP/RHOBS conflict fails here rather than crashlooping the CPO it is stamped onto. - monitoringAPIGroup, err := metrics.MonitoringAPIGroupFromEnv() - if err != nil { - return err - } - podspec.UpdateContainer(ComponentName, deployment.Spec.Template.Spec.Containers, func(c *corev1.Container) { c.Image = cpo.Image @@ -59,6 +52,12 @@ func (cpo *ControlPlaneOperatorOptions) adaptDeployment(cpContext component.Work ) } + // The HostedCluster controller only enables GMP monitoring for CPO images that declare support, + // since older CPO images reject unknown flags. + if cpContext.EnableGMPMonitoring { + c.Args = append(c.Args, "--enable-gmp-monitoring") + } + c.Env = append(c.Env, []corev1.EnvVar{ { Name: "CONTROL_PLANE_OPERATOR_IMAGE", @@ -129,12 +128,6 @@ func (cpo *ControlPlaneOperatorOptions) adaptDeployment(cpContext component.Work }, ) } - if monitoringAPIGroup != metrics.MonitoringAPIGroupCoreOS { - c.Env = append(c.Env, corev1.EnvVar{ - Name: metrics.MonitoringAPIGroupEnvVar, - Value: string(monitoringAPIGroup), - }) - } if os.Getenv("ENABLE_SIZE_TAGGING") == "1" { c.Env = append(c.Env, corev1.EnvVar{ diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment_test.go index 3b78b6554008..d528004acff3 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment_test.go +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/controlplaneoperator/deployment_test.go @@ -8,8 +8,6 @@ import ( hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" controlplanecomponent "github.com/openshift/hypershift/support/controlplane-component" - "github.com/openshift/hypershift/support/metrics" - "github.com/openshift/hypershift/support/rhobsmonitoring" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -163,42 +161,50 @@ func TestAdaptDeploymentWatchListClientEnv(t *testing.T) { } } -func TestAdaptDeployment(t *testing.T) { - for _, tc := range []struct { - name, group, rhobs string - wantGroup bool - wantErr bool +func TestAdaptDeploymentGMPMonitoringArg(t *testing.T) { + testCases := []struct { + name string + enableGMPMonitoring bool + expectPresent bool }{ - {name: "When GMP is selected, it should propagate the monitoring API group", group: string(metrics.MonitoringAPIGroupGMP), wantGroup: true}, - {name: "When monitoring selection is unset, it should preserve the default environment"}, - {name: "When CoreOS is explicit, it should preserve the default environment", group: string(metrics.MonitoringAPIGroupCoreOS)}, - {name: "When RHOBS uses CoreOS selection, it should preserve the RHOBS environment", group: string(metrics.MonitoringAPIGroupCoreOS), rhobs: "1"}, - {name: "When GMP conflicts with RHOBS, it should fail before mutating the deployment", group: string(metrics.MonitoringAPIGroupGMP), rhobs: "1", wantErr: true}, - {name: "When the monitoring API group is unsupported, it should fail before mutating the deployment", group: "unsupported", wantErr: true}, - } { + { + name: "When GMP monitoring is enabled, it should pass the flag", + enableGMPMonitoring: true, + expectPresent: true, + }, + { + name: "When GMP monitoring is disabled, it should not pass the flag", + }, + } + + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { g := NewGomegaWithT(t) - t.Setenv(metrics.MonitoringAPIGroupEnvVar, tc.group) - t.Setenv(rhobsmonitoring.EnvironmentVariable, tc.rhobs) - cpo := &ControlPlaneOperatorOptions{HostedCluster: &hyperv1.HostedCluster{}, Image: "quay.io/openshift-release-dev/ocp-v4.0-art-dev:control-plane-operator"} - cpContext := controlplanecomponent.WorkloadContext{Context: t.Context(), HCP: &hyperv1.HostedControlPlane{}} - deployment, err := assets.LoadDeploymentManifest(ComponentName) - g.Expect(err).NotTo(HaveOccurred()) - if tc.wantErr { - g.Expect(cpo.adaptDeployment(cpContext, deployment)).NotTo(Succeed()) - // The invalid selection must fail fast without stamping the group onto the deployment. - g.Expect(deployment.Spec.Template.Spec.Containers[0].Env).NotTo(ContainElement(HaveField("Name", metrics.MonitoringAPIGroupEnvVar))) - return + + cpo := &ControlPlaneOperatorOptions{ + HostedCluster: &hyperv1.HostedCluster{}, + Image: "test-image:latest", + UtilitiesImage: "utilities:latest", + HasUtilities: true, } - g.Expect(cpo.adaptDeployment(cpContext, deployment)).To(Succeed()) - env := deployment.Spec.Template.Spec.Containers[0].Env - if tc.wantGroup { - g.Expect(env).To(ContainElement(corev1.EnvVar{Name: metrics.MonitoringAPIGroupEnvVar, Value: tc.group})) - } else { - g.Expect(env).NotTo(ContainElement(HaveField("Name", metrics.MonitoringAPIGroupEnvVar))) + + cpContext := controlplanecomponent.WorkloadContext{ + Context: t.Context(), + HCP: &hyperv1.HostedControlPlane{}, + EnableGMPMonitoring: tc.enableGMPMonitoring, } - if tc.rhobs == "1" { - g.Expect(env).To(ContainElement(corev1.EnvVar{Name: rhobsmonitoring.EnvironmentVariable, Value: "1"})) + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = cpo.adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + args := deployment.Spec.Template.Spec.Containers[0].Args + if tc.expectPresent { + g.Expect(args).To(ContainElement("--enable-gmp-monitoring")) + } else { + g.Expect(args).ToNot(ContainElement("--enable-gmp-monitoring")) } }) } diff --git a/control-plane-operator/main.go b/control-plane-operator/main.go index 699a463855be..8639075d2149 100644 --- a/control-plane-operator/main.go +++ b/control-plane-operator/main.go @@ -44,6 +44,7 @@ import ( "github.com/openshift/hypershift/support/netutil" "github.com/openshift/hypershift/support/podspec" "github.com/openshift/hypershift/support/releaseinfo" + "github.com/openshift/hypershift/support/rhobsmonitoring" "github.com/openshift/hypershift/support/supportedversion" "github.com/openshift/hypershift/support/thirdparty/library-go/pkg/image/reference" "github.com/openshift/hypershift/support/upsert" @@ -337,6 +338,7 @@ func NewStartCommand() *cobra.Command { tokenMinterImage string inCluster bool enableCIDebugOutput bool + enableGMPMonitoring bool registryOverrides map[string]string imageOverridesStr string featureSet string @@ -354,6 +356,7 @@ func NewStartCommand() *cobra.Command { "cluster and will make some internal decisions to ease local development (e.g. using external endpoints where possible"+ "to avoid assuming access to the service network)") cmd.Flags().BoolVar(&enableCIDebugOutput, "enable-ci-debug-output", false, "If extra CI debug output should be enabled") + cmd.Flags().BoolVar(&enableGMPMonitoring, "enable-gmp-monitoring", false, "If true, emit Google Managed Prometheus (monitoring.googleapis.com) monitoring resources instead of monitoring.coreos.com resources") cmd.Flags().StringToStringVar(®istryOverrides, "registry-overrides", map[string]string{}, "registry-overrides contains the source registry string as a key and the destination registry string as value. Images before being applied are scanned for the source registry string and if found the string is replaced with the destination registry string. Format is: sr1=dr1,sr2=dr2") cmd.Flags().StringVar(&imageOverridesStr, "image-overrides", "", "List of images that should be used for a hosted cluster control plane instead of images from OpenShift release specified in HostedCluster. "+ @@ -521,12 +524,11 @@ func NewStartCommand() *cobra.Command { } setupLog.Info("Using metrics set", "set", metricsSet.String()) - monitoringAPIGroup, err := metrics.MonitoringAPIGroupFromEnv() - if err != nil { - setupLog.Error(err, "invalid monitoring API group") + if err := metrics.ValidateGMPMonitoring(enableGMPMonitoring, os.Getenv(rhobsmonitoring.EnvironmentVariable) == "1"); err != nil { + setupLog.Error(err, "invalid monitoring configuration") os.Exit(1) } - setupLog.Info("Using monitoring API group", "group", monitoringAPIGroup) + setupLog.Info("Using monitoring configuration", "gmpMonitoring", enableGMPMonitoring) enableCVOManagementClusterMetricsAccess := (os.Getenv(config.EnableCVOManagementClusterMetricsAccessEnvVar) == "1") @@ -540,7 +542,7 @@ func NewStartCommand() *cobra.Command { OperateOnReleaseImage: os.Getenv("OPERATE_ON_RELEASE_IMAGE"), DefaultIngressDomain: defaultIngressDomain, MetricsSet: metricsSet, - MonitoringAPIGroup: monitoringAPIGroup, + EnableGMPMonitoring: enableGMPMonitoring, KASHealthMetrics: kas.NewKASHealthMetrics(crmetrics.Registry), CertRotationScale: certRotationScale, EnableCVOManagementClusterMetricsAccess: enableCVOManagementClusterMetricsAccess, diff --git a/hack/gmp-api-gen/main.go b/hack/gmp-api-gen/main.go index 94bf427a40ca..3a148b5dea11 100644 --- a/hack/gmp-api-gen/main.go +++ b/hack/gmp-api-gen/main.go @@ -102,8 +102,11 @@ func generate(upstream, output string) error { if decl.Doc != nil { start = decl.Doc.Pos() } - source := sources[decl] - body.Write(source[fset.Position(start).Offset:fset.Position(decl.End()).Offset]) + source := sources[decl][fset.Position(start).Offset:fset.Position(decl.End()).Offset] + if err := rejectDirectives(source, fset.Position(start).String()); err != nil { + return err + } + body.Write(source) body.WriteString("\n\n") return nil } @@ -120,8 +123,11 @@ func generate(upstream, output string) error { } var result bytes.Buffer // Preserve the upstream license header and make provenance explicit. - licenseSource := sources[types["PodMonitoring"]] - result.Write(licenseSource[:packageOffsets[types["PodMonitoring"]]]) + licenseHeader := sources[types["PodMonitoring"]][:packageOffsets[types["PodMonitoring"]]] + if err := rejectDirectives(licenseHeader, "license header of "+fset.Position(types["PodMonitoring"].Pos()).Filename); err != nil { + return err + } + result.Write(licenseHeader) result.WriteString("// Code generated by hack/update-gmp-api.sh; DO NOT EDIT.\n\npackage gmpmonitoring\n\nimport (\n") aliases := make([]string, 0, len(usedImports)) for alias := range usedImports { @@ -141,6 +147,20 @@ func generate(upstream, output string) error { return os.WriteFile(filepath.Join(output, "types.go"), formatted, 0644) } +// rejectDirectives fails when copied upstream source contains Go tool or compiler directives. +// The snapshot is API data only, so directives such as go:generate or go:build must never be copied. +func rejectDirectives(source []byte, location string) error { + for _, line := range strings.Split(string(source), "\n") { + line = strings.TrimSpace(line) + for _, prefix := range []string{"//go:", "// +build", "//line ", "/*line ", "//export "} { + if strings.HasPrefix(line, prefix) { + return fmt.Errorf("upstream %s contains Go directive %q; review the upstream change", location, line) + } + } + } + return nil +} + func collectImports(decl *ast.GenDecl, imports, usedImports map[string]string) error { var importError error ast.Inspect(decl, func(node ast.Node) bool { diff --git a/hack/gmp-api-gen/main_test.go b/hack/gmp-api-gen/main_test.go index 18212bf1424f..1e008a43b127 100644 --- a/hack/gmp-api-gen/main_test.go +++ b/hack/gmp-api-gen/main_test.go @@ -133,9 +133,9 @@ func TestAPISourceFiles(t *testing.T) { func TestGenerate(t *testing.T) { for _, tc := range []struct { - name, nested, nestedImports, wantError string - extraFiles map[string]string - omitCondition bool + name, nested, nestedImports, podHeader, wantError string + extraFiles map[string]string + omitCondition bool }{ {name: "When nested data moves into another source file, it should copy only the data closure deterministically", nested: "struct { Labels map[string]string; Values []string }"}, {name: "When API data depends on a runtime interface, it should reject the dependency", nested: "interface { Reconcile() }", wantError: "unexpectedly depends on interface"}, @@ -145,6 +145,9 @@ func TestGenerate(t *testing.T) { {name: "When selected declarations conflict on an import alias, it should report the conflict", nested: "struct { Ref shared.SecretReference }", nestedImports: `import shared "k8s.io/api/core/v1"`, extraFiles: map[string]string{"rules_types.go": "package v1\nimport shared \"k8s.io/apimachinery/pkg/apis/meta/v1\"\ntype Rules struct { Selector shared.LabelSelector }\ntype RulesList struct { Items []Rules }"}, wantError: "conflicting upstream import alias shared"}, {name: "When the upstream condition constant is removed, it should report the missing declaration", nested: "string", omitCondition: true, wantError: "missing upstream monitoring condition constant"}, {name: "When a source file belongs to another package, it should fail explicitly", nested: "string", extraFiles: map[string]string{"other.go": "package other"}, wantError: "unexpected upstream package"}, + {name: "When a selected type's documentation contains a go:generate directive, it should reject the directive", nested: "string", extraFiles: map[string]string{"transport_types.go": "package v1\n// Nested data.\n//go:generate touch generated\ntype Nested string"}, wantError: `contains Go directive "//go:generate touch generated"`}, + {name: "When a selected type's field comment contains a go:generate directive, it should reject the directive", nested: "struct {\n\t//go:generate touch generated\n\tValue string\n}", wantError: "contains Go directive"}, + {name: "When the upstream license header contains a build constraint, it should reject the directive", nested: "string", podHeader: "//go:build ignore\n\n", wantError: "license header"}, } { t.Run(tc.name, func(t *testing.T) { upstream, output := t.TempDir(), t.TempDir() @@ -153,7 +156,7 @@ func TestGenerate(t *testing.T) { t.Fatal(err) } files := map[string]string{ - "pod_types.go": `// Copyright Google LLC + "pod_types.go": tc.podHeader + `// Copyright Google LLC package v1 import "coupled/operator" // PodMonitoring carries monitoring data. @@ -225,3 +228,28 @@ ConfigurationCreateSuccess MonitoringConditionType = "ConfigurationCreateSuccess }) } } + +func TestRejectDirectives(t *testing.T) { + for _, tc := range []struct{ name, source, wantError string }{ + {name: "When source contains only documentation and API markers, it should accept it", source: "// PodMonitoring carries data.\n// +kubebuilder:object:root=true\n// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object\ntype PodMonitoring struct{}"}, + {name: "When source contains a go:generate directive, it should reject it", source: "//go:generate touch generated", wantError: "//go:generate"}, + {name: "When source contains an indented go:generate directive, it should reject it", source: "type T struct {\n\t//go:generate touch generated\n}", wantError: "//go:generate"}, + {name: "When source contains a go:build constraint, it should reject it", source: "//go:build ignore", wantError: "//go:build"}, + {name: "When source contains a legacy build constraint, it should reject it", source: "// +build ignore", wantError: "// +build"}, + {name: "When source contains a line directive, it should reject it", source: "//line generated.go:1", wantError: "//line"}, + {name: "When source contains a cgo export directive, it should reject it", source: "//export Generated", wantError: "//export"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := rejectDirectives([]byte(tc.source), "types.go") + if tc.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("expected %q, got %v", tc.wantError, err) + } + return + } + if err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/hack/gmp-api-gen/provenance_test.go b/hack/gmp-api-gen/provenance_test.go index a161861c160d..189d614388e3 100644 --- a/hack/gmp-api-gen/provenance_test.go +++ b/hack/gmp-api-gen/provenance_test.go @@ -110,4 +110,42 @@ func TestWriteProvenance(t *testing.T) { } }) } + + // The committed provenance must match the pinned source module, so a pin change without + // make gmp-sync (for example an automated dependency update) fails offline unit tests. + t.Run("When the committed snapshot is checked, it should match the pinned source module", func(t *testing.T) { + const module = "github.com/GoogleCloudPlatform/prometheus-engine" + read := func(path string) string { + data, err := os.ReadFile(filepath.Join("..", "..", path)) + if err != nil { + t.Fatal(err) + } + return string(data) + } + var versions []string + for _, line := range strings.Split(read("hack/gmp-api-source/go.mod"), "\n") { + if fields := strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), "require ")); len(fields) >= 2 && fields[0] == module { + versions = append(versions, fields[1]) + } + } + if len(versions) != 1 { + t.Fatalf("hack/gmp-api-source/go.mod must require exactly one %s version, got %v; do not run go mod tidy in this download-only module", module, versions) + } + sums := map[string]string{} + for _, line := range strings.Split(read("hack/gmp-api-source/go.sum"), "\n") { + if fields := strings.Fields(line); len(fields) == 3 && fields[0] == module { + sums[fields[1]] = fields[2] + } + } + provenance := read("support/gmpmonitoring/upstream.sha256") + for _, want := range []string{ + "# Version: " + versions[0] + "\n", + "# Module sum: " + sums[versions[0]] + "\n", + "# go.mod sum: " + sums[versions[0]+"/go.mod"] + "\n", + } { + if !strings.Contains(provenance, want) { + t.Fatalf("support/gmpmonitoring/upstream.sha256 does not match hack/gmp-api-source (missing %q); run make gmp-sync", strings.TrimSpace(want)) + } + } + }) } diff --git a/hack/update-gmp-api.sh b/hack/update-gmp-api.sh index da8f69d5c734..16e7131dbde4 100755 --- a/hack/update-gmp-api.sh +++ b/hack/update-gmp-api.sh @@ -30,7 +30,10 @@ if len(versions) != 1: print(versions[0]) PY )" -if ! (cd "$scratch_dir" && GO111MODULE=on GOWORK=off GOFLAGS= go mod download -json "github.com/GoogleCloudPlatform/prometheus-engine@$upstream_version") > "$scratch_dir/source.json"; then +# Always authenticate the download against the public checksum database, even if the +# caller's environment exempts modules from it (GOPRIVATE, GONOSUMDB, GOSUMDB=off). +if ! (cd "$scratch_dir" && GO111MODULE=on GOWORK=off GOFLAGS= GOSUMDB=sum.golang.org GONOSUMDB= GOPRIVATE= GOINSECURE= \ + go mod download -json "github.com/GoogleCloudPlatform/prometheus-engine@$upstream_version") > "$scratch_dir/source.json"; then cat "$scratch_dir/source.json" >&2 exit 1 fi diff --git a/hypershift-operator/controllers/hostedcluster/hostedcluster_controller.go b/hypershift-operator/controllers/hostedcluster/hostedcluster_controller.go index 0f01306a49f5..bdf882dad382 100644 --- a/hypershift-operator/controllers/hostedcluster/hostedcluster_controller.go +++ b/hypershift-operator/controllers/hostedcluster/hostedcluster_controller.go @@ -138,6 +138,7 @@ const ( controlPlaneOperatorSubcommandsLabel = "io.openshift.hypershift.control-plane-operator-subcommands" controlPlaneOperatorSupportsKASCustomKubeconfigLabel = "io.openshift.hypershift.control-plane-operator-supports-kas-custom-kubeconfig" + controlPlaneOperatorSupportsGMPMonitoringLabel = "io.openshift.hypershift.control-plane-operator-supports-gmp-monitoring" controlPlaneOperatorAppliesManagementKASNetworkPolicyLabel = "io.openshift.hypershift.control-plane-operator-applies-management-kas-network-policy-label" controlPlanePKIOperatorSignsCSRsLabel = "io.openshift.hypershift.control-plane-pki-operator-signs-csrs" @@ -202,6 +203,10 @@ type HostedClusterReconciler struct { MetricsSet metrics.MetricsSet SREConfigHash string + // EnableGMPMonitoring selects Google Managed Prometheus monitoring resources + // instead of monitoring.coreos.com resources. + EnableGMPMonitoring bool + OperatorNamespace string RegistryProvider globalconfig.RegistryProvider @@ -1564,6 +1569,7 @@ func (r *HostedClusterReconciler) reconcile(ctx context.Context, req ctrl.Reques }) _, cpoSupportsKASCustomKubeconfig := controlPlaneOperatorImageLabels[controlPlaneOperatorSupportsKASCustomKubeconfigLabel] + enableGMPMonitoring := r.effectiveGMPMonitoring(log, controlPlaneOperatorImageLabels) _, controlPlaneOperatorAppliesManagementKASNetworkPolicyLabel := controlPlaneOperatorImageLabels[controlPlaneOperatorAppliesManagementKASNetworkPolicyLabel] _, controlPlanePKIOperatorSignsCSRs := controlPlaneOperatorImageLabels[controlPlanePKIOperatorSignsCSRsLabel] // Default to true when labels are unavailable (pull secret outage) — all @@ -1644,7 +1650,7 @@ func (r *HostedClusterReconciler) reconcile(ctx context.Context, req ctrl.Reques report.executeOrBlock("OperatorDeployments", func() error { return r.reconcileOperatorDeployments(ctx, createOrUpdate, hcluster, hcp, controlPlaneNamespace, p, controlPlaneOperatorImage, utilitiesImage, - cpoHasUtilities, r.CertRotationScale, releaseImage, releaseImageVersion, releaseProvider) + cpoHasUtilities, enableGMPMonitoring, r.CertRotationScale, releaseImage, releaseImageVersion, releaseProvider) }) report.executeOrBlock("RBACAndPolicies", func() error { @@ -1812,7 +1818,7 @@ func (r *HostedClusterReconciler) reconcileOperatorDeployments(ctx context.Conte hcluster *hyperv1.HostedCluster, hcp *hyperv1.HostedControlPlane, controlPlaneNamespace *corev1.Namespace, p platform.Platform, controlPlaneOperatorImage, utilitiesImage string, - cpoHasUtilities bool, certRotationScale time.Duration, + cpoHasUtilities, enableGMPMonitoring bool, certRotationScale time.Duration, releaseImage *releaseinfo.ReleaseImage, releaseImageVersion semver.Version, releaseProvider releaseinfo.ProviderWithOpenShiftImageRegistryOverrides, @@ -1842,6 +1848,7 @@ func (r *HostedClusterReconciler) reconcileOperatorDeployments(ctx context.Conte DefaultSecurityContextUID: securityContextUID, EnableCIDebugOutput: r.EnableCIDebugOutput, MetricsSet: metricsSet, + EnableGMPMonitoring: enableGMPMonitoring, ReleaseImageProvider: imageProvider, OmitOwnerReference: true, } @@ -5890,6 +5897,23 @@ func (r *HostedClusterReconciler) effectiveMetricsSet(monitoring hyperv1.Monitor return r.MetricsSet } +// effectiveGMPMonitoring returns whether a hosted control plane should use GMP monitoring. GMP is only +// used when the CPO image declares support, so the components rendered by the hypershift-operator and +// the CPO always emit monitors for the same API group. +func (r *HostedClusterReconciler) effectiveGMPMonitoring(log logr.Logger, controlPlaneOperatorImageLabels map[string]string) bool { + if !r.EnableGMPMonitoring { + return false + } + if _, supported := controlPlaneOperatorImageLabels[controlPlaneOperatorSupportsGMPMonitoringLabel]; !supported { + // Labels are nil when they could not be fetched; operator deployments are blocked in that case. + if controlPlaneOperatorImageLabels != nil { + log.Info("GMP monitoring is enabled but the control plane operator image does not support it, using monitoring.coreos.com resources") + } + return false + } + return true +} + // reconcileSREMetricsConfig loads the SRE metrics configuration (avoids parsing if the content is the same) // and ensures that it is synced to the hosted control plane func (r *HostedClusterReconciler) reconcileSREMetricsConfig(ctx context.Context, createOrUpdate upsert.CreateOrUpdateFN, hcp *hyperv1.HostedControlPlane) error { diff --git a/hypershift-operator/controllers/hostedcluster/hostedcluster_controller_test.go b/hypershift-operator/controllers/hostedcluster/hostedcluster_controller_test.go index 3d39fbc5cd75..69dfd600b81b 100644 --- a/hypershift-operator/controllers/hostedcluster/hostedcluster_controller_test.go +++ b/hypershift-operator/controllers/hostedcluster/hostedcluster_controller_test.go @@ -84,6 +84,7 @@ import ( "github.com/docker/distribution" "github.com/docker/distribution/manifest/manifestlist" "github.com/go-logr/logr" + "github.com/go-logr/logr/funcr" "github.com/google/go-cmp/cmp" orcv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1" "go.uber.org/mock/gomock" @@ -9737,3 +9738,49 @@ func TestReconcilePullSecretSync(t *testing.T) { }) } } + +func TestEffectiveGMPMonitoring(t *testing.T) { + supportedLabels := map[string]string{controlPlaneOperatorSupportsGMPMonitoringLabel: "true"} + testCases := []struct { + name string + enableGMPMonitoring bool + labels map[string]string + expected bool + expectLog bool + }{ + { + name: "When GMP monitoring is disabled, it should not use GMP monitoring", + labels: supportedLabels, + }, + { + name: "When GMP monitoring is enabled and the CPO image supports it, it should use GMP monitoring", + enableGMPMonitoring: true, + labels: supportedLabels, + expected: true, + }, + { + name: "When GMP monitoring is enabled and the CPO image does not support it, it should log and not use GMP monitoring", + enableGMPMonitoring: true, + labels: map[string]string{controlPlaneOperatorSubcommandsLabel: "true"}, + expectLog: true, + }, + { + name: "When GMP monitoring is enabled and the CPO image labels are unavailable, it should not use GMP monitoring", + enableGMPMonitoring: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + var logged []string + log := funcr.New(func(_, args string) { logged = append(logged, args) }, funcr.Options{}) + r := &HostedClusterReconciler{EnableGMPMonitoring: tc.enableGMPMonitoring} + g.Expect(r.effectiveGMPMonitoring(log, tc.labels)).To(Equal(tc.expected)) + if tc.expectLog { + g.Expect(logged).To(HaveLen(1)) + } else { + g.Expect(logged).To(BeEmpty()) + } + }) + } +} diff --git a/hypershift-operator/controllers/hostedcluster/reconcile_legacy.go b/hypershift-operator/controllers/hostedcluster/reconcile_legacy.go index 6b7bc9be62ca..6ec9a0092e08 100644 --- a/hypershift-operator/controllers/hostedcluster/reconcile_legacy.go +++ b/hypershift-operator/controllers/hostedcluster/reconcile_legacy.go @@ -1053,6 +1053,7 @@ func (r *HostedClusterReconciler) reconcileLegacy(ctx context.Context, req ctrl. utilitiesImage = r.HypershiftOperatorImage } + enableGMPMonitoring := r.effectiveGMPMonitoring(log, controlPlaneOperatorImageLabels) _, controlPlaneOperatorAppliesManagementKASNetworkPolicyLabel := controlPlaneOperatorImageLabels[controlPlaneOperatorAppliesManagementKASNetworkPolicyLabel] _, controlPlanePKIOperatorSignsCSRs := controlPlaneOperatorImageLabels[controlPlanePKIOperatorSignsCSRsLabel] _, useRestrictedPSA := controlPlaneOperatorImageLabels[useRestrictedPodSecurityLabel] @@ -1700,6 +1701,7 @@ func (r *HostedClusterReconciler) reconcileLegacy(ctx context.Context, req ctrl. DefaultSecurityContextUID: securityContextUID, EnableCIDebugOutput: r.EnableCIDebugOutput, MetricsSet: metricsSet, + EnableGMPMonitoring: enableGMPMonitoring, ReleaseImageProvider: imageProvider, OmitOwnerReference: true, } diff --git a/hypershift-operator/main.go b/hypershift-operator/main.go index 2a6e6e4e9f40..904cf766ff7f 100644 --- a/hypershift-operator/main.go +++ b/hypershift-operator/main.go @@ -66,6 +66,7 @@ import ( "github.com/openshift/hypershift/support/globalconfig" "github.com/openshift/hypershift/support/metrics" "github.com/openshift/hypershift/support/netutil" + "github.com/openshift/hypershift/support/rhobsmonitoring" "github.com/openshift/hypershift/support/supportedversion" "github.com/openshift/hypershift/support/tracing" "github.com/openshift/hypershift/support/upsert" @@ -164,6 +165,7 @@ type StartOptions struct { OIDCStorageProviderS3Credentials string GCPOIDCStorageBucketName string EnableUWMTelemetryRemoteWrite bool + EnableGMPMonitoring bool EnableValidatingWebhook bool EnableDedicatedRequestServingIsolation bool ScaleFromZeroProvider string @@ -209,6 +211,7 @@ func NewStartCommand() *cobra.Command { cmd.Flags().StringVar(&opts.OIDCStorageProviderS3Region, "oidc-storage-provider-s3-region", opts.OIDCStorageProviderS3Region, "Region in which the OIDC bucket is located. Required for AWS guest clusters") cmd.Flags().StringVar(&opts.OIDCStorageProviderS3Credentials, "oidc-storage-provider-s3-credentials", opts.OIDCStorageProviderS3Credentials, "Location of the credentials file for the OIDC bucket. Required for AWS guest clusters.") cmd.Flags().BoolVar(&opts.EnableUWMTelemetryRemoteWrite, "enable-uwm-telemetry-remote-write", opts.EnableUWMTelemetryRemoteWrite, "If true, enables a controller that ensures user workload monitoring is enabled and that it is configured to remote write telemetry metrics from control planes") + cmd.Flags().BoolVar(&opts.EnableGMPMonitoring, "enable-gmp-monitoring", opts.EnableGMPMonitoring, "If true, emit Google Managed Prometheus (monitoring.googleapis.com) monitoring resources instead of monitoring.coreos.com resources") cmd.Flags().BoolVar(&opts.EnableValidatingWebhook, "enable-validating-webhook", false, "Enable webhook for validating hypershift API types") cmd.Flags().BoolVar(&opts.EnableDedicatedRequestServingIsolation, "enable-dedicated-request-serving-isolation", true, "If true, enables scheduling of request serving components to dedicated nodes") cmd.Flags().StringVar(&opts.ScaleFromZeroProvider, "scale-from-zero-provider", opts.ScaleFromZeroProvider, "Platform type for scale-from-zero autoscaling (aws)") @@ -409,6 +412,10 @@ func validateStartOptions(opts *StartOptions, log logr.Logger) error { return fmt.Errorf("--etcd-backup-max-count must be at least 1, got %d", opts.EtcdBackupMaxCount) } + if err := metrics.ValidateGMPMonitoring(opts.EnableGMPMonitoring, os.Getenv(rhobsmonitoring.EnvironmentVariable) == "1"); err != nil { + return fmt.Errorf("--enable-gmp-monitoring: %w", err) + } + supportedProviders := set.New("aws", "azure") if opts.ScaleFromZeroCreds != "" { if opts.ScaleFromZeroProvider == "" { @@ -675,6 +682,7 @@ func setupHostedClusterController(ctx context.Context, mgr ctrl.Manager, opts *S EnableOCPClusterMonitoring: opts.EnableOCPClusterMonitoring, EnableCIDebugOutput: opts.EnableCIDebugOutput, MetricsSet: metricsSet, + EnableGMPMonitoring: opts.EnableGMPMonitoring, OperatorNamespace: opts.Namespace, SREConfigHash: sreConfigHash, KubevirtInfraClients: kvinfra.NewKubevirtInfraClientMap(), diff --git a/hypershift-operator/main_test.go b/hypershift-operator/main_test.go index 244bfda5b24c..8cfcd0899a78 100644 --- a/hypershift-operator/main_test.go +++ b/hypershift-operator/main_test.go @@ -5,8 +5,11 @@ import ( "net/http/httptest" "testing" + "github.com/go-logr/logr" . "github.com/onsi/gomega" + "github.com/openshift/hypershift/support/rhobsmonitoring" + "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/webhook" ) @@ -69,3 +72,31 @@ func TestSetupHealthChecks(t *testing.T) { }) } } + +func TestValidateStartOptions(t *testing.T) { + testCases := []struct { + name string + enableGMPMonitoring bool + rhobsEnv string + expectError bool + }{ + {name: "When GMP monitoring is disabled, it should pass"}, + {name: "When GMP monitoring is enabled without RHOBS, it should pass", enableGMPMonitoring: true}, + {name: "When RHOBS is enabled without GMP monitoring, it should pass", rhobsEnv: "1"}, + {name: "When GMP monitoring is enabled with RHOBS, it should fail", enableGMPMonitoring: true, rhobsEnv: "1", expectError: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewGomegaWithT(t) + t.Setenv(rhobsmonitoring.EnvironmentVariable, tc.rhobsEnv) + opts := &StartOptions{EtcdBackupMaxCount: 1, EnableGMPMonitoring: tc.enableGMPMonitoring} + err := validateStartOptions(opts, logr.Discard()) + if tc.expectError { + g.Expect(err).To(MatchError(ContainSubstring("--enable-gmp-monitoring"))) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + }) + } +} diff --git a/renovate.json b/renovate.json index 9bf71bb4a96f..5b6f09693735 100644 --- a/renovate.json +++ b/renovate.json @@ -100,6 +100,16 @@ "matchUpdateTypes": [ "patch" ] + }, + { + "description": "Disable updates (including vulnerability fixes) to the GMP API source pin; bump it deliberately with make gmp-sync, see support/gmpmonitoring/README.md", + "matchManagers": [ + "gomod" + ], + "matchFileNames": [ + "hack/gmp-api-source/go.mod" + ], + "enabled": false } ] } diff --git a/support/controlplane-component/controlplane-component.go b/support/controlplane-component/controlplane-component.go index 935865d2a64d..a6496ae53044 100644 --- a/support/controlplane-component/controlplane-component.go +++ b/support/controlplane-component/controlplane-component.go @@ -74,8 +74,8 @@ type ControlPlaneContext struct { EnableCIDebugOutput bool // MetricsSet specifies which metrics to use in the service/pod-monitors. MetricsSet metrics.MetricsSet - // MonitoringAPIGroup selects which monitoring API group to emit monitors for. - MonitoringAPIGroup metrics.MonitoringAPIGroup + // EnableGMPMonitoring selects Google Managed Prometheus monitoring resources instead of monitoring.coreos.com resources. + EnableGMPMonitoring bool // OmitOwnerReference determines whether the HCP OwnerReference should be omitted from resources deployed by this component. // This is useful when the component is not managed by the same HostedControlPlane controller like capi and the CPO itself. OmitOwnerReference bool @@ -112,7 +112,7 @@ type WorkloadContext struct { DefaultSecurityContextUID int64 EnableCIDebugOutput bool MetricsSet metrics.MetricsSet - MonitoringAPIGroup metrics.MonitoringAPIGroup + EnableGMPMonitoring bool // skip generation of certificates for unit tests SkipCertificateSigning bool @@ -130,7 +130,7 @@ func (cp *ControlPlaneContext) workloadContext() WorkloadContext { DefaultSecurityContextUID: cp.DefaultSecurityContextUID, EnableCIDebugOutput: cp.EnableCIDebugOutput, MetricsSet: cp.MetricsSet, - MonitoringAPIGroup: cp.MonitoringAPIGroup, + EnableGMPMonitoring: cp.EnableGMPMonitoring, ImageMetadataProvider: cp.ImageMetadataProvider, SkipCertificateSigning: cp.SkipCertificateSigning, } diff --git a/support/controlplane-component/controlplane-component_test.go b/support/controlplane-component/controlplane-component_test.go index fd05adfb7506..05dfcc847c0c 100644 --- a/support/controlplane-component/controlplane-component_test.go +++ b/support/controlplane-component/controlplane-component_test.go @@ -387,3 +387,21 @@ func componentsFakeObjects() ([]client.Object, error) { } return fakeObjects, nil } + +func TestWorkloadContext(t *testing.T) { + testCases := []struct { + name string + enableGMPMonitoring bool + }{ + {name: "When GMP monitoring is disabled, it should propagate the disabled value to the workload context"}, + {name: "When GMP monitoring is enabled, it should propagate the enabled value to the workload context", enableGMPMonitoring: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewGomegaWithT(t) + cp := &ControlPlaneContext{Context: t.Context(), EnableGMPMonitoring: tc.enableGMPMonitoring} + g.Expect(cp.workloadContext().EnableGMPMonitoring).To(Equal(tc.enableGMPMonitoring)) + }) + } +} diff --git a/support/controlplane-component/monitoring_api_group_test.go b/support/controlplane-component/monitoring_api_group_test.go deleted file mode 100644 index 92eafcafae1c..000000000000 --- a/support/controlplane-component/monitoring_api_group_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package controlplanecomponent - -import ( - "testing" - - "github.com/openshift/hypershift/support/metrics" - - "github.com/stretchr/testify/require" -) - -func TestWorkloadContext(t *testing.T) { - for _, tc := range []struct { - name string - group metrics.MonitoringAPIGroup - }{ - {name: "When monitoring selection is empty, it should preserve the empty context value"}, - {name: "When CoreOS is selected, it should thread CoreOS to the workload", group: metrics.MonitoringAPIGroupCoreOS}, - {name: "When GMP is selected, it should thread GMP to the workload", group: metrics.MonitoringAPIGroupGMP}, - } { - t.Run(tc.name, func(t *testing.T) { - cp := &ControlPlaneContext{Context: t.Context(), MetricsSet: metrics.MetricsSetSRE, MonitoringAPIGroup: tc.group} - workload := cp.workloadContext() - require.Equal(t, tc.group, workload.MonitoringAPIGroup) - require.Equal(t, cp.MetricsSet, workload.MetricsSet) - }) - } -} diff --git a/support/gmpmonitoring/README.md b/support/gmpmonitoring/README.md index 38c8a17cb720..a50850a6b3ef 100644 --- a/support/gmpmonitoring/README.md +++ b/support/gmpmonitoring/README.md @@ -33,9 +33,10 @@ download-only module; do not run `go mod tidy` there. HyperShift does not import the upstream operator package. Do not edit generated types, deepcopies or checksums by hand. -How candidate updates are detected remains an open maintenance decision. The -regeneration tooling does not monitor upstream releases or establish compatibility -with deployed GKE/GMP components. +Version bumps are deliberate. The pin should track the GMP version deployed by GKE, +not the latest upstream release, so `renovate.json` disables automated dependency +updates for `hack/gmp-api-source/go.mod`. The regeneration tooling does not monitor +upstream releases or establish compatibility with deployed GKE/GMP components. ### Regenerate the current snapshot @@ -57,7 +58,9 @@ to regenerate deepcopies offline. storage. It fails on missing, different or unexpected generated files without rewriting tracked files. It is a manual, maintainer-run check and is not wired into `make verify-ci` or the CI verify workflow; run it after any GMP snapshot, -tooling or dependency change. +tooling or dependency change. An offline unit test in `hack/gmp-api-gen` runs +with the regular unit tests and fails if `upstream.sha256` no longer matches the +version and checksums pinned in `hack/gmp-api-source`. ### Update to a newer upstream version @@ -81,6 +84,11 @@ by editing generated files. The extractor follows the type closure plus the monitoring condition constant group only. If an added type requires another constant, extend the generator deliberately. +The pinned source is always downloaded with checksum database verification +(`GOSUMDB=sum.golang.org`), regardless of local `GOPRIVATE`/`GONOSUMDB` settings. +Generation fails if copied upstream source contains Go directives such as +`//go:generate` or build constraints. + ## Compatibility checks The copied source version does not need to numerically match the deployed GMP @@ -88,7 +96,7 @@ image version. Compatibility depends on the fields HyperShift emits and the schemas and behavior supported by the deployed components. Record the GKE version, GMP operator image and validation performed when assessing compatibility. -Tests cover scheme registration in fresh CoreOS/GMP/RHOBS processes, actual +Tests cover scheme registration in fresh default (CoreOS) and RHOBS processes, actual HyperShift YAML/JSON serializer round trips (objects and lists, including status), deepcopy isolation and emitted objects against pinned upstream v1 OpenAPI schemas with negative controls. Schema fixtures are test input only; HyperShift does not diff --git a/support/gmpmonitoring/types_test.go b/support/gmpmonitoring/types_test.go index cda0514fb110..b4e4080fdfc5 100644 --- a/support/gmpmonitoring/types_test.go +++ b/support/gmpmonitoring/types_test.go @@ -10,9 +10,10 @@ import ( "testing" "time" + . "github.com/onsi/gomega" + hyperapi "github.com/openshift/hypershift/support/api" gmp "github.com/openshift/hypershift/support/gmpmonitoring" - "github.com/openshift/hypershift/support/metrics" "github.com/openshift/hypershift/support/rhobsmonitoring" corev1 "k8s.io/api/core/v1" @@ -28,7 +29,6 @@ import ( "github.com/go-openapi/strfmt" "github.com/go-openapi/validate" "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" ) func monitoringFixtures() (*gmp.PodMonitoring, *gmp.Rules) { @@ -68,20 +68,23 @@ func monitoringFixtures() (*gmp.PodMonitoring, *gmp.Rules) { } func TestAddToScheme(t *testing.T) { + g := NewWithT(t) scheme := runtime.NewScheme() - require.NoError(t, gmp.AddToScheme(scheme)) + g.Expect(gmp.AddToScheme(scheme)).To(Succeed()) for _, kind := range []string{"PodMonitoring", "PodMonitoringList", "Rules", "RulesList"} { t.Run("When "+kind+" is registered, it should construct a Kubernetes object", func(t *testing.T) { + g := NewWithT(t) obj, err := scheme.New(gmp.SchemeGroupVersion.WithKind(kind)) - require.NoError(t, err) - require.NotNil(t, obj.DeepCopyObject()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj.DeepCopyObject()).ToNot(BeNil()) }) } - require.False(t, scheme.Recognizes(gmp.SchemeGroupVersion.WithKind("ClusterPodMonitoring"))) - require.False(t, scheme.Recognizes(gmp.SchemeGroupVersion.WithKind("ClusterRules"))) + g.Expect(scheme.Recognizes(gmp.SchemeGroupVersion.WithKind("ClusterPodMonitoring"))).To(BeFalse()) + g.Expect(scheme.Recognizes(gmp.SchemeGroupVersion.WithKind("ClusterRules"))).To(BeFalse()) } func TestDeepCopy(t *testing.T) { + g := NewWithT(t) monitor, rules := monitoringFixtures() originalMonitor, originalRules := monitor.DeepCopy(), rules.DeepCopy() copy := monitor.DeepCopy() @@ -102,18 +105,18 @@ func TestDeepCopy(t *testing.T) { *group.Count = 99 group.SampleTargets[0].Labels["pod"] = "changed" *group.SampleTargets[0].LastError = "changed" - require.Equal(t, originalMonitor, monitor) + g.Expect(monitor).To(Equal(originalMonitor)) copiedRules := rules.DeepCopy() copiedRules.Spec.Groups[0].Rules[0].Labels["component"] = "changed" copiedRules.Spec.Groups[0].Rules[1].Annotations["summary"] = "changed" copiedRules.Status.Conditions[0].Reason = "changed" - require.Equal(t, originalRules, rules) + g.Expect(rules).To(Equal(originalRules)) monitorList := &gmp.PodMonitoringList{Items: []gmp.PodMonitoring{*monitor}} monitorList.DeepCopy().Items[0].Spec.Endpoints[0].TLS.CA.Secret.Name = "changed" - require.Equal(t, *originalMonitor, monitorList.Items[0]) + g.Expect(monitorList.Items[0]).To(Equal(*originalMonitor)) rulesList := &gmp.RulesList{Items: []gmp.Rules{*rules}} rulesList.DeepCopy().Items[0].Spec.Groups[0].Rules[0].Labels["component"] = "changed" - require.Equal(t, *originalRules, rulesList.Items[0]) + g.Expect(rulesList.Items[0]).To(Equal(*originalRules)) } func TestCompatibleYAMLEncode(t *testing.T) { @@ -124,6 +127,7 @@ func TestCompatibleYAMLEncode(t *testing.T) { } for _, obj := range objects { t.Run("When "+obj.GetObjectKind().GroupVersionKind().Kind+" is serialized, it should round trip specs and status", func(t *testing.T) { + g := NewWithT(t) for _, serializer := range []*runtimeSerializer{{encode: func(obj runtime.Object) ([]byte, error) { return hyperapi.CompatibleYAMLEncode(obj, hyperapi.YamlSerializer) }, decode: hyperapi.YamlSerializer.Decode}, @@ -133,14 +137,14 @@ func TestCompatibleYAMLEncode(t *testing.T) { return buf.Bytes(), err }, decode: hyperapi.JsonSerializer.Decode}} { encoded, err := serializer.encode(obj.DeepCopyObject()) - require.NoError(t, err) + g.Expect(err).ToNot(HaveOccurred()) decoded, gvk, err := serializer.decode(encoded, nil, nil) - require.NoError(t, err) - require.Equal(t, gmp.SchemeGroupVersion, gvk.GroupVersion()) - require.True(t, apiequality.Semantic.DeepEqual(obj, decoded), "round trip changed %T", obj) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(gvk.GroupVersion()).To(Equal(gmp.SchemeGroupVersion)) + g.Expect(apiequality.Semantic.DeepEqual(obj, decoded)).To(BeTrue(), "round trip changed %T", obj) decodedAll, _, err := hyperapi.AllMonitoringYamlSerializer.Decode(encoded, nil, nil) - require.NoError(t, err) - require.True(t, apiequality.Semantic.DeepEqual(obj, decodedAll), "all-monitoring round trip changed %T", obj) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(apiequality.Semantic.DeepEqual(obj, decodedAll)).To(BeTrue(), "all-monitoring round trip changed %T", obj) } }) } @@ -162,8 +166,9 @@ func TestValidateSchema(t *testing.T) { {name: "When typed Rules are emitted, it should conform to the upstream schema", fixture: "rules", object: rules, invalid: func(obj map[string]interface{}) { delete(obj["spec"].(map[string]interface{}), "groups") }}, } { t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) fixture, err := os.ReadFile(filepath.Join("testdata", tc.fixture+".crd.yaml")) - require.NoError(t, err) + g.Expect(err).ToNot(HaveOccurred()) var crd struct { Spec struct { Versions []struct { @@ -172,54 +177,56 @@ func TestValidateSchema(t *testing.T) { } } } - require.NoError(t, yaml.Unmarshal(fixture, &crd)) + g.Expect(yaml.Unmarshal(fixture, &crd)).To(Succeed()) var schema *spec.Schema for i := range crd.Spec.Versions { if crd.Spec.Versions[i].Name == "v1" { schema = &crd.Spec.Versions[i].Schema.OpenAPIV3Schema } } - require.NotNil(t, schema) + g.Expect(schema).ToNot(BeNil()) encoded, err := json.Marshal(tc.object) - require.NoError(t, err) + g.Expect(err).ToNot(HaveOccurred()) var obj map[string]interface{} - require.NoError(t, json.Unmarshal(encoded, &obj)) - require.NoError(t, validate.AgainstSchema(schema, obj, strfmt.Default)) + g.Expect(json.Unmarshal(encoded, &obj)).To(Succeed()) + g.Expect(validate.AgainstSchema(schema, obj, strfmt.Default)).To(Succeed()) tc.invalid(obj) - require.Error(t, validate.AgainstSchema(schema, obj, strfmt.Default), "negative control must be rejected") + g.Expect(validate.AgainstSchema(schema, obj, strfmt.Default)).ToNot(Succeed(), "negative control must be rejected") }) } } func TestSchemeInitialization(t *testing.T) { if os.Getenv("GMP_SCHEME_TEST_CHILD") == "1" { + g := NewWithT(t) for _, scheme := range []*runtime.Scheme{hyperapi.Scheme, hyperapi.AllMonitoringScheme} { - require.True(t, scheme.Recognizes(gmp.SchemeGroupVersion.WithKind("PodMonitoring"))) - require.True(t, scheme.Recognizes(gmp.SchemeGroupVersion.WithKind("Rules"))) + g.Expect(scheme.Recognizes(gmp.SchemeGroupVersion.WithKind("PodMonitoring"))).To(BeTrue()) + g.Expect(scheme.Recognizes(gmp.SchemeGroupVersion.WithKind("Rules"))).To(BeTrue()) } coreos := schema.GroupVersion{Group: "monitoring.coreos.com", Version: "v1"}.WithKind("ServiceMonitor") rhobs := rhobsmonitoring.SchemeGroupVersion.WithKind("ServiceMonitor") - require.Equal(t, os.Getenv(rhobsmonitoring.EnvironmentVariable) != "1", hyperapi.Scheme.Recognizes(coreos)) - require.Equal(t, os.Getenv(rhobsmonitoring.EnvironmentVariable) == "1", hyperapi.Scheme.Recognizes(rhobs)) - require.True(t, hyperapi.AllMonitoringScheme.Recognizes(coreos)) - require.Equal(t, os.Getenv(rhobsmonitoring.EnvironmentVariable) == "1", hyperapi.AllMonitoringScheme.Recognizes(rhobs)) + rhobsEnabled := os.Getenv(rhobsmonitoring.EnvironmentVariable) == "1" + g.Expect(hyperapi.Scheme.Recognizes(coreos)).To(Equal(!rhobsEnabled)) + g.Expect(hyperapi.Scheme.Recognizes(rhobs)).To(Equal(rhobsEnabled)) + g.Expect(hyperapi.AllMonitoringScheme.Recognizes(coreos)).To(BeTrue()) + g.Expect(hyperapi.AllMonitoringScheme.Recognizes(rhobs)).To(Equal(rhobsEnabled)) return } - for _, tc := range []struct{ name, group, rhobs string }{ - {name: "When the default process initializes, it should retain CoreOS registration"}, - {name: "When a GMP process initializes, it should register GMP additively", group: string(metrics.MonitoringAPIGroupGMP)}, - {name: "When a RHOBS process initializes, it should retain RHOBS registration", rhobs: "1"}, + for _, tc := range []struct{ name, rhobs string }{ + {name: "When the default process initializes, it should register GMP alongside CoreOS"}, + {name: "When a RHOBS process initializes, it should register GMP alongside RHOBS", rhobs: "1"}, } { t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestSchemeInitialization$") for _, env := range os.Environ() { - if !strings.HasPrefix(env, metrics.MonitoringAPIGroupEnvVar+"=") && !strings.HasPrefix(env, rhobsmonitoring.EnvironmentVariable+"=") && !strings.HasPrefix(env, "GMP_SCHEME_TEST_CHILD=") { + if !strings.HasPrefix(env, rhobsmonitoring.EnvironmentVariable+"=") && !strings.HasPrefix(env, "GMP_SCHEME_TEST_CHILD=") { cmd.Env = append(cmd.Env, env) } } - cmd.Env = append(cmd.Env, "GMP_SCHEME_TEST_CHILD=1", metrics.MonitoringAPIGroupEnvVar+"="+tc.group, rhobsmonitoring.EnvironmentVariable+"="+tc.rhobs) + cmd.Env = append(cmd.Env, "GMP_SCHEME_TEST_CHILD=1", rhobsmonitoring.EnvironmentVariable+"="+tc.rhobs) output, err := cmd.CombinedOutput() - require.NoError(t, err, "%s", output) + g.Expect(err).ToNot(HaveOccurred(), "%s", output) }) } } diff --git a/support/metrics/gmp_monitoring.go b/support/metrics/gmp_monitoring.go new file mode 100644 index 000000000000..fdf0bbaba42f --- /dev/null +++ b/support/metrics/gmp_monitoring.go @@ -0,0 +1,14 @@ +package metrics + +import ( + "fmt" +) + +// ValidateGMPMonitoring rejects enabling Google Managed Prometheus monitoring +// together with RHOBS monitoring, since only one monitoring API can be emitted. +func ValidateGMPMonitoring(gmpMonitoring, rhobsMonitoring bool) error { + if gmpMonitoring && rhobsMonitoring { + return fmt.Errorf("GMP monitoring cannot be enabled together with RHOBS monitoring") + } + return nil +} diff --git a/support/metrics/gmp_monitoring_test.go b/support/metrics/gmp_monitoring_test.go new file mode 100644 index 000000000000..61e3d92cbe65 --- /dev/null +++ b/support/metrics/gmp_monitoring_test.go @@ -0,0 +1,30 @@ +package metrics + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestValidateGMPMonitoring(t *testing.T) { + for _, tc := range []struct { + name string + gmp, rhobs bool + errorContains string + }{ + {name: "When neither GMP nor RHOBS is enabled, it should succeed"}, + {name: "When only GMP is enabled, it should succeed", gmp: true}, + {name: "When only RHOBS is enabled, it should succeed", rhobs: true}, + {name: "When GMP and RHOBS are both enabled, it should reject the conflict", gmp: true, rhobs: true, errorContains: "cannot be enabled together with RHOBS monitoring"}, + } { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + err := ValidateGMPMonitoring(tc.gmp, tc.rhobs) + if tc.errorContains != "" { + g.Expect(err).To(MatchError(ContainSubstring(tc.errorContains))) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + }) + } +} diff --git a/support/metrics/monitoring_api_group.go b/support/metrics/monitoring_api_group.go deleted file mode 100644 index 8ff7c4f8017a..000000000000 --- a/support/metrics/monitoring_api_group.go +++ /dev/null @@ -1,42 +0,0 @@ -package metrics - -import ( - "fmt" - "os" - - "github.com/openshift/hypershift/support/rhobsmonitoring" -) - -// MonitoringAPIGroup selects which monitoring API group to use for emitting monitors. -type MonitoringAPIGroup string - -const ( - MonitoringAPIGroupCoreOS MonitoringAPIGroup = "monitoring.coreos.com" - MonitoringAPIGroupGMP MonitoringAPIGroup = "monitoring.googleapis.com" - MonitoringAPIGroupEnvVar = "MONITORING_API_GROUP" -) - -// MonitoringAPIGroupFromEnv reads MONITORING_API_GROUP from the environment, defaulting to -// monitoring.coreos.com when unset. -func MonitoringAPIGroupFromEnv() (MonitoringAPIGroup, error) { - return ParseMonitoringAPIGroup(os.Getenv(MonitoringAPIGroupEnvVar), os.Getenv(rhobsmonitoring.EnvironmentVariable) == "1") -} - -// ParseMonitoringAPIGroup defaults an empty value to CoreOS and rejects unsupported -// groups and GMP combined with RHOBS monitoring. -func ParseMonitoringAPIGroup(val string, rhobsMonitoring bool) (MonitoringAPIGroup, error) { - if val == "" { - return MonitoringAPIGroupCoreOS, nil - } - switch MonitoringAPIGroup(val) { - case MonitoringAPIGroupCoreOS: - return MonitoringAPIGroupCoreOS, nil - case MonitoringAPIGroupGMP: - if rhobsMonitoring { - return "", fmt.Errorf("%s=%s cannot be used with RHOBS monitoring; unset %s and disable --rhobs-monitoring to use GMP", MonitoringAPIGroupEnvVar, MonitoringAPIGroupGMP, rhobsmonitoring.EnvironmentVariable) - } - return MonitoringAPIGroup(val), nil - default: - return "", fmt.Errorf("unsupported %s value %q: must be %q or %q", MonitoringAPIGroupEnvVar, val, MonitoringAPIGroupCoreOS, MonitoringAPIGroupGMP) - } -} diff --git a/support/metrics/monitoring_api_group_test.go b/support/metrics/monitoring_api_group_test.go deleted file mode 100644 index 68bfc602887a..000000000000 --- a/support/metrics/monitoring_api_group_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package metrics - -import ( - "testing" - - "github.com/openshift/hypershift/support/rhobsmonitoring" - - "github.com/stretchr/testify/require" -) - -func TestParseMonitoringAPIGroup(t *testing.T) { - for _, tc := range []struct { - name string - value string - rhobs bool - want MonitoringAPIGroup - errorContains string - }{ - {name: "When the value is empty, it should default to CoreOS", want: MonitoringAPIGroupCoreOS}, - {name: "When CoreOS is selected, it should accept CoreOS", value: string(MonitoringAPIGroupCoreOS), want: MonitoringAPIGroupCoreOS}, - {name: "When GMP is selected, it should accept GMP", value: string(MonitoringAPIGroupGMP), want: MonitoringAPIGroupGMP}, - {name: "When an unsupported group is selected, it should reject it", value: "monitoring.rhobs", errorContains: "unsupported MONITORING_API_GROUP value"}, - {name: "When the group contains whitespace, it should reject it", value: " monitoring.googleapis.com", errorContains: "unsupported MONITORING_API_GROUP value"}, - {name: "When RHOBS uses the default, it should retain CoreOS selection", rhobs: true, want: MonitoringAPIGroupCoreOS}, - {name: "When RHOBS uses explicit CoreOS, it should accept it", value: string(MonitoringAPIGroupCoreOS), rhobs: true, want: MonitoringAPIGroupCoreOS}, - {name: "When RHOBS and GMP are selected, it should reject the conflict", value: string(MonitoringAPIGroupGMP), rhobs: true, errorContains: "cannot be used with RHOBS monitoring"}, - } { - t.Run(tc.name, func(t *testing.T) { - got, err := ParseMonitoringAPIGroup(tc.value, tc.rhobs) - if tc.errorContains != "" { - require.ErrorContains(t, err, tc.errorContains) - } else { - require.NoError(t, err) - } - require.Equal(t, tc.want, got) - }) - } -} - -func TestMonitoringAPIGroupFromEnv(t *testing.T) { - for _, tc := range []struct { - name, value, rhobs string - want MonitoringAPIGroup - errorContains string - }{ - {name: "When the environment is empty, it should default to CoreOS", want: MonitoringAPIGroupCoreOS}, - {name: "When the environment selects GMP, it should return GMP", value: string(MonitoringAPIGroupGMP), want: MonitoringAPIGroupGMP}, - {name: "When the environment selects an invalid group, it should fail", value: "unsupported", errorContains: "unsupported MONITORING_API_GROUP value"}, - {name: "When RHOBS is enabled with GMP, it should fail", value: string(MonitoringAPIGroupGMP), rhobs: "1", errorContains: "cannot be used with RHOBS monitoring"}, - {name: "When RHOBS has an inactive value with GMP, it should accept GMP", value: string(MonitoringAPIGroupGMP), rhobs: "true", want: MonitoringAPIGroupGMP}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Setenv(MonitoringAPIGroupEnvVar, tc.value) - t.Setenv(rhobsmonitoring.EnvironmentVariable, tc.rhobs) - got, err := MonitoringAPIGroupFromEnv() - if tc.errorContains != "" { - require.ErrorContains(t, err, tc.errorContains) - } else { - require.NoError(t, err) - } - require.Equal(t, tc.want, got) - }) - } -} From 82cfa187698ae952bdaf739dac229f6116d92176 Mon Sep 17 00:00:00 2001 From: Drew Walczyk Date: Tue, 27 Oct 2026 19:27:59 -0500 Subject: [PATCH 5/8] test(hypershift-operator): fix gci import grouping in main_test.go The go-logr/logr import landed in the dot-import group ahead of the openshift/hypershift group, violating the gci custom-order sections in .golangci.yml. Move it to the trailing default section so `make lint` passes. Signed-off-by: Drew Walczyk Assisted-by: LLM Signed-off-by: Drew Walczyk --- hypershift-operator/main_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hypershift-operator/main_test.go b/hypershift-operator/main_test.go index 8cfcd0899a78..ea1075a37ac0 100644 --- a/hypershift-operator/main_test.go +++ b/hypershift-operator/main_test.go @@ -5,13 +5,14 @@ import ( "net/http/httptest" "testing" - "github.com/go-logr/logr" . "github.com/onsi/gomega" "github.com/openshift/hypershift/support/rhobsmonitoring" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/webhook" + + "github.com/go-logr/logr" ) type fakeHealthCheckManager struct { From 387fae2bf071bbcc2739717c83c37783535dffd6 Mon Sep 17 00:00:00 2001 From: Drew Walczyk Date: Tue, 27 Oct 2026 19:28:05 -0500 Subject: [PATCH 6/8] chore(gmp-api-gen): split TestWriteProvenance to satisfy gocyclo TestWriteProvenance's two t.Run bodies pushed its cyclomatic complexity to 36, over the gocyclo limit of 30, because golangci-lint folds nested closures into the enclosing named function. Extract each t.Run body into its own named function (testWriteProvenanceCase, testWriteProvenancePinnedSnapshot) with no change in behavior, table entries, or subtest names. Also regenerate skills/restructure-commits/ownership.json via `make ownership-rules`: rebasing onto main picked up the restructure-commits tooling, and the new support/gmpmonitoring generated-file markers need rules so `make verify-git-clean` passes. Signed-off-by: Drew Walczyk Assisted-by: LLM Signed-off-by: Drew Walczyk --- hack/gmp-api-gen/provenance_test.go | 248 +++++++++++----------- skills/restructure-commits/ownership.json | 2 + 2 files changed, 129 insertions(+), 121 deletions(-) diff --git a/hack/gmp-api-gen/provenance_test.go b/hack/gmp-api-gen/provenance_test.go index 189d614388e3..6b5e0dabedeb 100644 --- a/hack/gmp-api-gen/provenance_test.go +++ b/hack/gmp-api-gen/provenance_test.go @@ -8,10 +8,12 @@ import ( "testing" ) +type provenanceCase struct { + name, field, value, remove, wantError string +} + func TestWriteProvenance(t *testing.T) { - for _, tc := range []struct { - name, field, value, remove, wantError string - }{ + for _, tc := range []provenanceCase{ {name: "When the source is pinned, it should record deterministic provenance and detect changed input contents"}, {name: "When the source module is unexpected, it should reject the metadata", field: "Path", value: "github.com/openshift/hypershift", wantError: "unexpected upstream module"}, {name: "When the version is missing, it should reject incomplete metadata", field: "Version", wantError: "incomplete"}, @@ -21,131 +23,135 @@ func TestWriteProvenance(t *testing.T) { {name: "When the revision is malformed, it should reject the metadata", field: "Hash", value: "not-a-git-revision", wantError: "full Git revision"}, {name: "When a CRD fixture is missing, it should fail before writing provenance", remove: "charts/operator/crds/monitoring.googleapis.com_rules.yaml", wantError: "no such file"}, } { - t.Run(tc.name, func(t *testing.T) { - upstream, output := t.TempDir(), t.TempDir() - for _, name := range []string{ - "pkg/operator/apis/monitoring/v1/pod_types.go", - "pkg/operator/apis/monitoring/v1/transport_types.go", - "pkg/operator/apis/monitoring/v1/ignored_test.go", - "charts/operator/crds/monitoring.googleapis.com_podmonitorings.yaml", - "charts/operator/crds/monitoring.googleapis.com_rules.yaml", - } { - if name == tc.remove { - continue - } - path := filepath.Join(upstream, name) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("source"), 0644); err != nil { - t.Fatal(err) - } - } - info := map[string]interface{}{ - "Path": "github.com/GoogleCloudPlatform/prometheus-engine", "Version": "v0.17.2", - "Sum": "h1:module", "GoModSum": "h1:module-file", - "Origin": map[string]string{"Hash": "058752ec791edefba5c42486eb9237562c3f0d29"}, - } - if tc.field == "Hash" { - info["Origin"].(map[string]string)["Hash"] = tc.value - } else if tc.field != "" { - info[tc.field] = tc.value - } - data, err := json.Marshal(info) - if err != nil { - t.Fatal(err) - } - sourceInfo := filepath.Join(t.TempDir(), "source.json") - if err := os.WriteFile(sourceInfo, data, 0644); err != nil { - t.Fatal(err) - } - err = writeProvenance(upstream, sourceInfo, output) - if tc.wantError != "" { - if err == nil || !strings.Contains(err.Error(), tc.wantError) { - t.Fatalf("expected %q, got %v", tc.wantError, err) - } - if _, err := os.Stat(filepath.Join(output, "upstream.sha256")); !os.IsNotExist(err) { - t.Fatal("failed generation should not write provenance") - } - return - } - if err != nil { - t.Fatal(err) - } - manifest := filepath.Join(output, "upstream.sha256") - first, err := os.ReadFile(manifest) - if err != nil { - t.Fatal(err) - } - for _, required := range []string{"# Version: v0.17.2", "# Revision: 058752ec791edefba5c42486eb9237562c3f0d29", "# Module sum: h1:module", "transport_types.go", "monitoring.googleapis.com_rules.yaml"} { - if !strings.Contains(string(first), required) { - t.Fatalf("missing provenance %q", required) - } - } - if strings.Contains(string(first), "ignored_test.go") { - t.Fatal("upstream tests are not extraction inputs") - } - if err := writeProvenance(upstream, sourceInfo, output); err != nil { - t.Fatal(err) - } - second, err := os.ReadFile(manifest) - if err != nil { - t.Fatal(err) - } - if string(first) != string(second) { - t.Fatal("provenance changed on identical inputs") - } - if err := os.WriteFile(filepath.Join(upstream, "pkg/operator/apis/monitoring/v1/transport_types.go"), []byte("changed"), 0644); err != nil { - t.Fatal(err) - } - if err := writeProvenance(upstream, sourceInfo, output); err != nil { - t.Fatal(err) - } - third, err := os.ReadFile(manifest) - if err != nil { - t.Fatal(err) - } - if string(first) == string(third) { - t.Fatal("changed input contents did not change the recorded checksum") - } - }) + t.Run(tc.name, func(t *testing.T) { testWriteProvenanceCase(t, tc) }) } // The committed provenance must match the pinned source module, so a pin change without // make gmp-sync (for example an automated dependency update) fails offline unit tests. - t.Run("When the committed snapshot is checked, it should match the pinned source module", func(t *testing.T) { - const module = "github.com/GoogleCloudPlatform/prometheus-engine" - read := func(path string) string { - data, err := os.ReadFile(filepath.Join("..", "..", path)) - if err != nil { - t.Fatal(err) - } - return string(data) + t.Run("When the committed snapshot is checked, it should match the pinned source module", testWriteProvenancePinnedSnapshot) +} + +func testWriteProvenanceCase(t *testing.T, tc provenanceCase) { + upstream, output := t.TempDir(), t.TempDir() + for _, name := range []string{ + "pkg/operator/apis/monitoring/v1/pod_types.go", + "pkg/operator/apis/monitoring/v1/transport_types.go", + "pkg/operator/apis/monitoring/v1/ignored_test.go", + "charts/operator/crds/monitoring.googleapis.com_podmonitorings.yaml", + "charts/operator/crds/monitoring.googleapis.com_rules.yaml", + } { + if name == tc.remove { + continue } - var versions []string - for _, line := range strings.Split(read("hack/gmp-api-source/go.mod"), "\n") { - if fields := strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), "require ")); len(fields) >= 2 && fields[0] == module { - versions = append(versions, fields[1]) - } + path := filepath.Join(upstream, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) } - if len(versions) != 1 { - t.Fatalf("hack/gmp-api-source/go.mod must require exactly one %s version, got %v; do not run go mod tidy in this download-only module", module, versions) + if err := os.WriteFile(path, []byte("source"), 0644); err != nil { + t.Fatal(err) + } + } + info := map[string]interface{}{ + "Path": "github.com/GoogleCloudPlatform/prometheus-engine", "Version": "v0.17.2", + "Sum": "h1:module", "GoModSum": "h1:module-file", + "Origin": map[string]string{"Hash": "058752ec791edefba5c42486eb9237562c3f0d29"}, + } + if tc.field == "Hash" { + info["Origin"].(map[string]string)["Hash"] = tc.value + } else if tc.field != "" { + info[tc.field] = tc.value + } + data, err := json.Marshal(info) + if err != nil { + t.Fatal(err) + } + sourceInfo := filepath.Join(t.TempDir(), "source.json") + if err := os.WriteFile(sourceInfo, data, 0644); err != nil { + t.Fatal(err) + } + err = writeProvenance(upstream, sourceInfo, output) + if tc.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("expected %q, got %v", tc.wantError, err) } - sums := map[string]string{} - for _, line := range strings.Split(read("hack/gmp-api-source/go.sum"), "\n") { - if fields := strings.Fields(line); len(fields) == 3 && fields[0] == module { - sums[fields[1]] = fields[2] - } + if _, err := os.Stat(filepath.Join(output, "upstream.sha256")); !os.IsNotExist(err) { + t.Fatal("failed generation should not write provenance") } - provenance := read("support/gmpmonitoring/upstream.sha256") - for _, want := range []string{ - "# Version: " + versions[0] + "\n", - "# Module sum: " + sums[versions[0]] + "\n", - "# go.mod sum: " + sums[versions[0]+"/go.mod"] + "\n", - } { - if !strings.Contains(provenance, want) { - t.Fatalf("support/gmpmonitoring/upstream.sha256 does not match hack/gmp-api-source (missing %q); run make gmp-sync", strings.TrimSpace(want)) - } + return + } + if err != nil { + t.Fatal(err) + } + manifest := filepath.Join(output, "upstream.sha256") + first, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{"# Version: v0.17.2", "# Revision: 058752ec791edefba5c42486eb9237562c3f0d29", "# Module sum: h1:module", "transport_types.go", "monitoring.googleapis.com_rules.yaml"} { + if !strings.Contains(string(first), required) { + t.Fatalf("missing provenance %q", required) } - }) + } + if strings.Contains(string(first), "ignored_test.go") { + t.Fatal("upstream tests are not extraction inputs") + } + if err := writeProvenance(upstream, sourceInfo, output); err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Fatal("provenance changed on identical inputs") + } + if err := os.WriteFile(filepath.Join(upstream, "pkg/operator/apis/monitoring/v1/transport_types.go"), []byte("changed"), 0644); err != nil { + t.Fatal(err) + } + if err := writeProvenance(upstream, sourceInfo, output); err != nil { + t.Fatal(err) + } + third, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + if string(first) == string(third) { + t.Fatal("changed input contents did not change the recorded checksum") + } +} + +func testWriteProvenancePinnedSnapshot(t *testing.T) { + const module = "github.com/GoogleCloudPlatform/prometheus-engine" + read := func(path string) string { + data, err := os.ReadFile(filepath.Join("..", "..", path)) + if err != nil { + t.Fatal(err) + } + return string(data) + } + var versions []string + for _, line := range strings.Split(read("hack/gmp-api-source/go.mod"), "\n") { + if fields := strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), "require ")); len(fields) >= 2 && fields[0] == module { + versions = append(versions, fields[1]) + } + } + if len(versions) != 1 { + t.Fatalf("hack/gmp-api-source/go.mod must require exactly one %s version, got %v; do not run go mod tidy in this download-only module", module, versions) + } + sums := map[string]string{} + for _, line := range strings.Split(read("hack/gmp-api-source/go.sum"), "\n") { + if fields := strings.Fields(line); len(fields) == 3 && fields[0] == module { + sums[fields[1]] = fields[2] + } + } + provenance := read("support/gmpmonitoring/upstream.sha256") + for _, want := range []string{ + "# Version: " + versions[0] + "\n", + "# Module sum: " + sums[versions[0]] + "\n", + "# go.mod sum: " + sums[versions[0]+"/go.mod"] + "\n", + } { + if !strings.Contains(provenance, want) { + t.Fatalf("support/gmpmonitoring/upstream.sha256 does not match hack/gmp-api-source (missing %q); run make gmp-sync", strings.TrimSpace(want)) + } + } } diff --git a/skills/restructure-commits/ownership.json b/skills/restructure-commits/ownership.json index 297fde919fc8..1f7d52dfb6c7 100644 --- a/skills/restructure-commits/ownership.json +++ b/skills/restructure-commits/ownership.json @@ -38,6 +38,7 @@ {"pattern": "cmd/install/assets/crds/cluster-api-provider-kubevirt/**", "group": "Vendor", "why": "marked as generated"}, {"pattern": "cmd/install/assets/crds/cluster-api-provider-openstack/**", "group": "Vendor", "why": "marked as generated"}, {"pattern": "cmd/install/assets/crds/external-dns/**", "group": "Vendor", "why": "marked as generated"}, + {"pattern": "support/gmpmonitoring/testdata/**", "group": "Vendor", "why": "marked as generated"}, {"pattern": "cmd/infra/aws/delegating_client.go", "group": "Vendor", "why": "marked as generated"}, {"pattern": "hack/tools/deadcode-report/testdata/reachability/generated.go", "group": "Vendor", "why": "marked as generated"}, {"pattern": "support/awsapi/ec2.go", "group": "Vendor", "why": "marked as generated"}, @@ -47,6 +48,7 @@ {"pattern": "support/awsapi/route53.go", "group": "Vendor", "why": "marked as generated"}, {"pattern": "support/awsapi/s3.go", "group": "Vendor", "why": "marked as generated"}, {"pattern": "support/awsapi/sqs.go", "group": "Vendor", "why": "marked as generated"}, + {"pattern": "support/gmpmonitoring/types.go", "group": "Vendor", "why": "marked as generated"}, {"pattern": "go.mod", "group": "Dependencies", "why": "definition of module root"}, {"pattern": "go.sum", "group": "Dependencies", "why": "definition of module root"}, {"pattern": "api/go.mod", "group": "API", "why": "definition of module api"}, From 1146c4d2cbdb6874af14e49f1c3ac94656f07994 Mon Sep 17 00:00:00 2001 From: Drew Walczyk Date: Tue, 27 Oct 2026 20:28:06 -0500 Subject: [PATCH 7/8] chore(restructure-commits): detect generated .sha256 provenance files is_generated() only sniffed .go files for the "// Code generated ... DO NOT EDIT." marker and .yaml/.yml files for the controller-gen CRD marker, so support/gmpmonitoring/upstream.sha256 fell through to the generic support/** rule (HO) instead of Vendor, even though it carries the same generated-file header (as "# Code generated ...") written by hack/update-gmp-api.sh. Add a GENERATED_HASH marker and a .sha256 branch in is_generated() and regenerate ownership.json, so upstream.sha256 is classified Vendor alongside the other gmp-api-gen generated artifacts (types.go, testdata/**). Signed-off-by: Drew Walczyk Assisted-by: LLM Signed-off-by: Drew Walczyk --- skills/restructure-commits/generate_ownership.py | 3 +++ skills/restructure-commits/ownership.json | 1 + 2 files changed, 4 insertions(+) diff --git a/skills/restructure-commits/generate_ownership.py b/skills/restructure-commits/generate_ownership.py index aaf5424afc5e..14ddb7d426f7 100644 --- a/skills/restructure-commits/generate_ownership.py +++ b/skills/restructure-commits/generate_ownership.py @@ -87,6 +87,7 @@ # ---- FACTS ------------------------------------------------------------------------------- GENERATED_GO = re.compile(rb"^// Code generated .* DO NOT EDIT\.$", re.M) +GENERATED_HASH = re.compile(rb"^# Code generated .* DO NOT EDIT\.$", re.M) CRD_MARKER = b"controller-gen.kubebuilder.io/version" MAKE_OUTPUT_DIRS = [ re.compile(r"output:crd:artifacts:config=(\S+)"), @@ -147,6 +148,8 @@ def is_generated(path: str) -> bool: return bool(GENERATED_GO.search(read_head(path, 2048))) if path.endswith((".yaml", ".yml")): return CRD_MARKER in read_head(path) + if path.endswith(".sha256"): + return bool(GENERATED_HASH.search(read_head(path, 2048))) return False diff --git a/skills/restructure-commits/ownership.json b/skills/restructure-commits/ownership.json index 1f7d52dfb6c7..ef1a3918f995 100644 --- a/skills/restructure-commits/ownership.json +++ b/skills/restructure-commits/ownership.json @@ -49,6 +49,7 @@ {"pattern": "support/awsapi/s3.go", "group": "Vendor", "why": "marked as generated"}, {"pattern": "support/awsapi/sqs.go", "group": "Vendor", "why": "marked as generated"}, {"pattern": "support/gmpmonitoring/types.go", "group": "Vendor", "why": "marked as generated"}, + {"pattern": "support/gmpmonitoring/upstream.sha256", "group": "Vendor", "why": "marked as generated"}, {"pattern": "go.mod", "group": "Dependencies", "why": "definition of module root"}, {"pattern": "go.sum", "group": "Dependencies", "why": "definition of module root"}, {"pattern": "api/go.mod", "group": "API", "why": "definition of module api"}, From 96972a6033259896307d42f6578390e1fdce0cbc Mon Sep 17 00:00:00 2001 From: Drew Walczyk Date: Wed, 28 Oct 2026 14:09:09 -0500 Subject: [PATCH 8/8] chore(gmp-sync): wire gmp-sync/verify-gmp-sync into update/lint gmp-sync and verify-gmp-sync were standalone .PHONY targets with no CI guard, so a GMP snapshot could drift from the pinned source without failing verification. Add gmp-sync as an update prerequisite and verify-gmp-sync alongside verify-capi-sync in lint, which reaches verify, verify-parallel, and verify-ci. Update the README, which previously stated the check was not wired into CI. Signed-off-by: Drew Walczyk Co-Authored-By: Claude Sonnet 5 --- Makefile | 4 ++-- support/gmpmonitoring/README.md | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index e9e4c3c36662..fc32eb157d8c 100644 --- a/Makefile +++ b/Makefile @@ -97,7 +97,7 @@ build: hypershift-operator control-plane-operator control-plane-pki-operator kar CAPI_PROVIDERS := agent aws azure gcp ibmcloud kubevirt openstack .PHONY: update -update: api-deps workspace-sync capi-sync deps api api-docs clients docs-aggregate +update: api-deps workspace-sync capi-sync gmp-sync deps api api-docs clients docs-aggregate $(MAKE) ownership-rules GOLANGCI_LINT := $(abspath $(TOOLS_BIN_DIR)/golangci-lint) @@ -131,7 +131,7 @@ precommit-api-lint-fix: $(GOLANGCI_LINT) cd api && $(GOLANGCI_LINT) fmt --config ./.golangci.yml --enable gci $(patsubst api/%,%,$(FILES)) .PHONY: lint -lint: generate verify-capi-sync $(HYPERSHIFTLINTER_PLUGIN) +lint: generate verify-capi-sync verify-gmp-sync $(HYPERSHIFTLINTER_PLUGIN) $(MAKE) api-lint; api_rc=$$?; \ $(GOLANGCI_LINT) run --config ./.golangci.yml --modules-download-mode=readonly -v; main_rc=$$?; \ exit $$(( api_rc > main_rc ? api_rc : main_rc )) diff --git a/support/gmpmonitoring/README.md b/support/gmpmonitoring/README.md index a50850a6b3ef..f2bbb48e0d37 100644 --- a/support/gmpmonitoring/README.md +++ b/support/gmpmonitoring/README.md @@ -56,11 +56,13 @@ to regenerate deepcopies offline. `make verify-gmp-sync` verifies the recorded inputs and regenerates into temporary storage. It fails on missing, different or unexpected generated files without -rewriting tracked files. It is a manual, maintainer-run check and is not wired -into `make verify-ci` or the CI verify workflow; run it after any GMP snapshot, -tooling or dependency change. An offline unit test in `hack/gmp-api-gen` runs -with the regular unit tests and fails if `upstream.sha256` no longer matches the -version and checksums pinned in `hack/gmp-api-source`. +rewriting tracked files. It runs as part of `make lint` (and therefore `make +verify-ci`, `make verify`, and `make verify-parallel`), so a stale snapshot fails +CI; run it manually after any GMP snapshot, tooling or dependency change to catch +drift before pushing. `make gmp-sync` runs as part of `make update`. An offline +unit test in `hack/gmp-api-gen` runs with the regular unit tests and fails if +`upstream.sha256` no longer matches the version and checksums pinned in +`hack/gmp-api-source`. ### Update to a newer upstream version