diff --git a/gradle.properties b/gradle.properties index f646d00..3b9d1fa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,9 +7,9 @@ org.gradle.logging.level=INFO # Quarkus quarkusPluginId=io.quarkus -quarkusPluginVersion=3.31.2 +quarkusPluginVersion=3.32.1 # https://mvnrepository.com/artifact/io.quarkus.platform/quarkus-bom quarkusPlatformGroupId=io.quarkus.platform quarkusPlatformArtifactId=quarkus-bom -quarkusPlatformVersion=3.31.2 +quarkusPlatformVersion=3.32.1 systemProp.quarkus.analytics.disabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8242d32..14846fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] ## AboutBits Libraries ## -checkstyleConfig = "2.0.0-RC1" +checkstyleConfig = "2.0.0-RC2" # Axion Release Plugin # axionReleasePlugin = "1.21.1" @@ -9,15 +9,15 @@ axionReleasePlugin = "1.21.1" jooq = "3.20.11" jSpecify = "1.0.0" lombok = "1.18.42" -postgresql = "42.7.9" -quarkiverse-helm = "1.2.7" +postgresql = "42.7.10" +quarkiverse-helm = "1.3.0" scram-client = "3.2" ## Testing ## assertj = "3.27.7" -checkstyle = "13.1.0" -datafaker = "2.5.3" -errorProne = "2.46.0" +checkstyle = "13.2.0" +datafaker = "2.5.4" +errorProne = "2.47.0" errorPronePlugin = "5.0.0" nullAway = "0.13.1" diff --git a/operator/build.gradle.kts b/operator/build.gradle.kts index cbe6c1f..1f2f0bb 100644 --- a/operator/build.gradle.kts +++ b/operator/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { * Fabric8 Kubernetes Client */ implementation("io.fabric8:generator-annotations") + implementation("io.fabric8:crd-generator-api-v2") /** * jOOQ diff --git a/operator/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java b/operator/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java index 20f16db..3b86277 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java +++ b/operator/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java @@ -30,10 +30,11 @@ public HealthCheckResponse call() { var connections = kubernetesClient.resources(ClusterConnection.class).list().getItems(); boolean allUp = connections.stream() - .allMatch(connection -> checkInstance( + .map(connection -> checkInstance( connection, builder - )); + )) + .reduce(true, Boolean::logicalAnd); return builder.status(allUp).build(); } diff --git a/operator/src/main/java/it/aboutbits/postgresql/core/ClusterReference.java b/operator/src/main/java/it/aboutbits/postgresql/core/ClusterReference.java index b1b9a7d..735b444 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/core/ClusterReference.java +++ b/operator/src/main/java/it/aboutbits/postgresql/core/ClusterReference.java @@ -1,7 +1,10 @@ package it.aboutbits.postgresql.core; +import io.fabric8.crdv2.generator.v1.SchemaCustomizer; +import io.fabric8.generator.annotation.Max; import io.fabric8.generator.annotation.Required; import io.fabric8.generator.annotation.ValidationRule; +import it.aboutbits.postgresql.core.schema_customizer.KubernetesNameCustomizer; import lombok.Getter; import lombok.Setter; import org.jspecify.annotations.NullMarked; @@ -10,8 +13,10 @@ @NullMarked @Getter @Setter +@SchemaCustomizer(KubernetesNameCustomizer.class) public class ClusterReference { @Required + @Max(63) @ValidationRule( value = "self.trim().size() > 0", message = "The ClusterReference name must not be empty." @@ -19,6 +24,7 @@ public class ClusterReference { private String name = ""; @Nullable + @Max(63) @io.fabric8.generator.annotation.Nullable private String namespace; } diff --git a/operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java b/operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java index fdb5bb8..216a4fe 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java +++ b/operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java @@ -5,6 +5,7 @@ import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; import org.jooq.CloseableDSLContext; +import org.jooq.exception.DataAccessException; import org.jooq.impl.DSL; import org.jspecify.annotations.NullMarked; @@ -21,7 +22,7 @@ public class PostgreSQLContextFactory { private final KubernetesClient kubernetesClient; /// Create a DSLContext with a JDBC connection to the PostgreSQL maintenance database. - public CloseableDSLContext getDSLContext(ClusterConnection clusterConnection) { + public CloseableDSLContext getDSLContext(ClusterConnection clusterConnection) throws DataAccessException { return getDSLContext( clusterConnection, clusterConnection.getSpec().getDatabase() @@ -32,7 +33,7 @@ public CloseableDSLContext getDSLContext(ClusterConnection clusterConnection) { public CloseableDSLContext getDSLContext( ClusterConnection clusterConnection, String database - ) { + ) throws DataAccessException { var credentials = kubernetesService.getSecretRefCredentials( kubernetesClient, clusterConnection diff --git a/operator/src/main/java/it/aboutbits/postgresql/core/SecretRef.java b/operator/src/main/java/it/aboutbits/postgresql/core/SecretRef.java index 9e4b57a..628dfca 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/core/SecretRef.java +++ b/operator/src/main/java/it/aboutbits/postgresql/core/SecretRef.java @@ -1,7 +1,10 @@ package it.aboutbits.postgresql.core; +import io.fabric8.crdv2.generator.v1.SchemaCustomizer; +import io.fabric8.generator.annotation.Max; import io.fabric8.generator.annotation.Required; import io.fabric8.generator.annotation.ValidationRule; +import it.aboutbits.postgresql.core.schema_customizer.KubernetesNameCustomizer; import lombok.Getter; import lombok.Setter; import org.jspecify.annotations.NullMarked; @@ -10,8 +13,10 @@ @NullMarked @Getter @Setter +@SchemaCustomizer(KubernetesNameCustomizer.class) public class SecretRef { @Required + @Max(63) @ValidationRule( value = "self.trim().size() > 0", message = "The SecretRef name must not be empty." @@ -23,6 +28,7 @@ public class SecretRef { * If it is null, it means the Secret is in the same namespace as the resource referencing it. */ @Nullable + @Max(63) @io.fabric8.generator.annotation.Nullable private String namespace; } diff --git a/operator/src/main/java/it/aboutbits/postgresql/core/schema_customizer/HostCustomizer.java b/operator/src/main/java/it/aboutbits/postgresql/core/schema_customizer/HostCustomizer.java new file mode 100644 index 0000000..64d5717 --- /dev/null +++ b/operator/src/main/java/it/aboutbits/postgresql/core/schema_customizer/HostCustomizer.java @@ -0,0 +1,98 @@ +package it.aboutbits.postgresql.core.schema_customizer; + +import io.fabric8.crdv2.generator.v1.SchemaCustomizer; +import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaProps; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; +import org.jspecify.annotations.NullMarked; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/// A [SchemaCustomizer.Customizer] that sets the `format` of string properties +/// to `{"anyOf":[{"format":"hostname"},{"format":"ipv4"},{"format":"ipv6"}]}` +/// in the generated CRD JSON Schema. +/// +/// This customizer is intended to be used with the +/// [@SchemaCustomizer][SchemaCustomizer] annotation on a class whose properties +/// should be validated to valid hosts defined. +/// +/// ### Behavior +/// +/// - If `input` is **blank** (the default), the `"hostname"` format +/// is applied to **all** string properties of the annotated class. +/// - If `input` contains a **comma-separated list** of field names, +/// the format is applied **only** to the specified properties. +/// +/// ### Usage examples +/// +/// **Apply to all string properties:** +/// +/// ```java +/// @SchemaCustomizer(value = HostCustomizer.class) +/// public class ClusterConnectionSpec { +/// private String host = ""; // gets custom format +/// private String anotherHost = ""; // gets custom format +/// } +/// ``` +/// +/// **Apply to specific properties only:** +/// +/// ```java +/// @SchemaCustomizer(value = HostCustomizer.class, input = "host,anotherHost") +/// public class ClusterConnectionSpec { +/// private String host = ""; // gets custom format +/// private String anotherHost = ""; // gets custom format +/// private String unchangedHost = ""; // unchanged +/// } +/// ``` +/// +/// @see SchemaCustomizer +/// @see SchemaCustomizer.Customizer +@NullMarked +public class HostCustomizer implements SchemaCustomizer.Customizer { + @Override + public JSONSchemaProps apply( + JSONSchemaProps jsonSchemaProps, + String input, + KubernetesSerialization kubernetesSerialization + ) { + var properties = jsonSchemaProps.getProperties(); + if (properties == null) { + return jsonSchemaProps; + } + + var targetFields = input.isBlank() + ? Set.of() + : Arrays.stream(input.split(",")) + .map(String::trim) + .collect(Collectors.toSet()); + + for (var entry : properties.entrySet()) { + var prop = entry.getValue(); + if ("string".equals(prop.getType()) + && (targetFields.isEmpty() || targetFields.contains(entry.getKey())) + ) { + prop.setFormat(null); + + var hostnameProp = new JSONSchemaProps(); + hostnameProp.setFormat("hostname"); + + var ipv4Prop = new JSONSchemaProps(); + ipv4Prop.setFormat("ipv4"); + + var ipv6Prop = new JSONSchemaProps(); + ipv6Prop.setFormat("ipv6"); + + prop.setAnyOf(List.of( + hostnameProp, + ipv4Prop, + ipv6Prop + )); + } + } + + return jsonSchemaProps; + } +} diff --git a/operator/src/main/java/it/aboutbits/postgresql/core/schema_customizer/KubernetesNameCustomizer.java b/operator/src/main/java/it/aboutbits/postgresql/core/schema_customizer/KubernetesNameCustomizer.java new file mode 100644 index 0000000..baf3f5d --- /dev/null +++ b/operator/src/main/java/it/aboutbits/postgresql/core/schema_customizer/KubernetesNameCustomizer.java @@ -0,0 +1,90 @@ +package it.aboutbits.postgresql.core.schema_customizer; + +import io.fabric8.crdv2.generator.v1.SchemaCustomizer; +import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaProps; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; +import org.jspecify.annotations.NullMarked; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +/// A [SchemaCustomizer.Customizer] that adds a Kubernetes name validation +/// `pattern` (RFC 1123 DNS label) to string properties in the generated CRD +/// JSON Schema. +/// +/// The pattern enforces: +/// - Contain at most 63 characters +/// - Contain only lowercase alphanumeric characters or '-' +/// - Start with an alphabetic character +/// - End with an alphanumeric character +/// +/// This customizer is intended to be used with the +/// [@SchemaCustomizer][SchemaCustomizer] annotation on a class whose string +/// properties represent Kubernetes resource names. +/// +/// ### Behavior +/// +/// - If `input` is **blank** (the default), the `"hostname"` format +/// is applied to **all** string properties of the annotated class. +/// - If `input` contains a **comma-separated list** of field names, +/// the format is applied **only** to the specified properties. +/// +/// ### Usage examples +/// +/// **Apply to all string properties:** +/// +/// ```java +/// @SchemaCustomizer(KubernetesNameCustomizer.class) +/// public class SecretRef { +/// private String name = ""; // gets pattern: Kubernetes name regex +/// private String namespace; // gets pattern: Kubernetes name regex +/// } +/// ``` +/// +/// **Apply to specific properties only:** +/// +/// ```java +/// @SchemaCustomizer(value = KubernetesNameCustomizer.class, input = "name,anotherName") +/// public class SecretRef { +/// private String name = ""; // gets pattern: Kubernetes name regex +/// private String anotherName = ""; // gets pattern: Kubernetes name regex +/// private String namespace; // unchanged +/// } +/// ``` +/// +/// @see SchemaCustomizer +/// @see SchemaCustomizer.Customizer +@NullMarked +public class KubernetesNameCustomizer implements SchemaCustomizer.Customizer { + static final String KUBERNETES_NAME_PATTERN = "^[a-z]([a-z0-9\\-]{0,61}[a-z0-9])?$"; + + @Override + public JSONSchemaProps apply( + JSONSchemaProps jsonSchemaProps, + String input, + KubernetesSerialization kubernetesSerialization + ) { + var properties = jsonSchemaProps.getProperties(); + if (properties == null) { + return jsonSchemaProps; + } + + var targetFields = input.isBlank() + ? Set.of() + : Arrays.stream(input.split(",")) + .map(String::trim) + .collect(Collectors.toSet()); + + for (var entry : properties.entrySet()) { + var prop = entry.getValue(); + if ("string".equals(prop.getType()) + && (targetFields.isEmpty() || targetFields.contains(entry.getKey())) + ) { + prop.setPattern(KUBERNETES_NAME_PATTERN); + } + } + + return jsonSchemaProps; + } +} diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java b/operator/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java index 65b4ec8..b4c472c 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java @@ -1,10 +1,12 @@ package it.aboutbits.postgresql.crd.clusterconnection; +import io.fabric8.crdv2.generator.v1.SchemaCustomizer; import io.fabric8.generator.annotation.Max; import io.fabric8.generator.annotation.Min; import io.fabric8.generator.annotation.Required; import io.fabric8.generator.annotation.ValidationRule; import it.aboutbits.postgresql.core.SecretRef; +import it.aboutbits.postgresql.core.schema_customizer.HostCustomizer; import lombok.Getter; import lombok.Setter; import org.jspecify.annotations.NullMarked; @@ -15,6 +17,7 @@ @NullMarked @Getter @Setter +@SchemaCustomizer(value = HostCustomizer.class, input = "host") public class ClusterConnectionSpec { @Required @ValidationRule( diff --git a/operator/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java b/operator/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java index 548a2e0..d2b7464 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java +++ b/operator/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.Map; import java.util.Objects; import static org.assertj.core.api.Assertions.assertThat; @@ -67,13 +68,35 @@ void call_whenSomeConnectionsDown_shouldReturnDown() { given.one() .clusterConnection() .withName("db-1") - .returnFirst(); + .apply(); given.one() .clusterConnection() .withName("db-2") - .withHost("non-existent-host") - .returnFirst(); + .withHost("localhost") + .withPort(2345) // Wrong port + .apply(); + + given.one() + .clusterConnection() + .withName("db-3") + .withHost("127.0.0.1") + .withPort(2345) // Wrong port + .apply(); + + given.one() + .clusterConnection() + .withName("db-4") + .withHost("::1") + .withPort(2345) // Wrong port + .apply(); + + given.one() + .clusterConnection() + .withName("db-5") + .withHost("0:0:0:0:0:0:0:1") + .withPort(2345) // Wrong port + .apply(); var response = readinessCheck.call(); @@ -93,7 +116,12 @@ void call_whenSomeConnectionsDown_shouldReturnDown() { assertThat(dbStatus.toString()).startsWith("UP (PostgreSQL"); - assertThat(data).containsEntry("db-2", "DOWN"); + assertThat(data).containsAllEntriesOf(Map.of( + "db-2", "DOWN", + "db-3", "DOWN", + "db-4", "DOWN", + "db-5", "DOWN" + )); }); } }