Operator SDKの新プラグインJava Operatorを試してみた（リソース作成のみ）

# 目的

先日リリースされたOperator SDK v1.13.0で、Java Operatorに対応したようです。さっそく人柱になってみます。

# 今回作るもの

過去記事で作成したものと同様ですが、今回は簡単のためにレジストリにDocker Hubを使用します。

https://qiita.com/teruq/items/4fc4193a5e1de2b3802b

* ibmcom/helloイメージをデプロイする
* レプリカ数をCustom Resourceで変更できるようにする

また、今回は力尽きたので、リソースの作成のみを行います。余力があれば後日削除等も実装してみます。

# 手順

## Operator SDKのインストール（バージョンアップ）

リリースノートはこちらです。

https://github.com/operator-framework/operator-sdk/releases/tag/v1.13.0

```
$ wget https://github.com/operator-framework/operator-sdk/releases/download/v1.13.0/operator-sdk_linux_amd64
$ sudo cp operator-sdk_linux_amd64 /usr/local/bin/operator-sd
$ sudo chmod 755 /usr/local/bin/operator-sdk
$ operator-sdk version
$ operator-sdk version: "v1.13.0", commit: "6e84414b468029c5c3e07352cabe64cf3a682111", kubernetes version: "1.21", go version: "go1.16.8", GOOS: "linux", GOARCH: "amd64"
$ operator-sdk completion bash | sudo tee /etc/bash_completion.d/operator-sdk  >/dev/null
$ source /etc/bash_completion.d/operator-sdk
```

使用可能なプラグインの一覧を確認します。最下段に``quarkus.javaoperatorsdk.io/v1-alpha``が追加されています。

```
$ operator-sdk init --plugins
Error: flag needs an argument: --plugins
Usage:
  operator-sdk [flags]

Examples:
The first step is to initialize your project:
    operator-sdk init [--plugins=<PLUGIN KEYS> [--project-version=<PROJECT VERSION>]]

<PLUGIN KEYS> is a comma-separated list of plugin keys from the following table
and <PROJECT VERSION> a supported project version for these plugins.

                         Plugin keys | Supported project versions
-------------------------------------+----------------------------
 ansible.sdk.operatorframework.io/v1 |                          3
    declarative.go.kubebuilder.io/v1 |                       2, 3
                go.kubebuilder.io/v2 |                       2, 3
                go.kubebuilder.io/v3 |                          3
    helm.sdk.operatorframework.io/v1 |                          3
  kustomize.common.kubebuilder.io/v1 |                          3
 quarkus.javaoperatorsdk.io/v1-alpha |                          3

For more specific help for the init command of a certain plugins and project version
configuration please run:
    operator-sdk init --help --plugins=<PLUGIN KEYS> [--project-version=<PROJECT VERSION>]

Default plugin keys: "go.kubebuilder.io/v3"
Default project version: "3"


Flags:
  -h, --help                     help for operator-sdk
      --plugins strings          plugin keys to be used for this subcommand execution
      --project-version string   project version (default "3")

FATA[0000] flag needs an argument: --plugin
```

## プロジェクトの初期化

いつものようにinitで初期化します。

```
$ mkdir hello-java-operator
$ cd hello-java-operator/
$ operator-sdk init --plugins quarkus --domain teruq.example.com
Next: define a resource with:
#$ operator-sdk create api
```

今回はファイル構造がとてもシンプルです。

```
$ ls -F
Makefile  PROJECT  pom.xml  src/
```

## APIの追加

createでAPIを追加します。

```
$ operator-sdk create api --group example --version v1alpha1 --kind Hello
```

この時点でのファイル構造を見てみます。Hello APIに対応したソースが生成されていることがわかります。

```
$ find .
.
./.gitignore
./Makefile
./pom.xml
./PROJECT
./src
./src/main
./src/main/java
./src/main/java/.placeholder
./src/main/java/com
./src/main/java/com/example
./src/main/java/com/example/teruq
./src/main/java/com/example/teruq/Hello.java
./src/main/java/com/example/teruq/HelloController.java
./src/main/java/com/example/teruq/HelloSpec.java
./src/main/java/com/example/teruq/HelloStatus.java
./src/main/resources
./src/main/resources/application.properties
```

## 仮ビルド

まずはこの状態で何もしないOperatorを作ってデプロイしてみます。Mafefileを見てみましょう。これまでのものに比べてかなりシンプルです。ビルド処理はMavenに任せているようです。

```:Makefile
# Image URL to use all building/pushing image targets
IMG ?= controller:latest

all: docker-build

##@ General

# The help target prints out all targets with their descriptions organized
# beneath their categories. The categories are represented by '##@' and the
# target descriptions by '##'. The awk commands is responsible for reading the
# entire set of makefiles included in this invocation, looking for lines of the
# file as xyz: ## something, and then pretty-format the target and help. Then,
# if there's a line with ##@ something, that gets pretty-printed as a category.
# More info on the usage of ANSI control characters for terminal formatting:
# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters
# More info on the awk command:
# http://linuxcommand.org/lc3_adv_awk.php

help: ## Display this help.
        @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n  make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)

## Build

docker-build: ## Build docker image with the manager.
        mvn package -Dquarkus.container-image.build=true -Dquarkus.container-image.image=${IMG}

docker-push: ## Push docker image with the manager.
        mvn package -Dquarkus.container-image.push=true -Dquarkus.container-image.image=${IMG}

##@ Deployment

install: ## Install CRDs into the K8s cluster specified in ~/.kube/config.
        @$(foreach file, $(wildcard target/kubernetes/*-v1.yml), kubectl apply -f $(file);)

uninstall: ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config.
        @$(foreach file, $(wildcard target/kubernetes/*-v1.yml), kubectl delete -f $(file);)

deploy: ## Deploy controller to the K8s cluster specified in ~/.kube/config.
        kubectl apply -f target/kubernetes/kubernetes.yml

undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/config.
        kubectl delete -f target/kubernetes/kubernetes.yml
```

1か所プロパティを修正します。下記ファイルでイメージ名が-operator-operatorになっているので、削って1つにします。

```src/main/resources/application.properties
quarkus.container-image.build=true
#quarkus.container-image.group=
#quarkus.container-image.name=hello-java-operator-operator  # 削除
quarkus.container-image.name=hello-java-operator            # 修正
# set to true to automatically apply CRDs to the cluster when they get regenerated
quarkus.operator-sdk.crd.apply=false
```

> 私のプロジェクト名はhello-java-operatorでしたが、プロジェクト名に-operatorを付けてイメージ名にしているようです。

イメージをビルドしてプッシュします。今回は簡単のためにDocker Hubを使います。

```
$ make docker-build IMG=docker.io/teruq/hello-java-operator:0.0.1
...
[INFO] [io.quarkus.deployment.QuarkusAugmentor] Quarkus augmentation completed in 26618ms
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  33.762 s
[INFO] Finished at: 2021-10-07T01:30:30+09:00
[INFO] ------------------------------------------------------------------------
```

続けてプッシュします。

```
$ make docker-push IMG=docker.io/teruq/hello-java-operator:0.0.1
mvn package -Dquarkus.container-image.push=true -Dquarkus.container-image.image=docker.io/teruq/hello-java-operator:0.0.1
...
[INFO] [io.quarkus.deployment.QuarkusAugmentor] Quarkus augmentation completed in 22492ms
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  28.412 s
[INFO] Finished at: 2021-10-07T01:31:48+09:00
[INFO] ------------------------------------------------------------------------
```

Makefileによるとmake installでCRDのみ作成、make deployでControllerをデプロイするようです。

まずはCRDをインストールします。

```
$ oc new-project qiita-operators
$ make install
customresourcedefinition.apiextensions.k8s.io/hellos.example.teruq.example.com created

$ oc get crd hellos.example.teruq.example.com
NAME                               CREATED AT
hellos.example.teruq.example.com   2021-10-06T17:43:22Z
```

次にOperatorをデプロイします。

```
$ make deploy
kubectl apply -f target/kubernetes/kubernetes.yml
serviceaccount/hello-java-operator created
service/hello-java-operator created
rolebinding.rbac.authorization.k8s.io/hello-java-operator-view created
deployment.apps/hello-java-operator created

$ oc get pods
NAME                                   READY   STATUS   RESTARTS   AGE
hello-java-operator-54f4bd7b49-dwrn8   0/1     Error    2          46s
```

起動に失敗しました。原因を探ってみます。

