Initial bootstrap admin
This commit is contained in:
parent
6621fb3988
commit
59450948f4
6 changed files with 162 additions and 4 deletions
|
@ -0,0 +1,47 @@
|
||||||
|
package org.keycloak.operator.v2alpha1;
|
||||||
|
|
||||||
|
import io.fabric8.kubernetes.api.model.HasMetadata;
|
||||||
|
import io.fabric8.kubernetes.api.model.Secret;
|
||||||
|
import io.fabric8.kubernetes.api.model.SecretBuilder;
|
||||||
|
import io.fabric8.kubernetes.client.KubernetesClient;
|
||||||
|
import io.fabric8.kubernetes.client.utils.KubernetesResourceUtil;
|
||||||
|
import org.keycloak.operator.OperatorManagedResource;
|
||||||
|
import org.keycloak.operator.v2alpha1.crds.Keycloak;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class KeycloakAdminSecret extends OperatorManagedResource {
|
||||||
|
|
||||||
|
private final String secretName;
|
||||||
|
|
||||||
|
public KeycloakAdminSecret(KubernetesClient client, Keycloak keycloak) {
|
||||||
|
super(client, keycloak);
|
||||||
|
this.secretName = KubernetesResourceUtil.sanitizeName(keycloak.getMetadata().getName() + "-initial-admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Optional<HasMetadata> getReconciledResource() {
|
||||||
|
if (client.secrets().inNamespace(getNamespace()).withName(secretName).get() != null) {
|
||||||
|
return Optional.empty();
|
||||||
|
} else {
|
||||||
|
return Optional.of(createSecret());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Secret createSecret() {
|
||||||
|
return new SecretBuilder()
|
||||||
|
.withNewMetadata()
|
||||||
|
.withName(secretName)
|
||||||
|
.withNamespace(getNamespace())
|
||||||
|
.endMetadata()
|
||||||
|
.withType("kubernetes.io/basic-auth")
|
||||||
|
.addToStringData("username", "admin")
|
||||||
|
.addToStringData("password", UUID.randomUUID().toString().replace("-", ""))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() { return secretName; }
|
||||||
|
|
||||||
|
}
|
|
@ -91,9 +91,12 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
|
||||||
|
|
||||||
var statusBuilder = new KeycloakStatusBuilder();
|
var statusBuilder = new KeycloakStatusBuilder();
|
||||||
|
|
||||||
|
var kcAdminSecret = new KeycloakAdminSecret(client, kc);
|
||||||
|
kcAdminSecret.createOrUpdateReconciled();
|
||||||
|
|
||||||
// TODO use caches in secondary resources; this is a workaround for https://github.com/java-operator-sdk/java-operator-sdk/issues/830
|
// TODO use caches in secondary resources; this is a workaround for https://github.com/java-operator-sdk/java-operator-sdk/issues/830
|
||||||
// KeycloakDeployment deployment = new KeycloakDeployment(client, config, kc, context.getSecondaryResource(Deployment.class).orElse(null));
|
// KeycloakDeployment deployment = new KeycloakDeployment(client, config, kc, context.getSecondaryResource(Deployment.class).orElse(null));
|
||||||
var kcDeployment = new KeycloakDeployment(client, config, kc, null);
|
var kcDeployment = new KeycloakDeployment(client, config, kc, null, kcAdminSecret.getName());
|
||||||
kcDeployment.updateStatus(statusBuilder);
|
kcDeployment.updateStatus(statusBuilder);
|
||||||
kcDeployment.createOrUpdateReconciled();
|
kcDeployment.createOrUpdateReconciled();
|
||||||
|
|
||||||
|
|
|
@ -54,11 +54,13 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu
|
||||||
private final Keycloak keycloakCR;
|
private final Keycloak keycloakCR;
|
||||||
private final Deployment existingDeployment;
|
private final Deployment existingDeployment;
|
||||||
private final Deployment baseDeployment;
|
private final Deployment baseDeployment;
|
||||||
|
private final String adminSecretName;
|
||||||
|
|
||||||
public KeycloakDeployment(KubernetesClient client, Config config, Keycloak keycloakCR, Deployment existingDeployment) {
|
public KeycloakDeployment(KubernetesClient client, Config config, Keycloak keycloakCR, Deployment existingDeployment, String adminSecretName) {
|
||||||
super(client, keycloakCR);
|
super(client, keycloakCR);
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.keycloakCR = keycloakCR;
|
this.keycloakCR = keycloakCR;
|
||||||
|
this.adminSecretName = adminSecretName;
|
||||||
|
|
||||||
if (existingDeployment != null) {
|
if (existingDeployment != null) {
|
||||||
Log.info("Existing Deployment provided by controller");
|
Log.info("Existing Deployment provided by controller");
|
||||||
|
@ -483,12 +485,37 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu
|
||||||
if (keycloakCR.getSpec().getServerConfiguration() != null) {
|
if (keycloakCR.getSpec().getServerConfiguration() != null) {
|
||||||
serverConfig.putAll(keycloakCR.getSpec().getServerConfiguration());
|
serverConfig.putAll(keycloakCR.getSpec().getServerConfiguration());
|
||||||
}
|
}
|
||||||
return serverConfig.entrySet().stream()
|
var envVars = serverConfig.entrySet().stream()
|
||||||
.map(e -> new EnvVarBuilder()
|
.map(e -> new EnvVarBuilder()
|
||||||
.withName(e.getKey())
|
.withName(e.getKey())
|
||||||
.withValue(e.getValue())
|
.withValue(e.getValue())
|
||||||
.build())
|
.build())
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
envVars.add(
|
||||||
|
new EnvVarBuilder()
|
||||||
|
.withName("KEYCLOAK_ADMIN")
|
||||||
|
.withNewValueFrom()
|
||||||
|
.withNewSecretKeyRef()
|
||||||
|
.withName(this.adminSecretName)
|
||||||
|
.withKey("username")
|
||||||
|
.withOptional(false)
|
||||||
|
.endSecretKeyRef()
|
||||||
|
.endValueFrom()
|
||||||
|
.build());
|
||||||
|
envVars.add(
|
||||||
|
new EnvVarBuilder()
|
||||||
|
.withName("KEYCLOAK_ADMIN_PASSWORD")
|
||||||
|
.withNewValueFrom()
|
||||||
|
.withNewSecretKeyRef()
|
||||||
|
.withName(this.adminSecretName)
|
||||||
|
.withKey("password")
|
||||||
|
.withOptional(false)
|
||||||
|
.endSecretKeyRef()
|
||||||
|
.endValueFrom()
|
||||||
|
.build());
|
||||||
|
|
||||||
|
return envVars;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateStatus(KeycloakStatusBuilder status) {
|
public void updateStatus(KeycloakStatusBuilder status) {
|
||||||
|
|
|
@ -163,6 +163,11 @@ public abstract class ClusterOperatorTest {
|
||||||
.untilAsserted(() -> assertThat(k8sclient.apps().statefulSets().inNamespace(namespace).withName("postgresql-db").get().getStatus().getReadyReplicas()).isEqualTo(1));
|
.untilAsserted(() -> assertThat(k8sclient.apps().statefulSets().inNamespace(namespace).withName("postgresql-db").get().getStatus().getReadyReplicas()).isEqualTo(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static void deleteDB() {
|
||||||
|
// Delete the Postgres StatefulSet
|
||||||
|
k8sclient.apps().statefulSets().inNamespace(namespace).withName("postgresql-db").delete();
|
||||||
|
}
|
||||||
|
|
||||||
// TODO improve this (preferably move to JOSDK)
|
// TODO improve this (preferably move to JOSDK)
|
||||||
protected void savePodLogs() {
|
protected void savePodLogs() {
|
||||||
Log.infof("Saving pod logs to %s", POD_LOGS_DIR);
|
Log.infof("Saving pod logs to %s", POD_LOGS_DIR);
|
||||||
|
|
|
@ -7,16 +7,21 @@ import io.quarkus.test.junit.QuarkusTest;
|
||||||
import org.awaitility.Awaitility;
|
import org.awaitility.Awaitility;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.keycloak.operator.utils.K8sUtils;
|
import org.keycloak.operator.utils.K8sUtils;
|
||||||
|
import org.keycloak.operator.v2alpha1.KeycloakAdminSecret;
|
||||||
import org.keycloak.operator.v2alpha1.KeycloakService;
|
import org.keycloak.operator.v2alpha1.KeycloakService;
|
||||||
import org.keycloak.operator.v2alpha1.crds.Keycloak;
|
import org.keycloak.operator.v2alpha1.crds.Keycloak;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.keycloak.operator.Constants.DEFAULT_LABELS;
|
import static org.keycloak.operator.Constants.DEFAULT_LABELS;
|
||||||
import static org.keycloak.operator.utils.K8sUtils.deployKeycloak;
|
import static org.keycloak.operator.utils.K8sUtils.deployKeycloak;
|
||||||
|
@ -246,4 +251,75 @@ public class KeycloakDeploymentE2EIT extends ClusterOperatorTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reference curl command:
|
||||||
|
// curl --insecure --data "grant_type=password&client_id=admin-cli&username=admin&password=adminPassword" https://localhost:8443/realms/master/protocol/openid-connect/token
|
||||||
|
@Test
|
||||||
|
public void testInitialAdminUser() {
|
||||||
|
try {
|
||||||
|
// Recreating the database to keep this test isolated
|
||||||
|
deleteDB();
|
||||||
|
deployDB();
|
||||||
|
var kc = getDefaultKeycloakDeployment();
|
||||||
|
deployKeycloak(k8sclient, kc, true);
|
||||||
|
|
||||||
|
var decoder = Base64.getDecoder();
|
||||||
|
var service = new KeycloakService(k8sclient, kc);
|
||||||
|
var kcAdminSecret = new KeycloakAdminSecret(k8sclient, kc);
|
||||||
|
|
||||||
|
AtomicReference<String> adminUsername = new AtomicReference<>();
|
||||||
|
AtomicReference<String> adminPassword = new AtomicReference<>();
|
||||||
|
Awaitility.await()
|
||||||
|
.ignoreExceptions()
|
||||||
|
.untilAsserted(() -> {
|
||||||
|
Log.info("Checking secret, ns: " + namespace + ", name: " + kcAdminSecret.getName());
|
||||||
|
var adminSecret = k8sclient
|
||||||
|
.secrets()
|
||||||
|
.inNamespace(namespace)
|
||||||
|
.withName(kcAdminSecret.getName())
|
||||||
|
.get();
|
||||||
|
|
||||||
|
adminUsername.set(new String(decoder.decode(adminSecret.getData().get("username").getBytes(StandardCharsets.UTF_8))));
|
||||||
|
adminPassword.set(new String(decoder.decode(adminSecret.getData().get("password").getBytes(StandardCharsets.UTF_8))));
|
||||||
|
|
||||||
|
String url = "https://" + service.getName() + "." + namespace + ":" + Constants.KEYCLOAK_HTTPS_PORT + "/realms/master/protocol/openid-connect/token";
|
||||||
|
Log.info("Checking url: " + url);
|
||||||
|
|
||||||
|
var curlOutput = K8sUtils.inClusterCurl(k8sclient, namespace, "--insecure", "-s", "--data", "grant_type=password&client_id=admin-cli&username=" + adminUsername.get() + "&password=" + adminPassword.get(), url);
|
||||||
|
Log.info("Curl Output: " + curlOutput);
|
||||||
|
|
||||||
|
assertTrue(curlOutput.contains("\"access_token\""));
|
||||||
|
assertTrue(curlOutput.contains("\"token_type\":\"Bearer\""));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redeploy the same Keycloak without redeploying the Database
|
||||||
|
k8sclient.resource(kc).delete();
|
||||||
|
deployKeycloak(k8sclient, kc, true);
|
||||||
|
Awaitility.await()
|
||||||
|
.ignoreExceptions()
|
||||||
|
.untilAsserted(() -> {
|
||||||
|
Log.info("Checking secret, ns: " + namespace + ", name: " + kcAdminSecret.getName());
|
||||||
|
var adminSecret = k8sclient
|
||||||
|
.secrets()
|
||||||
|
.inNamespace(namespace)
|
||||||
|
.withName(kcAdminSecret.getName())
|
||||||
|
.get();
|
||||||
|
|
||||||
|
var newPassword = new String(decoder.decode(adminSecret.getData().get("password").getBytes(StandardCharsets.UTF_8)));
|
||||||
|
|
||||||
|
String url = "https://" + service.getName() + "." + namespace + ":" + Constants.KEYCLOAK_HTTPS_PORT + "/realms/master/protocol/openid-connect/token";
|
||||||
|
Log.info("Checking url: " + url);
|
||||||
|
|
||||||
|
var curlOutput = K8sUtils.inClusterCurl(k8sclient, namespace, "--insecure", "-s", "--data", "grant_type=password&client_id=admin-cli&username=" + adminUsername.get() + "&password=" + adminPassword.get(), url);
|
||||||
|
Log.info("Curl Output: " + curlOutput);
|
||||||
|
|
||||||
|
assertTrue(curlOutput.contains("\"access_token\""));
|
||||||
|
assertTrue(curlOutput.contains("\"token_type\":\"Bearer\""));
|
||||||
|
assertNotEquals(adminPassword.get(), newPassword);
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
savePodLogs();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,7 @@ public class PodTemplateTest {
|
||||||
spec.setHostname("example.com");
|
spec.setHostname("example.com");
|
||||||
spec.setTlsSecret("example-tls-secret");
|
spec.setTlsSecret("example-tls-secret");
|
||||||
kc.setSpec(spec);
|
kc.setSpec(spec);
|
||||||
var deployment = new KeycloakDeployment(null, config, kc, new Deployment());
|
var deployment = new KeycloakDeployment(null, config, kc, new Deployment(), "dummy-admin");
|
||||||
return (Deployment) deployment.getReconciledResource().get();
|
return (Deployment) deployment.getReconciledResource().get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue