feat: добавить аддон ingress-add-domains — добавить домены к сервисам кластера

Helm chart + Ansible роль, создающие Ingress-only правила для уже существующих
K8s сервисов. Каждый Ingress создаётся в namespace целевого сервиса.

Поддерживает: TLS (cert-manager или готовый Secret), basic auth (автохэш пароля
через openssl passwd -apr1), WebSocket, несколько хостов, per-entry аннотации.
This commit is contained in:
Sergey Antropoff
2026-04-26 12:02:41 +03:00
parent fafcc34f03
commit 5079975d5e
18 changed files with 823 additions and 1 deletions

View File

@@ -0,0 +1,214 @@
# ingress-add-domains
Добавляет дополнительные домены к уже существующим сервисам внутри кластера.
**Не создаёт** Service или Endpoints — только Ingress, указывающий на готовый K8s Service.
Каждый Ingress создаётся **в namespace целевого сервиса**, что является требованием Kubernetes.
Поддерживает:
- Внутренние домены (`*.local`) без TLS
- Внешние домены (`*.home.ru`) с TLS и cert-manager
- Basic auth (логин/пароль — хэш генерируется автоматически)
- WebSocket
- Per-entry переопределение любых параметров
## Когда использовать
| Сценарий | Инструмент |
|---|---|
| Открыть сервис кластера по новому домену | **ingress-add-domains** |
| Проксировать сервис вне кластера (по IP) | [ingress-proxypass](../ingress-proxypass/README.md) |
## Быстрый старт
```yaml
# group_vars/all/addons.yml
addon_ingress_add_domains: true
ingress_add_domains_entries:
# Внутренний домен без TLS
- name: gitea-local
hosts: [gitea.local]
service:
name: gitea-http
namespace: gitea
port: 3000
# Внешний домен с TLS + basic auth
- name: gitea-home
hosts: [gitea.home.ru]
service:
name: gitea-http
namespace: gitea
port: 3000
tls:
enabled: true
certManager:
enabled: true
issuer: letsencrypt-prod
issuerKind: ClusterIssuer
auth:
enabled: true
username: admin
password: "{{ vault_gitea_proxy_password }}"
```
```bash
make addon-ingress-add-domains
```
## Конфигурация
### Переменные Ansible
| Переменная | По умолчанию | Описание |
|---|---|---|
| `ingress_add_domains_release_name` | `ingress-add-domains` | Имя Helm release |
| `ingress_add_domains_release_namespace` | `ingress-add-domains` | Namespace для Helm-метаданных |
| `ingress_add_domains_defaults` | см. ниже | Глобальные умолчания |
| `ingress_add_domains_entries` | `[]` | Список записей |
### Поля entry
| Поле | Обязательно | Описание |
|---|---|---|
| `name` | да | Уникальное имя (становится именем Ingress-ресурса) |
| `hosts` | да | Список доменов |
| `service.name` | да | Имя существующего K8s Service |
| `service.namespace` | да | Namespace сервиса (Ingress создаётся в этом namespace) |
| `service.port` | да | Порт сервиса |
| `tls` | нет | Настройки TLS (см. ниже) |
| `auth` | нет | Basic auth (см. ниже) |
| `websocket` | нет | Включить поддержку WebSocket (`true`/`false`) |
| `path` | нет | URL-путь (по умолчанию `/`) |
| `pathType` | нет | `Prefix` или `Exact` (по умолчанию `Prefix`) |
| `ingressClass` | нет | Класс ingress (по умолчанию `nginx`) |
| `annotations` | нет | Дополнительные аннотации (перекрывают всё) |
### TLS
```yaml
tls:
enabled: true
secretName: wildcard-tls # использовать готовый Secret (опционально)
certManager:
enabled: true # автоматически создать Certificate
issuer: letsencrypt-prod
issuerKind: ClusterIssuer # или Issuer
```
### Basic auth
```yaml
auth:
enabled: true
username: admin
password: "{{ vault_password }}" # хэш генерируется автоматически
# ИЛИ готовая htpasswd-строка:
# credentials: "admin:$apr1$..."
# ИЛИ использовать существующий Secret:
# secretName: my-auth-secret
```
Если задан `username` + `password` — Ansible автоматически хэширует пароль через
`openssl passwd -apr1` и создаёт K8s Secret. Открытый пароль не попадает в Helm values.
### Глобальные умолчания
```yaml
ingress_add_domains_defaults:
ingressClass: nginx
tls:
enabled: false
certManager:
enabled: false
issuer: ""
issuerKind: ClusterIssuer
auth:
enabled: false
websocket: false
path: /
pathType: Prefix
annotations: {}
```
Любое поле defaults можно переопределить на уровне entry.
## Примеры
### Несколько доменов для одного сервиса
```yaml
ingress_add_domains_entries:
- name: nextcloud-all-domains
hosts:
- nextcloud.local
- cloud.local
- nextcloud.home.ru
service:
name: nextcloud
namespace: nextcloud
port: 8080
tls:
enabled: true
secretName: wildcard-home-tls
```
### Wildcard TLS Secret для всех записей
```yaml
ingress_add_domains_defaults:
tls:
enabled: true
secretName: wildcard-home-tls
ingress_add_domains_entries:
- name: gitea-home
hosts: [gitea.home.ru]
service: {name: gitea-http, namespace: gitea, port: 3000}
- name: harbor-home
hosts: [harbor.home.ru]
service: {name: harbor-core, namespace: harbor, port: 80}
tls:
secretName: harbor-specific-tls # перекрывает default
```
### Basic auth для всех записей
```yaml
ingress_add_domains_defaults:
auth:
enabled: true
username: admin
password: "{{ vault_proxy_password }}"
ingress_add_domains_entries:
- name: argocd-home
hosts: [argocd.home.ru]
service: {name: argocd-server, namespace: argocd, port: 80}
- name: grafana-home
hosts: [grafana.home.ru]
service: {name: prometheus-stack-grafana, namespace: monitoring, port: 80}
auth:
enabled: false # отключить auth для конкретной записи
```
## Диагностика
```bash
# Посмотреть все созданные Ingresses
kubectl get ingress -A -l app.kubernetes.io/instance=ingress-add-domains
# Детали конкретного Ingress
kubectl describe ingress <name> -n <namespace>
# Логи ingress-nginx
kubectl -n ingress-nginx logs -l app.kubernetes.io/name=ingress-nginx --tail=50
# Проверить auth Secret
kubectl -n <namespace> get secret <name>-auth
```

