Skip to content

Commit 3381f8c

Browse files
authored
Validate K8s resource references against the RFC 1123 hostname JSON Schema format (#37)
* update to Quarkus 3.32.1 and update dependencies to latest versions * validate K8s resource references against the RFC 1123 hostname format Kubernetes uses * fix the test by having a own SchemaCustomizer for Kubernetes names * do not short-circuit the PostgreSQLInstanceReadinessCheck check once one instance is down * let the PostgreSQLContextFactory exception bubble up * add explicit string max length check of 63
1 parent e9e4458 commit 3381f8c

File tree

9 files changed

+242
-8
lines changed

9 files changed

+242
-8
lines changed

operator/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies {
2020
* Fabric8 Kubernetes Client
2121
*/
2222
implementation("io.fabric8:generator-annotations")
23+
implementation("io.fabric8:crd-generator-api-v2")
2324

2425
/**
2526
* jOOQ

operator/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ public HealthCheckResponse call() {
3030
var connections = kubernetesClient.resources(ClusterConnection.class).list().getItems();
3131

3232
boolean allUp = connections.stream()
33-
.allMatch(connection -> checkInstance(
33+
.map(connection -> checkInstance(
3434
connection,
3535
builder
36-
));
36+
))
37+
.reduce(true, Boolean::logicalAnd);
3738

3839
return builder.status(allUp).build();
3940
}

operator/src/main/java/it/aboutbits/postgresql/core/ClusterReference.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package it.aboutbits.postgresql.core;
22

3+
import io.fabric8.crdv2.generator.v1.SchemaCustomizer;
4+
import io.fabric8.generator.annotation.Max;
35
import io.fabric8.generator.annotation.Required;
46
import io.fabric8.generator.annotation.ValidationRule;
7+
import it.aboutbits.postgresql.core.schema_customizer.KubernetesNameCustomizer;
58
import lombok.Getter;
69
import lombok.Setter;
710
import org.jspecify.annotations.NullMarked;
@@ -10,15 +13,18 @@
1013
@NullMarked
1114
@Getter
1215
@Setter
16+
@SchemaCustomizer(KubernetesNameCustomizer.class)
1317
public class ClusterReference {
1418
@Required
19+
@Max(63)
1520
@ValidationRule(
1621
value = "self.trim().size() > 0",
1722
message = "The ClusterReference name must not be empty."
1823
)
1924
private String name = "";
2025

2126
@Nullable
27+
@Max(63)
2228
@io.fabric8.generator.annotation.Nullable
2329
private String namespace;
2430
}

operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import jakarta.enterprise.context.ApplicationScoped;
66
import lombok.RequiredArgsConstructor;
77
import org.jooq.CloseableDSLContext;
8+
import org.jooq.exception.DataAccessException;
89
import org.jooq.impl.DSL;
910
import org.jspecify.annotations.NullMarked;
1011

@@ -21,7 +22,7 @@ public class PostgreSQLContextFactory {
2122
private final KubernetesClient kubernetesClient;
2223

2324
/// Create a DSLContext with a JDBC connection to the PostgreSQL maintenance database.
24-
public CloseableDSLContext getDSLContext(ClusterConnection clusterConnection) {
25+
public CloseableDSLContext getDSLContext(ClusterConnection clusterConnection) throws DataAccessException {
2526
return getDSLContext(
2627
clusterConnection,
2728
clusterConnection.getSpec().getDatabase()
@@ -32,7 +33,7 @@ public CloseableDSLContext getDSLContext(ClusterConnection clusterConnection) {
3233
public CloseableDSLContext getDSLContext(
3334
ClusterConnection clusterConnection,
3435
String database
35-
) {
36+
) throws DataAccessException {
3637
var credentials = kubernetesService.getSecretRefCredentials(
3738
kubernetesClient,
3839
clusterConnection

operator/src/main/java/it/aboutbits/postgresql/core/SecretRef.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package it.aboutbits.postgresql.core;
22

3+
import io.fabric8.crdv2.generator.v1.SchemaCustomizer;
4+
import io.fabric8.generator.annotation.Max;
35
import io.fabric8.generator.annotation.Required;
46
import io.fabric8.generator.annotation.ValidationRule;
7+
import it.aboutbits.postgresql.core.schema_customizer.KubernetesNameCustomizer;
58
import lombok.Getter;
69
import lombok.Setter;
710
import org.jspecify.annotations.NullMarked;
@@ -10,8 +13,10 @@
1013
@NullMarked
1114
@Getter
1215
@Setter
16+
@SchemaCustomizer(KubernetesNameCustomizer.class)
1317
public class SecretRef {
1418
@Required
19+
@Max(63)
1520
@ValidationRule(
1621
value = "self.trim().size() > 0",
1722
message = "The SecretRef name must not be empty."
@@ -23,6 +28,7 @@ public class SecretRef {
2328
* If it is null, it means the Secret is in the same namespace as the resource referencing it.
2429
*/
2530
@Nullable
31+
@Max(63)
2632
@io.fabric8.generator.annotation.Nullable
2733
private String namespace;
2834
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package it.aboutbits.postgresql.core.schema_customizer;
2+
3+
import io.fabric8.crdv2.generator.v1.SchemaCustomizer;
4+
import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaProps;
5+
import io.fabric8.kubernetes.client.utils.KubernetesSerialization;
6+
import org.jspecify.annotations.NullMarked;
7+
8+
import java.util.Arrays;
9+
import java.util.List;
10+
import java.util.Set;
11+
import java.util.stream.Collectors;
12+
13+
/// A [SchemaCustomizer.Customizer] that sets the `format` of string properties
14+
/// to `{"anyOf":[{"format":"hostname"},{"format":"ipv4"},{"format":"ipv6"}]}`
15+
/// in the generated CRD JSON Schema.
16+
///
17+
/// This customizer is intended to be used with the
18+
/// [@SchemaCustomizer][SchemaCustomizer] annotation on a class whose properties
19+
/// should be validated to valid hosts defined.
20+
///
21+
/// ### Behavior
22+
///
23+
/// - If `input` is **blank** (the default), the `"hostname"` format
24+
/// is applied to **all** string properties of the annotated class.
25+
/// - If `input` contains a **comma-separated list** of field names,
26+
/// the format is applied **only** to the specified properties.
27+
///
28+
/// ### Usage examples
29+
///
30+
/// **Apply to all string properties:**
31+
///
32+
/// ```java
33+
/// @SchemaCustomizer(value = HostCustomizer.class)
34+
/// public class ClusterConnectionSpec {
35+
/// private String host = ""; // gets custom format
36+
/// private String anotherHost = ""; // gets custom format
37+
/// }
38+
/// ```
39+
///
40+
/// **Apply to specific properties only:**
41+
///
42+
/// ```java
43+
/// @SchemaCustomizer(value = HostCustomizer.class, input = "host,anotherHost")
44+
/// public class ClusterConnectionSpec {
45+
/// private String host = ""; // gets custom format
46+
/// private String anotherHost = ""; // gets custom format
47+
/// private String unchangedHost = ""; // unchanged
48+
/// }
49+
/// ```
50+
///
51+
/// @see SchemaCustomizer
52+
/// @see SchemaCustomizer.Customizer
53+
@NullMarked
54+
public class HostCustomizer implements SchemaCustomizer.Customizer {
55+
@Override
56+
public JSONSchemaProps apply(
57+
JSONSchemaProps jsonSchemaProps,
58+
String input,
59+
KubernetesSerialization kubernetesSerialization
60+
) {
61+
var properties = jsonSchemaProps.getProperties();
62+
if (properties == null) {
63+
return jsonSchemaProps;
64+
}
65+
66+
var targetFields = input.isBlank()
67+
? Set.<String>of()
68+
: Arrays.stream(input.split(","))
69+
.map(String::trim)
70+
.collect(Collectors.toSet());
71+
72+
for (var entry : properties.entrySet()) {
73+
var prop = entry.getValue();
74+
if ("string".equals(prop.getType())
75+
&& (targetFields.isEmpty() || targetFields.contains(entry.getKey()))
76+
) {
77+
prop.setFormat(null);
78+
79+
var hostnameProp = new JSONSchemaProps();
80+
hostnameProp.setFormat("hostname");
81+
82+
var ipv4Prop = new JSONSchemaProps();
83+
ipv4Prop.setFormat("ipv4");
84+
85+
var ipv6Prop = new JSONSchemaProps();
86+
ipv6Prop.setFormat("ipv6");
87+
88+
prop.setAnyOf(List.of(
89+
hostnameProp,
90+
ipv4Prop,
91+
ipv6Prop
92+
));
93+
}
94+
}
95+
96+
return jsonSchemaProps;
97+
}
98+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package it.aboutbits.postgresql.core.schema_customizer;
2+
3+
import io.fabric8.crdv2.generator.v1.SchemaCustomizer;
4+
import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaProps;
5+
import io.fabric8.kubernetes.client.utils.KubernetesSerialization;
6+
import org.jspecify.annotations.NullMarked;
7+
8+
import java.util.Arrays;
9+
import java.util.Set;
10+
import java.util.stream.Collectors;
11+
12+
/// A [SchemaCustomizer.Customizer] that adds a Kubernetes name validation
13+
/// `pattern` (RFC 1123 DNS label) to string properties in the generated CRD
14+
/// JSON Schema.
15+
///
16+
/// The pattern enforces:
17+
/// - Contain at most 63 characters
18+
/// - Contain only lowercase alphanumeric characters or '-'
19+
/// - Start with an alphabetic character
20+
/// - End with an alphanumeric character
21+
///
22+
/// This customizer is intended to be used with the
23+
/// [@SchemaCustomizer][SchemaCustomizer] annotation on a class whose string
24+
/// properties represent Kubernetes resource names.
25+
///
26+
/// ### Behavior
27+
///
28+
/// - If `input` is **blank** (the default), the `"hostname"` format
29+
/// is applied to **all** string properties of the annotated class.
30+
/// - If `input` contains a **comma-separated list** of field names,
31+
/// the format is applied **only** to the specified properties.
32+
///
33+
/// ### Usage examples
34+
///
35+
/// **Apply to all string properties:**
36+
///
37+
/// ```java
38+
/// @SchemaCustomizer(KubernetesNameCustomizer.class)
39+
/// public class SecretRef {
40+
/// private String name = ""; // gets pattern: Kubernetes name regex
41+
/// private String namespace; // gets pattern: Kubernetes name regex
42+
/// }
43+
/// ```
44+
///
45+
/// **Apply to specific properties only:**
46+
///
47+
/// ```java
48+
/// @SchemaCustomizer(value = KubernetesNameCustomizer.class, input = "name,anotherName")
49+
/// public class SecretRef {
50+
/// private String name = ""; // gets pattern: Kubernetes name regex
51+
/// private String anotherName = ""; // gets pattern: Kubernetes name regex
52+
/// private String namespace; // unchanged
53+
/// }
54+
/// ```
55+
///
56+
/// @see SchemaCustomizer
57+
/// @see SchemaCustomizer.Customizer
58+
@NullMarked
59+
public class KubernetesNameCustomizer implements SchemaCustomizer.Customizer {
60+
static final String KUBERNETES_NAME_PATTERN = "^[a-z]([a-z0-9\\-]{0,61}[a-z0-9])?$";
61+
62+
@Override
63+
public JSONSchemaProps apply(
64+
JSONSchemaProps jsonSchemaProps,
65+
String input,
66+
KubernetesSerialization kubernetesSerialization
67+
) {
68+
var properties = jsonSchemaProps.getProperties();
69+
if (properties == null) {
70+
return jsonSchemaProps;
71+
}
72+
73+
var targetFields = input.isBlank()
74+
? Set.<String>of()
75+
: Arrays.stream(input.split(","))
76+
.map(String::trim)
77+
.collect(Collectors.toSet());
78+
79+
for (var entry : properties.entrySet()) {
80+
var prop = entry.getValue();
81+
if ("string".equals(prop.getType())
82+
&& (targetFields.isEmpty() || targetFields.contains(entry.getKey()))
83+
) {
84+
prop.setPattern(KUBERNETES_NAME_PATTERN);
85+
}
86+
}
87+
88+
return jsonSchemaProps;
89+
}
90+
}

operator/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package it.aboutbits.postgresql.crd.clusterconnection;
22

3+
import io.fabric8.crdv2.generator.v1.SchemaCustomizer;
34
import io.fabric8.generator.annotation.Max;
45
import io.fabric8.generator.annotation.Min;
56
import io.fabric8.generator.annotation.Required;
67
import io.fabric8.generator.annotation.ValidationRule;
78
import it.aboutbits.postgresql.core.SecretRef;
9+
import it.aboutbits.postgresql.core.schema_customizer.HostCustomizer;
810
import lombok.Getter;
911
import lombok.Setter;
1012
import org.jspecify.annotations.NullMarked;
@@ -15,6 +17,7 @@
1517
@NullMarked
1618
@Getter
1719
@Setter
20+
@SchemaCustomizer(value = HostCustomizer.class, input = "host")
1821
public class ClusterConnectionSpec {
1922
@Required
2023
@ValidationRule(

operator/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import org.junit.jupiter.api.BeforeEach;
1212
import org.junit.jupiter.api.Test;
1313

14+
import java.util.Map;
1415
import java.util.Objects;
1516

1617
import static org.assertj.core.api.Assertions.assertThat;
@@ -67,13 +68,35 @@ void call_whenSomeConnectionsDown_shouldReturnDown() {
6768
given.one()
6869
.clusterConnection()
6970
.withName("db-1")
70-
.returnFirst();
71+
.apply();
7172

7273
given.one()
7374
.clusterConnection()
7475
.withName("db-2")
75-
.withHost("non-existent-host")
76-
.returnFirst();
76+
.withHost("localhost")
77+
.withPort(2345) // Wrong port
78+
.apply();
79+
80+
given.one()
81+
.clusterConnection()
82+
.withName("db-3")
83+
.withHost("127.0.0.1")
84+
.withPort(2345) // Wrong port
85+
.apply();
86+
87+
given.one()
88+
.clusterConnection()
89+
.withName("db-4")
90+
.withHost("::1")
91+
.withPort(2345) // Wrong port
92+
.apply();
93+
94+
given.one()
95+
.clusterConnection()
96+
.withName("db-5")
97+
.withHost("0:0:0:0:0:0:0:1")
98+
.withPort(2345) // Wrong port
99+
.apply();
77100

78101
var response = readinessCheck.call();
79102

@@ -93,7 +116,12 @@ void call_whenSomeConnectionsDown_shouldReturnDown() {
93116

94117
assertThat(dbStatus.toString()).startsWith("UP (PostgreSQL");
95118

96-
assertThat(data).containsEntry("db-2", "DOWN");
119+
assertThat(data).containsAllEntriesOf(Map.of(
120+
"db-2", "DOWN",
121+
"db-3", "DOWN",
122+
"db-4", "DOWN",
123+
"db-5", "DOWN"
124+
));
97125
});
98126
}
99127
}

0 commit comments

Comments
 (0)