diff --git a/docs/content/en/docs/documentation/configuration.md b/docs/content/en/docs/documentation/configuration.md index 888804628f..34aa639525 100644 --- a/docs/content/en/docs/documentation/configuration.md +++ b/docs/content/en/docs/documentation/configuration.md @@ -149,6 +149,212 @@ For more information on how to use this feature, we recommend looking at how thi `KubernetesDependentResource` in the core framework, `SchemaDependentResource` in the samples or `CustomAnnotationDep` in the `BaseConfigurationServiceTest` test class. -## EventSource-level configuration +## Loading Configuration from External Sources + +JOSDK ships a `ConfigLoader` that bridges any key-value configuration source to the operator and +controller configuration APIs. This lets you drive operator behaviour from environment variables, +system properties, YAML files, or any config library (MicroProfile Config, SmallRye Config, +Spring Environment, etc.) without writing glue code by hand. + +### Architecture + +The system is built around two thin abstractions: + +- **[`ConfigProvider`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigProvider.java)** + — a single-method interface that resolves a typed value for a dot-separated key: + + ```java + public interface ConfigProvider { + Optional getValue(String key, Class type); + } + ``` + +- **[`ConfigLoader`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java)** + — reads all known JOSDK keys from a `ConfigProvider` and returns + `Consumer` / `Consumer>` + values that you pass directly to the `Operator` constructor or `operator.register()`. + +The default `ConfigLoader` (no-arg constructor) stacks environment variables over system +properties: environment variables win, system properties are the fallback. + +```java +// uses env vars + system properties out of the box +Operator operator = new Operator(ConfigLoader.getDefault().applyConfigs()); +``` + +### Built-in Providers + +| Provider | Source | Key mapping | +|---|---|---| +| `EnvVarConfigProvider` | `System.getenv()` | dots and hyphens → underscores, upper-cased (`josdk.check-crd` → `JOSDK_CHECK_CRD`) | +| `PropertiesConfigProvider` | `java.util.Properties` or `.properties` file | key used as-is; use `PropertiesConfigProvider.systemProperties()` to read Java system properties | +| `YamlConfigProvider` | YAML file | dot-separated key traverses nested mappings | +| `AgregatePriorityListConfigProvider` | ordered list of providers | first non-empty result wins | + +All string-based providers convert values to the target type automatically. +Supported types: `String`, `Boolean`, `Integer`, `Long`, `Double`, `Duration` (ISO-8601, e.g. `PT30S`). + +### Plugging in Any Config Library + +`ConfigProvider` is a single-method interface, so adapting any config library takes only a few +lines. As an example, here is an adapter for +[SmallRye Config](https://smallrye.io/smallrye-config/): + +```java +public class SmallRyeConfigProvider implements ConfigProvider { + + private final SmallRyeConfig config; + + public SmallRyeConfigProvider(SmallRyeConfig config) { + this.config = config; + } + + @Override + public Optional getValue(String key, Class type) { + return config.getOptionalValue(key, type); + } +} +``` + +The same pattern applies to MicroProfile Config, Spring `Environment`, Apache Commons +Configuration, or any other library that can look up typed values by string key. + +### Wiring Everything Together + +Pass the `ConfigLoader` results when constructing the operator and registering reconcilers: + +```java +// Load operator-wide config from a YAML file via SmallRye Config +URL configUrl = MyOperator.class.getResource("/application.yaml"); +var configLoader = new ConfigLoader( + new SmallRyeConfigProvider( + new SmallRyeConfigBuilder() + .withSources(new YamlConfigSource(configUrl)) + .build())); + +// applyConfigs() → Consumer +Operator operator = new Operator(configLoader.applyConfigs()); + +// applyControllerConfigs(name) → Consumer> +operator.register(new MyReconciler(), + configLoader.applyControllerConfigs(MyReconciler.NAME)); +``` + +Only keys that are actually present in the source are applied; everything else retains its +programmatic or annotation-based default. + +You can also compose multiple sources with explicit priority using +`AgregatePriorityListConfigProvider`: + +```java +var configLoader = new ConfigLoader( + new AgregatePriorityListConfigProvider(List.of( + new EnvVarConfigProvider(), // highest priority + PropertiesConfigProvider.systemProperties(), + new YamlConfigProvider(Path.of("config/operator.yaml")) // lowest priority + ))); +``` + +### Operator-Level Configuration Keys + +All operator-level keys are prefixed with `josdk.`. + +#### General + +| Key | Type | Description | +|---|---|---| +| `josdk.check-crd` | `Boolean` | Validate CRDs against local model on startup | +| `josdk.close-client-on-stop` | `Boolean` | Close the Kubernetes client when the operator stops | +| `josdk.use-ssa-to-patch-primary-resource` | `Boolean` | Use Server-Side Apply to patch the primary resource | +| `josdk.clone-secondary-resources-when-getting-from-cache` | `Boolean` | Clone secondary resources on cache reads | + +#### Reconciliation + +| Key | Type | Description | +|---|---|---| +| `josdk.reconciliation.concurrent-threads` | `Integer` | Thread pool size for reconciliation | +| `josdk.reconciliation.termination-timeout` | `Duration` | How long to wait for in-flight reconciliations to finish on shutdown | + +#### Workflow + +| Key | Type | Description | +|---|---|---| +| `josdk.workflow.executor-threads` | `Integer` | Thread pool size for workflow execution | + +#### Informer + +| Key | Type | Description | +|---|---|---| +| `josdk.informer.cache-sync-timeout` | `Duration` | Timeout for the initial informer cache sync | +| `josdk.informer.stop-on-error-during-startup` | `Boolean` | Stop the operator if an informer fails to start | + +#### Dependent Resources + +| Key | Type | Description | +|---|---|---| +| `josdk.dependent-resources.ssa-based-create-update-match` | `Boolean` | Use SSA-based matching for dependent resource create/update | + +#### Leader Election + +Leader election is activated when at least one `josdk.leader-election.*` key is present. +`josdk.leader-election.lease-name` is required when any other leader-election key is set. +Setting `josdk.leader-election.enabled=false` suppresses leader election even if other keys are +present. + +| Key | Type | Description | +|---|---|---| +| `josdk.leader-election.enabled` | `Boolean` | Explicitly enable (`true`) or disable (`false`) leader election | +| `josdk.leader-election.lease-name` | `String` | **Required.** Name of the Kubernetes Lease object used for leader election | +| `josdk.leader-election.lease-namespace` | `String` | Namespace for the Lease object (defaults to the operator's namespace) | +| `josdk.leader-election.identity` | `String` | Unique identity for this instance; defaults to the pod name | +| `josdk.leader-election.lease-duration` | `Duration` | How long a lease is valid (default `PT15S`) | +| `josdk.leader-election.renew-deadline` | `Duration` | How long the leader tries to renew before giving up (default `PT10S`) | +| `josdk.leader-election.retry-period` | `Duration` | How often a candidate polls while waiting to become leader (default `PT2S`) | + +### Controller-Level Configuration Keys + +All controller-level keys are prefixed with `josdk.controller..`, where +`` is the value returned by the reconciler's name (typically set via +`@ControllerConfiguration(name = "...")`). + +#### General + +| Key | Type | Description | +|---|---|---| +| `josdk.controller..finalizer` | `String` | Finalizer string added to managed resources | +| `josdk.controller..generation-aware` | `Boolean` | Skip reconciliation when the resource generation has not changed | +| `josdk.controller..label-selector` | `String` | Label selector to filter watched resources | +| `josdk.controller..max-reconciliation-interval` | `Duration` | Maximum interval between reconciliations even without events | +| `josdk.controller..field-manager` | `String` | Field manager name used for SSA operations | +| `josdk.controller..trigger-reconciler-on-all-events` | `Boolean` | Trigger reconciliation on every event, not only meaningful changes | + +#### Informer + +| Key | Type | Description | +|---|---|---| +| `josdk.controller..informer.label-selector` | `String` | Label selector for the primary resource informer (alias for `label-selector`) | +| `josdk.controller..informer.list-limit` | `Long` | Page size for paginated informer list requests; omit for no pagination | + +#### Retry + +If any `retry.*` key is present, a `GenericRetry` is configured starting from the +[default limited exponential retry](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java). +Only explicitly set keys override the defaults. + +| Key | Type | Description | +|---|---|---| +| `josdk.controller..retry.max-attempts` | `Integer` | Maximum number of retry attempts | +| `josdk.controller..retry.initial-interval` | `Long` (ms) | Initial backoff interval in milliseconds | +| `josdk.controller..retry.interval-multiplier` | `Double` | Exponential backoff multiplier | +| `josdk.controller..retry.max-interval` | `Long` (ms) | Maximum backoff interval in milliseconds | + +#### Rate Limiter + +The rate limiter is only activated when `rate-limiter.limit-for-period` is present and has a +positive value. `rate-limiter.refresh-period` is optional and falls back to the default of 10 s. + +| Key | Type | Description | +|---|---|---| +| `josdk.controller..rate-limiter.limit-for-period` | `Integer` | Maximum number of reconciliations allowed per refresh period. Must be positive to activate the limiter | +| `josdk.controller..rate-limiter.refresh-period` | `Duration` | Window over which the limit is counted (default `PT10S`) | -TODO diff --git a/helm/operator/Chart.yaml b/helm/operator/Chart.yaml new file mode 100644 index 0000000000..9ba424eb54 --- /dev/null +++ b/helm/operator/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v2 +name: josdk-operator +description: > + Generic Helm chart template for deploying a Java Operator SDK based operator. + Copy and customise this chart for your own operator: adjust the values, extend + the ClusterRole rules with your CRD API groups, and optionally add your own + templates. +type: application +version: 0.1.0 +# Set to the version of your operator image. +appVersion: "latest" +keywords: + - operator + - kubernetes + - java-operator-sdk +home: https://javaoperatorsdk.io +sources: + - https://github.com/operator-framework/java-operator-sdk diff --git a/helm/operator/templates/NOTES.txt b/helm/operator/templates/NOTES.txt new file mode 100644 index 0000000000..319f42fe6e --- /dev/null +++ b/helm/operator/templates/NOTES.txt @@ -0,0 +1,45 @@ +Thank you for installing {{ .Chart.Name }} ({{ .Chart.AppVersion }}). + +Release: {{ .Release.Name }} +Namespace: {{ include "josdk-operator.namespace" . }} + +Operator deployment: {{ include "josdk-operator.fullname" . }} + +{{- if .Values.josdkConfig.enabled }} + +ConfigLoader properties are mounted from ConfigMap + {{ include "josdk-operator.configMapName" . }} +at {{ .Values.josdkConfig.mountPath }}/josdk.properties. + +Wire it up in your operator main class: + + ConfigLoader loader = new ConfigLoader( + PropertiesConfigProvider.fromFile( + Path.of("{{ .Values.josdkConfig.mountPath }}/josdk.properties"))); +{{- end }} + +{{- if .Values.log4j2.enabled }} + +Log4j2 configuration is mounted from ConfigMap + {{ include "josdk-operator.log4j2ConfigMapName" . }} +at {{ .Values.log4j2.mountPath }}/log4j2.xml. + +Root log level: {{ .Values.log4j2.rootLevel }} +{{- if .Values.log4j2.loggers }} +Per-logger overrides: +{{- range $logger, $level := .Values.log4j2.loggers }} + {{ $logger }} -> {{ $level }} +{{- end }} +{{- end }} + +The JVM flag -Dlog4j2.configurationFile={{ .Values.log4j2.mountPath }}/log4j2.xml +has been added to JAVA_TOOL_OPTIONS automatically. +{{- end }} + +To change the log level at runtime without redeploying, update the ConfigMap: + + kubectl edit configmap {{ include "josdk-operator.log4j2ConfigMapName" . }} \ + -n {{ include "josdk-operator.namespace" . }} + +Log4j2 will pick up the change within 30 seconds (monitorInterval="30" in the +default configuration). diff --git a/helm/operator/templates/_helpers.tpl b/helm/operator/templates/_helpers.tpl new file mode 100644 index 0000000000..6adb60ebe7 --- /dev/null +++ b/helm/operator/templates/_helpers.tpl @@ -0,0 +1,93 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "josdk-operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "josdk-operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Chart label. +*/}} +{{- define "josdk-operator.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels applied to every resource. +*/}} +{{- define "josdk-operator.labels" -}} +helm.sh/chart: {{ include "josdk-operator.chart" . }} +{{ include "josdk-operator.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels used in Deployment and Service selectors. +*/}} +{{- define "josdk-operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "josdk-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +ServiceAccount name. +*/}} +{{- define "josdk-operator.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "josdk-operator.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Deployment namespace. +*/}} +{{- define "josdk-operator.namespace" -}} +{{- default .Release.Namespace .Values.namespace }} +{{- end }} + +{{/* +Name of the JOSDK config ConfigMap. +*/}} +{{- define "josdk-operator.configMapName" -}} +{{- default (printf "%s-config" (include "josdk-operator.fullname" .)) .Values.josdkConfig.configMapName }} +{{- end }} + +{{/* +Name of the log4j2 ConfigMap. +*/}} +{{- define "josdk-operator.log4j2ConfigMapName" -}} +{{- default (printf "%s-log4j2" (include "josdk-operator.fullname" .)) .Values.log4j2.configMapName }} +{{- end }} + +{{/* +JAVA_TOOL_OPTIONS / JVM args value. +Appends the log4j2 config file system property automatically when log4j2 is enabled. +*/}} +{{- define "josdk-operator.jvmArgs" -}} +{{- $args := .Values.jvmArgs | default "" }} +{{- if .Values.log4j2.enabled }} +{{- $args = printf "%s -Dlog4j2.configurationFile=%s/log4j2.xml" $args .Values.log4j2.mountPath | trim }} +{{- end }} +{{- $args }} +{{- end }} diff --git a/helm/operator/templates/clusterrole.yaml b/helm/operator/templates/clusterrole.yaml new file mode 100644 index 0000000000..92000cb618 --- /dev/null +++ b/helm/operator/templates/clusterrole.yaml @@ -0,0 +1,44 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "josdk-operator.fullname" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +rules: + # Required for JOSDK to install / validate CRDs on startup. + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get", "list", "watch", "create", "update", "patch"] + # Required for leader-election (if used). + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Required for JOSDK event-source on ConfigMaps / Secrets (optional; remove if not needed). + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + # Add your operator's custom resource rules here via values.rbac.additionalRules, e.g.: + # additionalRules: + # - apiGroups: ["mygroup.example.com"] + # resources: ["myresources", "myresources/status"] + # verbs: ["*"] + {{- with .Values.rbac.additionalRules }} + {{- toYaml . | nindent 2 }} + {{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "josdk-operator.fullname" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "josdk-operator.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "josdk-operator.serviceAccountName" . }} + namespace: {{ include "josdk-operator.namespace" . }} +{{- end }} diff --git a/helm/operator/templates/configmap-josdk.yaml b/helm/operator/templates/configmap-josdk.yaml new file mode 100644 index 0000000000..cd09277b2b --- /dev/null +++ b/helm/operator/templates/configmap-josdk.yaml @@ -0,0 +1,31 @@ +{{- if .Values.josdkConfig.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "josdk-operator.configMapName" . }} + namespace: {{ include "josdk-operator.namespace" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +data: + # All key-value pairs are written into josdk.properties. + # The operator reads this file via a file-backed ConfigProvider and passes it + # to ConfigLoader. See the ConfigLoader javadoc for supported property keys. + # + # Example operator-level keys: + # josdk.reconciliation.concurrent-threads=4 + # josdk.informer.stop-on-error-during-startup=false + # josdk.informer.cache-sync-timeout=PT30S + # + # Example controller-level keys (replace "my-controller" with the lower-cased + # reconciler class name): + # josdk.controller.my-controller.retry.max-attempts=5 + # josdk.controller.my-controller.retry.initial-interval=1000 + # josdk.controller.my-controller.retry.interval-multiplier=1.5 + # josdk.controller.my-controller.retry.max-interval=60000 + # josdk.controller.my-controller.rate-limiter.limit-for-period=10 + # josdk.controller.my-controller.rate-limiter.refresh-period=PT1S + josdk.properties: | + {{- range $key, $value := .Values.josdkConfig.properties }} + {{ $key }}={{ $value }} + {{- end }} +{{- end }} diff --git a/helm/operator/templates/configmap-log4j2.yaml b/helm/operator/templates/configmap-log4j2.yaml new file mode 100644 index 0000000000..2c630ac0bf --- /dev/null +++ b/helm/operator/templates/configmap-log4j2.yaml @@ -0,0 +1,37 @@ +{{- if .Values.log4j2.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "josdk-operator.log4j2ConfigMapName" . }} + namespace: {{ include "josdk-operator.namespace" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +data: + log4j2.xml: | + {{- if .Values.log4j2.xmlOverride }} + {{- .Values.log4j2.xmlOverride | nindent 4 }} + {{- else }} + + + + + + + + + + {{- range $logger, $level := .Values.log4j2.loggers }} + + + + {{- end }} + + + + + + {{- end }} +{{- end }} diff --git a/helm/operator/templates/deployment.yaml b/helm/operator/templates/deployment.yaml new file mode 100644 index 0000000000..efbcca02d5 --- /dev/null +++ b/helm/operator/templates/deployment.yaml @@ -0,0 +1,130 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "josdk-operator.fullname" . }} + namespace: {{ include "josdk-operator.namespace" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "josdk-operator.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "josdk-operator.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "josdk-operator.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: operator + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + # ------------------------------------------------------------------ + # Environment variables. + # JAVA_TOOL_OPTIONS is set when jvmArgs is non-empty or log4j2 is + # enabled (the helper appends -Dlog4j2.configurationFile). + # extraEnv entries are appended after. + # ------------------------------------------------------------------ + {{- $jvmArgs := include "josdk-operator.jvmArgs" . | trim }} + {{- if or $jvmArgs .Values.extraEnv }} + env: + {{- if $jvmArgs }} + - name: JAVA_TOOL_OPTIONS + value: {{ $jvmArgs | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- end }} + {{- if .Values.healthProbes.enabled }} + ports: + - name: health + containerPort: {{ .Values.healthProbes.port }} + protocol: TCP + startupProbe: + httpGet: + path: {{ .Values.healthProbes.startupProbe.path }} + port: {{ .Values.healthProbes.port }} + initialDelaySeconds: {{ .Values.healthProbes.startupProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.healthProbes.startupProbe.periodSeconds }} + failureThreshold: {{ .Values.healthProbes.startupProbe.failureThreshold }} + livenessProbe: + httpGet: + path: {{ .Values.healthProbes.livenessProbe.path }} + port: {{ .Values.healthProbes.port }} + initialDelaySeconds: {{ .Values.healthProbes.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.healthProbes.livenessProbe.periodSeconds }} + failureThreshold: {{ .Values.healthProbes.livenessProbe.failureThreshold }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + # ---------------------------------------------------------------- + # JOSDK ConfigLoader properties file + # Mounted at /josdk.properties. + # Wire it up in your operator main class, e.g.: + # + # ConfigLoader loader = new ConfigLoader( + # PropertiesConfigProvider.fromFile( + # Path.of("/config/josdk.properties"))); + # ---------------------------------------------------------------- + {{- if .Values.josdkConfig.enabled }} + - name: josdk-config + mountPath: {{ .Values.josdkConfig.mountPath }} + readOnly: true + {{- end }} + # ---------------------------------------------------------------- + # Log4j2 configuration file + # Mounted at /log4j2.xml. + # Picked up automatically via JAVA_TOOL_OPTIONS when log4j2.enabled=true. + # ---------------------------------------------------------------- + {{- if .Values.log4j2.enabled }} + - name: log4j2-config + mountPath: {{ .Values.log4j2.mountPath }} + readOnly: true + {{- end }} + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + {{- if .Values.josdkConfig.enabled }} + - name: josdk-config + configMap: + name: {{ include "josdk-operator.configMapName" . }} + {{- end }} + {{- if .Values.log4j2.enabled }} + - name: log4j2-config + configMap: + name: {{ include "josdk-operator.log4j2ConfigMapName" . }} + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/operator/templates/serviceaccount.yaml b/helm/operator/templates/serviceaccount.yaml new file mode 100644 index 0000000000..c31888988b --- /dev/null +++ b/helm/operator/templates/serviceaccount.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "josdk-operator.serviceAccountName" . }} + namespace: {{ include "josdk-operator.namespace" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} diff --git a/helm/operator/values.yaml b/helm/operator/values.yaml new file mode 100644 index 0000000000..bdf493dcdd --- /dev/null +++ b/helm/operator/values.yaml @@ -0,0 +1,153 @@ +# ----------------------------------------------------------------------- +# Generic JOSDK Operator Helm Chart – default values +# Override any of these in your own values.yaml or with --set on the CLI. +# ----------------------------------------------------------------------- + +# -- Operator identity ------------------------------------------------------- +nameOverride: "" +fullnameOverride: "" + +# -- Image ------------------------------------------------------------------- +image: + repository: my-operator + tag: latest + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +# -- Replicas ---------------------------------------------------------------- +replicaCount: 1 + +# -- Namespace the operator is deployed into. +# Defaults to the Helm release namespace (.Release.Namespace). +namespace: "" + +# -- Service account --------------------------------------------------------- +serviceAccount: + # Create a dedicated ServiceAccount for the operator. + create: true + # Annotations to add (e.g. for IRSA / Workload Identity). + annotations: {} + # Override the auto-generated name. + name: "" + +# -- Pod settings ------------------------------------------------------------ +podAnnotations: {} +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 2000 + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +# -- Health probes ----------------------------------------------------------- +# Requires the operator to expose an HTTP health endpoint. +# The JOSDK sample operators expose /startup and /healthz on port 8080 by +# default; adjust if your operator uses different paths or ports. +healthProbes: + enabled: false + port: 8080 + startupProbe: + path: /startup + initialDelaySeconds: 5 + periodSeconds: 2 + failureThreshold: 15 + livenessProbe: + path: /healthz + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 3 + +# -- RBAC -------------------------------------------------------------------- +rbac: + # Create ClusterRole + ClusterRoleBinding. + create: true + # Additional rules appended to the ClusterRole. + # Add entries for your operator's custom resources here, e.g.: + # - apiGroups: ["mygroup.example.com"] + # resources: ["myresources", "myresources/status"] + # verbs: ["*"] + additionalRules: [] + +# -- ConfigLoader configuration ConfigMap ------------------------------------ +# When enabled, a ConfigMap is created and mounted into the operator pod at +# /config/josdk.properties (or josdk.yaml). The operator must be coded to +# load it via ConfigLoader / ConfigProvider (or Spring, etc.). +# All key-value pairs under `config.properties` are written verbatim into the +# ConfigMap data entry `josdk.properties`. Use the JOSDK property keys +# documented in ConfigLoader (e.g. josdk.reconciliation.concurrent-threads). +josdkConfig: + enabled: false + # Name of the ConfigMap; defaults to -config. + configMapName: "" + # Mount path inside the container. + mountPath: /config + # Properties written into josdk.properties inside the ConfigMap. + # Example: + # properties: + # josdk.reconciliation.concurrent-threads: "4" + # josdk.workflow.executor-threads: "2" + # josdk.informer.stop-on-error-during-startup: "false" + # josdk.controller.my-controller.retry.max-attempts: "5" + # josdk.controller.my-controller.retry.initial-interval: "1000" + properties: {} + +# -- Log4j2 configuration ---------------------------------------------------- +# When enabled, a ConfigMap containing a log4j2.xml is created and mounted +# into the operator pod at /config/log4j2.xml. The operator must be launched +# with -Dlog4j2.configurationFile=/config/log4j2.xml (set via `jvmArgs` below) +# so that Log4j2 picks up the external file. +log4j2: + enabled: false + # Name of the ConfigMap; defaults to -log4j2. + configMapName: "" + # Mount path for the log4j2.xml file. + mountPath: /config + # Root log level (TRACE | DEBUG | INFO | WARN | ERROR | OFF). + rootLevel: INFO + # Per-logger overrides – map of logger-name → level. + # Example: + # loggers: + # io.javaoperatorsdk: DEBUG + # io.fabric8.kubernetes.client: WARN + loggers: {} + # Full override of the log4j2 XML content. When set, rootLevel and loggers + # are ignored and this raw XML is used instead. + xmlOverride: "" + +# -- Extra environment variables injected into the operator container -------- +# Example: +# extraEnv: +# - name: METRICS_CONSOLE_LOGGING +# value: "true" +extraEnv: [] + +# -- Extra volumes / mounts (user-defined, independent of the above) --------- +extraVolumes: [] +extraVolumeMounts: [] + +# -- JVM arguments passed to the operator process --------------------------- +# The log4j2 config file path is appended automatically when log4j2.enabled=true. +# Example: +# jvmArgs: "-Xmx256m -Xms128m" +jvmArgs: "" + +# -- Node scheduling --------------------------------------------------------- +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigBinding.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigBinding.java new file mode 100644 index 0000000000..7cb508b2f1 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigBinding.java @@ -0,0 +1,50 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader; + +import java.util.function.BiConsumer; + +/** + * Associates a configuration key and its expected type with the setter that should be called on an + * overrider when the {@link ConfigProvider} returns a value for that key. + * + * @param the overrider type (e.g. {@code ConfigurationServiceOverrider}) + * @param the value type expected for this key + */ +public class ConfigBinding { + + private final String key; + private final Class type; + private final BiConsumer setter; + + public ConfigBinding(String key, Class type, BiConsumer setter) { + this.key = key; + this.type = type; + this.setter = setter; + } + + public String key() { + return key; + } + + public Class type() { + return type; + } + + public BiConsumer setter() { + return setter; + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java new file mode 100644 index 0000000000..d46a6116d7 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -0,0 +1,383 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +import io.javaoperatorsdk.operator.api.config.LeaderElectionConfigurationBuilder; +import io.javaoperatorsdk.operator.config.loader.provider.AgregatePriorityListConfigProvider; +import io.javaoperatorsdk.operator.config.loader.provider.EnvVarConfigProvider; +import io.javaoperatorsdk.operator.config.loader.provider.PropertiesConfigProvider; +import io.javaoperatorsdk.operator.processing.event.rate.LinearRateLimiter; +import io.javaoperatorsdk.operator.processing.retry.GenericRetry; + +public class ConfigLoader { + + private static final Logger log = LoggerFactory.getLogger(ConfigLoader.class); + + private static final ConfigLoader DEFAULT = new ConfigLoader(); + + public static ConfigLoader getDefault() { + return DEFAULT; + } + + public static final String DEFAULT_OPERATOR_KEY_PREFIX = "josdk."; + public static final String DEFAULT_CONTROLLER_KEY_PREFIX = "josdk.controller."; + + /** + * Key prefix for controller-level properties. The controller name is inserted between this prefix + * and the property name, e.g. {@code josdk.controller.my-controller.finalizer}. + */ + private final String controllerKeyPrefix; + + private final String operatorKeyPrefix; + + // --------------------------------------------------------------------------- + // Operator-level (ConfigurationServiceOverrider) bindings + // Only scalar / value types that a key-value ConfigProvider can supply are + // included. Complex objects (KubernetesClient, ExecutorService, …) must be + // configured programmatically and are intentionally omitted. + // --------------------------------------------------------------------------- + static final List> OPERATOR_BINDINGS = + List.of( + new ConfigBinding<>( + "check-crd", + Boolean.class, + ConfigurationServiceOverrider::checkingCRDAndValidateLocalModel), + new ConfigBinding<>( + "reconciliation.termination-timeout", + Duration.class, + ConfigurationServiceOverrider::withReconciliationTerminationTimeout), + new ConfigBinding<>( + "reconciliation.concurrent-threads", + Integer.class, + ConfigurationServiceOverrider::withConcurrentReconciliationThreads), + new ConfigBinding<>( + "workflow.executor-threads", + Integer.class, + ConfigurationServiceOverrider::withConcurrentWorkflowExecutorThreads), + new ConfigBinding<>( + "close-client-on-stop", + Boolean.class, + ConfigurationServiceOverrider::withCloseClientOnStop), + new ConfigBinding<>( + "informer.stop-on-error-during-startup", + Boolean.class, + ConfigurationServiceOverrider::withStopOnInformerErrorDuringStartup), + new ConfigBinding<>( + "informer.cache-sync-timeout", + Duration.class, + ConfigurationServiceOverrider::withCacheSyncTimeout), + new ConfigBinding<>( + "dependent-resources.ssa-based-create-update-match", + Boolean.class, + ConfigurationServiceOverrider::withSSABasedCreateUpdateMatchForDependentResources), + new ConfigBinding<>( + "use-ssa-to-patch-primary-resource", + Boolean.class, + ConfigurationServiceOverrider::withUseSSAToPatchPrimaryResource), + new ConfigBinding<>( + "clone-secondary-resources-when-getting-from-cache", + Boolean.class, + ConfigurationServiceOverrider::withCloneSecondaryResourcesWhenGettingFromCache)); + + // --------------------------------------------------------------------------- + // Operator-level leader-election property keys + // --------------------------------------------------------------------------- + static final String LEADER_ELECTION_ENABLED_KEY = "leader-election.enabled"; + static final String LEADER_ELECTION_LEASE_NAME_KEY = "leader-election.lease-name"; + static final String LEADER_ELECTION_LEASE_NAMESPACE_KEY = "leader-election.lease-namespace"; + static final String LEADER_ELECTION_IDENTITY_KEY = "leader-election.identity"; + static final String LEADER_ELECTION_LEASE_DURATION_KEY = "leader-election.lease-duration"; + static final String LEADER_ELECTION_RENEW_DEADLINE_KEY = "leader-election.renew-deadline"; + static final String LEADER_ELECTION_RETRY_PERIOD_KEY = "leader-election.retry-period"; + + // --------------------------------------------------------------------------- + // Controller-level retry property suffixes + // --------------------------------------------------------------------------- + static final String RETRY_MAX_ATTEMPTS_SUFFIX = "retry.max-attempts"; + static final String RETRY_INITIAL_INTERVAL_SUFFIX = "retry.initial-interval"; + static final String RETRY_INTERVAL_MULTIPLIER_SUFFIX = "retry.interval-multiplier"; + static final String RETRY_MAX_INTERVAL_SUFFIX = "retry.max-interval"; + + // --------------------------------------------------------------------------- + // Controller-level rate-limiter property suffixes + // --------------------------------------------------------------------------- + static final String RATE_LIMITER_REFRESH_PERIOD_SUFFIX = "rate-limiter.refresh-period"; + static final String RATE_LIMITER_LIMIT_FOR_PERIOD_SUFFIX = "rate-limiter.limit-for-period"; + + // --------------------------------------------------------------------------- + // Controller-level (ControllerConfigurationOverrider) bindings + // The key used at runtime is built as: + // CONTROLLER_KEY_PREFIX + controllerName + "." + + // --------------------------------------------------------------------------- + static final List, ?>> CONTROLLER_BINDINGS = + List.of( + new ConfigBinding<>( + "finalizer", String.class, ControllerConfigurationOverrider::withFinalizer), + new ConfigBinding<>( + "generation-aware", + Boolean.class, + ControllerConfigurationOverrider::withGenerationAware), + new ConfigBinding<>( + "label-selector", String.class, ControllerConfigurationOverrider::withLabelSelector), + new ConfigBinding<>( + "max-reconciliation-interval", + Duration.class, + ControllerConfigurationOverrider::withReconciliationMaxInterval), + new ConfigBinding<>( + "field-manager", String.class, ControllerConfigurationOverrider::withFieldManager), + new ConfigBinding<>( + "trigger-reconciler-on-all-events", + Boolean.class, + ControllerConfigurationOverrider::withTriggerReconcilerOnAllEvents), + new ConfigBinding<>( + "informer.label-selector", + String.class, + ControllerConfigurationOverrider::withLabelSelector), + new ConfigBinding<>( + "informer.list-limit", + Long.class, + ControllerConfigurationOverrider::withInformerListLimit)); + + private final ConfigProvider configProvider; + + public ConfigLoader() { + this( + new AgregatePriorityListConfigProvider( + List.of(new EnvVarConfigProvider(), PropertiesConfigProvider.systemProperties())), + DEFAULT_CONTROLLER_KEY_PREFIX, + DEFAULT_OPERATOR_KEY_PREFIX); + } + + public ConfigLoader(ConfigProvider configProvider) { + this(configProvider, DEFAULT_CONTROLLER_KEY_PREFIX, DEFAULT_OPERATOR_KEY_PREFIX); + } + + public ConfigLoader( + ConfigProvider configProvider, String controllerKeyPrefix, String operatorKeyPrefix) { + this.configProvider = configProvider; + this.controllerKeyPrefix = controllerKeyPrefix; + this.operatorKeyPrefix = operatorKeyPrefix; + } + + /** + * Returns a {@link Consumer} that applies every operator-level property found in the {@link + * ConfigProvider} to the given {@link ConfigurationServiceOverrider}. Returns no-op consumer when + * no binding has a matching value, preserving the previous behavior. + */ + public Consumer applyConfigs() { + Consumer consumer = + buildConsumer(OPERATOR_BINDINGS, operatorKeyPrefix); + + Consumer leaderElectionStep = + buildLeaderElectionConsumer(operatorKeyPrefix); + if (leaderElectionStep != null) { + consumer = consumer.andThen(leaderElectionStep); + } + return consumer; + } + + /** + * Returns a {@link Consumer} that applies every controller-level property found in the {@link + * ConfigProvider} to the given {@link ControllerConfigurationOverrider}. The keys are looked up + * as {@code josdk.controller..}. + */ + @SuppressWarnings("unchecked") + public + Consumer> applyControllerConfigs(String controllerName) { + String prefix = controllerKeyPrefix + controllerName + "."; + // Cast is safe: the setter BiConsumer, T> is covariant in + // its first parameter for our usage – we only ever call it with + // ControllerConfigurationOverrider. + List, ?>> bindings = + (List, ?>>) (List) CONTROLLER_BINDINGS; + Consumer> consumer = buildConsumer(bindings, prefix); + + Consumer> retryStep = buildRetryConsumer(prefix); + if (retryStep != null) { + consumer = consumer == null ? retryStep : consumer.andThen(retryStep); + } + Consumer> rateLimiterStep = + buildRateLimiterConsumer(prefix); + if (rateLimiterStep != null) { + consumer = consumer.andThen(rateLimiterStep); + } + return consumer; + } + + /** + * If at least one retry property is present for the given prefix, returns a {@link Consumer} that + * builds a {@link GenericRetry} starting from {@link GenericRetry#defaultLimitedExponentialRetry} + * and overrides only the properties that are explicitly set. + */ + private Consumer> buildRetryConsumer( + String prefix) { + Optional maxAttempts = + configProvider.getValue(prefix + RETRY_MAX_ATTEMPTS_SUFFIX, Integer.class); + Optional initialInterval = + configProvider.getValue(prefix + RETRY_INITIAL_INTERVAL_SUFFIX, Long.class); + Optional intervalMultiplier = + configProvider.getValue(prefix + RETRY_INTERVAL_MULTIPLIER_SUFFIX, Double.class); + Optional maxInterval = + configProvider.getValue(prefix + RETRY_MAX_INTERVAL_SUFFIX, Long.class); + + if (maxAttempts.isEmpty() + && initialInterval.isEmpty() + && intervalMultiplier.isEmpty() + && maxInterval.isEmpty()) { + return null; + } + + return overrider -> { + GenericRetry retry = GenericRetry.defaultLimitedExponentialRetry(); + maxAttempts.ifPresent(retry::setMaxAttempts); + initialInterval.ifPresent(retry::setInitialInterval); + intervalMultiplier.ifPresent(retry::setIntervalMultiplier); + maxInterval.ifPresent(retry::setMaxInterval); + overrider.withRetry(retry); + }; + } + + /** + * Returns a {@link Consumer} that builds a {@link LinearRateLimiter} only if {@code + * rate-limiter.limit-for-period} is present and positive (a non-positive value would deactivate + * the limiter and is therefore treated as absent). {@code rate-limiter.refresh-period} is applied + * when also present; otherwise the default refresh period is used. Returns {@code null} when no + * effective rate-limiter configuration is found. + */ + private + Consumer> buildRateLimiterConsumer(String prefix) { + Optional refreshPeriod = + configProvider.getValue(prefix + RATE_LIMITER_REFRESH_PERIOD_SUFFIX, Duration.class); + Optional limitForPeriod = + configProvider.getValue(prefix + RATE_LIMITER_LIMIT_FOR_PERIOD_SUFFIX, Integer.class); + + if (limitForPeriod.isEmpty() || limitForPeriod.get() <= 0) { + return null; + } + + return overrider -> { + var rateLimiter = + new LinearRateLimiter( + refreshPeriod.orElse(LinearRateLimiter.DEFAULT_REFRESH_PERIOD), limitForPeriod.get()); + overrider.withRateLimiter(rateLimiter); + }; + } + + /** + * If leader election is explicitly disabled via {@code leader-election.enabled=false}, returns + * {@code null}. Otherwise, if at least one leader-election property is present (with {@code + * leader-election.lease-name} being required), returns a {@link Consumer} that builds a {@link + * io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration} via {@link + * LeaderElectionConfigurationBuilder} and applies it to the overrider. Returns {@code null} when + * no leader-election properties are present at all. + */ + private Consumer buildLeaderElectionConsumer(String prefix) { + Optional enabled = + configProvider.getValue(prefix + LEADER_ELECTION_ENABLED_KEY, Boolean.class); + if (enabled.isPresent() && !enabled.get()) { + return null; + } + + Optional leaseName = + configProvider.getValue(prefix + LEADER_ELECTION_LEASE_NAME_KEY, String.class); + Optional leaseNamespace = + configProvider.getValue(prefix + LEADER_ELECTION_LEASE_NAMESPACE_KEY, String.class); + Optional identity = + configProvider.getValue(prefix + LEADER_ELECTION_IDENTITY_KEY, String.class); + Optional leaseDuration = + configProvider.getValue(prefix + LEADER_ELECTION_LEASE_DURATION_KEY, Duration.class); + Optional renewDeadline = + configProvider.getValue(prefix + LEADER_ELECTION_RENEW_DEADLINE_KEY, Duration.class); + Optional retryPeriod = + configProvider.getValue(prefix + LEADER_ELECTION_RETRY_PERIOD_KEY, Duration.class); + + if (leaseName.isEmpty() + && leaseNamespace.isEmpty() + && identity.isEmpty() + && leaseDuration.isEmpty() + && renewDeadline.isEmpty() + && retryPeriod.isEmpty()) { + return null; + } + + return overrider -> { + var builder = + LeaderElectionConfigurationBuilder.aLeaderElectionConfiguration( + leaseName.orElseThrow( + () -> + new IllegalStateException( + "leader-election.lease-name must be set when configuring leader" + + " election"))); + leaseNamespace.ifPresent(builder::withLeaseNamespace); + identity.ifPresent(builder::withIdentity); + leaseDuration.ifPresent(builder::withLeaseDuration); + renewDeadline.ifPresent(builder::withRenewDeadline); + retryPeriod.ifPresent(builder::withRetryPeriod); + overrider.withLeaderElectionConfiguration(builder.build()); + }; + } + + /** + * Iterates {@code bindings} and, for each one whose key (optionally prefixed by {@code + * keyPrefix}) is present in the {@link ConfigProvider}, accumulates a call to the binding's + * setter. + * + * @param bindings the predefined bindings to check + * @param keyPrefix when non-null the key stored in the binding is treated as a suffix and this + * prefix is prepended before the lookup + * @return a consumer that applies all found values, or a no-op consumer if none were found + */ + private Consumer buildConsumer(List> bindings, String keyPrefix) { + Consumer consumer = null; + for (var binding : bindings) { + String lookupKey = keyPrefix == null ? binding.key() : keyPrefix + binding.key(); + Consumer step = resolveStep(binding, lookupKey); + if (step != null) { + consumer = consumer == null ? step : consumer.andThen(step); + } + } + return consumer == null ? o -> {} : consumer; + } + + /** + * Queries the {@link ConfigProvider} for {@code key} with the binding's type. If a value is + * present, returns a {@link Consumer} that calls the binding's setter; otherwise returns {@code + * null}. + */ + private Consumer resolveStep(ConfigBinding binding, String key) { + return configProvider + .getValue(key, binding.type()) + .map( + value -> + (Consumer) + overrider -> { + log.debug("Found config property: {} = {}", key, value); + binding.setter().accept(overrider, value); + }) + .orElse(null); + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigProvider.java new file mode 100644 index 0000000000..000131ff3b --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigProvider.java @@ -0,0 +1,33 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader; + +import java.util.Optional; + +public interface ConfigProvider { + + /** + * Returns the value associated with {@code key}, converted to {@code type}, or an empty {@link + * Optional} if the key is not set. + * + * @param key the dot-separated configuration key, e.g. {@code josdk.cache.sync.timeout} + * @param type the expected type of the value; supported types depend on the implementation + * @param the value type + * @return an {@link Optional} containing the typed value, or empty if the key is absent + * @throws IllegalArgumentException if {@code type} is not supported by the implementation + */ + Optional getValue(String key, Class type); +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePriorityListConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePriorityListConfigProvider.java new file mode 100644 index 0000000000..5190156ce5 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePriorityListConfigProvider.java @@ -0,0 +1,45 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader.provider; + +import java.util.List; +import java.util.Optional; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; + +/** + * A {@link ConfigProvider} that delegates to an ordered list of providers. Providers are queried in + * list order; the first non-empty result wins. + */ +public class AgregatePriorityListConfigProvider implements ConfigProvider { + + private final List providers; + + public AgregatePriorityListConfigProvider(List providers) { + this.providers = List.copyOf(providers); + } + + @Override + public Optional getValue(String key, Class type) { + for (ConfigProvider provider : providers) { + Optional value = provider.getValue(key, type); + if (value.isPresent()) { + return value; + } + } + return Optional.empty(); + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/ConfigValueConverter.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/ConfigValueConverter.java new file mode 100644 index 0000000000..09c5c3fcf2 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/ConfigValueConverter.java @@ -0,0 +1,51 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader.provider; + +import java.time.Duration; + +/** Utility for converting raw string config values to typed instances. */ +final class ConfigValueConverter { + + private ConfigValueConverter() {} + + /** + * Converts {@code raw} to an instance of {@code type}. Supported types: {@link String}, {@link + * Boolean}, {@link Integer}, {@link Long}, {@link Double}, and {@link Duration} (ISO-8601 format, + * e.g. {@code PT30S}). + * + * @throws IllegalArgumentException if {@code type} is not supported + */ + public static T convert(String raw, Class type) { + final Object converted; + if (type == String.class) { + converted = raw; + } else if (type == Boolean.class) { + converted = Boolean.parseBoolean(raw); + } else if (type == Integer.class) { + converted = Integer.parseInt(raw); + } else if (type == Long.class) { + converted = Long.parseLong(raw); + } else if (type == Double.class) { + converted = Double.parseDouble(raw); + } else if (type == Duration.class) { + converted = Duration.parse(raw); + } else { + throw new IllegalArgumentException("Unsupported config type: " + type.getName()); + } + return type.cast(converted); + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProvider.java new file mode 100644 index 0000000000..916ee6391d --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProvider.java @@ -0,0 +1,60 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader.provider; + +import java.util.Optional; +import java.util.function.Function; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; + +/** + * A {@link ConfigProvider} that resolves configuration values from environment variables. + * + *