View File

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

View File

@@ -0,0 +1,15 @@
apiVersion: v2
name: ingress-add-domains
description: |
Добавляет дополнительные домены (Ingress) к уже существующим сервисам внутри кластера.
Не создаёт Service/Endpoints — только Ingress, указывающий на готовый K8s Service.
Каждый Ingress создаётся в namespace целевого сервиса.
Поддерживает: TLS (готовый Secret или cert-manager), basic auth, WebSocket, несколько хостов.
type: application
version: 1.0.0
appVersion: "1.0.0"
keywords:
- ingress
- domains
- nginx
home: https://git.antropoff.ru/DevOpsTools/K3S

View File

@@ -0,0 +1,12 @@
ingress-add-domains deployed successfully.
Ingresses created (each in the namespace of its target Service):
{{- range .Values.entries }}
{{- $svc := .service }}
• {{ include "ingress-add-domains.resourceName" .name }} (namespace: {{ $svc.namespace }})
hosts: {{ .hosts | default (list (.host | default "")) | join ", " }}
→ {{ $svc.name }}:{{ $svc.port }}
{{- end }}
Verify:
kubectl get ingress -A -l app.kubernetes.io/instance={{ .Release.Name }}

View File

@@ -0,0 +1,24 @@
{{/*
Normalize an entry name to a safe Kubernetes resource name.
Usage: {{ include "ingress-add-domains.resourceName" "my_service.name" }}
*/}}
{{- define "ingress-add-domains.resourceName" -}}
{{- . | lower | replace "_" "-" | replace "." "-" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Chart label string.
*/}}
{{- define "ingress-add-domains.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels applied to all resources.
*/}}
{{- define "ingress-add-domains.labels" -}}
helm.sh/chart: {{ include "ingress-add-domains.chart" . }}
app.kubernetes.io/name: {{ default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

View File

@@ -0,0 +1,122 @@
{{/*
Creates one Ingress per entry, placed in the SAME namespace as the target Service.
metadata.namespace is set explicitly so each Ingress lives next to its backend.
Feature resolution order (highest → lowest priority):
1. Per-entry annotations (.entries[*].annotations) — override everything
2. Per-entry feature flags (tls, auth, websocket, …)
3. Global defaults (.defaults.*)
4. Built-in generated annotations (ingressClass, auth secret pointer)
*/}}
{{- range .Values.entries }}
{{- $entry := . }}
{{- $d := $.Values.defaults }}
{{- $entryName := include "ingress-add-domains.resourceName" $entry.name }}
{{- $svc := $entry.service }}
{{/* ── Resolve settings with fallback to defaults ─────────────────────────── */}}
{{- $ingressClass := $entry.ingressClass | default $d.ingressClass | default "nginx" }}
{{- $path := $entry.path | default $d.path | default "/" }}
{{- $pathType := $entry.pathType | default $d.pathType | default "Prefix" }}
{{/* websocket: nil-safe three-way check */}}
{{- $websocket := false }}
{{- if ne ($entry.websocket | toString) "<nil>" }}
{{- $websocket = $entry.websocket }}
{{- else if ne ($d.websocket | toString) "<nil>" }}
{{- $websocket = $d.websocket }}
{{- end }}
{{/* ── TLS ──────────────────────────────────────────────────────────────────── */}}
{{- $entryTLS := $entry.tls | default dict }}
{{- $defTLS := $d.tls | default dict }}
{{- $entryCM := $entryTLS.certManager | default dict }}
{{- $defCM := $defTLS.certManager | default dict }}
{{- $tlsEnabled := $entryTLS.enabled | default $defTLS.enabled | default false }}
{{- $tlsSecret := $entryTLS.secretName | default $defTLS.secretName | default "" }}
{{- $cmEnabled := $entryCM.enabled | default $defCM.enabled | default false }}
{{- $cmIssuer := $entryCM.issuer | default $defCM.issuer | default "" }}
{{- $cmKind := $entryCM.issuerKind | default $defCM.issuerKind | default "ClusterIssuer" }}
{{/* ── Auth ─────────────────────────────────────────────────────────────────── */}}
{{- $entryAuth := $entry.auth | default dict }}
{{- $defAuth := $d.auth | default dict }}
{{- $authEnabled := $entryAuth.enabled | default $defAuth.enabled | default false }}
{{- $authSecret := "" }}
{{- if $authEnabled }}
{{- $authSecret = $entryAuth.secretName | default $defAuth.secretName | default (printf "%s-auth" $entryName) }}
{{- end }}
{{/* ── Hosts ────────────────────────────────────────────────────────────────── */}}
{{- $hosts := $entry.hosts | default (list ($entry.host | default "")) }}
{{/* ── Build annotation dict ───────────────────────────────────────────────── */}}
{{- $ann := dict }}
{{/* Step 1: global default annotations (lowest priority) */}}
{{- range $k, $v := ($d.annotations | default dict) }}
{{- $_ := set $ann $k ($v | toString) }}
{{- end }}
{{/* Step 2: WebSocket */}}
{{- if $websocket }}
{{- $_ := set $ann "nginx.ingress.kubernetes.io/proxy-http-version" "1.1" }}
{{- end }}
{{/* Step 3: basic auth */}}
{{- if $authEnabled }}
{{- $_ := set $ann "nginx.ingress.kubernetes.io/auth-type" "basic" }}
{{- $_ := set $ann "nginx.ingress.kubernetes.io/auth-secret" $authSecret }}
{{- $_ := set $ann "nginx.ingress.kubernetes.io/auth-realm" "Authentication Required" }}
{{- end }}
{{/* Step 4: cert-manager */}}
{{- if $cmEnabled }}
{{- $cmAnnotationKey := printf "cert-manager.io/%s" ($cmKind | lower) }}
{{- $_ := set $ann $cmAnnotationKey $cmIssuer }}
{{- end }}
{{/* Step 5: per-entry custom annotations override everything above */}}
{{- range $k, $v := ($entry.annotations | default dict) }}
{{- $_ := set $ann $k ($v | toString) }}
{{- end }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $entryName }}
namespace: {{ $svc.namespace }}
labels:
{{- include "ingress-add-domains.labels" $ | nindent 4 }}
app.kubernetes.io/component: {{ $entryName }}
{{- if $ann }}
annotations:
{{- toYaml $ann | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ $ingressClass }}
{{- if $tlsEnabled }}
tls:
- hosts:
{{- range $hosts }}
- {{ . | quote }}
{{- end }}
{{- if $tlsSecret }}
secretName: {{ $tlsSecret | quote }}
{{- end }}
{{- end }}
rules:
{{- range $hosts }}
- host: {{ . | quote }}
http:
paths:
- path: {{ $path | quote }}
pathType: {{ $pathType }}
backend:
service:
name: {{ $svc.name }}
port:
number: {{ $svc.port }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,38 @@
{{/*
Creates a basic-auth Secret for each entry that has:
auth.enabled: true
auth.credentials: "<user:$apr1$...>" (no auth.secretName — use existing instead)
The Secret is created in the SAME namespace as the target Service so that
nginx can read it (Ingress and Secret must be in the same namespace).
The Secret key MUST be "auth" for nginx's auth-file type.
*/}}
{{- range .Values.entries }}
{{- $entry := . }}
{{- $d := $.Values.defaults }}
{{- $entryName := include "ingress-add-domains.resourceName" $entry.name }}
{{- $svc := $entry.service }}
{{- $entryAuth := $entry.auth | default dict }}
{{- $defAuth := $d.auth | default dict }}
{{- $authEnabled := $entryAuth.enabled | default $defAuth.enabled | default false }}
{{- $existingSec := $entryAuth.secretName | default $defAuth.secretName | default "" }}
{{- $credentials := $entryAuth.credentials | default $defAuth.credentials | default "" }}
{{/* Only create a Secret when auth is on, no existing secret is referenced, and credentials are provided */}}
{{- if and $authEnabled (not $existingSec) $credentials }}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ $entryName }}-auth
namespace: {{ $svc.namespace }}
labels:
{{- include "ingress-add-domains.labels" $ | nindent 4 }}
app.kubernetes.io/component: {{ $entryName }}
type: Opaque
data:
auth: {{ $credentials | b64enc | quote }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,23 @@
# Default values — override via group_vars/all/addons.yml → ingress_add_domains_*
defaults:
ingressClass: nginx
tls:
enabled: false
secretName: ""
certManager:
enabled: false
issuer: ""
issuerKind: ClusterIssuer
auth:
enabled: false
credentials: "" # htpasswd string: user:$apr1$...
secretName: "" # use existing Secret instead
websocket: false
path: /
pathType: Prefix
annotations: {}
# Each entry creates one Ingress in the same namespace as the target Service.
# See README.md for full field reference.
entries: []

View File

@@ -0,0 +1,61 @@
---
# ─── Helm release ─────────────────────────────────────────────────────────────
ingress_add_domains_release_name: "ingress-add-domains"
ingress_add_domains_release_namespace: "ingress-add-domains"
# ─── Global defaults (mirror of chart values.defaults) ────────────────────────
ingress_add_domains_defaults:
ingressClass: nginx
tls:
enabled: false
secretName: ""
certManager:
enabled: false
issuer: ""
issuerKind: ClusterIssuer
auth:
enabled: false
username: "" # логин — хэшируется автоматически через openssl passwd -apr1
password: "" # пароль в открытом виде (задай в vault.yml!)
credentials: "" # готовая htpasswd-строка (если задана — username/password игнорируются)
secretName: "" # использовать существующий Secret вместо генерации нового
websocket: false
path: /
pathType: Prefix
annotations: {}
# ─── Entry definitions ────────────────────────────────────────────────────────
# Каждая запись создаёт один Ingress в namespace целевого сервиса.
# Все поля поддерживают per-entry переопределение ingress_add_domains_defaults.
#
# Минимальный пример (*.local без TLS):
# ingress_add_domains_entries:
# - name: gitea-local
# hosts: [gitea.local]
# service:
# name: gitea-http
# namespace: gitea
# port: 3000
#
# Полный пример (*.home.ru с TLS + basic auth):
# ingress_add_domains_entries:
# - name: gitea-home
# hosts:
# - gitea.home.ru
# service:
# name: gitea-http
# namespace: gitea
# port: 3000
# tls:
# enabled: true
# certManager:
# enabled: true
# issuer: letsencrypt-prod
# issuerKind: ClusterIssuer
# auth:
# enabled: true
# username: admin
# password: "{{ vault_gitea_proxy_password }}"
# annotations:
# nginx.ingress.kubernetes.io/proxy-body-size: "0"
ingress_add_domains_entries: []

View File

@@ -0,0 +1,176 @@
---
# ── Validate inputs ───────────────────────────────────────────────────────────
- name: Validate ingress_add_domains_entries is defined and non-empty
ansible.builtin.assert:
that:
- ingress_add_domains_entries is defined
- ingress_add_domains_entries | length > 0
fail_msg: >
ingress_add_domains_entries is empty. Define at least one entry in
group_vars/all/addons.yml → ingress_add_domains_entries.
success_msg: "ingress_add_domains_entries: {{ ingress_add_domains_entries | length }} entry/entries defined"
# ── Create Helm release namespace ─────────────────────────────────────────────
- name: Create ingress-add-domains namespace
ansible.builtin.command: >
k3s kubectl create namespace {{ ingress_add_domains_release_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/ingress-add-domains-chart
state: absent
become: true
- name: Create chart temp directory
ansible.builtin.file:
path: /tmp/ingress-add-domains-chart
state: directory
mode: "0755"
become: true
- name: Copy Helm chart to master
ansible.builtin.copy:
src: "{{ role_path }}/chart/"
dest: /tmp/ingress-add-domains-chart/
mode: preserve
become: true
# ── Generate htpasswd hashes from plain username/password ────────────────────
# Если auth.username + auth.password заданы — автоматически хэшируем через
# openssl passwd -apr1, записываем в auth.credentials и убираем открытые поля.
# Пароли в открытом виде нигде не попадают в Helm values или логи.
- name: Generate htpasswd credentials (username/password → apr1 hash)
ansible.builtin.command:
argv:
- python3
- -c
- |
import json, subprocess, sys
entries = json.loads(sys.argv[1])
def_auth = json.loads(sys.argv[2])
def gen_credentials(auth, fallback):
username = auth.get('username') or fallback.get('username', '')
password = auth.get('password') or fallback.get('password', '')
credentials = auth.get('credentials') or fallback.get('credentials', '')
secret_name = auth.get('secretName') or fallback.get('secretName', '')
if credentials or secret_name:
return credentials
if username and password:
r = subprocess.run(
['openssl', 'passwd', '-apr1', password],
capture_output=True, text=True, check=True
)
return username + ':' + r.stdout.strip()
return ''
cleaned_def_auth = {k: v for k, v in def_auth.items()
if k not in ('username', 'password')}
creds = gen_credentials(def_auth, {})
if creds:
cleaned_def_auth['credentials'] = creds
for entry in entries:
auth = dict(entry.get('auth') or {})
enabled = auth.get('enabled', def_auth.get('enabled', False))
if enabled:
creds = gen_credentials(auth, def_auth)
if creds:
auth['credentials'] = creds
auth.pop('username', None)
auth.pop('password', None)
entry['auth'] = auth
print(json.dumps({'entries': entries, 'def_auth': cleaned_def_auth}))
- "{{ ingress_add_domains_entries | to_json }}"
- "{{ ingress_add_domains_defaults.auth | to_json }}"
register: _auth_processed
changed_when: false
no_log: true
- name: Set final entries and defaults with generated credentials
ansible.builtin.set_fact:
_ingress_add_domains_entries_final: "{{ (_auth_processed.stdout | from_json).entries }}"
_ingress_add_domains_def_auth_final: "{{ (_auth_processed.stdout | from_json).def_auth }}"
# ── Template Helm values ──────────────────────────────────────────────────────
- name: Template Helm values
ansible.builtin.template:
src: values.yaml.j2
dest: /tmp/ingress-add-domains-values.yaml
mode: "0640"
become: true
- name: Show generated Helm values
ansible.builtin.command: cat /tmp/ingress-add-domains-values.yaml
become: true
changed_when: false
register: _ingress_add_domains_values
- name: Debug generated values
ansible.builtin.debug:
var: _ingress_add_domains_values.stdout_lines
# ── Lint chart before deploying ───────────────────────────────────────────────
- name: Lint Helm chart
ansible.builtin.command: >
helm lint /tmp/ingress-add-domains-chart
--values /tmp/ingress-add-domains-values.yaml
become: true
changed_when: false
register: _helm_lint
failed_when: _helm_lint.rc != 0
# ── Deploy chart ──────────────────────────────────────────────────────────────
- name: Deploy ingress-add-domains via Helm
ansible.builtin.command: >
helm upgrade --install {{ ingress_add_domains_release_name }}
/tmp/ingress-add-domains-chart
--namespace {{ ingress_add_domains_release_namespace }}
--values /tmp/ingress-add-domains-values.yaml
--atomic
--wait
--timeout 60s
become: true
register: _helm_result
changed_when: true
# ── Verify deployment ─────────────────────────────────────────────────────────
- name: Get all created Ingresses
ansible.builtin.command: >
k3s kubectl get ingress -A
-l app.kubernetes.io/instance={{ ingress_add_domains_release_name }}
-o wide
become: true
changed_when: false
register: _ingress_list
# ── Summary ───────────────────────────────────────────────────────────────────
- name: "=== ingress-add-domains Ready ==="
ansible.builtin.debug:
msg:
- "╔══════════════════════════════════════════════════════════════╗"
- "║ ingress-add-domains — Deployed ║"
- "╚══════════════════════════════════════════════════════════════╝"
- ""
- " Release : {{ ingress_add_domains_release_name }}"
- " Entries : {{ ingress_add_domains_entries | length }}"
- ""
- " Ingress resources:"
- "{{ _ingress_list.stdout_lines | to_yaml }}"
- ""
- " Verify: kubectl get ingress -A -l app.kubernetes.io/instance={{ ingress_add_domains_release_name }}"

View File

@@ -0,0 +1,9 @@
# Generated by Ansible — do not edit manually.
# Configure via: group_vars/all/addons.yml → ingress_add_domains_* variables.
# Note: auth.username/password are resolved to htpasswd hashes before this file is written.
defaults:
{{ (ingress_add_domains_defaults | combine({'auth': _ingress_add_domains_def_auth_final})) | to_yaml | indent(2, True) }}
entries:
{{ _ingress_add_domains_entries_final | to_yaml | indent(2, True) }}