```
$ oc logs hello-java-operator-54f4bd7b49-dwrn8
__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2021-10-06 17:45:54,845 INFO  [io.jav.ope.Operator] (main) Registered Controller: 'hellocontroller' for CRD: 'class com.example.teruq.Hello' for namespace(s): [all namespaces]
2021-10-06 17:45:54,847 INFO  [io.qua.ope.run.AppEventListener] (main) Quarkus Java Operator SDK extension 2.0.0.Beta5 (commit: f520a8c on branch: f520a8c49b2b91405a12b3e5b465780c8962b918)
2021-10-06 17:45:54,858 INFO  [io.jav.ope.Operator] (main) Operator SDK 1.9.6 (commit: 8a9c614) built on Thu Sep 02 19:07:29 GMT 2021 starting...
2021-10-06 17:45:54,858 INFO  [io.jav.ope.Operator] (main) Client version: 5.7.2
2021-10-06 17:45:55,310 INFO  [io.jav.ope.Operator] (main) Server version: 1.19
2021-10-06 17:45:55,388 INFO  [io.jav.ope.Operator] (main) Operator SDK 1.9.6 is shutting down...
2021-10-06 17:45:55,388 INFO  [io.qua.it.ope.cli.run.OpenShiftClientProducer] (main) Closing OpenShift client
2021-10-06 17:45:55,409 ERROR [io.qua.run.Application] (main) Failed to start application (with profile prod): io.fabric8.kubernetes.client.KubernetesClientException: Failure executing: GET at: https://172.21.0.1/apis/apiextensions.k8s.io/v1/customresourcedefinitions/hellos.example.teruq.example.com. Message: Forbidden!Configured service account doesn't have access. Service account may have been revoked. customresourcedefinitions.apiextensions.k8s.io "hellos.example.teruq.example.com" is forbidden: User "system:serviceaccount:qiita-operators:hello-java-operator" cannot get resource "customresourcedefinitions" in API group "apiextensions.k8s.io" at the cluster scope.
        at io.fabric8.kubernetes.client.dsl.base.OperationSupport.requestFailure(OperationSupport.java:686)
        at io.fabric8.kubernetes.client.dsl.base.OperationSupport.assertResponseCode(OperationSupport.java:623)
        at io.fabric8.kubernetes.client.dsl.base.OperationSupport.handleResponse(OperationSupport.java:565)
        at io.fabric8.kubernetes.client.dsl.base.OperationSupport.handleResponse(OperationSupport.java:526)
        at io.fabric8.kubernetes.client.dsl.base.OperationSupport.handleGet(OperationSupport.java:493)
```

Operatorのサービスアカウントに権限がないようです。サービスアカウント名を確認します。

```
$ oc get pod hello-java-operator-54f4bd7b49-dwrn8 -o yaml | grep serviceAccountName
        f:serviceAccountName: {}
  serviceAccountName: hello-java-operator
```

確かに何も権限が付与されていません。

```
$ oc get clusterrolebinding -o wide | grep hello-
```

今回はずるをして、cluster-adminをサービスアカウントに割り当ててしまいます。

> 実運用では推奨されません。きちんと必要最低限の権限だけを付与するようにしてください。

```
$ oc create clusterrolebinding hello-java-operator-admin --clusterrole=cluster-admin --serviceaccount=qiita-operators:hello-java-operator
clusterrolebinding.rbac.authorization.k8s.io/hello-java-operator-admin created

$ oc get clusterrolebinding -o wide | grep hello-
hello-java-operator-admin                                                   ClusterRole/cluster-admin                                                               79s                                                                                                      qiita-operators/hello-java-operator
```

OperatorのPodを再作成します。

```
$ oc scale deploy hello-java-operator --replicas 0
$ oc scale deploy hello-java-operator --replicas 1
```

今度は無事起動しました。

```
$ oc get pods
NAME                                   READY   STATUS    RESTARTS   AGE
hello-java-operator-54f4bd7b49-bhcg2   1/1     Running   0          16s
```

エラーも出ていません。

```
$ oc logs hello-java-operator-54f4bd7b49-bhcg2
__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2021-10-06 18:02:15,695 INFO  [io.jav.ope.Operator] (main) Registered Controller: 'hellocontroller' for CRD: 'class com.example.teruq.Hello' for namespace(s): [all namespaces]
2021-10-06 18:02:15,698 INFO  [io.qua.ope.run.AppEventListener] (main) Quarkus Java Operator SDK extension 2.0.0.Beta5 (commit: f520a8c on branch: f520a8c49b2b91405a12b3e5b465780c8962b918)
2021-10-06 18:02:15,732 INFO  [io.jav.ope.Operator] (main) Operator SDK 1.9.6 (commit: 8a9c614) built on Thu Sep 02 19:07:29 GMT 2021 starting...
2021-10-06 18:02:15,733 INFO  [io.jav.ope.Operator] (main) Client version: 5.7.2
2021-10-06 18:02:16,231 INFO  [io.jav.ope.Operator] (main) Server version: 4.6.44
2021-10-06 18:02:16,560 INFO  [io.quarkus] (main) hello-java-operator 0.0.1-SNAPSHOT on JVM (powered by Quarkus 2.2.1.Final) started in 2.288s. Listening on: http://0.0.0.0:8080
2021-10-06 18:02:16,560 INFO  [io.quarkus] (main) Profile prod activated.
2021-10-06 18:02:16,561 INFO  [io.quarkus] (main) Installed features: [cdi, kubernetes, kubernetes-client, openshift-client, operator-sdk, smallrye-context-propagation, smallrye-health]
```

