Compare commits

..
3 Commits
Author SHA1 Message Date
deytenit 9ee672504f fix: PageHead header alignment, analytics proxy, and infra improvements (#64)
🤖 Pull request created from `various-fixes` containing PageHead
breadcrumbs and navigation alignment fixes, Umami analytics proxy, and
infrastructure updates.
2026-10-24 16:52:49 +03:00
deytenit ff5a50bad8 fix(ci): allow plan tools in headless mode (#62) (#63) 2026-09-24 14:35:11 +03:00
adam-at-deytenit 68dcfc303c chore(release): deploy www sha-2bf21cd [skip ci] 2026-09-18 18:15:32 +00:00
112 changed files with 2651 additions and 1762 deletions

No files matched your search

+1 -1
View File
@@ -10,4 +10,4 @@ COM_ERMNVLDMR_STATIC_S3_REGION="encrypted:BAj3iuo7tWWNR2V4yQ+LHnFCB4JcZp7TTfUtvv
COM_ERMNVLDMR_STATIC_S3_ENDPOINT_URL="encrypted:BNkQrgsrxtz544vuJbgwWUh5cPjHG5wbmHNcxTUWrzbFJJsdncaAitviCvIrS7Q41w+FiWkssv3D05Tu8Inn2P62E6G3PU4gNxO2oLdmGEdrzUqB/xvxOvLvos42j54QOZf7lOqMPKH6SL2/rheCAo10gSCicCqkfh4xsCAbYpyL"
COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID="encrypted:BCsp8p4bp3cB+9grxHo634isRUaFGU3MOc1lr8ClSFRYLjqjqukyrbJCTZWV2Xdo8iQ33EylmbzUltbwzTWMQcjJSvIskesVLyV93q60o/Mu9L9jg/apwHuBgHv4zVuHstJ0zkHWgapbNTIlWD3RCm0ujeX/H1gBH3U="
COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY="encrypted:BM7aPliOyiPrqFPZMfqpn534Nu+Y0Nbh/YRoxv2KY6XAi2dNO0LSEf97DqSUQYdsnu9iibZmVNktF2K/D6mtHr9+gumF6KsmaLH65KE7c9ly7NdFQHL9I26wxRq1Sr3Zgrfs954J6oURSlBRKqJFdcTsBltuuupGuqJHyLS9+DE0jD+bl0Qg0lA="
SOPS_AGE_KEY="encrypted:BOJ7q79Tws4DMxpFuSymgv//IC4f+j713Bd7mNlUCEaUYVSfJMayMVPzks0FcsYo12XvlbH6gQ+tCf3t9yj/LcKKJzl3wtxlSVjvCRr0hasEBohcoxkLnFZ73DFQGzWcVC5Jf5IMK7qdYdNVzW0dVY/sn/NlTgl+A3YB6/08XYPhljl9993ky76zQqWDJQ66bKJ7YxWvst2TUNoT0lJEL0M4pg9ztRsuGqNZ"
SOPS_AGE_KEY="encrypted:BKiJVxKYhiadu0kNCw8wVLYdaEWZreAVu2PaujxhvcXeCowCGjivEuQD/uhduAy/CwwDWyQkgQJ2FJMEKA47e7QDjS9TMdPCXO19/teR7LThgqTf6AbSM3sebln5zalgyplay4A/7cVytPXuOvVkaYyoh9CgzTVsPJKXRq5yMaDEx/9/91Yo8zUMF3z1vq3/preI5baK4/1xXewR3OwfZqnNGyd7LXkdqyT5"
+51
View File
@@ -0,0 +1,51 @@
name: CodeQL
on:
push:
branches: [next]
paths-ignore:
- '**/kustomization.yaml'
pull_request:
branches: [next]
paths-ignore:
- '**/kustomization.yaml'
schedule:
- cron: '43 8 * * 3'
concurrency:
group: codeql-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
security-events: write
actions: read
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
security-events: write
actions: read
strategy:
fail-fast: false
matrix:
language: ['actions', 'javascript-typescript']
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -176,7 +176,7 @@ jobs:
PROMPT="You are operating via GitHub Actions in non-interactive headless mode on Issue #${ISSUE_NUMBER}.\n\nIssue Context:\n${ISSUE_DATA}\n\nLatest instruction:\n${COMMENT_BODY}\n\nPlease generate/update a detailed technical implementation plan adhering to project GEMINI.md guidelines."
PLAN_OUTPUT=$(agy $CONTINUE_FLAG --mode plan --print-timeout 20m -p "$PROMPT" || echo "Agent encountered an error generating the plan.")
PLAN_OUTPUT=$(agy $CONTINUE_FLAG --mode plan --dangerously-skip-permissions --print-timeout 20m -p "$PROMPT" || echo "Agent encountered an error generating the plan.")
DISCLOSURE="\n\n---\n*Disclosure: I'm an AI assistant helping deytenit; I'm not deytenit.*"
gh issue comment "$ISSUE_NUMBER" --body "${PLAN_OUTPUT}${DISCLOSURE}"
+9
View File
@@ -25,6 +25,9 @@ playwright-report/
.env
.env.keys
# local development certificates
.certs/
# macOS-specific files
.DS_Store
@@ -43,3 +46,9 @@ docs/superpowers/
.sops/keys.txt
ci/k8s/**/secret.yaml
ci/cloud-init/user-data.yaml
# Packer & QEMU Build Artifacts
output-qemu/
*.qcow2
*.iso
+1 -1
View File
@@ -1,4 +1,4 @@
creation_rules:
- path_regex: ci/k8s/.*secret.*\.yaml$
encrypted_regex: '^(data|stringData)$'
age: "age1jpa7hqyzzdfq7lvnw3l8qy6648mtjgpcqnljl5jz92dcqjrnsqfsn7m0fl"
age: "age15zq3cje3ttmx2fhqp9cpeght6feq2w8e5m38799stjetj8urcccs36r4rc,age1jpa7hqyzzdfq7lvnw3l8qy6648mtjgpcqnljl5jz92dcqjrnsqfsn7m0fl"
+3 -181
View File
@@ -35,186 +35,8 @@ write_files:
# public key: age1jpa7hqyzzdfq7lvnw3l8qy6648mtjgpcqnljl5jz92dcqjrnsqfsn7m0fl
AGE-SECRET-KEY-1PLACEHOLDERFORYOURPRIVATEAGEKEY
# ----------------------------------------------------------------------------
# 3. Security Hardening Configuration
# ----------------------------------------------------------------------------
- path: /etc/ssh/sshd_config.d/99-security-hardening.conf
owner: root:root
permissions: '0600'
content: |
PasswordAuthentication no
PermitRootLogin prohibit-password
KbdInteractiveAuthentication no
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
- path: /etc/sysctl.d/99-k3s-hardening.conf
owner: root:root
permissions: '0644'
content: |
# Network SYN Flood Protection
net.ipv4.tcp_syncookies = 1
# Disable ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
# Disable Source Routing
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
# Anti-spoofing (Reverse Path Filtering)
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Required for K8s pod networking
net.ipv4.ip_forward = 1
# System Hardening
kernel.kptr_restrict = 2
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 512
- path: /etc/rancher/k3s/config.yaml
owner: root:root
permissions: '0600'
content: |
write-kubeconfig-mode: "0644"
# ----------------------------------------------------------------------------
# 4. Bootstrap Cluster Script
# ----------------------------------------------------------------------------
- path: /usr/local/bin/bootstrap-cluster.sh
owner: root:root
permissions: '0755'
content: |
#!/usr/bin/env bash
set -euo pipefail
echo "=== [Bootstrap] Ensuring Reliable DNS Resolution ==="
if ! grep -q "nameserver" /etc/resolv.conf 2>/dev/null; then
cat << 'EOF_DNS' > /etc/resolv.conf
nameserver 1.1.1.1
nameserver 8.8.8.8
nameserver 77.88.8.8
EOF_DNS
fi
echo "=== [Bootstrap] Applying Sysctl Hardening ==="
sysctl --system
echo "=== [Bootstrap] Ensuring SSH Host Keys & Starting SSH ==="
ssh-keygen -A
systemctl restart ssh || systemctl restart sshd || true
echo "=== [Bootstrap] Configuring UFW Firewall ==="
ufw default deny incoming
ufw default allow outgoing
ufw default allow routed
ufw allow 22/tcp comment 'SSH'
ufw allow 80/tcp comment 'HTTP Traefik'
ufw allow 443/tcp comment 'HTTPS Traefik'
# Allow Kubernetes Pod & Service traffic
ufw allow in on cni0 || true
ufw allow in on flannel.1 || true
ufw allow from 10.42.0.0/16 comment 'K3s Pod CIDR'
ufw allow from 10.43.0.0/16 comment 'K3s Service CIDR'
ufw --force enable
echo "=== [Bootstrap] Enabling Fail2ban ==="
systemctl enable --now fail2ban || true
echo "=== [Bootstrap] Checking for Secondary Persistent Data Disk ==="
if [ -b /dev/vdb ]; then
echo "Secondary disk /dev/vdb detected."
if ! blkid /dev/vdb >/dev/null 2>&1; then
echo "Formatting /dev/vdb with ext4 filesystem..."
mkfs.ext4 -F -L k3s-storage /dev/vdb
fi
mkdir -p /var/lib/rancher/k3s/storage
if ! grep -qs '/var/lib/rancher/k3s/storage' /proc/mounts; then
mount /dev/vdb /var/lib/rancher/k3s/storage
echo "LABEL=k3s-storage /var/lib/rancher/k3s/storage ext4 defaults,noatime 0 2" >> /etc/fstab
echo "Mounted /dev/vdb to /var/lib/rancher/k3s/storage."
fi
fi
echo "=== [Bootstrap] Starting K3s Service ==="
systemctl enable --now k3s.service
echo "=== [Bootstrap] Waiting for K3s API server ready ==="
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
until kubectl get nodes >/dev/null 2>&1; do
echo "Waiting for Kubernetes API..."
sleep 3
done
echo "=== [Bootstrap] Cluster node is online ==="
kubectl get nodes
echo "=== [Bootstrap] Creating flux-system namespace ==="
kubectl create namespace flux-system --dry-run=client -o yaml | kubectl apply -f -
echo "=== [Bootstrap] Injecting SOPS age decryption key ==="
if [ -f /root/.config/sops/age/keys.txt ]; then
kubectl create secret generic sops-age \
--namespace=flux-system \
--from-file=age.agekey=/root/.config/sops/age/keys.txt \
--dry-run=client -o yaml | kubectl apply -f -
echo "sops-age secret created."
else
echo "WARNING: /root/.config/sops/age/keys.txt not found. Inject key manually via kubectl."
fi
echo "=== [Bootstrap] Installing Flux Operator / Custom Resources ==="
kubectl apply -f https://github.com/fluxcd/flux2/releases/download/v2.4.0/install.yaml
echo "=== [Bootstrap] Setting up Kubectl access for non-root users ==="
for home_dir in /home/*; do
if [ -d "$home_dir" ]; then
user_name=$(basename "$home_dir")
mkdir -p "$home_dir/.kube"
cp /etc/rancher/k3s/k3s.yaml "$home_dir/.kube/config"
chown -R "$user_name:$user_name" "$home_dir/.kube"
chmod 600 "$home_dir/.kube/config"
fi
done
echo "=== [Bootstrap] Applying initial GitOps root sync ==="
kubectl apply -f - << 'EOF_GITOPS'
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: ermnvldmr-repo
namespace: flux-system
spec:
interval: 1m
url: https://github.com/deytenit/ermnvldmr.com.git
ref:
branch: next
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: cluster-sync
namespace: flux-system
spec:
interval: 5m
sourceRef:
kind: GitRepository
name: ermnvldmr-repo
path: ./ci/k8s/clusters/production
prune: true
wait: true
timeout: 2m
decryption:
provider: sops
secretRef:
name: sops-age
EOF_GITOPS
echo "=== [Bootstrap] First-Boot Bootstrap Sequence Complete ==="
# ------------------------------------------------------------------------------
# 3. Bootstrap Cluster (Pre-baked script mounts disk, starts K3s, and syncs Flux)
# ------------------------------------------------------------------------------
runcmd:
- /usr/local/bin/bootstrap-cluster.sh
+7
View File
@@ -5,6 +5,13 @@ server {
root /usr/share/nginx/html;
index index.html;
# Security Headers (Defense in depth)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Gzip compression
gzip on;
gzip_vary on;
+14 -5
View File
@@ -12,11 +12,20 @@ sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxRXIvMEpaWU1XNjV2OFpp
Z1lYb25kOFVVaXlRYkdzSmxWbmxqaXR6TEhRCnF0M2ovRVNmdTU3WnNOcy82OExV
YXNjZVJrbTdXQWJxMjQvNEdZNlBwcjAKLS0tIEEzczNmZjFwT1d2aXVKV1Bvdm1Q
UnZoNVNCYjJiYWd6WlNobGEyUGh5NmMKkUTDkYlNkXH8DegvBT7ymkL7h1B9x77N
IxeZI5mp8vc347/620+cbMMr2+aELlgs9/bJhUcLRm0Bv8UDVLOleg==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRWFNtaHhPbmlodUZXSFgy
S1YwUjc4ZnZWdy9TM1NiejdrRHJuZnR5aG5rCjJrMWY2OXExZkpmUHpUNTE1T05x
dzBYbmI3ZmVHRlhXaHk5dFFEMjIxUUUKLS0tIEd0L2N4TUsxV0tjWm81cUQ4dVdS
WDVuSGxuNVAxeVk2UUdhQnFDV1FZOW8K4Fc8T/XM+E6F0iOqpSMbh/pOAI2vq3am
9rMDxzwfIL9hwNDoy5LVPdnBfOXlCoZ8OnMR9Euj8FBW6pHErkmBUA==
-----END AGE ENCRYPTED FILE-----
recipient: age15zq3cje3ttmx2fhqp9cpeght6feq2w8e5m38799stjetj8urcccs36r4rc
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0K3k2cis1T3hrSmZGa2hq
ME1VQy9HSG5MWUhjdXgzb0cwNitYY1ZvRVI4Cm0vYXptOW9TUDJFZzZvNEJLOHJB
VnhnK1hOWUgxd2E1VmFDbFJPZUlQajAKLS0tIEszV0FRMHoyQ2lFUm5IWGtnVUNH
Q244Nmd2czZXSmxEMnk3cWV1bm8rS28KGyANylD4EQkhWKDHwmGQHyAm1ZjA5acw
VisJZnJ9KuFPB2Toi7FTMeHRNYPk1uXgwKoDFLSrzMElPXWDWm4B7A==
-----END AGE ENCRYPTED FILE-----
recipient: age1jpa7hqyzzdfq7lvnw3l8qy6648mtjgpcqnljl5jz92dcqjrnsqfsn7m0fl
encrypted_regex: ^(data|stringData)$
+1
View File
@@ -6,3 +6,4 @@ metadata:
data:
DATABASE_TYPE: "postgresql"
CLIENT_IP_HEADER: "X-Forwarded-For"
TRACKER_SCRIPT_NAME: "beacon.js"
+2
View File
@@ -17,6 +17,8 @@ spec:
metadata:
labels:
app: umami
annotations:
config/tracker-script-name: "beacon.js"
spec:
containers:
- name: umami
+14 -5
View File
@@ -11,11 +11,20 @@ sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGZ2h6THNGTnY2ay9Tc2Q3
S0xxU3NEZGx2TS9MRFdqZDV6aEtUalBWRFVJCjZMd1VCdkg1cjIxSlliZXNRQnU0
YnMzTXJOSGdsUDUwNkdDdWZWcmRaZjgKLS0tIDdEOFoyOWtvZytITys4bk9ra082
WmFIREJMS1BVU0o1VnZHby9ZSHN4cGcKReBW7hdTPH8eGqPzEYkOdrIUYm9a1jOi
du/1fhAyhjimbbCNdOSuSjrSP8QX6mxwPoDb1dVlKi0Rah5VGW+HWw==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4SHJWSm54SHorbTZBOGtF
VkRrQlpHbTgxSkh3L2ljZW42emEzQlBHa0hvCmxuQ3VjR2hQNzVlZ3FVS2Q3THp4
SEZGa09EM1BYSlUxWEtuMGp0Tld3R3cKLS0tIEcwcUd6ZkdDdTAvMDFtSGpCZ3ls
TVJYdHRDMXlWVmx4WStsT2hGUUZkeEkK26wJbgH0yvKPFXR4TC2rdeBD0axAs4WU
rfJ8MWet6AbEAyOmiAjigNbM9bJwGlDW1UThnGQ/fFQfJW6U4T1Tgw==
-----END AGE ENCRYPTED FILE-----
recipient: age15zq3cje3ttmx2fhqp9cpeght6feq2w8e5m38799stjetj8urcccs36r4rc
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKVUt1UlNLTHZmV1djWEZp
akVLUnliV2VQY0dzSmVMdmxMRTRoL2FWZm5rCm1WWkZPK2tEWW4rNVRObktuVVdk
cEdNTHd6V2t6ZjVRMWhRbk5GNXpFdjgKLS0tIHZoZS9wblR1bTFkMzV4OWxLSm5B
QWpwUG93dTc5MGNBbjAyVnNWMlRJblEK8ZRPL4/aCSKjPUg1K+kH9IigwZX1tB1U
Y1P8IYiuTJ+YD/UcQyh0Pp2d8l5gQsTc1ES9388wcKBKMeKHV9FqYg==
-----END AGE ENCRYPTED FILE-----
recipient: age1jpa7hqyzzdfq7lvnw3l8qy6648mtjgpcqnljl5jz92dcqjrnsqfsn7m0fl
encrypted_regex: ^(data|stringData)$
+1 -1
View File
@@ -6,4 +6,4 @@ resources:
- service.yaml
images:
- name: ghcr.io/deytenit/ermnvldmr.com/www
newTag: sha-a60b095
newTag: sha-2bf21cd
@@ -0,0 +1,39 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: beacon-ingress
namespace: com-ermnvldmr
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
traefik.ingress.kubernetes.io/router.middlewares: com-ermnvldmr-strip-beacon@kubernetescrd
traefik.ingress.kubernetes.io/router.priority: "100"
spec:
ingressClassName: traefik
tls:
- hosts:
- www.ermnvldmr.com
secretName: ermnvldmr-tls
- hosts:
- docs.ermnvldmr.com
secretName: docs-ermnvldmr-tls
rules:
- host: www.ermnvldmr.com
http:
paths:
- path: /beacon
pathType: Prefix
backend:
service:
name: umami-service
port:
number: 3000
- host: docs.ermnvldmr.com
http:
paths:
- path: /beacon
pathType: Prefix
backend:
service:
name: umami-service
port:
number: 3000
+36 -7
View File
@@ -1,23 +1,18 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: main-ingress
name: www-ingress
namespace: com-ermnvldmr
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
traefik.ingress.kubernetes.io/router.middlewares: com-ermnvldmr-sec-headers-common@kubernetescrd,com-ermnvldmr-sec-headers-www@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- www.ermnvldmr.com
secretName: ermnvldmr-tls
- hosts:
- docs.ermnvldmr.com
secretName: docs-ermnvldmr-tls
- hosts:
- umami.ermnvldmr.com
secretName: umami-ermnvldmr-tls
rules:
- host: www.ermnvldmr.com
http:
@@ -29,6 +24,23 @@ spec:
name: www-service
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: docs-ingress
namespace: com-ermnvldmr
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
traefik.ingress.kubernetes.io/router.middlewares: com-ermnvldmr-sec-headers-common@kubernetescrd,com-ermnvldmr-sec-headers-docs@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- docs.ermnvldmr.com
secretName: docs-ermnvldmr-tls
rules:
- host: docs.ermnvldmr.com
http:
paths:
@@ -39,6 +51,23 @@ spec:
name: docs-service
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: umami-ingress
namespace: com-ermnvldmr
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
traefik.ingress.kubernetes.io/router.middlewares: com-ermnvldmr-sec-headers-common@kubernetescrd,com-ermnvldmr-sec-headers-umami@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- umami.ermnvldmr.com
secretName: umami-ermnvldmr-tls
rules:
- host: umami.ermnvldmr.com
http:
paths:
@@ -2,3 +2,9 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ingress.yaml
- middleware-strip-beacon.yaml
- ingress-beacon.yaml
- middleware-sec-headers-common.yaml
- middleware-sec-headers-www.yaml
- middleware-sec-headers-docs.yaml
- middleware-sec-headers-umami.yaml
@@ -0,0 +1,18 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: sec-headers-common
namespace: com-ermnvldmr
spec:
headers:
stsSeconds: 63072000
stsIncludeSubdomains: true
stsPreload: true
forceSTSHeader: true
customFrameOptionsValue: "DENY"
contentTypeNosniff: true
browserXssFilter: false
referrerPolicy: "strict-origin-when-cross-origin"
customResponseHeaders:
Cross-Origin-Opener-Policy: "same-origin"
Permissions-Policy: "camera=(), microphone=(), geolocation=(), browsing-topics=()"
@@ -0,0 +1,8 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: sec-headers-docs
namespace: com-ermnvldmr
spec:
headers:
contentSecurityPolicy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://static.ermnvldmr.com; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; require-trusted-types-for 'script'; trusted-types default; upgrade-insecure-requests;"
@@ -0,0 +1,10 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: sec-headers-umami
namespace: com-ermnvldmr
spec:
headers:
contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;"
customResponseHeaders:
Content-Security-Policy-Report-Only: "require-trusted-types-for 'script'; trusted-types default;"
@@ -0,0 +1,8 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: sec-headers-www
namespace: com-ermnvldmr
spec:
headers:
contentSecurityPolicy: "default-src 'self'; script-src 'self' 'sha256-gUpIj4OcSwECFlSp351SCYJAe1wTOCQlrKBlASuO3CY=' 'sha256-3VjVzYfJpuNMLKyrxQTIc8ACxv37paQ4Talwg1s/7N0='; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://static.ermnvldmr.com; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; require-trusted-types-for 'script'; trusted-types default; upgrade-insecure-requests;"
@@ -0,0 +1,9 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: strip-beacon
namespace: com-ermnvldmr
spec:
stripPrefix:
prefixes:
- /beacon
+3 -1
View File
@@ -9,7 +9,7 @@ packer {
variable "debian_cloud_image_url" {
type = string
default = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-nocloud-amd64.qcow2"
default = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
}
variable "debian_cloud_image_checksum" {
@@ -42,6 +42,8 @@ source "qemu" "debian_k3s" {
ssh_password = "packer"
ssh_timeout = "5m"
vm_name = var.vm_name
cpus = 2
memory = 2048
net_device = "virtio-net"
disk_interface = "virtio"
headless = true
+41
View File
@@ -85,4 +85,45 @@ auto eth0
iface eth0 inet dhcp
EOF
# Apply kernel hardening sysctl parameters
cat << 'EOF' > /etc/sysctl.d/99-k3s-hardening.conf
# Network SYN Flood Protection
net.ipv4.tcp_syncookies = 1
# Disable ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
# Disable Source Routing
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
# Anti-spoofing (Reverse Path Filtering)
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Required for K8s pod networking
net.ipv4.ip_forward = 1
# System Hardening
kernel.kptr_restrict = 2
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 512
EOF
sysctl --system || true
# Configure baseline UFW firewall & fail2ban
ufw default deny incoming
ufw default allow outgoing
ufw default allow routed
ufw allow 22/tcp comment 'SSH'
ufw allow 80/tcp comment 'HTTP Traefik'
ufw allow 443/tcp comment 'HTTPS Traefik'
ufw allow 6443/tcp comment 'K3s API'
ufw allow in on cni0 || true
ufw allow in on flannel.1 || true
ufw allow from 10.42.0.0/16 comment 'K3s Pod CIDR'
ufw allow from 10.43.0.0/16 comment 'K3s Service CIDR'
ufw --force enable
systemctl enable fail2ban || true
echo "=== Base System Configuration Complete ==="
+110 -1
View File
@@ -19,7 +19,116 @@ k3s --version
systemctl stop k3s.service || true
systemctl disable k3s.service || true
# Create configuration directory
# Create configuration directory and default config
mkdir -p /etc/rancher/k3s
cat << 'EOF' > /etc/rancher/k3s/config.yaml
write-kubeconfig-mode: "0644"
EOF
chmod 644 /etc/rancher/k3s/config.yaml
# Pre-stage default GitOps root sync manifest
mkdir -p /etc/flux
cat << 'EOF' > /etc/flux/cluster-sync.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: ermnvldmr-repo
namespace: flux-system
spec:
interval: 1m
url: https://github.com/deytenit/ermnvldmr.com.git
ref:
branch: next
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: cluster-sync
namespace: flux-system
spec:
interval: 1m
sourceRef:
kind: GitRepository
name: ermnvldmr-repo
path: ./ci/k8s/clusters/production
prune: true
wait: true
timeout: 3m
decryption:
provider: sops
secretRef:
name: sops-age
EOF
# Install the cluster bootstrap script into /usr/local/bin
cat << 'EOF' > /usr/local/bin/bootstrap-cluster.sh
#!/usr/bin/env bash
set -euo pipefail
echo "=== [Bootstrap] Checking for Secondary Persistent Data Disk ==="
if [ -b /dev/vdb ]; then
echo "Secondary disk /dev/vdb detected."
if ! blkid /dev/vdb >/dev/null 2>&1; then
echo "Formatting /dev/vdb with ext4 filesystem..."
mkfs.ext4 -F -L k3s-storage /dev/vdb
fi
mkdir -p /var/lib/rancher/k3s/storage
if ! grep -qs '/var/lib/rancher/k3s/storage' /proc/mounts; then
mount /dev/vdb /var/lib/rancher/k3s/storage
echo "LABEL=k3s-storage /var/lib/rancher/k3s/storage ext4 defaults,noatime 0 2" >> /etc/fstab
echo "Mounted /dev/vdb to /var/lib/rancher/k3s/storage."
fi
fi
echo "=== [Bootstrap] Starting K3s Service ==="
systemctl enable --now k3s.service
echo "=== [Bootstrap] Waiting for K3s API server ready ==="
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
until kubectl get nodes >/dev/null 2>&1; do
echo "Waiting for Kubernetes API..."
sleep 2
done
echo "=== [Bootstrap] Cluster node is online ==="
kubectl get nodes
echo "=== [Bootstrap] Setting up Kubectl access for non-root users ==="
for home_dir in /home/*; do
if [ -d "$home_dir" ]; then
user_name=$(basename "$home_dir")
mkdir -p "$home_dir/.kube"
cp /etc/rancher/k3s/k3s.yaml "$home_dir/.kube/config"
chown -R "$user_name:$user_name" "$home_dir/.kube"
chmod 600 "$home_dir/.kube/config"
fi
done
echo "=== [Bootstrap] Creating flux-system namespace ==="
kubectl create namespace flux-system --dry-run=client -o yaml | kubectl apply -f -
echo "=== [Bootstrap] Injecting SOPS age decryption key ==="
if [ -f /root/.config/sops/age/keys.txt ]; then
kubectl create secret generic sops-age \
--namespace=flux-system \
--from-file=age.agekey=/root/.config/sops/age/keys.txt \
--dry-run=client -o yaml | kubectl apply -f -
echo "sops-age secret created."
else
echo "WARNING: /root/.config/sops/age/keys.txt not found. Inject key manually via kubectl."
fi
echo "=== [Bootstrap] Installing Flux Operator / Custom Resources ==="
kubectl apply -k "https://github.com/fluxcd/flux2/manifests/install?ref=v2.4.0" || true
echo "=== [Bootstrap] Applying initial GitOps root sync ==="
if [ -f /etc/flux/cluster-sync.yaml ]; then
kubectl apply -f /etc/flux/cluster-sync.yaml
fi
echo "=== [Bootstrap] First-Boot Bootstrap Sequence Complete ==="
EOF
chmod 755 /usr/local/bin/bootstrap-cluster.sh
echo "=== K3s Pre-installation Complete ==="
+15
View File
@@ -4,6 +4,21 @@ export DEBIAN_FRONTEND=noninteractive
echo "=== Sanitizing Machine Identity & Temporary Files ==="
# Lock temporary packer build user
passwd -l debian || true
# Apply SSH security hardening before final image capture
mkdir -p /etc/ssh/sshd_config.d
cat << 'EOF' > /etc/ssh/sshd_config.d/99-security-hardening.conf
PasswordAuthentication no
PermitRootLogin prohibit-password
KbdInteractiveAuthentication no
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
# Clean APT caches and logs
apt-get autoremove -y --purge
apt-get clean
+310
View File
@@ -0,0 +1,310 @@
import { execFileSync } from 'node:child_process';
import {
appendFileSync,
chmodSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Checks whether a hostname entry exists in the specified hosts file.
*
* @param {string} domain - Domain to search for
* @param {string} hostsPath - Path to hosts file
* @returns {boolean} Whether domain is present
*/
export function hasHostsEntry(domain = 'localhost.ermnvldmr.com', hostsPath = '/etc/hosts') {
if (!existsSync(hostsPath)) return false;
try {
const content = readFileSync(hostsPath, 'utf8');
const lines = content.split('\n');
for (const line of lines) {
const uncommented = (line.split('#')[0] || '').trim();
if (!uncommented) continue;
const tokens = uncommented.split(/\s+/);
const hostnames = tokens.slice(1);
if (tokens.length >= 2 && hostnames.some((host) => host === domain)) {
return true;
}
}
return false;
} catch {
return false;
}
}
const DOMAIN_PATTERN = /^[a-zA-Z0-9.-]+$/;
const IP_PATTERN = /^[\da-fA-F.:]+$/;
/**
* Ensures a mapping for the domain exists in /etc/hosts.
*
* @param {string} domain - Domain to map
* @param {string} ip - Target IP address
* @param {string} hostsPath - Path to hosts file
* @returns {boolean} Whether entry is present
*/
export function ensureHostsEntry(
domain = 'localhost.ermnvldmr.com',
ip = '127.0.0.1',
hostsPath = '/etc/hosts'
) {
if (!DOMAIN_PATTERN.test(domain) || !IP_PATTERN.test(ip)) {
return false;
}
if (hasHostsEntry(domain, hostsPath)) {
return true;
}
const entry = `${ip} ${domain}`;
try {
appendFileSync(hostsPath, `\n${entry}\n`);
console.log(`[hosts] Added "${entry}" to ${hostsPath}`);
return true;
} catch {
// Requires elevated permissions
}
const isInteractive = Boolean(
process.stdin && process.stdin.isTTY && process.stdout && process.stdout.isTTY
);
if (isInteractive) {
try {
console.log(`[hosts] Adding "${entry}" to ${hostsPath} (requires sudo)...`);
execFileSync('sudo', ['tee', '-a', hostsPath], {
input: `\n${entry}\n`,
stdio: ['pipe', 'ignore', 'inherit'],
});
console.log(`[hosts] Successfully added "${entry}" to ${hostsPath}`);
return true;
} catch {
console.warn(`[hosts] Could not automatically update ${hostsPath}.`);
}
}
console.warn(
`[hosts] Notice: "${entry}" is missing from ${hostsPath}.\nPlease run:\n sudo sh -c 'echo "${entry}" >> ${hostsPath}'`
);
return false;
}
/**
* Traverses upwards to find the monorepo root containing pnpm-workspace.yaml.
*
* @param {string} startDir - Directory to start searching from
* @returns {string} Absolute path to workspace root
*/
export function findWorkspaceRoot(startDir = dirname(fileURLToPath(import.meta.url))) {
let current = startDir;
while (current !== dirname(current)) {
if (existsSync(join(current, 'pnpm-workspace.yaml'))) {
return current;
}
current = dirname(current);
}
return process.cwd();
}
/**
* Ensures valid development certificates exist for localhost.ermnvldmr.com.
*
* @param {string} workspaceRoot - Absolute path to workspace root
* @returns {{ certPath: string, keyPath: string }} Paths to cert and key
*/
export function ensureDevCertificates(workspaceRoot = findWorkspaceRoot()) {
const certsDir = join(workspaceRoot, '.certs');
const certPath = join(certsDir, 'localhost.ermnvldmr.com.pem');
const keyPath = join(certsDir, 'localhost.ermnvldmr.com-key.pem');
ensureHostsEntry('localhost.ermnvldmr.com', '127.0.0.1');
if (existsSync(certPath) && existsSync(keyPath)) {
return { certPath, keyPath };
}
mkdirSync(certsDir, { recursive: true, mode: 0o700 });
const home = homedir();
const hugoCaCert = join(home, '.local/share/mkcert/rootCA.pem');
const hugoCaKey = join(home, '.local/share/mkcert/rootCA-key.pem');
let hasMkcertCli = false;
try {
execFileSync('mkcert', ['-help'], { stdio: 'ignore' });
hasMkcertCli = true;
} catch {
hasMkcertCli = false;
}
if (hasMkcertCli) {
execFileSync(
'mkcert',
[
'-cert-file',
certPath,
'-key-file',
keyPath,
'localhost.ermnvldmr.com',
'*.localhost.ermnvldmr.com',
'localhost',
'127.0.0.1',
'::1',
],
{ stdio: 'inherit' }
);
chmodSync(keyPath, 0o600);
return { certPath, keyPath };
}
if (existsSync(hugoCaCert) && existsSync(hugoCaKey)) {
const tempDir = tmpdir();
const tempCsr = join(tempDir, `ermnvldmr-dev-${Date.now()}.csr`);
const tempExt = join(tempDir, `ermnvldmr-dev-${Date.now()}.cnf`);
try {
writeFileSync(
tempExt,
'subjectAltName=DNS:localhost.ermnvldmr.com,DNS:*.localhost.ermnvldmr.com,DNS:localhost,IP:127.0.0.1,IP:::1\n'
);
execFileSync(
'openssl',
[
'req',
'-new',
'-nodes',
'-newkey',
'rsa:2048',
'-keyout',
keyPath,
'-out',
tempCsr,
'-subj',
'/CN=localhost.ermnvldmr.com',
'-batch',
],
{ stdio: 'pipe' }
);
execFileSync(
'openssl',
[
'x509',
'-req',
'-in',
tempCsr,
'-CA',
hugoCaCert,
'-CAkey',
hugoCaKey,
'-CAcreateserial',
'-out',
certPath,
'-days',
'825',
'-extfile',
tempExt,
],
{ stdio: 'pipe' }
);
chmodSync(keyPath, 0o600);
chmodSync(certPath, 0o644);
} finally {
rmSync(tempCsr, { force: true });
rmSync(tempExt, { force: true });
}
return { certPath, keyPath };
}
const caDir = join(home, '.local/share/mkcert');
mkdirSync(caDir, { recursive: true });
execFileSync(
'openssl',
[
'req',
'-x509',
'-nodes',
'-newkey',
'rsa:3072',
'-keyout',
hugoCaKey,
'-out',
hugoCaCert,
'-days',
'3650',
'-subj',
'/CN=mkcert development CA/O=mkcert development CA',
'-batch',
],
{ stdio: 'pipe' }
);
const tempDir = tmpdir();
const tempCsr = join(tempDir, `ermnvldmr-dev-${Date.now()}.csr`);
const tempExt = join(tempDir, `ermnvldmr-dev-${Date.now()}.cnf`);
try {
writeFileSync(
tempExt,
'subjectAltName=DNS:localhost.ermnvldmr.com,DNS:*.localhost.ermnvldmr.com,DNS:localhost,IP:127.0.0.1,IP:::1\n'
);
execFileSync(
'openssl',
[
'req',
'-new',
'-nodes',
'-newkey',
'rsa:2048',
'-keyout',
keyPath,
'-out',
tempCsr,
'-subj',
'/CN=localhost.ermnvldmr.com',
'-batch',
],
{ stdio: 'pipe' }
);
execFileSync(
'openssl',
[
'x509',
'-req',
'-in',
tempCsr,
'-CA',
hugoCaCert,
'-CAkey',
hugoCaKey,
'-CAcreateserial',
'-out',
certPath,
'-days',
'825',
'-extfile',
tempExt,
],
{ stdio: 'pipe' }
);
chmodSync(keyPath, 0o600);
chmodSync(certPath, 0o644);
} finally {
rmSync(tempCsr, { force: true });
rmSync(tempExt, { force: true });
}
return { certPath, keyPath };
}
// Run if called directly
if (process.argv[1] && process.argv[1].endsWith('ensure-dev-certs.mjs')) {
const { certPath, keyPath } = ensureDevCertificates();
console.log(`[certs] Ready:\n Cert: ${certPath}\n Key: ${keyPath}`);
}
+1 -1
View File
@@ -2,7 +2,7 @@
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATIC_DIR="$SCRIPT_DIR/../../services/www/public/static"
STATIC_DIR="${1:-$SCRIPT_DIR/../../services/www/public/static}"
if [ ! -d "$STATIC_DIR" ]; then
echo "Directory $STATIC_DIR does not exist. Skipping."
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# ==============================================================================
# End-to-End QEMU Verification Script for Golden Image & Cloud-Init User-Data
# ==============================================================================
set -euo pipefail
IMAGE_PATH="${1:-output-qemu/com-ermnvldmr-debian-12-k3s-amd64.qcow2}"
USER_DATA="${2:-ci/cloud-init/user-data.yaml}"
SSH_KEY="${3:-$HOME/.ssh/adam@ermnvldmr.com:ed25519}"
SSH_PORT="${4:-22222}"
if [ ! -f "$IMAGE_PATH" ]; then
echo "[-] Golden image not found at: $IMAGE_PATH"
echo " Build it first using: packer build -var 'accelerator=kvm' ci/packer/debian-12-k3s.pkr.hcl"
exit 1
fi
if [ ! -f "$USER_DATA" ]; then
echo "[-] User-data file not found at: $USER_DATA"
exit 1
fi
if [ ! -f "$SSH_KEY" ]; then
echo "[-] SSH private key not found at: $SSH_KEY"
exit 1
fi
# Ensure prerequisite tools exist
for cmd in qemu-system-x86_64 qemu-img genisoimage ssh; do
if ! command -v "$cmd" &>/dev/null; then
echo "[-] Required command not found in PATH: $cmd"
exit 1
fi
done
TMPDIR=$(mktemp -d /tmp/qemu-test-k3s-XXXXXX)
echo "=== [QEMU Test] Workdir: $TMPDIR ==="
QEMU_PID=""
cleanup() {
echo "=== [QEMU Test] Cleaning up resources ==="
if [ -n "$QEMU_PID" ] && kill -0 "$QEMU_PID" 2>/dev/null; then
echo "Stopping QEMU process (PID: $QEMU_PID)..."
kill "$QEMU_PID" 2>/dev/null || true
wait "$QEMU_PID" 2>/dev/null || true
fi
rm -rf "$TMPDIR"
echo "=== [QEMU Test] Cleanup complete ==="
}
trap cleanup EXIT
echo "=== [QEMU Test] 1. Preparing Cloud-Init cidata ISO ==="
cp "$USER_DATA" "$TMPDIR/user-data"
cat << 'EOF' > "$TMPDIR/meta-data"
instance-id: qemu-test-vm-01
local-hostname: com-ermnvldmr
EOF
genisoimage -output "$TMPDIR/cidata.iso" -volid cidata -joliet -rock "$TMPDIR/user-data" "$TMPDIR/meta-data" 2>/dev/null
echo "=== [QEMU Test] 2. Creating Ephemeral Disks ==="
# CoW overlay for root OS disk (preserves original golden image intact)
qemu-img create -f qcow2 -b "$(realpath "$IMAGE_PATH")" -F qcow2 "$TMPDIR/test-os.qcow2" 20G >/dev/null
# Secondary data disk (/dev/vdb) to test persistent storage auto-mounting
qemu-img create -f qcow2 "$TMPDIR/test-vdb.qcow2" 10G >/dev/null
echo "=== [QEMU Test] 3. Launching VM in QEMU (KVM accelerated) ==="
qemu-system-x86_64 \
-enable-kvm \
-m 4096 \
-smp 2 \
-drive file="$TMPDIR/test-os.qcow2",if=virtio,format=qcow2 \
-drive file="$TMPDIR/test-vdb.qcow2",if=virtio,format=qcow2 \
-drive file="$TMPDIR/cidata.iso",if=virtio,format=raw \
-netdev user,id=net0,hostfwd=tcp::${SSH_PORT}-:22 \
-device virtio-net,netdev=net0 \
-nographic \
-serial file:"$TMPDIR/serial.log" &
QEMU_PID=$!
echo "QEMU started with PID $QEMU_PID. Forwarding guest SSH to localhost:$SSH_PORT"
echo "=== [QEMU Test] 4. Waiting for SSH Availability (up to 120s) ==="
MAX_WAIT=120
WAIT_COUNT=0
SSH_OPTS=(-i "$SSH_KEY" -p "$SSH_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=3 -o BatchMode=yes)
until ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "echo ssh_ready" 2>/dev/null; do
sleep 3
WAIT_COUNT=$((WAIT_COUNT + 3))
if [ "$WAIT_COUNT" -ge "$MAX_WAIT" ]; then
echo "[-] Timed out waiting for SSH on localhost:$SSH_PORT!"
echo "--- Last 50 lines of serial console log ---"
tail -n 50 "$TMPDIR/serial.log" || true
exit 1
fi
done
echo "[+] SSH connection established!"
echo "=== [QEMU Test] 5. Waiting for Cloud-Init Completion ==="
ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "sudo cloud-init status --wait"
echo "=== [QEMU Test] 6. Verifying System Assertions ==="
# Check Hostname
HOSTNAME_ACTUAL=$(ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "hostname")
echo "Hostname: $HOSTNAME_ACTUAL (Expected: com-ermnvldmr)"
[ "$HOSTNAME_ACTUAL" = "com-ermnvldmr" ]
# Check Secondary Disk Mount
MOUNT_CHECK=$(ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "grep -qs '/var/lib/rancher/k3s/storage' /proc/mounts && echo MOUNTED || echo NOT_MOUNTED")
echo "Secondary disk mount: $MOUNT_CHECK"
[ "$MOUNT_CHECK" = "MOUNTED" ]
# Check K3s Node Status
NODE_STATUS=$(ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "kubectl get nodes -o jsonpath='{.items[0].status.conditions[?(@.type==\"Ready\")].status}'")
echo "K3s Node Ready Status: $NODE_STATUS"
[ "$NODE_STATUS" = "True" ]
# Check Non-root Kubectl access
echo "Checking non-root kubectl execution without sudo:"
ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "kubectl get nodes -o wide"
# Check Flux namespace and Age secret
echo "Checking Flux namespace and SOPS Age secret:"
ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "kubectl get secret sops-age -n flux-system"
echo "=========================================================================="
echo " [SUCCESS] All Golden Image & Cloud-Init Assertions Passed in QEMU!"
echo "=========================================================================="
+2
View File
@@ -32,6 +32,7 @@
"www:storybook": "pnpm --filter @ermnvldmr/www storybook",
"docs:dev": "pnpm --filter @ermnvldmr/docs dev",
"docs:build": "pnpm --filter @ermnvldmr/docs build",
"docs:sync:static": "pnpm --filter @ermnvldmr/docs sync:static",
"static:dev": "pnpm --filter @ermnvldmr/static dev",
"static:build": "pnpm --filter @ermnvldmr/static build",
"static:lint": "pnpm --filter @ermnvldmr/static lint",
@@ -41,6 +42,7 @@
"dev:www": "pnpm ui:build && pnpm kits:build && pnpm www:dev",
"dev:static": "pnpm ui:build && pnpm kits:build && pnpm static:dev",
"dev:docs": "pnpm docs:dev",
"dev:certs": "node ci/scripts/ensure-dev-certs.mjs",
"build": "pnpm ui:build && pnpm --filter @ermnvldmr/kits build && pnpm -r --filter='./services/*' build",
"build:storybooks": "pnpm ui:build && pnpm --filter @ermnvldmr/kits build && pnpm --filter @ermnvldmr/ui build-storybook && pnpm --filter @ermnvldmr/kits build-storybook && pnpm --filter @ermnvldmr/www build-storybook",
"lint": "pnpm -r lint",
@@ -297,4 +297,83 @@ describe('runS3Sync', () => {
const commandInstance = mockSend.mock.calls[0][0];
expect(commandInstance.input.Key).toBe('test/logo.webp');
});
it('should support custom sourceFilesPattern and urlPrefixes for docs', async () => {
const staticDir = path.join(tempDir, 'static');
fs.mkdirSync(staticDir, { recursive: true });
fs.writeFileSync(path.join(staticDir, 'banner.png'), 'png-content');
fs.writeFileSync(path.join(staticDir, 'banner.webp'), 'webp-content');
const contentDir = path.join(tempDir, 'content');
fs.mkdirSync(contentDir, { recursive: true });
const docPath = path.join(contentDir, 'index.md');
fs.writeFileSync(docPath, '![Banner](/images/banner.png)');
const cmd: S3SyncCommandConfig = {
name: 'sync',
description: 'desc',
targetPrefix: 'docs',
sourceDir: './static',
sourceFilesPattern: 'content/**/*.md',
urlPrefixes: ['/images/'],
type: 's3-sync',
};
await runS3Sync(cmd, {});
const content = fs.readFileSync(docPath, 'utf-8');
expect(content).toContain('https://s3.example.com/my-bucket/docs/banner.webp');
expect(content).not.toContain('/images/banner.png');
});
it('should use custom domain directly if bucket contains a dot', async () => {
const staticDir = path.join(tempDir, 'static');
fs.mkdirSync(staticDir, { recursive: true });
fs.writeFileSync(path.join(staticDir, 'img.webp'), 'webp-content');
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
const srcPath = path.join(srcDir, 'index.ts');
fs.writeFileSync(srcPath, 'const img = "/static/img.webp";');
const cmd: S3SyncCommandConfig = {
name: 'sync',
description: 'desc',
bucket: 'static.ermnvldmr.com',
targetPrefix: 'docs',
sourceDir: './static',
type: 's3-sync',
};
await runS3Sync(cmd, {});
const content = fs.readFileSync(srcPath, 'utf-8');
expect(content).toContain('https://static.ermnvldmr.com/docs/img.webp');
});
it('should use explicit publicUrl if provided', async () => {
const staticDir = path.join(tempDir, 'static');
fs.mkdirSync(staticDir, { recursive: true });
fs.writeFileSync(path.join(staticDir, 'img.webp'), 'webp-content');
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
const srcPath = path.join(srcDir, 'index.ts');
fs.writeFileSync(srcPath, 'const img = "/static/img.webp";');
const cmd: S3SyncCommandConfig = {
name: 'sync',
description: 'desc',
targetPrefix: 'www',
sourceDir: './static',
publicUrl: 'https://cdn.example.com',
type: 's3-sync',
};
await runS3Sync(cmd, {});
const content = fs.readFileSync(srcPath, 'utf-8');
expect(content).toContain('https://cdn.example.com/www/img.webp');
});
});
+38 -16
View File
@@ -149,6 +149,13 @@ export async function runS3Sync(
*/
const getS3Url = (finalName: string): string => {
const normalizedKey = normalizeKey(cmd.targetPrefix, finalName);
if (cmd.publicUrl) {
const cleanPublicUrl = cmd.publicUrl.replace(/\/+$/, '');
return `${cleanPublicUrl}/${normalizedKey}`;
}
if (bucket.includes('.')) {
return `https://${bucket}/${normalizedKey}`;
}
if (endpoint) {
const cleanEndpoint = endpoint.replace(/\/+$/, '');
return `${cleanEndpoint}/${bucket}/${normalizedKey}`;
@@ -156,12 +163,17 @@ export async function runS3Sync(
return `https://${bucket}.s3.${region}.amazonaws.com/${normalizedKey}`;
};
// Find workspace source files matching `src/**/*.{ts,tsx,css,md,mdx}`
const sourceFiles = await fg('src/**/*.{ts,tsx,css,md,mdx}', {
// Find workspace source files matching configured pattern or default `src/**/*.{ts,tsx,css,md,mdx}`
const filePattern = cmd.sourceFilesPattern ?? 'src/**/*.{ts,tsx,css,md,mdx}';
const sourceFiles = await fg(filePattern, {
cwd: process.cwd(),
absolute: true,
});
const urlPrefixes = cmd.urlPrefixes ?? ['/static/'];
const hasMatchingPrefix = (text: string): boolean =>
urlPrefixes.some((prefix) => text.includes(prefix));
// Sort assets by length descending to handle overlapping name replacement correctly
const sortedAssets = [...processedFiles].sort(
(a, b) => b.originalName.length - a.originalName.length
@@ -170,28 +182,38 @@ export async function runS3Sync(
// Replace static asset references in workspace source files
for (const sourceFile of sourceFiles) {
let content = await fs.promises.readFile(sourceFile, 'utf-8');
if (!content.includes('/static/')) {
if (!hasMatchingPrefix(content)) {
continue;
}
let modified = false;
const originalContent = content;
for (const asset of sortedAssets) {
let searchName = asset.originalName;
if (searchName.startsWith('static/')) {
searchName = searchName.substring('static/'.length);
} else if (searchName.startsWith('/static/')) {
searchName = searchName.substring('/static/'.length);
}
const searchStr = `/static/${searchName}`;
const assetUrl = getS3Url(asset.finalName);
const namesToSearch =
asset.finalName !== asset.originalName
? [asset.originalName, asset.finalName]
: [asset.originalName];
if (content.includes(searchStr)) {
content = content.split(searchStr).join(assetUrl);
modified = true;
for (const prefix of urlPrefixes) {
const cleanPrefix = prefix.replace(/^\/+|\/+$/g, '');
for (const name of namesToSearch) {
let searchName = name;
if (searchName.startsWith(`${cleanPrefix}/`)) {
searchName = searchName.substring(`${cleanPrefix}/`.length);
} else if (searchName.startsWith(`/${cleanPrefix}/`)) {
searchName = searchName.substring(`/${cleanPrefix}/`.length);
}
const searchStr = `/${cleanPrefix}/${searchName}`;
const assetUrl = getS3Url(asset.finalName);
const escaped = searchStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp('(?<!https?:\\/\\/[^\\s"\'`()]*)' + escaped, 'g');
content = content.replace(regex, assetUrl);
}
}
}
if (modified) {
if (content !== originalContent) {
await fs.promises.writeFile(sourceFile, content, 'utf-8');
}
}
+6
View File
@@ -106,6 +106,10 @@ export interface S3SyncCommandConfig extends BaseCommandConfig {
type: 's3-sync';
/** The source directory containing files to sync */
sourceDir?: string;
/** Glob pattern(s) matching source files where asset references should be replaced */
sourceFilesPattern?: string | string[];
/** URL prefixes to detect and replace with S3 URLs, defaults to ['/static/'] */
urlPrefixes?: string[];
/** The prefix (directory) under which to upload the files in the S3 bucket */
targetPrefix: string;
/** Optional S3 bucket name */
@@ -114,6 +118,8 @@ export interface S3SyncCommandConfig extends BaseCommandConfig {
region?: string;
/** Optional custom endpoint URL (e.g., for MinIO or Yandex Cloud) */
endpoint?: string;
/** Optional public base URL for assets (e.g., 'https://static.ermnvldmr.com') */
publicUrl?: string;
/** Optional AWS Access Key ID */
accessKeyId?: string;
/** Optional AWS Secret Access Key */
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": false,
"exports": {
".": {
"import": "./dist/index.js",
+3
View File
@@ -6,6 +6,9 @@
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": [
"**/*.css"
],
"exports": {
".": {
"import": "./dist/index.js",
@@ -12,19 +12,19 @@ describe('PageHead', () => {
expect(screen.getByText('Test Caption')).toBeInTheDocument();
});
it('renders breadcrumbs, addonLeft, and addonRight', () => {
it('renders breadcrumbs and addons in both expanded and collapsed views', () => {
render(
<PageHead
addonLeft={<span>Left Addon</span>}
addonRight={<span>Right Addon</span>}
addonLeft={<span>Logo Left</span>}
addonRight={<span>Nav Menu</span>}
breadcrumbs={<span>Breadcrumbs Trail</span>}
heading="Test Heading"
heading="Wanted"
/>
);
expect(screen.getByText('Breadcrumbs Trail')).toBeInTheDocument();
expect(screen.getAllByText('Left Addon').length).toBeGreaterThan(0);
expect(screen.getAllByText('Right Addon').length).toBeGreaterThan(0);
expect(screen.getAllByText('Breadcrumbs Trail').length).toBe(2);
expect(screen.getAllByText('Nav Menu').length).toBe(2);
expect(screen.getAllByText('Logo Left').length).toBe(2);
});
it('renders correctly with always-collapsed-fixed strategy', () => {
@@ -82,16 +82,22 @@ export function PageHead({
const rootClasses = cn(pageHeadRootVariants({ strategy }), className);
const hasTopBar = Boolean(addonLeft ?? breadcrumbs ?? addonRight);
const expandedContent = (
<PageContainer paddingY="none">
<VStack align="stretch" className="pb-8 sm:pb-12" gap={6}>
<HStack align="center" className="py-2.5" gap={4} justify="between" wrap="wrap">
<HStack align="center" className="flex-1" gap={4}>
{addonLeft && <div className="flex-shrink-0 whitespace-nowrap">{addonLeft}</div>}
{breadcrumbs && <div className="hidden sm:block">{breadcrumbs}</div>}
<div>
{hasTopBar && (
<PageContainer paddingY="none">
<HStack align="center" className="min-h-[60px] py-2.5" gap={4} justify="between" wrap="wrap">
<HStack align="center" className="flex-1" gap={4}>
{addonLeft && <div className="flex-shrink-0 whitespace-nowrap">{addonLeft}</div>}
{breadcrumbs && <div className="hidden sm:block">{breadcrumbs}</div>}
</HStack>
{addonRight && <div className="flex-shrink-0">{addonRight}</div>}
</HStack>
{addonRight && <div className="flex-shrink-0">{addonRight}</div>}
</HStack>
</PageContainer>
)}
<PageContainer className={cn(hasTopBar ? 'pt-4 pb-8 sm:pt-6 sm:pb-12' : 'py-8 sm:py-12')}>
<VStack gap={4}>
<Header level={1}>{heading}</Header>
{typeof caption === 'string' ? (
@@ -102,16 +108,21 @@ export function PageHead({
caption
)}
</VStack>
</VStack>
</PageContainer>
</PageContainer>
</div>
);
const collapsedContent = (
<PageContainer paddingY="none">
<HStack align="center" className="py-2.5" gap={4} justify="between" wrap="wrap">
<HStack align="center" className="min-h-[60px] py-2.5" gap={4} justify="between" wrap="wrap">
<HStack align="center" className="flex-1" gap={4}>
{addonLeft && <div className="flex-shrink-0 whitespace-nowrap">{addonLeft}</div>}
<Header className="min-w-0" level={4} overflow="ellipsis">
{breadcrumbs && <div className="hidden sm:block">{breadcrumbs}</div>}
<Header
className={cn('min-w-0', breadcrumbs && 'sm:hidden')}
level={4}
overflow="ellipsis"
>
{heading}
</Header>
</HStack>
+2 -1
View File
@@ -7,7 +7,8 @@
"./testing": "./dist/testing.js"
},
"scripts": {
"build": "tsc"
"build": "tsc",
"test": "vitest run src"
},
"dependencies": {
"@rsbuild/plugin-react": "^1.1.1",
@@ -1,2 +1,3 @@
export * from './rsbuild.js';
export * from './server.js';
export * from './vitest.js';
@@ -0,0 +1,98 @@
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
ensureDevCertificates,
ensureHostsEntry,
findWorkspaceRoot,
hasHostsEntry,
localDevServer,
} from './server.js';
describe('server dev configuration', () => {
it('locates the workspace root with pnpm-workspace.yaml', () => {
const root = findWorkspaceRoot();
expect(existsSync(`${root}/pnpm-workspace.yaml`)).toBe(true);
});
it('ensures dev certificates exist and returns existing paths', () => {
const certs = ensureDevCertificates();
expect(existsSync(certs.certPath)).toBe(true);
expect(existsSync(certs.keyPath)).toBe(true);
expect(certs.certPath).toContain('.certs/localhost.ermnvldmr.com.pem');
});
it('returns production configuration without https when NODE_ENV is production', () => {
const prevEnv = process.env.NODE_ENV;
try {
process.env.NODE_ENV = 'production';
const config = localDevServer({ port: 3000 });
expect(config.port).toBe(3000);
expect(config.https).toBeUndefined();
} finally {
process.env.NODE_ENV = prevEnv;
}
});
it('configures dev server with https and formatted urls for localhost.ermnvldmr.com', () => {
const prevEnv = process.env.NODE_ENV;
try {
process.env.NODE_ENV = 'development';
const config = localDevServer({ port: 3000 });
expect(config.host).toBe('0.0.0.0');
expect(config.port).toBe(3000);
expect(config.cors).toBe(true);
expect(config.https).toBeDefined();
if (typeof config.printUrls === 'function') {
const urls = config.printUrls({
protocol: 'https',
port: 3000,
urls: [],
routes: [],
});
expect(urls).toEqual(['https://localhost.ermnvldmr.com:3000']);
}
} finally {
process.env.NODE_ENV = prevEnv;
}
});
it('detects entries correctly in hosts file', () => {
const mockHostsPath = join(tmpdir(), `hosts-test-${Date.now()}`);
try {
writeFileSync(mockHostsPath, '127.0.0.1 localhost\n# 127.0.0.1 localhost.ermnvldmr.com\n');
expect(hasHostsEntry('localhost.ermnvldmr.com', mockHostsPath)).toBe(false);
writeFileSync(
mockHostsPath,
'127.0.0.1 localhost\n127.0.0.1 localhost.ermnvldmr.com other.domain\n'
);
expect(hasHostsEntry('localhost.ermnvldmr.com', mockHostsPath)).toBe(true);
} finally {
if (existsSync(mockHostsPath)) unlinkSync(mockHostsPath);
}
});
it('appends missing entry to writable hosts file', () => {
const mockHostsPath = join(tmpdir(), `hosts-test-${Date.now()}`);
try {
writeFileSync(mockHostsPath, '127.0.0.1 localhost\n');
const added = ensureHostsEntry('localhost.ermnvldmr.com', '127.0.0.1', mockHostsPath);
expect(added).toBe(true);
const content = readFileSync(mockHostsPath, 'utf8');
expect(content).toContain('127.0.0.1 localhost.ermnvldmr.com');
expect(hasHostsEntry('localhost.ermnvldmr.com', mockHostsPath)).toBe(true);
} finally {
if (existsSync(mockHostsPath)) unlinkSync(mockHostsPath);
}
});
it('rejects invalid domain or ip in ensureHostsEntry', () => {
expect(ensureHostsEntry('invalid domain with spaces', '127.0.0.1')).toBe(false);
expect(ensureHostsEntry('localhost.ermnvldmr.com', 'invalid-ip-format')).toBe(false);
});
});
@@ -0,0 +1,374 @@
import { execFileSync } from 'node:child_process';
import {
appendFileSync,
chmodSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import type { ServerConfig } from '@rsbuild/core';
/**
* Configuration options for the local development server.
*/
export interface LocalDevServerOptions {
/**
* The port number on which the dev server should listen.
*/
port: number;
/**
* Optional custom domain name. Defaults to 'localhost.ermnvldmr.com'.
*/
host?: string;
}
/**
* Certificate file paths resolved for local development.
*/
export interface DevCertificates {
/**
* Absolute path to the PEM certificate file.
*/
certPath: string;
/**
* Absolute path to the PEM private key file.
*/
keyPath: string;
}
/**
* Checks whether a hostname entry exists in the specified hosts file.
*
* @param domain - Domain to search for, defaults to 'localhost.ermnvldmr.com'
* @param hostsPath - Path to hosts file, defaults to '/etc/hosts'
* @returns Whether the domain is mapped in the hosts file
*/
export function hasHostsEntry(
domain: string = 'localhost.ermnvldmr.com',
hostsPath: string = '/etc/hosts'
): boolean {
if (!existsSync(hostsPath)) return false;
try {
const content = readFileSync(hostsPath, 'utf8');
const lines = content.split('\n');
for (const line of lines) {
const uncommented = line.split('#')[0]?.trim() ?? '';
if (!uncommented) continue;
const tokens = uncommented.split(/\s+/);
const hostnames = tokens.slice(1);
if (tokens.length >= 2 && hostnames.some((host) => host === domain)) {
return true;
}
}
return false;
} catch {
return false;
}
}
const DOMAIN_PATTERN = /^[a-zA-Z0-9.-]+$/;
const IP_PATTERN = /^[\da-fA-F.:]+$/;
/**
* Ensures a mapping for the domain exists in the hosts file.
*
* If not present and the file is not directly writable, attempts to invoke sudo
* when running in an interactive terminal.
*
* @param domain - Domain to map, defaults to 'localhost.ermnvldmr.com'
* @param ip - Target IP address, defaults to '127.0.0.1'
* @param hostsPath - Path to hosts file, defaults to '/etc/hosts'
* @returns Whether the entry is now present in the hosts file
*/
export function ensureHostsEntry(
domain: string = 'localhost.ermnvldmr.com',
ip: string = '127.0.0.1',
hostsPath: string = '/etc/hosts'
): boolean {
if (!DOMAIN_PATTERN.test(domain) || !IP_PATTERN.test(ip)) {
return false;
}
if (hasHostsEntry(domain, hostsPath)) {
return true;
}
const entry = `${ip} ${domain}`;
try {
appendFileSync(hostsPath, `\n${entry}\n`);
console.log(`[hosts] Added "${entry}" to ${hostsPath}`);
return true;
} catch {
// Requires elevated permissions
}
const isInteractive = Boolean(process.stdin?.isTTY && process.stdout?.isTTY);
if (isInteractive) {
try {
console.log(`[hosts] Adding "${entry}" to ${hostsPath} (requires sudo)...`);
execFileSync('sudo', ['tee', '-a', hostsPath], {
input: `\n${entry}\n`,
stdio: ['pipe', 'ignore', 'inherit'],
});
console.log(`[hosts] Successfully added "${entry}" to ${hostsPath}`);
return true;
} catch {
console.warn(`[hosts] Could not automatically update ${hostsPath}.`);
}
}
console.warn(
`[hosts] Notice: "${entry}" is missing from ${hostsPath}.\nPlease run:\n sudo sh -c 'echo "${entry}" >> ${hostsPath}'`
);
return false;
}
/**
* Traverses directory hierarchy upwards to locate the workspace root containing pnpm-workspace.yaml.
*
* @param startDir - Starting directory for lookup, defaults to current process directory
* @returns Absolute path to the workspace root directory
*/
export function findWorkspaceRoot(startDir: string = process.cwd()): string {
let current = startDir;
while (current !== dirname(current)) {
if (existsSync(join(current, 'pnpm-workspace.yaml'))) {
return current;
}
current = dirname(current);
}
return startDir;
}
/**
* Ensures that valid TLS development certificates exist for localhost.ermnvldmr.com.
*
* Checks for certificates in the workspace `.certs` directory. If absent, generates
* new certificates signed by either an existing mkcert/Hugo root CA or an openssl-generated CA.
*
* @param workspaceRoot - Absolute path to workspace root
* @returns Paths to the certificate and private key files
*/
export function ensureDevCertificates(
workspaceRoot: string = findWorkspaceRoot()
): DevCertificates {
const certsDir = join(workspaceRoot, '.certs');
const certPath = join(certsDir, 'localhost.ermnvldmr.com.pem');
const keyPath = join(certsDir, 'localhost.ermnvldmr.com-key.pem');
ensureHostsEntry('localhost.ermnvldmr.com', '127.0.0.1');
if (existsSync(certPath) && existsSync(keyPath)) {
return { certPath, keyPath };
}
mkdirSync(certsDir, { recursive: true, mode: 0o700 });
const home = homedir();
const hugoCaCert = join(home, '.local/share/mkcert/rootCA.pem');
const hugoCaKey = join(home, '.local/share/mkcert/rootCA-key.pem');
let hasMkcertCli = false;
try {
execFileSync('mkcert', ['-help'], { stdio: 'ignore' });
hasMkcertCli = true;
} catch {
hasMkcertCli = false;
}
if (hasMkcertCli) {
execFileSync(
'mkcert',
[
'-cert-file',
certPath,
'-key-file',
keyPath,
'localhost.ermnvldmr.com',
'*.localhost.ermnvldmr.com',
'localhost',
'127.0.0.1',
'::1',
],
{ stdio: 'inherit' }
);
chmodSync(keyPath, 0o600);
return { certPath, keyPath };
}
if (existsSync(hugoCaCert) && existsSync(hugoCaKey)) {
const tempDir = tmpdir();
const tempCsr = join(tempDir, `ermnvldmr-dev-${Date.now()}.csr`);
const tempExt = join(tempDir, `ermnvldmr-dev-${Date.now()}.cnf`);
try {
writeFileSync(
tempExt,
'subjectAltName=DNS:localhost.ermnvldmr.com,DNS:*.localhost.ermnvldmr.com,DNS:localhost,IP:127.0.0.1,IP:::1\n'
);
execFileSync(
'openssl',
[
'req',
'-new',
'-nodes',
'-newkey',
'rsa:2048',
'-keyout',
keyPath,
'-out',
tempCsr,
'-subj',
'/CN=localhost.ermnvldmr.com',
'-batch',
],
{ stdio: 'pipe' }
);
execFileSync(
'openssl',
[
'x509',
'-req',
'-in',
tempCsr,
'-CA',
hugoCaCert,
'-CAkey',
hugoCaKey,
'-CAcreateserial',
'-out',
certPath,
'-days',
'825',
'-extfile',
tempExt,
],
{ stdio: 'pipe' }
);
chmodSync(keyPath, 0o600);
chmodSync(certPath, 0o644);
} finally {
rmSync(tempCsr, { force: true });
rmSync(tempExt, { force: true });
}
return { certPath, keyPath };
}
const caDir = join(home, '.local/share/mkcert');
mkdirSync(caDir, { recursive: true });
execFileSync(
'openssl',
[
'req',
'-x509',
'-nodes',
'-newkey',
'rsa:3072',
'-keyout',
hugoCaKey,
'-out',
hugoCaCert,
'-days',
'3650',
'-subj',
'/CN=mkcert development CA/O=mkcert development CA',
'-batch',
],
{ stdio: 'pipe' }
);
const tempDir = tmpdir();
const tempCsr = join(tempDir, `ermnvldmr-dev-${Date.now()}.csr`);
const tempExt = join(tempDir, `ermnvldmr-dev-${Date.now()}.cnf`);
try {
writeFileSync(
tempExt,
'subjectAltName=DNS:localhost.ermnvldmr.com,DNS:*.localhost.ermnvldmr.com,DNS:localhost,IP:127.0.0.1,IP:::1\n'
);
execFileSync(
'openssl',
[
'req',
'-new',
'-nodes',
'-newkey',
'rsa:2048',
'-keyout',
keyPath,
'-out',
tempCsr,
'-subj',
'/CN=localhost.ermnvldmr.com',
'-batch',
],
{ stdio: 'pipe' }
);
execFileSync(
'openssl',
[
'x509',
'-req',
'-in',
tempCsr,
'-CA',
hugoCaCert,
'-CAkey',
hugoCaKey,
'-CAcreateserial',
'-out',
certPath,
'-days',
'825',
'-extfile',
tempExt,
],
{ stdio: 'pipe' }
);
chmodSync(keyPath, 0o600);
chmodSync(certPath, 0o644);
} finally {
rmSync(tempCsr, { force: true });
rmSync(tempExt, { force: true });
}
return { certPath, keyPath };
}
/**
* Returns a dev server configuration preset with HTTPS and localhost.ermnvldmr.com domain formatting.
*
* @param options - Server configuration options (port, host)
* @returns Rsbuild ServerConfig preset
*/
export function localDevServer(options: LocalDevServerOptions): ServerConfig {
if (process.env.NODE_ENV === 'production') {
return {
port: options.port,
};
}
const { certPath, keyPath } = ensureDevCertificates();
const cert = readFileSync(certPath);
const key = readFileSync(keyPath);
const host = options.host ?? 'localhost.ermnvldmr.com';
return {
host: '0.0.0.0',
port: options.port,
cors: true,
https: {
key,
cert,
},
printUrls: ({ protocol, port }: { protocol: string; port: number }) => {
return [`${protocol}://${host}:${port}`];
},
};
}
@@ -12,6 +12,8 @@ export interface AnalyticsHtmlTag {
head?: boolean;
/** Whether the tag should be appended at the end. */
append?: boolean;
/** Whether to prepend publicPath / assetPrefix to the tag URL. Defaults to true in Rsbuild. */
publicPath?: boolean | string;
}
/**
@@ -92,6 +92,7 @@ export function umamiAnalytics(options: UmamiAnalyticsOptions): AnalyticsProvide
tag: 'script',
attrs,
head: true,
publicPath: false,
},
];
},
+1 -1
View File
@@ -147,7 +147,7 @@ export function ssgPlugin(): RsbuildPlugin {
const critters = new Critters({
path: webDistPath,
publicPath: configuredAssetPrefix,
preload: 'swap',
preload: 'media',
inlineFonts: false,
pruneSource: false,
reduceInlineStyles: false,
+27 -15
View File
@@ -1,5 +1,4 @@
import React from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
/**
* Options for page creation.
@@ -15,9 +14,8 @@ export interface PageOptions {
* Mounts or hydrates a React component into the `#root` element.
*
* Skips DOM mounting during the SSG server-side render pass
* (`typeof window === 'undefined'`). On the client, hydrates if the
* root already has children (SSG output), otherwise performs a fresh
* `createRoot` render.
* (`typeof window === 'undefined'`). On the client, hydrates asynchronously
* during idle time to unblock First Contentful Paint.
*
* @param Component - The React component to render.
* @param options - Optional page metadata (title, description).
@@ -45,17 +43,31 @@ export function createPage(Component: React.ComponentType, options: PageOptions
metaDescription.setAttribute('content', options.description);
}
if (rootElement) {
const node = (
<React.StrictMode>
<Component />
</React.StrictMode>
);
if (!rootElement) return;
if (rootElement.hasChildNodes()) {
hydrateRoot(rootElement, node);
} else {
createRoot(rootElement).render(node);
}
const mountOrHydrate = () => {
void import('react-dom/client')
.then(({ createRoot, hydrateRoot }) => {
const node = (
<React.StrictMode>
<Component />
</React.StrictMode>
);
if (rootElement.hasChildNodes()) {
hydrateRoot(rootElement, node);
} else {
createRoot(rootElement).render(node);
}
})
.catch((err: unknown) => {
console.error('[createPage] Client hydration failed:', err);
});
};
if ('requestIdleCallback' in window) {
window.requestIdleCallback(mountOrHydrate);
} else {
setTimeout(mountOrHydrate, 1);
}
}
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": false,
"exports": {
".": {
"import": "./dist/index.js",
+1
View File
@@ -3,3 +3,4 @@ export * from './functions/index.js';
export * from './object/index.js';
export * from './window/localStorage.js';
export * from './react/index.js';
export * from './security.js';
+84
View File
@@ -0,0 +1,84 @@
import { createHash } from 'node:crypto';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
import {
THEME_INIT_SCRIPT_HASH,
TRUSTED_TYPES_INIT_SCRIPT,
TRUSTED_TYPES_INIT_SCRIPT_HASH,
} from './security';
interface MockPolicyOptions {
createHTML?: (s: string) => string;
createScript?: (s: string) => string;
createScriptURL?: (u: string) => string;
}
interface MockTrustedTypes {
createPolicy: (name: string, options: MockPolicyOptions) => unknown;
}
interface PolicyRecorder {
name: string;
options: MockPolicyOptions;
}
describe('Security policy script hashes and execution', () => {
it('computes exact SHA-256 base64 hash for TRUSTED_TYPES_INIT_SCRIPT', () => {
const computedHash = `sha256-${createHash('sha256').update(TRUSTED_TYPES_INIT_SCRIPT).digest('base64')}`;
expect(computedHash).toBe(TRUSTED_TYPES_INIT_SCRIPT_HASH);
});
it('computes exact SHA-256 base64 hash for THEME_INIT_SCRIPT constant', () => {
// Theme initializer literal from packages/ui
const themeScript =
'(function(){try{var k=\'ermnvldmr/ui/lib/theme\';var p=JSON.parse(localStorage.getItem(k)||\'"system"\');var d=p===\'dark\'||(p===\'system\'&&window.matchMedia(\'(prefers-color-scheme:dark)\').matches);document.documentElement.classList.toggle(\'dark\',d)}catch(e){}})();';
const computedHash = `sha256-${createHash('sha256').update(themeScript).digest('base64')}`;
expect(computedHash).toBe(THEME_INIT_SCRIPT_HASH);
});
it('executes TRUSTED_TYPES_INIT_SCRIPT without errors when window.trustedTypes is absent', () => {
const sandbox = {
window: {},
};
vm.createContext(sandbox);
expect(() => {
vm.runInContext(TRUSTED_TYPES_INIT_SCRIPT, sandbox);
}).not.toThrow();
});
it('evaluates TRUSTED_TYPES_INIT_SCRIPT and registers default policy when window.trustedTypes is present', () => {
const recorder: PolicyRecorder = {
name: '',
options: {},
};
const mockTrustedTypes: MockTrustedTypes = {
createPolicy: (name: string, options: MockPolicyOptions) => {
recorder.name = name;
recorder.options = options;
return { name };
},
};
const sandbox = {
window: {
trustedTypes: mockTrustedTypes,
},
};
vm.createContext(sandbox);
expect(() => {
vm.runInContext(TRUSTED_TYPES_INIT_SCRIPT, sandbox);
}).not.toThrow();
expect(recorder.name).toBe('default');
expect(recorder.options.createHTML?.('<p>safe</p>')).toBe('<p>safe</p>');
expect(recorder.options.createScript?.('console.log(1)')).toBe('console.log(1)');
expect(recorder.options.createScriptURL?.('https://example.com/lib.js')).toBe(
'https://example.com/lib.js'
);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* Inline bootstrapper script that registers a transparent default Trusted Types policy.
* This prevents runtime DOM-based XSS injection crashes in React 19, Hextra, and third-party DOM sinks
* when `require-trusted-types-for 'script'` is enforced via Content-Security-Policy.
*/
export const TRUSTED_TYPES_INIT_SCRIPT =
'(function(){if(typeof window!=="undefined"&&window.trustedTypes&&window.trustedTypes.createPolicy){try{window.trustedTypes.createPolicy("default",{createHTML:function(s){return s},createScript:function(s){return s},createScriptURL:function(u){return u}});}catch(e){}}})();';
/**
* SHA-256 base64 hash of `TRUSTED_TYPES_INIT_SCRIPT` for inclusion in CSP `script-src`.
*/
export const TRUSTED_TYPES_INIT_SCRIPT_HASH =
'sha256-3VjVzYfJpuNMLKyrxQTIc8ACxv37paQ4Talwg1s/7N0=';
/**
* SHA-256 base64 hash of `@ermnvldmr/ui` `THEME_INIT_SCRIPT` for inclusion in CSP `script-src`.
*/
export const THEME_INIT_SCRIPT_HASH =
'sha256-gUpIj4OcSwECFlSp351SCYJAe1wTOCQlrKBlASuO3CY=';
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
+3
View File
@@ -5,6 +5,9 @@
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": [
"**/*.css"
],
"exports": {
".": {
"import": "./dist/index.js",
@@ -1,5 +1,3 @@
import { createHighlighter } from 'shiki';
import type { Highlighter } from 'shiki';
/**
@@ -77,14 +75,16 @@ class CodeHighlighter {
return this.initializingPromise;
}
this.initializingPromise = createHighlighter({
themes: ['github-dark', 'github-light'],
langs: SUPPORTED_LANGUAGES,
}).then((highlighter) => {
this.initializingPromise = (async () => {
const { createHighlighter } = await import('shiki');
const highlighter = await createHighlighter({
themes: ['github-dark', 'github-light'],
langs: SUPPORTED_LANGUAGES,
});
this.highlighter = highlighter;
this.initializingPromise = null;
return highlighter;
});
})();
return this.initializingPromise;
}
+79 -2
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { vi } from 'vitest';
import { Link } from './Link';
import { render, screen } from '../../lib/testing';
import { render, screen, fireEvent } from '../../lib/testing';
describe('components/Link', () => {
beforeEach(() => {
@@ -66,6 +66,83 @@ describe('components/Link', () => {
</Link>
);
const link = screen.getByRole('link');
expect(link).toHaveClass('opacity-0');
expect(link).toHaveStyle({ animationDelay: '500ms', animationFillMode: 'both' });
expect(link).toHaveClass('animate-in');
});
it('obfuscates mailto link until user interaction', () => {
render(
<Link isObfuscated href="mailto:personal@ermnvldmr.com">
personal@ermnvldmr.com
</Link>
);
const link = screen.getByRole('link', { name: 'personal@ermnvldmr.com' });
expect(link).toHaveAttribute('href', '#');
expect(link).toHaveAttribute('data-user', 'personal');
expect(link).toHaveAttribute('data-domain', 'ermnvldmr.com');
fireEvent.pointerEnter(link);
expect(link).toHaveAttribute('href', 'mailto:personal@ermnvldmr.com');
});
it('activates obfuscated mailto link on focus and pointer down', () => {
const { rerender } = render(
<Link isObfuscated href="mailto:focus@ermnvldmr.com">
focus@ermnvldmr.com
</Link>
);
const link = screen.getByRole('link', { name: 'focus@ermnvldmr.com' });
fireEvent.focus(link);
expect(link).toHaveAttribute('href', 'mailto:focus@ermnvldmr.com');
rerender(
<Link key="pointer-down" isObfuscated href="mailto:pointer@ermnvldmr.com">
pointer@ermnvldmr.com
</Link>
);
const linkPointer = screen.getByRole('link', { name: 'pointer@ermnvldmr.com' });
fireEvent.pointerDown(linkPointer);
expect(linkPointer).toHaveAttribute('href', 'mailto:pointer@ermnvldmr.com');
});
it('handles click on unactivated and activated obfuscated mailto link', () => {
const mockLocation = { href: '' };
vi.stubGlobal('location', mockLocation);
render(
<Link isObfuscated href="mailto:click@ermnvldmr.com">
click@ermnvldmr.com
</Link>
);
const link = screen.getByRole('link', { name: 'click@ermnvldmr.com' });
fireEvent.click(link);
expect(mockLocation.href).toBe('mailto:click@ermnvldmr.com');
expect(link).toHaveAttribute('href', 'mailto:click@ermnvldmr.com');
// Click again when already activated
fireEvent.click(link);
expect(mockLocation.href).toBe('mailto:click@ermnvldmr.com');
});
it('renders custom children directly when obfuscated instead of bidi override', () => {
render(
<Link isObfuscated href="mailto:custom@ermnvldmr.com">
Send an Email
</Link>
);
const link = screen.getByRole('link', { name: 'custom@ermnvldmr.com' });
expect(link).toHaveTextContent('Send an Email');
});
it('handles obfuscated mailto without at symbol gracefully', () => {
render(
<Link isObfuscated href="mailto:plainaddress">
plainaddress
</Link>
);
const link = screen.getByRole('link', { name: 'plainaddress' });
expect(link).toHaveAttribute('data-user', '');
expect(link).toHaveAttribute('data-domain', '');
});
});
+63 -4
View File
@@ -1,6 +1,6 @@
import { localePath } from '@ermnvldmr/i18n';
import { castRef, cn, filterDataAttributes } from '@ermnvldmr/stl';
import React, { memo, useRef } from 'react';
import React, { memo, useCallback, useRef, useState } from 'react';
import { useLink } from 'react-aria';
import { Text } from '../Text/Text';
@@ -27,6 +27,8 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps, DataAttribut
color?: TextColor;
/** Delay in milliseconds before the link appears after entering the viewport. */
delay?: number;
/** Whether to obfuscate the email/link from static HTML scrapers. */
isObfuscated?: boolean;
}
/**
@@ -36,7 +38,40 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps, DataAttribut
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
*/
export const Link = memo(function Link(props: LinkProps) {
const { children, className, href, isExternal, type, size, color, delay, ...otherProps } = props;
const {
children,
className,
href,
isExternal,
type,
size,
color,
delay,
isObfuscated = false,
...otherProps
} = props;
const [isActivated, setIsActivated] = useState(false);
const isMailto = Boolean(isObfuscated && href?.startsWith('mailto:'));
const rawEmail = isMailto && href ? href.slice(7) : '';
const atIndex = rawEmail.indexOf('@');
const user = atIndex !== -1 ? rawEmail.slice(0, atIndex) : '';
const domain = atIndex !== -1 ? rawEmail.slice(atIndex + 1) : '';
const activate = useCallback(() => {
setIsActivated(true);
}, []);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
activate();
if (!isActivated && href) {
window.location.href = href;
e.preventDefault();
}
},
[activate, href, isActivated]
);
// We use a specific ref type that useLink expects.
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
@@ -53,15 +88,35 @@ export const Link = memo(function Link(props: LinkProps) {
const dataAttributes = filterDataAttributes(props);
if (href) {
const resolvedHref = localePath(href);
const isAutoExternal = isExternal ?? href.startsWith('http');
const externalProps = isAutoExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {};
const resolvedHref = isMailto ? (isActivated ? href : '#') : localePath(href);
const isChildrenEmail = typeof children === 'string' && children === rawEmail;
const displayedContent =
isMailto && !isActivated && isChildrenEmail ? (
<span style={{ unicodeBidi: 'bidi-override', direction: 'rtl' }}>
{domain.split('').reverse().join('')}@{user.split('').reverse().join('')}
</span>
) : (
children
);
const obfuscationAttrs = isMailto
? {
'data-user': user,
'data-domain': domain,
'aria-label': rawEmail,
}
: {};
return (
<Text
{...linkProps}
{...externalProps}
{...dataAttributes}
{...obfuscationAttrs}
ref={ref}
as="a"
className={sharedClasses}
@@ -70,8 +125,12 @@ export const Link = memo(function Link(props: LinkProps) {
href={resolvedHref}
size={size}
type={type}
onClick={isMailto ? handleClick : undefined}
onFocus={isMailto ? activate : undefined}
onPointerDown={isMailto ? activate : undefined}
onPointerEnter={isMailto ? activate : undefined}
>
{children}
{displayedContent}
</Text>
);
}
+8 -46
View File
@@ -2,19 +2,14 @@ import React from 'react';
import { vi } from 'vitest';
import { Text } from './Text';
import { render, screen, act } from '../../lib/testing';
import { render, screen } from '../../lib/testing';
describe('components/Text', () => {
let intersectionCallback: (entries: any[]) => void;
beforeEach(() => {
vi.useFakeTimers();
// Mock IntersectionObserver
class MockIntersectionObserver {
constructor(public callback: (entries: any[]) => void) {
intersectionCallback = callback;
}
observe = vi.fn();
disconnect = vi.fn();
}
@@ -31,58 +26,25 @@ describe('components/Text', () => {
expect(screen.getByText('Test content')).toBeInTheDocument();
});
it('is immediately visible when no delay is provided', () => {
it('is immediately visible without animation when no delay is provided', () => {
render(<Text>No delay</Text>);
const el = screen.getByText('No delay');
expect(el).not.toHaveClass('opacity-0');
expect(el).not.toHaveClass('animate-in');
expect(el.style.animationDelay).toBe('');
});
it('is initially hidden when delay is provided', () => {
it('applies CSS animation classes and delay style when delay is provided', () => {
render(<Text delay={500}>With delay</Text>);
const el = screen.getByText('With delay');
expect(el).toHaveClass('opacity-0');
expect(el).toHaveClass('animate-in', 'fade-in', 'slide-in-from-bottom-2');
expect(el).toHaveStyle({ animationDelay: '500ms', animationFillMode: 'both' });
});
it('becomes visible after intersection and specified delay', () => {
render(<Text delay={500}>Delayed visible</Text>);
const el = screen.getByText('Delayed visible');
expect(el).toHaveClass('opacity-0');
// Simulate intersection
act(() => {
intersectionCallback([{ isIntersecting: true }]);
});
// Should still be hidden before delay
act(() => {
vi.advanceTimersByTime(250);
});
expect(el).toHaveClass('opacity-0');
// Should be visible after delay
act(() => {
vi.advanceTimersByTime(250);
});
expect(el).not.toHaveClass('opacity-0');
expect(el).toHaveClass('animate-in');
expect(el).toHaveClass('fade-in');
});
it('becomes visible immediately after intersection if delay is 0', () => {
it('applies CSS animation classes and zero delay when delay is 0', () => {
render(<Text delay={0}>Zero delay</Text>);
const el = screen.getByText('Zero delay');
expect(el).toHaveClass('opacity-0');
// Simulate intersection
act(() => {
intersectionCallback([{ isIntersecting: true }]);
});
expect(el).not.toHaveClass('opacity-0');
expect(el).toHaveClass('animate-in');
expect(el).toHaveStyle({ animationDelay: '0ms', animationFillMode: 'both' });
});
it('applies alignment classes', () => {
+14 -23
View File
@@ -1,5 +1,5 @@
import { cn, genericMemo, useIntersectionObserver, castMutableRef } from '@ermnvldmr/stl';
import React, { forwardRef, useState, useEffect, useRef } from 'react';
import { cn, genericMemo, castMutableRef } from '@ermnvldmr/stl';
import React, { forwardRef, useEffect, useRef } from 'react';
import {
getTypographyClassNames,
@@ -85,7 +85,6 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
},
ref
) {
const [isVisible, setIsVisible] = useState(delay === undefined);
const internalRef = useRef<HTMLElement>(null);
useEffect(() => {
@@ -97,22 +96,13 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
}
}, [ref]);
const isIntersecting = useIntersectionObserver(internalRef, {
threshold: 0.1,
once: true,
});
useEffect(() => {
if (delay !== undefined && isIntersecting) {
if (delay > 0) {
const timer = setTimeout(() => setIsVisible(true), delay);
return () => clearTimeout(timer);
} else {
setIsVisible(true);
}
}
return undefined;
}, [delay, isIntersecting]);
const delayStyle: React.CSSProperties | undefined =
delay !== undefined
? {
animationDelay: `${delay}ms`,
animationFillMode: 'both',
}
: undefined;
return (
<Component
@@ -132,13 +122,14 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
overflow,
maxLines,
}),
delay !== undefined &&
(isVisible
? 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out'
: 'opacity-0'),
delay !== undefined && 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out',
className
)}
data-testid={testId}
style={{
...delayStyle,
...props.style,
}}
type={htmlType ?? (Component === 'button' ? 'button' : undefined)}
>
{children}
+3
View File
@@ -602,6 +602,9 @@ importers:
services/docs:
devDependencies:
'@ermnvldmr/cli':
specifier: workspace:*
version: link:../../packages/cli
'@ermnvldmr/eslint-config':
specifier: workspace:*
version: link:../../packages/eslint-config
+22
View File
@@ -0,0 +1,22 @@
import { defineCli, env, s3SyncCommand } from '@ermnvldmr/cli';
export default defineCli({
commands: [
s3SyncCommand({
name: 'sync:static',
description: 'Sync static assets to S3',
targetPrefix: 'docs',
sourceDir: './static',
sourceFilesPattern: 'content/**/*.{md,html}',
urlPrefixes: ['/static/', '/images/'],
bucket: env('COM_ERMNVLDMR_STATIC_S3_BUCKET'),
region: env('COM_ERMNVLDMR_STATIC_S3_REGION'),
endpoint: env('COM_ERMNVLDMR_STATIC_S3_ENDPOINT_URL'),
accessKeyId: env('COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID'),
secretAccessKey: env('COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY'),
hooks: {
beforeSync: 'bash ../../ci/scripts/optimize-images.sh ./static',
},
}),
],
});
@@ -0,0 +1,11 @@
(function () {
if (typeof window !== "undefined" && window.trustedTypes && window.trustedTypes.createPolicy) {
try {
window.trustedTypes.createPolicy("default", {
createHTML: function (s) { return s; },
createScript: function (s) { return s; },
createScriptURL: function (u) { return u; }
});
} catch (e) {}
}
})();
+47 -7
View File
@@ -1,13 +1,53 @@
---
title: "Ermnvldmr's Documentation"
title: "ermnvldmr.com Documentation"
layout: hextra-home
---
<br />
<div class="hx:w-full hx:flex hx:flex-col hx:sm:flex-row hx:items-stretch hx:justify-between hx:my-6" style="gap: 2.5rem;">
<div style="flex: 1 1 58%; min-width: 0;">
Welcome to the unified documentation for my own projects
<div class="hx:mb-6">
{{< hextra/hero-headline >}}
Documenting Decisions<br class="hx:sm:block hx:hidden" />Without the&nbsp;Actual Decisions
{{< /hextra/hero-headline >}}
</div>
{{< cards >}}
{{< card link="ermnvldmr" title="ermnvldmr.com Platform" icon="cloud" subtitle="Cloud-native infrastructure, Packer golden image, K3s cluster, and GitOps platform" >}}
{{< card link="apex" title="apex.ermnvldmr.com" icon="server" subtitle="Fleet operations framework: a stdlib-python engine and a shared compose core" >}}
{{< card link="quetza" title="Quetza Bot" icon="terminal" subtitle="Modular Discord bot framework documentation" >}}
<div class="hx:mb-6">
{{< hextra/hero-subtitle >}}
I&nbsp;have neither the&nbsp;time nor the&nbsp;desire to&nbsp;manually craft every line of&nbsp;this half-baked novel. Yet&nbsp;capturing context and&nbsp;architectural decisions is&nbsp;essential, otherwise the&nbsp;number of&nbsp;entities comprehending what goes on&nbsp;here drops to&nbsp;zero. Consider this a&nbsp;structured neural knowledge dump for&nbsp;AI&nbsp;agents and&nbsp;rare human visitors.
<br /><br />
If&nbsp;you ever feel like fixing anything (why though?)&nbsp;— any contribution here is&nbsp;guaranteed to&nbsp;be an&nbsp;improvement.
{{< /hextra/hero-subtitle >}}
</div>
<div class="hx:flex hx:flex-wrap hx:items-center hx:mb-8" style="gap: 1rem;">
<a href="https://ermnvldmr.com" target="_blank" rel="noreferrer" class="not-prose hx:font-medium hx:cursor-pointer hx:px-6 hx:py-3 hx:rounded-full hx:text-center hx:text-gray-800 hx:dark:text-gray-200 hx:inline-flex hx:items-center hx:gap-2 hx:bg-gray-100 hx:hover:bg-gray-200 hx:dark:bg-neutral-800 hx:dark:hover:bg-neutral-700 hx:border hx:border-gray-200 hx:dark:border-neutral-700 hx:transition-all hx:ease-in hx:duration-200">
<span>ermnvldmr.com</span>
{{< icon name="external-link" attributes="height=16" >}}
</a>
<a href="https://github.com/deytenit/ermnvldmr.com" target="_blank" rel="noreferrer" class="not-prose hx:font-medium hx:cursor-pointer hx:px-6 hx:py-3 hx:rounded-full hx:text-center hx:text-gray-800 hx:dark:text-gray-200 hx:inline-flex hx:items-center hx:gap-2 hx:bg-gray-100 hx:hover:bg-gray-200 hx:dark:bg-neutral-800 hx:dark:hover:bg-neutral-700 hx:border hx:border-gray-200 hx:dark:border-neutral-700 hx:transition-all hx:ease-in hx:duration-200">
{{< icon name="github" attributes="height=18" >}}
<span>GitHub</span>
</a>
</div>
<div id="projects" class="hx:w-full hx:mb-4">
<h2 class="hx:text-2xl hx:font-bold hx:tracking-tight">Projects</h2>
</div>
{{< cards cols="2" >}}
{{< card link="apex" title="apex.ermnvldmr.com" icon="server" subtitle="Fleet operations framework: a&nbsp;python3-stdlib engine and a&nbsp;shared compose core for&nbsp;self-hosted nodes." >}}
{{< card link="quetza" title="Quetza Bot" icon="terminal" subtitle="Modular Discord bot framework: plugin architecture, event handling, and&nbsp;state management." >}}
{{< /cards >}}
</div>
<div class="hx:w-full" style="flex: 1 1 42%; max-width: 460px; display: flex; flex-direction: column;">
<div class="hx:overflow-hidden hx:border hx:border-gray-200 hx:dark:border-neutral-800 hx:shadow-lg hx:bg-gray-50 hx:dark:bg-neutral-900/50" style="border-radius: 1.5rem; height: 100%; display: flex; flex-direction: column;">
<img src="https://static.ermnvldmr.com/docs/images/home/dead-internet-debugging.webp" alt="Dead Internet Debugging" class="hx:w-full" style="flex: 1 1 auto; width: 100%; height: 100%; min-height: 340px; object-fit: cover; object-position: top; border-radius: 1.5rem 1.5rem 0 0;" />
<div class="hx:p-3 hx:text-xs hx:text-gray-500 hx:dark:text-gray-400 hx:text-center hx:border-t hx:border-gray-200 hx:dark:border-neutral-800" style="flex-shrink: 0;">
Image enhanced with Gemini&nbsp;4&nbsp;Argon&nbsp;· <a href="https://www.reddit.com/r/ProgrammerHumor/s/UVVd1CnFxH" target="_blank" rel="noreferrer" class="hx:underline hx:hover:text-primary-600">Original on&nbsp;Reddit</a>
</div>
</div>
</div>
</div>
@@ -1,33 +0,0 @@
---
title: "ermnvldmr.com Platform"
weight: 1
type: docs
cascade:
type: docs
---
**ermnvldmr.com Platform** is the complete cloud-native infrastructure, automation pipeline, and GitOps delivery system powering all `ermnvldmr.com` production services.
The platform is designed around strict infrastructure-as-code principles, immutable golden OS images, secret encryption at rest, automated continuous reconciliation, and zero-downtime rolling updates.
---
## Documentation Sections
{{% cards %}}
{{< card link="architecture" title="Architecture & Flow" icon="server" subtitle="End-to-end architectural design: Packer, Cloud-Init, K3s, Flux GitOps, and Cloudflare." >}}
{{< card link="golden-image" title="Golden Image Automation" icon="cube" subtitle="Automated QEMU Packer build pipeline, kernel & OS bake-in, S3 artifact storage, and quota management." >}}
{{< card link="deployment-gitops" title="GitOps & Release Engine" icon="refresh" subtitle="Continuous deployment, semantic releases, fast-track deploys, and automated /version.json rollout checks." >}}
{{< card link="operations" title="Operations & Runbook" icon="shield-check" subtitle="SSH access, SOPS age encryption, persistent storage management, database backups, and disaster recovery." >}}
{{% /cards %}}
---
## Key Platform Characteristics
* **Immutable Golden Images**: Standardized Debian 12 + K3s base built via Packer and QEMU, isolated and stored in S3.
* **Autonomous First-Boot Initialization**: Cloud-Init automatically provisions storage, network hardening, firewall, age encryption secrets, and bootstraps Flux GitOps without requiring manual console access.
* **Declarative GitOps Engine**: Flux v2 continuously reconciles Kubernetes cluster state against the `next` branch in GitHub.
* **End-to-End Encryption**: SOPS + Age encrypts all sensitive secrets in Git; Let's Encrypt and Cloudflare Full (Strict) secure all public traffic.
* **Automated Production Rollout Verification**: Release pipelines query public `/version.json` probes to verify that rolling updates actively serve user traffic before finishing.
@@ -1,116 +0,0 @@
---
title: "Architecture & Flow"
weight: 2
type: docs
---
The `ermnvldmr.com` infrastructure architecture combines immutable OS image baking, autonomous cloud-init bootstrapping, and declarative Flux GitOps reconciliation into a single unified deployment model.
---
## 1. High-Level System Architecture
```mermaid
flowchart TD
subgraph GitHub ["GitHub Infrastructure"]
Repo["Git Repository (next branch)"]
Workflows["GitHub Actions Pipelines"]
GHCR["GitHub Container Registry (GHCR)"]
end
subgraph S3 ["S3-Compatible Object Storage"]
GoldenImage["Packer Golden Image (.qcow2)"]
Backups["Daily PostgreSQL Backups"]
end
subgraph Cloud ["Cloud Infrastructure / Compute Platform"]
subgraph VM ["Compute Virtual Machine"]
subgraph OS ["Debian 12 Bookworm"]
CloudInit["Cloud-Init First-Boot"]
UFW["UFW Firewall (80, 443, 22)"]
SSD["Secondary SSD (/dev/vdb /var/lib/rancher/k3s/storage)"]
end
subgraph K3s ["K3s Single-Node Cluster"]
Flux["Flux v2 Operator"]
Traefik["Traefik Ingress (HostPort 80/443)"]
CertManager["Cert-Manager (Let's Encrypt Prod)"]
subgraph Apps ["Namespaced Workloads (com-ermnvldmr)"]
WWW["www (React 19 SSG + Nginx)"]
Docs["docs (Hugo + Hextra + Nginx)"]
Postgres["PostgreSQL 16 StatefulSet"]
Umami["Umami Analytics Server"]
BackupCron["Postgres S3 Backup CronJob"]
end
end
end
end
subgraph Edge ["Cloudflare Edge"]
CF["Cloudflare Proxy (Full Strict)"]
end
Users["Internet Visitors"] --> CF
CF --> Traefik
Traefik --> WWW
Traefik --> Docs
Traefik --> Umami
Umami --> Postgres
BackupCron --> Backups
Workflows --> GHCR
Workflows --> GoldenImage
Repo --> Flux
Flux --> Apps
```
---
## 2. Platform Lifecycle Stages
The lifecycle of the platform operates in three distinct phases:
### Phase 1: Golden Image Build (Packer)
1. **Automated Trigger**: GitHub Actions runs `.github/workflows/ci-next-build-golden-image.yml`.
2. **QEMU Virtualization**: Packer launches a headless QEMU Debian 12 installation with preseed automation (`preseed.cfg`).
3. **Provisioning**:
* Installs base system packages (`curl`, `git`, `htop`, `fail2ban`, `ufw`, `qemu-guest-agent`, `isc-dhcp-client`).
* Configures `ds-identify` with `policy: enabled` so cloud-init is guaranteed to run.
* Pre-installs K3s binary (`INSTALL_K3S_SKIP_ENABLE=true`, `INSTALL_K3S_SKIP_START=true`).
* Strips machine identity (`/etc/machine-id`, DBus ID, SSH host keys, network MAC caches) for clean first-boot isolation.
4. **S3 Upload & Quota Management**: Converts image to compressed `qcow2` format, prunes older images in `dev/images/` to prevent bucket exhaustion, and uploads the verified golden image to S3-compatible object storage.
---
### Phase 2: Autonomous First-Boot (Cloud-Init)
When a VM is created from the golden image with `ci/cloud-init/user-data.template.yaml`:
1. **User & Network Setup**:
* Creates administrative user `adam` with public SSH key.
* Disables password authentication and root direct SSH access.
* Configures reliable fallback DNS nameservers (`1.1.1.1`, `8.8.8.8`, `77.88.8.8`).
2. **Security & Kernel Hardening**:
* Applies sysctl rules: SYN flood protection, disabling ICMP redirects, source routing protection, and strict reverse path filtering.
* Configures UFW firewall rules allowing only SSH (22), Traefik HTTP (80), and Traefik HTTPS (443), while routing pod CIDR (`10.42.0.0/16`) and service CIDR (`10.43.0.0/16`).
* Enables Fail2ban.
3. **Persistent Secondary Storage**:
* Detects secondary virtual disk `/dev/vdb`.
* Formats with `ext4` and filesystem label `k3s-storage` if not yet formatted.
* Mounts to `/var/lib/rancher/k3s/storage` and persists mount configuration in `/etc/fstab`.
4. **K3s & GitOps Engine Startup**:
* Starts `k3s.service` with built-in ServiceLB enabled.
* Injects the SOPS age decryption key into Kubernetes secret `flux-system/sops-age`.
* Installs Flux v2 custom resource definitions and controllers.
* Applies the root GitOps sync (`GitRepository` + `Kustomization`) pointing directly to `https://github.com/deytenit/ermnvldmr.com.git` on branch `next`.
---
### Phase 3: Continuous Reconciliation & Delivery (GitOps)
1. **Flux Controller**:
* Polls the `next` branch every 1 minute for repository changes.
* Evaluates Kustomize overlays in `ci/k8s/clusters/production`.
* Automatically decrypts `*.sops.yaml` secrets in-cluster using the injected `sops-age` key.
* Enforces zero-downtime rolling update strategies (`maxUnavailable: 0`, `maxSurge: 1`).
2. **Ingress & TLS Automation**:
* Traefik binds HostPorts 80 and 443.
* Cert-Manager solves Let's Encrypt HTTP-01 challenges via Ingress routes.
* Cloudflare Edge operates in **Full (strict)** SSL mode, proxying traffic securely to Traefik.
@@ -1,74 +0,0 @@
---
title: "GitOps & Release Engine"
weight: 4
type: docs
---
The platform uses a declarative GitOps model powered by **Flux v2** and **Kustomize**, with automated post-deployment rollout validation across all production workloads.
---
## 1. Monorepo GitOps Layout
All Kubernetes manifests are located in [`ci/k8s/`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/k8s/):
```text
ci/k8s/
├── apps/ # Application workloads
│ ├── docs/ # Hugo documentation service
│ ├── postgres/ # PostgreSQL 16 StatefulSet & S3 backup CronJob
│ ├── umami/ # Umami web analytics
│ └── www/ # React 19 primary website
├── base/ # Base cluster resources
│ └── namespaces/ # `com-ermnvldmr` namespace definition
├── clusters/ # Flux cluster root definitions
│ └── production/ # Root Kustomization point for production
└── infrastructure/ # Shared cluster infrastructure
├── cert-manager/ # Cert-Manager v1.17.0 & Let's Encrypt ClusterIssuer
└── networking/ # Traefik Ingress routing rules
```
---
## 2. Release Workflows & Continuous Deployment
### Service Deployment Modes:
1. **`services/docs` (Continuous Deployment)**:
* **Trigger**: Push to `next` modifying `services/docs/**`.
* **Workflow**: `.github/workflows/services-docs-next-required.yml`.
* **Action**: Builds Docker image, updates `ci/k8s/apps/docs/kustomization.yaml`, commits to `next`, and runs automated `verify-rollout`.
2. **`services/www` (Trunk-Based CD with CalVer Release Gate)**:
* **Trigger**: Push to `next` modifying `services/www/**` or workspace dependencies (`packages/ui/**`, `packages/stl/**`, `packages/i18n/**`), or manual `workflow_dispatch`.
* **Workflow**: `.github/workflows/services-www-next-required.yml`.
* **Pipeline**: Full validation suite (Vitest, Storybook Playwright, Lint, Typecheck, Build) ➔ Builds Docker image with `sha-<commit>` tag ➔ Approval gate (`services-www-releases-publish`) tags CalVer `YYYY.MM.MICRO` in Git & GHCR ➔ Approval gate (`services-www-releases-deploy`) updates GitOps tag ➔ Runs `verify-rollout`.
---
## 3. Automated Post-Deployment Verification (`/version.json`)
To guarantee that a deployment has actually completed and is serving user traffic:
1. Every container image includes a generated `/usr/share/nginx/html/version.json` with `COMMIT_SHA` and `VERSION` build metadata.
2. Nginx serves `/version.json` with `Cache-Control: no-store, no-cache, must-revalidate`.
3. The `verify-rollout` GitHub Actions job polls the public endpoint (`https://www.ermnvldmr.com/version.json` or `https://docs.ermnvldmr.com/version.json`) every 10 seconds (up to 5 minutes) until the live production commit matches the deployed commit.
```bash
TARGET_COMMIT="sha-26f0c23"
for i in {1..30}; do
LIVE_COMMIT=$(curl -sf https://www.ermnvldmr.com/version.json | jq -r '.commit')
if [ "$LIVE_COMMIT" = "$TARGET_COMMIT" ]; then
echo "✅ Production rollout verified!"
exit 0
fi
sleep 10
done
```
---
## 4. Rollback Strategy
* **Git Revert (Standard)**: Revert the deployment commit in Git and push to `next`.
* **GitOps Script**: Run `bash ci/scripts/deploy-gitops.sh <service> <previous_tag>`.
* **Emergency Kubernetes Rollback**: Run `kubectl rollout undo deployment/<service> -n com-ermnvldmr` on the cluster for instant 2-second rollback.
@@ -1,61 +0,0 @@
---
title: "Golden Image Automation"
weight: 3
type: docs
---
The `ermnvldmr.com` golden image pipeline automates the generation of standardized, hardened Debian 12 virtual machine images pre-configured for single-node K3s clustering.
---
## 1. Packer Configuration & Provisioning Pipeline
Golden images are defined in [`ci/packer/`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/packer/) and built using the `qemu` Packer builder.
### Key Files:
* **[`debian-12-k3s.pkr.hcl`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/packer/debian-12-k3s.pkr.hcl)**: Main Packer template specifying CPU, RAM, disk size (10GB qcow2), Debian 12 Netinst ISO, boot commands, and provisioner sequence.
* **[`http/preseed.cfg`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/packer/http/preseed.cfg)**: Debian automated preseed installation rules (minimal system, non-interactive partitioning, APT mirrors).
* **[`scripts/01-base-system.sh`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/packer/scripts/01-base-system.sh)**: Base system package installation, cloud-init forced policy enablement (`/etc/cloud/ds-identify.cfg`), kernel optimization, and fail2ban setup.
* **[`scripts/02-install-k3s.sh`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/packer/scripts/02-install-k3s.sh)**: Downloads and installs the K3s binary (`INSTALL_K3S_SKIP_ENABLE=true`, `INSTALL_K3S_SKIP_START=true`) so the binary is baked into `/usr/local/bin/k3s` without creating a cluster during image build.
* **[`scripts/03-cleanup.sh`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/packer/scripts/03-cleanup.sh)**: Machine identity reset (`/etc/machine-id`, `/var/lib/dbus/machine-id`, `/etc/ssh/ssh_host_*`), cache purging, and zeroing free space for maximum image compression.
---
## 2. GitHub Actions Automation
The image build is executed via `.github/workflows/ci-next-build-golden-image.yml`.
### Workflow Stages:
1. **Validate Provisioner Scripts**: Verifies bash syntax (`bash -n ci/packer/scripts/*.sh`) and Packer template formatting (`packer fmt -check`).
2. **Build Golden Image**:
* Initializes Packer plugins (`packer init`).
* Runs headless QEMU virtual machine build (`packer build`).
* Compresses the output artifact to `qcow2`.
3. **S3 Storage Upload & Quota Management**:
* Configures AWS S3 CLI with S3-compatible Object Storage credentials.
* **Automatic Quota Pruning**: Removes older images matching `dev/images/com-ermnvldmr-debian-12-k3s-amd64-*.qcow2` before uploading to prevent exceeding bucket limits.
* **Uploads**: The newly baked image is pushed to:
```text
s3://<bucket>/dev/images/com-ermnvldmr-debian-12-k3s-amd64-<git_short_sha>.qcow2
```
---
## 3. Naming Convention
Images follow a deterministic naming structure based on the git commit:
```text
com-ermnvldmr-debian-12-k3s-amd64-<short_sha>.qcow2
```
Example: `com-ermnvldmr-debian-12-k3s-amd64-f4ba6d1.qcow2`
---
## 4. First-Boot Cloud-Init Integration
When launching a compute instance from the uploaded golden image in your cloud infrastructure:
1. Pass the instantiated [`user-data.template.yaml`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/cloud-init/user-data.template.yaml) in the instance metadata under `user-data`.
2. Cloud-init executes `/usr/local/bin/bootstrap-cluster.sh` autonomously on first boot.
3. The instance initializes the network, formats persistent SSDs, starts K3s, installs Flux, and syncs production workloads within ~90 seconds.
@@ -1,100 +0,0 @@
---
title: "Operations & Runbook"
weight: 5
type: docs
---
Practical runbook and operational guidelines for managing, debugging, and maintaining the `ermnvldmr.com` production cluster.
---
## 1. Remote SSH Access
Connect to the virtual machine using the administrative `adam` account:
```bash
ssh -i ~/.ssh/id_ed25519 adam@<SERVER_IP>
```
The `adam` user has passwordless `sudo` privileges and automatically configured `kubectl` credentials in `~/.kube/config`.
---
## 2. Cluster Health Inspection
### Check Pod Status
```bash
kubectl get pods -n com-ermnvldmr -o wide
```
### Check Ingress & TLS Certificates
```bash
kubectl get certificates -n com-ermnvldmr
kubectl get ingress -n com-ermnvldmr
```
### Check Flux GitOps Sync Status
```bash
kubectl get gitrepositories -n flux-system
kubectl get kustomizations -n flux-system
```
### Force Immediate GitOps Reconciliation
```bash
kubectl annotate --overwrite kustomization -n flux-system cluster-sync reconcile.fluxcd.io/requestedAt="$(date +%s)"
```
---
## 3. Secret Management with SOPS + Age
All sensitive files (`*.sops.yaml`) are committed directly to Git using SOPS with Age asymmetric encryption.
### SOPS Configuration (`.sops.yaml`):
```yaml
creation_rules:
- path_regex: 'ci/k8s/.*secret.*\.yaml$'
encrypted_regex: '^(data|stringData)$'
age: 'age1<YOUR_PUBLIC_AGE_KEY>'
```
> **Important**: The `encrypted_regex` rule preserves `apiVersion`, `kind`, and `metadata.name` in plaintext so Kustomize and Flux can inspect and route resources without pre-parsing errors.
### Encrypting a Secret
```bash
sops --encrypt --in-place ci/k8s/apps/postgres/secret.sops.yaml
```
### Editing an Encrypted Secret
```bash
sops ci/k8s/apps/postgres/secret.sops.yaml
```
---
## 4. PostgreSQL Storage & S3 Backups
### Storage Architecture
* PostgreSQL data resides on a persistent 5Gi PVC backed by `/dev/vdb` (mounted at `/var/lib/rancher/k3s/storage`).
* Storage survives VM restarts and node maintenance.
### Automated Daily S3 Backup CronJob
* Manifest: [`ci/k8s/apps/postgres/backup-cronjob.yaml`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/k8s/apps/postgres/backup-cronjob.yaml).
* Runs daily at 03:00 UTC (`0 3 * * *`).
* Executes `pg_dump` with gzip compression and uploads directly to `s3://<bucket>/backups/postgres/`.
### Manual Backup Trigger
```bash
kubectl create job --from=cronjob/postgres-backup manual-backup-$(date +%s) -n com-ermnvldmr
```
---
## 5. Cloudflare SSL/TLS Configuration
* **SSL Mode**: **Full (strict)**
* **HTTPS Redirect Bypass Rule**: Ensure an exception rule exists for ACME HTTP-01 challenges:
```text
http.request.scheme eq "http" and not starts_with(http.request.uri.path, "/.well-known/acme-challenge/")
```
This allows cert-manager to renew Let's Encrypt certificates over port 80 without being redirected or blocked.
+47 -7
View File
@@ -1,13 +1,53 @@
---
title: 'Документация ermnvldmr.com'
title: "Документация ermnvldmr.com"
layout: hextra-home
---
<br />
<div class="hx:w-full hx:flex hx:flex-col hx:sm:flex-row hx:items-stretch hx:justify-between hx:my-6" style="gap: 2.5rem;">
<div style="flex: 1 1 58%; min-width: 0;">
Добро пожаловать в единую документацию для моих собственных проектов
<div class="hx:mb-6">
{{< hextra/hero-headline >}}
Документация решений<br class="hx:sm:block hx:hidden" />без&nbsp;решений как&nbsp;таковых
{{< /hextra/hero-headline >}}
</div>
{{< cards >}}
{{< card link="ermnvldmr" title="Платформа ermnvldmr.com" icon="cloud" subtitle="Cloud-native инфраструктура, golden image на Packer, K3s кластер и GitOps" >}}
{{< card link="/en/apex" title="apex.ermnvldmr.com" icon="server" subtitle="Fleet operations framework (English only)" >}}
{{< card link="quetza" title="Quetza Bot" icon="terminal" subtitle="Документация модульного фреймворка Discord-бота" >}}
<div class="hx:mb-6">
{{< hextra/hero-subtitle >}}
У&nbsp;меня нет ни&nbsp;времени, ни&nbsp;желания вручную выписывать каждую строчку этого недоромана. Однако фиксировать контекст и&nbsp;принятые решения нужно, иначе число сущностей, понимающих происходящее, упадёт до&nbsp;нуля. Считайте это структурированной нейробазой для&nbsp;агентов и&nbsp;редких живых гостей.
<br /><br />
А&nbsp;если вдруг захотите что-то исправить (зачем?)&nbsp;— любой вклад здесь пойдёт на&nbsp;пользу.
{{< /hextra/hero-subtitle >}}
</div>
<div class="hx:flex hx:flex-wrap hx:items-center hx:mb-8" style="gap: 1rem;">
<a href="https://ermnvldmr.com" target="_blank" rel="noreferrer" class="not-prose hx:font-medium hx:cursor-pointer hx:px-6 hx:py-3 hx:rounded-full hx:text-center hx:text-gray-800 hx:dark:text-gray-200 hx:inline-flex hx:items-center hx:gap-2 hx:bg-gray-100 hx:hover:bg-gray-200 hx:dark:bg-neutral-800 hx:dark:hover:bg-neutral-700 hx:border hx:border-gray-200 hx:dark:border-neutral-700 hx:transition-all hx:ease-in hx:duration-200">
<span>ermnvldmr.com</span>
{{< icon name="external-link" attributes="height=16" >}}
</a>
<a href="https://github.com/deytenit/ermnvldmr.com" target="_blank" rel="noreferrer" class="not-prose hx:font-medium hx:cursor-pointer hx:px-6 hx:py-3 hx:rounded-full hx:text-center hx:text-gray-800 hx:dark:text-gray-200 hx:inline-flex hx:items-center hx:gap-2 hx:bg-gray-100 hx:hover:bg-gray-200 hx:dark:bg-neutral-800 hx:dark:hover:bg-neutral-700 hx:border hx:border-gray-200 hx:dark:border-neutral-700 hx:transition-all hx:ease-in hx:duration-200">
{{< icon name="github" attributes="height=18" >}}
<span>GitHub</span>
</a>
</div>
<div id="projects" class="hx:w-full hx:mb-4">
<h2 class="hx:text-2xl hx:font-bold hx:tracking-tight">Проекты</h2>
</div>
{{< cards cols="2" >}}
{{< card link="apex" title="apex.ermnvldmr.com" icon="server" subtitle="Fleet operations framework: движок на&nbsp;python3-stdlib и&nbsp;общее ядро Docker&nbsp;Compose для&nbsp;self-hosted узлов." >}}
{{< card link="quetza" title="Quetza Bot" icon="terminal" subtitle="Модульный фреймворк для&nbsp;Discord-ботов: архитектура плагинов, обработка событий и&nbsp;управление состоянием." >}}
{{< /cards >}}
</div>
<div class="hx:w-full" style="flex: 1 1 42%; max-width: 460px; display: flex; flex-direction: column;">
<div class="hx:overflow-hidden hx:border hx:border-gray-200 hx:dark:border-neutral-800 hx:shadow-lg hx:bg-gray-50 hx:dark:bg-neutral-900/50" style="border-radius: 1.5rem; height: 100%; display: flex; flex-direction: column;">
<img src="https://static.ermnvldmr.com/docs/images/home/dead-internet-debugging.webp" alt="Dead Internet Debugging" class="hx:w-full" style="flex: 1 1 auto; width: 100%; height: 100%; min-height: 340px; object-fit: cover; object-position: top; border-radius: 1.5rem 1.5rem 0 0;" />
<div class="hx:p-3 hx:text-xs hx:text-gray-500 hx:dark:text-gray-400 hx:text-center hx:border-t hx:border-gray-200 hx:dark:border-neutral-800" style="flex-shrink: 0;">
Изображение улучшено с&nbsp;помощью Gemini&nbsp;4&nbsp;Argon&nbsp;· <a href="https://www.reddit.com/r/ProgrammerHumor/s/UVVd1CnFxH" target="_blank" rel="noreferrer" class="hx:underline hx:hover:text-primary-600">Оригинал на&nbsp;Reddit</a>
</div>
</div>
</div>
</div>
+14 -14
View File
@@ -1,33 +1,33 @@
---
title: Документация фреймворка операций флота apex
description: "Документация фреймворка операций флота apex: движок на stdlib-python и общее ядро compose для self-hosted узлов."
description: "Документация фреймворка операций флота apex: движок на stdlib Python и общее ядро compose для self-hosted-узлов."
weight: 1
type: docs
cascade:
type: docs
---
`apex` — это [commons](/apex/glossary#commons) (общий репозиторий) флота self-hosted [узлов](/apex/glossary#node): [движок](/apex/glossary#engine) на python3-stdlib, который запускает сгруппированные [действия](/apex/glossary#action) как `apex <group>/<action>`, в паре с общим, параметризуемым через `${env}` ядром docker-compose. Каждый репозиторий узла подключает фреймворк как закреплённый git-субмодуль `commons/`. apex приходит на смену bash-фреймворку commons под названием `root`.
`apex` — это [commons](/apex/glossary#commons) (общий репозиторий) флота self-hosted-[узлов](/apex/glossary#node): [движок](/apex/glossary#engine) на python3-stdlib, который запускает сгруппированные [действия](/apex/glossary#action) как `apex <group>/<action>`, в сочетании с общим ядром docker-compose, параметризуемым через `${env}`. Каждый репозиторий узла подключает фреймворк как зафиксированный git-субмодуль `commons/`. `apex` приходит на смену bash-фреймворку `root`.
Стек намеренно минимален: `git` плюс стандартная библиотека Python 3. Здесь нет ни `pip`, ни Node, ни Go, ни CUE, а compose-файлы написаны вручную — без кодогенерации.
Стек намеренно минимален: `git` и стандартная библиотека Python 3. Здесь нет ни `pip`, ни Node, ни Go, ни CUE, а compose-файлы написаны вручную, без кодогенерации.
## Основные возможности
- **Единая точка входа в локальном чекауте** — `apex` всегда действует на том чекауте, из которого он запущен; аргумент `<node>` передавать не нужно.
- **Явная идентичность из `node.env`** — [идентичность узла](/apex/glossary#node-identity) объявляется для каждого узла в `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`); фреймворк не предполагает какого-либо конкретного домена, а короткое `name` узла — это просто первая метка хоста.
- **Репозитории узлов из четырёх опор** — каждый репозиторий узла имеет одинаковую структуру: закреплённый субмодуль `commons/`, локальные [проприетарные модули узла](/apex/glossary#proprietaries), его [композиции](/apex/glossary#compositions) и `node.env`.
- **Проприетарный оверлей** — локальные проприетарные модули узла [накладываются](/apex/glossary#overlay) поверх общих действий; commons содержит только то, что разделяют все узлы.
- **Многоуровневое хранилище с изоляцией привилегий** — стандартизированные [уровни](/apex/glossary#tier) хранилища с изоляцией привилегий на уровне проекта.
- **Общая базовая композиция** — [базовая композиция](/apex/glossary#core-composition) (`compositions/apex/docker-compose.yml`) параметризуется через `${env}` и включается в композиции каждого узла.
- **Единая точка входа в локальном чекауте** — `apex` всегда выполняется в том чекауте, из которого запущен; аргумент `<node>` передавать не нужно.
- **Явная идентичность из `node.env`** — [идентичность узла](/apex/glossary#node-identity) задаётся в `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`); фреймворк не привязан к конкретному домену, а короткое имя `name` узла — просто первая метка хоста.
- **Репозитории узлов из четырёх опор** — каждый репозиторий узла устроен одинаково: зафиксированный субмодуль `commons/`, локальные [проприетарные модули узла](/apex/glossary#proprietaries), его [композиции](/apex/glossary#compositions) и `node.env`.
- **Проприетарный оверлей** — локальные проприетарные модули узла [накладываются](/apex/glossary#overlay) поверх общих действий; commons содержит только то, что объединяет все узлы.
- **Многоуровневое хранилище с изоляцией привилегий** — стандартизированные [уровни](/apex/glossary#tier) хранилища с изоляцией прав на уровне каждого проекта.
- **Общая базовая композиция** — [базовая композиция](/apex/glossary#core-composition) (`compositions/apex/docker-compose.yml`) параметризуется через `${env}` и включается в композиции каждого узла.
## Разделы
{{% cards %}}
{{< card link="getting-started" title="Начало работы" icon="map" subtitle="Предварительные требования, начальная настройка и первые действия на новом узле." >}}
{{< card link="concepts" title="Концепции" icon="book-open" subtitle="Архитектура, идентичность, оверлей и модель хранилища." >}}
{{< card link="guides" title="Руководства" icon="map" subtitle="Практические руководства по эксплуатации и расширению узла." >}}
{{< card link="reference" title="Справочник" icon="code" subtitle="Действия, вспомогательная библиотека движка и коды выхода." >}}
{{< card link="troubleshooting" title="Устранение неполадок" icon="shield-check" subtitle="Симптомы, диагностика и исправление типовых сбоев." >}}
{{< card link="getting-started" title="Начало работы" icon="map" subtitle="Предварительные требования, начальная настройка и первые действия на новом узле." >}}
{{< card link="concepts" title="Концепции" icon="book-open" subtitle="Архитектура, идентичность, оверлей и модель хранилища." >}}
{{< card link="guides" title="Руководства" icon="map" subtitle="Практические руководства по эксплуатации и расширению узла." >}}
{{< card link="reference" title="Справочник" icon="code" subtitle="Действия, вспомогательная библиотека движка и коды выхода." >}}
{{< card link="troubleshooting" title="Устранение неполадок" icon="shield-check" subtitle="Симптомы, диагностика и исправление типовых сбоев." >}}
{{% /cards %}}
---
@@ -1,20 +1,20 @@
---
title: Концепции
description: "Архитектура, идентичность, действия, контекст выполнения, композиции, хранилище, адресация и безопасность."
description: "Архитектура, идентичность, действия, контекст выполнения, композиции, хранилище, адресация и безопасность."
weight: 10
---
Теоретические основы фреймворка операций флота apex: как [узел](/apex/glossary#node) объявляет свою [идентичность](/apex/concepts/node-identity), как выполняются [действия](/apex/glossary#action) и как узел организует свои сервисы, хранилище и адресацию.
Теоретические основы фреймворка операций флота apex: как [узел](/apex/glossary#node) объявляет свою [идентичность](/apex/concepts/node-identity), как выполняются [действия](/apex/glossary#action) и как узел организует свои сервисы, хранилище и адресацию.
{{% cards %}}
{{< card link="architecture" title="Архитектура" icon="share" subtitle="Слои движка, commons и configs и как они сочетаются друг с другом." >}}
{{< card link="node-identity" title="Идентичность узла" icon="finger-print" subtitle="Как узел объявляет, кто он и где ему место." >}}
{{< card link="actions-and-overlay" title="Действия и оверлей" icon="lightning-bolt" subtitle="Общие действия и локальные переопределения узла." >}}
{{< card link="execution-context" title="Контекст выполнения" icon="terminal" subtitle="Окружение, которое каждое действие получает во время выполнения." >}}
{{< card link="compositions" title="Композиции" icon="cube" subtitle="Как стеки сервисов объявляются и разворачиваются на каждом узле." >}}
{{< card link="tiered-storage" title="Многоуровневое хранилище" icon="database" subtitle="Стандартизированная классификация хранилища для данных сервисов." >}}
{{< card link="addressing" title="Адресация" icon="globe-alt" subtitle="Подсеть узла, якорные IP и соглашение об анклаве." >}}
{{< card link="security-model" title="Модель безопасности" icon="shield-check" subtitle="Изоляция привилегий и усиление защиты сети." >}}
{{< card link="architecture" title="Архитектура" icon="share" subtitle="Слои движка, commons и configs: как они устроены и сочетаются друг с другом." >}}
{{< card link="node-identity" title="Идентичность узла" icon="finger-print" subtitle="Как узел объявляет, кто он и где его место во флоте." >}}
{{< card link="actions-and-overlay" title="Действия и оверлей" icon="lightning-bolt" subtitle="Общие действия и локальные переопределения узла." >}}
{{< card link="execution-context" title="Контекст выполнения" icon="terminal" subtitle="Окружение, которое каждое действие получает во время выполнения." >}}
{{< card link="compositions" title="Композиции" icon="cube" subtitle="Как стеки сервисов объявляются и разворачиваются на каждом узле." >}}
{{< card link="tiered-storage" title="Многоуровневое хранилище" icon="database" subtitle="Стандартизированная классификация хранилища для данных сервисов." >}}
{{< card link="addressing" title="Адресация" icon="globe-alt" subtitle="Подсеть узла, якорные IP-адреса и соглашение об анклаве." >}}
{{< card link="security-model" title="Модель безопасности" icon="shield-check" subtitle="Изоляция привилегий и усиление защиты сети." >}}
{{% /cards %}}
---
@@ -1,19 +1,19 @@
---
title: "Действия и оверлей"
description: "Контракт действия и как узло-локальные проприетарии затеняют действия commons."
description: "Контракт действия и затенение действий commons локальными проприетариями узла."
weight: 13
---
Каждая единица автоматизации в apex — это [действие](/apex/glossary#action): один Python-модуль, который [движок](/apex/glossary#engine) диспетчеризует по имени. [Оверлей](/apex/glossary#overlay) позволяет [проприетариям](/apex/glossary#proprietaries) узла добавлять, переопределять или отключать действия [«commons»](/apex/glossary#commons), не форкая их. Эта страница определяет контракт действия, декларативные дескрипторы аргументов и правила разрешения.
Каждая единица автоматизации в apex — это [действие](/apex/glossary#action): один Python-модуль, который [движок](/apex/glossary#engine) диспетчеризует по имени. [Оверлей](/apex/glossary#overlay) позволяет [проприетариям](/apex/glossary#proprietaries) узла добавлять, переопределять или отключать действия [«commons»](/apex/glossary#commons) без создания форков. На этой странице описаны контракт действия, декларативные дескрипторы аргументов и правила разрешения.
## Контракт действия {#contract}
Действие — это один файл `.py` в дереве `actions/`. Путь к файлу **является** именем действия: `actions/<group>/<action>.py` вызывается как `apex <group>/<action> [args...]`. Нет реестра, который нужно обновлять — размещение файла в дереве публикует действие.
Действие — это отдельный файл `.py` в дереве `actions/`. Путь к файлу служит именем действия: `actions/<group>/<action>.py` вызывается как `apex <group>/<action> [args...]`. Обновлять реестр не нужно: появление файла в дереве сразу публикует действие.
Модуль должен предоставлять две вещи:
- **`METADATA = Meta(...)`** &rarr; краткое описание и дескрипторы аргументов, которые управляют как текстом справки, так и парсингом. Система дескрипторов пришла на смену heredoc-у `action_metadata()` bash-фреймворка «root» commons, предшественника apex.
- **`run(ctx, args)`** &rarr; точка входа. `ctx` — это построенный движком контекст выполнения; `args` — распарсенное пространство имён `argparse`.
- **`METADATA = Meta(...)`** &rarr; краткое описание и дескрипторы аргументов, управляющие как текстом справки, так и парсингом. Система дескрипторов пришла на смену heredoc-блоку `action_metadata()` bash-фреймворка «root» commons, предшественника apex.
- **`run(ctx, args)`** &rarr; точка входа. `ctx` — сформированный движком контекст выполнения; `args` — распарсенное пространство имён `argparse`.
```python {filename="actions/<group>/<action>.py"}
from engine.descriptor import Meta, Arg, Flag
@@ -31,38 +31,38 @@ def run(ctx, args):
return 0
```
Возвращаемое значение `run` становится кодом завершения процесса. Нецелочисленный возврат (включая `None`) трактуется как успех (`0`). Необработанное исключение логируется с префиксом контекста и завершается с кодом `1`.
Возвращаемое значение `run` становится кодом завершения процесса. Нецелочисленный результат (включая `None`) трактуется как успешное завершение (`0`). Необработанное исключение логируется с префиксом контекста, после чего процесс завершается с кодом `1`.
> [!NOTE]
> `METADATA` технически необязательна — модуль без неё диспетчеризуется с пустым `Meta` и не принимает аргументов — но каждое настоящее действие должно её объявлять, поскольку вывод в перечислении и `--help` генерируется из неё.
> Технически `METADATA` необязательна: модуль без неё диспетчеризуется с пустым `Meta` и не принимает аргументов. Однако в реальных действиях её объявлять необходимо, поскольку вывод перечисления и справка `--help` генерируются именно из неё.
## Декларативные аргументы {#arguments}
`Meta.args` — это список дескрипторов. Каждый отображается ровно на одну конструкцию `argparse`:
`Meta.args` — это список дескрипторов. Каждый отображается ровно на одну конструкцию `argparse`:
| Дескриптор | Отображается на | Поведение |
| --- | --- | --- |
| `Arg("name")` | позиционный аргумент | Обязателен по умолчанию; `required=False` делает его опциональным позиционным (`nargs="?"`, по умолчанию `None`). |
| `Opt("--name", default=...)` | опция со значением | Опциональный флаг, принимающий значение, с объявленным значением по умолчанию. |
| `Arg("name")` | позиционный аргумент | Обязателен по умолчанию; `required=False` делает его опциональным позиционным (`nargs="?"`, по умолчанию `None`). |
| `Opt("--name", default=...)` | опция со значением | Опциональный флаг, принимающий значение, с объявленным значением по умолчанию. |
| `Flag("--name")` | булев флаг | `store_true`; наличие означает `True`. |
| `Rest("name")` | остаток | Жадная сквозная передача всех оставшихся argv (`argparse.REMAINDER`). |
| `Rest("name")` | остаток | Жадная сквозная передача всех оставшихся аргументов командной строки (`argparse.REMAINDER`). |
Движок строит парсер как `apex <group>/<action>` и парсит оставшиеся argv перед вызовом `run`. `--help` обрабатывается на этапе парсинга. Ошибка использования — неизвестный флаг, отсутствующий позиционный аргумент — печатает usage и завершается с кодом `64` (`EX_USAGE`) вместо стандартного для argparse `2`.
Движок строит парсер как `apex <group>/<action>` и разбирает оставшиеся аргументы перед вызовом `run`. Флаг `--help` обрабатывается на этапе парсинга. При ошибке использования (неизвестный флаг, отсутствующий позиционный аргумент) выводится справка по синтаксису (usage), а процесс завершается с кодом `64` (`EX_USAGE`) вместо стандартного для argparse кода `2`.
## Перечисление без импорта {#listing}
`apex` без аргументов (или `--help`) перечисляет каждое доступное действие. Перечисление никогда не импортирует модули действий: движок статически сканирует каждый файл модулем `ast` и извлекает только краткое описание `METADATA` и любую константу `DISABLED`. Поэтому код действия не может выполниться — или упасть — во время перечисления; файл, который не удаётся распарсить, перечисляется как `(unparseable)`, а не ломает перечисление.
Команда `apex` без аргументов (или с флагом `--help`) выводит список всех доступных действий. Перечисление никогда не импортирует модули действий: движок статически сканирует каждый файл с помощью модуля `ast` и извлекает только краткое описание `METADATA` и любую константу `DISABLED`. Поэтому код действия не выполняется и не может вызвать сбой во время перечисления: файл, который не удалось распарсить, помечается как `(unparseable)`, не нарушая работу всей команды.
Каждая запись помечается своим источником оверлея:
- нет метки &rarr; действие commons,
- `(local)` &rarr; существует только в проприетариях,
- `(local override)` &rarr; действие проприетариев, затеняющее действие commons,
- `[DISABLED: reason]` &rarr; действие объявляет `DISABLED`.
- нет метки &rarr; действие commons;
- `(local)` &rarr; существует только в проприетариях;
- `(local override)` &rarr; действие проприетариев, затеняющее действие commons;
- `[DISABLED: reason]` &rarr; действие объявляет константу `DISABLED`.
## Оверлей {#overlay}
Имена действий разрешаются по двум деревьям, сначала проприетарии:
Имена действий разрешаются по двум деревьям, начиная с проприетариев:
{{< filetree/container >}}
{{< filetree/folder name="node repository" state="open" >}}
@@ -81,26 +81,26 @@ def run(ctx, args):
{{< /filetree/folder >}}
{{< /filetree/container >}}
Движок определяет эту схему по своему собственному расположению: когда он запускается из каталога `commons/`, родитель которого содержит `node.env`, родитель является корнем репозитория [узла](/apex/glossary#node), а `proprietaries/actions/` — деревом оверлея. Автономный checkout commons является собственным корнем и не имеет слоя оверлея. Внутри репозитория узла [идентичность узла](/apex/glossary#node-identity) разрешается из FQDN хоста перед диспетчеризацией.
Движок определяет структуру размещения по своему расположению на диске: если запуск происходит из каталога `commons/`, а в родительском каталоге присутствует `node.env`, родительский каталог считается корнем репозитория [узла](/apex/glossary#node), а `proprietaries/actions/` — деревом оверлея. Автономный checkout commons сам выступает в качестве корня и не имеет слоя оверлея. Внутри репозитория узла [идентичность узла](/apex/glossary#node-identity) разрешается из FQDN хоста перед диспетчеризацией.
Разрешение для заданного имени проверяет `proprietaries/actions/<name>.py` первым и `commons/actions/<name>.py` вторым; ровно один модуль загружается по пути. Неизвестное имя — это ошибка (код завершения `1`). Оверлей поддерживает четыре приёма:
При разрешении имени движок сначала проверяет `proprietaries/actions/<name>.py`, а затем `commons/actions/<name>.py`; по найденному пути загружается ровно один модуль. Неизвестное имя считается ошибкой (код завершения `1`). Оверлей поддерживает четыре сценария:
- **Добавление (Add)** &rarr; файл, существующий только в проприетариях, становится узло-локальным действием, перечисляется как `(local)`.
- **Переопределение (Override)** &rarr; файл проприетариев по тому же относительному пути полностью затеняет реализацию commons; путь commons записывается в разрешении как `shadowed`.
- **Обёртывание (Wrap)** &rarr; переопределяющий модуль может делегировать реализации, которую он затеняет, через `ctx.commons.run(args)`, добавляя узло-локальное поведение до или после логики commons вместо её замены.
- **Отключение (Disable)** &rarr; модуль, присваивающий `DISABLED = "reason"`, отклоняется при диспетчеризации: движок логирует причину и завершается с кодом `1`. Перечисление показывает `[DISABLED: reason]`. Голый `DISABLED = True` также отключает, с обобщённой причиной `disabled`; пустая строка не отключает.
- **Добавление (Add)** &rarr; файл, существующий только в проприетариях, становится локальным действием узла и отображается в списке как `(local)`.
- **Переопределение (Override)** &rarr; файл проприетариев по тому же относительному пути полностью затеняет реализацию commons; путь commons помечается при разрешении как `shadowed`.
- **Обёртывание (Wrap)** &rarr; переопределяющий модуль может делегировать вызов затенённой реализации через `ctx.commons.run(args)`, дополняя поведение commons локальной логикой узла до или после её вызова.
- **Отключение (Disable)** &rarr; модуль с объявлением `DISABLED = "reason"` отклоняется при диспетчеризации: движок логирует причину и завершается с кодом `1`. При перечислении выводится `[DISABLED: reason]`. Простое значение `DISABLED = True` также отключает действие со стандартной причиной `disabled`; пустая строка действие не отключает.
## Коды завершения {#exit-codes}
Действия и движок следуют фиксированному соглашению о кодах завершения:
Действия и движок следуют единому соглашению о кодах завершения:
| Код | Значение | Типичная причина |
| --- | --- | --- |
| `0` | успех | `run` вернул `0` или нецелочисленное значение. |
| `0` | успех | Функция `run` вернула `0` или нецелочисленное значение. |
| `1` | общая ошибка | Неизвестное действие, отключённое действие, необработанное исключение, нецелочисленный `SystemExit`. |
| `64` | ошибка использования | Аргументы отклонены парсером (`EX_USAGE`). |
| `65` | ошибка данных | Некорректные входные данные (`EX_DATAERR`), поднимается библиотеками движка. |
| `66` | отсутствующий ввод | Требуемый ввод отсутствует (`EX_NOINPUT`), поднимается библиотеками движка. |
| `65` | ошибка данных | Некорректные входные данные (`EX_DATAERR`), выбрасывается библиотеками движка. |
| `66` | отсутствующий ввод | Требуемый ввод отсутствует (`EX_NOINPUT`), выбрасывается библиотеками движка. |
---
@@ -1,42 +1,42 @@
---
title: "Схема адресации флота"
description: "Подсети /24 для каждого узла и статические якорные IP внутри пространства анклава (по умолчанию 198.18.0.0/16)."
description: "Подсети /24 для каждого узла и статические якорные IP внутри пространства анклава (по умолчанию 198.18.0.0/16)."
weight: 17
---
Apex назначает каждому сервису фиксированный, предсказуемый IPv4-адрес внутри подсети узла. Каждый узел объявляет собственный `/24`, якоря сервисов — это статические адреса внутри него, а вся схема механически принудительно проверяется линтером якорных IP.
Фреймворк apex назначает каждому сервису фиксированный, предсказуемый IPv4-адрес внутри подсети узла. Каждый узел объявляет собственный `/24`, якоря сервисов — это статические адреса внутри него, а вся схема автоматически валидируется линтером якорных IP.
## Адресное пространство {#address-space}
Пространство [анклава](/apex/glossary#enclave) по умолчанию равно `198.18.0.0/16` — диапазону для бенчмаркинга, выделенному [RFC 2544: Benchmarking Methodology for Network Interconnect Devices](https://datatracker.ietf.org/doc/html/rfc2544). Этот диапазон никогда не маршрутизируется в публичном Интернете и не конфликтует с распространёнными частными диапазонами (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), которые уже используют облачные провайдеры и домашние сети. Оператор может переопределить его через `APEX_ENCLAVE_SUBNET`.
Пространство [анклава](/apex/glossary#enclave) по умолчанию равно `198.18.0.0/16` — диапазону для бенчмаркинга, выделенному [RFC 2544: Benchmarking Methodology for Network Interconnect Devices](https://datatracker.ietf.org/doc/html/rfc2544). Этот диапазон никогда не маршрутизируется в публичном интернете и не конфликтует с распространёнными частными диапазонами (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), которые уже используют облачные провайдеры и домашние сети. Оператор может переопределить его через переменную `APEX_ENCLAVE_SUBNET`.
## Подсети узлов {#node-subnets}
Каждый [узел](/apex/glossary#node) объявляет ровно один `/24` как `APEX_SUBNET` в своём `node.env`, и эта подсеть является диапазоном IPAM сети `direct`. Оператор назначает её; по соглашению она располагается внутри пространства анклава (`198.18.0.0/16` по умолчанию или того, что задано в `APEX_ENCLAVE_SUBNET`), но фреймворк не накладывает на этот выбор никакой арифметики.
Каждый [узел](/apex/glossary#node) объявляет ровно один диапазон `/24` как `APEX_SUBNET` в своём файле `node.env`. Эта подсеть служит пулом IPAM сети `direct`. Оператор назначает её вручную: по соглашению она располагается внутри пространства анклава (`198.18.0.0/16` по умолчанию или в подсети из `APEX_ENCLAVE_SUBNET`), однако фреймворк не накладывает строгих ограничений на выбор адресов.
> [!NOTE]
> В качестве опционального соглашения оператора вы можете организовать `/24` узлов в непрерывные блоки (например, группируя связанные узлы), чтобы сохранять читаемость распределений. Это вопрос учёта, а не правило фреймворка.
> В качестве соглашения оператор может группировать подсети `/24` связанных узлов в непрерывные блоки, чтобы сохранять наглядность адресного плана. Это вопрос внутренней организации, а не жёсткое требование фреймворка.
## Распределение внутри `/24` узла {#node-allocation}
Внутри `/24` узла адреса хостов разделяются по ролям:
Внутри подсети `/24` узла адреса хостов разделяются по ролям:
| Диапазон | Назначение |
|---|---|
| `.10`–`.19` | Зарезервировано для якорей [базовой композиции](/apex/glossary#core-composition); `traefik` всегда `.10` (через `APEX_TRAEFIK_DIRECT_IP`) |
| `.20` и выше | Якоря сервисов, распределяемые по `<project>` |
| `.10`–`.19` | Зарезервировано для якорей [базовой композиции](/apex/glossary#core-composition); сервис `traefik` всегда занимает адрес `.10` (через `APEX_TRAEFIK_DIRECT_IP`) |
| `.20` и выше | Якоря сервисов, распределяемые по проектам (`<project>`) |
Каждый якорь сервиса — это статический `ipv4_address` в сети direct — фиксированный [якорный IP](/apex/glossary#anchor-ip), а не динамически назначаемая аренда. Распределения записываются в `compositions/ADDRESSING.md`, который служит человекочитаемым реестром подсети узла.
Каждый якорь сервиса представляет собой статический `ipv4_address` в сети direct — фиксированный [якорный IP](/apex/glossary#anchor-ip), а не динамическую аренду (lease). Назначенные адреса фиксируются в `compositions/ADDRESSING.md`, который служит человекочитаемым реестром подсети узла.
## Принудительная проверка {#enforcement}
## Автоматическая проверка {#enforcement}
Линтер compose проверяет якорные IP по всем [композициям](/apex/glossary#compositions) на узле. Для каждого `ipv4_address`, найденного в `compositions/*/docker-compose.yml`, он проверяет, что адрес:
Линтер compose проверяет якорные IP-адреса во всех [композициях](/apex/glossary#compositions) узла. Для каждого значения `ipv4_address`, найденного в файлах `compositions/*/docker-compose.yml`, проверяются следующие условия:
- **Корректно сформирован** — некорректный литерал сообщается как ошибка линтера, а не крах.
- **Уникален** — один и тот же якорный IP не может встречаться в двух проектах; дубликаты сообщаются с указанием обоих конфликтующих проектов.
- **Внутри подсети узла** — каждый якорь должен попадать в объявленный `/24` узла.
- **Корректный формат** — о некорректном литерале линтер сообщает как об ошибке, не падая аварийно.
- **Уникальность** — один и тот же якорный IP не может использоваться в двух разных проектах; при обнаружении дубликатов выводятся имена обоих конфликтующих проектов.
- **Принадлежность подсети узла** — каждый якорь должен входить в объявленную подсеть `/24` узла.
Любое нарушение приводит к провалу линтера с кодом выхода `1` (общая ошибка). Успешное прохождение проверки логирует `Anchor IP validation passed.` Реализация находится в [действии](/apex/glossary#action) `utils/lint-docker-compose`; см. [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com).
При любом нарушении линтер завершается с ошибкой и кодом `1`. При успешной проверке в лог выводится сообщение `Anchor IP validation passed.` Логика проверки реализована в [действии](/apex/glossary#action) `utils/lint-docker-compose`; см. [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com).
---
@@ -1,10 +1,10 @@
---
title: Архитектура фреймворка
description: Четыре опоры репозиториев узла, субмодуль «commons» и поток диспетчеризации.
description: Четыре опоры репозиториев узла, субмодуль commons и поток диспетчеризации.
weight: 11
---
apex разделяет автоматизацию флота на один общий репозиторий — [«commons» (общий репозиторий)](/apex/glossary#commons) — и по одному репозиторию на каждый [узел](/apex/glossary#node). «commons» несёт всё то, что общее для каждого узла: [движок](/apex/glossary#engine), общие [действия](/apex/glossary#action) и [ядровую композицию](/apex/glossary#core-composition). Репозиторий узла несёт всё то, что делает этот узел самим собой: его идентичность, хостовые [конфигурации](/apex/glossary#configs), сервисные [композиции](/apex/glossary#compositions) и узло-локальные [проприетарии](/apex/glossary#proprietaries).
Фреймворк apex делит автоматизацию флота между одним общим репозиторием ([«commons»](/apex/glossary#commons)) и отдельными репозиториями для каждого [узла](/apex/glossary#node). В commons сосредоточено всё общее для флота: [движок](/apex/glossary#engine), стандартные [действия](/apex/glossary#action) и [ядровая композиция](/apex/glossary#core-composition). Репозиторий узла содержит специфичные для хоста данные: его идентичность, хостовые [конфигурации](/apex/glossary#configs), сервисные [композиции](/apex/glossary#compositions) и локальные [проприетарии](/apex/glossary#proprietaries).
## Четыре опоры {#pillars}
@@ -23,16 +23,16 @@ apex разделяет автоматизацию флота на один об
| Опора | Содержимое | Владение |
|---|---|---|
| `commons/` | Git-субмодуль → `apex.ermnvldmr.com`, закреплённый на релизном теге | Общее, версионируемое |
| `proprietaries/` | Узло-локальные действия, библиотеки и исходники инструментов ([накладываются оверлеем](/apex/glossary#overlay) поверх commons) | Узел |
| `commons/` | Git-субмодуль → `apex.ermnvldmr.com`, закреплённый на релизном теге | Общее, версионируемое |
| `proprietaries/` | Узло-локальные действия, библиотеки и исходники инструментов ([накладываются оверлеем](/apex/glossary#overlay) поверх commons) | Узел |
| `configs/` | Исходники конфигурации хоста: `ufw/`, `cron/crontab`, `systemd/`, `crowdsec/` | Узел |
| `compositions/` | По одному каталогу на проект плюс ядровая обёртка `apex/` и реестр адресации | Узел |
| `compositions/` | По одному каталогу на проект, ядровая обёртка `apex/` и реестр адресации | Узел |
Закрепление субмодуля — это версионный контракт фреймворка: узел выполняет ровно тот движок и ту ядровую композицию, которые записаны в его gitlink. Обновление узла — это осознанное действие: переключитесь на более новый тег внутри `commons/` и зафиксируйте перемещённый указатель.
Фиксация субмодуля — версионный контракт фреймворка: узел выполняет именно ту версию движка и ядровой композиции, которая зафиксирована в его gitlink. Обновление узла выполняется явно: достаточно переключиться на новый тег внутри `commons/` и закоммитить изменённый указатель.
## Поток диспетчеризации {#dispatch}
Лаунчер `commons/apex` действует на локальный checkout; аргумента узла нет нигде.
Лаунчер `commons/apex` работает с локальным чекаутом; передавать узел в качестве аргумента не нужно нигде.
```mermaid
flowchart TD
@@ -47,27 +47,27 @@ flowchart TD
H --> I["run(ctx, args)"]
```
Движок определяет схему размещения по своему собственному расположению: запуск из каталога с именем `commons/`, родитель которого содержит `node.env`, означает «checkout узла» — родитель становится корнем репозитория, а `proprietaries/actions/` становится деревом оверлея. Любое другое расположение — это автономный checkout commons, выступающий в роли собственного корня.
Движок определяет режим работы по своему расположению на диске. Если он запущен из каталога `commons/`, а в родительском каталоге присутствует `node.env`, то родительский каталог принимается за корень репозитория узла, а `proprietaries/actions/` становится деревом оверлея. При любом другом расположении commons считается автономным чекаутом и выступает собственным корнем.
Перечисление (`apex --help`) никогда не импортирует модули действий; краткие описания и маркеры отключённых действий извлекаются статическим сканированием через `ast`. Диспетчеризация загружает ровно один модуль по пути. Подробности в [Концепция: Действия и оверлей](/apex/concepts/actions-and-overlay).
Команда перечисления (`apex --help`) никогда не импортирует модули действий: краткие описания и маркеры отключения извлекаются статически с помощью модуля `ast`. При диспетчеризации загружается ровно один модуль по вычисленному пути. Подробнее — в разделе [Концепция: Действия и оверлей](/apex/concepts/actions-and-overlay).
## Модель «capture-up» {#capture-up}
Репозитории узлов — это реестры, а не просто источники: хост сам записывает то, что он выполняет.
Репозитории узлов служат реестрами состояния, а не просто источниками конфигурации: хост сам фиксирует то, что выполняет на практике.
1. Запланированный запуск `sync/repository` на хосте фиксирует рабочее дерево в ветку `sync/<node>` (включая указатель субмодуля commons), выполняет rebase на `origin/main` и отправляет с `--force-with-lease`.
2. Запланированное CI-задание в репозитории узла вливает `sync/<node>` обратно в `main` под идентичностью бота.
1. Регулярная задача `sync/repository` на хосте коммитит рабочее дерево в ветку `sync/<node>` (включая указатель субмодуля commons), делает rebase поверх `origin/main` и выполняет push с флагом `--force-with-lease`.
2. Запланированное CI-задание в репозитории узла вливает ветку `sync/<node>` обратно в `main` от имени бота.
Этот цикл «capture-up» ([capture-up](/apex/glossary#capture-up)) означает, что дрейф на хосте превращается в коммит — видимый, поддающийся сравнению через diff и откату — вместо тихого расхождения. Синхронизация прерывается вместо фиксации, если субмодуль commons «грязный» или дерево `compositions/` отсутствует, поэтому сломанный checkout никогда не становится записанным состоянием.
Благодаря циклу [«capture-up»](/apex/glossary#capture-up) любой дрейф конфигурации на хосте превращается в коммит: изменение наглядно отображается в diff и легко откатывается, исключая незаметные расхождения. Если субмодуль commons имеет незакоммиченные изменения («грязный») или каталог `compositions/` отсутствует, синхронизация прерывается без коммита, поэтому повреждённое состояние чекаута никогда не попадёт в историю.
> [!NOTE]
> CI-задание слияния выполняет checkout репозитория *без* субмодулей: субмодуль commons использует SSH-URL, который стандартный CI-токен не может получить, и слияние в любом случае лишь перемещает указатель gitlink.
> CI-задание слияния клонирует репозиторий *без* субмодулей: субмодуль commons подключён по SSH-URL, недоступному стандартному токену CI, а самому слиянию требуется лишь обновить указатель gitlink.
## Проектные ограничения {#constraints}
- **Только stdlib.** Движок работает на штатном Python 3 из Debian — без `pip`, без сторонних импортов. Опциональный каталог `vendor/` существует для закреплённого чистого Python-кода, но поставляется пустым.
- **Composes, написанные вручную.** Нет слоя манифестов или генерации кода; compose-файлы являются источником истины.
- **Свидетельство важнее соглашения.** Всё, что может быть обеспечено принудительно, обеспечивается действием (линт якорного IP, откат crontab, самовосстановление блоков-маркеров), а не документацией.
- **Только стандартная библиотека (stdlib).** Движок работает на штатном Python 3 из Debian — без `pip` и сторонних зависимостей. Каталог `vendor/` зарезервирован для зафиксированного чистого Python-кода, но по умолчанию поставляется пустым.
- **Ручные compose-файлы.** Никаких промежуточных манифестов или кодогенерации: файлы compose служат единственным источником истины.
- **Автоматическая проверка вместо соглашений.** Всё, что можно проверить или применить автоматически, выполняется действием (проверка якорных IP, откат crontab, самовосстановление блоков-маркеров), а не остаётся на совести документации.
---
@@ -1,14 +1,14 @@
---
title: Композиции и общее ядро
description: Ядровая композиция на основе include, профили, сети и наслоение окружения.
title: Композиции и общее ядро
description: Ядровая композиция на основе include, профили, сети и наслоение окружения.
weight: 15
---
Среда выполнения узла объявляется как [композиции](/apex/glossary#compositions): по одному docker-compose-проекту на сервис в каталоге `compositions/<project>/`, плюс [ядровая композиция](/apex/glossary#core-composition) — граничные и инфраструктурные сервисы, необходимые каждому узлу. Ядро располагается в [«commons»](/apex/glossary#commons) и потребляется каждым узлом через тонкую обёртку, поэтому его определение написано один раз и версионируется вместе с фреймворком.
Среда выполнения узла описывается в виде [композиций](/apex/glossary#compositions): по одному docker-compose-проекту на сервис в каталоге `compositions/<project>/`, плюс [ядровая композиция](/apex/glossary#core-composition) — граничные и инфраструктурные сервисы, необходимые каждому узлу. Ядро находится в [«commons»](/apex/glossary#commons) и подключается каждым узлом через тонкую обёртку. Благодаря этому его манифест пишется один раз и версионируется вместе с фреймворком.
## Обёртка include {#include}
Файл `compositions/apex/docker-compose.yml` каждого узла состоит из трёх строк:
Файл `compositions/apex/docker-compose.yml` каждого узла состоит всего из трёх строк:
```yaml {filename="compositions/apex/docker-compose.yml"}
name: apex
@@ -16,9 +16,9 @@ include:
- ../../commons/compositions/apex/docker-compose.yml
```
Общий файл полностью параметризован через `${APEX_*}` и не содержит относительных хостовых путей — каждое монтирование разрешается через переменные, которые [движок](/apex/glossary#engine) экспортирует при вызове, поэтому один и тот же файл корректен на каждом узле.
Общий файл полностью параметризован переменными `${APEX_*}` и не содержит относительных путей хоста: все точки монтирования разрешаются через переменные, экспортируемые [движком](/apex/glossary#engine) при вызове, поэтому один и тот же файл корректно работает на каждом узле.
Структурное исключение — узел, которому действительно нужно изменить ядровой сервис, например опубликовать дополнительный хостовый порт, — наслаивает переопределение через форму include со списком путей вместо редактирования общего файла:
Если узлу требуется изменить ядровой сервис (например, опубликовать дополнительный порт хоста), переопределение накладывается через список путей в директиве `include:` без редактирования общего файла:
```yaml {filename="compositions/apex/docker-compose.yml"}
name: apex
@@ -29,54 +29,54 @@ include:
```
> [!NOTE]
> Директива `include:` требует Docker Compose v2.20 или новее.
> Директива `include:` требует Docker Compose версии v2.20 или новее.
## Ядровые сервисы и профили {#core-services}
Ядровая композиция определяет общий набор сервисов флота. Всегда включённые сервисы:
- **traefik** — граничный обратный прокси и якорь сети `direct` (всегда `.10` в подсети узла). Он завершает TLS через DNS-челлендж, обнаруживает бэкенды через docker-метки и выставляет свою панель управления на FQDN узла за списком разрешённых IP [анклава](/apex/glossary#enclave).
- **socket-proxy** — отфильтрованный прокси для docker-сокета; traefik читает состояние контейнеров через него и никогда не обращается к сокету напрямую.
- **traefik** — граничный обратный прокси и якорь сети `direct` (всегда занимает `.10` в подсети узла). Он терминирует TLS через DNS-челлендж, обнаруживает бэкенды по docker-меткам и открывает панель управления на FQDN узла, ограничивая доступ списком разрешённых IP-адресов [анклава](/apex/glossary#enclave).
- **socket-proxy** — отфильтрованный прокси для docker-сокета: traefik получает состояние контейнеров через него и никогда не обращается к сокету напрямую.
Опциональные сервисы, управляемые через compose-профили, которые узел включает в `COMPOSE_PROFILES` (задаётся в `apex.env`):
Опциональные сервисы управляются compose-профилями, которые узел активирует в переменной `COMPOSE_PROFILES` (задаётся в `apex.env`):
| Профиль | Сервис | Роль |
|---|---|---|
| `xray` | xray | Прокси-край с host-network для анклава. |
| `adguard` | adguard | DNS-фильтрация с использованием сетевого пространства имён traefik; маршрутизируется по `/adguard` на FQDN узла. |
| `alloy` | alloy | Понодовый агент наблюдаемости (Grafana Alloy). Собирает метрики хоста и контейнеров, а также логи journal и контейнеров и **проталкивает** их исходящим потоком в центральные Prometheus и Loki (см. [Концепция: Модель безопасности](/apex/concepts/security-model#observability)). Его `hostname` контейнера установлен в `APEX_NODE_HOST`, и это значение проставляет метки `node` и `instance` на каждой метрике и потоке логов. |
| `manual` | resticontainer | Одноразовый исполнитель резервного копирования, управляемый метками (`ghcr.io/deytenit/resticontainer`) — никогда не запускается через `up`; вызывается действием `backup/run`, которое обнаруживает намерение резервного копирования для каждого сервиса из compose-меток `restic.*`. |
| `xray` | xray | Прокси-шлюз с режимом host-network для анклава. |
| `adguard` | adguard | DNS-фильтрация в сетевом пространстве имён traefik; маршрутизируется по пути `/adguard` на FQDN узла. |
| `alloy` | alloy | Агент наблюдаемости на каждом узле (Grafana Alloy). Собирает метрики хоста и контейнеров, а также логи systemd journal и контейнеров, после чего **проталкивает** их исходящим потоком в центральные Prometheus и Loki (см. [Концепция: Модель безопасности](/apex/concepts/security-model#observability)). Имя хоста (`hostname`) контейнера совпадает с `APEX_NODE_HOST`, благодаря чему метки `node` и `instance` автоматически проставляются на всех метриках и потоках логов. |
| `manual` | resticontainer | Одноразовый контейнер резервного копирования на основе меток (`ghcr.io/deytenit/resticontainer`). Никогда не запускается командой `up`; вызывается действием `backup/run`, которое определяет необходимость бэкапа каждого сервиса по compose-меткам `restic.*`. |
## Сети {#networks}
Ядровая композиция владеет тремя сетями узла; сервисные композиции ссылаются на них по ключу с `external: true`.
Ядровая композиция создаёт три сети узла, а сервисные композиции подключаются к ним через директиву `external: true`:
| Сеть | Тип | Назначение |
|---|---|---|
| `direct` | bridge, подсеть = `APEX_SUBNET` | Сеть со статическими [якорными IP](/apex/glossary#anchor-ip); якорь каждого проекта удерживает здесь фиксированный адрес (см. [Концепция: Схема адресации флота](/apex/concepts/addressing)). |
| `enclave` | bridge, `internal: true` | Бэкенд-сеть обратного прокси; traefik достигает помеченных сервисов здесь. |
| `socket` | bridge, `internal: true` | Несёт только трафик traefik ↔ socket-proxy. |
| `direct` | bridge, подсеть = `APEX_SUBNET` | Сеть со статическими [якорными IP](/apex/glossary#anchor-ip); якорь каждого проекта удерживает здесь фиксированный адрес (см. [Концепция: Схема адресации флота](/apex/concepts/addressing)). |
| `enclave` | bridge, `internal: true` | Бэкенд-сеть обратного прокси: через неё traefik направляет трафик к целевым сервисам. |
| `socket` | bridge, `internal: true` | Изолирует трафик между traefik и socket-proxy. |
Внутри проекта соседние контейнеры обычно присоединяются к сетевому пространству имён якоря (`network_mode: service:<anchor>`) вместо самостоятельного подключения к сетям.
Внутри проекта зависимые контейнеры обычно используют сетевое пространство имён якоря (`network_mode: service:<anchor>`) вместо прямого подключения к сетям.
## Наслоение окружения {#env-layering}
Значения достигают композиции из трёх слоёв в порядке возрастания приоритета:
Переменные попадают в композицию из трёх слоёв (в порядке возрастания приоритета):
1. **`.env`** — незакоммиченные секреты, символьная ссылка в хранилище уровня 1 (учётные данные `APEX_RESTIC_*`, токены `APEX_TRAEFIK_*`, блок UID/GID).
2. **`apex.env`** — закоммиченные несекретные скалярные значения (`APEX_TRAEFIK_DIRECT_IP`, `APEX_RESTIC_REPOSITORY`, `COMPOSE_PROFILES`, опциональный `APEX_RESTIC_COMPRESSION`).
3. **Экспорты движка** — идентичность `APEX_*` и пути [уровней](/apex/glossary#tier) из `ctx.vars()`, внедряемые в окружение процесса действиями оркестрации и потому выигрывающие у обоих файлов.
1. **`.env`** — незакоммиченные секреты, символьная ссылка на хранилище уровня 1 (учётные данные `APEX_RESTIC_*`, токены `APEX_TRAEFIK_*`, блок UID/GID).
2. **`apex.env`** — закоммиченные несекретные параметры (`APEX_TRAEFIK_DIRECT_IP`, `APEX_RESTIC_REPOSITORY`, `COMPOSE_PROFILES`, опциональный `APEX_RESTIC_COMPRESSION`).
3. **Экспорт движка** — переменные идентичности `APEX_*` и пути [уровней](/apex/glossary#tier) из `ctx.vars()`. Они внедряются в окружение процесса действиями оркестрации и потому имеют наивысший приоритет над обоими файлами.
Критические переменные несут маркеры `?error` в общем файле, поэтому отсутствующий секрет громко проваливает загрузку конфигурации композиции вместо запуска неправильно настроенного контейнера.
Критические переменные снабжены суффиксом `?error` в общем файле: если секрет не задан, загрузка конфигурации композиции завершается явной ошибкой, предотвращая запуск некорректно настроенного контейнера.
## Оркестрация {#orchestration}
`apex compose <action>` управляет каждой композицией на узле:
Команда `apex compose <action>` управляет всеми композициями на узле:
- **Порядок** — сначала ядровой проект (он владеет сетями), затем каждая сервисная композиция в алфавитном порядке; `down` разворачивает последовательность.
- **Env-файлы** — для каждого проекта `.env` и `apex.env` передаются как `--env-file`, если они присутствуют.
- **`up`** подразумевает `-d`; любые дополнительные аргументы передаются в `docker compose`.
- **`--dry-run`** печатает каждую команду с её рабочим каталогом вместо выполнения — принимается в любом месте командной строки.
- **Порядок:** сначала ядровой проект (он владеет сетями), затем остальные сервисные композиции по алфавиту; команда `down` выполняет действия в обратном порядке.
- **Env-файлы:** для каждого проекта существующие файлы `.env` и `apex.env` передаются через флаг `--env-file`.
- **Команда `up`:** по умолчанию подразумевает флаг `-d`; любые дополнительные аргументы передаются напрямую в `docker compose`.
- **Флаг `--dry-run`:** выводит каждую команду с её рабочим каталогом без выполнения; поддерживается в любом месте командной строки.
---
@@ -1,66 +1,66 @@
---
title: Контекст выполнения
description: ctx — единый шов между действием и хостом.
description: ctx — единый интерфейс взаимодействия между действием и хостом.
weight: 14
---
Каждое [действие](/apex/glossary#action) получает объект `ctx` в качестве первого аргумента. Это единственный санкционированный способ взаимодействовать с хостом: логирование, выполнение процессов, рендеринг шаблонов, изменение файлов, управление фаерволом и сервисами, а также уведомления — всё проходит через него. Поэтому действия остаются декларативными, а поведение, которое должно быть согласованным по всему флоту (формат логов, дисциплина резервного копирования, полезная нагрузка уведомлений), находится в одном месте — [движке](/apex/glossary#engine).
Каждое [действие](/apex/glossary#action) получает объект `ctx` первым аргументом. Это единственный штатный способ взаимодействия с хостом: логирование, запуск процессов, рендеринг шаблонов, правка файлов, настройка межсетевого экрана и сервисов, а также отправка уведомлений — всё выполняется через него. Благодаря этому действия остаются компактными и декларативными, а общие для всего флота правила (формат логов, регламент резервного копирования, структура уведомлений) сосредоточены в одном месте — [движке](/apex/glossary#engine).
## Члены {#members}
| Член | Назначение |
|---|---|
| `ctx.log` | Логирование с префиксом: `[<node>] [<action>] [LEVEL] message`; ошибки идут в stderr, всё остальное — в stdout. |
| `ctx.sys` | Выполнение процессов (`run`, `sudo`, `ok`), помощники для сервисов systemd и идемпотентная установка пакетов. |
| `ctx.tpl` | Рендеринг шаблонов над опорой `configs/` с помощью `string.Template.safe_substitute`. |
| `ctx.host` | Идемпотентное изменение хоста: резервные копии с меткой времени, атомарная запись файлов, инъекция блоков-маркеров, установка crontab с откатом. |
| `ctx.ufw` | Применение фаервола хоста и контейнеров (см. [Концепция: Модель безопасности](/apex/concepts/security-model)). |
| `ctx.crowdsec` | Установка CrowdSec, развёртывание конфигурации и управление коллекциями. |
| `ctx.systemd` | Рендеринг и включение systemd-юнитов из `configs/systemd/`. |
| `ctx.notify` | Уведомления Telegram; каждый метод возвращает булево значение, чтобы действия могли ставить код выхода в зависимость от доставки. |
| `ctx.paths` | Разрешённые пути репозитория: `repo_root`, `commons`, `proprietaries`, `configs`, `compositions`, `core` и свойства уровней. |
| `ctx.node` | Разрешённая запись [идентичности узла](/apex/glossary#node-identity) (`name`, `subnet`, `fqdn`, `host`); `None` в автономном чекауте commons. |
| `ctx.commons` | Доступен только в переопределяющем проприетарном действии: `ctx.commons.run(args)` делегирует затенённой реализации commons. |
| `ctx.log` | Логирование с префиксом: `[<node>] [<action>] [LEVEL] message`; ошибки пишутся в stderr, остальные сообщения — в stdout. |
| `ctx.sys` | Выполнение процессов (`run`, `sudo`, `ok`), вспомогательные функции для сервисов systemd и идемпотентная установка пакетов. |
| `ctx.tpl` | Рендеринг шаблонов из каталога `configs/` с помощью `string.Template.safe_substitute`. |
| `ctx.host` | Идемпотентные изменения на хосте: резервные копии с временными метками, атомарная запись файлов, внедрение блоков-маркеров, установка crontab с откатом. |
| `ctx.ufw` | Управление правилами межсетевого экрана хоста и контейнеров (см. [Концепция: Модель безопасности](/apex/concepts/security-model)). |
| `ctx.crowdsec` | Установка CrowdSec, развёртывание конфигураций и управление коллекциями. |
| `ctx.systemd` | Рендеринг и активация systemd-юнитов из `configs/systemd/`. |
| `ctx.notify` | Отправка уведомлений в Telegram; методы возвращают булево значение, позволяя действиям учитывать факт доставки в коде завершения. |
| `ctx.paths` | Разрешённые пути репозитория: `repo_root`, `commons`, `proprietaries`, `configs`, `compositions`, `core` и свойства уровней хранилища. |
| `ctx.node` | Разрешённая запись [идентичности узла](/apex/glossary#node-identity) (`name`, `subnet`, `fqdn`, `host`); принимает значение `None` в автономном чекауте commons. |
| `ctx.commons` | Доступен только в переопределяющих проприетарных действиях: вызов `ctx.commons.run(args)` делегирует управление затенённой реализации commons. |
Сигнатуры уровня методов перечислены в [Справочник: API движка](/apex/reference/engine-api).
Сигнатуры методов подробно описаны в разделе [Справочник: API движка](/apex/reference/engine-api).
## Семантика шаблонов {#templates}
`ctx.tpl` рендерит файлы из `configs/` с помощью питоновского `string.Template.safe_substitute`:
Модуль `ctx.tpl` рендерит файлы из `configs/` с помощью `string.Template.safe_substitute`:
- Известные ссылки `$VAR` и `${VAR}` заменяются.
- **Неизвестные ссылки остаются буквальными.** Опечатка в шаблоне сохраняется в отрендеренном выводе, где она видна, вместо того чтобы молча свернуться в пустую строку.
- Подстановка команд shell (`$(...)`) проходит нетронутой, что сохраняет целостность записей crontab со встроенным `$(cat ...)`.
- Известные переменные `$VAR` и `${VAR}` подставляются.
- **Неизвестные переменные сохраняются буквально:** опечатка в имени переменной останется в готовом файле на виду, а не превратится незаметно в пустую строку.
- Подстановки команд shell (`$(cmd)`) не затрагиваются, что сохраняет целостность строк crontab со встроенными командами вида `$(cat ...)`.
Набор подстановки — это `ctx.vars()`: полное окружение процесса плюс предоставляемые движком значения `APEX_*`, приведённые ниже. Циклы и условия никогда не живут в шаблонах; их место — в действии.
Подстановка выполняется по словарю `ctx.vars()`: это полное окружение процесса плюс переменные `APEX_*`, предоставляемые движком (перечислены ниже). В шаблонах нет циклов и условий — вся логика ветвления находится в действиях.
## Экспорт переменных {#vars}
`ctx.vars()` возвращает окружение, используемое для каждого рендеринга шаблона, а его подмножество `APEX_*` экспортируется в `docker compose` действиями оркестрации. На узле оно содержит:
Функция `ctx.vars()` возвращает словарь окружения, используемый при каждом рендеринге шаблонов. Подмножество переменных `APEX_*` также передаётся в `docker compose` действиями оркестрации. На узле оно включает:
| Переменная | Значение |
|---|---|
| `APEX_COMMONS` | Абсолютный путь директории commons (лаунчер находится в `$APEX_COMMONS/apex`). |
| `APEX_REPO_ROOT` | Абсолютный путь корня репозитория узла. |
| `APEX_NODE_HOST`, `APEX_NODE_FQDN`, `APEX_SUBNET` | Разрешённые поля идентичности. |
| `APEX_TIER1`…`APEX_TIER3` | Директории данных базового проекта по каждому [уровню](/apex/glossary#tier). |
| `APEX_COMMONS` | Абсолютный путь к каталогу commons (лаунчер расположен по пути `$APEX_COMMONS/apex`). |
| `APEX_REPO_ROOT` | Абсолютный путь к корню репозитория узла. |
| `APEX_NODE_HOST`, `APEX_NODE_FQDN`, `APEX_SUBNET` | Разрешённые поля идентичности узла. |
| `APEX_TIER1`…`APEX_TIER3` | Каталоги данных базового проекта для каждого [уровня](/apex/glossary#tier). |
| `APEX_TIER1_SHARED`…`APEX_TIER3_SHARED` | Общие области внутри каждого уровня. |
| `APEX_TIER_ROOT1`…`APEX_TIER_ROOT3` | Корни хранилищ уровней (ссылки уровня узла). |
| `APEX_TIER_ROOT1`…`APEX_TIER_ROOT3` | Корневые каталоги хранилищ уровней (ссылки уровня узла). |
Полная матрица источников/потребителей приведена в [Справочник: Переменные окружения](/apex/reference/environment).
Полная матрица источников и потребителей переменных приведена в разделе [Справочник: Переменные окружения](/apex/reference/environment).
## Дисциплина изменения хоста {#host}
`ctx.host` кодирует правила записи фреймворка:
Объект `ctx.host` реализует строгие правила записи:
- **Атомарная запись.** `write_file` рендерит во временный файл (режим `0644`, соответствующий тому, что создал бы редирект root-шелла) и устанавливает его через `mv` или `sudo cp`; перезапись существующего файла сохраняет режим этого файла.
- **Резервные копии с меткой времени.** По запросу предыдущий файл сохраняется рядом с целевым как `<name>.<epoch>.bak` перед записью.
- **Инъекция блоков-маркеров.** Управляемые блоки внутри системных файлов (например, `/etc/ufw/before.rules`) ограничиваются начальными/конечными комментариями-маркерами. Инъекция удаляет каждый предыдущий управляемый блок — включая блоки, оставленные предшествующим инструментарием, — перед вставкой свежего после его якорной строки, поэтому повторные запуски никогда не накапливаются.
- **Откат crontab.** `install_crontab` делает снимок текущего crontab, отказывается от пустого рендера (код выхода `65`), показывает предпросмотр результата и восстанавливает снимок, если установка не удалась.
- **Атомарная запись:** метод `write_file` сначала формирует временный файл (права `0644`, аналогичные перенаправлению вывода из-под root) и перемещает его на место через `mv` или `sudo cp`. При перезаписи существующего файла его исходные права сохраняются.
- **Резервные копии с временной меткой:** по запросу перед записью создаётся копия старого файла рядом с целевым: `<name>.<epoch>.bak`.
- **Внедрение блоков-маркеров:** изменения внутри системных файлов (например, `/etc/ufw/before.rules`) обрамляются комментариями-маркерами начала и конца. Перед вставкой нового блока после якорной строки движок удаляет все предыдущие управляемые блоки (включая оставшиеся от старого инструментария), предотвращая накопление дубликатов при повторных запусках.
- **Откат crontab:** метод `install_crontab` делает снимок текущего crontab, отклоняет пустой результат рендеринга (код выхода `65`), показывает diff изменений и автоматически восстанавливает снимок при ошибке установки.
## Уведомления {#notifications}
`ctx.notify` отправляет сообщения Telegram через стандартную библиотеку (`urllib`). Полезная нагрузка несёт имя инстанса — публичный хост узла `APEX_NODE_HOST` — статус (`firing`/`resolved`), уровень эскалации и сообщение, усечённое до 200 символов. Каждый метод возвращает `True` только тогда, когда отправка удалась; автоматические действия трактуют неудачную отправку своего финального сообщения о статусе как сбой (код выхода `1`), поэтому сломанный канал уведомлений замечается, а не молча терпится.
Модуль `ctx.notify` отправляет сообщения в Telegram средствами стандартной библиотеки (`urllib`). Полезная нагрузка содержит имя хоста (`APEX_NODE_HOST`), статус (`firing`/`resolved`), уровень важности и текст сообщения, обрезанный до 200 символов. Методы возвращают `True` только при успешной доставке; автоматические действия трактуют сбой отправки итогового статуса как критическую ошибку (код завершения `1`), поэтому проблемы со связью сразу становятся заметны.
---
@@ -1,22 +1,22 @@
---
title: "Разрешение идентичности узла"
description: "Как узел узнаёт, кто он: ключи идентичности node.env и запасной вариант FQDN."
description: "Как узел определяет собственную идентичность: параметры node.env и резервный FQDN."
weight: 12
---
Каждая операция apex выполняется в контексте ровно одного [узла](/apex/glossary#node). [Движок](/apex/glossary#engine) выводит эту [идентичность узла](/apex/glossary#node-identity) — имя узла, подсеть, FQDN и публичный хост — из файла `node.env` в корне репозитория, используя имя хоста ОС только как запасной вариант для FQDN. Фреймворк не предполагает ни домена, ни кластеризации: узел объявляет, кто он, явно. Этот шаг разрешения приходит на смену механизму `root_require_node` bash-фреймворка commons «root», который заменяет apex.
Каждая операция apex выполняется в контексте строго одного [узла](/apex/glossary#node). [Движок](/apex/glossary#engine) извлекает [идентичность узла](/apex/glossary#node-identity) — имя, подсеть, FQDN и публичный адрес — из файла `node.env` в корне репозитория. Системное имя хоста используется исключительно как резервный вариант для FQDN. Фреймворк не делает предположений о домене или наличии кластера: узел заявляет о себе явно. Этот механизм пришёл на смену функции `root_require_node` bash-фреймворка «root» commons, развитием которого стал apex.
## Источники идентичности {#sources}
### node.env
`node.env` — это обычный файл вида `KEY=value` (пригодный для `source` в shell), расположенный в корне репозитория. При разборе комментарии и пустые строки игнорируются, а окружающие значения одинарные или двойные кавычки удаляются.
Файл `node.env` — это простой конфигурационный файл формата `KEY=value` (совместимый с командой `source` в shell), расположенный в корне репозитория. При разборе комментарии и пустые строки игнорируются, а одинарные или двойные кавычки вокруг значений отбрасываются.
Он объявляет идентичность явно через следующие ключи:
Идентичность объявляется явно с помощью следующих ключей:
- `APEX_NODE_FQDN` — реальный FQDN узла, используемый для маршрутизации traefik `Host()`. Если не задан, движок откатывается к имени хоста ОС (и выводит предупреждение).
- `APEX_NODE_HOST` — публичное имя хоста узла, используемое для `--host` резервного копирования и `instance` для наблюдаемости/уведомлений. По умолчанию равно `APEX_NODE_FQDN`.
- `APEX_SUBNET` — `/24` узла для сети `direct`. **Обязателен**; другого источника для него нет (см. [Концепция: Схема адресации флота](/apex/concepts/addressing)).
- `APEX_NODE_FQDN` — канонический FQDN узла для правила `Host()` в traefik. Если ключ не задан, движок берёт имя хоста из ОС и выводит предупреждение.
- `APEX_NODE_HOST` — публичное имя хоста узла, используемое для флага `--host` при резервном копировании и метки `instance` в системе мониторинга и уведомлений. По умолчанию совпадает с `APEX_NODE_FQDN`.
- `APEX_SUBNET` — подсеть `/24` узла для сети `direct`. Параметр **обязателен**: альтернативных источников для него нет (см. [Концепция: Схема адресации флота](/apex/concepts/addressing)).
```ini {filename="node.env"}
APEX_NODE_FQDN=node1.example.com
@@ -26,49 +26,49 @@ APEX_SUBNET=198.18.16.0/24
### Имя хоста ОС
Имя хоста ОС используется только как запасной вариант для `APEX_NODE_FQDN`. Когда в `node.env` не задан `APEX_NODE_FQDN`, движок использует имя хоста и выводит предупреждение. Оно никогда не разбирается для получения имени узла или любой другой координаты.
Имя хоста операционной системы используется только как резервный вариант для `APEX_NODE_FQDN`. Если в `node.env` значение `APEX_NODE_FQDN` не указано, движок берёт имя хоста ОС и выводит предупреждение. Это значение никогда не парсится для вычисления короткого имени узла или других параметров.
## Порядок разрешения {#resolution}
{{% steps %}}
### Разрешите FQDN
### Определение FQDN
`fqdn` — это `APEX_NODE_FQDN` из `node.env` или имя хоста ОС, когда этот ключ не задан. Если ни то ни другое не даёт значения, разрешение завершается неудачей (см. [Режимы сбоев](#failures)).
Параметр `fqdn` принимает значение `APEX_NODE_FQDN` из `node.env` либо системное имя хоста ОС, если ключ не задан. Если оба источника пусты, разрешение завершается фатальной ошибкой (см. [Режимы сбоев](#failures)).
### Разрешите публичный хост
### Определение публичного хоста
`host` — это `APEX_NODE_HOST` из `node.env` или `fqdn`, когда этот ключ не задан.
Параметр `host` берётся из `APEX_NODE_HOST` в `node.env` либо приравнивается к вычисленному `fqdn`.
### Выведите короткое имя
### Вычисление короткого имени
`name` — это **первая метка** `host` (`host.split(".")[0]`) — например, `node1` для `node1.example.com`. Оно используется для логов, ветки `sync/<node>` и сообщений.
Значение `name` — это **первая часть** имени `host` (`host.split(".")[0]`), например `node1` для `node1.example.com`. Оно используется в логах, имени ветки `sync/<node>` и уведомлениях.
### Прочитайте подсеть
### Получение подсети
`subnet` всегда читается из `APEX_SUBNET` в `node.env`. Она обязательна.
Значение `subnet` всегда читается из переменной `APEX_SUBNET` файла `node.env`. Этот параметр строго обязателен.
{{% /steps %}}
Результат — запись `Node` с четырьмя полями: `name`, `subnet`, `fqdn` и `host`.
В результате формируется структура `Node` с четырьмя полями: `name`, `subnet`, `fqdn` и `host`.
## Предупреждения {#warnings}
Разрешение идентичности выводит ровно одно предупреждение; оно сигнализирует об исправимой несогласованности, которую следует устранить.
При разрешении идентичности может выводиться одно штатное предупреждение: оно указывает на неполную конфигурацию, которую стоит исправить.
| Условие | Сообщение предупреждения |
|---|---|
| `APEX_NODE_FQDN` не задан в `node.env` (вместо него используется имя хоста) | `APEX_NODE_FQDN not set in node.env; using hostname '<fqdn>'.` |
| `APEX_NODE_FQDN` не задан в `node.env` (используется имя хоста ОС) | `APEX_NODE_FQDN not set in node.env; using hostname '<fqdn>'.` |
## Режимы сбоев {#failures}
Разрешение отказывается угадывать. Два условия фатальны, и оба завершаются с кодом `66` — соглашение движка о коде выхода для отсутствующего ввода:
Движок никогда не пытается угадывать параметры наугад. Две ситуации считаются критическими и приводят к завершению с кодом `66` (соглашение движка о коде выхода при отсутствии обязательного ввода):
- **Нет FQDN вовсе.** `APEX_NODE_FQDN` не задан в `node.env` *и* имя хоста ОС пусто. Движок пишет `identity: APEX_NODE_FQDN missing from node.env and no hostname` в stderr и завершается.
- **Нет подсети.** `APEX_SUBNET` отсутствует в `node.env` (в том числе когда сам файл отсутствует). Движок пишет `identity: APEX_SUBNET missing from node.env` в stderr и завершается.
- **FQDN не определён:** ключ `APEX_NODE_FQDN` отсутствует в `node.env`, *а также* не задано имя хоста в ОС. Движок выводит в stderr сообщение `identity: APEX_NODE_FQDN missing from node.env and no hostname` и завершает работу.
- **Подсеть не задана:** ключ `APEX_SUBNET` отсутствует в `node.env` (либо сам файл отсутствует). Движок выводит в stderr сообщение `identity: APEX_SUBNET missing from node.env` и завершает работу.
> [!WARNING]
> Действительный `node.env`, несущий `APEX_SUBNET`, обязателен на каждом узле. `APEX_NODE_FQDN` можно опустить только тогда, когда имя хоста ОС предоставляет пригодный FQDN.
> Корректный файл `node.env` с заданным параметром `APEX_SUBNET` обязателен на каждом узле. Параметр `APEX_NODE_FQDN` допустимо опускать только в том случае, если системное имя хоста ОС возвращает валидный FQDN.
---
@@ -1,48 +1,48 @@
---
title: Модель безопасности
description: Изоляция привилегий, управление межсетевым экраном, обнаружение вторжений и обработка секретов.
description: Изоляция привилегий, управление межсетевым экраном, обнаружение вторжений и защита секретов.
weight: 18
---
apex укрепляет [узел](/apex/glossary#node) в независимых слоях: изоляция привилегий на уровне файловой системы, управляемый межсетевой экран хоста, обнаружение вторжений, ограничение на уровне контейнеров и строгая граница секретов. Каждый слой применяется [действием](/apex/glossary#action) и потому воспроизводим и повторно запускаем.
Фреймворк apex защищает [узел](/apex/glossary#node) на нескольких независимых уровнях: изоляция привилегий в файловой системе, управляемый межсетевой экран хоста, обнаружение вторжений, разграничение прав в контейнерах и строгая изоляция секретов. Каждый уровень настраивается отдельным [действием](/apex/glossary#action), что гарантирует воспроизводимость и идемпотентность конфигурации.
## Изоляция привилегий {#privilege}
Каждый проект выполняется под собственным заблокированным системным пользователем `noroot-<project>`; общее хранилище доступно на запись группе с установленным sticky-битом, поэтому проекты могут обмениваться файлами, не имея возможности удалять файлы друг друга. Полный механизм — пользователи, группы и матрица владения — описан в [Концепция: Многоуровневое хранилище](/apex/concepts/tiered-storage).
Каждый проект работает под изолированным системным пользователем `noroot-<project>` без права входа. Общее хранилище открыто на запись группе с установленным sticky-битом: проекты могут обмениваться файлами, но не могут удалять чужие данные. Подробное устройство механизма (пользователи, группы и матрица прав) описано в разделе [Концепция: Многоуровневое хранилище](/apex/concepts/tiered-storage).
## Межсетевой экран хоста {#firewall}
`apex configure/ufw` применяет межсетевой экран узла из опоры `configs/ufw/` в трёх частях:
Команда `apex configure/ufw` настраивает межсетевой экран узла на основе каталога `configs/ufw/` по трём направлениям:
- **Правила хоста** (`configs/ufw/host/host.rules`) — простые команды `ufw`, по одной на строку, применяемые по порядку.
- **Блоки правил уровня ядра** (`configs/ufw/host/before.rules`, `after.rules`) — внедряются в `/etc/ufw/before.rules` и `/etc/ufw/after.rules` как управляемые блоки-маркеры. Внедрение является **самовосстанавливающимся**: каждый предыдущий управляемый блок — включая блоки, оставленные инструментарием-предшественником, — удаляется перед вставкой свежего блока после якоря ufw `# End required lines`, а рядом сохраняется `.bak` целевого файла с меткой времени.
- **Правила контейнеров** (`configs/ufw/docker/*.rules`) — применяются через `ufw-docker`, который транслирует их в правила, переживающие собственные манипуляции Docker с iptables. `configure/base` устанавливает вспомогательный инструмент в `~/.local/bin/ufw-docker`.
- **Правила хоста** (`configs/ufw/host/host.rules`) — список команд `ufw` (по одной на строку), применяемых последовательно.
- **Правила уровня ядра** (`configs/ufw/host/before.rules`, `after.rules`) — внедряются в `/etc/ufw/before.rules` и `/etc/ufw/after.rules` в виде управляемых блоков-маркеров. Процесс внедрения **самовосстанавливающийся**: любые ранее добавленные блоки (включая оставшиеся от предыдущих утилит) удаляются перед вставкой актуального блока после якорной строки `# End required lines`. Рядом с изменяемым файлом сохраняется резервная копия `.bak` с временной меткой.
- **Правила для контейнеров** (`configs/ufw/docker/*.rules`) — применяются через утилиту `ufw-docker`, которая формирует правила iptables, устойчивые к перезапускам и манипуляциям самого Docker. Команда `configure/base` устанавливает этот инструмент в `~/.local/bin/ufw-docker`.
Отсутствующая конфигурация — это ошибка, а не тихий пропуск: отсутствующий каталог `docker/` или `host/` завершается с кодом `66`, если существует его аналог, а отсутствующий целевой файл `/etc/ufw` прерывает запуск. Действие завершается напоминанием, что `ufw` должен быть перезапущен, чтобы изменения вступили в силу, — и что SSH-порт должен быть разрешён в первую очередь.
Отсутствующая конфигурация считается ошибкой, а не поводом для тихого пропуска: отсутствие каталогов `docker/` или `host/` (при наличии парного каталога) завершает работу с кодом `66`, а отсутствие целевого системного файла в `/etc/ufw` немедленно прерывает выполнение. В конце действие напоминает о необходимости перезапустить `ufw` для применения изменений и проверить доступность порта SSH.
## Обнаружение вторжений {#crowdsec}
`apex configure/crowdsec` устанавливает CrowdSec через его официальный установщик, когда `cscli` отсутствует, рендерит всё из `configs/crowdsec/` в `/etc/crowdsec/` (источники сбора, парсеры, конфигурация bouncer) и устанавливает коллекции `crowdsecurity/linux` и `crowdsecurity/traefik`. Когда конфигурации узла включают конфигурацию firewall-bouncer, устанавливается и соответствующий пакет bouncer, замыкая цикл от обнаружения к блокировке.
Команда `apex configure/crowdsec` автоматически устанавливает CrowdSec через официальный инсталлятор (если утилита `cscli` отсутствует), генерирует конфигурацию из `configs/crowdsec/` в `/etc/crowdsec/` (источники логов, парсеры, параметры bouncer) и устанавливает коллекции `crowdsecurity/linux` и `crowdsecurity/traefik`. Если конфигурация узла предусматривает firewall-bouncer, соответствующий пакет bouncer устанавливается автоматически, связывая обнаружение атак с немедленной блокировкой на уровне сети.
## Ограничение на уровне контейнеров {#containers}
[Ядровая композиция](/apex/glossary#core-composition) кодирует укрепление на уровне контейнеров:
[Ядровая композиция](/apex/glossary#core-composition) задаёт строгие ограничения для контейнеров:
- Граничный прокси никогда не обращается к Docker-сокету: он обнаруживает бэкенды через **отфильтрованный socket-proxy**, который выставляет состояние контейнеров/сервисов в режиме только для чтения в изолированной сети.
- `enclave` и `socket` — сети с `internal: true` — без исходящего трафика, без выставления хостовых портов.
- Ядровые сервисы устанавливают `no-new-privileges` и работают под noroot-идентичностью проекта везде, где это позволяет нагрузка; capabilities выдаются для каждого сервиса, а не глобально.
- Граничный прокси изолирован от сокета Docker: он получает данные о сервисах через **отфильтрованный socket-proxy**, который отдаёт состояние контейнеров только для чтения в замкнутой сети.
- Сети `enclave` и `socket` объявлены с параметром `internal: true`: у них нет прямого доступа в интернет и опубликованных портов хоста.
- Ядровые сервисы запускаются с опцией `no-new-privileges` под noroot-пользователем везде, где позволяет рабочая нагрузка; Linux capabilities выдаются точечно каждому сервису, а не глобально.
## Наблюдаемость {#observability}
Наблюдаемость — это **исходящий push**, а не выставленный маршрут. Опциональный ядровой сервис `alloy` (Grafana Alloy, один агент на узел) собирает метрики хоста (эквивалент `node_exporter`), метрики контейнеров (cadvisor), systemd journal и логи docker-контейнеров и проталкивает их в **центральные** Prometheus и Loki, которые располагаются на выделенном узле — метрики через `prometheus.remote_write` в `APEX_PROM_PUSH_URL`, логи через `loki.write` в `APEX_LOKI_PUSH_URL`. Обе цели аутентифицируются через HTTP basic auth с использованием `APEX_OBS_BASIC_AUTH_USER` / `APEX_OBS_BASIC_AUTH_PASS`. Поскольку push является исходящим, Alloy подключается к сети `direct` (у которой есть исходящий трафик), а не к `internal`-сети `enclave`; узло-локального хранилища логов нет, как нет и маршрута `/loki`, который нужно было бы защищать.
Система мониторинга построена по модели **исходящей отправки (push)**, а не входящих опросов. Опциональный сервис `alloy` (Grafana Alloy, один агент на узел) собирает метрики хоста (аналог `node_exporter`), метрики контейнеров (cadvisor), системный журнал systemd journal и логи docker-контейнеров, после чего передаёт их в **центральные** хранилища Prometheus и Loki на выделенном узле. Метрики отправляются через `prometheus.remote_write` на адрес `APEX_PROM_PUSH_URL`, логи — через `loki.write` на `APEX_LOKI_PUSH_URL`. Обе конечные точки защищены базовой аутентификацией HTTP basic auth (`APEX_OBS_BASIC_AUTH_USER` / `APEX_OBS_BASIC_AUTH_PASS`). Поскольку трафик отправляется наружу, Alloy подключён к сети `direct` с доступом в интернет, а не к закрытой сети `enclave`. На самих узлах логи не накапливаются, и открывать внешний доступ к эндпоинтам сервисов не требуется.
## Граница секретов {#secrets}
## Изоляция секретов {#secrets}
- Секреты живут только в файлах `.env` для каждого проекта, которые являются **символьными ссылками в хранилище уровня 1** — за пределами рабочего дерева репозитория и покрытыми `.gitignore` (`**/.env`).
- Каждый репозиторий узла документирует ожидаемые ключи в `SECRETS.md`; файл документирует имена, но никогда — значения.
- Закоммиченная конфигурация (`apex.env`) ограничена несекретными скалярными значениями.
- Общая композиция помечает критические секреты через `?error`, поэтому отсутствующее значение быстро проваливается на этапе загрузки конфигурации compose.
- Коммиты «capture-up» безопасны по построению: игнорируемые ссылки `.env` и данные уровней никогда не попадают в git-индекс.
- Секреты хранятся исключительно в файлах `.env` каждого проекта. Эти файлы представляют собой **символьные ссылки на хранилище уровня 1**, расположены вне рабочего дерева git и внесены в `.gitignore` (`**/.env`).
- Для каждого узла перечень обязательных переменных задокументирован в файле `SECRETS.md`, причём указываются только имена ключей, но ни в коем случае не их значения.
- Версионируемый файл `apex.env` содержит только несекретные параметры.
- В ядровых compose-файлах критически важные секреты снабжены маркером `?error`: при отсутствии переменной запуск немедленно останавливается с ошибкой ещё на этапе разбора конфигурации.
- Механизм [«capture-up»](/apex/glossary#capture-up) безопасен конструктивно: игнорируемые файлы `.env` и данные уровней хранилища никогда не попадают в git-индекс.
---
@@ -1,25 +1,25 @@
---
title: Многоуровневое хранилище и изоляция привилегий
description: Структура ссылок @tier, общее хранилище и понодовые noroot-пользователи.
description: Структура ссылок @tier, разделяемое хранилище и локальные noroot-пользователи для проектов.
weight: 16
---
Данные сервисов никогда не живут внутри репозитория [узла](/apex/glossary#node). Они живут в трёх [уровнях](/apex/glossary#tier) хранилища — раздельных расположениях, выбираемых для каждого хоста, — и достигают [композиций](/apex/glossary#compositions) через стандартизированную структуру символьных ссылок. Владение этими данными делегируется системным пользователям для каждого проекта, поэтому ни один сервисный контейнер не пишет от имени root.
Данные сервисов никогда не хранятся внутри репозитория [узла](/apex/glossary#node). Они распределяются по трём [уровням](/apex/glossary#tier) хранилища — отдельным каталогам, настраиваемым индивидуально для каждого хоста, — и подключаются к [композициям](/apex/glossary#compositions) через стандартизированную структуру символьных ссылок. Права на эти каталоги делегируются системным пользователям проектов, благодаря чему ни один контейнер не ведёт запись от имени root.
## Структура ссылок уровней {#links}
`apex tiers/link <tier1> <tier2> <tier3>` строит два слоя ссылок:
Команда `apex tiers/link <tier1> <tier2> <tier3>` формирует два уровня символьных ссылок:
- **На уровне узла:** `compositions/@tierN` → корень хранилища уровня. Именно их движок выставляет как `APEX_TIER_ROOT1..3`.
- **Для каждого проекта:** для каждого `compositions/<project>/` действие создаёт `<tier-root>/<project>/` и связывает `compositions/<project>/@tierN` → `<tier-root>/<project>`. Внутри каждого уровня оно также поддерживает `shared/` с подкаталогом для каждого проекта и относительной ссылкой `shared` внутри каталога уровня каждого проекта, указывающей обратно на него.
- **На уровне узла:** `compositions/@tierN` → корень хранилища соответствующего уровня. Именно эти пути движок предоставляет в переменных `APEX_TIER_ROOT1..3`.
- **Для каждого проекта:** в каталоге `compositions/<project>/` создаётся путь `<tier-root>/<project>/`, на который указывает ссылка `compositions/<project>/@tierN`. Внутри каждого уровня поддерживается общий каталог `shared/` с поддиректорией для каждого проекта, а внутри каталога уровня проекта создаётся относительная ссылка `shared`, ведущая обратно в общую зону.
Каждый проект дополнительно получает `compositions/<project>/.env` → `<tier1-root>/<project>/.env` (создаётся пустым, если отсутствует) — файл незакоммиченных секретов, описанный в [Концепция: Композиции](/apex/concepts/compositions). Ссылки `@tierN` и файлы `.env` игнорируются git; репозиторий остаётся чистым источником.
Каждый проект дополнительно получает символьную ссылку `compositions/<project>/.env` → `<tier1-root>/<project>/.env` (создаётся пустым, если файл отсутствует). В нём хранятся незакоммиченные секреты (см. [Концепция: Композиции и общее ядро](/apex/concepts/compositions)). Ссылки `@tierN` и файлы `.env` исключены из git через `.gitignore`, благодаря чему репозиторий остаётся чистым источником кода.
Если путь, который должен быть символьной ссылкой, существует как настоящий файл или каталог, действие предупреждает и запрашивает подтверждение перед его удалением; отказ (включая неинтерактивный EOF) пропускает эту ссылку и продолжает.
Если по пути предполагаемой символьной ссылки уже существует обычный файл или каталог, действие предупреждает об этом и запрашивает подтверждение перед удалением. При отказе (включая неинтерактивный EOF) создание ссылки пропускается, а выполнение продолжается дальше.
## Пользователи и группы {#users}
`apex tiers/useradd` провизионирует по одному заблокированному системному пользователю на проект — `noroot-<project>` (имя санитизируется, собственная первичная группа) — плюс общефлотовую группу `noroot-shared`, к которой присоединяется каждый пользователь проекта. Затем оно внедряет разрешённые ID в `.env` проекта между комментариями-маркерами, заменяя любой предыдущий блок при повторных запусках:
Команда `apex tiers/useradd` создаёт по одному заблокированному системному пользователю на проект — `noroot-<project>` (с санитизацией имени и собственной первичной группой), а также общефлотовую группу `noroot-shared`, в которую входят все пользователи проектов. Затем сгенерированные идентификаторы прописываются в файл `.env` соответствующего проекта между комментариями-маркерами, перезаписывая старый блок при повторных запусках:
```ini {filename="compositions/<project>/.env"}
# START AUTO GENERATED APEX-NOROOT-USERS
@@ -29,42 +29,42 @@ APEX_SHARED_GID=997
# END AUTO GENERATED APEX-NOROOT-USERS
```
Композиции потребляют их как `user: "${APEX_UID?error}:${APEX_GID?error}"` и `group_add: ["${APEX_SHARED_GID?error}"]`.
Композиции подключают эти идентификаторы через переменные `user: "${APEX_UID?error}:${APEX_GID?error}"` и `group_add: ["${APEX_SHARED_GID?error}"]`.
## Матрица владения {#ownership}
`apex tiers/chown` обеспечивает владение по всем трём уровням. Без аргументов оно работает с корнями уровней (`APEX_TIER_ROOT1..3`); явные пути уровней могут переопределить это.
Действие `apex tiers/chown` выставляет корректные права по всем трём уровням хранилища. Без аргументов оно обрабатывает корни уровней (`APEX_TIER_ROOT1..3`); при необходимости можно указать конкретные пути вручную.
| Путь | Владение | Примечания |
|---|---|---|
| `<tier-root>` | `root:root` | Только inode каталога. |
| `<tier-root>/shared` | `root:root` | Только inode каталога — никогда рекурсивно, поэтому существующие данные проектов внутри не затрагиваются. |
| `<tier-root>/shared` | `root:root` | Только inode каталога (без рекурсии, чтобы не затронуть пользовательские данные проектов). |
| `<tier-root>/node-infra` | `root:root` | Рекурсивно. |
| `<tier-root>/<project>` | `uid:gid` | Рекурсивно; ID читаются из `.env` проекта. |
| `<tier-root>/shared/<project>` | `uid:shared_gid` | Рекурсивно, плюс sticky-бит — участники могут добавлять файлы, но удалять только свои. |
| `<tier-root>/<project>` | `uid:gid` | Рекурсивно; идентификаторы читаются из `.env` проекта. |
| `<tier-root>/shared/<project>` | `uid:shared_gid` | Рекурсивно, с установкой sticky-бита: участники могут добавлять файлы, но удалять — только собственные. |
Проекты с неполным набором `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` пропускаются с предупреждением, указывающим на `tiers/useradd`.
Проекты с неполным набором переменных `APEX_UID`, `APEX_GID` или `APEX_SHARED_GID` пропускаются с предупреждением и рекомендацией выполнить `tiers/useradd`.
## Переменные уровней движка {#variables}
| Переменная | Разрешается в |
|---|---|
| `APEX_TIER_ROOT1..3` | Корни хранилищ уровней (`compositions/@tierN`, полностью разрешённые). |
| `APEX_TIER1..3` | Каталоги данных ядрового проекта внутри каждого уровня (`compositions/apex/@tierN`). |
| `APEX_TIER1..3_SHARED` | Общая область внутри уровней ядрового проекта. |
| `APEX_TIER_ROOT1..3` | Корни хранилищ уровней (`compositions/@tierN`, абсолютные пути). |
| `APEX_TIER1..3` | Каталоги данных ядрового проекта на каждом уровне (`compositions/apex/@tierN`). |
| `APEX_TIER1..3_SHARED` | Общие области внутри уровней ядрового проекта. |
Ядровая композиция монтирует исключительно через них; исполнитель резервного копирования вместо этого монтирует корень данных флота `/srv` в режиме только для чтения и разрешает контейнерные монтирования каждого помеченного сервиса обратно в их хостовые пути.
Ядровая композиция монтирует каталоги исключительно через эти переменные. Контейнер резервного копирования, напротив, монтирует общий корень данных флота `/srv` в режиме «только для чтения» и сопоставляет контейнерные пути каждого сервиса с их реальными путями на хосте.
## Резервные копии {#backups}
## Резервное копирование {#backups}
`apex backup/run <telegram-bot-url>` управляет ядровым сервисом `resticontainer` (профиль `manual`, одноразовый) от начала до конца. Намерение резервного копирования **управляется метками**: resticontainer обнаруживает каждый сервис, несущий `restic.enable=true`, читает его `restic.backup.paths` (и опциональные `restic.backup.stop` / `restic.hooks.*`), разрешает эти контейнерные пути в хостовые пути и делает снимок их объединения в одном запуске restic.
Команда `apex backup/run <telegram-bot-url>` полностью берёт на себя управление сервисом `resticontainer` (профиль `manual`, одноразовый запуск). Резервное копирование **управляется метками**: resticontainer находит все контейнеры с меткой `restic.enable=true`, считывает список путей `restic.backup.paths` (и необязательные параметры `restic.backup.stop` / `restic.hooks.*`), транслирует пути контейнеров в пути хоста и создаёт единый снимок restic.
1. Проверяет доступность репозитория через `snapshots`; инициализирует репозиторий при необходимости.
2. Выполняет `backup` с `--host <APEX_NODE_HOST>` и `--tag biweekly` — resticontainer выполняет pre-hook каждого помеченного сервиса, останавливает любой контейнер, помеченный `restic.backup.stop`, делает снимок разрешённых хостовых путей, затем перезапускает и выполняет post-hooks. Запуск отказывается сообщать об успехе, если не появляется новый снимок (защита от обнаружения нулевого количества меток).
3. Применяет удержание через `forget --prune` (7 ежедневных, 4 еженедельных, 12 ежемесячных).
4. Отправляет статус в Telegram в любом случае; успех, уведомление о котором не может быть доставлено, всё равно завершается с кодом `1`, чтобы планировщики это заметили.
1. Проверяет доступность репозитория с помощью команды `snapshots`; при необходимости автоматически инициализирует его.
2. Запускает команду `backup` с параметрами `--host <APEX_NODE_HOST>` и `--tag biweekly`. При этом resticontainer выполняет pre-hook сервиса, временно останавливает контейнеры с меткой `restic.backup.stop`, делает снимок хостовых путей, перезапускает остановленные контейнеры и запускает post-hooks. Запуск не засчитывается как успешный, если в репозитории не появился новый снимок (защита от ложного успеха при отсутствии помеченных сервисов).
3. Применяет политику хранения с помощью `forget --prune` (хранятся 7 ежедневных, 4 еженедельных и 12 ежемесячных снимков).
4. Отправляет статус выполнения в Telegram. Если уведомление не удалось доставить, операция завершается с кодом ошибки `1` даже при успешном бэкапе, чтобы сбой в канале оповещения не остался незамеченным.
Расположение репозитория и учётные данные полностью берутся из конфигурации `APEX_RESTIC_*` (см. [Справочник: Переменные окружения](/apex/reference/environment)). Какие данные захватываются, решается для каждого сервиса его метками `restic.*` — например, ядровые сервисы резервируют только свою конфигурацию (динамические правила traefik, конфигурацию xray/adguard), оставляя воспроизводимые данные уровня 3, такие как кэши и логи, вне набора.
Параметры подключения и учётные данные репозитория считываются из конфигурации `APEX_RESTIC_*` (см. [Справочник: Переменные окружения](/apex/reference/environment)). Состав сохраняемых данных определяется метками каждого отдельного сервиса. Например, для ядровых сервисов копируются только файлы конфигурации (динамические правила traefik, настройки xray и adguard), тогда как легко восстановимые данные уровня 3 (кэши и журналы) в снимок не включаются.
---
@@ -1,18 +1,18 @@
---
title: Начало работы с узлом apex
description: Начальная настройка репозитория узла и перевод хоста под управление apex.
title: Начало работы с узлом apex
description: Начальная настройка репозитория узла и перевод хоста под управление apex.
weight: 5
---
Это краткое руководство проведёт новый хост от склонированного репозитория [узла](/apex/glossary#node) до работающего стека сервисов. Предполагается, что репозиторий узла уже существует — чтобы собрать его с нуля, см. [Руководство: Сборка репозитория узла](/apex/guides/node-repository).
В этом руководстве показано, как настроить новый хост: от клонирования репозитория [узла](/apex/glossary#node) до запуска рабочего стека сервисов. Предполагается, что репозиторий узла уже создан; если его нужно собрать с нуля, см. [Руководство: Сборка репозитория узла](/apex/guides/node-repository).
## Предварительные требования
- Хост на базе Debian. Подойдёт любой FQDN — [идентичность узла](/apex/glossary#node-identity) объявляется явно в `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`), а не выводится из обязательного формата имени хоста.
- `git` и Python 3 — [движок](/apex/glossary#engine) использует только стандартную библиотеку; `pip` никогда не требуется.
- Docker с Compose версии v2.20 или новее ([базовая композиция](/apex/glossary#core-composition) подключается через директиву `include:`).
- Пользователь для входа с возможностью sudo.
- Три места хранения для [уровней](/apex/glossary#tier) (например, три точки монтирования).
- Хост на базе Debian. Подойдёт любой FQDN: [идентичность узла](/apex/glossary#node-identity) задаётся явно в `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`), а не выводится из имени хоста.
- `git` и Python 3 — [движок](/apex/glossary#engine) использует только стандартную библиотеку, `pip` не нужен.
- Docker с Compose версии 2.20 или новее ([базовая композиция](/apex/glossary#core-composition) подключается через директиву `include:`).
- Учётная запись с правами sudo для входа на хост.
- Три хранилища под [уровни](/apex/glossary#tier) (например, три точки монтирования).
## Начальная настройка
@@ -27,7 +27,7 @@ cd <node-repo>
source ~/.bashrc
```
`init.sh` инициализирует субмодуль `commons/`, указывает `core.hooksPath` на `commons/githooks`, помечает чекаут как `safe.directory` и добавляет `commons/` в начало `PATH` — лаунчер находится в `commons/apex`.
Скрипт `init.sh` инициализирует субмодуль `commons/`, настраивает `core.hooksPath` на `commons/githooks`, помечает чекаут как `safe.directory` и добавляет `commons/` в начало `PATH` (лаунчер находится в `commons/apex`).
### Проверьте обнаружение действий
@@ -35,7 +35,7 @@ source ~/.bashrc
apex --help
```
В списке отображается каждое действие [commons](/apex/glossary#commons) плюс любые локальные [проприетарные модули](/apex/glossary#proprietaries) узла с меткой `(local)`. Когда в `node.env` отсутствует `APEX_NODE_FQDN`, движок выводит предупреждение и откатывается к имени хоста ОС — безобидно на рабочей станции, но на самом узле задайте `APEX_NODE_FQDN` явно. (`APEX_SUBNET` обязателен: если он отсутствует, движок завершается с кодом `66`.)
В списке отображается каждое действие [commons](/apex/glossary#commons) плюс любые локальные [проприетарные модули](/apex/glossary#proprietaries) узла с меткой `(local)`. Если в `node.env` не задан `APEX_NODE_FQDN`, движок выводит предупреждение и использует имя хоста ОС. На рабочей станции это безобидно, но на самом узле задайте `APEX_NODE_FQDN` явно. Параметр `APEX_SUBNET` обязателен: без него движок завершит работу с кодом `66`.
### Свяжите уровни хранилища
@@ -43,7 +43,7 @@ apex --help
apex tiers/link <tier1-path> <tier2-path> <tier3-path>
```
Создаёт ссылки `compositions/@tierN` уровня узла, директории уровней и ссылки `@tierN` для каждого проекта, области `shared/` и символьную ссылку `.env` каждого проекта в уровень 1. Успех завершается сообщением `@tier setup completed.`
Команда создаёт ссылки `compositions/@tierN` на уровне узла, каталоги уровней и ссылки `@tierN` для каждого проекта, области `shared/`, а также символьную ссылку на `.env` каждого проекта в уровень 1. При успешном выполнении выводится сообщение `@tier setup completed.`.
### Заведите пользователей проектов
@@ -52,11 +52,11 @@ apex tiers/useradd
apex tiers/chown
```
`tiers/useradd` создаёт по одному системному пользователю `noroot-<project>` на каждую композицию плюс группу `noroot-shared`, а также добавляет `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` в `.env` каждого проекта. Затем `tiers/chown` применяет матрицу владения (см. [Концепция: Многоуровневое хранилище](/apex/concepts/tiered-storage)) и завершается сообщением `Permissions applied.`
Команда `tiers/useradd` создаёт по одному системному пользователю `noroot-<project>` для каждой композиции и общую группу `noroot-shared`, а также прописывает `APEX_UID`, `APEX_GID` и `APEX_SHARED_GID` в `.env` каждого проекта. Затем `tiers/chown` выставляет права по матрице владения (см. [Концепция: Многоуровневое хранилище](/apex/concepts/tiered-storage)) и выводит сообщение `Permissions applied.`.
### Заполните базовые секреты
Отредактируйте `compositions/apex/.env` (символьная ссылка в хранилище уровня 1, никогда не коммитится), указав ключи, задокументированные в файле `SECRETS.md` репозитория узла:
Отредактируйте `compositions/apex/.env` (символьная ссылка в хранилище уровня 1, никогда не коммитится), указав ключи из файла `SECRETS.md` репозитория узла:
```ini {filename="compositions/apex/.env"}
APEX_RESTIC_PASSWORD=...
@@ -67,7 +67,7 @@ APEX_TRAEFIK_ACME_EMAIL=...
APEX_OBS_BASIC_AUTH_PASS=... # только когда включён профиль наблюдаемости alloy
```
`APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` уже присутствуют — `tiers/useradd` поддерживает их между комментариями-маркерами. Несекретные скалярные значения (`APEX_RESTIC_REPOSITORY`, push-URL-ы, `COMPOSE_PROFILES`, `TZ`, …) хранятся в коммитируемом `compositions/apex/apex.env` — см. [Руководство: Сборка репозитория узла](/apex/guides/node-repository).
Переменные `APEX_UID`, `APEX_GID` и `APEX_SHARED_GID` уже добавлены: `tiers/useradd` сохраняет их между специальными комментариями-маркерами. Несекретные скалярные значения (`APEX_RESTIC_REPOSITORY`, push-URL, `COMPOSE_PROFILES`, `TZ` и др.) хранятся в отслеживаемом файле `compositions/apex/apex.env` (см. [Руководство: Сборка репозитория узла](/apex/guides/node-repository)).
### Настройте хост
@@ -75,7 +75,7 @@ APEX_OBS_BASIC_AUTH_PASS=... # только когда включён проф
apex configure
```
Запускает каждое действие `configure/*` в фиксированном порядке: `base`, `ufw`, `crowdsec`, `cron`, `systemd`, `routing`. Каждый шаг можно вызвать и по отдельности; некоторые принимают `--dry-run`. См. [Руководство: Эксплуатация узла](/apex/guides/host-operations).
Команда запускает все действия `configure/*` в строгом порядке: `base`, `ufw`, `crowdsec`, `cron`, `systemd`, `routing`. Каждый шаг можно выполнить и отдельно; некоторые из них поддерживают флаг `--dry-run` (см. [Руководство: Эксплуатация узла](/apex/guides/host-operations)).
### Поднимите стек
@@ -83,15 +83,15 @@ apex configure
apex compose up
```
Сначала поднимается базовая композиция (она владеет сетями), затем каждая композиция сервиса в алфавитном порядке.
Сначала запускается базовая композиция (она создаёт общие сети), затем — композиции отдельных сервисов в алфавитном порядке.
{{% /steps %}}
## Проверка
- `apex --help` выводит список действий без предупреждений на работающем хосте.
- `docker ps` показывает базовые контейнеры (`apex-traefik`, `apex-socket-proxy` плюс любые сервисы, включённые профилями) и якорь каждого проекта.
- `apex utils/lint-docker-compose` сообщает `Anchor IP validation passed.`
- `apex --help` выводит список действий без предупреждений на работающем хосте.
- `docker ps` показывает базовые контейнеры (`apex-traefik`, `apex-socket-proxy` и дополнительные сервисы активных профилей), а также якорный контейнер каждого проекта.
- `apex utils/lint-docker-compose` завершается сообщением `Anchor IP validation passed.`.
---
+16 -16
View File
@@ -1,66 +1,66 @@
---
title: Глоссарий
description: Терминология, используемая в рамках фреймворка операций флота apex.
description: Термины и понятия фреймворка операций флота apex.
weight: 100
---
Терминология, используемая в рамках фреймворка операций флота apex.
Термины и понятия фреймворка операций флота apex.
## Action {#action}
**Action (Действие)** - Атомарная задача автоматизации: один модуль на Python, экспортирующий `METADATA = Meta(...)` и `run(ctx, args)`, диспетчеризуемый по пути своего файла как `apex <group>/<action>`.
**Action (действие)** — атомарная задача автоматизации: один модуль на Python, экспортирующий `METADATA = Meta(...)` и `run(ctx, args)`. Диспетчеризуется по пути к своему файлу как `apex <group>/<action>`.
## Anchor IP {#anchor-ip}
**Anchor IP (Якорный IP)** - Статический `ipv4_address`, который основной контейнер проекта удерживает в сети `direct`. Якоря делают адреса сервисов предсказуемыми и проверяются линтером compose.
**Anchor IP (якорный IP)** — статический `ipv4_address`, который основной контейнер проекта занимает в сети `direct`. Якоря делают адреса сервисов предсказуемыми и проверяются линтером compose.
## Capture-up {#capture-up}
**Capture-up** - «capture-up» (запись состояния хоста в ветку `sync/<node>`): поток, который фиксирует текущее состояние хоста обратно в его репозиторий — плановый коммит в ветку `sync/<node>`, ребазируемый на `main` и сворачиваемый обратно средствами CI. Дрейф становится diff-ом, а не расхождением.
**Capture-up** — механизм фиксации состояния хоста в репозитории: регулярный коммит в ветку `sync/<node>`, который перебазируется на `main` и сливается средствами CI. В результате любой дрейф конфигурации становится наглядным diff, а не скрытым расхождением.
## Commons {#commons}
**Commons** - «commons» (общий репозиторий) (`apex.ermnvldmr.com`), содержащий движок, общие действия и базовую композицию. Каждый узел подключает его как закреплённый git-субмодуль `commons/`.
**Commons** — общий репозиторий (`apex.ermnvldmr.com`), содержащий движок, общие действия и базовую композицию. Каждый узел подключает его как зафиксированный git-субмодуль `commons/`.
## Compositions {#compositions}
**Compositions (Композиции)** - Опора репозитория узла, объявляющая среду выполнения узла: по одному docker-compose-проекту на сервис плюс базовая обёртка и реестр адресации.
**Compositions (композиции)** — структурная часть репозитория узла, определяющая среду его выполнения: по одному проекту docker-compose на каждый сервис плюс базовая обёртка и реестр адресов.
## Configs {#configs}
**Configs (Конфигурации)** - Опора репозитория узла, содержащая исходники конфигурации хоста (`ufw/`, `cron/`, `systemd/`, `crowdsec/`), рендеримые через слой шаблонов действиями `configure/*`.
**Configs (конфигурации)** — директория репозитория с исходными файлами конфигурации хоста (`ufw/`, `cron/`, `systemd/`, `crowdsec/`), которые обрабатываются шаблонизатором в действиях `configure/*`.
## Core composition {#core-composition}
**Core composition (Базовая композиция)** - Общий, параметризуемый через `${APEX_*}` docker-compose-проект, определяющий общие сервисы флота (краевой прокси, socket-прокси и опциональные сервисы профилей). Определяется один раз в commons, включается на каждом узле.
**Core composition (базовая композиция)** — общий проект docker-compose, параметризуемый переменными `${APEX_*}`. Он определяет общие сервисы флота (пограничный прокси, socket-прокси и дополнительные сервисы профилей). Задаётся один раз в commons и подключается на каждом узле.
## Enclave {#enclave}
**Enclave (Анклав)** - Приватное адресное пространство флота, `198.18.0.0/16`, и, как расширение, внутренняя backend-сеть обратного прокси с именем `enclave` в базовой композиции.
**Enclave (анклав)** — изолированное адресное пространство флота (`198.18.0.0/16`), а также внутренняя сеть обратного прокси с именем `enclave` в базовой композиции.
## Engine {#engine}
**Engine (Движок)** - Среда выполнения на stdlib-Python в `commons/engine/`: лаунчер, разрешение идентичности, оверлей, контекст выполнения и вспомогательная библиотека `ctx.*`.
**Engine (движок)** — среда выполнения на стандартной библиотеке Python в `commons/engine/`: включает лаунчер, определение идентичности узла, механизм оверлеев, контекст выполнения и вспомогательную библиотеку `ctx.*`.
## Node {#node}
**Node (Узел)** - Хост, управляемый фреймворком, определяемый ровно одним репозиторием узла и идентифицируемый по FQDN, который он объявляет в `node.env` (например, `node1.example.com`).
**Node (узел)** — хост под управлением фреймворка. Задаётся отдельным репозиторием и идентифицируется по FQDN, указанному в файле `node.env` (например, `node1.example.com`).
## Node identity {#node-identity}
**Node identity (Идентичность узла)** - Разрешённая запись о том, кто есть узел — имя, подсеть, FQDN, публичный хост — выведенная из `node.env`, с именем хоста ОС в качестве запасного варианта для FQDN.
**Node identity (идентичность узла)** — набор параметров узла (имя, подсеть, FQDN и публичный хост), вычисленный из `node.env`. В качестве резервного значения для FQDN используется имя хоста ОС.
## Overlay {#overlay}
**Overlay (Оверлей)** - Правило разрешения, позволяющее проприетарным модулям узла добавлять, переопределять, оборачивать или отключать действия commons: `proprietaries/actions/` проверяется раньше `commons/actions/`.
**Overlay (оверлей)** — механизм разрешения путей, позволяющий локальным модулям узла добавлять, переопределять, оборачивать или отключать общие действия: каталог `proprietaries/actions/` проверяется раньше `commons/actions/`.
## Proprietaries {#proprietaries}
**Proprietaries (Проприетарные модули)** - Опора репозитория узла для локального кода узла: действий, библиотек и исходников инструментов, которые применяются ровно к одному узлу и потому не относятся к commons.
**Proprietaries (проприетарные модули)** — каталог репозитория для локального кода конкретного узла: действий, библиотек и инструментов, которые нужны только этому узлу и не входят в commons.
## Tier {#tier}
**Tier (Уровень)** - Один из трёх стандартизированных классов хранилища, монтируемых узлом (конфигурация, ценные данные, воспроизводимые данные), доступный из композиций через структуру символьных ссылок `@tierN`.
**Tier (уровень)** — один из трёх стандартных классов хранилища узла (конфигурация, ценные данные, воспроизводимые данные), доступный сервисам через символьные ссылки вида `@tierN`.
---
@@ -1,23 +1,23 @@
---
title: Руководства
description: Практические руководства по сборке репозиториев узлов, действий и композиций.
description: Практические руководства по сборке репозиториев узлов, разработке действий и настройке композиций.
weight: 20
---
Практические руководства для повседневной работы с apex. Каждое руководство решает одну повторяющуюся задачу: сборка репозитория [узла](/apex/glossary#node), разработка [действий](/apex/glossary#action) для [движка](/apex/glossary#engine), определение [композиций](/apex/glossary#compositions) сервисов и эксплуатация лежащего в основе хоста.
Практические руководства для&nbsp;повседневной работы с&nbsp;apex. Каждое руководство решает одну прикладную задачу: сборку репозитория [узла](/apex/glossary#node), разработку [действий](/apex/glossary#action) для&nbsp;[движка](/apex/glossary#engine), определение [композиций](/apex/glossary#compositions) сервисов и&nbsp;эксплуатацию базового хоста.
## Доступные руководства {#available-guides}
{{< cards >}}
{{< card link="/apex/guides/node-repository" title="Репозиторий узла" subtitle="Соберите репозиторий узла, который объявляет идентичность узла, его конфигурации и композиции." >}}
{{< card link="/apex/guides/action-development" title="Разработка действий" subtitle="Пишите действия, которые движок диспетчеризует на узле." >}}
{{< card link="/apex/guides/service-composition" title="Композиция сервисов" subtitle="Определяйте композиции проектов и разворачивайте их вместе с базовой композицией." >}}
{{< card link="/apex/guides/host-operations" title="Операции с хостом" subtitle="Выполняйте рутинные операции над работающим хостом через движок." >}}
{{< card link="/apex/guides/node-repository" title="Репозиторий узла" subtitle="Соберите репозиторий с&nbsp;декларацией идентичности узла, его конфигураций и&nbsp;композиций." >}}
{{< card link="/apex/guides/action-development" title="Разработка действий" subtitle="Создавайте действия, которые движок запускает на&nbsp;узле." >}}
{{< card link="/apex/guides/service-composition" title="Композиция сервисов" subtitle="Определяйте композиции проектов и&nbsp;разворачивайте их вместе с&nbsp;базовой композицией." >}}
{{< card link="/apex/guides/host-operations" title="Операции с хостом" subtitle="Выполняйте регулярные операции на&nbsp;работающем хосте через движок." >}}
{{< /cards >}}
---
**См. также:**
**См.&nbsp;также:**
- [Docs: Getting started](/apex/getting-started)
- [Docs: Concepts](/apex/concepts)
@@ -1,10 +1,10 @@
---
title: Разработка действий
description: Пишите, переопределяйте и тестируйте действия движка в commons или proprietaries.
description: Создавайте, переопределяйте и тестируйте действия движка в commons или proprietaries.
weight: 22
---
**Контекст:** вся автоматизация поставляется в виде [действий](/apex/glossary#action) — по одному Python-модулю на каждое, диспетчеризуемых по пути. Общее поведение размещается в [commons](/apex/glossary#commons) `actions/` («commons» — общий репозиторий); всё, что специфично для узла, размещается в `proprietaries/actions/` этого узла. Оба используют идентичный контракт, а запускатель помещает оба дерева в `sys.path`, поэтому проприетарное действие импортирует `engine.*` точно так же, как и действие из commons.
**Контекст:** вся автоматизация устроена в&nbsp;виде [действий](/apex/glossary#action)&nbsp;— по&nbsp;одному Python-модулю на&nbsp;каждое действие, диспетчеризуемых по&nbsp;пути к&nbsp;файлу. Общий код находится в&nbsp;каталоге [commons](/apex/glossary#commons) `actions/` («commons»&nbsp;— общий репозиторий), а&nbsp;специфика конкретного узла&nbsp;— в&nbsp;его `proprietaries/actions/`. Оба дерева используют единый контракт, а&nbsp;запускатель добавляет оба каталога в&nbsp;`sys.path`. Поэтому локальное действие импортирует `engine.*` точно так&nbsp;же, как и&nbsp;действие из&nbsp;commons.
## Минимальное действие {#minimal}
@@ -29,14 +29,14 @@ def run(ctx, args):
ctx.log.success("Конфигурация развёрнута.")
```
Путь к файлу — это его имя: модуль выше диспетчеризуется как `apex utils/example`. Верните `0`/`None` для успеха, целое число для конкретного кода выхода или возбудите `SystemExit(code)`; [соглашение о кодах выхода](/apex/concepts/actions-and-overlay#exit-codes) таково: `64` — ошибка использования, `65` — ошибка данных, `66` — отсутствующий ввод, `1` — общая ошибка.
Путь к&nbsp;файлу задаёт имя действия: модуль выше вызывается как `apex utils/example`. Верните `0` или `None` при&nbsp;успехе, целое число&nbsp;— для&nbsp;конкретного кода выхода, либо выбросьте `SystemExit(code)`. [Соглашение о&nbsp;кодах выхода](/apex/concepts/actions-and-overlay#exit-codes): `64`&nbsp;— ошибка использования, `65`&nbsp;— ошибка данных, `66`&nbsp;— отсутствие входных данных, `1`&nbsp;— общая ошибка.
## Соглашения для действий без надзора {#unattended}
## Соглашения для фоновых действий {#unattended}
Действия, запускаемые из cron, следуют двум правилам, чтобы сбои приводили к оповещению, а не исчезали бесследно:
Действия, которые запускаются по&nbsp;cron без&nbsp;надзора оператора, следуют двум правилам, чтобы сбои отправляли оповещение, а&nbsp;не&nbsp;исчезали бесследно:
- **Оберните тело** в `try/except Exception` и отправьте `ctx.notify.error(...)` перед повторным возбуждением как `SystemExit(1)` — Python-эквивалент shell-ловушки ERR.
- **Привяжите код выхода к финальному уведомлению**: `ctx.notify.*` возвращает `False` при неудачной доставке; завершённый запуск, чьё статусное сообщение так и не пришло, всё равно должен завершиться с кодом `1`.
- **Оберните тело** в&nbsp;`try/except Exception` и&nbsp;отправьте `ctx.notify.error(...)` перед тем как выбросить `SystemExit(1)`&nbsp;— это Python-аналог перехвата `ERR` в&nbsp;shell.
- **Привяжите код выхода к&nbsp;итоговому уведомлению**: методы `ctx.notify.*` возвращают `False`, если доставить сообщение не&nbsp;удалось. Запуск, чьё статусное сообщение не&nbsp;дошло, всё равно должен завершиться с&nbsp;кодом&nbsp;1.
```python
try:
@@ -54,7 +54,7 @@ def run(ctx, args):
## Переопределение, обёртывание, отключение {#override}
Проприетарный файл с тем же относительным путём, что и действие из commons, полностью затеняет его. Внутри затеняющего модуля `ctx.commons.run(args)` вызывает затенённую реализацию — паттерн обёртывания:
Проприетарный файл с&nbsp;тем&nbsp;же относительным путём, что и&nbsp;действие из&nbsp;commons, полностью перекрывает его. Внутри переопределяющего модуля вызов `ctx.commons.run(args)` обращается к&nbsp;исходной реализации из&nbsp;commons&nbsp;— по&nbsp;паттерну обёртки:
```python {filename="proprietaries/actions/sync/packages.py"}
from engine.descriptor import Meta, Arg
@@ -67,23 +67,23 @@ def run(ctx, args):
return ctx.commons.run(args) # делегирование реализации из commons
```
Чтобы вывести действие из эксплуатации на одном узле, присвойте `DISABLED = "reason"` на верхнем уровне модуля: диспетчеризация отклонит его (код выхода `1`), а в списке отобразится `[DISABLED: reason]`.
Чтобы отключить действие на&nbsp;конкретном узле, укажите `DISABLED = "reason"` на&nbsp;верхнем уровне модуля. Диспетчер отклонит запуск такого действия с&nbsp;кодом выхода&nbsp;1, а&nbsp;в&nbsp;общем списке появится отметка `[DISABLED: reason]`.
## Тестирование {#testing}
Набор тестов commons использует stdlib `unittest`:
Набор тестов commons построен на&nbsp;стандартном модуле `unittest`:
```bash
./tests/run.sh # python3 -m unittest discover -s tests -p 'test_*.py' -v
```
Изменения движка должны сохранять набор тестов «зелёным». Дешёвая smoke-проверка для любого действия — из commons или проприетарного — это его парсер: `apex <name> --help` должен завершиться с кодом `0`, что доказывает, что модуль разбирается, его `METADATA` корректно сформирован, и его видит механизм обнаружения.
Изменения в&nbsp;движке должны сохранять тесты зелёными. Быстрая smoke-проверка для&nbsp;любого действия (как из&nbsp;commons, так и&nbsp;проприетарного)&nbsp;— запуск со&nbsp;справкой: команда `apex <name> --help` должна завершаться с&nbsp;кодом&nbsp;0. Это подтверждает, что модуль синтаксически корректен, объект `METADATA` сформирован без&nbsp;ошибок, а&nbsp;механизм обнаружения находит действие.
Pre-commit-хук commons (подключаемый `init.sh` через `core.hooksPath`) запускает `apex utils/lint-docker-compose --hook` для staged-файлов compose, поэтому изменения композиций линтуются в момент коммита.
Хук pre-commit в&nbsp;commons (скрипт `init.sh` подключает его через `core.hooksPath`) запускает `apex utils/lint-docker-compose --hook` для&nbsp;подготовленных (staged) compose-файлов, поэтому ошибки в&nbsp;композициях отлавливаются ещё на&nbsp;этапе коммита.
---
**См. также:**
**См.&nbsp;также:**
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
- [Concept: The execution context](/apex/concepts/execution-context)
@@ -4,22 +4,22 @@ description: "Операции day-2: конфигурирование, комп
weight: 24
---
**Контекст:** после первоначальной настройки [узел](/apex/glossary#node) эксплуатируется полностью через [движок](/apex/glossary#engine). Это руководство охватывает повторяющиеся операции и то, что каждая из них фактически делает на хосте.
**Контекст:** после первоначальной настройки [узел](/apex/glossary#node) управляется исключительно через [движок](/apex/glossary#engine). В&nbsp;этом руководстве описаны регулярные операции и&nbsp;их фактические действия на&nbsp;хосте.
## Конфигурирование хоста {#configure}
`apex configure` запускает полный набор конфигурирования в фиксированном порядке; каждый шаг вызывается индивидуально, когда изменился только один слой:
Команда `apex configure` выполняет все шаги настройки в&nbsp;строгой последовательности. Если изменился только один слой, нужный шаг можно вызвать отдельно:
| Шаг | Эффект |
|---|---|
| `configure/base` | Обеспечивает базу Debian: обновление индекса пакетов, необходимые пакеты (`curl`, `wget`, `git`, `rsyslog`), запущенный rsyslog, установленный `ufw` и вспомогательный скрипт `ufw-docker` в `~/.local/bin/ufw-docker`. |
| `configure/ufw` | Применяет правила хоста, внедряет управляемые блоки правил уровня ядра, применяет правила контейнеров через `ufw-docker`. Поддерживает `--dry-run`. |
| `configure/crowdsec` | Устанавливает CrowdSec, если он отсутствует, разворачивает `configs/crowdsec/` в `/etc/crowdsec/`, устанавливает коллекции и firewall bouncer, если он настроен. Поддерживает `--dry-run`. |
| `configure/cron` | Рендерит `configs/cron/crontab` через слой шаблонов и устанавливает его — с предпросмотром, отказом при пустом рендере (код выхода `65`) и откатом при неудачной установке. Поддерживает `--dry-run`. |
| `configure/systemd` | Рендерит файлы верхнего уровня из `configs/systemd/` в `/etc/systemd/system/`, затем выполняет daemon-reload и включает каждый `.service`. Поддерживает `--dry-run`. |
| `configure/routing` | Включает IP-форвардинг и устанавливает правило и таблицу policy-routing по fwmark, используемые границей анклава; идемпотентно. |
| `configure/base` | Готовит базовую систему Debian: обновляет списки пакетов, ставит базовые утилиты (`curl`, `wget`, `git`, `rsyslog`), запускает службу rsyslog, устанавливает `ufw` и&nbsp;вспомогательный скрипт `ufw-docker` в&nbsp;`~/.local/bin/ufw-docker`. |
| `configure/ufw` | Применяет правила хоста, настраивает цепочки правил ядра и&nbsp;пробрасывает правила для&nbsp;контейнеров через `ufw-docker`. Поддерживает флаг `--dry-run`. |
| `configure/crowdsec` | Устанавливает CrowdSec (если он ещё не&nbsp;поставлен), копирует `configs/crowdsec/` в&nbsp;`/etc/crowdsec/`, ставит коллекции и&nbsp;firewall bouncer (если включён в&nbsp;настройках). Поддерживает флаг `--dry-run`. |
| `configure/cron` | Рендерит `configs/cron/crontab` через шаблонизатор и&nbsp;устанавливает расписание. Предусмотрены предпросмотр, аварийная остановка при&nbsp;пустом результате (код выхода&nbsp;65) и&nbsp;откат при&nbsp;ошибке установки. Поддерживает флаг `--dry-run`. |
| `configure/systemd` | Рендерит файлы первого уровня из&nbsp;`configs/systemd/` в&nbsp;`/etc/systemd/system/`, перезагружает конфигурацию systemd (daemon-reload) и&nbsp;активирует каждый юнит `.service`. Поддерживает флаг `--dry-run`. |
| `configure/routing` | Включает пересылку IP-пакетов (forwarding), задаёт правило и&nbsp;таблицу маршрутизации (policy-routing) по&nbsp;метке fwmark для&nbsp;границы анклава. Шаг идемпотентен. |
После `configure/ufw` намеренно перезапустите `ufw` (`sudo systemctl restart ufw`) — действие напоминает об этом и предупреждает, что порт SSH уже должен быть разрешён.
После `configure/ufw` обязательно перезапустите службу: `sudo systemctl restart ufw`. Действие выведет напоминание и&nbsp;проверит, что порт SSH остаётся открытым.
## Жизненный цикл композиции {#compose}
@@ -30,7 +30,7 @@ apex compose restart
apex compose up --dry-run # только напечатать команды
```
Дополнительные аргументы передаются в `docker compose` для каждого проекта. Файлы `.env` и `apex.env` каждого проекта подставляются автоматически, если они присутствуют.
Все дополнительные аргументы движок передаёт напрямую в&nbsp;`docker compose` каждого проекта. Файлы `.env` и&nbsp;`apex.env` проектных каталогов подставляются автоматически при&nbsp;их наличии.
## Резервные копии {#backups}
@@ -38,7 +38,14 @@ apex compose up --dry-run # только напечатать команды
apex backup/run "$(cat <path-to-telegram-url-file>)"
```
Запускает [resticontainer](https://github.com/deytenit/resticontainer) в режиме one-shot под профилем compose `manual`: init-if-needed, затем управляемое метками резервное копирование, которое определяет намерение каждого сервиса из его меток compose `restic.*` (`restic.enable`, `restic.backup.paths`, …), делает снимок объединения разрешённых путей хоста в одном запуске restic (`--host` = `APEX_NODE_HOST`, `--compression max`), применяет политику хранения (`forget --prune`) и уведомляет. Он отказывается сообщать об успехе, если ни один сервис с меткой `restic.*` не создал нового снимка. Полный процесс описан в [Concept: Tiered storage](/apex/concepts/tiered-storage#backups). Запланируйте его в `configs/cron/crontab`, чтобы запуск и его результат в Telegram проходили без надзора.
Команда запускает [resticontainer](https://github.com/deytenit/resticontainer) в&nbsp;режиме one-shot под&nbsp;профилем compose `manual`. Процесс проходит следующие этапы:
1. Инициализирует репозиторий (если он ещё не&nbsp;создан).
2. Считывает compose-метки `restic.*` (`restic.enable`, `restic.backup.paths` и&nbsp;т.&nbsp;д.), чтобы определить, какие сервисы и&nbsp;пути нужно сохранять.
3. Создаёт единый снимок restic для&nbsp;всех выбранных путей хоста (`--host`&nbsp;= `APEX_NODE_HOST`, `--compression max`).
4. Применяет политику хранения (`forget --prune`) и&nbsp;отправляет уведомление.
Если ни&nbsp;один сервис с&nbsp;меткой `restic.*` не&nbsp;сформировал нового снимка, действие завершается с&nbsp;ошибкой. Полный процесс описан в&nbsp;разделе [Concept: Tiered storage](/apex/concepts/tiered-storage#backups). Чтобы резервное копирование и&nbsp;отправка отчётов в&nbsp;Telegram выполнялись автономно, добавьте команду в&nbsp;`configs/cron/crontab`.
## Синхронизация репозитория (capture-up) {#sync}
@@ -46,15 +53,17 @@ apex backup/run "$(cat <path-to-telegram-url-file>)"
apex sync/repository <telegram-bot-url>
```
Коммитит рабочую копию в `sync/<node>`, выполняет rebase на `origin/main` и force-push с lease; CI сворачивает ветку обратно в `main`. «capture-up» — механизм захвата состояния узла обратно в репозиторий. Действие отказывается выполняться — с постраничной ошибкой — когда сабмодуль commons «грязный» или дерево `compositions/` отсутствует, и аккуратно прерывается при конфликте rebase (требуется ручное вмешательство). См. [Concept: Framework architecture](/apex/concepts/architecture#capture-up) для описания модели.
Действие фиксирует изменения рабочей копии в&nbsp;ветке `sync/<node>`, выполняет rebase на&nbsp;`origin/main` и&nbsp;отправляет ветку через force-push с&nbsp;lease. Затем CI автоматически вливает изменения обратно в&nbsp;`main`. Этот механизм (capture-up) сохраняет актуальное состояние узла в&nbsp;репозиторий.
## Осведомлённость об обновлениях {#updates}
Действие прерывается с&nbsp;подробным сообщением, если подмодуль commons содержит незакоммиченные изменения или каталог `compositions/` отсутствует. При&nbsp;конфликтах rebase выполнение останавливается для&nbsp;ручного вмешательства. Подробнее архитектурная модель описана в&nbsp;разделе [Concept: Framework architecture](/apex/concepts/architecture#capture-up).
## Мониторинг обновлений {#updates}
```bash
apex sync/packages <telegram-bot-url>
```
Сообщает об ожидающих apt-обновлениях и устаревших образах контейнеров (локальные дайджесты сравниваются с реестром через `skopeo`) в едином дайджесте Telegram. Он ничего не изменяет на хосте — это плановая проверка осведомлённости.
Формирует единую сводку в&nbsp;Telegram о&nbsp;доступных обновлениях пакетов apt и&nbsp;устаревших образах контейнеров (локальные дайджесты сравниваются с&nbsp;реестром через `skopeo`). Действие ничего не&nbsp;меняет на&nbsp;хосте&nbsp;— это исключительно плановая проверка.
## Извлечение сертификатов {#certs}
@@ -62,11 +71,11 @@ apex sync/packages <telegram-bot-url>
apex utils/extract-traefik-certs <acme.json> <dest-dir> [telegram-bot-url]
```
Выгружает хранилище ACME граничного прокси в пары `fullchain.pem`/`privkey.pem` по каждому домену (через контейнеризованный dumper), ограничивает права на ключи до `600` и переназначает владельца вывода вызывающему пользователю — для сервисов, которым нужен «сырой» PEM-материал вместо TLS, терминируемого прокси.
Экспортирует сертификаты из&nbsp;ACME-хранилища граничного прокси в&nbsp;пары `fullchain.pem` и&nbsp;`privkey.pem` для&nbsp;каждого домена (с&nbsp;помощью утилиты в&nbsp;контейнере). Скрипт ограничивает права доступа к&nbsp;ключам до&nbsp;`600` и&nbsp;передаёт владение файлами запустившему пользователю. Это нужно сервисам, которым требуются исходные PEM-файлы вместо готового TLS-соединения от&nbsp;прокси.
---
**См. также:**
**См.&nbsp;также:**
- [Getting started with an apex node](/apex/getting-started)
- [Reference: Actions](/apex/reference/actions)
@@ -4,11 +4,11 @@ description: Соберите четыре опоры, зафиксируйте
weight: 21
---
**Контекст:** каждый [узел](/apex/glossary#node) получает ровно один репозиторий. Выбирайте любое имя репозитория — [идентичность](/apex/glossary#node-identity) объявляется явно в `node.env`, поэтому имя репозитория — это соглашение, а не функциональный ввод. Это руководство собирает такой репозиторий с нуля.
**Контекст:** у&nbsp;каждого [узла](/apex/glossary#node)&nbsp;— ровно один репозиторий. Название репозитория может быть любым: [идентичность](/apex/glossary#node-identity) задаётся явно в&nbsp;файле `node.env`, поэтому имя репозитория служит лишь удобным обозначением. В&nbsp;этом руководстве мы пошагово соберём такой репозиторий с&nbsp;нуля.
{{% steps %}}
### Инициализируйте репозиторий и зафиксируйте commons
### Инициализируйте репозиторий и&nbsp;зафиксируйте commons
```bash
git init <node-repo> && cd <node-repo>
@@ -18,7 +18,7 @@ git add .gitmodules commons
mkdir -p proprietaries/actions proprietaries/lib configs compositions/apex .github/workflows
```
Gitlink фиксирует точную версию движка, которую запускает узел. Обновление в дальнейшем означает checkout более нового тега внутри `commons/` и коммит перемещённого указателя.
Ссылка gitlink фиксирует точную версию движка, на&nbsp;которой работает узел. Чтобы обновить движок в&nbsp;будущем, достаточно переключиться на&nbsp;новый тег внутри каталога `commons/` и&nbsp;закоммитить смещение указателя.
### Объявите идентичность
@@ -30,9 +30,14 @@ APEX_GIT_AUTHOR_NAME=apex [bot]
APEX_GIT_AUTHOR_EMAIL=apex@localhost
```
`node.env` объявляет идентичность узла явно — не предполагается никакой формы домена. `APEX_NODE_FQDN` — это реальный FQDN, используемый для маршрутизации traefik по `Host()` (fallback: имя хоста ОС, с предупреждением). `APEX_NODE_HOST` — это публичное имя хоста, используемое для `--host` резервного копирования и метки `instance` наблюдаемости/уведомлений (по умолчанию: `APEX_NODE_FQDN`); короткое `name` узла — это его первая метка. `APEX_SUBNET` — это /24 узла для сети `direct` и является **обязательным** — отсутствующее значение приводит к выходу с кодом `66` (см. [Concept: Fleet addressing scheme](/apex/concepts/addressing)). `APEX_GIT_AUTHOR_NAME`/`APEX_GIT_AUTHOR_EMAIL` — это идентичность бота capture-up (общие значения по умолчанию, если не заданы).
Файл `node.env` явно задаёт параметры идентификации узла (доменная зона не&nbsp;подразумевается заранее):
### Напишите базовую обёртку и её env-файлы
- `APEX_NODE_FQDN`&nbsp;— FQDN хоста для&nbsp;маршрутизации Traefik по&nbsp;правилу `Host()` (если не&nbsp;указан, движок выведет предупреждение и&nbsp;возьмёт имя хоста из&nbsp;ОС).
- `APEX_NODE_HOST`&nbsp;— публичный адрес хоста для&nbsp;параметра `--host` резервного копирования и&nbsp;метки `instance` в&nbsp;системе мониторинга и&nbsp;уведомлениях (по&nbsp;умолчанию совпадает с&nbsp;`APEX_NODE_FQDN`). Первая часть FQDN до&nbsp;точки используется как короткое имя (`name`) узла.
- `APEX_SUBNET`&nbsp;— выделенная подсеть /24 для&nbsp;внутренней сети `direct`. Параметр **обязателен**: без&nbsp;него запуск завершится с&nbsp;кодом&nbsp;66 (см. [Concept: Fleet addressing scheme](/apex/concepts/addressing)).
- `APEX_GIT_AUTHOR_NAME` и&nbsp;`APEX_GIT_AUTHOR_EMAIL`&nbsp;— имя и&nbsp;почта сервисного бота capture-up (если не&nbsp;заданы, применяются значения по&nbsp;умолчанию).
### Напишите базовую обёртку и&nbsp;файлы переменных окружения
```yaml {filename="compositions/apex/docker-compose.yml"}
name: apex
@@ -55,9 +60,11 @@ APEX_OBS_BASIC_AUTH_USER=<node-user>
# APEX_RESTIC_AWS_ENDPOINT= # напр., URL S3-совместимого эндпоинта
```
`apex.env` содержит только коммитируемые несекретные скаляры. `COMPOSE_PROFILES` выбирает, какие опциональные базовые сервисы запускает этот узел — `adguard`, `xray`, `alloy` (агент push-наблюдаемости) и `manual` (одноразовые резервные копии) — и может быть пустым. Задокументируйте некоммитируемые секретные ключи в `compositions/apex/SECRETS.md`, чтобы оператор на свежем хосте знал, что заполнить в `.env` (`APEX_RESTIC_PASSWORD`, ключи доступа S3, `APEX_TRAEFIK_CF_DNS_API_TOKEN`, `APEX_TRAEFIK_ACME_EMAIL` и `APEX_OBS_BASIC_AUTH_PASS`, когда наблюдаемость включена).
Файл `apex.env` хранит только публичные параметры конфигурации, которые безопасно коммитить в&nbsp;репозиторий. Переменная `COMPOSE_PROFILES` определяет набор опциональных базовых сервисов: `adguard`, `xray`, `alloy` (агент сбора метрик и&nbsp;логов) и&nbsp;`manual` (разовые запуски бэкапа); список может быть пустым.
### Игнорируйте то, чему никогда не место в git
Все секреты, которые нельзя хранить в&nbsp;Git, задокументируйте в&nbsp;`compositions/apex/SECRETS.md`. Это поможет при&nbsp;развёртывании нового узла заполнить локальный файл `.env`: пароль `APEX_RESTIC_PASSWORD`, ключи доступа к&nbsp;S3, токен `APEX_TRAEFIK_CF_DNS_API_TOKEN`, почту `APEX_TRAEFIK_ACME_EMAIL`, а&nbsp;также `APEX_OBS_BASIC_AUTH_PASS` (если включён мониторинг).
### Настройте правила игнорирования для&nbsp;Git
```gitignore {filename=".gitignore"}
**/@tier1
@@ -71,34 +78,34 @@ __pycache__/
### Добавьте bootstrap-скрипт
`init.sh` подготавливает свежий клон на хосте: `git submodule update --init --recursive`, `git config core.hooksPath commons/githooks`, `git config --global --add safe.directory <repo>` и добавляет `<repo>/commons` в начало `PATH` в login-shell, чтобы `apex` разрешался. Пометьте его исполняемым.
Скрипт `init.sh` подготавливает свежий клон репозитория на&nbsp;хосте: выполняет `git submodule update --init --recursive`, настраивает хуки `git config core.hooksPath commons/githooks`, добавляет репозиторий в&nbsp;доверенные (`git config --global --add safe.directory <repo>`) и&nbsp;прописывает `<repo>/commons` в&nbsp;переменную `PATH` командной оболочки, чтобы утилита `apex` была доступна глобально. Не&nbsp;забудьте выставить флаг исполняемого файла: `chmod +x init.sh`.
### Заведите реестр адресации
Создайте `compositions/ADDRESSING.md` с подсетью узла (`APEX_SUBNET`) и зарезервированными диапазонами (`.10`–`.19` — core, `.20`+ — сервисы). Каждый [якорный IP](/apex/glossary#anchor-ip), выделяемый позднее, записывается здесь.
Создайте файл `compositions/ADDRESSING.md` с&nbsp;подсетью узла (`APEX_SUBNET`) и&nbsp;схемой распределения адресов: диапазоны `.10`–`.19` зарезервированы за&nbsp;базовыми сервисами (core), а&nbsp;`.20` и&nbsp;выше выделяются под&nbsp;проекты. Все назначаемые в&nbsp;будущем [якорные IP](/apex/glossary#anchor-ip) фиксируются в&nbsp;этом документе.
### Подключите CI capture-up
Добавьте плановый workflow, который сливает `sync/<node>` в `main`, когда ветка существует, коммитя под идентичностью бота. Выполняйте checkout **без** сабмодулей — слияние лишь перемещает указатель commons, а submodule-URL по SSH не извлекается с CI-токеном по умолчанию. См. [Concept: Framework architecture](/apex/concepts/architecture) для полного цикла [capture-up](/apex/glossary#capture-up).
Настройте периодический CI-workflow, который автоматически вливает ветку `sync/<node>` в&nbsp;`main` (если ветка существует) с&nbsp;авторством бота. Шаг checkout должен выполняться **без** загрузки подмодулей: слияние только сдвигает указатель `commons`, а&nbsp;SSH-ссылка на&nbsp;подмодуль недоступна по&nbsp;стандартному CI-токену. Полный цикл описан в&nbsp;разделе [Concept: Framework architecture](/apex/concepts/architecture) ([capture-up](/apex/glossary#capture-up)).
### Проверьте
### Проверьте работу
```bash
./commons/apex --help
```
В списке должно отображаться каждое действие из commons (плюс ваши proprietaries, после их добавления). Вне хоста — до заполнения `node.env` — ожидайте безобидное предупреждение о том, что `APEX_NODE_FQDN` не задан (движок откатывается к имени хоста ОС); `APEX_SUBNET` при этом всё равно должен быть задан, иначе движок завершится с кодом `66`. Закоммитьте собранный репозиторий в `main`.
В&nbsp;списке должны появиться все базовые действия из&nbsp;commons, а&nbsp;позже&nbsp;— и&nbsp;локальные действия из&nbsp;proprietaries. При&nbsp;запуске вне хоста (до&nbsp;заполнения `node.env`) появится предупреждение о&nbsp;том, что `APEX_NODE_FQDN` не&nbsp;задан: в&nbsp;этом случае движок подставит системное имя хоста. При&nbsp;этом переменная `APEX_SUBNET` обязательно должна быть указана, иначе выполнение завершится с&nbsp;кодом&nbsp;66. Закоммитьте подготовленный репозиторий в&nbsp;ветку `main`.
{{% /steps %}}
## Заметки
- Опора `proprietaries/` может начинаться пустой (сохраняйте её присутствие с помощью файлов `.gitkeep`); движок корректно обрабатывает отсутствующий оверлей.
- Переносите источники конфигурации хоста в `configs/` (`ufw/`, `cron/crontab`, `systemd/`, `crowdsec/`) по мере того как узел принимает каждый шаг `configure/*` — действия потребляют `configs/` дословно через слой шаблонов.
- Каталог `proprietaries/` изначально может быть пустым (сохраните структуру с&nbsp;помощью файлов `.gitkeep`): движок корректно работает без&nbsp;пользовательских переопределений.
- Переносите конфигурационные файлы хоста в&nbsp;`configs/` (`ufw/`, `cron/crontab`, `systemd/`, `crowdsec/`) по&nbsp;мере подключения шагов `configure/*`. Действия считывают шаблоны из&nbsp;`configs/` напрямую.
---
**См. также:**
**См.&nbsp;также:**
- [Getting started with an apex node](/apex/getting-started)
- [Concept: Framework architecture](/apex/concepts/architecture)
@@ -4,13 +4,13 @@ description: "Подключите новый проект к узлу: якор
weight: 23
---
**Контекст:** проект присоединяется к [узлу](/apex/glossary#node) как один каталог в `compositions/`, содержащий написанный вручную файл compose. Контракт фреймворка для этого файла невелик: статический [якорный IP](/apex/glossary#anchor-ip) в сети `direct`, ссылки на внешние сети по ключу и пути к данным относительно уровней.
**Контекст:** проект подключается к&nbsp;[узлу](/apex/glossary#node) в&nbsp;виде отдельного каталога в&nbsp;`compositions/` со&nbsp;своим compose-файлом. Требования фреймворка к&nbsp;этому файлу минимальны: статический [якорный IP](/apex/glossary#anchor-ip) в&nbsp;сети `direct`, подключение к&nbsp;внешним сетям по&nbsp;имени и&nbsp;пути к&nbsp;данным относительно уровней хранения.
{{% steps %}}
### Выделите якорный адрес
Выберите следующий свободный адрес `.20` или выше в /24 узла и запишите его в `compositions/ADDRESSING.md`. Адреса `.10`–`.19` зарезервированы для [базовой композиции](/apex/glossary#core-composition) (см. [Concept: Fleet addressing scheme](/apex/concepts/addressing)).
Выберите следующий свободный адрес `.20` или выше в&nbsp;подсети узла и&nbsp;зафиксируйте его в&nbsp;`compositions/ADDRESSING.md`. Диапазон `.10`–`.19` зарезервирован для&nbsp;[базовой композиции](/apex/glossary#core-composition) (см. [Concept: Fleet addressing scheme](/apex/concepts/addressing)).
### Напишите файл compose
@@ -52,14 +52,14 @@ networks:
Применяемые соглашения:
- **Один якорь на проект** держит статический `ipv4_address` в `direct`; sidecar-контейнеры разделяют его сетевое пространство имён через `network_mode: service:<anchor>`.
- **На сети ссылаются по ключу** (`direct`, `enclave`, `socket`) с `external: true` — их определения принадлежат базовой композиции.
- **Членство в `enclave` плюс метки traefik** публикуют сервис через граничный прокси; прокси обнаруживает его через socket-прокси, никогда через опубликованные порты.
- **Пути к данным относительны уровням** (`./@tierN/...`) и разрешаются через симлинки, создаваемые `tiers/link`.
- **Резервное копирование включается по выбору через метки `restic.*`.** `apex backup/run` управляет [resticontainer](https://github.com/deytenit/resticontainer), который определяет, что снимать, из меток compose: `restic.enable=true` помечает сервис, `restic.backup.paths=<container-path>` называет монтирования для резервного копирования (resticontainer разрешает их в пути хоста). Сервис без меток `restic.*` просто не резервируется. Резервируйте конфигурацию только там, где данные восстановимы.
- Тройка `${APEX_UID...}` берётся из `.env` проекта, поддерживаемого `tiers/useradd`.
- **Один якорь на&nbsp;проект**: контейнер-якорь получает постоянный `ipv4_address` в&nbsp;сети `direct`, а&nbsp;вспомогательные sidecar-контейнеры используют его сетевой стек через `network_mode: service:<anchor>`.
- **Внешние сети подключаются по&nbsp;имени**: для&nbsp;сетей `direct`, `enclave` и&nbsp;`socket` указывается `external: true`, поскольку они объявляются в&nbsp;базовой композиции.
- **Публикация через `enclave` и&nbsp;метки Traefik**: подключение к&nbsp;сети `enclave` вместе с&nbsp;метками маршрутизации открывает сервис через граничный прокси. Прокси связывается с&nbsp;ним через socket-прокси, без&nbsp;проброса портов наружу.
- **Пути к&nbsp;данным привязаны к&nbsp;уровням**: пути вида `./@tierN/...` разрешаются через символические ссылки, которые создаёт действие `tiers/link`.
- **Резервное копирование настраивается метками `restic.*`**: действие `apex backup/run` запускает утилиту [resticontainer](https://github.com/deytenit/resticontainer). Сервис помечается меткой `restic.enable=true`, а&nbsp;пути монтирования для&nbsp;бэкапа перечисляются в&nbsp;`restic.backup.paths=<container-path>` (resticontainer сопоставляет их с&nbsp;путями на&nbsp;хосте). Сервисы без&nbsp;этих меток игнорируются. Сохраняйте только восстановимые данные и&nbsp;конфигурацию.
- **Идентификаторы UID и&nbsp;GID**: переменные `${APEX_UID...}` берутся из&nbsp;файла `.env` проекта, который формирует действие `tiers/useradd`.
### Обеспечьте хранилище и идентичность
### Настройте хранилище и&nbsp;права доступа
```bash
apex tiers/link <tier1> <tier2> <tier3>
@@ -67,34 +67,34 @@ apex tiers/useradd
apex tiers/chown
```
Повторный запуск тройки идемпотентен; он подхватывает новый проект, создаёт его каталоги уровней и `.env`, обеспечивает его пользователя `noroot-<project>` и применяет матрицу владения.
Повторный запуск этих команд идемпотентен: движок находит новый проект, создаёт для&nbsp;него каталоги хранения и&nbsp;файл `.env`, заводит системного пользователя `noroot-<project>` и&nbsp;выставляет права доступа.
### Линтинг
### Проверьте конфигурацию
```bash
apex utils/lint-docker-compose
```
Проверяет каждый якорный IP (корректность формы, уникальность, нахождение внутри подсети узла) и запускает линтер compose по репозиторию. Pre-commit-хук обеспечивает тот же линтинг для staged-файлов compose.
Действие проверяет корректность, уникальность и&nbsp;принадлежность подсети для&nbsp;каждого якорного IP, после чего запускает проверку compose-файлов по&nbsp;всему репозиторию. Тот&nbsp;же линтинг автоматически выполняется хуком pre-commit для&nbsp;всех подготовленных к&nbsp;коммиту файлов.
### Разверните
### Разверните сервисы
```bash
apex compose up
```
Базовая композиция уже поднята; новый проект стартует в своём алфавитном слоте. Проверьте с помощью `docker ps` и, если сервис проксируется, через его маршрут на границе.
Базовая композиция уже работает, поэтому новый проект запустится в&nbsp;порядке своей очереди по&nbsp;алфавиту. Проверьте статус контейнеров через `docker ps`, а&nbsp;доступность маршрута&nbsp;— через граничный прокси.
{{% /steps %}}
## Заметки
- Внутренние секреты сервиса (например, пароль базы данных) размещаются в собственном `.env` проекта в хранилище уровня 1, рядом с автоматически внедряемым блоком UID.
- Никогда не определяйте `direct`/`enclave`/`socket` внутри файла проекта — второе определение будет конкурировать с базовой композицией за владение сетью.
- Секретные данные проекта (например, пароль к&nbsp;базе данных) хранятся в&nbsp;собственном `.env` на&nbsp;уровне 1&nbsp;(@tier1), рядом с&nbsp;автоматически сгенерированным блоком UID.
- Не&nbsp;объявляйте сети `direct`, `enclave` и&nbsp;`socket` заново внутри файла проекта: дублирование определений приведёт к&nbsp;конфликту с&nbsp;базовой композицией за&nbsp;управление сетью.
---
**См. также:**
**См.&nbsp;также:**
- [Concept: Compositions and the shared core](/apex/concepts/compositions)
- [Concept: Tiered storage and privilege isolation](/apex/concepts/tiered-storage)
@@ -1,20 +1,20 @@
---
title: Справочник
description: CLI, действия, переменные окружения и API движка.
description: CLI, действия, переменные окружения и API движка.
weight: 30
---
Технический справочник по поверхности фреймворка операций над флотом apex: интерфейс командной строки, встроенные [действия](/apex/glossary#action), контракт окружения, который они потребляют, и API [движка](/apex/glossary#engine) на Python.
Технический справочник по фреймворку управления флотом apex: интерфейс командной строки, встроенные [действия](/apex/glossary#action), контракт используемого окружения и API [движка](/apex/glossary#engine) на Python.
{{% cards %}}
{{< card link="cli" title="Справочник CLI" icon="terminal" subtitle="Вызов из командной строки, подкоманды и коды возврата." >}}
{{< card link="actions" title="Справочник действий" icon="play" subtitle="Встроенные действия и их аргументы." >}}
{{< card link="environment" title="Справочник окружения" icon="variable" subtitle="Переменные окружения, доступные действиям и композициям." >}}
{{< card link="cli" title="Справочник CLI" icon="terminal" subtitle="Вызов из командной строки, подкоманды и коды возврата." >}}
{{< card link="actions" title="Справочник действий" icon="play" subtitle="Встроенные действия и их аргументы." >}}
{{< card link="environment" title="Справочник окружения" icon="variable" subtitle="Переменные окружения, доступные действиям и композициям." >}}
{{< card link="engine-api" title="Справочник API движка" icon="code" subtitle="Модули Python, обеспечивающие работу движка." >}}
{{% /cards %}}
---
**См. также:**
**См. также:**
- [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com)
@@ -1,10 +1,10 @@
---
title: Справочник действий
description: "Каждое действие commons: аргументы и поведение."
description: "Каждое действие commons: аргументы и поведение."
weight: 32
---
Шестнадцать действий [«commons»](/apex/glossary#commons) (общего репозитория), по группам. Аргументы показаны в том виде, в котором их принимает парсер; необязательные аргументы заключены в квадратные скобки. Коды возврата следуют [соглашению движка](/apex/reference/cli#exit-codes).
Шестнадцать действий [«commons»](/apex/glossary#commons) (общего репозитория), разбитых по группам. Аргументы показаны в том виде, в котором их принимает парсер; необязательные аргументы заключены в квадратные скобки. Коды возврата следуют [соглашению движка](/apex/reference/cli#exit-codes).
## configure {#configure}
@@ -12,43 +12,43 @@ weight: 32
`apex configure [--dry-run]`
Запускает весь набор в фиксированном порядке — `base`, `ufw`, `crowdsec`, `cron`, `systemd`, `routing` — и останавливается на первом неудачном шаге, пробрасывая его код возврата. `--dry-run` передаётся тем шагам, которые его поддерживают.
Запускает весь набор в фиксированном порядке (`base`, `ufw`, `crowdsec`, `cron`, `systemd`, `routing`) и останавливается на первом неудачном шаге, возвращая его код возврата. Флаг `--dry-run` передаётся шагам, которые его поддерживают.
### configure/base
`apex configure/base`
Проверка только для Debian (иначе выход с кодом `1`). Обновляет индекс пакетов, устанавливает необходимые пакеты (`curl`, `wget`, `git`, `rsyslog`), гарантирует, что rsyslog запущен, устанавливает `ufw`, если он отсутствует, и загружает вспомогательную утилиту `ufw-docker` в `~/.local/bin/ufw-docker` (пропускается, если она уже присутствует).
Проверяет, что операционная система — Debian (иначе завершается с кодом `1`). Обновляет индекс пакетов, устанавливает необходимые пакеты (`curl`, `wget`, `git`, `rsyslog`), проверяет статус службы `rsyslog`, устанавливает `ufw`, если его нет, и загружает вспомогательную утилиту `ufw-docker` в `~/.local/bin/ufw-docker` (если утилита уже есть, загрузка пропускается).
### configure/ufw
`apex configure/ufw [--dry-run]`
Применяет межсетевой экран узла из `configs/ufw/`: `host/host.rules` через обычный `ufw`, `host/before.rules`/`after.rules` как самовосстанавливающиеся управляемые блоки внутри `/etc/ufw/`, а `docker/*.rules` — через `ufw-docker`. Выходит с кодом `1`, когда отсутствует корень конфигурации, `66` — когда отсутствует ровно один из каталогов `docker/`/`host/`, `1` — когда целевой файл в `/etc/ufw` не существует. Требует предварительного выполнения `configure/base` (проверяет `ufw` и `ufw-docker`). Завершается напоминанием о необходимости перезапуска.
Применяет правила межсетевого экрана узла из `configs/ufw/`: `host/host.rules` через штатный `ufw`, `host/before.rules` и `after.rules` как самовосстанавливающиеся блоки внутри `/etc/ufw/`, а `docker/*.rules` — через `ufw-docker`. Завершается с кодом `1`, если отсутствует корень конфигурации, с кодом `66` — если отсутствует ровно один из каталогов `docker/` или `host/`, и с кодом `1` — если целевой файл в `/etc/ufw` не существует. Требует предварительного выполнения `configure/base` (проверяет наличие `ufw` и `ufw-docker`). В конце выводит напоминание о необходимости перезагрузки правил.
### configure/crowdsec
`apex configure/crowdsec [--dry-run]`
Устанавливает CrowdSec через официальный скрипт, если `cscli` отсутствует, рендерит `configs/crowdsec/` в `/etc/crowdsec/` (создавая резервные копии перезаписываемых файлов), устанавливает коллекции `crowdsecurity/linux` и `crowdsecurity/traefik`, а также устанавливает firewall-баунсер iptables, когда существует `configs/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml.local`, а пакет отсутствует. `--dry-run` рендерит без записи и пропускает шаги установки.
Устанавливает CrowdSec через официальный скрипт, если `cscli` отсутствует. Рендерит шаблоны из `configs/crowdsec/` в `/etc/crowdsec/` (создавая резервные копии перезаписываемых файлов), устанавливает коллекции `crowdsecurity/linux` и `crowdsecurity/traefik`, а также устанавливает firewall-баунсер iptables, если файл `configs/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml.local` существует, а пакет ещё не установлен. Флаг `--dry-run` выполняет рендеринг без записи на диск и пропускает установку.
### configure/cron
`apex configure/cron [--dry-run]`
Рендерит `configs/cron/crontab` с помощью `ctx.vars()` и устанавливает его через `crontab -`. Отсутствующий исходный файл: выход `1`. Пустой результат рендеринга: выход `65`, предыдущий crontab сохраняется. Неудачная установка: предыдущий crontab восстанавливается, выход `1`. Результат рендеринга всегда выводится для проверки.
Рендерит `configs/cron/crontab` с помощью `ctx.vars()` и устанавливает полученное расписание через `crontab -`. Если исходный файл отсутствует, действие завершается с кодом `1`. Если результат рендеринга пуст, возвращается код `65`, а предыдущий crontab сохраняется. При сбое установки восстанавливается прежний crontab и возвращается код `1`. Результат рендеринга всегда выводится для проверки.
### configure/systemd
`apex configure/systemd [--dry-run]`
Рендерит каждый файл верхнего уровня (не скрытый) из `configs/systemd/` в `/etc/systemd/system/<name>`; для каждого юнита `.service` выполняет daemon-reload и включает его с `--now`. Отсутствующий каталог конфигурации: выход `1`.
Рендерит каждый открытый файл (кроме скрытых) верхнего уровня из `configs/systemd/` в `/etc/systemd/system/<name>`. Для каждого сервиса `.service` выполняет daemon-reload и активирует его командой с флагом `--now`. Если каталог конфигурации отсутствует, завершается с кодом `1`.
### configure/routing
`apex configure/routing`
Включает `net.ipv4.ip_forward`, устанавливает правило политики fwmark `0x1` → routing-table-100 и локальный маршрут по умолчанию в этой таблице. Обе проверки идемпотентны — повторные запуски ничего не меняют.
Включает `net.ipv4.ip_forward`, настраивает правило политики fwmark `0x1` → routing-table-100 и локальный маршрут по умолчанию в этой таблице. Обе операции идемпотентны: повторные запуски ничего не меняют.
## sync {#sync}
@@ -56,13 +56,13 @@ weight: 32
`apex sync/repository <telegram_bot_url>`
Коммит [«capture-up»](/apex/glossary#capture-up) (захват изменений): переключается на (или создаёт) `sync/<node>`, добавляет всё дерево плюс обновлённый указатель на подмодуль commons, коммитит под идентичностью бота, если что-либо изменилось, выполняет rebase на `origin/main`, обновляет подмодули и пушит с `--force-with-lease`. Прерывается (выход `1`), когда подмодуль commons содержит незакоммиченные изменения или отсутствует `compositions/`; при конфликте rebase прерывает rebase и оповещает через Telegram. Успех и каждый путь отказа сопровождаются уведомлением; неудачная отправка уведомления об успехе также приводит к выходу с кодом `1`.
Коммит [«capture-up»](/apex/glossary#capture-up) (фиксация локальных изменений): переключается на ветку `sync/<node>` (или создаёт её), индексирует всё дерево вместе с обновлённым указателем на подмодуль commons, создаёт коммит от имени бота (если есть изменения), выполняет rebase на `origin/main`, обновляет подмодули и отправляет изменения командой с флагом `--force-with-lease`. Прерывается с кодом `1`, если подмодуль commons содержит незакоммиченные правки или отсутствует каталог `compositions/`. При конфликте во время rebase операция отменяется и отправляется оповещение в Telegram. Об успехе и каждом сбое отправляется уведомление; сбой при отправке уведомления об успехе также завершает команду с кодом `1`.
### sync/packages
`apex sync/packages <telegram_bot_url>`
Только для Debian. Собирает ожидающие обновления apt (`--just-print`) и устаревшие образы работающих контейнеров (локальный RepoDigest против дайджеста реестра через `skopeo`), затем отправляет одну сводку в Telegram. Только для чтения в отношении пакетов и образов. Необработанные сбои оповещают через Telegram и приводят к выходу `1`; так же ведёт себя и неудачная доставка сводки.
Только для Debian. Собирает список доступных обновлений apt (`--just-print`) и устаревших образов запущенных контейнеров (сравнивая локальный RepoDigest с дайджестом в реестре через `skopeo`), после чего отправляет единую сводку в Telegram. Работает исключительно в режиме чтения: пакеты и образы не изменяются. При необработанных сбоях отправляет оповещение в Telegram и завершается с кодом `1`; так же действие ведёт себя и при ошибке доставки сводки.
## backup {#backup}
@@ -70,7 +70,7 @@ weight: 32
`apex backup/run <telegram_bot_url>`
Управляет однократным запуском основного сервиса `resticontainer`, управляемого метками (профиль `manual`): проверка доступности репозитория с инициализацией при необходимости, затем один `backup`, в котором resticontainer обнаруживает каждый сервис, несущий метки compose `restic.*`, запускает их pre-хуки, останавливает все контейнеры, помеченные `restic.backup.stop`, разрешает помеченные монтирования контейнеров в пути на хосте и делает снимок объединения за один запуск restic. Снимок помечается `--host <APEX_NODE_HOST>`, `--tag biweekly`, `--compression max`. Затем защита подтверждает, что свежий снимок для этого хоста действительно создан — если метки `restic.*` не были обнаружены, резервное копирование является тихой пустой операцией, поэтому оно отказывается сообщать об успехе. Далее следует политика удержания через `forget --prune` (7 daily / 4 weekly / 12 monthly). Каждый сбой restic оповещает и приводит к выходу `1`; успех тоже оповещает, а неудачное уведомление об успехе приводит к выходу `1`.
Управляет однократным запуском основного сервиса `resticontainer`, работающего по меткам (профиль `manual`). Проверяет доступность репозитория (при необходимости выполняет инициализацию), затем запускает процедуру `backup`. При этом resticontainer находит каждый сервис с метками compose вида `restic.*`, вызывает их предварительные хуки, останавливает контейнеры с меткой `restic.backup.stop`, сопоставляет пути монтирования контейнеров с путями на хосте и создаёт единый снимок за один вызов restic. Снимок помечается флагами `--host <APEX_NODE_HOST>`, `--tag biweekly`, `--compression max`. Затем защитная проверка удостоверяется, что свежий снимок для этого хоста действительно создан: если метки `restic.*` не были найдены, резервное копирование завершилось бы вхолостую, поэтому действие откажется сообщать об успехе. Следом применяется политика хранения `forget --prune` (7 daily / 4 weekly / 12 monthly). При любом сбое restic отправляет оповещение и завершается с кодом `1`. При успешном завершении также отправляется оповещение, а ошибка его доставки приводит к выходу с кодом `1`.
## tiers {#tiers}
@@ -78,19 +78,19 @@ weight: 32
`apex tiers/link <tier1_path> <tier2_path> <tier3_path>`
Строит всю структуру символьных ссылок [уровней](/apex/glossary#tier): ссылки уровня узла `compositions/@tierN`, каталоги уровней для каждого проекта, области `shared/` с обратными ссылками, ссылки `@tierN` для каждого проекта и символьную ссылку `.env` в уровень 1 (цель создаётся, если отсутствует). Существующие пути, не являющиеся символьными ссылками, запрашивают подтверждение; отказ пропускает создание этой ссылки.
Создаёт полную структуру символьных ссылок для [уровней хранения](/apex/glossary#tier): ссылки уровня узла `compositions/@tierN`, каталоги уровней для каждого проекта, общие области `shared/` с обратными ссылками, проектные ссылки `@tierN` и символьную ссылку `.env` на первый уровень (если файл назначения отсутствует, он создаётся). Если существующий путь не является символьной ссылкой, действие запрашивает подтверждение; отказ пропускает создание ссылки.
### tiers/useradd
`apex tiers/useradd`
Гарантирует наличие группы `noroot-shared`, затем создаёт по одному заблокированному системному пользователю `noroot-<project>` на каждый проект композиции (собственная основная группа, без домашнего каталога, оболочка `nologin`, член общей группы) и внедряет `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` в `.env` каждого проекта между управляемыми маркерами.
Проверяет наличие группы `noroot-shared`, затем создаёт по одному заблокированному системному пользователю `noroot-<project>` для каждого проекта композиций (собственная основная группа, без домашнего каталога, оболочка `nologin`, участник общей группы) и прописывает переменные `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` в `.env` каждого проекта между служебными маркерами.
### tiers/chown
`apex tiers/chown [tier1_path] [tier2_path] [tier3_path]`
Применяет матрицу владения (см. [Концепция: Многоуровневое хранилище](/apex/concepts/tiered-storage#ownership)) по корням уровней — по умолчанию используются ссылки уровней уровня узла. Требует наличия `noroot-shared` (иначе выход `1`); проекты с неполной идентичностью в `.env` пропускаются с предупреждением.
Применяет матрицу прав владения (см. [Концепция: Многоуровневое хранилище](/apex/concepts/tiered-storage#ownership)) к корням уровней — по умолчанию используются ссылки уровней узла. Требует наличия группы `noroot-shared` (иначе завершается с кодом `1`); проекты с неполными идентификаторами в `.env` пропускаются с предупреждением.
## utils {#utils}
@@ -98,13 +98,13 @@ weight: 32
`apex utils/extract-traefik-certs <source_acme> <dest_dir> [telegram_bot_url]`
Извлекает хранилище ACME в `fullchain.pem`/`privkey.pem` для каждого домена с помощью контейнеризованного дампера, затем выполняет `chmod 600` для ключей и переназначает владение выходным деревом вызывающему пользователю. Отсутствующий источник: выход `1`. Сбои внутри извлечения оповещают через Telegram, если был указан URL.
Извлекает сертификаты из хранилища ACME в файлы `fullchain.pem`/`privkey.pem` для каждого домена с помощью контейнера-дампера, выставляет права `chmod 600` на ключи и передаёт права владения выходным каталогом вызывающему пользователю. Если исходный файл отсутствует, действие завершается с кодом `1`. При ошибках извлечения отправляет оповещение в Telegram (если указан URL).
### utils/lint-docker-compose
`apex utils/lint-docker-compose [--hook]`
Два прохода: (1) проверка [якорных IP](/apex/glossary#anchor-ip) по `compositions/*/docker-compose.yml` — корректность формата, уникальность, нахождение внутри подсети узла; (2) линтер compose `dclint` с `--fix`, запускаемый в контейнере по репозиторию (ссылки уровней, `.env` и подмодуль commons исключены). `--hook` ограничивает проход 2 файлами compose в индексе и повторно добавляет в индекс то, что исправил линтер. Код возврата отражает оба прохода.
Выполняется в два этапа: (1) проверка [якорных IP](/apex/glossary#anchor-ip) в `compositions/*/docker-compose.yml` на корректность формата, уникальность и принадлежность подсети узла; (2) запуск compose-линтера `dclint` с флагом `--fix` в контейнере по всему репозиторию (за исключением ссылок уровней, `.env` и подмодуля commons). Флаг `--hook` ограничивает второй этап файлами compose в индексе git и повторно добавляет в индекс исправленные файлы. Код возврата отражает результаты обоих этапов.
## compose {#compose}
@@ -112,11 +112,11 @@ weight: 32
`apex compose <action> [--dry-run] [extra...]`
Оркеструет `docker compose <action>` по всему узлу: сначала основной проект, затем сервисы в алфавитном порядке (обратном для `down`). `up` подразумевает `-d`; `.env`/`apex.env` передаются для каждого проекта при наличии; дополнительные аргументы пробрасываются; `--dry-run` (принимается в любой позиции) выводит команды вместо их выполнения.
Оркеструет запуск `docker compose <action>` по всему узлу: сначала основной проект, затем сервисы в алфавитном порядке (в обратном — для команды `down`). Команда `up` по умолчанию использует ключ `-d`; файлы `.env` и `apex.env` подключаются для каждого проекта при их наличии; дополнительные аргументы пробрасываются дальше; флаг `--dry-run` (принимается в любой позиции) выводит сформированные команды без их выполнения.
---
**См. также:**
**См. также:**
- [Справочник: CLI](/apex/reference/cli)
- [Руководство: Эксплуатация узла](/apex/guides/host-operations)
+19 -19
View File
@@ -1,56 +1,56 @@
---
title: Справочник CLI
description: "Лаунчер apex: перечисление, диспетчеризация, справка и коды возврата."
description: "Лаунчер apex: перечисление, диспетчеризация, справка и коды возврата."
weight: 31
---
Лаунчер — это единый скрипт на Python из стандартной библиотеки, `commons/apex` (в `PATH` после `init.sh`). Всё, что он делает, нацелено на локальный checkout — аргумента узла не существует.
Лаунчер — это скрипт на Python из стандартной библиотеки, `commons/apex` (попадает в `PATH` после выполнения `init.sh`). Все операции выполняются над локальным checkout: аргумента узла не существует.
## Формы вызова {#invocation}
| Команда | Поведение |
|---|---|
| `apex` или `apex --help` | Перечислить каждое доступное [действие](/apex/glossary#action) с его кратким описанием; выход `0`. |
| `apex` или `apex --help` | Перечислить каждое доступное [действие](/apex/glossary#action) с его кратким описанием; выход с кодом `0`. |
| `apex <group>/<action> [args...]` | Выполнить одно действие. |
| `apex <group>/<action> --help` | Вывести справку argparse для действия, сгенерированную из его `METADATA`; выход `0`. |
| `apex <group>/<action> --help` | Вывести справку argparse для действия, сгенерированную из `METADATA`; выход с кодом `0`. |
Перечисление помечает каждую запись источником [оверлея](/apex/glossary#overlay) — без метки (commons), `(local)`, `(local override)` — и добавляет `[DISABLED: reason]`, когда модуль объявляет `DISABLED`. Перечисление использует статическое сканирование `ast`, поэтому код действий не выполняется.
Перечисление помечает каждую запись источником [оверлея](/apex/glossary#overlay) — без метки (commons), `(local)`, `(local override)` — и добавляет `[DISABLED: reason]`, если модуль объявляет `DISABLED`. Перечисление использует статическое сканирование через `ast`, поэтому код самих действий не выполняется.
## Раскладка и разрешение путей {#layout}
## Раскладка и разрешение путей {#layout}
При запуске лаунчер определяет, в какой из двух раскладок он находится:
При запуске лаунчер определяет, в какой из двух раскладок он находится:
- **Checkout узла** — лаунчер находится в каталоге с именем `commons/`, родитель которого содержит `node.env`. Родитель является корнем репозитория; `proprietaries/actions/` — это дерево оверлея; [идентичность узла](/apex/glossary#node-identity) разрешается перед диспетчеризацией, и любые предупреждения об идентичности логируются первыми.
- **Автономный commons** — любое другое расположение. Каталог commons является собственным корнем, оверлея нет, а `ctx.node` равен `None` (строки лога несут префикс `global`).
- **Checkout узла** — лаунчер находится в каталоге `commons/`, родительский каталог которого содержит `node.env`. Родительский каталог служит корнем репозитория; `proprietaries/actions/` — дерево оверлея; [идентичность узла](/apex/glossary#node-identity) разрешается перед диспетчеризацией, а любые предупреждения об идентичности выводятся первыми в логе.
- **Автономный commons** — любое другое расположение. Каталог commons служит собственным корнем, оверлей отсутствует, а `ctx.node` равен `None` (строки лога содержат префикс `global`).
`sys.path` собирается так: каталог commons (делает `engine` импортируемым), затем `commons/vendor/` при наличии, затем `../proprietaries/` в checkout узла (делает проприетарный код `lib/` импортируемым).
`sys.path` формируется следующим образом: каталог commons (позволяет импортировать `engine`), затем `commons/vendor/` при наличии и `../proprietaries/` в checkout узла (позволяет импортировать проприетарный код из `lib/`).
## Коды возврата {#exit-codes}
| Код | Источник |
|---|---|
| `0` | Действие вернуло `0` или нецелое значение; пути `--help`. |
| `1` | Неизвестное действие; отключённое действие; необработанное исключение (логируется как `Type: message` с префиксом контекста); нецелый `SystemExit`. |
| `0` | Действие вернуло `0` или нецелое значение; вызовы `--help`. |
| `1` | Неизвестное действие; отключённое действие; необработанное исключение (выводится в лог как `Type: message` с префиксом контекста); нецелый `SystemExit`. |
| `64` | Ошибка использования при разборе аргументов (`EX_USAGE`). |
| `65` | Ошибка данных, вызванная библиотеками движка (`EX_DATAERR`) — например, пустой отрендеренный crontab. |
| `66` | Отсутствующий ввод (`EX_NOINPUT`) — например, отсутствующие поля идентичности или отсутствующий каталог конфигурации ufw. |
| `65` | Ошибка данных, вызванная библиотеками движка (`EX_DATAERR`) — например, пустой результат рендеринга crontab. |
| `66` | Неполный или отсутствующий ввод (`EX_NOINPUT`) — например, не заданы поля идентичности или отсутствует каталог конфигурации ufw. |
Любой другой код — это то, что вернуло или вызвало само действие.
Любой другой код — это значение, которое вернуло или вызвало само действие.
## Формат лога {#log-format}
Весь вывод движка и действий следует одной форме строки:
Весь вывод движка и действий оформляется по единому шаблону:
```text
[<node>] [<group>/<action>] [LEVEL] message
```
`LEVEL` — это `INFO`, `WARN`, `ERROR` или `SUCCESS`; строки `ERROR` идут в stderr, всё остальное — в stdout — удобно для cron как для захвата, так и для оповещения.
`LEVEL` — это `INFO`, `WARN`, `ERROR` или `SUCCESS`. Строки `ERROR` выводятся в stderr, а остальные — в stdout. Такой формат удобен для cron: упрощает сбор логов и отправку оповещений.
---
**См. также:**
**См. также:**
- [Концепция: Действия и оверлей](/apex/concepts/actions-and-overlay)
- [Концепция: Действия и оверлей](/apex/concepts/actions-and-overlay)
- [Справочник: Действия](/apex/reference/actions)
- [Справочник: API движка](/apex/reference/engine-api)
@@ -1,10 +1,10 @@
---
title: Справочник API движка
description: API дескрипторов и ctx, доступные авторам действий.
description: API дескрипторов и ctx, доступные авторам действий.
weight: 34
---
Поверхность, с которой программирует автор [действия](/apex/glossary#action): дескрипторы аргументов в `engine.descriptor` и члены контекста выполнения. Концептуальная основа — в [Концепция: Контекст выполнения](/apex/concepts/execution-context).
Инструменты для разработки [действий](/apex/glossary#action): дескрипторы аргументов в `engine.descriptor` и методы контекста выполнения. Концептуальные основы описаны в разделе [Концепция: Контекст выполнения](/apex/concepts/execution-context).
## Дескрипторы {#descriptors}
@@ -12,56 +12,56 @@ weight: 34
from engine.descriptor import Meta, Arg, Opt, Flag, Rest
```
| Класс | Сигнатура | Сопоставление с argparse |
| Класс | Сигнатура | Сопоставление с argparse |
|---|---|---|
| `Meta` | `Meta(summary, args=[])` | Управляет как кратким описанием в перечислении, так и парсером. |
| `Arg` | `Arg(name, help="", required=True)` | Позиционный; `required=False` → `nargs="?"`, по умолчанию `None`. |
| `Opt` | `Opt(name, help="", default=None)` | Опция со значением (`--name VALUE`) со значением по умолчанию. |
| `Meta` | `Meta(summary, args=[])` | Управляет как кратким описанием в перечислении, так и парсером. |
| `Arg` | `Arg(name, help="", required=True)` | Позиционный аргумент; `required=False` → `nargs="?"`, по умолчанию `None`. |
| `Opt` | `Opt(name, help="", default=None)` | Параметр со значением (`--name VALUE`) и значением по умолчанию. |
| `Flag` | `Flag(name, help="")` | Логический флаг `store_true`. |
| `Rest` | `Rest(name, help="")` | Жадный остаток (`argparse.REMAINDER`). |
| `Rest` | `Rest(name, help="")` | Жадный остаток аргументов (`argparse.REMAINDER`). |
Ошибки использования приводят к выходу `64` вместо стандартного для argparse `2`.
Ошибки использования приводят к выходу с кодом `64` вместо стандартного для argparse кода `2`.
> [!WARNING]
> `Rest` захватывает *всё* после первого позиционного аргумента — включая флаги. `Flag`, объявленный рядом с `Rest`, распознаётся только *перед* позиционным аргументом; действие, которое должно принимать свой флаг в любом месте, обязано само вырезать его из остатка (действие `compose` делает именно это для `--dry-run`).
> `Rest` захватывает *всё* после первого позиционного аргумента — включая флаги. `Flag`, объявленный рядом с `Rest`, распознаётся только *перед* позиционным аргументом; действие, которое должно принимать свой флаг в любом месте, обязано само извлечь его из остатка (действие `compose` делает именно так для `--dry-run`).
## ctx.log {#log}
`info(msg)` · `warn(msg)` · `success(msg)` → stdout; `error(msg)` → stderr. Все строки несут префикс `[<node>] [<action>] [LEVEL]`.
`info(msg)` · `warn(msg)` · `success(msg)` → stdout; `error(msg)` → stderr. Все строки выводятся с префиксом `[<node>] [<action>] [LEVEL]`.
## ctx.sys {#sys}
| Метод | Поведение |
|---|---|
| `run(cmd, check=True, capture=False, cwd=None, env=None, input=None)` | Обёртка над `subprocess.run`, возвращающая `CompletedProcess`; `capture=True` собирает текстовые stdout/stderr. |
| `run(cmd, check=True, capture=False, cwd=None, env=None, input=None)` | Обёртка над `subprocess.run`, возвращающая `CompletedProcess`; `capture=True` собирает текстовые потоки stdout/stderr. |
| `sudo(cmd, **kw)` | `run(["sudo", *cmd])`. |
| `ok(cmd, **kw)` | `True` тогда и только тогда, когда код возврата `0`; никогда не вызывает исключение. |
| `service_exists(name)` / `service_is_active(name)` / `service_is_enabled(name)` | Запросы к юнитам systemd. |
| `start_and_enable(name)` | Запуск + включение при необходимости; возвращает итоговое активное состояние. |
| `ensure_running(name)` | `enable --now`, когда неактивен. |
| `restart(name)` / `reload(name, dry_run=False)` / `daemon_reload()` | Жизненный цикл сервиса. |
| `wait_for(name, timeout=30, interval=2)` | Опрашивать, пока не станет активным. |
| `install_packages(packages)` | Для каждого пакета: пропустить, когда его бинарник в `PATH`, иначе `apt-get update -y` + `apt-get install -y`. |
| `ok(cmd, **kw)` | `True` тогда и только тогда, когда код возврата `0`; никогда не вызывает исключений. |
| `service_exists(name)` / `service_is_active(name)` / `service_is_enabled(name)` | Запросы к юнитам systemd. |
| `start_and_enable(name)` | Запуск и включение при необходимости; возвращает итоговое состояние активности. |
| `ensure_running(name)` | Выполняет `enable --now`, если сервис неактивен. |
| `restart(name)` / `reload(name, dry_run=False)` / `daemon_reload()` | Управление жизненным циклом сервиса. |
| `wait_for(name, timeout=30, interval=2)` | Опрашивает статус, пока сервис не станет активным. |
| `install_packages(packages)` | Для каждого пакета: пропускает, если бинарный файл уже есть в `PATH`, иначе выполняет `apt-get update -y` и `apt-get install -y`. |
## ctx.tpl {#tpl}
| Метод | Поведение |
|---|---|
| `render(path, vars)` | Отрендерить один файл (относительные пути разрешаются относительно `configs/`); семантика `safe_substitute` — неизвестные переменные остаются буквальными. |
| `render_dir(rel_dir, vars)` | Отрендерить каждый файл в поддереве `configs/`; возвращает `{relative_path: content}`. |
| `render(path, vars)` | Рендерит один файл (относительные пути отсчитываются от `configs/`); семантика `safe_substitute` — неизвестные переменные сохраняются как есть. |
| `render_dir(rel_dir, vars)` | Рендерит каждый файл в поддереве `configs/`; возвращает `{relative_path: content}`. |
## ctx.host {#host}
| Метод | Поведение |
|---|---|
| `backup(path, sudo=False)` | Скопировать файл в `<path>.<epoch>.bak`; возвращает путь резервной копии или `None`. |
| `write_file(path, content, backup=True, dry_run=False, sudo=False)` | Атомарная установка через временный файл (режим `0644`); опциональная предварительная резервная копия; вариант `sudo` сохраняет режим существующей цели. |
| `inject_block(target, content, start, end, anchor=None, old_pairs=(), dry_run=False, sudo=True)` | Замена управляемого блока по маркерам; удаляет текущие и устаревшие блоки, вставляет после первой строки-якоря; `False`, когда якорь отсутствует; отсутствующая цель — ошибка. |
| `install_crontab(text, dry_run=False)` | Снимок, отказ при пустом рендере (`SystemExit(65)`), предпросмотр, установка через `crontab -`, восстановление снимка при сбое (`SystemExit(1)`). |
| `backup(path, sudo=False)` | Копирует файл в `<path>.<epoch>.bak`; возвращает путь к резервной копии или `None`. |
| `write_file(path, content, backup=True, dry_run=False, sudo=False)` | Атомарная запись через временный файл (права `0644`); опциональное создание резервной копии; при `sudo=True` сохраняются права целевого файла. |
| `inject_block(target, content, start, end, anchor=None, old_pairs=(), dry_run=False, sudo=True)` | Заменяет управляемый блок по маркерам: удаляет текущие и устаревшие блоки, вставляет новый блок после первой строки-якоря. Возвращает `False`, если якорь отсутствует. Если целевой файл не найден, завершается с ошибкой. |
| `install_crontab(text, dry_run=False)` | Создаёт резервную копию crontab, завершается с ошибкой при пустом результате рендеринга (`SystemExit(65)`), выводит предпросмотр, устанавливает crontab через `crontab -` и восстанавливает прежнюю версию при сбое (`SystemExit(1)`). |
## ctx.ufw {#ufw}
`apply(rules_dir, config_dir=None, dry_run=False)` — полный проход межсетевого экрана: проверки каталогов (выход `66`), проверки инструментов (`ufw`, `ufw-docker`), правила хоста, внедрение управляемого блока в `/etc/ufw/before.rules` и `after.rules`, правила контейнеров из файлов `*.rules`.
`apply(rules_dir, config_dir=None, dry_run=False)` — полная настройка межсетевого экрана: проверка каталогов (выход с кодом `66`), проверка утилит (`ufw`, `ufw-docker`), применение правил хоста, внедрение управляемого блока в `/etc/ufw/before.rules` и `after.rules`, а также применение правил контейнеров из файлов `*.rules`.
## ctx.crowdsec {#crowdsec}
@@ -69,19 +69,19 @@ from engine.descriptor import Meta, Arg, Opt, Flag, Rest
## ctx.systemd {#systemd}
`deploy_units(configs_subdir, vars, dry_run=False)` — отрендерить файлы верхнего уровня (не скрытые) поддерева в `/etc/systemd/system/`, затем выполнить daemon-reload и `enable --now` для каждого `.service`.
`deploy_units(configs_subdir, vars, dry_run=False)` — рендерит файлы верхнего уровня (не скрытые) из подкаталога в `/etc/systemd/system/`, затем выполняет daemon-reload и `enable --now` для каждого сервиса `.service`.
## ctx.notify {#notify}
`telegram(title, bot_url, message, node=None, level="INFO")` плюс сокращения `success` / `error` / `info` / `warn`. Все возвращают `bool` — `True` только когда сообщение было доставлено; пустой `bot_url` логирует предупреждение и возвращает `False`. Поле `instance` в оповещении по умолчанию равно публичному хосту узла (`APEX_NODE_HOST`) — префикс домена или узла не применяется.
`telegram(title, bot_url, message, node=None, level="INFO")` плюс вспомогательные методы `success` / `error` / `info` / `warn`. Все методы возвращают `bool`: `True` — только если сообщение доставлено. При пустом `bot_url` выводится предупреждение в лог и возвращается `False`. Поле `instance` в оповещении по умолчанию содержит публичный хост узла (`APEX_NODE_HOST`), префикс домена или узла к нему не добавляется.
## ctx.paths и ctx.node {#paths}
## ctx.paths и ctx.node {#paths}
`ctx.paths`: `repo_root`, `commons`, `proprietaries`, `configs`, `compositions`, `core`, `tier1..3`, `stier1..3`, `root_tier1..3`. `ctx.node`: запись идентичности (`name`, `subnet`, `fqdn`, `host`) или `None` в checkout автономного commons. `ctx.vars()`: окружение шаблонов/compose (см. [Справочник: Переменные окружения](/apex/reference/environment)). `ctx.commons.run(args)`: делегирование затенённому действию commons — допустимо только в переопределяющем проприетарном модуле.
`ctx.paths`: `repo_root`, `commons`, `proprietaries`, `configs`, `compositions`, `core`, `tier1..3`, `stier1..3`, `root_tier1..3`. `ctx.node`: запись идентичности (`name`, `subnet`, `fqdn`, `host`) или `None` в checkout автономного commons. `ctx.vars()`: окружение шаблонов/compose (см. [Справочник: Переменные окружения](/apex/reference/environment)). `ctx.commons.run(args)`: делегирование затенённому действию commons — допустимо только в переопределяющем проприетарном модуле.
---
**См. также:**
**См. также:**
- [Концепция: Контекст выполнения](/apex/concepts/execution-context)
- [Руководство: Разработка действий](/apex/guides/action-development)
@@ -1,78 +1,78 @@
---
title: Справочник переменных окружения
description: Каждая переменная APEX_*, где она задаётся и кто её потребляет.
description: "Переменные окружения APEX_*: источники определения и область применения."
weight: 33
---
Пространство имён `APEX_*` — это вся поверхность конфигурации фреймворка. Переменные происходят из одного из четырёх слоёв; более поздние слои побеждают, когда действия оркестрации собирают окружение процесса.
Пространство имён `APEX_*` охватывает всю конфигурацию фреймворка. Переменные формируются на четырёх уровнях: при сборке окружения процесса действиями оркестрации значения из более поздних слоёв переопределяют предыдущие.
## node.env — идентичность {#node-env}
Закоммичена в корне репозитория; читается при разрешении [идентичности узла](/apex/glossary#node-identity). Фреймворк агностичен к домену: узел объявляет свой собственный FQDN и публичное имя хоста — концепции кластера нет, и обязательной формы FQDN нет.
Закоммичен в корне репозитория; считывается при разрешении [идентичности узла](/apex/glossary#node-identity). Фреймворк не привязан к доменной структуре: узел объявляет собственный FQDN и публичное имя хоста. Концепция кластера отсутствует, а формат FQDN ничем не ограничен.
| Переменная | Значение | Примечания |
|---|---|---|
| `APEX_NODE_FQDN` | Реальный FQDN узла (маршрутизация Traefik `Host()`) | При отсутствии откатывается к имени хоста ОС (с предупреждением). |
| `APEX_NODE_HOST` | Публичное имя хоста узла (резервное копирование `--host`, `instance` для наблюдаемости/уведомлений) | По умолчанию равно `APEX_NODE_FQDN`. Его первая метка становится коротким `name`. |
| `APEX_SUBNET` | Сеть /24 узла для сети `direct` | Обязательна, единственный источник подсети. Отсутствие = выход `66`. |
| `APEX_GIT_AUTHOR_NAME` | Идентичность бота для коммитов capture-up | Общее значение по умолчанию (`apex [bot]`). |
| `APEX_GIT_AUTHOR_EMAIL` | Email идентичности бота для коммитов capture-up | Общее значение по умолчанию (`apex@localhost`). |
| `APEX_NODE_FQDN` | Действительный FQDN узла (маршрутизация Traefik `Host()`) | Если не задан, используется имя хоста ОС (с выводом предупреждения). |
| `APEX_NODE_HOST` | Публичное имя хоста узла (резервное копирование `--host`, `instance` для наблюдаемости/уведомлений) | По умолчанию совпадает с `APEX_NODE_FQDN`. Первая часть доменного имени используется как короткое имя `name`. |
| `APEX_SUBNET` | Сеть /24 узла для сети `direct` | Обязательна, единственный источник подсети. Если не задана, запуск завершается с кодом `66`. |
| `APEX_GIT_AUTHOR_NAME` | Имя автора для коммитов capture-up | По умолчанию — `apex [bot]`. |
| `APEX_GIT_AUTHOR_EMAIL` | Email автора для коммитов capture-up | По умолчанию — `apex@localhost`. |
## apex.env — закоммиченные скаляры {#apex-env}
Закоммичены в `compositions/apex/`; несекретны по определению.
Закоммичен в `compositions/apex/`; не содержит секретных данных.
| Переменная | Значение | Примечания |
|---|---|---|
| `APEX_TRAEFIK_DIRECT_IP` | [Якорный IP](/apex/glossary#anchor-ip) краевого прокси в подсети узла | Требуется основной композицией (`?error`). |
| `APEX_TRAEFIK_DIRECT_IP` | [Якорный IP](/apex/glossary#anchor-ip) краевого прокси в подсети узла | Обязательна для основной композиции (`?error`). |
| `APEX_RESTIC_REPOSITORY` | Локатор репозитория резервных копий | Обязательна (`?error`). |
| `APEX_RESTIC_COMPRESSION` | Уровень сжатия restic | Опциональна; по умолчанию `max` (политика флота — `backup/run` также принудительно задаёт `--compression max`). |
| `APEX_RESTIC_AWS_REGION` | Регион бэкенда S3 | Опциональна; по умолчанию `us-east-1`. |
| `APEX_RESTIC_AWS_ENDPOINT` | URL эндпоинта бэкенда S3 | Опциональна; по умолчанию пусто (AWS S3). Задавайте для любого не-AWS S3-совместимого хранилища. |
| `APEX_LOKI_PUSH_URL` | URL приёма Loki для агента [Alloy](/apex/concepts/architecture) (`loki.write`) | Требуется профилем `alloy` (`?error`). |
| `APEX_PROM_PUSH_URL` | URL `remote_write` Prometheus для агента Alloy | Требуется профилем `alloy` (`?error`). |
| `APEX_OBS_BASIC_AUTH_USER` | Пользователь basic-auth для целей отправки наблюдаемости | Требуется профилем `alloy` (`?error`). |
| `COMPOSE_PROFILES` | Подключаемые основные профили (`adguard`, `xray`, `alloy`) | Может быть пустой; `manual` активируется через `backup/run`, а не здесь. |
| `TZ` | Часовой пояс контейнера | Опциональна; по умолчанию `Etc/UTC`. |
| `APEX_RESTIC_COMPRESSION` | Уровень сжатия restic | Опциональна; по умолчанию `max` (стандарт флота: `backup/run` также принудительно передаёт `--compression max`). |
| `APEX_RESTIC_AWS_REGION` | Регион бэкенда S3 | Опциональна; по умолчанию `us-east-1`. |
| `APEX_RESTIC_AWS_ENDPOINT` | URL эндпоинта бэкенда S3 | Опциональна; по умолчанию не задан (AWS S3). Задаётся для любого S3-совместимого хранилища сторонних провайдеров. |
| `APEX_LOKI_PUSH_URL` | URL приёма Loki для агента [Alloy](/apex/concepts/architecture) (`loki.write`) | Требуется для профиля `alloy` (`?error`). |
| `APEX_PROM_PUSH_URL` | URL `remote_write` Prometheus для агента Alloy | Требуется для профиля `alloy` (`?error`). |
| `APEX_OBS_BASIC_AUTH_USER` | Пользователь basic-auth для отправки метрик наблюдаемости | Требуется для профиля `alloy` (`?error`). |
| `COMPOSE_PROFILES` | Подключаемые основные профили (`adguard`, `xray`, `alloy`) | Может быть пустой; профиль `manual` активируется через `backup/run`, а не здесь. |
| `TZ` | Часовой пояс контейнера | Опциональна; по умолчанию `Etc/UTC`. |
## .env — секреты {#secrets}
Не закоммичен; символьная ссылка в хранилище уровня 1. Ключи документируются для каждого узла в `SECRETS.md`.
Не коммитится в репозиторий; представляет собой символьную ссылку в хранилище первого уровня. Список ключей каждого узла задокументирован в `SECRETS.md`.
| Переменная | Значение | Примечания |
|---|---|---|
| `APEX_UID`, `APEX_GID`, `APEX_SHARED_GID` | Идентичность noroot проекта | Автоматически внедряются через `tiers/useradd`; обязательны (`?error`). |
| `APEX_UID`, `APEX_GID`, `APEX_SHARED_GID` | Идентификаторы пользователя noroot для проекта | Внедряются автоматически через `tiers/useradd`; обязательны (`?error`). |
| `APEX_RESTIC_PASSWORD` | Пароль репозитория резервных копий | Обязательна (`?error`). |
| `APEX_RESTIC_AWS_ACCESS_KEY_ID`, `APEX_RESTIC_AWS_SECRET_ACCESS_KEY` | Учётные данные объектного хранилища | Обязательны (`?error`). |
| `APEX_TRAEFIK_CF_DNS_API_TOKEN` | Токен DNS-challenge для ACME | Обязательна (`?error`). |
| `APEX_TRAEFIK_ACME_EMAIL` | Email учётной записи ACME | Обязательна (`?error`). |
| `APEX_OBS_BASIC_AUTH_PASS` | Пароль basic-auth для целей отправки наблюдаемости | Требуется профилем `alloy` (`?error`). |
| `APEX_LOKI_BASIC_AUTH` | Пользователи htpasswd для центрального приёма Loki/Prometheus | Не требуется основной композицией. Только на узле, который сам фронтит центральный приём наблюдаемости. |
| `APEX_OBS_BASIC_AUTH_PASS` | Пароль basic-auth для отправки метрик и логов наблюдаемости | Требуется для профиля `alloy` (`?error`). |
| `APEX_LOKI_BASIC_AUTH` | Пользователи htpasswd для центрального шлюза Loki/Prometheus | Не требуется для основной композиции. Задаётся только на узле, принимающем данные наблюдаемости. |
## Экспорты движка — ctx.vars() {#engine}
Вычисляются [движком](/apex/glossary#engine) при вызове; экспортируются в `docker compose` действиями оркестрации и доступны каждому рендеру шаблона. Никогда не задавайте их вручную.
Вычисляются [движком](/apex/glossary#engine) при запуске; экспортируются в `docker compose` действиями оркестрации и доступны при рендеринге любого шаблона. Не задавайте их вручную.
| Переменная | Значение |
|---|---|
| `APEX_NODE_HOST` | Публичное имя хоста узла (`node.host`); резервное копирование `--host` и `instance` для наблюдаемости/уведомлений. |
| `APEX_NODE_FQDN` | Реальный FQDN узла (`node.fqdn`); используется для правил краевой маршрутизации. |
| `APEX_NODE_HOST` | Публичное имя хоста узла (`node.host`); используется для `--host` в резервном копировании и как `instance` для наблюдаемости и уведомлений. |
| `APEX_NODE_FQDN` | Действительный FQDN узла (`node.fqdn`); используется для правил краевой маршрутизации. |
| `APEX_SUBNET` | Подсеть узла. |
| `APEX_COMMONS` | Абсолютный каталог commons (`$APEX_COMMONS/apex` — это лаунчер, его используют crontab и юниты systemd). |
| `APEX_REPO_ROOT` | Абсолютный корень репозитория узла. |
| `APEX_TIER1`, `APEX_TIER2`, `APEX_TIER3` | Каталог данных основного проекта для каждого [уровня](/apex/glossary#tier). |
| `APEX_COMMONS` | Абсолютный путь к каталогу commons (`$APEX_COMMONS/apex` — лаунчер, используемый в crontab и юнитах systemd). |
| `APEX_REPO_ROOT` | Абсолютный путь к корню репозитория узла. |
| `APEX_TIER1`, `APEX_TIER2`, `APEX_TIER3` | Каталог данных основного проекта для каждого [уровня хранения](/apex/glossary#tier). |
| `APEX_TIER1_SHARED`, `APEX_TIER2_SHARED`, `APEX_TIER3_SHARED` | Общая область для каждого уровня. |
| `APEX_TIER_ROOT1`, `APEX_TIER_ROOT2`, `APEX_TIER_ROOT3` | Корни хранилища уровней. |
| `APEX_TIER_ROOT1`, `APEX_TIER_ROOT2`, `APEX_TIER_ROOT3` | Корневые каталоги уровней хранения. |
> [!NOTE]
> Переменные, помеченные выше `?error`, обеспечиваются общей основной композицией: `docker compose` отказывается вычислять модель, пока они не заданы, что превращает забытый секрет в немедленный, явный сбой.
> Для переменных, отмеченных выше как `?error`, проверка выполняется на уровне общей основной композиции: `docker compose` прерывает разбор конфигурации, если хотя бы одна из них не задана. Благодаря этому пропущенный секрет приводит к немедленной и понятной ошибке.
Несколько переменных основной композиции используют вместо этого `${VAR:-default}`, поэтому они опциональны и переопределяемы для каждого узла: `TZ` (`Etc/UTC`), `APEX_RESTIC_AWS_REGION` (`us-east-1`), `APEX_RESTIC_AWS_ENDPOINT` (пусто), `APEX_RESTIC_COMPRESSION` (`max`) и `APEX_ENCLAVE_SUBNET` (`198.18.0.0/16`, исходный диапазон ipallowlist для маршрутов панели Traefik/adguard).
Ряд переменных основной композиции использует конструкцию `${VAR:-default}`, поэтому их указывать необязательно, а значения по умолчанию можно переопределить для конкретного узла: `TZ` (`Etc/UTC`), `APEX_RESTIC_AWS_REGION` (`us-east-1`), `APEX_RESTIC_AWS_ENDPOINT` (пусто), `APEX_RESTIC_COMPRESSION` (`max`) и `APEX_ENCLAVE_SUBNET` (`198.18.0.0/16` — разрешённый диапазон IP-адресов в ipallowlist для маршрутов к панелям Traefik и AdGuard).
---
**См. также:**
**См. также:**
- [Концепция: Контекст выполнения](/apex/concepts/execution-context)
- [Концепция: Композиции и общее ядро](/apex/concepts/compositions)
- [Концепция: Композиции и общее ядро](/apex/concepts/compositions)
- [Концепция: Разрешение идентичности узла](/apex/concepts/node-identity)
@@ -1,74 +1,74 @@
---
title: Устранение неполадок
description: Симптомы, диагностика и исправление типовых сбоев apex.
description: Симптомы, диагностика и исправление типовых сбоев apex.
weight: 40
---
Режимы сбоев, о которых сообщает [движок](/apex/glossary#engine), с точными строками лога, которые он выводит.
Типовые сбои в работе [движка](/apex/glossary#engine), точные сообщения из логов и способы их устранения.
## APEX_NODE_FQDN откатывается к имени хоста {#fqdn-fallback}
## APEX_NODE_FQDN откатывается к имени хоста {#fqdn-fallback}
**Симптомы:** каждое действие пишет в лог `APEX_NODE_FQDN not set in node.env; using hostname '<fqdn>'.`
**Симптомы:** каждое действие пишет в лог `APEX_NODE_FQDN not set in node.env; using hostname '<fqdn>'.`
**Диагностика:** в `node.env` не задан `APEX_NODE_FQDN`, поэтому движок вывел FQDN из имени хоста ОС. На узле, чьё имя хоста и есть предполагаемый FQDN, это безобидно; в противном случае маршрутизация traefik `Host()` будет использовать неверное имя.
**Диагностика:** в `node.env` не задан `APEX_NODE_FQDN`, поэтому движок взял FQDN из имени хоста ОС. Если имя хоста совпадает с целевым FQDN, это не повредит работе. В противном случае маршрутизация Traefik по правилу `Host()` получит неверное доменное имя.
**Решение:** задайте `APEX_NODE_FQDN` явно в `node.env` в корне репозитория (например, `APEX_NODE_FQDN=node1.example.com`).
**Решение:** явно укажите `APEX_NODE_FQDN` в файле `node.env` в корне репозитория (например, `APEX_NODE_FQDN=node1.example.com`).
## Разрешение идентичности завершается с кодом 66 {#identity-66}
## Разрешение идентичности завершается с кодом 66 {#identity-66}
**Симптомы:** `identity: APEX_NODE_FQDN missing from node.env and no hostname` или `identity: APEX_SUBNET missing from node.env`, код выхода `66`.
**Симптомы:** `identity: APEX_NODE_FQDN missing from node.env and no hostname` или `identity: APEX_SUBNET missing from node.env`, код выхода `66`.
**Диагностика:** обязательное поле идентичности не удаётся разрешить. Первая строка означает, что ни `APEX_NODE_FQDN`, ни имя хоста ОС не дают FQDN; вторая означает, что отсутствует `APEX_SUBNET`. Движок отказывается угадывать.
**Диагностика:** не удалось определить обязательный параметр идентичности. Первое сообщение указывает, что ни в `node.env`, ни в имени хоста ОС нет FQDN; второе — что не задана подсеть `APEX_SUBNET`. Движок не пытается угадывать эти значения.
**Решение:** создайте или дополните `node.env` в корне репозитория — задайте `APEX_SUBNET` и задайте `APEX_NODE_FQDN` (либо убедитесь, что имя хоста ОС пригодно к использованию).
**Решение:** укажите нужные переменные в файле `node.env` в корне репозитория: задайте `APEX_SUBNET` и `APEX_NODE_FQDN` (либо настройте корректное имя хоста в ОС).
## configure/ufw завершается с кодом 66 {#ufw-66}
## configure/ufw завершается с кодом 66 {#ufw-66}
**Симптомы:** `Rules directory does not exist: ...` или `Config directory does not exist: ...`, код выхода `66`.
**Симптомы:** `Rules directory does not exist: ...` или `Config directory does not exist: ...`, код выхода `66`.
**Диагностика:** отсутствует ровно одна из директорий `configs/ufw/docker/` или `configs/ufw/host/` — это сломанная структура конфигурации, а не намеренно оставленный без правил узел.
**Диагностика:** отсутствует одна из директорий: `configs/ufw/docker/` либо `configs/ufw/host/`. Это свидетельствует о нарушении структуры каталогов, а не об осознанном отключении правил.
**Решение:** восстановите отсутствующую директорию (пустая допустима: пустой `docker/` выводит предупреждение `No .rules files found ... nothing to apply.` и продолжает работу).
**Решение:** создайте недостающую директорию (она может быть пустой: в этом случае `docker/` лишь выведет предупреждение `No .rules files found ... nothing to apply.` и продолжит выполнение).
## Отсутствует инструментарий ufw {#ufw-tools}
**Симптомы:** `ufw not installed/working via sudo. Run configure/base first.` или `ufw-docker not found at ~/.local/bin/ufw-docker. Run configure/base first.`, код выхода `1`.
**Симптомы:** `ufw not installed/working via sudo. Run configure/base first.` или `ufw-docker not found at ~/.local/bin/ufw-docker. Run configure/base first.`, код выхода `1`.
**Диагностика:** отсутствуют предварительные требования слоя фаервола.
**Диагностика:** не установлены компоненты, необходимые для настройки фаервола.
**Решение:** запустите `apex configure/base`, затем повторите `apex configure/ufw`.
**Решение:** запустите `apex configure/base`, затем повторите вызов `apex configure/ufw`.
## Установка crontab отклонена или откачена {#cron}
**Симптомы:** `Rendered crontab is empty; aborting (previous kept).` (код выхода `65`) или `crontab install failed; restoring previous.` (код выхода `1`).
**Симптомы:** `Rendered crontab is empty; aborting (previous kept).` (код выхода `65`) или `crontab install failed; restoring previous.` (код выхода `1`).
**Диагностика:** отрендеренный `configs/cron/crontab` оказался пустым (проблема шаблона/переменной — помните, что неизвестные `$VARS` остаются буквальными, они не обнуляют файл) или `crontab` отклонил синтаксис.
**Диагностика:** сгенерированный файл `configs/cron/crontab` оказался пустым (ошибка в шаблоне или переменных: неизвестные `$VARS` остаются как есть и не очищают файл) либо утилита `crontab` отклонила синтаксис.
**Решение:** изучите выведенный предпросмотр, исправьте шаблон, повторно запустите `apex configure/cron`. Предыдущий crontab остаётся целым в обоих случаях.
**Решение:** проверьте вывод предпросмотра, исправьте шаблон и запустите `apex configure/cron` заново. В обоих случаях предыдущая версия crontab остаётся без изменений.
## sync/repository прерывается {#sync-aborts}
**Симптомы:** `Commons submodule at <path> has uncommitted changes. Aborting.` или `Directory '<repo>/compositions' does not exist. Cannot sync.`, код выхода `1`.
**Симптомы:** `Commons submodule at <path> has uncommitted changes. Aborting.` или `Directory '<repo>/compositions' does not exist. Cannot sync.`, код выхода `1`.
**Диагностика:** коммит [capture-up](/apex/glossary#capture-up) отказывается фиксировать сломанный чекаут — грязный закреплённый субмодуль или отсутствующее дерево [композиций](/apex/glossary#compositions).
**Диагностика:** операция [capture-up](/apex/glossary#capture-up) прерывается, чтобы не фиксировать некорректное состояние репозитория (незакоммиченные изменения в субмодуле или отсутствующий каталог [композиций](/apex/glossary#compositions)).
**Решение:** для грязного субмодуля — `git -C commons checkout -- .` (или закоммитьте намеренное перемещение пина); для отсутствующего дерева — восстановите чекаут до следующего планового запуска.
**Решение:** при наличии изменений в субмодуле сбросьте их командой `git -C commons checkout -- .` (либо зафиксируйте намеренное обновление субмодуля в коммите). Если отсутствует дерево каталогов, восстановите чекаут до следующего планового запуска.
## Telegram не доставлен {#telegram}
**Симптомы:** `Telegram Bot URL not provided. Skipping notification.` или `Failed to send Telegram notification: <error>` — и действия, зависящие от уведомлений (`backup/run`, `sync/repository`, `sync/packages`), завершаются с кодом `1`, даже если их работа выполнена успешно.
**Симптомы:** в логе появляется `Telegram Bot URL not provided. Skipping notification.` или `Failed to send Telegram notification: <error>`. При этом действия, отправляющие уведомления (`backup/run`, `sync/repository`, `sync/packages`), завершаются с кодом `1`, даже если основная задача выполнена.
**Диагностика:** аргумент с URL бота был пустым или отправка не удалась (сеть, прокси, отозванный токен). Ненулевой код выхода сделан намеренно: автоматический запуск, статус которого нельзя сообщить, считается неуспешным.
**Диагностика:** не указан URL бота либо сбой произошёл при отправке запроса (проблемы с сетью, прокси или отозванный токен). Ненулевой код возврата предусмотрен намеренно: автоматическая задача, о результате которой невозможно сообщить, считается неудачной.
**Решение:** проверьте файл-источник URL, который читает строка cron, доступность сети/прокси и токен бота; повторите запуск вручную, чтобы подтвердить доставку.
**Решение:** проверьте файл с URL бота, доступность сети и прокси, а также валидность токена. Затем выполните команду вручную, чтобы проверить доставку.
## docker compose отклоняет базовую обёртку {#compose-include}
**Симптомы:** `docker compose` выдаёт ошибку на ключевом слове `include` при вычислении `compositions/apex/docker-compose.yml`.
**Симптомы:** команда `docker compose` сообщает об ошибке на директиве `include` при обработке `compositions/apex/docker-compose.yml`.
**Диагностика:** [базовая композиция](/apex/glossary#core-composition) подключается через `include:`, что требует Docker Compose v2.20+.
**Диагностика:** [базовая композиция](/apex/glossary#core-composition) подключается через директиву `include:`, для которой требуется Docker Compose версии 2.20 или новее.
**Решение:** обновите плагин Compose (`docker compose version` для проверки).
**Решение:** обновите плагин Compose (проверить текущую версию можно командой `docker compose version`).
---
@@ -1,32 +0,0 @@
---
title: "Платформа ermnvldmr.com"
weight: 1
type: docs
cascade:
type: docs
---
**Платформа ermnvldmr.com** — это облачная инфраструктура, автоматизированный сборочный пайплайн и GitOps-система доставки, обеспечивающая работу всех сервисов `ermnvldmr.com`.
Платформа построена на принципах Infrastructure as Code, неизменяемых golden-образах ОС, сквозном шифровании секретов в Git, непрерывной автоматической синхронизации и бесшовных (zero-downtime) обновлениях.
---
## Разделы документации
{{% cards %}}
{{< card link="architecture" title="Архитектура и потоки данных" icon="server" subtitle="Сквозная архитектура: Packer, Cloud-Init, K3s, Flux GitOps и Cloudflare." >}}
{{< card link="golden-image" title="Автоматизация Golden Image" icon="cube" subtitle="Сборочный пайплайн Packer на базе QEMU, запекание ядра и K3s, хранение в S3 и управление квотами." >}}
{{< card link="deployment-gitops" title="GitOps и пайплайн релизов" icon="refresh" subtitle="Непрерывный деплой, семантические релизы, fast-track деплои и проверка готовности через /version.json." >}}
{{< card link="operations" title="Эксплуатация и регламенты" icon="shield-check" subtitle="SSH-доступ, шифрование SOPS Age, постоянное хранилище SSD, резервное копирование и восстановление." >}}
{{% /cards %}}
---
## Ключевые характеристики платформы
* **Неизменяемые Golden Images**: Стандартизированная база Debian 12 + K3s, собираемая через Packer и QEMU, изолированная от machine-id и хранимая в S3.
* **Автономная инициализация первого запуска**: Cloud-Init автоматически настраивает диск, безопасность ядра, файрвол, ключи Age и запускает Flux GitOps без необходимости подключения к серийной консоли.
* **Декларативный GitOps-движок**: Flux v2 непрерывно синхронизирует состояние Kubernetes-кластера с веткой `next` в GitHub.
* **Сквозное шифрование**: SOPS + Age шифрует все секреты в репозитории; Let's Encrypt и Cloudflare Full (Strict) защищают внешний трафик.
* **Автоматическая верификация раскатки**: Релизные пайплайны опрашивают публичный эндпоинт `/version.json`, подтверждая успешную доставку коммита реальным пользователям.
@@ -1,114 +0,0 @@
---
title: "Архитектура и потоки данных"
weight: 2
type: docs
---
Архитектура инфраструктуры `ermnvldmr.com` объединяет запекание неизменяемых образов ОС, автономную bootstrap-инициализацию через cloud-init и декларативную GitOps-синхронизацию Flux в единую надежную модель развертывания.
---
## 1. Общая схема системы
```mermaid
flowchart TD
subgraph GitHub ["Инфраструктура GitHub"]
Repo["Git-репозиторий (ветка next)"]
Workflows["GitHub Actions пайплайны"]
GHCR["GitHub Container Registry (GHCR)"]
end
subgraph S3 ["Объектное S3-хранилище"]
GoldenImage["Packer Golden Image (.qcow2)"]
Backups["Ежедневные бэкапы PostgreSQL"]
end
subgraph Cloud ["Облачная инфраструктура / Платформа Compute"]
subgraph VM ["Виртуальная машина Compute"]
subgraph OS ["Debian 12 Bookworm"]
CloudInit["Cloud-Init First-Boot"]
UFW["Файрвол UFW (80, 443, 22)"]
SSD["Постоянный SSD (/dev/vdb /var/lib/rancher/k3s/storage)"]
end
subgraph K3s ["Кластер K3s (Single-Node)"]
Flux["Оператор Flux v2"]
Traefik["Traefik Ingress (HostPort 80/443)"]
CertManager["Cert-Manager (Let's Encrypt Prod)"]
subgraph Apps ["Сервисы кластера (com-ermnvldmr)"]
WWW["www (React 19 SSG + Nginx)"]
Docs["docs (Hugo + Hextra + Nginx)"]
Postgres["PostgreSQL 16 StatefulSet"]
Umami["Сервер аналитики Umami"]
BackupCron["CronJob бэкапа Postgres в S3"]
end
end
end
end
subgraph Edge ["Cloudflare Edge"]
CF["Прокси Cloudflare (Full Strict)"]
end
Users["Пользователи"] --> CF
CF --> Traefik
Traefik --> WWW
Traefik --> Docs
Traefik --> Umami
Umami --> Postgres
BackupCron --> Backups
Workflows --> GHCR
Workflows --> GoldenImage
Repo --> Flux
Flux --> Apps
```
---
## 2. Фазы жизненного цикла платформы
### Фаза 1: Сборка Golden Image (Packer)
1. **Автоматический запуск**: GitHub Actions выполняет `.github/workflows/ci-next-build-golden-image.yml`.
2. **QEMU-виртуализация**: Packer запускает виртуальную установку Debian 12 с автоматизацией preseed (`preseed.cfg`).
3. **Инициализация**:
* Установка системных утилит (`curl`, `git`, `htop`, `fail2ban`, `ufw`, `qemu-guest-agent`, `isc-dhcp-client`).
* Настройка `/etc/cloud/ds-identify.cfg` с `policy: enabled` для гарантированного старта cloud-init.
* Предустановка бинарного файла K3s (`INSTALL_K3S_SKIP_ENABLE=true`, `INSTALL_K3S_SKIP_START=true`) в `/usr/local/bin/k3s`.
* Сброс идентичности хоста (`/etc/machine-id`, DBus ID, ключи SSH, сетевые кэши).
4. **Сжатие и загрузка в S3**: Образ конвертируется в `qcow2`, старые образы в `dev/images/` автоматически удаляются для соблюдения квот хранилища, и готовый образ загружается в объектное S3-хранилище.
---
### Фаза 2: Автономный первый запуск (Cloud-Init)
При создании виртуальной машины с метаданными `ci/cloud-init/user-data.template.yaml`:
1. **Настройка пользователей и сети**:
* Создание пользователя `adam` с публичным SSH-ключом и правами sudo без пароля.
* Отключение парольной аутентификации и прямого доступа root по SSH.
* Конфигурация fallback DNS-серверов (`1.1.1.1`, `8.8.8.8`, `77.88.8.8`).
2. **Харденинг ядра и безопасность**:
* Применение правил sysctl (защита от SYN Flood, запрет ICMP redirects, фильтрация обратного пути rp_filter).
* Настройка UFW: открыты только порты 22 (SSH), 80 (HTTP Traefik) и 443 (HTTPS Traefik), настроена внутренняя маршрутизация подсетей K3s Pods (`10.42.0.0/16`) и Services (`10.43.0.0/16`).
* Включение службы Fail2ban.
3. **Подключение постоянного SSD**:
* Определение вторичного диска `/dev/vdb`.
* Автоматическое форматирование в `ext4` с меткой `k3s-storage`, если диск не был отформатирован.
* Монтирование в `/var/lib/rancher/k3s/storage` и добавление записи в `/etc/fstab`.
4. **Запуск K3s и GitOps-движка**:
* Старт службы `k3s.service` с активным ServiceLB.
* Создание секрета `flux-system/sops-age` с ключом дешифрования.
* Установка CRD и контроллеров Flux v2.
* Применение корневого манифеста синхронизации (`GitRepository` + `Kustomization`), привязанного к ветке `next`.
---
### Фаза 3: Непрерывная доставка и синхронизация (GitOps)
1. **Контроллер Flux**:
* Опрашивает ветку `next` каждую 1 минуту.
* Применяет Kustomize-оверлеи из `ci/k8s/clusters/production`.
* Дешифрует секреты `*.sops.yaml` прямо внутри кластера с помощью ключа Age.
* Гарантирует бесшовный rollout подов (`maxUnavailable: 0`, `maxSurge: 1`).
2. **Сетевой уровень и TLS**:
* Traefik слушает порты 80 и 443.
* Cert-Manager валидирует HTTP-01 ACME challenge от Let's Encrypt через правила Ingress.
* Прокси Cloudflare работает в режиме **Full (strict)**, обеспечивая максимальный уровень безопасности.
@@ -1,74 +0,0 @@
---
title: "GitOps и пайплайн релизов"
weight: 4
type: docs
---
Платформа использует декларативную модель GitOps на базе **Flux v2** и **Kustomize** со встроенной автоматической проверкой раскатки сервисов в продакшене.
---
## 1. Структура GitOps-манифестов
Все манифесты Kubernetes расположены в каталоге [`ci/k8s/`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/k8s/):
```text
ci/k8s/
├── apps/ # Сервисы и приложения
│ ├── docs/ # Документация на Hugo
│ ├── postgres/ # PostgreSQL 16 StatefulSet и бэкап-CronJob
│ ├── umami/ # Аналитика Umami
│ └── www/ # Основной сайт на React 19
├── base/ # Базовые ресурсы
│ └── namespaces/ # Определение неймспейса `com-ermnvldmr`
├── clusters/ # Точки входа для Flux
│ └── production/ # Корневая Kustomization продакшена
└── infrastructure/ # Кластерная инфраструктура
├── cert-manager/ # Cert-Manager v1.17.0 и ClusterIssuer Let's Encrypt
└── networking/ # Правила маршрутизации Traefik Ingress
```
---
## 2. Пайплайны развертывания и релизов
### Режимы деплоя сервисов:
1. **`services/docs` (Непрерывный деплой)**:
* **Триггер**: Push в ветку `next` с изменениями в `services/docs/**`.
* **Workflow**: `.github/workflows/services-docs-next-required.yml`.
* **Действие**: Сборка Docker-образа, обновление `ci/k8s/apps/docs/kustomization.yaml`, коммит в `next` и запуск задачи `verify-rollout`.
2. **`services/www` (Trunk-Based CD с релизным гейтом CalVer)**:
* **Триггер**: Push в ветку `next` с изменениями в `services/www/**` или зависимых пакетах (`packages/ui/**`, `packages/stl/**`, `packages/i18n/**`), либо ручной `workflow_dispatch`.
* **Workflow**: `.github/workflows/services-www-next-required.yml`.
* **Пайплайн**: Полный цикл тестов и проверок (Vitest, Storybook Playwright, Lint, Typecheck, Build) ➔ Сборка Docker-образа с тегом `sha-<commit>` ➔ Гейт подтверждения (`services-www-releases-publish`) выставляет тег CalVer `YYYY.MM.MICRO` в Git и GHCR ➔ Гейт подтверждения (`services-www-releases-deploy`) обновляет GitOps ➔ Проверка раскатки через `verify-rollout`.
---
## 3. Автоматическая проверка раскатки (`/version.json`)
Для 100% уверенности в том, что релиз дошел до пользователей:
1. Каждый Docker-образ содержит файл `/usr/share/nginx/html/version.json` со значениями `COMMIT_SHA` и `VERSION`.
2. Nginx отдает `/version.json` с заголовками `Cache-Control: no-store, no-cache, must-revalidate`.
3. Шаг `verify-rollout` в GitHub Actions опрашивает публичный адрес (`https://www.ermnvldmr.com/version.json` или `https://docs.ermnvldmr.com/version.json`) каждые 10 секунд (до 5 минут), пока коммит в продакшене не совпадет с задеплоенным.
```bash
TARGET_COMMIT="sha-26f0c23"
for i in {1..30}; do
LIVE_COMMIT=$(curl -sf https://www.ermnvldmr.com/version.json | jq -r '.commit')
if [ "$LIVE_COMMIT" = "$TARGET_COMMIT" ]; then
echo "✅ Раскатка в продакшене подтверждена!"
exit 0
fi
sleep 10
done
```
---
## 4. Стратегия отката (Rollback)
* **Git Revert (Стандартный)**: Создание revert-коммита для деплой-коммита и пуш в `next`.
* **GitOps-скрипт**: Запуск `bash ci/scripts/deploy-gitops.sh <service> <previous_tag>`.
* **Экстренный откат в кластере**: Команда `kubectl rollout undo deployment/<service> -n com-ermnvldmr` для мгновенного переключения трафика.
@@ -1,61 +0,0 @@
---
title: "Автоматизация Golden Image"
weight: 3
type: docs
---
Сборочный пайплайн Golden Image автоматизирует создание стандартизированных и защищенных образов виртуальных машин Debian 12 с предустановленным кластером K3s.
---
## 1. Конфигурация Packer и скрипты провижининга
Конфигурация сборок расположена в каталоге [`ci/packer/`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/packer/) и использует builder `qemu`.
### Основные компоненты:
* **[`debian-12-k3s.pkr.hcl`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/packer/debian-12-k3s.pkr.hcl)**: HCL-шаблон Packer, задающий параметры виртуальной машины (CPU, RAM, диск 10GB qcow2, образ Debian 12 Netinst, параметры загрузки и скрипты).
* **[`http/preseed.cfg`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/packer/http/preseed.cfg)**: Правила автоматической неинтерактивной установки Debian через Preseed.
* **[`scripts/01-base-system.sh`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/packer/scripts/01-base-system.sh)**: Установка базовых утилит, принудительное включение cloud-init (`/etc/cloud/ds-identify.cfg`), оптимизация сетевого стека и настройка fail2ban.
* **[`scripts/02-install-k3s.sh`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/packer/scripts/02-install-k3s.sh)**: Скачивание и установка бинарного файла K3s (`INSTALL_K3S_SKIP_ENABLE=true`, `INSTALL_K3S_SKIP_START=true`) в `/usr/local/bin/k3s` без запуска сервиса во время сборки.
* **[`scripts/03-cleanup.sh`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/packer/scripts/03-cleanup.sh)**: Сброс идентификаторов машины (`/etc/machine-id`, `/var/lib/dbus/machine-id`, хостовые ключи SSH), очистка кэшей apt и зануление свободного места на диске.
---
## 2. Автоматизация в GitHub Actions
Сборка образов запускается пайплайном `.github/workflows/ci-next-build-golden-image.yml`.
### Этапы workflow:
1. **Валидация скриптов**: Проверка синтаксиса bash (`bash -n ci/packer/scripts/*.sh`) и форматирования Packer (`packer fmt -check`).
2. **Сборка образа**:
* Инициализация плагинов (`packer init`).
* Сборка в headless QEMU (`packer build`).
* Сжатие артефакта в формат `qcow2`.
3. **Загрузка в S3 и контроль квот**:
* Настройка AWS S3 CLI для работы с S3-совместимым объектным хранилищем.
* **Автоматическая очистка**: Удаление устаревших образов в префиксе `dev/images/com-ermnvldmr-debian-12-k3s-amd64-*.qcow2` для предотвращения переполнения квоты бакета.
* **Загрузка**: Сохранение нового образа:
```text
s3://<bucket>/dev/images/com-ermnvldmr-debian-12-k3s-amd64-<git_short_sha>.qcow2
```
---
## 3. Схема именования образов
Имя образа жестко связано с коммитом Git:
```text
com-ermnvldmr-debian-12-k3s-amd64-<short_sha>.qcow2
```
Пример: `com-ermnvldmr-debian-12-k3s-amd64-f4ba6d1.qcow2`
---
## 4. Развертывание через Cloud-Init
Для запуска виртуальной машины в облачной инфраструктуре из готового образа:
1. Передайте заполненный шаблон [`user-data.template.yaml`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/cloud-init/user-data.template.yaml) в метаданные инстанса в поле `user-data`.
2. Cloud-init автоматически выполнит `/usr/local/bin/bootstrap-cluster.sh` при первом старте.
3. Инстанс настроит сеть, смонтирует постоянный SSD, запустит K3s, установит Flux и поднимет все сервисы примерно за 90 секунд.
@@ -1,100 +0,0 @@
---
title: "Эксплуатация и регламенты"
weight: 5
type: docs
---
Практическое руководство и регламенты для администрирования, отладки и обслуживания продакшен-кластера `ermnvldmr.com`.
---
## 1. Удаленный SSH-доступ
Подключение к виртуальной машине осуществляется под учетной записью `adam`:
```bash
ssh -i ~/.ssh/id_ed25519 adam@<SERVER_IP>
```
Пользователь `adam` имеет права `sudo` без пароля и преднастроенный доступ к `kubectl` через `~/.kube/config`.
---
## 2. Диагностика состояния кластера
### Проверка статуса подов
```bash
kubectl get pods -n com-ermnvldmr -o wide
```
### Проверка Ingress и TLS-сертификатов
```bash
kubectl get certificates -n com-ermnvldmr
kubectl get ingress -n com-ermnvldmr
```
### Проверка синхронизации Flux GitOps
```bash
kubectl get gitrepositories -n flux-system
kubectl get kustomizations -n flux-system
```
### Принудительный запуск синхронизации GitOps
```bash
kubectl annotate --overwrite kustomization -n flux-system cluster-sync reconcile.fluxcd.io/requestedAt="$(date +%s)"
```
---
## 3. Управление секретами через SOPS + Age
Все файлы секретов (`*.sops.yaml`) хранятся в репозитории Git в зашифрованном виде с использованием асимметричных ключей Age.
### Конфигурация SOPS (`.sops.yaml`):
```yaml
creation_rules:
- path_regex: 'ci/k8s/.*secret.*\.yaml$'
encrypted_regex: '^(data|stringData)$'
age: 'age1<YOUR_PUBLIC_AGE_KEY>'
```
> **Важно**: Параметр `encrypted_regex` оставляет `apiVersion`, `kind` и `metadata.name` открытыми, что предотвращает ошибки синтаксического разбора в Kustomize и Flux.
### Шифрование секрета
```bash
sops --encrypt --in-place ci/k8s/apps/postgres/secret.sops.yaml
```
### Редактирование зашифрованного секрета
```bash
sops ci/k8s/apps/postgres/secret.sops.yaml
```
---
## 4. Хранилище PostgreSQL и резервное копирование в S3
### Архитектура постоянного хранения
* Данные PostgreSQL размещены на постоянном томе PVC (5Gi) на диске `/dev/vdb` (смонтированном в `/var/lib/rancher/k3s/storage`).
* Данные сохраняются при перезагрузках виртуальной машины и перезапусках подов.
### Автоматический бэкап в S3 (CronJob)
* Манифест: [`ci/k8s/apps/postgres/backup-cronjob.yaml`](file:///home/deytenit/Source/repos/deytenit/ermnvldmr.com/ci/k8s/apps/postgres/backup-cronjob.yaml).
* Расписание: ежедневно в 03:00 UTC (`0 3 * * *`).
* Делает дамп `pg_dump` со сжатием gzip и отправляет его в `s3://<bucket>/backups/postgres/`.
### Ручной запуск бэкапа
```bash
kubectl create job --from=cronjob/postgres-backup manual-backup-$(date +%s) -n com-ermnvldmr
```
---
## 5. Настройки SSL/TLS в Cloudflare
* **Режим SSL**: **Full (strict)**
* **Правило исключения для HTTPS Redirect**: Обязательно наличие правила для исключения ACME HTTP-01 challenge:
```text
http.request.scheme eq "http" and not starts_with(http.request.uri.path, "/.well-known/acme-challenge/")
```
Это позволяет cert-manager продлевать сертификаты Let's Encrypt по порту 80 без блокировок и редиректов.
+11 -11
View File
@@ -7,29 +7,29 @@ cascade:
type: docs
---
**Quetza** — это модульный фреймворк для Discord-ботов, построенный на TypeScript, который позволяет разработчикам создавать многофункциональные приложения для Discord с помощью самодостаточных, композитных модулей.
**Quetza** — модульный фреймворк на TypeScript для Discord-ботов. Он позволяет собирать приложения из самодостаточных независимых модулей.
Фреймворк обеспечивает архитектурный фундамент, который превращает разработку функций из модификации ядра в добавление модулей.
Архитектура Quetza переносит разработку новой функциональности из модификации ядра в добавление отдельных модулей.
## Основные возможности
- **Обнаружение модулей на основе соглашений**: Просто создайте папку, и Quetza загрузит ее как функциональный модуль.
- **Изоляция контроллеров**: Управляйте состоянием на уровне сервера (guild), предотвращая утечки данных между серверами.
- **Декларативные команды**: Определяйте Slash-команды Discord с помощью простого, типизированного интерфейса.
- **Реактивные события**: Подписывайтесь на любые события Discord.js внутри ваших модулей.
- **Обнаружение модулей по соглашениям:** создайте папку, и Quetza автоматически загрузит её как модуль.
- **Изоляция контроллеров:** управление состоянием происходит на уровне сервера (guild), что исключает утечки данных между серверами.
- **Декларативные команды:** определение слэш-команд Discord через типизированный интерфейс.
- **Реактивные события:** подписка на любые события Discord.js внутри модулей.
## Разделы
{{% cards %}}
{{< card link="getting-started" title="Начало работы" icon="lightning-bolt" subtitle="Поймите философию и архитектуру Quetza." >}}
{{< card link="concepts" title="Концепции" icon="light-bulb" subtitle="Глубокое погружение в модульную систему и структуру контроллеров." >}}
{{< card link="getting-started" title="Начало работы" icon="lightning-bolt" subtitle="Философия и архитектура Quetza." >}}
{{< card link="concepts" title="Концепции" icon="light-bulb" subtitle="Модульная система и структура контроллеров." >}}
{{< card link="guides" title="Руководства" icon="book-open" subtitle="Пошаговые инструкции по разработке модулей и деплою." >}}
{{< card link="troubleshooting" title="Устранение неполадок" icon="shield-exclamation" subtitle="Диагностика распространенных проблем в разработке модулей." >}}
{{< card link="troubleshooting" title="Устранение неполадок" icon="shield-exclamation" subtitle="Диагностика частых проблем при разработке модулей." >}}
{{% /cards %}}
---
**See also:**
## См. также
- [GitHub: Quetza](https://github.com/deytenit/Quetza)
- [Discord.js Documentation](https://discord.js.org/)
- [Документация Discord.js](https://discord.js.org/)
@@ -3,4 +3,4 @@ title: Концепции
weight: 2
---
В этом разделе описываются архитектурные принципы, на которых построена Quetza. Понимание этих концепций поможет вам проектировать более надежные и поддерживаемые модули.
Здесь описаны ключевые архитектурные принципы Quetza. Понимание этих концепций помогает проектировать надёжные и легко поддерживаемые модули.
@@ -1,45 +1,45 @@
---
title: Архитектура и Философия
title: Архитектура и философия
weight: 1
---
**Цель:** Понять основные принципы проектирования, лежащие в основе модульности Quetza.
**Цель:** изучить принципы проектирования, определяющие модульную структуру Quetza.
## Философия проектирования
### Соглашение важнее конфигурации (Convention Over Configuration)
### Соглашение важнее конфигурации (Convention over Configuration)
Quetza минимизирует шаблонный код, полагаясь на строгую структуру папок.
Quetza сокращает шаблонный код за счёт строгой структуры каталогов.
- **Никакого центрального реестра:** Сама файловая система _является_ реестром.
- **Результат:** Вам не нужно обновлять файл конфигурации, чтобы добавить модуль; вы просто создаете папку.
- **Без центрального реестра:** файловая система сама выступает реестром.
- **Результат:** чтобы подключить модуль, не нужно редактировать конфигурационные файлы — достаточно создать каталог.
### Композиция важнее наследования (Composition Over Inheritance)
### Композиция важнее наследования (Composition over Inheritance)
Модули состоят из возможностей (capabilities), а не наследуются от базового класса.
Модули собираются из набора возможностей (capabilities), а не наследуются от базового класса.
- **Гибкость:** Модули могут использовать любую внутреннюю архитектуру (Функциональную, ООП), пока они экспортируют необходимые интерфейсы.
- **Гибкость:** внутри модуля можно использовать любую архитектуру (функциональную или ООП), если модуль экспортирует обязательные интерфейсы.
### Изоляция важнее интеграции (Isolation Over Integration)
### Изоляция важнее интеграции (Isolation over Integration)
Модули не могут напрямую вызывать друг друга.
Модули не вызывают друг друга напрямую.
- **Почему:** Это предотвращает сильную связность (tight coupling).
- **Преимущество:** Вы можете удалить любой модуль, не сломав остальную часть бота.
- **Причина:** исключает сильную связанность (tight coupling).
- **Преимущество:** любой модуль можно удалить без риска сломать остального бота.
## Основные сущности
| Сущность | Роль | Жизненный цикл |
| :--- | :--- | :--- |
| **Client** | Локатор сервисов и оркестратор | Синглтон. Руководит всем. |
| **Module** | Пространство имен для функций | Загружается при запуске. |
| **Command** | Конечная точка взаимодействия с пользователем | Без состояния (Stateless). Запрос/Ответ. |
| **Event** | Реактивный наблюдатель (Observer) | Подписывается при запуске. |
| **Controller** | Менеджер состояния | Сохраняется между взаимодействиями. |
| **Client** | Локатор сервисов и оркестратор | Синглтон. Управляет всеми компонентами системы. |
| **Module** | Пространство имён для функций | Загружается при старте. |
| **Command** | Точка взаимодействия с пользователем | Без состояния (stateless). Запрос — ответ. |
| **Event** | Реактивный наблюдатель (observer) | Регистрируется при старте. |
| **Controller** | Менеджер состояния | Сохраняет состояние между запросами. |
---
**См. также:**
## См. также
- [Паттерн Service Locator](https://ru.wikipedia.org/wiki/Локатор_служб)
- [Паттерн Observer](https://ru.wikipedia.org/wiki/Наблюдатель_(шаблон_проектирования))
@@ -3,13 +3,13 @@ title: Модульная система
weight: 2
---
**Цель:** Определить словарь и механизмы основной абстрактной единицы Quetza.
**Цель:** разобрать терминологию и механику работы базовой единицы Quetza — модуля.
## Системный словарь
## Терминология
### Объявление модуля (Module Declaration)
Файл `module.ts` выступает в качестве манифеста. Это точка входа для обнаружения.
Файл `module.ts` служит манифестом и точкой входа при поиске модулей.
```typescript
export const name = 'my-module'; // ID
@@ -18,50 +18,50 @@ export const description = '...'; // Документация
### Регистрация команд (Command Registration)
Происходит во время события `ready`.
Выполняется при наступлении события `ready`:
1. Quetza сканирует все папки `commands/`.
2. Компилирует `ApplicationCommandData`.
3. Отправляет (PUT) данные в API Discord.
1. Quetza сканирует каталоги `commands/`.
2. Формирует структуру `ApplicationCommandData`.
3. Отправляет данные (PUT-запрос) в API Discord.
### Внедрение контроллера (Controller Injection)
Состояние внедряется (injected), а не импортируется.
Экземпляр контроллера передаётся аргументом при вызове (injected), а не импортируется модулем напрямую:
```typescript
// Фреймворк вызывает это:
execute(client, interaction, controller);
```
- **Зачем?** Позволяет фреймворку управлять жизненным циклом контроллера (синглтон на каждый модуль).
- **Зачем:** фреймворк берёт на себя управление жизненным циклом контроллера (один синглтон на модуль).
## Философия управления состоянием
## Управление состоянием
### Эфемерность по умолчанию
Quetza **не предоставляет встроенной базы данных**.
В Quetza **нет встроенной базы данных**.
- **Память:** Контроллеры хранят состояние в JS объектах `Map` или `Set`.
- **Перезапуск:** Все состояние теряется при завершении процесса.
- **Постоянство:** Должно быть реализовано вручную (например, SQLite, Redis) внутри Контроллера.
- **Память:** контроллеры сохраняют данные в стандартных JavaScript-коллекциях `Map` или `Set`.
- **Перезапуск:** при остановке процесса всё состояние в памяти сбрасывается.
- **Постоянное хранение:** если данные нужно сохранять между запусками, подключите внешнюю СУБД (например, SQLite или Redis) внутри контроллера.
### Область видимости как ключ
Состояние должно быть ключировано по его границам.
Ключи состояния должны соответствовать области его видимости:
- `Map<GuildID, ...>` - Специфично для сервера (Наиболее частое).
- `Map<UserID, ...>` - Специфично для пользователя (Боты для личных сообщений - DM).
- `Map<GuildID, ...>` — уровень сервера (основной сценарий).
- `Map<UserID, ...>` — уровень пользователя (боты для личных сообщений, DM).
## Модель обработки ошибок
**Быстрый и дружелюбный отказ (Fail Fast & Friendly).**
**Быстрый и понятный отказ (Fail Fast & Friendly).**
- **Валидация:** Проверяйте права/вводы немедленно.
- **Для пользователя:** "У вас нет прав" (а не "Ошибка: 403").
- **Изоляция:** Сбой в `/ping` перехватывается сам по себе и не "убивает" бота.
- **Валидация:** проверяйте права и входные параметры сразу.
- **Сообщения для пользователя:** «У вас нет прав» вместо сухого кода вроде «Ошибка: 403».
- **Изоляция:** сбой внутри `/ping` перехватывается локально и не роняет процесс бота.
---
**См. также:**
## См. также
- [Жизненный цикл взаимодействия Discord (Interaction Lifecycle)](https://discord.com/developers/docs/interactions/receiving-and-responding)
@@ -3,64 +3,64 @@ title: Начало работы
weight: 1
---
**Quetza** — это модульный фреймворк для Discord-ботов, построенный на TypeScript, который позволяет разработчикам создавать многофункциональные приложения для Discord с помощью самодостаточных, композитных модулей.
**Quetza** — модульный фреймворк на TypeScript для Discord-ботов. Он позволяет собирать ботов из самодостаточных независимых модулей.
## Что такое Quetza?
Quetza предоставляет архитектурный фундамент, который превращает разработку функций из модификации ядра в добавление модулей. Он решает проблему жесткого дизайна ботов за счет инверсии структур зависимостей: ядро ничего не знает о модулях, однако модули имеют полный доступ к возможностям через определенные контракты.
Quetza даёт архитектурный каркас, где разработка новой функциональности сводится к добавлению модулей, а не правке ядра. Это решает проблему жёсткой архитектуры за счёт инверсии зависимостей: ядро ничего не знает о модулях, а модули взаимодействуют с системой через типизированные контракты.
## Основная философия
### Проблема
Традиционные монолитные боты страдают от хрупкости. Добавление функций требует изменения логики ядра, переплетения зависимостей и управления растущими базами кода, которые трудно тестировать или изменять.
Традиционные монолитные боты хрупки: каждая новая функция требует правок в ядре, запутывает зависимости и раздувает кодовую базу, которую со временем становится сложно тестировать и поддерживать.
### Решение
Quetza рассматривает модули как плагины.
Quetza работает с модулями как с плагинами:
- **Расширение без правки ядра:** Добавляйте функции, создавая папку. Модификация кода ядра не требуется.
- **Изоляция:** Модули работают независимо.
- **Композиция:** Включайте или выключайте функции, просто добавляя или удаляя папки модулей.
- **Расширение без правки ядра:** добавьте каталог с модулем, и функция подключена. Менять исходный код ядра не нужно.
- **Изоляция:** модули выполняются независимо друг от друга.
- **Композиция:** подключайте и отключайте возможности, добавляя или удаляя каталоги модулей.
## Высокоуровневая архитектура
Quetza состоит из трех основных уровней:
Quetza состоит из трёх уровней:
1. **Базовый уровень (Foundation Layer)**: Клиент `Discord.js`, расширенный реестрами модулей (команды, события, модули). Он действует как сервис-локатор.
2. **Модульный уровень (Module Layer)**: Самодостаточные пакеты функций. Каждый модуль предоставляет собственные команды и события через стандартизированную структуру файлов.
3. **Сервисный уровень (Service Layer)**: Опциональные **Контроллеры**, которые управляют состоянием бизнес-логики (например, аудиоплееры, контекст разговора) на разных серверах (guilds).
1. **Базовый уровень (Foundation Layer):** клиент `Discord.js`, расширенный реестрами команд, событий и модулей. Выступает в роли сервис-локатора.
2. **Модульный уровень (Module Layer):** изолированные наборы функций. Модуль объявляет собственные команды и события через стандартизированную структуру файлов.
3. **Сервисный уровень (Service Layer):** контроллеры для управления состоянием бизнес-логики (например, аудиоплеером или контекстом диалога) на разных серверах (guilds).
## Ключевые концепции
### Ментальная модель
Для эффективной работы с Quetza примите следующую парадигму:
При работе с Quetza полезна следующая аналогия:
- **Модули — это плагины:** Независимые папки, обнаруживаемые во время выполнения.
- **Команды — это конечные точки (Endpoints):** Обработчики без сохранения состояния, действующие подобно эндпоинтам REST API.
- **События — это наблюдатели (Observers):** Реактивная логика, подписывающаяся на события жизненного цикла Discord.
- **Контроллеры — это сервисы:** Менеджеры состояния, восполняющие пробел между командами без состояния и постоянным поведением.
- **Модули — плагины:** независимые каталоги, обнаруживаемые во время запуска.
- **Команды — эндпоинты:** обработчики без состояния, работающие аналогично конечным точкам REST API.
- **События — наблюдатели (observers):** реактивная логика, подписанная на события жизненного цикла Discord.
- **Контроллеры — сервисы:** менеджеры состояния, связывающие команды без состояния с долговременной логикой бота.
### Границы системы
Понимание ограничений критически важно для проектирования:
Ограничения, которые важно учитывать при проектировании:
- **Отсутствие постоянства (Persistence):** Состояние в памяти теряется при перезагрузке. Для постоянного хранения требуется внешняя база данных.
- **Отсутствие межмодульного взаимодействия:** Модули не могут напрямую вызывать друг друга, что обеспечивает слабую связанность.
- **Отсутствие динамической загрузки:** Модули загружаются только при запуске.
- **Нет встроенного постоянного хранилища (persistence):** состояние в памяти сбрасывается при перезапуске. Для долговременного хранения нужна внешняя база данных.
- **Нет межмодульного взаимодействия:** модули не вызывают друг друга напрямую, что гарантирует слабую связанность.
- **Нет динамической перезагрузки:** модули загружаются один раз при старте процесса.
## Архитектурные инварианты
Quetza жертвует гибкостью ради предсказуемости с помощью строгих соглашений:
Quetza ставит предсказуемость выше избыточной гибкости за счёт строгих соглашений:
- **Обнаружение на основе соглашений:** Модули _обязаны_ следовать структуре директорий (`module.ts`, `commands/`, `events/`) для загрузки.
- **Однофазная инициализация:** Вся загрузка происходит до установления соединения с Discord.
- **Соблюдение интерфейсов:** Все команды и события должны экспортировать специфические интерфейсы для их регистрации.
- **Обнаружение по соглашениям:** чтобы модуль загрузился, структура его каталога _обязана_ содержать `module.ts`, `commands/` и `events/`.
- **Однофазная инициализация:** все модули загружаются до установки соединения с Discord.
- **Соблюдение контрактов:** команды и события должны экспортировать типизированные интерфейсы, необходимые для регистрации.
---
**See also:**
## См. также
- [Discord.js Documentation](https://discord.js.org/)
- [TypeScript Documentation](https://www.typescriptlang.org/)
- [Документация Discord.js](https://discord.js.org/)
- [Документация TypeScript](https://www.typescriptlang.org/)
@@ -3,4 +3,4 @@ title: Руководства
weight: 3
---
Пошаговые инструкции и руководства по реализации функций в Quetza.
Пошаговые руководства и инструкции по разработке и настройке возможностей Quetza.
@@ -3,34 +3,34 @@ title: Интеграция разговорного ИИ
weight: 4
---
**Цель:** Интегрировать большие языковые модели (LLM), управляя контекстом и ограничениями тайм-аута Discord.
**Цель:** подключить большие языковые модели (LLM), настроить управление контекстом и уложиться в тайм-ауты Discord.
## 1. Стратегия управления контекстом
Разговоры требуют истории, чтобы быть связными.
Чтобы диалог оставался связным, модели нужна история сообщений:
- **Область видимости (Scoping):** `Map<GuildID, Map<UserID, Message[]>>`
- **Ключ гильдии (Guild Key):** Предотвращает утечки между серверами.
- **Ключ пользователя (User Key):** Предотвращает путаницу между пользователями в публичных каналах.
- **Жизненный цикл:** Реализуйте "скользящее окно" (например, последние 50 сообщений) для предотвращения разрастания контекста.
- **Ключ сервера (Guild Key):** изолирует контекст и защищает от утечек данных между серверами.
- **Ключ пользователя (User Key):** разделяет реплики разных участников в публичных каналах.
- **Жизненный цикл:** настройте «скользящее окно» (например, последние 50 сообщений), чтобы контекст не разрастался.
## 2. Потоковая передача ответов
LLM работают медленно, а Discord требует быстрых ответов (тайм-аут 3 секунды).
LLM генерируют ответ постепенно, а Discord ждёт реакции не дольше 3 секунд.
{{% steps %}}
### Шаг 1: Отложенное взаимодействие (Defer Interaction)
### Шаг 1: Отложенный ответ (Defer Interaction)
Немедленно вызовите `interaction.deferReply()`. Это даст вам 15 минут.
Сразу вызовите `interaction.deferReply()`. Это продлит время ожидания ответа до 15 минут.
### Шаг 2: Вызов LLM
### Шаг 2: Запрос к LLM
Асинхронно вызовите ваш ИИ-сервис.
Асинхронно отправьте запрос к ИИ-сервису.
### Шаг 3: Редактирование ответа
### Шаг 3: Отправка ответа
Когда данные поступят, используйте `interaction.editReply(content)`.
Когда ответ готов, вызовите `interaction.editReply(content)`.
{{% /steps %}}
## 3. Пример реализации
@@ -52,5 +52,5 @@ export async function execute(interaction, controller) {
```
> [!WARNING]
> **Затраты на API:**
> API ИИ часто тарифицируются по токенам. Внедрите строгие лимиты запросов (rate limits) для пользователей в вашем Контроллере, чтобы предотвратить злоупотребления.
> **Расходы на API:**
> Провайдеры ИИ обычно тарифицируют запросы по токенам. Настройте в контроллере строгие лимиты запросов (rate limits) для пользователей, чтобы избежать лишних расходов.
@@ -1,25 +1,25 @@
---
title: Развертывание и эксплуатация
title: Развёртывание и эксплуатация
weight: 2
---
**Цель:** Настроить, развернуть и мониторить экземпляр бота Quetza.
**Цель:** настроить, развернуть и контролировать работу бота Quetza.
## 1. Подготовка окружения
Quetza полагается на переменные окружения для конфигурации. **Никогда** не сохраняйте их в систему контроля версий.
Quetza настраивается через переменные окружения. **Никогда** не сохраняйте их в систему контроля версий.
| Переменная | Описание | Обязательно |
| :--- | :--- | :--- |
| `DISCORD_TOKEN` | Токен бота из Discord Developer Portal | **Да** |
| `CLIENT_ID` | ID приложения для регистрации slash-команд | **Да** |
| `LLAMA_API_KEY` | Требуется при использовании модуля ИИ | Нет |
| `DISCORD_TOKEN` | Токен бота из Discord Developer Portal | **Да** |
| `CLIENT_ID` | Идентификатор приложения для регистрации слэш-команд | **Да** |
| `LLAMA_API_KEY` | Требуется для модуля ИИ | Нет |
## 2. Стратегии развертывания
## 2. Способы развёртывания
{{< tabs >}}
{{< tab name="Docker (Рекомендуется)" >}}
**Docker** обеспечивает стабильное окружение выполнения.
{{< tab name="Docker (рекомендуется)" >}}
**Docker** обеспечивает предсказуемое и изолированное окружение.
1. **Сборка** образа:
@@ -35,8 +35,8 @@ Quetza полагается на переменные окружения для
quetza-bot
```
{{< /tab >}}
{{< tab name="Из исходников (Source)" >}}
Развертывание **из исходников** полезно для разработки.
{{< tab name="Из исходного кода" >}}
Развёртывание **из исходного кода** подходит для разработки и отладки.
1. **Установка** зависимостей:
@@ -57,17 +57,17 @@ Quetza полагается на переменные окружения для
{{< /tab >}}
{{< /tabs >}}
## 3. Операционный мониторинг
## 3. Мониторинг
После запуска используйте следующие механизмы для проверки работоспособности:
После запуска проверьте состояние бота:
- **Логи:** Проверьте стандартный вывод (логи Docker) на наличие ошибок инициализации.
- **Команда `/ping`:** Проверяет задержку сети и отзывчивость API.
- **Команда `/modules`:** Перечисляет все загруженные модули, чтобы подтвердить доступность функций.
- **Логи:** проверьте стандартный вывод (логи Docker) на ошибки инициализации.
- **Команда `/ping`:** измеряет задержку сети и отзывчивость API.
- **Команда `/modules`:** выводит список загруженных модулей, подтверждая доступность функций.
> [!WARNING]
> **Ограничение масштабирования:**
> Quetza следует модели "один экземпляр на один токен". Не запускайте несколько экземпляров с одним и тем же токеном, так как это вызовет конфликты WebSocket.
> Quetza работает по модели «один экземпляр на один токен». Не запускайте несколько копий бота с одним токеном: это вызовет конфликты соединений WebSocket.
---
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more