The key is converted to an environment variable name by replacing dots and hyphens with + * underscores and converting to upper case (e.g. {@code josdk.cache-sync.timeout} → {@code + * JOSDK_CACHE_SYNC_TIMEOUT}). + * + *

Supported value types are: {@link String}, {@link Boolean}, {@link Integer}, {@link Long}, + * {@link Double}, and {@link java.time.Duration} (ISO-8601 format, e.g. {@code PT30S}). + */ +public class EnvVarConfigProvider implements ConfigProvider { + + private final Function envLookup; + + public EnvVarConfigProvider() { + this(System::getenv); + } + + EnvVarConfigProvider(Function envLookup) { + this.envLookup = envLookup; + } + + @Override + public Optional getValue(String key, Class type) { + if (key == null) { + return Optional.empty(); + } + String raw = envLookup.apply(toEnvKey(key)); + if (raw == null) { + return Optional.empty(); + } + return Optional.of(ConfigValueConverter.convert(raw, type)); + } + + static String toEnvKey(String key) { + return key.trim().replace('.', '_').replace('-', '_').toUpperCase(); + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java new file mode 100644 index 0000000000..35dd38f406 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java @@ -0,0 +1,79 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader.provider; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Properties; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; + +/** + * A {@link ConfigProvider} that resolves configuration values from a {@link Properties} file. + * + *

Keys are looked up as-is against the loaded properties. Supported value types are: {@link + * String}, {@link Boolean}, {@link Integer}, {@link Long}, {@link Double}, and {@link + * java.time.Duration} (ISO-8601 format, e.g. {@code PT30S}). + */ +public class PropertiesConfigProvider implements ConfigProvider { + + private final Properties properties; + + /** Returns a {@link PropertiesConfigProvider} backed by {@link System#getProperties()}. */ + public static PropertiesConfigProvider systemProperties() { + return new PropertiesConfigProvider(System.getProperties()); + } + + /** + * Loads properties from the given file path. + * + * @throws UncheckedIOException if the file cannot be read + */ + public PropertiesConfigProvider(Path path) { + this.properties = load(path); + } + + /** Uses the supplied {@link Properties} instance directly. */ + public PropertiesConfigProvider(Properties properties) { + this.properties = properties; + } + + @Override + public Optional getValue(String key, Class type) { + if (key == null) { + return Optional.empty(); + } + String raw = properties.getProperty(key); + if (raw == null) { + return Optional.empty(); + } + return Optional.of(ConfigValueConverter.convert(raw, type)); + } + + private static Properties load(Path path) { + try (InputStream in = Files.newInputStream(path)) { + Properties props = new Properties(); + props.load(in); + return props; + } catch (IOException e) { + throw new UncheckedIOException("Failed to load config properties from " + path, e); + } + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProvider.java new file mode 100644 index 0000000000..52b07b011d --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProvider.java @@ -0,0 +1,89 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader.provider; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +/** + * A {@link ConfigProvider} that resolves configuration values from a YAML file. + * + *

Keys use dot-separated notation to address nested YAML mappings (e.g. {@code + * josdk.cache-sync.timeout} maps to {@code josdk → cache-sync → timeout} in the YAML document). + * Leaf values are converted to the requested type via {@link ConfigValueConverter}. Supported value + * types are: {@link String}, {@link Boolean}, {@link Integer}, {@link Long}, {@link Double}, and + * {@link java.time.Duration} (ISO-8601 format, e.g. {@code PT30S}). + */ +public class YamlConfigProvider implements ConfigProvider { + + private static final ObjectMapper MAPPER = new ObjectMapper(new YAMLFactory()); + + private final Map data; + + /** + * Loads YAML from the given file path. + * + * @throws UncheckedIOException if the file cannot be read + */ + public YamlConfigProvider(Path path) { + this.data = load(path); + } + + /** Uses the supplied map directly (useful for testing). */ + public YamlConfigProvider(Map data) { + this.data = data; + } + + @Override + @SuppressWarnings("unchecked") + public Optional getValue(String key, Class type) { + if (key == null) { + return Optional.empty(); + } + String[] parts = key.split("\\.", -1); + Object current = data; + for (String part : parts) { + if (!(current instanceof Map)) { + return Optional.empty(); + } + current = ((Map) current).get(part); + if (current == null) { + return Optional.empty(); + } + } + return Optional.of(ConfigValueConverter.convert(current.toString(), type)); + } + + @SuppressWarnings("unchecked") + private static Map load(Path path) { + try (InputStream in = Files.newInputStream(path)) { + Map result = MAPPER.readValue(in, Map.class); + return result != null ? result : Map.of(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to load config YAML from " + path, e); + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java new file mode 100644 index 0000000000..d1ee0afa59 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java @@ -0,0 +1,146 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.configloader; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +import io.javaoperatorsdk.operator.config.loader.ConfigLoader; +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.TestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Integration tests that verify {@link ConfigLoader} property overrides take effect when wiring up + * a real operator instance via {@link LocallyRunOperatorExtension}. + * + *

Each nested class exercises a distinct group of properties so that failures are easy to + * pinpoint. + */ +class ConfigLoaderIT { + + /** Builds a {@link ConfigProvider} backed by a plain map. */ + private static ConfigProvider mapProvider(Map values) { + return new ConfigProvider() { + @Override + @SuppressWarnings("unchecked") + public Optional getValue(String key, Class type) { + return Optional.ofNullable((T) values.get(key)); + } + }; + } + + // --------------------------------------------------------------------------- + // Operator-level properties + // --------------------------------------------------------------------------- + + @Nested + class OperatorLevelProperties { + + /** + * Verifies that {@code josdk.reconciliation.concurrent-threads} loaded via {@link ConfigLoader} + * and applied through {@code withConfigurationService} actually changes the operator's thread + * pool size. + */ + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new ConfigLoaderTestReconciler(0)) + .withConfigurationService( + new ConfigLoader(mapProvider(Map.of("josdk.reconciliation.concurrent-threads", 2))) + .applyConfigs()) + .build(); + + @Test + void concurrentReconciliationThreadsIsAppliedFromConfigLoader() { + assertThat(operator.getOperator().getConfigurationService().concurrentReconciliationThreads()) + .isEqualTo(2); + } + } + + // --------------------------------------------------------------------------- + // Controller-level retry + // --------------------------------------------------------------------------- + + @Nested + class ControllerRetryProperties { + + static final int FAILS = 2; + // controller name is the lower-cased simple class name by default + static final String CTRL_NAME = ConfigLoaderTestReconciler.class.getSimpleName().toLowerCase(); + + /** + * Verifies that retry properties read by {@link ConfigLoader} for a specific controller name + * are applied when registering the reconciler via a {@code configurationOverrider} consumer, + * and that the resulting operator actually retries and eventually succeeds. + */ + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler( + new ConfigLoaderTestReconciler(FAILS), + // applyControllerConfigs returns Consumer>; + // withReconciler takes the raw Consumer + (Consumer) + (Consumer) + new ConfigLoader( + mapProvider( + Map.of( + "josdk.controller." + CTRL_NAME + ".retry.max-attempts", + 5, + "josdk.controller." + CTRL_NAME + ".retry.initial-interval", + 100L))) + .applyControllerConfigs(CTRL_NAME)) + .build(); + + @Test + void retryConfigFromConfigLoaderIsAppliedAndReconcilerEventuallySucceeds() { + var resource = createResource("1"); + operator.create(resource); + + await("reconciler succeeds after retries") + .atMost(10, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> { + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(FAILS + 1); + var updated = + operator.get( + ConfigLoaderTestCustomResource.class, resource.getMetadata().getName()); + assertThat(updated.getStatus()).isNotNull(); + assertThat(updated.getStatus().getState()) + .isEqualTo(ConfigLoaderTestCustomResourceStatus.State.SUCCESS); + }); + } + + private ConfigLoaderTestCustomResource createResource(String id) { + var resource = new ConfigLoaderTestCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName("cfgloader-retry-" + id).build()); + return resource; + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResource.java new file mode 100644 index 0000000000..a892b2391d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResource.java @@ -0,0 +1,30 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.configloader; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Kind; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@Kind("ConfigLoaderSample") +@ShortNames("cls") +public class ConfigLoaderTestCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResourceStatus.java new file mode 100644 index 0000000000..c70202bb73 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResourceStatus.java @@ -0,0 +1,35 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.configloader; + +public class ConfigLoaderTestCustomResourceStatus { + + public enum State { + SUCCESS, + ERROR + } + + private State state; + + public State getState() { + return state; + } + + public ConfigLoaderTestCustomResourceStatus setState(State state) { + this.state = state; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestReconciler.java new file mode 100644 index 0000000000..dbadfd4414 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestReconciler.java @@ -0,0 +1,58 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.configloader; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +/** + * A reconciler that fails for the first {@code numberOfFailures} invocations and then succeeds, + * setting the status to {@link ConfigLoaderTestCustomResourceStatus.State#SUCCESS}. + */ +@ControllerConfiguration +public class ConfigLoaderTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private final int numberOfFailures; + + public ConfigLoaderTestReconciler(int numberOfFailures) { + this.numberOfFailures = numberOfFailures; + } + + @Override + public UpdateControl reconcile( + ConfigLoaderTestCustomResource resource, Context context) { + int execution = numberOfExecutions.incrementAndGet(); + if (execution <= numberOfFailures) { + throw new RuntimeException("Simulated failure on execution " + execution); + } + var status = new ConfigLoaderTestCustomResourceStatus(); + status.setState(ConfigLoaderTestCustomResourceStatus.State.SUCCESS); + resource.setStatus(status); + return UpdateControl.patchStatus(resource); + } + + @Override + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigBindingTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigBindingTest.java new file mode 100644 index 0000000000..384ebb600c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigBindingTest.java @@ -0,0 +1,39 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConfigBindingTest { + + @Test + void storesKeyTypeAndSetter() { + List calls = new ArrayList<>(); + ConfigBinding, String> binding = + new ConfigBinding<>("my.key", String.class, (list, v) -> list.add(v)); + + assertThat(binding.key()).isEqualTo("my.key"); + assertThat(binding.type()).isEqualTo(String.class); + + binding.setter().accept(calls, "hello"); + assertThat(calls).containsExactly("hello"); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java new file mode 100644 index 0000000000..1fc1ebe98f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java @@ -0,0 +1,558 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class ConfigLoaderTest { + + // A simple ConfigProvider backed by a plain map for test control. + private static ConfigProvider mapProvider(Map values) { + return new ConfigProvider() { + @Override + @SuppressWarnings("unchecked") + public Optional getValue(String key, Class type) { + return Optional.ofNullable((T) values.get(key)); + } + }; + } + + @Test + void applyConfigsReturnsNoOpWhenNothingConfigured() { + var loader = new ConfigLoader(mapProvider(Map.of())); + var base = new BaseConfigurationService(null); + // consumer must be non-null and must leave all defaults unchanged + var consumer = loader.applyConfigs(); + assertThat(consumer).isNotNull(); + var result = ConfigurationService.newOverriddenConfigurationService(base, consumer); + assertThat(result.concurrentReconciliationThreads()) + .isEqualTo(base.concurrentReconciliationThreads()); + assertThat(result.concurrentWorkflowExecutorThreads()) + .isEqualTo(base.concurrentWorkflowExecutorThreads()); + } + + @Test + void applyConfigsAppliesConcurrentReconciliationThreads() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.reconciliation.concurrent-threads", 7))); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.concurrentReconciliationThreads()).isEqualTo(7); + } + + @Test + void applyConfigsAppliesConcurrentWorkflowExecutorThreads() { + var loader = new ConfigLoader(mapProvider(Map.of("josdk.workflow.executor-threads", 3))); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.concurrentWorkflowExecutorThreads()).isEqualTo(3); + } + + @Test + void applyConfigsAppliesBooleanFlags() { + var values = new HashMap(); + values.put("josdk.check-crd", true); + values.put("josdk.close-client-on-stop", false); + values.put("josdk.informer.stop-on-error-during-startup", false); + values.put("josdk.dependent-resources.ssa-based-create-update-match", false); + values.put("josdk.use-ssa-to-patch-primary-resource", false); + values.put("josdk.clone-secondary-resources-when-getting-from-cache", true); + var loader = new ConfigLoader(mapProvider(values)); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.checkCRDAndValidateLocalModel()).isTrue(); + assertThat(result.closeClientOnStop()).isFalse(); + assertThat(result.stopOnInformerErrorDuringStartup()).isFalse(); + assertThat(result.ssaBasedCreateUpdateMatchForDependentResources()).isFalse(); + assertThat(result.useSSAToPatchPrimaryResource()).isFalse(); + assertThat(result.cloneSecondaryResourcesWhenGettingFromCache()).isTrue(); + } + + @Test + void applyConfigsAppliesDurations() { + var values = new HashMap(); + values.put("josdk.informer.cache-sync-timeout", Duration.ofSeconds(10)); + values.put("josdk.reconciliation.termination-timeout", Duration.ofSeconds(5)); + var loader = new ConfigLoader(mapProvider(values)); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.cacheSyncTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(result.reconciliationTerminationTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void applyConfigsOnlyAppliesPresentKeys() { + // Only one key present — other defaults must be unchanged. + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.reconciliation.concurrent-threads", 12))); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.concurrentReconciliationThreads()).isEqualTo(12); + // Default unchanged + assertThat(result.concurrentWorkflowExecutorThreads()) + .isEqualTo(base.concurrentWorkflowExecutorThreads()); + } + + // -- applyControllerConfigs ------------------------------------------------- + + @Test + void applyControllerConfigsReturnsNoOpWhenNothingConfigured() { + var loader = new ConfigLoader(mapProvider(Map.of())); + assertThat(loader.applyControllerConfigs("my-controller")).isNotNull(); + } + + @Test + void applyControllerConfigsQueriesKeysPrefixedWithControllerName() { + // Record every key the loader asks for, regardless of whether a value exists. + var queriedKeys = new ArrayList(); + ConfigProvider recordingProvider = + new ConfigProvider() { + @Override + public Optional getValue(String key, Class type) { + queriedKeys.add(key); + return Optional.empty(); + } + }; + + new ConfigLoader(recordingProvider).applyControllerConfigs("my-ctrl"); + + assertThat(queriedKeys).allMatch(k -> k.startsWith("josdk.controller.my-ctrl.")); + } + + @Test + void applyControllerConfigsIsolatesControllersByName() { + // Two controllers configured in the same provider — only matching keys must be returned. + var values = new HashMap(); + values.put("josdk.controller.alpha.finalizer", "alpha-finalizer"); + values.put("josdk.controller.beta.finalizer", "beta-finalizer"); + var loader = new ConfigLoader(mapProvider(values)); + + // alpha gets a consumer (key found), beta gets a consumer (key found) + assertThat(loader.applyControllerConfigs("alpha")).isNotNull(); + assertThat(loader.applyControllerConfigs("beta")).isNotNull(); + // a controller with no configured keys still gets a non-null no-op consumer + assertThat(loader.applyControllerConfigs("gamma")).isNotNull(); + } + + @Test + void applyControllerConfigsQueriesAllExpectedPropertySuffixes() { + var queriedKeys = new ArrayList(); + ConfigProvider recordingProvider = + new ConfigProvider() { + @Override + public Optional getValue(String key, Class type) { + queriedKeys.add(key); + return Optional.empty(); + } + }; + + new ConfigLoader(recordingProvider).applyControllerConfigs("ctrl"); + + assertThat(queriedKeys) + .contains( + "josdk.controller.ctrl.finalizer", + "josdk.controller.ctrl.generation-aware", + "josdk.controller.ctrl.label-selector", + "josdk.controller.ctrl.max-reconciliation-interval", + "josdk.controller.ctrl.field-manager", + "josdk.controller.ctrl.trigger-reconciler-on-all-events", + "josdk.controller.ctrl.informer.label-selector", + "josdk.controller.ctrl.informer.list-limit", + "josdk.controller.ctrl.rate-limiter.refresh-period", + "josdk.controller.ctrl.rate-limiter.limit-for-period"); + } + + @Test + void operatorKeyPrefixIsJosdkDot() { + assertThat(ConfigLoader.DEFAULT_OPERATOR_KEY_PREFIX).isEqualTo("josdk."); + } + + @Test + void controllerKeyPrefixIsJosdkControllerDot() { + assertThat(ConfigLoader.DEFAULT_CONTROLLER_KEY_PREFIX).isEqualTo("josdk.controller."); + } + + // -- rate limiter ----------------------------------------------------------- + + @Test + void rateLimiterQueriesExpectedKeys() { + var queriedKeys = new ArrayList(); + ConfigProvider recordingProvider = + new ConfigProvider() { + @Override + public Optional getValue(String key, Class type) { + queriedKeys.add(key); + return Optional.empty(); + } + }; + new ConfigLoader(recordingProvider).applyControllerConfigs("ctrl"); + assertThat(queriedKeys) + .contains( + "josdk.controller.ctrl.rate-limiter.refresh-period", + "josdk.controller.ctrl.rate-limiter.limit-for-period"); + } + + // -- binding coverage ------------------------------------------------------- + + /** + * Supported scalar types that AgregatePriorityListConfigProvider can parse from a string. Every + * binding's type must be one of these. + */ + private static final Set> SUPPORTED_TYPES = + Set.of( + Boolean.class, + boolean.class, + Integer.class, + int.class, + Long.class, + long.class, + Double.class, + double.class, + Duration.class, + String.class); + + @Test + void operatorBindingsCoverAllSingleScalarSettersOnConfigurationServiceOverrider() { + Set expectedSetters = + Arrays.stream(ConfigurationServiceOverrider.class.getMethods()) + .filter(m -> m.getParameterCount() == 1) + .filter(m -> SUPPORTED_TYPES.contains(m.getParameterTypes()[0])) + .filter(m -> m.getReturnType() == ConfigurationServiceOverrider.class) + .map(java.lang.reflect.Method::getName) + .collect(Collectors.toSet()); + + Set boundMethodNames = + ConfigLoader.OPERATOR_BINDINGS.stream() + .flatMap( + b -> + Arrays.stream(ConfigurationServiceOverrider.class.getMethods()) + .filter(m -> m.getParameterCount() == 1) + .filter(m -> isTypeCompatible(m.getParameterTypes()[0], b.type())) + .filter(m -> m.getReturnType() == ConfigurationServiceOverrider.class) + .map(java.lang.reflect.Method::getName)) + .collect(Collectors.toSet()); + + assertThat(boundMethodNames) + .as("Every scalar setter on ConfigurationServiceOverrider must be covered by a binding") + .containsExactlyInAnyOrderElementsOf(expectedSetters); + } + + @Test + void controllerBindingsCoverAllSingleScalarSettersOnControllerConfigurationOverrider() { + Set expectedSetters = + Arrays.stream(ControllerConfigurationOverrider.class.getMethods()) + .filter(m -> m.getParameterCount() == 1) + .filter(m -> SUPPORTED_TYPES.contains(m.getParameterTypes()[0])) + .filter(m -> m.getReturnType() == ControllerConfigurationOverrider.class) + .filter(m -> m.getAnnotation(Deprecated.class) == null) + .map(java.lang.reflect.Method::getName) + .collect(Collectors.toSet()); + + Set boundMethodNames = + ConfigLoader.CONTROLLER_BINDINGS.stream() + .flatMap( + b -> + Arrays.stream(ControllerConfigurationOverrider.class.getMethods()) + .filter(m -> m.getParameterCount() == 1) + .filter(m -> isTypeCompatible(m.getParameterTypes()[0], b.type())) + .filter(m -> m.getReturnType() == ControllerConfigurationOverrider.class) + .filter(m -> m.getAnnotation(Deprecated.class) == null) + .map(java.lang.reflect.Method::getName)) + .collect(Collectors.toSet()); + + assertThat(boundMethodNames) + .as( + "Every scalar setter on ControllerConfigurationOverrider should be covered by a" + + " binding") + .containsExactlyInAnyOrderElementsOf(expectedSetters); + } + + // -- leader election -------------------------------------------------------- + + @Test + void leaderElectionIsNotConfiguredWhenNoPropertiesPresent() { + var loader = new ConfigLoader(mapProvider(Map.of())); + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + assertThat(result.getLeaderElectionConfiguration()).isEmpty(); + } + + @Test + void leaderElectionIsNotConfiguredWhenExplicitlyDisabled() { + var values = new HashMap(); + values.put("josdk.leader-election.enabled", false); + values.put("josdk.leader-election.lease-name", "my-lease"); + var loader = new ConfigLoader(mapProvider(values)); + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + assertThat(result.getLeaderElectionConfiguration()).isEmpty(); + } + + @Test + void leaderElectionConfiguredWithLeaseNameOnly() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.leader-election.lease-name", "my-lease"))); + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + assertThat(result.getLeaderElectionConfiguration()) + .hasValueSatisfying( + le -> { + assertThat(le.getLeaseName()).isEqualTo("my-lease"); + assertThat(le.getLeaseNamespace()).isEmpty(); + assertThat(le.getIdentity()).isEmpty(); + }); + } + + @Test + void leaderElectionConfiguredWithAllProperties() { + var values = new HashMap(); + values.put("josdk.leader-election.enabled", true); + values.put("josdk.leader-election.lease-name", "my-lease"); + values.put("josdk.leader-election.lease-namespace", "my-ns"); + values.put("josdk.leader-election.identity", "pod-1"); + values.put("josdk.leader-election.lease-duration", Duration.ofSeconds(20)); + values.put("josdk.leader-election.renew-deadline", Duration.ofSeconds(15)); + values.put("josdk.leader-election.retry-period", Duration.ofSeconds(3)); + var loader = new ConfigLoader(mapProvider(values)); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.getLeaderElectionConfiguration()) + .hasValueSatisfying( + le -> { + assertThat(le.getLeaseName()).isEqualTo("my-lease"); + assertThat(le.getLeaseNamespace()).hasValue("my-ns"); + assertThat(le.getIdentity()).hasValue("pod-1"); + assertThat(le.getLeaseDuration()).isEqualTo(Duration.ofSeconds(20)); + assertThat(le.getRenewDeadline()).isEqualTo(Duration.ofSeconds(15)); + assertThat(le.getRetryPeriod()).isEqualTo(Duration.ofSeconds(3)); + }); + } + + @Test + void leaderElectionMissingLeaseNameThrowsWhenOtherPropertiesPresent() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.leader-election.lease-namespace", "my-ns"))); + var base = new BaseConfigurationService(null); + var consumer = loader.applyConfigs(); + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> ConfigurationService.newOverriddenConfigurationService(base, consumer)) + .withMessageContaining("lease-name"); + } + + // -- retry ------------------------------------------------------------------ + + /** A minimal reconciler used to obtain a base ControllerConfiguration in retry tests. */ + @io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration + private static class DummyReconciler + implements io.javaoperatorsdk.operator.api.reconciler.Reconciler< + io.fabric8.kubernetes.api.model.ConfigMap> { + @Override + public io.javaoperatorsdk.operator.api.reconciler.UpdateControl< + io.fabric8.kubernetes.api.model.ConfigMap> + reconcile( + io.fabric8.kubernetes.api.model.ConfigMap r, + io.javaoperatorsdk.operator.api.reconciler.Context< + io.fabric8.kubernetes.api.model.ConfigMap> + ctx) { + return io.javaoperatorsdk.operator.api.reconciler.UpdateControl.noUpdate(); + } + } + + private static io.javaoperatorsdk.operator.api.config.ControllerConfiguration< + io.fabric8.kubernetes.api.model.ConfigMap> + baseControllerConfig() { + return new BaseConfigurationService().getConfigurationFor(new DummyReconciler()); + } + + private static io.javaoperatorsdk.operator.processing.retry.GenericRetry applyAndGetRetry( + java.util.function.Consumer< + io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider< + io.fabric8.kubernetes.api.model.ConfigMap>> + consumer) { + var overrider = + io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( + baseControllerConfig()); + consumer.accept(overrider); + return (io.javaoperatorsdk.operator.processing.retry.GenericRetry) overrider.build().getRetry(); + } + + @Test + void retryIsNotConfiguredWhenNoRetryPropertiesPresent() { + var loader = new ConfigLoader(mapProvider(Map.of())); + var consumer = loader.applyControllerConfigs("ctrl"); + var overrider = + io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( + baseControllerConfig()); + consumer.accept(overrider); + // no retry property set → retry stays at the controller's default (null or unchanged) + var result = overrider.build(); + // The consumer must not throw and the config is buildable + assertThat(result).isNotNull(); + } + + @Test + void retryQueriesExpectedKeys() { + var queriedKeys = new ArrayList(); + ConfigProvider recordingProvider = + new ConfigProvider() { + @Override + public Optional getValue(String key, Class type) { + queriedKeys.add(key); + return Optional.empty(); + } + }; + new ConfigLoader(recordingProvider).applyControllerConfigs("ctrl"); + assertThat(queriedKeys) + .contains( + "josdk.controller.ctrl.retry.max-attempts", + "josdk.controller.ctrl.retry.initial-interval", + "josdk.controller.ctrl.retry.interval-multiplier", + "josdk.controller.ctrl.retry.max-interval"); + } + + @Test + void retryMaxAttemptsIsApplied() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.retry.max-attempts", 10))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getMaxAttempts()).isEqualTo(10); + // other fields stay at their defaults + assertThat(retry.getInitialInterval()) + .isEqualTo( + io.javaoperatorsdk.operator.processing.retry.GenericRetry + .defaultLimitedExponentialRetry() + .getInitialInterval()); + } + + @Test + void retryInitialIntervalIsApplied() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.retry.initial-interval", 500L))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getInitialInterval()).isEqualTo(500L); + } + + @Test + void retryIntervalMultiplierIsApplied() { + var loader = + new ConfigLoader( + mapProvider(Map.of("josdk.controller.ctrl.retry.interval-multiplier", 2.0))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getIntervalMultiplier()).isEqualTo(2.0); + } + + @Test + void retryMaxIntervalIsApplied() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.retry.max-interval", 30000L))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getMaxInterval()).isEqualTo(30000L); + } + + @Test + void retryAllPropertiesApplied() { + var values = new HashMap(); + values.put("josdk.controller.ctrl.retry.max-attempts", 7); + values.put("josdk.controller.ctrl.retry.initial-interval", 1000L); + values.put("josdk.controller.ctrl.retry.interval-multiplier", 3.0); + values.put("josdk.controller.ctrl.retry.max-interval", 60000L); + var loader = new ConfigLoader(mapProvider(values)); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getMaxAttempts()).isEqualTo(7); + assertThat(retry.getInitialInterval()).isEqualTo(1000L); + assertThat(retry.getIntervalMultiplier()).isEqualTo(3.0); + assertThat(retry.getMaxInterval()).isEqualTo(60000L); + } + + @Test + void retryStartsFromDefaultLimitedExponentialRetryDefaults() { + // Only max-attempts is overridden — other fields must still be the defaults. + var defaults = + io.javaoperatorsdk.operator.processing.retry.GenericRetry.defaultLimitedExponentialRetry(); + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.retry.max-attempts", 3))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getMaxAttempts()).isEqualTo(3); + assertThat(retry.getInitialInterval()).isEqualTo(defaults.getInitialInterval()); + assertThat(retry.getIntervalMultiplier()).isEqualTo(defaults.getIntervalMultiplier()); + assertThat(retry.getMaxInterval()).isEqualTo(defaults.getMaxInterval()); + } + + @Test + void retryIsIsolatedPerControllerName() { + var values = new HashMap(); + values.put("josdk.controller.alpha.retry.max-attempts", 4); + values.put("josdk.controller.beta.retry.max-attempts", 9); + var loader = new ConfigLoader(mapProvider(values)); + + var alphaRetry = applyAndGetRetry(loader.applyControllerConfigs("alpha")); + var betaRetry = applyAndGetRetry(loader.applyControllerConfigs("beta")); + + assertThat(alphaRetry.getMaxAttempts()).isEqualTo(4); + assertThat(betaRetry.getMaxAttempts()).isEqualTo(9); + } + + private static boolean isTypeCompatible(Class methodParam, Class bindingType) { + if (methodParam == bindingType) return true; + if (methodParam == boolean.class && bindingType == Boolean.class) return true; + if (methodParam == Boolean.class && bindingType == boolean.class) return true; + if (methodParam == int.class && bindingType == Integer.class) return true; + if (methodParam == Integer.class && bindingType == int.class) return true; + if (methodParam == long.class && bindingType == Long.class) return true; + if (methodParam == Long.class && bindingType == long.class) return true; + if (methodParam == double.class && bindingType == Double.class) return true; + if (methodParam == Double.class && bindingType == double.class) return true; + return false; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProviderTest.java new file mode 100644 index 0000000000..3a4d07dd60 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProviderTest.java @@ -0,0 +1,62 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader.provider; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class EnvVarConfigProviderTest { + + @Test + void returnsEmptyWhenEnvVariableAbsent() { + var provider = new EnvVarConfigProvider(k -> null); + assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); + } + + @Test + void returnsEmptyForNullKey() { + var provider = new EnvVarConfigProvider(k -> "value"); + assertThat(provider.getValue(null, String.class)).isEmpty(); + } + + @Test + void readsStringFromEnvVariable() { + var provider = new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_STRING") ? "from-env" : null); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("from-env"); + } + + @Test + void convertsDotsAndHyphensToUnderscoresAndUppercases() { + var provider = + new EnvVarConfigProvider(k -> k.equals("JOSDK_CACHE_SYNC_TIMEOUT") ? "PT10S" : null); + assertThat(provider.getValue("josdk.cache-sync.timeout", Duration.class)) + .hasValue(Duration.ofSeconds(10)); + } + + @Test + void throwsForUnsupportedType() { + var provider = + new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_UNSUPPORTED") ? "value" : null); + assertThatIllegalArgumentException() + .isThrownBy(() -> provider.getValue("josdk.test.unsupported", AtomicInteger.class)) + .withMessageContaining("Unsupported config type"); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java new file mode 100644 index 0000000000..ad2a332868 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java @@ -0,0 +1,72 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader.provider; + +import java.util.List; +import java.util.Properties; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PriorityListConfigProviderTest { + + private static PropertiesConfigProvider propsProvider(String key, String value) { + Properties props = new Properties(); + if (key != null) { + props.setProperty(key, value); + } + return new PropertiesConfigProvider(props); + } + + @Test + void returnsEmptyWhenAllProvidersReturnEmpty() { + var provider = + new AgregatePriorityListConfigProvider( + List.of(new EnvVarConfigProvider(k -> null), propsProvider(null, null))); + assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); + } + + @Test + void firstProviderWins() { + var provider = + new AgregatePriorityListConfigProvider( + List.of( + new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_KEY") ? "first" : null), + propsProvider("josdk.test.key", "second"))); + assertThat(provider.getValue("josdk.test.key", String.class)).hasValue("first"); + } + + @Test + void fallsBackToLaterProviderWhenEarlierReturnsEmpty() { + var provider = + new AgregatePriorityListConfigProvider( + List.of( + new EnvVarConfigProvider(k -> null), + propsProvider("josdk.test.key", "from-second"))); + assertThat(provider.getValue("josdk.test.key", String.class)).hasValue("from-second"); + } + + @Test + void respectsOrderWithThreeProviders() { + var first = new EnvVarConfigProvider(k -> null); + var second = propsProvider("josdk.test.key", "from-second"); + var third = new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_KEY") ? "from-third" : null); + + var provider = new AgregatePriorityListConfigProvider(List.of(first, second, third)); + assertThat(provider.getValue("josdk.test.key", String.class)).hasValue("from-second"); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProviderTest.java new file mode 100644 index 0000000000..c44534eb3a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProviderTest.java @@ -0,0 +1,129 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader.provider; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class PropertiesConfigProviderTest { + + // -- Properties constructor ------------------------------------------------- + + @Test + void returnsEmptyWhenKeyAbsent() { + var provider = new PropertiesConfigProvider(new Properties()); + assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); + } + + @Test + void returnsEmptyForNullKey() { + var props = new Properties(); + props.setProperty("josdk.test.key", "value"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue(null, String.class)).isEmpty(); + } + + @Test + void readsString() { + var props = new Properties(); + props.setProperty("josdk.test.string", "hello"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("hello"); + } + + @Test + void readsBoolean() { + var props = new Properties(); + props.setProperty("josdk.test.bool", "true"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.bool", Boolean.class)).hasValue(true); + } + + @Test + void readsInteger() { + var props = new Properties(); + props.setProperty("josdk.test.integer", "42"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(42); + } + + @Test + void readsLong() { + var props = new Properties(); + props.setProperty("josdk.test.long", "123456789"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.long", Long.class)).hasValue(123456789L); + } + + @Test + void readsDouble() { + var props = new Properties(); + props.setProperty("josdk.test.double", "3.14"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.double", Double.class)).hasValue(3.14); + } + + @Test + void readsDuration() { + var props = new Properties(); + props.setProperty("josdk.test.duration", "PT30S"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.duration", Duration.class)) + .hasValue(Duration.ofSeconds(30)); + } + + @Test + void throwsForUnsupportedType() { + var props = new Properties(); + props.setProperty("josdk.test.unsupported", "value"); + var provider = new PropertiesConfigProvider(props); + assertThatIllegalArgumentException() + .isThrownBy(() -> provider.getValue("josdk.test.unsupported", AtomicInteger.class)) + .withMessageContaining("Unsupported config type"); + } + + // -- Path constructor ------------------------------------------------------- + + @Test + void loadsFromFile(@TempDir Path dir) throws IOException { + Path file = dir.resolve("test.properties"); + Files.writeString(file, "josdk.test.string=from-file\njosdk.test.integer=7\n"); + + var provider = new PropertiesConfigProvider(file); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("from-file"); + assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(7); + } + + @Test + void throwsUncheckedIOExceptionForMissingFile(@TempDir Path dir) { + Path missing = dir.resolve("does-not-exist.properties"); + assertThatExceptionOfType(UncheckedIOException.class) + .isThrownBy(() -> new PropertiesConfigProvider(missing)) + .withMessageContaining("does-not-exist.properties"); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProviderTest.java new file mode 100644 index 0000000000..4f8c53ac38 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProviderTest.java @@ -0,0 +1,144 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader.provider; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class YamlConfigProviderTest { + + // -- Map constructor -------------------------------------------------------- + + @Test + void returnsEmptyWhenKeyAbsent() { + var provider = new YamlConfigProvider(Map.of()); + assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); + } + + @Test + void returnsEmptyForNullKey() { + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("test", "value"))); + assertThat(provider.getValue(null, String.class)).isEmpty(); + } + + @Test + void readsTopLevelString() { + var provider = new YamlConfigProvider(Map.of("key", "hello")); + assertThat(provider.getValue("key", String.class)).hasValue("hello"); + } + + @Test + void readsNestedString() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("string", "hello")))); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("hello"); + } + + @Test + void readsBoolean() { + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("bool", "true")))); + assertThat(provider.getValue("josdk.test.bool", Boolean.class)).hasValue(true); + } + + @Test + void readsInteger() { + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("integer", 42)))); + assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(42); + } + + @Test + void readsLong() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("long", 123456789L)))); + assertThat(provider.getValue("josdk.test.long", Long.class)).hasValue(123456789L); + } + + @Test + void readsDouble() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("double", "3.14")))); + assertThat(provider.getValue("josdk.test.double", Double.class)).hasValue(3.14); + } + + @Test + void readsDuration() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("duration", "PT30S")))); + assertThat(provider.getValue("josdk.test.duration", Duration.class)) + .hasValue(Duration.ofSeconds(30)); + } + + @Test + void returnsEmptyWhenIntermediateSegmentMissing() { + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("other", "value"))); + assertThat(provider.getValue("josdk.test.key", String.class)).isEmpty(); + } + + @Test + void returnsEmptyWhenIntermediateSegmentIsLeaf() { + // "josdk.test" is a leaf – trying to drill further should return empty + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("test", "leaf"))); + assertThat(provider.getValue("josdk.test.key", String.class)).isEmpty(); + } + + @Test + void throwsForUnsupportedType() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("unsupported", "value")))); + assertThatIllegalArgumentException() + .isThrownBy(() -> provider.getValue("josdk.test.unsupported", AtomicInteger.class)) + .withMessageContaining("Unsupported config type"); + } + + // -- Path constructor ------------------------------------------------------- + + @Test + void loadsFromFile(@TempDir Path dir) throws IOException { + Path file = dir.resolve("test.yaml"); + Files.writeString( + file, + """ + josdk: + test: + string: from-file + integer: 7 + """); + + var provider = new YamlConfigProvider(file); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("from-file"); + assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(7); + } + + @Test + void throwsUncheckedIOExceptionForMissingFile(@TempDir Path dir) { + Path missing = dir.resolve("does-not-exist.yaml"); + assertThatExceptionOfType(UncheckedIOException.class) + .isThrownBy(() -> new YamlConfigProvider(missing)) + .withMessageContaining("does-not-exist.yaml"); + } +}