次の作業のためデプロイしたリソースを削除します。

```
$ make uninstall undeploy
```

## helloコンテナのデプロイ処理の実装

今のOperatorは何もしてくれないので、レプリカ数を指定してアプリを起動する処理を入れてみます。今回もibmcom/helloに登場してもらいます。

ここから先はほとんどネットで情報が見つけられなかったので、手探りで進めています。間違っていることや効率の悪いことをしているかもしれません、お気づきの際はご指摘ください。

### プロパティの追加

初期状態のCRDを確認します。

```target/kubernetes/hellos.example.teruq.example.com-v1.yml
# Generated by Fabric8 CRDGenerator, manual edits might get overwritten!
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: hellos.example.teruq.example.com
spec:
  group: example.teruq.example.com
  names:
    kind: Hello
    plural: hellos
    singular: hello
  scope: Namespaced
  versions:
  - name: v1alpha1
    schema:
      openAPIV3Schema:
        properties:
          spec:
            type: object
          status:
            type: object
        type: object
    served: true
    storage: true
    subresources:
      status: {}
```

何もプロパティがありません。HelloSpec.javaを修正し、メンバー``replicas``とアクセサメソッドを追加します。

```src/main/java/com/example/teruq/HelloSpec.java
public class HelloSpec {

    private int replicas;

    public void setReplicas(int replicas) {
        this.replicas = replicas;
    }

    public int getReplicas() {
        return replicas;
    }

}
```

もう1つ、HelloStatus.javaを修正し、Stateを含むメンバとアクセサメソッドを追加します。

```src/main/java/com/example/teruq/HelloStatus.java
public class HelloStatus {

    public enum State {
        CREATED,
        ALREADY_PRESENT,
        PROCESSING,
        ERROR,
        UNKNOWN
    }

    private State state = State.UNKNOWN;
    private boolean error;
    private String message = "";

    public void setState(State state) {
        this.state = state;
    }

    public State getState() {
        return state;
    }

    public void setError(boolean error) {
        this.error = error;
    }

    public boolean isError() {
        return error;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public String getMessage() {
        return message;
    }

}
```


ビルドのみ行います。

```
$ make docker-build IMG=docker.io/teruq/hello-java-operator:0.0.1
```

CRDを確認すると、``replicas``プロパティとStateが追加されました。

```target/kubernetes/hellos.example.teruq.example.com-v1.yml
# Generated by Fabric8 CRDGenerator, manual edits might get overwritten!
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: hellos.example.teruq.example.com
spec:
  group: example.teruq.example.com
  names:
    kind: Hello
    plural: hellos
    singular: hello
  scope: Namespaced
  versions:
  - name: v1alpha1
    schema:
      openAPIV3Schema:
        properties:
          spec:
            properties:
              replicas:
                type: integer
            type: object
          status:
            properties:
              error:
                type: boolean
              state:
                enum:
                - UNKNOWN
                - ERROR
                - CREATED
                - ALREADY_PRESENT
                - PROCESSING
                type: string
              message:
                type: string
            type: object
        type: object
    served: true
    storage: true
    subresources:
      status: {}
```

### デプロイ処理の追加

いよいよ本体の処理を記述します。初期状態ではほぼ空ですので、頑張って実装します。お試しなのでエラー処理も何もありません。

