feat: harden firewall policies telemetry and operations
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
Implement reliable agent apply, telemetry v2, analytics retention, guarded rollouts, enrollment, labels and webhooks. Improve ReUI frontend data safety and bounded list loading, and add Docker smoke, recovery and resource checks. Document local verification and pending native acceptance.
This commit is contained in:
1 parent
aa475848fd
commit
9fa6269174
242 files changed
+19872
-3057
No files matched your search
@@ -3,6 +3,12 @@ services:
|
||||
# evofw и evofirewall — один образ (алиас для drop-in).
|
||||
image: git.shx.one/denozord/evofw:latest
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
environment:
|
||||
DATABASE_URL: sqlite:/data/app.db
|
||||
SERVER_PORT: "8080"
|
||||
|
||||
@@ -14,16 +14,16 @@ BuildKit кэширует pnpm store через `RUN --mount=type=cache`. На C
|
||||
|
||||
| Образ | Runtime base | Заметка |
|
||||
|-------|----------------|---------|
|
||||
| `evofw`, `evofirewall` | `node:22-alpine` | один процесс: API + SPA + SQLite; `better-sqlite3` собирается в alpine на stage `build` |
|
||||
| `evofw`, `evofirewall` | `node:22-alpine` | один процесс: API + SPA + SQLite; `better-sqlite3` собирается в alpine в dependency stage |
|
||||
|
||||
`python3` / `make` / `g++` только в `deps`/`build`. Context режется корневым [.dockerignore](../../.dockerignore).
|
||||
`python3` / `make` / `g++` только в build stages. Независимые `api-build` / `web-build` ветви используют общий `deps`; runtime получает только production API deploy и SPA dist. Context режется корневым [.dockerignore](../../.dockerignore).
|
||||
|
||||
Теги `evofw` и `evofirewall` — один и тот же образ (`evofirewall` — drop-in для старого compose).
|
||||
|
||||
### Кэш registry
|
||||
|
||||
- `git.shx.one/<owner>/evofw-buildcache:node-buildcache` — **запись** только из `node-build`
|
||||
- `git.shx.one/<owner>/evofw-buildcache:base-node-22-alpine` — зеркало FROM с Docker Hub. Только `linux/amd64`; skip если тег уже в Gitea. Неуспешный copy не валит CD — bake берёт Docker Hub FROM.
|
||||
- `git.shx.one/<owner>/evofw-buildcache:node-buildcache` — **запись** только из финального `evofw` target (`mode=max` включает обе build branches). PR импортирует cache без записи.
|
||||
- `git.shx.one/<owner>/evofw-buildcache:base-node-22-alpine-<digest>` — зеркало FROM с Docker Hub. Только `linux/amd64`; upstream digest проверяется перед выбором зеркала, bake получает mirror reference по digest. Ошибка resolution/copy блокирует CD.
|
||||
|
||||
`pull = false` в bake: не перекачивать FROM, если слой уже в builder. Не запускайте `docker system prune -a` на runner.
|
||||
|
||||
@@ -31,6 +31,8 @@ BuildKit кэширует pnpm store через `RUN --mount=type=cache`. На C
|
||||
|
||||
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evofw-buildcache:*` в registry и пересоберите.
|
||||
|
||||
Runtime smoke, backup/restore, baseline и оставшиеся release gates: [runtime-validation.md](../../docs/runtime-validation.md).
|
||||
|
||||
## Локальная сборка
|
||||
|
||||
Из корня репозитория:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
container="${1:?Usage: backup.sh CONTAINER OUTPUT_FILE}"
|
||||
output="${2:?OUTPUT_FILE required}"
|
||||
[ ! -e "$output" ] || { echo 'Output already exists' >&2; exit 1; }
|
||||
temporary="/tmp/evofw-backup-$(date +%s)-$$.db"
|
||||
cleanup() { docker exec "$container" rm -f "$temporary" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
docker exec "$container" sh -ec 'node tools/sqlite-backup.mjs backup "${DATABASE_URL#sqlite:}" "$1"' sh "$temporary"
|
||||
docker cp "$container:$temporary" "$output.partial"
|
||||
mv "$output.partial" "$output"
|
||||
@@ -0,0 +1,17 @@
|
||||
# Bound persistent CI builder storage; registry mode=max remains the durable cache.
|
||||
[worker.oci]
|
||||
gc = true
|
||||
reservedSpace = "2GB"
|
||||
maxUsedSpace = "12GB"
|
||||
minFreeSpace = "5GB"
|
||||
|
||||
[[worker.oci.gcpolicy]]
|
||||
filters = ["type==source.local", "type==exec.cachemount", "type==source.git.checkout"]
|
||||
keepDuration = "48h"
|
||||
maxUsedSpace = "1GB"
|
||||
|
||||
[[worker.oci.gcpolicy]]
|
||||
all = true
|
||||
reservedSpace = "2GB"
|
||||
maxUsedSpace = "12GB"
|
||||
minFreeSpace = "5GB"
|
||||
@@ -72,32 +72,6 @@ target "_common" {
|
||||
pull = false
|
||||
}
|
||||
|
||||
target "node-deps" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evofw/Dockerfile"
|
||||
target = "deps"
|
||||
args = {
|
||||
BASE_NODE = BASE_NODE
|
||||
}
|
||||
cache-from = node-cache-from()
|
||||
}
|
||||
|
||||
target "node-build" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evofw/Dockerfile"
|
||||
target = "build"
|
||||
args = {
|
||||
BASE_NODE = BASE_NODE
|
||||
}
|
||||
contexts = {
|
||||
deps = "target:node-deps"
|
||||
}
|
||||
cache-from = node-cache-from()
|
||||
cache-to = node-cache-to-export()
|
||||
}
|
||||
|
||||
target "evofw" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
@@ -109,9 +83,7 @@ target "evofw" {
|
||||
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
||||
BUILD_TIME = BUILD_TIME
|
||||
}
|
||||
contexts = {
|
||||
build = "target:node-build"
|
||||
}
|
||||
cache-from = node-cache-from()
|
||||
cache-to = node-cache-to-export()
|
||||
tags = concat(image-tags("evofw"), image-tags("evofirewall"))
|
||||
}
|
||||
@@ -16,23 +16,22 @@ COPY packages/db/package.json packages/db/
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM deps AS build
|
||||
COPY apps/web apps/web
|
||||
FROM deps AS api-build
|
||||
COPY apps/api apps/api
|
||||
COPY packages/ui packages/ui
|
||||
COPY packages/shared packages/shared
|
||||
COPY packages/db packages/db
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm turbo build --filter=@evofw/web --filter=@evofw/api \
|
||||
pnpm turbo build --filter=@evofw/api \
|
||||
&& pnpm --filter @evofw/api deploy --prod /out \
|
||||
&& cp -r apps/web/dist /out/static \
|
||||
&& mkdir -p /out/dist/agent-scripts \
|
||||
&& cp -r apps/api/src/agent-scripts/* /out/dist/agent-scripts/ \
|
||||
&& rm -rf /out/src /out/test /out/.turbo \
|
||||
&& rm -rf /out/node_modules/@evofw/db/src /out/node_modules/@evofw/db/.turbo \
|
||||
&& rm -rf /out/node_modules/@evofw/shared/src /out/node_modules/@evofw/shared/.turbo \
|
||||
&& find /out -mindepth 1 -maxdepth 1 ! -name dist ! -name node_modules ! -name package.json -exec rm -rf {} + \
|
||||
&& find /out -name '*.map' -delete
|
||||
|
||||
FROM deps AS web-build
|
||||
COPY apps/web apps/web
|
||||
COPY packages/ui packages/ui
|
||||
COPY packages/shared packages/shared
|
||||
RUN pnpm turbo build --filter=@evofw/web
|
||||
|
||||
FROM ${BASE_NODE} AS runtime
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates
|
||||
@@ -46,9 +45,12 @@ ENV NODE_ENV=production \
|
||||
APP_VERSION=${VERSION} \
|
||||
GIT_SHA=${GIT_SHA} \
|
||||
BUILD_TIME=${BUILD_TIME}
|
||||
COPY --from=build /out ./
|
||||
COPY --from=api-build /out ./
|
||||
COPY --from=web-build /repo/apps/web/dist ./static
|
||||
COPY deploy/docker/evofw/sqlite-backup.mjs ./tools/sqlite-backup.mjs
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.SERVER_PORT||8080)+'/ready',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
STOPSIGNAL SIGTERM
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs'
|
||||
|
||||
// Resolve through the production workspace package; pnpm isolates transitive deps.
|
||||
const require = createRequire(resolve('node_modules/@evofw/db/package.json'))
|
||||
const Database = require('better-sqlite3')
|
||||
const [mode, input, output, offline] = process.argv.slice(2)
|
||||
if (!['backup', 'restore', 'verify'].includes(mode) || !input || (mode !== 'verify' && !output)) {
|
||||
throw new Error('Usage: sqlite-backup.mjs backup|verify|restore INPUT [OUTPUT] [--offline]')
|
||||
}
|
||||
if (mode === 'restore' && offline !== '--offline') throw new Error('Restore requires a stopped volume and --offline')
|
||||
if (output && resolve(input) === resolve(output)) throw new Error('Input and output must differ')
|
||||
if (mode === 'backup' && existsSync(output)) throw new Error('Backup destination already exists')
|
||||
const source = new Database(input, { readonly: true, fileMustExist: true })
|
||||
let temporary
|
||||
try {
|
||||
if (source.pragma('integrity_check', { simple: true }) !== 'ok') throw new Error('Source integrity_check failed')
|
||||
if (mode !== 'verify') {
|
||||
mkdirSync(dirname(output), { recursive: true })
|
||||
temporary = `${output}.partial-${process.pid}`
|
||||
// SQLite online backup includes committed WAL pages while the API runs.
|
||||
await source.backup(temporary)
|
||||
const check = new Database(temporary, { readonly: true, fileMustExist: true })
|
||||
try {
|
||||
if (check.pragma('integrity_check', { simple: true }) !== 'ok') throw new Error('Backup integrity_check failed')
|
||||
if (check.pragma('foreign_key_check').length) throw new Error('Backup foreign_key_check failed')
|
||||
} finally { check.close() }
|
||||
if (mode === 'restore') {
|
||||
// Only safe after the caller has excluded every process using this volume.
|
||||
rmSync(`${output}-wal`, { force: true })
|
||||
rmSync(`${output}-shm`, { force: true })
|
||||
}
|
||||
renameSync(temporary, output)
|
||||
temporary = undefined
|
||||
}
|
||||
console.log(JSON.stringify({ mode, input, output, integrity: 'ok' }))
|
||||
} finally {
|
||||
source.close()
|
||||
if (temporary) rmSync(temporary, { force: true })
|
||||
}
|
||||
@@ -1,72 +1,40 @@
|
||||
#!/usr/bin/env sh
|
||||
# Copy-by-digest зеркало базовых образов в Gitea Container Registry (без rebuild).
|
||||
# Требует: docker login в REGISTRY host, docker buildx.
|
||||
# REGISTRY=git.shx.one/<owner> (без суффикса /evofw-buildcache)
|
||||
#
|
||||
# Источник library-образов — Docker Hub (docker.io).
|
||||
# Если тег уже есть в Gitea — skip (без повторного pull).
|
||||
# Копируем только linux/amd64 — bake platforms совпадает.
|
||||
# Шаг CD не должен падать: неуспешный copy оставляет bake на Docker Hub FROM.
|
||||
# Resolve upstream before mirroring; never reuse a stale mutable mirror silently.
|
||||
set -eu
|
||||
REGISTRY="${REGISTRY:?REGISTRY required (git.shx.one/<owner>)}"
|
||||
CACHE_REPO="${REGISTRY}/evofw-buildcache"
|
||||
ENV_FILE="${MIRROR_ENV_FILE:-}"
|
||||
SOURCE="${BASE_NODE_SOURCE:-docker.io/library/node:22-alpine}"
|
||||
PLATFORM="${MIRROR_PLATFORM:-linux/amd64}"
|
||||
RETRIES="${MIRROR_RETRIES:-4}"
|
||||
|
||||
tmp="$(mktemp)"
|
||||
cleanup() { rm -f "$tmp"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
dest_exists() {
|
||||
docker buildx imagetools inspect "$1" >/dev/null 2>&1
|
||||
resolve_digest() {
|
||||
digest="$(docker buildx imagetools inspect "$1" | awk '/^Digest:/ {print $2; exit}')"
|
||||
printf '%s\n' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$' || { echo "Cannot resolve verified digest: $1" >&2; return 1; }
|
||||
printf '%s' "$digest"
|
||||
}
|
||||
|
||||
copy_retry() {
|
||||
src="$1"
|
||||
dest="$2"
|
||||
n=0
|
||||
while [ "$n" -lt "$RETRIES" ]; do
|
||||
n=$((n + 1))
|
||||
if docker buildx imagetools create --platform "$PLATFORM" --tag "$dest" "$src"; then
|
||||
return 0
|
||||
fi
|
||||
delay=$((n * 25))
|
||||
echo "mirror retry ${n}/${RETRIES}, sleep ${delay}s: ${dest}"
|
||||
sleep "$delay"
|
||||
source_digest="$(resolve_digest "$SOURCE")"
|
||||
source_repo="${SOURCE%@*}"
|
||||
# Tags may remain before @; repo:tag@digest is a valid immutable reference.
|
||||
source_ref="$source_repo@$source_digest"
|
||||
suffix="$(printf '%s' "${source_digest#sha256:}" | cut -c1-16)"
|
||||
destination="$REGISTRY/evofw-buildcache:base-node-22-alpine-$suffix"
|
||||
if ! docker buildx imagetools inspect "$destination" >/dev/null 2>&1; then
|
||||
attempt=0
|
||||
until docker buildx imagetools create --platform "$PLATFORM" --tag "$destination" "$source_ref"; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 4 ] || exit 1
|
||||
sleep 30
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
mirror() {
|
||||
src="$1"
|
||||
tag="$2"
|
||||
var="$3"
|
||||
dest="${CACHE_REPO}:${tag}"
|
||||
if dest_exists "$dest"; then
|
||||
echo "skip (already in registry): ${dest}"
|
||||
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
|
||||
return 0
|
||||
fi
|
||||
echo "mirror ${src} -> ${dest} (${PLATFORM})"
|
||||
if copy_retry "$src" "$dest"; then
|
||||
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
|
||||
return 0
|
||||
fi
|
||||
echo "warn: ${dest} not mirrored — bake uses Docker Hub FROM for ${var}"
|
||||
return 0
|
||||
}
|
||||
|
||||
mirror "docker.io/library/node:22-alpine" "base-node-22-alpine" BASE_NODE
|
||||
|
||||
echo "----- mirrored BASE_* -----"
|
||||
cat "$tmp"
|
||||
if [ -n "$ENV_FILE" ]; then
|
||||
env_dir="$(dirname "$ENV_FILE")"
|
||||
if [ -d "$env_dir" ]; then
|
||||
cp "$tmp" "$ENV_FILE"
|
||||
echo "wrote ${ENV_FILE}"
|
||||
else
|
||||
echo "warn: MIRROR_ENV_FILE dir missing (${env_dir}) — skip write"
|
||||
fi
|
||||
fi
|
||||
mirror_digest="$(resolve_digest "$destination")"
|
||||
# Existing tags are untrusted: compare the selected platform descriptor against
|
||||
# the immutable upstream index, even when no copy was necessary this run.
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
|
||||
docker buildx imagetools inspect --raw "$source_ref" >"$tmp/source.json"
|
||||
docker buildx imagetools inspect --raw "$destination@$mirror_digest" >"$tmp/mirror.json"
|
||||
source_platform="$(node scripts/ci/manifest-platform.mjs "$tmp/source.json" "$source_digest" "$PLATFORM")"
|
||||
mirror_platform="$(node scripts/ci/manifest-platform.mjs "$tmp/mirror.json" "$mirror_digest" "$PLATFORM")"
|
||||
[ "$source_platform" = "$mirror_platform" ] || { echo 'Mirror platform digest differs from upstream; refusing existing tag' >&2; exit 1; }
|
||||
reference="$destination@$mirror_digest"
|
||||
printf 'BASE_NODE=%s\n' "$reference"
|
||||
if [ -n "${MIRROR_ENV_FILE:-}" ]; then
|
||||
printf 'BASE_NODE=%s\n' "$reference" >"$MIRROR_ENV_FILE"
|
||||
fi
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
image="${1:?Usage: restore.sh IMAGE VOLUME BACKUP_FILE}"
|
||||
volume="${2:?VOLUME required}"
|
||||
backup="${3:?BACKUP_FILE required}"
|
||||
[ -f "$backup" ] || { echo 'Backup file missing' >&2; exit 1; }
|
||||
backup="$(cd "$(dirname "$backup")" && pwd)/$(basename "$backup")"
|
||||
# Named volumes only. All users must remain stopped for the whole operation.
|
||||
docker volume inspect "$volume" >/dev/null
|
||||
[ -z "$(docker ps -q --filter "volume=$volume")" ] || { echo 'Stop every container using the volume before restore' >&2; exit 1; }
|
||||
docker run --rm --network none --mount "type=volume,src=$volume,dst=/data" \
|
||||
--mount "type=bind,src=$backup,dst=/backup.db,readonly" --entrypoint node \
|
||||
"$image" tools/sqlite-backup.mjs restore /backup.db /data/app.db --offline
|
||||
Reference in new issue
Block a user