feat: предупреждение о доменах caddy.conf, которые не указывают на сервер
app_deploy возвращает домены сайтов из caddy.conf (caddy adapt), App после деплоя сверяет их A/AAAA с адресом сервера, бот предупреждает: сайт не откроется, сертификат не выпустится. Со своим CA сверки нет. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
803a6bf9f2
commit
adcfb48750
7 files changed
+118
-8
No files matched your search
+3
-1
@@ -9,6 +9,8 @@ Django приложение связывающее все части сайта
|
|||||||
-[x] Супервизор долгих задач и ежедневная проверка точек
|
-[x] Супервизор долгих задач и ежедневная проверка точек
|
||||||
-[x] Docker-серверы: приложения в `~/apps` из бота
|
-[x] Docker-серверы: приложения в `~/apps` из бота
|
||||||
-[ ] Бэкапы named volumes docker-приложений: сейчас данные сервисов (база Gitea, RustDesk и т.п.) теряются при переустановке ОС и `remove` с `volumes`
|
-[ ] Бэкапы named volumes docker-приложений: сейчас данные сервисов (база Gitea, RustDesk и т.п.) теряются при переустановке ОС и `remove` с `volumes`
|
||||||
|
-[ ] Проверка `reverse_proxy` из `caddy.conf` приложения: проверять не только домен, но и доступность самого docker-сервиса за прокси
|
||||||
|
-[ ] Опционально свой registry для образов docker-приложений
|
||||||
|
|
||||||
Зависимости: `Telegram` (интерфейс бота, пользователи, уведомления) и `serverus` (настройка серверов).
|
Зависимости: `Telegram` (интерфейс бота, пользователи, уведомления) и `serverus` (настройка серверов).
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ Django приложение связывающее все части сайта
|
|||||||
- `bot/texts.py` - кнопки и тексты.
|
- `bot/texts.py` - кнопки и тексты.
|
||||||
|
|
||||||
- `uploads.py` - разбор присланных файлов приложения без Django: zip, tar, tar.gz или один файл, слияние (`merge`), разница (`diff`) и проверка по конвенции (`check`: ошибки не дают применить, предупреждения - нет). Мусор архиваторов пропускается, пути наружу и ссылки - отказ, лимиты 5 Мб архив, 20 Мб распаковано, 500 файлов.
|
- `uploads.py` - разбор присланных файлов приложения без Django: zip, tar, tar.gz или один файл, слияние (`merge`), разница (`diff`) и проверка по конвенции (`check`: ошибки не дают применить, предупреждения - нет). Мусор архиваторов пропускается, пути наружу и ссылки - отказ, лимиты 5 Мб архив, 20 Мб распаковано, 500 файлов.
|
||||||
- `platforms.py` - сценарии docker-платформы поверх `serverus.DockerHost`: `install_platform`, `reinstall` (приложения разворачиваются заново с текущими версиями), `release_platform`, `create_app`, `prepare_upload`, `prepare_rollback`, `apply`, `control`, `logs`, `remove_app`, `refresh`.
|
- `platforms.py` - сценарии docker-платформы поверх `serverus.DockerHost`: `install_platform`, `reinstall` (приложения разворачиваются заново с текущими версиями), `release_platform`, `create_app`, `prepare_upload`, `prepare_rollback`, `apply` (после деплоя сверяет домены из `caddy.conf` с адресом сервера: `foreign_domains`, бот предупреждает о не указывающих на него), `control`, `logs`, `remove_app`, `refresh`.
|
||||||
|
|
||||||
## Docker-сервер
|
## Docker-сервер
|
||||||
|
|
||||||
|
|||||||
@@ -490,6 +490,13 @@ def deployed(name: str, data: dict) -> str:
|
|||||||
recreated = data.get('recreated') or []
|
recreated = data.get('recreated') or []
|
||||||
if recreated:
|
if recreated:
|
||||||
lines.append('Пересозданы с новыми настройками: ' + ', '.join(f'<code>{escape(s)}</code>' for s in recreated))
|
lines.append('Пересозданы с новыми настройками: ' + ', '.join(f'<code>{escape(s)}</code>' for s in recreated))
|
||||||
|
for item in data.get('foreign_domains') or []:
|
||||||
|
domain = f'<code>{escape(item.domain)}</code>'
|
||||||
|
if item.addresses:
|
||||||
|
where = f'указывает на {escape(", ".join(item.addresses))}, а сервер - {escape(item.server)}'
|
||||||
|
else:
|
||||||
|
where = 'не найден в DNS'
|
||||||
|
lines.append(f'⚠️ Домен {domain} {where}: сайт по нему не откроется, сертификат не выпустится.')
|
||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+51
-2
@@ -3,7 +3,9 @@
|
|||||||
Файлы приложения живут в App версиями (AppVersion): апдейт сливается с текущей версией здесь, а сервер всегда
|
Файлы приложения живут в App версиями (AppVersion): апдейт сливается с текущей версией здесь, а сервер всегда
|
||||||
получает полный набор файлов. Операции, которые меняют сервер, выполняются задачами (App.tasks) по одной.
|
получает полный набор файлов. Операции, которые меняют сервер, выполняются задачами (App.tasks) по одной.
|
||||||
"""
|
"""
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import serverus
|
import serverus
|
||||||
@@ -26,6 +28,15 @@ class Upload:
|
|||||||
warnings: list[str]
|
warnings: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ForeignDomain:
|
||||||
|
"""Домен из caddy.conf, который не указывает на сервер: сайт по нему не откроется, сертификат не выпустится."""
|
||||||
|
domain: str
|
||||||
|
addresses: tuple[str, ...]
|
||||||
|
"""Адреса домена того же семейства, что адрес сервера. Пусто - домен не найден в DNS."""
|
||||||
|
server: str
|
||||||
|
|
||||||
|
|
||||||
def docker(platform: Platform) -> serverus.DockerHost:
|
def docker(platform: Platform) -> serverus.DockerHost:
|
||||||
return serverus.DockerHost(platform.server.host())
|
return serverus.DockerHost(platform.server.host())
|
||||||
|
|
||||||
@@ -112,7 +123,8 @@ def _draft(app: DockerApp, old: dict[str, bytes], files: dict[str, bytes], kind:
|
|||||||
|
|
||||||
|
|
||||||
def apply(version: AppVersion) -> dict:
|
def apply(version: AppVersion) -> dict:
|
||||||
"""Разворачивает черновик на сервере. Возвращает data DockerHost.deploy: changed, removed, recreated, services.
|
"""Разворачивает черновик на сервере. Возвращает data DockerHost.deploy: changed, removed, recreated, services,
|
||||||
|
domains, и foreign_domains - домены из caddy.conf, которые не указывают на сервер (см. foreign_domains).
|
||||||
|
|
||||||
Ошибка - версия отмечается неудачной. Ошибка проверки compose-файла, его портов или caddy.conf - на сервере остается
|
Ошибка - версия отмечается неудачной. Ошибка проверки compose-файла, его портов или caddy.conf - на сервере остается
|
||||||
прежняя; ошибка запуска (например, образ не скачался) - файлы на сервере уже новые, прежние не возвращаются.
|
прежняя; ошибка запуска (например, образ не скачался) - файлы на сервере уже новые, прежние не возвращаются.
|
||||||
@@ -127,7 +139,44 @@ def apply(version: AppVersion) -> dict:
|
|||||||
raise
|
raise
|
||||||
version.mark_applied()
|
version.mark_applied()
|
||||||
_refresh_after(platform)
|
_refresh_after(platform)
|
||||||
return result.data
|
return {**result.data, 'foreign_domains': foreign_domains(platform, result.data.get('domains') or [])}
|
||||||
|
|
||||||
|
|
||||||
|
def foreign_domains(platform: Platform, domains: list[str]) -> list[ForeignDomain]:
|
||||||
|
"""Домены, которые не указывают на сервер платформы: Let's Encrypt не выпустит для них сертификат.
|
||||||
|
|
||||||
|
Сверяются записи того же семейства, что адрес сервера (A для IPv4). Wildcard (для него нужен DNS-challenge) и адреса
|
||||||
|
вместо доменов пропускаются. Со своим CA (local_ca) сертификаты выпускаются без проверки домена, сверки нет.
|
||||||
|
"""
|
||||||
|
if local_ca() is not None:
|
||||||
|
return []
|
||||||
|
server = ipaddress.ip_address(platform.server.address)
|
||||||
|
family = socket.AF_INET6 if server.version == 6 else socket.AF_INET
|
||||||
|
result = []
|
||||||
|
for domain in domains:
|
||||||
|
if '*' in domain or _is_ip(domain):
|
||||||
|
continue
|
||||||
|
addresses = _resolve(domain, family)
|
||||||
|
if not addresses or any(ipaddress.ip_address(address) != server for address in addresses):
|
||||||
|
result.append(ForeignDomain(domain, addresses, str(server)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ip(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(domain: str, family: int) -> tuple[str, ...]:
|
||||||
|
"""Адреса домена. Не нашелся или DNS не ответил - пусто: деплой уже прошел, проверка его не роняет."""
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(domain, None, family, socket.SOCK_STREAM)
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
return ()
|
||||||
|
return tuple(sorted({info[4][0] for info in infos}))
|
||||||
|
|
||||||
|
|
||||||
def control(app: DockerApp, action: str, service: str = ''):
|
def control(app: DockerApp, action: str, service: str = ''):
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import io
|
import io
|
||||||
|
import socket
|
||||||
import tarfile
|
import tarfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import serverus
|
import serverus
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.test import TestCase
|
from django.test import TestCase, override_settings
|
||||||
from Telegram import interfaces
|
from Telegram import interfaces
|
||||||
from Telegram.models import Bot, Chat, User as TelegramUser
|
from Telegram.models import Bot, Chat, User as TelegramUser
|
||||||
from Telegram.testing import Conversation, FakeApi
|
from Telegram.testing import Conversation, FakeApi
|
||||||
@@ -55,10 +56,11 @@ def make_tar(files: dict[str, bytes], symlink: str = '') -> bytes:
|
|||||||
class DockerRunner(FakeRunner):
|
class DockerRunner(FakeRunner):
|
||||||
"""FakeRunner с ответами плейбуков docker-платформы. Запоминает файлы, ушедшие в deploy."""
|
"""FakeRunner с ответами плейбуков docker-платформы. Запоминает файлы, ушедшие в deploy."""
|
||||||
|
|
||||||
def __init__(self, fail=None, snapshot=None):
|
def __init__(self, fail=None, snapshot=None, domains=()):
|
||||||
super().__init__(fail=fail)
|
super().__init__(fail=fail)
|
||||||
self.deployed = {}
|
self.deployed = {}
|
||||||
self.snapshot = SNAPSHOT if snapshot is None else snapshot
|
self.snapshot = SNAPSHOT if snapshot is None else snapshot
|
||||||
|
self.domains = list(domains)
|
||||||
|
|
||||||
def __call__(self, playbook, host, extravars):
|
def __call__(self, playbook, host, extravars):
|
||||||
result = super().__call__(playbook, host, extravars)
|
result = super().__call__(playbook, host, extravars)
|
||||||
@@ -71,7 +73,7 @@ class DockerRunner(FakeRunner):
|
|||||||
return result
|
return result
|
||||||
data = {
|
data = {
|
||||||
'app_status': self.snapshot,
|
'app_status': self.snapshot,
|
||||||
'app_deploy': {'changed': [], 'removed': [], 'recreated': ['web'], 'services': ['web']},
|
'app_deploy': {'changed': [], 'removed': [], 'recreated': ['web'], 'services': ['web'], 'domains': self.domains},
|
||||||
'app_logs': {'logs': 'line 1\nline 2'},
|
'app_logs': {'logs': 'line 1\nline 2'},
|
||||||
}.get(playbook)
|
}.get(playbook)
|
||||||
if data is None:
|
if data is None:
|
||||||
@@ -320,6 +322,35 @@ class PlatformScenarioTests(TestCase):
|
|||||||
platform.refresh_from_db()
|
platform.refresh_from_db()
|
||||||
self.assertEqual((platform.status, platform.status_snapshot), (Platform.Status.READY, SNAPSHOT))
|
self.assertEqual((platform.status, platform.status_snapshot), (Platform.Status.READY, SNAPSHOT))
|
||||||
|
|
||||||
|
def test_apply_reports_foreign_domains(self):
|
||||||
|
app = DockerApp.objects.create(platform=ready_platform(), name='git')
|
||||||
|
upload = platforms.prepare_upload(app, 'app.tar.gz', make_tar({'docker-compose.yaml': COMPOSE}), AppVersion.Kind.FULL)
|
||||||
|
answers = {'ok.example.com': ['10.0.1.1'], 'git.example.com': ['93.184.215.14'], 'half.example.com': ['10.0.1.1', '10.0.1.2']}
|
||||||
|
|
||||||
|
def getaddrinfo(host, port, family, kind):
|
||||||
|
if host not in answers:
|
||||||
|
raise socket.gaierror(socket.EAI_NONAME, 'Name or service not known')
|
||||||
|
return [(family, kind, 6, '', (address, 0)) for address in answers[host]]
|
||||||
|
|
||||||
|
domains = ['ok.example.com', 'git.example.com', 'half.example.com', 'none.example.com', '*.example.com', '10.0.1.2']
|
||||||
|
with patch_runner(DockerRunner(domains=domains)), mock.patch('App.platforms.socket.getaddrinfo', getaddrinfo):
|
||||||
|
data = platforms.apply(upload.version)
|
||||||
|
self.assertEqual(data['foreign_domains'], [
|
||||||
|
platforms.ForeignDomain('git.example.com', ('93.184.215.14',), '10.0.1.1'),
|
||||||
|
platforms.ForeignDomain('half.example.com', ('10.0.1.1', '10.0.1.2'), '10.0.1.1'),
|
||||||
|
platforms.ForeignDomain('none.example.com', (), '10.0.1.1'),
|
||||||
|
])
|
||||||
|
message = texts.deployed('git', data)
|
||||||
|
self.assertIn('<code>git.example.com</code> указывает на 93.184.215.14, а сервер - 10.0.1.1', message)
|
||||||
|
self.assertIn('<code>none.example.com</code> не найден в DNS', message)
|
||||||
|
self.assertNotIn('ok.example.com', message)
|
||||||
|
|
||||||
|
@override_settings(SERVERUS_CERTIFICATES='local')
|
||||||
|
def test_local_ca_skips_domain_check(self):
|
||||||
|
with mock.patch('App.platforms.socket.getaddrinfo') as getaddrinfo:
|
||||||
|
self.assertEqual(platforms.foreign_domains(ready_platform(), ['git.example.com']), [])
|
||||||
|
getaddrinfo.assert_not_called()
|
||||||
|
|
||||||
def test_apply_failure_keeps_previous_version(self):
|
def test_apply_failure_keeps_previous_version(self):
|
||||||
platform = ready_platform()
|
platform = ready_platform()
|
||||||
app = deployed_app(platform)
|
app = deployed_app(platform)
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ app.example.com {
|
|||||||
|
|
||||||
Caddy выпускает сертификат для каждого домена из блоков сам, домен должен указывать на сервер.
|
Caddy выпускает сертификат для каждого домена из блоков сам, домен должен указывать на сервер.
|
||||||
|
|
||||||
- `deploy(name, files)` - `files` - полный набор файлов `{путь: bytes}`. Compose-файл, его опубликованные порты (после подстановки переменных из `.env`: не слушает ли их уже сервер или контейнер другого приложения) и `caddy.conf` (`caddy validate` на копии конфигурации Caddy с новым блоком: ошибки и дубли доменов) проверяются до изменений: ошибка - приложение не тронуто. Файлы обновляются по месту (тот же inode, смонтированный файл остается тем же файлом), затем `docker compose up -d --remove-orphans`: compose сам пересоздает сервисы, у которых изменилась конфигурация, в том числе из-за `.env`. Сервисы, у которых изменилось что-то в `data/<сервис>/`, пересоздаются отдельно. Изменился `caddy.conf` - Caddy перезагружается; не принял блок - прежний возвращается, `deploy` падает с ошибкой Caddy. Упал сам `up` (например, образ не скачался) - файлы на сервере уже новые, отката нет: исправленные файлы нужно залить заново. Возвращает `data`: `changed`, `removed` (пути), `recreated`, `services`.
|
- `deploy(name, files)` - `files` - полный набор файлов `{путь: bytes}`. Compose-файл, его опубликованные порты (после подстановки переменных из `.env`: не слушает ли их уже сервер или контейнер другого приложения) и `caddy.conf` (`caddy validate` на копии конфигурации Caddy с новым блоком: ошибки и дубли доменов) проверяются до изменений: ошибка - приложение не тронуто. Файлы обновляются по месту (тот же inode, смонтированный файл остается тем же файлом), затем `docker compose up -d --remove-orphans`: compose сам пересоздает сервисы, у которых изменилась конфигурация, в том числе из-за `.env`. Сервисы, у которых изменилось что-то в `data/<сервис>/`, пересоздаются отдельно. Изменился `caddy.conf` - Caddy перезагружается; не принял блок - прежний возвращается, `deploy` падает с ошибкой Caddy. Упал сам `up` (например, образ не скачался) - файлы на сервере уже новые, отката нет: исправленные файлы нужно залить заново. Возвращает `data`: `changed`, `removed` (пути), `recreated`, `services`, `domains` - хосты сайтов из `caddy.conf` (`caddy adapt`, включая wildcard): вызывающий может проверить, что они указывают на сервер, иначе сертификат не выпустится.
|
||||||
- `status()` - все приложения: сервисы из compose-файла, сверенные с `docker ps -a`. Сервис без контейнера - `missing`. `data`: `{'apps': {app: {'services': {service: {'state', 'health', 'status'}}, 'error': ''}}}`.
|
- `status()` - все приложения: сервисы из compose-файла, сверенные с `docker ps -a`. Сервис без контейнера - `missing`. `data`: `{'apps': {app: {'services': {service: {'state', 'health', 'status'}}, 'error': ''}}}`.
|
||||||
- `control(name, action, service='')` - `start` (`up -d`), `stop`, `restart`, `pull` (новые образы и `up -d`) приложения или одного сервиса.
|
- `control(name, action, service='')` - `start` (`up -d`), `stop`, `restart`, `pull` (новые образы и `up -d`) приложения или одного сервиса.
|
||||||
- `logs(name, service='', lines=500)` - `data['logs']`, до 1 Мб.
|
- `logs(name, service='', lines=500)` - `data['logs']`, до 1 Мб.
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ class DockerHost(Server):
|
|||||||
конфигурацией) и пересоздает сервисы, у которых изменилось что-то в DATA_DIR/<сервис>/. Изменился CADDY_FILE -
|
конфигурацией) и пересоздает сервисы, у которых изменилось что-то в DATA_DIR/<сервис>/. Изменился CADDY_FILE -
|
||||||
перезагружает Caddy; не принял - прежний блок возвращается, deploy завершается ошибкой.
|
перезагружает Caddy; не принял - прежний блок возвращается, deploy завершается ошибкой.
|
||||||
Упал сам запуск (например, образ не скачался) - файлы на сервере уже новые, отката нет: нужно залить исправленные.
|
Упал сам запуск (например, образ не скачался) - файлы на сервере уже новые, отката нет: нужно залить исправленные.
|
||||||
Возвращает data: {'changed': [...], 'removed': [...], 'recreated': [...], 'services': [...]}.
|
Возвращает data: {'changed': [...], 'removed': [...], 'recreated': [...], 'services': [...], 'domains': [...]}.
|
||||||
|
domains - хосты сайтов из CADDY_FILE как их видит Caddy, в том числе wildcard: Caddy выпустит для них сертификаты,
|
||||||
|
только если они указывают на сервер.
|
||||||
"""
|
"""
|
||||||
_check_app(name)
|
_check_app(name)
|
||||||
if name == INGRESS:
|
if name == INGRESS:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#
|
#
|
||||||
# 1. Файлы распаковываются во временную папку, compose-файл, его порты (app_ports.py: не заняты ли на сервере)
|
# 1. Файлы распаковываются во временную папку, compose-файл, его порты (app_ports.py: не заняты ли на сервере)
|
||||||
# и caddy.conf (caddy validate на копии конфигурации с новым блоком) проверяются там: ошибка - приложение не тронуто.
|
# и caddy.conf (caddy validate на копии конфигурации с новым блоком) проверяются там: ошибка - приложение не тронуто.
|
||||||
|
# Домены сайтов из caddy.conf (caddy adapt) возвращаются в результате.
|
||||||
# 2. Папка приложения приводится к присланной копии по месту (app_sync.py), лишние файлы удаляются.
|
# 2. Папка приложения приводится к присланной копии по месту (app_sync.py), лишние файлы удаляются.
|
||||||
# 3. docker compose up -d --remove-orphans: compose сам пересоздает сервисы с изменившейся конфигурацией.
|
# 3. docker compose up -d --remove-orphans: compose сам пересоздает сервисы с изменившейся конфигурацией.
|
||||||
# Упал (например, образ не скачался) - файлы уже новые, отката нет.
|
# Упал (например, образ не скачался) - файлы уже новые, отката нет.
|
||||||
@@ -75,6 +76,17 @@
|
|||||||
changed_when: false
|
changed_when: false
|
||||||
when: new_caddy_conf.stat.exists
|
when: new_caddy_conf.stat.exists
|
||||||
|
|
||||||
|
# домены сайтов из блока: вызывающий проверит, что они указывают на сервер, иначе сертификат не выпустится
|
||||||
|
- name: Read caddy.conf domains
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
docker compose exec -T caddy caddy adapt --adapter caddyfile
|
||||||
|
--config /etc/caddy/.check/sites/{{ (app + '.caddy') | quote }}
|
||||||
|
args:
|
||||||
|
chdir: "{{ apps_dir }}/caddy"
|
||||||
|
register: caddy_adapt
|
||||||
|
changed_when: false
|
||||||
|
when: new_caddy_conf.stat.exists
|
||||||
|
|
||||||
- name: Sync files
|
- name: Sync files
|
||||||
ansible.builtin.command: python3 {{ apps_dir }}/.serverus/app_sync.py {{ stage | quote }} {{ app_dir | quote }}
|
ansible.builtin.command: python3 {{ apps_dir }}/.serverus/app_sync.py {{ stage | quote }} {{ app_dir | quote }}
|
||||||
register: sync_out
|
register: sync_out
|
||||||
@@ -102,6 +114,12 @@
|
|||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
sync: "{{ sync_out.stdout | from_json }}"
|
sync: "{{ sync_out.stdout | from_json }}"
|
||||||
services: "{{ services_out.stdout_lines }}"
|
services: "{{ services_out.stdout_lines }}"
|
||||||
|
domains: >-
|
||||||
|
{{ [] if caddy_adapt is skipped else
|
||||||
|
((caddy_adapt.stdout | from_json).get('apps', {}).get('http', {}).get('servers', {}) | dict2items
|
||||||
|
| map(attribute='value') | selectattr('routes', 'defined') | map(attribute='routes') | flatten(levels=1)
|
||||||
|
| selectattr('match', 'defined') | map(attribute='match') | flatten(levels=1)
|
||||||
|
| selectattr('host', 'defined') | map(attribute='host') | flatten(levels=1) | unique | list) }}
|
||||||
# новое приложение: up только что создал все контейнеры с присланными настройками
|
# новое приложение: up только что создал все контейнеры с присланными настройками
|
||||||
recreate: >-
|
recreate: >-
|
||||||
{{ [] if (sync_out.stdout | from_json).created else
|
{{ [] if (sync_out.stdout | from_json).created else
|
||||||
@@ -181,3 +199,4 @@
|
|||||||
removed: "{{ sync.removed }}"
|
removed: "{{ sync.removed }}"
|
||||||
recreated: "{{ recreate }}"
|
recreated: "{{ recreate }}"
|
||||||
services: "{{ services }}"
|
services: "{{ services }}"
|
||||||
|
domains: "{{ domains }}"
|
||||||
Reference in new issue
Block a user