```src/main/java/com/example/teruq/HelloController.java
public UpdateControl<Hello> createOrUpdateResource(Hello resource, Context<Hello> context) {
        System.out.println("createOrUpdateResource()");
        System.out.println(resource);

        // Pod識別用ラベル
        Map<String, String> appLabels = new HashMap<>();
        appLabels.put("app", "hello");

        // Deployment定義
        Deployment deployment = new Deployment("apps/v1", "Deployment", new ObjectMeta(), new DeploymentSpec(), new DeploymentStatus());
        
        deployment.getMetadata().setName(resource.getMetadata().getName() + "-" + resource.getKind().toLowerCase());
        deployment.getMetadata().setNamespace(resource.getMetadata().getNamespace());

        deployment.getSpec().setReplicas(resource.getSpec().getReplicas());
        deployment.getSpec().setSelector(new LabelSelector());
        deployment.getSpec().getSelector().setMatchLabels(appLabels);

        deployment.getSpec().setTemplate(new PodTemplateSpec());
        deployment.getSpec().getTemplate().setMetadata(new ObjectMeta());
        deployment.getSpec().getTemplate().getMetadata().setLabels(appLabels);

        deployment.getSpec().getTemplate().setSpec(new PodSpec());
        deployment.getSpec().getTemplate().getSpec().setContainers(new ArrayList<Container>());
        deployment.getSpec().getTemplate().getSpec().getContainers().add(new Container());
        deployment.getSpec().getTemplate().getSpec().getContainers().get(0).setName("hello");
        deployment.getSpec().getTemplate().getSpec().getContainers().get(0).setImage("docker.io/ibmcom/hello");

        // Service定義
        Service service = new Service("v1", "Service", new ObjectMeta(), new ServiceSpec(), new ServiceStatus());

        service.getMetadata().setName(resource.getMetadata().getName() + "-" + resource.getKind().toLowerCase());
        service.getMetadata().setNamespace(resource.getMetadata().getNamespace());

        service.getSpec().setPorts(new ArrayList<ServicePort>());
        service.getSpec().getPorts().add(new ServicePort());
        service.getSpec().getPorts().get(0).setName("8080-tcp");
        service.getSpec().getPorts().get(0).setPort(8080);
        service.getSpec().getPorts().get(0).setProtocol("TCP");
        service.getSpec().getPorts().get(0).setTargetPort(new IntOrString(8080));

        service.getSpec().setSelector(appLabels);
        service.getSpec().setType("ClusterIP");

        // Deployment,Service作成
        client.resource(deployment).createOrReplace();
        client.resource(service).createOrReplace();

        HelloStatus status = new HelloStatus();
        status.setState(State.CREATED);
        resource.setStatus(status);

        return UpdateControl.updateStatusSubResource(resource);
    }
```

少し解説します。Custom Resourceが作成されると、Controllerの``createOrUpdateResource()``メソッドが呼ばれます。その際、引数resourceにCustom Resourceの情報が格納されてきますので、そこから``replicas``等を取り出してDeploymentにセットしています。

### ビルド＆デプロイ

```
$ make docker-build docker-push
$ make install deploy
```

オペレーターが起動します。

```
$ oc get pods
NAME                                   READY   STATUS    RESTARTS   AGE
hello-java-operator-5748bfc4c5-4zmlf   1/1     Running   0          29m
```

## Custom Resourceの作成

他のタイプのOperator SDKはCustom Resourceのひな型を自動生成してくれましたが、Java Operatorはしてくれないようです。次のマニフェストファイルを作成します。

```hello.yaml
apiVersion: example.teruq.example.com/v1alpha1
kind: Hello
metadata:
  name: sample
spec:
  replicas: 2
```

クラスターにCustom Resourceを登録します。

```
$ oc create -f hello.yaml -n qiita
hello.example.teruq.example.com/sample created
```

無事、Podが起動しました。

```
$ oc get pods -n qiita | grep hello
sample-hello-7cfff5db9-7z8cn      1/1     Running   0          119s
sample-hello-7cfff5db9-lpgnd      1/1     Running   0          119s
```

Custom Resourceで``replicas``を2から3に変更します。

```
$ oc patch hello sample --type merge -p '{"spec": {"replicas": 3}}' -n qiita
hello.example.teruq.example.com/sample patched

$ oc get pods -n qiita | grep hello
sample-hello-7cfff5db9-7z8cn      1/1     Running   0          7m4s
sample-hello-7cfff5db9-lpgnd      1/1     Running   0          7m4s
sample-hello-7cfff5db9-pqxrh      1/1     Running   0          6s
```

これも無事動きました。

## クリーンアップ

Custom Resourceの削除をまだ実装していないので、手動で削除します。

```
$ make uninstall undeploy
$ oc delete hello sample -n qiita
$ oc delete deploy sample-hello -n qiita
$ oc delete svc sample-hello
```

# まとめと感想

* これまでのSDKとは違い、Operatorを実行するサービスアカウントのClusterRoleを付与してくれないので、自力で付与する必要があった
* ネットに情報が少ないので、トライアンドエラーしながら挙動を調査する必要があった
* 私はGoよりはJavaの方が得意なので、Javaが使えるのはありがたかった
* Bundleの作成機能がないので、SDKの力だけではOLMは利用できないと思われる
* Reconcileループのようなことはできなさそう、単にCRの作成・更新・削除をイベントドリブンで処理するだけ
* やっぱりAnsible Operatorは楽だった