feat: добавить аддон yandex-dns-controller — управление DNS Yandex 360

CronJob (*/5 мин) reconcile ConfigMap → Yandex 360 DNS API.
Safe mode: управляет только записями с managed: true.
Никогда не удаляет неуправляемые записи (MX, DKIM, SPF и т.д.).

Удаление только при двух условиях одновременно:
  1. Запись была создана контроллером (есть в state ConfigMap)
  2. Запись полностью удалена из ConfigMap (не просто managed: false)

Переключение managed: true → false = release без удаления из DNS.

API: /directory/v1/org/{org_id}/domains/{domain}/dns
Fields: A→content, CNAME→target, TXT→text, MX→exchange+preference
This commit is contained in:
Sergey Antropoff
2026-04-26 12:16:32 +03:00
parent 5079975d5e
commit 404347b535
23 changed files with 976 additions and 1 deletions

View File

@@ -0,0 +1,220 @@
# yandex-dns-controller
CronJob-контроллер для управления DNS-записями Yandex 360.
**Safe mode**: управляет только записями с `managed: true`. Все остальные записи игнорируются — никогда не изменяются и не удаляются.
## Принцип работы
```
ConfigMap (zones.yaml)
CronJob (*/5 мин)
controller.py
Yandex 360 DNS API
```
### Модель управления
| Запись | Поведение |
|---|---|
| `managed: true` в ConfigMap | Контроллер добавляет, обновляет, и при полном удалении из конфига — удаляет |
| `managed: false` в ConfigMap | Документация только, контроллер не трогает |
| Отсутствует в ConfigMap | Игнорируется полностью, никогда не удаляется |
### Логика удаления (очень важно)
Запись удаляется из DNS **только при одновременном выполнении двух условий**:
1. Запись была создана этим контроллером (есть в state)
2. Запись **полностью удалена** из ConfigMap (не просто переключена в `managed: false`)
Если запись переключается из `managed: true``managed: false`, контроллер **перестаёт её отслеживать, но не удаляет** из DNS.
### State ConfigMap
Контроллер хранит состояние в ConfigMap `yandex-dns-controller-state`. Это позволяет безопасно удалять записи которые были удалены из конфига.
```bash
# Просмотр текущего состояния
kubectl -n yandex-dns-controller get cm yandex-dns-controller-state \
-o jsonpath='{.data.state\.json}' | jq .
```
## Быстрый старт
### 1. Настроить credentials
```yaml
# group_vars/all/vault.yml (зашифровать через ansible-vault)
yandex_dns:
org_id: "3312086" # https://admin.yandex.ru/company-profile
token: "y0_Agk..." # https://oauth.yandex.ru/ (scope: DNS)
```
Как получить токен:
1. Создай приложение на https://oauth.yandex.ru/
2. Доступ: **Управление записями DNS**
3. Открой в браузере: `https://oauth.yandex.ru/authorize?response_type=token&client_id={CLIENT_ID}`
4. Скопируй `access_token` со страницы
### 2. Настроить зоны
```yaml
# group_vars/all/addons.yml
addon_yandex_dns_controller: true
yandex_dns_controller_zones:
domains:
- name: antropoff.ru
# 🔐 СИСТЕМНЫЕ — не трогаем (managed: false — только документация)
systemRecords:
- name: "@"
type: MX
ttl: 21600
value: "mx.yandex.net."
priority: 10
managed: false
- name: "@"
type: TXT
ttl: 21600
value: "v=spf1 redirect=_spf.yandex.net"
managed: false
- name: "mail"
type: CNAME
ttl: 21600
value: "domain.mail.yandex.net."
managed: false
# 🌐 ОСНОВНЫЕ A-записи — не трогаем
coreRecords:
- name: "@"
type: A
ttl: 3600
value: "217.150.201.203"
managed: false
# 🧩 СЕРВИСЫ — не трогаем
serviceRecords:
- name: "git"
type: CNAME
ttl: 3600
value: "antropoff.ru."
managed: false
# ...остальные CNAME...
# ✅ УПРАВЛЯЕМ КОНТРОЛЛЕРОМ
- name: "k8s-test"
type: CNAME
ttl: 300
value: "antropoff.ru."
managed: true
# Записи которыми управляет K3S кластер
records:
- name: k8s-ingress
type: A
ttl: 300
value: "192.168.1.100" # kube-vip VIP
managed: true
```
### 3. Развернуть
```bash
make addon-yandex-dns-controller
```
## Конфигурация
| Переменная | По умолчанию | Описание |
|---|---|---|
| `yandex_dns_controller_namespace` | `yandex-dns-controller` | Namespace |
| `yandex_dns_controller_schedule` | `*/5 * * * *` | Расписание CronJob |
| `yandex_dns_controller_dry_run` | `false` | Dry-run (не вносить изменения) |
| `yandex_dns_controller_image` | `python:3.11-slim` | Docker образ |
| `yandex_dns_controller_zones` | `{domains: []}` | Конфигурация зон |
## Поддерживаемые типы записей
| Тип | Поле API | Пример value |
|---|---|---|
| `A` | `content` | `"192.168.1.1"` |
| `AAAA` | `content` | `"::1"` |
| `CNAME` | `target` | `"antropoff.ru."` |
| `TXT` | `text` | `"v=spf1 ..."` |
| `MX` | `exchange` + `preference` | `"mx.yandex.net."` + `priority: 10` |
| `NS` | `target` | `"ns1.example.com."` |
## Dry-run режим
Включить временно без изменения конфига:
```bash
make addon-yandex-dns-controller ARGS="-e yandex_dns_controller_dry_run=true"
```
Или задать постоянно:
```yaml
yandex_dns_controller_dry_run: true
```
В dry-run режиме контроллер показывает что бы он сделал, но не вносит изменений в DNS.
## Ручной запуск
```bash
# Запустить немедленно
kubectl create job --from=cronjob/yandex-dns-controller \
dns-manual-$(date +%s) -n yandex-dns-controller
# Наблюдать за логами
kubectl -n yandex-dns-controller logs \
-l app.kubernetes.io/name=yandex-dns-controller \
--tail=100 -f
```
## Диагностика
```bash
# Статус CronJob
kubectl -n yandex-dns-controller get cronjob,jobs,pods
# Логи последнего запуска
kubectl -n yandex-dns-controller logs \
$(kubectl -n yandex-dns-controller get pods \
-l app.kubernetes.io/name=yandex-dns-controller \
--sort-by=.metadata.creationTimestamp -o name | tail -1) \
-c controller --tail=100
# Что контролируется (state)
kubectl -n yandex-dns-controller get cm yandex-dns-controller-state \
-o jsonpath='{.data.state\.json}' | jq .
# Просмотр текущего ConfigMap зон
kubectl -n yandex-dns-controller get cm yandex-dns-zones -o yaml
# Редактировать зоны вручную (без Ansible)
kubectl -n yandex-dns-controller edit cm yandex-dns-zones
```
## Обновление записей через GitOps
Все изменения DNS делаются через `group_vars/all/addons.yml`:
1. Добавить/изменить запись в `yandex_dns_controller_zones` с `managed: true`
2. Закоммитить и запустить `make addon-yandex-dns-controller`
3. Контроллер подхватит изменение на следующем запуске CronJob
## Защита существующих записей
Все записи с `managed: false` — это документация. Контроллер **никогда**:
- Не создаёт их (даже если они отсутствуют в DNS)
- Не обновляет (даже если value отличается)
- Не удаляет
Это гарантирует что MX-записи Яндекс.Почты, DKIM, SPF и другие критичные записи не будут затронуты.

