fix(ci): harden release and deployment workflow

This commit is contained in:
Andrey committed 2026-07-14 12:19:38 +03:00
1 parent ae98760824
commit e13542fc45
27 files changed
+1424 -348

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# common.sh — shared helpers for the CustoCRM deployment CLI.
CUSTOCRM_NON_INTERACTIVE=0
CUSTOCRM_JSON=0
log() { printf '[custocrm] %s\n' "$*" >&2; }
log_ok() { printf '[custocrm] OK: %s\n' "$*" >&2; }
log_warn() { printf '[custocrm] WARN: %s\n' "$*" >&2; }
log_err() { printf '[custocrm] ERROR: %s\n' "$*" >&2; }
die() {
local msg="$1"
local code="${2:-1}"
if [[ "$CUSTOCRM_JSON" == "1" ]]; then
printf '{"status":"error","error":%s}\n' "$(json_escape "$msg")"
else
log_err "$msg"
fi
exit "$code"
}
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
printf '"%s"' "$s"
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" 2
}
release_dir() {
printf '%s' "${CUSTOCRM_RELEASE_DIR:?CUSTOCRM_RELEASE_DIR is not set}"
}
instance_dir() {
printf '%s' "${CUSTOCRM_INSTANCE_DIR:?CUSTOCRM_INSTANCE_DIR is not set}"
}
instance_env_file() { printf '%s/.env' "$(instance_dir)"; }
release_env_file() { printf '%s/release.env' "$(release_dir)"; }
release_checksums_file() { printf '%s/checksums.txt' "$(release_dir)"; }
compose_file() { printf '%s/compose.yaml' "$(release_dir)"; }
state_dir() { printf '%s/state' "$(instance_dir)"; }
data_dir() { printf '%s/data' "$(instance_dir)"; }
ensure_instance_dirs() {
local d
for d in "$(instance_dir)" "$(state_dir)" "$(data_dir)" \
"$(instance_dir)/backups" "$(instance_dir)/logs"; do
mkdir -p "$d" || die "cannot create directory: $d"
done
}
LOCK_FD=9
acquire_lock() {
local lock_file
lock_file="$(state_dir)/deploy.lock"
exec 9>"$lock_file" || die "cannot open lock file: $lock_file"
if ! flock -n 9; then
die "another operation is in progress (lock held): $lock_file" 3
fi
}
release_lock() {
flock -u 9 2>/dev/null || true
exec 9>&- 2>/dev/null || true
}
env_get() {
local file="$1" key="$2"
[[ -f "$file" ]] || return 0
awk -F= -v k="$key" '$1==k && $0 !~ /^#/ {sub(/^[^=]*=/,""); print; exit}' "$file"
}
verify_release_checksums() {
local checksums
checksums="$(release_checksums_file)"
[[ -f "$checksums" ]] || {
log_err "release checksums missing: $checksums"
return 1
}
command -v sha256sum >/dev/null 2>&1 || {
log_err "required command not found: sha256sum"
return 1
}
(
cd "$(release_dir)" || exit 1
sha256sum -c checksums.txt >/dev/null
) || {
log_err "release checksum verification failed"
return 1
}
}
validate_calls_network_boundary() {
local web_ip turn_ip
web_ip="$(env_get "$(instance_env_file)" CUSTOCRM_WEB_LISTENING_IP)"
turn_ip="$(env_get "$(instance_env_file)" HUB_TURN_LISTENING_IP)"
[[ -n "$web_ip" ]] || {
log_err "CUSTOCRM_WEB_LISTENING_IP is required for calls profile"
return 1
}
[[ "$web_ip" != "0.0.0.0" ]] || {
log_err "CUSTOCRM_WEB_LISTENING_IP cannot be 0.0.0.0 when calls profile uses TURN TLS on 443"
return 1
}
[[ "$web_ip" != "$turn_ip" ]] || {
log_err "web and TURN listeners must use different public IP addresses"
return 1
}
}
release_image_keys() {
printf '%s\n' \
CUSTOCRM_BACKEND_IMAGE \
CUSTOCRM_FRONTEND_IMAGE \
CUSTOCRM_POSTGRES_IMAGE \
CUSTOCRM_REDIS_IMAGE \
CUSTOCRM_GATEWAY_IMAGE \
CUSTOCRM_COTURN_IMAGE
}
validate_release_image_refs() {
local key ref failed=0
while IFS= read -r key; do
ref="$(env_get "$(release_env_file)" "$key")"
if [[ ! "$ref" =~ @sha256:[0-9a-fA-F]{64}$ ]]; then
log_err "$key must be an immutable @sha256 reference"
failed=1
fi
done < <(release_image_keys)
[[ "$failed" == "0" ]]
}
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# compose.sh — canonical Docker Compose invocation for a release + instance pair.
# Release files remain immutable; runtime data and .env stay in INSTANCE_DIR.
run_compose() {
local inst rel
inst="$(instance_dir)"
rel="$(release_dir)"
(
cd "$inst" || exit 1
docker compose \
--project-directory "$inst" \
--env-file "$inst/.env" \
--env-file "$rel/release.env" \
-f "$rel/compose.yaml" \
"$@"
)
}
compose_config_validate() {
run_compose config -q
}
profile_enabled() {
local p="$1" profiles
profiles="$(env_get "$(instance_env_file)" COMPOSE_PROFILES)"
[[ ",$profiles," == *",$p,"* ]]
}
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# deploy.sh — canonical deployment workflow (ADR-HUB-0028 / SPEC-HUB-0019).
cmd_deploy() {
ensure_instance_dirs
acquire_lock
trap release_lock EXIT
log "deploy: validating release and instance config"
_deploy_validate || die "deploy: validation failed" 1
log "deploy: pulling immutable images"
run_compose pull || die "deploy: image pull failed" 1
log "deploy: starting infrastructure (postgres, redis)"
run_compose up -d postgres redis || die "deploy: infrastructure start failed" 1
_wait_healthy postgres 60 || die "deploy: postgres did not become healthy" 1
_wait_healthy redis 30 || die "deploy: redis did not become healthy" 1
log "deploy: running one-shot init (migrate)"
run_compose run --rm init || die "deploy: init (migrate) failed" 1
log "deploy: starting application services"
local app_services=(backend worker frontend gateway)
if profile_enabled calls; then
app_services+=(coturn)
fi
run_compose up -d "${app_services[@]}" || die "deploy: application start failed" 1
log "deploy: waiting for application health"
_wait_healthy backend 90 || die "deploy: backend did not become healthy" 1
_wait_running frontend 30 || die "deploy: frontend did not start" 1
_wait_running gateway 30 || die "deploy: gateway did not start" 1
if profile_enabled calls; then
_wait_healthy coturn 60 || die "deploy: coturn did not become healthy" 1
fi
log "deploy: running smoke checks"
_smoke || die "deploy: smoke checks failed" 1
_record_release
log_ok "deploy: complete (release: $(_applied_version_target))"
}
_deploy_validate() {
[[ -f "$(compose_file)" ]] || { log_err "compose.yaml missing"; return 1; }
[[ -f "$(instance_env_file)" ]] || { log_err "instance .env missing"; return 1; }
[[ -f "$(release_env_file)" ]] || { log_err "release.env missing"; return 1; }
verify_release_checksums || return 1
validate_release_image_refs || return 1
local domain
domain="$(env_get "$(instance_env_file)" CUSTOCRM_DOMAIN)"
[[ -n "$domain" ]] || { log_err "CUSTOCRM_DOMAIN not set"; return 1; }
if profile_enabled calls; then
validate_calls_network_boundary || return 1
fi
compose_config_validate >/dev/null 2>&1 || {
log_err "compose config invalid"
return 1
}
}
_wait_healthy() {
local svc="$1" timeout="$2" waited=0 state
while [[ "$waited" -lt "$timeout" ]]; do
state="$(run_compose ps --format json "$svc" 2>/dev/null | _first_json_service_state)"
[[ "$state" == "healthy" ]] && return 0
sleep 3
waited=$((waited + 3))
done
return 1
}
_wait_running() {
local svc="$1" timeout="$2" waited=0 state
while [[ "$waited" -lt "$timeout" ]]; do
state="$(run_compose ps --format json "$svc" 2>/dev/null | _first_json_service_state)"
case "$state" in
healthy|running|Up*) return 0 ;;
esac
sleep 3
waited=$((waited + 3))
done
return 1
}
_first_json_service_state() {
local line
IFS= read -r line || return 0
if [[ $line =~ \"Health\":\"([^\"]*)\" ]] && [[ -n "${BASH_REMATCH[1]}" ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
elif [[ $line =~ \"State\":\"([^\"]*)\" ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
elif [[ $line =~ \"Status\":\"([^\"]*)\" ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
fi
}
_smoke() {
local domain
domain="$(env_get "$(instance_env_file)" CUSTOCRM_DOMAIN)"
run_compose exec -T backend python - <<'PY' >/dev/null 2>&1 || {
import os
import urllib.error
import urllib.request
health_host = os.environ.get("HUB_HEALTHCHECK_HOST") or os.environ["CUSTOCRM_DOMAIN"]
health_request = urllib.request.Request(
"http://127.0.0.1:8000/api/v1/health/ready/",
headers={"Host": health_host, "X-Forwarded-Proto": "https"},
)
with urllib.request.urlopen(health_request, timeout=5) as response:
assert response.status == 200
with urllib.request.urlopen("http://frontend/", timeout=5) as response:
assert response.status == 200
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, request, file_pointer, code, message, headers, new_url):
return None
opener = urllib.request.build_opener(NoRedirect)
domain = os.environ["CUSTOCRM_DOMAIN"]
gateway_request = urllib.request.Request("http://gateway/", headers={"Host": domain})
try:
opener.open(gateway_request, timeout=5)
except urllib.error.HTTPError as error:
assert error.code in {301, 302, 303, 307, 308}
assert error.headers.get("Location", "").startswith(f"https://{domain}")
else:
raise AssertionError("gateway did not redirect HTTP to HTTPS")
PY
log_err "smoke: internal backend/frontend/gateway checks failed"
return 1
}
log_ok "smoke: backend, frontend and gateway"
if command -v curl >/dev/null 2>&1; then
local code
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "https://$domain/" 2>/dev/null || true)"
if [[ "$code" =~ ^(200|30[12378])$ ]]; then
log_ok "smoke: public HTTPS endpoint"
else
log_warn "smoke: public HTTPS endpoint is not reachable yet (HTTP $code)"
fi
fi
}
_state_file() { printf '%s/applied_release' "$(state_dir)"; }
_record_release() {
local ver
ver="$(env_get "$(release_env_file)" CUSTOCRM_VERSION)"
printf 'applied_version=%s\napplied_at=%s\n' "${ver:-unknown}" \
"$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" > "$(_state_file)"
}
_applied_version_target() {
env_get "$(release_env_file)" CUSTOCRM_VERSION || printf 'unknown'
}
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# doctor.sh — read-only pre-flight checks before deployment.
cmd_doctor() {
local failures=0 inst rel
inst="$(instance_dir)"
rel="$(release_dir)"
_doctor_report() {
local ok="$1"
shift
local msg="$*"
if [[ "$ok" == "1" ]]; then
[[ "$CUSTOCRM_JSON" == "1" ]] || log_ok "$msg"
else
[[ "$CUSTOCRM_JSON" == "1" ]] || log_err "$msg"
failures=$((failures + 1))
fi
}
require_cmd docker
_doctor_report 1 "docker found"
require_cmd flock
_doctor_report 1 "flock found"
if command -v sha256sum >/dev/null 2>&1; then
_doctor_report 1 "sha256sum found"
else
_doctor_report 0 "sha256sum missing"
fi
if docker info >/dev/null 2>&1; then
_doctor_report 1 "docker daemon reachable"
else
_doctor_report 0 "docker daemon not reachable"
fi
if docker compose version >/dev/null 2>&1; then
_doctor_report 1 "docker compose plugin available"
else
_doctor_report 0 "docker compose plugin missing"
fi
local arch
arch="$(uname -m 2>/dev/null || echo unknown)"
if [[ "$arch" == "x86_64" ]]; then
_doctor_report 1 "host arch x86_64"
else
_doctor_report 0 "unsupported host arch: $arch (target: x86_64)"
fi
if [[ -d "$inst" ]] && [[ -w "$inst" ]]; then
_doctor_report 1 "instance dir writable: $inst"
else
_doctor_report 0 "instance dir not writable: $inst"
fi
if [[ -f "$(compose_file)" ]]; then
_doctor_report 1 "compose.yaml present in release: $rel"
else
_doctor_report 0 "compose.yaml missing in release: $rel"
fi
if [[ -f "$(instance_env_file)" ]]; then
_doctor_report 1 "instance .env present"
else
_doctor_report 0 "instance .env missing: $(instance_env_file)"
fi
if [[ -f "$(release_env_file)" ]]; then
_doctor_report 1 "release.env present"
else
_doctor_report 0 "release.env missing: $(release_env_file)"
fi
if verify_release_checksums; then
_doctor_report 1 "release checksums valid"
else
_doctor_report 0 "release checksums invalid"
fi
if validate_release_image_refs; then
_doctor_report 1 "release image references are immutable"
else
_doctor_report 0 "release image references are invalid"
fi
local domain
domain="$(env_get "$(instance_env_file)" CUSTOCRM_DOMAIN)"
if [[ -n "$domain" ]]; then
_doctor_report 1 "CUSTOCRM_DOMAIN set: $domain"
else
_doctor_report 0 "CUSTOCRM_DOMAIN not set"
fi
local pg_pwd
pg_pwd="$(env_get "$(instance_env_file)" POSTGRES_PASSWORD)"
if [[ -n "$pg_pwd" ]]; then
_doctor_report 1 "POSTGRES_PASSWORD set"
else
_doctor_report 0 "POSTGRES_PASSWORD empty"
fi
local secret
secret="$(env_get "$(instance_env_file)" HUB_SECRET_KEY)"
if [[ -n "$secret" ]] && [[ "$secret" != "change-me-long-random-secret" ]]; then
_doctor_report 1 "HUB_SECRET_KEY set"
else
_doctor_report 0 "HUB_SECRET_KEY default/empty"
fi
if profile_enabled calls; then
local missing=0 k
for k in HUB_CALL_TURN_SECRET HUB_CALL_TURN_REALM HUB_TURN_EXTERNAL_IP HUB_TURN_LISTENING_IP; do
if [[ -z "$(env_get "$(instance_env_file)" "$k")" ]]; then
_doctor_report 0 "$k required for calls profile"
missing=1
fi
done
if [[ "$missing" == "0" ]] && validate_calls_network_boundary; then
_doctor_report 1 "calls profile network boundary valid"
else
_doctor_report 0 "calls profile network boundary invalid"
fi
fi
if { [[ -d "$inst/backups" ]] && [[ -w "$inst/backups" ]]; } || [[ -w "$inst" ]]; then
_doctor_report 1 "backup dir available"
else
_doctor_report 0 "backup dir not creatable: $inst/backups"
fi
if compose_config_validate >/dev/null 2>&1; then
_doctor_report 1 "compose config valid"
else
_doctor_report 0 "compose config invalid"
fi
if [[ "$failures" != "0" ]]; then
die "doctor: $failures check(s) failed" 1
fi
if [[ "$CUSTOCRM_JSON" == "1" ]]; then
printf '{"status":"ok","checks":"passed"}\n'
else
log_ok "doctor: all checks passed"
fi
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# logs.sh — обёртка над `docker compose logs` с фильтром сервиса.
cmd_logs() {
local svc=""
while [[ $# -gt 0 ]]; do
case "$1" in
-f|--follow) shift; ;;
*) svc="$1"; shift; ;;
esac
done
if [[ -n "$svc" ]]; then
run_compose logs --tail=200 "$svc" || die "logs: service not found or not running: $svc" 1
else
run_compose logs --tail=200 || die "logs: cannot fetch logs (not deployed yet?)" 1
fi
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# status.sh — observability: версия, состояние сервисов, profiles, последняя
# ошибка (SPEC-HUB-0019 §29). Installation-status (NEW/READY/…) — этап 2.
cmd_status() {
local inst rel applied target
inst="$(instance_dir)"
rel="$(release_dir)"
target="$(env_get "$(release_env_file)" CUSTOCRM_VERSION)"
applied="$(env_get "$(_state_file)" applied_version)"
if [[ "$CUSTOCRM_JSON" == "1" ]]; then
printf '{"status":"info","instance":%s,"release_dir":%s,"target_version":%s,"applied_version":%s}\n' \
"$(json_escape "$inst")" "$(json_escape "$rel")" \
"$(json_escape "${target:-unknown}")" "$(json_escape "${applied:-none}")"
return 0
fi
printf 'CustoCRM status\n'
printf ' instance dir: %s\n' "$inst"
printf ' release dir: %s\n' "$rel"
printf ' target version: %s\n' "${target:-unknown}"
printf ' applied version: %s\n' "${applied:-none}"
printf ' profiles: %s\n' "$(env_get "$(instance_env_file)" COMPOSE_PROFILES || echo none)"
if [[ -f "$(_state_file)" ]]; then
printf ' last applied at: %s\n' "$(env_get "$(_state_file)" applied_at)"
else
printf ' last applied at: (none)\n'
fi
if docker info >/dev/null 2>&1; then
printf '\nServices:\n'
run_compose ps 2>/dev/null || log_warn "compose ps failed (not deployed yet?)"
else
printf '\nServices: (docker daemon not reachable)\n'
fi
}
_state_file() { printf '%s/applied_release' "$(state_dir)"; }
+2 -3
View File
@@ -14,12 +14,11 @@ COPY apps/internal-ui ./apps/internal-ui
COPY apps/web-chat ./apps/web-chat
COPY packages ./packages
# Build-time домен не привязывается (ADR-HUB-0028 §10): один образ работает на любом
# домене; сниппет/WS/API выводятся от текущего origin в рантайме.
ARG VITE_API_BASE_URL=
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ARG VITE_PUBLIC_HUB_URL=
ENV VITE_PUBLIC_HUB_URL=${VITE_PUBLIC_HUB_URL}
RUN npm --workspace @edevs/internal-ui run build
RUN npm --workspace @edevs/web-chat run build
-34
View File
@@ -1,34 +0,0 @@
server {
listen 80;
server_name hub.edevs.tech;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name hub.edevs.tech;
# Set these paths after issuing the certificate with certbot.
ssl_certificate /etc/letsencrypt/live/hub.edevs.tech/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hub.edevs.tech/privkey.pem;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ server {
}
location / {
proxy_pass http://internal-ui:5173;
proxy_pass http://frontend:5173;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;