controller-runtime で Server Side Apply

## TL;DR

-   `controller-runtime` を利用して server-side apply してみたよ。
-   Unit Test で fake client を使ってたらテストできないよ！(envtestを使おう)
-   client-go で SSA したかったら[別の記事](https://qiita.com/superbrothers/items/aeba9406691388b6a19e)を読んでね！

## 環境

```go.mod
module github.com/yuanying/test-controller-runtime

go 1.13

require (
        k8s.io/api v0.18.6
        k8s.io/apimachinery v0.18.6
        k8s.io/client-go v0.18.6
        sigs.k8s.io/controller-runtime v0.6.2
)
```

## Let's SSA

例えば Namespace のラベルをいい感じに controller が付与したラベルとkubectlでユーザが付与したラベルをマージしたかったとする。

`controller-runtime` を使うと以下ようなコードで server-side apply を実現できる。

```go
package main

import (
        "context"
        "os"

        corev1 "k8s.io/api/core/v1"
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
        _ "k8s.io/client-go/plugin/pkg/client/auth"
        ctrl "sigs.k8s.io/controller-runtime"
        "sigs.k8s.io/controller-runtime/pkg/client"
        "sigs.k8s.io/controller-runtime/pkg/client/config"
        "sigs.k8s.io/controller-runtime/pkg/log/zap"
)

func main() {
        ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
        log := ctrl.Log.WithName("test")
        cfg := config.GetConfigOrDie()
        ctx := context.Background()

        cli, err := client.New(cfg, client.Options{})
        if err != nil {
                log.Error(err, "Can't load client")
                os.Exit(1)
        }
        ns := &corev1.Namespace{
                TypeMeta: metav1.TypeMeta{
                        Kind:       "Namespace",
                        APIVersion: corev1.SchemeGroupVersion.String(),
                },
        }
        ns.Name = "new-test"
        ns.Labels = map[string]string{
                "by-ssa": "v1",
        }
        // client.ForceOwnerShip を指定することで、conflict が発生した場合強制的にownerを変更して上書きする。
        // 指定しない場合は conflict でエラーが発生する。
        opts := []client.PatchOption{client.ForceOwnership, client.FieldOwner("test")}
        if err := cli.Patch(ctx, ns, client.Apply, opts...); err != nil {
                log.Error(err, "Couldn't patch")
                os.Exit(1)
        }
}
```

実行してできた `Namespace` を確認してみる。

```console
test-controller-runtime on  master [?] at ☸️  fraction (minecraft)
✦ ➜ go run main.go --kubeconfig=~/.kube/config

test-controller-runtime on  master [?] at ☸️  fraction (minecraft)  took 3s
✦ ➜ k get ns new-test -o yaml
apiVersion: v1
kind: Namespace
metadata:
  creationTimestamp: "2020-09-17T23:14:52Z"
  labels:
    by-ssa: "v1"
  managedFields:
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:metadata:
        f:labels:
          f:by-ssa: {}
    manager: test
    operation: Apply
    time: "2020-09-17T23:16:35Z"
  name: new-test
  resourceVersion: "254973205"
  selfLink: /api/v1/namespaces/new-test
  uid: 37a946e4-e345-4823-b784-6a23e67d5c38
spec:
  finalizers:
  - kubernetes
status:
  phase: Active
```

`by-ssa` というラベルがついた `Namespace` ができてることが確認できる。

それでは、手動で `manual` というラベルをつけて、確認してみる。

```console
test-controller-runtime on  master [?] at ☸️  fraction (minecraft)
➜ cat <<EOF | k apply -f - --server-side
➜ apiVersion: v1
kind: Namespace
metadata:
  labels:
    manual: v1
  name: new-test
➜ EOF
namespace/new-test serverside-applied

test-controller-runtime on  master [?] at ☸️  fraction (minecraft)
➜ k get ns new-test -o yaml
apiVersion: v1
kind: Namespace
metadata:
  creationTimestamp: "2020-09-17T23:29:50Z"
  labels:
    by-ssa: v1
    manual: v1
  managedFields:
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:metadata:
        f:labels:
          f:by-ssa: {}
    manager: test
    operation: Apply
    time: "2020-09-17T23:29:50Z"
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:metadata:
        f:labels:
          f:manual: {}
    manager: kubectl
    operation: Apply
    time: "2020-09-17T23:31:13Z"
  name: new-test
  resourceVersion: "254979686"
  selfLink: /api/v1/namespaces/new-test
  uid: d721a8cf-3dfa-4c96-9466-897157b335dc
spec:
  finalizers:
  - kubernetes
status:
  phase: Active
```

field owner (manager) が kubectl になったラベル `manual` が追加されているのがわかる。それではコードを変更して `by-ssa2` というラベルをコードから追加してみよう。

```diff
test-controller-runtime on  master [!] at ☸️  fraction (minecraft)  took 16s
➜ git diff
diff --git a/main.go b/main.go
index 6ec4745..3bc494f 100644
--- a/main.go
+++ b/main.go
@@ -32,7 +32,8 @@ func main() {
        }
        ns.Name = "new-test"
        ns.Labels = map[string]string{
-               "by-ssa": "v1",
+               "by-ssa":  "v2",
+               "by-ssa2": "v1",
        }
        opts := []client.PatchOption{client.ForceOwnership, client.FieldOwner("test")}
        // opts := []client.PatchOption{client.FieldOwner("test")}
```

ついでに `by-ssa` ラベルも "v2" という値に変更してみた。

```console
test-controller-runtime on  master [!?] at ☸️  fraction (minecraft)  took 15s
➜ go run main.go --kubeconfig=~/.kube/config

test-controller-runtime on  master [!?] at ☸️  fraction (minecraft)  took 3s
➜ k get ns new-test -o yaml
apiVersion: v1
kind: Namespace
metadata:
  creationTimestamp: "2020-09-17T23:29:50Z"
  labels:
    by-ssa: v2
    by-ssa2: v1
    manual: v1
  managedFields:
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:metadata:
        f:labels:
          f:manual: {}
    manager: kubectl
    operation: Apply
    time: "2020-09-17T23:31:13Z"
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:metadata:
        f:labels:
          f:by-ssa: {}
          f:by-ssa2: {}
    manager: test
    operation: Apply
    time: "2020-09-17T23:32:43Z"
  name: new-test
  resourceVersion: "254980338"
  selfLink: /api/v1/namespaces/new-test
  uid: d721a8cf-3dfa-4c96-9466-897157b335dc
spec:
  finalizers:
  - kubernetes
status:
  phase: Active
```

`by-ssa` ラベルの値が変更され、`by-ssa2` ラベルが追加された。また、`manual` ラベルが保持されていることがわかる。

もちろん、ラベルを削除することもできる。以下のようにコード変更して`by-ssa2`ラベルのみ削除する。

```diff
test-controller-runtime on  master [!?] at ☸️  fraction (minecraft)  took 5s
➜ git diff
diff --git a/main.go b/main.go
index 3bc494f..ba46ec8 100644
--- a/main.go
+++ b/main.go
@@ -32,8 +32,7 @@ func main() {
        }
        ns.Name = "new-test"
        ns.Labels = map[string]string{
-               "by-ssa":  "v2",
-               "by-ssa2": "v1",
+               "by-ssa": "v2",
        }
        opts := []client.PatchOption{client.ForceOwnership, client.FieldOwner("test")}
        // opts := []client.PatchOption{client.FieldOwner("test")}
```

実行して確認すると、`by-ssa2`ラベルのみが削除されていることが確認できる。

```console
test-controller-runtime on  master [!?] at ☸️  fraction (minecraft)
➜ go run main.go --kubeconfig=~/.kube/config

test-controller-runtime on  master [!?] at ☸️  fraction (minecraft)  took 3s
➜ k get ns new-test -o yaml
apiVersion: v1
kind: Namespace
metadata:
  creationTimestamp: "2020-09-17T23:29:50Z"
  labels:
    by-ssa: v2
    manual: v1
  managedFields:
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:metadata:
        f:labels:
          f:manual: {}
    manager: kubectl
    operation: Apply
    time: "2020-09-17T23:31:13Z"
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:metadata:
        f:labels:
          f:by-ssa: {}
    manager: test
    operation: Apply
    time: "2020-09-17T23:35:41Z"
  name: new-test
  resourceVersion: "254981630"
  selfLink: /api/v1/namespaces/new-test
  uid: d721a8cf-3dfa-4c96-9466-897157b335dc
spec:
  finalizers:
  - kubernetes
status:
  phase: Active
```

コード側でラベルを付与している際に、動的にラベルの付与、削除を行おうとすると、どのラベルがコード側から付与して、どのラベルがユーザが付与したのか判断つかず、**ラベルの削除** をコード側からできないという問題が、これで簡単に解決できる。

ただ、問題は、、利用しているプロジェクトにもよるが、もし、単体テストに [fakeclient](https://github.com/kubernetes-sigs/controller-runtime/tree/v0.6.3/pkg/client/fake) を利用していた場合、fakeclient が ApplyPatchType に対応していないためテストが書けない。(実戦投入していません。)