diff --git a/Makefile b/Makefile index 96040bd..467c73e 100644 --- a/Makefile +++ b/Makefile @@ -17,13 +17,14 @@ DEV_TAG ?= dev-$(GIT_COMMIT) BUILD_DATE := $(shell date -u +'%Y-%m-%dT%H:%M:%SZ') # LDFLAGS for build -# Note: Variables are in package main, so use main.varName (not full import path) +# Version info is set in pkg/version package for use across the codebase +VERSION_PKG := github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/version LDFLAGS := -w -s -LDFLAGS += -X main.version=$(VERSION) -LDFLAGS += -X main.commit=$(GIT_COMMIT) -LDFLAGS += -X main.buildDate=$(BUILD_DATE) +LDFLAGS += -X $(VERSION_PKG).Version=$(VERSION) +LDFLAGS += -X $(VERSION_PKG).Commit=$(GIT_COMMIT) +LDFLAGS += -X $(VERSION_PKG).BuildDate=$(BUILD_DATE) ifneq ($(GIT_TAG),) -LDFLAGS += -X main.tag=$(GIT_TAG) +LDFLAGS += -X $(VERSION_PKG).Tag=$(GIT_TAG) endif # Go parameters diff --git a/README.md b/README.md index 7079581..a27a639 100644 --- a/README.md +++ b/README.md @@ -157,12 +157,26 @@ A HyperFleet Adapter requires several files for configuration: The adapter supports multiple configuration sources with the following priority order: -1. **Environment Variable** (`ADAPTER_CONFIG_FILE`) - Highest priority -2. **Default location Mount** (`/etc/adapter/adapterconfig.yaml`) +1. **Environment Variable** (`ADAPTER_CONFIG_PATH`) - Highest priority +2. **ConfigMap Mount** (`/etc/adapter/config/adapter-deployment-config.yaml`) See `configs/adapterconfig-template.yaml` for configuration template. -#### Broker Configuration +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ADAPTER_CONFIG_PATH` | Path to adapter configuration file | `/etc/adapter/config/adapter-deployment-config.yaml` | +| `HYPERFLEET_USER_AGENT` | Custom User-Agent string for HTTP clients (Maestro, HyperFleet API) | `hyperfleet-adapter/{version}` | +| `HYPERFLEET_API_BASE_URL` | Base URL for HyperFleet API | (from config) | +| `HYPERFLEET_API_VERSION` | API version for HyperFleet API | (from config) | +| `BROKER_SUBSCRIPTION_ID` | Message broker subscription ID | (required) | +| `BROKER_TOPIC` | Message broker topic | (required) | +| `LOG_LEVEL` | Log level (debug, info, warn, error) | `info` | +| `LOG_FORMAT` | Log format (text, json) | `json` | +| `LOG_OUTPUT` | Log output (stdout, stderr) | `stdout` | + +### Broker Configuration Broker configuration is managed separately and can be provided via: diff --git a/cmd/adapter/main.go b/cmd/adapter/main.go index d4f8d04..e77c5a5 100644 --- a/cmd/adapter/main.go +++ b/cmd/adapter/main.go @@ -16,19 +16,12 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/health" "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/logger" "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/otel" + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/version" "github.com/openshift-hyperfleet/hyperfleet-broker/broker" "github.com/spf13/cobra" "github.com/spf13/pflag" ) -// Build-time variables set via ldflags -var ( - version = "0.1.0" - commit = "none" - buildDate = "unknown" - tag = "none" -) - // Command-line flags var ( configPath string @@ -107,11 +100,12 @@ and HyperFleet API calls.`, Use: "version", Short: "Print version information", Run: func(cmd *cobra.Command, args []string) { + info := version.Info() fmt.Printf("HyperFleet Adapter\n") - fmt.Printf(" Version: %s\n", version) - fmt.Printf(" Commit: %s\n", commit) - fmt.Printf(" Built: %s\n", buildDate) - fmt.Printf(" Tag: %s\n", tag) + fmt.Printf(" Version: %s\n", info.Version) + fmt.Printf(" Commit: %s\n", info.Commit) + fmt.Printf(" Built: %s\n", info.BuildDate) + fmt.Printf(" Tag: %s\n", info.Tag) }, } @@ -142,7 +136,7 @@ func buildLoggerConfig(component string) logger.Config { } cfg.Component = component - cfg.Version = version + cfg.Version = version.Version return cfg } @@ -159,12 +153,12 @@ func runServe() error { return fmt.Errorf("failed to create logger: %w", err) } - log.Infof(ctx, "Starting Hyperfleet Adapter version=%s commit=%s built=%s tag=%s", version, commit, buildDate, tag) + log.Infof(ctx, "Starting Hyperfleet Adapter version=%s commit=%s built=%s tag=%s", version.Version, version.Commit, version.BuildDate, version.Tag) // Load adapter configuration // If configPath flag is empty, config_loader.Load will read from ADAPTER_CONFIG_PATH env var log.Info(ctx, "Loading adapter configuration...") - adapterConfig, err := config_loader.Load(configPath, config_loader.WithAdapterVersion(version)) + adapterConfig, err := config_loader.Load(configPath, config_loader.WithAdapterVersion(version.Version)) if err != nil { errCtx := logger.WithErrorField(ctx, err) log.Errorf(errCtx, "Failed to load adapter configuration") @@ -187,7 +181,7 @@ func runServe() error { sampleRatio := otel.GetTraceSampleRatio(log, ctx) // Initialize OpenTelemetry for trace_id/span_id generation and HTTP propagation - tp, err := otel.InitTracer(adapterConfig.Metadata.Name, version, sampleRatio) + tp, err := otel.InitTracer(adapterConfig.Metadata.Name, version.Version, sampleRatio) if err != nil { errCtx := logger.WithErrorField(ctx, err) log.Errorf(errCtx, "Failed to initialize OpenTelemetry") @@ -223,8 +217,8 @@ func runServe() error { // Start metrics server with build info metricsServer := health.NewMetricsServer(log, MetricsServerPort, health.MetricsConfig{ Component: adapterConfig.Metadata.Name, - Version: version, - Commit: commit, + Version: version.Version, + Commit: version.Commit, }) if err := metricsServer.Start(ctx); err != nil { errCtx := logger.WithErrorField(ctx, err) diff --git a/configs/adapter-deployment-config.yaml b/configs/adapter-deployment-config.yaml new file mode 100644 index 0000000..0b95112 --- /dev/null +++ b/configs/adapter-deployment-config.yaml @@ -0,0 +1,105 @@ +# HyperFleet Adapter Deployment Configuration +# +# This file contains ONLY infrastructure and deployment-related settings: +# - Client connections (Maestro, HyperFleet API, Kubernetes) +# - Authentication and TLS configuration +# - Connection timeouts and retry policies +# +# NOTE: This is a SAMPLE configuration file for reference and local development. +# It is NOT automatically packaged with the container image (see Dockerfile). +# +# In production, provide configuration via one of these methods: +# 1. ADAPTER_CONFIG_PATH environment variable pointing to a config file (highest priority) +# 2. ConfigMap mounted at /etc/adapter/config/adapter-deployment-config.yaml +# +# Example Kubernetes deployment: +# env: +# - name: ADAPTER_CONFIG_PATH +# value: /etc/adapter/config/adapter-deployment-config.yaml +# volumeMounts: +# - name: config +# mountPath: /etc/adapter/config +# +# For business logic configuration (params, preconditions, resources, post-actions), +# use a separate business config file. See configs/adapter-config-template.yaml + +apiVersion: hyperfleet.redhat.com/v1alpha1 +kind: AdapterDeploymentConfig +metadata: + name: hyperfleet-adapter + namespace: hyperfleet-system + labels: + hyperfleet.io/component: adapter + +spec: + adapter: + version: "0.1.0" + + # Client configurations for external services + clients: + # Maestro transport client configuration + maestro: + # gRPC server address + # Environment variable: HYPERFLEET_MAESTRO_GRPC_SERVER_ADDRESS + # Flag: --maestro-grpc-server-address + grpcServerAddress: "maestro-grpc.maestro.svc.cluster.local:8090" + + # HTTPS server address for REST API operations (optional) + # Environment variable: HYPERFLEET_MAESTRO_HTTP_SERVER_ADDRESS + httpServerAddress: "https://maestro-api.maestro.svc.cluster.local" + + # Source identifier for CloudEvents routing (must be unique across adapters) + # Environment variable: HYPERFLEET_MAESTRO_SOURCE_ID + sourceId: "hyperfleet-adapter" + + # Client identifier (defaults to sourceId if not specified) + # Environment variable: HYPERFLEET_MAESTRO_CLIENT_ID + clientId: "hyperfleet-adapter-client" + + # Authentication configuration + auth: + type: "tls" # TLS certificate-based mTLS + + tlsConfig: + # gRPC TLS configuration + # Certificate paths (mounted from Kubernetes secrets) + # Environment variable: HYPERFLEET_MAESTRO_CA_FILE + caFile: "/etc/maestro/certs/grpc/ca.crt" + + # Environment variable: HYPERFLEET_MAESTRO_CERT_FILE + certFile: "/etc/maestro/certs/grpc/client.crt" + + # Environment variable: HYPERFLEET_MAESTRO_KEY_FILE + keyFile: "/etc/maestro/certs/grpc/client.key" + + # Server name for TLS verification + # Environment variable: HYPERFLEET_MAESTRO_SERVER_NAME + serverName: "maestro-grpc.maestro.svc.cluster.local" + + # HTTP API TLS configuration (may use different CA than gRPC) + # If not set, falls back to caFile for backwards compatibility + # Environment variable: HYPERFLEET_MAESTRO_HTTP_CA_FILE + httpCaFile: "/etc/maestro/certs/https/ca.crt" + + # Connection settings + timeout: "30s" + retryAttempts: 3 + retryBackoff: "exponential" + + # Keep-alive for long-lived gRPC connections + keepalive: + time: "30s" + timeout: "10s" + permitWithoutStream: true + + # HyperFleet HTTP API client + hyperfleetApi: + timeout: 2s + retryAttempts: 3 + retryBackoff: exponential + + # Kubernetes client (for direct K8s resources) + kubernetes: + apiVersion: "v1" + # Uses in-cluster service account by default + # Or set kubeconfig path via KUBECONFIG environment variable diff --git a/configs/adapter.yaml b/configs/adapter.yaml deleted file mode 100644 index 7be3533..0000000 --- a/configs/adapter.yaml +++ /dev/null @@ -1,87 +0,0 @@ -# HyperFleet Adapter Framework Configuration (Default/Fallback) -# -# This is a minimal default configuration packaged with the container image. -# In production, this should be overridden via: -# 1. CONFIG_FILE environment variable (highest priority) -# 2. ConfigMap mounted at /etc/adapter/config/adapter.yaml -# -# See configs/adapter-config-template.yaml for full configuration options. - -apiVersion: hyperfleet.redhat.com/v1alpha1 -kind: AdapterConfig -metadata: - name: hyperfleet-adapter - namespace: hyperfleet-system - labels: - hyperfleet.io/component: adapter - -spec: - adapter: - version: "0.1.0" - - hyperfleetApi: - timeout: 2s - retryAttempts: 3 - retryBackoff: exponential - - kubernetes: - apiVersion: "v1" - - # Global params - these should be provided via environment variables or ConfigMap - params: - - name: "hyperfleetApiBaseUrl" - source: "env.HYPERFLEET_API_BASE_URL" - type: "string" - description: "Base URL for the HyperFleet API" - required: true - - - name: "hyperfleetApiVersion" - source: "env.HYPERFLEET_API_VERSION" - type: "string" - default: "v1" - description: "API version to use" - required: false - - - name: "hyperfleetApiToken" - source: "env.HYPERFLEET_API_TOKEN" - type: "string" - description: "Authentication token for API access" - required: true - - - name: "clusterId" - source: "event.cluster_id" - type: "string" - description: "Unique identifier for the target cluster" - required: true - - - name: "resourceId" - source: "event.resource_id" - type: "string" - description: "Unique identifier for the resource" - required: true - - - name: "resourceType" - source: "event.resource_type" - type: "string" - description: "Type of the resource being managed" - required: true - - - name: "eventGenerationId" - source: "event.generation" - type: "string" - description: "Event generation ID for idempotency checks" - required: true - - - name: "eventHref" - source: "event.href" - type: "string" - description: "Reference URL for the resource" - required: true - - # Preconditions, resources, and post-processing should be defined - # in a production configuration mounted via ConfigMap - preconditions: [] - resources: [] - post: - payloads: [] - postActions: [] diff --git a/configs/broker-configmap-pubsub-template.yaml b/configs/broker-configmap-pubsub-template.yaml index 6e3aec2..cfce7df 100644 --- a/configs/broker-configmap-pubsub-template.yaml +++ b/configs/broker-configmap-pubsub-template.yaml @@ -38,27 +38,60 @@ data: # BROKER_GOOGLEPUBSUB_PROJECT_ID=my-project # SUBSCRIBER_PARALLELISM=5 broker.yaml: | + # Set to true to log the loaded configuration on startup (useful for debugging) + log_config: false + broker: # Broker type: "rabbitmq" or "googlepubsub" type: googlepubsub # Google Pub/Sub Configuration googlepubsub: - # GCP Project ID (required for googlepubsub) + # ==== Connection Settings (required) ==== + # GCP Project ID project_id: "my-gcp-project" - - # Topic name (optional, can be specified per publish/subscribe call) - topic: "" - - # Subscription name (optional, can be specified per subscribe call) - subscription: "" - - # Maximum outstanding messages (optional, default: 1000) + + # ==== Subscription Settings ==== + # Time for subscriber to acknowledge message (10-600 seconds, default: 10) + ack_deadline_seconds: 60 + + # How long to retain unacknowledged messages (10m to 31d, default: 7d) + # Format: "Ns" (seconds), "Nm" (minutes), "Nh" (hours), "Nd" (days) + message_retention_duration: "604800s" # 7 days + + # Time of inactivity before subscription is deleted (min 1d, or 0 = never expire) + expiration_ttl: "2678400s" # 31 days + + # Enable ordered message delivery by ordering key + enable_message_ordering: false + + # ==== Retry Policy ==== + # Retry policy for failed message delivery (0s to 600s) + retry_min_backoff: "10s" + retry_max_backoff: "600s" + + # ==== Dead Letter Settings ==== + # Dead letter topic for messages that fail repeatedly + # If create_topic_if_missing is true, a dead letter topic named "{subscription_id}-dlq" + # will be created automatically + # dead_letter_topic: "my-dead-letter-topic" # Optional: customize dead letter topic name + dead_letter_max_attempts: 5 # 5-100, default: 5 + + # ==== Topic Settings ==== + # How long the topic retains messages for replay scenarios (0 = disabled) + # topic_retention_duration: "86400s" # 1 day + + # ==== Receive Settings (client-side flow control) ==== max_outstanding_messages: 1000 - - # Number of goroutines for Pub/Sub client (optional, default: 10) + max_outstanding_bytes: 104857600 # 100MB num_goroutines: 10 + # ==== Behavior Flags ==== + # Default: false - infrastructure must exist (recommended for production) + # Set to true to automatically create topics/subscriptions if they don't exist + create_topic_if_missing: true + create_subscription_if_missing: true + # Subscriber Configuration subscriber: # Number of parallel workers for processing messages (default: 1) @@ -95,6 +128,11 @@ data: # configMapKeyRef: # name: hyperfleet-broker-config # key: BROKER_SUBSCRIPTION_ID +# - name: BROKER_TOPIC +# valueFrom: +# configMapKeyRef: +# name: hyperfleet-broker-config +# key: BROKER_TOPIC # # Point to broker config file # - name: BROKER_CONFIG_FILE # value: /etc/broker/broker.yaml @@ -156,4 +194,3 @@ data: # - name: gcp-credentials # secret: # secretName: gcp-service-account-key - diff --git a/go.mod b/go.mod index f4fd06c..682cdf8 100644 --- a/go.mod +++ b/go.mod @@ -9,29 +9,32 @@ require ( github.com/google/cel-go v0.26.1 github.com/mitchellh/copystructure v1.2.0 github.com/openshift-hyperfleet/hyperfleet-broker v1.0.1 + github.com/openshift-online/maestro v0.0.0-20260202062555-48b47506a254 github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.40.0 - go.opentelemetry.io/otel v1.38.0 - go.opentelemetry.io/otel/sdk v1.38.0 - go.opentelemetry.io/otel/trace v1.38.0 - golang.org/x/text v0.32.0 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/sdk v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 + golang.org/x/text v0.33.0 gopkg.in/yaml.v3 v3.0.1 - k8s.io/apimachinery v0.34.1 - k8s.io/client-go v0.34.1 + k8s.io/apimachinery v0.34.3 + k8s.io/client-go v0.34.3 + open-cluster-management.io/api v1.2.0 + open-cluster-management.io/sdk-go v1.2.0 sigs.k8s.io/controller-runtime v0.22.4 ) require ( cel.dev/expr v0.24.0 // indirect - cloud.google.com/go v0.121.4 // indirect - cloud.google.com/go/auth v0.16.3 // indirect + cloud.google.com/go v0.121.6 // indirect + cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect - cloud.google.com/go/pubsub/v2 v2.0.0 // indirect + cloud.google.com/go/pubsub/v2 v2.3.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -40,6 +43,7 @@ require ( github.com/ThreeDotsLabs/watermill-googlecloud/v2 v2.0.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bwmarrin/snowflake v0.3.0 // indirect github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -55,22 +59,27 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.8.4 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/getsentry/sentry-go v0.20.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/glog v1.2.5 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect @@ -85,7 +94,7 @@ require ( github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.1.0 // indirect @@ -99,20 +108,19 @@ require ( github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid v1.3.1 // indirect - github.com/onsi/ginkgo/v2 v2.25.1 // indirect - github.com/onsi/gomega v1.38.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/openshift-online/ocm-sdk-go v0.1.493 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.17.0 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect github.com/rabbitmq/amqp091-go v1.10.0 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/segmentio/ksuid v1.0.4 // indirect github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sony/gobreaker v1.0.0 // indirect @@ -120,35 +128,39 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/viper v1.21.0 // indirect - github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/time v0.12.0 // indirect - google.golang.org/api v0.243.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/api v0.255.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect - google.golang.org/grpc v1.74.2 // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/api v0.34.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/api v0.34.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect diff --git a/go.sum b/go.sum index 897782b..0e492ae 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,18 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs= -cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s= -cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc= -cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= +cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= +cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= +cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= +cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= -cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0= -cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= +cloud.google.com/go/pubsub/v2 v2.3.0 h1:DgAN907x+sP0nScYfBzneRiIhWoXcpCD8ZAut8WX9vs= +cloud.google.com/go/pubsub/v2 v2.3.0/go.mod h1:O5f0KHG9zDheZAd3z5rlCRhxt2JQtB+t/IYLKK3Bpvw= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= @@ -34,6 +34,8 @@ github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8 github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= +github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -47,6 +49,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -58,7 +62,6 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -80,7 +83,14 @@ github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRr github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -93,6 +103,10 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getsentry/sentry-go v0.20.0 h1:bwXW98iMRIWxn+4FgPW7vMrjmbym6HblXALmhjHmQaQ= +github.com/getsentry/sentry-go v0.20.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -102,14 +116,12 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -118,6 +130,7 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= @@ -125,6 +138,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -154,8 +169,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -166,8 +181,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -183,11 +198,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -200,8 +212,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -234,22 +246,32 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onsi/ginkgo/v2 v2.25.1 h1:Fwp6crTREKM+oA6Cz4MsO8RhKQzs2/gOIVOUscMAfZY= -github.com/onsi/ginkgo/v2 v2.25.1/go.mod h1:ppTWQ1dh9KM/F1XgpeRqelR+zHVwV81DGRSDnFxK7Sk= -github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= -github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/openshift-hyperfleet/hyperfleet-broker v1.0.1 h1:Mvx0ojBvttYlwu3VfOHwvH+eEM1xA40GzNOZqv1cGyQ= github.com/openshift-hyperfleet/hyperfleet-broker v1.0.1/go.mod h1:z7QpS2m6gaqTbgPazl1lYYy+JuyNDMkMtco12rM29nU= +github.com/openshift-online/maestro v0.0.0-20260202062555-48b47506a254 h1:v/jYqdzZpzB/bscVpajlbcKgCNeV4tx4fkm5m2JR8Ug= +github.com/openshift-online/maestro v0.0.0-20260202062555-48b47506a254/go.mod h1:cyeif610uObNrbcyn5s1fZg7OWseVjaMAqgrEDA2Aec= +github.com/openshift-online/ocm-sdk-go v0.1.493 h1:+889zmbwN0guA8LFRr5WHpH2+VJNq8+r0fvrXY+x/6E= +github.com/openshift-online/ocm-sdk-go v0.1.493/go.mod h1:ThqKHtIyvTvDA5AxGFZph80sllVr63lZ+sb4qQP57+o= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -257,10 +279,10 @@ github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UH github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -268,6 +290,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= +github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -287,8 +311,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= -github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -309,6 +333,8 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -319,53 +345,53 @@ go.einride.tech/aip v0.73.0 h1:bPo4oqBo2ZQeBKo4ZzLb1kxYXTY1ysJhpvQyfuGzvps= go.einride.tech/aip v0.73.0/go.mod h1:Mj7rFbmXEgw0dq1dqJ7JGMvYCZZVxmGOR3S4ZcV5LvQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= -go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= -go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo= +golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -375,11 +401,11 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -397,16 +423,16 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -415,14 +441,16 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.243.0 h1:sw+ESIJ4BVnlJcWu9S+p2Z6Qq1PjG77T8IJ1xtp4jZQ= -google.golang.org/api v0.243.0/go.mod h1:GE4QtYfaybx1KmeHMdBnNnyLzBZCVihGBXAmJu/uUr8= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.255.0 h1:OaF+IbRwOottVCYV2wZan7KUq7UeNUQn1BcPc4K7lE4= +google.golang.org/api v0.255.0/go.mod h1:d1/EtvCLdtiWEV4rAEHDHGh2bCnqsWhw+M8y2ECN4a8= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -430,17 +458,17 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= -google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 h1:mVXdvnmR3S3BQOqHECm9NGMjYiRtEvDYcqAqedTXY6s= -google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:vYFwMYFbmA8vl6Z/krj/h7+U/AqpHknwJX4Uqgfyc7I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 h1:1ZwqphdOdWYXsUHgMpU/101nCtf/kSp9hOrcvFsnl10= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -450,8 +478,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -459,6 +487,8 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSP gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -466,20 +496,24 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= -k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= -k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= -k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= -k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= -k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= -k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= +k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= +k8s.io/apiextensions-apiserver v0.34.3 h1:p10fGlkDY09eWKOTeUSioxwLukJnm+KuDZdrW71y40g= +k8s.io/apiextensions-apiserver v0.34.3/go.mod h1:aujxvqGFRdb/cmXYfcRTeppN7S2XV/t7WMEc64zB5A0= +k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= +k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= +k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +open-cluster-management.io/api v1.2.0 h1:+yeQgJiErrur5S4s205UM37EcZ2XbC9pFSm0xgV5/hU= +open-cluster-management.io/api v1.2.0/go.mod h1:YcmA6SpGEekIMxdoeVIIyOaBhMA6ImWRLXP4g8n8T+4= +open-cluster-management.io/sdk-go v1.2.0 h1:O9LCOoy5JfgK3k4e2OCBY2ZoCqBEwLbEWClqP01FkQI= +open-cluster-management.io/sdk-go v1.2.0/go.mod h1:OHM74Kw1gh9RHxg7QjJlGXCDlPm7x2CtCkejHSdczs4= sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= diff --git a/internal/executor/resource_executor.go b/internal/executor/resource_executor.go index 74a304d..4e398ca 100644 --- a/internal/executor/resource_executor.go +++ b/internal/executor/resource_executor.go @@ -8,8 +8,9 @@ import ( "github.com/mitchellh/copystructure" "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/config_loader" + "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/generation" "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/k8s_client" - apperrors "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/errors" + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/constants" "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/logger" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -80,61 +81,77 @@ func (re *ResourceExecutor) executeResource(ctx context.Context, resource config re.log.Debugf(ctx, "Resource[%s] manifest built: namespace=%s", resource.Name, manifest.GetNamespace()) - // Step 2: Check for existing resource using discovery + // Step 2: Delegate to applyResource which handles discovery, generation comparison, and operations + return re.applyResource(ctx, resource, manifest, execCtx) +} + +// applyResource handles resource discovery, generation comparison, and execution of operations. +// It discovers existing resources (via Discovery config or by name), compares generations, +// and performs the appropriate operation (create, update, recreate, or skip). +func (re *ResourceExecutor) applyResource(ctx context.Context, resource config_loader.Resource, manifest *unstructured.Unstructured, execCtx *ExecutionContext) (ResourceResult, error) { + result := ResourceResult{ + Name: resource.Name, + Kind: manifest.GetKind(), + Namespace: manifest.GetNamespace(), + ResourceName: manifest.GetName(), + Status: StatusSuccess, + } + + if re.k8sClient == nil { + result.Status = StatusFailed + result.Error = fmt.Errorf("kubernetes client not configured") + return result, NewExecutorError(PhaseResources, resource.Name, "kubernetes client not configured", result.Error) + } + + gvk := manifest.GroupVersionKind() + + // Discover existing resource var existingResource *unstructured.Unstructured + var err error if resource.Discovery != nil { - re.log.Debugf(ctx, "Discovering existing resource...") + // Use Discovery config to find existing resource (e.g., by label selector) + re.log.Debugf(ctx, "Discovering existing resource using discovery config...") existingResource, err = re.discoverExistingResource(ctx, gvk, resource.Discovery, execCtx) - if err != nil && !apierrors.IsNotFound(err) { - if apperrors.IsRetryableDiscoveryError(err) { - // Transient/network error - log and continue, we'll try to create - re.log.Warnf(ctx, "Transient discovery error (continuing): %v", err) - } else { - // Fatal error (auth, permission, validation) - fail fast - result.Status = StatusFailed - result.Error = err - return result, NewExecutorError(PhaseResources, resource.Name, "failed to discover existing resource", err) - } - } - if existingResource != nil { - re.log.Debugf(ctx, "Existing resource found: %s/%s", existingResource.GetNamespace(), existingResource.GetName()) - } else { - re.log.Debugf(ctx, "No existing resource found, will create") - } + } else { + // No Discovery config - lookup by name from manifest + re.log.Debugf(ctx, "Looking up existing resource by name...") + existingResource, err = re.k8sClient.GetResource(ctx, gvk, manifest.GetNamespace(), manifest.GetName()) + } + + // Fail fast on any error except NotFound (which means resource doesn't exist yet) + if err != nil && !apierrors.IsNotFound(err) { + result.Status = StatusFailed + result.Error = err + return result, NewExecutorError(PhaseResources, resource.Name, "failed to find existing resource", err) + } + + if existingResource != nil { + re.log.Debugf(ctx, "Existing resource found: %s/%s", existingResource.GetNamespace(), existingResource.GetName()) + } else { + re.log.Debugf(ctx, "No existing resource found, will create") } - // Step 3: Determine and perform the appropriate operation // Extract manifest generation once for use in comparison and logging - manifestGen := k8s_client.GetGenerationAnnotation(manifest) + manifestGen := generation.GetGenerationFromUnstructured(manifest) // Add observed_generation to context early so it appears in all subsequent logs - if manifestGen > 0 { - ctx = logger.WithObservedGeneration(ctx, manifestGen) - } + ctx = logger.WithObservedGeneration(ctx, manifestGen) + // Get existing generation (0 if not found) + var existingGen int64 if existingResource != nil { - // Check if generation annotations match - skip update if unchanged - existingGen := k8s_client.GetGenerationAnnotation(existingResource) - - if existingGen == manifestGen { - // Generations match - no action needed - result.Operation = OperationSkip - result.Resource = existingResource - result.OperationReason = fmt.Sprintf("generation %d unchanged", existingGen) - } else { - // Generations do not match - perform the appropriate action - if resource.RecreateOnChange { - result.Operation = OperationRecreate - result.OperationReason = fmt.Sprintf("generation changed %d->%d, recreateOnChange=true", existingGen, manifestGen) - } else { - result.Operation = OperationUpdate - result.OperationReason = fmt.Sprintf("generation changed %d->%d", existingGen, manifestGen) - } - } - } else { - // Create new resource - result.Operation = OperationCreate - result.OperationReason = "resource not found" + existingGen = generation.GetGenerationFromUnstructured(existingResource) + } + + // Compare generations to determine operation + decision := generation.CompareGenerations(manifestGen, existingGen, existingResource != nil) + + // Handle recreateOnChange override + result.Operation = decision.Operation + result.OperationReason = decision.Reason + if decision.Operation == generation.OperationUpdate && resource.RecreateOnChange { + result.Operation = generation.OperationRecreate + result.OperationReason = fmt.Sprintf("%s, recreateOnChange=true", decision.Reason) } // Log the operation decision @@ -143,14 +160,14 @@ func (re *ResourceExecutor) executeResource(ctx context.Context, resource config // Execute the operation switch result.Operation { - case OperationCreate: + case generation.OperationCreate: result.Resource, err = re.createResource(ctx, manifest) - case OperationUpdate: + case generation.OperationUpdate: result.Resource, err = re.updateResource(ctx, existingResource, manifest) - case OperationRecreate: + case generation.OperationRecreate: result.Resource, err = re.recreateResource(ctx, existingResource, manifest) - case OperationSkip: - // No action needed, resource already set above + case generation.OperationSkip: + result.Resource = existingResource } if err != nil { @@ -234,8 +251,8 @@ func validateManifest(obj *unstructured.Unstructured) error { } // Validate required generation annotation - if k8s_client.GetGenerationAnnotation(obj) == 0 { - return fmt.Errorf("manifest missing required annotation %q", k8s_client.AnnotationGeneration) + if generation.GetGenerationFromUnstructured(obj) == 0 { + return fmt.Errorf("manifest missing required annotation %q", constants.AnnotationGeneration) } return nil @@ -295,7 +312,7 @@ func (re *ResourceExecutor) discoverExistingResource(ctx context.Context, gvk sc return nil, apierrors.NewNotFound(schema.GroupResource{Group: gvk.Group, Resource: gvk.Kind}, "") } - return k8s_client.GetLatestGenerationResource(list), nil + return generation.GetLatestGenerationFromList(list), nil } return nil, fmt.Errorf("discovery config must specify byName or bySelectors") diff --git a/internal/executor/types.go b/internal/executor/types.go index 752b418..4f0e791 100644 --- a/internal/executor/types.go +++ b/internal/executor/types.go @@ -7,6 +7,7 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/config_loader" "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/criteria" + "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/generation" "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/hyperfleet_api" "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/k8s_client" "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/logger" @@ -132,8 +133,8 @@ type ResourceResult struct { ResourceName string // Status is the result status Status ExecutionStatus - // Operation is the operation performed (create, update, skip) - Operation ResourceOperation + // Operation is the operation performed (create, update, recreate, skip) + Operation generation.Operation // Resource is the created/updated resource (if successful) Resource *unstructured.Unstructured // OperationReason explains why this operation was performed @@ -143,20 +144,6 @@ type ResourceResult struct { Error error } -// ResourceOperation represents the operation performed on a resource -type ResourceOperation string - -const ( - // OperationCreate indicates a resource was created - OperationCreate ResourceOperation = "create" - // OperationUpdate indicates a resource was updated - OperationUpdate ResourceOperation = "update" - // OperationRecreate indicates a resource was deleted and recreated - OperationRecreate ResourceOperation = "recreate" - // OperationSkip indicates no operation was needed - OperationSkip ResourceOperation = "skip" -) - // PostActionResult contains the result of a single post-action execution type PostActionResult struct { // Name is the post-action name diff --git a/internal/generation/generation.go b/internal/generation/generation.go new file mode 100644 index 0000000..6814f2b --- /dev/null +++ b/internal/generation/generation.go @@ -0,0 +1,263 @@ +// Package generation provides utilities for generation-based resource tracking. +// +// This package handles generation annotation validation, comparison, and extraction +// for both k8s_client (Kubernetes resources) and maestro_client (ManifestWork). +package generation + +import ( + "fmt" + "sort" + "strconv" + + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/constants" + apperrors "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + workv1 "open-cluster-management.io/api/work/v1" +) + +// Operation represents the type of operation to perform on a resource +type Operation string + +const ( + // OperationCreate indicates the resource should be created + OperationCreate Operation = "create" + // OperationUpdate indicates the resource should be updated + OperationUpdate Operation = "update" + // OperationRecreate indicates the resource should be deleted and recreated + OperationRecreate Operation = "recreate" + // OperationSkip indicates no operation is needed (generations match) + OperationSkip Operation = "skip" +) + +// ApplyDecision contains the decision about what operation to perform +// based on comparing generations between an existing resource and a new resource. +type ApplyDecision struct { + // Operation is the recommended operation based on generation comparison + Operation Operation + // Reason explains why this operation was chosen + Reason string + // NewGeneration is the generation of the new resource + NewGeneration int64 + // ExistingGeneration is the generation of the existing resource (0 if not found) + ExistingGeneration int64 +} + +// CompareGenerations compares the generation of a new resource against an existing one +// and returns the recommended operation. +// +// Decision logic: +// - If exists is false: Create (resource doesn't exist) +// - If generations match: Skip (no changes needed) +// - If generations differ: Update (apply changes) +// +// This function encapsulates the generation comparison logic used by both +// resource_executor (for k8s resources) and maestro_client (for ManifestWorks). +func CompareGenerations(newGen, existingGen int64, exists bool) ApplyDecision { + if !exists { + return ApplyDecision{ + Operation: OperationCreate, + Reason: "resource not found", + NewGeneration: newGen, + ExistingGeneration: 0, + } + } + + if existingGen == newGen { + return ApplyDecision{ + Operation: OperationSkip, + Reason: fmt.Sprintf("generation %d unchanged", existingGen), + NewGeneration: newGen, + ExistingGeneration: existingGen, + } + } + + return ApplyDecision{ + Operation: OperationUpdate, + Reason: fmt.Sprintf("generation changed %d->%d", existingGen, newGen), + NewGeneration: newGen, + ExistingGeneration: existingGen, + } +} + +// GetGeneration extracts the generation annotation value from ObjectMeta. +// Returns 0 if the annotation is not found, empty, or cannot be parsed. +// +// This works with any Kubernetes resource that has ObjectMeta, including: +// - Unstructured objects (via obj.GetAnnotations()) +// - ManifestWork objects (via work.ObjectMeta or work.Annotations) +// - Any typed Kubernetes resource (via resource.ObjectMeta) +func GetGeneration(meta metav1.ObjectMeta) int64 { + if meta.Annotations == nil { + return 0 + } + + genStr, ok := meta.Annotations[constants.AnnotationGeneration] + if !ok || genStr == "" { + return 0 + } + + gen, err := strconv.ParseInt(genStr, 10, 64) + if err != nil { + return 0 + } + + return gen +} + +// GetGenerationFromUnstructured is a convenience wrapper for getting generation from unstructured.Unstructured. +// Returns 0 if the resource is nil, has no annotations, or the annotation cannot be parsed. +func GetGenerationFromUnstructured(obj *unstructured.Unstructured) int64 { + if obj == nil { + return 0 + } + annotations := obj.GetAnnotations() + if annotations == nil { + return 0 + } + genStr, ok := annotations[constants.AnnotationGeneration] + if !ok || genStr == "" { + return 0 + } + gen, err := strconv.ParseInt(genStr, 10, 64) + if err != nil { + return 0 + } + return gen +} + +// ValidateGeneration validates that the generation annotation exists and is valid on ObjectMeta. +// Returns error if: +// - Annotation is missing +// - Annotation value is empty +// - Annotation value cannot be parsed as int64 +// - Annotation value is <= 0 (must be positive) +// +// This is used to validate that templates properly set the generation annotation. +func ValidateGeneration(meta metav1.ObjectMeta) error { + if meta.Annotations == nil { + return apperrors.Validation("missing %s annotation", constants.AnnotationGeneration).AsError() + } + + genStr, ok := meta.Annotations[constants.AnnotationGeneration] + if !ok { + return apperrors.Validation("missing %s annotation", constants.AnnotationGeneration).AsError() + } + + if genStr == "" { + return apperrors.Validation("%s annotation is empty", constants.AnnotationGeneration).AsError() + } + + gen, err := strconv.ParseInt(genStr, 10, 64) + if err != nil { + return apperrors.Validation("invalid %s annotation value %q: %v", constants.AnnotationGeneration, genStr, err).AsError() + } + + if gen <= 0 { + return apperrors.Validation("%s annotation must be > 0, got %d", constants.AnnotationGeneration, gen).AsError() + } + + return nil +} + +// ValidateManifestWorkGeneration validates that the generation annotation exists on both: +// 1. The ManifestWork metadata (required) +// 2. All manifests within the ManifestWork workload (required) +// +// Returns error if any generation annotation is missing or invalid. +// This ensures templates properly set generation annotations throughout the ManifestWork. +func ValidateManifestWorkGeneration(work *workv1.ManifestWork) error { + if work == nil { + return apperrors.Validation("work cannot be nil").AsError() + } + + // Validate ManifestWork-level generation (required) + if err := ValidateGeneration(work.ObjectMeta); err != nil { + return apperrors.Validation("ManifestWork %q: %v", work.Name, err).AsError() + } + + // Validate each manifest has generation annotation (required) + for i, m := range work.Spec.Workload.Manifests { + obj := &unstructured.Unstructured{} + if err := obj.UnmarshalJSON(m.Raw); err != nil { + return apperrors.Validation("ManifestWork %q manifest[%d]: failed to unmarshal: %v", work.Name, i, err).AsError() + } + + // Validate generation annotation exists + if err := ValidateGenerationFromUnstructured(obj); err != nil { + kind := obj.GetKind() + name := obj.GetName() + return apperrors.Validation("ManifestWork %q manifest[%d] %s/%s: %v", work.Name, i, kind, name, err).AsError() + } + } + + return nil +} + +// ValidateGenerationFromUnstructured validates that the generation annotation exists and is valid on an Unstructured object. +// Returns error if: +// - Object is nil +// - Annotation is missing +// - Annotation value is empty +// - Annotation value cannot be parsed as int64 +// - Annotation value is <= 0 (must be positive) +func ValidateGenerationFromUnstructured(obj *unstructured.Unstructured) error { + if obj == nil { + return apperrors.Validation("object cannot be nil").AsError() + } + + annotations := obj.GetAnnotations() + if annotations == nil { + return apperrors.Validation("missing %s annotation", constants.AnnotationGeneration).AsError() + } + + genStr, ok := annotations[constants.AnnotationGeneration] + if !ok { + return apperrors.Validation("missing %s annotation", constants.AnnotationGeneration).AsError() + } + + if genStr == "" { + return apperrors.Validation("%s annotation is empty", constants.AnnotationGeneration).AsError() + } + + gen, err := strconv.ParseInt(genStr, 10, 64) + if err != nil { + return apperrors.Validation("invalid %s annotation value %q: %v", constants.AnnotationGeneration, genStr, err).AsError() + } + + if gen <= 0 { + return apperrors.Validation("%s annotation must be > 0, got %d", constants.AnnotationGeneration, gen).AsError() + } + + return nil +} + +// GetLatestGenerationFromList returns the resource with the highest generation annotation from a list. +// It sorts by generation annotation (descending) and uses metadata.name as a secondary sort key +// for deterministic behavior when generations are equal. +// Returns nil if the list is nil or empty. +// +// Useful for finding the most recent version of a resource when multiple versions exist. +func GetLatestGenerationFromList(list *unstructured.UnstructuredList) *unstructured.Unstructured { + if list == nil || len(list.Items) == 0 { + return nil + } + + // Copy items to avoid modifying input + items := make([]unstructured.Unstructured, len(list.Items)) + copy(items, list.Items) + + // Sort by generation annotation (descending) to return the one with the latest generation + // Secondary sort by metadata.name for consistency when generations are equal + sort.Slice(items, func(i, j int) bool { + genI := GetGenerationFromUnstructured(&items[i]) + genJ := GetGenerationFromUnstructured(&items[j]) + if genI != genJ { + return genI > genJ // Descending order - latest generation first + } + // Fall back to metadata.name for deterministic ordering when generations are equal + return items[i].GetName() < items[j].GetName() + }) + + return &items[0] +} diff --git a/internal/generation/generation_test.go b/internal/generation/generation_test.go new file mode 100644 index 0000000..7493878 --- /dev/null +++ b/internal/generation/generation_test.go @@ -0,0 +1,720 @@ +package generation + +import ( + "testing" + + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/constants" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + workv1 "open-cluster-management.io/api/work/v1" +) + +func TestCompareGenerations(t *testing.T) { + tests := []struct { + name string + newGen int64 + existingGen int64 + exists bool + expectedOperation Operation + expectedReason string + }{ + { + name: "resource does not exist - create", + newGen: 5, + existingGen: 0, + exists: false, + expectedOperation: OperationCreate, + expectedReason: "resource not found", + }, + { + name: "generations match - skip", + newGen: 5, + existingGen: 5, + exists: true, + expectedOperation: OperationSkip, + expectedReason: "generation 5 unchanged", + }, + { + name: "newer generation - update", + newGen: 6, + existingGen: 5, + exists: true, + expectedOperation: OperationUpdate, + expectedReason: "generation changed 5->6", + }, + { + name: "older generation (rollback) - update", + newGen: 4, + existingGen: 5, + exists: true, + expectedOperation: OperationUpdate, + expectedReason: "generation changed 5->4", + }, + { + name: "large generation difference - update", + newGen: 100, + existingGen: 1, + exists: true, + expectedOperation: OperationUpdate, + expectedReason: "generation changed 1->100", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := CompareGenerations(tt.newGen, tt.existingGen, tt.exists) + + if result.Operation != tt.expectedOperation { + t.Errorf("Operation = %v, want %v", result.Operation, tt.expectedOperation) + } + + if result.Reason != tt.expectedReason { + t.Errorf("Reason = %v, want %v", result.Reason, tt.expectedReason) + } + + if result.NewGeneration != tt.newGen { + t.Errorf("NewGeneration = %v, want %v", result.NewGeneration, tt.newGen) + } + + if tt.exists && result.ExistingGeneration != tt.existingGen { + t.Errorf("ExistingGeneration = %v, want %v", result.ExistingGeneration, tt.existingGen) + } + }) + } +} + +func TestGetGeneration(t *testing.T) { + tests := []struct { + name string + meta metav1.ObjectMeta + expected int64 + }{ + { + name: "with valid generation annotation", + meta: metav1.ObjectMeta{ + Annotations: map[string]string{ + constants.AnnotationGeneration: "42", + }, + }, + expected: 42, + }, + { + name: "with no annotations", + meta: metav1.ObjectMeta{}, + expected: 0, + }, + { + name: "with empty generation annotation", + meta: metav1.ObjectMeta{ + Annotations: map[string]string{ + constants.AnnotationGeneration: "", + }, + }, + expected: 0, + }, + { + name: "with invalid generation annotation", + meta: metav1.ObjectMeta{ + Annotations: map[string]string{ + constants.AnnotationGeneration: "not-a-number", + }, + }, + expected: 0, + }, + { + name: "with other annotations only (no generation)", + meta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "other": "value", + }, + }, + expected: 0, + }, + { + name: "with generation and other annotations", + meta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "other": "value", + "another/annotation": "foo", + constants.AnnotationGeneration: "5", + }, + }, + expected: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetGeneration(tt.meta) + if result != tt.expected { + t.Errorf("GetGeneration() = %d, want %d", result, tt.expected) + } + }) + } +} + +func TestGetGenerationFromUnstructured(t *testing.T) { + tests := []struct { + name string + obj *unstructured.Unstructured + expected int64 + }{ + { + name: "with valid generation", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "100", + }, + }, + }, + }, + expected: 100, + }, + { + name: "nil object", + obj: nil, + expected: 0, + }, + { + name: "no annotations", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{}, + }, + }, + expected: 0, + }, + { + name: "with generation and other annotations", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "other": "value", + constants.AnnotationGeneration: "42", + }, + }, + }, + }, + expected: 42, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetGenerationFromUnstructured(tt.obj) + if result != tt.expected { + t.Errorf("GetGenerationFromUnstructured() = %d, want %d", result, tt.expected) + } + }) + } +} + +func TestValidateGeneration(t *testing.T) { + tests := []struct { + name string + meta metav1.ObjectMeta + expectError bool + }{ + { + name: "valid generation annotation", + meta: metav1.ObjectMeta{ + Annotations: map[string]string{ + constants.AnnotationGeneration: "42", + }, + }, + expectError: false, + }, + { + name: "generation 0 is invalid (must be > 0)", + meta: metav1.ObjectMeta{ + Annotations: map[string]string{ + constants.AnnotationGeneration: "0", + }, + }, + expectError: true, + }, + { + name: "large generation is valid", + meta: metav1.ObjectMeta{ + Annotations: map[string]string{ + constants.AnnotationGeneration: "9999999999", + }, + }, + expectError: false, + }, + { + name: "valid generation with other annotations", + meta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "other": "value", + constants.AnnotationGeneration: "10", + }, + }, + expectError: false, + }, + { + name: "missing annotations", + meta: metav1.ObjectMeta{}, + expectError: true, + }, + { + name: "missing generation annotation", + meta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "other": "annotation", + }, + }, + expectError: true, + }, + { + name: "empty generation annotation", + meta: metav1.ObjectMeta{ + Annotations: map[string]string{ + constants.AnnotationGeneration: "", + }, + }, + expectError: true, + }, + { + name: "invalid generation value", + meta: metav1.ObjectMeta{ + Annotations: map[string]string{ + constants.AnnotationGeneration: "not-a-number", + }, + }, + expectError: true, + }, + { + name: "negative generation", + meta: metav1.ObjectMeta{ + Annotations: map[string]string{ + constants.AnnotationGeneration: "-5", + }, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateGeneration(tt.meta) + + if tt.expectError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestValidateGenerationFromUnstructured(t *testing.T) { + tests := []struct { + name string + obj *unstructured.Unstructured + expectError bool + }{ + { + name: "valid generation annotation", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{ + "name": "test", + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "5", + }, + }, + }, + }, + expectError: false, + }, + { + name: "nil object", + obj: nil, + expectError: true, + }, + { + name: "missing annotations", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + expectError: true, + }, + { + name: "invalid generation value", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{ + "name": "test", + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "invalid", + }, + }, + }, + }, + expectError: true, + }, + { + name: "negative generation", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{ + "name": "test", + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "-10", + }, + }, + }, + }, + expectError: true, + }, + { + name: "generation 0 is invalid (must be > 0)", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{ + "name": "test", + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "0", + }, + }, + }, + }, + expectError: true, + }, + { + name: "valid generation with other annotations", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{ + "name": "test", + "annotations": map[string]interface{}{ + "other": "value", + constants.AnnotationGeneration: "15", + }, + }, + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateGenerationFromUnstructured(tt.obj) + + if tt.expectError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestValidateManifestWorkGeneration(t *testing.T) { + // Helper to create a valid manifest with generation + createManifest := func(kind, name, generation string) workv1.Manifest { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": kind, + "metadata": map[string]interface{}{ + "name": name, + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: generation, + }, + }, + }, + } + raw, _ := obj.MarshalJSON() + return workv1.Manifest{RawExtension: runtime.RawExtension{Raw: raw}} + } + + // Helper to create a manifest without generation + createManifestNoGeneration := func(kind, name string) workv1.Manifest { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": kind, + "metadata": map[string]interface{}{ + "name": name, + }, + }, + } + raw, _ := obj.MarshalJSON() + return workv1.Manifest{RawExtension: runtime.RawExtension{Raw: raw}} + } + + tests := []struct { + name string + work *workv1.ManifestWork + expectError bool + }{ + { + name: "valid ManifestWork with generation on all", + work: &workv1.ManifestWork{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-work", + Annotations: map[string]string{ + constants.AnnotationGeneration: "5", + }, + }, + Spec: workv1.ManifestWorkSpec{ + Workload: workv1.ManifestsTemplate{ + Manifests: []workv1.Manifest{ + createManifest("Namespace", "test-ns", "5"), + createManifest("ConfigMap", "test-cm", "5"), + }, + }, + }, + }, + expectError: false, + }, + { + name: "nil work", + work: nil, + expectError: true, + }, + { + name: "ManifestWork without generation annotation", + work: &workv1.ManifestWork{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-work", + }, + Spec: workv1.ManifestWorkSpec{ + Workload: workv1.ManifestsTemplate{ + Manifests: []workv1.Manifest{ + createManifest("Namespace", "test-ns", "5"), + }, + }, + }, + }, + expectError: true, + }, + { + name: "manifest without generation annotation fails", + work: &workv1.ManifestWork{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-work", + Annotations: map[string]string{ + constants.AnnotationGeneration: "5", + }, + }, + Spec: workv1.ManifestWorkSpec{ + Workload: workv1.ManifestsTemplate{ + Manifests: []workv1.Manifest{ + createManifest("Namespace", "test-ns", "5"), + createManifestNoGeneration("ConfigMap", "test-cm"), // Missing generation - error + }, + }, + }, + }, + expectError: true, + }, + { + name: "empty manifests is valid", + work: &workv1.ManifestWork{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-work", + Annotations: map[string]string{ + constants.AnnotationGeneration: "5", + }, + }, + Spec: workv1.ManifestWorkSpec{ + Workload: workv1.ManifestsTemplate{ + Manifests: []workv1.Manifest{}, + }, + }, + }, + expectError: false, + }, + { + name: "different generation values is valid", + work: &workv1.ManifestWork{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-work", + Annotations: map[string]string{ + constants.AnnotationGeneration: "5", + }, + }, + Spec: workv1.ManifestWorkSpec{ + Workload: workv1.ManifestsTemplate{ + Manifests: []workv1.Manifest{ + createManifest("Namespace", "test-ns", "3"), // Different from ManifestWork + createManifest("ConfigMap", "test-cm", "7"), // Different from ManifestWork + }, + }, + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateManifestWorkGeneration(tt.work) + + if tt.expectError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestGetLatestGenerationFromList(t *testing.T) { + tests := []struct { + name string + list *unstructured.UnstructuredList + expectedName string + expectNil bool + }{ + { + name: "nil list returns nil", + list: nil, + expectNil: true, + }, + { + name: "empty list returns nil", + list: &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{}, + }, + expectNil: true, + }, + { + name: "returns resource with highest generation", + list: &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "resource1", + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "10", + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "resource2", + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "42", + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "resource3", + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "5", + }, + }, + }, + }, + }, + }, + expectedName: "resource2", + }, + { + name: "sorts by name when generations are equal", + list: &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "resource-c", + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "10", + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "resource-a", + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "10", + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "resource-b", + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "10", + }, + }, + }, + }, + }, + }, + expectedName: "resource-a", // Alphabetically first + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetLatestGenerationFromList(tt.list) + + if tt.expectNil { + if result != nil { + t.Errorf("GetLatestGenerationFromList() = %v, want nil", result) + } + return + } + + if result == nil { + t.Errorf("GetLatestGenerationFromList() = nil, want non-nil") + return + } + + if result.GetName() != tt.expectedName { + t.Errorf("GetLatestGenerationFromList() name = %s, want %s", result.GetName(), tt.expectedName) + } + }) + } +} diff --git a/internal/hyperfleet_api/client.go b/internal/hyperfleet_api/client.go index 705ff54..ba5e8e7 100644 --- a/internal/hyperfleet_api/client.go +++ b/internal/hyperfleet_api/client.go @@ -15,6 +15,7 @@ import ( apierrors "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/errors" "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/logger" + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/version" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/propagation" @@ -320,6 +321,11 @@ func (c *httpClient) doRequest(ctx context.Context, req *Request) (*Response, er httpReq.Header.Set("Content-Type", "application/json") } + // Set User-Agent header (respect explicit caller override) + if httpReq.Header.Get("User-Agent") == "" { + httpReq.Header.Set("User-Agent", version.UserAgent()) + } + // Inject OpenTelemetry trace context into headers (W3C Trace Context format) // This propagates trace_id and span_id via the 'traceparent' header otel.GetTextMapPropagator().Inject(reqCtx, propagation.HeaderCarrier(httpReq.Header)) diff --git a/internal/k8s_client/client.go b/internal/k8s_client/client.go index 455f8eb..fa03cbe 100644 --- a/internal/k8s_client/client.go +++ b/internal/k8s_client/client.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "os" - "sort" - "strconv" apperrors "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/errors" "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/logger" @@ -22,9 +20,6 @@ import ( // EnvKubeConfig is the environment variable for kubeconfig path const EnvKubeConfig = "KUBECONFIG" -// AnnotationGeneration is the annotation key for tracking resource generation -const AnnotationGeneration = "hyperfleet.io/generation" - // Client is the Kubernetes client for managing resources using controller-runtime type Client struct { client client.Client @@ -373,50 +368,3 @@ func (c *Client) PatchResource(ctx context.Context, gvk schema.GroupVersionKind, // Get the updated resource to return return c.GetResource(ctx, gvk, namespace, name) } - -// GetGenerationAnnotation extracts the generation annotation value from a resource. -// Returns 0 if the resource is nil, has no annotations, or the annotation cannot be parsed. -// Used for resource management to determine if a resource has changed. -// In MVP we won't do validation for the mandatory annotations. Here return 0 if there is no generation annotation. -func GetGenerationAnnotation(obj *unstructured.Unstructured) int64 { - if obj == nil { - return 0 - } - annotations := obj.GetAnnotations() - if annotations == nil { - return 0 - } - genStr, ok := annotations[AnnotationGeneration] - if !ok || genStr == "" { - return 0 - } - gen, err := strconv.ParseInt(genStr, 10, 64) - if err != nil { - return 0 - } - return gen -} - -// GetLatestGenerationResource returns the resource with the highest generation annotation from a list. -// It sorts by generation annotation (descending) and uses metadata.name as a secondary sort key -// for deterministic behavior when generations are equal. -// Returns nil if the list is nil or empty. -func GetLatestGenerationResource(list *unstructured.UnstructuredList) *unstructured.Unstructured { - if list == nil || len(list.Items) == 0 { - return nil - } - - // Sort by generation annotation (descending) to return the one with the latest generation - // Secondary sort by metadata.name for consistency when generations are equal - sort.Slice(list.Items, func(i, j int) bool { - genI := GetGenerationAnnotation(&list.Items[i]) - genJ := GetGenerationAnnotation(&list.Items[j]) - if genI != genJ { - return genI > genJ // Descending order - latest generation first - } - // Fall back to metadata.name for deterministic ordering when generations are equal - return list.Items[i].GetName() < list.Items[j].GetName() - }) - - return &list.Items[0] -} diff --git a/internal/maestro_client/client.go b/internal/maestro_client/client.go new file mode 100644 index 0000000..4c665a4 --- /dev/null +++ b/internal/maestro_client/client.go @@ -0,0 +1,398 @@ +package maestro_client + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" + + apperrors "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/errors" + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/logger" + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/version" + "github.com/openshift-online/maestro/pkg/api/openapi" + "github.com/openshift-online/maestro/pkg/client/cloudevents/grpcsource" + workv1client "open-cluster-management.io/api/client/work/clientset/versioned/typed/work/v1" + "open-cluster-management.io/sdk-go/pkg/cloudevents/generic/options/cert" + "open-cluster-management.io/sdk-go/pkg/cloudevents/generic/options/grpc" +) + +// Default configuration values +const ( + DefaultHTTPTimeout = 10 * time.Second + DefaultServerHealthinessTimeout = 20 * time.Second +) + +// Client is the Maestro client for managing ManifestWorks via CloudEvents gRPC +type Client struct { + workClient workv1client.WorkV1Interface + maestroAPIClient *openapi.APIClient + config *Config + log logger.Logger + grpcOptions *grpc.GRPCOptions +} + +// Config holds configuration for creating a Maestro client +// Following the official Maestro client pattern: +// https://github.com/openshift-online/maestro/blob/main/examples/manifestwork/client.go +type Config struct { + // MaestroServerAddr is the Maestro HTTP API server address (e.g., "https://maestro.example.com:8000") + // This is used for the OpenAPI client to communicate with Maestro's REST API + MaestroServerAddr string + + // GRPCServerAddr is the Maestro gRPC server address (e.g., "maestro-grpc.example.com:8090") + // This is used for CloudEvents communication + GRPCServerAddr string + + // SourceID is a unique identifier for this client (used for CloudEvents routing) + // This identifies the source of ManifestWork operations + SourceID string + + // TLS Configuration for gRPC (optional - for secure connections) + // CAFile is the path to the CA certificate file for verifying the gRPC server + CAFile string + // ClientCertFile is the path to the client certificate file for mutual TLS (gRPC) + ClientCertFile string + // ClientKeyFile is the path to the client key file for mutual TLS (gRPC) + ClientKeyFile string + // TokenFile is the path to a token file for token-based authentication (alternative to cert auth) + TokenFile string + + // TLS Configuration for HTTP API (optional - may use different CA than gRPC) + // HTTPCAFile is the path to the CA certificate file for verifying the HTTPS API server + // If not set, falls back to CAFile for backwards compatibility + HTTPCAFile string + + // Insecure disables TLS verification and allows plaintext connections + // Use this for local testing without TLS or with self-signed certificates + // WARNING: NOT recommended for production + Insecure bool + + // HTTPTimeout is the timeout for HTTP requests to Maestro API (default: 10s) + HTTPTimeout time.Duration + // ServerHealthinessTimeout is the timeout for gRPC server health checks (default: 20s) + ServerHealthinessTimeout time.Duration +} + +// NewMaestroClient creates a new Maestro client using the official Maestro client pattern +// +// The client uses: +// - Maestro HTTP API (OpenAPI client) for resource management +// - CloudEvents over gRPC for ManifestWork operations +// +// Example Usage: +// +// config := &Config{ +// MaestroServerAddr: "https://maestro.example.com:8000", +// GRPCServerAddr: "maestro-grpc.example.com:8090", +// SourceID: "hyperfleet-adapter", +// CAFile: "/etc/maestro/certs/ca.crt", +// ClientCertFile: "/etc/maestro/certs/client.crt", +// ClientKeyFile: "/etc/maestro/certs/client.key", +// } +// client, err := NewMaestroClient(ctx, config, log) +func NewMaestroClient(ctx context.Context, config *Config, log logger.Logger) (*Client, error) { + if config == nil { + return nil, apperrors.ConfigurationError("maestro config is required") + } + if config.MaestroServerAddr == "" { + return nil, apperrors.ConfigurationError("maestro server address is required") + } + + // Validate MaestroServerAddr URL scheme + serverURL, err := url.Parse(config.MaestroServerAddr) + if err != nil { + return nil, apperrors.ConfigurationError("invalid MaestroServerAddr URL: %v", err) + } + // Require http or https scheme (reject schemeless or other schemes like ftp://, grpc://, etc.) + if serverURL.Scheme != "http" && serverURL.Scheme != "https" { + return nil, apperrors.ConfigurationError( + "MaestroServerAddr must use http:// or https:// scheme (got scheme %q in %q)", + serverURL.Scheme, config.MaestroServerAddr) + } + // Enforce https when Insecure=false + if !config.Insecure && serverURL.Scheme != "https" { + return nil, apperrors.ConfigurationError( + "MaestroServerAddr must use https:// scheme when Insecure=false (got %q); "+ + "use https:// URL or set Insecure=true for http:// connections", + serverURL.Scheme) + } + + if config.GRPCServerAddr == "" { + return nil, apperrors.ConfigurationError("maestro gRPC server address is required") + } + if config.SourceID == "" { + return nil, apperrors.ConfigurationError("maestro sourceID is required") + } + + // Apply defaults + httpTimeout := config.HTTPTimeout + if httpTimeout == 0 { + httpTimeout = DefaultHTTPTimeout + } + serverHealthinessTimeout := config.ServerHealthinessTimeout + if serverHealthinessTimeout == 0 { + serverHealthinessTimeout = DefaultServerHealthinessTimeout + } + + log.WithFields(map[string]interface{}{ + "maestroServer": config.MaestroServerAddr, + "grpcServer": config.GRPCServerAddr, + "sourceID": config.SourceID, + }).Info(ctx, "Creating Maestro client") + + // Create HTTP client with appropriate TLS configuration + httpTransport, err := createHTTPTransport(config) + if err != nil { + return nil, apperrors.ConfigurationError("failed to create HTTP transport: %v", err) + } + + // Create Maestro HTTP API client (OpenAPI) + maestroAPIClient := openapi.NewAPIClient(&openapi.Configuration{ + DefaultHeader: make(map[string]string), + UserAgent: version.UserAgent(), + Debug: false, + Servers: openapi.ServerConfigurations{ + { + URL: config.MaestroServerAddr, + Description: "Maestro API Server", + }, + }, + OperationServers: map[string]openapi.ServerConfigurations{}, + HTTPClient: &http.Client{ + Transport: httpTransport, + Timeout: httpTimeout, + }, + }) + + // Create gRPC options + grpcOptions := &grpc.GRPCOptions{ + Dialer: &grpc.GRPCDialer{}, + ServerHealthinessTimeout: &serverHealthinessTimeout, + } + grpcOptions.Dialer.URL = config.GRPCServerAddr + + // Configure TLS if certificates are provided + if err := configureTLS(config, grpcOptions); err != nil { + return nil, apperrors.ConfigurationError("failed to configure TLS: %v", err) + } + + // Create the Maestro gRPC work client using the official pattern + // This returns a workv1client.WorkV1Interface with Kubernetes-style API + workClient, err := grpcsource.NewMaestroGRPCSourceWorkClient( + ctx, + newOCMLoggerAdapter(log), + maestroAPIClient, + grpcOptions, + config.SourceID, + ) + if err != nil { + return nil, apperrors.MaestroError("failed to create Maestro work client: %v", err) + } + + log.WithFields(map[string]interface{}{ + "sourceID": config.SourceID, + }).Info(ctx, "Maestro client created successfully") + + return &Client{ + workClient: workClient, + maestroAPIClient: maestroAPIClient, + config: config, + log: log, + grpcOptions: grpcOptions, + }, nil +} + +// createHTTPTransport creates an HTTP transport with appropriate TLS configuration. +// It clones http.DefaultTransport to preserve important defaults like ProxyFromEnvironment, +// connection pooling, timeouts, etc., and only overrides TLS settings. +func createHTTPTransport(config *Config) (*http.Transport, error) { + // Clone default transport to preserve ProxyFromEnvironment, DialContext, + // MaxIdleConns, IdleConnTimeout, TLSHandshakeTimeout, etc. + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil, apperrors.ConfigurationError("http.DefaultTransport is not *http.Transport").AsError() + } + transport := defaultTransport.Clone() + + // Build TLS config + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + if config.Insecure { + // Insecure mode: skip TLS verification (works for both http:// and https://) + tlsConfig.InsecureSkipVerify = true //nolint:gosec // Intentional: user explicitly set Insecure=true + } else { + // Secure mode: load CA certificate if provided + // HTTPCAFile takes precedence, falls back to CAFile for backwards compatibility + httpCAFile := config.HTTPCAFile + if httpCAFile == "" { + httpCAFile = config.CAFile + } + + if httpCAFile != "" { + caCert, err := os.ReadFile(httpCAFile) + if err != nil { + return nil, err + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, apperrors.ConfigurationError("failed to parse CA certificate from %s", httpCAFile).AsError() + } + tlsConfig.RootCAs = caCertPool + } + } + + transport.TLSClientConfig = tlsConfig + return transport, nil +} + +// configureTLS sets up TLS configuration for the gRPC connection +func configureTLS(config *Config, grpcOptions *grpc.GRPCOptions) error { + // Insecure mode: plaintext gRPC connection (no TLS) + // Note: Unlike HTTP where InsecureSkipVerify allows both http:// and https://, + // gRPC TLS always requires a TLS handshake on the server side. + // For self-signed certs with gRPC, use CAFile instead of Insecure=true. + if config.Insecure { + grpcOptions.Dialer.TLSConfig = nil + return nil + } + + // Option 1: Mutual TLS with certificates + if config.CAFile != "" && config.ClientCertFile != "" && config.ClientKeyFile != "" { + certConfig := cert.CertConfig{ + CAFile: config.CAFile, + ClientCertFile: config.ClientCertFile, + ClientKeyFile: config.ClientKeyFile, + } + if err := certConfig.EmbedCerts(); err != nil { + return err + } + + tlsConfig, err := cert.AutoLoadTLSConfig( + certConfig, + func() (*cert.CertConfig, error) { + c := cert.CertConfig{ + CAFile: config.CAFile, + ClientCertFile: config.ClientCertFile, + ClientKeyFile: config.ClientKeyFile, + } + if err := c.EmbedCerts(); err != nil { + return nil, err + } + return &c, nil + }, + grpcOptions.Dialer, + ) + if err != nil { + return err + } + grpcOptions.Dialer.TLSConfig = tlsConfig + return nil + } + + // Option 2: Token-based authentication with CA + if config.CAFile != "" && config.TokenFile != "" { + token, err := readTokenFile(config.TokenFile) + if err != nil { + return err + } + grpcOptions.Dialer.Token = token + + certConfig := cert.CertConfig{ + CAFile: config.CAFile, + } + if err := certConfig.EmbedCerts(); err != nil { + return err + } + + tlsConfig, err := cert.AutoLoadTLSConfig( + certConfig, + func() (*cert.CertConfig, error) { + c := cert.CertConfig{ + CAFile: config.CAFile, + } + if err := c.EmbedCerts(); err != nil { + return nil, err + } + return &c, nil + }, + grpcOptions.Dialer, + ) + if err != nil { + return err + } + grpcOptions.Dialer.TLSConfig = tlsConfig + return nil + } + + // Option 3: CA only (server verification without client auth) + if config.CAFile != "" { + certConfig := cert.CertConfig{ + CAFile: config.CAFile, + } + if err := certConfig.EmbedCerts(); err != nil { + return err + } + + tlsConfig, err := cert.AutoLoadTLSConfig( + certConfig, + func() (*cert.CertConfig, error) { + c := cert.CertConfig{ + CAFile: config.CAFile, + } + if err := c.EmbedCerts(); err != nil { + return nil, err + } + return &c, nil + }, + grpcOptions.Dialer, + ) + if err != nil { + return err + } + grpcOptions.Dialer.TLSConfig = tlsConfig + return nil + } + + // Fail fast: Insecure=false but no TLS configuration was provided + // This prevents silently falling back to plaintext connections + return fmt.Errorf("no TLS configuration provided: set CAFile (with optional ClientCertFile/ClientKeyFile or TokenFile) or set Insecure=true for plaintext connections") +} + +// readTokenFile reads a token from a file and trims whitespace. +// Returns an error if the file is empty or contains only whitespace. +func readTokenFile(path string) (string, error) { + token, err := os.ReadFile(path) + if err != nil { + return "", err + } + trimmed := strings.TrimSpace(string(token)) + if trimmed == "" { + return "", fmt.Errorf("token file %s is empty or contains only whitespace", path) + } + return trimmed, nil +} + +// Close closes the gRPC connection +func (c *Client) Close() error { + if c.grpcOptions != nil && c.grpcOptions.Dialer != nil { + return c.grpcOptions.Dialer.Close() + } + return nil +} + +// WorkClient returns the underlying WorkV1Interface for ManifestWork operations +func (c *Client) WorkClient() workv1client.WorkV1Interface { + return c.workClient +} + +// SourceID returns the configured source ID +func (c *Client) SourceID() string { + return c.config.SourceID +} diff --git a/internal/maestro_client/interface.go b/internal/maestro_client/interface.go new file mode 100644 index 0000000..4df65d6 --- /dev/null +++ b/internal/maestro_client/interface.go @@ -0,0 +1,32 @@ +package maestro_client + +import ( + "context" + + workv1 "open-cluster-management.io/api/work/v1" +) + +// ManifestWorkClient defines the interface for ManifestWork operations. +// This interface enables easier testing through mocking. +type ManifestWorkClient interface { + // CreateManifestWork creates a new ManifestWork for a target cluster (consumer) + CreateManifestWork(ctx context.Context, consumerName string, work *workv1.ManifestWork) (*workv1.ManifestWork, error) + + // GetManifestWork retrieves a ManifestWork by name from a target cluster + GetManifestWork(ctx context.Context, consumerName string, workName string) (*workv1.ManifestWork, error) + + // ApplyManifestWork creates or updates a ManifestWork (upsert operation) + ApplyManifestWork(ctx context.Context, consumerName string, work *workv1.ManifestWork) (*workv1.ManifestWork, error) + + // DeleteManifestWork deletes a ManifestWork from a target cluster + DeleteManifestWork(ctx context.Context, consumerName string, workName string) error + + // ListManifestWorks lists all ManifestWorks for a target cluster + ListManifestWorks(ctx context.Context, consumerName string, labelSelector string) (*workv1.ManifestWorkList, error) + + // PatchManifestWork patches an existing ManifestWork using JSON merge patch + PatchManifestWork(ctx context.Context, consumerName string, workName string, patchData []byte) (*workv1.ManifestWork, error) +} + +// Ensure Client implements ManifestWorkClient +var _ ManifestWorkClient = (*Client)(nil) diff --git a/internal/maestro_client/ocm_logger_adapter.go b/internal/maestro_client/ocm_logger_adapter.go new file mode 100644 index 0000000..7698d70 --- /dev/null +++ b/internal/maestro_client/ocm_logger_adapter.go @@ -0,0 +1,84 @@ +package maestro_client + +import ( + "context" + + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/logger" + "github.com/openshift-online/ocm-sdk-go/logging" +) + +// Ensure ocmLoggerAdapter implements the OCM SDK logging.Logger interface +var _ logging.Logger = &ocmLoggerAdapter{} + +// ocmLoggerAdapter adapts our logger.Logger interface to the OCM SDK logging.Logger interface. +// This allows using our logger with Maestro's grpcsource client. +type ocmLoggerAdapter struct { + log logger.Logger +} + +// newOCMLoggerAdapter creates a new OCM SDK compatible logger adapter +func newOCMLoggerAdapter(log logger.Logger) *ocmLoggerAdapter { + return &ocmLoggerAdapter{log: log} +} + +// DebugEnabled returns true if the debug level is enabled. +// Always returns true - let the underlying logger filter. +func (a *ocmLoggerAdapter) DebugEnabled() bool { + return true +} + +// InfoEnabled returns true if the information level is enabled. +func (a *ocmLoggerAdapter) InfoEnabled() bool { + return true +} + +// WarnEnabled returns true if the warning level is enabled. +func (a *ocmLoggerAdapter) WarnEnabled() bool { + return true +} + +// ErrorEnabled returns true if the error level is enabled. +func (a *ocmLoggerAdapter) ErrorEnabled() bool { + return true +} + +// Debug logs at debug level with formatting. +func (a *ocmLoggerAdapter) Debug(ctx context.Context, format string, args ...interface{}) { + if ctx == nil { + ctx = context.Background() + } + a.log.Debugf(ctx, format, args...) +} + +// Info logs at info level with formatting. +func (a *ocmLoggerAdapter) Info(ctx context.Context, format string, args ...interface{}) { + if ctx == nil { + ctx = context.Background() + } + a.log.Infof(ctx, format, args...) +} + +// Warn logs at warn level with formatting. +func (a *ocmLoggerAdapter) Warn(ctx context.Context, format string, args ...interface{}) { + if ctx == nil { + ctx = context.Background() + } + a.log.Warnf(ctx, format, args...) +} + +// Error logs at error level with formatting. +func (a *ocmLoggerAdapter) Error(ctx context.Context, format string, args ...interface{}) { + if ctx == nil { + ctx = context.Background() + } + a.log.Errorf(ctx, format, args...) +} + +// Fatal logs at error level with formatting. +// Note: Does not exit - the underlying logger handles that behavior. +func (a *ocmLoggerAdapter) Fatal(ctx context.Context, format string, args ...interface{}) { + if ctx == nil { + ctx = context.Background() + } + a.log.Errorf(ctx, "FATAL: "+format, args...) +} diff --git a/internal/maestro_client/operations.go b/internal/maestro_client/operations.go new file mode 100644 index 0000000..768e362 --- /dev/null +++ b/internal/maestro_client/operations.go @@ -0,0 +1,264 @@ +package maestro_client + +import ( + "context" + "encoding/json" + + "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/generation" + apperrors "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/errors" + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/logger" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubetypes "k8s.io/apimachinery/pkg/types" + workv1 "open-cluster-management.io/api/work/v1" +) + +// CreateManifestWork creates a new ManifestWork for a target cluster (consumer) +// +// The ManifestWork object should be pre-constructed from a template with: +// - hyperfleet.io/generation annotation on ManifestWork metadata +// - hyperfleet.io/generation annotation on each manifest within the workload +// +// This method validates that generation annotations are present and sets the namespace. +// +// Parameters: +// - ctx: Context for the operation +// - consumerName: The target cluster name (Maestro consumer) - will be set as namespace +// - work: Pre-constructed ManifestWork object (from template with generation annotations) +// +// Returns the created ManifestWork or an error +func (c *Client) CreateManifestWork( + ctx context.Context, + consumerName string, + work *workv1.ManifestWork, +) (*workv1.ManifestWork, error) { + if work == nil { + return nil, apperrors.MaestroError("work for manifestwork cannot be nil") + } + + // Validate that generation annotations are present (required on ManifestWork and all manifests) + if err := generation.ValidateManifestWorkGeneration(work); err != nil { + return nil, apperrors.MaestroError("invalid ManifestWork: %v", err) + } + + // Enrich context with common fields + ctx = logger.WithMaestroConsumer(ctx, consumerName) + ctx = logger.WithLogField(ctx, "manifestwork", work.Name) + ctx = logger.WithObservedGeneration(ctx, generation.GetGeneration(work.ObjectMeta)) + + c.log.WithFields(map[string]interface{}{ + "manifests": len(work.Spec.Workload.Manifests), + }).Debug(ctx, "Creating ManifestWork") + + // Set namespace to consumer name (required by Maestro) + work.Namespace = consumerName + + // Create via the work client + created, err := c.workClient.ManifestWorks(consumerName).Create(ctx, work, metav1.CreateOptions{}) + if err != nil { + return nil, apperrors.MaestroError("failed to create ManifestWork %s/%s: %v", + consumerName, work.Name, err) + } + + c.log.Info(ctx, "Created ManifestWork") + return created, nil +} + +// GetManifestWork retrieves a ManifestWork by name from a target cluster +func (c *Client) GetManifestWork( + ctx context.Context, + consumerName string, + workName string, +) (*workv1.ManifestWork, error) { + ctx = logger.WithMaestroConsumer(ctx, consumerName) + ctx = logger.WithLogField(ctx, "manifestwork", workName) + + c.log.Debug(ctx, "Getting ManifestWork") + + work, err := c.workClient.ManifestWorks(consumerName).Get(ctx, workName, metav1.GetOptions{}) + if err != nil { + // Return not found error without wrapping for callers to check + if apierrors.IsNotFound(err) { + return nil, err + } + return nil, apperrors.MaestroError("failed to get ManifestWork %s/%s: %v", + consumerName, workName, err) + } + + return work, nil +} + +// PatchManifestWork patches an existing ManifestWork using JSON merge patch +func (c *Client) PatchManifestWork( + ctx context.Context, + consumerName string, + workName string, + patchData []byte, +) (*workv1.ManifestWork, error) { + ctx = logger.WithMaestroConsumer(ctx, consumerName) + ctx = logger.WithLogField(ctx, "manifestwork", workName) + + c.log.Debug(ctx, "Patching ManifestWork") + + patched, err := c.workClient.ManifestWorks(consumerName).Patch( + ctx, + workName, + kubetypes.MergePatchType, + patchData, + metav1.PatchOptions{}, + ) + if err != nil { + return nil, apperrors.MaestroError("failed to patch ManifestWork %s/%s: %v", + consumerName, workName, err) + } + + c.log.Info(ctx, "Patched ManifestWork") + return patched, nil +} + +// DeleteManifestWork deletes a ManifestWork from a target cluster +func (c *Client) DeleteManifestWork( + ctx context.Context, + consumerName string, + workName string, +) error { + ctx = logger.WithMaestroConsumer(ctx, consumerName) + ctx = logger.WithLogField(ctx, "manifestwork", workName) + + c.log.Debug(ctx, "Deleting ManifestWork") + + err := c.workClient.ManifestWorks(consumerName).Delete(ctx, workName, metav1.DeleteOptions{}) + if err != nil { + // Ignore not found errors (already deleted) + if apierrors.IsNotFound(err) { + c.log.Debug(ctx, "ManifestWork already deleted") + return nil + } + return apperrors.MaestroError("failed to delete ManifestWork %s/%s: %v", + consumerName, workName, err) + } + + c.log.Info(ctx, "Deleted ManifestWork") + return nil +} + +// ListManifestWorks lists all ManifestWorks for a target cluster +func (c *Client) ListManifestWorks( + ctx context.Context, + consumerName string, + labelSelector string, +) (*workv1.ManifestWorkList, error) { + ctx = logger.WithMaestroConsumer(ctx, consumerName) + + c.log.WithFields(map[string]interface{}{ + "labelSelector": labelSelector, + }).Debug(ctx, "Listing ManifestWorks") + + opts := metav1.ListOptions{} + if labelSelector != "" { + opts.LabelSelector = labelSelector + } + + list, err := c.workClient.ManifestWorks(consumerName).List(ctx, opts) + if err != nil { + return nil, apperrors.MaestroError("failed to list ManifestWorks for consumer %s: %v", + consumerName, err) + } + + c.log.WithFields(map[string]interface{}{ + "count": len(list.Items), + }).Debug(ctx, "Listed ManifestWorks") + return list, nil +} + +// ApplyManifestWork creates or updates a ManifestWork (upsert operation) +// +// If the ManifestWork doesn't exist, it creates it. +// If it exists and the generation differs, it updates the ManifestWork. +// If it exists and the generation matches, it skips the update (idempotent). +// +// The ManifestWork object should be pre-constructed from a template with: +// - hyperfleet.io/generation annotation on ManifestWork metadata +// - hyperfleet.io/generation annotation on each manifest within the workload +// +// Parameters: +// - ctx: Context for the operation +// - consumerName: The target cluster name (will be set as namespace) +// - work: Pre-constructed ManifestWork object (from template with generation annotations) +// +// Returns the created or updated ManifestWork or an error +func (c *Client) ApplyManifestWork( + ctx context.Context, + consumerName string, + manifestWork *workv1.ManifestWork, +) (*workv1.ManifestWork, error) { + if manifestWork == nil { + return nil, apperrors.MaestroError("work cannot be nil") + } + + // Validate that generation annotations are present (required on ManifestWork and all manifests) + if err := generation.ValidateManifestWorkGeneration(manifestWork); err != nil { + return nil, apperrors.MaestroError("invalid ManifestWork: %v", err) + } + + // Get generation from the work (set by template) + newGeneration := generation.GetGeneration(manifestWork.ObjectMeta) + + // Enrich context with common fields + ctx = logger.WithMaestroConsumer(ctx, consumerName) + ctx = logger.WithLogField(ctx, "manifestwork", manifestWork.Name) + ctx = logger.WithObservedGeneration(ctx, newGeneration) + + c.log.Debug(ctx, "Applying ManifestWork") + + // Check if ManifestWork exists + existing, err := c.GetManifestWork(ctx, consumerName, manifestWork.Name) + exists := err == nil + if err != nil && !apierrors.IsNotFound(err) { + return nil, err + } + + // Get existing generation (0 if not found) + var existingGeneration int64 + if exists { + existingGeneration = generation.GetGeneration(existing.ObjectMeta) + } + + // Compare generations to determine operation + decision := generation.CompareGenerations(newGeneration, existingGeneration, exists) + + c.log.WithFields(map[string]interface{}{ + "operation": decision.Operation, + "reason": decision.Reason, + }).Debug(ctx, "Apply operation determined") + + // Execute operation based on comparison result + switch decision.Operation { + case generation.OperationCreate: + return c.CreateManifestWork(ctx, consumerName, manifestWork) + case generation.OperationSkip: + return existing, nil + case generation.OperationUpdate: + // Use Patch instead of Update since Maestro gRPC doesn't support Update + patchData, err := createManifestWorkPatch(manifestWork) + if err != nil { + return nil, apperrors.MaestroError("failed to create patch: %v", err) + } + return c.PatchManifestWork(ctx, consumerName, manifestWork.Name, patchData) + default: + return nil, apperrors.MaestroError("unexpected operation: %s", decision.Operation) + } +} + +// createManifestWorkPatch creates a JSON merge patch for updating a ManifestWork +func createManifestWorkPatch(work *workv1.ManifestWork) ([]byte, error) { + // Create patch with metadata (labels, annotations) and spec + patch := map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": work.Labels, + "annotations": work.Annotations, + }, + "spec": work.Spec, + } + return json.Marshal(patch) +} diff --git a/internal/maestro_client/operations_test.go b/internal/maestro_client/operations_test.go new file mode 100644 index 0000000..c8d624b --- /dev/null +++ b/internal/maestro_client/operations_test.go @@ -0,0 +1,170 @@ +// Package maestro_client tests +// +// Note: Tests for generation.ValidateGeneration, generation.ValidateGenerationFromUnstructured, +// and generation.ValidateManifestWorkGeneration are in internal/generation/generation_test.go. +// This file contains tests specific to maestro_client functionality. +package maestro_client + +import ( + "testing" + + "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/generation" + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/constants" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + workv1 "open-cluster-management.io/api/work/v1" +) + +func TestGetGenerationFromManifestWork(t *testing.T) { + tests := []struct { + name string + work *workv1.ManifestWork + expected int64 + }{ + { + name: "nil work returns 0", + work: nil, + expected: 0, + }, + { + name: "work with generation annotation", + work: &workv1.ManifestWork{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + constants.AnnotationGeneration: "42", + }, + }, + }, + expected: 42, + }, + { + name: "work without annotations", + work: &workv1.ManifestWork{ + ObjectMeta: metav1.ObjectMeta{}, + }, + expected: 0, + }, + { + name: "work with invalid generation value", + work: &workv1.ManifestWork{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + constants.AnnotationGeneration: "invalid", + }, + }, + }, + expected: 0, + }, + { + name: "work with empty generation value", + work: &workv1.ManifestWork{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + constants.AnnotationGeneration: "", + }, + }, + }, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var result int64 + if tt.work == nil { + result = 0 + } else { + result = generation.GetGeneration(tt.work.ObjectMeta) + } + if result != tt.expected { + t.Errorf("expected generation %d, got %d", tt.expected, result) + } + }) + } +} + +// BuildManifestWorkName generates a consistent ManifestWork name for testing +// Format: -- +func BuildManifestWorkName(adapterName, resourceName, clusterID string) string { + return adapterName + "-" + resourceName + "-" + clusterID +} + +func TestBuildManifestWorkName(t *testing.T) { + tests := []struct { + name string + adapterName string + resourceName string + clusterID string + expected string + }{ + { + name: "basic name construction", + adapterName: "my-adapter", + resourceName: "namespace", + clusterID: "cluster-123", + expected: "my-adapter-namespace-cluster-123", + }, + { + name: "with special characters", + adapterName: "adapter_v1", + resourceName: "config-map", + clusterID: "prod-us-east-1", + expected: "adapter_v1-config-map-prod-us-east-1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := BuildManifestWorkName(tt.adapterName, tt.resourceName, tt.clusterID) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestGenerationComparison(t *testing.T) { + tests := []struct { + name string + existingGeneration int64 + newGeneration int64 + shouldUpdate bool + description string + }{ + { + name: "same generation - no update", + existingGeneration: 5, + newGeneration: 5, + shouldUpdate: false, + description: "When generations match, should skip update", + }, + { + name: "newer generation - update", + existingGeneration: 5, + newGeneration: 6, + shouldUpdate: true, + description: "When new generation is higher, should update", + }, + { + name: "older generation - still update", + existingGeneration: 6, + newGeneration: 5, + shouldUpdate: true, + description: "When new generation is lower, should still update (allow rollback)", + }, + // Note: "both 0" case is no longer valid since validation requires generation > 0 + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Logic from ApplyManifestWork: + // if existingGeneration == generation { return existing } + shouldSkipUpdate := tt.existingGeneration == tt.newGeneration + shouldUpdate := !shouldSkipUpdate + + if shouldUpdate != tt.shouldUpdate { + t.Errorf("%s: expected shouldUpdate=%v, got %v", + tt.description, tt.shouldUpdate, shouldUpdate) + } + }) + } +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go new file mode 100644 index 0000000..21f329e --- /dev/null +++ b/pkg/constants/constants.go @@ -0,0 +1,53 @@ +package constants + +// HyperFleet Kubernetes Resource Annotations and Labels +// These constants define standard annotations and labels used across HyperFleet resources +// for tracking, management, and identification purposes. + +const ( + // AnnotationGeneration is the annotation key for tracking resource generation. + // This is used to track changes and ensure resources are updated with the correct generation. + // Format: "hyperfleet.io/generation" + // Example value: "5" (integer as string) + AnnotationGeneration = "hyperfleet.io/generation" + + // LabelGeneration is the label key for tracking resource generation. + // Used for label-based filtering and selection of resources by generation. + // Format: "hyperfleet.io/generation" + // Example value: "5" (integer as string) + LabelGeneration = "hyperfleet.io/generation" + + // AnnotationClusterID is the annotation key for cluster identification. + // Links resources to their target cluster. + // Format: "hyperfleet.io/cluster-id" + AnnotationClusterID = "hyperfleet.io/cluster-id" + + // LabelClusterID is the label key for cluster identification. + // Used for label-based filtering and selection of resources by cluster. + // Format: "hyperfleet.io/cluster-id" + LabelClusterID = "hyperfleet.io/cluster-id" + + // AnnotationAdapter is the annotation key for adapter identification. + // Identifies which adapter created or manages the resource. + // Format: "hyperfleet.io/adapter" + AnnotationAdapter = "hyperfleet.io/adapter" + + // LabelAdapter is the label key for adapter identification. + // Used for label-based filtering and selection of resources by adapter. + // Format: "hyperfleet.io/adapter" + LabelAdapter = "hyperfleet.io/adapter" + + // AnnotationManagedBy identifies the entity managing the resource. + // Format: "hyperfleet.io/managed-by" + // Example value: "hyperfleet-adapter" + AnnotationManagedBy = "hyperfleet.io/managed-by" + + // LabelManagedBy is the label key for managed-by identification. + // Format: "hyperfleet.io/managed-by" + LabelManagedBy = "hyperfleet.io/managed-by" + + // AnnotationCreatedBy identifies the entity that created the resource. + // Format: "hyperfleet.io/created-by" + // Example value: "hyperfleet-adapter" + AnnotationCreatedBy = "hyperfleet.io/created-by" +) diff --git a/pkg/errors/error.go b/pkg/errors/error.go index 97a34d2..bb62f87 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -59,6 +59,12 @@ const ( // InvalidCloudEvent occurs when a CloudEvent is invalid or malformed ErrorInvalidCloudEvent ServiceErrorCode = 15 + + // MaestroError occurs when there's an error interacting with Maestro API + ErrorMaestroError ServiceErrorCode = 16 + + // ConfigurationError occurs when there's a configuration error + ErrorConfigurationError ServiceErrorCode = 17 ) type ServiceErrorCode int @@ -91,6 +97,8 @@ func Errors() ServiceErrors { ServiceError{ErrorKubernetesError, "Kubernetes API error", http.StatusInternalServerError}, ServiceError{ErrorHyperFleetAPIError, "HyperFleet API error", http.StatusInternalServerError}, ServiceError{ErrorInvalidCloudEvent, "Invalid CloudEvent", http.StatusBadRequest}, + ServiceError{ErrorMaestroError, "Maestro API error", http.StatusInternalServerError}, + ServiceError{ErrorConfigurationError, "Configuration error", http.StatusInternalServerError}, } } @@ -211,3 +219,11 @@ func HyperFleetAPIError(reason string, values ...interface{}) *ServiceError { func InvalidCloudEvent(reason string, values ...interface{}) *ServiceError { return New(ErrorInvalidCloudEvent, reason, values...) } + +func MaestroError(reason string, values ...interface{}) *ServiceError { + return New(ErrorMaestroError, reason, values...) +} + +func ConfigurationError(reason string, values ...interface{}) *ServiceError { + return New(ErrorConfigurationError, reason, values...) +} diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index 073ea32..d2a57c8 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -71,8 +71,8 @@ func TestErrors(t *testing.T) { t.Run("all_errors_defined", func(t *testing.T) { errors := Errors() - // Should have 15 error codes defined - expectedCount := 15 + // Should have 17 error codes defined + expectedCount := 17 if len(errors) != expectedCount { t.Errorf("Expected %d errors, got %d", expectedCount, len(errors)) } diff --git a/pkg/logger/context.go b/pkg/logger/context.go index 5ab8721..9f0ff6c 100644 --- a/pkg/logger/context.go +++ b/pkg/logger/context.go @@ -10,39 +10,45 @@ import ( // contextKey is a custom type for context keys to avoid collisions type contextKey string +// Context keys for storing values in context.Context +const ( + LogFieldsKey contextKey = "log_fields" +) + +// Log field name constants - use these directly in WithFields maps const ( // Required fields (per logging spec) - ComponentKey contextKey = "component" - VersionKey contextKey = "version" - HostnameKey contextKey = "hostname" + ComponentKey = "component" + VersionKey = "version" + HostnameKey = "hostname" // Error fields (per logging spec) - ErrorKey contextKey = "error" - StackTraceKey contextKey = "stack_trace" + ErrorKey = "error" + StackTraceKey = "stack_trace" // Correlation fields (distributed tracing) - TraceIDKey contextKey = "trace_id" - SpanIDKey contextKey = "span_id" - EventIDKey contextKey = "event_id" + TraceIDKey = "trace_id" + SpanIDKey = "span_id" + EventIDKey = "event_id" // Resource fields (from event data) - ClusterIDKey contextKey = "cluster_id" - ResourceTypeKey contextKey = "resource_type" - ResourceIDKey contextKey = "resource_id" + ClusterIDKey = "cluster_id" + ResourceTypeKey = "resource_type" + ResourceIDKey = "resource_id" // K8s manifest fields - K8sKindKey contextKey = "k8s_kind" - K8sNameKey contextKey = "k8s_name" - K8sNamespaceKey contextKey = "k8s_namespace" - K8sResultKey contextKey = "k8s_result" + K8sKindKey = "k8s_kind" + K8sNameKey = "k8s_name" + K8sNamespaceKey = "k8s_namespace" + K8sResultKey = "k8s_result" // Adapter-specific fields - AdapterKey contextKey = "adapter" - ObservedGenerationKey contextKey = "observed_generation" - SubscriptionKey contextKey = "subscription" + AdapterKey = "adapter" + ObservedGenerationKey = "observed_generation" + SubscriptionKey = "subscription" - // Dynamic log fields - LogFieldsKey contextKey = "log_fields" + // Maestro-specific fields + MaestroConsumerKey = "maestro_consumer" ) // LogFields holds dynamic key-value pairs for logging @@ -85,67 +91,72 @@ func WithDynamicResourceID(ctx context.Context, resourceType string, resourceID // WithTraceID returns a context with the trace ID set func WithTraceID(ctx context.Context, traceID string) context.Context { - return WithLogField(ctx, string(TraceIDKey), traceID) + return WithLogField(ctx, TraceIDKey, traceID) } // WithSpanID returns a context with the span ID set func WithSpanID(ctx context.Context, spanID string) context.Context { - return WithLogField(ctx, string(SpanIDKey), spanID) + return WithLogField(ctx, SpanIDKey, spanID) } // WithEventID returns a context with the event ID set func WithEventID(ctx context.Context, eventID string) context.Context { - return WithLogField(ctx, string(EventIDKey), eventID) + return WithLogField(ctx, EventIDKey, eventID) } // WithClusterID returns a context with the cluster ID set func WithClusterID(ctx context.Context, clusterID string) context.Context { - return WithLogField(ctx, string(ClusterIDKey), clusterID) + return WithLogField(ctx, ClusterIDKey, clusterID) } // WithResourceType returns a context with the event resource type set (e.g., "cluster", "nodepool") func WithResourceType(ctx context.Context, resourceType string) context.Context { - return WithLogField(ctx, string(ResourceTypeKey), resourceType) + return WithLogField(ctx, ResourceTypeKey, resourceType) } // WithResourceID returns a context with the event resource ID set func WithResourceID(ctx context.Context, resourceID string) context.Context { - return WithLogField(ctx, string(ResourceIDKey), resourceID) + return WithLogField(ctx, ResourceIDKey, resourceID) } // WithK8sKind returns a context with the K8s resource kind set (e.g., "Deployment", "Job") func WithK8sKind(ctx context.Context, kind string) context.Context { - return WithLogField(ctx, string(K8sKindKey), kind) + return WithLogField(ctx, K8sKindKey, kind) } // WithK8sName returns a context with the K8s resource name set func WithK8sName(ctx context.Context, name string) context.Context { - return WithLogField(ctx, string(K8sNameKey), name) + return WithLogField(ctx, K8sNameKey, name) } // WithK8sNamespace returns a context with the K8s resource namespace set func WithK8sNamespace(ctx context.Context, namespace string) context.Context { - return WithLogField(ctx, string(K8sNamespaceKey), namespace) + return WithLogField(ctx, K8sNamespaceKey, namespace) } // WithK8sResult returns a context with the K8s resource operation result set (SUCCESS/FAILED) func WithK8sResult(ctx context.Context, result string) context.Context { - return WithLogField(ctx, string(K8sResultKey), result) + return WithLogField(ctx, K8sResultKey, result) } // WithAdapter returns a context with the adapter name set func WithAdapter(ctx context.Context, adapter string) context.Context { - return WithLogField(ctx, string(AdapterKey), adapter) + return WithLogField(ctx, AdapterKey, adapter) } // WithObservedGeneration returns a context with the observed generation set func WithObservedGeneration(ctx context.Context, generation int64) context.Context { - return WithLogField(ctx, string(ObservedGenerationKey), generation) + return WithLogField(ctx, ObservedGenerationKey, generation) } // WithSubscription returns a context with the subscription name set func WithSubscription(ctx context.Context, subscription string) context.Context { - return WithLogField(ctx, string(SubscriptionKey), subscription) + return WithLogField(ctx, SubscriptionKey, subscription) +} + +// WithMaestroConsumer returns a context with the Maestro consumer name set +func WithMaestroConsumer(ctx context.Context, consumer string) context.Context { + return WithLogField(ctx, MaestroConsumerKey, consumer) } // WithErrorField returns a context with the error message set. @@ -157,7 +168,7 @@ func WithErrorField(ctx context.Context, err error) context.Context { if err == nil { return ctx } - ctx = WithLogField(ctx, string(ErrorKey), err.Error()) + ctx = WithLogField(ctx, ErrorKey, err.Error()) // Only capture stack trace for unexpected/internal errors if shouldCaptureStackTrace(err) { @@ -196,12 +207,12 @@ func WithOTelTraceContext(ctx context.Context) context.Context { // Add trace_id if valid if spanCtx.HasTraceID() { - ctx = WithLogField(ctx, string(TraceIDKey), spanCtx.TraceID().String()) + ctx = WithLogField(ctx, TraceIDKey, spanCtx.TraceID().String()) } // Add span_id if valid if spanCtx.HasSpanID() { - ctx = WithLogField(ctx, string(SpanIDKey), spanCtx.SpanID().String()) + ctx = WithLogField(ctx, SpanIDKey, spanCtx.SpanID().String()) } return ctx diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 9e5834b..c8f2942 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -145,9 +145,9 @@ func NewLogger(cfg Config) (Logger, error) { // Create base logger with required fields (per logging spec) slogLogger := slog.New(handler).With( - string(ComponentKey), cfg.Component, - string(VersionKey), cfg.Version, - string(HostnameKey), hostname, + ComponentKey, cfg.Component, + VersionKey, cfg.Version, + HostnameKey, hostname, ) return &logger{ diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index 3764b81..a4919e6 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -280,10 +280,10 @@ func TestLoggerChaining(t *testing.T) { }) } -func TestContextKeys(t *testing.T) { +func TestFieldConstants(t *testing.T) { tests := []struct { name string - key contextKey + key string expected string }{ { @@ -316,12 +316,17 @@ func TestContextKeys(t *testing.T) { key: SubscriptionKey, expected: "subscription", }, + { + name: "MaestroConsumerKey", + key: MaestroConsumerKey, + expected: "maestro_consumer", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if string(tt.key) != tt.expected { - t.Errorf("Expected %s, got %s", tt.expected, string(tt.key)) + if tt.key != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, tt.key) } }) } diff --git a/pkg/logger/stack_trace.go b/pkg/logger/stack_trace.go index 2e685bb..b3a13f7 100644 --- a/pkg/logger/stack_trace.go +++ b/pkg/logger/stack_trace.go @@ -104,7 +104,7 @@ func withStackTraceField(ctx context.Context, frames []string) context.Context { if len(frames) == 0 { return ctx } - return WithLogField(ctx, string(StackTraceKey), frames) + return WithLogField(ctx, StackTraceKey, frames) } // CaptureStackTrace captures the current call stack and returns it as a slice of strings. diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 0000000..38599a7 --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,52 @@ +// Package version provides build version information for the hyperfleet-adapter. +// Version values are set at build time via ldflags. +package version + +import "os" + +// Environment variable for overriding UserAgent +const EnvUserAgent = "HYPERFLEET_USER_AGENT" + +// Build-time variables set via ldflags +// Example: go build -ldflags "-X github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/version.Version=1.0.0" +var ( + // Version is the semantic version of the adapter + Version = "0.1.0" + + // Commit is the git commit SHA + Commit = "none" + + // BuildDate is the date when the binary was built + BuildDate = "unknown" + + // Tag is the git tag (if any) + Tag = "none" +) + +// UserAgent returns the User-Agent string for HTTP clients. +// It first checks the HYPERFLEET_USER_AGENT environment variable (EnvUserAgent), +// and if not set, returns the default "hyperfleet-adapter/{version}" string. +func UserAgent() string { + if ua := os.Getenv(EnvUserAgent); ua != "" { + return ua + } + return "hyperfleet-adapter/" + Version +} + +// Info returns all version information as a struct +func Info() VersionInfo { + return VersionInfo{ + Version: Version, + Commit: Commit, + BuildDate: BuildDate, + Tag: Tag, + } +} + +// VersionInfo contains all build version information +type VersionInfo struct { + Version string + Commit string + BuildDate string + Tag string +} diff --git a/test/integration/config-loader/config_criteria_integration_test.go b/test/integration/config-loader/config_criteria_integration_test.go index 1088e3e..a0f2cfb 100644 --- a/test/integration/config-loader/config_criteria_integration_test.go +++ b/test/integration/config-loader/config_criteria_integration_test.go @@ -21,6 +21,7 @@ import ( func TestMain(m *testing.M) { // Set required environment variables for tests os.Setenv("HYPERFLEET_API_BASE_URL", "http://test-api.example.com") + os.Setenv("HYPERFLEET_API_TOKEN", "test-token-for-integration-tests") os.Exit(m.Run()) } diff --git a/test/integration/config-loader/loader_template_test.go b/test/integration/config-loader/loader_template_test.go index 4018a2a..2248b0f 100644 --- a/test/integration/config-loader/loader_template_test.go +++ b/test/integration/config-loader/loader_template_test.go @@ -40,6 +40,7 @@ func getProjectRoot() string { func TestLoadTemplateConfig(t *testing.T) { // Set required environment variables for the template config t.Setenv("HYPERFLEET_API_BASE_URL", "http://test-api.example.com") + t.Setenv("HYPERFLEET_API_TOKEN", "test-token-for-integration-tests") projectRoot := getProjectRoot() configPath := filepath.Join(projectRoot, "configs/adapterconfig-template.yaml") diff --git a/test/integration/executor/executor_k8s_integration_test.go b/test/integration/executor/executor_k8s_integration_test.go index c0c2523..bfa96af 100644 --- a/test/integration/executor/executor_k8s_integration_test.go +++ b/test/integration/executor/executor_k8s_integration_test.go @@ -14,6 +14,7 @@ import ( "github.com/cloudevents/sdk-go/v2/event" "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/config_loader" "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/executor" + "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/generation" "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/hyperfleet_api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -368,7 +369,7 @@ func TestExecutor_K8s_CreateResources(t *testing.T) { cmResult := result.ResourceResults[0] assert.Equal(t, "clusterConfigMap", cmResult.Name) assert.Equal(t, executor.StatusSuccess, cmResult.Status, "ConfigMap creation should succeed") - assert.Equal(t, executor.OperationCreate, cmResult.Operation, "Should be create operation") + assert.Equal(t, generation.OperationCreate, cmResult.Operation, "Should be create operation") assert.Equal(t, "ConfigMap", cmResult.Kind) t.Logf("ConfigMap created: %s/%s (operation: %s)", cmResult.Namespace, cmResult.ResourceName, cmResult.Operation) @@ -376,7 +377,7 @@ func TestExecutor_K8s_CreateResources(t *testing.T) { secretResult := result.ResourceResults[1] assert.Equal(t, "clusterSecret", secretResult.Name) assert.Equal(t, executor.StatusSuccess, secretResult.Status, "Secret creation should succeed") - assert.Equal(t, executor.OperationCreate, secretResult.Operation) + assert.Equal(t, generation.OperationCreate, secretResult.Operation) assert.Equal(t, "Secret", secretResult.Kind) t.Logf("Secret created: %s/%s (operation: %s)", secretResult.Namespace, secretResult.ResourceName, secretResult.Operation) @@ -501,7 +502,7 @@ func TestExecutor_K8s_UpdateExistingResource(t *testing.T) { // Verify it was an update operation require.Len(t, result.ResourceResults, 1) cmResult := result.ResourceResults[0] - assert.Equal(t, executor.OperationUpdate, cmResult.Operation, "Should be update operation") + assert.Equal(t, generation.OperationUpdate, cmResult.Operation, "Should be update operation") t.Logf("Resource operation: %s", cmResult.Operation) // Verify ConfigMap was updated with new data @@ -605,14 +606,14 @@ func TestExecutor_K8s_DiscoveryByLabels(t *testing.T) { evt := createK8sTestEvent(clusterId) result1 := exec.Execute(ctx, evt) require.Equal(t, executor.StatusSuccess, result1.Status) - assert.Equal(t, executor.OperationCreate, result1.ResourceResults[0].Operation) + assert.Equal(t, generation.OperationCreate, result1.ResourceResults[0].Operation) t.Logf("First execution: %s", result1.ResourceResults[0].Operation) // Second execution - should find by labels and update evt2 := createK8sTestEvent(clusterId) result2 := exec.Execute(ctx, evt2) require.Equal(t, executor.StatusSuccess, result2.Status) - assert.Equal(t, executor.OperationUpdate, result2.ResourceResults[0].Operation) + assert.Equal(t, generation.OperationUpdate, result2.ResourceResults[0].Operation) t.Logf("Second execution: %s (discovered by labels)", result2.ResourceResults[0].Operation) } @@ -676,7 +677,7 @@ func TestExecutor_K8s_RecreateOnChange(t *testing.T) { evt := createK8sTestEvent(clusterId) result1 := exec.Execute(ctx, evt) require.Equal(t, executor.StatusSuccess, result1.Status) - assert.Equal(t, executor.OperationCreate, result1.ResourceResults[0].Operation) + assert.Equal(t, generation.OperationCreate, result1.ResourceResults[0].Operation) // Get the original UID cmGVK := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} @@ -690,7 +691,7 @@ func TestExecutor_K8s_RecreateOnChange(t *testing.T) { evt2 := createK8sTestEvent(clusterId) result2 := exec.Execute(ctx, evt2) require.Equal(t, executor.StatusSuccess, result2.Status) - assert.Equal(t, executor.OperationRecreate, result2.ResourceResults[0].Operation) + assert.Equal(t, generation.OperationRecreate, result2.ResourceResults[0].Operation) t.Logf("Second execution: %s", result2.ResourceResults[0].Operation) // Verify it's a new resource (different UID) @@ -739,7 +740,7 @@ func TestExecutor_K8s_MultipleResourceTypes(t *testing.T) { // Verify both resources created for _, rr := range result.ResourceResults { assert.Equal(t, executor.StatusSuccess, rr.Status, "Resource %s should succeed", rr.Name) - assert.Equal(t, executor.OperationCreate, rr.Operation) + assert.Equal(t, generation.OperationCreate, rr.Operation) t.Logf("Created %s: %s/%s", rr.Kind, rr.Namespace, rr.ResourceName) } @@ -940,7 +941,7 @@ func TestExecutor_K8s_MultipleMatchingResources(t *testing.T) { // Should create a new resource (no discovery configured) rr := result.ResourceResults[0] - assert.Equal(t, executor.OperationCreate, rr.Operation, + assert.Equal(t, generation.OperationCreate, rr.Operation, "Should create new resource (no discovery configured)") t.Logf("Operation: %s on resource: %s/%s", rr.Operation, rr.Namespace, rr.ResourceName) diff --git a/test/integration/maestro_client/client_integration_test.go b/test/integration/maestro_client/client_integration_test.go new file mode 100644 index 0000000..385e855 --- /dev/null +++ b/test/integration/maestro_client/client_integration_test.go @@ -0,0 +1,291 @@ +package maestro_client_integration + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/maestro_client" + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/constants" + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + workv1 "open-cluster-management.io/api/work/v1" +) + +// testClient holds resources for a test client that need cleanup +type testClient struct { + Client *maestro_client.Client + Ctx context.Context + Cancel context.CancelFunc +} + +// Close cleans up test client resources +func (tc *testClient) Close() { + if tc.Client != nil { + _ = tc.Client.Close() + } + if tc.Cancel != nil { + tc.Cancel() + } +} + +// createTestClient creates a Maestro client for integration testing. +// It handles all common setup: env, logger, context, config, and client creation. +// The caller should defer tc.Close() to ensure cleanup. +func createTestClient(t *testing.T, sourceID string, timeout time.Duration) *testClient { + t.Helper() + + env := GetSharedEnv(t) + + log, err := logger.NewLogger(logger.Config{ + Level: "debug", + Format: "text", + Component: "maestro-integration-test", + }) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + + config := &maestro_client.Config{ + MaestroServerAddr: env.MaestroServerAddr, + GRPCServerAddr: env.MaestroGRPCAddr, + SourceID: sourceID, + Insecure: true, + } + + client, err := maestro_client.NewMaestroClient(ctx, config, log) + if err != nil { + cancel() + require.NoError(t, err, "Should create Maestro client successfully") + } + + return &testClient{ + Client: client, + Ctx: ctx, + Cancel: cancel, + } +} + +// TestMaestroClientConnection tests basic client connection to Maestro +func TestMaestroClientConnection(t *testing.T) { + tc := createTestClient(t, "integration-test-source", 30*time.Second) + defer tc.Close() + + assert.NotNil(t, tc.Client.WorkClient(), "WorkClient should not be nil") + assert.Equal(t, "integration-test-source", tc.Client.SourceID()) +} + +// TestMaestroClientCreateManifestWork tests creating a ManifestWork +func TestMaestroClientCreateManifestWork(t *testing.T) { + tc := createTestClient(t, "integration-test-create", 60*time.Second) + defer tc.Close() + + consumerName := "test-cluster-create" + + // Create a simple namespace manifest + namespaceManifest := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{ + "name": "test-namespace", + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "1", + }, + }, + } + + namespaceJSON, err := json.Marshal(namespaceManifest) + require.NoError(t, err) + + // Create ManifestWork + work := &workv1.ManifestWork{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-manifestwork-create", + Namespace: consumerName, + Annotations: map[string]string{ + constants.AnnotationGeneration: "1", + }, + Labels: map[string]string{ + "test": "integration", + }, + }, + Spec: workv1.ManifestWorkSpec{ + Workload: workv1.ManifestsTemplate{ + Manifests: []workv1.Manifest{ + { + RawExtension: runtime.RawExtension{ + Raw: namespaceJSON, + }, + }, + }, + }, + }, + } + + // Create the ManifestWork + created, err := tc.Client.CreateManifestWork(tc.Ctx, consumerName, work) + + // Consumer should be registered during test setup, so this should succeed + require.NoError(t, err, "CreateManifestWork should succeed (consumer %s should be registered)", consumerName) + require.NotNil(t, created) + assert.Equal(t, work.Name, created.Name) + t.Logf("Created ManifestWork: %s/%s", created.Namespace, created.Name) +} + +// TestMaestroClientListManifestWorks tests listing ManifestWorks +func TestMaestroClientListManifestWorks(t *testing.T) { + tc := createTestClient(t, "integration-test-list", 30*time.Second) + defer tc.Close() + + consumerName := "test-cluster-list" + + // List ManifestWorks (empty label selector = list all) + list, err := tc.Client.ListManifestWorks(tc.Ctx, consumerName, "") + + // Consumer should be registered during test setup, so this should succeed + require.NoError(t, err, "ListManifestWorks should succeed (consumer %s should be registered)", consumerName) + require.NotNil(t, list) + t.Logf("Found %d ManifestWorks for consumer %s", len(list.Items), consumerName) +} + +// TestMaestroClientApplyManifestWork tests the apply (create or update) operation +func TestMaestroClientApplyManifestWork(t *testing.T) { + tc := createTestClient(t, "integration-test-apply", 60*time.Second) + defer tc.Close() + + consumerName := "test-cluster-apply" + + // Create a ConfigMap manifest + configMapManifest := map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-config", + "namespace": "default", + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "1", + }, + }, + "data": map[string]interface{}{ + "key1": "value1", + }, + } + + configMapJSON, err := json.Marshal(configMapManifest) + require.NoError(t, err) + + // Create ManifestWork + work := &workv1.ManifestWork{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-manifestwork-apply", + Namespace: consumerName, + Annotations: map[string]string{ + constants.AnnotationGeneration: "1", + }, + }, + Spec: workv1.ManifestWorkSpec{ + Workload: workv1.ManifestsTemplate{ + Manifests: []workv1.Manifest{ + { + RawExtension: runtime.RawExtension{ + Raw: configMapJSON, + }, + }, + }, + }, + }, + } + + // Apply the ManifestWork (should create if not exists) + applied, err := tc.Client.ApplyManifestWork(tc.Ctx, consumerName, work) + + // Consumer should be registered during test setup, so this should succeed + require.NoError(t, err, "ApplyManifestWork should succeed (consumer %s should be registered)", consumerName) + require.NotNil(t, applied) + t.Logf("Applied ManifestWork: %s/%s", applied.Namespace, applied.Name) + + // Now apply again with updated generation (should update) + work.Annotations[constants.AnnotationGeneration] = "2" + // Safe: manifest structure is defined above in this test with known nested maps + configMapManifest["metadata"].(map[string]interface{})["annotations"].(map[string]interface{})[constants.AnnotationGeneration] = "2" + configMapManifest["data"].(map[string]interface{})["key2"] = "value2" + configMapJSON, _ = json.Marshal(configMapManifest) + work.Spec.Workload.Manifests[0].Raw = configMapJSON + + updated, err := tc.Client.ApplyManifestWork(tc.Ctx, consumerName, work) + require.NoError(t, err, "ApplyManifestWork (update) should succeed") + require.NotNil(t, updated) + t.Logf("Updated ManifestWork: %s/%s", updated.Namespace, updated.Name) +} + +// TestMaestroClientGenerationSkip tests that apply skips when generation matches +func TestMaestroClientGenerationSkip(t *testing.T) { + tc := createTestClient(t, "integration-test-skip", 60*time.Second) + defer tc.Close() + + consumerName := "test-cluster-skip" + + // Create a simple manifest + manifest := map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-skip-config", + "namespace": "default", + "annotations": map[string]interface{}{ + constants.AnnotationGeneration: "5", + }, + }, + "data": map[string]interface{}{ + "test": "data", + }, + } + + manifestJSON, err := json.Marshal(manifest) + require.NoError(t, err) + + work := &workv1.ManifestWork{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-manifestwork-skip", + Namespace: consumerName, + Annotations: map[string]string{ + constants.AnnotationGeneration: "5", + }, + }, + Spec: workv1.ManifestWorkSpec{ + Workload: workv1.ManifestsTemplate{ + Manifests: []workv1.Manifest{ + { + RawExtension: runtime.RawExtension{ + Raw: manifestJSON, + }, + }, + }, + }, + }, + } + + // First apply + result1, err := tc.Client.ApplyManifestWork(tc.Ctx, consumerName, work) + if err != nil { + t.Skipf("Skipping generation skip test - consumer may not be registered: %v", err) + } + require.NotNil(t, result1) + + // Apply again with same generation - should skip (return existing without update) + result2, err := tc.Client.ApplyManifestWork(tc.Ctx, consumerName, work) + require.NoError(t, err) + require.NotNil(t, result2) + + // When skipped, both results should refer to the same resource (same name/namespace) + assert.Equal(t, result1.Name, result2.Name, + "ManifestWork name should match when generation unchanged (skip)") + assert.Equal(t, result1.Namespace, result2.Namespace, + "ManifestWork namespace should match when generation unchanged (skip)") + t.Logf("Skip test passed - result1.ResourceVersion=%s, result2.ResourceVersion=%s", + result1.ResourceVersion, result2.ResourceVersion) +} diff --git a/test/integration/maestro_client/main_test.go b/test/integration/maestro_client/main_test.go new file mode 100644 index 0000000..0ab4a93 --- /dev/null +++ b/test/integration/maestro_client/main_test.go @@ -0,0 +1,159 @@ +// main_test.go provides shared test setup for Maestro integration tests. +// It starts PostgreSQL and Maestro server containers that are reused across all test functions. + +package maestro_client_integration + +import ( + "context" + "flag" + "fmt" + "os" + "runtime" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" +) + +const ( + // MaestroImage is the Maestro server container image + MaestroImage = "quay.io/redhat-user-workloads/maestro-rhtap-tenant/maestro/maestro:latest" + + // PostgresImage is the PostgreSQL container image + PostgresImage = "docker.io/library/postgres:14.2" + + // Default ports + PostgresPort = "5432/tcp" + MaestroHTTPPort = "8000/tcp" + MaestroGRPCPort = "8090/tcp" + MaestroHealthPort = "8083/tcp" +) + +// MaestroTestEnv holds the test environment configuration +type MaestroTestEnv struct { + // PostgreSQL + PostgresContainer testcontainers.Container + PostgresHost string + PostgresPort string + + // Maestro + MaestroContainer testcontainers.Container + MaestroHost string + MaestroHTTPPort string + MaestroGRPCPort string + MaestroHealthPort string + + // Connection strings + MaestroServerAddr string // HTTP API address (e.g., "http://localhost:32000") + MaestroGRPCAddr string // gRPC address (e.g., "localhost:32001") +} + +// sharedEnv holds the shared test environment for all integration tests +var sharedEnv *MaestroTestEnv + +// skipReason holds reason to skip tests (e.g., ARM64 without local image, no container runtime) +var skipReason string + +// setupErr holds any error that occurred during setup (consumer registration, container start, etc.) +// These errors should cause test FAILURE, not skip. +var setupErr error + +// TestMain runs before all tests to set up the shared containers +func TestMain(m *testing.M) { + flag.Parse() + + // Check if we should skip integration tests + if testing.Short() { + os.Exit(m.Run()) + } + + // Check if SKIP_MAESTRO_INTEGRATION_TESTS is set + if os.Getenv("SKIP_MAESTRO_INTEGRATION_TESTS") == "true" { + skipReason = "SKIP_MAESTRO_INTEGRATION_TESTS is set" + println("⚠️ SKIP_MAESTRO_INTEGRATION_TESTS is set, skipping maestro_client integration tests") + os.Exit(m.Run()) + } + + // Skip on ARM64 Macs unless MAESTRO_ARM64_TEST is set (user has local ARM64 image) + // To run on ARM64, build a local image from the Maestro source and tag it as: + // quay.io/redhat-user-workloads/maestro-rhtap-tenant/maestro/maestro:latest + if runtime.GOARCH == "arm64" && os.Getenv("MAESTRO_ARM64_TEST") != "true" { + skipReason = "ARM64 architecture without MAESTRO_ARM64_TEST=true (set this env if you have a local ARM64 Maestro image)" + println("⚠️ Skipping Maestro integration tests on ARM64") + println(" The official Maestro image is amd64 only.") + println(" To run locally, build from source and set MAESTRO_ARM64_TEST=true:") + println(" cd /path/to/maestro && podman build -t quay.io/redhat-user-workloads/maestro-rhtap-tenant/maestro/maestro:latest .") + os.Exit(m.Run()) + } + + // Quick check if testcontainers can work + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + provider, err := testcontainers.NewDockerProvider() + if err != nil { + skipReason = fmt.Sprintf("container runtime not available: %v", err) + println("⚠️ Warning: Could not connect to container runtime:", err.Error()) + println(" Tests will be skipped") + } else { + info, err := provider.DaemonHost(ctx) + _ = provider.Close() + + if err != nil { + skipReason = fmt.Sprintf("container runtime info not available: %v", err) + println("⚠️ Warning: Could not get container runtime info:", err.Error()) + println(" Tests will be skipped") + } else { + println("✅ Container runtime available:", info) + println("🚀 Starting Maestro test environment...") + + // Set up the shared environment + env, err := setupMaestroTestEnv() + if err != nil { + // Setup failures (including consumer registration) should FAIL tests, not skip + setupErr = err + println("❌ Failed to set up Maestro environment:", err.Error()) + println(" Tests will FAIL") + } else { + sharedEnv = env + println("✅ Maestro test environment ready!") + println(fmt.Sprintf(" HTTP API: %s", env.MaestroServerAddr)) + println(fmt.Sprintf(" gRPC: %s", env.MaestroGRPCAddr)) + } + } + } + println() + + // Run tests + exitCode := m.Run() + + // Cleanup after all tests + if sharedEnv != nil { + println() + println("🧹 Cleaning up Maestro test environment...") + cleanupMaestroTestEnv(sharedEnv) + } + + os.Exit(exitCode) +} + +// GetSharedEnv returns the shared test environment. +// - If there's a skipReason (ARM64, no container runtime), tests are skipped +// - If there's a setupErr (consumer registration, container start failed), tests FAIL +// - If in short mode, tests are skipped +func GetSharedEnv(t *testing.T) *MaestroTestEnv { + t.Helper() + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + if skipReason != "" { + t.Skipf("Skipping: %s", skipReason) + } + if setupErr != nil { + t.Fatalf("Maestro environment setup failed: %v", setupErr) + } + if sharedEnv == nil { + t.Fatal("Shared test environment is not initialized") + } + return sharedEnv +} diff --git a/test/integration/maestro_client/setup_test.go b/test/integration/maestro_client/setup_test.go new file mode 100644 index 0000000..1dddd6e --- /dev/null +++ b/test/integration/maestro_client/setup_test.go @@ -0,0 +1,394 @@ +package maestro_client_integration + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/docker/go-connections/nat" + "github.com/openshift-online/maestro/pkg/api/openapi" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + // Database configuration + dbName = "maestro" + dbUser = "maestro" + dbPassword = "maestro-test-password" +) + +// setupMaestroTestEnv starts PostgreSQL and Maestro containers +func setupMaestroTestEnv() (*MaestroTestEnv, error) { + ctx := context.Background() + env := &MaestroTestEnv{} + + // Step 1: Start PostgreSQL + println(" 📦 Starting PostgreSQL container...") + pgContainer, err := startPostgresContainer(ctx) + if err != nil { + return nil, fmt.Errorf("failed to start PostgreSQL: %w", err) + } + env.PostgresContainer = pgContainer + + // Get PostgreSQL connection info + host, err := pgContainer.Host(ctx) + if err != nil { + _ = pgContainer.Terminate(ctx) + return nil, fmt.Errorf("failed to get PostgreSQL host: %w", err) + } + env.PostgresHost = host + + port, err := pgContainer.MappedPort(ctx, nat.Port(PostgresPort)) + if err != nil { + _ = pgContainer.Terminate(ctx) + return nil, fmt.Errorf("failed to get PostgreSQL port: %w", err) + } + env.PostgresPort = port.Port() + println(fmt.Sprintf(" ✅ PostgreSQL ready at %s:%s", env.PostgresHost, env.PostgresPort)) + + // Step 2: Run Maestro migration + println(" 🔄 Running Maestro database migration...") + if err := runMaestroMigration(ctx, env); err != nil { + _ = pgContainer.Terminate(ctx) + return nil, fmt.Errorf("failed to run Maestro migration: %w", err) + } + println(" ✅ Database migration complete") + + // Step 3: Start Maestro server + println(" 📦 Starting Maestro server container...") + maestroContainer, err := startMaestroServer(ctx, env) + if err != nil { + _ = pgContainer.Terminate(ctx) + return nil, fmt.Errorf("failed to start Maestro server: %w", err) + } + env.MaestroContainer = maestroContainer + + // Get Maestro connection info + env.MaestroHost, err = maestroContainer.Host(ctx) + if err != nil { + cleanupMaestroTestEnv(env) + return nil, fmt.Errorf("failed to get Maestro host: %w", err) + } + + httpPort, err := maestroContainer.MappedPort(ctx, nat.Port(MaestroHTTPPort)) + if err != nil { + cleanupMaestroTestEnv(env) + return nil, fmt.Errorf("failed to get Maestro HTTP port: %w", err) + } + env.MaestroHTTPPort = httpPort.Port() + + grpcPort, err := maestroContainer.MappedPort(ctx, nat.Port(MaestroGRPCPort)) + if err != nil { + cleanupMaestroTestEnv(env) + return nil, fmt.Errorf("failed to get Maestro gRPC port: %w", err) + } + env.MaestroGRPCPort = grpcPort.Port() + + healthPort, err := maestroContainer.MappedPort(ctx, nat.Port(MaestroHealthPort)) + if err != nil { + cleanupMaestroTestEnv(env) + return nil, fmt.Errorf("failed to get Maestro health port: %w", err) + } + env.MaestroHealthPort = healthPort.Port() + + // Build connection strings - use 127.0.0.1 to avoid IPv6 issues + env.MaestroServerAddr = fmt.Sprintf("http://127.0.0.1:%s", env.MaestroHTTPPort) + env.MaestroGRPCAddr = fmt.Sprintf("127.0.0.1:%s", env.MaestroGRPCPort) + + println(" ✅ Maestro server ready") + + // Step 4: Register test consumers (waitForMaestroAPI handles initialization delay) + println(" 📝 Registering test consumers...") + if err := registerTestConsumers(ctx, env); err != nil { + cleanupMaestroTestEnv(env) + return nil, fmt.Errorf("failed to register test consumers: %w", err) + } + println(" ✅ Test consumers registered") + + return env, nil +} + +// startPostgresContainer starts a PostgreSQL container +func startPostgresContainer(ctx context.Context) (testcontainers.Container, error) { + req := testcontainers.ContainerRequest{ + Image: PostgresImage, + ExposedPorts: []string{PostgresPort}, + Env: map[string]string{ + "POSTGRES_DB": dbName, + "POSTGRES_USER": dbUser, + "POSTGRES_PASSWORD": dbPassword, + }, + WaitingFor: wait.ForAll( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + wait.ForListeningPort(nat.Port(PostgresPort)). + WithStartupTimeout(60*time.Second), + ), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return nil, err + } + + return container, nil +} + +// getPostgresIP returns the PostgreSQL container's IP address for inter-container communication +func getPostgresIP(ctx context.Context, container testcontainers.Container) (string, error) { + pgInspect, err := container.Inspect(ctx) + if err != nil { + return "", fmt.Errorf("failed to inspect PostgreSQL container: %w", err) + } + + // Try to get the container IP from any network + for _, network := range pgInspect.NetworkSettings.Networks { + if network.IPAddress != "" { + return network.IPAddress, nil + } + } + + // Fallback to host.docker.internal for Docker Desktop + return "host.docker.internal", nil +} + +// runMaestroMigration runs the Maestro database migration +func runMaestroMigration(ctx context.Context, env *MaestroTestEnv) error { + pgIP, err := getPostgresIP(ctx, env.PostgresContainer) + if err != nil { + return err + } + + // Maestro now uses file-based database configuration + // Create files via shell script in entrypoint + setupScript := fmt.Sprintf(`#!/bin/sh +mkdir -p /secrets +echo -n '%s' > /secrets/db.host +echo -n '5432' > /secrets/db.port +echo -n '%s' > /secrets/db.user +echo -n '%s' > /secrets/db.password +echo -n '%s' > /secrets/db.name +exec /usr/local/bin/maestro migration \ + --db-host-file=/secrets/db.host \ + --db-port-file=/secrets/db.port \ + --db-user-file=/secrets/db.user \ + --db-password-file=/secrets/db.password \ + --db-name-file=/secrets/db.name \ + --db-sslmode=disable \ + --alsologtostderr \ + -v=2 +`, pgIP, dbUser, dbPassword, dbName) + + req := testcontainers.ContainerRequest{ + Image: MaestroImage, + Entrypoint: []string{"/bin/sh", "-c", setupScript}, + WaitingFor: wait.ForExit().WithExitTimeout(120 * time.Second), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return fmt.Errorf("failed to run migration container: %w", err) + } + defer func() { + _ = container.Terminate(ctx) + }() + + // Check exit code + state, err := container.State(ctx) + if err != nil { + return fmt.Errorf("failed to get migration container state: %w", err) + } + + if state.ExitCode != 0 { + // Get logs for debugging (read full output to avoid truncation) + logs, _ := container.Logs(ctx) + if logs != nil { + defer logs.Close() //nolint:errcheck + logBytes, _ := io.ReadAll(logs) + println(fmt.Sprintf(" Migration logs: %s", string(logBytes))) + } + return fmt.Errorf("migration failed with exit code %d", state.ExitCode) + } + + return nil +} + +// startMaestroServer starts the Maestro server container +func startMaestroServer(ctx context.Context, env *MaestroTestEnv) (testcontainers.Container, error) { + pgIP, err := getPostgresIP(ctx, env.PostgresContainer) + if err != nil { + return nil, err + } + + // Maestro now uses file-based database configuration + // Create files via shell script in entrypoint + setupScript := fmt.Sprintf(`#!/bin/sh +mkdir -p /secrets +echo -n '%s' > /secrets/db.host +echo -n '5432' > /secrets/db.port +echo -n '%s' > /secrets/db.user +echo -n '%s' > /secrets/db.password +echo -n '%s' > /secrets/db.name +exec /usr/local/bin/maestro server \ + --db-host-file=/secrets/db.host \ + --db-port-file=/secrets/db.port \ + --db-user-file=/secrets/db.user \ + --db-password-file=/secrets/db.password \ + --db-name-file=/secrets/db.name \ + --db-sslmode=disable \ + --server-hostname=0.0.0.0 \ + --enable-grpc-server=true \ + --grpc-server-bindport=8090 \ + --http-server-bindport=8000 \ + --health-check-server-bindport=8083 \ + --message-broker-type=grpc \ + --alsologtostderr \ + -v=2 +`, pgIP, dbUser, dbPassword, dbName) + + req := testcontainers.ContainerRequest{ + Image: MaestroImage, + ExposedPorts: []string{MaestroHTTPPort, MaestroGRPCPort, MaestroHealthPort}, + Entrypoint: []string{"/bin/sh", "-c", setupScript}, + WaitingFor: wait.ForAll( + wait.ForListeningPort(nat.Port(MaestroHTTPPort)).WithStartupTimeout(120*time.Second), + wait.ForListeningPort(nat.Port(MaestroGRPCPort)).WithStartupTimeout(120*time.Second), + ), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return nil, err + } + + return container, nil +} + +// cleanupMaestroTestEnv cleans up all containers +func cleanupMaestroTestEnv(env *MaestroTestEnv) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if env.MaestroContainer != nil { + println(" Stopping Maestro server...") + if err := env.MaestroContainer.Terminate(ctx); err != nil { + println(fmt.Sprintf(" ⚠️ Warning: Failed to terminate Maestro: %v", err)) + } + } + + if env.PostgresContainer != nil { + println(" Stopping PostgreSQL...") + if err := env.PostgresContainer.Terminate(ctx); err != nil { + println(fmt.Sprintf(" ⚠️ Warning: Failed to terminate PostgreSQL: %v", err)) + } + } + + println(" ✅ Cleanup complete") +} + +// testConsumerNames lists all consumer names used by integration tests +var testConsumerNames = []string{ + "test-cluster-create", + "test-cluster-list", + "test-cluster-apply", + "test-cluster-skip", +} + +// waitForMaestroAPI waits for Maestro API to be fully ready +func waitForMaestroAPI(ctx context.Context, env *MaestroTestEnv) error { + httpClient := &http.Client{ + Timeout: 5 * time.Second, + } + + // Use the consumers endpoint to verify API readiness + apiURL := fmt.Sprintf("%s/api/maestro/v1/consumers", env.MaestroServerAddr) + println(fmt.Sprintf(" API URL: %s", apiURL)) + + // More retries to handle slow startup + maxRetries := 20 + var lastErr error + for i := 0; i < maxRetries; i++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := httpClient.Do(req) + if err == nil { + _ = resp.Body.Close() + // Accept any response (2xx-4xx means API is responding) + if resp.StatusCode < 500 { + println(fmt.Sprintf(" API check succeeded on attempt %d (HTTP %d)", i+1, resp.StatusCode)) + return nil + } + lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) + println(fmt.Sprintf(" API check attempt %d: HTTP %d", i+1, resp.StatusCode)) + } else { + lastErr = err + if i < 3 || i%5 == 0 { + println(fmt.Sprintf(" API check attempt %d: %v", i+1, err)) + } + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(1 * time.Second): + // Retry + } + } + + return fmt.Errorf("Maestro API check failed after %d retries, last error: %v", maxRetries, lastErr) +} + +// registerTestConsumers registers fake consumers for integration testing +func registerTestConsumers(ctx context.Context, env *MaestroTestEnv) error { + // Wait for Maestro API to be fully ready (not just ports listening) + println(" Waiting for Maestro health check...") + if err := waitForMaestroAPI(ctx, env); err != nil { + return fmt.Errorf("Maestro health check failed: %w", err) + } + println(" Maestro API is ready") + + // Create an openapi client for the Maestro API + apiConfig := openapi.NewConfiguration() + apiConfig.Servers = openapi.ServerConfigurations{ + {URL: env.MaestroServerAddr}, + } + apiConfig.HTTPClient = &http.Client{ + Timeout: 10 * time.Second, + } + apiClient := openapi.NewAPIClient(apiConfig) + + // Register each test consumer + for _, consumerName := range testConsumerNames { + consumer := openapi.NewConsumer() + consumer.SetName(consumerName) + + _, resp, err := apiClient.DefaultAPI.ApiMaestroV1ConsumersPost(ctx).Consumer(*consumer).Execute() + if err != nil { + // Check if it's a conflict (consumer already exists) - that's OK + if resp != nil && resp.StatusCode == http.StatusConflict { + println(fmt.Sprintf(" Consumer %s already exists (OK)", consumerName)) + continue + } + return fmt.Errorf("failed to register consumer %s: %w", consumerName, err) + } + println(fmt.Sprintf(" Registered consumer: %s", consumerName)) + } + + return nil +}