View File

@@ -0,0 +1,7 @@
---
- name: Install yandex-dns-controller
hosts: k3s_master[0]
gather_facts: false
become: true
roles:
- role: "{{ playbook_dir }}/role"

View File

@@ -0,0 +1,14 @@
apiVersion: v2
name: yandex-dns-controller
description: |
CronJob-контроллер для управления DNS-записями Yandex 360.
Safe mode: управляет только записями с managed: true.
Никогда не удаляет неуправляемые записи.
type: application
version: 1.0.0
appVersion: "1.0.0"
keywords:
- dns
- yandex
- yandex360
home: https://git.antropoff.ru/DevOpsTools/K3S

View File

@@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""
Yandex 360 DNS Controller — safe mode.
Rules:
ADD — record in ConfigMap (managed:true) but absent from live DNS
UPDATE — record in ConfigMap (managed:true) with changed value/ttl
DELETE — record was in previous state (was managed) AND is now completely
removed from ConfigMap (not just set to managed:false)
NEVER — touch any record not tracked in controller state
RELEASE— record changed from managed:true → managed:false:
stop tracking it, do NOT delete from DNS
"""
import json
import os
import sys
import time
import traceback
import requests
import yaml
# ─── Configuration ────────────────────────────────────────────────────────────
BASE_URL = "https://api360.yandex.net/directory/v1/org"
K8S_API = "https://kubernetes.default.svc"
K8S_TOKEN = "/var/run/secrets/kubernetes.io/serviceaccount/token"
K8S_CA = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
ZONES_FILE = os.environ.get("ZONES_FILE", "/etc/dns-zones/zones.yaml")
STATE_NS = os.environ.get("STATE_NAMESPACE", "yandex-dns-controller")
STATE_CM = os.environ.get("STATE_CONFIGMAP", "yandex-dns-controller-state")
DRY_RUN = os.environ.get("DRY_RUN", "false").lower() in ("true", "1", "yes")
# ─── HTTP with retry ──────────────────────────────────────────────────────────
def _req(method, url, retries=3, **kwargs):
last = None
for attempt in range(retries):
try:
r = requests.request(method, url, timeout=30, **kwargs)
if r.status_code == 429:
wait = 2 ** (attempt + 1)
print(f" rate-limited, retry in {wait}s...")
time.sleep(wait)
continue
r.raise_for_status()
return r
except requests.RequestException as e:
last = e
if attempt < retries - 1:
time.sleep(1)
raise last
# ─── Yandex 360 DNS API ───────────────────────────────────────────────────────
# Endpoint: /directory/v1/org/{org_id}/domains/{domain}/dns
# Field mapping (from working bash reference):
# A/AAAA → content
# CNAME → target
# TXT → text
# MX → exchange + preference
# NS → target
def _url(org_id, domain, record_id=None):
base = f"{BASE_URL}/{org_id}/domains/{domain}/dns"
return f"{base}/{record_id}" if record_id else base
def _headers(token):
return {"Authorization": f"OAuth {token}", "Content-Type": "application/json"}
def yandex_list(org_id, domain, token):
"""Fetch all DNS records with pagination."""
records, page = [], 1
while True:
data = _req("GET", _url(org_id, domain),
headers=_headers(token),
params={"page": page, "perPage": 50}).json()
records.extend(data.get("records", []))
if page >= data.get("pages", 1):
break
page += 1
time.sleep(0.1)
return records
def yandex_create(org_id, domain, token, payload):
return _req("POST", _url(org_id, domain),
headers=_headers(token), json=payload).json()
def yandex_update(org_id, domain, token, record_id, payload):
return _req("PUT", _url(org_id, domain, record_id),
headers=_headers(token), json=payload).json()
def yandex_delete(org_id, domain, token, record_id):
_req("DELETE", _url(org_id, domain, record_id), headers=_headers(token))
# ─── Record value helpers ─────────────────────────────────────────────────────
def api_value(rec):
"""Extract canonical value from a live Yandex API record."""
t = rec.get("type", "")
if t in ("A", "AAAA"): return rec.get("content", "")
if t in ("CNAME", "NS", "SRV"): return rec.get("target", "")
if t == "TXT": return rec.get("text", "")
if t == "MX": return rec.get("exchange", "")
return ""
def build_payload(r):
"""Build API request payload from a ConfigMap record."""
rtype = r["type"]
value = r["value"]
payload = {"name": r["name"], "type": rtype, "ttl": r.get("ttl", 3600)}
if rtype in ("A", "AAAA"): payload["content"] = value
elif rtype in ("CNAME", "NS"): payload["target"] = value
elif rtype == "TXT": payload["text"] = value
elif rtype == "MX":
payload["exchange"] = value
payload["preference"] = r.get("priority", 10)
elif rtype == "SRV": payload["target"] = value
return payload
def _norm(v):
"""Normalize for comparison: strip trailing dot and whitespace."""
return str(v).rstrip(".").strip()
def records_equal(desired, live):
"""True if desired matches live DNS record."""
if _norm(desired["value"]) != _norm(api_value(live)):
return False
if desired.get("ttl") and int(desired["ttl"]) != int(live.get("ttl", 0)):
return False
rtype = desired["type"]
if rtype == "MX":
want_prio = desired.get("priority", 10)
if want_prio != live.get("preference"):
return False
return True
# ─── Kubernetes state ConfigMap ───────────────────────────────────────────────
# State format: {"domain.ru": {"name:type": record_id, ...}, ...}
def _k8s_session():
with open(K8S_TOKEN) as f:
tok = f.read().strip()
s = requests.Session()
s.verify = K8S_CA
s.headers["Authorization"] = f"Bearer {tok}"
return s
def load_state(k8s):
url = f"{K8S_API}/api/v1/namespaces/{STATE_NS}/configmaps/{STATE_CM}"
r = k8s.get(url, timeout=10)
if r.status_code == 404:
return {}
r.raise_for_status()
try:
return json.loads(r.json().get("data", {}).get("state.json", "{}"))
except json.JSONDecodeError:
return {}
def save_state(k8s, state):
if DRY_RUN:
print(" [dry-run] state not saved")
return
url = f"{K8S_API}/api/v1/namespaces/{STATE_NS}/configmaps/{STATE_CM}"
body = {
"apiVersion": "v1", "kind": "ConfigMap",
"metadata": {
"name": STATE_CM, "namespace": STATE_NS,
"labels": {"app.kubernetes.io/managed-by": "yandex-dns-controller"},
},
"data": {"state.json": json.dumps(state, indent=2, ensure_ascii=False)},
}
existing = k8s.get(url, timeout=10)
if existing.status_code == 404:
k8s.post(f"{K8S_API}/api/v1/namespaces/{STATE_NS}/configmaps",
json=body, timeout=10).raise_for_status()
else:
existing.raise_for_status()
k8s.put(url, json=body, timeout=10).raise_for_status()
# ─── Reconcile ────────────────────────────────────────────────────────────────
def collect_records(domain_cfg):
"""Flatten all sections of a domain config into one list."""
out = []
for section in ("records", "systemRecords", "coreRecords", "serviceRecords"):
out.extend(domain_cfg.get(section, []))
return out
def reconcile_domain(org_id, domain, desired_list, live_records, prev_state, token):
"""
Returns the new state dict for the domain.
"""
# live records indexed by "name:type"
live = {}
for rec in live_records:
k = f"{rec['name']}:{rec['type']}"
live.setdefault(k, []).append(rec)
# desired managed records (managed: true)
desired = {
f"{r['name']}:{r['type']}": r
for r in desired_list if r.get("managed", False)
}
# all keys present in config in any section (any managed value)
all_config_keys = {f"{r['name']}:{r['type']}" for r in desired_list}
new_state = {}
stats = dict(ok=0, created=0, updated=0, deleted=0, released=0)
# ── Reconcile desired managed records ─────────────────────────────────────
for key, rec in desired.items():
payload = build_payload(rec)
if key in live:
live_rec = live[key][0]
rid = live_rec["recordId"]
if records_equal(rec, live_rec):
print(f"{key:<50} {rec['value']}")
stats["ok"] += 1
else:
old_val = api_value(live_rec)
print(f"{key:<50} {old_val!r}{rec['value']!r}")
if not DRY_RUN:
yandex_update(org_id, domain, token, rid, payload)
stats["updated"] += 1
new_state[key] = rid
else:
print(f" + {key:<50} {rec['value']} [CREATE]")
if not DRY_RUN:
result = yandex_create(org_id, domain, token, payload)
new_state[key] = result["recordId"]
else:
new_state[key] = -1
stats["created"] += 1
# ── Handle previously managed records that are no longer managed ──────────
for key, rid in prev_state.items():
if key in desired:
continue # already handled above
if key in all_config_keys:
# Still in config, but managed: false now → release without deleting
print(f"{key:<50} [managed:false → released, record preserved in DNS]")
stats["released"] += 1
# NOT added to new_state → stops tracking
else:
# Completely removed from ConfigMap → safe to delete
print(f"{key:<50} [removed from ConfigMap → DELETE]")
if not DRY_RUN and rid != -1:
try:
yandex_delete(org_id, domain, token, rid)
except Exception as e:
print(f" WARN: delete failed (already gone?): {e}")
stats["deleted"] += 1
# NOT added to new_state
print(f"\n {stats['ok']} ok {stats['created']} created "
f"{stats['updated']} updated {stats['deleted']} deleted "
f"{stats['released']} released")
return new_state
# ─── Entry point ─────────────────────────────────────────────────────────────
def main():
token = os.environ.get("YANDEX_TOKEN", "").strip()
org_id = os.environ.get("YANDEX_ORG_ID", "").strip()
if not token:
sys.exit("FATAL: YANDEX_TOKEN is not set")
if not org_id:
sys.exit("FATAL: YANDEX_ORG_ID is not set")
if DRY_RUN:
print("=" * 60)
print("DRY-RUN MODE — no changes will be made to DNS")
print("=" * 60)
with open(ZONES_FILE) as f:
config = yaml.safe_load(f)
k8s = _k8s_session()
state = load_state(k8s)
errors = 0
for domain_cfg in config.get("domains", []):
domain = domain_cfg["name"]
print(f"\n{'='*60}")
print(f" Domain : {domain}")
print(f"{'='*60}")
try:
live = yandex_list(org_id, domain, token)
desired_list = collect_records(domain_cfg)
prev_state = state.get(domain, {})
managed_n = sum(1 for r in desired_list if r.get("managed", False))
print(f" live={len(live)} config={len(desired_list)} managed={managed_n}\n")
state[domain] = reconcile_domain(
org_id, domain, desired_list, live, prev_state, token
)
except Exception as e:
print(f"\n ERROR: {e}")
traceback.print_exc()
errors += 1
save_state(k8s, state)
if errors:
print(f"\n{errors} domain(s) failed")
sys.exit(1)
print("\n✓ All domains reconciled successfully")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,22 @@
yandex-dns-controller deployed.
Schedule : {{ .Values.controller.schedule }}
Dry-run : {{ .Values.controller.dryRun }}
Domains : {{ .Values.zones.domains | len }}
The controller runs every {{ .Values.controller.schedule }} and reconciles only
records marked with managed: true in the ConfigMap.
Trigger a manual run:
kubectl create job --from=cronjob/yandex-dns-controller dns-manual-$(date +%s) \
-n {{ .Release.Namespace }}
Watch logs:
kubectl -n {{ .Release.Namespace }} logs -l app.kubernetes.io/name=yandex-dns-controller \
--tail=100 -f
View controller state:
kubectl -n {{ .Release.Namespace }} get configmap yandex-dns-controller-state -o jsonpath='{.data.state\.json}' | jq .
Edit DNS zones (triggers reconcile on next CronJob run):
kubectl -n {{ .Release.Namespace }} edit configmap yandex-dns-zones

View File

@@ -0,0 +1,10 @@
{{- define "yandex-dns-controller.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "yandex-dns-controller.labels" -}}
helm.sh/chart: {{ include "yandex-dns-controller.chart" . }}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

View File

@@ -0,0 +1,10 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: yandex-dns-controller-script
namespace: {{ .Release.Namespace }}
labels:
{{- include "yandex-dns-controller.labels" . | nindent 4 }}
data:
controller.py: |
{{ .Files.Get "files/controller.py" | indent 4 }}

View File

@@ -0,0 +1,10 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: yandex-dns-zones
namespace: {{ .Release.Namespace }}
labels:
{{- include "yandex-dns-controller.labels" . | nindent 4 }}
data:
zones.yaml: |
{{ .Values.zones | toYaml | indent 4 }}

View File

@@ -0,0 +1,87 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: yandex-dns-controller
namespace: {{ .Release.Namespace }}
labels:
{{- include "yandex-dns-controller.labels" . | nindent 4 }}
spec:
schedule: {{ .Values.controller.schedule | quote }}
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
template:
metadata:
labels:
{{- include "yandex-dns-controller.labels" . | nindent 12 }}
spec:
serviceAccountName: yandex-dns-controller
restartPolicy: OnFailure
volumes:
- name: zones
configMap:
name: yandex-dns-zones
- name: script
configMap:
name: yandex-dns-controller-script
defaultMode: 0755
- name: pip-pkgs
emptyDir: {}
initContainers:
- name: install-deps
image: {{ .Values.controller.image }}
command:
- pip
- install
- -q
- --no-cache-dir
- --target
- /pip-pkgs
- requests
- pyyaml
volumeMounts:
- name: pip-pkgs
mountPath: /pip-pkgs
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
containers:
- name: controller
image: {{ .Values.controller.image }}
command: [python, /scripts/controller.py]
env:
- name: YANDEX_TOKEN
valueFrom:
secretKeyRef:
name: yandex-dns-credentials
key: token
- name: YANDEX_ORG_ID
valueFrom:
secretKeyRef:
name: yandex-dns-credentials
key: orgId
- name: STATE_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: STATE_CONFIGMAP
value: yandex-dns-controller-state
- name: DRY_RUN
value: {{ .Values.controller.dryRun | toString | quote }}
- name: PYTHONPATH
value: /pip-pkgs
volumeMounts:
- name: zones
mountPath: /etc/dns-zones
- name: script
mountPath: /scripts
- name: pip-pkgs
mountPath: /pip-pkgs
resources:
{{- toYaml .Values.controller.resources | nindent 16 }}

View File

@@ -0,0 +1,11 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: yandex-dns-controller
namespace: {{ .Release.Namespace }}
labels:
{{- include "yandex-dns-controller.labels" . | nindent 4 }}
rules:
- apiGroups: [""]
resources: [configmaps]
verbs: [get, create, update, patch]

View File

@@ -0,0 +1,15 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: yandex-dns-controller
namespace: {{ .Release.Namespace }}
labels:
{{- include "yandex-dns-controller.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: yandex-dns-controller
subjects:
- kind: ServiceAccount
name: yandex-dns-controller
namespace: {{ .Release.Namespace }}

View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: Secret
metadata:
name: yandex-dns-credentials
namespace: {{ .Release.Namespace }}
labels:
{{- include "yandex-dns-controller.labels" . | nindent 4 }}
type: Opaque
data:
token: {{ .Values.secret.token | b64enc | quote }}
orgId: {{ .Values.secret.orgId | b64enc | quote }}

View File

@@ -0,0 +1,7 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: yandex-dns-controller
namespace: {{ .Release.Namespace }}
labels:
{{- include "yandex-dns-controller.labels" . | nindent 4 }}

View File

@@ -0,0 +1,23 @@
# Default values — override via group_vars/all/addons.yml → yandex_dns_controller_*
controller:
image: python:3.11-slim
schedule: "*/5 * * * *" # every 5 minutes
dryRun: false
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
secret:
orgId: ""
token: ""
# Full zones config — mirrors the zones.yaml ConfigMap content.
# All sections (records, systemRecords, coreRecords, serviceRecords) are supported.
# Controller only acts on records with managed: true.
zones:
domains: []

View File

@@ -0,0 +1,43 @@
---
# ─── Helm release ─────────────────────────────────────────────────────────────
yandex_dns_controller_namespace: "yandex-dns-controller"
yandex_dns_controller_release_name: "yandex-dns-controller"
# ─── CronJob settings ─────────────────────────────────────────────────────────
yandex_dns_controller_schedule: "*/5 * * * *"
yandex_dns_controller_dry_run: false
yandex_dns_controller_image: "python:3.11-slim"
# ─── API credentials (задаются в vault.yml) ───────────────────────────────────
# yandex_dns:
# org_id: "3312086" # ID организации: https://admin.yandex.ru/company-profile
# token: "y0_..." # OAuth-токен: https://oauth.yandex.ru/
# ─── DNS zones ────────────────────────────────────────────────────────────────
# Полное содержимое zones.yaml.
# Контроллер обрабатывает все секции: records, systemRecords, coreRecords,
# serviceRecords. Управляются только записи с managed: true.
#
# Пример:
# yandex_dns_controller_zones:
# domains:
# - name: example.ru
#
# # НЕ ТРОГАЕМ — только документация
# systemRecords:
# - name: "@"
# type: MX
# ttl: 21600
# value: "mx.yandex.net."
# priority: 10
# managed: false
#
# # УПРАВЛЯЕМ контроллером
# records:
# - name: k8s-ingress
# type: A
# ttl: 300
# value: "192.168.1.100"
# managed: true
yandex_dns_controller_zones:
domains: []

View File

@@ -0,0 +1,135 @@
---
# ── Validate inputs ───────────────────────────────────────────────────────────
- name: Validate yandex_dns credentials
ansible.builtin.assert:
that:
- yandex_dns is defined
- yandex_dns.org_id is defined and yandex_dns.org_id | length > 0
- yandex_dns.token is defined and yandex_dns.token | length > 0
fail_msg: >
yandex_dns.org_id and yandex_dns.token must be set in vault.yml.
Get org_id from https://admin.yandex.ru/company-profile
Get token from https://oauth.yandex.ru/
- name: Validate yandex_dns_controller_zones has at least one domain
ansible.builtin.assert:
that:
- yandex_dns_controller_zones.domains is defined
- yandex_dns_controller_zones.domains | length > 0
fail_msg: >
yandex_dns_controller_zones.domains is empty.
Define at least one domain in group_vars/all/addons.yml.
# ── Create namespace ──────────────────────────────────────────────────────────
- name: Create yandex-dns-controller namespace
ansible.builtin.command: >
k3s kubectl create namespace {{ yandex_dns_controller_namespace }}
--dry-run=client -o yaml | k3s kubectl apply -f -
become: true
changed_when: false
# ── Copy Helm chart to master node ───────────────────────────────────────────
- name: Ensure chart temp directory is clean
ansible.builtin.file:
path: /tmp/yandex-dns-controller-chart
state: absent
become: true
- name: Create chart temp directory
ansible.builtin.file:
path: /tmp/yandex-dns-controller-chart
state: directory
mode: "0755"
become: true
- name: Copy Helm chart to master
ansible.builtin.copy:
src: "{{ role_path }}/chart/"
dest: /tmp/yandex-dns-controller-chart/
mode: preserve
become: true
# ── Template Helm values ──────────────────────────────────────────────────────
- name: Template Helm values
ansible.builtin.template:
src: values.yaml.j2
dest: /tmp/yandex-dns-controller-values.yaml
mode: "0600"
become: true
no_log: true # contains OAuth token
# ── Lint chart ────────────────────────────────────────────────────────────────
- name: Lint Helm chart
ansible.builtin.command: >
helm lint /tmp/yandex-dns-controller-chart
--values /tmp/yandex-dns-controller-values.yaml
become: true
changed_when: false
register: _helm_lint
failed_when: _helm_lint.rc != 0
# ── Deploy chart ──────────────────────────────────────────────────────────────
- name: Deploy yandex-dns-controller via Helm
ansible.builtin.command: >
helm upgrade --install {{ yandex_dns_controller_release_name }}
/tmp/yandex-dns-controller-chart
--namespace {{ yandex_dns_controller_namespace }}
--values /tmp/yandex-dns-controller-values.yaml
--atomic
--wait
--timeout 120s
become: true
register: _helm_result
changed_when: true
# ── Cleanup temp values file (contains token) ─────────────────────────────────
- name: Remove temp values file
ansible.builtin.file:
path: /tmp/yandex-dns-controller-values.yaml
state: absent
become: true
# ── Verify ────────────────────────────────────────────────────────────────────
- name: Get CronJob status
ansible.builtin.command: >
k3s kubectl -n {{ yandex_dns_controller_namespace }} get cronjob -o wide
become: true
changed_when: false
register: _cronjob_status
# ── Summary ───────────────────────────────────────────────────────────────────
- name: "=== yandex-dns-controller Ready ==="
ansible.builtin.debug:
msg:
- "╔══════════════════════════════════════════════════════════════╗"
- "║ Yandex 360 DNS Controller — Deployed ║"
- "╚══════════════════════════════════════════════════════════════╝"
- ""
- " Namespace : {{ yandex_dns_controller_namespace }}"
- " Schedule : {{ yandex_dns_controller_schedule }}"
- " Dry-run : {{ yandex_dns_controller_dry_run }}"
- " Domains : {{ yandex_dns_controller_zones.domains | map(attribute='name') | list | join(', ') }}"
- ""
- " CronJob:"
- "{{ _cronjob_status.stdout_lines | to_yaml }}"
- ""
- " Manual trigger:"
- " kubectl create job --from=cronjob/yandex-dns-controller dns-manual-1 \\"
- " -n {{ yandex_dns_controller_namespace }}"
- ""
- " Logs:"
- " kubectl -n {{ yandex_dns_controller_namespace }} logs \\"
- " -l app.kubernetes.io/name=yandex-dns-controller --tail=100 -f"
- ""
- " State:"
- " kubectl -n {{ yandex_dns_controller_namespace }} get cm yandex-dns-controller-state \\"
- " -o jsonpath='{.data.state\\.json}' | jq ."

View File

@@ -0,0 +1,15 @@
# Generated by Ansible — do not edit manually.
# Configure via: group_vars/all/addons.yml → yandex_dns_controller_* variables.
# Credentials from vault.yml → yandex_dns.org_id / yandex_dns.token
controller:
image: {{ yandex_dns_controller_image | quote }}
schedule: {{ yandex_dns_controller_schedule | quote }}
dryRun: {{ yandex_dns_controller_dry_run | string | lower }}
secret:
orgId: {{ yandex_dns.org_id | quote }}
token: {{ yandex_dns.token | quote }}
zones:
{{ yandex_dns_controller_zones | to_yaml | indent(2, True) }}