Red Hat Cert-Manager Opertor を ROSA 上で使用してみる 


`Cert-Manager Operator` を使った証明書の更新方法は、[ドキュメント](https://docs.openshift.com/container-platform/4.15/security/cert_manager_operator/cert-manager-operator-install.html)に載っているのですが、`ccoctl` という `OpenShift` の独自ツールを使用して `IAM Role` や `IAM Policy` を作成するようになっています。

できるだけシンプルに導入したいので、そこは標準の `AWS CLI` を利用する事にして、それ以外は基本、ドキュメントに従って導入して行きます。

ここでは、GUI から `Cert-Manager Operator` をインストールしていますが、[CLIからも導入](https://docs.openshift.com/container-platform/4.15/security/cert_manager_operator/cert-manager-operator-install.html#cert-manager-install-cli_cert-manager-operator-install)できます。(ちょっと手数が多くなります）

# 今回の環境

今回の手順は以下の環境で作成しました。

・`Kubernetes` のディストリビューションは、OpenShift の AWS 上の Managed Service である `ROSA` のバージョン `4.14.27`を使用 (`ROSA` の最新アーキテクチャ版である `ROSA` `HCP(Hosted Control Plane)` を使用) 
・`Cert-Manger Operator` は、`Community` 版と`Red Hat`版があるが `Red Hat` 版を使用。
・証明書を発行する対象ドメインは、`ocp4.work`
・証明書を発行する対象ドメイン `ocp4.work`は、`Route53` を権威 DNSサーバーにする
・`ROSA` と`ocp4.work` の権威DNSである `Route53` は、同じ AWS Account 内に存在
・証明書の CA は `Let's Encrypt` とする。

:::note info
AWS の Public 向けの証明書を発行する ACM (AWS Certificate Manager)は、AWSのサービスに対してだけ証明書を発行する特殊なサービスなので、Cert-Manager と組み合わせて使用する事はできません。Cert-Manager は複数のCAをサポートしていますが、今回は Let's Encrypt を使用します。Cert-Manager がサポートする CAは、[ドキュメントのこの辺り](https://cert-manager.io/docs/configuration/)から書かれています。
:::

# 環境の準備

以下の環境を使用します

・Amazon Linux 2
・oc コマンド が実行できる環境  (インストールするための [こちらにスクリプトを作りました](https://gist.github.com/yuhkih/d9760d16084aa79812428e64ce0ec5d9) ) 
・AWS CLIがセットアップ済み (Amazon Linux 2 に aws コマンドは入って居るので aws configure で構成) 
・jq コマンドがインストール済み ( `sudo yum install -y jq`) 


# 必要な情報の収集

今回は、`Cert-Manager` が、`Let's Encrypt` の Challenge に対して自動的に `Route53` のレコードを書き替えて対応するようにします。そのために、AWS の `IAM Role`/`IAM Policy` を作成したり、対象ドメイン(この例では `ocp4.work`)をホストしている `Route53`の情報が必要になります。

AWS CLI の構成をしておきます。

```
$ aws configure
AWS Access Key ID [None]: ASBDERFFI4B4VVUXXYYZZ
AWS Secret Access Key [None]: xXv7/X4FsXlLaTEteaabbccddjwV+6M7SHm02
Default region name [None]: ap-northeast-1
Default output format [None]: 
$ 
```


作業ディレクトリの作成
```
export WORK_DIR=~/work
```

```
mkdir -pv $WORK_DIR
```

`rosa list cluster` で表示される作成済みの `ROSA` クラスターの名前

```
CLUSTER_NAME="rosa-cluster"   
```

`rh-oidc.s3.us-east-1.amazonaws.com/20s0h5421tff8a6tqsoh2hk78m1ocp0o` のような値
```
export OIDC_PROVIDER=$(oc get authentication.config.openshift.io cluster -o json \
| jq -r .spec.serviceAccountIssuer| sed -e "s/^https:\/\///")   
```

AWS Account ID の値。 `201068234712` のような値
```
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)  
```

Let's Encrypt に対して証明書を申請する際に使う有効なメールアドレス
```
export LETSENCRYPT_EMAIL=test@test.com  
```

この例での証明書発行をリクエストするドメイン名は `ocp4.work` です。自分で取得するドメイン名に読み替えて下さい。
このドメインを `Route53` でホストします。



```
export HOSTED_ZONE_REGION=ap-northeast-1     
```
```
export DOMAIN=ocp4.work
```

HOSTED_ZONE_ID は、証明書発行ドメインをホストしている Route53 の Zone IDです。`Z01429121KPW1GKICNEO2` のような値です。

```
export HOSTED_ZONE_ID=$(aws route53 list-hosted-zones --query "HostedZones[].[Name,Id,Config.PrivateZone,ResourceRecordSetCount]" --output text | grep $DOMAIN | awk '{print $2}' | sed -e "s/.*\///")
```

HOSTED_ZONE_ID は、上記の例のように AWS CLIで取得するか、Route53 の画面に表示されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99425/28cdffdd-c8f4-cf3e-8f86-2368f954c92b.png)

# AWS Role と Policy の作成

:::note info
Cert-Manager Operator は、証明書の発行や更新のために AWS の Route53 にアクセスして編集などの作業するので、AWS の STS(Secure Token Service) を使用して必要な権限を一時的に取得します。その時にどの対象からのリクエストを信頼して、どのような許可を与えるかを AWS の IAM Policy や  IAM Role を使って事前に定義しておく必要があります。
:::

route53 用 許可ポリシーの json ファイルの作成します。

```
cat <<EOF > $WORK_DIR/cert-manager-r53-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "route53:GetChange",
      "Resource": "arn:aws:route53:::change/*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "route53:ChangeResourceRecordSets",
        "route53:ListResourceRecordSets"
      ],
      "Resource": "arn:aws:route53:::hostedzone/*"
    },
    {
      "Effect": "Allow",
      "Action": "route53:ListHostedZonesByName",
      "Resource": "*"
    }
  ]
}
EOF

```

:::note info
上記の Policy を良く見ると、"Action" として Route53 への
幾つかの権限を与えている事がわかります。
:::

作成したjson ファイルを使用して、AWS上に許可ポリシーを作成します。

```
POLICY=$(aws iam create-policy --policy-name "${CLUSTER_NAME}-cert-manager-r53-policy" \
   --policy-document file://$WORK_DIR/cert-manager-r53-policy.json \
   --query 'Policy.Arn' --output text) || echo $POLICY
```

変数にきちんと値が返っているか確認します。

```
$ echo $POLICY
arn:aws:iam::452752386616:policy/rosa-cluster-cert-manager-r53-policy
$
```

次に、信頼ポリシー用のjson ファイルを作成します。

この `Policy` は、`cert-manager` という `project` 名の `cert-manager` という `Service Account` を条件(`Condition`) に設定している事に注意して下さい。

導入する `project` を変更した場合は、内容を変更する必要があります。

```
cat <<EOF > $WORK_DIR/TrustPolicy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::${AWS_ACCOUNT_ID}:oidc-provider/${OIDC_PROVIDER}"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "${OIDC_PROVIDER}:sub": [
            "system:serviceaccount:cert-manager:cert-manager"
          ]
        }
      }
    }
  ]
}
EOF
```

:::note info
上記の `Policy` を良く見ると `AWS_ACCOUNT_ID` が `Principal` として指定されていたり、さらに条件 (`Condition`) として、`OIDC_RPOVIDER` の値や、`Service Account` 名や `project` 名 等も指定されている事がわかります。これらの条件に一致しない相手でないと信頼されない(=Tokenが発行されない)事になります。
:::


作成した 信頼ポリシー(Trust Policy) のファイルを指定して、AWS上に`IAM Role`を作成します。

```
ROLE=$(aws iam create-role \
  --role-name "${CLUSTER_NAME}-cert-manager-operator" \
  --assume-role-policy-document file://$WORK_DIR/TrustPolicy.json \
  --query "Role.Arn" --output text)
```

上手くできているか変数の値を確認します。

```
$ echo $ROLE
arn:aws:iam::081920418460:role/myhcpcluster-cert-manager-operator
$ 
```

作成したIAM Role に、一番はじめに作成した 許可ポリシーも Attach します。


```
aws iam attach-role-policy \
    --role-name "${CLUSTER_NAME}-cert-manager-operator" \
    --policy-arn $POLICY
```

これで、IAM Role (`${CLUSTER_NAME}-cert-manager-operator`) に、許可ポリシーと信頼ポリシーがアタッチされました。

# Cert-Manager Operator のインストール

Cert-Manager は CLI からもインストールする事ができますが、ここでは OpenShift Console の GUI 上の OperatorHub からインストールします。

Cert-Manager は、Community 版と、Red Hat 版があるので、Red Hat 版を選択します。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99425/9b82256f-40c9-e5ce-553e-8d50a52476b9.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99425/e39d5fd5-ae88-00c4-dc55-f9c6e3af46ab.png)

`role ARN` では、前のステップで作成した AWS の `IAM Role` の ARN `arn:aws:iam::081920418460:role/myhcpcluster-cert-manager-operator` を入力します。(ここに IAM Role の ARN を入力すると Service Account に annotation が付くのかと思ったのですが、そういう訳ではありませんでした。用途がちょっと謎です）

また、ここでは `Update approval` は、`Automatic` を選択し、承認なしで自動でアップデートされるようにしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99425/17ca5dc9-ae96-12ca-43f1-a54fea216cd9.png)

以下の表示が出ればインストールは完了しているはずです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99425/1ab33bb4-f12a-49bc-59be-ef47d23566d3.png)


CLI に戻り、以下で導入を確認します。
```
 oc get pods -n cert-manager
```

こんな風に3つのPodが動いてればOKです。
```
$ oc get pods -n cert-manager
NAME                                       READY   STATUS    RESTARTS   AGE
cert-manager-85dfdd4f8b-zgvc8              1/1     Running   0          65m
cert-manager-cainjector-56b774cdf8-bt4tm   1/1     Running   0          66m
cert-manager-webhook-8f694d645-tfcsn       1/1     Running   0          66m
$ 
```

以下のような service account が作られているはずです。

```
$ oc get sa -n cert-manager | grep cert
cert-manager              1         3h38m
cert-manager-cainjector   1         3h38m
cert-manager-webhook      1         3h38m
$ 
```

cert-manager という Service Account に、前のステップで作成した IAM Role 名を入れた annotation を付けます。

```
$ echo $ROLE
arn:aws:iam::081920418460:role/myhcpcluster-cert-manager-operator
$
```

```
oc annotate -n cert-manager serviceaccount cert-manager  eks.amazonaws.com/role-arn=$ROLE
```
``` 
$ oc describe -n cert-manager sa cert-manager  | grep Annotation
Annotations:         eks.amazonaws.com/role-arn: arn:aws:iam::081920418460:role/myhcpcluster-cert-manager-operator
$ 
```

:::note info
この annotation を付ける事で、この Service Account (cert-manager) は、作業に必要な AWS の IAMRole を STSを使って手に入れる(AssumeRole)事ができるようになります。
:::

`Service Account` に `annotation` を付けたので、新しい権限で Pod を再起動します。以下のコマンドで delete すると再起動します。

```
oc delete pods -l app.kubernetes.io/name=cert-manager -n cert-manager
```

# CertMnager リソースの編集

証明書を保管するための `project` を作成します。

```
CERT_PROJECT=letencrypt
```
```
oc new-project $CERT_PROJECT
```

この手順は、[ドキュメントのこちら](https://docs.openshift.com/container-platform/4.15/security/cert_manager_operator/cert-manager-operator-issuer-acme.html#cert-manager-acme-dns01-ambient-aws_cert-manager-operator-issuer-acme) を参考にしています。

`CertManager` リソースを編集して `--issuer-ambient-credentials` 引数をつけます。

```
oc patch certmanager/cluster --type=merge -p='{"spec":{"controllerConfig":{"overrideArgs":["--issuer-ambient-credentials"]}}}'
```

# ClusterIssure リソースの作成

`ClusterIssure` リソースを作成します。

```
envsubst  <<EOF | oc apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencryptissuer
  namespace: $CERT_PROJECT
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: $LETSENCRYPT_EMAIL
    # This key doesn't exist, cert-manager creates it
    privateKeySecretRef:
      name: prod-letsencrypt-issuer-account-key
    solvers:
    - dns01:
        route53:
         hostedZoneID: $HOSTED_ZONE_ID
         region: $HOSTED_ZONE_REGION
EOF
```

:::note warn
[公式ドキュメント](https://docs.openshift.com/container-platform/4.15/security/cert_manager_operator/cert-manager-operator-issuer-acme.html#cert-manager-acme-dns01-explicit-gcp_cert-manager-operator-issuer-acme)の`spec.acme.server` field の値は、` https://acme-staging-v02.api.letsencrypt.org/directory` となっており `staging` 用の `Let's Encrypt` 証明書を発行するようになっています。`staging` 用証明書の CA が普通の環境の Trusted Store には登録されてないため、`curl` 等では `-k` オプションを使ってアプリケーションのテストする必要があります。

一方、ここで作成する `非staging` 環境用 (本場用:`spec.acme.server` field の値が `https://acme-v02.api.letsencrypt.org/directory`) の証明書は、一週間に5回以上、同じドメインの証明書をリクエストをすると `Rate Limit` にひっかかり新規の発行ができなくなります。

そのため、実験で繰り返す事が予想される場合は、本番用の証明書を発行する`ClusterIssure`リソースの他に、`staging` 用の証明書を作成する`ClusterIssure`リソース (`spec.acme.server` の値は、` https://acme-staging-v02.api.letsencrypt.org/directory`）も作成しておく事をおすすめします。
:::

Read が True になるまで待ちます。

```
$ oc get  clusterissuer letsencryptissuer
NAME                READY   AGE
letsencryptissuer           90s
$ 
$ oc get  clusterissuer letsencryptissuer
NAME                READY   AGE
letsencryptissuer   True    5m34s
$ 
```

# Certificate リソースの作成

`Certficate` リソースを作成します。`Certificate` リソースを作成する事で、証明書の発行をリクエストします。

前段で作成した `ClusterIssure` 名 `letsencryptissuer` は、証明書のリクエスト先 CA (Certificate Auhtority) としてこのリソースの中に指定されています。

```
envsubst  <<EOF | oc apply -f -
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: customdomain-cert
  namespace: $CERT_PROJECT
spec:
  isCA: false
  secretName: custom-domain-certificate-tls
  issuerRef:
     name: letsencryptissuer
     kind: ClusterIssuer
  commonName: "*.$DOMAIN"
  dnsNames:
  - "*.$DOMAIN"
EOF
```

状態確認をします。`True` になるまで待ちます。

```
$  oc get -n $CERT_PROJECT certificate customdomain-cert 
NAME                READY   SECRET                          AGE
customdomain-cert   False   custom-domain-certificate-tls   21s
$ 
$  oc get -n $CERT_PROJECT certificate customdomain-cert 
NAME                READY   SECRET                          AGE
customdomain-cert   True    custom-domain-certificate-tls   21s
$ 
```

ここで作成された証明書は上記の表示にあるように `custom-domain-certificate-tls` という `Secret` になります。

**debugの方法**

数分で作成される場合もありますが、場合によっては `Certificate` の発行が待たされている場合があります。
状況は `Pod` のログを見る事で確認できます。

```
$ oc get -n cert-manager pods | grep cert-manager
NAME                                       READY   STATUS    RESTARTS   AGE
cert-manager-5cb58c6b55-r4hcj              1/1     Running   0          56m
cert-manager-cainjector-7889dc796c-mjqqm   1/1     Running   0          60m
cert-manager-webhook-85f99cb964-2qk6n      1/1     Running   0          60m
$ 
```

`cert-manager-<ランダム>` な `Pod` のログを確認します。ここでは、一番上の `Pod` のログを確認します。

```
$ oc logs -f cert-manager-5cb58c6b55-r4hcj 
<省略>
I0102 03:10:10.041269       1 conditions.go:192] Found status change for Certificate "customdomain-cert" condition "Issuing": "True" -> "False"; setting lastTransitionTime to 2023-01-02 03:10:10.041262527 +0000 UTC m=+54.359300594
I0102 03:10:10.055415       1 trigger_controller.go:179] cert-manager/certificates-trigger "msg"="Backing off from issuance due to previously failed issuance(s). Issuance will next be attempted at 2023-01-02 04:10:10.000000666 +0000 UTC m=+3654.318038737" "key"="cert-manager/customdomain-cert
I0102 03:10:10.088248       1 trigger_controller.go:179] cert-manager/certificates-trigger "msg"="Backing off from issuance due to previously failed issuance(s). Issuance will next be attempted at 2023-01-02 04:10:10.000000521 +0000 UTC m=+3654.318038589" "key"="cert-manager/customdomain-cert"
```

# [実践編] IngressController の証明書を Cert-Manager Operator が管理する証明書に置きかえる


ここでは実践編として、ROSA に追加で作成した `Ingresscontroller` (name: additional) が使用している証明書の Secret を、Cert-Manager Operator が使用している Secert と置き換えてみます。[こちらの記事の環境](https://qiita.com/Yuhkih/items/67c61418db4dac463ace)を前提にしています。

**前提条件**

以下のように既に Secondary の `IngressController` である `additional` という `IngressController` がデプロイされています。

```
$ oc get ingresscontroller -n openshift-ingress-operator
NAME         AGE
additional   46m
default      8h
$ 
```

この `IngressController` の使用している `defaultCertifidcate` は、`my-tls` という名前になっています。これを `Cert-Manager` が作成したものに置き換えてみます。

```
$ oc get ingresscontroller additional -n openshift-ingress-operator -o yaml | grep defaultCertificate: -A1
  defaultCertificate:
    name: my-tls
$ 
```



**現在の証明遺書の確認**

まず、現在の `IngressController` を使っているアプリの現在の証明書の期限を確認しておきます。

```
$ echo | openssl s_client -connect hello.ocp4.work:443 2>&1 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | openssl x509 -text 2>/dev/null | grep -e "Public-Key" -e "Not"
            Not Before: Jun 10 10:59:15 2024 GMT
            Not After : Sep  8 10:59:14 2024 GMT
                Public-Key: (2048 bit)
$ 

```

まず、`openshift-ingress` `project` 内に 新しい証明書の `Secret` を作成します。

:::note warn
証明書の `Secret` を生成する `project` は、`IngressController` が Router pod を生成する `openshift-ingress` `project` である必要があります。

`Certificate` リソースによってリクエストされた 証明書(`Secret`) は、`Certificate`リソースと同じ`project`に作成されます。
:::

```
envsubst  <<EOF | oc apply -f -
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: my-tls2
  namespace: openshift-ingress
spec:
  isCA: false
  secretName: my-tls2
  issuerRef:
     name: letsencryptissuer
     kind: ClusterIssuer
  commonName: "*.$DOMAIN"
  dnsNames:
  - "*.$DOMAIN"
EOF
```
作成を確認します。

```
$ oc get -n openshift-ingress certificate my-tls2 
NAME      READY   SECRET    AGE
my-tls2   True    my-tls2   14s
$ 
```

Secret の作成を確認します。
```
$ oc get secret my-tls2 -n openshift-ingress
NAME      TYPE                DATA   AGE
my-tls2   kubernetes.io/tls   2      36s
$ 
```

次にこの Secret を IngressController に指定します。

:::note warn
証明書の `Secret` を作成したのは、`openshift-ingress` `project` ですが、`Secret` 名を指定する `IngressController` リソースは `openshift-ingress-operator` に存在します。
:::

```
oc edit ingresscontroller additional -n openshift-ingress-operator
```

`spec.defaultCertificate.name` の部分に、先ほど作成した `Secret` の名前を指定します。`project` 名は指定する field は無いので指定できません。Router Pod が作成される `openshif-ingress` `project` 内の `Secret` である必要があります。

```
apiVersion: operator.openshift.io/v1
kind: IngressController
metadata:
  creationTimestamp: "2024-06-07T00:37:53Z"
  finalizers:
  - ingresscontroller.operator.openshift.io/finalizer-ingresscontroller
  generation: 5
  name: acme
  namespace: openshift-ingress-operator
  resourceVersion: "363927"
  uid: 3851a50e-66b8-4218-9266-9811aae54055
spec:
  clientTLS:
    clientCA:
      name: ""
    clientCertificatePolicy: ""
  defaultCertificate:
    name: my-tls2 # ここを作成した Secret 名 (my-tls2)に
  domain: acme.rosa-1w8dvp.18to.p1.openshiftapps.com
  endpointPublishingStrategy:
    loadBalancer:
      dnsManagementPolicy: Manage
      ...
```


**更新の確認**

証明書が置き換えられているかどうかを確認します。


`openshift-ingress` `project` の Pod が証明書の更新のために再起動するはずなので、少し待ちます。

```
$ oc get pods -n openshift-ingress
NAME                                 READY   STATUS        RESTARTS   AGE
router-additional-6667c9f8d6-5gr5l   1/1     Running       1          104m
router-additional-6667c9f8d6-bmtrl   1/1     Terminating   1          104m
router-additional-6b6b6464d6-f4m9k   1/1     Running       0          15s
router-additional-6b6b6464d6-n8mqq   1/1     Running       0          13s
router-default-578b69d8f4-k99j7      1/1     Running       1          9h
router-default-578b69d8f4-tq9m8      1/1     Running       1          9h
$ 
```

再起動が完了したら、証明書の日付を確認してみます。

```
$ echo | openssl s_client -connect hello.$DOMAIN:443 2>&1 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | openssl x509 -text 2>/dev/null | grep -e "Public-Key" -e "Not"   
            Not Before: Jun 10 12:56:20 2024 GMT
            Not After : Sep  8 12:56:19 2024 GMT
                Public-Key: (2048 bit)
$ 
```

有効期限の日付は同じですが、時間が以前と変わっているので無事置き換わった事がわかります。