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:
protokeyandClaude Opus 5.5 committed 2026-09-29 20:27:05 +04:00
1 parent 65c19ea1cd
commit a8d1f286d1
42 files changed
+3509 -232

No files matched your search

+29
View File
@@ -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] }}"
+177
View File
@@ -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 }}"
+21
View File
@@ -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:] }}"
+27
View File
@@ -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
+16
View File
@@ -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 }}"
+135
View File
@@ -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:])
+76
View File
@@ -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])
+24 -3
View File
@@ -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"
+15 -1
View File
@@ -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: