# Kubernetes — сертифікат у TLS-секреті оновлюється сам

> CronJob раз на добу забирає свіжий сертифікат і оновлює TLS-секрет, з яким працює ingress; ключ лишається в тому самому секреті.

URL: https://zrazu.com/docs/ssl-auto-kubernetes/

Підходить, якщо сайт працює в Kubernetes за ingress (ingress-nginx, Traefik, HAProxy Ingress тощо) і сертифікат лежить у секреті типу `kubernetes.io/tls`. Ingress-контролери стежать за секретами самі: оновили секрет — за хвилину-дві сайт віддає новий сертифікат.

Як це працює: CronJob раз на добу забирає сертифікат за посиланням, порівнює з тим, що в секреті, перевіряє, що він підходить до ключа з того самого секрету, і замінює лише `tls.crt`. Ключ нікуди не копіюється. Права задачі — читати й змінювати один секрет в одному namespace.

## Що знадобиться

- Посилання на сертифікат із кабінету: `https://api.zrazu.com/ssl-pull/zsp_ВАШ_ТОКЕН`.
- `kubectl` з доступом до namespace сайту.
- Вихід із кластера в інтернет (до `api.zrazu.com` і до дзеркал Alpine Linux — образ ставить `curl`, `openssl` і `jq` при кожному запуску).

## Крок 1. TLS-секрет (якщо його ще немає)

Завантажте `https://api.zrazu.com/ssl-pull/zsp_ВАШ_ТОКЕН/fullchain.pem` у файл `fullchain.pem` і створіть секрет зі своїм ключем:

```sh
curl -fsS -o fullchain.pem "https://api.zrazu.com/ssl-pull/zsp_ВАШ_ТОКЕН/fullchain.pem"
kubectl -n НАЗВА_NAMESPACE create secret tls zrazu-tls --cert=fullchain.pem --key=key.pem
```

В Ingress сайту вкажіть цей секрет: `spec.tls[].secretName: zrazu-tls`.

## Крок 2. CronJob

Збережіть текст нижче у файл `zrazu-ssl.yaml`. Якщо секрет у вас називається інакше, замініть `zrazu-tls` (він трапляється двічі).

```yaml file=zrazu-ssl-k8s.yaml
# ZRAZU: оновлення сертифіката в TLS-секреті за особистим посиланням. Запускається раз на добу.
apiVersion: v1
kind: ServiceAccount
metadata:
  name: zrazu-ssl
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: zrazu-ssl
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["zrazu-tls"]
    verbs: ["get", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: zrazu-ssl
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: zrazu-ssl
subjects:
  - kind: ServiceAccount
    name: zrazu-ssl
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: zrazu-ssl
spec:
  schedule: "17 4 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          serviceAccountName: zrazu-ssl
          restartPolicy: Never
          containers:
            - name: update
              image: alpine:3.20
              env:
                - name: PULL
                  value: "https://api.zrazu.com/ssl-pull/zsp_ВАШ_ТОКЕН"
                - name: SECRET
                  value: "zrazu-tls"
              command: ["/bin/sh", "-c"]
              args:
                - |
                  set -eu
                  apk add --no-cache curl openssl jq >/dev/null
                  SA=/var/run/secrets/kubernetes.io/serviceaccount
                  API="https://kubernetes.default.svc/api/v1/namespaces/$(cat $SA/namespace)/secrets/$SECRET"
                  k() { curl -fsS --cacert "$SA/ca.crt" -H "Authorization: Bearer $(cat $SA/token)" "$@"; }
                  cd /tmp
                  curl -fsS --retry 3 --max-time 60 -o new.pem "$PULL/fullchain.pem"
                  grep -q "BEGIN CERTIFICATE" new.pem || { echo "ZRAZU: not a certificate"; exit 1; }
                  k "$API" > secret.json
                  jq -r '.data["tls.crt"]' secret.json | base64 -d > current.pem
                  if cmp -s new.pem current.pem; then echo "ZRAZU: unchanged"; exit 0; fi
                  jq -r '.data["tls.key"]' secret.json | base64 -d > key.pem
                  cert_pub=$(openssl x509 -in new.pem -noout -pubkey | openssl sha256)
                  key_pub=$(openssl pkey -in key.pem -pubout | openssl sha256)
                  rm -f key.pem
                  [ "$cert_pub" = "$key_pub" ] || { echo "ZRAZU: certificate is for another key"; exit 1; }
                  openssl x509 -in new.pem -noout -checkend 0 >/dev/null || { echo "ZRAZU: expired"; exit 1; }
                  crt=$(base64 < new.pem | tr -d '\n')
                  k -X PATCH -H "Content-Type: application/merge-patch+json" --data "{\"data\":{\"tls.crt\":\"$crt\"}}" "$API" >/dev/null
                  echo "ZRAZU: updated, valid until $(openssl x509 -in new.pem -noout -enddate | cut -d= -f2)"
```

Застосуйте в namespace сайту:

```sh
kubectl -n НАЗВА_NAMESPACE apply -f zrazu-ssl.yaml
```

## Перевірка

1. Запустіть задачу один раз руками: `kubectl -n НАЗВА_NAMESPACE create job --from=cronjob/zrazu-ssl zrazu-ssl-test`.
2. Журнал: `kubectl -n НАЗВА_NAMESPACE logs job/zrazu-ssl-test` — «updated» або «unchanged».
3. Сайт: [https://zrazu.com/tools/ssl/example.com/](https://zrazu.com/tools/ssl/example.com/).
4. Приберіть пробну задачу: `kubectl -n НАЗВА_NAMESPACE delete job zrazu-ssl-test`.

## Якщо не вийшло

- `403` у журналі — Role не на той секрет чи не в тому namespace: назва в `resourceNames` має збігатися з назвою секрету.
- `apk: … network error` — кластер не має виходу в інтернет до дзеркал Alpine. Зберіть свій образ із `curl`, `openssl` і `jq` і приберіть рядок `apk add`.
- «certificate is for another key» — сертифікат перевипускали з новим ключем: перестворіть секрет (крок 1) з новим ключем.
- Секрет керує cert-manager — тоді ця інструкція не потрібна: cert-manager поновлює сертифікат сам через ACME-сервер ZRAZU (у кабінеті, «Встановлення» → ACME).
