diff --git a/.github/workflows/operator-ci.yml b/.github/workflows/operator-ci.yml new file mode 100644 index 0000000000..554f82c354 --- /dev/null +++ b/.github/workflows/operator-ci.yml @@ -0,0 +1,39 @@ +name: Keycloak Operator CI + +on: [push, pull_request] + +env: + JDK_VERSION: 11 + +concurrency: + # Only run once for latest commit per ref and cancel other (previous) runs. + group: ci-operator-keycloak-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Update maven settings + run: mkdir -p ~/.m2 ; cp .github/settings.xml ~/.m2/ + - uses: actions/setup-java@v1 + with: + java-version: ${{ env.JDK_VERSION }} + - name: Cache Maven packages + id: cache + uses: actions/cache@v2 + with: + path: | + ~/.m2/repository + key: cache-1-${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: cache-1-${{ runner.os }}-m2 + + - name: Create the Keycloak distribution + run: | + mvn clean install -DskipTests -DskipExamples -DskipTestsuite + + - name: Build the Keycloak Operator + run: | + mvn clean package -nsu -B -e -pl operator -Doperator -Dquarkus.container-image.build=true -Dquarkus.kubernetes.deployment-target=minikube diff --git a/operator/README.md b/operator/README.md new file mode 100644 index 0000000000..866a3dbab9 --- /dev/null +++ b/operator/README.md @@ -0,0 +1,40 @@ +# Keycloak on Quarkus + +The module holds the codebase to build the Keycloak Operator on top of [Quarkus](https://quarkus.io/). +Using the [Quarkus Operator SDK](https://github.com/quarkiverse/quarkus-operator-sdk). + +## Activating the Module + +When build from the project root directory, this module is only enabled if the installed JDK is 11 or newer. + +## Building + +Ensure you have JDK 11 (or newer) installed. + +Build the Docker image with: + +```bash +mvn clean package -Doperator -Dquarkus.container-image.build=true +``` + +## Contributing + +### Quick start on Minikube + +Enable the Minikube Docker daemon: + +```bash +eval $(minikube -p minikube docker-env) +``` + +Compile the project and generate the Docker image with JIB: + +```bash +mvn clean package -Doperator -Dquarkus.container-image.build=true -Dquarkus.kubernetes.deployment-target=minikube +``` + +Install the CRD definition and the operator in the cluster: + +```bash +kubectl apply -k . +``` diff --git a/operator/kubernetes/deployments-role.yaml b/operator/kubernetes/deployments-role.yaml new file mode 100644 index 0000000000..ed480562b9 --- /dev/null +++ b/operator/kubernetes/deployments-role.yaml @@ -0,0 +1,31 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: keycloak-operator-role +rules: + - apiGroups: + - apps + resources: + - deployments + verbs: + - get + - list + - watch + - create + - delete + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/name: keycloak-operator + name: keycloak-operator-role-binding +roleRef: + kind: Role + apiGroup: rbac.authorization.k8s.io + name: keycloak-operator-role +subjects: + - kind: ServiceAccount + name: keycloak-operator diff --git a/operator/kustomization.yaml b/operator/kustomization.yaml new file mode 100644 index 0000000000..8da405279a --- /dev/null +++ b/operator/kustomization.yaml @@ -0,0 +1,4 @@ +resources: + - target/kubernetes/keycloaks.keycloak.org-v1.yml + - target/kubernetes/minikube.yml + - kubernetes/deployments-role.yaml diff --git a/operator/pom.xml b/operator/pom.xml new file mode 100644 index 0000000000..0d672d5d67 --- /dev/null +++ b/operator/pom.xml @@ -0,0 +1,149 @@ + + + 4.0.0 + + + keycloak-parent + org.keycloak + 17.0.0-SNAPSHOT + + + Keycloak Operator + keycloak-operator + + + + 4.7.4.Final + 1.5.4.Final-format-001 + + 3.8.1 + true + 11 + 11 + 11 + UTF-8 + UTF-8 + 3.0.0-SNAPSHOT + 2.6.1.Final + keycloak + eclipse-temurin:11 + Never + + + + + s01.oss.sonatype + https://s01.oss.sonatype.org/content/repositories/snapshots/ + + + + + + + io.quarkiverse.operatorsdk + quarkus-operator-sdk-bom + ${quarkus.operator.sdk.version} + pom + import + + + + + + + + + io.quarkiverse.operatorsdk + quarkus-operator-sdk + + + io.quarkiverse.operatorsdk + quarkus-operator-sdk-csv-generator + + + io.quarkus + quarkus-resteasy-jackson + + + io.quarkus + quarkus-rest-client + + + io.quarkus + quarkus-rest-client-jackson + + + io.quarkus + quarkus-openshift + + + io.quarkus + quarkus-minikube + + + io.quarkus + quarkus-kubernetes-client + + + + + + io.quarkiverse.operatorsdk + quarkus-operator-sdk-csv-generator-deployment + provided + + + + + org.keycloak + keycloak-core + + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + maven-compiler-plugin + ${compiler-plugin.version} + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + + build + + + + + + + + + + + native + + native + + + + diff --git a/operator/src/main/java/org/keycloak/operator/Constants.java b/operator/src/main/java/org/keycloak/operator/Constants.java new file mode 100644 index 0000000000..210821ab14 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/Constants.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 org.keycloak.operator; + +import java.util.Map; + +public final class Constants { + public static final String CRDS_GROUP = "keycloak.org"; + public static final String CRDS_VERSION = "v2alpha1"; + public static final String SHORT_NAME = "kc"; + public static final String NAME = "keycloak"; + public static final String PLURAL_NAME = "keycloaks"; + public static final String MANAGED_BY_LABEL = "app.kubernetes.io/managed-by"; + public static final String MANAGED_BY_VALUE = "keycloak-operator"; + + public static final Map DEFAULT_LABELS = Map.of( + "app", NAME + ); + + public static final String DEFAULT_KEYCLOAK_IMAGE = "quay.io/keycloak/keycloak-x:latest"; + public static final String DEFAULT_KEYCLOAK_INIT_IMAGE = "quay.io/keycloak/keycloak-init-container:latest"; +} diff --git a/operator/src/main/java/org/keycloak/operator/v2alpha1/KeycloakController.java b/operator/src/main/java/org/keycloak/operator/v2alpha1/KeycloakController.java new file mode 100644 index 0000000000..0ef1d0cf59 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/v2alpha1/KeycloakController.java @@ -0,0 +1,76 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 org.keycloak.operator.v2alpha1; + +import javax.inject.Inject; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +import org.jboss.logging.Logger; +import org.keycloak.operator.v2alpha1.crds.Keycloak; +import org.keycloak.operator.v2alpha1.crds.KeycloakStatus; + +@ControllerConfiguration(namespaces = Constants.WATCH_CURRENT_NAMESPACE, finalizerName = Constants.NO_FINALIZER) +public class KeycloakController implements Reconciler { + + @Inject + Logger logger; + + @Inject + KubernetesClient client; + + @Override + public UpdateControl reconcile(Keycloak kc, Context context) { + logger.trace("Reconcile loop started"); + final var spec = kc.getSpec(); + + logger.info("Reconciling Keycloak: " + kc.getMetadata().getName() + " in namespace: " + kc.getMetadata().getNamespace()); + + KeycloakStatus status = kc.getStatus(); + var deployment = new KeycloakDeployment(client); + + try { + var kcDeployment = deployment.getKeycloakDeployment(kc); + + if (kcDeployment == null) { + // Need to create the deployment + deployment.createKeycloakDeployment(kc); + } + + var nextStatus = deployment.getNextStatus(spec, status, kcDeployment); + + if (!nextStatus.equals(status)) { + logger.trace("Updating the status"); + kc.setStatus(nextStatus); + return UpdateControl.updateStatus(kc); + } else { + logger.trace("Nothing to do"); + return UpdateControl.noUpdate(); + } + } catch (Exception e) { + logger.error("Error reconciling", e); + status = new KeycloakStatus(); + status.setMessage("Error performing operations:\n" + e.getMessage()); + status.setState(KeycloakStatus.State.ERROR); + status.setError(true); + + kc.setStatus(status); + return UpdateControl.updateStatus(kc); + } + } +} diff --git a/operator/src/main/java/org/keycloak/operator/v2alpha1/KeycloakDeployment.java b/operator/src/main/java/org/keycloak/operator/v2alpha1/KeycloakDeployment.java new file mode 100644 index 0000000000..e0e0faa6c5 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/v2alpha1/KeycloakDeployment.java @@ -0,0 +1,97 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 org.keycloak.operator.v2alpha1; + +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import org.keycloak.operator.v2alpha1.crds.Keycloak; +import org.keycloak.operator.v2alpha1.crds.KeycloakSpec; +import org.keycloak.operator.v2alpha1.crds.KeycloakStatus; + +import java.net.URL; + +import static org.keycloak.operator.v2alpha1.crds.KeycloakStatus.State.*; + +public class KeycloakDeployment { + + KubernetesClient client = null; + + KeycloakDeployment(KubernetesClient client) { + this.client = client; + } + + private Deployment baseDeployment; + + public Deployment getKeycloakDeployment(Keycloak keycloak) { + // TODO this should be done through an informer to leverage caches + // WORKAROUND for: https://github.com/java-operator-sdk/java-operator-sdk/issues/781 + return client + .apps() + .deployments() + .inNamespace(keycloak.getMetadata().getNamespace()) + .list() + .getItems() + .stream() + .filter((d) -> d.getMetadata().getName().equals(org.keycloak.operator.Constants.NAME)) + .findFirst() + .orElse(null); +// .withName(Constants.NAME) +// .get(); + } + + public void createKeycloakDeployment(Keycloak keycloak) { + client + .apps() + .deployments() + .inNamespace(keycloak.getMetadata().getNamespace()) + .create(newKeycloakDeployment(keycloak)); + } + + public Deployment newKeycloakDeployment(Keycloak keycloak) { + if (baseDeployment == null) { + URL url = this.getClass().getResource("/base-deployment.yaml"); + baseDeployment = client.apps().deployments().load(url).get(); + } + + var deployment = baseDeployment; + + deployment + .getSpec() + .setReplicas(keycloak.getSpec().getInstances()); + + return new DeploymentBuilder(deployment).build(); + } + + public KeycloakStatus getNextStatus(KeycloakSpec desired, KeycloakStatus prev, Deployment current) { + var isReady = (current != null && + current.getStatus() != null && + current.getStatus().getReadyReplicas() != null && + current.getStatus().getReadyReplicas() == desired.getInstances()); + + var newStatus = new KeycloakStatus(); + if (isReady) { + newStatus.setState(UNKNOWN); + newStatus.setMessage("Keycloak status is unmanaged"); + } else { + newStatus.setState(READY); + newStatus.setMessage("Keycloak status is ready"); + } + return newStatus; + } + +} diff --git a/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/Keycloak.java b/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/Keycloak.java new file mode 100644 index 0000000000..291cef145e --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/Keycloak.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 org.keycloak.operator.v2alpha1.crds; + +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.Plural; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; +import org.keycloak.operator.Constants; + +@Group(Constants.CRDS_GROUP) +@Version(Constants.CRDS_VERSION) +@ShortNames(Constants.SHORT_NAME) +@Plural(Constants.PLURAL_NAME) +public class Keycloak extends CustomResource implements Namespaced { + +} diff --git a/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakSpec.java b/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakSpec.java new file mode 100644 index 0000000000..47655f91c8 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakSpec.java @@ -0,0 +1,30 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 org.keycloak.operator.v2alpha1.crds; + +public class KeycloakSpec { + + private int instances = 1; + + public int getInstances() { + return instances; + } + + public void setInstances(int instances) { + this.instances = instances; + } +} diff --git a/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakStatus.java b/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakStatus.java new file mode 100644 index 0000000000..aa171eeb44 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakStatus.java @@ -0,0 +1,61 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 org.keycloak.operator.v2alpha1.crds; + +public class KeycloakStatus { + public enum State { + READY, + ERROR, + UNKNOWN + } + + private State state = State.UNKNOWN; + private boolean error; + private String message; + + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + public boolean isError() { + return error; + } + + public void setError(boolean error) { + this.error = error; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public KeycloakStatus clone() { + var status = new KeycloakStatus(); + status.setMessage(this.message); + status.setState(this.state); + status.setError(this.error); + return status; + } +} diff --git a/operator/src/main/resources/application.properties b/operator/src/main/resources/application.properties new file mode 100644 index 0000000000..66c98a4359 --- /dev/null +++ b/operator/src/main/resources/application.properties @@ -0,0 +1,4 @@ +quarkus.operator-sdk.crd.apply=true +quarkus.operator-sdk.generate-csv=true +quarkus.container-image.builder=jib +quarkus.operator-sdk.crd.validate=false diff --git a/operator/src/main/resources/base-deployment.yaml b/operator/src/main/resources/base-deployment.yaml new file mode 100644 index 0000000000..34f9096bcd --- /dev/null +++ b/operator/src/main/resources/base-deployment.yaml @@ -0,0 +1,40 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubernetes.io/managed-by: keycloak-operator + name: keycloak + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: keycloak + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + labels: + app: keycloak + spec: + containers: + - args: + - start-dev + image: quay.io/keycloak/keycloak-x:latest + imagePullPolicy: Always + name: keycloak + ports: + - containerPort: 8443 + protocol: TCP + - containerPort: 8080 + protocol: TCP + dnsPolicy: ClusterFirst + initContainers: + - image: quay.io/keycloak/keycloak-init-container:latest + imagePullPolicy: Always + name: init-container + restartPolicy: Always + terminationGracePeriodSeconds: 30 diff --git a/operator/src/main/resources/example-keycloak.yml b/operator/src/main/resources/example-keycloak.yml new file mode 100644 index 0000000000..da57ab6008 --- /dev/null +++ b/operator/src/main/resources/example-keycloak.yml @@ -0,0 +1,6 @@ +apiVersion: keycloak.io/v2alpha1 +kind: Keycloak +metadata: + name: example-kc +spec: + instances: 1 diff --git a/pom.xml b/pom.xml index 0415c4c18d..9a6684889f 100644 --- a/pom.xml +++ b/pom.xml @@ -2040,6 +2040,19 @@ + + operator + + [11,) + + operator + + + + operator + + + doclint-java8-disable