feat: docker-серверы - приложения в ~/apps под управлением бота
serverus: DockerHost - установка Docker и ingress Caddy, deploy приложения с проверкой compose и caddy.conf до изменений и пересозданием только изменившихся сервисов, status, control, logs, remove. nftables перезапускается только при изменении конфига, Docker следом восстанавливает свои правила; release восстанавливает конфиг Debian вместо остановки nftables. Telegram: прием документов и отправка файлов, раздача файлов своего Bot API сервера через nginx (telegram-files). App: общая модель Managed для Point и Platform, DockerApp и версии файлов AppVersion, разбор архивов (uploads), сценарии платформы (platforms), задачи и меню docker-серверов в боте. Проверено на стенде Debian 13 с local_certs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
65c19ea1cd
commit
a8d1f286d1
42 files changed
+3509
-232
No files matched your search
@@ -0,0 +1,29 @@
|
||||
# extravars: apps_dir, app, action (start, stop, restart, pull), service (optional, без него - все приложение)
|
||||
- name: Control app
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
vars:
|
||||
app_dir: "{{ apps_dir }}/{{ app }}"
|
||||
services: "{{ [service] if service | default('') else [] }}"
|
||||
commands:
|
||||
start: [ [ up, -d ] ]
|
||||
stop: [ [ stop ] ]
|
||||
restart: [ [ restart ] ]
|
||||
pull: [ [ pull ], [ up, -d ] ]
|
||||
tasks:
|
||||
- name: Check app
|
||||
ansible.builtin.stat:
|
||||
path: "{{ app_dir }}/docker-compose.yaml"
|
||||
register: compose_file
|
||||
|
||||
- name: Require app
|
||||
ansible.builtin.assert:
|
||||
that: compose_file.stat.exists
|
||||
fail_msg: "app {{ app }} not found"
|
||||
quiet: true
|
||||
|
||||
- name: Run docker compose
|
||||
ansible.builtin.command:
|
||||
argv: "{{ ['docker', 'compose'] + item + services }}"
|
||||
chdir: "{{ app_dir }}"
|
||||
loop: "{{ commands[action] }}"
|
||||
@@ -0,0 +1,177 @@
|
||||
# extravars: apps_dir, app, archive (tar.gz на управляющей машине: полный набор файлов приложения)
|
||||
#
|
||||
# 1. Файлы распаковываются во временную папку, compose-файл и caddy.conf (caddy validate на копии конфигурации
|
||||
# с новым блоком) проверяются там: ошибка - приложение не тронуто.
|
||||
# 2. Папка приложения приводится к присланной копии по месту (app_sync.py), лишние файлы удаляются.
|
||||
# 3. docker compose up -d --remove-orphans: compose сам пересоздает сервисы с изменившейся конфигурацией.
|
||||
# 4. Сервисы, у которых изменилось что-то в data/<сервис>/, пересоздаются: конфиги они читают при старте.
|
||||
# У нового приложения - нет: up только что создал все контейнеры.
|
||||
# 5. caddy.conf приложения становится блоком ingress. Caddy все же не принял - прежний блок возвращается, ошибка.
|
||||
- name: Deploy app
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
vars:
|
||||
app_dir: "{{ apps_dir }}/{{ app }}"
|
||||
stage: "{{ apps_dir }}/.staging/{{ app }}"
|
||||
snippet: "{{ apps_dir }}/caddy/data/caddy/sites/{{ app }}.caddy"
|
||||
caddy_check: "{{ apps_dir }}/caddy/data/caddy/.check"
|
||||
tasks:
|
||||
- ansible.builtin.import_tasks: tasks/app_scripts.yml
|
||||
|
||||
- name: Deploy
|
||||
block:
|
||||
- name: Clean staging
|
||||
ansible.builtin.file:
|
||||
path: "{{ stage }}"
|
||||
state: absent
|
||||
|
||||
- name: Create staging
|
||||
ansible.builtin.file:
|
||||
path: "{{ stage }}"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Unpack files
|
||||
ansible.builtin.unarchive:
|
||||
src: "{{ archive }}"
|
||||
dest: "{{ stage }}"
|
||||
|
||||
- name: Validate compose file
|
||||
ansible.builtin.command: docker compose -p {{ app | quote }} config -q
|
||||
args:
|
||||
chdir: "{{ stage }}"
|
||||
changed_when: false
|
||||
|
||||
- name: Check new caddy.conf
|
||||
ansible.builtin.stat:
|
||||
path: "{{ stage }}/caddy.conf"
|
||||
register: new_caddy_conf
|
||||
|
||||
# копия конфигурации Caddy с новым блоком вместо прежнего: дубли доменов и ошибки видны до изменений
|
||||
- name: Validate caddy.conf
|
||||
ansible.builtin.shell: |
|
||||
set -e
|
||||
rm -rf {{ caddy_check | quote }}
|
||||
mkdir -p {{ caddy_check | quote }}/sites
|
||||
cp {{ apps_dir | quote }}/caddy/data/caddy/Caddyfile {{ caddy_check | quote }}/
|
||||
find {{ apps_dir | quote }}/caddy/data/caddy/sites -maxdepth 1 -name '*.caddy' ! -name {{ (app + '.caddy') | quote }} \
|
||||
-exec cp {} {{ caddy_check | quote }}/sites/ \;
|
||||
cp {{ stage | quote }}/caddy.conf {{ caddy_check | quote }}/sites/{{ (app + '.caddy') | quote }}
|
||||
cd {{ apps_dir | quote }}/caddy
|
||||
# ошибку показываем без информационных строк Caddy
|
||||
rc=0
|
||||
out=$(docker compose exec -T caddy caddy validate --config /etc/caddy/.check/Caddyfile --adapter caddyfile 2>&1) || rc=$?
|
||||
printf '%s\n' "$out" | grep -v '"level":"info"' >&2 || true
|
||||
exit $rc
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: caddy_validate
|
||||
changed_when: false
|
||||
when: new_caddy_conf.stat.exists
|
||||
|
||||
- name: Sync files
|
||||
ansible.builtin.command: python3 {{ apps_dir }}/.serverus/app_sync.py {{ stage | quote }} {{ app_dir | quote }}
|
||||
register: sync_out
|
||||
changed_when: sync_out.rc == 0 and ((sync_out.stdout | from_json).changed + (sync_out.stdout | from_json).removed) | length > 0
|
||||
always:
|
||||
- name: Remove staging
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop: [ "{{ stage }}", "{{ caddy_check }}" ]
|
||||
|
||||
- name: Start app
|
||||
ansible.builtin.command: docker compose up -d --remove-orphans
|
||||
args:
|
||||
chdir: "{{ app_dir }}"
|
||||
|
||||
- name: Read services
|
||||
ansible.builtin.command: docker compose config --services
|
||||
args:
|
||||
chdir: "{{ app_dir }}"
|
||||
register: services_out
|
||||
changed_when: false
|
||||
|
||||
- name: Find services with changed settings
|
||||
ansible.builtin.set_fact:
|
||||
sync: "{{ sync_out.stdout | from_json }}"
|
||||
services: "{{ services_out.stdout_lines }}"
|
||||
# новое приложение: up только что создал все контейнеры с присланными настройками
|
||||
recreate: >-
|
||||
{{ [] if (sync_out.stdout | from_json).created else
|
||||
((sync_out.stdout | from_json).changed + (sync_out.stdout | from_json).removed)
|
||||
| select('match', '^data/[^/]+/') | map('regex_replace', '^data/([^/]+)/.*$', '\1')
|
||||
| unique | intersect(services_out.stdout_lines) | list }}
|
||||
|
||||
- name: Recreate services with changed settings
|
||||
ansible.builtin.command:
|
||||
argv: [ docker, compose, up, -d, --force-recreate, --no-deps, "{{ item }}" ]
|
||||
chdir: "{{ app_dir }}"
|
||||
loop: "{{ recreate }}"
|
||||
|
||||
- name: Check caddy.conf
|
||||
ansible.builtin.stat:
|
||||
path: "{{ app_dir }}/caddy.conf"
|
||||
register: caddy_conf
|
||||
|
||||
- name: Check current Caddy block
|
||||
ansible.builtin.stat:
|
||||
path: "{{ snippet }}"
|
||||
register: old_snippet
|
||||
|
||||
- name: Save current Caddy block
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ snippet }}"
|
||||
register: old_snippet_content
|
||||
when: old_snippet.stat.exists
|
||||
|
||||
- name: Update Caddy block
|
||||
block:
|
||||
- name: Install Caddy block
|
||||
ansible.builtin.copy:
|
||||
src: "{{ app_dir }}/caddy.conf"
|
||||
dest: "{{ snippet }}"
|
||||
remote_src: true
|
||||
mode: "0644"
|
||||
register: snippet_copy
|
||||
when: caddy_conf.stat.exists
|
||||
|
||||
- name: Remove Caddy block
|
||||
ansible.builtin.file:
|
||||
path: "{{ snippet }}"
|
||||
state: absent
|
||||
register: snippet_remove
|
||||
when: not caddy_conf.stat.exists
|
||||
|
||||
- ansible.builtin.include_tasks: tasks/caddy_reload.yml
|
||||
when: snippet_copy.changed or snippet_remove.changed
|
||||
rescue:
|
||||
- name: Restore previous Caddy block
|
||||
ansible.builtin.copy:
|
||||
content: "{{ old_snippet_content.content | b64decode }}"
|
||||
dest: "{{ snippet }}"
|
||||
mode: "0644"
|
||||
when: old_snippet.stat.exists
|
||||
|
||||
- name: Remove rejected Caddy block
|
||||
ansible.builtin.file:
|
||||
path: "{{ snippet }}"
|
||||
state: absent
|
||||
when: not old_snippet.stat.exists
|
||||
|
||||
- ansible.builtin.include_tasks: tasks/caddy_reload.yml
|
||||
|
||||
- name: Report rejected caddy.conf
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
app is deployed, but Caddy rejected caddy.conf, previous block is kept:
|
||||
{{ (ansible_failed_result.stderr | default(ansible_failed_result.msg | default(''))) | trim | truncate(800) }}
|
||||
|
||||
- name: Return result
|
||||
ansible.builtin.set_stats:
|
||||
aggregate: false
|
||||
data:
|
||||
changed: "{{ sync.changed }}"
|
||||
removed: "{{ sync.removed }}"
|
||||
recreated: "{{ recreate }}"
|
||||
services: "{{ services }}"
|
||||
@@ -0,0 +1,21 @@
|
||||
# extravars: apps_dir, app, service (optional), lines
|
||||
- name: App logs
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
vars:
|
||||
app_dir: "{{ apps_dir }}/{{ app }}"
|
||||
# лог уходит через события ansible: больше мегабайта не нужно
|
||||
logs_limit: 1000000
|
||||
tasks:
|
||||
- name: Read logs
|
||||
ansible.builtin.command:
|
||||
argv: "{{ ['docker', 'compose', 'logs', '--no-color', '--timestamps', '--tail', lines | string] + ([service] if service | default('') else []) }}"
|
||||
chdir: "{{ app_dir }}"
|
||||
register: logs_out
|
||||
changed_when: false
|
||||
|
||||
- name: Return logs
|
||||
ansible.builtin.set_stats:
|
||||
aggregate: false
|
||||
data:
|
||||
logs: "{{ logs_out.stdout[-logs_limit:] }}"
|
||||
@@ -0,0 +1,27 @@
|
||||
# extravars: apps_dir, app, volumes (true - удалить и данные)
|
||||
- name: Remove app
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
vars:
|
||||
app_dir: "{{ apps_dir }}/{{ app }}"
|
||||
snippet: "{{ apps_dir }}/caddy/data/caddy/sites/{{ app }}.caddy"
|
||||
tasks:
|
||||
# по имени проекта: контейнеры удаляются, даже если папки уже нет
|
||||
- name: Stop and remove containers
|
||||
ansible.builtin.command:
|
||||
argv: "{{ ['docker', 'compose', '-p', app, 'down', '--remove-orphans'] + (['--volumes'] if volumes | bool else []) }}"
|
||||
chdir: "{{ apps_dir }}"
|
||||
|
||||
- name: Remove app directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ app_dir }}"
|
||||
state: absent
|
||||
|
||||
- name: Remove Caddy block
|
||||
ansible.builtin.file:
|
||||
path: "{{ snippet }}"
|
||||
state: absent
|
||||
register: snippet_remove
|
||||
|
||||
- ansible.builtin.include_tasks: tasks/caddy_reload.yml
|
||||
when: snippet_remove.changed
|
||||
@@ -0,0 +1,16 @@
|
||||
# extravars: apps_dir
|
||||
- name: Apps status
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- ansible.builtin.import_tasks: tasks/app_scripts.yml
|
||||
|
||||
- name: Read status
|
||||
ansible.builtin.command: python3 {{ apps_dir }}/.serverus/app_status.py {{ apps_dir | quote }}
|
||||
register: status_out
|
||||
changed_when: false
|
||||
|
||||
- name: Return status
|
||||
ansible.builtin.set_stats:
|
||||
aggregate: false
|
||||
data: "{{ status_out.stdout | from_json }}"
|
||||
@@ -0,0 +1,135 @@
|
||||
# extravars: apps_dir, caddy_image, local_certs
|
||||
#
|
||||
# Docker из репозитория Docker, логи контейнеров с ротацией, автоматические обновления безопасности, папка приложений,
|
||||
# сеть web и ingress: приложение caddy, единственное, которое публикует порты. Caddy подключает блоки приложений
|
||||
# из data/caddy/sites/ и сам выпускает сертификаты для их доменов. local_certs - сертификаты своего CA Caddy.
|
||||
# Фаервол настраивается первым: nftables при старте стирает все правила, Docker ставит свои поверх.
|
||||
- name: Install Docker platform
|
||||
hosts: target
|
||||
gather_facts: true
|
||||
vars:
|
||||
caddy_dir: "{{ apps_dir }}/caddy"
|
||||
tasks:
|
||||
- ansible.builtin.import_tasks: tasks/firewall.yml
|
||||
vars:
|
||||
firewall_tcp_ports: [ "{{ ansible_port }}", 80, 443 ]
|
||||
firewall_udp_ports: [ 443 ]
|
||||
# трафик контейнеров идет через forward: его фильтрует Docker
|
||||
firewall_forward: true
|
||||
|
||||
- name: Install prerequisites
|
||||
ansible.builtin.apt:
|
||||
name: [ ca-certificates, curl, python3, unattended-upgrades ]
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Enable automatic security updates
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/apt/apt.conf.d/20auto-upgrades
|
||||
mode: "0644"
|
||||
content: |
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
|
||||
- name: Create keyrings directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/apt/keyrings
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Download Docker key
|
||||
ansible.builtin.get_url:
|
||||
url: https://download.docker.com/linux/debian/gpg
|
||||
dest: /etc/apt/keyrings/docker.asc
|
||||
mode: "0644"
|
||||
|
||||
- name: Add Docker repository
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/apt/sources.list.d/docker.sources
|
||||
mode: "0644"
|
||||
content: |
|
||||
Types: deb
|
||||
URIs: https://download.docker.com/linux/debian
|
||||
Suites: {{ ansible_facts.distribution_release }}
|
||||
Components: stable
|
||||
Architectures: {{ 'arm64' if ansible_facts.architecture == 'aarch64' else 'amd64' }}
|
||||
Signed-By: /etc/apt/keyrings/docker.asc
|
||||
|
||||
- name: Create Docker config directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/docker
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Configure Docker daemon
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/docker/daemon.json
|
||||
mode: "0644"
|
||||
content: |
|
||||
{
|
||||
"log-driver": "local",
|
||||
"log-opts": {"max-size": "10m", "max-file": "3"}
|
||||
}
|
||||
register: docker_daemon
|
||||
|
||||
- name: Install Docker
|
||||
ansible.builtin.apt:
|
||||
name: [ docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin, docker-compose-plugin ]
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
# после частых перезапусков (фаервол, release) systemd может держать Docker остановленным по лимиту запусков
|
||||
- name: Reset Docker start limit
|
||||
ansible.builtin.command: systemctl reset-failed docker.service
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Start Docker
|
||||
ansible.builtin.systemd_service:
|
||||
name: docker
|
||||
enabled: true
|
||||
state: "{{ 'restarted' if docker_daemon.changed else 'started' }}"
|
||||
|
||||
- name: Create apps directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ apps_dir }}"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Create web network
|
||||
ansible.builtin.shell: docker network inspect web >/dev/null 2>&1 || { docker network create web && echo created; }
|
||||
register: web_network
|
||||
changed_when: "'created' in web_network.stdout"
|
||||
|
||||
- name: Create ingress directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ caddy_dir }}/data/caddy/sites"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Write ingress compose file
|
||||
ansible.builtin.template:
|
||||
src: templates/caddy-compose.yaml.j2
|
||||
dest: "{{ caddy_dir }}/docker-compose.yaml"
|
||||
mode: "0600"
|
||||
|
||||
# Caddyfile смонтирован папкой: файл, замененный целиком, контейнер увидел бы только после пересоздания
|
||||
- name: Write Caddyfile
|
||||
ansible.builtin.template:
|
||||
src: templates/Caddyfile.j2
|
||||
dest: "{{ caddy_dir }}/data/caddy/Caddyfile"
|
||||
mode: "0644"
|
||||
register: caddyfile
|
||||
|
||||
- name: Start ingress
|
||||
ansible.builtin.command: docker compose up -d --remove-orphans
|
||||
args:
|
||||
chdir: "{{ caddy_dir }}"
|
||||
register: caddy_up
|
||||
changed_when: "'Started' in caddy_up.stderr or 'Recreated' in caddy_up.stderr"
|
||||
|
||||
- name: Reload Caddyfile
|
||||
ansible.builtin.command: docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
args:
|
||||
chdir: "{{ caddy_dir }}"
|
||||
when: caddyfile.changed and not caddy_up.changed
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Состояние docker-приложений: app_status.py <apps_dir> [<app>].
|
||||
|
||||
Сервисы берутся из compose-файла и сверяются с docker compose ps -a: описан, но контейнера нет - state missing.
|
||||
Контейнеры, которых нет в compose-файле (остались от прошлой версии), тоже попадают в список.
|
||||
Печатает JSON: {"apps": {app: {"services": {service: {"state", "health", "status"}}, "error": ""}}}.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
COMPOSE_FILE = 'docker-compose.yaml'
|
||||
|
||||
|
||||
def compose(app_dir, *args):
|
||||
result = subprocess.run(
|
||||
['docker', 'compose', *args], cwd=app_dir, capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError((result.stderr or result.stdout).strip()[-500:])
|
||||
return result.stdout
|
||||
|
||||
|
||||
def containers(output):
|
||||
"""ps --format json: старый compose печатает массив, новый - объект на строку."""
|
||||
output = output.strip()
|
||||
if not output:
|
||||
return []
|
||||
if output.startswith('['):
|
||||
return json.loads(output)
|
||||
return [json.loads(line) for line in output.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def app_status(app_dir):
|
||||
services = {
|
||||
name: {'state': 'missing', 'health': '', 'status': ''}
|
||||
for name in compose(app_dir, 'config', '--services').split()
|
||||
}
|
||||
for container in containers(compose(app_dir, 'ps', '-a', '--format', 'json')):
|
||||
state = {
|
||||
'state': container.get('State', ''),
|
||||
'health': container.get('Health', ''),
|
||||
'status': container.get('Status', ''),
|
||||
}
|
||||
name = container.get('Service', '')
|
||||
# несколько реплик: показываем проблемную
|
||||
if services.get(name, {}).get('state') in (None, 'missing', 'running'):
|
||||
services[name] = state
|
||||
return services
|
||||
|
||||
|
||||
def main(apps_dir, only=''):
|
||||
apps = {}
|
||||
names = [only] if only else sorted(os.listdir(apps_dir)) if os.path.isdir(apps_dir) else []
|
||||
for name in names:
|
||||
app_dir = os.path.join(apps_dir, name)
|
||||
if name.startswith('.') or not os.path.isfile(os.path.join(app_dir, COMPOSE_FILE)):
|
||||
continue
|
||||
try:
|
||||
apps[name] = {'services': app_status(app_dir), 'error': ''}
|
||||
except (RuntimeError, ValueError, subprocess.TimeoutExpired) as error:
|
||||
apps[name] = {'services': {}, 'error': str(error)}
|
||||
print(json.dumps({'apps': apps}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(*sys.argv[1:])
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Приводит папку приложения к присланной копии: app_sync.py <stage> <app_dir>.
|
||||
|
||||
Файлы переписываются по месту (тот же inode): смонтированный в контейнер файл остается тем же файлом.
|
||||
Лишние файлы удаляются, пустые папки за ними тоже.
|
||||
Печатает JSON: {"changed": [...], "removed": [...], "created": true, если папки приложения не было}.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
|
||||
|
||||
def files(root):
|
||||
result = set()
|
||||
for directory, _, names in os.walk(root):
|
||||
for name in names:
|
||||
result.add(os.path.relpath(os.path.join(directory, name), root))
|
||||
return result
|
||||
|
||||
|
||||
def same(src, dst):
|
||||
if not os.path.isfile(dst) or os.path.islink(dst):
|
||||
return False
|
||||
if stat.S_IMODE(os.stat(src).st_mode) != stat.S_IMODE(os.stat(dst).st_mode):
|
||||
return False
|
||||
with open(src, 'rb') as a, open(dst, 'rb') as b:
|
||||
return a.read() == b.read()
|
||||
|
||||
|
||||
def clear(path):
|
||||
"""Убирает то, что мешает положить файл: папку или ссылку на его месте."""
|
||||
if os.path.islink(path) or (os.path.exists(path) and not os.path.isdir(path)):
|
||||
os.remove(path)
|
||||
elif os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
|
||||
|
||||
def ensure_dir(app_dir, relative):
|
||||
"""Создает папки до файла; файл или ссылка на месте папки удаляется."""
|
||||
current = app_dir
|
||||
for part in relative.split(os.sep)[:-1]:
|
||||
current = os.path.join(current, part)
|
||||
if os.path.islink(current) or (os.path.exists(current) and not os.path.isdir(current)):
|
||||
os.remove(current)
|
||||
os.makedirs(current, exist_ok=True)
|
||||
|
||||
|
||||
def main(stage, app_dir):
|
||||
created = not os.path.isdir(app_dir)
|
||||
os.makedirs(app_dir, mode=0o700, exist_ok=True)
|
||||
new, old = files(stage), files(app_dir)
|
||||
changed, removed = [], []
|
||||
for relative in sorted(old - new):
|
||||
os.remove(os.path.join(app_dir, relative))
|
||||
removed.append(relative)
|
||||
for relative in sorted(new):
|
||||
src, dst = os.path.join(stage, relative), os.path.join(app_dir, relative)
|
||||
if same(src, dst):
|
||||
continue
|
||||
ensure_dir(app_dir, relative)
|
||||
if os.path.isdir(dst) or os.path.islink(dst):
|
||||
clear(dst)
|
||||
with open(src, 'rb') as source, open(dst, 'wb') as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
os.chmod(dst, stat.S_IMODE(os.stat(src).st_mode))
|
||||
changed.append(relative)
|
||||
for directory, _, _ in os.walk(app_dir, topdown=False):
|
||||
if directory != app_dir and not os.listdir(directory):
|
||||
os.rmdir(directory)
|
||||
print(json.dumps({'changed': changed, 'removed': removed, 'created': created}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
@@ -5,19 +5,40 @@
|
||||
tasks:
|
||||
- ansible.builtin.import_tasks: tasks/root_password.yml
|
||||
|
||||
# конфиг Debian по умолчанию: все разрешено. nftables остается включенным
|
||||
- name: Restore default nftables config
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/nftables.conf
|
||||
mode: "0755"
|
||||
content: |
|
||||
#!/usr/sbin/nft -f
|
||||
|
||||
flush ruleset
|
||||
|
||||
- name: Flush and disable nftables
|
||||
table inet filter {
|
||||
chain input {
|
||||
type filter hook input priority filter;
|
||||
}
|
||||
chain forward {
|
||||
type filter hook forward priority filter;
|
||||
}
|
||||
chain output {
|
||||
type filter hook output priority filter;
|
||||
}
|
||||
}
|
||||
|
||||
- name: Apply default nftables rules
|
||||
ansible.builtin.systemd_service:
|
||||
name: nftables
|
||||
enabled: false
|
||||
state: stopped
|
||||
state: restarted
|
||||
|
||||
# flush ruleset стер и правила Docker, приложения на сервере остаются работать
|
||||
- name: Restore Docker rules
|
||||
ansible.builtin.shell: systemctl reset-failed docker.service 2>/dev/null; systemctl try-restart docker
|
||||
register: docker_restart
|
||||
changed_when: false
|
||||
# rc 5 - Docker не установлен
|
||||
failed_when: docker_restart.rc not in [0, 5]
|
||||
|
||||
- name: Remove traffic redirect
|
||||
ansible.builtin.file:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Скрипты приложений на сервере: копируются на каждом запуске, чтобы соответствовать версии serverus
|
||||
- name: Create serverus scripts directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ apps_dir }}/.serverus"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Install app scripts
|
||||
ansible.builtin.copy:
|
||||
src: "files/{{ item }}"
|
||||
dest: "{{ apps_dir }}/.serverus/{{ item }}"
|
||||
mode: "0755"
|
||||
loop: [ app_sync.py, app_status.py ]
|
||||
@@ -0,0 +1,4 @@
|
||||
- name: Reload Caddy
|
||||
ansible.builtin.command: docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
args:
|
||||
chdir: "{{ apps_dir }}/caddy"
|
||||
@@ -1,4 +1,8 @@
|
||||
# vars: firewall_tcp_ports, firewall_udp_ports (optional), firewall_forward (optional, пропускать транзитный трафик)
|
||||
#
|
||||
# Конфиг начинается с flush ruleset: при загрузке nftables стартует раньше Docker, и Docker добавляет свои правила
|
||||
# поверх. Перезапуск nftables на работающем сервере стирает правила Docker, поэтому nftables перезапускается только
|
||||
# при изменении конфига, а Docker следом восстанавливает свои правила (контейнеры с restart поднимутся сами).
|
||||
- name: Install nftables
|
||||
ansible.builtin.apt:
|
||||
name: nftables
|
||||
@@ -10,9 +14,19 @@
|
||||
dest: /etc/nftables.conf
|
||||
mode: "0755"
|
||||
validate: /usr/sbin/nft -c -f %s
|
||||
register: firewall_config
|
||||
|
||||
- name: Apply nftables
|
||||
ansible.builtin.systemd_service:
|
||||
name: nftables
|
||||
enabled: true
|
||||
state: restarted
|
||||
state: "{{ 'restarted' if firewall_config.changed else 'started' }}"
|
||||
|
||||
# bootstrap меняет конфиг дважды подряд: reset-failed сбрасывает счетчик запусков, иначе systemd откажет по лимиту
|
||||
- name: Restore Docker rules
|
||||
ansible.builtin.shell: systemctl reset-failed docker.service 2>/dev/null; systemctl try-restart docker
|
||||
when: firewall_config.changed
|
||||
register: docker_restart
|
||||
changed_when: false
|
||||
# rc 5 - Docker не установлен
|
||||
failed_when: docker_restart.rc not in [0, 5]
|
||||
@@ -0,0 +1,9 @@
|
||||
# {{ ansible_managed }}
|
||||
{% if local_certs | bool %}
|
||||
{
|
||||
# стенд без публичного адреса: сертификаты от собственного CA Caddy вместо Let's Encrypt
|
||||
local_certs
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
import sites/*.caddy
|
||||
@@ -0,0 +1,23 @@
|
||||
# {{ ansible_managed }}
|
||||
# ingress: единственное приложение, которое публикует порты. Блоки приложений - data/caddy/sites/<app>.caddy
|
||||
services:
|
||||
caddy:
|
||||
image: {{ caddy_image }}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
volumes:
|
||||
- ./data/caddy:/etc/caddy:ro
|
||||
- data:/data
|
||||
- config:/config
|
||||
networks: [ web ]
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
data:
|
||||
config:
|
||||
Reference in new issue
Block a user