Skip to content

Commit 7de8101

Browse files
authored
Fix Helm chart values.yaml having github-container-registry as default imagePullSecret (#47)
* fix Helm chart `values.yaml` having `github-container-registry` as default mage pull secret * fix typo * test the generated Helm chart * test should depend on quarkusAppPartsBuild for HelmTest * fix the Gradle configuration cache issue by lazily configuring Mockito and lazily resolving the testRuntimeClasspath `mockito-core` dependency * make the behavior of Quarkiverse Kubernetes/Helm more apparent * fix typo CRD_GROP -> CRD_GROUP
1 parent f0a650a commit 7de8101

4 files changed

Lines changed: 280 additions & 14 deletions

File tree

operator/build.gradle.kts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,29 @@ tasks.quarkusAppPartsBuild {
7171
doNotTrackState("Always execute Gradle task quarkusAppPartsBuild to generate the K8s deploy manifest kubernetes.yml, the CRDs, and to publish the Helm chart")
7272
}
7373

74-
tasks.withType<Test> {
75-
val mockitoAgent = configurations.testRuntimeClasspath.get().find {
76-
it.name.contains("mockito-core")
77-
}
78-
if (mockitoAgent != null) {
79-
jvmArgs("-javaagent:${mockitoAgent.absolutePath}")
74+
val mockitoAgentProvider = configurations.named("testRuntimeClasspath").map { classpath ->
75+
classpath.find { it.name.contains("mockito-core") }
76+
}
77+
78+
tasks.withType<Test>().configureEach {
79+
// Required for the HelmTest
80+
dependsOn(tasks.quarkusAppPartsBuild)
81+
82+
jvmArgumentProviders.add(MockitoArgumentProvider(mockitoAgentProvider))
83+
}
84+
85+
class MockitoArgumentProvider(
86+
@get:Optional
87+
@get:InputFile
88+
@get:PathSensitive(PathSensitivity.NONE)
89+
val agentProvider: Provider<File>
90+
) : CommandLineArgumentProvider {
91+
override fun asArguments(): Iterable<String> {
92+
val agentFile = agentProvider.orNull
93+
return if (agentFile != null) {
94+
listOf("-javaagent:${agentFile.absolutePath}")
95+
} else {
96+
emptyList()
97+
}
8098
}
8199
}

operator/src/main/kubernetes/kubernetes.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ spec:
77
template:
88
spec:
99
affinity: {}
10+
imagePullSecrets: [~]

operator/src/main/resources/application.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ quarkus:
8484
value: IfNotPresent
8585
paths:
8686
- (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).imagePullPolicy
87+
image-pull-secrets:
88+
property: imagePullSecrets
89+
value:
90+
- null
91+
paths:
92+
- (kind == Deployment).spec.template.spec.imagePullSecrets
93+
expression: "{{- toYaml .Values.app.imagePullSecrets | nindent 8 }}"
94+
description: Kubernetes image pull secrets to use if the OCI image is hosted on a private registry
8795
resource-requests-cpu:
8896
property: resources.requests.cpu
8997
value: ${quarkus.kubernetes.resources.requests.cpu}
@@ -99,12 +107,6 @@ quarkus:
99107
value: ${quarkus.kubernetes.resources.limits.memory}
100108
paths:
101109
- (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).resources.limits.memory
102-
image-pull-secret:
103-
property: imagePullSecret
104-
value: ${quarkus.kubernetes.image-pull-secrets[0]}
105-
paths:
106-
- (kind == Deployment).spec.template.spec.imagePullSecrets[0].name
107-
description: Kubernetes image pull secret to use if the OCI image is hosted on a private registry
108110
affinity:
109111
property: affinity
110112
value-as-map: {}
@@ -149,8 +151,6 @@ quarkus:
149151
version: ${quarkus.application.version}
150152
add-version-to-label-selectors: false
151153
image-pull-policy: IfNotPresent
152-
image-pull-secrets:
153-
- github-container-registry
154154
replicas: 1
155155
annotations:
156156
"app.kubernetes.io/version": ${quarkus.application.version}
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
package it.aboutbits.postgresql.helm;
2+
3+
import io.fabric8.kubernetes.api.model.ConfigBuilder;
4+
import io.fabric8.kubernetes.api.model.LocalObjectReference;
5+
import io.fabric8.kubernetes.client.KubernetesClient;
6+
import io.fabric8.kubernetes.client.utils.Serialization;
7+
import io.quarkus.test.junit.QuarkusTest;
8+
import io.smallrye.common.process.ProcessBuilder;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.eclipse.microprofile.config.inject.ConfigProperty;
11+
import org.jspecify.annotations.NullMarked;
12+
import org.junit.jupiter.api.DisplayName;
13+
import org.junit.jupiter.api.Test;
14+
15+
import java.io.IOException;
16+
import java.nio.file.Files;
17+
import java.nio.file.Path;
18+
import java.nio.file.Paths;
19+
import java.util.List;
20+
import java.util.Map;
21+
import java.util.Objects;
22+
import java.util.concurrent.TimeUnit;
23+
24+
import static org.assertj.core.api.Assertions.assertThat;
25+
import static org.awaitility.Awaitility.await;
26+
27+
@Slf4j
28+
@QuarkusTest
29+
@NullMarked
30+
class HelmTest {
31+
private static final String ENV_VAR_KUBECONFIG = "KUBECONFIG";
32+
33+
private static final String CRD_GROUP = "postgresql.aboutbits.it";
34+
private static final List<String> CRD_NAMES = List.of(
35+
"clusterconnection",
36+
"database",
37+
"schema",
38+
"role",
39+
"grant",
40+
"defaultprivilege"
41+
);
42+
43+
private final String chartName;
44+
private final String rootValuesAlias;
45+
private final KubernetesClient kubernetesClient;
46+
47+
HelmTest(
48+
KubernetesClient kubernetesClient,
49+
@ConfigProperty(name = "quarkus.helm.name") String chartName,
50+
@ConfigProperty(name = "quarkus.helm.values-root-alias", defaultValue = "app") String rootValuesAlias
51+
) {
52+
this.kubernetesClient = kubernetesClient;
53+
this.chartName = chartName;
54+
this.rootValuesAlias = rootValuesAlias;
55+
}
56+
57+
@SuppressWarnings("checkstyle:MethodLength")
58+
@Test
59+
@DisplayName("When the Helm chart is installed, the operator deployment should be created")
60+
void helmInstall_createsDeployment() throws IOException {
61+
// The chart is generated by the quarkus-helm extension in the build directory.
62+
// For Gradle, it's build/helm/kubernetes/postgresql-operator
63+
var chartPath = Paths.get("build", "helm", "kubernetes", chartName);
64+
65+
assertThat(chartPath)
66+
.withFailMessage("Helm chart not found at %s. Ensure that the chart is generated before running this test.", chartPath)
67+
.exists();
68+
69+
// 1. Verify files exist and contain expected data
70+
// ./Chart.yaml
71+
@SuppressWarnings("unchecked")
72+
Map<String, Object> chartMetadata = Serialization.yamlMapper()
73+
.readValue(
74+
chartPath.resolve("Chart.yaml").toFile(),
75+
Map.class
76+
);
77+
78+
assertThat(chartMetadata.get("name")).isEqualTo(chartName);
79+
80+
// ./values.yaml
81+
@SuppressWarnings("unchecked")
82+
Map<String, Object> values = Serialization.yamlMapper()
83+
.readValue(
84+
chartPath.resolve("values.yaml").toFile(),
85+
Map.class
86+
);
87+
88+
assertThat(values).containsKey(rootValuesAlias);
89+
90+
@SuppressWarnings("unchecked")
91+
var appValues = (Map<String, Object>) values.get(rootValuesAlias);
92+
93+
Objects.requireNonNull(appValues, "appValues should not be null");
94+
assertThat(appValues.get("image")).isNotNull();
95+
96+
assertThat(chartPath.resolve("LICENSE")).exists();
97+
assertThat(chartPath.resolve("README.md")).exists();
98+
assertThat(chartPath.resolve("values.schema.json")).exists();
99+
100+
// ./crds/
101+
for (var crdName : CRD_NAMES) {
102+
assertThat(chartPath.resolve("crds/%ss.%s-v1.yml".formatted(
103+
crdName,
104+
CRD_GROUP
105+
))).exists();
106+
}
107+
108+
// ./templates/
109+
assertThat(chartPath.resolve("templates/clusterrole.yaml")).exists();
110+
assertThat(chartPath.resolve("templates/clusterrolebinding.yaml")).exists();
111+
assertThat(chartPath.resolve("templates/deployment.yaml")).exists();
112+
assertThat(chartPath.resolve("templates/rolebinding.yaml")).exists();
113+
assertThat(chartPath.resolve("templates/service.yaml")).exists();
114+
assertThat(chartPath.resolve("templates/serviceaccount.yaml")).exists();
115+
assertThat(chartPath.resolve("templates/validating-clusterrolebinding.yaml")).exists();
116+
117+
for (var crdName : CRD_NAMES) {
118+
assertThat(chartPath.resolve("templates/%sreconciler-crd-role-binding.yaml".formatted(
119+
crdName
120+
))).exists();
121+
}
122+
123+
// 2. Prepare a temporary KubeConfig for the 'helm' CLI
124+
// This ensures 'helm' uses the same Kubernetes cluster as the test environment (e.g., provided by DevServices).
125+
var kubeConfigPath = createTempKubeConfig();
126+
127+
try {
128+
// 3. Install the Helm chart using 'helm install'
129+
var releaseName = "helm-install-test-" + System.nanoTime();
130+
131+
var holder = new Object() {
132+
int exitCode;
133+
};
134+
var installOutput = new StringBuilder();
135+
136+
ProcessBuilder.newBuilder(
137+
"helm",
138+
"install", releaseName, chartPath.toAbsolutePath().toString(), "--set", rootValuesAlias + ".image=postgresql-operator:test"
139+
).environment(Map.of(
140+
ENV_VAR_KUBECONFIG,
141+
kubeConfigPath.toAbsolutePath().toString()
142+
))
143+
.exitCodeChecker(ec -> {
144+
holder.exitCode = ec;
145+
return true;
146+
})
147+
.error().redirect()
148+
.output()
149+
.consumeLinesWith(65536, line -> installOutput.append(line).append(System.lineSeparator()))
150+
.run();
151+
152+
int installExitCode = holder.exitCode;
153+
assertThat(installExitCode)
154+
.withFailMessage("Helm install failed with output:\n" + installOutput)
155+
.isZero();
156+
157+
try {
158+
// 4. Verify that the deployment is created in Kubernetes
159+
await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
160+
var deployment = kubernetesClient.apps().deployments().withName(chartName).get();
161+
162+
assertThat(deployment).isNotNull();
163+
164+
// Helm sets labels based on the release name
165+
assertThat(deployment.getMetadata())
166+
.isNotNull()
167+
.satisfies(metadata -> assertThat(metadata.getLabels())
168+
.containsAllEntriesOf(Map.of(
169+
"app.kubernetes.io/name", releaseName,
170+
"app.kubernetes.io/managed-by", "Helm"
171+
))
172+
);
173+
174+
assertThat(deployment.getSpec())
175+
.isNotNull()
176+
.satisfies(spec -> assertThat(spec.getTemplate().getSpec().getImagePullSecrets())
177+
.isNotEmpty()
178+
.element(0)
179+
.isNotNull()
180+
.isEqualTo(new LocalObjectReference(null))
181+
);
182+
183+
var selector = deployment.getSpec().getSelector();
184+
185+
var pods = kubernetesClient.pods()
186+
.withLabelSelector(selector)
187+
.list()
188+
.getItems();
189+
190+
assertThat(pods).isNotEmpty();
191+
});
192+
} finally {
193+
// 5. Cleanup the created resources using 'helm uninstall'
194+
ProcessBuilder.newBuilder(
195+
"helm",
196+
"uninstall", releaseName
197+
).environment(Map.of(
198+
ENV_VAR_KUBECONFIG,
199+
kubeConfigPath.toAbsolutePath().toString()
200+
))
201+
.error().consumeLinesWith(
202+
8192,
203+
log::error
204+
)
205+
.run();
206+
}
207+
} finally {
208+
Files.deleteIfExists(kubeConfigPath);
209+
}
210+
}
211+
212+
private Path createTempKubeConfig() throws IOException {
213+
var clientConfig = kubernetesClient.getConfiguration();
214+
215+
var kubeConfig = new ConfigBuilder()
216+
.addNewCluster()
217+
.withName("dev-cluster")
218+
.withNewCluster()
219+
.withServer(clientConfig.getMasterUrl())
220+
.withCertificateAuthorityData(clientConfig.getCaCertData())
221+
.endCluster()
222+
.endCluster()
223+
.addNewUser()
224+
.withName("dev-user")
225+
.withNewUser()
226+
.withClientCertificateData(clientConfig.getClientCertData())
227+
.withClientKeyData(clientConfig.getClientKeyData())
228+
.endUser()
229+
.endUser()
230+
.addNewContext()
231+
.withName("dev-context")
232+
.withNewContext()
233+
.withCluster("dev-cluster")
234+
.withUser("dev-user")
235+
.withNamespace(clientConfig.getNamespace())
236+
.endContext()
237+
.endContext()
238+
.withCurrentContext("dev-context")
239+
.build();
240+
241+
var path = Files.createTempFile("kubeconfig-helm-test-", ".yaml");
242+
243+
Files.writeString(path, Serialization.asYaml(kubeConfig));
244+
245+
return path;
246+
}
247+
}

0 commit comments

Comments
 (0)