feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
storage/backups/
|
||||
storage/geoip/
|
||||
@@ -0,0 +1,66 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-bookworm-slim AS deps
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
# Do not set NODE_ENV=production here — npm would omit typescript needed for the build stage.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/contracts/package.json packages/contracts/
|
||||
COPY backend/package.json backend/
|
||||
# Drop root frontend deps (Next/React/UI) so backend image stays lean.
|
||||
RUN node -e "\
|
||||
const fs=require('fs');\
|
||||
const p=JSON.parse(fs.readFileSync('package.json','utf8'));\
|
||||
p.dependencies={};\
|
||||
p.devDependencies={};\
|
||||
delete p.scripts;\
|
||||
p.workspaces=['packages/*','backend'];\
|
||||
fs.writeFileSync('package.json', JSON.stringify(p,null,2)+'\\n');\
|
||||
"
|
||||
# Prefer npm ci; if lockfile rejects stripped root package.json, fall back to install.
|
||||
RUN (npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts \
|
||||
|| npm install --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts) \
|
||||
&& npm rebuild better-sqlite3
|
||||
|
||||
FROM deps AS build
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
COPY packages/contracts packages/contracts
|
||||
COPY backend backend
|
||||
RUN npm run build -w @mmapp/contracts \
|
||||
&& npm run build -w mikrotik-manager-backend \
|
||||
&& npm prune --omit=dev \
|
||||
# npm may nest workspace deps (e.g. dotenv) under backend/node_modules — keep dir for COPY
|
||||
&& mkdir -p backend/node_modules
|
||||
|
||||
FROM node:22-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
# Keep system packages independent of application changes and version tags.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gnupg wget ca-certificates \
|
||||
&& wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client-18 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /app/data
|
||||
ARG APP_VERSION=dev
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8000
|
||||
ENV DATABASE_PATH=/app/data/mikrotik.db
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
COPY --from=build /app/package.json /app/package-lock.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/packages/contracts ./packages/contracts
|
||||
COPY --from=build /app/backend/dist ./backend/dist
|
||||
COPY --from=build /app/backend/dist-operator ./backend/dist-operator
|
||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||
COPY --from=build /app/backend/mcp-coverage.json ./backend/mcp-coverage.json
|
||||
COPY --from=build /app/backend/drizzle ./backend/drizzle
|
||||
COPY deploy/netflow-agent/install.sh ./deploy/netflow-agent/install.sh
|
||||
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
||||
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||
EXPOSE 8000
|
||||
CMD ["node", "backend/dist/index.js"]
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "drizzle-kit"
|
||||
import { config } from "dotenv"
|
||||
|
||||
config()
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "postgres://mmapp:mmapp@127.0.0.1:5432/mmapp",
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
})
|
||||
@@ -0,0 +1,626 @@
|
||||
-- MikrotikManager PostgreSQL 18 schema (not 1:1 SQLite).
|
||||
-- Applied once at backend boot via src/db/migrate.ts
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 443,
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
type TEXT NOT NULL DEFAULT 'home-router'
|
||||
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
|
||||
site TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
asn TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
lan_subnet TEXT NOT NULL DEFAULT '',
|
||||
wan_uplinks JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 120,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_public_key TEXT NOT NULL DEFAULT '',
|
||||
host_private_key TEXT NOT NULL DEFAULT '',
|
||||
hub_server_id BIGINT,
|
||||
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||
top_n INTEGER NOT NULL DEFAULT 200,
|
||||
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
|
||||
last_datagram_at TIMESTAMPTZ,
|
||||
last_exporter_ip TEXT,
|
||||
last_error TEXT,
|
||||
packets_received BIGINT NOT NULL DEFAULT 0,
|
||||
peers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
bot_token TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
message_thread_id INTEGER,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||
account_private_key TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
||||
hour INTEGER NOT NULL DEFAULT 3,
|
||||
minute INTEGER NOT NULL DEFAULT 0,
|
||||
week_day INTEGER NOT NULL DEFAULT 0,
|
||||
month_day INTEGER NOT NULL DEFAULT 1,
|
||||
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
|
||||
server_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
last_source_finished_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS data_migration (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
sqlite_imported_at TIMESTAMPTZ,
|
||||
sqlite_path TEXT,
|
||||
sqlite_sha256 TEXT,
|
||||
report_json JSONB
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
community TEXT NOT NULL,
|
||||
community_name TEXT,
|
||||
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
|
||||
gateway TEXT NOT NULL DEFAULT '',
|
||||
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
dst_address TEXT NOT NULL,
|
||||
gateway TEXT NOT NULL,
|
||||
distance INTEGER NOT NULL DEFAULT 1,
|
||||
scope INTEGER,
|
||||
target_scope INTEGER,
|
||||
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||
check_gateway TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
polled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
|
||||
latency_ms DOUBLE PRECISION,
|
||||
ros_version TEXT,
|
||||
board_name TEXT,
|
||||
uptime TEXT,
|
||||
cpu_load INTEGER,
|
||||
free_memory BIGINT,
|
||||
total_memory BIGINT,
|
||||
identity_name TEXT,
|
||||
raw_interfaces JSONB,
|
||||
raw_ip_addresses JSONB,
|
||||
PRIMARY KEY (id, polled_at)
|
||||
) PARTITION BY RANGE (polled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_snapshots_polled_brin ON server_snapshots USING BRIN (polled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
rx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
running BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time ON traffic_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_sampled_brin ON traffic_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
ok BOOLEAN NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
error TEXT,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id ON servers_rest_ping_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_brin ON servers_rest_ping_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
src TEXT NOT NULL,
|
||||
dst TEXT NOT NULL,
|
||||
proto INTEGER NOT NULL DEFAULT 0,
|
||||
src_port INTEGER NOT NULL DEFAULT 0,
|
||||
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
in_iface TEXT NOT NULL DEFAULT '',
|
||||
out_iface TEXT NOT NULL DEFAULT '',
|
||||
next_hop TEXT NOT NULL DEFAULT '',
|
||||
flow_start_ms BIGINT NOT NULL DEFAULT 0,
|
||||
flow_end_ms BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_bucket_brin ON flow_buckets USING BRIN (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_stats (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
unique_src INTEGER NOT NULL DEFAULT 0,
|
||||
unique_dst INTEGER NOT NULL DEFAULT 0,
|
||||
conversations INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_stats_brin ON flow_minute_stats USING BRIN (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, dim, key)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_brin ON flow_minute_dims USING BRIN (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_daily_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
day DATE NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, day, dim, key)
|
||||
) PARTITION BY RANGE (day);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
asn INTEGER NOT NULL DEFAULT 0,
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
ok INTEGER NOT NULL DEFAULT 1,
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||
asn INTEGER PRIMARY KEY,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 0,
|
||||
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rtt_ms INTEGER,
|
||||
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time ON uptime_probe_samples(probe_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_brin ON uptime_probe_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
|
||||
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||
free_memory BIGINT NOT NULL DEFAULT 0,
|
||||
total_memory BIGINT NOT NULL DEFAULT 0,
|
||||
free_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
total_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
uptime_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
board_name TEXT NOT NULL DEFAULT '',
|
||||
ros_version TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time ON uptime_resource_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_brin ON uptime_resource_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_tx_avg_mbps DOUBLE PRECISION,
|
||||
last_rx_avg_mbps DOUBLE PRECISION,
|
||||
last_status TEXT CHECK (last_status IN ('done', 'error')),
|
||||
last_error TEXT,
|
||||
last_ping_rtt_ms INTEGER,
|
||||
last_ping_loss_pct INTEGER,
|
||||
last_ping_at TIMESTAMPTZ,
|
||||
last_ping_error TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
probe_id TEXT,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
src_address TEXT,
|
||||
dst_address TEXT,
|
||||
src_interface_address TEXT,
|
||||
dst_interface_address TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
tx_avg_mbps DOUBLE PRECISION,
|
||||
rx_avg_mbps DOUBLE PRECISION,
|
||||
ping_rtt_ms INTEGER,
|
||||
ping_loss_pct INTEGER,
|
||||
ping_error TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
|
||||
step TEXT NOT NULL DEFAULT 'queued',
|
||||
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
cert_name TEXT NOT NULL,
|
||||
domain_names JSONB NOT NULL,
|
||||
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown_override TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
condition TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown TEXT NOT NULL DEFAULT '5м',
|
||||
rule_chat_id TEXT NOT NULL DEFAULT '',
|
||||
confirm_stability_sec INTEGER,
|
||||
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
|
||||
recovery_stability_sec INTEGER,
|
||||
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
target TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
condition_line TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
||||
scope_key TEXT PRIMARY KEY,
|
||||
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||
last_payload_hash TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
||||
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
||||
rule_id TEXT PRIMARY KEY,
|
||||
payload_hash TEXT NOT NULL,
|
||||
since_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT,
|
||||
group_id TEXT,
|
||||
rule_name TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
message TEXT NOT NULL,
|
||||
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
fired_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
dedupe_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
sent_at TIMESTAMPTZ,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_key TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
finished_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
|
||||
error TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
result_json JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
|
||||
event_type TEXT NOT NULL,
|
||||
source_module TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
payload_json JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
avatar TEXT NOT NULL DEFAULT '',
|
||||
last_seen TIMESTAMPTZ,
|
||||
sections_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
servers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (server_id, interface_name, peer_public_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB NOT NULL,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_brin ON internet_path_snapshots USING BRIN (sampled_at);
|
||||
|
||||
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,620 @@
|
||||
-- Compact PostgreSQL 18 schema: wipe all app tables (not schema_migrations),
|
||||
-- recreate with smaller time-series rows. SQLite is re-imported after this.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename <> 'schema_migrations'
|
||||
LOOP
|
||||
EXECUTE format('DROP TABLE IF EXISTS public.%I CASCADE', r.tablename);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 443,
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
type TEXT NOT NULL DEFAULT 'home-router'
|
||||
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
|
||||
site TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
asn TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
lan_subnet TEXT NOT NULL DEFAULT '',
|
||||
wan_uplinks JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 120,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_public_key TEXT NOT NULL DEFAULT '',
|
||||
host_private_key TEXT NOT NULL DEFAULT '',
|
||||
hub_server_id BIGINT,
|
||||
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||
top_n INTEGER NOT NULL DEFAULT 200,
|
||||
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
|
||||
last_datagram_at TIMESTAMPTZ,
|
||||
last_exporter_ip TEXT,
|
||||
last_error TEXT,
|
||||
packets_received BIGINT NOT NULL DEFAULT 0,
|
||||
peers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
bot_token TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
message_thread_id INTEGER,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||
account_private_key TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
||||
hour INTEGER NOT NULL DEFAULT 3,
|
||||
minute INTEGER NOT NULL DEFAULT 0,
|
||||
week_day INTEGER NOT NULL DEFAULT 0,
|
||||
month_day INTEGER NOT NULL DEFAULT 1,
|
||||
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
|
||||
server_ids_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
last_source_finished_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS data_migration (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
sqlite_imported_at TIMESTAMPTZ,
|
||||
sqlite_path TEXT,
|
||||
sqlite_sha256 TEXT,
|
||||
report_json JSONB COMPRESSION lz4
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
community TEXT NOT NULL,
|
||||
community_name TEXT,
|
||||
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
|
||||
gateway TEXT NOT NULL DEFAULT '',
|
||||
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
dst_address TEXT NOT NULL,
|
||||
gateway TEXT NOT NULL,
|
||||
distance INTEGER NOT NULL DEFAULT 1,
|
||||
scope INTEGER,
|
||||
target_scope INTEGER,
|
||||
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||
check_gateway TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
polled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
|
||||
latency_ms DOUBLE PRECISION,
|
||||
ros_version TEXT,
|
||||
board_name TEXT,
|
||||
uptime TEXT,
|
||||
cpu_load INTEGER,
|
||||
free_memory BIGINT,
|
||||
total_memory BIGINT,
|
||||
identity_name TEXT,
|
||||
raw_interfaces JSONB COMPRESSION lz4,
|
||||
raw_ip_addresses JSONB COMPRESSION lz4,
|
||||
PRIMARY KEY (id, polled_at)
|
||||
) PARTITION BY RANGE (polled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
rx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
flags SMALLINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, sampled_at, interface_name, peer_public_key)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
ok BOOLEAN NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
error TEXT,
|
||||
PRIMARY KEY (server_id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
src INET NOT NULL,
|
||||
dst INET NOT NULL,
|
||||
proto SMALLINT NOT NULL DEFAULT 0,
|
||||
src_port INTEGER NOT NULL DEFAULT 0,
|
||||
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
in_iface TEXT NOT NULL DEFAULT '',
|
||||
out_iface TEXT NOT NULL DEFAULT '',
|
||||
next_hop INET,
|
||||
flow_start_ms BIGINT NOT NULL DEFAULT 0,
|
||||
flow_end_ms BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_stats (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
unique_src INTEGER NOT NULL DEFAULT 0,
|
||||
unique_dst INTEGER NOT NULL DEFAULT 0,
|
||||
conversations INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, dim, key)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_daily_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
day DATE NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, day, dim, key)
|
||||
) PARTITION BY RANGE (day);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
asn INTEGER NOT NULL DEFAULT 0,
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
ok INTEGER NOT NULL DEFAULT 1,
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||
asn INTEGER PRIMARY KEY,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 0,
|
||||
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rtt_ms INTEGER,
|
||||
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
|
||||
PRIMARY KEY (probe_id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
|
||||
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||
free_memory BIGINT NOT NULL DEFAULT 0,
|
||||
total_memory BIGINT NOT NULL DEFAULT 0,
|
||||
free_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
total_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
uptime_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_tx_avg_mbps DOUBLE PRECISION,
|
||||
last_rx_avg_mbps DOUBLE PRECISION,
|
||||
last_status TEXT CHECK (last_status IN ('done', 'error')),
|
||||
last_error TEXT,
|
||||
last_ping_rtt_ms INTEGER,
|
||||
last_ping_loss_pct INTEGER,
|
||||
last_ping_at TIMESTAMPTZ,
|
||||
last_ping_error TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
probe_id TEXT,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
src_address TEXT,
|
||||
dst_address TEXT,
|
||||
src_interface_address TEXT,
|
||||
dst_interface_address TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
tx_avg_mbps DOUBLE PRECISION,
|
||||
rx_avg_mbps DOUBLE PRECISION,
|
||||
ping_rtt_ms INTEGER,
|
||||
ping_loss_pct INTEGER,
|
||||
ping_error TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
|
||||
step TEXT NOT NULL DEFAULT 'queued',
|
||||
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
cert_name TEXT NOT NULL,
|
||||
domain_names JSONB COMPRESSION lz4 NOT NULL,
|
||||
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown_override TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
condition TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown TEXT NOT NULL DEFAULT '5м',
|
||||
rule_chat_id TEXT NOT NULL DEFAULT '',
|
||||
confirm_stability_sec INTEGER,
|
||||
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
|
||||
recovery_stability_sec INTEGER,
|
||||
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
target TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
condition_line TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
||||
scope_key TEXT PRIMARY KEY,
|
||||
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||
last_payload_hash TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
||||
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
|
||||
payload_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
||||
rule_id TEXT PRIMARY KEY,
|
||||
payload_hash TEXT NOT NULL,
|
||||
since_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT,
|
||||
group_id TEXT,
|
||||
rule_name TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
message TEXT NOT NULL,
|
||||
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
fired_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
dedupe_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB COMPRESSION lz4 NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
sent_at TIMESTAMPTZ,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_key TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
finished_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
|
||||
error TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
result_json JSONB COMPRESSION lz4
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
|
||||
event_type TEXT NOT NULL,
|
||||
source_module TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
payload_json JSONB COMPRESSION lz4
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
avatar TEXT NOT NULL DEFAULT '',
|
||||
last_seen TIMESTAMPTZ,
|
||||
sections_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
servers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (server_id, interface_name, peer_public_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB COMPRESSION lz4 NOT NULL,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
|
||||
|
||||
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- S3-compatible storage for RouterOS backups
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_storage_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
provider TEXT NOT NULL DEFAULT 'local' CHECK (provider IN ('local', 's3')),
|
||||
s3_endpoint TEXT NOT NULL DEFAULT '',
|
||||
s3_region TEXT NOT NULL DEFAULT 'us-east-1',
|
||||
s3_bucket TEXT NOT NULL DEFAULT '',
|
||||
s3_prefix TEXT NOT NULL DEFAULT 'mikrotik',
|
||||
s3_access_key_id TEXT NOT NULL DEFAULT '',
|
||||
s3_secret_access_key TEXT NOT NULL DEFAULT '',
|
||||
s3_force_path_style BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
keep_local_copy BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_test_at TIMESTAMPTZ,
|
||||
last_test_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO backup_storage_settings (id)
|
||||
VALUES (1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS storage TEXT NOT NULL DEFAULT 'local';
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS s3_key TEXT;
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS s3_etag TEXT;
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS upload_error TEXT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'backup_entries_storage_check'
|
||||
) THEN
|
||||
ALTER TABLE backup_entries
|
||||
ADD CONSTRAINT backup_entries_storage_check
|
||||
CHECK (storage IN ('local', 's3', 'both'));
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- GeoLite2 mmdb (страна/ASN для netflow): настройки автообновления зеркала P3TERX
|
||||
|
||||
CREATE TABLE IF NOT EXISTS geoip_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
update_interval_sec INTEGER NOT NULL DEFAULT 604800,
|
||||
last_check_at TIMESTAMPTZ,
|
||||
last_success_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
country_build_at TIMESTAMPTZ,
|
||||
asn_build_at TIMESTAMPTZ,
|
||||
etags_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO geoip_settings (id)
|
||||
VALUES (1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Statistics cube: hour + daily facts (server × iface × country × service × ASN).
|
||||
-- Compact types. FILLFACTOR/autovacuum нельзя на partitioned parent (PG 42809) —
|
||||
-- задаются на листовых партициях в ensurePartitionFor.
|
||||
-- Retention: DROP partitions only (see PARTITION_SPECS).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_hour_facts (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
iface TEXT NOT NULL,
|
||||
country CHAR(2) NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
asn INTEGER NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, iface, country, service, asn)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_daily_facts (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
day DATE NOT NULL,
|
||||
iface TEXT NOT NULL,
|
||||
country CHAR(2) NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
asn INTEGER NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, day, iface, country, service, asn)
|
||||
) PARTITION BY RANGE (day);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- История desired/actual снапшотов managed-секций (фильтры, рекурсивные маршруты).
|
||||
-- Retention — prune в сервисе (последние 50 на пару server+section).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_revisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
section TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_revisions_server_section_created
|
||||
ON config_revisions (server_id, section, created_at DESC);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- IPFIX postNAT (IANA 225/226) + postNAPT ports (IANA 227/228) from MikroTik Traffic Flow.
|
||||
-- Needed to rebuild facts with the same internet dest as the network map.
|
||||
|
||||
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src INET;
|
||||
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst INET;
|
||||
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src_port INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst_port INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Захват перед POST и удержание неизвестных исходов. Исторические строки не меняются.
|
||||
BEGIN;
|
||||
ALTER TABLE alert_outbox DROP CONSTRAINT IF EXISTS alert_outbox_status_check;
|
||||
ALTER TABLE alert_outbox ADD CONSTRAINT alert_outbox_status_check
|
||||
CHECK (status IN ('pending', 'processing', 'unknown', 'sent', 'failed'));
|
||||
COMMIT;
|
||||
@@ -0,0 +1,4 @@
|
||||
BEGIN;
|
||||
ALTER TABLE alert_outbox ADD COLUMN IF NOT EXISTS resolution_version integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE alert_outbox ADD CONSTRAINT alert_outbox_resolution_version_check CHECK (resolution_version >= 0);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- A failed command is an attempt, not a measured packet-loss percentage.
|
||||
ALTER TABLE uptime_probe_samples ADD COLUMN IF NOT EXISTS measurement_known BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE uptime_probe_samples ADD COLUMN IF NOT EXISTS observation_error TEXT;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS traffic_collection_state (
|
||||
server_id BIGINT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE,
|
||||
server_updated_at TIMESTAMPTZ NOT NULL,
|
||||
last_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
last_succeeded_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending', 'ok', 'error')),
|
||||
error TEXT
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE traffic_samples ADD COLUMN IF NOT EXISTS native_counter_identity TEXT;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Retention runs after each scheduler receipt; avoid scanning the complete history.
|
||||
-- On a busy existing installation, pre-create this same index CONCURRENTLY
|
||||
-- outside a transaction before deploying (deploy/PRODUCTION-RETENTION-INDEX.md).
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_finished_at ON scheduler_runs (finished_at);
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE IF NOT EXISTS router_log_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 10 CHECK (interval_sec BETWEEN 5 AND 3600),
|
||||
retention_days INTEGER NOT NULL DEFAULT 14 CHECK (retention_days BETWEEN 1 AND 365)
|
||||
);
|
||||
INSERT INTO router_log_settings(id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||
-- No foreign key: collected history survives device deletion.
|
||||
CREATE TABLE IF NOT EXISTS router_log_collection_state (
|
||||
server_id INTEGER PRIMARY KEY, server_name TEXT NOT NULL, host TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL DEFAULT 0, snapshot JSONB NOT NULL DEFAULT '[]',
|
||||
last_collected_at TIMESTAMPTZ, last_error TEXT, gap_at TIMESTAMPTZ,
|
||||
next_poll_at TIMESTAMPTZ, failures INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS router_log_entries (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, server_id INTEGER NOT NULL,
|
||||
server_name TEXT NOT NULL, host TEXT NOT NULL, router_id TEXT NOT NULL, raw_time TEXT NOT NULL,
|
||||
event_at TIMESTAMPTZ, received_at TIMESTAMPTZ NOT NULL,
|
||||
effective_at TIMESTAMPTZ NOT NULL, topics TEXT[] NOT NULL, level TEXT NOT NULL,
|
||||
message TEXT NOT NULL, generation INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS router_logs_server_time ON router_log_entries(server_id, effective_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS router_logs_time ON router_log_entries(effective_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS router_logs_level_time ON router_log_entries(level, effective_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS router_logs_topics ON router_log_entries USING gin(topics);
|
||||
CREATE INDEX IF NOT EXISTS router_logs_received ON router_log_entries(received_at);
|
||||
CREATE INDEX IF NOT EXISTS router_logs_server_cursor ON router_log_entries(server_id, id);
|
||||
@@ -0,0 +1,101 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_settings (
|
||||
id integer PRIMARY KEY CHECK(id=1), enabled boolean NOT NULL DEFAULT false,
|
||||
requests_retention_days integer NOT NULL DEFAULT 30 CHECK(requests_retention_days BETWEEN 1 AND 365),
|
||||
audit_retention_days integer NOT NULL DEFAULT 180 CHECK(audit_retention_days BETWEEN 30 AND 3650),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO mcp_settings(id) VALUES(1) ON CONFLICT DO NOTHING;
|
||||
CREATE TABLE IF NOT EXISTS mcp_connections (
|
||||
id uuid PRIMARY KEY, name text NOT NULL, owner_id text NOT NULL, owner_permissions jsonb NOT NULL,
|
||||
policy jsonb NOT NULL, generation text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(), last_used_at timestamptz
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_credentials (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id) ON DELETE CASCADE,
|
||||
digest text NOT NULL UNIQUE, kind text NOT NULL CHECK(kind IN ('bearer','access','refresh')),
|
||||
expires_at timestamptz NOT NULL, revoked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
family_id uuid, client_id text, audience text
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_credentials_connection ON mcp_credentials(connection_id);
|
||||
CREATE TABLE IF NOT EXISTS mcp_audit (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, actor_id text NOT NULL, action text NOT NULL,
|
||||
connection_id uuid, payload jsonb NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_audit_time ON mcp_audit(created_at,id);
|
||||
CREATE TABLE IF NOT EXISTS mcp_requests (
|
||||
id uuid PRIMARY KEY, connection_id uuid REFERENCES mcp_connections(id) ON DELETE SET NULL,
|
||||
actor_id text, tool text NOT NULL, server_ids bigint[] NOT NULL DEFAULT '{}',
|
||||
status text NOT NULL, input jsonb NOT NULL DEFAULT '{}', result jsonb, error_code text,
|
||||
duration_ms integer, created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_requests_connection_time ON mcp_requests(connection_id,created_at,id);
|
||||
CREATE INDEX IF NOT EXISTS mcp_requests_server ON mcp_requests USING gin(server_ids);
|
||||
CREATE TABLE IF NOT EXISTS mcp_protections (
|
||||
id uuid PRIMARY KEY, object_ref jsonb NOT NULL, reason text NOT NULL DEFAULT '',
|
||||
created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_plans (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id), operation_id text NOT NULL,
|
||||
operation_version integer NOT NULL, policy_version integer NOT NULL, generation text NOT NULL,
|
||||
payload jsonb NOT NULL, evidence jsonb NOT NULL, state text NOT NULL, expires_at timestamptz NOT NULL,
|
||||
approved_by text, approved_at timestamptz, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_plans_connection ON mcp_plans(connection_id,created_at);
|
||||
CREATE TABLE IF NOT EXISTS mcp_operations (
|
||||
id uuid PRIMARY KEY, plan_id uuid NOT NULL UNIQUE REFERENCES mcp_plans(id),
|
||||
connection_id uuid NOT NULL REFERENCES mcp_connections(id), idempotency_key text NOT NULL,
|
||||
state text NOT NULL DEFAULT 'pending', verification text NOT NULL DEFAULT 'pending',
|
||||
result jsonb, error_code text, created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
|
||||
UNIQUE(connection_id,idempotency_key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_operation_steps (
|
||||
operation_id uuid NOT NULL REFERENCES mcp_operations(id), step_index integer NOT NULL,
|
||||
server_id bigint, state text NOT NULL, before_state jsonb, intended_state jsonb, result jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
|
||||
PRIMARY KEY(operation_id,step_index)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_budget_buckets (
|
||||
connection_id uuid NOT NULL REFERENCES mcp_connections(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL, bucket timestamptz NOT NULL, used integer NOT NULL CHECK(used>=0),
|
||||
PRIMARY KEY(connection_id,kind,bucket)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_read_leases (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_grants (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id) ON DELETE CASCADE,
|
||||
payload jsonb NOT NULL, revoked_at timestamptz
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_secrets (
|
||||
id uuid PRIMARY KEY, purpose text NOT NULL, server_id bigint, ciphertext text NOT NULL,
|
||||
created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_artifacts (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id),
|
||||
filename text NOT NULL, media_type text NOT NULL, ciphertext text NOT NULL,
|
||||
expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_oauth_clients (
|
||||
id text PRIMARY KEY, name text NOT NULL, redirect_uris jsonb NOT NULL, enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_oauth_requests (
|
||||
id uuid PRIMARY KEY, client_id text NOT NULL REFERENCES mcp_oauth_clients(id),
|
||||
redirect_uri text NOT NULL, challenge text NOT NULL, state text, resource text NOT NULL,
|
||||
scope text NOT NULL, expires_at timestamptz NOT NULL, consumed_at timestamptz,
|
||||
connection_id uuid REFERENCES mcp_connections(id), code_digest text UNIQUE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_routing_profiles (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id),
|
||||
version integer NOT NULL DEFAULT 1, config jsonb NOT NULL, state jsonb NOT NULL DEFAULT '{}',
|
||||
enabled boolean NOT NULL DEFAULT false, grant_expires_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_drift (
|
||||
id uuid PRIMARY KEY, operation_id uuid NOT NULL REFERENCES mcp_operations(id),
|
||||
server_id bigint, expected jsonb NOT NULL, observed jsonb NOT NULL,
|
||||
acknowledged_at timestamptz, detected_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,12 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_routing_observations (
|
||||
id uuid PRIMARY KEY, src_server_id bigint NOT NULL, dst_server_id bigint NOT NULL,
|
||||
src_interface text NOT NULL, dst_interface text NOT NULL, family text NOT NULL CHECK(family IN ('ipv4','ipv6')),
|
||||
routing_table text NOT NULL DEFAULT 'main', ping_ms double precision, loss_pct double precision,
|
||||
dl_mbps double precision, ul_mbps double precision, ping_at timestamptz, bandwidth_at timestamptz,
|
||||
context_verified boolean NOT NULL DEFAULT false, source text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), CHECK(ping_ms IS NULL OR ping_ms>=0),
|
||||
CHECK(loss_pct IS NULL OR loss_pct BETWEEN 0 AND 100), CHECK(dl_mbps IS NULL OR dl_mbps>=0), CHECK(ul_mbps IS NULL OR ul_mbps>=0)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_routing_observations_leg ON mcp_routing_observations(src_server_id,dst_server_id,src_interface,family,routing_table,created_at DESC);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,7 @@
|
||||
BEGIN;
|
||||
ALTER TABLE mcp_routing_profiles ADD COLUMN IF NOT EXISTS credential_id uuid REFERENCES mcp_credentials(id);
|
||||
ALTER TABLE mcp_routing_profiles ADD COLUMN IF NOT EXISTS generation text;
|
||||
ALTER TABLE mcp_routing_profiles ADD COLUMN IF NOT EXISTS policy_version integer;
|
||||
CREATE INDEX IF NOT EXISTS mcp_routing_profiles_enabled ON mcp_routing_profiles(updated_at) WHERE enabled;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mcp_drift_open_step ON mcp_drift(operation_id,(expected->>'stepIndex')) WHERE acknowledged_at IS NULL;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,3 @@
|
||||
BEGIN;
|
||||
ALTER TABLE mcp_connections ADD COLUMN IF NOT EXISTS owner_valid_until timestamptz NOT NULL DEFAULT now();
|
||||
COMMIT;
|
||||
@@ -0,0 +1,7 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_operation_checkpoints (
|
||||
operation_id uuid NOT NULL REFERENCES mcp_operations(id), step_index integer NOT NULL,
|
||||
before_sealed text NOT NULL, after_sealed text, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(operation_id,step_index)
|
||||
);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,8 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_management_channels (
|
||||
id uuid PRIMARY KEY, server_id bigint NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
|
||||
config_sealed text NOT NULL, identity_hash text NOT NULL, attestation text NOT NULL,
|
||||
created_by text NOT NULL, generation text NOT NULL, expires_at timestamptz NOT NULL,
|
||||
verified_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz
|
||||
);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,8 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_path_checks (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id),
|
||||
profile_hash text NOT NULL, candidate_id text NOT NULL, status text NOT NULL CHECK(status IN ('passed','failed','unknown')),
|
||||
evidence jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_path_checks_latest ON mcp_path_checks(connection_id,profile_hash,candidate_id,created_at DESC);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,7 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_recovery_intents (
|
||||
operation_id uuid PRIMARY KEY REFERENCES mcp_operations(id), state text NOT NULL,
|
||||
authority_profile_id uuid NOT NULL REFERENCES mcp_routing_profiles(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz
|
||||
);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_unauthorized_buckets (
|
||||
bucket timestamptz NOT NULL, client_hash text NOT NULL, reason text NOT NULL,
|
||||
attempts integer NOT NULL DEFAULT 1, PRIMARY KEY(bucket,client_hash,reason)
|
||||
);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,4 @@
|
||||
BEGIN;
|
||||
ALTER TABLE user_interface_bindings DROP CONSTRAINT IF EXISTS user_interface_bindings_interface_type_check;
|
||||
ALTER TABLE user_interface_bindings ADD CONSTRAINT user_interface_bindings_interface_type_check CHECK(interface_type IN ('ether','bridge','vlan','vxlan','gre','wg','ipsec','other'));
|
||||
COMMIT;
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_owner_revocations (
|
||||
owner_id text PRIMARY KEY, invalid_before timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_portal_events (
|
||||
id uuid PRIMARY KEY, owner_id text NOT NULL, payload_hash text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_portal_events_time ON mcp_portal_events(created_at);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,3 @@
|
||||
BEGIN;
|
||||
ALTER TABLE mcp_connections ADD COLUMN IF NOT EXISTS owner_claims_issued_at timestamptz NOT NULL DEFAULT to_timestamp(0);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE INDEX IF NOT EXISTS mcp_audit_handshake_cooldown
|
||||
ON mcp_audit(connection_id, created_at DESC)
|
||||
WHERE action = 'connection.handshake.start';
|
||||
CREATE INDEX IF NOT EXISTS mcp_credentials_readiness_expired
|
||||
ON mcp_credentials(connection_id, expires_at)
|
||||
WHERE client_id = 'mcp-readiness-probe';
|
||||
CREATE INDEX IF NOT EXISTS mcp_grants_credential
|
||||
ON mcp_grants(connection_id, (payload->>'credentialId'));
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE mcp_artifacts ADD COLUMN IF NOT EXISTS access_scope jsonb;
|
||||
CREATE INDEX IF NOT EXISTS mcp_artifacts_expiry ON mcp_artifacts(expires_at);
|
||||
@@ -0,0 +1,23 @@
|
||||
BEGIN;
|
||||
ALTER TABLE servers ADD COLUMN IF NOT EXISTS routing_endpoint_epoch uuid NOT NULL DEFAULT gen_random_uuid();
|
||||
CREATE OR REPLACE FUNCTION mm_routing_endpoint_epoch() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF ROW(NEW.host,NEW.port,NEW.username,NEW.password,NEW.use_ssl,NEW.verify_ssl,NEW.enabled)
|
||||
IS DISTINCT FROM ROW(OLD.host,OLD.port,OLD.username,OLD.password,OLD.use_ssl,OLD.verify_ssl,OLD.enabled) THEN
|
||||
NEW.routing_endpoint_epoch := gen_random_uuid();
|
||||
ELSE
|
||||
NEW.routing_endpoint_epoch := OLD.routing_endpoint_epoch;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
DROP TRIGGER IF EXISTS mm_routing_endpoint_epoch ON servers;
|
||||
CREATE TRIGGER mm_routing_endpoint_epoch BEFORE UPDATE ON servers FOR EACH ROW EXECUTE FUNCTION mm_routing_endpoint_epoch();
|
||||
ALTER TABLE mcp_routing_observations ADD COLUMN IF NOT EXISTS source_proof text;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='mcp_routing_observation_source_proof' AND conrelid='mcp_routing_observations'::regclass) THEN
|
||||
ALTER TABLE mcp_routing_observations ADD CONSTRAINT mcp_routing_observation_source_proof CHECK(source_proof IS NULL OR source_proof ~ '^[a-f0-9]{64}$');
|
||||
END IF;
|
||||
END $$;
|
||||
-- Existing measurements stay readable but unsigned. Never attest old samples
|
||||
-- using today's endpoint: that would invent capture-time provenance.
|
||||
COMMIT;
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
-- Service-owned exact read-back must survive a subsequent network probe failure.
|
||||
-- Generic after_sealed remains an array of resource snapshots.
|
||||
ALTER TABLE mcp_operation_checkpoints ADD COLUMN IF NOT EXISTS configuration_sealed text;
|
||||
ALTER TABLE mcp_operation_checkpoints ADD COLUMN IF NOT EXISTS configuration_proof text;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,24 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_scoped_recovery_intents (
|
||||
source_operation_id uuid NOT NULL REFERENCES mcp_operations(id),
|
||||
source_step_index integer NOT NULL CHECK(source_step_index>=0),
|
||||
source_receipt_id uuid NOT NULL,
|
||||
source_configuration_proof text NOT NULL,
|
||||
recovery_receipt_id uuid NOT NULL UNIQUE,
|
||||
recovery_operation_id uuid REFERENCES mcp_operations(id),
|
||||
mode text NOT NULL CHECK(mode IN ('manual','reserved')),
|
||||
authority_profile_id uuid REFERENCES mcp_routing_profiles(id),
|
||||
authority_profile_version integer,
|
||||
intent_sealed text NOT NULL,
|
||||
intent_proof text NOT NULL,
|
||||
state text NOT NULL CHECK(state IN ('intent','verified','unknown')),
|
||||
deadline timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
verified_sealed text,
|
||||
verified_proof text,
|
||||
PRIMARY KEY(source_operation_id,source_step_index),
|
||||
CHECK(mode='manual' OR authority_profile_id IS NOT NULL AND authority_profile_version IS NOT NULL),
|
||||
CHECK((state='verified')=(verified_sealed IS NOT NULL AND verified_proof IS NOT NULL))
|
||||
);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Immutable commit evidence shared by ingestion and REST/MCP fact rebuild.
|
||||
-- No raw flow payload, IP addresses, credentials or notification contents belong here.
|
||||
CREATE TABLE IF NOT EXISTS flow_write_receipts (
|
||||
batch_id uuid PRIMARY KEY,
|
||||
kind text NOT NULL CHECK (kind IN ('ingest', 'facts', 'rebuild')),
|
||||
source_proof text NOT NULL CHECK (source_proof ~ '^[a-f0-9]{64}$'),
|
||||
result jsonb NOT NULL CHECK (jsonb_typeof(result) = 'object' AND octet_length(result::text) <= 65536),
|
||||
committed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Receipts are intentionally not pruned: an owned unknown batch may still need them.
|
||||
@@ -0,0 +1,11 @@
|
||||
-- A claim survives unknown results and is never recycled for another attempt.
|
||||
CREATE TABLE IF NOT EXISTS flow_settings_intents (
|
||||
receipt_id uuid NOT NULL,
|
||||
phase text NOT NULL CHECK (phase IN ('configuration', 'listener')),
|
||||
request_proof text NOT NULL,
|
||||
state text NOT NULL DEFAULT 'claimed' CHECK (state IN ('claimed', 'completed')),
|
||||
result jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (receipt_id, phase)
|
||||
);
|
||||
INSERT INTO traffic_flow_settings(id) VALUES(1) ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Precondition receipts only. No executor capability is granted by this table.
|
||||
CREATE TABLE IF NOT EXISTS vrf_production_tickets (
|
||||
ticket_id uuid PRIMARY KEY,
|
||||
plan_id uuid NOT NULL UNIQUE REFERENCES mcp_plans(id),
|
||||
connection_id uuid NOT NULL REFERENCES mcp_connections(id),
|
||||
server_id bigint NOT NULL REFERENCES servers(id),
|
||||
owner_id text NOT NULL,
|
||||
action_id text NOT NULL CHECK (action_id IN ('ros.vrf.create','ros.vrf.update','ros.vrf.delete')),
|
||||
action_version integer NOT NULL CHECK (action_version > 0),
|
||||
source_proof text NOT NULL CHECK (source_proof ~ '^[a-f0-9]{64}$'),
|
||||
digest text NOT NULL CHECK (digest ~ '^[a-f0-9]{64}$'),
|
||||
ciphertext text NOT NULL,
|
||||
state text NOT NULL DEFAULT 'issued' CHECK (state IN ('issued','claimed')),
|
||||
issued_at timestamptz NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
claimed_at timestamptz,
|
||||
CHECK (expires_at > issued_at AND expires_at <= issued_at + interval '5 minutes'),
|
||||
CHECK ((state='issued' AND claimed_at IS NULL) OR (state='claimed' AND claimed_at IS NOT NULL))
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Durable diagnostic attempts; this migration registers no MCP action.
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE server_probe_executions (
|
||||
receipt_id uuid PRIMARY KEY, binding_proof text NOT NULL CHECK(binding_proof ~ '^[a-f0-9]{64}$'),
|
||||
identity_kind text NOT NULL CHECK(identity_kind IN ('mcp','rest')),
|
||||
execution_id uuid NOT NULL, actor_id text NOT NULL CHECK(length(actor_id) BETWEEN 1 AND 200),
|
||||
authentication text NOT NULL CHECK(authentication IN ('mcp','jwt','local')),
|
||||
mcp_connection_id uuid REFERENCES mcp_connections(id), mcp_credential_id uuid REFERENCES mcp_credentials(id), request_id text,
|
||||
authority_issued_at bigint NOT NULL CHECK(authority_issued_at>=0), authority_expires_at timestamptz NOT NULL,
|
||||
source_server_id integer NOT NULL CHECK(source_server_id>0), destination_server_id integer CHECK(destination_server_id>0),
|
||||
tool text NOT NULL CHECK(tool IN ('ping','traceroute','bandwidth','dns','route','mtu')),
|
||||
state text NOT NULL DEFAULT 'claimed' CHECK(state IN ('claimed','completed','failed','cancelled')),
|
||||
attempted_stages integer CHECK(attempted_stages BETWEEN 0 AND 11), completed_stages integer CHECK(completed_stages BETWEEN 0 AND attempted_stages),
|
||||
receipt_ciphertext text CHECK(octet_length(receipt_ciphertext)<=262144), receipt_proof text CHECK(receipt_proof ~ '^[a-f0-9]{64}$'),
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(), finished_at timestamptz,
|
||||
UNIQUE(identity_kind,execution_id),
|
||||
CHECK((identity_kind='mcp' AND authentication='mcp' AND mcp_connection_id IS NOT NULL AND mcp_credential_id IS NOT NULL AND request_id IS NULL)
|
||||
OR (identity_kind='rest' AND authentication IN ('jwt','local') AND mcp_connection_id IS NULL AND mcp_credential_id IS NULL AND request_id IS NOT NULL AND length(request_id) BETWEEN 1 AND 200)),
|
||||
CHECK((tool='bandwidth')=(destination_server_id IS NOT NULL)),
|
||||
CHECK((state='claimed' AND receipt_ciphertext IS NULL AND receipt_proof IS NULL AND finished_at IS NULL AND attempted_stages IS NULL AND completed_stages IS NULL)
|
||||
OR (state<>'claimed' AND receipt_ciphertext IS NOT NULL AND receipt_proof IS NOT NULL AND finished_at IS NOT NULL AND attempted_stages IS NOT NULL AND completed_stages IS NOT NULL))
|
||||
);
|
||||
CREATE TABLE server_probe_execution_stages (
|
||||
receipt_id uuid NOT NULL REFERENCES server_probe_executions(receipt_id), stage_index integer NOT NULL CHECK(stage_index BETWEEN 1 AND 11),
|
||||
kind text NOT NULL CHECK(kind IN ('source-dns-A','native-ping','native-traceroute','native-bandwidth','native-route-read',
|
||||
'manager-dns-A','manager-dns-AAAA','manager-dns-MX','manager-dns-NS','manager-dns-TXT','manager-dns-CNAME','manager-dns-PTR',
|
||||
'native-mtu-1500','native-mtu-1492','native-mtu-1480','native-mtu-1476','native-mtu-1472','native-mtu-1468','native-mtu-1400','native-mtu-1280','native-mtu-1024','native-mtu-576')),
|
||||
timeout_ms integer NOT NULL CHECK(timeout_ms BETWEEN 1 AND 120000),
|
||||
state text NOT NULL DEFAULT 'intent' CHECK(state IN ('intent','received')),
|
||||
outcome text CHECK(outcome IN ('received','source-unavailable')),
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(), received_at timestamptz,
|
||||
PRIMARY KEY(receipt_id,stage_index),
|
||||
CHECK((state='intent' AND outcome IS NULL AND received_at IS NULL) OR (state='received' AND outcome IS NOT NULL AND received_at IS NOT NULL))
|
||||
);
|
||||
CREATE TABLE server_probe_execution_quarantines (
|
||||
receipt_id uuid PRIMARY KEY, binding_proof text NOT NULL CHECK(binding_proof ~ '^[a-f0-9]{64}$'),
|
||||
identity_kind text NOT NULL CHECK(identity_kind IN ('mcp','rest')), execution_id uuid NOT NULL,
|
||||
identity_proof text NOT NULL CHECK(identity_proof ~ '^[a-f0-9]{64}$'),
|
||||
error_code text NOT NULL CHECK(error_code ~ '^[A-Z_]{1,64}$'),
|
||||
attempted_stages integer NOT NULL CHECK(attempted_stages BETWEEN 0 AND 11), completed_stages integer NOT NULL CHECK(completed_stages BETWEEN 0 AND attempted_stages),
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(), UNIQUE(identity_kind,execution_id)
|
||||
);
|
||||
CREATE FUNCTION server_probe_execution_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'probe execution journal is append only'; END IF;
|
||||
IF OLD.state<>'claimed' OR (to_jsonb(OLD)-ARRAY['state','attempted_stages','completed_stages','receipt_ciphertext','receipt_proof','finished_at']) IS DISTINCT FROM
|
||||
(to_jsonb(NEW)-ARRAY['state','attempted_stages','completed_stages','receipt_ciphertext','receipt_proof','finished_at']) OR NEW.state='claimed'
|
||||
THEN RAISE EXCEPTION 'probe execution identity or terminal receipt is immutable'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM server_probe_execution_quarantines WHERE receipt_id=OLD.receipt_id) THEN RAISE EXCEPTION 'probe execution quarantined'; END IF;
|
||||
RETURN NEW; END $$;
|
||||
CREATE TRIGGER server_probe_execution_immutable BEFORE UPDATE OR DELETE ON server_probe_executions FOR EACH ROW EXECUTE FUNCTION server_probe_execution_immutable();
|
||||
CREATE FUNCTION server_probe_stage_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'probe stage journal is append only'; END IF;
|
||||
PERFORM receipt_id FROM server_probe_executions WHERE receipt_id=NEW.receipt_id AND state='claimed' FOR UPDATE;
|
||||
IF NOT FOUND OR EXISTS(SELECT 1 FROM server_probe_execution_quarantines WHERE receipt_id=NEW.receipt_id) THEN RAISE EXCEPTION 'probe attempt is not executable'; END IF;
|
||||
IF TG_OP='INSERT' AND (NEW.stage_index<>(SELECT COALESCE(max(stage_index),0)+1 FROM server_probe_execution_stages WHERE receipt_id=NEW.receipt_id)
|
||||
OR EXISTS(SELECT 1 FROM server_probe_execution_stages WHERE receipt_id=NEW.receipt_id AND state<>'received'))
|
||||
THEN RAISE EXCEPTION 'probe stages must be serial and contiguous'; END IF;
|
||||
IF TG_OP='UPDATE' AND (OLD.state<>'intent' OR NEW.state<>'received' OR (to_jsonb(OLD)-ARRAY['state','outcome','received_at']) IS DISTINCT FROM (to_jsonb(NEW)-ARRAY['state','outcome','received_at']))
|
||||
THEN RAISE EXCEPTION 'probe stage terminal state is immutable'; END IF;
|
||||
RETURN NEW; END $$;
|
||||
CREATE TRIGGER server_probe_stage_immutable BEFORE INSERT OR UPDATE OR DELETE ON server_probe_execution_stages FOR EACH ROW EXECUTE FUNCTION server_probe_stage_immutable();
|
||||
CREATE FUNCTION server_probe_quarantine_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'probe quarantine is immutable'; END $$;
|
||||
CREATE TRIGGER server_probe_quarantine_immutable BEFORE UPDATE OR DELETE ON server_probe_execution_quarantines FOR EACH ROW EXECUTE FUNCTION server_probe_quarantine_immutable();
|
||||
|
||||
INSERT INTO schema_migrations(id) VALUES ('0035_server_probe_journal') ON CONFLICT (id) DO NOTHING;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Durable stop requests for opt-in diagnostics, not configuration rollback.
|
||||
BEGIN;
|
||||
CREATE TABLE mcp_diagnostic_cancellations (
|
||||
operation_id uuid PRIMARY KEY REFERENCES mcp_operations(id),
|
||||
connection_id uuid NOT NULL REFERENCES mcp_connections(id),
|
||||
actor_id text NOT NULL CHECK (length(actor_id) BETWEEN 1 AND 200),
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
CREATE FUNCTION mcp_diagnostic_cancellation_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'diagnostic cancellation request is immutable'; END $$;
|
||||
CREATE TRIGGER mcp_diagnostic_cancellation_immutable BEFORE UPDATE OR DELETE ON mcp_diagnostic_cancellations
|
||||
FOR EACH ROW EXECUTE FUNCTION mcp_diagnostic_cancellation_immutable();
|
||||
INSERT INTO schema_migrations(id) VALUES ('0036_diagnostic_cancellations') ON CONFLICT (id) DO NOTHING;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,226 @@
|
||||
-- Fresh-only enrollment of empty diagnostic tables. No historical backfill,
|
||||
-- source-generation claim, public registration or automatic cleanup is granted.
|
||||
BEGIN;
|
||||
LOCK TABLE server_probe_executions,server_probe_execution_stages,server_probe_execution_quarantines,mcp_diagnostic_cancellations IN ACCESS EXCLUSIVE MODE;
|
||||
DO $$ BEGIN
|
||||
IF EXISTS(SELECT 1 FROM server_probe_executions)
|
||||
OR EXISTS(SELECT 1 FROM server_probe_execution_stages)
|
||||
OR EXISTS(SELECT 1 FROM server_probe_execution_quarantines)
|
||||
OR EXISTS(SELECT 1 FROM mcp_diagnostic_cancellations)
|
||||
THEN RAISE EXCEPTION 'nonempty legacy diagnostic history prevents fresh storage enrollment'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM pg_catalog.pg_class WHERE relname IN
|
||||
('server_probe_storage_usage','server_probe_storage_admissions','server_probe_execution_payloads'))
|
||||
THEN RAISE EXCEPTION 'unmarked diagnostic storage relation prevents fresh storage enrollment'; END IF;
|
||||
IF NOT EXISTS(SELECT 1 FROM mcp_settings WHERE id=1 AND requests_retention_days BETWEEN 1 AND 365)
|
||||
THEN RAISE EXCEPTION 'authoritative probe retention setting unavailable'; END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE server_probe_storage_usage (
|
||||
scope text PRIMARY KEY CHECK(scope='global' OR (scope LIKE 'owner:%' AND length(scope) BETWEEN 7 AND 206)),
|
||||
admissions bigint NOT NULL DEFAULT 0 CHECK(admissions>=0),
|
||||
reserved_files bigint NOT NULL DEFAULT 0 CHECK(reserved_files>=0 AND reserved_files<=admissions),
|
||||
reserved_bytes bigint NOT NULL DEFAULT 0 CHECK(reserved_bytes>=0),
|
||||
CHECK(reserved_bytes=admissions*32768+reserved_files*262144)
|
||||
);
|
||||
CREATE TABLE server_probe_storage_admissions (
|
||||
receipt_id uuid PRIMARY KEY,
|
||||
owner_id text NOT NULL CHECK(length(owner_id) BETWEEN 1 AND 200),
|
||||
identity_kind text NOT NULL CHECK(identity_kind IN ('mcp','rest')), execution_id uuid NOT NULL,
|
||||
binding_proof text NOT NULL CHECK(binding_proof ~ '^[a-f0-9]{64}$'),
|
||||
authority_expires_at timestamptz NOT NULL,
|
||||
claim_token uuid, claim_attempted_at timestamptz,
|
||||
purged_at timestamptz, cancelled_at timestamptz, created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
retention_days integer NOT NULL CHECK(retention_days BETWEEN 1 AND 365),
|
||||
safety_ms integer NOT NULL DEFAULT 60000 CHECK(safety_ms BETWEEN 60000 AND 86400000),
|
||||
gc_not_before timestamptz NOT NULL DEFAULT 'infinity',
|
||||
CHECK(gc_not_before>=authority_expires_at+make_interval(days=>retention_days)+safety_ms*interval '1 millisecond'),
|
||||
UNIQUE(identity_kind,execution_id),
|
||||
CHECK(claim_token IS NULL OR (claim_attempted_at IS NULL AND purged_at IS NULL AND cancelled_at IS NULL)),
|
||||
CHECK(cancelled_at IS NULL OR (claim_token IS NULL AND claim_attempted_at IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX server_probe_storage_admissions_owner_age ON server_probe_storage_admissions(owner_id,authority_expires_at,receipt_id);
|
||||
CREATE TABLE server_probe_execution_payloads (
|
||||
receipt_id uuid PRIMARY KEY REFERENCES server_probe_executions(receipt_id),
|
||||
receipt_ciphertext text NOT NULL CHECK(octet_length(receipt_ciphertext) BETWEEN 1 AND 262144),
|
||||
receipt_proof text NOT NULL CHECK(receipt_proof ~ '^[a-f0-9]{64}$')
|
||||
);
|
||||
INSERT INTO server_probe_storage_usage(scope,admissions,reserved_files,reserved_bytes) VALUES('global',0,0,0);
|
||||
|
||||
-- PostgreSQL drops table CHECKs dependent on this column together with it.
|
||||
-- The locked empty-table guard precedes this column removal; no data is copied.
|
||||
ALTER TABLE server_probe_executions DROP COLUMN receipt_ciphertext;
|
||||
ALTER TABLE server_probe_executions ADD CONSTRAINT server_probe_storage_terminal_state CHECK(
|
||||
(state='claimed' AND receipt_proof IS NULL AND finished_at IS NULL AND attempted_stages IS NULL AND completed_stages IS NULL)
|
||||
OR(state<>'claimed' AND receipt_proof IS NOT NULL AND finished_at IS NOT NULL AND attempted_stages IS NOT NULL AND completed_stages IS NOT NULL));
|
||||
|
||||
-- Predicate only: it performs no DELETE. Every guarded exception below must
|
||||
-- independently satisfy the persisted lifetime, actual current retention and
|
||||
-- original terminal operation. The GUC alone never grants cleanup authority.
|
||||
CREATE FUNCTION server_probe_storage_gc_allowed(id uuid) RETURNS boolean LANGUAGE plpgsql SET TimeZone='UTC' AS $$
|
||||
DECLARE a server_probe_storage_admissions%ROWTYPE; activity timestamptz; current_retention integer; terminal_at timestamptz;
|
||||
BEGIN
|
||||
IF current_setting('mm.probe_storage_gc',true) IS DISTINCT FROM 'on' THEN RETURN false; END IF;
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||
SELECT * INTO a FROM server_probe_storage_admissions WHERE receipt_id=id FOR UPDATE;
|
||||
IF NOT FOUND OR a.gc_not_before>=clock_timestamp() OR a.authority_expires_at>=clock_timestamp() THEN RETURN false; END IF;
|
||||
SELECT settings.requests_retention_days INTO current_retention FROM mcp_settings settings WHERE settings.id=1;
|
||||
IF current_retention IS NULL OR current_retention NOT BETWEEN 1 AND 365 THEN RETURN false; END IF;
|
||||
terminal_at:=a.created_at;
|
||||
IF a.identity_kind='mcp' THEN
|
||||
SELECT GREATEST(o.finished_at,p.expires_at,COALESCE(cancellation.created_at,o.finished_at)) INTO terminal_at
|
||||
FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||
JOIN mcp_connections owner_connection ON owner_connection.id=o.connection_id
|
||||
LEFT JOIN mcp_diagnostic_cancellations cancellation ON cancellation.operation_id=o.id
|
||||
WHERE o.id=a.execution_id AND owner_connection.owner_id=a.owner_id AND p.operation_id='servers.probes.run'
|
||||
AND o.state IN ('applied','failed','unknown','rejected','partial','cancelled') AND o.finished_at IS NOT NULL
|
||||
AND (cancellation.operation_id IS NULL OR(cancellation.connection_id=o.connection_id AND cancellation.actor_id=a.owner_id))
|
||||
FOR UPDATE OF o,p,owner_connection;
|
||||
IF NOT FOUND OR terminal_at IS NULL THEN RETURN false; END IF;
|
||||
END IF;
|
||||
SELECT GREATEST(a.created_at,a.authority_expires_at,terminal_at,COALESCE(a.cancelled_at,a.created_at),
|
||||
COALESCE((SELECT finished_at FROM server_probe_executions WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT created_at FROM server_probe_execution_quarantines WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT max(GREATEST(created_at,COALESCE(received_at,created_at))) FROM server_probe_execution_stages WHERE receipt_id=id),a.created_at)) INTO activity;
|
||||
RETURN activity+make_interval(days=>GREATEST(a.retention_days,current_retention))+a.safety_ms*interval '1 millisecond'<clock_timestamp();
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION server_probe_storage_admission_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE current_retention integer; activity timestamptz;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||
IF TG_OP='DELETE' THEN
|
||||
IF server_probe_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||
RAISE EXCEPTION 'probe admission lifetime has not elapsed';
|
||||
END IF;
|
||||
SELECT requests_retention_days INTO current_retention FROM mcp_settings WHERE id=1;
|
||||
IF current_retention IS NULL OR current_retention NOT BETWEEN 1 AND 365 THEN RAISE EXCEPTION 'probe retention unavailable'; END IF;
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.purged_at IS NOT NULL OR (NEW.cancelled_at IS NULL AND
|
||||
(NEW.authority_expires_at<=clock_timestamp() OR NEW.claim_token IS NULL OR NEW.claim_attempted_at IS NOT NULL))
|
||||
OR (NEW.cancelled_at IS NOT NULL AND (NEW.claim_token IS NOT NULL OR NEW.claim_attempted_at IS NULL))
|
||||
THEN RAISE EXCEPTION 'invalid fresh probe admission'; END IF;
|
||||
IF NEW.cancelled_at IS NOT NULL THEN NEW.cancelled_at:=clock_timestamp(); NEW.claim_attempted_at:=NEW.cancelled_at; END IF;
|
||||
NEW.retention_days:=current_retention; RETURN NEW;
|
||||
END IF;
|
||||
IF (to_jsonb(OLD)-ARRAY['claim_token','claim_attempted_at','purged_at','cancelled_at']) IS DISTINCT FROM
|
||||
(to_jsonb(NEW)-ARRAY['claim_token','claim_attempted_at','purged_at','cancelled_at'])
|
||||
THEN RAISE EXCEPTION 'immutable probe admission identity'; END IF;
|
||||
IF OLD.cancelled_at IS DISTINCT FROM NEW.cancelled_at THEN
|
||||
IF OLD.cancelled_at IS NULL AND NEW.cancelled_at IS NOT NULL AND NEW.claim_token IS NULL
|
||||
AND NEW.purged_at IS NOT DISTINCT FROM OLD.purged_at AND NEW.claim_attempted_at IS NOT NULL
|
||||
AND (OLD.claim_attempted_at IS NULL OR NEW.claim_attempted_at IS NOT DISTINCT FROM OLD.claim_attempted_at)
|
||||
THEN NEW.cancelled_at:=clock_timestamp(); IF OLD.claim_attempted_at IS NULL THEN NEW.claim_attempted_at:=NEW.cancelled_at; END IF; RETURN NEW; END IF;
|
||||
RAISE EXCEPTION 'probe cancellation is irreversible';
|
||||
END IF;
|
||||
IF OLD.purged_at IS NULL AND NEW.purged_at IS NULL AND OLD.claim_token IS NOT NULL AND NEW.claim_token IS NULL
|
||||
AND OLD.cancelled_at IS NULL AND OLD.claim_attempted_at IS NULL AND NEW.claim_attempted_at IS NOT NULL AND OLD.authority_expires_at>clock_timestamp()
|
||||
THEN RETURN NEW; END IF;
|
||||
IF OLD.purged_at IS NULL AND NEW.purged_at IS NOT NULL AND NEW.claim_token IS NULL
|
||||
AND NEW.claim_attempted_at IS NOT DISTINCT FROM OLD.claim_attempted_at THEN
|
||||
SELECT GREATEST(OLD.authority_expires_at,COALESCE(e.finished_at,OLD.authority_expires_at),COALESCE(q.created_at,OLD.authority_expires_at)) INTO activity
|
||||
FROM (SELECT 1) x LEFT JOIN server_probe_executions e ON e.receipt_id=OLD.receipt_id
|
||||
LEFT JOIN server_probe_execution_quarantines q ON q.receipt_id=OLD.receipt_id;
|
||||
IF activity<clock_timestamp()-make_interval(days=>GREATEST(OLD.retention_days,current_retention)) THEN RETURN NEW; END IF;
|
||||
END IF;
|
||||
RAISE EXCEPTION 'probe admission transition refused';
|
||||
END $$;
|
||||
CREATE TRIGGER server_probe_storage_admission_guard BEFORE INSERT OR UPDATE OR DELETE ON server_probe_storage_admissions
|
||||
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_admission_guard();
|
||||
|
||||
CREATE FUNCTION server_probe_storage_anchor_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE a server_probe_storage_admissions%ROWTYPE;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||
SELECT * INTO a FROM server_probe_storage_admissions WHERE receipt_id=NEW.receipt_id FOR UPDATE;
|
||||
IF NOT FOUND OR a.owner_id<>NEW.actor_id OR a.identity_kind<>NEW.identity_kind OR a.execution_id<>NEW.execution_id
|
||||
OR a.binding_proof<>NEW.binding_proof OR a.authority_expires_at<>NEW.authority_expires_at
|
||||
OR a.purged_at IS NOT NULL OR a.cancelled_at IS NOT NULL OR a.claim_token IS NOT NULL OR a.claim_attempted_at IS NULL OR a.authority_expires_at<=clock_timestamp()
|
||||
THEN RAISE EXCEPTION 'probe anchor has no live consumed admission'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER server_probe_storage_anchor_guard BEFORE INSERT OR UPDATE ON server_probe_executions
|
||||
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_anchor_guard();
|
||||
|
||||
CREATE OR REPLACE FUNCTION server_probe_execution_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF TG_OP='DELETE' THEN
|
||||
IF server_probe_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||
RAISE EXCEPTION 'probe execution lifetime has not elapsed';
|
||||
END IF;
|
||||
IF OLD.state<>'claimed' OR (to_jsonb(OLD)-ARRAY['state','attempted_stages','completed_stages','receipt_proof','finished_at']) IS DISTINCT FROM
|
||||
(to_jsonb(NEW)-ARRAY['state','attempted_stages','completed_stages','receipt_proof','finished_at']) OR NEW.state='claimed'
|
||||
THEN RAISE EXCEPTION 'probe execution identity or terminal receipt is immutable'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM server_probe_execution_quarantines WHERE receipt_id=OLD.receipt_id)
|
||||
THEN RAISE EXCEPTION 'probe execution quarantined'; END IF;
|
||||
RETURN NEW; END $$;
|
||||
|
||||
CREATE FUNCTION server_probe_storage_stage_guard() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||
PERFORM receipt_id FROM server_probe_storage_admissions WHERE receipt_id=NEW.receipt_id AND purged_at IS NULL AND cancelled_at IS NULL
|
||||
AND claim_token IS NULL AND claim_attempted_at IS NOT NULL AND authority_expires_at>clock_timestamp() FOR UPDATE;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'probe stage has no live admission'; END IF;
|
||||
RETURN NEW; END $$;
|
||||
CREATE TRIGGER server_probe_storage_stage_guard BEFORE INSERT OR UPDATE ON server_probe_execution_stages
|
||||
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_stage_guard();
|
||||
|
||||
CREATE FUNCTION server_probe_storage_quarantine_guard() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||
PERFORM receipt_id FROM server_probe_storage_admissions WHERE receipt_id=NEW.receipt_id AND identity_kind=NEW.identity_kind
|
||||
AND execution_id=NEW.execution_id AND binding_proof=NEW.binding_proof FOR UPDATE;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'probe quarantine has no committed matching admission'; END IF;
|
||||
RETURN NEW; END $$;
|
||||
CREATE TRIGGER server_probe_storage_quarantine_guard BEFORE INSERT ON server_probe_execution_quarantines
|
||||
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_quarantine_guard();
|
||||
|
||||
CREATE FUNCTION server_probe_storage_payload_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE a server_probe_storage_admissions%ROWTYPE; e server_probe_executions%ROWTYPE;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||
IF TG_OP='UPDATE' THEN RAISE EXCEPTION 'probe payload is immutable'; END IF;
|
||||
IF TG_OP='DELETE' THEN
|
||||
PERFORM receipt_id FROM server_probe_storage_admissions WHERE receipt_id=OLD.receipt_id AND purged_at IS NOT NULL FOR UPDATE;
|
||||
IF NOT FOUND AND NOT server_probe_storage_gc_allowed(OLD.receipt_id) THEN RAISE EXCEPTION 'probe payload is not purged'; END IF;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
SELECT * INTO a FROM server_probe_storage_admissions WHERE receipt_id=NEW.receipt_id FOR UPDATE;
|
||||
IF NOT FOUND OR a.purged_at IS NOT NULL OR a.cancelled_at IS NOT NULL OR a.claim_token IS NOT NULL OR a.claim_attempted_at IS NULL OR a.authority_expires_at<=clock_timestamp()
|
||||
THEN RAISE EXCEPTION 'probe payload has no live admission'; END IF;
|
||||
SELECT * INTO e FROM server_probe_executions WHERE receipt_id=NEW.receipt_id FOR UPDATE;
|
||||
IF NOT FOUND OR e.state='claimed' OR e.receipt_proof<>NEW.receipt_proof OR e.actor_id<>a.owner_id OR e.binding_proof<>a.binding_proof
|
||||
OR e.identity_kind<>a.identity_kind OR e.execution_id<>a.execution_id OR EXISTS(SELECT 1 FROM server_probe_execution_quarantines q
|
||||
WHERE q.receipt_id=NEW.receipt_id OR(q.identity_kind=a.identity_kind AND q.execution_id=a.execution_id))
|
||||
THEN RAISE EXCEPTION 'probe payload terminal identity mismatch or quarantine'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER server_probe_storage_payload_guard BEFORE INSERT OR UPDATE OR DELETE ON server_probe_execution_payloads
|
||||
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_payload_guard();
|
||||
-- Preserve every original INSERT/UPDATE rule; only the already-validated
|
||||
-- lifetime predicate can admit DELETE; no cleanup is scheduled by migration.
|
||||
CREATE OR REPLACE FUNCTION server_probe_stage_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF TG_OP='DELETE' THEN
|
||||
IF server_probe_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||
RAISE EXCEPTION 'probe stage lifetime has not elapsed';
|
||||
END IF;
|
||||
PERFORM receipt_id FROM server_probe_executions WHERE receipt_id=NEW.receipt_id AND state='claimed' FOR UPDATE;
|
||||
IF NOT FOUND OR EXISTS(SELECT 1 FROM server_probe_execution_quarantines WHERE receipt_id=NEW.receipt_id) THEN RAISE EXCEPTION 'probe attempt is not executable'; END IF;
|
||||
IF TG_OP='INSERT' AND (NEW.stage_index<>(SELECT COALESCE(max(stage_index),0)+1 FROM server_probe_execution_stages WHERE receipt_id=NEW.receipt_id)
|
||||
OR EXISTS(SELECT 1 FROM server_probe_execution_stages WHERE receipt_id=NEW.receipt_id AND state<>'received'))
|
||||
THEN RAISE EXCEPTION 'probe stages must be serial and contiguous'; END IF;
|
||||
IF TG_OP='UPDATE' AND (OLD.state<>'intent' OR NEW.state<>'received' OR (to_jsonb(OLD)-ARRAY['state','outcome','received_at']) IS DISTINCT FROM (to_jsonb(NEW)-ARRAY['state','outcome','received_at']))
|
||||
THEN RAISE EXCEPTION 'probe stage terminal state is immutable'; END IF;
|
||||
RETURN NEW; END $$;
|
||||
CREATE OR REPLACE FUNCTION server_probe_quarantine_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF TG_OP='DELETE' AND server_probe_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||
RAISE EXCEPTION 'probe quarantine remains immutable'; END $$;
|
||||
CREATE OR REPLACE FUNCTION mcp_diagnostic_cancellation_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE original_receipt uuid;
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN
|
||||
SELECT a.receipt_id INTO original_receipt FROM server_probe_storage_admissions a
|
||||
JOIN mcp_operations o ON o.id=a.execution_id AND o.connection_id=OLD.connection_id
|
||||
WHERE a.identity_kind='mcp' AND a.execution_id=OLD.operation_id AND a.owner_id=OLD.actor_id;
|
||||
IF FOUND AND server_probe_storage_gc_allowed(original_receipt) THEN RETURN OLD; END IF;
|
||||
END IF;
|
||||
RAISE EXCEPTION 'diagnostic cancellation lifetime has not elapsed';
|
||||
END $$;
|
||||
INSERT INTO schema_migrations(id) VALUES ('0037_server_probe_storage_v2') ON CONFLICT (id) DO NOTHING;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Initial poll journal and adopted partition-writer backstop.
|
||||
-- Mixed old replicas MUST be drained before activation: triggers alone run
|
||||
-- after relation access and cannot replace pre-DML shared writer admission.
|
||||
BEGIN;
|
||||
DO $$ BEGIN
|
||||
PERFORM pg_catalog.pg_advisory_xact_lock(8723110::bigint);
|
||||
IF EXISTS(SELECT 1 FROM pg_catalog.pg_class WHERE relname=ANY(ARRAY['server_poll_executions','server_poll_intents','server_poll_acks','server_poll_receipts','server_poll_quarantines']))
|
||||
OR EXISTS(SELECT 1 FROM pg_catalog.pg_proc WHERE proname=ANY(ARRAY['server_poll_journal_immutable','server_partition_writer_guard']))
|
||||
OR EXISTS(SELECT 1 FROM pg_catalog.pg_trigger WHERE tgname='server_partition_writer_guard')
|
||||
THEN RAISE EXCEPTION 'unmarked poll journal or partition guard requires explicit reconciliation'; END IF;
|
||||
END $$;
|
||||
CREATE TABLE server_poll_executions (
|
||||
receipt_id uuid PRIMARY KEY,identity_kind text NOT NULL CHECK(identity_kind IN ('mcp','rest')),execution_id uuid NOT NULL,
|
||||
binding_proof text NOT NULL CHECK(binding_proof ~ '^[a-f0-9]{64}$'),context_ciphertext text NOT NULL CHECK(octet_length(context_ciphertext)<=2097152),
|
||||
context_proof text NOT NULL CHECK(context_proof ~ '^[a-f0-9]{64}$'),created_at timestamptz NOT NULL DEFAULT clock_timestamp(),UNIQUE(identity_kind,execution_id));
|
||||
CREATE TABLE server_poll_intents (
|
||||
receipt_id uuid REFERENCES server_poll_executions(receipt_id),stage text NOT NULL CHECK(stage ~ '^(native\.(identity|resource|interfaces|addresses)|publish\.[0-9]{1,4})$'),
|
||||
before_proof text NOT NULL CHECK(before_proof ~ '^[a-f0-9]{64}$'),effect_proof text NOT NULL CHECK(effect_proof ~ '^[a-f0-9]{64}$'),
|
||||
intent_proof text NOT NULL CHECK(intent_proof ~ '^[a-f0-9]{64}$'),created_at timestamptz NOT NULL DEFAULT clock_timestamp(),PRIMARY KEY(receipt_id,stage));
|
||||
CREATE TABLE server_poll_acks (
|
||||
receipt_id uuid NOT NULL,stage text NOT NULL,result_proof text NOT NULL CHECK(result_proof ~ '^[a-f0-9]{64}$'),outcome text NOT NULL CHECK(outcome IN ('confirmed','read-failed')),
|
||||
ack_proof text NOT NULL CHECK(ack_proof ~ '^[a-f0-9]{64}$'),created_at timestamptz NOT NULL DEFAULT clock_timestamp(),PRIMARY KEY(receipt_id,stage),FOREIGN KEY(receipt_id,stage) REFERENCES server_poll_intents(receipt_id,stage),CHECK(stage LIKE 'native.%' OR outcome='confirmed'));
|
||||
CREATE TABLE server_poll_receipts (
|
||||
receipt_id uuid PRIMARY KEY REFERENCES server_poll_executions(receipt_id),receipt_ciphertext text NOT NULL CHECK(octet_length(receipt_ciphertext)<=262144),receipt_proof text NOT NULL CHECK(receipt_proof ~ '^[a-f0-9]{64}$'),created_at timestamptz NOT NULL DEFAULT clock_timestamp());
|
||||
CREATE TABLE server_poll_quarantines (
|
||||
receipt_id uuid PRIMARY KEY,identity_kind text NOT NULL CHECK(identity_kind IN ('mcp','rest')),execution_id uuid NOT NULL,context_proof text NOT NULL CHECK(context_proof ~ '^[a-f0-9]{64}$'),created_at timestamptz NOT NULL DEFAULT clock_timestamp(),UNIQUE(identity_kind,execution_id));
|
||||
CREATE FUNCTION server_poll_journal_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'server poll journal is append only'; END $$;
|
||||
CREATE TRIGGER server_poll_execution_immutable BEFORE UPDATE OR DELETE ON server_poll_executions FOR EACH ROW EXECUTE FUNCTION server_poll_journal_immutable();
|
||||
CREATE TRIGGER server_poll_intent_immutable BEFORE UPDATE OR DELETE ON server_poll_intents FOR EACH ROW EXECUTE FUNCTION server_poll_journal_immutable();
|
||||
CREATE TRIGGER server_poll_ack_immutable BEFORE UPDATE OR DELETE ON server_poll_acks FOR EACH ROW EXECUTE FUNCTION server_poll_journal_immutable();
|
||||
CREATE TRIGGER server_poll_receipt_immutable BEFORE UPDATE OR DELETE ON server_poll_receipts FOR EACH ROW EXECUTE FUNCTION server_poll_journal_immutable();
|
||||
CREATE TRIGGER server_poll_quarantine_immutable BEFORE UPDATE OR DELETE ON server_poll_quarantines FOR EACH ROW EXECUTE FUNCTION server_poll_journal_immutable();
|
||||
CREATE FUNCTION public.server_partition_writer_guard() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_advisory_xact_lock_shared(8723110::bigint); IF TG_OP='DELETE' THEN RETURN OLD; END IF; RETURN NEW; END; $$;
|
||||
CREATE TRIGGER server_partition_writer_guard BEFORE INSERT OR UPDATE OR DELETE ON public."flow_buckets" FOR EACH ROW EXECUTE FUNCTION public.server_partition_writer_guard();
|
||||
ALTER TABLE public."flow_buckets" ENABLE ALWAYS TRIGGER server_partition_writer_guard;
|
||||
CREATE TRIGGER server_partition_writer_guard BEFORE INSERT OR UPDATE OR DELETE ON public."flow_minute_stats" FOR EACH ROW EXECUTE FUNCTION public.server_partition_writer_guard();
|
||||
ALTER TABLE public."flow_minute_stats" ENABLE ALWAYS TRIGGER server_partition_writer_guard;
|
||||
CREATE TRIGGER server_partition_writer_guard BEFORE INSERT OR UPDATE OR DELETE ON public."flow_minute_dims" FOR EACH ROW EXECUTE FUNCTION public.server_partition_writer_guard();
|
||||
ALTER TABLE public."flow_minute_dims" ENABLE ALWAYS TRIGGER server_partition_writer_guard;
|
||||
CREATE TRIGGER server_partition_writer_guard BEFORE INSERT OR UPDATE OR DELETE ON public."flow_daily_dims" FOR EACH ROW EXECUTE FUNCTION public.server_partition_writer_guard();
|
||||
ALTER TABLE public."flow_daily_dims" ENABLE ALWAYS TRIGGER server_partition_writer_guard;
|
||||
CREATE TRIGGER server_partition_writer_guard BEFORE INSERT OR UPDATE OR DELETE ON public."flow_hour_facts" FOR EACH ROW EXECUTE FUNCTION public.server_partition_writer_guard();
|
||||
ALTER TABLE public."flow_hour_facts" ENABLE ALWAYS TRIGGER server_partition_writer_guard;
|
||||
CREATE TRIGGER server_partition_writer_guard BEFORE INSERT OR UPDATE OR DELETE ON public."flow_daily_facts" FOR EACH ROW EXECUTE FUNCTION public.server_partition_writer_guard();
|
||||
ALTER TABLE public."flow_daily_facts" ENABLE ALWAYS TRIGGER server_partition_writer_guard;
|
||||
CREATE TRIGGER server_partition_writer_guard BEFORE INSERT OR UPDATE OR DELETE ON public."traffic_samples" FOR EACH ROW EXECUTE FUNCTION public.server_partition_writer_guard();
|
||||
ALTER TABLE public."traffic_samples" ENABLE ALWAYS TRIGGER server_partition_writer_guard;
|
||||
CREATE TRIGGER server_partition_writer_guard BEFORE INSERT OR UPDATE OR DELETE ON public."servers_rest_ping_samples" FOR EACH ROW EXECUTE FUNCTION public.server_partition_writer_guard();
|
||||
ALTER TABLE public."servers_rest_ping_samples" ENABLE ALWAYS TRIGGER server_partition_writer_guard;
|
||||
CREATE TRIGGER server_partition_writer_guard BEFORE INSERT OR UPDATE OR DELETE ON public."uptime_probe_samples" FOR EACH ROW EXECUTE FUNCTION public.server_partition_writer_guard();
|
||||
ALTER TABLE public."uptime_probe_samples" ENABLE ALWAYS TRIGGER server_partition_writer_guard;
|
||||
CREATE TRIGGER server_partition_writer_guard BEFORE INSERT OR UPDATE OR DELETE ON public."uptime_resource_samples" FOR EACH ROW EXECUTE FUNCTION public.server_partition_writer_guard();
|
||||
ALTER TABLE public."uptime_resource_samples" ENABLE ALWAYS TRIGGER server_partition_writer_guard;
|
||||
CREATE TRIGGER server_partition_writer_guard BEFORE INSERT OR UPDATE OR DELETE ON public."server_snapshots" FOR EACH ROW EXECUTE FUNCTION public.server_partition_writer_guard();
|
||||
ALTER TABLE public."server_snapshots" ENABLE ALWAYS TRIGGER server_partition_writer_guard;
|
||||
CREATE TRIGGER server_partition_writer_guard BEFORE INSERT OR UPDATE OR DELETE ON public."internet_path_snapshots" FOR EACH ROW EXECUTE FUNCTION public.server_partition_writer_guard();
|
||||
ALTER TABLE public."internet_path_snapshots" ENABLE ALWAYS TRIGGER server_partition_writer_guard;
|
||||
INSERT INTO schema_migrations(id) VALUES ('0038_server_poll_journal_and_writer_guards') ON CONFLICT(id) DO NOTHING;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,175 @@
|
||||
-- Fresh-only enrollment of the reviewed bounded poll journal. No historical backfill.
|
||||
-- Fixed logical quota; physical heap/index/WAL and vacuum remain independent.
|
||||
-- Requires the empty 0038 poll journal. No scheduling or public activation.
|
||||
BEGIN;
|
||||
LOCK TABLE server_poll_executions,server_poll_intents,server_poll_acks,server_poll_receipts,server_poll_quarantines IN ACCESS EXCLUSIVE MODE;
|
||||
DO $$ BEGIN
|
||||
IF EXISTS(SELECT 1 FROM server_poll_executions) OR EXISTS(SELECT 1 FROM server_poll_intents)
|
||||
OR EXISTS(SELECT 1 FROM server_poll_acks) OR EXISTS(SELECT 1 FROM server_poll_receipts)
|
||||
OR EXISTS(SELECT 1 FROM server_poll_quarantines)
|
||||
THEN RAISE EXCEPTION 'nonempty poll journal needs explicit reconciliation'; END IF;
|
||||
IF NOT EXISTS(SELECT 1 FROM mcp_settings WHERE id=1 AND requests_retention_days BETWEEN 1 AND 365)
|
||||
THEN RAISE EXCEPTION 'poll retention unavailable'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM pg_catalog.pg_class WHERE relname IN('server_poll_storage_usage','server_poll_storage_admissions'))
|
||||
OR EXISTS(SELECT 1 FROM pg_catalog.pg_proc WHERE proname IN('server_poll_storage_gc_allowed','server_poll_storage_admission_guard','server_poll_storage_row_guard','server_poll_storage_row_meter'))
|
||||
OR EXISTS(SELECT 1 FROM pg_catalog.pg_trigger WHERE tgname LIKE 'server_poll_storage_%')
|
||||
THEN RAISE EXCEPTION 'unmarked poll storage requires explicit reconciliation'; END IF;
|
||||
END $$;
|
||||
CREATE TABLE server_poll_storage_usage (
|
||||
scope text PRIMARY KEY CHECK(scope='global' OR(scope LIKE 'owner:%' AND length(scope) BETWEEN 7 AND 206)),
|
||||
admissions bigint NOT NULL DEFAULT 0 CHECK(admissions>=0 AND admissions<=128),
|
||||
reserved_rows bigint NOT NULL DEFAULT 0 CHECK(reserved_rows=admissions*4019),
|
||||
reserved_bytes bigint NOT NULL DEFAULT 0 CHECK(reserved_bytes=admissions*8421376),
|
||||
CHECK(scope='global' OR admissions<=8)
|
||||
);
|
||||
CREATE TABLE server_poll_storage_admissions (
|
||||
receipt_id uuid PRIMARY KEY, identity_kind text NOT NULL CHECK(identity_kind IN('mcp','rest')),
|
||||
execution_id uuid NOT NULL, connection_id uuid, owner_id text NOT NULL CHECK(length(owner_id) BETWEEN 1 AND 200 AND btrim(owner_id)=owner_id AND owner_id !~ '[[:cntrl:]]'),
|
||||
binding_proof text NOT NULL CHECK(binding_proof ~ '^[a-f0-9]{64}$'), context_proof text NOT NULL CHECK(context_proof ~ '^[a-f0-9]{64}$'),
|
||||
authority_issued_at timestamptz NOT NULL CHECK(isfinite(authority_issued_at)),
|
||||
authority_expires_at timestamptz NOT NULL CHECK(isfinite(authority_expires_at) AND authority_expires_at>authority_issued_at),
|
||||
max_stages integer NOT NULL CHECK(max_stages BETWEEN 8 AND 2008),
|
||||
claim_token uuid, claim_attempted_at timestamptz, created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
retention_days integer NOT NULL CHECK(retention_days BETWEEN 1 AND 365),
|
||||
safety_ms integer NOT NULL DEFAULT 60000 CHECK(safety_ms=60000),
|
||||
gc_not_before timestamptz NOT NULL CHECK(isfinite(gc_not_before)),
|
||||
used_rows integer NOT NULL DEFAULT 0 CHECK(used_rows BETWEEN 0 AND 4019 AND used_rows<=max_stages*2+3),
|
||||
used_bytes bigint NOT NULL DEFAULT 0 CHECK(used_bytes BETWEEN 0 AND 8388608),
|
||||
UNIQUE(identity_kind,execution_id),
|
||||
CHECK((identity_kind='mcp' AND connection_id IS NOT NULL) OR(identity_kind='rest' AND connection_id IS NULL)),
|
||||
CHECK((claim_token IS NOT NULL AND claim_attempted_at IS NULL) OR(claim_token IS NULL AND claim_attempted_at IS NOT NULL)),
|
||||
CHECK(gc_not_before>=authority_expires_at+make_interval(days=>retention_days)+interval '60 seconds')
|
||||
);
|
||||
CREATE INDEX server_poll_storage_expiry ON server_poll_storage_admissions(gc_not_before,receipt_id);
|
||||
INSERT INTO server_poll_storage_usage(scope) VALUES('global');
|
||||
|
||||
-- Every guard is installed in this same empty-enrollment transaction;
|
||||
-- no historical admission or journal identity is inferred or imported.
|
||||
CREATE FUNCTION server_poll_storage_gc_allowed(id uuid) RETURNS boolean LANGUAGE plpgsql SET TimeZone='UTC' AS $$
|
||||
DECLARE a server_poll_storage_admissions%ROWTYPE; retention integer; activity timestamptz; terminal_at timestamptz;
|
||||
BEGIN
|
||||
IF current_setting('mm.poll_storage_gc',true) IS DISTINCT FROM 'on' THEN RETURN false; END IF;
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||
SELECT * INTO a FROM server_poll_storage_admissions WHERE receipt_id=id FOR UPDATE;
|
||||
IF NOT FOUND OR a.authority_expires_at>=clock_timestamp() OR a.gc_not_before>=clock_timestamp() THEN RETURN false; END IF;
|
||||
SELECT requests_retention_days INTO retention FROM mcp_settings WHERE mcp_settings.id=1 FOR SHARE;
|
||||
IF retention IS NULL OR retention NOT BETWEEN 1 AND 365 THEN RETURN false; END IF;
|
||||
terminal_at:=a.created_at;
|
||||
IF a.identity_kind='mcp' THEN
|
||||
SELECT GREATEST(o.finished_at,p.expires_at) INTO terminal_at FROM mcp_operations o
|
||||
JOIN mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||
JOIN mcp_connections c ON c.id=o.connection_id
|
||||
WHERE o.id=a.execution_id AND o.connection_id=a.connection_id AND c.owner_id=a.owner_id AND p.operation_id='servers.poll'
|
||||
AND o.state IN('applied','failed','unknown','rejected','partial','cancelled') AND o.finished_at IS NOT NULL
|
||||
FOR UPDATE OF o,p,c;
|
||||
IF NOT FOUND OR terminal_at IS NULL THEN RETURN false; END IF;
|
||||
END IF;
|
||||
SELECT GREATEST(a.authority_expires_at,a.created_at,COALESCE(a.claim_attempted_at,a.created_at),terminal_at,
|
||||
COALESCE((SELECT created_at FROM server_poll_executions WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT max(created_at) FROM server_poll_intents WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT max(created_at) FROM server_poll_acks WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT created_at FROM server_poll_receipts WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT created_at FROM server_poll_quarantines WHERE receipt_id=id),a.created_at)) INTO activity;
|
||||
RETURN activity+make_interval(days=>GREATEST(a.retention_days,retention))+a.safety_ms*interval '1 millisecond'<clock_timestamp();
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION server_poll_storage_admission_guard() RETURNS trigger LANGUAGE plpgsql SET TimeZone='UTC' AS $$
|
||||
DECLARE retention integer; charged integer;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||
IF TG_OP='DELETE' THEN
|
||||
IF NOT server_poll_storage_gc_allowed(OLD.receipt_id) OR EXISTS(SELECT 1 FROM server_poll_executions WHERE receipt_id=OLD.receipt_id)
|
||||
OR EXISTS(SELECT 1 FROM server_poll_intents WHERE receipt_id=OLD.receipt_id) OR EXISTS(SELECT 1 FROM server_poll_acks WHERE receipt_id=OLD.receipt_id)
|
||||
OR EXISTS(SELECT 1 FROM server_poll_receipts WHERE receipt_id=OLD.receipt_id) OR EXISTS(SELECT 1 FROM server_poll_quarantines WHERE receipt_id=OLD.receipt_id)
|
||||
THEN RAISE EXCEPTION 'poll lifetime or children prevent admission deletion'; END IF;
|
||||
UPDATE server_poll_storage_usage SET admissions=admissions-1,reserved_rows=reserved_rows-4019,reserved_bytes=reserved_bytes-8421376
|
||||
WHERE scope IN('global','owner:'||OLD.owner_id) AND admissions>=1 AND reserved_rows>=4019 AND reserved_bytes>=8421376;
|
||||
GET DIAGNOSTICS charged=ROW_COUNT;
|
||||
IF charged<>2 THEN RAISE EXCEPTION 'poll quota refund mismatch'; END IF;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
IF TG_OP='UPDATE' THEN
|
||||
IF(to_jsonb(OLD)-ARRAY['claim_token','claim_attempted_at','used_rows','used_bytes']) IS DISTINCT FROM
|
||||
(to_jsonb(NEW)-ARRAY['claim_token','claim_attempted_at','used_rows','used_bytes']) THEN RAISE EXCEPTION 'poll admission identity is immutable'; END IF;
|
||||
IF OLD.claim_token IS NOT NULL AND NEW.claim_token IS NULL AND OLD.claim_attempted_at IS NULL AND NEW.claim_attempted_at IS NOT NULL
|
||||
AND NEW.used_rows=OLD.used_rows AND NEW.used_bytes=OLD.used_bytes AND OLD.authority_expires_at>clock_timestamp()
|
||||
THEN NEW.claim_attempted_at:=clock_timestamp(); RETURN NEW; END IF;
|
||||
IF OLD.claim_token IS NOT DISTINCT FROM NEW.claim_token AND OLD.claim_attempted_at IS NOT DISTINCT FROM NEW.claim_attempted_at
|
||||
AND NEW.used_rows=OLD.used_rows+1 AND NEW.used_bytes>OLD.used_bytes THEN RETURN NEW; END IF;
|
||||
RAISE EXCEPTION 'poll admission cannot refresh, reopen or refund itself';
|
||||
END IF;
|
||||
SELECT requests_retention_days INTO retention FROM mcp_settings WHERE id=1;
|
||||
IF retention IS NULL OR retention NOT BETWEEN 1 AND 365 OR NEW.authority_issued_at>clock_timestamp()
|
||||
OR NEW.authority_expires_at<=clock_timestamp() OR NEW.claim_token IS NULL OR NEW.claim_attempted_at IS NOT NULL
|
||||
OR NEW.used_rows<>0 OR NEW.used_bytes<>0
|
||||
THEN RAISE EXCEPTION 'new poll admission needs original live authority'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM server_poll_storage_admissions WHERE receipt_id=NEW.receipt_id OR(identity_kind=NEW.identity_kind AND execution_id=NEW.execution_id))
|
||||
THEN RAISE EXCEPTION 'poll identity already spent'; END IF;
|
||||
NEW.created_at:=clock_timestamp(); NEW.retention_days:=retention; NEW.safety_ms:=60000;
|
||||
NEW.gc_not_before:=NEW.authority_expires_at+make_interval(days=>retention)+interval '60 seconds';
|
||||
INSERT INTO server_poll_storage_usage(scope) VALUES('owner:'||NEW.owner_id) ON CONFLICT DO NOTHING;
|
||||
IF(SELECT count(*) FROM server_poll_storage_usage WHERE scope IN('global','owner:'||NEW.owner_id)
|
||||
AND admissions<CASE WHEN scope='global' THEN 128 ELSE 8 END)<>2 THEN RAISE EXCEPTION 'poll admission quota exhausted'; END IF;
|
||||
UPDATE server_poll_storage_usage SET admissions=admissions+1,reserved_rows=reserved_rows+4019,reserved_bytes=reserved_bytes+8421376
|
||||
WHERE scope IN('global','owner:'||NEW.owner_id);
|
||||
GET DIAGNOSTICS charged=ROW_COUNT; IF charged<>2 THEN RAISE EXCEPTION 'poll admission quota unavailable'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER server_poll_storage_admission_guard BEFORE INSERT OR UPDATE OR DELETE ON server_poll_storage_admissions
|
||||
FOR EACH ROW EXECUTE FUNCTION server_poll_storage_admission_guard();
|
||||
|
||||
CREATE FUNCTION server_poll_storage_row_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE a server_poll_storage_admissions%ROWTYPE; n integer;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||
SELECT * INTO a FROM server_poll_storage_admissions WHERE receipt_id=NEW.receipt_id FOR UPDATE;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'poll journal needs a committed admission'; END IF;
|
||||
IF TG_TABLE_NAME='server_poll_quarantines' THEN
|
||||
IF NEW.identity_kind<>a.identity_kind OR NEW.execution_id<>a.execution_id OR NEW.context_proof<>a.context_proof
|
||||
THEN RAISE EXCEPTION 'poll quarantine admission mismatch'; END IF;
|
||||
ELSE
|
||||
IF a.claim_token IS NOT NULL OR a.claim_attempted_at IS NULL OR a.authority_expires_at<=clock_timestamp()
|
||||
THEN RAISE EXCEPTION 'poll admission is not consumed and live'; END IF;
|
||||
END IF;
|
||||
IF TG_TABLE_NAME='server_poll_executions' THEN
|
||||
IF NEW.identity_kind<>a.identity_kind OR NEW.execution_id<>a.execution_id OR NEW.context_proof<>a.context_proof OR NEW.binding_proof<>a.binding_proof
|
||||
THEN RAISE EXCEPTION 'poll anchor admission mismatch'; END IF;
|
||||
ELSIF TG_TABLE_NAME IN('server_poll_intents','server_poll_acks') THEN
|
||||
IF NEW.stage LIKE 'publish.%' AND substring(NEW.stage FROM 9)::integer>=a.max_stages-4 THEN RAISE EXCEPTION 'poll stage exceeds private reservation'; END IF;
|
||||
IF TG_TABLE_NAME='server_poll_intents' THEN SELECT count(*) INTO n FROM server_poll_intents WHERE receipt_id=NEW.receipt_id;
|
||||
ELSE SELECT count(*) INTO n FROM server_poll_acks WHERE receipt_id=NEW.receipt_id; END IF;
|
||||
IF n>=a.max_stages THEN RAISE EXCEPTION 'poll stage row budget exhausted'; END IF;
|
||||
END IF;
|
||||
IF TG_TABLE_NAME<>'server_poll_quarantines' AND EXISTS(SELECT 1 FROM server_poll_quarantines WHERE receipt_id=NEW.receipt_id)
|
||||
THEN RAISE EXCEPTION 'poll identity quarantined'; END IF;
|
||||
NEW.created_at:=clock_timestamp();
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
-- Meter actual inserts, not BEFORE attempts: ON CONFLICT DO NOTHING must not
|
||||
-- spend another row/byte allowance for a marker which was never inserted.
|
||||
CREATE FUNCTION server_poll_storage_row_meter() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE cost bigint; n integer;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||
cost:=pg_column_size(NEW);
|
||||
UPDATE server_poll_storage_admissions SET used_rows=used_rows+1,used_bytes=used_bytes+cost
|
||||
WHERE receipt_id=NEW.receipt_id AND used_rows<max_stages*2+3 AND used_bytes<=8388608-cost;
|
||||
GET DIAGNOSTICS n=ROW_COUNT; IF n<>1 THEN RAISE EXCEPTION 'poll journal byte or row budget exhausted'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER server_poll_storage_anchor_guard BEFORE INSERT ON server_poll_executions FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_guard();
|
||||
CREATE TRIGGER server_poll_storage_intent_guard BEFORE INSERT ON server_poll_intents FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_guard();
|
||||
CREATE TRIGGER server_poll_storage_ack_guard BEFORE INSERT ON server_poll_acks FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_guard();
|
||||
CREATE TRIGGER server_poll_storage_receipt_guard BEFORE INSERT ON server_poll_receipts FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_guard();
|
||||
CREATE TRIGGER server_poll_storage_quarantine_guard BEFORE INSERT ON server_poll_quarantines FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_guard();
|
||||
CREATE TRIGGER server_poll_storage_anchor_meter AFTER INSERT ON server_poll_executions FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_meter();
|
||||
CREATE TRIGGER server_poll_storage_intent_meter AFTER INSERT ON server_poll_intents FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_meter();
|
||||
CREATE TRIGGER server_poll_storage_ack_meter AFTER INSERT ON server_poll_acks FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_meter();
|
||||
CREATE TRIGGER server_poll_storage_receipt_meter AFTER INSERT ON server_poll_receipts FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_meter();
|
||||
CREATE TRIGGER server_poll_storage_quarantine_meter AFTER INSERT ON server_poll_quarantines FOR EACH ROW EXECUTE FUNCTION server_poll_storage_row_meter();
|
||||
CREATE OR REPLACE FUNCTION server_poll_journal_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF TG_OP='DELETE' AND server_poll_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||
RAISE EXCEPTION 'server poll journal is immutable before its full lifetime elapses';
|
||||
END $$;
|
||||
INSERT INTO schema_migrations(id) VALUES ('0039_server_poll_storage') ON CONFLICT(id) DO NOTHING;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Additive, inactive reserved recovery ledger. Existing manual evidence is retained.
|
||||
-- Legacy reserved rows cannot prove an original two-write debit: never backfill them.
|
||||
BEGIN;
|
||||
LOCK TABLE public.mcp_scoped_recovery_intents IN ACCESS EXCLUSIVE MODE;
|
||||
DO $$ BEGIN
|
||||
IF EXISTS(SELECT 1 FROM public.mcp_scoped_recovery_intents WHERE mode='reserved')
|
||||
THEN RAISE EXCEPTION 'legacy reserved recovery needs explicit reconciliation'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
|
||||
WHERE n.nspname='public' AND c.relname IN('mcp_routing_reservations','mcp_routing_reservation_slots','mcp_routing_reservation_slots_window'))
|
||||
OR EXISTS(SELECT 1 FROM pg_catalog.pg_attribute a WHERE a.attrelid='public.mcp_scoped_recovery_intents'::regclass
|
||||
AND NOT a.attisdropped AND a.attname IN('reservation_id','slot_phase','inverse_intent_sealed','inverse_intent_proof','inverse_configuration_sealed','inverse_configuration_proof'))
|
||||
OR EXISTS(SELECT 1 FROM pg_catalog.pg_constraint c WHERE c.conrelid='public.mcp_scoped_recovery_intents'::regclass
|
||||
AND c.conname IN('scoped_reservation_slot_shape','reserved_inverse_evidence_pairs'))
|
||||
THEN RAISE EXCEPTION 'unmarked routing reservation objects need explicit reconciliation'; END IF;
|
||||
END $$;
|
||||
CREATE TABLE public.mcp_routing_reservations (
|
||||
id uuid PRIMARY KEY,
|
||||
source_plan_id uuid NOT NULL REFERENCES public.mcp_plans(id),
|
||||
source_operation_id uuid NOT NULL REFERENCES public.mcp_operations(id),
|
||||
source_step_index integer NOT NULL CHECK(source_step_index=0),
|
||||
profile_id uuid NOT NULL REFERENCES public.mcp_routing_profiles(id),
|
||||
connection_id uuid NOT NULL REFERENCES public.mcp_connections(id),
|
||||
binding_sealed text NOT NULL,binding_proof text NOT NULL,
|
||||
state_sealed text NOT NULL,state_proof text NOT NULL,revision integer NOT NULL CHECK(revision>=1),
|
||||
deadline timestamptz NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(source_operation_id,source_step_index)
|
||||
);
|
||||
CREATE TABLE public.mcp_routing_reservation_slots (
|
||||
reservation_id uuid NOT NULL REFERENCES public.mcp_routing_reservations(id),
|
||||
profile_id uuid NOT NULL REFERENCES public.mcp_routing_profiles(id),
|
||||
lane text NOT NULL CHECK(lane IN ('forward','inverse')),
|
||||
state text NOT NULL CHECK(state IN ('held','spent','proven-unused','unknown')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(reservation_id,lane)
|
||||
);
|
||||
CREATE INDEX mcp_routing_reservation_slots_window ON public.mcp_routing_reservation_slots(profile_id,created_at);
|
||||
ALTER TABLE public.mcp_scoped_recovery_intents ADD COLUMN reservation_id uuid REFERENCES public.mcp_routing_reservations(id);
|
||||
ALTER TABLE public.mcp_scoped_recovery_intents ADD COLUMN slot_phase text NOT NULL DEFAULT 'recovery-intent';
|
||||
ALTER TABLE public.mcp_scoped_recovery_intents ALTER COLUMN source_configuration_proof DROP NOT NULL;
|
||||
ALTER TABLE public.mcp_scoped_recovery_intents ADD COLUMN inverse_intent_sealed text;
|
||||
ALTER TABLE public.mcp_scoped_recovery_intents ADD COLUMN inverse_intent_proof text;
|
||||
ALTER TABLE public.mcp_scoped_recovery_intents ADD COLUMN inverse_configuration_sealed text;
|
||||
ALTER TABLE public.mcp_scoped_recovery_intents ADD COLUMN inverse_configuration_proof text;
|
||||
ALTER TABLE public.mcp_scoped_recovery_intents ADD CONSTRAINT scoped_reservation_slot_shape CHECK (
|
||||
(mode='manual' AND reservation_id IS NULL AND slot_phase='recovery-intent' AND source_configuration_proof IS NOT NULL)
|
||||
OR (mode='reserved' AND reservation_id IS NOT NULL AND
|
||||
((slot_phase='reservation-held' AND source_configuration_proof IS NULL)
|
||||
OR (slot_phase IN ('reservation-bound','reserved-attempt') AND source_configuration_proof IS NOT NULL)))
|
||||
);
|
||||
ALTER TABLE public.mcp_scoped_recovery_intents ADD CONSTRAINT reserved_inverse_evidence_pairs CHECK (
|
||||
(inverse_intent_sealed IS NULL)=(inverse_intent_proof IS NULL)
|
||||
AND (inverse_configuration_sealed IS NULL)=(inverse_configuration_proof IS NULL)
|
||||
AND (inverse_configuration_sealed IS NULL OR inverse_intent_sealed IS NOT NULL)
|
||||
AND (inverse_intent_sealed IS NULL OR mode='reserved')
|
||||
);
|
||||
INSERT INTO public.schema_migrations(id) VALUES ('0040_mcp_routing_reservations') ON CONFLICT(id) DO NOTHING;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,333 @@
|
||||
-- Additive terminal poll archives. Existing 0040 full journals are retained.
|
||||
-- No compaction, retention run, source backfill or worker activation occurs here.
|
||||
BEGIN;
|
||||
DO $$ BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||
IF EXISTS(SELECT 1 FROM pg_class WHERE relname='server_poll_archives')
|
||||
OR EXISTS(SELECT 1 FROM pg_attribute WHERE attrelid='public.server_poll_storage_admissions'::regclass AND NOT attisdropped AND attname IN('storage_state','compact_at','archive_bytes','original_activity_at','compaction_claim','compaction_claimed_at','compaction_claim_xid'))
|
||||
OR EXISTS(SELECT 1 FROM pg_attribute WHERE attrelid='public.server_poll_storage_usage'::regclass AND NOT attisdropped AND attname IN('compacted_admissions','archive_bytes'))
|
||||
OR EXISTS(SELECT 1 FROM pg_proc WHERE proname IN('server_poll_storage_source_payload','server_poll_storage_compact_allowed','server_poll_storage_accounting_valid','server_poll_archive_insert_guard','server_poll_archive_insert_finish','server_poll_archive_immutable','server_poll_archive_final_guard','server_poll_compacted_row_guard'))
|
||||
OR EXISTS(SELECT 1 FROM pg_trigger WHERE tgname IN('server_poll_archive_insert_guard','server_poll_archive_insert_finish','server_poll_archive_immutable','server_poll_archive_final_guard','server_poll_admission_final_guard','server_poll_compact_final_guard','server_poll_compacted_anchor_guard','server_poll_compacted_intent_guard','server_poll_compacted_ack_guard','server_poll_compacted_receipt_guard','server_poll_compacted_quarantine_guard'))
|
||||
THEN RAISE EXCEPTION 'unmarked poll archive objects require explicit reconciliation'; END IF;
|
||||
END $$;
|
||||
LOCK TABLE server_poll_storage_usage,server_poll_storage_admissions,server_poll_executions,server_poll_intents,server_poll_acks,server_poll_receipts,server_poll_quarantines IN ACCESS EXCLUSIVE MODE;
|
||||
DO $$ BEGIN
|
||||
IF (SELECT count(*) FROM server_poll_storage_usage WHERE scope='global')<>1
|
||||
OR EXISTS(SELECT 1 FROM server_poll_storage_usage u WHERE
|
||||
u.admissions<>(SELECT count(*) FROM server_poll_storage_admissions a WHERE u.scope='global' OR u.scope='owner:'||a.owner_id)
|
||||
OR u.reserved_rows<>u.admissions*4019 OR u.reserved_bytes<>u.admissions*8421376)
|
||||
OR EXISTS(SELECT 1 FROM server_poll_storage_admissions a WHERE NOT EXISTS(SELECT 1 FROM server_poll_storage_usage u WHERE u.scope='owner:'||a.owner_id))
|
||||
THEN RAISE EXCEPTION 'existing poll accounting requires explicit reconciliation'; END IF;
|
||||
IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conrelid='public.server_poll_storage_usage'::regclass AND conname='server_poll_storage_usage_check'
|
||||
AND pg_get_constraintdef(oid)='CHECK ((reserved_rows = (admissions * 4019)))')
|
||||
OR NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conrelid='public.server_poll_storage_usage'::regclass AND conname='server_poll_storage_usage_check1'
|
||||
AND pg_get_constraintdef(oid)='CHECK ((reserved_bytes = (admissions * 8421376)))')
|
||||
THEN RAISE EXCEPTION 'original poll accounting constraints require explicit reconciliation'; END IF;
|
||||
END $$;
|
||||
ALTER TABLE server_poll_storage_admissions
|
||||
ADD COLUMN storage_state text NOT NULL DEFAULT 'full' CHECK(storage_state IN('full','compacted')),
|
||||
ADD COLUMN compact_at timestamptz,
|
||||
ADD COLUMN archive_bytes integer,
|
||||
ADD COLUMN original_activity_at timestamptz,
|
||||
ADD COLUMN compaction_claim uuid,
|
||||
ADD COLUMN compaction_claimed_at timestamptz,
|
||||
ADD COLUMN compaction_claim_xid xid8,
|
||||
ADD CONSTRAINT server_poll_compaction_claim_shape CHECK(
|
||||
(compaction_claim IS NULL AND compaction_claimed_at IS NULL AND compaction_claim_xid IS NULL)
|
||||
OR(compaction_claim IS NOT NULL AND compaction_claimed_at IS NOT NULL AND compaction_claim_xid IS NOT NULL AND isfinite(compaction_claimed_at))),
|
||||
ADD CONSTRAINT server_poll_storage_compact_shape CHECK(
|
||||
(storage_state='full' AND compact_at IS NULL AND archive_bytes IS NULL AND original_activity_at IS NULL)
|
||||
OR(storage_state='compacted' AND identity_kind='mcp' AND claim_token IS NULL AND claim_attempted_at IS NOT NULL
|
||||
AND compaction_claim IS NOT NULL AND compaction_claimed_at IS NOT NULL AND compaction_claim_xid IS NOT NULL
|
||||
AND compact_at IS NOT NULL AND archive_bytes IS NOT NULL AND original_activity_at IS NOT NULL
|
||||
AND isfinite(compact_at) AND archive_bytes BETWEEN 1 AND 131072 AND isfinite(original_activity_at)
|
||||
AND original_activity_at>=created_at AND compact_at>authority_expires_at+safety_ms*interval '1 millisecond'));
|
||||
ALTER TABLE server_poll_storage_usage
|
||||
DROP CONSTRAINT server_poll_storage_usage_check,
|
||||
DROP CONSTRAINT server_poll_storage_usage_check1,
|
||||
ADD COLUMN compacted_admissions bigint NOT NULL DEFAULT 0 CHECK(compacted_admissions>=0),
|
||||
ADD COLUMN archive_bytes bigint NOT NULL DEFAULT 0 CHECK(archive_bytes>=0),
|
||||
ADD CONSTRAINT server_poll_storage_rows_charge CHECK(reserved_rows=admissions*4019+compacted_admissions*2
|
||||
AND reserved_rows<=CASE WHEN scope='global' THEN 514432 ELSE 32152 END),
|
||||
ADD CONSTRAINT server_poll_storage_bytes_charge CHECK(reserved_bytes=admissions*8421376+compacted_admissions*32768+archive_bytes
|
||||
AND reserved_bytes<=CASE WHEN scope='global' THEN 1077936128 ELSE 67371008 END),
|
||||
ADD CONSTRAINT server_poll_storage_archive_charge CHECK(archive_bytes>=compacted_admissions AND archive_bytes<=compacted_admissions*131072);
|
||||
CREATE TABLE server_poll_archives (
|
||||
receipt_id uuid PRIMARY KEY REFERENCES server_poll_storage_admissions(receipt_id),version smallint NOT NULL CHECK(version=1),
|
||||
execution_id uuid NOT NULL UNIQUE,connection_id uuid NOT NULL,owner_id text NOT NULL,
|
||||
binding_proof text NOT NULL CHECK(binding_proof ~ '^[a-f0-9]{64}$'),context_proof text NOT NULL CHECK(context_proof ~ '^[a-f0-9]{64}$'),
|
||||
source_digest text NOT NULL CHECK(source_digest ~ '^[a-f0-9]{64}$'),source_sha256 text NOT NULL CHECK(source_sha256 ~ '^[a-f0-9]{64}$'),
|
||||
terminal_digest text NOT NULL CHECK(terminal_digest ~ '^[a-f0-9]{64}$'),
|
||||
original_activity_at timestamptz NOT NULL CHECK(isfinite(original_activity_at)),original_expires_at timestamptz NOT NULL CHECK(isfinite(original_expires_at)),
|
||||
decoded_bytes integer NOT NULL CHECK(decoded_bytes BETWEEN 1 AND 16777216),compressed_bytes integer NOT NULL CHECK(compressed_bytes BETWEEN 1 AND 98048),
|
||||
ciphertext text NOT NULL,ciphertext_bytes integer NOT NULL CHECK(ciphertext_bytes BETWEEN 1 AND 131072 AND ciphertext_bytes=octet_length(ciphertext)),
|
||||
archive_proof text NOT NULL CHECK(archive_proof ~ '^[a-f0-9]{64}$'),
|
||||
created_at timestamptz NOT NULL,compaction_xid xid8 NOT NULL
|
||||
);
|
||||
|
||||
-- SQL checks structure/ownership/current rows. The fixed private producer and
|
||||
-- historical reader additionally authenticate every existing keyed proof and
|
||||
-- the archive MAC; SQL never receives an encryption/HMAC key or boolean grant.
|
||||
CREATE FUNCTION server_poll_storage_compact_allowed(id uuid) RETURNS boolean LANGUAGE plpgsql SET TimeZone='UTC' SET search_path=pg_catalog,public AS $$
|
||||
DECLARE a public.server_poll_storage_admissions%ROWTYPE; o public.mcp_operations%ROWTYPE; p public.mcp_plans%ROWTYPE; s public.mcp_operation_steps%ROWTYPE; n integer; pubs integer; activity timestamptz;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||
SELECT * INTO a FROM public.server_poll_storage_admissions WHERE receipt_id=id FOR UPDATE;
|
||||
IF NOT FOUND OR a.storage_state<>'full' OR a.identity_kind<>'mcp' OR a.claim_token IS NOT NULL OR a.claim_attempted_at IS NULL
|
||||
OR a.authority_expires_at+a.safety_ms*interval '1 millisecond'>=clock_timestamp()
|
||||
OR EXISTS(SELECT 1 FROM public.server_poll_quarantines WHERE receipt_id=id OR(identity_kind=a.identity_kind AND execution_id=a.execution_id))
|
||||
THEN RETURN false; END IF;
|
||||
IF NOT pg_try_advisory_xact_lock(hashtextextended('mcp-operation:'||a.execution_id::text,0)) THEN RETURN false; END IF;
|
||||
IF NOT pg_try_advisory_xact_lock(hashtextextended('server-poll-execution-v1:mcp:'||a.execution_id::text,0)) THEN RETURN false; END IF;
|
||||
IF NOT pg_try_advisory_xact_lock(hashtextextended('server-poll-receipt-v1:'||a.receipt_id::text,0)) THEN RETURN false; END IF;
|
||||
BEGIN
|
||||
SELECT * INTO o FROM public.mcp_operations WHERE mcp_operations.id=a.execution_id AND connection_id=a.connection_id FOR UPDATE NOWAIT;
|
||||
IF NOT FOUND OR o.state<>'applied' OR o.verification<>'passed' OR o.error_code IS NOT NULL OR o.finished_at IS NULL THEN RETURN false; END IF;
|
||||
SELECT * INTO p FROM public.mcp_plans WHERE mcp_plans.id=o.plan_id AND connection_id=a.connection_id FOR UPDATE NOWAIT;
|
||||
IF NOT FOUND OR p.operation_id<>'servers.poll' OR p.operation_version<>1 OR p.state<>'completed' OR p.approved_by IS NULL OR p.approved_at IS NULL
|
||||
OR p.created_at>p.approved_at OR p.approved_at>o.created_at OR o.created_at>=LEAST(p.expires_at,a.authority_expires_at) THEN RETURN false; END IF;
|
||||
SELECT * INTO s FROM public.mcp_operation_steps WHERE operation_id=o.id AND step_index=0 FOR UPDATE NOWAIT;
|
||||
IF NOT FOUND OR s.state<>'verified' OR s.server_id IS NOT NULL OR s.result IS NULL OR s.finished_at IS NULL
|
||||
OR(SELECT count(*) FROM public.mcp_operation_steps WHERE operation_id=o.id)<>1
|
||||
OR NOT EXISTS(SELECT 1 FROM public.mcp_connections c WHERE c.id=a.connection_id AND c.owner_id=a.owner_id)
|
||||
OR s.intended_state->>'kind' IS DISTINCT FROM 'service' OR s.intended_state->>'service' IS DISTINCT FROM 'servers.poll'
|
||||
OR s.intended_state#>>'{input,receiptId}' IS DISTINCT FROM id::text
|
||||
OR s.result->>'operationId' IS DISTINCT FROM o.id::text OR s.result->>'receiptId' IS DISTINCT FROM id::text
|
||||
OR s.result->'nativeReads' IS DISTINCT FROM '4'::jsonb
|
||||
OR s.result->'complete' IS DISTINCT FROM 'true'::jsonb OR s.result->'maintenanceComplete' IS DISTINCT FROM 'true'::jsonb
|
||||
OR s.result->'configurationVerified' IS DISTINCT FROM 'true'::jsonb OR s.result->'automaticReplay' IS DISTINCT FROM 'false'::jsonb
|
||||
THEN RETURN false; END IF;
|
||||
EXCEPTION WHEN lock_not_available THEN RETURN false; END;
|
||||
IF NOT EXISTS(SELECT 1 FROM public.server_poll_executions e WHERE e.receipt_id=id AND e.identity_kind=a.identity_kind AND e.execution_id=a.execution_id AND e.binding_proof=a.binding_proof AND e.context_proof=a.context_proof)
|
||||
OR NOT EXISTS(SELECT 1 FROM public.server_poll_receipts r WHERE r.receipt_id=id AND r.created_at<a.authority_expires_at) THEN RETURN false; END IF;
|
||||
SELECT count(*) INTO n FROM public.server_poll_intents WHERE receipt_id=id;
|
||||
SELECT count(*) INTO pubs FROM public.server_poll_intents WHERE receipt_id=id AND stage LIKE 'publish.%';
|
||||
IF n<>pubs+4 OR pubs NOT IN(a.max_stages-6,a.max_stages-4) OR a.used_rows<>2*n+2
|
||||
OR(SELECT count(*) FROM public.server_poll_acks WHERE receipt_id=id)<>n
|
||||
OR s.result->'confirmedPublications' IS DISTINCT FROM to_jsonb(pubs)
|
||||
OR s.result->'completedPublications' IS DISTINCT FROM to_jsonb(pubs)
|
||||
OR EXISTS(SELECT 1 FROM public.server_poll_acks WHERE receipt_id=id AND outcome<>'confirmed')
|
||||
OR EXISTS(SELECT 1 FROM public.server_poll_acks WHERE receipt_id=id AND created_at>=a.authority_expires_at)
|
||||
OR EXISTS(SELECT 1 FROM unnest(ARRAY['native.identity','native.resource','native.interfaces','native.addresses']) wanted(stage)
|
||||
WHERE NOT EXISTS(SELECT 1 FROM public.server_poll_intents i JOIN public.server_poll_acks k USING(receipt_id,stage) WHERE i.receipt_id=id AND i.stage=wanted.stage AND k.outcome='confirmed'))
|
||||
OR EXISTS(SELECT 1 FROM generate_series(0,pubs-1) wanted(stage) WHERE NOT EXISTS(SELECT 1 FROM public.server_poll_intents i JOIN public.server_poll_acks k USING(receipt_id,stage) WHERE i.receipt_id=id AND i.stage='publish.'||wanted.stage::text AND k.outcome='confirmed'))
|
||||
THEN RETURN false; END IF;
|
||||
SELECT GREATEST(a.authority_expires_at,a.created_at,a.claim_attempted_at,o.finished_at,p.expires_at,s.finished_at,
|
||||
(SELECT created_at FROM public.server_poll_executions WHERE receipt_id=id),(SELECT max(created_at) FROM public.server_poll_intents WHERE receipt_id=id),
|
||||
(SELECT max(created_at) FROM public.server_poll_acks WHERE receipt_id=id),(SELECT created_at FROM public.server_poll_receipts WHERE receipt_id=id)) INTO activity;
|
||||
RETURN isfinite(activity) AND activity+a.safety_ms*interval '1 millisecond'<clock_timestamp();
|
||||
END $$;
|
||||
CREATE FUNCTION server_poll_storage_source_payload(receipt_uuid uuid) RETURNS jsonb LANGUAGE plpgsql SET TimeZone='UTC' SET search_path=pg_catalog,public AS $$
|
||||
DECLARE payload jsonb;
|
||||
BEGIN
|
||||
IF NOT public.server_poll_storage_compact_allowed(receipt_uuid) THEN RETURN NULL; END IF;
|
||||
SELECT jsonb_build_object('admission',to_jsonb(a),'journal',jsonb_build_object(
|
||||
'execution',(SELECT to_jsonb(e) FROM public.server_poll_executions e WHERE e.receipt_id=receipt_uuid),
|
||||
'intents',(SELECT COALESCE(jsonb_agg(to_jsonb(i) ORDER BY i.stage),'[]'::jsonb) FROM public.server_poll_intents i WHERE i.receipt_id=receipt_uuid),
|
||||
'acks',(SELECT COALESCE(jsonb_agg(to_jsonb(k) ORDER BY k.stage),'[]'::jsonb) FROM public.server_poll_acks k WHERE k.receipt_id=receipt_uuid),
|
||||
'receipt',(SELECT to_jsonb(r) FROM public.server_poll_receipts r WHERE r.receipt_id=receipt_uuid),
|
||||
'quarantines',(SELECT COALESCE(jsonb_agg(to_jsonb(q) ORDER BY q.created_at,q.receipt_id),'[]'::jsonb) FROM public.server_poll_quarantines q WHERE q.receipt_id=receipt_uuid)),
|
||||
'terminal',jsonb_build_object('operation',to_jsonb(o),'plan',to_jsonb(p),'step',to_jsonb(s))) INTO payload
|
||||
FROM public.server_poll_storage_admissions a JOIN public.mcp_operations o ON o.id=a.execution_id AND o.connection_id=a.connection_id
|
||||
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id JOIN public.mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=0
|
||||
WHERE a.receipt_id=receipt_uuid;
|
||||
IF octet_length(payload::text)>16777216 THEN RETURN NULL; END IF;
|
||||
RETURN payload;
|
||||
END $$;
|
||||
CREATE FUNCTION server_poll_storage_accounting_valid() RETURNS boolean LANGUAGE sql SET search_path=pg_catalog,public AS $$
|
||||
WITH charges AS (
|
||||
SELECT owner_id,CASE WHEN storage_state='full' THEN 1 ELSE 0 END::bigint AS heavy,
|
||||
CASE WHEN storage_state='compacted' THEN 1 ELSE 0 END::bigint AS compact,
|
||||
COALESCE(archive_bytes,0)::bigint AS archives,
|
||||
CASE WHEN storage_state='full' THEN 4019 ELSE 2 END::bigint AS rows,
|
||||
CASE WHEN storage_state='full' THEN 8421376 ELSE 32768+archive_bytes END::bigint AS bytes
|
||||
FROM public.server_poll_storage_admissions
|
||||
), expected AS (
|
||||
SELECT 'global' AS scope,COALESCE(sum(heavy),0) AS heavy,COALESCE(sum(compact),0) AS compact,COALESCE(sum(archives),0) AS archives,COALESCE(sum(rows),0) AS rows,COALESCE(sum(bytes),0) AS bytes FROM charges
|
||||
UNION ALL SELECT 'owner:'||owner_id,sum(heavy),sum(compact),sum(archives),sum(rows),sum(bytes) FROM charges GROUP BY owner_id
|
||||
) SELECT NOT EXISTS(SELECT 1 FROM public.server_poll_storage_usage u FULL JOIN expected e USING(scope)
|
||||
WHERE u.scope IS NULL OR u.admissions<>COALESCE(e.heavy,0) OR u.compacted_admissions<>COALESCE(e.compact,0)
|
||||
OR u.archive_bytes<>COALESCE(e.archives,0) OR u.reserved_rows<>COALESCE(e.rows,0) OR u.reserved_bytes<>COALESCE(e.bytes,0))
|
||||
AND NOT EXISTS(SELECT 1 FROM public.server_poll_storage_admissions a LEFT JOIN public.server_poll_archives r USING(receipt_id)
|
||||
WHERE (a.storage_state='full' AND r.receipt_id IS NOT NULL) OR(a.storage_state='compacted' AND(r.receipt_id IS NULL OR a.archive_bytes<>r.ciphertext_bytes)))
|
||||
$$;
|
||||
CREATE FUNCTION server_poll_archive_insert_guard() RETURNS trigger LANGUAGE plpgsql SET TimeZone='UTC' SET search_path=pg_catalog,public AS $$
|
||||
DECLARE a public.server_poll_storage_admissions%ROWTYPE; payload jsonb; activity timestamptz;
|
||||
BEGIN
|
||||
payload:=public.server_poll_storage_source_payload(NEW.receipt_id);
|
||||
IF payload IS NULL OR NOT public.server_poll_storage_accounting_valid() THEN RAISE EXCEPTION 'poll source is not eligible for terminal archive'; END IF;
|
||||
SELECT * INTO a FROM public.server_poll_storage_admissions WHERE receipt_id=NEW.receipt_id FOR UPDATE;
|
||||
SELECT GREATEST(a.authority_expires_at,a.created_at,a.claim_attempted_at,(payload#>>'{terminal,operation,finished_at}')::timestamptz,
|
||||
(payload#>>'{terminal,plan,expires_at}')::timestamptz,(payload#>>'{terminal,step,finished_at}')::timestamptz,
|
||||
(payload#>>'{journal,execution,created_at}')::timestamptz,(payload#>>'{journal,receipt,created_at}')::timestamptz,
|
||||
(SELECT max((v->>'created_at')::timestamptz) FROM jsonb_array_elements(payload#>'{journal,intents}') v),
|
||||
(SELECT max((v->>'created_at')::timestamptz) FROM jsonb_array_elements(payload#>'{journal,acks}') v)) INTO activity;
|
||||
IF a.compaction_claim IS NULL OR a.compaction_claimed_at IS NULL OR NOT isfinite(a.compaction_claimed_at)
|
||||
OR a.compaction_claim_xid IS NULL OR a.compaction_claim_xid=pg_current_xact_id()
|
||||
OR NEW.version<>1 OR NEW.execution_id<>a.execution_id OR NEW.connection_id<>a.connection_id OR NEW.owner_id<>a.owner_id
|
||||
OR NEW.binding_proof<>a.binding_proof OR NEW.context_proof<>a.context_proof OR NEW.original_expires_at<>a.authority_expires_at
|
||||
OR NEW.original_activity_at<>activity OR NEW.decoded_bytes<>octet_length(payload::text)
|
||||
OR NEW.source_sha256<>encode(sha256(convert_to(payload::text,'UTF8')),'hex')
|
||||
OR NEW.ciphertext_bytes<>octet_length(NEW.ciphertext) OR NEW.ciphertext_bytes>131072
|
||||
OR NEW.ciphertext !~ '^[A-Za-z0-9+/]+={0,2}$'
|
||||
OR replace(encode(decode(NEW.ciphertext,'base64'),'base64'),E'\n','')<>NEW.ciphertext
|
||||
OR octet_length(decode(NEW.ciphertext,'base64'))<>NEW.compressed_bytes+28
|
||||
THEN RAISE EXCEPTION 'poll archive does not match the entire immutable source'; END IF;
|
||||
NEW.created_at:=clock_timestamp(); NEW.compaction_xid:=pg_current_xact_id();
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER server_poll_archive_insert_guard BEFORE INSERT ON server_poll_archives FOR EACH ROW EXECUTE FUNCTION server_poll_archive_insert_guard();
|
||||
|
||||
CREATE OR REPLACE FUNCTION server_poll_journal_immutable() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public AS $$ BEGIN
|
||||
IF TG_OP='DELETE' AND EXISTS(SELECT 1 FROM public.server_poll_archives r JOIN public.server_poll_storage_admissions a USING(receipt_id)
|
||||
WHERE r.receipt_id=OLD.receipt_id AND r.compaction_xid=pg_current_xact_id() AND a.storage_state='full') THEN RETURN OLD; END IF;
|
||||
IF TG_OP='DELETE' AND public.server_poll_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||
RAISE EXCEPTION 'server poll journal is immutable outside complete archive or original lifetime GC';
|
||||
END $$;
|
||||
CREATE OR REPLACE FUNCTION server_poll_storage_admission_guard() RETURNS trigger LANGUAGE plpgsql SET TimeZone='UTC' SET search_path=pg_catalog,public AS $$
|
||||
DECLARE retention integer; charged integer; r public.server_poll_archives%ROWTYPE;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||
IF TG_OP='DELETE' THEN
|
||||
IF NOT public.server_poll_storage_gc_allowed(OLD.receipt_id) OR EXISTS(SELECT 1 FROM public.server_poll_executions WHERE receipt_id=OLD.receipt_id)
|
||||
OR EXISTS(SELECT 1 FROM public.server_poll_intents WHERE receipt_id=OLD.receipt_id) OR EXISTS(SELECT 1 FROM public.server_poll_acks WHERE receipt_id=OLD.receipt_id)
|
||||
OR EXISTS(SELECT 1 FROM public.server_poll_receipts WHERE receipt_id=OLD.receipt_id) OR EXISTS(SELECT 1 FROM public.server_poll_quarantines WHERE receipt_id=OLD.receipt_id)
|
||||
OR EXISTS(SELECT 1 FROM public.server_poll_archives WHERE receipt_id=OLD.receipt_id) THEN RAISE EXCEPTION 'poll lifetime or children prevent admission deletion'; END IF;
|
||||
UPDATE public.server_poll_storage_usage SET admissions=admissions-CASE WHEN OLD.storage_state='full' THEN 1 ELSE 0 END,
|
||||
compacted_admissions=compacted_admissions-CASE WHEN OLD.storage_state='compacted' THEN 1 ELSE 0 END,
|
||||
archive_bytes=archive_bytes-COALESCE(OLD.archive_bytes,0),
|
||||
reserved_rows=reserved_rows-CASE WHEN OLD.storage_state='full' THEN 4019 ELSE 2 END,
|
||||
reserved_bytes=reserved_bytes-CASE WHEN OLD.storage_state='full' THEN 8421376 ELSE 32768+OLD.archive_bytes END
|
||||
WHERE scope IN('global','owner:'||OLD.owner_id);
|
||||
GET DIAGNOSTICS charged=ROW_COUNT; IF charged<>2 THEN RAISE EXCEPTION 'poll quota refund mismatch'; END IF; RETURN OLD;
|
||||
END IF;
|
||||
IF TG_OP='UPDATE' THEN
|
||||
-- This durable one-shot marker is written and ACKed in its OWN transaction
|
||||
-- before archive construction. A committed/unknown claim never resets and
|
||||
-- the worker never retries destructive work against an already claimed full
|
||||
-- source. Claim time is maintenance metadata, not renewed execution activity.
|
||||
IF OLD.storage_state='full' AND NEW.storage_state='full' AND OLD.compaction_claim IS NULL AND OLD.compaction_claimed_at IS NULL AND OLD.compaction_claim_xid IS NULL
|
||||
AND NEW.compaction_claim IS NOT NULL AND NEW.compaction_claim_xid IS NULL
|
||||
AND(to_jsonb(OLD)-ARRAY['compaction_claim','compaction_claimed_at','compaction_claim_xid'])=(to_jsonb(NEW)-ARRAY['compaction_claim','compaction_claimed_at','compaction_claim_xid'])
|
||||
AND public.server_poll_storage_compact_allowed(OLD.receipt_id)
|
||||
THEN NEW.compaction_claimed_at:=clock_timestamp(); NEW.compaction_claim_xid:=pg_current_xact_id(); RETURN NEW; END IF;
|
||||
IF(to_jsonb(OLD)-ARRAY['claim_token','claim_attempted_at','used_rows','used_bytes','storage_state','compact_at','archive_bytes','original_activity_at']) IS DISTINCT FROM
|
||||
(to_jsonb(NEW)-ARRAY['claim_token','claim_attempted_at','used_rows','used_bytes','storage_state','compact_at','archive_bytes','original_activity_at']) THEN RAISE EXCEPTION 'poll admission identity is immutable'; END IF;
|
||||
IF OLD.storage_state<>'full' THEN RAISE EXCEPTION 'compacted poll identity cannot reopen or change'; END IF;
|
||||
IF NEW.storage_state='compacted' THEN
|
||||
SELECT * INTO r FROM public.server_poll_archives WHERE receipt_id=OLD.receipt_id AND compaction_xid=pg_current_xact_id();
|
||||
IF NOT FOUND OR NEW.used_rows<>OLD.used_rows OR NEW.used_bytes<>OLD.used_bytes OR NEW.claim_token IS DISTINCT FROM OLD.claim_token
|
||||
OR NEW.claim_attempted_at IS DISTINCT FROM OLD.claim_attempted_at OR NEW.archive_bytes<>r.ciphertext_bytes OR NEW.original_activity_at<>r.original_activity_at
|
||||
OR EXISTS(SELECT 1 FROM public.server_poll_executions WHERE receipt_id=OLD.receipt_id)
|
||||
OR EXISTS(SELECT 1 FROM public.server_poll_intents WHERE receipt_id=OLD.receipt_id) OR EXISTS(SELECT 1 FROM public.server_poll_acks WHERE receipt_id=OLD.receipt_id)
|
||||
OR EXISTS(SELECT 1 FROM public.server_poll_receipts WHERE receipt_id=OLD.receipt_id) OR EXISTS(SELECT 1 FROM public.server_poll_quarantines WHERE receipt_id=OLD.receipt_id)
|
||||
THEN RAISE EXCEPTION 'poll compaction requires the complete current-transaction archive'; END IF;
|
||||
NEW.compact_at:=r.created_at;
|
||||
UPDATE public.server_poll_storage_usage SET admissions=admissions-1,compacted_admissions=compacted_admissions+1,
|
||||
archive_bytes=archive_bytes+r.ciphertext_bytes,reserved_rows=reserved_rows-4019+2,reserved_bytes=reserved_bytes-8421376+32768+r.ciphertext_bytes
|
||||
WHERE scope IN('global','owner:'||OLD.owner_id) AND admissions>=1;
|
||||
GET DIAGNOSTICS charged=ROW_COUNT; IF charged<>2 THEN RAISE EXCEPTION 'poll compaction charge mismatch'; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.compact_at IS NOT NULL OR NEW.archive_bytes IS NOT NULL OR NEW.original_activity_at IS NOT NULL THEN RAISE EXCEPTION 'full poll cannot carry archive metadata'; END IF;
|
||||
IF OLD.claim_token IS NOT NULL AND NEW.claim_token IS NULL AND OLD.claim_attempted_at IS NULL AND NEW.claim_attempted_at IS NOT NULL
|
||||
AND NEW.used_rows=OLD.used_rows AND NEW.used_bytes=OLD.used_bytes AND OLD.authority_expires_at>clock_timestamp()
|
||||
THEN NEW.claim_attempted_at:=clock_timestamp(); RETURN NEW; END IF;
|
||||
IF OLD.claim_token IS NOT DISTINCT FROM NEW.claim_token AND OLD.claim_attempted_at IS NOT DISTINCT FROM NEW.claim_attempted_at
|
||||
AND NEW.used_rows=OLD.used_rows+1 AND NEW.used_bytes>OLD.used_bytes THEN RETURN NEW; END IF;
|
||||
RAISE EXCEPTION 'poll admission cannot refresh, reopen or refund itself';
|
||||
END IF;
|
||||
IF NEW.storage_state<>'full' OR NEW.compact_at IS NOT NULL OR NEW.archive_bytes IS NOT NULL OR NEW.original_activity_at IS NOT NULL
|
||||
OR NEW.compaction_claim IS NOT NULL OR NEW.compaction_claimed_at IS NOT NULL OR NEW.compaction_claim_xid IS NOT NULL
|
||||
OR NOT public.server_poll_storage_accounting_valid() THEN RAISE EXCEPTION 'new poll requires full reservation and exact ledger'; END IF;
|
||||
SELECT requests_retention_days INTO retention FROM public.mcp_settings WHERE id=1;
|
||||
IF retention IS NULL OR retention NOT BETWEEN 1 AND 365 OR NEW.authority_issued_at>clock_timestamp() OR NEW.authority_expires_at<=clock_timestamp()
|
||||
OR NEW.claim_token IS NULL OR NEW.claim_attempted_at IS NOT NULL OR NEW.used_rows<>0 OR NEW.used_bytes<>0 THEN RAISE EXCEPTION 'new poll admission needs original live authority'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM public.server_poll_storage_admissions WHERE receipt_id=NEW.receipt_id OR(identity_kind=NEW.identity_kind AND execution_id=NEW.execution_id)) THEN RAISE EXCEPTION 'poll identity already spent'; END IF;
|
||||
NEW.created_at:=clock_timestamp(); NEW.retention_days:=retention; NEW.safety_ms:=60000;
|
||||
NEW.gc_not_before:=NEW.authority_expires_at+make_interval(days=>retention)+interval '60 seconds';
|
||||
INSERT INTO public.server_poll_storage_usage(scope) VALUES('owner:'||NEW.owner_id) ON CONFLICT DO NOTHING;
|
||||
IF(SELECT count(*) FROM public.server_poll_storage_usage WHERE scope IN('global','owner:'||NEW.owner_id)
|
||||
AND admissions<CASE WHEN scope='global' THEN 128 ELSE 8 END
|
||||
AND reserved_rows<=CASE WHEN scope='global' THEN 514432 ELSE 32152 END-4019
|
||||
AND reserved_bytes<=CASE WHEN scope='global' THEN 1077936128 ELSE 67371008 END-8421376)<>2 THEN RAISE EXCEPTION 'poll admission quota exhausted'; END IF;
|
||||
UPDATE public.server_poll_storage_usage SET admissions=admissions+1,reserved_rows=reserved_rows+4019,reserved_bytes=reserved_bytes+8421376 WHERE scope IN('global','owner:'||NEW.owner_id);
|
||||
GET DIAGNOSTICS charged=ROW_COUNT; IF charged<>2 THEN RAISE EXCEPTION 'poll admission quota unavailable'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE FUNCTION server_poll_archive_insert_finish() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public AS $$ BEGIN
|
||||
DELETE FROM public.server_poll_acks WHERE receipt_id=NEW.receipt_id;
|
||||
DELETE FROM public.server_poll_intents WHERE receipt_id=NEW.receipt_id;
|
||||
DELETE FROM public.server_poll_receipts WHERE receipt_id=NEW.receipt_id;
|
||||
DELETE FROM public.server_poll_executions WHERE receipt_id=NEW.receipt_id;
|
||||
UPDATE public.server_poll_storage_admissions SET storage_state='compacted',compact_at=NEW.created_at,archive_bytes=NEW.ciphertext_bytes,original_activity_at=NEW.original_activity_at WHERE receipt_id=NEW.receipt_id AND storage_state='full';
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'poll admission did not transition with archive'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER server_poll_archive_insert_finish AFTER INSERT ON server_poll_archives FOR EACH ROW EXECUTE FUNCTION server_poll_archive_insert_finish();
|
||||
|
||||
CREATE OR REPLACE FUNCTION server_poll_storage_gc_allowed(id uuid) RETURNS boolean LANGUAGE plpgsql SET TimeZone='UTC' SET search_path=pg_catalog,public AS $$
|
||||
DECLARE a public.server_poll_storage_admissions%ROWTYPE; retention integer; activity timestamptz; terminal_at timestamptz;
|
||||
BEGIN
|
||||
IF current_setting('mm.poll_storage_gc',true) IS DISTINCT FROM 'on' THEN RETURN false; END IF;
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||
SELECT * INTO a FROM public.server_poll_storage_admissions WHERE receipt_id=id FOR UPDATE;
|
||||
IF NOT FOUND OR a.authority_expires_at>=clock_timestamp() OR a.gc_not_before>=clock_timestamp() THEN RETURN false; END IF;
|
||||
SELECT requests_retention_days INTO retention FROM public.mcp_settings WHERE mcp_settings.id=1 FOR SHARE;
|
||||
IF retention IS NULL OR retention NOT BETWEEN 1 AND 365 THEN RETURN false; END IF;
|
||||
terminal_at:=a.created_at;
|
||||
IF a.identity_kind='mcp' THEN
|
||||
IF NOT pg_try_advisory_xact_lock(hashtextextended('mcp-operation:'||a.execution_id::text,0)) THEN RETURN false; END IF;
|
||||
BEGIN
|
||||
SELECT GREATEST(o.finished_at,p.expires_at) INTO terminal_at FROM public.mcp_operations o
|
||||
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id JOIN public.mcp_connections c ON c.id=o.connection_id
|
||||
WHERE o.id=a.execution_id AND o.connection_id=a.connection_id AND c.owner_id=a.owner_id AND p.operation_id='servers.poll'
|
||||
AND o.state IN('applied','failed','unknown','rejected','partial','cancelled') AND o.finished_at IS NOT NULL FOR UPDATE OF o,p NOWAIT;
|
||||
IF NOT FOUND OR terminal_at IS NULL THEN RETURN false; END IF;
|
||||
EXCEPTION WHEN lock_not_available THEN RETURN false; END;
|
||||
END IF;
|
||||
IF a.storage_state='compacted' THEN activity:=GREATEST(a.original_activity_at,a.authority_expires_at,terminal_at);
|
||||
ELSE
|
||||
SELECT GREATEST(a.authority_expires_at,a.created_at,COALESCE(a.claim_attempted_at,a.created_at),terminal_at,
|
||||
COALESCE((SELECT created_at FROM public.server_poll_executions WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT max(created_at) FROM public.server_poll_intents WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT max(created_at) FROM public.server_poll_acks WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT created_at FROM public.server_poll_receipts WHERE receipt_id=id),a.created_at),
|
||||
COALESCE((SELECT created_at FROM public.server_poll_quarantines WHERE receipt_id=id),a.created_at)) INTO activity;
|
||||
END IF;
|
||||
RETURN isfinite(activity) AND activity+make_interval(days=>GREATEST(a.retention_days,retention))+a.safety_ms*interval '1 millisecond'<clock_timestamp();
|
||||
END $$;
|
||||
CREATE FUNCTION server_poll_archive_immutable() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public AS $$ BEGIN
|
||||
IF TG_OP='DELETE' AND public.server_poll_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||
RAISE EXCEPTION 'poll archive is immutable until original full retention GC';
|
||||
END $$;
|
||||
CREATE TRIGGER server_poll_archive_immutable BEFORE UPDATE OR DELETE ON server_poll_archives FOR EACH ROW EXECUTE FUNCTION server_poll_archive_immutable();
|
||||
CREATE FUNCTION server_poll_archive_final_guard() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public AS $$
|
||||
DECLARE id uuid; a public.server_poll_storage_admissions%ROWTYPE;
|
||||
BEGIN
|
||||
id:=CASE WHEN TG_OP='DELETE' THEN OLD.receipt_id ELSE NEW.receipt_id END;
|
||||
SELECT * INTO a FROM public.server_poll_storage_admissions WHERE receipt_id=id;
|
||||
IF FOUND AND a.storage_state='compacted' AND(EXISTS(SELECT 1 FROM public.server_poll_executions WHERE receipt_id=id)
|
||||
OR EXISTS(SELECT 1 FROM public.server_poll_intents WHERE receipt_id=id) OR EXISTS(SELECT 1 FROM public.server_poll_acks WHERE receipt_id=id)
|
||||
OR EXISTS(SELECT 1 FROM public.server_poll_receipts WHERE receipt_id=id) OR EXISTS(SELECT 1 FROM public.server_poll_quarantines WHERE receipt_id=id))
|
||||
THEN RAISE EXCEPTION 'compacted poll retained incomplete original children'; END IF;
|
||||
IF NOT public.server_poll_storage_accounting_valid() THEN RAISE EXCEPTION 'poll final archive or exact ledger mismatch'; END IF;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER server_poll_archive_final_guard AFTER INSERT OR DELETE ON server_poll_archives DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION server_poll_archive_final_guard();
|
||||
CREATE CONSTRAINT TRIGGER server_poll_admission_final_guard AFTER INSERT OR DELETE ON server_poll_storage_admissions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION server_poll_archive_final_guard();
|
||||
CREATE CONSTRAINT TRIGGER server_poll_compact_final_guard AFTER UPDATE ON server_poll_storage_admissions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW WHEN(OLD.storage_state IS DISTINCT FROM NEW.storage_state) EXECUTE FUNCTION server_poll_archive_final_guard();
|
||||
-- Existing row guard additionally refuses compacted tombstones, including late
|
||||
-- quarantine attempts; the original meter and immutable identity stay intact.
|
||||
CREATE FUNCTION server_poll_compacted_row_guard() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog,public AS $$ BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0));
|
||||
IF EXISTS(SELECT 1 FROM public.server_poll_storage_admissions WHERE receipt_id=NEW.receipt_id AND storage_state='compacted') THEN RAISE EXCEPTION 'compacted poll journal cannot reopen'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER server_poll_compacted_anchor_guard BEFORE INSERT ON server_poll_executions FOR EACH ROW EXECUTE FUNCTION server_poll_compacted_row_guard();
|
||||
CREATE TRIGGER server_poll_compacted_intent_guard BEFORE INSERT ON server_poll_intents FOR EACH ROW EXECUTE FUNCTION server_poll_compacted_row_guard();
|
||||
CREATE TRIGGER server_poll_compacted_ack_guard BEFORE INSERT ON server_poll_acks FOR EACH ROW EXECUTE FUNCTION server_poll_compacted_row_guard();
|
||||
CREATE TRIGGER server_poll_compacted_receipt_guard BEFORE INSERT ON server_poll_receipts FOR EACH ROW EXECUTE FUNCTION server_poll_compacted_row_guard();
|
||||
CREATE TRIGGER server_poll_compacted_quarantine_guard BEFORE INSERT ON server_poll_quarantines FOR EACH ROW EXECUTE FUNCTION server_poll_compacted_row_guard();
|
||||
INSERT INTO schema_migrations(id) VALUES('0041_server_poll_terminal_archives') ON CONFLICT(id) DO NOTHING;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Approval-time configuration identity. DDL and its marker commit atomically.
|
||||
BEGIN;
|
||||
-- Existing rows retain all fields; the new epoch invalidates old complete proofs.
|
||||
-- This schema addition does not activate the delayed-approval execution protocol.
|
||||
LOCK TABLE public.traffic_settings, public.uptime_settings,
|
||||
public.internet_path_settings IN ACCESS EXCLUSIVE MODE;
|
||||
ALTER TABLE public.traffic_settings ADD COLUMN poll_configuration_epoch uuid NOT NULL DEFAULT pg_catalog.gen_random_uuid();
|
||||
ALTER TABLE public.uptime_settings ADD COLUMN poll_configuration_epoch uuid NOT NULL DEFAULT pg_catalog.gen_random_uuid();
|
||||
ALTER TABLE public.internet_path_settings ADD COLUMN poll_configuration_epoch uuid NOT NULL DEFAULT pg_catalog.gen_random_uuid();
|
||||
CREATE FUNCTION public.server_poll_settings_epoch_guard() RETURNS trigger
|
||||
LANGUAGE plpgsql SET search_path = pg_catalog AS $epoch$
|
||||
BEGIN
|
||||
IF TG_TABLE_SCHEMA <> 'public' OR TG_TABLE_NAME NOT IN
|
||||
('traffic_settings', 'uptime_settings', 'internet_path_settings')
|
||||
OR TG_LEVEL <> 'ROW' OR TG_WHEN <> 'BEFORE' THEN
|
||||
RAISE EXCEPTION 'unexpected poll settings epoch trigger target';
|
||||
END IF;
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
-- Never accept an epoch supplied by an importer or application caller.
|
||||
NEW.poll_configuration_epoch := pg_catalog.gen_random_uuid();
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
-- Every unknown/future field is configuration by default. Only these four
|
||||
-- current collector fields are excluded; created_at stays significant.
|
||||
IF (pg_catalog.to_jsonb(NEW) - ARRAY['poll_configuration_epoch',
|
||||
'last_collected_at', 'last_duration_ms', 'last_error', 'updated_at'])
|
||||
IS DISTINCT FROM
|
||||
(pg_catalog.to_jsonb(OLD) - ARRAY['poll_configuration_epoch',
|
||||
'last_collected_at', 'last_duration_ms', 'last_error', 'updated_at']) THEN
|
||||
NEW.poll_configuration_epoch := pg_catalog.gen_random_uuid();
|
||||
ELSE
|
||||
NEW.poll_configuration_epoch := OLD.poll_configuration_epoch;
|
||||
END IF;
|
||||
ELSE
|
||||
RAISE EXCEPTION 'unexpected poll settings epoch trigger operation';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$epoch$;
|
||||
CREATE TRIGGER server_poll_settings_epoch BEFORE INSERT OR UPDATE ON public.traffic_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION public.server_poll_settings_epoch_guard();
|
||||
CREATE TRIGGER server_poll_settings_epoch BEFORE INSERT OR UPDATE ON public.uptime_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION public.server_poll_settings_epoch_guard();
|
||||
CREATE TRIGGER server_poll_settings_epoch BEFORE INSERT OR UPDATE ON public.internet_path_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION public.server_poll_settings_epoch_guard();
|
||||
ALTER TABLE public.traffic_settings ENABLE ALWAYS TRIGGER server_poll_settings_epoch;
|
||||
ALTER TABLE public.uptime_settings ENABLE ALWAYS TRIGGER server_poll_settings_epoch;
|
||||
ALTER TABLE public.internet_path_settings ENABLE ALWAYS TRIGGER server_poll_settings_epoch;
|
||||
INSERT INTO schema_migrations(id) VALUES('0042_server_poll_settings_epoch') ON CONFLICT(id) DO NOTHING;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Durable persistent-forward charge; no execution authority is issued.
|
||||
-- Reuses the accepted B2.a immutable-parent charging rule, narrowed to ONE
|
||||
-- forward charge. Existing 0040 two-slot reservations/0031 remain untouched.
|
||||
-- This SQL cannot authenticate a binding or grant execution authority.
|
||||
BEGIN;
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('public.mcp_routing_forward_charges') IS NOT NULL
|
||||
THEN RAISE EXCEPTION 'unmarked forward charge storage requires explicit reconciliation'; END IF;
|
||||
END $$;
|
||||
CREATE TABLE public.mcp_routing_forward_charges (
|
||||
-- Logical historical identities, intentionally NOT lifetime FKs. INSERT is
|
||||
-- checked against the actual current source below; later source/profile
|
||||
-- retention cannot erase this charge or be blocked forever by old charges.
|
||||
source_operation_id uuid PRIMARY KEY,
|
||||
source_plan_id uuid NOT NULL,
|
||||
source_step_index integer NOT NULL CHECK(source_step_index=0),
|
||||
profile_id uuid NOT NULL,
|
||||
connection_id uuid NOT NULL,
|
||||
charge_units integer NOT NULL DEFAULT 1 CHECK(charge_units=1),
|
||||
binding_sealed text NOT NULL CHECK(octet_length(binding_sealed) BETWEEN 1 AND 262144),
|
||||
binding_proof text NOT NULL CHECK(binding_proof ~ '^[a-f0-9]{64}$'),
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX mcp_routing_forward_charges_window
|
||||
ON public.mcp_routing_forward_charges(profile_id,created_at);
|
||||
CREATE INDEX mcp_routing_forward_charges_retention
|
||||
ON public.mcp_routing_forward_charges(created_at,source_operation_id);
|
||||
-- The immutable parent IS the charged unit. No mutable status/child can drop it
|
||||
-- from the hourly projection after reject/unknown, human save or restart.
|
||||
CREATE FUNCTION public.mcp_routing_forward_charge_source() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS(
|
||||
SELECT 1 FROM public.mcp_operations o
|
||||
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||
JOIN public.mcp_routing_profiles r ON r.id=NEW.profile_id AND r.connection_id=o.connection_id
|
||||
WHERE o.id=NEW.source_operation_id AND p.id=NEW.source_plan_id
|
||||
AND o.connection_id=NEW.connection_id AND o.state='pending' AND p.state='executing'
|
||||
AND p.operation_id='routing.community.switch' AND p.operation_version=7
|
||||
) THEN RAISE EXCEPTION 'forward charge differs from actual pending admission source'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER mcp_routing_forward_charge_source BEFORE INSERT ON public.mcp_routing_forward_charges
|
||||
FOR EACH ROW EXECUTE FUNCTION public.mcp_routing_forward_charge_source();
|
||||
CREATE FUNCTION public.mcp_routing_forward_charge_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE retention integer;
|
||||
BEGIN
|
||||
IF TG_OP<>'DELETE' THEN RAISE EXCEPTION 'immutable forward charge; rewrite/refund forbidden'; END IF;
|
||||
SELECT requests_retention_days INTO retention FROM public.mcp_settings WHERE id=1 FOR SHARE;
|
||||
IF retention IS NULL OR retention NOT BETWEEN 1 AND 365
|
||||
OR OLD.created_at>clock_timestamp()-interval '1 hour'
|
||||
OR OLD.created_at>=clock_timestamp()-make_interval(days=>retention)
|
||||
OR EXISTS(SELECT 1 FROM public.mcp_operations WHERE id=OLD.source_operation_id AND state IN('pending','running'))
|
||||
THEN RAISE EXCEPTION 'forward charge is still retained or unresolved'; END IF;
|
||||
RETURN OLD;
|
||||
END $$;
|
||||
CREATE TRIGGER mcp_routing_forward_charge_immutable BEFORE UPDATE OR DELETE ON public.mcp_routing_forward_charges
|
||||
FOR EACH ROW EXECUTE FUNCTION public.mcp_routing_forward_charge_immutable();
|
||||
CREATE TRIGGER mcp_routing_forward_charge_no_truncate BEFORE TRUNCATE ON public.mcp_routing_forward_charges
|
||||
FOR EACH STATEMENT EXECUTE FUNCTION public.mcp_routing_forward_charge_immutable();
|
||||
-- DDL and exact file marker commit atomically. No SECURITY DEFINER or grants.
|
||||
INSERT INTO public.schema_migrations (id) VALUES ('0043_mcp_routing_forward_charges') ON CONFLICT (id) DO NOTHING;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,93 @@
|
||||
-- Additive durable scoped manual inverse-one charge. No execution grant.
|
||||
-- Existing 0043 is immutable. Logical charge identities outlive family FKs.
|
||||
BEGIN;
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('public.mcp_routing_manual_inverse_charges') IS NOT NULL
|
||||
THEN RAISE EXCEPTION 'unmarked manual inverse storage requires reconciliation'; END IF;
|
||||
END $$;
|
||||
CREATE TABLE public.mcp_routing_manual_inverse_charges (
|
||||
recovery_operation_id uuid PRIMARY KEY,
|
||||
recovery_plan_id uuid NOT NULL,
|
||||
source_operation_id uuid NOT NULL,
|
||||
source_plan_id uuid NOT NULL,
|
||||
source_step_index integer NOT NULL CHECK(source_step_index=0),
|
||||
profile_id uuid NOT NULL,
|
||||
profile_version integer NOT NULL CHECK(profile_version>0),
|
||||
connection_id uuid NOT NULL,
|
||||
owner_id text NOT NULL CHECK(octet_length(owner_id) BETWEEN 1 AND 1000),
|
||||
charge_units integer NOT NULL CHECK(charge_units=1),
|
||||
binding_sealed text NOT NULL CHECK(octet_length(binding_sealed) BETWEEN 1 AND 262144),
|
||||
binding_proof text NOT NULL CHECK(binding_proof ~ '^[a-f0-9]{64}$'),
|
||||
created_at timestamptz NOT NULL,
|
||||
CONSTRAINT mcp_manual_inverse_source_step_unique UNIQUE(source_operation_id,source_step_index)
|
||||
);
|
||||
CREATE INDEX mcp_routing_manual_inverse_charges_window
|
||||
ON public.mcp_routing_manual_inverse_charges(profile_id,created_at);
|
||||
CREATE INDEX mcp_routing_manual_inverse_charges_retention
|
||||
ON public.mcp_routing_manual_inverse_charges(created_at,recovery_operation_id);
|
||||
CREATE INDEX mcp_scoped_recovery_manual_history
|
||||
ON public.mcp_scoped_recovery_intents(mode,source_operation_id,source_step_index)
|
||||
WHERE mode='manual';
|
||||
CREATE FUNCTION public.mcp_routing_manual_inverse_charge_source() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS(
|
||||
SELECT 1 FROM public.mcp_operations o
|
||||
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||
JOIN public.mcp_routing_profiles r ON r.id=NEW.profile_id AND r.connection_id=o.connection_id
|
||||
JOIN public.mcp_operations source ON source.id=NEW.source_operation_id AND source.connection_id=o.connection_id
|
||||
JOIN public.mcp_plans sp ON sp.id=source.plan_id AND sp.connection_id=source.connection_id
|
||||
JOIN public.mcp_connections c ON c.id=o.connection_id
|
||||
WHERE o.id=NEW.recovery_operation_id AND p.id=NEW.recovery_plan_id
|
||||
AND o.connection_id=NEW.connection_id AND c.owner_id=NEW.owner_id
|
||||
AND r.version=NEW.profile_version AND o.state='pending' AND p.state='executing'
|
||||
AND p.operation_id='operations.recover' AND p.operation_version=7
|
||||
AND sp.id=NEW.source_plan_id AND sp.operation_id='routing.community.switch' AND sp.operation_version=7
|
||||
AND source.state IN('applied','partial')
|
||||
) THEN RAISE EXCEPTION 'manual inverse charge differs from pending admission'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER mcp_routing_manual_inverse_charge_source BEFORE INSERT ON public.mcp_routing_manual_inverse_charges
|
||||
FOR EACH ROW EXECUTE FUNCTION public.mcp_routing_manual_inverse_charge_source();
|
||||
CREATE FUNCTION public.mcp_routing_manual_inverse_charge_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE retention integer;
|
||||
BEGIN
|
||||
IF TG_OP<>'DELETE' THEN RAISE EXCEPTION 'immutable manual inverse charge; rewrite/refund forbidden'; END IF;
|
||||
SELECT requests_retention_days INTO retention FROM public.mcp_settings WHERE id=1 FOR SHARE;
|
||||
IF retention IS NULL OR retention NOT BETWEEN 1 AND 365
|
||||
OR OLD.created_at>clock_timestamp()-interval '1 hour'
|
||||
OR OLD.created_at>=clock_timestamp()-make_interval(days=>retention)
|
||||
OR EXISTS(SELECT 1 FROM public.mcp_operations WHERE id=OLD.recovery_operation_id AND state IN('pending','running'))
|
||||
THEN RAISE EXCEPTION 'manual inverse charge still retained or unresolved'; END IF;
|
||||
RETURN OLD;
|
||||
END $$;
|
||||
CREATE TRIGGER mcp_routing_manual_inverse_charge_immutable BEFORE UPDATE OR DELETE ON public.mcp_routing_manual_inverse_charges
|
||||
FOR EACH ROW EXECUTE FUNCTION public.mcp_routing_manual_inverse_charge_immutable();
|
||||
CREATE TRIGGER mcp_routing_manual_inverse_charge_no_truncate BEFORE TRUNCATE ON public.mcp_routing_manual_inverse_charges
|
||||
FOR EACH STATEMENT EXECUTE FUNCTION public.mcp_routing_manual_inverse_charge_immutable();
|
||||
-- This is a version/identity fence, never authentication of encrypted source.
|
||||
-- Old binaries cannot create a fresh v6 manual intent after this DDL commits.
|
||||
-- Already-claimed old work requires the normal drained-writer cutover.
|
||||
CREATE FUNCTION public.mcp_scoped_recovery_manual_version() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.mode='manual' AND NOT pg_try_advisory_xact_lock(hashtextextended('mcp-runner-admission',0))
|
||||
THEN RAISE EXCEPTION 'manual intent admission snapshot busy'; END IF;
|
||||
IF NEW.mode='manual' AND NOT EXISTS(
|
||||
SELECT 1 FROM public.mcp_operations o
|
||||
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||
JOIN public.mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=0
|
||||
JOIN public.mcp_operations source ON source.id=NEW.source_operation_id AND source.connection_id=o.connection_id
|
||||
WHERE o.id=NEW.recovery_operation_id AND o.state='running'
|
||||
AND p.state='executing' AND p.operation_id='operations.recover' AND p.operation_version=7
|
||||
AND NEW.source_step_index=0 AND s.state='intent'
|
||||
AND s.intended_state->>'kind'='service'
|
||||
AND s.intended_state->>'service'='routing.community.scoped.recover'
|
||||
AND s.intended_state->'input'->>'mode'='manual'
|
||||
AND s.intended_state->'input'->>'sourceOperationId'=NEW.source_operation_id::text
|
||||
AND s.intended_state->'input'->>'receiptId'=NEW.recovery_receipt_id::text
|
||||
) THEN RAISE EXCEPTION 'manual inverse requires current exact running recovery'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER mcp_scoped_recovery_manual_version BEFORE INSERT ON public.mcp_scoped_recovery_intents
|
||||
FOR EACH ROW EXECUTE FUNCTION public.mcp_scoped_recovery_manual_version();
|
||||
INSERT INTO public.schema_migrations(id) VALUES('0044_mcp_routing_manual_inverse_charges') ON CONFLICT(id) DO NOTHING;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,276 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "0b2f7dde-98dc-44e9-84a9-fd04e004299d",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"server_snapshots": {
|
||||
"name": "server_snapshots",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"server_id": {
|
||||
"name": "server_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"polled_at": {
|
||||
"name": "polled_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"latency_ms": {
|
||||
"name": "latency_ms",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ros_version": {
|
||||
"name": "ros_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"board_name": {
|
||||
"name": "board_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"uptime": {
|
||||
"name": "uptime",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cpu_load": {
|
||||
"name": "cpu_load",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"free_memory": {
|
||||
"name": "free_memory",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"total_memory": {
|
||||
"name": "total_memory",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identity_name": {
|
||||
"name": "identity_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"raw_interfaces": {
|
||||
"name": "raw_interfaces",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"raw_ip_addresses": {
|
||||
"name": "raw_ip_addresses",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"server_snapshots_server_id_servers_id_fk": {
|
||||
"name": "server_snapshots_server_id_servers_id_fk",
|
||||
"tableFrom": "server_snapshots",
|
||||
"tableTo": "servers",
|
||||
"columnsFrom": [
|
||||
"server_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"servers": {
|
||||
"name": "servers",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"host": {
|
||||
"name": "host",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"port": {
|
||||
"name": "port",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 443
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'admin'"
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"use_ssl": {
|
||||
"name": "use_ssl",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"verify_ssl": {
|
||||
"name": "verify_ssl",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'home-router'"
|
||||
},
|
||||
"site": {
|
||||
"name": "site",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"asn": {
|
||||
"name": "asn",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"comment": {
|
||||
"name": "comment",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(datetime('now'))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(datetime('now'))"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1777572014210,
|
||||
"tag": "0000_postgresql",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,208 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "MikroTik Manager backend — Fastify + Drizzle + PostgreSQL",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:mcp:module-completion": "tsx src/services/ipsec-server-init-model.test.ts && tsx src/modules/mcp/ipsec-server-init-actions.test.ts && tsx src/services/wireguard-revision-restore.test.ts && tsx src/modules/mcp/vrf-ticket-runtime.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-pool-dependencies.test.ts && tsx src/services/vrf-production-transition.test.ts && tsx src/services/vrf-production-action-binding.test.ts && tsx src/services/ipsec-revision-restore-model.test.ts && tsx src/modules/mcp/ipsec-revision-actions.test.ts && tsx src/modules/mcp/service-native-progress.test.ts && tsx src/services/ipsec-revision-restore.test.ts && tsx src/services/ipsec-revision-secret.test.ts",
|
||||
"test:mcp:module-completion:pg": "node scripts/test-mcp-postgres.mjs --module-completion --restore",
|
||||
"test:mcp:pg": "node scripts/test-mcp-postgres.mjs",
|
||||
"test:mcp:external-process:pg": "node scripts/mcp-external-process/run-owned.mjs",
|
||||
"test:traffic-flow:ownership": "tsx src/services/traffic-flow-batch-proof.test.ts && tsx src/services/traffic-flow-owned-batch.test.ts && tsx src/services/traffic-flow-owned-writer.test.ts && tsx src/services/traffic-flow-write-transaction.test.ts && tsx src/services/traffic-flow-writer-exclusion.test.ts && tsx src/services/traffic-flow-drain-protocol.test.ts && tsx src/services/traffic-flow-facts-rebuild-execution.test.ts && tsx src/services/traffic-flow-facts-rebuild-source.test.ts && tsx src/services/traffic-flow-facts-rebuild-classifier.test.ts && tsx src/services/traffic-flow-facts-rebuild-service.test.ts && tsx src/services/traffic-flow-ripe-prune-owned.test.ts && tsx src/services/traffic-flow-engine-owned.test.ts && tsx src/routes/traffic-flow-rebuild.test.ts && tsx src/services/traffic-flow-settings-execution.test.ts",
|
||||
"test:traffic-flow:rebuild:pg": "node scripts/test-mcp-postgres.mjs --flow-rebuild",
|
||||
"test:traffic-flow:pg": "node scripts/test-mcp-postgres.mjs --flow-writes",
|
||||
"test:traffic-flow:quality:pg": "node scripts/test-mcp-postgres.mjs --flow-quality",
|
||||
"test:mcp:pg:all": "node scripts/test-mcp-postgres.mjs --all",
|
||||
"mcp:stdio": "node dist/scripts/mcp-stdio.js",
|
||||
"mcp:stdio:dev": "tsx src/scripts/mcp-stdio.ts",
|
||||
"test:logs": "tsx src/services/router-logs-export.test.ts && tsx src/services/router-logs-model.test.ts && tsx src/plugins/auth.logs.test.ts && tsx src/services/mikrotik-logs.test.ts",
|
||||
"test:logs:pg": "tsx src/services/router-logs-sql.test.ts",
|
||||
"test:sidebar-counts": "tsx src/services/sidebar-counts.test.ts && tsx src/services/complete-count.test.ts && tsx src/routes/sidebar-counts.test.ts",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "npm run build -w @mmapp/contracts && tsc && npm run build:database-operator",
|
||||
"build:database-operator": "node scripts/build-database-operator.mjs",
|
||||
"test:alerts-telegram": "tsx src/services/alerts-outbox-retry.test.ts && tsx src/services/alerts-outbox-overview.test.ts && tsx src/services/alert-engine/outbox-execution.test.ts && tsx src/services/alerts-telegram-delivery.test.ts && tsx src/services/alerts-revision.test.ts && tsx src/services/alerts-overview.test.ts && tsx ../packages/contracts/src/alerts-normalization.test.ts && tsx src/services/alerts-config-execution.test.ts && tsx src/services/alerts-telegram-settings.test.ts && tsx src/services/alerts-telegram-send.test.ts && tsx src/routes/alerts.test.ts && tsx src/plugins/auth.alerts.test.ts",
|
||||
"test:alert-engine": "tsx src/services/alert-engine/execution-access.test.ts && tsx src/services/alert-engine/server-signal-identity.test.ts && tsx src/services/alert-engine/collector-source-signals.test.ts && tsx src/services/alert-engine/monitoring-history.test.ts && tsx src/services/alert-engine/groups.test.ts && tsx src/services/alert-engine/evaluate-rule.test.ts && tsx src/services/gre-tunnels-live.test.ts && tsx src/services/bgp-peers-live.test.ts && tsx src/services/gre-bgp-snapshot-collector.test.ts && tsx src/services/gre-bgp-snapshot-history.test.ts && tsx src/services/gre-bgp-snapshot-sql.test.ts && tsx src/services/alert-engine/collector-source-sql.test.ts && tsx src/services/alert-engine/confirm-stability.test.ts",
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
|
||||
"facts:rebuild": "tsx src/scripts/rebuild-flow-facts.ts",
|
||||
"test:mcp:vrf-cleanup": "tsx src/test/chr-vrf-companion-cleanup-proof.test.ts && tsx src/test/chr-vrf-companion-disabled-cleanup-proof.test.ts && tsx src/test/chr-vrf-companion-transfer-cleanup-proof.test.ts",
|
||||
"test:probes": "npm run test:speed-workflow && tsx src/services/manual-data-collection-execution.test.ts && tsx src/routes/manual-data-collection.test.ts && tsx src/services/network-host-resolution.test.ts && tsx src/routes/probes.test.ts && tsx src/routes/probes.cancel.test.ts && tsx src/services/ping-measurement.test.ts && tsx src/services/uptime-probe-observations.test.ts && tsx src/services/ping-measurement-sql.test.ts",
|
||||
"test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/services/terminal-diagnostics.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
|
||||
"test:auth": "tsx --test src/modules/mcp/routing-manual-inverse-history.test.ts && tsx src/db/application-writer-profile-registry-structure.test.ts && tsx src/db/application-writer-profile-registry-read.test.ts && tsx src/db/application-writer-profile-registry-read-parity.test.ts && tsx src/db/application-writer-profile-format.test.ts && tsx src/plugins/auth-path.test.ts && tsx src/lib/permissions.test.ts && tsx src/plugins/auth-provenance.test.ts && tsx src/services/bandwidth-rest-speed-original.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts && npm run test:mcp",
|
||||
"test:mcp": "tsx --test src/services/server-poll-error-provenance.test.ts && tsx --test src/modules/mcp/plan-expiry.test.ts src/modules/mcp/result-budget.test.ts src/modules/mcp/result-delivery.test.ts src/modules/mcp/wire-admission.test.ts src/modules/mcp/flow-envelope-allowance.test.ts src/modules/mcp/sdk-terminal-budget.test.ts src/modules/mcp/inflight-owner.test.ts && npm run test:database-source-binding && tsx --test src/modules/mcp/integration-fixture-expiry.test.ts && npm run test:mcp:operation-origin && npm run test:mcp:service-context && npm run test:mcp:operation-locks && tsx --test src/modules/mcp/routing-community-public.test.ts src/modules/mcp/management-transport.test.ts src/modules/mcp/routing-community-context-model.test.ts src/modules/mcp/routing-community-context-native.test.ts ../packages/contracts/src/mcp-routing.test.ts && tsx src/modules/mcp/human-artifact-receipts.test.ts && tsx src/modules/mcp/chr-overlay-readiness.test.ts && tsx src/modules/mcp/chr-recovery-source.test.ts && tsx src/modules/mcp/chr-native-failure.test.ts && tsx src/modules/mcp/ospf-owner-graph.test.ts && tsx src/modules/mcp/routing-measurement-address.test.ts && tsx src/modules/mcp/routing-observation-source.test.ts && tsx src/services/traffic-flow-talkers-model.test.ts && tsx src/services/bgp-prefix-model.test.ts && tsx src/services/vrf-members.test.ts && tsx src/services/routing-gateway-reference.test.ts && tsx src/services/bgp-export-model.test.ts && tsx src/services/traffic-history-model.test.ts && tsx src/services/internet-path-snapshot.test.ts && tsx src/services/internet-path-observation.test.ts && tsx src/modules/mcp/coverage.test.ts && tsx src/modules/mcp/policy.test.ts && tsx src/modules/mcp/events-batch-model.test.ts && tsx src/modules/mcp/operation-revisions.test.ts && tsx src/modules/mcp/native-create-identity.test.ts && tsx src/modules/mcp/ros-step-verification.test.ts && tsx src/modules/mcp/ros-prefix.test.ts && tsx src/modules/mcp/native-conservation-http.test.ts && tsx src/modules/mcp/security.test.ts && tsx src/modules/mcp/state-file-publish.test.ts && tsx src/modules/mcp/routing-model.test.ts && tsx src/modules/mcp/routing-observations-model.test.ts && tsx src/modules/mcp/ospf-health.test.ts && tsx src/modules/mcp/ecmp-preflight.test.ts && tsx src/modules/mcp/recursive-recovery.test.ts && tsx src/modules/mcp/control-path-model.test.ts && tsx src/modules/mcp/routing-community-scoped-model.test.ts && tsx src/modules/mcp/routing-community-scoped-native.test.ts && tsx src/modules/mcp/path-verification.test.ts && tsx src/modules/mcp/database-maintenance.test.ts && tsx src/routes/mcp.test.ts && tsx ../lib/auth-mcp.test.ts && tsx --test src/modules/mcp/routing-controller-fence.test.ts && tsx src/test/chr-vrf-companion-proof.test.ts && tsx src/test/chr-vrf-companion-telemetry.test.ts && tsx src/test/chr-vrf-companion-address-space.test.ts && tsx src/modules/mcp/flow-facts-rebuild-actions.test.ts && tsx src/modules/mcp/ipsec-peer-create-dependencies.test.ts && tsx src/services/ipsec-certificate-update-model.test.ts && tsx src/modules/mcp/ipsec-native-dispatch.test.ts && tsx src/modules/mcp/ipsec-user-update-actions.test.ts && tsx src/modules/mcp/ipsec-server-delete-actions.test.ts && tsx src/services/ipsec-proof-telemetry.test.ts && tsx src/services/vrf-production-admission.test.ts && tsx src/modules/mcp/flow-host-keys-actions.test.ts && tsx src/services/vrf-production-read.test.ts && tsx src/modules/mcp/ipsec-user-create-actions.test.ts && tsx src/modules/mcp/flow-settings-actions.test.ts && tsx src/services/vrf-production-ticket-store.test.ts && npm run test:mcp:routing-baseline",
|
||||
"test:wireguard": "tsx src/services/mikrotik-response-bounds.test.ts && tsx src/services/wireguard-interface-update.test.ts && tsx src/services/wireguard-peer-update.test.ts && npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts && tsx src/services/wireguard-interface-create.test.ts && tsx src/services/wireguard-peer-create.test.ts && tsx src/services/wireguard-import-plan.test.ts && tsx src/services/wireguard-import-execution.test.ts && tsx src/modules/mcp/wireguard-import-actions.test.ts && tsx --test src/services/wireguard-export.test.ts",
|
||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts && tsx src/services/traffic-identity.test.ts && tsx src/services/traffic-membership.test.ts && tsx src/services/traffic-aggregate-peak.test.ts && tsx src/services/traffic-users-cache.test.ts && tsx src/services/traffic-collection-lock.test.ts && tsx src/services/traffic-concurrency-sql.test.ts",
|
||||
"test:traffic-flow": "tsx --test src/services/traffic-flow-heartbeat.test.ts src/services/traffic-flow-heartbeat-wiring.test.ts src/services/traffic-flow-heartbeat-receiver.test.ts src/services/traffic-flow-ingest-quality.test.ts src/services/traffic-flow-quality-contract.test.ts && tsx src/services/traffic-flow-catalog-read.test.ts && tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-fact-accumulator.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts && tsx src/services/geoip-manual-update.test.ts && tsx src/services/traffic-flow-overlay-model.test.ts && npm run test:traffic-flow:ownership && tsx src/services/traffic-flow-worker-platform.test.ts && tsx src/services/traffic-flow-template-cache.test.ts && tsx --test src/services/traffic-flow-template-lifetime.test.ts && tsx src/services/traffic-flow-protocol-scope.test.ts && npm run test:traffic-flow:read-budget && npm run test:traffic-flow:resource-profile",
|
||||
"test:users": "tsx ../packages/contracts/src/users.test.ts && tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
|
||||
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/db/scheduler-retention-index.test.ts && tsx src/services/config-revisions.test.ts",
|
||||
"test:recursive-routes": "tsx src/services/recursive-route-identity.test.ts && tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-effective-properties.test.ts && tsx src/services/recursive-apply.test.ts",
|
||||
"test:config-sync": "tsx src/services/owned-routing-policy.test.ts && tsx src/services/routing-filter-body.test.ts && tsx src/services/bgp-community-policy-model.test.ts && tsx src/services/bgp-connection-read.test.ts && tsx src/services/bgp-filter-binding-model.test.ts && tsx src/services/filter-rules-write-model.test.ts && tsx src/services/filter-rules-mutation.test.ts && tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts",
|
||||
"test:ros-file": "tsx src/services/mikrotik-get-cancel.test.ts && tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.test.ts && tsx src/services/mikrotik-owned-file.test.ts && tsx --test src/services/mikrotik-owned-file-zeroization.test.ts",
|
||||
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
|
||||
"test:backups": "tsx src/modules/mcp/backup-storage-actions.test.ts && tsx src/services/backup-job-read.test.ts && tsx src/services/backup-local-file-state.test.ts && tsx src/services/router-config-export.test.ts && tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts && tsx src/services/backup-content-bounds.test.ts",
|
||||
"test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-operation.test.ts && tsx src/routes/ipsec-mutation.test.ts && tsx src/routes/ipsec-nat.test.ts && tsx src/routes/ipsec-group.test.ts && tsx src/services/ipsec-peer-delete.test.ts && tsx src/modules/mcp/ipsec-peer-delete-actions.test.ts && tsx src/services/ipsec-user-delete.test.ts && tsx src/modules/mcp/ipsec-user-delete-actions.test.ts && tsx src/services/ipsec-certificate-delete.test.ts && tsx src/modules/mcp/ipsec-certificate-delete-actions.test.ts && tsx src/services/ipsec-peer-update.test.ts && tsx src/modules/mcp/ipsec-peer-update-actions.test.ts && tsx src/services/ipsec-user-update-model.test.ts && tsx src/modules/mcp/ipsec-user-update-actions.test.ts && tsx src/services/ipsec-server-delete-model.test.ts && tsx src/modules/mcp/ipsec-server-delete-actions.test.ts && tsx src/services/ipsec-proof-telemetry.test.ts && tsx src/services/ipsec-user-create-model.test.ts",
|
||||
"test:live-maps": "tsx src/services/gre-revision-restore.test.ts && tsx src/services/gre-native-mutation.test.ts && tsx src/services/gre-mutation.test.ts && tsx src/services/filter-rules-observed.test.ts && tsx src/services/firewall-revision-restore.test.ts && tsx src/services/firewall-address-write.test.ts && tsx src/services/firewall-rule-write.test.ts && tsx src/services/gre-observed-read.test.ts && tsx src/services/network-observed-read.test.ts && tsx src/services/firewall-rule-move.test.ts && tsx src/services/ospf-state-read.test.ts && tsx src/modules/mcp/readiness-http.test.ts && tsx src/services/probe-interface-model.test.ts && tsx src/services/wan-runtime-model.test.ts && tsx src/services/gre-keepalive.test.ts && tsx src/services/gre-read.test.ts && tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts && tsx src/routes/ospf-legacy.test.ts",
|
||||
"test": "node scripts/test.mjs",
|
||||
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/services/evobgp-client.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
|
||||
"test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/services/flow-purge-partitions.test.ts && tsx --test src/services/traffic-flow-worker-lifecycle.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
|
||||
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts && tsx src/services/traffic-flow-agent-install.test.ts && tsx src/services/traffic-flow-agent-client.test.ts && tsx src/services/traffic-flow-operation-queue.test.ts && tsx src/routes/traffic-flow-host.test.ts && tsx src/services/traffic-flow-settings-execution.test.ts && tsx src/modules/mcp/flow-host-keys-actions.test.ts && tsx src/services/traffic-flow-lifecycle-fence.test.ts && tsx src/services/traffic-flow-settings-store.test.ts && tsx src/services/traffic-flow-settings-service.test.ts && tsx src/modules/mcp/flow-settings-actions.test.ts",
|
||||
"test:geoip": "tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts",
|
||||
"test:ospf-optimizer": "tsx src/services/ospf-cost-optimization.test.ts && tsx src/modules/mcp/ospf-legacy-source.test.ts && tsx src/services/ospf-legacy-optimization.test.ts && tsx src/services/ospf-template-flags.test.ts && tsx src/services/ospf-route-proof.test.ts && tsx src/services/ospf-link-catalog.test.ts && tsx src/services/ospf-identity-catalog.test.ts && tsx src/services/ospf-template-cost-write.test.ts && tsx src/services/ospf-template-cost-http.test.ts && tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts",
|
||||
"test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts",
|
||||
"test:certificates": "tsx src/services/certificate-installation.test.ts && tsx src/services/certificate-renew-collector.test.ts && tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts && tsx src/services/acme-settings-test.test.ts && tsx src/services/acme-settings-update-model.test.ts && tsx src/modules/mcp/acme-settings-actions.test.ts",
|
||||
"test:system-database": "tsx src/services/bounded-database-dump.test.ts && tsx src/services/database-dump-connection.test.ts && tsx src/services/system-database-partition-restore.test.ts && tsx src/services/system-database-cache.test.ts && tsx src/services/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/services/system-database-schema-readiness.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts && tsx src/services/database-control-archive-boundary.test.ts && tsx src/db/database-schema-control-model.test.ts && tsx src/db/database-schema-control.test.ts && tsx src/db/controlled-migrations.test.ts && tsx src/db/controlled-schema-verification.test.ts && tsx --test src/db/migration-data-inventory.test.ts",
|
||||
"test:all": "npm run test:logs && npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:sidebar-counts && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database && npm run test:server-privacy && npm run test:mcp:module-completion && npm run test:mcp:loopback-agent && npm run test:server-probe-management && npm run test:server-poll-management && npm run test:mcp:routing-authority && npm run test:mcp:native-readers && npm run test:mcp:chr-acquisition && npm run test:mcp:shared-context && npm run test:mcp:telemetry-configuration",
|
||||
"test:server-privacy": "tsx ../packages/contracts/src/servers.test.ts && tsx src/routes/server-secrets-sql.test.ts && tsx --test src/services/server-connection-test.test.ts",
|
||||
"test:mcp:flow-rebuild:pg": "node scripts/test-mcp-postgres.mjs --flow-rebuild-mcp",
|
||||
"test:mcp:flow-host-keys:pg": "node scripts/test-mcp-postgres.mjs --flow-host-keys",
|
||||
"test:mcp:loopback-agent": "tsx src/test/loopback-http-agent.test.ts",
|
||||
"test:mcp:pending-foundations": "tsx src/services/server-poll-execution.test.ts && tsx src/services/server-probe-execution.test.ts && tsx src/services/ipsec-existing-certificate-export.test.ts && tsx src/modules/mcp/ipsec-existing-certificate-export-actions.test.ts && tsx src/modules/mcp/ipsec-existing-certificate-export-production.test.ts && tsx src/services/ipsec-existing-certificate-export-rest.test.ts && tsx src/services/ipsec-existing-certificate-export-rest-production.test.ts && tsx src/services/ipsec-existing-certificate-export-rest-journal.test.ts && tsx src/services/ipsec-existing-certificate-export-rest-http.test.ts && tsx src/services/ipsec-existing-certificate-export-artifact.test.ts && tsx src/modules/mcp/ipsec-existing-certificate-export-registration.test.ts",
|
||||
"test:mcp:ipsec-create:pg": "node scripts/test-mcp-postgres.mjs --module-completion --only=ipsec-create --restore",
|
||||
"test:mcp:ipsec-export-journal:pg": "node scripts/test-mcp-postgres.mjs --ipsec-export-journal --restore",
|
||||
"test:mcp:ipsec-export:http:pg": "node scripts/test-mcp-postgres.mjs --ipsec-export-http --restore",
|
||||
"test:mcp:diagnostic-cancel-host:pg": "node scripts/test-mcp-postgres.mjs --diagnostic-cancel-host --restore",
|
||||
"test:server-probe-management": "tsx src/services/server-probe-authority.test.ts && tsx src/services/server-probe-public-result.test.ts && tsx src/services/server-probe-execution.test.ts && tsx src/services/server-probe-rest-contract.test.ts && tsx src/services/server-probe-rest-execution.test.ts && tsx src/services/server-probe-journal.test.ts && tsx src/services/server-probe-journal-history.test.ts && tsx src/modules/mcp/server-probe-actions.test.ts && tsx src/modules/mcp/diagnostic-cancellation.test.ts && tsx src/modules/mcp/service-diagnostic-progress.test.ts && tsx src/services/mikrotik-get-admission.test.ts && tsx src/services/server-probe-storage-journal-adapter.test.ts && tsx --test src/services/server-probe-storage-history.test.ts && npm run test:server-probe-storage-models",
|
||||
"test:server-poll-http:pg": "node scripts/test-server-poll-http-owned.mjs",
|
||||
"test:server-poll-approval:pg": "node scripts/test-server-poll-approval-owned.mjs",
|
||||
"test:server-poll-atomic:pg": "node scripts/test-server-poll-atomic-owned.mjs",
|
||||
"test:source-capture-adopted:pg": "node scripts/test-source-capture-adopted-postgres.mjs",
|
||||
"test:server-poll-settings-epoch:pg": "node scripts/test-server-poll-settings-epoch-owned.mjs",
|
||||
"test:server-poll-management": "tsx --test src/services/server-poll-maintenance-planner.test.ts src/services/server-poll-maintenance-groups.test.ts && tsx src/services/server-poll-execution.test.ts && tsx src/services/server-poll-production.test.ts && tsx src/services/server-poll-journal.test.ts && tsx src/services/server-poll-authority.test.ts && tsx src/services/server-poll-rest.test.ts && tsx src/services/server-partition-maintenance-lease.test.ts && npm run test:server-poll-storage && npm run test:partition-writers && tsx --test src/services/server-poll-history-verification.test.ts src/services/server-poll-terminal-source.test.ts src/services/server-poll-terminal-archive.test.ts src/services/server-poll-archive-accounting.test.ts src/services/server-poll-terminal-compaction.test.ts src/services/server-poll-history-reader.test.ts src/modules/mcp/server-poll-actions.test.ts src/modules/mcp/server-poll-production.test.ts src/modules/mcp/server-poll-registration.test.ts && tsx src/modules/mcp/effective-connection-source.test.ts && tsx src/services/server-poll-cost-diagnostics.test.ts && tsx src/services/server-poll-journal-snapshot.test.ts && tsx src/modules/mcp/principal-security-source.test.ts && tsx --test src/modules/mcp/poll-runtime-policy-source.test.ts src/modules/mcp/poll-policy-coordinator.test.ts && tsx src/services/server-poll-http.test.ts && tsx --test src/services/server-poll-approval-context.test.ts",
|
||||
"test:server-poll-storage": "tsx src/services/server-poll-capacity.test.ts && tsx src/services/server-poll-storage-model.test.ts && tsx src/services/server-poll-storage.test.ts && tsx src/services/server-poll-storage-journal.test.ts && tsx src/services/server-poll-storage-retention.test.ts",
|
||||
"test:bandwidth-admission:pg": "node scripts/test-mcp-postgres.mjs --bandwidth-admission --restore",
|
||||
"test:bandwidth-control-source:pg": "node scripts/test-mcp-postgres.mjs --bandwidth-control-source --restore",
|
||||
"test:bandwidth-control-source": "tsx src/services/bandwidth-control-role.test.ts && tsx src/services/bandwidth-control-catalog.test.ts && tsx --test src/services/bandwidth-control-identity.test.ts",
|
||||
"bandwidth:control:seal": "tsx src/scripts/seal-bandwidth-control-source.ts",
|
||||
"test:bandwidth-node-observation": "tsx src/services/bandwidth-node-observation.test.ts",
|
||||
"test:bandwidth-peers": "tsx src/services/bandwidth-peer-observation.test.ts && tsx src/services/bandwidth-peer-network-observation.test.ts && tsx src/services/bandwidth-peer-direct-observation.test.ts && tsx src/services/bandwidth-operator-enrollment.test.ts && tsx --test --test-concurrency=1 src/services/bandwidth-control-registry-frame.test.ts src/services/bandwidth-control-registry-identity.test.ts src/services/bandwidth-node-identity.test.ts src/services/bandwidth-node-registry-batch.test.ts src/services/bandwidth-operator-docker-stop.test.ts src/services/bandwidth-operator-writer-window.test.ts src/services/bandwidth-operator-writer-composition.test.ts src/services/bandwidth-operator-registry.test.ts src/services/bandwidth-external-node-registry.test.ts src/services/bandwidth-external-node-registry-catalog.test.ts src/services/bandwidth-external-node-registry-role.test.ts src/modules/mcp/routing-bandwidth-original.test.ts src/services/bandwidth-peer-identity.test.ts src/services/bandwidth-peer-resolver.test.ts && node --experimental-test-module-mocks --import tsx --test --test-concurrency=1 src/services/bandwidth-node-proof-batch.test.ts src/services/bandwidth-node-key-effect.test.ts src/services/bandwidth-control-file.test.ts src/services/bandwidth-node-bootstrap-v2.test.ts src/services/bandwidth-peer-fence.test.ts src/services/bandwidth-operator-public-files.test.ts src/services/bandwidth-registry-combined.test.ts && npm run test:bandwidth-public-metadata && npm run test:bandwidth-public-registry && npm run test:bandwidth-peer-metadata && npm run test:bandwidth-guarded-peer-sources",
|
||||
"test:bandwidth-peers:pg": "node scripts/test-mcp-postgres.mjs --bandwidth-external-registry",
|
||||
"test:bandwidth-rest-origin:pg": "node scripts/test-mcp-postgres.mjs --rest-speed-original",
|
||||
"test:peer-speed-workflow:pg": "node scripts/test-mcp-postgres.mjs --bandwidth-speed-workflow",
|
||||
"test:scheduled-speed-original:pg": "node scripts/test-mcp-postgres.mjs --scheduled-speed-original",
|
||||
"test:operator-docker:pg": "node scripts/test-mcp-postgres.mjs --operator-docker-sql",
|
||||
"test:bandwidth-public-metadata": "node --experimental-test-module-mocks --import tsx --test --test-concurrency=1 src/services/bandwidth-control-public-metadata.test.ts src/services/bandwidth-node-public-metadata.test.ts",
|
||||
"test:bandwidth-public-registry:pg": "node scripts/test-mcp-postgres.mjs --public-registry-metadata",
|
||||
"test:bandwidth-public-registry": "node --experimental-test-module-mocks --import tsx --test --test-concurrency=1 src/services/bandwidth-control-public-registry.test.ts src/services/bandwidth-node-public-registry.test.ts",
|
||||
"test:bandwidth-peer-metadata": "node --experimental-test-module-mocks --import tsx --test --test-concurrency=1 src/services/bandwidth-peer-metadata.test.ts src/services/bandwidth-peer-metadata-source.test.ts src/services/bandwidth-operator-metadata.test.ts",
|
||||
"test:operator-peer-metadata:pg": "node scripts/test-mcp-postgres.mjs --operator-peer-metadata",
|
||||
"test:bandwidth-guarded-peer-sources": "node --experimental-test-module-mocks --import tsx --test --test-concurrency=1 src/services/server-probe-storage-connection.test.ts src/services/bandwidth-operator-peer-source-guard.test.ts src/services/bandwidth-external-peer-source.test.ts src/services/bandwidth-managed-peer-source.test.ts src/services/bandwidth-operator-peer-source-composition.test.ts",
|
||||
"test:guarded-peer-sources:pg": "node scripts/test-mcp-postgres.mjs --external-peer-metadata",
|
||||
"test:mcp:operation-origin": "node --experimental-test-module-mocks --import tsx --test src/modules/mcp/operation-execution-origin.test.ts",
|
||||
"test:mcp:operation-origin:pg": "node scripts/test-mcp-postgres.mjs --mcp-operation-origin",
|
||||
"test:mcp:speed-original": "node --import tsx --test src/modules/mcp/speed-bandwidth-original.test.ts",
|
||||
"test:mcp:speed-original:pg": "node scripts/test-mcp-postgres.mjs --mcp-speed-original",
|
||||
"test:router-configuration-guard:pg": "node scripts/test-mcp-postgres.mjs --router-configuration-guard",
|
||||
"test:router-configuration-writer": "node --experimental-test-module-mocks --import tsx --test --test-concurrency=1 src/services/router-configuration-writer-context.test.ts src/services/router-configuration-native-transport.test.ts",
|
||||
"test:scheduled-speed-original": "node --experimental-test-module-mocks --import tsx src/services/bandwidth-scheduled-speed-original.test.ts",
|
||||
"test:peer-speed-workflow": "node --experimental-test-module-mocks --import tsx --test src/services/bandwidth-speed-run-identity.test.ts src/services/bandwidth-speed-workflow.test.ts",
|
||||
"test:bandwidth-node-registry:pg": "node scripts/test-mcp-postgres.mjs --bandwidth-node-registry --restore",
|
||||
"test:mcp:poll-terminal-claim-loss:pg": "node scripts/test-mcp-postgres.mjs --poll-terminal-archive-claim-loss --restore",
|
||||
"test:mcp:poll-terminal-history-cold:pg": "node scripts/test-mcp-postgres.mjs --poll-terminal-archive-history-cold --restore",
|
||||
"test:mcp:poll-terminal-history:pg": "node scripts/test-mcp-postgres.mjs --poll-terminal-archive-history --restore",
|
||||
"test:mcp:poll-terminal-archive-lost-ack:pg": "node scripts/test-mcp-postgres.mjs --poll-terminal-archive-lost-ack --restore",
|
||||
"test:mcp:poll-terminal-archive:pg": "node scripts/test-mcp-postgres.mjs --poll-terminal-archive --restore",
|
||||
"test:mcp:poll-archive-schema:pg": "node scripts/test-mcp-postgres.mjs --poll-archive-schema",
|
||||
"test:mcp:telemetry-configuration:pg": "node scripts/test-mcp-postgres.mjs --telemetry-settings-configuration",
|
||||
"test:mcp:telemetry-configuration": "tsx --test src/modules/mcp/telemetry-settings-configuration.test.ts",
|
||||
"test:mcp:routing-schema:pg": "node scripts/test-mcp-postgres.mjs --routing-reservation-schema",
|
||||
"test:mcp:reserved-step": "tsx --test src/modules/mcp/routing-reserved-step.test.ts",
|
||||
"test:mcp:routing-forward-rounds:pg": "node scripts/test-mcp-postgres.mjs --routing-network-forward-rounds",
|
||||
"test:flow-active-services:pg": "node scripts/test-mcp-postgres.mjs --flow-active-services",
|
||||
"test:flow-active-rebuild:pg": "node scripts/test-mcp-postgres.mjs --flow-active-rebuild",
|
||||
"test:flow-worker-ipc:pg": "node scripts/test-mcp-postgres.mjs --flow-worker-ipc",
|
||||
"test:manual-uptime:pg": "node scripts/test-mcp-postgres.mjs --manual-uptime",
|
||||
"test:manual-ping-group:pg": "node scripts/test-mcp-postgres.mjs --manual-ping-group",
|
||||
"test:manual-traffic:pg": "node scripts/test-mcp-postgres.mjs --manual-traffic",
|
||||
"test:manual-remaining": "node --experimental-test-module-mocks --import tsx --test --test-concurrency=1 src/modules/mcp/manual-uptime-actions.test.ts src/modules/mcp/manual-ping-group-actions.test.ts src/modules/mcp/manual-traffic-actions.test.ts src/services/manual-data-collection-execution.test.ts src/routes/manual-data-collection.test.ts",
|
||||
"test:manual-api-ping:pg": "node scripts/test-mcp-postgres.mjs --manual-api-ping",
|
||||
"test:manual-api-ping": "node --experimental-test-module-mocks --import tsx --test src/modules/mcp/manual-api-ping-actions.test.ts src/services/manual-data-collection-execution.test.ts src/routes/manual-data-collection.test.ts",
|
||||
"test:simple-speed:pg": "node scripts/test-mcp-postgres.mjs --simple-speed",
|
||||
"test:simple-speed:chr": "node scripts/test-mcp-postgres.mjs --simple-speed-chr",
|
||||
"test:simple-speed": "node --experimental-test-module-mocks --import tsx --test src/modules/mcp/speed-test-actions.test.ts src/services/uptime-speed-service.test.ts && node --import tsx --test src/routes/speed-test.test.ts src/services/speed-run-persistence.test.ts src/services/btest-node-locks.test.ts src/services/bandwidth-test-status.test.ts src/services/bandwidth-test-rates.test.ts",
|
||||
"test:bandwidth-node-key-effect": "node --experimental-test-module-mocks --import tsx --test --test-concurrency=1 src/services/bandwidth-node-key-effect.test.ts src/services/bandwidth-node-proof-batch.test.ts",
|
||||
"test:bandwidth-node-source": "tsx src/services/bandwidth-node-registry-role.test.ts && tsx src/services/bandwidth-node-registry-catalog.test.ts",
|
||||
"test:bandwidth-node-source:pg": "node scripts/test-mcp-postgres.mjs --bandwidth-node-source --restore",
|
||||
"test:mcp:service-context": "tsx --test src/services/config-mcp-service-execution.test.ts src/modules/mcp/temporary-grant-origin.test.ts src/modules/mcp/routing-measurement-origin.test.ts",
|
||||
"test:mcp:service-context:pg": "node scripts/test-mcp-postgres.mjs --service-execution-origin",
|
||||
"test:mcp:operation-locks": "tsx --test src/services/operation-config-lock-lease.test.ts && npm run test:scheduler-automatic",
|
||||
"test:mcp:operation-locks:pg": "node scripts/test-mcp-postgres.mjs --operation-locks",
|
||||
"test:scheduler-automatic": "node --test src/services/scheduler-automatic-root.test.mjs src/services/scheduler-automatic-authority.test.mjs",
|
||||
"test:speed-workflow": "npm run test:router-configuration-writer && npm run test:mcp:speed-original && npm run test:scheduled-speed-original && npm run test:peer-speed-workflow && npm run test:bandwidth-node-observation && npm run test:bandwidth-native-permit && tsx src/services/bandwidth-admission.test.ts && tsx src/services/btest-node-locks.test.ts && tsx src/services/mikrotik-speed-admission.test.ts && tsx src/services/bandwidth-test-status.test.ts && tsx src/services/bandwidth-test-rates.test.ts && tsx src/services/speed-run-persistence.test.ts && tsx src/routes/speed-test.test.ts && tsx src/routes/speed-probes-write.test.ts",
|
||||
"test:bandwidth-native-dispatch": "node --experimental-test-module-mocks --import tsx --test --test-concurrency=1 src/services/mikrotik-managed-source.test.ts src/services/bandwidth-native-dispatch-composite.test.ts src/services/bandwidth-peer-execution-host-fence.test.ts src/services/bandwidth-native-dispatch-source.test.ts src/services/bandwidth-native-dispatch.test.ts src/services/bandwidth-native-permit.test.ts src/services/bandwidth-admission.test.ts src/services/mikrotik-speed-admission.test.ts",
|
||||
"test:bandwidth-native-permit": "tsx src/services/bandwidth-native-permit.test.ts",
|
||||
"test:bandwidth-native-permit:pg": "node scripts/test-mcp-postgres.mjs --bandwidth-control-source --restore",
|
||||
"test:server-poll-mcp": "tsx src/services/server-poll-boundary.test.ts && tsx src/modules/mcp/server-poll-actions.test.ts && tsx src/modules/mcp/server-poll-production.test.ts && tsx src/modules/mcp/server-poll-registration.test.ts",
|
||||
"test:server-poll-storage:pg": "node scripts/test-mcp-postgres.mjs --server-poll-storage --restore",
|
||||
"test:server-poll-storage-fresh:pg": "node scripts/test-mcp-postgres.mjs --server-poll-storage-fresh --restore",
|
||||
"test:partition-writers": "tsx src/services/partition-write-transaction.test.ts && tsx src/services/collector-partition-writes.test.ts && tsx src/db/partitions-writer.test.ts && tsx src/db/migrate-partition-admission.test.ts && tsx src/db/migration-manifest.test.ts && tsx src/db/sqlite-import-partition-admission.test.ts && tsx src/services/system-database-partition-restore.test.ts && tsx src/services/traffic-flow-write-transaction.test.ts && tsx src/services/traffic-flow-facts-rebuild-cli.test.ts",
|
||||
"test:mcp:routing-authority": "tsx --test src/modules/mcp/routing-switch-ledger-contract.test.ts && tsx src/modules/mcp/routing-profile-authority.test.ts && tsx src/modules/mcp/routing-recovery-authority.test.ts && tsx src/modules/mcp/routing-reservation.test.ts && tsx src/modules/mcp/routing-community-reserved-source.test.ts && tsx src/modules/mcp/routing-network-verifier-model.test.ts && tsx --test src/test/committed-scoped-checkpoint-observer.test.ts && node --test src/modules/mcp/routing-forward-transport-charge.test.cjs",
|
||||
"test:mcp:native-readers": "tsx src/modules/mcp/routing-network-verifier-model.test.ts && tsx src/modules/mcp/routing-network-native-parser.test.ts && tsx src/modules/mcp/routing-network-native-session.test.ts && tsx src/db/migration-schema-inventory.test.ts && tsx src/modules/mcp/routing-network-observation-assembly.test.ts && tsx src/modules/mcp/routing-network-configuration-bridge.test.ts",
|
||||
"test:mcp:native-readers:pg": "node scripts/test-mcp-postgres.mjs --routing-network-native --restore",
|
||||
"test:mcp:schema-inventory:pg": "node scripts/test-mcp-postgres.mjs --migration-schema-inventory --restore",
|
||||
"test:mcp:native-tls": "tsx src/modules/mcp/routing-network-native-tls.test.ts",
|
||||
"test:mcp:schema-control:pg": "node scripts/test-mcp-postgres.mjs --database-schema-control --restore",
|
||||
"test:mcp:chr-acquisition": "node --test scripts/acceptance/chr-recursive-observation.test.mjs",
|
||||
"test:mcp:controlled-migrations:pg": "node scripts/test-mcp-postgres.mjs --controlled-migrations --restore",
|
||||
"test:mcp:shared-context": "tsx --test src/modules/mcp/routing-network-scoped-context.test.ts",
|
||||
"test:mcp:controlled-reference:pg": "node scripts/test-mcp-postgres.mjs --controlled-schema-reference --restore",
|
||||
"test:server-probe-storage-fresh:pg": "node scripts/test-mcp-postgres.mjs --probe-storage-fresh --restore",
|
||||
"test:server-poll-durable:pg": "node scripts/test-mcp-postgres.mjs --server-poll-durable --restore",
|
||||
"test:server-probe-legacy:pg": "node scripts/test-mcp-postgres.mjs --probe-legacy-orphans",
|
||||
"test:server-probe-storage:pg": "node scripts/test-mcp-postgres.mjs --probe-storage-journal",
|
||||
"test:server-probe-history": "tsx --test src/services/server-probe-storage-history.test.ts",
|
||||
"test:server-probe-history:pg": "node scripts/test-mcp-postgres.mjs --probe-storage-history",
|
||||
"test:server-probe-storage-models": "tsx src/services/server-probe-journal-maintenance.test.ts && tsx src/modules/mcp/diagnostic-cancellation-source.test.ts && tsx src/services/server-probe-storage-source.test.ts && tsx src/services/server-probe-storage-connection.test.ts && tsx src/modules/mcp/server-probe-storage-host.test.ts && tsx src/services/server-probe-storage-rest.test.ts && tsx src/services/server-probe-storage-metadata.test.ts && tsx src/services/server-probe-legacy-verifier.test.ts && tsx src/services/server-probe-legacy-snapshot.test.ts && tsx src/services/server-probe-legacy-inspection.test.ts && tsx src/db/database-source-observation.test.ts && tsx src/services/server-probe-retention.test.ts && tsx src/modules/mcp/server-probe-registration.test.ts",
|
||||
"test:mcp:migration-data:pg": "node scripts/test-mcp-postgres.mjs --migration-data-inventory --restore",
|
||||
"test:mcp:poll-memory:pg": "npm run build -w @mmapp/contracts && node scripts/acceptance/build-poll-memory.mjs && node scripts/test-mcp-postgres.mjs --poll-memory",
|
||||
"test:mcp:poll-memory-bootstrap:pg": "npm run build -w @mmapp/contracts && node scripts/acceptance/build-poll-memory.mjs && node scripts/test-mcp-postgres.mjs --poll-memory-bootstrap",
|
||||
"test:mcp:poll-memory-cost:pg": "npm run build -w @mmapp/contracts && node scripts/acceptance/build-poll-memory.mjs && node scripts/test-mcp-postgres.mjs --poll-memory-cost",
|
||||
"test:mcp:poll-journal-snapshot:pg": "node scripts/test-mcp-postgres.mjs --poll-journal-snapshot",
|
||||
"test:mcp:principal-security-source:pg": "node scripts/test-mcp-postgres.mjs --principal-security-source",
|
||||
"test:traffic-flow:read-budget": "tsx --test src/services/traffic-flow-read-budget.test.ts src/modules/mcp/flow-read-quality.test.ts && node scripts/test-flow-read-adapters.mjs",
|
||||
"test:traffic-flow:read-http:pg": "node scripts/test-flow-read-http-owned.mjs",
|
||||
"test:traffic-flow:read-source:pg": "tsx scripts/test-flow-read-sql-owned.mts",
|
||||
"test:mcp:routing-baseline": "tsx --test src/modules/mcp/routing-controller-active-writer.test.ts src/modules/mcp/routing-controller-fence.test.ts src/modules/mcp/routing-community-public.test.ts src/modules/mcp/routing-controller-overlap.test.ts",
|
||||
"test:mcp:routing-baseline:pg": "node scripts/test-routing-controller-baseline.mjs",
|
||||
"test:mcp:routing-baseline-inverse:pg": "node scripts/test-routing-controller-baseline.mjs --default-scoped",
|
||||
"test:sqlite-import-control:pg": "node scripts/test-sqlite-import-control-postgres.mjs",
|
||||
"test:traffic-flow:resource-profile": "tsx --test src/services/traffic-flow-read-resource-profile.test.ts && node scripts/test-flow-read-profile-startup.mjs",
|
||||
"test:traffic-flow:settings-read-profiles:pg": "node scripts/test-flow-settings-read-profiles-owned.mjs",
|
||||
"test:database-source-binding": "node scripts/test-database-source-binding.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
"@mmapp/contracts": "1.0.0",
|
||||
"@modelcontextprotocol/client": "2.3.1",
|
||||
"@modelcontextprotocol/fastify": "2.0.1",
|
||||
"@modelcontextprotocol/node": "2.1.1",
|
||||
"@modelcontextprotocol/server": "2.3.1",
|
||||
"acme-client": "^5.4.0",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fastify": "^5.8.5",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"maxmind": "^5.0.7",
|
||||
"pg": "^8.23.0",
|
||||
"undici": "^8.1.0",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/pg": "^8.23.1",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"embedded-postgres": "18.4.0-beta.17",
|
||||
"jose": "^6.2.11",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import fs from 'node:fs';import assert from 'node:assert/strict';import {chromium,expect} from '@playwright/test';
|
||||
const privatePath=process.env.MMAPP_ACME_FIXTURE||'tmp/acme250-private.json',p=JSON.parse(fs.readFileSync(privatePath)),login=JSON.parse(fs.readFileSync('tmp/portal195-private.json'));
|
||||
assert(/^mmt250-[a-f0-9]{8}\.pngs\.top$/.test(p.fqdn));assert(!p.jobId,'Never submit an unknown or existing issue job again');
|
||||
const report={date:'2026-10-14',block:'12.5.250',fqdn:p.fqdn,certName:p.certName,serverId:p.serverId,realPortalLogin:true,noMocks:true,issuePosts:0,pageErrors:[]};
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
try{
|
||||
const page=await browser.newPage({viewport:{width:1440,height:1000}});page.on('pageerror',e=>report.pageErrors.push(e.name));page.on('request',r=>{if(new URL(r.url()).pathname==='/api/certificates/issue'&&r.method()==='POST'){const b=r.postDataJSON();assert.equal(String(b.serverId),p.serverId);assert.equal(b.certName,p.certName);assert.deepEqual(b.domainNames,[p.fqdn]);p.jobId=b.requestId;assert(p.jobId);report.issuePosts++;report.jobId=p.jobId;fs.writeFileSync(privatePath,JSON.stringify(p))}});
|
||||
await page.goto('http://localhost:3000/certificates?server=2');await page.getByLabel('Email',{exact:true}).fill(login.email);await page.getByLabel('Пароль',{exact:true}).fill(login.password);await page.getByRole('button',{name:'Войти',exact:true}).click();await expect(page.getByRole('navigation',{name:'Путь страницы'})).toContainText('Сертификаты',{timeout:30000});
|
||||
const settings=await page.evaluate(async({token,zoneId})=>{const headers={Authorization:'Bearer '+localStorage.getItem('mmapp_token'),'Content-Type':'application/json'};const before=await fetch('/api/certificates/acme-settings',{headers});if(!before.ok)throw Error('Settings read failed');const old=await before.json();if(old.tokenConfigured)throw Error('Existing CF token must not be overwritten');const test=await fetch('/api/certificates/acme-settings/test',{method:'POST',headers,body:JSON.stringify({cloudflareApiToken:token})});const tested=await test.json();if(!test.ok||tested.ok!==true)return{testStatus:test.status,testOk:false,message:tested.message};const put=await fetch('/api/certificates/acme-settings',{method:'PUT',headers,body:JSON.stringify({directoryUrl:'https://acme-v02.api.letsencrypt.org/directory',defaultZoneId:zoneId,cloudflareApiToken:token})});const configured=await put.json();if(!put.ok||!configured.tokenConfigured)throw Error('Settings save failed');return{testStatus:test.status,testOk:true,saveStatus:put.status,tokenConfigured:configured.tokenConfigured,directoryUrl:configured.directoryUrl}}, {token:p.token,zoneId:p.zoneId});
|
||||
report.settings=settings;assert.equal(settings.testOk,true);
|
||||
await page.getByRole('button',{name:'Выпустить сертификат',exact:true}).click();const dialog=page.getByRole('dialog',{name:'Выпуск сертификата'});await dialog.getByRole('combobox',{name:'Сервер выпуска'}).selectOption(p.serverId);await dialog.getByRole('textbox',{name:'Имя сертификата',exact:true}).fill(p.certName);await dialog.getByRole('button',{name:'Далее',exact:true}).click();await dialog.getByRole('textbox',{name:'Основной домен',exact:true}).fill(p.fqdn);await dialog.getByRole('button',{name:'Далее',exact:true}).click();await dialog.getByRole('button',{name:'Далее',exact:true}).click();await expect(dialog).toContainText(p.fqdn);
|
||||
const response=page.waitForResponse(r=>new URL(r.url()).pathname==='/api/certificates/issue'&&r.request().method()==='POST');await dialog.getByRole('button',{name:'Выпустить',exact:true}).click();const accepted=await response;assert.equal(accepted.status(),200);assert.equal((await accepted.json()).jobId,p.jobId);report.accepted=true;fs.writeFileSync('docs/ux-ui/acceptance/acme250-live-ui.json',JSON.stringify(report,null,2)+'\n');console.log('One ACME issue accepted for '+p.fqdn);
|
||||
await expect.poll(()=>page.evaluate(async(jobId)=>{const r=await fetch('/api/certificates/issue/'+jobId,{headers:{Authorization:'Bearer '+localStorage.getItem('mmapp_token')}});if(!r.ok)return'unknown';return(await r.json()).status},p.jobId),{timeout:180000,intervals:[2000]}).toMatch(/^(done|failed)$/);
|
||||
report.job=await page.evaluate(async(jobId)=>{const r=await fetch('/api/certificates/issue/'+jobId,{headers:{Authorization:'Bearer '+localStorage.getItem('mmapp_token')}});const b=await r.json();return{id:b.id,status:b.status,step:b.step,error:b.error}},p.jobId);assert.equal(report.issuePosts,1);assert.equal(report.job.status,'done');await expect(dialog.getByRole('status')).toContainText('Завершена',{timeout:15000});assert.deepEqual(report.pageErrors,[]);report.uiTerminalConfirmed=true;
|
||||
fs.writeFileSync('docs/ux-ui/acceptance/acme250-live-ui.json',JSON.stringify(report,null,2)+'\n');console.log('ACME UI job done; one issue POST, JS errors0');
|
||||
}catch(e){report.failure={name:e.name};fs.writeFileSync('docs/ux-ui/acceptance/acme250-live-ui.json',JSON.stringify(report,null,2)+'\n');console.error('ACME test incomplete: '+e.name);process.exitCode=1}finally{await browser.close()}
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import tls from 'node:tls';
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const fingerprint = value => {
|
||||
assert.equal(typeof value, 'string', 'Fingerprint is required');
|
||||
const normalized = value.replaceAll(':', '').toLowerCase();
|
||||
assert(/^[a-f0-9]{64}$/.test(normalized), 'Expected SHA-256 fingerprint');
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export function compareRenewal(before, after) {
|
||||
for (const sample of [before, after]) {
|
||||
assert(/^mmt253-[a-f0-9]{8}\.pngs\.top$/.test(sample.fqdn), 'Only the prepared exact test domain is permitted');
|
||||
assert.equal(sample.remoteAddress, '104.165.244.7');
|
||||
assert.equal(sample.authorized, true);
|
||||
assert.equal(sample.hostnameMatched, true);
|
||||
assert.equal(sample.nativePrivateKey, true);
|
||||
assert.equal(sample.nativeName, sample.certName);
|
||||
assert.equal(sample.certName, sample.fqdn.replace('.pngs.top', ''));
|
||||
assert.equal(fingerprint(sample.nativeFingerprint256), fingerprint(sample.fingerprint256), 'Native leaf must match served TLS');
|
||||
assert(Number.isFinite(Date.parse(sample.validUntil)), 'Expiry is required');
|
||||
assert(Number.isFinite(Date.parse(sample.sampledAt)), 'Sample time is required');
|
||||
assert(Date.parse(sample.validUntil) > Date.parse(sample.sampledAt), 'Certificate is expired');
|
||||
}
|
||||
assert.equal(after.fqdn, before.fqdn);
|
||||
assert.equal(after.certName, before.certName);
|
||||
assert.notEqual(fingerprint(after.fingerprint256), fingerprint(before.fingerprint256), 'Old certificate is still being served');
|
||||
assert(Date.parse(after.sampledAt) >= Date.parse(before.sampledAt));
|
||||
assert(Date.parse(after.validUntil) > Date.parse(after.sampledAt), 'New certificate is expired');
|
||||
// A second issuance on the same day need not extend expiry by a full day.
|
||||
return { fingerprintChanged: true, nativeAndTlsMatch: true, trustedHostnameMatch: true };
|
||||
}
|
||||
|
||||
async function sample(fixture) {
|
||||
assert(/^mmt253-[a-f0-9]{8}\.pngs\.top$/.test(fixture.fqdn));
|
||||
assert.equal(fixture.certName, fixture.fqdn.replace('.pngs.top', ''));
|
||||
assert.equal(fixture.nativePrivateKey, true);
|
||||
fingerprint(fixture.nativeFingerprint256);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = tls.connect({ host: '104.165.244.7', port: 443, servername: fixture.fqdn, rejectUnauthorized: true });
|
||||
socket.setTimeout(15000, () => socket.destroy(new Error('TLS acceptance timeout')));
|
||||
socket.once('error', reject);
|
||||
socket.once('secureConnect', () => {
|
||||
try {
|
||||
assert.equal(socket.authorized, true);
|
||||
const cert = new X509Certificate(socket.getPeerCertificate().raw);
|
||||
assert(cert.checkHost(fixture.fqdn), 'Hostname mismatch');
|
||||
assert(Date.parse(cert.validTo) > Date.now(), 'Certificate is expired');
|
||||
assert.equal(fingerprint(cert.fingerprint256), fingerprint(fixture.nativeFingerprint256), 'Native leaf does not match TLS');
|
||||
resolve({ sampledAt: new Date().toISOString(), fqdn: fixture.fqdn, certName: fixture.certName,
|
||||
remoteAddress: socket.remoteAddress, authorized: socket.authorized, hostnameMatched: true,
|
||||
protocol: socket.getProtocol(), fingerprint256: cert.fingerprint256,
|
||||
validFrom: cert.validFrom, validUntil: cert.validTo,
|
||||
nativeName: fixture.certName, nativePrivateKey: true, nativeFingerprint256: fixture.nativeFingerprint256 });
|
||||
} catch (error) { reject(error); } finally { socket.end(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const [mode, input, output] = process.argv.slice(2);
|
||||
assert(input && output && ['sample', 'compare'].includes(mode), 'Usage: sample native-summary.json output.json | compare samples.json output.json');
|
||||
assert(!fs.existsSync(output), 'Never overwrite acceptance evidence');
|
||||
const data = JSON.parse(fs.readFileSync(input, 'utf8'));
|
||||
const result = mode === 'sample' ? await sample(data) : compareRenewal(data.before, data.after);
|
||||
fs.writeFileSync(output, JSON.stringify(result, null, 2) + '\n', { flag: 'wx' });
|
||||
console.log(mode === 'sample' ? 'Trusted TLS/native fingerprint sample saved' : 'Renewal fingerprint comparison passed');
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { compareRenewal } from './acme-renew-tls.mjs';
|
||||
const before = { fqdn: 'mmt253-a1b2c3d4.pngs.top', certName: 'mmt253-a1b2c3d4', nativeName: 'mmt253-a1b2c3d4',
|
||||
remoteAddress: '104.165.244.7', authorized: true, hostnameMatched: true, nativePrivateKey: true,
|
||||
fingerprint256: 'a'.repeat(64), nativeFingerprint256: 'a'.repeat(64), sampledAt: '2026-10-14T12:00:00Z', validUntil: '2027-01-12T12:00:00Z' };
|
||||
const after = { ...before, fingerprint256: 'b'.repeat(64), nativeFingerprint256: 'b'.repeat(64), sampledAt: '2026-10-14T12:01:00Z' };
|
||||
assert.equal(compareRenewal(before, after).fingerprintChanged, true);
|
||||
for (const patch of [{ fingerprint256: before.fingerprint256, nativeFingerprint256: before.nativeFingerprint256 },
|
||||
{ nativeFingerprint256: before.nativeFingerprint256 }, { authorized: false }, { hostnameMatched: false },
|
||||
{ nativePrivateKey: false }, { nativeName: 'another' }, { fqdn: 'other.pngs.top' },
|
||||
{ remoteAddress: '194.226.121.150' }, { validUntil: 'bad' }, { validUntil: '2026-10-13T12:00:00Z' },
|
||||
{ sampledAt: '2026-10-14T11:00:00Z' }]) assert.throws(() => compareRenewal(before, { ...after, ...patch }));
|
||||
console.log('Renewal acceptance comparison: changed leaf passes; 11 false-success cases rejected. No network or CA calls.');
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own211.invalid';process.env.AUTH_PORTAL_URL='https://own211.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const psk='own211-'+randomBytes(24).toString('base64url'),baseline=false,cases=[];let app,serverId,nativeWrites=0,nativeCalls=0
|
||||
const native=createServer((req,res)=>{nativeCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');res.end(JSON.stringify(req.url.endsWith('/interface/gre')?[{'.id':'*1',name:'own211-encrypted','local-address':'192.0.2.1','remote-address':'192.0.2.2','ipsec-secret':psk,mtu:'1476',keepalive:'10s,3',disabled:'false',running:'true','allow-fast-path':'false'},{'.id':'*2',name:'own211-plain','remote-address':'192.0.2.3','ipsec-secret':'',disabled:'false',running:'true'}]:[]))});await new Promise(r=>native.listen(0,'127.0.0.1',r))
|
||||
const report={date:'2026-10-13',block:'12.5.211',database,baseline,cases,scope:'Production compiled canonical auth/RBAC using own restricted PostgreSQL, local native HTTP fixture and synthetic JWT/PSK',passed:false}
|
||||
try{await initDatabase();serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own211-native','127.0.0.1',$1,false,true,'fixture','fixture') RETURNING id",[native.address().port])).rows[0].id);app=await buildApp({logger:false,startScheduler:false});await app.ready()
|
||||
for(const path of ['/api/servers','/api/%73ervers','/%61pi/servers','/%61%70%69/servers','/api/gre/tunnels','/%61pi/gre/tunnels'])for(const method of ['GET','HEAD']){const before=nativeCalls;const r=await app.inject({method,url:path});assert.equal(r.statusCode,401);assert.equal(nativeCalls,before);cases.push({path,method,role:'anonymous',status:401})}
|
||||
for(const path of ['/api/gre/tunnels','/api/%67re/tunnels','/%61pi/gre/tunnels'])for(const role of ['dashboard','reader','writer']){const permissions=role==='dashboard'?['mm:dashboard:read']:role==='reader'?['mm:network:read']:['mm:network:write'],token=app.jwt.sign({sub:'own211-'+role,apps:['mm'],permissions,iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});const r=await app.inject({method:'GET',url:path+'?serverId='+serverId,headers:{authorization:'Bearer '+token}});assert.equal(r.statusCode,role==='dashboard'?403:200);if(role!=='dashboard')assert.equal(r.json().tunnels.find(t=>t.name==='own211-encrypted').ipsec.secret,role==='writer'?psk:'');cases.push({path,method:'GET',role,status:r.statusCode,privateMaterialStored:false});if(role!=='writer'){const patch=await app.inject({method:'PATCH',url:path,headers:{authorization:'Bearer '+token},payload:{serverId:String(serverId),rosId:'*1',comment:'not applied'}});assert.equal(patch.statusCode,403);cases.push({path,method:'PATCH',role,status:403})}}
|
||||
for(const path of ['/api/auth/config','/%61pi/auth/config']){assert.equal((await app.inject({url:path})).statusCode,200);cases.push({path,role:'anonymous',status:200,publicConfig:true})}
|
||||
assert.equal(nativeWrites,0);report.nativeWrites=0;report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre211-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/auth211-production-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}
|
||||
if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,3 @@
|
||||
import assert from'node:assert/strict';import fs from'node:fs';import{createRequire}from'node:module';import{createServer}from'node:http';
|
||||
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
|
||||
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool,db}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{servers}=await import('/app/backend/dist/db/schema.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');let nativeRequests=0;const native=createServer((_req,res)=>{nativeRequests++;res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));const[server]=await db.insert(servers).values({name:'owned-availability237',host:'127.0.0.1',port:native.address().port,useSsl:false,username:'fixture',password:'fixture',enabled:true,type:'exit-node'}).returning();fs.mkdirSync('storage/backups',{recursive:true});fs.writeFileSync('storage/backups/available237.rsc','# harmless own fixture\n');fs.mkdirSync('storage/backups/directory237.rsc');const fixtures=[['available237','available237.rsc','local',null],['missing237','missing237.rsc','local',null],['both237','both237.rsc','both','own-test/both237.rsc'],['remote237','remote237.rsc','s3','own-test/remote237.rsc'],['directory237','directory237.rsc','local',null]];for(const[id,filename,storage,key]of fixtures)await pool.query('INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES($1,$2,$3,$4,1,$5,$6,$7,now())',[id,server.id,'fixture',filename,'manual',storage,key]);const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const report={date:'2026-10-14',block:'12.5.237',passed:false,checks:[],externalS3Contacts:0,chrContacts:0};try{let r=await app.inject({method:'GET',url:'/api/backups'});assert.equal(r.statusCode,200);const statuses=Object.fromEntries(r.json().map(r=>[r.id,r.localFileStatus]));assert.deepEqual(statuses,{available237:'available',missing237:'missing',both237:'missing',remote237:'not-stored',directory237:'unknown'});report.checks.push({listStates:statuses});r=await app.inject({method:'GET',url:'/api/backups/available237/download'});assert.equal(r.statusCode,200);assert.equal(r.body,'# harmless own fixture\n');report.checks.push({availableDownload:200});r=await app.inject({method:'GET',url:'/api/backups/missing237/download'});assert.equal(r.statusCode,404);report.checks.push({missingDownload:404});r=await app.inject({method:'POST',url:'/api/backups/missing237/restore'});assert.equal(r.statusCode,400);assert.equal(r.json().outcome,'not-applied');assert.match(r.json().error,/Локальный файл/);assert.equal(nativeRequests,0);report.checks.push({missingRestore:400,outcome:'not-applied',nativeRequests:0});r=await app.inject({method:'GET',url:'/api/backups'});assert.equal(r.json().length,5);report.checks.push({failurePreservesCatalog:true});fs.writeFileSync('storage/backups/missing237.rsc','# recreated fixture\n');r=await app.inject({method:'DELETE',url:'/api/backups/missing237?recordOnly=true'});assert.equal(r.statusCode,409);assert(fs.existsSync('storage/backups/missing237.rsc'));assert.equal((await pool.query("SELECT count(*)::int AS n FROM backup_entries WHERE id='missing237'")).rows[0].n,1);report.checks.push({recreatedFilePreserved:409});fs.unlinkSync('storage/backups/missing237.rsc');r=await app.inject({method:'DELETE',url:'/api/backups/missing237?recordOnly=true'});assert.equal(r.statusCode,204);assert.equal(r.headers['x-backup-delete-mode'],'record-only');report.checks.push({missingRecordDeletion:204,mode:'record-only'});report.passed=true}catch(e){report.failure={name:e.name}}finally{await databaseBackgroundWork.drain();await app.close();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/checks/backup-availability237.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
|
||||
@@ -0,0 +1,3 @@
|
||||
import assert from 'node:assert/strict';import fs from 'node:fs';import{randomUUID}from'node:crypto';import{createServer}from'node:http';import{createRequire}from'node:module';
|
||||
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
|
||||
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool,db}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{servers}=await import('/app/backend/dist/db/schema.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');let nativeRequests=0;const native=createServer((_req,res)=>{nativeRequests++;res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));const[server]=await db.insert(servers).values({name:'owned-legacy234',host:'127.0.0.1',port:native.address().port,useSsl:false,username:'fixture',password:'fixture',enabled:true,type:'exit-node'}).returning();const filename='legacy234-'+randomUUID()+'.rsc',file='storage/backups/'+filename;fs.mkdirSync('storage/backups',{recursive:true});await pool.query("INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,created_at) VALUES('owned-legacy234',$1,'fixture',$2,1,'manual','local',now())",[server.id,filename]);const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const report={date:'2026-10-14',block:'12.5.234',checks:[],passed:false,chrContacts:0,externalS3Contacts:0};try{for(const prefix of['','\uFEFF\r\n ']){const content=prefix+'# synthetic export generated by MikrotikManager\n/system identity\nset name=legacy-fixture\n';fs.writeFileSync(file,content);const download=await app.inject({method:'GET',url:'/api/backups/owned-legacy234/download'});assert.equal(download.statusCode,200);assert.equal(download.body,content);const restore=await app.inject({method:'POST',url:'/api/backups/owned-legacy234/restore'});assert.equal(restore.statusCode,400);assert.equal(restore.json().outcome,'not-applied');assert.match(restore.json().error,/частичным экспортом/);assert.equal(fs.readFileSync(file,'utf8'),content);assert.equal(nativeRequests,0);assert.equal((await pool.query("SELECT count(*)::int AS n FROM backup_entries WHERE id='owned-legacy234'")).rows[0].n,1);report.checks.push({bomAndWhitespace:Boolean(prefix),downloadStatus:200,restoreStatus:400,outcome:'not-applied',fileAndCatalogPreserved:true,nativeRequests:0})}report.passed=true}catch(e){report.failure={name:e.name}}finally{await databaseBackgroundWork.drain();const deleted=await app.inject({method:'DELETE',url:'/api/backups/owned-legacy234'});assert.equal(deleted.statusCode,204);assert(!fs.existsSync(file));assert.equal((await pool.query('SELECT count(*)::int AS n FROM backup_entries')).rows[0].n,0);report.cleanup={catalogRows:0,localFiles:0};await app.close();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/checks/legacy-restore234.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
|
||||
@@ -0,0 +1,5 @@
|
||||
import assert from 'node:assert/strict';import fs from 'node:fs';import{createRequire}from'node:module';
|
||||
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
|
||||
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');const baseline=process.env.MMAPP_ORIGIN_BASELINE==='true',report={date:'2026-10-14',block:'12.5.233',baseline,checks:[],externalS3Contacts:0,chrContacts:0,passed:false};const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const config={provider:'s3',s3Endpoint:'https://s3.invalid',s3Region:'test-region',s3Bucket:'origin-bucket',s3Prefix:'owned-test',s3AccessKeyId:'fixture',s3SecretAccessKey:'fixture',s3ForcePathStyle:false,keepLocalCopy:true};async function api(method,path,payload,status=200){const r=await app.inject({method,url:'/api'+path,...(payload?{payload}:{})});assert.equal(r.statusCode,status,method+' '+path);return r}
|
||||
async function insert(){await pool.query("INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES('owned-origin',NULL,'fixture','fixture.rsc',1,'manual','s3','owned-test/fixture.rsc',now())")}
|
||||
try{await api('PUT','/backups/storage',config);await insert();for(const patch of[{s3Bucket:'other-bucket'},{s3Endpoint:'https://other.invalid'},{s3Region:'other'},{s3ForcePathStyle:true},{provider:'local'}]){const before=(await api('GET','/backups/storage')).json();await api('PUT','/backups/storage',patch,baseline?200:409);const after=(await api('GET','/backups/storage')).json();if(!baseline)assert.deepEqual(after,before);else await api('PUT','/backups/storage',config);report.checks.push({change:Object.keys(patch)[0],status:baseline?200:409,settingsUnchanged:!baseline})}for(const patch of[{s3Prefix:'new-prefix'},{s3SecretAccessKey:'rotated-fixture'},{keepLocalCopy:false}]){await api('PUT','/backups/storage',patch);report.checks.push({allowed:Object.keys(patch)[0],status:200})}await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'");await api('PUT','/backups/storage',{s3Bucket:'empty-catalog-bucket'});report.checks.push({emptyCatalogChangeAllowed:true});if(!baseline){const{withBackupStorageOperation}=await import('/app/backend/dist/services/backup-storage-operation.js');let release,started;const active=new Promise(r=>started=r),wait=new Promise(r=>release=r);const creator=withBackupStorageOperation(async()=>{started();await wait;await insert()});await active;let settled=false;const update=api('PUT','/backups/storage',{s3Bucket:'racing-bucket'},409).then(()=>settled=true);await new Promise(r=>setTimeout(r,20));await update;assert.equal(settled,true);release();await creator;assert.equal((await api('GET','/backups/storage')).json().s3Bucket,'empty-catalog-bucket');report.checks.push({inFlightUpdateRejectedBeforeCatalogPublication:true});const{readBackupContent,getBackupById}=await import('/app/backend/dist/services/backup-service.js');const stale=await getBackupById('owned-origin');let releaseDelete;const deletion=withBackupStorageOperation(async()=>{await new Promise(r=>releaseDelete=r);await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'")});const download=readBackupContent(stale);const rejected=assert.rejects(download,/Бэкап не найден/);await Promise.resolve();releaseDelete();await deletion;await rejected;report.checks.push({staleDownloadRejectsBeforeS3AfterDeletion:true})}report.passed=true}catch(e){report.failure={name:e.name}}finally{await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'");await databaseBackgroundWork.drain();await app.close();await closePool();fs.writeFileSync('/checks/storage-origin233.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own216.invalid';process.env.AUTH_PORTAL_URL='https://own216.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js'),{fetchBgpSessionsForAlerts}=await import('/app/backend/dist/services/bgp-peers-live.js'),{collectGreBgpSnapshotOnce}=await import('/app/backend/dist/services/gre-bgp-snapshot-collector.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
let phase='populated',app,nativeCalls=0,nativeWrites=0,disabledCalls=0;const cases=[]
|
||||
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');if(phase==='failed'||(phase==='partial'&&label==='second')){res.statusCode=401;res.end(JSON.stringify({error:401,message:'own216 fixture denied'}));return}const rows=phase==='populated'?[{'.id':'*1',name:'own216-peer',state:'established'}]:[];res.end(JSON.stringify(rows))})
|
||||
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
|
||||
const baseline=process.env.MMAPP_BASELINE==='true';const report={date:'2026-10-14',block:'12.5.216',baseline,database,cases,scope:'Compiled production routes/auth, isolated PostgreSQL and RouterOS HTTP fixture; real CHR unchanged',passed:false}
|
||||
try{
|
||||
await initDatabase();const ids=[];for(let i=0;i<natives.length;i++)ids.push(Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4) RETURNING id",['own216-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])).rows[0].id))
|
||||
app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own216-reader',apps:['mm'],permissions:['mm:bgp:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token}
|
||||
const wrongToken=app.jwt.sign({sub:'own216-other-reader',apps:['mm'],permissions:['mm:network:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});for(const [name,h,status] of [['anonymous',{},401],['wrong permission',{authorization:'Bearer '+wrongToken},403]]){const before=nativeCalls,r=await app.inject({method:'GET',url:'/api/bgp/sessions',headers:h});assert.equal(r.statusCode,status);assert.equal(nativeCalls,before);cases.push({phase:name,endpoint:'bgp/sessions',status,nativeCalls:0})}
|
||||
for(phase of ['populated','empty','partial','failed','disabled']){
|
||||
if(phase==='disabled')await pool.query('UPDATE servers SET enabled=false')
|
||||
const hasFailure=['partial','failed'].includes(phase),expected=phase==='partial'?[ids[1]]:phase==='failed'?ids.slice(0,2):[]
|
||||
for(const endpoint of ['bgp/sessions','bgp/catalog']){
|
||||
const before=nativeCalls,r=await app.inject({method:'GET',url:'/api/'+endpoint,headers}),body=r.json(),isCatalog=endpoint==='bgp/catalog'
|
||||
assert.equal(r.statusCode,!baseline&&hasFailure&&!isCatalog?503:200)
|
||||
if(isCatalog||(!baseline&&hasFailure)){assert.deepEqual(body.failures.map(f=>f.serverId),expected);if(isCatalog)assert.equal(body.sessions.length,phase==='populated'?2:0)}else{assert(Array.isArray(body));assert.equal(body.length,phase==='populated'?2:0)}
|
||||
if(phase==='disabled')assert.equal(nativeCalls,before)
|
||||
cases.push({phase,endpoint,status:r.statusCode,failures:body.failures?.length??0,nativeCalls:nativeCalls-before})
|
||||
}
|
||||
const snapshot=await collectGreBgpSnapshotOnce({fetchGre:async()=>[{targetLabel:'own216-GRE-control',status:'up'}],fetchBgp:fetchBgpSessionsForAlerts})
|
||||
assert.equal(snapshot.greWritten,1);assert.equal(snapshot.bgpWritten,phase==='populated'?2:0);assert.equal(snapshot.errors.length,!baseline&&hasFailure?1:0)
|
||||
if(snapshot.errors.length)assert.match(snapshot.errors[0],/BGP: Не опрошены устройства BGP/)
|
||||
cases.push({phase,endpoint:'compiled snapshot collector',bgpWritten:snapshot.bgpWritten,greWritten:1,sourceErrors:snapshot.errors.length})
|
||||
}
|
||||
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/bgp216-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/bgp216-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Separate output: never replace an in-flight or accepted poll-memory tree. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdir, lstat } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
assert(process.argv.length===2||process.argv.length===3,'Supply at most one new output name')
|
||||
const outputName=process.argv[2]??'dist-installation-memory'
|
||||
assert(/^dist-installation-memory(?:-[a-z0-9-]{3,64})?$/.test(outputName),'Invalid compiled output name')
|
||||
const backend=resolve(dirname(fileURLToPath(import.meta.url)),'../..'), output=resolve(backend,outputName)
|
||||
const existing=await lstat(output).catch(error=>{if(error.code!=='ENOENT')throw error})
|
||||
assert(!existing,'Use a new output directory; no implicit cleanup or concurrent rebuild')
|
||||
await mkdir(output)
|
||||
const configPath=resolve(backend,'tsconfig.json'),config=ts.readConfigFile(configPath,ts.sys.readFile)
|
||||
assert(!config.error)
|
||||
const parsed=ts.parseJsonConfigFileContent(config.config,ts.sys,backend,undefined,configPath)
|
||||
const options={...parsed.options,rootDir:resolve(backend,'src'),outDir:output,noEmit:false,noEmitOnError:true,declaration:false,declarationMap:false,sourceMap:false,incremental:false,composite:false,tsBuildInfoFile:undefined}
|
||||
const roots=['src/services/installation-co-load.owned.test.ts','src/services/traffic-flow-collector-worker.ts'].map(file=>resolve(backend,file))
|
||||
const program=ts.createProgram({rootNames:roots,options}),diagnostics=ts.getPreEmitDiagnostics(program)
|
||||
if(diagnostics.length)throw Error(ts.formatDiagnosticsWithColorAndContext(diagnostics,{getCurrentDirectory:()=>backend,getCanonicalFileName:file=>file,getNewLine:()=> '\n'}))
|
||||
assert(!program.emit().emitSkipped)
|
||||
console.log(JSON.stringify({output,rootEntries:roots.length,actualWorkerIncluded:true,productionModulesSubstituted:false}))
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Emit one owned memory entry plus its actual production import tree. The
|
||||
* measured child runs plain node, not the TypeScript compiler or tsx. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { lstat, rm } from 'node:fs/promises'
|
||||
import { dirname, basename, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
|
||||
const backend = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const output = resolve(backend, 'dist-mcp-memory')
|
||||
assert.equal(dirname(output), backend)
|
||||
assert.equal(basename(output), 'dist-mcp-memory')
|
||||
const existing = await lstat(output).catch(error => { if (error.code !== 'ENOENT') throw error })
|
||||
assert(!existing?.isSymbolicLink(), 'Refuse to clean a redirected output directory')
|
||||
if (existing) await rm(output, { recursive: true })
|
||||
const configPath = resolve(backend, 'tsconfig.json')
|
||||
const config = ts.readConfigFile(configPath, ts.sys.readFile)
|
||||
const format = diagnostics => ts.formatDiagnosticsWithColorAndContext(diagnostics, {
|
||||
getCurrentDirectory: () => backend, getCanonicalFileName: name => name, getNewLine: () => '\n',
|
||||
})
|
||||
if (config.error) throw Error(format([config.error]))
|
||||
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, backend, undefined, configPath)
|
||||
if (parsed.errors.length) throw Error(format(parsed.errors))
|
||||
const entry = resolve(backend, 'src/services/server-poll-memory.owned.test.ts')
|
||||
const options = { ...parsed.options, rootDir: resolve(backend, 'src'), outDir: output,
|
||||
noEmit: false, noEmitOnError: true, declaration: false, declarationMap: false, sourceMap: false,
|
||||
incremental: false, composite: false, tsBuildInfoFile: undefined }
|
||||
// Deliberately override tsconfig include/exclude with this single test root.
|
||||
// TypeScript still follows all normal imports; no module/path/IO substitutes.
|
||||
const program = ts.createProgram({ rootNames: [entry], options })
|
||||
const diagnostics = ts.getPreEmitDiagnostics(program)
|
||||
if (diagnostics.length) throw Error(format(diagnostics))
|
||||
const emitted = program.emit()
|
||||
if (emitted.emitSkipped || emitted.diagnostics.length) throw Error(format(emitted.diagnostics) || 'Memory entry emit skipped')
|
||||
console.log(JSON.stringify({ compiledPollMemoryEntry: resolve(output, 'services/server-poll-memory.owned.test.js'),
|
||||
outputDirectory: output, rootEntries: 1, noEmitOnError: true, measuredChildUsesTsx: false }))
|
||||
@@ -0,0 +1,169 @@
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, realpath, lstat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
export const SOURCES = Object.freeze({
|
||||
'194.226.121.150': Object.freeze({ serverId: 1, port: 19081 }),
|
||||
'104.165.244.7': Object.freeze({ serverId: 2, port: 19082 }),
|
||||
});
|
||||
export const PATHS = Object.freeze([
|
||||
'/system/resource', '/routing/route', '/routing/nexthop', '/interface',
|
||||
'/routing/table', '/ip/vrf', '/routing/nexthop', '/routing/route', '/system/resource',
|
||||
]);
|
||||
const LIMITS = Object.freeze({ response: 1048576, bytes: 8388608, requests: 256, rows: 1000, totalRows: 10000, duration: 120000 });
|
||||
const sha = value => createHash('sha256').update(value).digest('hex');
|
||||
const deny = code => { throw new Error(code); };
|
||||
|
||||
export function configuration(args) {
|
||||
const options = {};
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
if (!['--source', '--transport', '--output', '--owned-tunnel-attestation'].includes(args[i]) || !args[i + 1] || options[args[i]] !== undefined) deny('invalid-options');
|
||||
options[args[i]] = args[i + 1];
|
||||
}
|
||||
const source = options['--source'], transport = options['--transport'];
|
||||
if (!Object.hasOwn(SOURCES, source) || !['https', 'ssh-loopback'].includes(transport) || !options['--output']) deny('invalid-source-or-transport');
|
||||
const attestation = options['--owned-tunnel-attestation'];
|
||||
if (transport === 'ssh-loopback' && attestation !== 'verified-known-host-161.104.46.222-owned-forward') deny('owned-tunnel-attestation-required');
|
||||
if (transport === 'https' && attestation !== undefined) deny('irrelevant-tunnel-attestation');
|
||||
return { source, serverId: SOURCES[source].serverId, transport, output: options['--output'],
|
||||
endpoint: transport === 'https' ? `https://${source}:443` : `http://127.0.0.1:${SOURCES[source].port}`,
|
||||
tunnelAttestation: attestation ?? null };
|
||||
}
|
||||
|
||||
export async function privateDirectory(output, root = ROOT) {
|
||||
// Reject symlinks/junctions at every existing component, including tmp itself.
|
||||
const base = path.resolve(root, 'tmp'), target = path.resolve(root, output);
|
||||
const relative = path.relative(base, target);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) deny('output-must-be-inside-tmp');
|
||||
await mkdir(base, { recursive: true, mode: 0o700 });
|
||||
if ((await lstat(base)).isSymbolicLink() || await realpath(base) !== base) deny('output-symlink-refused');
|
||||
let current = base;
|
||||
for (const part of relative.split(path.sep)) {
|
||||
current = path.join(current, part);
|
||||
try { await mkdir(current, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
||||
const stat = await lstat(current);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(current) !== current) deny('output-symlink-refused');
|
||||
}
|
||||
// Exclusive claim prevents mixing runs and refuses existing captures.
|
||||
await writeFile(path.join(target, 'capture.claim'), 'chr-recursive-observation-v1\n', { flag: 'wx', mode: 0o600 });
|
||||
return target;
|
||||
}
|
||||
|
||||
export function decodeBody(body) {
|
||||
let text, payload;
|
||||
try { text = new TextDecoder('utf-8', { fatal: true }).decode(body); payload = JSON.parse(text); } catch { deny('invalid-utf8-or-json'); }
|
||||
const rows = Array.isArray(payload) ? payload : payload && typeof payload === 'object' ? [payload] : deny('invalid-row-shape');
|
||||
if (rows.length > LIMITS.rows || rows.some(row => !row || typeof row !== 'object' || Array.isArray(row))) deny('row-limit-or-shape');
|
||||
return { rows };
|
||||
}
|
||||
|
||||
export function readResponse(endpoint, resource, authorization, budget) {
|
||||
const allowed = Object.entries(SOURCES).flatMap(([host, item]) => [`https://${host}:443`, `http://127.0.0.1:${item.port}`]);
|
||||
if (!allowed.includes(endpoint)) deny('non-whitelisted-endpoint');
|
||||
if (!PATHS.includes(resource) || budget.bytes >= LIMITS.bytes || budget.rows >= LIMITS.totalRows || ++budget.requests > LIMITS.requests || Date.now() >= budget.deadline) deny('request-budget');
|
||||
const url = new URL('/rest' + resource, endpoint);
|
||||
return new Promise((resolve, reject) => {
|
||||
const fail = (code, status) => reject(Object.assign(new Error(code), Number.isInteger(status) ? { httpStatus: status } : {}));
|
||||
const req = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, {
|
||||
method: 'GET', agent: false, rejectUnauthorized: true, maxHeaderSize: 16384,
|
||||
headers: { Authorization: authorization, Accept: 'application/json', 'Accept-Encoding': 'identity' },
|
||||
}, res => {
|
||||
const chunks = []; let size = 0;
|
||||
res.on('data', chunk => {
|
||||
size += chunk.length; budget.bytes += chunk.length;
|
||||
if (size > LIMITS.response || budget.bytes > LIMITS.bytes) { req.destroy(); fail('byte-limit'); return; }
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('aborted', () => fail('response-aborted'));
|
||||
res.on('error', () => fail('response-failed'));
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
// Raw failed HTTP responses are excluded: error bodies can reflect secrets.
|
||||
if (res.statusCode !== 200) return fail('http-status-refused', res.statusCode);
|
||||
const type = res.headers['content-type'];
|
||||
if (typeof type !== 'string' || !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(type) || res.headers['content-encoding'] && res.headers['content-encoding'] !== 'identity') return fail('response-encoding-refused');
|
||||
try {
|
||||
const { rows } = decodeBody(body);
|
||||
budget.rows += rows.length;
|
||||
if (budget.rows > LIMITS.totalRows || Date.now() >= budget.deadline) return fail('response-budget');
|
||||
resolve({ body, rows, status: res.statusCode, contentType: type });
|
||||
} catch { fail('invalid-response'); }
|
||||
});
|
||||
});
|
||||
const timer = setTimeout(() => { req.destroy(); fail('absolute-deadline'); }, Math.max(1, budget.deadline - Date.now()));
|
||||
req.on('error', () => fail('transport-failed'));
|
||||
req.on('close', () => clearTimeout(timer));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function acquire(config, credentials) {
|
||||
if (typeof credentials?.username !== 'string' || !credentials.username || credentials.username.length > 256 || /[:\r\n]/.test(credentials.username) || typeof credentials.password !== 'string' || !credentials.password || credentials.password.length > 4096 || /[\r\n]/.test(credentials.password)) deny('invalid-private-credentials');
|
||||
// Validate caller configuration again; exported API cannot supply a custom host.
|
||||
const fixed = configuration(['--source', config.source, '--transport', config.transport, '--output', config.output,
|
||||
...(config.tunnelAttestation ? ['--owned-tunnel-attestation', config.tunnelAttestation] : [])]);
|
||||
const directory = await privateDirectory(fixed.output);
|
||||
const budget = { requests: 0, bytes: 0, rows: 0, deadline: Date.now() + LIMITS.duration };
|
||||
const authorization = 'Basic ' + Buffer.from(credentials.username + ':' + credentials.password).toString('base64');
|
||||
const report = { format: 'chr-recursive-raw-acquisition-v1', source: fixed.source, serverId: fixed.serverId,
|
||||
transport: fixed.transport, tunnelAttestation: fixed.tunnelAttestation, startedAt: new Date().toISOString(),
|
||||
semanticProof: false, complete: false, records: [], limits: LIMITS };
|
||||
let version, attemptedResource;
|
||||
try {
|
||||
for (const [index, resource] of PATHS.entries()) {
|
||||
attemptedResource = resource;
|
||||
const startedAt = new Date().toISOString();
|
||||
const response = await readResponse(fixed.endpoint, resource, authorization, budget);
|
||||
// Never persist a body reflecting the private credential or Basic value.
|
||||
if ([credentials.password, authorization].some(secret => response.body.includes(Buffer.from(secret)))) deny('credential-reflection-refused');
|
||||
if (resource === '/system/resource') {
|
||||
const current = response.rows.length === 1 && response.rows[0].version;
|
||||
if (typeof current !== 'string' || !/^7\.2[0-6](?:\.\d{1,3})?(?: \((?:stable|long-term)\))?$/.test(current) || version && version !== current) deny('unsupported-or-drifting-version');
|
||||
version = current;
|
||||
}
|
||||
const file = `${String(index + 1).padStart(2, '0')}-${resource.slice(1).replaceAll('/', '-')}.json`;
|
||||
await writeFile(path.join(directory, file), response.body, { flag: 'wx', mode: 0o600 });
|
||||
report.records.push({ file, resource, startedAt, finishedAt: new Date().toISOString(), version,
|
||||
status: response.status, contentType: response.contentType, bytes: response.body.length, rows: response.rows.length, rawSha256: sha(response.body) });
|
||||
}
|
||||
report.complete = true;
|
||||
} catch (error) {
|
||||
report.failure = 'acquisition-incomplete';
|
||||
report.failedResource = attemptedResource;
|
||||
if (Number.isInteger(error.httpStatus) && error.httpStatus >= 100 && error.httpStatus <= 599) report.failedHttpStatus = error.httpStatus;
|
||||
}
|
||||
report.finishedAt = new Date().toISOString();
|
||||
report.usage = { requests: budget.requests, bytes: budget.bytes, rows: budget.rows };
|
||||
await writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
||||
return report;
|
||||
}
|
||||
|
||||
async function privateStdin() {
|
||||
// Piped stdin is private (no terminal echo). TTY raw mode hides all input.
|
||||
const input = process.stdin, chunks = []; let size = 0;
|
||||
if (input.isTTY) { process.stderr.write('Private credentials JSON (hidden), then Enter: '); input.setRawMode(true); }
|
||||
try {
|
||||
for await (const chunk of input) {
|
||||
size += chunk.length;
|
||||
if (size > 8192) deny('private-input-limit');
|
||||
if (input.isTTY && chunk.includes(3)) deny('private-input-cancelled');
|
||||
chunks.push(chunk);
|
||||
if (input.isTTY && (chunk.includes(13) || chunk.includes(10))) break;
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8').trim());
|
||||
} finally { if (input.isTTY) { input.setRawMode(false); process.stderr.write('\n'); } }
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
const config = configuration(process.argv.slice(2));
|
||||
const report = await acquire(config, await privateStdin());
|
||||
// Stdout contains metadata only; no native bodies, credentials or errors.
|
||||
process.stdout.write(JSON.stringify({ complete: report.complete, semanticProof: false, source: report.source, records: report.records.length }) + '\n');
|
||||
if (!report.complete) process.exitCode = 1;
|
||||
} catch { process.stderr.write('Read-only acquisition refused or incomplete.\n'); process.exitCode = 1; }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { createServer } from 'node:http';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { configuration, decodeBody, readResponse, acquire, privateDirectory, PATHS } from './chr-recursive-observation.mjs';
|
||||
|
||||
test('source/transport allowlist refuses arbitrary hosts, plaintext external, missing tunnel attestation and option injection', () => {
|
||||
const args = ['--source', '194.226.121.150', '--transport', 'https', '--output', 'tmp/acquisition-test'];
|
||||
assert.equal(configuration(args).endpoint, 'https://194.226.121.150:443');
|
||||
for (const changes of [args.map(x => x === '194.226.121.150' ? 'example.org' : x), args.map(x => x === 'https' ? 'http' : x),
|
||||
args.map(x => x === 'https' ? 'ssh-loopback' : x), [...args, '--password', 'forbidden'], [...args, '--source', '104.165.244.7']]) assert.throws(() => configuration(changes));
|
||||
const tunnel = configuration([...args.map(x => x === 'https' ? 'ssh-loopback' : x), '--owned-tunnel-attestation', 'verified-known-host-161.104.46.222-owned-forward']);
|
||||
assert.equal(tunnel.endpoint, 'http://127.0.0.1:19081');
|
||||
});
|
||||
|
||||
test('full own fixture saves nine exact immutable captures and metadata; reflected errors never enter reports', async () => {
|
||||
const output = fileURLToPath(new URL('../../../tmp/chr-recursive-own-fixture-' + randomUUID(), import.meta.url)), seen = [];
|
||||
let reflect = false;
|
||||
const server = createServer((req, res) => {
|
||||
seen.push(req.url); res.setHeader('Content-Type', 'application/json');
|
||||
if (reflect) { res.statusCode = 401; res.end('{"message":"own-fixture-secret-only"}'); return; }
|
||||
res.end(req.url === '/rest/system/resource' ? '[{"version":"7.26"}]\n' : '[{".id":"*1"}]\n');
|
||||
});
|
||||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(19081, '127.0.0.1', resolve); });
|
||||
const makeConfig = destination => configuration(['--source', '194.226.121.150', '--transport', 'ssh-loopback', '--output', destination,
|
||||
'--owned-tunnel-attestation', 'verified-known-host-161.104.46.222-owned-forward']);
|
||||
try {
|
||||
const report = await acquire(makeConfig(output), { username: 'own-fixture', password: 'own-fixture-secret-only' });
|
||||
assert.equal(report.complete, true); assert.equal(report.semanticProof, false);
|
||||
assert.deepEqual(seen, PATHS.map(resource => '/rest' + resource));
|
||||
for (const record of report.records) {
|
||||
const body = await readFile(output + '/' + record.file);
|
||||
assert.equal(createHash('sha256').update(body).digest('hex'), record.rawSha256);
|
||||
assert.equal(body.length, record.bytes); assert.equal(record.version, '7.26');
|
||||
}
|
||||
await assert.rejects(acquire(makeConfig(output), { username: 'own-fixture', password: 'own-fixture-secret-only' }));
|
||||
assert.equal(seen.length, 9);
|
||||
await assert.rejects(privateDirectory('../outside-tmp'));
|
||||
reflect = true;
|
||||
const failedOutput = output + '-failed';
|
||||
const failed = await acquire(makeConfig(failedOutput), { username: 'own-fixture', password: 'own-fixture-secret-only' });
|
||||
assert.equal(failed.complete, false); assert.equal(failed.records.length, 0);
|
||||
assert.equal(failed.failedResource, '/system/resource'); assert.equal(failed.failedHttpStatus, 401);
|
||||
assert.deepEqual((await readdir(failedOutput)).sort(), ['capture.claim', 'report.json']);
|
||||
assert(!String(await readFile(failedOutput + '/report.json')).includes('own-fixture-secret-only'));
|
||||
} finally { await new Promise(resolve => server.close(resolve)); }
|
||||
});
|
||||
|
||||
test('exact UTF8 and row bounds fail closed', () => {
|
||||
assert.equal(decodeBody(Buffer.from('[{"version":"7.26"}]')).rows.length, 1);
|
||||
for (const body of [Buffer.from([0xff]), Buffer.from('null'), Buffer.from('[[]]'), Buffer.from(JSON.stringify(Array.from({ length: 1001 }, () => ({}))))]) assert.throws(() => decodeBody(body));
|
||||
});
|
||||
|
||||
test('own loopback fixture observes exact GET paths, refuses redirect/error/encoding/bytes, no retry or follow', async () => {
|
||||
let mode = 'normal', requests = 0;
|
||||
const server = createServer((req, res) => {
|
||||
requests++;
|
||||
assert.equal(req.method, 'GET'); assert.equal(req.url, '/rest/system/resource');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (mode === 'redirect') { res.statusCode = 302; res.setHeader('Location', 'http://example.invalid'); res.end('[]'); }
|
||||
else if (mode === 'error') { res.statusCode = 401; res.end('{"error":"reflected-private-input"}'); }
|
||||
else if (mode === 'encoding') { res.setHeader('Content-Encoding', 'gzip'); res.end('[]'); }
|
||||
else if (mode === 'oversized') res.end(' '.repeat(1048577));
|
||||
else res.end('[{"version":"7.26"}]\n');
|
||||
});
|
||||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(19081, '127.0.0.1', resolve); });
|
||||
const budget = () => ({ requests: 0, bytes: 0, rows: 0, deadline: Date.now() + 5000 });
|
||||
try {
|
||||
const first = await readResponse('http://127.0.0.1:19081', PATHS[0], 'Basic own-fixture', budget());
|
||||
assert.equal(first.body.toString(), '[{"version":"7.26"}]\n');
|
||||
for (mode of ['redirect', 'error', 'encoding', 'oversized']) await assert.rejects(readResponse('http://127.0.0.1:19081', PATHS[0], 'Basic own-fixture', budget()));
|
||||
assert.equal(requests, 5);
|
||||
assert.throws(() => readResponse('http://194.226.121.150', PATHS[0], '', budget()));
|
||||
assert.throws(() => readResponse('http://127.0.0.1:19081', '/system/reboot', '', budget()));
|
||||
assert.throws(() => readResponse('http://127.0.0.1:19081', PATHS[0], '', { ...budget(), requests: 256 }));
|
||||
assert.throws(() => readResponse('http://127.0.0.1:19081', PATHS[0], '', { ...budget(), deadline: 0 }));
|
||||
assert.equal(requests, 5);
|
||||
} finally { await new Promise(resolve => server.close(resolve)); }
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import assert from 'node:assert/strict';import fs from 'node:fs';import path from 'node:path';import{pathToFileURL}from'node:url';import{createHash}from'node:crypto';
|
||||
const vantage=process.env.MMAPP_TEST_VANTAGE;assert(['workstation','debian'].includes(vantage));const output=process.env.MMAPP_TEST_REPORT;assert(output);const hosts=['194.226.121.150','104.165.244.7'],root=path.resolve('backend/dist'),{MikrotikClient}=await import(pathToFileURL(root+'/services/mikrotik.js'));let rows;
|
||||
if(vantage==='debian'){const{pool}=await import(pathToFileURL(root+'/db/index.js'));try{rows=(await pool.query("SELECT host,port,use_ssl,enabled,username,password FROM servers WHERE host IN ('194.226.121.150','104.165.244.7') ORDER BY host")).rows}finally{await pool.end()}}else{assert(process.env.MMAPP_TEST_CHR_USER&&process.env.MMAPP_TEST_CHR_PASSWORD);rows=hosts.map(host=>({host,port:80,use_ssl:false,enabled:true,username:process.env.MMAPP_TEST_CHR_USER,password:process.env.MMAPP_TEST_CHR_PASSWORD}))}assert.equal(rows.length,2);assert.deepEqual(rows.map(r=>r.host).sort(),[...hosts].sort());assert(rows.every(r=>r.enabled&&r.port===80&&!r.use_ssl));
|
||||
const cycles=Number(process.env.MMAPP_REST_CYCLES??8),periodMs=Number(process.env.MMAPP_REST_PERIOD_MS??12000),maxMs=Number(process.env.MMAPP_REST_MAX_MS??960000);assert(Number.isInteger(cycles)&&cycles>=1&&cycles<=32);assert(Number.isInteger(periodMs)&&periodMs>=1000&&periodMs<=60000);assert(Number.isInteger(maxMs)&&maxMs>=10000&&maxMs<=960000);const deadline=Date.now()+maxMs;const report={date:'2026-10-14',block:process.env.MMAPP_REST_BLOCK??'12.5.236',vantage,cycles,periodMs,maxMs,credentialSource:vantage==='debian'?'stored-enabled-server-rows':'explicit-owned-test-env',startedAt:new Date().toISOString(),requests:[],diagnostics:[],configurationWrites:0,passwordChanges:0,rootCauseKnown:false};const sleep=ms=>new Promise(r=>setTimeout(r,ms));const clients=new Map(rows.map(r=>[r.host,new MikrotikClient({host:r.host,port:80,useSsl:false,verifySsl:false,username:r.username,password:r.password})]));
|
||||
function errorMetadata(e,body){let obj;try{obj=JSON.parse(body)}catch{}const detail=String(obj?.detail??'');return{errorName:e?.name??null,errorBodySha256:body?createHash('sha256').update(body).digest('hex'):null,errorKeys:obj&&typeof obj==='object'?Object.keys(obj).sort():[],detailCategory:/not enough permissions/i.test(detail)?'insufficient-policy':/not allowed/i.test(detail)?'not-allowed':/session closed/i.test(detail)?'session-closed':detail?'unclassified':'absent'}}
|
||||
async function read(row,endpoint,mode,cycle,kind='sample'){const begin=Date.now(),record={host:row.host,path:endpoint,mode,cycle,kind,startedAt:new Date().toISOString()};let data;try{if(mode==='compiled-client'){data=await clients.get(row.host).get(endpoint,6000);record.status=200}else{const authorization='Basic '+Buffer.from(row.username+':'+row.password).toString('base64');const r=await fetch('http://'+row.host+'/rest'+endpoint,{headers:{authorization,Connection:'close'},signal:AbortSignal.timeout(6000)}),body=await r.text();record.status=r.status;record.basicChallenge=/Basic/i.test(r.headers.get('www-authenticate')??'');if(r.ok)data=JSON.parse(body);else Object.assign(record,errorMetadata(null,body))}}catch(e){record.status=e.statusCode??null;Object.assign(record,errorMetadata(e,e.body??''))}record.durationMs=Date.now()-begin;report.requests.push(record);await sleep(125);return data}
|
||||
async function diagnostic(row,phase){const userRows=await read(row,'/user','compiled-client',0,'diagnostic'),users=Array.isArray(userRows)?userRows.filter(u=>u.name===row.username):[];const groups=await read(row,'/user/group','compiled-client',0,'diagnostic'),group=Array.isArray(groups)?groups.find(g=>g.name===users[0]?.group):null;const active=await read(row,'/user/active','compiled-client',0,'diagnostic');const apiSessions=Array.isArray(active)?active.filter(s=>s.name===row.username&&s.via==='api').map(s=>createHash('sha256').update(String(s['.id'])+'|'+String(s.when)).digest('hex')).sort():null;const services=await read(row,'/ip/service','compiled-client',0,'diagnostic'),resource=await read(row,'/system/resource','compiled-client',0,'diagnostic'),logs=await read(row,'/log','compiled-client',0,'diagnostic');const auth=Array.isArray(logs)?logs.filter(l=>/login failure/i.test(String(l.message))):[];const events=auth.map(l=>{const message=String(l.message);return{time:l.time??null,via:message.match(/via (\S+)/)?.[1]??null,fromTestDebian:/from 161\.104\.46\.222\b/.test(message),forConfiguredUser:message.includes('for user '+row.username+' ')}});const www=Array.isArray(services)?services.filter(s=>s.name==='www'):[];report.diagnostics.push({host:row.host,phase,apiSessionFingerprints:apiSessions,retainedRestSessions:Array.isArray(active)?active.filter(s=>s.via==='rest-api').length:null,userPresent:users.length===1,userDisabled:users[0]?.disabled??null,userAddressRestriction:users[0]?.address??null,hasApiPolicy:group?String(group.policy).split(',').includes('api'):null,hasRestApiPolicy:group?String(group.policy).split(',').includes('rest-api'):null,wwwEnabled:www.some(s=>s.disabled==='false'),wwwMaxSessions:www.find(s=>s['max-sessions'])?.['max-sessions']??null,wwwDynamicRows:www.filter(s=>s.dynamic==='true').length,resource:resource?{version:resource.version,uptime:resource.uptime,cpuLoad:resource['cpu-load'],freeMemory:resource['free-memory']}:null,retainedAuthFailureCount:auth.length,recentFailures:events.slice(-15),retainedLogIsNotCurrentRate:true})}
|
||||
function save(){const samples=report.requests.filter(r=>r.kind==='sample');report.completedAt=new Date().toISOString();report.summary={sampleRequests:samples.length,failedSamples:samples.filter(r=>r.status!==200).length,allRequests:report.requests.length,failures:report.requests.filter(r=>r.status!==200).map(r=>({host:r.host,path:r.path,mode:r.mode,status:r.status,kind:r.kind,startedAt:r.startedAt}))};fs.writeFileSync(output,JSON.stringify(report,null,2)+'\n')}
|
||||
try{for(const row of rows)await diagnostic(row,'before');const started=Date.now(),paths=['/system/resource','/interface/gre','/routing/bgp/session','/routing/ospf/neighbor','/interface/wireguard/peers','/interface/vxlan'];for(let cycle=1;cycle<=cycles&&Date.now()<deadline;cycle++){for(const row of rows)for(const endpoint of paths){const modes=cycle%2?['compiled-client','fetch-close']:['fetch-close','compiled-client'];for(const mode of modes){const previous=report.requests.length;await read(row,endpoint,mode,cycle);if(report.requests[previous].status!==200){await read(row,'/system/resource','fetch-close',cycle,'failure-control');await diagnostic(row,'failure-'+cycle)}}}save();console.log(JSON.stringify({vantage,cycle,samples:report.summary.sampleRequests,failed:report.summary.failedSamples}));if(cycle<cycles)await sleep(Math.max(0,Math.min(deadline,started+cycle*periodMs)-Date.now()))}for(const row of rows)await diagnostic(row,'after')}finally{save();console.log(JSON.stringify({vantage,summary:report.summary,rootCauseKnown:false}))}
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
|
||||
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? '').split(',')
|
||||
assert.deepEqual(hosts, ['194.226.121.150', '104.165.244.7'], 'Explicit owned lab hosts required')
|
||||
const { MMAPP_TEST_CHR_USER: username, MMAPP_TEST_CHR_PASSWORD: password } = process.env
|
||||
assert(username && password, 'Explicit test credentials required')
|
||||
const vantage = process.env.MMAPP_TEST_VANTAGE
|
||||
assert(['workstation', 'debian'].includes(vantage), 'Explicit vantage required')
|
||||
const output = process.env.MMAPP_TEST_REPORT
|
||||
assert(output, 'Explicit report path required')
|
||||
const authorization = 'Basic ' + Buffer.from(username + ':' + password).toString('base64')
|
||||
const report = { block: '12.5.225', date: '2026-10-14', vantage, startedAt: new Date().toISOString(), requests: [], diagnostics: [], configurationWrites: 0, passwordChanges: 0, rootCauseKnown: false }
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
async function read(host, path) {
|
||||
const startedAt = new Date().toISOString(), start = Date.now()
|
||||
try {
|
||||
const response = await fetch(`http://${host}/rest${path}`, { headers: { authorization, Connection: 'close' }, signal: AbortSignal.timeout(8000) })
|
||||
const body = await response.text()
|
||||
report.requests.push({ host, path, startedAt, durationMs: Date.now() - start, status: response.status, headers: Object.fromEntries(['date', 'server', 'www-authenticate', 'connection'].map(key => [key, response.headers.get(key)])) })
|
||||
if (!response.ok) return null
|
||||
try { return JSON.parse(body) } catch { return null }
|
||||
} catch (error) {
|
||||
report.requests.push({ host, path, startedAt, durationMs: Date.now() - start, status: null, errorName: error.name })
|
||||
return null
|
||||
} finally { await sleep(300) }
|
||||
}
|
||||
|
||||
for (let cycle = 1; cycle <= 3; cycle++) {
|
||||
for (const host of hosts) for (const path of ['/system/resource', '/interface/gre']) await read(host, path)
|
||||
if (cycle < 3) await sleep(5000)
|
||||
}
|
||||
for (const host of hosts) {
|
||||
const clock = await read(host, '/system/clock')
|
||||
const services = await read(host, '/ip/service')
|
||||
const logs = await read(host, '/log')
|
||||
const failures = Array.isArray(logs) ? logs.filter(row => /login failure/i.test(String(row.message))) : null
|
||||
const sources = {}
|
||||
const events = failures?.map(row => {
|
||||
const message = String(row.message), source = message.match(/from ([\d.]+)/)?.[1] ?? 'unparsed'
|
||||
const origin = source === '161.104.46.222' ? 'test-debian' : 'other-or-unidentified'
|
||||
sources[origin] = (sources[origin] ?? 0) + 1
|
||||
return { time: row.time ?? null, origin, via: message.match(/via (\w+)/)?.[1] ?? null }
|
||||
})
|
||||
report.diagnostics.push({ host, clock: clock ? { date: clock.date, time: clock.time, gmtOffset: clock['gmt-offset'] } : null, wwwServices: Array.isArray(services) ? services.filter(row => row.name === 'www').map(row => ({ disabled: row.disabled, maxSessions: row['max-sessions'] ?? null, dynamic: row.dynamic ?? null })) : null, retainedAuthFailures: failures?.length ?? null, sources, recentFailures: events?.slice(-20) ?? null, retainedLogIsNotCurrentRate: true })
|
||||
}
|
||||
report.completedAt = new Date().toISOString()
|
||||
report.summary = hosts.map(host => { const rows = report.requests.filter(row => row.host === host); return { host, requests: rows.length, ok: rows.filter(row => row.status === 200).length, unauthorized: rows.filter(row => row.status === 401).length, serverErrors: rows.filter(row => row.status >= 500).length, transportErrors: rows.filter(row => row.status === null).length } })
|
||||
fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n')
|
||||
console.log(JSON.stringify({ vantage, summary: report.summary, configurationWrites: 0 }))
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
const hosts=(process.env.MMAPP_TEST_CHR_HOSTS??'').split(',');assert.deepEqual(hosts,['194.226.121.150','104.165.244.7'],'Explicit owned lab hosts required')
|
||||
const username=process.env.MMAPP_TEST_CHR_USER,password=process.env.MMAPP_TEST_CHR_PASSWORD;assert(username&&password,'Explicit test credentials required')
|
||||
const authorization='Basic '+Buffer.from(username+':'+password).toString('base64'),paths=['/system/resource','/interface/gre','/routing/bgp/session','/routing/ospf/neighbor'],cases=[],start=Date.now(),deadline=start+360000,samples=20,periodMs=10000
|
||||
const sleep=ms=>new Promise(r=>setTimeout(r,ms)),output='docs/ux-ui/acceptance/chr224-rest-observation-2026-10-14.json'
|
||||
function save(completed){const failed=cases.filter(c=>c.status!==200);fs.writeFileSync(output,JSON.stringify({date:'2026-10-14',block:'12.5.224',startedAt:new Date(start).toISOString(),completedAt:new Date().toISOString(),plannedSamples:samples,completedSamples:completed,periodMs,requests:cases.length,failedRequests:failed.length,cases,configurationWrites:0,passwordChanges:0,scope:'Low-rate direct native REST with Connection close; short window, no burst/load/physical WAN/SLA/root-cause proof'},null,2)+'\n')}
|
||||
for(let sample=1;sample<=samples;sample++){
|
||||
if(Date.now()>deadline)break
|
||||
for(const host of hosts)for(const path of paths){const began=Date.now();try{const r=await fetch('http://'+host+'/rest'+path,{headers:{authorization,Connection:'close'},signal:AbortSignal.timeout(8000)});await r.arrayBuffer();cases.push({sample,host,path,status:r.status,durationMs:Date.now()-began})}catch(e){cases.push({sample,host,path,status:null,errorName:e.name,durationMs:Date.now()-began})}await sleep(150)}
|
||||
save(sample);console.log(JSON.stringify({completedSamples:sample,requests:cases.length,failedRequests:cases.filter(c=>c.status!==200).length}));if(sample<samples)await sleep(Math.max(0,start+sample*periodMs-Date.now()))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Real Portal login; credentials stay in the caller's ignored private fixture.
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium, expect } from '@playwright/test';
|
||||
|
||||
(async () => {
|
||||
const phase = process.argv[2];
|
||||
assert(['guard', 'remove', 'repeat'].includes(phase));
|
||||
const login = JSON.parse(fs.readFileSync(process.env.MMAPP_PORTAL_FIXTURE || 'tmp/portal195-private.json'));
|
||||
const own = JSON.parse(fs.readFileSync(process.env.MMAPP_CONTAINER_FIXTURE || 'tmp/state248-public.json'));
|
||||
const reportPath = 'docs/ux-ui/acceptance/containers248-live-ui.json';
|
||||
const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath)) : { date: '2026-10-14', block: '12.5.248', realPortalLogin: true, noMocks: true, steps: [], pageErrors: [] };
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.on('pageerror', error => report.pageErrors.push(error.message));
|
||||
await page.goto((process.env.MMAPP_TEST_URL || 'http://localhost:3000') + '/containers');
|
||||
await page.getByLabel('Email', { exact: true }).fill(login.email);
|
||||
await page.getByLabel('Пароль', { exact: true }).fill(login.password);
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Путь страницы' })).toContainText('Контейнеры', { timeout: 30000 });
|
||||
const endpoint = `/api/servers/${own.serverId}/containers/remove`;
|
||||
const direct = () => page.evaluate(async ({ endpoint, rosId }) => {
|
||||
const r = await fetch(endpoint, { method: 'POST', headers: { Authorization: 'Bearer ' + localStorage.getItem('mmapp_token'), 'Content-Type': 'application/json' }, body: JSON.stringify({ rosId }) });
|
||||
const body = await r.json();
|
||||
return { status: r.status, ok: body.ok === true, error: body.error };
|
||||
}, { endpoint, rosId: own.rosId });
|
||||
if (phase === 'repeat') {
|
||||
const result = await direct(); assert.equal(result.status, 409); assert.equal(result.ok, false); assert.match(result.error, /уже отсутствует/);
|
||||
report.steps.push({ phase, status: result.status, absentConflict: true });
|
||||
} else {
|
||||
await page.getByRole('button', { name: 'Действия контейнера ' + own.name, exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'Удалить', exact: true }).click();
|
||||
const dialog = page.getByRole('alertdialog');
|
||||
await expect(dialog).toContainText(own.name);
|
||||
if (phase === 'guard') {
|
||||
report.layout = [];
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await expect(dialog).toContainText('после подтверждённой остановки');
|
||||
await expect(dialog.getByRole('button', { name: 'Удалить', exact: true })).toBeDisabled();
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth > innerWidth), { timeout: 5000 }).toBe(false);
|
||||
const layout = await page.evaluate(() => ({ viewport: innerWidth, scrollWidth: document.documentElement.scrollWidth }));
|
||||
(report.layout ??= []).push(layout);
|
||||
if (layout.scrollWidth > layout.viewport) {
|
||||
report.overflow = await page.evaluate(() => Array.from(document.querySelectorAll('*')).map(el => ({tag:el.tagName,slot:el.getAttribute('data-slot'),left:el.getBoundingClientRect().left,right:el.getBoundingClientRect().right,width:el.getBoundingClientRect().width,position:getComputedStyle(el).position})).filter(el=>el.right>innerWidth+1).slice(0,20));
|
||||
await page.screenshot({ path: `tmp/containers248-guard-${width}.png`, fullPage: true });
|
||||
}
|
||||
assert.equal(layout.scrollWidth > layout.viewport, false);
|
||||
}
|
||||
const result = await direct(); assert.equal(result.status, 409); assert.equal(result.ok, false);
|
||||
report.steps.push({ phase, viewports: [1440, 390], uiDeleteDisabled: true, backendConflict: result.status });
|
||||
} else {
|
||||
await expect(dialog).toContainText('остановленный контейнер');
|
||||
const response = page.waitForResponse(r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST');
|
||||
await dialog.getByRole('button', { name: 'Удалить', exact: true }).click();
|
||||
const r = await response; assert.equal(r.request().postDataJSON().rosId, own.rosId); assert.equal(r.status(), 200); assert.equal((await r.json()).ok, true);
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Действия контейнера ' + own.name, exact: true })).toHaveCount(0);
|
||||
report.steps.push({ phase, status: 200, receiptOk: true, targetMatched: true, rowAbsent: true });
|
||||
}
|
||||
}
|
||||
assert.deepEqual(report.pageErrors, []);
|
||||
delete report.overflow;
|
||||
if (report.failure?.phase === phase) { (report.harnessFailures ??= []).push(report.failure); delete report.failure; }
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n');
|
||||
console.log('Live container removal: ' + phase + ' passed');
|
||||
} catch (error) {
|
||||
report.failure = { phase, name: error.name, ...(error.name === 'AssertionError' ? { actual: error.actual, expected: error.expected } : {}) }; fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); throw error;
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error('Live container removal failed: ' + error.name); process.exitCode = 1; });
|
||||
@@ -0,0 +1,97 @@
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
const { pool } = await import('/app/backend/dist/db/index.js');
|
||||
const { MikrotikClient } = await import('/app/backend/dist/services/mikrotik.js');
|
||||
const rows = (await pool.query("SELECT * FROM servers WHERE host='104.165.244.7' AND enabled=true")).rows; assert.equal(rows.length, 1);
|
||||
const server = rows[0], client = new MikrotikClient({ host: server.host, port: 80, useSsl: false, verifySsl: false, username: server.username, password: server.password });
|
||||
const controlRows = (await pool.query("SELECT * FROM servers WHERE host='194.226.121.150' AND enabled=true")).rows; assert.equal(controlRows.length, 1);
|
||||
const control = new MikrotikClient({ host: controlRows[0].host, port: 80, useSsl: false, verifySsl: false, username: controlRows[0].username, password: controlRows[0].password });
|
||||
const statePath = '/checks/state247-private.json', phase = process.argv[2];
|
||||
const save = state => fs.writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 });
|
||||
const sha = value => createHash('sha256').update(value).digest('hex');
|
||||
const normalizedExport = value => value.split(/\r?\n/).filter(line => !line.startsWith('#') && line.trim()).join('\n');
|
||||
// RouterOS may list available, uninstalled extra packages after reboot; they have no installed version.
|
||||
const packages = rows => rows.filter(p => typeof p.version === 'string' && p.version.trim()).map(p => ({ name: p.name, version: p.version, disabled: p.disabled ?? 'false' })).sort((a,b) => a.name.localeCompare(b.name));
|
||||
const ownContainers = async state => (await client.get('/container', 5000)).filter(c => c.interface === state.veth && c.comment === state.comment);
|
||||
const nativeStatus = row => row.status ?? (row.running === 'true' && row.stopped !== 'true' ? 'running' : row.stopped === 'true' && row.running !== 'true' ? 'stopped' : 'unknown');
|
||||
async function poll(read, accept, maxMs = 150000) {
|
||||
const until = Date.now() + maxMs; let last;
|
||||
while (Date.now() < until) { try { last = await read(); if (accept(last)) return last; } catch {} await new Promise(r => setTimeout(r, 1500)); }
|
||||
throw Error('Native state not confirmed within polling budget');
|
||||
}
|
||||
async function reboot(state, key) {
|
||||
assert(!state[key], 'Never repeat a possibly applied reboot');
|
||||
state[key] = { requested: true, acknowledgement: 'unknown' }; save(state);
|
||||
try { await client.post('/system/reboot', {}, 5000); state[key].acknowledgement = 'confirmed'; }
|
||||
catch (e) { state[key].errorName = e.name; state[key].status = e.statusCode ?? null; }
|
||||
save(state); console.log(JSON.stringify({ phase, rebootRequestedOnce: true, acknowledgement: state[key].acknowledgement }));
|
||||
}
|
||||
try {
|
||||
if (phase === 'init') {
|
||||
assert(!fs.existsSync(statePath));
|
||||
const uuid = randomUUID().slice(0,8), state = { serverId: Number(server.id), targetHost: server.host, veth: 'mmc247-' + uuid, root: 'mmc247-' + uuid + '-root', comment: 'mmc247-' + uuid, imageFile: 'mmc247-busybox.tar', packageFile: 'container-7.24.5.npk', packageSize: 1163409, imageSize: 2226176, beforePackages: packages(await client.get('/system/package')), beforeFiles: await client.get('/file'), beforeExport: normalizedExport(await client.exportConfigScript()), stages: [] };
|
||||
assert(!state.beforePackages.some(p => p.name === 'container')); assert(state.beforePackages.every(p => p.version === '7.24.5'));
|
||||
assert(!state.beforeFiles.some(f => f.name === state.imageFile || f.name === state.packageFile || f.name.startsWith(state.root)));
|
||||
assert(!(await client.get('/interface')).some(i => i.name === state.veth)); await control.get('/system/resource', 5000);
|
||||
state.beforeExportSha256 = sha(state.beforeExport); save(state); console.log('Private native baseline captured; control CHR reachable');
|
||||
} else {
|
||||
const state = JSON.parse(fs.readFileSync(statePath)); assert(/^mmc247-[a-f0-9]{8}$/.test(state.veth)); assert.equal(state.root, state.veth + '-root');
|
||||
if (phase === 'reboot-install') {
|
||||
const files = await client.get('/file');
|
||||
assert(files.some(f => f.name === state.packageFile && Number(f.size) === state.packageSize));
|
||||
assert(files.some(f => f.name === state.imageFile && Number(f.size) === state.imageSize));
|
||||
await control.get('/system/resource', 5000); await reboot(state, 'installReboot');
|
||||
} else if (phase === 'installed') {
|
||||
const p = await poll(() => client.get('/system/package', 4000), p => p.some(p => p.name === 'container' && p.version === '7.24.5' && p.disabled === 'false'));
|
||||
assert.equal((await client.get('/container')).length, 0); await control.get('/system/resource', 5000);
|
||||
state.afterInstallPackages = packages(p); state.stages.push({ phase, packageInstalled: true, version: '7.24.5', controlReachable: true }); save(state); console.log('Container package installed on7.24.5; native endpoint200/control reachable');
|
||||
} else if (phase === 'create') {
|
||||
assert(state.afterInstallPackages); assert.equal((await ownContainers(state)).length, 0);
|
||||
await client.put('/interface/veth', { name: state.veth, address: '198.18.247.2/30', gateway: '198.18.247.1', comment: state.comment });
|
||||
await client.put('/container', { file: state.imageFile, interface: state.veth, 'root-dir': state.root, cmd: '/bin/sleep 600', 'start-on-boot': 'false', comment: state.comment });
|
||||
const containers = await poll(() => ownContainers(state), rows => rows.length === 1 && nativeStatus(rows[0]) === 'stopped'); assert.equal(containers.length, 1);
|
||||
state.containerId = containers[0]['.id']; state.stages.push({ phase, initialStatus: 'stopped' }); save(state);
|
||||
fs.writeFileSync('/checks/state247-public.json', JSON.stringify({ serverId: state.serverId, rosId: state.containerId, interface: state.veth, comment: state.comment, root: state.root })); console.log('Own container extracted/stopped; public identity saved');
|
||||
} else if (phase === 'adopt-created') {
|
||||
const containers = await ownContainers(state); assert.equal(containers.length, 1); assert.equal(nativeStatus(containers[0]), 'stopped');
|
||||
state.containerId = containers[0]['.id']; state.stages.push({ phase: 'create', initialStatus: 'stopped', observedNativeStoppedFlag: containers[0].stopped }); save(state);
|
||||
fs.writeFileSync('/checks/state247-public.json', JSON.stringify({ serverId: state.serverId, rosId: state.containerId, name: containers[0].name, interface: state.veth, comment: state.comment, root: state.root })); console.log('Existing own extracted container adopted read-only');
|
||||
} else if (phase === 'running' || phase === 'stopped') {
|
||||
const containers = await poll(() => ownContainers(state), rows => rows.length === 1 && nativeStatus(rows[0]) === phase, 30000);
|
||||
assert.equal(containers[0]['.id'], state.containerId); state.stages.push({ phase, nativeConfirmed: true }); save(state); console.log('Native status confirmed: ' + phase);
|
||||
} else if (phase === 'cleanup-network') {
|
||||
const containers = await ownContainers(state); assert(containers.length <= 1);
|
||||
if (containers.length) { if (nativeStatus(containers[0]) !== 'stopped') { await client.post('/container/stop', { '.id': containers[0]['.id'] }); await poll(() => ownContainers(state), rows => rows.length === 1 && nativeStatus(rows[0]) === 'stopped', 30000); } await client.delete('/container/' + encodeURIComponent(containers[0]['.id'])); }
|
||||
const veth = (await client.get('/interface/veth')).filter(i => i.name === state.veth); assert(veth.length <= 1);
|
||||
for (const i of veth) { assert.equal(i.comment, state.comment); await client.delete('/interface/veth/' + encodeURIComponent(i['.id'])); }
|
||||
const files = await client.get('/file');
|
||||
const ownFile = f => { const name = String(f.name).replace(/^\/+/, ''); return name === state.imageFile || name === state.packageFile || name === state.root || name.startsWith(state.root + '/'); };
|
||||
assert(!state.beforeFiles.some(ownFile), 'Never remove an original file');
|
||||
const own = files.filter(ownFile).sort((a,b) => b.name.length - a.name.length);
|
||||
assert(own.every(f => !f.name.split('/').includes('..')));
|
||||
for (const f of own) { const current = (await client.get('/file')).find(x => x.name === f.name); if (current) await client.removeFile(current['.id']); }
|
||||
assert.equal((await ownContainers(state)).length, 0); assert(!(await client.get('/interface/veth')).some(i => i.name === state.veth));
|
||||
assert(!(await client.get('/file')).some(ownFile));
|
||||
state.stages.push({ phase, ownObjectsAbsent: true }); save(state); console.log('Own container/veth/image/root removed');
|
||||
} else if (phase === 'uninstall') {
|
||||
assert(state.stages.some(s => s.phase === 'cleanup-network'));
|
||||
const p = (await client.get('/system/package')).filter(p => p.name === 'container'); assert.equal(p.length, 1); assert.equal(p[0].version, '7.24.5');
|
||||
await client.post('/system/package/uninstall', { '.id': p[0]['.id'] });
|
||||
const scheduled = (await client.get('/system/package')).find(p => p.name === 'container'); assert(['uninstall','scheduled for uninstall'].includes(scheduled.scheduled));
|
||||
await reboot(state, 'uninstallReboot');
|
||||
} else if (phase === 'reboot-uninstall') {
|
||||
const scheduled = (await client.get('/system/package')).find(p => p.name === 'container'); assert(scheduled && ['uninstall','scheduled for uninstall'].includes(scheduled.scheduled));
|
||||
await reboot(state, 'uninstallReboot');
|
||||
} else if (phase === 'final') {
|
||||
const p = await poll(() => client.get('/system/package', 4000), p => !packages(p).some(p => p.name === 'container'));
|
||||
assert.deepEqual(packages(p), state.beforePackages);
|
||||
// Automatic timezone discovery can settle after package removal/reboot.
|
||||
// Wait for equality without excluding settings or logging the private export.
|
||||
await poll(async () => normalizedExport(await client.exportConfigScript()), value => value === state.beforeExport);
|
||||
await control.get('/system/resource', 5000);
|
||||
const report = { date: '2026-10-14', block: '12.5.247', targetHost: server.host, controlHost: '194.226.121.150', stages: state.stages, installReboot: state.installReboot, uninstallReboot: state.uninstallReboot, originalPackagesMatched: true, originalNormalizedExportSha256: state.beforeExportSha256, originalNormalizedExportMatched: true, controlReachable: true, passwordChanges: 0, deviceModeChanges: 0, firmwareVersionChanged: false, passed: true };
|
||||
fs.writeFileSync('/checks/containers247-native.json', JSON.stringify(report, null, 2) + '\n'); fs.unlinkSync(statePath); console.log(JSON.stringify(report));
|
||||
} else throw Error('Unsupported phase');
|
||||
}
|
||||
} finally { await pool.end(); }
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
const { pool } = await import('/app/backend/dist/db/index.js');
|
||||
const { MikrotikClient } = await import('/app/backend/dist/services/mikrotik.js');
|
||||
const { getAcmeSettingsPublic } = await import('/app/backend/dist/services/certificates-service.js');
|
||||
const rows = (await pool.query("SELECT * FROM servers WHERE host IN ('194.226.121.150','104.165.244.7') ORDER BY host")).rows;
|
||||
assert.equal(rows.length, 2); assert(rows.every(s => s.enabled && s.port === 80 && !s.use_ssl));
|
||||
const report = { date: '2026-10-14', block: '12.5.243', scope: 'Read-only availability, not container lifecycle or ACME issuance', hosts: [], requests: [], routerosWrites: 0, databaseWrites: 0, dnsWrites: 0, passwordChanges: 0 };
|
||||
async function read(client, host, endpoint) {
|
||||
try { const result = await client.get(endpoint, 8000); report.requests.push({ host, path: endpoint, status: 200 }); return result; }
|
||||
catch (e) { report.requests.push({ host, path: endpoint, status: e.statusCode ?? null, errorName: e.name, errorBodySha256: e.body ? createHash('sha256').update(e.body).digest('hex') : null }); return undefined; }
|
||||
}
|
||||
try {
|
||||
for (const server of rows) {
|
||||
const client = new MikrotikClient({ host: server.host, port: 80, useSsl: false, verifySsl: false, username: server.username, password: server.password });
|
||||
const packages = await read(client, server.host, '/system/package');
|
||||
const resource = await read(client, server.host, '/system/resource');
|
||||
const mode = await read(client, server.host, '/system/device-mode');
|
||||
const disks = await read(client, server.host, '/disk');
|
||||
const containers = await read(client, server.host, '/container');
|
||||
const m = Array.isArray(mode) ? mode[0] : mode;
|
||||
report.hosts.push({ host: server.host, packages: Array.isArray(packages) ? packages.map(p => ({ name: p.name, version: p.version, disabled: p.disabled })) : null, resource: resource ? { version: resource.version, architecture: resource['architecture-name'], boardName: resource['board-name'], freeHddSpace: resource['free-hdd-space'], totalHddSpace: resource['total-hdd-space'], freeMemory: resource['free-memory'] } : null, deviceMode: m ? { mode: m.mode ?? null, container: m.container ?? null, flagged: m.flagged ?? null } : null, disks: Array.isArray(disks) ? disks.length : null, containers: Array.isArray(containers) ? { count: containers.length, statuses: containers.map(c => c.status ?? null) } : null });
|
||||
}
|
||||
const acme = await getAcmeSettingsPublic();
|
||||
report.acme = { tokenConfigured: acme.tokenConfigured, defaultZoneConfigured: Boolean(acme.defaultZoneId), directoryHost: null, directoryStatus: null };
|
||||
const directory = new URL(acme.directoryUrl);
|
||||
assert(!directory.username && !directory.password);
|
||||
report.acme.directoryHost = directory.hostname;
|
||||
if (directory.protocol === 'https:' && ['acme-v02.api.letsencrypt.org','acme-staging-v02.api.letsencrypt.org'].includes(directory.hostname) && directory.pathname === '/directory') {
|
||||
const r = await fetch(directory, { signal: AbortSignal.timeout(8000) }); report.acme.directoryStatus = r.status;
|
||||
if (r.ok) { const data = await r.json(); report.acme.directoryKeys = Object.keys(data).sort(); }
|
||||
}
|
||||
} finally { await pool.end(); fs.writeFileSync('/checks/external-jobs243-availability.json', JSON.stringify(report, null, 2) + '\n'); console.log(JSON.stringify(report)); }
|
||||
@@ -0,0 +1,25 @@
|
||||
import fs from 'node:fs'
|
||||
import assert from 'node:assert/strict'
|
||||
import {execFileSync} from 'node:child_process'
|
||||
import pg from 'pg'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,root='/app/backend/acceptance'
|
||||
assert(/^mm_accept_166_\d+$/.test(database))
|
||||
const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
const client=new pg.Client({connectionString:url.toString()});await client.connect()
|
||||
assert.equal((await client.query('SELECT current_database() AS name')).rows[0].name,database)
|
||||
const role=(await client.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]
|
||||
assert(Object.values(role).every(value=>value===false));await client.end()
|
||||
const pkg=JSON.parse(fs.readFileSync('/app/backend/package.json'))
|
||||
const profiles=pkg.scripts['test:all'].split(/\s*&&\s*/).map(c=>{const m=/^npm run (test:[\w-]+)$/.exec(c);assert(m);return m[1]})
|
||||
const report={date:'2026-10-14',block:'12.5.222',scope:'full backend profile list on owned isolated PostgreSQL; no production DSN',database,profiles:[],passed:false}
|
||||
for(const profile of profiles){
|
||||
const started=Date.now();let output='',passed=true,errorName
|
||||
try{output=execFileSync('npm',['--prefix','backend','run',profile],{cwd:'/app',timeout:240_000,maxBuffer:16*1024**2,encoding:'utf8',env:{...process.env,NODE_ENV:'test',GEOIP_DIR:'/tmp/'+database+'/geoip',DATABASE_PATH:'/tmp/'+database+'/legacy.db'},stdio:['ignore','pipe','pipe']})}
|
||||
catch(e){passed=false;errorName=e.name;output=String(e.stdout??'')+'\n'+String(e.stderr??'')}
|
||||
fs.writeFileSync(root+'/'+profile.replace(':','-')+'-private.log',output,{mode:0o600})
|
||||
const skippedLines=output.split('\n').filter(s=>/\bskip(?:ped)?\b/i.test(s));const unavailableSql=skippedLines.filter(s=>/PostgreSQL|owned|own SQL|explicit owned|database|MMAPP_OWN/i.test(s)&&!s.includes('skip/release/recovery passed'));
|
||||
report.profiles.push({profile,passed,durationMs:Date.now()-started,errorName,skippedLineCount:skippedLines.length,unavailableSqlBranches:unavailableSql.map(line=>line.trim())})
|
||||
fs.writeFileSync(root+'/owned222-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report.profiles.at(-1)))
|
||||
}
|
||||
report.passed=report.profiles.every(p=>p.passed);fs.writeFileSync(root+'/owned222-result.json',JSON.stringify(report,null,2))
|
||||
if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
const database = process.env.MMAPP_OWN_SQL_DB;
|
||||
assert(/^mm_accept_166_\d+$/.test(database));
|
||||
const url = new URL(process.env.DATABASE_URL); assert.equal(url.pathname, '/' + database); assert.equal(url.username, database);
|
||||
assert.equal(process.env.GEOIP_DIR, '/tmp/' + database + '/geoip');
|
||||
fs.mkdirSync('/tmp/' + database, { recursive: true }); process.chdir('/tmp/' + database);
|
||||
const { pool, closePool } = await import('/app/backend/dist/db/index.js');
|
||||
const { initDatabase } = await import('/app/backend/dist/db/bootstrap.js');
|
||||
const role = (await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];
|
||||
assert(Object.values(role).every(v => v === false)); await initDatabase();
|
||||
const { collectGeoipUpdateOnce, setGeoipFetchForTests, resetGeoipUpdateForTests } = await import('/app/backend/dist/services/geoip-update-collector.js');
|
||||
const { getGeoipSettings, updateGeoipSettings } = await import('/app/backend/dist/services/geoip-settings.js');
|
||||
const { geoipReadersStatus, lookupGeoip, GEOIP_COUNTRY_FILE, GEOIP_ASN_FILE } = await import('/app/backend/dist/services/traffic-flow-geoip.js');
|
||||
const { databaseBackgroundWork } = await import('/app/backend/dist/services/database-background-work.js');
|
||||
const report = { date: '2026-10-14', block: '12.5.242', passed: false, scope: 'Compiled GeoIP collector + owned PostgreSQL/files + actual public mirror; controlled negative bodies separately marked', mirrorRequests: [], checks: [], chrContacts: 0, mainDatabaseWrites: 0 };
|
||||
const file = name => path.join(process.env.GEOIP_DIR, name);
|
||||
const sha = name => createHash('sha256').update(fs.readFileSync(file(name))).digest('hex');
|
||||
const save = () => fs.writeFileSync('/checks/geoip-mirror242.json', JSON.stringify(report, null, 2) + '\n');
|
||||
const check = (name, detail = {}) => { report.checks.push({ name, ...detail }); save(); };
|
||||
const realFetch = globalThis.fetch.bind(globalThis);
|
||||
async function observedFetch(url, options) {
|
||||
const started = Date.now(), response = await realFetch(url, options);
|
||||
report.mirrorRequests.push({ file: new URL(String(url)).pathname.split('/').at(-1), conditional: Boolean(options?.headers?.['If-None-Match']), status: response.status, headersMs: Date.now() - started }); save(); return response;
|
||||
}
|
||||
try {
|
||||
await updateGeoipSettings({ enabled: false }); setGeoipFetchForTests(observedFetch);
|
||||
let snapshot = await collectGeoipUpdateOnce({ force: true });
|
||||
assert(!snapshot.fatalError); assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.downloaded, 2);
|
||||
assert(geoipReadersStatus().countryLoaded && geoipReadersStatus().asnLoaded);
|
||||
const initial = { country: sha(GEOIP_COUNTRY_FILE), asn: sha(GEOIP_ASN_FILE) };
|
||||
const lookup = lookupGeoip('8.8.8.8'); assert.equal(lookup?.country, 'US'); assert.equal(lookup?.asn, 15169);
|
||||
check('actual-two-downloads-valid-mmdb-and-lookup', { bytes: snapshot.bytes, hashes: initial, settings: await getGeoipSettings(), lookup: { country: lookup.country, asn: lookup.asn } });
|
||||
const first = collectGeoipUpdateOnce({ force: true });
|
||||
await assert.rejects(collectGeoipUpdateOnce({ force: true }), e => e.statusCode === 409);
|
||||
assert.equal((await collectGeoipUpdateOnce()).skipped, true);
|
||||
snapshot = await first; assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.skippedUnchanged, 2); assert.equal(snapshot.downloaded, 0);
|
||||
check('actual-conditional304-and-concurrent409', { skippedUnchanged: 2, hashesUnchanged: sha(GEOIP_COUNTRY_FILE) === initial.country && sha(GEOIP_ASN_FILE) === initial.asn });
|
||||
fs.unlinkSync(file(GEOIP_COUNTRY_FILE));
|
||||
const beforeMissing = report.mirrorRequests.length; snapshot = await collectGeoipUpdateOnce({ force: true });
|
||||
assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.downloaded, 1); assert.equal(snapshot.skippedUnchanged, 1);
|
||||
assert(report.mirrorRequests.slice(beforeMissing).some(r => r.file === GEOIP_COUNTRY_FILE && !r.conditional && r.status === 200));
|
||||
assert.equal(sha(GEOIP_COUNTRY_FILE), initial.country);
|
||||
check('missing-country-refetched-without-conditional-header');
|
||||
fs.writeFileSync(file(GEOIP_COUNTRY_FILE), 'Own corrupt GeoIP fixture');
|
||||
snapshot = await collectGeoipUpdateOnce({ force: true }); assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.downloaded, 1); assert.equal(sha(GEOIP_COUNTRY_FILE), initial.country);
|
||||
check('corrupt-local-country-refetched-and-validated');
|
||||
const lastSuccess = (await getGeoipSettings()).lastSuccessAt;
|
||||
setGeoipFetchForTests(async (url, options) => String(url).endsWith(GEOIP_COUNTRY_FILE) ? new Response('Own invalid MMDB body', { status: 200 }) : observedFetch(url, options));
|
||||
snapshot = await collectGeoipUpdateOnce({ force: true }); assert.equal(snapshot.errors.length, 1); assert.equal(snapshot.downloaded, 0); assert.equal(snapshot.skippedUnchanged, 1);
|
||||
assert.equal(sha(GEOIP_COUNTRY_FILE), initial.country); assert.equal((await getGeoipSettings()).lastSuccessAt, lastSuccess);
|
||||
assert((await getGeoipSettings()).lastError); assert(!fs.existsSync(file(GEOIP_COUNTRY_FILE) + '.tmp'));
|
||||
check('controlled-invalid-body-preserves-file-and-last-success', { negativeCountryResponse: 'controlled-200-invalid-MMDB', realAsnStatus: 304 });
|
||||
fs.unlinkSync(file(GEOIP_COUNTRY_FILE));
|
||||
setGeoipFetchForTests(async (url, options) => String(url).endsWith(GEOIP_COUNTRY_FILE) ? new Response(null, { status: 304 }) : observedFetch(url, options));
|
||||
snapshot = await collectGeoipUpdateOnce({ force: true }); assert.equal(snapshot.errors.length, 1); assert.equal(snapshot.skippedUnchanged, 1); assert(snapshot.errors[0].includes('304 без проверенной локальной базы'));
|
||||
assert.equal((await getGeoipSettings()).lastSuccessAt, lastSuccess);
|
||||
check('controlled-uncached304-is-error-with-last-success-retained');
|
||||
setGeoipFetchForTests(observedFetch); snapshot = await collectGeoipUpdateOnce({ force: true }); assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.downloaded, 1);
|
||||
assert.equal(sha(GEOIP_COUNTRY_FILE), initial.country); assert.equal(sha(GEOIP_ASN_FILE), initial.asn);
|
||||
const beforeSkip = report.mirrorRequests.length; assert.equal((await collectGeoipUpdateOnce()).skipped, true); assert.equal(report.mirrorRequests.length, beforeSkip);
|
||||
check('actual-recovery-and-disabled-background-no-fetch'); report.passed = true;
|
||||
} catch (e) { report.failure = { name: e.name }; process.exitCode = 1; }
|
||||
finally { resetGeoipUpdateForTests(); await databaseBackgroundWork.drain(); await closePool(); save(); console.log(JSON.stringify(report)); }
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own212.invalid';process.env.AUTH_PORTAL_URL='https://own212.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
|
||||
const rows=[],writes=[],cases=[];let app,serverId,nativeCalls=0;
|
||||
const native=createServer(async(req,res)=>{nativeCalls++;let text='';for await(const chunk of req)text+=chunk;const body=text?JSON.parse(text):{};res.setHeader('content-type','application/json');if(req.method==='GET'){res.end(JSON.stringify(req.url.endsWith('/interface/gre')?rows:[]));return}writes.push({method:req.method,path:req.url,body});if(req.method==='PUT'){const row={...body,'.id':'*'+(rows.length+1),running:'false'};rows.push(row);res.end(JSON.stringify(row));return}if(req.method==='PATCH'){const id=decodeURIComponent(req.url.split('/').pop()),row=rows.find(r=>r['.id']===id);assert(row);Object.assign(row,body);res.end(JSON.stringify(row));return}res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));
|
||||
const report={date:'2026-10-13',block:'12.5.212',database,cases,scope:'Production compiled GRE create/patch using isolated PostgreSQL and native HTTP fixture',passed:false};
|
||||
try{await initDatabase();serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own212-native','127.0.0.1',$1,false,true,'fixture','fixture') RETURNING id",[native.address().port])).rows[0].id);app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own212-writer',apps:['mm'],permissions:['mm:network:write'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token};
|
||||
for(const field of ['keepaliveInterval','keepaliveRetries'])for(const value of [0,-1,1.5,4294967296]){const before=nativeCalls;const r=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name:'invalid',remoteAddress:'192.0.2.2',[field]:value}});assert.equal(r.statusCode,400);assert.equal(nativeCalls,before);cases.push({field,value,status:400,nativeCalls:0})}
|
||||
for(const params of [{},{keepaliveInterval:60},{keepaliveRetries:3},{keepaliveInterval:90,keepaliveRetries:7}]){const name='own212-'+rows.length;const r=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name,remoteAddress:'192.0.2.2',enabled:false,...params}});assert.equal(r.statusCode,201);const expected=(params.keepaliveInterval??10)+'s,'+(params.keepaliveRetries??10);assert.equal(rows.at(-1).keepalive,expected);cases.push({operation:'create',params,keepalive:expected,status:201})}
|
||||
rows[0].keepalive='1m,3';const patch=await app.inject({method:'PATCH',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),rosId:rows[0]['.id'],comment:'own212 updated'}});assert.equal(patch.statusCode,200);assert.equal(rows[0].keepalive,'1m,3');assert.equal(rows[0].comment,'own212 updated');cases.push({operation:'patch-other-field',nativeKeepalivePreserved:'1m,3',status:200});const read=await app.inject({method:'GET',url:'/api/gre/tunnels?serverId='+serverId,headers});assert.equal(read.statusCode,200);assert.equal(read.json().tunnels.find(t=>t.name===rows[0].name).keepaliveInterval,60);cases.push({operation:'read-minutes',seconds:60,status:200});const encrypted=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name:'own212-psk',remoteAddress:'192.0.2.2',enabled:false,ipsecSecret:'own212-synthetic-psk'}});assert.equal(encrypted.statusCode,201);const secured=rows.find(r=>r.name==='own212-psk');assert.equal(secured['allow-fast-path'],'no');cases.push({operation:'psk-create-default-fastpath',status:201,fastPath:'no'});let before=nativeCalls;const conflict=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name:'conflict',remoteAddress:'192.0.2.2',ipsecSecret:'own212-synthetic-psk',allowFastPath:true}});assert.equal(conflict.statusCode,400);assert.equal(nativeCalls,before);cases.push({operation:'explicit-psk-fastpath-conflict',status:400,nativeCalls:0});before=writes.length;const invalidPatch=await app.inject({method:'PATCH',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),rosId:secured['.id'],allowFastPath:true}});assert.equal(invalidPatch.statusCode,400);assert.equal(writes.length,before);assert.equal(secured['allow-fast-path'],'no');cases.push({operation:'existing-psk-fastpath-conflict',status:400,nativeWrites:0});const upgrade=await app.inject({method:'PATCH',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),rosId:rows[1]['.id'],ipsecSecret:'own212-synthetic-psk'}});assert.equal(upgrade.statusCode,200);assert.equal(rows[1]['allow-fast-path'],'no');cases.push({operation:'enable-psk-with-omitted-fastpath',status:200,fastPath:'no'});report.passed=true;report.nativeWrites=writes.length;
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre212-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/gre212-keepalive-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1;
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{collectGreBgpSnapshotOnce}=await import('/app/backend/dist/services/gre-bgp-snapshot-collector.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const baseline=process.env.MMAPP_BASELINE==='true',cases=[];let phase='populated',nativeCalls=0,nativeWrites=0,disabledCalls=0
|
||||
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');const gre=req.url==='/rest/interface/gre',failed=(gre&&phase==='gre-failed')||(label==='second'&&(gre&&['gre-partial','both-partial'].includes(phase)||!gre&&['bgp-partial','both-partial'].includes(phase)));if(failed){res.statusCode=401;res.end(JSON.stringify({error:401,message:'own219 denied'}));return}const row=gre?{'.id':'*1',name:'own219-gre',running:'true',disabled:'false'}:{'.id':'*1',name:'own219-peer',established:'true','remote.as':'65219','local.as':'65220'};const rows=phase==='empty'?[]:gre&&label==='second'&&phase==='malformed'?[{unknown:'fixture'}]:gre&&label==='second'&&phase==='duplicate'?[row,row]:[row];res.end(JSON.stringify(rows))})
|
||||
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
|
||||
const report={date:'2026-10-14',block:'12.5.219',baseline,database,cases,scope:'Actual compiled GRE/BGP sources and snapshot collector, owned SQL/native HTTP fixture; no API auth claim',passed:false}
|
||||
try{
|
||||
await initDatabase();for(let i=0;i<natives.length;i++)await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4)",['own219-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])
|
||||
for(phase of ['populated','empty','gre-partial','gre-failed','bgp-partial','both-partial','malformed','duplicate','disabled','recovery']){
|
||||
if(phase==='disabled')await pool.query('UPDATE servers SET enabled=false');if(phase==='recovery')await pool.query("UPDATE servers SET enabled=true WHERE name IN ('own219-0','own219-1')")
|
||||
const before=nativeCalls,snapshot=await collectGreBgpSnapshotOnce(),greFail=['gre-partial','gre-failed','both-partial','malformed','duplicate'].includes(phase),bgpFail=['bgp-partial','both-partial'].includes(phase),empty=['empty','disabled'].includes(phase),greCount=empty?0:greFail?(baseline?phase==='gre-failed'?0:phase==='malformed'?2:phase==='duplicate'?3:1:0):2,bgpCount=empty||bgpFail?0:2,errorCount=Number(bgpFail)+Number(greFail&&!baseline)
|
||||
assert.equal(snapshot.greWritten,greCount);assert.equal(snapshot.bgpWritten,bgpCount);assert.equal(snapshot.errors.length,errorCount);assert.equal(snapshot.fatalError,undefined)
|
||||
if(greFail&&!baseline)assert(snapshot.errors.some(e=>e.startsWith('GRE: Не опрошены устройства GRE:')))
|
||||
if(bgpFail)assert(snapshot.errors.some(e=>e.startsWith('BGP: Не опрошены устройства BGP:')))
|
||||
if(phase==='disabled')assert.equal(nativeCalls,before)
|
||||
cases.push({phase,greWritten:snapshot.greWritten,bgpWritten:snapshot.bgpWritten,sourceErrors:snapshot.errors.length,nativeCalls:nativeCalls-before})
|
||||
}
|
||||
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre219-private.log',String(e.stack??e),{mode:0o600})}finally{for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/gre219-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own210.invalid';process.env.AUTH_PORTAL_URL='https://own210.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
|
||||
const rows=[],writes=[],cases=[];let app,serverId,nativeCalls=0;
|
||||
const native=createServer(async(req,res)=>{nativeCalls++;let text='';for await(const chunk of req)text+=chunk;const body=text?JSON.parse(text):{};res.setHeader('content-type','application/json');if(req.method==='GET'){res.end(JSON.stringify(req.url.endsWith('/interface/gre')?rows:[]));return}writes.push({method:req.method,path:req.url,body});if(req.method==='PUT'){const row={...body,'.id':'*'+(rows.length+1),running:'false'};rows.push(row);res.end(JSON.stringify(row));return}if(req.method==='PATCH'){const id=decodeURIComponent(req.url.split('/').pop()),row=rows.find(r=>r['.id']===id);assert(row);Object.assign(row,body);res.end(JSON.stringify(row));return}res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));
|
||||
const report={date:'2026-10-13',block:'12.5.210',database,cases,scope:'Production compiled GRE create/patch using isolated PostgreSQL and native HTTP fixture',passed:false};
|
||||
try{await initDatabase();serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own210-native','127.0.0.1',$1,false,true,'fixture','fixture') RETURNING id",[native.address().port])).rows[0].id);app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own210-writer',apps:['mm'],permissions:['mm:network:write'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token};
|
||||
for(const field of ['keepaliveInterval','keepaliveRetries'])for(const value of [0,-1,1.5,4294967296]){const before=nativeCalls;const r=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name:'invalid',remoteAddress:'192.0.2.2',[field]:value}});assert.equal(r.statusCode,400);assert.equal(nativeCalls,before);cases.push({field,value,status:400,nativeCalls:0})}
|
||||
for(const params of [{},{keepaliveInterval:60},{keepaliveRetries:3},{keepaliveInterval:90,keepaliveRetries:7}]){const name='own210-'+rows.length;const r=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name,remoteAddress:'192.0.2.2',enabled:false,...params}});assert.equal(r.statusCode,201);const expected=(params.keepaliveInterval??10)+'s,'+(params.keepaliveRetries??10);assert.equal(rows.at(-1).keepalive,expected);cases.push({operation:'create',params,keepalive:expected,status:201})}
|
||||
rows[0].keepalive='1m,3';const patch=await app.inject({method:'PATCH',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),rosId:rows[0]['.id'],comment:'own210 updated'}});assert.equal(patch.statusCode,200);assert.equal(rows[0].keepalive,'1m,3');assert.equal(rows[0].comment,'own210 updated');cases.push({operation:'patch-other-field',nativeKeepalivePreserved:'1m,3',status:200});const read=await app.inject({method:'GET',url:'/api/gre/tunnels?serverId='+serverId,headers});assert.equal(read.statusCode,200);assert.equal(read.json().tunnels.find(t=>t.name===rows[0].name).keepaliveInterval,60);cases.push({operation:'read-minutes',seconds:60,status:200});report.passed=true;report.nativeWrites=writes.length;
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre210-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/gre210-keepalive-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1;
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own221.invalid';process.env.AUTH_PORTAL_URL='https://own221.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{collectGreBgpSnapshotOnce}=await import('/app/backend/dist/services/gre-bgp-snapshot-collector.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const baseline=process.env.MMAPP_BASELINE==='true',cases=[];let app,nativeCalls=0,nativeWrites=0,disabledCalls=0,variant='valid'
|
||||
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');const gre=req.url==='/rest/interface/gre',bgp=req.url==='/rest/routing/bgp/session',normal={'.id':'*A',name:'own221-GRE «тест» / peer',disabled:'false',running:'true'},kinds={valid:[normal],empty:[],'missing-id':[{name:normal.name,disabled:'false',running:'true'}],'missing-name':[{'.id':'*A',disabled:'false',running:'true'}],'blank-id':[{...normal,'.id':''}],'blank-name':[{...normal,name:' '}],'whitespace-id':[{...normal,'.id':' *A'}],'number-field':[{...normal,mtu:1400}],'duplicate-id':[normal,{...normal,name:'other-GRE'}],'duplicate-name':[normal,{...normal,'.id':'*B'}],'trimmed-name-collision':[normal,{...normal,'.id':'*B',name:' '+normal.name+' '}]},rows=gre?(label==='second'?kinds[variant]:[normal]):bgp?[{'.id':'*1',name:'own221-peer',established:'true','remote.as':'65221','local.as':'65222'}]:[];res.end(JSON.stringify(rows))})
|
||||
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
|
||||
const report={date:'2026-10-14',block:'12.5.221',baseline,database,cases,scope:'Actual compiled Native GRE identity and actual API/config/snapshot paths, owned SQL/native HTTP fixture; signed reader/authenticated GRE API',passed:false}
|
||||
try{
|
||||
await initDatabase();const ids=[];for(let i=0;i<natives.length;i++)ids.push(Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4) RETURNING id",['own221-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])).rows[0].id))
|
||||
app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own221-reader',apps:['mm'],permissions:['mm:network:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token};
|
||||
for(variant of ['valid','empty','missing-id','missing-name','blank-id','blank-name','whitespace-id','number-field','duplicate-id','duplicate-name','trimmed-name-collision']){
|
||||
const bad=!['valid','empty'].includes(variant),oldSourceAccepts=['valid','empty','whitespace-id','duplicate-name','trimmed-name-collision'].includes(variant),sourceUnknown=baseline?!oldSourceAccepts:bad,oldCount=['duplicate-id','duplicate-name','trimmed-name-collision'].includes(variant)?3:variant==='empty'?1:2,snapshot=await collectGreBgpSnapshotOnce()
|
||||
assert.equal(snapshot.greWritten,sourceUnknown?0:oldCount);assert.equal(snapshot.bgpWritten,2);assert.equal(snapshot.errors.length,sourceUnknown?1:0);assert.equal(snapshot.fatalError,undefined)
|
||||
cases.push({phase:variant,endpoint:'compiled collector',greWritten:snapshot.greWritten,bgpWritten:2,sourceErrors:snapshot.errors.length})
|
||||
const before=Number((await pool.query("SELECT count(*) AS count FROM config_revisions WHERE server_id=$1 AND section='gre'",[ids[1]])).rows[0].count),api=await app.inject({method:'GET',url:'/api/gre/tunnels',headers}),body=api.json();assert.equal(api.statusCode,200);assert.equal(body.failures.length,bad&&!baseline?1:0);assert.equal(body.tunnels.length,bad&&!baseline?1:oldCount);const after=Number((await pool.query("SELECT count(*) AS count FROM config_revisions WHERE server_id=$1 AND section='gre'",[ids[1]])).rows[0].count);if(bad&&!baseline)assert.equal(after,before)
|
||||
cases.push({phase:variant,endpoint:'signed GRE catalog',status:200,tunnels:body.tunnels.length,failures:body.failures.length,observedRevisionsAdded:after-before})
|
||||
}
|
||||
|
||||
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre221-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/gre221-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own220.invalid';process.env.AUTH_PORTAL_URL='https://own220.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{collectGreBgpSnapshotOnce}=await import('/app/backend/dist/services/gre-bgp-snapshot-collector.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const baseline=process.env.MMAPP_BASELINE==='true',cases=[];let app,nativeCalls=0,nativeWrites=0,disabledCalls=0,flags={running:'true',disabled:'false'}
|
||||
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');const gre=req.url==='/rest/interface/gre',bgp=req.url==='/rest/routing/bgp/session',row=gre?{'.id':'*1',name:'own220-gre',...(label==='second'?flags:{running:'true',disabled:'false'})}:{'.id':'*1',name:'own220-peer',established:'true','remote.as':'65220','local.as':'65221'};res.end(JSON.stringify(gre||bgp?[row]:[]))})
|
||||
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
|
||||
const report={date:'2026-10-14',block:'12.5.220',baseline,database,cases,scope:'Actual compiled GRE operational flags and actual snapshot collector, owned SQL/native HTTP fixture; signed reader/authenticated GRE API',passed:false}
|
||||
try{
|
||||
await initDatabase();for(let i=0;i<natives.length;i++)await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4)",['own220-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])
|
||||
app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own220-reader',apps:['mm'],permissions:['mm:network:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token};
|
||||
const flagCases=[{name:'up',flags:{running:'true',disabled:'false'}},{name:'degraded',flags:{running:'false',disabled:'false'}},{name:'down',flags:{running:'false',disabled:'true'}},{name:'yes-up',flags:{running:'yes',disabled:'no'}},{name:'yes-degraded',flags:{running:'no',disabled:'no'}},{name:'yes-down',flags:{running:'no',disabled:'yes'}},{name:'missing-disabled',flags:{running:'true'}},{name:'missing-running',flags:{disabled:'false'}},{name:'blank-disabled',flags:{running:'true',disabled:''}},{name:'invalid-running',flags:{running:'junk',disabled:'false'}},{name:'invalid-disabled',flags:{running:'true',disabled:'junk'}},{name:'both-missing',flags:{}},{name:'disabled-missing-running',flags:{disabled:'true'}},{name:'disabled-invalid-running',flags:{disabled:'true',running:'junk'}},{name:'recovery',flags:{running:'true',disabled:'false'}}]
|
||||
for(const item of flagCases){
|
||||
flags=item.flags;const off=['true','yes'].includes(flags.disabled??''),on=['false','no'].includes(flags.disabled??''),runningKnown=['true','yes','false','no'].includes(flags.running??''),unknown=!off&&!(on&&runningKnown),before=nativeCalls,snapshot=await collectGreBgpSnapshotOnce()
|
||||
assert.equal(snapshot.greWritten,unknown&&!baseline?0:2);assert.equal(snapshot.bgpWritten,2);assert.equal(snapshot.errors.length,unknown&&!baseline?1:0);assert.equal(snapshot.fatalError,undefined)
|
||||
if(unknown&&!baseline){assert.match(snapshot.errors[0],/Не подтверждено состояние GRE/)}else{const row=snapshot.greTunnels.find(row=>row.targetLabel.endsWith('own220-1'));assert(row);const expected=baseline?flags.disabled==='true'?'down':flags.running==='true'?'up':'degraded':off?'down':['true','yes'].includes(flags.running??'')?'up':'degraded';assert.equal(row.status,expected)}
|
||||
const api=await app.inject({method:'GET',url:'/api/gre/tunnels',headers}),body=api.json();assert.equal(api.statusCode,200);assert.equal(body.failures.length,unknown&&!baseline?1:0);assert.equal(body.tunnels.length,unknown&&!baseline?1:2);cases.push({phase:item.name,endpoint:'GRE authenticated catalog',status:200,tunnels:body.tunnels.length,failures:body.failures.length});
|
||||
cases.push({phase:item.name,flags,greWritten:snapshot.greWritten,bgpWritten:2,sourceErrors:snapshot.errors.length,secondStatus:snapshot.greTunnels.find(row=>row.targetLabel.endsWith('own220-1'))?.status??null,nativeCalls:nativeCalls-before})
|
||||
}
|
||||
|
||||
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre220-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/gre220-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own209.invalid';process.env.AUTH_PORTAL_URL='https://own209.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const psk='own209-'+randomBytes(24).toString('base64url'),baseline=process.env.MMAPP_GRE_SECRET_EXPECT==='leak',cases=[];let app,serverId,nativeWrites=0
|
||||
const native=createServer((req,res)=>{if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');res.end(JSON.stringify(req.url.endsWith('/interface/gre')?[{'.id':'*1',name:'own209-encrypted','local-address':'192.0.2.1','remote-address':'192.0.2.2','ipsec-secret':psk,mtu:'1476',keepalive:'10s,3',disabled:'false',running:'true','allow-fast-path':'false'},{'.id':'*2',name:'own209-plain','remote-address':'192.0.2.3','ipsec-secret':'',disabled:'false',running:'true'}]:[]))});await new Promise(r=>native.listen(0,'127.0.0.1',r))
|
||||
const report={date:'2026-10-13',block:'12.5.209',database,baseline,cases,scope:'Production compiled GRE/Filters GET and auth with own restricted PostgreSQL, local native HTTP fixture, synthetic nonempty PSK and isolated signed JWT',passed:false}
|
||||
try{await initDatabase();serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own209-native','127.0.0.1',$1,false,true,'fixture','fixture') RETURNING id",[native.address().port])).rows[0].id);app=await buildApp({logger:false,startScheduler:false});await app.ready()
|
||||
for(const role of ['reader','filter-writer','network-writer','network-admin','is-admin-only']){const permissions=['mm:network:read','mm:filters:read',...(role==='filter-writer'?['mm:filters:write']:role==='network-writer'?['mm:network:write']:role==='network-admin'?['mm:network:admin']:[])],token=app.jwt.sign({sub:'own209-'+role,apps:['mm'],permissions,is_admin:role==='is-admin-only',iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});for(const path of ['/api/gre/tunnels','/api/filters/gre-tunnels']){const r=await app.inject({method:'GET',url:path+'?serverId='+serverId,headers:{authorization:'Bearer '+token}});assert.equal(r.statusCode,200);const rows=r.json().tunnels;assert.equal(rows.length,2);const encrypted=rows.find(t=>t.name==='own209-encrypted'),plain=rows.find(t=>t.name==='own209-plain');assert.equal(plain.ipsec,null);const direct=path==='/api/gre/tunnels',privileged=role==='network-writer'||role==='network-admin';if(direct){assert(encrypted.ipsec);assert.equal(encrypted.ipsec.secret,baseline||privileged?psk:'')}else if(baseline)assert.equal(encrypted.ipsec,null);else{assert(encrypted.ipsec);assert.equal(encrypted.ipsec.secret,'')}const exposed=r.body.includes(psk);assert.equal(exposed,direct&&(baseline||privileged));cases.push({role,path,status:200,pskExposed:exposed,configuredStatePresent:!!encrypted.ipsec,plainStateNull:true})}}
|
||||
for(const path of ['/api/gre/tunnels','/api/filters/gre-tunnels']){assert.equal((await app.inject({method:'GET',url:path})).statusCode,401);cases.push({role:'unauthenticated',path,status:401})}
|
||||
const revisions=(await pool.query("SELECT payload FROM config_revisions WHERE server_id=$1 AND section='gre'",[serverId])).rows;assert(revisions.length>0);assert(revisions.some(r=>JSON.stringify(r.payload).includes(psk)));assert.equal(nativeWrites,0);report.internalSnapshotRetainsPsk=true;report.nativeWrites=0;report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre209-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/gre209-'+(baseline?'baseline':'fixed')+'-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}
|
||||
if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,155 @@
|
||||
/** Arithmetic preflight only. No Docker/host access, changes or workload admission.
|
||||
* Roles are operator-supplied labels, not authenticated service identities.
|
||||
* Even a fitting budget cannot prove runtime <=2GB or permission to change limits. */
|
||||
const MiB = 1024 * 1024
|
||||
const roles = new Set(['backend', 'postgres', 'frontend', 'portal', 'proxy', 'agent', 'other'])
|
||||
const singleton = new Set(['backend', 'postgres', 'frontend', 'portal'])
|
||||
const integer = value => Number.isSafeInteger(value) && value >= 0
|
||||
const exact = (value, fields) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& Object.keys(value).sort().join('|') === [...fields].sort().join('|')
|
||||
const id = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
|
||||
|
||||
export function evaluateInstallationMemoryBudget(input) {
|
||||
if (!exact(input, ['hostMemTotalBytes', 'osReserveBytes', 'observedContainerIds', 'containers'])
|
||||
|| !integer(input.hostMemTotalBytes) || !integer(input.osReserveBytes)
|
||||
|| !Array.isArray(input.observedContainerIds) || !Array.isArray(input.containers)
|
||||
|| input.observedContainerIds.length > 128 || input.containers.length > 128) throw Error('Invalid bounded installation inventory')
|
||||
if (input.observedContainerIds.some(value => !id(value)) || new Set(input.observedContainerIds).size !== input.observedContainerIds.length) throw Error('Invalid observed container set')
|
||||
const seen = new Set(), counts = new Map(), reasons = new Set()
|
||||
let configuredBytes = 0
|
||||
for (const row of input.containers) {
|
||||
if (!exact(row, ['id', 'role', 'configuredLimitBytes']) || !id(row.id) || !roles.has(row.role)
|
||||
|| !integer(row.configuredLimitBytes) || seen.has(row.id)) throw Error('Invalid container budget')
|
||||
seen.add(row.id); counts.set(row.role, (counts.get(row.role) ?? 0) + 1)
|
||||
if (row.configuredLimitBytes === 0) reasons.add('UNBOUNDED_CONTAINER')
|
||||
if (row.configuredLimitBytes > input.hostMemTotalBytes) reasons.add('CONTAINER_LIMIT_EXCEEDS_HOST')
|
||||
if (!Number.isSafeInteger(configuredBytes + row.configuredLimitBytes)) throw Error('Container budget overflow')
|
||||
configuredBytes += row.configuredLimitBytes
|
||||
}
|
||||
if (seen.size !== input.observedContainerIds.length || input.observedContainerIds.some(value => !seen.has(value))) reasons.add('INCOMPLETE_HOST_CONTAINER_INVENTORY')
|
||||
for (const role of ['backend', 'postgres', 'frontend']) if (!counts.has(role)) reasons.add('REQUIRED_SERVICE_MISSING')
|
||||
for (const role of singleton) if ((counts.get(role) ?? 0) > 1) reasons.add('DUPLICATE_APPLICATION_STACK')
|
||||
if (input.hostMemTotalBytes === 0 || input.hostMemTotalBytes > 2 * 1024 * MiB) reasons.add('HOST_OUTSIDE_2GIB_TARGET')
|
||||
if (input.osReserveBytes < 512 * MiB) reasons.add('OS_RESERVE_BELOW_CANDIDATE_FLOOR')
|
||||
if (!Number.isSafeInteger(configuredBytes + input.osReserveBytes)) throw Error('Installation budget overflow')
|
||||
const plannedBytes = configuredBytes + input.osReserveBytes
|
||||
if (plannedBytes > input.hostMemTotalBytes) reasons.add('INSTALLATION_BUDGET_EXCEEDS_HOST')
|
||||
return Object.freeze({ scope: 'arithmetic-preflight-only', budgetFits: reasons.size === 0,
|
||||
wholeInstallation2GbAccepted: false, runtimeAcceptanceClaimed: false, changesAuthorized: false,
|
||||
configuredContainerBytes: configuredBytes, osReserveBytes: input.osReserveBytes,
|
||||
plannedBytes, remainingBytes: input.hostMemTotalBytes - plannedBytes,
|
||||
observedContainers: input.observedContainerIds.length, reasons: Object.freeze([...reasons].sort()) })
|
||||
}
|
||||
|
||||
/** Project the whitelist-only read-only inventory. Never substitute idle RSS or
|
||||
* Docker's reported host ceiling for HostConfig.Memory. Labels/freshness are
|
||||
* not authenticated here; this result must not be used as mutation authority. */
|
||||
export function evaluateObservedInstallationBudget(inventory, roleById, osReserveBytes = 512 * MiB) {
|
||||
if (!inventory || inventory.readOnly !== true || inventory.containerSetStable !== true
|
||||
|| inventory.environmentRead !== false || inventory.serviceChanges !== false || inventory.newFixtureStarted !== false
|
||||
|| !Array.isArray(inventory.containers) || inventory.containers.length > 128
|
||||
|| !roleById || typeof roleById !== 'object' || Array.isArray(roleById)) throw Error('Invalid read-only installation inventory')
|
||||
const containers = inventory.containers.map(row => {
|
||||
if (!row || !id(row.id) || row.running !== true || !integer(row.memoryLimitBytes)
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(row.imageId) || !Number.isFinite(Date.parse(row.startedAt))) throw Error('Incomplete configured-limit observation')
|
||||
const label = Object.getOwnPropertyDescriptor(roleById, row.id)
|
||||
if (!label || typeof label.value !== 'string' || !roles.has(label.value)) throw Error('Explicit container role missing')
|
||||
return { id: row.id, role: label.value, configuredLimitBytes: row.memoryLimitBytes }
|
||||
})
|
||||
const observedContainerIds = containers.map(row => row.id)
|
||||
if (Object.keys(roleById).length !== observedContainerIds.length || Object.keys(roleById).some(key => !observedContainerIds.includes(key))) throw Error('Foreign or incomplete role inventory')
|
||||
const budget = evaluateInstallationMemoryBudget({ hostMemTotalBytes: inventory.hostMemTotalBytes, osReserveBytes, observedContainerIds, containers })
|
||||
return Object.freeze({ ...budget, configuredLimitsSource: 'observed-HostConfig.Memory', roleLabelsAuthenticated: false, inventoryFreshnessProven: false })
|
||||
}
|
||||
|
||||
/** NEW pure raw-text bridge for measure-memory schema2/3. This is NOT the
|
||||
* separate GET-only inventory contract consumed by evaluateObserved above.
|
||||
* No file/container/socket access or mutation authority. Parse raw text only:
|
||||
* precision lost by a caller's previous JSON.parse cannot be recovered. */
|
||||
export function evaluateMeasuredInstallationBudget(rawText, roleById, osReserveBytes = 512 * MiB) {
|
||||
if (typeof rawText !== 'string') throw Error('Raw measurement JSON text required; preparsed precision cannot be recovered')
|
||||
if (Buffer.byteLength(rawText, 'utf8') > 32 * 1024 * 1024) throw Error('Measurement text bound exceeded')
|
||||
// A narrow numeric lexical gate BEFORE JSON.parse, not a JSON parser. The
|
||||
// Python producer emits only integer JSON numbers (PSI averages are strings).
|
||||
// Reject decimal/exponent/unsafe integers even where JSON.parse would round
|
||||
// a fractional lexeme into a seemingly safe integer. JSON.parse checks syntax.
|
||||
const number = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y
|
||||
let quoted = false, escaped = false
|
||||
for (let index = 0; index < rawText.length; index++) {
|
||||
const char = rawText[index]
|
||||
if (quoted) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === '"') quoted = false; continue }
|
||||
if (char === '"') { quoted = true; continue }
|
||||
if (char === '-' || char >= '0' && char <= '9') {
|
||||
number.lastIndex = index; const token = number.exec(rawText)?.[0]
|
||||
if (!token || token === '-0' || !/^-?(?:0|[1-9][0-9]*)$/.test(token) || token.replace('-', '').length > 16) throw Error('Unsafe or noninteger measurement JSON number')
|
||||
const value = BigInt(token)
|
||||
if (value < -9007199254740991n || value > 9007199254740991n) throw Error('Unsafe measurement JSON integer')
|
||||
index = number.lastIndex - 1
|
||||
}
|
||||
}
|
||||
const data = JSON.parse(rawText)
|
||||
if (!data || ![2, 3].includes(data.schemaVersion) || data.readOnlyDockerAndProc !== true || data.serviceChanges !== 0
|
||||
|| data.summary?.acceptancePassed !== false || !Array.isArray(data.observations) || !data.observations.length || data.observations.length > 300
|
||||
|| !roleById || typeof roleById !== 'object' || Array.isArray(roleById)) throw Error('Unsupported or incomplete memory measurement')
|
||||
const nonnegativeSafe = value => Number.isSafeInteger(value) && value >= 0
|
||||
const limit = value => value === 'max' || nonnegativeSafe(value) ? value : null
|
||||
const swap = row => row.configuredMemorySwapStatus === 'available' && Number.isSafeInteger(row.configuredMemorySwapBytes) && row.configuredMemorySwapBytes >= -1 ? row.configuredMemorySwapBytes : null
|
||||
const cpu = value => value && ['nr_periods', 'nr_throttled', 'throttled_usec'].every(key => nonnegativeSafe(value[key]))
|
||||
? Object.fromEntries(['nr_periods', 'nr_throttled', 'throttled_usec'].map(key => [key, value[key]])) : null
|
||||
const pressure = host => {
|
||||
if (host.memoryPressure?.status !== 'available') return null
|
||||
const result = {}
|
||||
for (const key of ['some', 'full']) {
|
||||
const value = host.memoryPressure.metrics?.[key]
|
||||
if (!value || !nonnegativeSafe(value.totalMicros)) return null
|
||||
for (const avg of ['avg10Percent', 'avg60Percent', 'avg300Percent']) {
|
||||
if (typeof value[avg] !== 'string' || !/^[0-9]{1,3}\.[0-9]{2}$/.test(value[avg]) || Number(value[avg]) > 100) return null
|
||||
}
|
||||
result[key] = Object.fromEntries(['avg10Percent', 'avg60Percent', 'avg300Percent', 'totalMicros'].map(name => [name, value[name]]))
|
||||
}
|
||||
return result
|
||||
}
|
||||
let baseline, hostMemory, priorElapsed = -1, complete = data.schemaVersion === 3
|
||||
const extendedObservations = []
|
||||
for (const observation of data.observations) {
|
||||
if (!observation?.host || !nonnegativeSafe(observation.elapsedMs) || observation.elapsedMs < priorElapsed || observation.elapsedMs > 360000
|
||||
|| !nonnegativeSafe(observation.host.MemTotalBytes) || !observation.host.MemTotalBytes
|
||||
|| !Array.isArray(observation.containers) || !observation.containers.length || observation.containers.length > 16) throw Error('Incomplete bounded measurement sample')
|
||||
priorElapsed = observation.elapsedMs
|
||||
hostMemory ??= observation.host.MemTotalBytes
|
||||
if (hostMemory !== observation.host.MemTotalBytes) throw Error('Measurement host memory changed')
|
||||
const ids = new Set(), identities = [], extended = []
|
||||
for (const row of observation.containers) {
|
||||
if (!row || !id(row.id) || ids.has(row.id) || !/^sha256:[a-f0-9]{64}$/.test(row.imageId) || (typeof row.startedAt !== 'string' || !Number.isFinite(Date.parse(row.startedAt)))
|
||||
|| row.running !== true || row.runningAfter !== true || row.identityStableDuringSample !== true || row.oomKilled !== false || row.oomKilledAfter !== false
|
||||
|| !nonnegativeSafe(row.restartCount) || !Number.isSafeInteger(row.pid) || row.pid < 1 || row.pid > 2147483647 || !nonnegativeSafe(row.configuredLimitBytes)) throw Error('Unavailable measurement container identity or configured cap')
|
||||
ids.add(row.id); identities.push([row.id, row.imageId, row.startedAt, row.pid, row.restartCount, row.configuredLimitBytes, data.schemaVersion === 3 ? swap(row) : null])
|
||||
if (data.schemaVersion === 3) {
|
||||
const metrics = row.cgroupV2?.metrics, memoryMax = limit(metrics?.['memory.max']), swapMax = limit(metrics?.['memory.swap.max'])
|
||||
const cpuThrottling = cpu(metrics?.['cpu.stat']), configuredMemorySwapBytes = swap(row)
|
||||
const available = row.cgroupV2?.status === 'available' && row.resourceLimitsStableDuringSample === true
|
||||
&& memoryMax !== null && swapMax !== null && cpuThrottling !== null && configuredMemorySwapBytes !== null
|
||||
complete &&= available
|
||||
extended.push({id: row.id, memoryMax, swapMax, configuredMemorySwapBytes, cpuThrottling, available})
|
||||
} else extended.push({id: row.id, memoryMax: null, swapMax: null, configuredMemorySwapBytes: null, cpuThrottling: null, available: false})
|
||||
}
|
||||
identities.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
const encoded = JSON.stringify(identities)
|
||||
baseline ??= encoded
|
||||
if (encoded !== baseline) throw Error('Measurement container set, identity or memory cap changed')
|
||||
const memoryPressure = data.schemaVersion === 3 ? pressure(observation.host) : null
|
||||
complete &&= memoryPressure !== null
|
||||
extendedObservations.push({elapsedMs: observation.elapsedMs, memoryPressure, containers: extended})
|
||||
}
|
||||
const captured = JSON.parse(baseline)
|
||||
if (Object.keys(roleById).length !== captured.length || Object.keys(roleById).some(key => !captured.some(row => row[0] === key))) throw Error('Incomplete or foreign measured role inventory')
|
||||
const containers = captured.map(([identifier, _image, _start, _pid, _restart, configuredLimitBytes]) => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(roleById, identifier)
|
||||
if (!descriptor || typeof descriptor.value !== 'string' || !roles.has(descriptor.value)) throw Error('Explicit measured container role missing')
|
||||
return {id: identifier, role: descriptor.value, configuredLimitBytes}
|
||||
})
|
||||
const budget = evaluateInstallationMemoryBudget({hostMemTotalBytes: hostMemory, osReserveBytes, observedContainerIds: containers.map(row => row.id), containers})
|
||||
return Object.freeze({...budget, measurementSchemaVersion: data.schemaVersion, measurementSamples: data.observations.length,
|
||||
extendedMetricsComplete: complete, extendedObservations: Object.freeze(extendedObservations),
|
||||
roleLabelsAuthenticated: false, inventoryFreshnessProven: false, hostContainerInventoryComplete: false, measurementSelectedContainersOnly: true, noSwapAccepted: false, sourceGenerationAuthenticated: false})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { evaluateInstallationMemoryBudget as evaluate } from './installation-memory-budget.mjs'
|
||||
const MiB = 1024 * 1024
|
||||
function fixture() {
|
||||
const containers = [['backend',512],['postgres',384],['frontend',160],['portal',192]].map(([role, limit], i) => ({ id: String(i+1).repeat(64), role, configuredLimitBytes: limit * MiB }))
|
||||
return { hostMemTotalBytes: 2021212 * 1024, osReserveBytes: 512 * MiB, observedContainerIds: containers.map(row => row.id), containers }
|
||||
}
|
||||
test('complete four-service candidate fits arithmetic, never runtime acceptance or change permission', () => {
|
||||
const input = fixture(), before = structuredClone(input), result = evaluate(input)
|
||||
assert.equal(result.budgetFits,true); assert.equal(result.plannedBytes,1760 * MiB)
|
||||
assert.equal(result.wholeInstallation2GbAccepted,false); assert.equal(result.runtimeAcceptanceClaimed,false); assert.equal(result.changesAuthorized,false)
|
||||
assert(Object.isFrozen(result)); assert(Object.isFrozen(result.reasons)); assert.deepEqual(input,before)
|
||||
})
|
||||
test('coexisting baseline and owned app/PG cannot represent one installation', () => {
|
||||
const input = fixture()
|
||||
for (const [i,role,limit] of [[5,'backend',320],[6,'postgres',192]]) { input.containers.push({id:String(i).repeat(64),role,configuredLimitBytes:limit*MiB}); input.observedContainerIds.push(String(i).repeat(64)) }
|
||||
const result = evaluate(input)
|
||||
assert.equal(result.budgetFits,false); assert(result.reasons.includes('DUPLICATE_APPLICATION_STACK')); assert(result.reasons.includes('INSTALLATION_BUDGET_EXCEEDS_HOST'))
|
||||
})
|
||||
test('unknown host containers, missing and foreign inventory entries refuse', () => {
|
||||
for (const mode of ['omitted','foreign','missing-service']) {
|
||||
const input = fixture()
|
||||
if (mode === 'omitted') input.observedContainerIds.push('a'.repeat(64))
|
||||
if (mode === 'foreign') input.containers[0].id='b'.repeat(64)
|
||||
if (mode === 'missing-service') { input.containers.splice(0,1); input.observedContainerIds.splice(0,1) }
|
||||
const result = evaluate(input); assert.equal(result.budgetFits,false)
|
||||
assert(result.reasons.includes(mode === 'missing-service'?'REQUIRED_SERVICE_MISSING':'INCOMPLETE_HOST_CONTAINER_INVENTORY'))
|
||||
}
|
||||
})
|
||||
test('unlimited cap is distinct from observed idle RSS or Docker host limit display', () => {
|
||||
const input = fixture(); input.containers[0].configuredLimitBytes=0
|
||||
assert(evaluate(input).reasons.includes('UNBOUNDED_CONTAINER'))
|
||||
input.containers[0].configuredLimitBytes=3*1024*MiB
|
||||
assert(evaluate(input).reasons.includes('CONTAINER_LIMIT_EXCEEDS_HOST'))
|
||||
})
|
||||
test('extra service, reserve and physical host all count toward the same budget', () => {
|
||||
const input = fixture(); input.containers.push({id:'a'.repeat(64),role:'other',configuredLimitBytes:256*MiB}); input.observedContainerIds.push('a'.repeat(64))
|
||||
assert(evaluate(input).reasons.includes('INSTALLATION_BUDGET_EXCEEDS_HOST'))
|
||||
const low = fixture(); low.osReserveBytes=128*MiB; assert(evaluate(low).reasons.includes('OS_RESERVE_BELOW_CANDIDATE_FLOOR'))
|
||||
const large = fixture(); large.hostMemTotalBytes=4*1024*MiB; assert(evaluate(large).reasons.includes('HOST_OUTSIDE_2GIB_TARGET'))
|
||||
})
|
||||
test('malformed, duplicate, fractional, oversized or unsafe inventory rejects without I/O', () => {
|
||||
const mutations = [input=>{input.inject='unknown'},input=>{input.containers[0].role='invented'},input=>{input.containers[0].configuredLimitBytes=-1},input=>{input.containers[0].configuredLimitBytes=0.5},input=>{input.containers[0].id='../foreign'},input=>{input.containers[1].id=input.containers[0].id},input=>{input.observedContainerIds.push(input.observedContainerIds[0])},input=>{input.containers[0].configuredLimitBytes=Number.MAX_SAFE_INTEGER},input=>{input.observedContainerIds=Array(129).fill('a'.repeat(64))}]
|
||||
for (const mutate of mutations) { const input=fixture();mutate(input);assert.throws(()=>evaluate(input)) }
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {test} from 'node:test'
|
||||
import {evaluateMeasuredInstallationBudget as evaluate,evaluateInstallationMemoryBudget as legacy} from './installation-memory-budget.mjs'
|
||||
const MiB=1024*1024
|
||||
function fixture(version=3){
|
||||
const roles=['backend','postgres','frontend','portal'],caps=[512,384,160,192]
|
||||
const containers=roles.map((role,i)=>({id:String(i+1).repeat(64),imageId:'sha256:'+'a'.repeat(64),startedAt:'2026-11-02T00:00:00Z',pid:i+1,restartCount:0,running:true,runningAfter:true,identityStableDuringSample:true,oomKilled:false,oomKilledAfter:false,configuredLimitBytes:caps[i]*MiB,reportedLimitBytes:2*1024*MiB,
|
||||
...(version===3?{configuredMemorySwapBytes:caps[i]*MiB,configuredMemorySwapStatus:'available',resourceLimitsStableDuringSample:true,cgroupV2:{status:'available',metrics:{'memory.max':caps[i]*MiB,'memory.swap.max':0,'cpu.stat':{nr_periods:10,nr_throttled:2,throttled_usec:1234,notWhitelisted:999}}}}:{cgroupV2:{status:'available',metrics:{'memory.current':1,'memory.peak':2}}})}))
|
||||
const p={avg10Percent:'0.00',avg60Percent:'0.01',avg300Percent:'1.25',totalMicros:123456}
|
||||
const host={MemTotalBytes:2069721088,MemAvailableBytes:1000000000,pswpin:0,pswpout:0,oom_kill:0,...(version===3?{memoryPressure:{status:'available',metrics:{some:p,full:{...p,totalMicros:0}}}}:{})}
|
||||
const data={schemaVersion:version,readOnlyDockerAndProc:true,serviceChanges:0,summary:{acceptancePassed:false},observations:[{elapsedMs:0,host,containers},{elapsedMs:1000,host:structuredClone(host),containers:structuredClone(containers)}]}
|
||||
return {data,roleById:Object.fromEntries(containers.map((c,i)=>[c.id,roles[i]])),raw:()=>JSON.stringify(data)}
|
||||
}
|
||||
test('real schema2 producer contract remains arithmetic-only; missing extended measurements stay unknown',()=>{const f=fixture(2),r=evaluate(f.raw(),f.roleById);assert.equal(r.budgetFits,true);assert.equal(r.plannedBytes,1760*MiB);assert.equal(r.measurementSchemaVersion,2);assert.equal(r.extendedMetricsComplete,false);assert.equal(r.extendedObservations[0].memoryPressure,null);assert.equal(r.extendedObservations[0].containers[0].memoryMax,null);assert.equal(r.wholeInstallation2GbAccepted,false)})
|
||||
test('schema3 projects only fixed new fields and never converts data to runtime/no-swap authority',()=>{const f=fixture(),r=evaluate(f.raw(),f.roleById);assert.equal(r.extendedMetricsComplete,true);assert.equal(r.measurementSamples,2);assert.equal(r.extendedObservations[0].containers[0].cpuThrottling.throttled_usec,1234);assert.equal(r.extendedObservations[0].memoryPressure.some.avg300Percent,'1.25');assert(!JSON.stringify(r).includes('notWhitelisted'));assert.equal(r.runtimeAcceptanceClaimed,false);assert.equal(r.changesAuthorized,false);assert.equal(r.noSwapAccepted,false);assert.equal(r.inventoryFreshnessProven,false);assert.equal(r.hostContainerInventoryComplete,false);assert.equal(r.measurementSelectedContainersOnly,true)})
|
||||
test('numeric unlimited max/zero/-1/daemon-default preserve their different observed meanings',()=>{const f=fixture();for(const o of f.data.observations){const c=o.containers[0];c.cgroupV2.metrics['memory.max']='max';c.cgroupV2.metrics['memory.swap.max']='max';c.configuredMemorySwapBytes=-1}let r=evaluate(f.raw(),f.roleById);assert.equal(r.extendedObservations[0].containers[0].memoryMax,'max');assert.equal(r.extendedObservations[0].containers[0].configuredMemorySwapBytes,-1);assert.equal(r.noSwapAccepted,false);for(const o of f.data.observations)o.containers[0].configuredMemorySwapBytes=0;r=evaluate(f.raw(),f.roleById);assert.equal(r.extendedObservations[0].containers[0].configuredMemorySwapBytes,0)})
|
||||
test('missing schema3 fields, partial cgroup and invalid PSI are unknown rather than zero',()=>{for(const mutate of [o=>{delete o.containers[0].configuredMemorySwapBytes},o=>{o.containers[0].cgroupV2.status='partial'},o=>{o.containers[0].cgroupV2.metrics['memory.max']=null},o=>{delete o.containers[0].cgroupV2.metrics['cpu.stat'].nr_periods},o=>{o.host.memoryPressure.metrics.some.avg10Percent='100.01'},o=>{o.host.memoryPressure.status='unavailable'}]){const f=fixture();for(const o of f.data.observations)mutate(o);const r=evaluate(f.raw(),f.roleById);assert.equal(r.extendedMetricsComplete,false);assert.equal(r.wholeInstallation2GbAccepted,false)}})
|
||||
test('unsafe uint64 raw lexemes fail before JSON.parse; no rounded Number is recovered',()=>{for(const token of ['9007199254740992','9007199254740993','18446744073709551615','1.0000000000000001','9007199254740991.1','1e0','-0']){const f=fixture(),raw=f.raw().replace('123456',token);assert.throws(()=>evaluate(raw,f.roleById),/Unsafe|noninteger/);assert.throws(()=>evaluate(JSON.parse(raw),f.roleById),/Raw measurement JSON text required/)} })
|
||||
test('safe integer boundary is exact and large number-like text inside strings is not parsed as a counter',()=>{const f=fixture();for(const o of f.data.observations)o.host.memoryPressure.metrics.some.totalMicros=Number.MAX_SAFE_INTEGER;f.data.ignored='9007199254740993 1.0000000000000001 \\" escaped';const r=evaluate(f.raw(),f.roleById);assert.equal(r.extendedObservations[0].memoryPressure.some.totalMicros,Number.MAX_SAFE_INTEGER);assert(!JSON.stringify(r).includes('ignored'))})
|
||||
test('malformed JSON, unsupported schemas or claimed producer acceptance refuse',()=>{const f=fixture();assert.throws(()=>evaluate('{bad',f.roleById));for(const mutate of [d=>{d.schemaVersion=1},d=>{d.schemaVersion=4},d=>{d.serviceChanges=1},d=>{d.readOnlyDockerAndProc=false},d=>{d.summary.acceptancePassed=true},d=>{d.observations=[]}]){const x=fixture();mutate(x.data);assert.throws(()=>evaluate(x.raw(),x.roleById))}})
|
||||
test('full IDs, complete stable container set, OOM/running/process/image/caps stay mandatory',()=>{for(const mutate of [d=>{d.observations[1].containers.pop()},d=>{d.observations[1].containers.push(d.observations[1].containers[0])},d=>{d.observations[1].containers[0].pid++},d=>{d.observations[1].containers[0].runningAfter=false},d=>{d.observations[1].containers[0].oomKilledAfter=true},d=>{d.observations[1].containers[0].configuredLimitBytes++},d=>{d.observations[1].containers[0].configuredMemorySwapBytes++},d=>{d.observations[1].host.MemTotalBytes++},d=>{d.observations[1].containers[0].startedAt=123}]){const f=fixture();mutate(f.data);assert.throws(()=>evaluate(f.raw(),f.roleById))}})
|
||||
test('role labels remain exact own DATA descriptors, never an inherited/accessor authority',()=>{for(const kind of ['getter','inherited','missing','foreign']){const f=fixture(),key=f.data.observations[0].containers[0].id;if(kind==='getter')Object.defineProperty(f.roleById,key,{enumerable:true,get(){assert.fail('Getter executed')}});if(kind==='inherited'){const x=Object.create({[key]:'backend'});for(const [id,role]of Object.entries(f.roleById))if(id!==key)x[id]=role;f.roleById=x}if(kind==='missing')delete f.roleById[key];if(kind==='foreign')f.roleById['f'.repeat(64)]='other';assert.throws(()=>evaluate(f.raw(),f.roleById))}})
|
||||
test('legacy arithmetic export keeps soft metadata and no-runtime semantics',()=>{const f=fixture(),input={hostMemTotalBytes:2069721088,osReserveBytes:512*MiB,observedContainerIds:Object.keys(f.roleById),containers:f.data.observations[0].containers.map(c=>({id:c.id,role:f.roleById[c.id],configuredLimitBytes:c.configuredLimitBytes}))};assert.equal(legacy(input).budgetFits,true);assert.equal(legacy(input).changesAuthorized,false)})
|
||||
import {readFileSync} from 'node:fs'
|
||||
test('actual base/candidate Python producers interoperate through synthetic raw wire files',()=>{for(const version of [2,3]){const raw=readFileSync(new URL('./schema'+version+'.main.synthetic.json',import.meta.url),'utf8');const r=evaluate(raw,{['a'.repeat(64)]:'backend'});assert.equal(r.measurementSchemaVersion,version);assert.equal(r.extendedMetricsComplete,version===3);assert.equal(r.budgetFits,false);assert.equal(r.wholeInstallation2GbAccepted,false);if(version===3){assert.equal(r.extendedObservations[0].containers[0].configuredMemorySwapBytes,1024);assert.equal(r.extendedObservations[0].memoryPressure.some.totalMicros,123456)}}})
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { evaluateObservedInstallationBudget as evaluate } from './installation-memory-budget.mjs'
|
||||
const MiB=1024*1024
|
||||
function fixture() {
|
||||
const containers=['backend','postgres','frontend','portal'].map((role,index)=>({id:String(index+1).repeat(64),role,running:true,imageId:'sha256:'+'a'.repeat(64),startedAt:'2026-10-28T12:00:00Z',memoryLimitBytes:0,reportedLimitBytes:2*1024*MiB}))
|
||||
const roleById=Object.fromEntries(containers.map(row=>[row.id,row.role]))
|
||||
return {inventory:{readOnly:true,containerSetStable:true,environmentRead:false,serviceChanges:false,newFixtureStarted:false,hostMemTotalBytes:2021212*1024,containers},roleById}
|
||||
}
|
||||
test('observed zero configured caps stay unbounded despite positive Docker reported host limits',()=>{
|
||||
const f=fixture(), result=evaluate(f.inventory,f.roleById)
|
||||
assert.equal(result.budgetFits,false);assert(result.reasons.includes('UNBOUNDED_CONTAINER'));assert.equal(result.configuredContainerBytes,0)
|
||||
assert.equal(result.wholeInstallation2GbAccepted,false);assert.equal(result.roleLabelsAuthenticated,false);assert.equal(result.inventoryFreshnessProven,false)
|
||||
})
|
||||
test('complete explicit configured caps can fit arithmetic without becoming runtime acceptance',()=>{
|
||||
const f=fixture();[512,384,160,192].forEach((limit,index)=>{f.inventory.containers[index].memoryLimitBytes=limit*MiB})
|
||||
const result=evaluate(f.inventory,f.roleById);assert(result.budgetFits);assert.equal(result.plannedBytes,1760*MiB);assert.equal(result.changesAuthorized,false)
|
||||
})
|
||||
test('uncertain snapshots, started fixtures and missing limit/identity fields refuse',()=>{
|
||||
for(const mutate of [f=>{f.inventory.containerSetStable=false},f=>{f.inventory.serviceChanges=true},f=>{f.inventory.newFixtureStarted=true},f=>{delete f.inventory.containers[0].memoryLimitBytes},f=>{f.inventory.containers[0].running=false},f=>{f.inventory.containers[0].imageId='unknown'},f=>{f.inventory.containers[0].startedAt='invalid'}]){const f=fixture();mutate(f);assert.throws(()=>evaluate(f.inventory,f.roleById))}
|
||||
})
|
||||
test('missing, foreign, inherited and accessor role labels cannot silently classify a service',()=>{
|
||||
for(const mode of ['missing','foreign','inherited','accessor']){
|
||||
const f=fixture(),key=f.inventory.containers[0].id
|
||||
if(mode==='missing')delete f.roleById[key]
|
||||
if(mode==='foreign')f.roleById['f'.repeat(64)]='other'
|
||||
if(mode==='inherited'){const labels=Object.create({[key]:'backend'});for(const [id,role]of Object.entries(f.roleById))if(id!==key)labels[id]=role;f.roleById=labels}
|
||||
if(mode==='accessor')Object.defineProperty(f.roleById,key,{enumerable:true,get(){assert.fail('Role getter must not execute')}})
|
||||
assert.throws(()=>evaluate(f.inventory,f.roleById))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own203.invalid';process.env.AUTH_PORTAL_URL='https://own203.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const secrets={s3:'own203-'+randomBytes(16).toString('hex'),evo:'own203-'+randomBytes(16).toString('hex'),telegram:'12345:'+randomBytes(24).toString('base64url')},cases=[];let app,requests=0;const originalFetch=globalThis.fetch;globalThis.fetch=async()=>{requests++;throw Error('External fetch forbidden in owned secret DTO test')}
|
||||
const report={date:'2026-10-13',block:'12.5.203',database,scope:'Actual compiled buildApp/auth/GET handlers and own restricted PostgreSQL with nonempty synthetic credentials; isolated signed test JWT, not real Portal login',cases,passed:false}
|
||||
try{
|
||||
await initDatabase()
|
||||
await pool.query("INSERT INTO backup_storage_settings(id,provider,s3_endpoint,s3_access_key_id,s3_secret_access_key) VALUES(1,'local','https://s3.own.invalid','OWN203_PUBLIC_ACCESS_ID',$1) ON CONFLICT(id) DO UPDATE SET s3_secret_access_key=$1",[secrets.s3])
|
||||
await pool.query("INSERT INTO evobgp_settings(id,base_url,api_key,enabled) VALUES(1,'https://evo.own.invalid',$1,false) ON CONFLICT(id) DO UPDATE SET api_key=$1",[secrets.evo])
|
||||
await pool.query("INSERT INTO alert_telegram_settings(id,bot_token,chat_id) VALUES(1,$1,'0') ON CONFLICT(id) DO UPDATE SET bot_token=$1",[secrets.telegram])
|
||||
app=await buildApp({logger:false,startScheduler:false});await app.ready()
|
||||
for(const role of ['reader','admin']){const permissions=['mm:backups:read','mm:alerts:read',...(role==='admin'?['mm:settings:admin']:[])];const jwt=app.jwt.sign({sub:'own203-'+role,apps:['mm'],permissions,iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});for(const path of ['/api/backups/storage','/api/evobgp/settings','/api/alerts']){const r=await app.inject({method:'GET',url:path,headers:{authorization:'Bearer '+jwt}});assert.equal(r.statusCode,role==='reader'&&path.includes('evobgp')?403:200);for(const secret of Object.values(secrets))assert(!r.body.includes(secret));if(r.statusCode===200){const dto=r.json();if(path.includes('storage')||path.includes('evobgp'))assert.equal(dto.secretConfigured,true);else assert.equal(dto.telegram.tokenConfigured,true)}cases.push({role,path,status:r.statusCode,syntheticSecretExposed:false,configuredFlag:r.statusCode===200});}}
|
||||
for(const path of ['/api/backups/storage','/api/evobgp/settings','/api/alerts']){assert.equal((await app.inject({method:'GET',url:path})).statusCode,401);cases.push({role:'unauthenticated',path,status:401})}
|
||||
assert.equal((await pool.query('SELECT s3_secret_access_key FROM backup_storage_settings WHERE id=1')).rows[0].s3_secret_access_key,secrets.s3);assert.equal((await pool.query('SELECT api_key FROM evobgp_settings WHERE id=1')).rows[0].api_key,secrets.evo);assert.equal((await pool.query('SELECT bot_token FROM alert_telegram_settings WHERE id=1')).rows[0].bot_token,secrets.telegram);assert.equal(requests,0);report.externalRequests=0;report.storedSyntheticSecretsUnchanged=true;report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/secret203-private.log',String(e.stack??e),{mode:0o600})}
|
||||
finally{globalThis.fetch=originalFetch;if(app)await app.close();await closePool();fs.writeFileSync('/app/backend/acceptance/secret203-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}
|
||||
if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Synthetic-only: no Docker/socket, real /proc/cgroup reads, process enumeration or main()."""
|
||||
import copy
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
AREA = Path(__file__).parent
|
||||
spec = importlib.util.spec_from_file_location('memory_measurement_extended', AREA / 'measure-memory.py')
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
MEMINFO = 'MemTotal: 2048 kB\nMemAvailable: 1024 kB\nSwapTotal: 4096 kB\nSwapFree: 3072 kB\n'
|
||||
VMSTAT = 'pswpin 20\npswpout 30\noom_kill 0\n'
|
||||
PSI = 'some avg10=0.00 avg60=1.25 avg300=100.00 total=123456\nfull avg10=0.00 avg60=0.01 avg300=1.00 total=0\n'
|
||||
ID = 'a' * 64
|
||||
CG = '/sys/fs/cgroup/system.slice/docker-' + ID + '.scope'
|
||||
|
||||
class Synthetic(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.reads = []
|
||||
self.gets = []
|
||||
self.before = {'Id': ID, 'Image': 'sha256:' + 'b' * 64, 'RestartCount': 0,
|
||||
'State': {'StartedAt': '2026-11-02T00:00:00Z', 'Pid': 123, 'Running': True, 'OOMKilled': False},
|
||||
'HostConfig': {'Memory': 512, 'MemorySwap': 1024}, 'Config': {'Env': ['SECRET=never-retain']}}
|
||||
self.stats = {'id': ID, 'memory_stats': {'usage': 100, 'limit': 2048, 'stats': {'inactive_file': 30}}}
|
||||
self.files = {'/proc/meminfo': MEMINFO, '/proc/vmstat': VMSTAT, '/proc/pressure/memory': PSI,
|
||||
'/proc/self/mountinfo': '1 0 0:1 / /sys/fs/cgroup rw - cgroup2 cgroup rw\n',
|
||||
'/proc/123/cgroup': '0::/system.slice/docker-' + ID + '.scope\n',
|
||||
'/proc/123/stat': '123 (owned fixture) S ' + ' '.join(['0'] * 18 + ['987'] + ['0'] * 8),
|
||||
CG + '/memory.current': '100\n', CG + '/memory.peak': '200\n',
|
||||
CG + '/memory.events': 'low 0\nhigh 0\nmax 0\noom 0\noom_kill 0\n',
|
||||
CG + '/memory.swap.current': '0\n', CG + '/memory.swap.peak': '0\n',
|
||||
CG + '/memory.swap.events': 'high 0\nmax 0\nfail 0\n',
|
||||
CG + '/memory.max': '512\n', CG + '/memory.swap.max': '512\n',
|
||||
CG + '/cpu.stat': 'usage_usec 50\nuser_usec 30\nsystem_usec 20\nnr_periods 10\nnr_throttled 2\nthrottled_usec 1234\nnr_bursts 0\n'}
|
||||
def deny(*_args, **_kwargs):
|
||||
raise AssertionError('Real external/OS I/O is forbidden by synthetic test')
|
||||
# Module-local OS proxy avoids changing unittest/Python's filesystem operations.
|
||||
fake_os = SimpleNamespace(**vars(m.os))
|
||||
fake_os.path = SimpleNamespace(**vars(m.os.path))
|
||||
fake_os.sep = '/' # synthetic Linux path contract, not this Windows host
|
||||
fake_os.open = deny
|
||||
fake_os.stat = deny
|
||||
fake_os.path.realpath = deny
|
||||
self.enterContext(patch.object(m, 'os', fake_os))
|
||||
self.enterContext(patch.object(m, 'read_bounded', side_effect=deny))
|
||||
self.enterContext(patch.object(m, 'docker_get', side_effect=deny))
|
||||
self.enterContext(patch.object(m.socket, 'socket', side_effect=deny))
|
||||
|
||||
def read(self, name, expected_device=None, deadline=None):
|
||||
self.reads.append(name)
|
||||
if name not in self.files:
|
||||
raise FileNotFoundError('synthetic missing')
|
||||
value = self.files[name]
|
||||
if isinstance(value, BaseException):
|
||||
raise value
|
||||
return value
|
||||
|
||||
def observe(self, after=None, realpath=None, statter=None):
|
||||
after = copy.deepcopy(self.before) if after is None else after
|
||||
def get(name, deadline=None):
|
||||
self.gets.append(name)
|
||||
if self.gets == ['/containers/' + ID + '/json']:
|
||||
return copy.deepcopy(self.before)
|
||||
if self.gets == ['/containers/' + ID + '/json', '/containers/' + ID + '/stats?stream=false&one-shot=true']:
|
||||
return copy.deepcopy(self.stats)
|
||||
if self.gets == ['/containers/' + ID + '/json', '/containers/' + ID + '/stats?stream=false&one-shot=true', '/containers/' + ID + '/json']:
|
||||
return copy.deepcopy(after)
|
||||
raise AssertionError('Unexpected Docker path or extra request')
|
||||
def statter_default(name):
|
||||
allowed = {'/sys/fs/cgroup': (8, 1), CG: (8, 2), '/proc/123/root': (8, 3)}
|
||||
if name not in allowed:
|
||||
raise AssertionError('Unexpected stat path')
|
||||
dev, ino = allowed[name]
|
||||
return SimpleNamespace(st_dev=dev, st_ino=ino)
|
||||
with patch.object(m, 'read_bounded', side_effect=self.read), patch.object(m, 'docker_get', side_effect=get), \
|
||||
patch.object(m.os.path, 'realpath', side_effect=realpath or (lambda p: p)), \
|
||||
patch.object(m.os, 'stat', side_effect=statter or statter_default):
|
||||
return m.collect_container(ID)
|
||||
|
||||
def test_limits_max_zero_finite_are_not_missing_or_host_ram(self):
|
||||
for value, expected in [('max\n', 'max'), ('max', 'max'), ('0\n', 0), ('512\n', 512), (str(2**64-1), 2**64-1)]:
|
||||
self.assertEqual(m.parse_limit(value), expected)
|
||||
for bad in ('MAX', '-1', ' 0', '00', '1.0', str(2**64), 'max\n\n'):
|
||||
with self.assertRaises(ValueError): m.parse_limit(bad)
|
||||
|
||||
def test_cpu_throttling_units_required_fields_and_output_whitelist(self):
|
||||
parsed = m.parse_cpu_throttling(self.files[CG + '/cpu.stat'])
|
||||
self.assertEqual(parsed, {'nr_periods':10,'nr_throttled':2,'throttled_usec':1234})
|
||||
for bad in ('nr_periods 1\nnr_throttled 0\n', 'nr_periods 1\nnr_periods 2\nnr_throttled 0\nthrottled_usec 0\n', 'nr_periods -1\nnr_throttled 0\nthrottled_usec 0\n'):
|
||||
with self.assertRaises(ValueError): m.parse_cpu_throttling(bad)
|
||||
|
||||
def test_pressure_fixed_groups_percent_and_microseconds(self):
|
||||
parsed = m.parse_memory_pressure(PSI)
|
||||
self.assertEqual(parsed['some'], {'avg10Percent':'0.00','avg60Percent':'1.25','avg300Percent':'100.00','totalMicros':123456})
|
||||
self.assertEqual(parsed['full']['totalMicros'], 0)
|
||||
for bad in (PSI.replace('100.00','100.01'), PSI.replace('1.25','nan'), PSI.replace('total=0','total=-1'), PSI.splitlines()[0], PSI.replace('full','some'), PSI+'extra\n', PSI.replace('total=0','total='+str(2**64))):
|
||||
with self.assertRaises(ValueError): m.parse_memory_pressure(bad)
|
||||
|
||||
def test_docker_memoryswap_raw_minus1_zero_finite_and_missing_differ(self):
|
||||
for raw in (-1,0,512,2**63-1):
|
||||
item = copy.deepcopy(self.before); item['HostConfig']['MemorySwap'] = raw
|
||||
self.assertEqual(m.configured_memory_swap(item), raw)
|
||||
for bad in (None,False,True,'0',-2,2**63):
|
||||
item = copy.deepcopy(self.before); item['HostConfig']['MemorySwap'] = bad
|
||||
self.assertIsNone(m.configured_memory_swap(item))
|
||||
for host in ({},None,[]):
|
||||
self.assertIsNone(m.configured_memory_swap({'HostConfig':host}))
|
||||
|
||||
def test_real_collection_paths_are_only_fixed_host_and_retained_cgroup(self):
|
||||
row = self.observe()
|
||||
self.assertTrue(row['identityStableDuringSample'])
|
||||
self.assertTrue(row['resourceLimitsStableDuringSample'])
|
||||
self.assertEqual(row['configuredMemorySwapBytes'], 1024)
|
||||
self.assertEqual(row['cgroupV2']['status'], 'available')
|
||||
self.assertEqual(row['cgroupV2']['metrics']['memory.max'], 512)
|
||||
self.assertEqual(row['cgroupV2']['metrics']['cpu.stat']['throttled_usec'], 1234)
|
||||
self.assertTrue(set(self.reads).issubset(set(self.files)))
|
||||
self.assertNotIn('SECRET', str(row)); self.assertNotIn('Env', str(row))
|
||||
self.assertEqual(len(self.gets), 3)
|
||||
|
||||
def test_unlimited_limits_do_not_become_numeric_zero(self):
|
||||
self.files[CG+'/memory.max']='max\n'; self.files[CG+'/memory.swap.max']='max\n'
|
||||
row=self.observe();self.assertEqual(row['cgroupV2']['metrics']['memory.max'],'max');self.assertEqual(row['cgroupV2']['metrics']['memory.swap.max'],'max')
|
||||
|
||||
def test_each_new_cgroup_source_missing_or_malformed_is_unknown(self):
|
||||
for name in ('memory.max','memory.swap.max','cpu.stat'):
|
||||
for value in (None,'not-a-counter\n'):
|
||||
with self.subTest(name=name,value=value):
|
||||
self.setUp_fixture_only()
|
||||
if value is None: self.files.pop(CG+'/'+name)
|
||||
else: self.files[CG+'/'+name]=value
|
||||
row=self.observe();self.assertEqual(row['cgroupV2']['status'],'partial');self.assertIsNone(row['cgroupV2']['metrics'][name]);self.assertIn(name,row['cgroupV2']['unavailable'])
|
||||
def setUp_fixture_only(self):
|
||||
# Recreate dictionary, not guards/patches; no real I/O.
|
||||
self.files.update({CG+'/memory.max':'512\n',CG+'/memory.swap.max':'512\n',CG+'/cpu.stat':'nr_periods 10\nnr_throttled 2\nthrottled_usec 1234\n'});self.gets=[]
|
||||
|
||||
def test_missing_or_changed_swap_configuration_cannot_authenticate_limits(self):
|
||||
after=copy.deepcopy(self.before);after['HostConfig']['MemorySwap']=2048
|
||||
row=self.observe(after);self.assertFalse(row['resourceLimitsStableDuringSample']);self.assertEqual(row['cgroupV2']['status'],'partial');self.assertIsNone(row['cgroupV2']['metrics']['memory.swap.max'])
|
||||
self.gets=[];self.before['HostConfig'].pop('MemorySwap');after=copy.deepcopy(self.before)
|
||||
row=self.observe(after);self.assertIsNone(row['configuredMemorySwapBytes']);self.assertEqual(row['configuredMemorySwapStatus'],'unavailable');self.assertIsNone(row['resourceLimitsStableDuringSample']);self.assertEqual(row['cgroupV2']['status'],'partial')
|
||||
|
||||
def test_memory_limit_change_cannot_reuse_stable_container_identity_as_limit_proof(self):
|
||||
after=copy.deepcopy(self.before);after['HostConfig']['Memory']=1024
|
||||
row=self.observe(after)
|
||||
self.assertTrue(row['identityStableDuringSample'])
|
||||
self.assertFalse(row['resourceLimitsStableDuringSample'])
|
||||
for key in ('memory.max','memory.swap.max'):
|
||||
self.assertIsNone(row['cgroupV2']['metrics'][key])
|
||||
self.assertEqual(row['cgroupV2']['unavailable'][key],'container-resource-limits-changed-or-unavailable')
|
||||
|
||||
def test_legacy_projection_fields_keep_cache_limit_and_rss_distinctions(self):
|
||||
item=copy.deepcopy(self.before);item['HostConfig'].pop('MemorySwap');item['HostConfig']['Memory']=0
|
||||
row=m.project_container(item,self.stats,ID)
|
||||
self.assertEqual(row['usageIncludingCacheBytes'],100)
|
||||
self.assertEqual(row['usageMinusInactiveFileBytes'],70)
|
||||
self.assertEqual(row['configuredLimitBytes'],0)
|
||||
self.assertEqual(row['reportedLimitBytes'],2048)
|
||||
self.assertEqual(row['configuredMemorySwapStatus'],'unavailable')
|
||||
self.assertIsNone(row['configuredMemorySwapBytes'])
|
||||
|
||||
def test_identity_drift_symlink_and_pid_generation_still_refuse(self):
|
||||
after=copy.deepcopy(self.before);after['State']['Pid']=456
|
||||
row=self.observe(after);self.assertFalse(row['identityStableDuringSample']);self.assertIsNone(row['resourceLimitsStableDuringSample']);self.assertEqual(row['cgroupV2']['status'],'unavailable')
|
||||
self.gets=[];row=self.observe(realpath=lambda p:'/other' if p.endswith('memory.swap.max') else p);self.assertEqual(row['cgroupV2']['status'],'partial');self.assertIsNone(row['cgroupV2']['metrics']['memory.swap.max'])
|
||||
self.gets=[]
|
||||
with patch.object(m,'process_generation',side_effect=[(1,8,3),(2,8,3)]):
|
||||
self.assertEqual(self.observe()['cgroupV2']['status'],'unavailable')
|
||||
|
||||
def test_host_collection_whitelists_one_psi_file_and_missing_is_explicit(self):
|
||||
with patch.object(m,'read_bounded',side_effect=self.read):
|
||||
host=m.collect_host();self.assertEqual(host['memoryPressure']['status'],'available');self.assertEqual(host['MemTotalBytes'],2048*1024)
|
||||
self.assertEqual(self.reads,['/proc/meminfo','/proc/vmstat','/proc/pressure/memory'])
|
||||
self.files.pop('/proc/pressure/memory');host=m.collect_host();self.assertEqual(host['memoryPressure']['status'],'unavailable');self.assertIsNone(host['memoryPressure']['metrics'])
|
||||
|
||||
def test_deadline_never_becomes_partial_or_success(self):
|
||||
self.files[CG+'/cpu.stat']=m.ObservationDeadlineExceeded('fixture-deadline')
|
||||
with self.assertRaises(m.ObservationDeadlineExceeded): self.observe()
|
||||
with patch.object(m,'read_bounded',side_effect=[MEMINFO,VMSTAT,m.ObservationDeadlineExceeded('fixture-deadline')]):
|
||||
with self.assertRaises(m.ObservationDeadlineExceeded): m.collect_host()
|
||||
|
||||
def test_summary_exposes_unknown_and_never_authorizes_runtime(self):
|
||||
row=self.observe()
|
||||
with patch.object(m,'read_bounded',side_effect=self.read):host=m.collect_host()
|
||||
sample={'elapsedMs':0,'host':host,'containers':[row]}
|
||||
out=m.summarize([sample]);self.assertTrue(out['allResourceLimitsStableDuringSample']);self.assertTrue(out['allHostMemoryPressureAvailable']);self.assertFalse(out['acceptancePassed'])
|
||||
row['resourceLimitsStableDuringSample']=None;host['memoryPressure']['status']='unavailable'
|
||||
out=m.summarize([sample]);self.assertFalse(out['allResourceLimitsStableDuringSample']);self.assertFalse(out['allHostMemoryPressureAvailable']);self.assertFalse(out['acceptancePassed'])
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
@@ -0,0 +1,459 @@
|
||||
"""Bounded Linux/Docker memory observation; GET only, no service changes.
|
||||
|
||||
Run alongside the named acceptance workload. Output is evidence, never an
|
||||
automatic declaration that a 2 GB deployment has passed its load acceptance.
|
||||
Requires Python 3 and read access to /proc and the local Docker socket.
|
||||
"""
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
|
||||
MAX_RESPONSE = 4 * 1024 * 1024
|
||||
MAX_FILE_BYTES = 65536
|
||||
MAX_FILE_LINES = 256
|
||||
CGROUP_ROOT = '/sys/fs/cgroup'
|
||||
CGROUP_FILES = ('memory.current', 'memory.peak', 'memory.events',
|
||||
'memory.swap.current', 'memory.swap.peak', 'memory.swap.events',
|
||||
'memory.max', 'memory.swap.max', 'cpu.stat')
|
||||
|
||||
|
||||
class CgroupUnavailable(ValueError):
|
||||
"""Fixed reason codes only; never include private paths or raw content."""
|
||||
|
||||
|
||||
class ObservationDeadlineExceeded(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def check_deadline(deadline):
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
raise ObservationDeadlineExceeded('Observation time bound exceeded')
|
||||
|
||||
|
||||
def read_bounded(path, expected_device=None, deadline=None):
|
||||
# Fixed production paths only; no CLI path or executable is accepted.
|
||||
check_deadline(deadline)
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0))
|
||||
with os.fdopen(descriptor, 'rb') as handle:
|
||||
identity = os.fstat(handle.fileno())
|
||||
if not stat.S_ISREG(identity.st_mode) or (expected_device is not None and identity.st_dev != expected_device):
|
||||
raise ValueError('Non-regular observation')
|
||||
raw = handle.read(MAX_FILE_BYTES + 1)
|
||||
check_deadline(deadline)
|
||||
if len(raw) > MAX_FILE_BYTES:
|
||||
raise ValueError('Observation file too large')
|
||||
text = raw.decode('ascii')
|
||||
if len(text.splitlines()) > MAX_FILE_LINES:
|
||||
raise ValueError('Too many observation lines')
|
||||
return text
|
||||
|
||||
|
||||
def parse_counter(text):
|
||||
if not re.fullmatch(r'(?:0|[1-9][0-9]{0,19})\n?', text):
|
||||
raise ValueError('Invalid counter')
|
||||
value = int(text)
|
||||
if value > 2 ** 64 - 1:
|
||||
raise ValueError('Counter overflow')
|
||||
return value
|
||||
|
||||
|
||||
def parse_events(text, required):
|
||||
lines = text.splitlines()
|
||||
if not 1 <= len(lines) <= 32:
|
||||
raise ValueError('Invalid event count')
|
||||
result = {}
|
||||
for line in lines:
|
||||
fields = line.split(' ')
|
||||
if len(fields) != 2 or not re.fullmatch(r'[a-z_]{1,64}', fields[0]) or fields[0] in result:
|
||||
raise ValueError('Invalid event')
|
||||
result[fields[0]] = parse_counter(fields[1])
|
||||
if not set(required).issubset(result):
|
||||
raise ValueError('Incomplete events')
|
||||
return result
|
||||
|
||||
|
||||
|
||||
def parse_limit(text):
|
||||
# Preserve unlimited vs zero vs unavailable; never substitute host RAM.
|
||||
return 'max' if text in ('max', 'max\n') else parse_counter(text)
|
||||
|
||||
|
||||
def parse_cpu_throttling(text):
|
||||
required = ('nr_periods', 'nr_throttled', 'throttled_usec')
|
||||
parsed = parse_events(text, required)
|
||||
# Kernel extensions are validated but do not expand the output whitelist.
|
||||
return {key: parsed[key] for key in required}
|
||||
|
||||
|
||||
def parse_memory_pressure(text):
|
||||
rows = text.splitlines()
|
||||
if len(rows) != 2:
|
||||
raise ValueError('Incomplete memory pressure')
|
||||
result = {}
|
||||
for line in rows:
|
||||
match = re.fullmatch(r'(some|full) avg10=([0-9]{1,3}\.[0-9]{2}) avg60=([0-9]{1,3}\.[0-9]{2}) avg300=([0-9]{1,3}\.[0-9]{2}) total=((?:0|[1-9][0-9]{0,19}))', line)
|
||||
if not match or match[1] in result:
|
||||
raise ValueError('Invalid memory pressure')
|
||||
for value in match.group(2, 3, 4):
|
||||
whole, fraction = value.split('.')
|
||||
if int(whole) * 100 + int(fraction) > 10000:
|
||||
raise ValueError('Invalid memory pressure percentage')
|
||||
result[match[1]] = {'avg10Percent': match[2], 'avg60Percent': match[3],
|
||||
'avg300Percent': match[4], 'totalMicros': parse_counter(match[5])}
|
||||
if set(result) != {'some', 'full'}:
|
||||
raise ValueError('Incomplete memory pressure')
|
||||
return result
|
||||
|
||||
|
||||
def configured_memory_swap(inspect):
|
||||
# Docker MemorySwap is TOTAL memory+swap; 0 means daemon default, -1 unlimited.
|
||||
host = inspect.get('HostConfig')
|
||||
value = host.get('MemorySwap') if isinstance(host, dict) else None
|
||||
if type(value) is not int or not -1 <= value <= 2 ** 63 - 1:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def resource_limits_stable(before, after):
|
||||
values = []
|
||||
for item in (before, after):
|
||||
host = item.get('HostConfig')
|
||||
memory = host.get('Memory') if isinstance(host, dict) else None
|
||||
swap = configured_memory_swap(item)
|
||||
if type(memory) is not int or not 0 <= memory <= 2 ** 63 - 1 or swap is None:
|
||||
return None
|
||||
values.append((memory, swap))
|
||||
return values[0] == values[1]
|
||||
|
||||
|
||||
def collect_host(deadline=None):
|
||||
memory = parse_host(read_bounded('/proc/meminfo', deadline=deadline),
|
||||
read_bounded('/proc/vmstat', deadline=deadline))
|
||||
try:
|
||||
pressure = parse_memory_pressure(read_bounded('/proc/pressure/memory', deadline=deadline))
|
||||
memory['memoryPressure'] = {'status': 'available', 'metrics': pressure, 'reason': None}
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
memory['memoryPressure'] = {'status': 'unavailable', 'metrics': None,
|
||||
'reason': 'missing-unreadable-or-malformed'}
|
||||
return memory
|
||||
|
||||
|
||||
def container_identity(inspect, identifier):
|
||||
state = inspect['State']
|
||||
if (inspect.get('Id') != identifier or not re.fullmatch(r'[a-f0-9]{64}', identifier)
|
||||
or type(state.get('Pid')) is not int or not 0 < state['Pid'] <= 2 ** 31 - 1
|
||||
or state.get('Running') is not True or type(state.get('OOMKilled')) is not bool
|
||||
or not isinstance(state.get('StartedAt'), str) or not state['StartedAt']
|
||||
or type(inspect.get('RestartCount')) is not int or inspect['RestartCount'] < 0
|
||||
or not isinstance(inspect.get('Image'), str) or not inspect['Image']):
|
||||
raise ValueError('Unavailable container identity')
|
||||
return (identifier, inspect['Image'], state['StartedAt'], state['Pid'], inspect['RestartCount'], state['OOMKilled'])
|
||||
|
||||
|
||||
def cgroup_location(pid, identifier, deadline=None):
|
||||
check_deadline(deadline)
|
||||
mounts = read_bounded('/proc/self/mountinfo', deadline=deadline).splitlines()
|
||||
# Namespace-relative/subtree mounts are deliberately unsupported: their
|
||||
# root cannot authenticate a host container's complete cgroup membership.
|
||||
matches = [line.split(' - ', 1) for line in mounts if ' - ' in line]
|
||||
if not any(left.split()[3:5] == ['/', CGROUP_ROOT] and right.split()[0] == 'cgroup2'
|
||||
for left, right in matches):
|
||||
raise CgroupUnavailable('unsupported-or-unavailable-host-cgroup2-mount')
|
||||
membership = read_bounded('/proc/' + str(pid) + '/cgroup', deadline=deadline)
|
||||
lines = membership.splitlines()
|
||||
if len(lines) != 1 or not lines[0].startswith('0::/'):
|
||||
raise CgroupUnavailable('unsupported-v1-hybrid-or-malformed-membership')
|
||||
relative = lines[0][3:]
|
||||
parts = relative.split('/')[1:]
|
||||
if (not parts or any(not p or p in ('.', '..') or not re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}', p) for p in parts)
|
||||
or parts[-1] not in (identifier, 'docker-' + identifier + '.scope')):
|
||||
raise ValueError('Unbound cgroup membership')
|
||||
root = os.path.realpath(CGROUP_ROOT)
|
||||
path = os.path.realpath(CGROUP_ROOT + relative)
|
||||
if root != CGROUP_ROOT or path != root + relative or not path.startswith(root + os.sep):
|
||||
raise ValueError('Cgroup path escaped')
|
||||
return path, membership
|
||||
|
||||
|
||||
def process_generation(pid, deadline=None):
|
||||
check_deadline(deadline)
|
||||
raw = read_bounded('/proc/' + str(pid) + '/stat', deadline=deadline)
|
||||
prefix, suffix = raw.rsplit(')', 1)
|
||||
if prefix.split(' ', 1)[0] != str(pid) or ' (' not in prefix:
|
||||
raise ValueError('Invalid process identity')
|
||||
fields = suffix.split()
|
||||
if len(fields) < 20:
|
||||
raise ValueError('Incomplete process identity')
|
||||
start = parse_counter(fields[19])
|
||||
root = os.stat('/proc/' + str(pid) + '/root')
|
||||
return start, root.st_dev, root.st_ino
|
||||
|
||||
|
||||
def unavailable_cgroup(reason):
|
||||
return {'version': 2, 'status': 'unavailable', 'reason': reason,
|
||||
'metrics': {name: None for name in CGROUP_FILES}, 'unavailable': {name: reason for name in CGROUP_FILES}}
|
||||
|
||||
|
||||
def collect_container(identifier, deadline=None):
|
||||
check_deadline(deadline)
|
||||
before = docker_get('/containers/' + identifier + '/json', deadline)
|
||||
stats = docker_get('/containers/' + identifier + '/stats?stream=false&one-shot=true', deadline)
|
||||
check_deadline(deadline)
|
||||
result = project_container(before, stats, identifier)
|
||||
cgroup = unavailable_cgroup('identity-unavailable')
|
||||
try:
|
||||
identity = container_identity(before, identifier)
|
||||
pid = identity[3]
|
||||
generation = process_generation(pid, deadline)
|
||||
path, membership = cgroup_location(pid, identifier, deadline)
|
||||
directory = os.stat(path)
|
||||
if directory.st_dev != os.stat(CGROUP_ROOT).st_dev:
|
||||
raise ValueError('Cgroup filesystem changed')
|
||||
metrics, missing = {}, {}
|
||||
for name in CGROUP_FILES:
|
||||
check_deadline(deadline)
|
||||
try:
|
||||
candidate = path + '/' + name
|
||||
if os.path.realpath(candidate) != candidate:
|
||||
raise ValueError('Symlinked metric')
|
||||
raw = read_bounded(candidate, directory.st_dev, deadline=deadline)
|
||||
check_deadline(deadline)
|
||||
if name == 'memory.events':
|
||||
metrics[name] = parse_events(raw, ('low', 'high', 'max', 'oom', 'oom_kill'))
|
||||
elif name == 'memory.swap.events':
|
||||
metrics[name] = parse_events(raw, ('high', 'max', 'fail'))
|
||||
elif name in ('memory.max', 'memory.swap.max'):
|
||||
metrics[name] = parse_limit(raw)
|
||||
elif name == 'cpu.stat':
|
||||
metrics[name] = parse_cpu_throttling(raw)
|
||||
else:
|
||||
metrics[name] = parse_counter(raw)
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
metrics[name] = None
|
||||
missing[name] = 'missing-unreadable-or-malformed'
|
||||
check_deadline(deadline)
|
||||
after_path, after_membership = cgroup_location(pid, identifier, deadline)
|
||||
after_directory = os.stat(path)
|
||||
if (process_generation(pid, deadline) != generation or after_path != path or after_membership != membership
|
||||
or (directory.st_dev, directory.st_ino) != (after_directory.st_dev, after_directory.st_ino)):
|
||||
cgroup = unavailable_cgroup('process-or-cgroup-changed')
|
||||
else:
|
||||
cgroup = {'version': 2, 'status': 'partial' if missing else 'available',
|
||||
'reason': 'some-metrics-unavailable' if missing else None,
|
||||
'metrics': metrics, 'unavailable': missing,
|
||||
'peakScope': 'cgroup lifetime or since external reset; not an isolated workload peak',
|
||||
'provenance': 'fixed host cgroup2 mount + exact Docker ID membership + process/root generation'}
|
||||
except CgroupUnavailable as error:
|
||||
cgroup = unavailable_cgroup(str(error))
|
||||
except (OSError, ValueError, KeyError, IndexError, UnicodeError):
|
||||
cgroup = unavailable_cgroup('identity-process-or-cgroup-unavailable-or-unsupported')
|
||||
# Even failed cgroup observations are bracketed by a fresh Docker inspect.
|
||||
check_deadline(deadline)
|
||||
after = docker_get('/containers/' + identifier + '/json', deadline)
|
||||
check_deadline(deadline)
|
||||
try:
|
||||
stable = container_identity(before, identifier) == container_identity(after, identifier)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
stable = False
|
||||
if not stable:
|
||||
cgroup = unavailable_cgroup('container-identity-changed-or-unavailable')
|
||||
limits_stable = resource_limits_stable(before, after) if stable else None
|
||||
if stable and limits_stable is not True and cgroup['status'] != 'unavailable':
|
||||
for key in ('memory.max', 'memory.swap.max'):
|
||||
cgroup['metrics'][key] = None
|
||||
cgroup['unavailable'][key] = 'container-resource-limits-changed-or-unavailable'
|
||||
cgroup['status'] = 'partial'
|
||||
cgroup['reason'] = 'some-metrics-unavailable'
|
||||
result.update({'resourceLimitsStableDuringSample': limits_stable,
|
||||
'pid': before['State'].get('Pid'), 'identityStableDuringSample': stable,
|
||||
'runningAfter': after.get('State', {}).get('Running'),
|
||||
'oomKilledAfter': after.get('State', {}).get('OOMKilled'), 'cgroupV2': cgroup})
|
||||
return result
|
||||
|
||||
|
||||
def docker_get(path, deadline=None):
|
||||
deadline = time.monotonic() + 5 if deadline is None else deadline
|
||||
check_deadline(deadline)
|
||||
connection = http.client.HTTPConnection('localhost', timeout=min(5, deadline - time.monotonic()))
|
||||
owned_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
connection.sock = owned_socket
|
||||
# A socket timeout alone permits an endless body that drips bytes. The
|
||||
# watchdog owns precisely this request's socket, including after HTTP's
|
||||
# Connection:close detaches it from HTTPConnection into HTTPResponse.
|
||||
def expire():
|
||||
try:
|
||||
owned_socket.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
owned_socket.close()
|
||||
watchdog = threading.Timer(max(0, deadline - time.monotonic()), expire)
|
||||
watchdog.daemon = True
|
||||
watchdog_started = False
|
||||
response = None
|
||||
try:
|
||||
watchdog.start()
|
||||
watchdog_started = True
|
||||
owned_socket.settimeout(min(5, max(0.001, deadline - time.monotonic())))
|
||||
check_deadline(deadline)
|
||||
owned_socket.connect('/var/run/docker.sock')
|
||||
check_deadline(deadline)
|
||||
connection.request('GET', '/v1.45' + path, headers={'Connection': 'close'})
|
||||
response = connection.getresponse()
|
||||
raw = response.read(MAX_RESPONSE + 1)
|
||||
check_deadline(deadline)
|
||||
if response.status != 200 or len(raw) > MAX_RESPONSE:
|
||||
raise ValueError('Docker observation unavailable')
|
||||
result = json.loads(raw)
|
||||
check_deadline(deadline)
|
||||
return result
|
||||
except Exception:
|
||||
check_deadline(deadline)
|
||||
raise
|
||||
finally:
|
||||
watchdog.cancel()
|
||||
if watchdog_started:
|
||||
watchdog.join()
|
||||
try:
|
||||
if response is not None:
|
||||
response.close()
|
||||
finally:
|
||||
try:
|
||||
connection.close()
|
||||
finally:
|
||||
owned_socket.close()
|
||||
|
||||
|
||||
def nonnegative(value):
|
||||
if type(value) is not int or value < 0:
|
||||
raise ValueError('Invalid numeric observation')
|
||||
return value
|
||||
|
||||
|
||||
def parse_host(meminfo, vmstat):
|
||||
memory = {}
|
||||
for line in meminfo.splitlines():
|
||||
fields = line.split()
|
||||
if fields and fields[0] in ('MemTotal:', 'MemAvailable:', 'SwapTotal:', 'SwapFree:'):
|
||||
if len(fields) != 3 or fields[2] != 'kB':
|
||||
raise ValueError('Invalid meminfo units')
|
||||
memory[fields[0][:-1] + 'Bytes'] = nonnegative(int(fields[1])) * 1024
|
||||
if len(memory) != 4 or memory['MemAvailableBytes'] > memory['MemTotalBytes'] or memory['SwapFreeBytes'] > memory['SwapTotalBytes']:
|
||||
raise ValueError('Incomplete host memory observation')
|
||||
for line in vmstat.splitlines():
|
||||
fields = line.split()
|
||||
if fields and fields[0] in ('pswpin', 'pswpout', 'oom_kill'):
|
||||
memory[fields[0]] = nonnegative(int(fields[1]))
|
||||
return memory
|
||||
|
||||
|
||||
def project_container(inspect, stats, identifier):
|
||||
if inspect.get('Id') != identifier or stats.get('id') != identifier:
|
||||
raise ValueError('Pinned container changed')
|
||||
state = inspect['State']
|
||||
memory = stats['memory_stats']
|
||||
usage = nonnegative(memory['usage'])
|
||||
inactive = memory.get('stats', {}).get('inactive_file')
|
||||
if inactive is not None:
|
||||
inactive = nonnegative(inactive)
|
||||
if inactive > usage:
|
||||
raise ValueError('Invalid cache observation')
|
||||
return {
|
||||
'id': identifier, 'imageId': inspect['Image'],
|
||||
'startedAt': state['StartedAt'], 'running': state['Running'] is True,
|
||||
'oomKilled': state.get('OOMKilled'), 'restartCount': inspect.get('RestartCount'),
|
||||
'usageIncludingCacheBytes': usage, 'inactiveFileBytes': inactive,
|
||||
'usageMinusInactiveFileBytes': usage - inactive if inactive is not None else None,
|
||||
'configuredLimitBytes': nonnegative(inspect['HostConfig']['Memory']),
|
||||
'configuredMemorySwapBytes': configured_memory_swap(inspect),
|
||||
'configuredMemorySwapStatus': 'available' if configured_memory_swap(inspect) is not None else 'unavailable',
|
||||
'reportedLimitBytes': nonnegative(memory['limit']),
|
||||
'swapUsageBytes': memory.get('swap_usage'),
|
||||
}
|
||||
|
||||
|
||||
def summarize(samples):
|
||||
if not samples:
|
||||
raise ValueError('No observations')
|
||||
baseline = {c['id']: (c['imageId'], c['startedAt'], c['restartCount'], c.get('pid'), c.get('oomKilled')) for c in samples[0]['containers']}
|
||||
stable = all({c['id']: (c['imageId'], c['startedAt'], c['restartCount'], c.get('pid'), c.get('oomKilled')) for c in sample['containers']} == baseline
|
||||
and all(c.get('identityStableDuringSample', True) for c in sample['containers']) for sample in samples)
|
||||
counters = {}
|
||||
for field in ('pswpin', 'pswpout', 'oom_kill'):
|
||||
first = samples[0]['host'].get(field)
|
||||
last = samples[-1]['host'].get(field)
|
||||
counters[field + 'Delta'] = last - first if first is not None and last is not None and last >= first else None
|
||||
return {
|
||||
'samples': len(samples), 'elapsedMs': samples[-1]['elapsedMs'],
|
||||
'hostMemTotalBytes': samples[0]['host']['MemTotalBytes'],
|
||||
'minHostAvailableBytes': min(s['host']['MemAvailableBytes'] for s in samples),
|
||||
'maxObservedContainerUsageIncludingCacheBytes': max(sum(c['usageIncludingCacheBytes'] for c in s['containers']) for s in samples),
|
||||
'containerIdentityAndStartStable': stable,
|
||||
'allObservedRunning': all(c['running'] is True and c.get('runningAfter') is True
|
||||
and c.get('identityStableDuringSample') is True for s in samples for c in s['containers']),
|
||||
'anyObservedOomKilled': any(c['oomKilled'] is True or c.get('oomKilledAfter') is True for s in samples for c in s['containers']),
|
||||
'allCgroupV2MetricsAvailable': all(s['containers'] and all(c.get('cgroupV2', {}).get('status') == 'available' for c in s['containers']) for s in samples),
|
||||
**counters,
|
||||
'allResourceLimitsStableDuringSample': all(c.get('resourceLimitsStableDuringSample') is True for s in samples for c in s['containers']) and all(s['containers'] for s in samples),
|
||||
'allHostMemoryPressureAvailable': all(s['host'].get('memoryPressure', {}).get('status') == 'available' for s in samples),
|
||||
'acceptancePassed': False,
|
||||
'scope': 'sampled evidence only; correlate with workload, limits, errors and latency; unsampled peaks are unknown',
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--daemon-id', required=True)
|
||||
parser.add_argument('--container', action='append', required=True)
|
||||
parser.add_argument('--samples', type=int, default=30)
|
||||
parser.add_argument('--interval', type=float, default=1)
|
||||
parser.add_argument('--output', required=True)
|
||||
args = parser.parse_args()
|
||||
if os.name != 'posix' or not hasattr(socket, 'AF_UNIX'):
|
||||
parser.error('Run on the Linux Docker host')
|
||||
if not re.fullmatch(r'[A-Za-z0-9-]{8,128}', args.daemon_id) or not 1 <= len(args.container) <= 16 or len(set(args.container)) != len(args.container) or any(not re.fullmatch(r'[a-f0-9]{64}', c) for c in args.container):
|
||||
parser.error('Pin exact daemon ID and 1..16 distinct full container IDs')
|
||||
if not 1 <= args.samples <= 300 or not 0.2 <= args.interval <= 10 or args.samples * args.interval > 300:
|
||||
parser.error('Observation bound: 300 samples / 300 seconds')
|
||||
started = time.monotonic()
|
||||
deadline = started + 360
|
||||
if docker_get('/info', deadline).get('ID') != args.daemon_id:
|
||||
raise ValueError('Pinned Docker daemon changed')
|
||||
samples = []
|
||||
for index in range(args.samples):
|
||||
check_deadline(deadline)
|
||||
host = collect_host(deadline)
|
||||
containers = []
|
||||
for identifier in args.container:
|
||||
check_deadline(deadline)
|
||||
containers.append(collect_container(identifier, deadline))
|
||||
check_deadline(deadline)
|
||||
samples.append({'elapsedMs': round((time.monotonic() - started) * 1000), 'host': host, 'containers': containers})
|
||||
if index + 1 < args.samples:
|
||||
time.sleep(min(args.interval, max(0, deadline - time.monotonic())))
|
||||
check_deadline(deadline)
|
||||
if docker_get('/info', deadline).get('ID') != args.daemon_id:
|
||||
raise ValueError('Pinned Docker daemon changed')
|
||||
result = {'schemaVersion': 3, 'readOnlyDockerAndProc': True, 'serviceChanges': 0, 'summary': summarize(samples), 'observations': samples}
|
||||
encoded = json.dumps(result, indent=2) + '\n'
|
||||
check_deadline(deadline)
|
||||
# Refuse to replace an existing evidence file. Never dump Docker env/logs.
|
||||
with open(args.output, 'x', encoding='utf-8') as handle:
|
||||
check_deadline(deadline)
|
||||
handle.write(encoded)
|
||||
check_deadline(deadline)
|
||||
print(json.dumps(result['summary']))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(json.dumps({'status': 'observation-refused', 'errorClass': type(error).__name__, 'serviceChanges': 0}))
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,384 @@
|
||||
import copy
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import os
|
||||
import tempfile
|
||||
import io
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location('memory_measure', Path(__file__).with_name('measure-memory.py'))
|
||||
measure = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(measure)
|
||||
|
||||
|
||||
class MemoryEvidenceTests(unittest.TestCase):
|
||||
def test_host_units_and_missing_fields(self):
|
||||
raw = 'MemTotal: 2048 kB\nMemAvailable: 1024 kB\nSwapTotal: 4096 kB\nSwapFree: 3072 kB\n'
|
||||
parsed = measure.parse_host(raw, 'pswpin 20\npswpout 30\noom_kill 1\n')
|
||||
self.assertEqual(parsed['MemTotalBytes'], 2048 * 1024)
|
||||
self.assertEqual(parsed['pswpin'], 20)
|
||||
for invalid in (raw.replace('kB', 'MB'), raw.replace('MemAvailable: 1024 kB\n', ''), raw.replace('MemAvailable: 1024', 'MemAvailable: 4096')):
|
||||
with self.assertRaises(ValueError):
|
||||
measure.parse_host(invalid, '')
|
||||
|
||||
def test_cache_and_no_configured_limit_are_distinct(self):
|
||||
item = {'Id': 'a' * 64, 'Image': 'sha256:fixed', 'State': {'StartedAt': 't0', 'Running': True}, 'HostConfig': {'Memory': 0}}
|
||||
stats = {'id': 'a' * 64, 'memory_stats': {'usage': 100, 'limit': 2048, 'stats': {'inactive_file': 30}}}
|
||||
result = measure.project_container(item, stats, 'a' * 64)
|
||||
self.assertEqual(result['usageIncludingCacheBytes'], 100)
|
||||
self.assertEqual(result['usageMinusInactiveFileBytes'], 70)
|
||||
self.assertEqual(result['configuredLimitBytes'], 0)
|
||||
with self.assertRaises(ValueError):
|
||||
measure.project_container(item, stats, 'b' * 64)
|
||||
|
||||
def test_simultaneous_peak_is_not_sum_of_separate_peaks(self):
|
||||
host = {'MemTotalBytes': 2048, 'MemAvailableBytes': 1024, 'pswpin': 3, 'pswpout': 5, 'oom_kill': 1}
|
||||
def container(identifier, usage):
|
||||
return {'id': identifier, 'imageId': 'i', 'startedAt': 't0', 'restartCount': 0, 'running': True, 'oomKilled': False, 'usageIncludingCacheBytes': usage}
|
||||
samples = [{'elapsedMs': 0, 'host': host, 'containers': [container('a', 100), container('b', 20)]},
|
||||
{'elapsedMs': 1000, 'host': {**host, 'pswpin': 4}, 'containers': [container('a', 20), container('b', 100)]}]
|
||||
result = measure.summarize(samples)
|
||||
self.assertEqual(result['maxObservedContainerUsageIncludingCacheBytes'], 120)
|
||||
self.assertEqual(result['pswpinDelta'], 1)
|
||||
self.assertTrue(result['containerIdentityAndStartStable'])
|
||||
self.assertFalse(result['acceptancePassed'])
|
||||
changed = copy.deepcopy(samples)
|
||||
changed[-1]['containers'][0]['startedAt'] = 't1'
|
||||
changed[-1]['containers'][1]['oomKilled'] = True
|
||||
self.assertFalse(measure.summarize(changed)['containerIdentityAndStartStable'])
|
||||
self.assertTrue(measure.summarize(changed)['anyObservedOomKilled'])
|
||||
|
||||
def test_counter_rollback_is_unknown(self):
|
||||
samples = [{'elapsedMs': i, 'host': {'MemTotalBytes': 2, 'MemAvailableBytes': 1, 'pswpin': value}, 'containers': []} for i, value in enumerate([100, 2])]
|
||||
self.assertIsNone(measure.summarize(samples)['pswpinDelta'])
|
||||
|
||||
|
||||
class CgroupEvidenceTests(unittest.TestCase):
|
||||
identifier = 'a' * 64
|
||||
path = '/sys/fs/cgroup/system.slice/docker-' + identifier + '.scope'
|
||||
|
||||
def setUp(self):
|
||||
self.before = {'Id': self.identifier, 'Image': 'sha256:fixed', 'RestartCount': 0,
|
||||
'State': {'StartedAt': 'original-start', 'Pid': 123, 'Running': True, 'OOMKilled': False},
|
||||
'HostConfig': {'Memory': 512, 'MemorySwap': 1024}}
|
||||
self.stats = {'id': self.identifier, 'memory_stats': {'usage': 100, 'limit': 512, 'stats': {'inactive_file': 30}}}
|
||||
self.files = {
|
||||
'/proc/self/mountinfo': '1 0 0:1 / /sys/fs/cgroup rw - cgroup2 cgroup rw\n',
|
||||
'/proc/123/cgroup': '0::/system.slice/docker-' + self.identifier + '.scope\n',
|
||||
'/proc/123/stat': '123 (a name with ) parentheses) S ' + ' '.join(['0'] * 18 + ['987'] + ['0'] * 8),
|
||||
self.path + '/memory.max': '512\n', self.path + '/memory.swap.max': '512\n',
|
||||
self.path + '/cpu.stat': 'nr_periods 10\nnr_throttled 2\nthrottled_usec 1234\n',
|
||||
self.path + '/memory.current': '100\n', self.path + '/memory.peak': '200\n',
|
||||
self.path + '/memory.events': 'low 0\nhigh 1\nmax 2\noom 3\noom_kill 4\n',
|
||||
self.path + '/memory.swap.current': '5\n', self.path + '/memory.swap.peak': '6\n',
|
||||
self.path + '/memory.swap.events': 'high 0\nmax 2\nfail 1\n',
|
||||
}
|
||||
|
||||
def observe(self, after=None, reader=None, resolver=None, statter=None):
|
||||
def read(path, _expected_device=None, deadline=None):
|
||||
if path not in self.files:
|
||||
raise FileNotFoundError()
|
||||
return self.files[path]
|
||||
queries = []
|
||||
def get(path, deadline=None):
|
||||
queries.append(path)
|
||||
if '/stats?' in path:
|
||||
return self.stats
|
||||
return self.before if len(queries) == 1 else (after or self.before)
|
||||
with patch.object(measure, 'docker_get', side_effect=get), \
|
||||
patch.object(measure, 'read_bounded', side_effect=reader or read), \
|
||||
patch.object(measure.os.path, 'realpath', side_effect=resolver or (lambda path: path)), \
|
||||
patch.object(measure.os, 'stat', side_effect=statter or (lambda path: os.stat_result((0, 7, 8, 0, 0, 0, 0, 0, 0, 0)))), \
|
||||
patch.object(measure.os, 'sep', '/'):
|
||||
result = measure.collect_container(self.identifier)
|
||||
self.assertEqual(queries, ['/containers/' + self.identifier + '/json',
|
||||
'/containers/' + self.identifier + '/stats?stream=false&one-shot=true',
|
||||
'/containers/' + self.identifier + '/json'])
|
||||
return result
|
||||
|
||||
def test_exact_identity_metrics_and_docker_cache_are_distinct(self):
|
||||
result = self.observe()
|
||||
self.assertTrue(result['identityStableDuringSample'])
|
||||
self.assertEqual(result['usageMinusInactiveFileBytes'], 70)
|
||||
self.assertEqual(result['cgroupV2']['status'], 'available')
|
||||
self.assertEqual(result['cgroupV2']['metrics']['memory.current'], 100)
|
||||
self.assertEqual(result['cgroupV2']['metrics']['memory.peak'], 200)
|
||||
self.assertEqual(result['cgroupV2']['metrics']['memory.events']['oom_kill'], 4)
|
||||
self.assertEqual(result['cgroupV2']['metrics']['memory.swap.current'], 5)
|
||||
self.assertEqual(result['cgroupV2']['metrics']['memory.swap.peak'], 6)
|
||||
self.assertEqual(result['cgroupV2']['metrics']['memory.swap.events']['fail'], 1)
|
||||
|
||||
def test_optional_swap_peak_missing_is_unknown_not_zero(self):
|
||||
del self.files[self.path + '/memory.swap.peak']
|
||||
result = self.observe()['cgroupV2']
|
||||
self.assertEqual(result['status'], 'partial')
|
||||
self.assertIsNone(result['metrics']['memory.swap.peak'])
|
||||
self.assertEqual(result['metrics']['memory.peak'], 200)
|
||||
self.assertIn('memory.swap.peak', result['unavailable'])
|
||||
|
||||
def test_each_missing_metric_is_explicit_unknown(self):
|
||||
for name in measure.CGROUP_FILES:
|
||||
with self.subTest(name=name):
|
||||
value = self.files.pop(self.path + '/' + name)
|
||||
result = self.observe()['cgroupV2']
|
||||
self.assertIsNone(result['metrics'][name])
|
||||
self.assertEqual(result['status'], 'partial')
|
||||
self.files[self.path + '/' + name] = value
|
||||
|
||||
def test_malformed_metrics_never_coerce_to_zero(self):
|
||||
for value in ('-1\n', '+1\n', ' 1\n', '01\n', '1\n2\n', '18446744073709551616\n', 'max\n'):
|
||||
with self.subTest(value=value):
|
||||
self.files[self.path + '/memory.current'] = value
|
||||
self.assertIsNone(self.observe()['cgroupV2']['metrics']['memory.current'])
|
||||
for value in ('oom 1\noom 2\n', 'low 0\nhigh -1\nmax 0\noom 0\noom_kill 0\n', 'low 0\n', 'low 0\n'):
|
||||
with self.subTest(value=value):
|
||||
self.files[self.path + '/memory.events'] = value
|
||||
self.assertIsNone(self.observe()['cgroupV2']['metrics']['memory.events'])
|
||||
|
||||
def test_docker_identity_drift_invalidates_entire_cgroup_sample(self):
|
||||
for field, value in [('Pid', 124), ('StartedAt', 'new'), ('OOMKilled', True), ('Running', False)]:
|
||||
after = copy.deepcopy(self.before); after['State'][field] = value
|
||||
result = self.observe(after)
|
||||
self.assertFalse(result['identityStableDuringSample'])
|
||||
self.assertTrue(all(value is None for value in result['cgroupV2']['metrics'].values()))
|
||||
for field, value in [('Id', 'b' * 64), ('RestartCount', 1), ('Image', 'sha256:new')]:
|
||||
after = copy.deepcopy(self.before); after[field] = value
|
||||
self.assertEqual(self.observe(after)['cgroupV2']['status'], 'unavailable')
|
||||
|
||||
def test_process_reuse_rootfs_swap_and_membership_move_are_unknown(self):
|
||||
for changed in ('/proc/123/stat', '/proc/123/cgroup'):
|
||||
count = 0
|
||||
def read(path, _expected_device=None, deadline=None):
|
||||
nonlocal count
|
||||
if path == changed:
|
||||
count += 1
|
||||
if count == 2:
|
||||
return self.files[path].replace('987', '988') if path.endswith('/stat') else '0::/docker/' + 'b' * 64 + '\n'
|
||||
return self.files[path]
|
||||
self.assertEqual(self.observe(reader=read)['cgroupV2']['status'], 'unavailable')
|
||||
count = 0
|
||||
def statter(path):
|
||||
nonlocal count
|
||||
if path == '/proc/123/root':
|
||||
count += 1
|
||||
return os.stat_result((0, count, 8, 0, 0, 0, 0, 0, 0, 0))
|
||||
self.assertEqual(self.observe(statter=statter)['cgroupV2']['status'], 'unavailable')
|
||||
|
||||
def test_unsupported_v1_namespace_subtree_and_unbound_membership(self):
|
||||
original = self.files['/proc/123/cgroup']
|
||||
for value in ('2:memory:/docker/' + self.identifier + '\n', '0::/\n', '0::/docker/' + 'b' * 64 + '\n',
|
||||
'0::/docker/' + self.identifier + '/../other\n', original.rstrip() + '/child\n', original + original):
|
||||
self.files['/proc/123/cgroup'] = value
|
||||
self.assertEqual(self.observe()['cgroupV2']['status'], 'unavailable')
|
||||
self.files['/proc/123/cgroup'] = original
|
||||
self.files['/proc/self/mountinfo'] = '1 0 0:1 /subtree /sys/fs/cgroup rw - cgroup2 cgroup rw\n'
|
||||
self.assertEqual(self.observe()['cgroupV2']['status'], 'unavailable')
|
||||
|
||||
def test_symlink_outside_or_to_other_container_is_refused(self):
|
||||
for destination in ('/etc', '/sys/fs/cgroup/docker/' + 'b' * 64):
|
||||
self.assertEqual(self.observe(resolver=lambda path: destination if path == self.path else path)['cgroupV2']['status'], 'unavailable')
|
||||
result = self.observe(resolver=lambda path: '/etc/passwd' if path.endswith('/memory.current') else path)
|
||||
self.assertIsNone(result['cgroupV2']['metrics']['memory.current'])
|
||||
|
||||
def test_cgroup_directory_replacement_or_nested_filesystem_is_unknown(self):
|
||||
for nested in (False, True):
|
||||
calls = 0
|
||||
def statter(path):
|
||||
nonlocal calls
|
||||
if path == self.path:
|
||||
calls += 1
|
||||
inode = 8 if path == self.path and calls > 1 else 7
|
||||
device = 9 if nested and path == self.path else 8
|
||||
return os.stat_result((0, inode, device, 0, 0, 0, 0, 0, 0, 0))
|
||||
result = self.observe(statter=statter)['cgroupV2']
|
||||
self.assertEqual(result['status'], 'unavailable')
|
||||
self.assertTrue(all(value is None for value in result['metrics'].values()))
|
||||
|
||||
def test_pid_reuse_unreadable_process_and_after_oom_stay_unknown(self):
|
||||
after = copy.deepcopy(self.before); after['State']['OOMKilled'] = True
|
||||
result = self.observe(after)
|
||||
host = {'MemTotalBytes': 2048, 'MemAvailableBytes': 1024}
|
||||
summary = measure.summarize([{'elapsedMs': 0, 'host': host, 'containers': [result]}])
|
||||
self.assertTrue(summary['anyObservedOomKilled'])
|
||||
self.assertFalse(summary['containerIdentityAndStartStable'])
|
||||
self.assertFalse(summary['allCgroupV2MetricsAvailable'])
|
||||
self.assertFalse(summary['acceptancePassed'])
|
||||
def read(path, _expected_device=None, deadline=None):
|
||||
if path == '/proc/123/stat':
|
||||
raise PermissionError()
|
||||
return self.files[path]
|
||||
self.assertEqual(self.observe(reader=read)['cgroupV2']['status'], 'unavailable')
|
||||
|
||||
def test_real_temp_files_are_bounded_and_strictly_ascii(self):
|
||||
with tempfile.TemporaryDirectory(dir=Path(__file__).resolve().parent) as directory:
|
||||
path = Path(directory) / 'memory.current'
|
||||
path.write_bytes(b'123\n')
|
||||
self.assertEqual(measure.parse_counter(measure.read_bounded(path)), 123)
|
||||
with self.assertRaises(ValueError):
|
||||
measure.read_bounded(path, path.stat().st_dev + 1)
|
||||
for raw in (b'0' * (measure.MAX_FILE_BYTES + 1), b'0\n' * (measure.MAX_FILE_LINES + 1), b'\xff'):
|
||||
path.write_bytes(raw)
|
||||
with self.assertRaises((ValueError, UnicodeError)):
|
||||
measure.read_bounded(path)
|
||||
|
||||
def test_after_stopped_or_unknown_identity_cannot_summarize_running(self):
|
||||
for value in (False, None):
|
||||
after = copy.deepcopy(self.before); after['State']['Running'] = value
|
||||
result = self.observe(after)
|
||||
self.assertIs(result['runningAfter'], value)
|
||||
summary = measure.summarize([{'elapsedMs': 0, 'host': {'MemTotalBytes': 2048, 'MemAvailableBytes': 1024}, 'containers': [result]}])
|
||||
self.assertFalse(summary['allObservedRunning'])
|
||||
result = self.observe()
|
||||
summary = measure.summarize([{'elapsedMs': 0, 'host': {'MemTotalBytes': 2048, 'MemAvailableBytes': 1024}, 'containers': [result]}])
|
||||
self.assertTrue(summary['allObservedRunning'])
|
||||
|
||||
def test_event_future_counters_retained_but_duplicates_and_overflow_refuse(self):
|
||||
self.assertEqual(measure.parse_events('high 0\nmax 1\nfail 2\nfuture 3\n', ('high', 'max', 'fail'))['future'], 3)
|
||||
for value in ('x 1\nx 2\n', 'x 18446744073709551616\n', ''.join('x_' + str(i) + ' 0\n' for i in range(33))):
|
||||
with self.assertRaises(ValueError):
|
||||
measure.parse_events(value, ())
|
||||
|
||||
|
||||
class DeadlineEvidenceTests(unittest.TestCase):
|
||||
def test_expired_http_never_opens_socket_or_schedules_watchdog(self):
|
||||
with patch.object(measure.time, 'monotonic', return_value=10), \
|
||||
patch.object(measure.socket, 'socket') as own_socket, patch.object(measure.threading, 'Timer') as timer:
|
||||
with self.assertRaises(measure.ObservationDeadlineExceeded):
|
||||
measure.docker_get('/info', 10)
|
||||
own_socket.assert_not_called()
|
||||
timer.assert_not_called()
|
||||
|
||||
def test_expired_container_read_never_starts_io(self):
|
||||
with patch.object(measure.time, 'monotonic', return_value=10), patch.object(measure, 'docker_get') as get:
|
||||
with self.assertRaises(measure.ObservationDeadlineExceeded):
|
||||
measure.collect_container('a' * 64, 10)
|
||||
get.assert_not_called()
|
||||
|
||||
def test_near_cutoff_after_stats_stops_before_proc_or_more_gets(self):
|
||||
clock, queries = [9.9], []
|
||||
def get(path, deadline):
|
||||
self.assertEqual(deadline, 10)
|
||||
queries.append(path)
|
||||
if len(queries) == 2:
|
||||
clock[0] = 10
|
||||
return {}
|
||||
with patch.object(measure.time, 'monotonic', side_effect=lambda: clock[0]), \
|
||||
patch.object(measure, 'docker_get', side_effect=get), patch.object(measure, 'read_bounded') as read:
|
||||
with self.assertRaises(measure.ObservationDeadlineExceeded):
|
||||
measure.collect_container('a' * 64, 10)
|
||||
self.assertEqual(len(queries), 2)
|
||||
read.assert_not_called()
|
||||
|
||||
def test_metric_deadline_is_not_downgraded_to_partial_or_followed_by_io(self):
|
||||
fixture = CgroupEvidenceTests(); fixture.setUp()
|
||||
clock, reads, queries = [9.9], [], []
|
||||
def read(path, _device=None, deadline=None):
|
||||
self.assertEqual(deadline, 10)
|
||||
reads.append(path)
|
||||
if path.endswith('/memory.current'):
|
||||
clock[0] = 10
|
||||
return fixture.files[path]
|
||||
def get(path, deadline):
|
||||
queries.append(path)
|
||||
return fixture.stats if '/stats?' in path else fixture.before
|
||||
with patch.object(measure.time, 'monotonic', side_effect=lambda: clock[0]), \
|
||||
patch.object(measure, 'docker_get', side_effect=get), patch.object(measure, 'read_bounded', side_effect=read), \
|
||||
patch.object(measure.os.path, 'realpath', side_effect=lambda path: path), \
|
||||
patch.object(measure.os, 'stat', return_value=os.stat_result((0, 7, 8, 0, 0, 0, 0, 0, 0, 0))), \
|
||||
patch.object(measure.os, 'sep', '/'):
|
||||
with self.assertRaises(measure.ObservationDeadlineExceeded):
|
||||
measure.collect_container(fixture.identifier, 10)
|
||||
self.assertEqual(reads[-1], fixture.path + '/memory.current')
|
||||
self.assertEqual(len(queries), 2)
|
||||
|
||||
def test_final_daemon_check_crosses_deadline_without_writing_report(self):
|
||||
clock, calls = [0], []
|
||||
def get(path, deadline):
|
||||
self.assertEqual(deadline, 360)
|
||||
calls.append(path)
|
||||
if len(calls) == 2:
|
||||
clock[0] = 361
|
||||
return {'ID': 'owned-daemon'}
|
||||
host = 'MemTotal: 2048 kB\nMemAvailable: 1024 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB\n'
|
||||
args = ['measure-memory.py', '--daemon-id', 'owned-daemon', '--container', 'a' * 64, '--samples', '1', '--output', 'NEVER_CREATED']
|
||||
with patch.object(sys, 'argv', args), patch.object(measure.os, 'name', 'posix'), \
|
||||
patch.object(measure.socket, 'AF_UNIX', 1, create=True), \
|
||||
patch.object(measure.time, 'monotonic', side_effect=lambda: clock[0]), patch.object(measure, 'docker_get', side_effect=get), \
|
||||
patch.object(measure, 'read_bounded', side_effect=[host, '', 'some avg10=0.00 avg60=0.00 avg300=0.00 total=0\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n']), patch.object(measure, 'collect_container', return_value={}), \
|
||||
patch.object(measure, 'summarize', return_value={}), patch('builtins.open') as output:
|
||||
with self.assertRaises(measure.ObservationDeadlineExceeded):
|
||||
measure.main()
|
||||
output.assert_not_called()
|
||||
|
||||
def test_slow_body_absolute_deadline_closes_only_own_socket_and_joins(self):
|
||||
# A real HTTPResponse/BufferedReader and real watchdog timer, with an
|
||||
# in-memory socket transport. No AF_UNIX availability or local network
|
||||
# permissions are needed on Windows. Inactivity timeouts never fire:
|
||||
# the body keeps producing bytes until shutdown interrupts the read.
|
||||
class DripBody(io.RawIOBase):
|
||||
def __init__(self, stopped):
|
||||
self.header = b'HTTP/1.1 200 OK\r\nContent-Length: 1000\r\nConnection: close\r\n\r\n'
|
||||
self.stopped = stopped
|
||||
self.body_reads = 0
|
||||
def readable(self):
|
||||
return True
|
||||
def readinto(self, buffer):
|
||||
if self.header:
|
||||
value, self.header = self.header[:len(buffer)], self.header[len(buffer):]
|
||||
buffer[:len(value)] = value
|
||||
return len(value)
|
||||
if self.stopped.wait(0.01):
|
||||
return 0
|
||||
self.body_reads += 1
|
||||
buffer[0] = 32
|
||||
return 1
|
||||
class SocketTransport:
|
||||
def __init__(self):
|
||||
self.stopped = threading.Event()
|
||||
self.body = DripBody(self.stopped)
|
||||
self.closed = False
|
||||
self.request = b''
|
||||
def connect(self, path):
|
||||
self.path = path
|
||||
def settimeout(self, value):
|
||||
self.timeout = value
|
||||
def sendall(self, value):
|
||||
self.request += value
|
||||
def makefile(self, _mode):
|
||||
return io.BufferedReader(self.body)
|
||||
def shutdown(self, _how):
|
||||
self.stopped.set()
|
||||
def close(self):
|
||||
self.closed = True
|
||||
client, unrelated = SocketTransport(), SocketTransport()
|
||||
started = time.monotonic()
|
||||
original_timer = measure.threading.Timer
|
||||
timers = []
|
||||
def timer(*args, **kwargs):
|
||||
value = original_timer(*args, **kwargs)
|
||||
timers.append(value)
|
||||
return value
|
||||
with patch.object(measure.socket, 'AF_UNIX', 1, create=True), \
|
||||
patch.object(measure.socket, 'socket', return_value=client), \
|
||||
patch.object(measure.threading, 'Timer', side_effect=timer):
|
||||
with self.assertRaises(measure.ObservationDeadlineExceeded):
|
||||
measure.docker_get('/info', started + 0.12)
|
||||
self.assertLess(time.monotonic() - started, 1.5)
|
||||
self.assertTrue(client.closed)
|
||||
self.assertTrue(client.stopped.is_set())
|
||||
self.assertGreater(client.body.body_reads, 0)
|
||||
self.assertFalse(unrelated.closed)
|
||||
self.assertFalse(unrelated.stopped.is_set())
|
||||
self.assertEqual(client.path, '/var/run/docker.sock')
|
||||
self.assertIn(b'GET /v1.45/info ', client.request)
|
||||
self.assertEqual(len(timers), 1)
|
||||
self.assertFalse(timers[0].is_alive(), 'No deadline callback can perform late I/O after return')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own215.invalid';process.env.AUTH_PORTAL_URL='https://own215.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
let phase='populated',app,nativeCalls=0,nativeWrites=0,disabledCalls=0;const cases=[]
|
||||
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');if(phase==='failed'||(phase==='partial'&&label==='second')){res.statusCode=401;res.end(JSON.stringify({error:401,message:'own215 fixture denied'}));return}const rows=phase==='populated'&&req.url==='/rest/routing/ospf/instance'?[{'.id':'*1',name:'own215-ospf',disabled:'false'}]:phase==='populated'&&req.url==='/rest/routing/bfd/session'?[{'.id':'*1','local-address':'192.0.2.1','remote-address':'192.0.2.2',state:'up'}]:[];res.end(JSON.stringify(rows))})
|
||||
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
|
||||
const baseline=process.env.MMAPP_BASELINE==='true';const report={date:'2026-10-14',block:'12.5.215',baseline,database,cases,scope:'Compiled production routes/auth, isolated PostgreSQL and RouterOS HTTP fixture; real CHR unchanged',passed:false}
|
||||
try{
|
||||
await initDatabase();const ids=[];for(let i=0;i<natives.length;i++)ids.push(Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4) RETURNING id",['own215-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])).rows[0].id))
|
||||
app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own215-reader',apps:['mm'],permissions:['mm:network:read','mm:dashboard:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token}
|
||||
for(phase of ['populated','empty','partial','failed','disabled']){
|
||||
if(phase==='disabled')await pool.query('UPDATE servers SET enabled=false')
|
||||
for(const endpoint of ['ospf/neighbors','ospf/interfaces','ospf/instances','ospf/all','bfd/sessions']){
|
||||
const before=nativeCalls,r=await app.inject({method:'GET',url:'/api/'+endpoint,headers}),body=r.json(),hasFailure=['partial','failed'].includes(phase)
|
||||
assert.equal(r.statusCode,!baseline&&hasFailure?503:200)
|
||||
if(!baseline&&hasFailure){const expected=phase==='partial'?[ids[1]]:ids.slice(0,2);assert.deepEqual(body.failures.map(f=>f.serverId),expected);assert(body.failures.every(f=>f.error.includes('401')));assert.match(body.error,/OSPF\/BFD/)}
|
||||
else if(endpoint==='ospf/all'){assert.deepEqual(Object.keys(body).sort(),['bfdSessions','instances','interfaces','neighbors','routes']);assert.equal(body.instances.length,phase==='populated'?2:0);assert.equal(body.bfdSessions.length,phase==='populated'?2:0)}
|
||||
else{assert(Array.isArray(body));assert.equal(body.length,phase==='populated'&&['ospf/instances','bfd/sessions'].includes(endpoint)?2:0)}
|
||||
if(phase==='disabled')assert.equal(nativeCalls,before)
|
||||
cases.push({phase,endpoint,status:r.statusCode,failures:body.failures?.length??0,nativeCalls:nativeCalls-before})
|
||||
}
|
||||
}
|
||||
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/ospf215-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/ospf215-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,69 @@
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium, expect } from '@playwright/test';
|
||||
|
||||
const output = 'tmp/rest262-soak.json';
|
||||
assert(!fs.existsSync(output), 'Never overwrite acceptance evidence');
|
||||
const credentials = JSON.parse(fs.readFileSync('tmp/portal195-private.json'));
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
let token;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://localhost:3000/certificates');
|
||||
await page.getByLabel('Email', { exact: true }).fill(credentials.email);
|
||||
await page.getByLabel('Пароль', { exact: true }).fill(credentials.password);
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Путь страницы' })).toContainText('Сертификаты', { timeout: 30000 });
|
||||
token = await page.evaluate(() => localStorage.getItem('mmapp_token'));
|
||||
assert(token);
|
||||
} finally { await browser.close(); }
|
||||
|
||||
const report = {
|
||||
date: '2026-10-15', block: '12.5.262', realPortalLogin: true, mocks: false,
|
||||
backend: 'ux-certificates258', endpoint: '/api/certificates/state', directBackendTunnel: true,
|
||||
concurrentRequestsPerWave: 12, waveIntervalMs: 30000, plannedWindowMs: 600000,
|
||||
nonAuthApiWrites: 0, nativeConfigurationWrites: 0, waves: [],
|
||||
bounds: ['One backend, two CHR over HTTP REST; not a global cap for external clients or multiple backend instances', 'Concurrent normal collectors and UI acceptance may run; not maximum throughput', 'A finite successful window does not prove all historical 401/500 causes or indefinite stability'],
|
||||
};
|
||||
const start = Date.now();
|
||||
const lastReceipts = new Map();
|
||||
try {
|
||||
for (let wave = 0; wave <= 20; wave++) {
|
||||
const wait = start + wave * report.waveIntervalMs - Date.now();
|
||||
if (wait > 0) await new Promise(resolve => setTimeout(resolve, wait));
|
||||
const began = Date.now();
|
||||
const samples = await Promise.all(Array.from({ length: 12 }, async () => {
|
||||
const requested = Date.now();
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/certificates/state', {
|
||||
headers: { Authorization: 'Bearer ' + token }, signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
const body = await response.json();
|
||||
return { status: response.status, durationMs: Date.now() - requested, servers: body.servers?.map(s => ({ serverId: s.serverId, collectedAt: s.collectedAt, error: s.error !== null })) ?? [] };
|
||||
} catch (error) { return { status: null, durationMs: Date.now() - requested, failure: error.name, servers: [] }; }
|
||||
}));
|
||||
const item = { wave, startedAt: new Date(began).toISOString(), elapsedMs: Date.now() - start, durationMs: Date.now() - began, samples };
|
||||
report.waves.push(item);
|
||||
fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n');
|
||||
assert(samples.every(s => s.status === 200 && s.servers.length === 2 && s.servers.every(row => !row.error) && s.servers.map(row => row.serverId).sort().join(',') === '1,2'), 'Request or device read failed; stopping without automatic replay');
|
||||
for (const id of ['1', '2']) {
|
||||
const receipts = samples.flatMap(s => s.servers.filter(row => row.serverId === id).map(row => Date.parse(row.collectedAt)));
|
||||
assert(receipts.every(Number.isFinite));
|
||||
const earliest = Math.min(...receipts);
|
||||
if (lastReceipts.has(id)) assert(earliest > lastReceipts.get(id), 'A later wave returned an old receipt');
|
||||
lastReceipts.set(id, Math.max(...receipts));
|
||||
}
|
||||
console.log(JSON.stringify({ wave, durationMs: item.durationMs, requests: samples.length, errors: 0 }));
|
||||
}
|
||||
report.completed = true;
|
||||
report.durationMs = Date.now() - start;
|
||||
assert(report.durationMs >= report.plannedWindowMs);
|
||||
report.passed = true;
|
||||
} catch (error) {
|
||||
report.passed = false;
|
||||
report.failure = { name: error.name, message: error.message.slice(0, 200) };
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
token = undefined;
|
||||
fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n');
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
cd /opt/mmapp-test
|
||||
mm233_db="mm_accept_166_$(date +%s%3N)"
|
||||
mm233_pass="$(openssl rand -hex 24)"
|
||||
mm233_role_created=false
|
||||
mm233_db_created=false
|
||||
cleanup233() {
|
||||
docker rm mmapp-s3app233-owned >/dev/null 2>&1 || true
|
||||
if $mm233_db_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP DATABASE \"$mm233_db\" WITH (FORCE)"; fi
|
||||
if $mm233_role_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP ROLE \"$mm233_db\""; fi
|
||||
remaining=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm233_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm233_db')")
|
||||
[ "$remaining" = 0 ]
|
||||
python3 - <<'PY'
|
||||
import json,pathlib
|
||||
p=pathlib.Path('/opt/mmapp-test/artifacts/backup233-checks/storage-origin233.json')
|
||||
if p.exists():
|
||||
r=json.loads(p.read_text());r['ownedDatabaseAndRoleRemoved']=True;p.write_text(json.dumps(r,indent=2)+'\n')
|
||||
PY
|
||||
printf 'OWNED_DATABASE_AND_ROLE_REMOVED\n'
|
||||
unset mm233_pass
|
||||
}
|
||||
trap cleanup233 EXIT
|
||||
[[ "$mm233_db" =~ ^mm_accept_166_[0-9]+$ ]]
|
||||
existing=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm233_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm233_db')")
|
||||
[ "$existing" = 0 ]
|
||||
printf "CREATE ROLE \"%s\" LOGIN PASSWORD '%s' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;\n" "$mm233_db" "$mm233_pass" | docker exec -i mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1
|
||||
mm233_role_created=true
|
||||
docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$mm233_db\" OWNER \"$mm233_db\""
|
||||
mm233_db_created=true
|
||||
docker run --name mmapp-s3app233-owned --network mmapp-test_default --cap-drop ALL --security-opt no-new-privileges -e "MMAPP_ORIGIN_BASELINE=${2:-false}" -e NODE_ENV=test -e "DATABASE_URL=postgres://$mm233_db:$mm233_pass@postgres:5432/$mm233_db" -e "MMAPP_OWN_SQL_DB=$mm233_db" -e "DATABASE_PATH=/tmp/$mm233_db/legacy.db" -e "GEOIP_DIR=/tmp/$mm233_db/geoip" -v /opt/mmapp-test/artifacts/backup233-checks:/checks --entrypoint node "${1:-mmapp-test/backend:ux-backup233}" /checks/backup-storage-origin.mjs
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
cd /opt/mmapp-test
|
||||
mm242_db="mm_accept_166_$(date +%s%3N)"
|
||||
mm242_pass="$(openssl rand -hex 24)"
|
||||
mm242_role_created=false
|
||||
mm242_db_created=false
|
||||
cleanup242() {
|
||||
docker rm mmapp-s3app242-owned >/dev/null 2>&1 || true
|
||||
if $mm242_db_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP DATABASE \"$mm242_db\" WITH (FORCE)"; fi
|
||||
if $mm242_role_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP ROLE \"$mm242_db\""; fi
|
||||
remaining=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm242_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm242_db')")
|
||||
[ "$remaining" = 0 ]
|
||||
python3 - <<'PY'
|
||||
import json,pathlib
|
||||
p=pathlib.Path('/opt/mmapp-test/artifacts/backup242-checks/geoip-mirror242.json')
|
||||
if p.exists():
|
||||
r=json.loads(p.read_text());r['ownedDatabaseAndRoleRemoved']=True;p.write_text(json.dumps(r,indent=2)+'\n')
|
||||
PY
|
||||
printf 'OWNED_DATABASE_AND_ROLE_REMOVED\n'
|
||||
unset mm242_pass
|
||||
}
|
||||
trap cleanup242 EXIT
|
||||
[[ "$mm242_db" =~ ^mm_accept_166_[0-9]+$ ]]
|
||||
existing=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm242_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm242_db')")
|
||||
[ "$existing" = 0 ]
|
||||
printf "CREATE ROLE \"%s\" LOGIN PASSWORD '%s' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;\n" "$mm242_db" "$mm242_pass" | docker exec -i mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1
|
||||
mm242_role_created=true
|
||||
docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$mm242_db\" OWNER \"$mm242_db\""
|
||||
mm242_db_created=true
|
||||
docker run --name mmapp-s3app242-owned --network mmapp-test_default --cap-drop ALL --security-opt no-new-privileges -e "MMAPP_ORIGIN_BASELINE=${2:-false}" -e NODE_ENV=test -e "DATABASE_URL=postgres://$mm242_db:$mm242_pass@postgres:5432/$mm242_db" -e "MMAPP_OWN_SQL_DB=$mm242_db" -e "DATABASE_PATH=/tmp/$mm242_db/legacy.db" -e "GEOIP_DIR=/tmp/$mm242_db/geoip" -v /opt/mmapp-test/artifacts/backup242-checks:/checks --entrypoint node "${1:-mmapp-test/backend:ux-geoip242}" /checks/geoip-mirror.mjs
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Local smoke preparation only: owns a new embedded PG and one compiled child.
|
||||
* Does not represent frontend/portal/Docker/whole-installation acceptance. */
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join,resolve,dirname,basename } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createServer } from 'node:net'
|
||||
import { randomBytes,createHash } from 'node:crypto'
|
||||
import { spawn } from 'node:child_process'
|
||||
const root=resolve(dirname(fileURLToPath(import.meta.url)),'../../..')
|
||||
assert(process.argv.length>=3&&process.argv.length<=5,'Supply a unique evidence name, optional compiled output and delayed-approval scenario')
|
||||
const delayedApproval=process.argv[4]==='delayed-approval'
|
||||
assert(process.argv[4]===undefined||delayedApproval,'Unknown isolated scenario')
|
||||
const name=process.argv[2];assert(/^[a-z0-9][a-z0-9-]{3,99}$/.test(name),'Invalid evidence name')
|
||||
const outputName=process.argv[3]??'dist-installation-memory'
|
||||
assert(/^dist-installation-memory(?:-[a-z0-9-]{3,64})?$/.test(outputName),'Invalid compiled output name')
|
||||
const prefix=resolve(root,'tmp',name), statusPath=prefix+'-status.json'
|
||||
const exclusive=await fs.open(statusPath,'wx');await exclusive.close()
|
||||
const state={scope:'local-backend-coload-only',wholeInstallation2GbAccepted:false,compiledOutputName:outputName,delayedHumanApprovalScenario:delayedApproval,status:'starting',nodeExitCode:null,sourcePinsUnchanged:false,cleanupConfirmed:false,startedAt:new Date().toISOString(),wrapperPid:process.pid}
|
||||
const save=()=>fs.writeFile(statusPath,JSON.stringify(state,null,2)+'\n');await save()
|
||||
const require=createRequire(new URL('../../package.json',import.meta.url)),EmbeddedPostgres=require('embedded-postgres').default
|
||||
const directory=await fs.mkdtemp(join(tmpdir(),'mm-mcp-test-')),server=createServer()
|
||||
await new Promise(done=>server.listen(0,'127.0.0.1',done));const port=server.address().port;await new Promise(done=>server.close(done))
|
||||
const password=randomBytes(24).toString('hex')
|
||||
const postgres=new EmbeddedPostgres({databaseDir:join(directory,'pg'),port,user:'mcp_test',password,persistent:true,createPostgresUser:false,
|
||||
initdbFlags:['--encoding=UTF8'],postgresFlags:['-h','127.0.0.1'],onLog:()=>{},onError:()=>{}})
|
||||
const output=resolve(root,'backend',outputName),entry=resolve(output,'services/installation-co-load.owned.test.js')
|
||||
async function pins() {
|
||||
const rows=[]
|
||||
async function walk(dir){for(const item of await fs.readdir(dir,{withFileTypes:true})){const file=join(dir,item.name);if(item.isDirectory())await walk(file);else rows.push({path:file.slice(root.length+1).replaceAll('\\','/'),sha256:createHash('sha256').update(await fs.readFile(file)).digest('hex')})}}
|
||||
await walk(output);return rows.sort((a,b)=>a.path.localeCompare(b.path))
|
||||
}
|
||||
const before=await pins();await fs.writeFile(prefix+'-pins.json',JSON.stringify({before},null,2)+'\n')
|
||||
let log,timer
|
||||
try {
|
||||
await postgres.initialise();await postgres.start()
|
||||
log=await fs.open(prefix+'.log','wx')
|
||||
const retained=Object.fromEntries(['SystemRoot','WINDIR','TEMP','TMP','LANG','LC_ALL'].filter(key=>process.env[key]).map(key=>[key,process.env[key]]))
|
||||
const child=spawn(process.execPath,['--max-old-space-size=192','--max-semi-space-size=16',entry],{cwd:directory,windowsHide:true,stdio:['ignore',log.fd,log.fd],env:{...retained,
|
||||
NODE_OPTIONS:'',NODE_ENV:'test',PORT:'8000',AUTH_ISSUER:'https://auth.shnt.top',AUTH_PORTAL_URL:'http://localhost:5175',SQLITE_IMPORT_FULL_HISTORY:'false',
|
||||
MM_FLOW_READ_RESOURCE_PROFILE:'constrained',DATABASE_URL:`postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`,DATABASE_PATH:join(directory,'no-legacy.sqlite'),
|
||||
MCP_STATE_DIR:join(directory,'state'),AUTH_REQUIRED:'true',AUTH_JWT_SECRET:randomBytes(32).toString('hex'),MCP_PUBLIC_URL:'http://localhost:8000/mcp',MMAPP_OWN_INSTALLATION_COLOAD:'true',MMAPP_OWN_COLOAD_DELAYED_APPROVAL:String(delayedApproval)}})
|
||||
state.status='running';state.childPid=child.pid;await save()
|
||||
timer=setTimeout(()=>{state.outerTimedOut=true;child.kill()},300000)
|
||||
state.nodeExitCode=await new Promise((done,reject)=>{child.once('error',reject);child.once('exit',code=>done(code??1))})
|
||||
clearTimeout(timer);await log.close();log=undefined
|
||||
const after=await pins();state.sourcePinsUnchanged=JSON.stringify(before)===JSON.stringify(after)
|
||||
await fs.writeFile(prefix+'-pins.json',JSON.stringify({before,after,unchanged:state.sourcePinsUnchanged},null,2)+'\n')
|
||||
process.exitCode=state.nodeExitCode===0&&state.sourcePinsUnchanged?0:1
|
||||
}catch(error){state.failure=error instanceof Error?error.name:'UnknownError';process.exitCode=1}
|
||||
finally {
|
||||
if(timer)clearTimeout(timer);if(log)await log.close()
|
||||
try{
|
||||
if(postgres.process?.exitCode===null)await postgres.stop()
|
||||
const actual=await fs.realpath(directory),parent=await fs.realpath(tmpdir())
|
||||
assert.equal(dirname(actual),parent);assert.match(basename(actual),/^mm-mcp-test-[A-Za-z0-9-]+$/);assert.equal((await fs.lstat(directory)).isSymbolicLink(),false)
|
||||
await fs.rm(actual,{recursive:true,force:true,maxRetries:20,retryDelay:100});state.cleanupConfirmed=true
|
||||
}catch{state.cleanupConfirmed=false;process.exitCode=1}
|
||||
state.status='terminal';state.finishedAt=new Date().toISOString();await save();console.log(JSON.stringify(state))
|
||||
}
|
||||
if(state.cleanupConfirmed)process.exit(process.exitCode??1)
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
cd /opt/mmapp-test
|
||||
mm240_db="mm_accept_166_$(date +%s%3N)"
|
||||
mm240_pass="$(openssl rand -hex 24)"
|
||||
mm240_role_created=false
|
||||
mm240_db_created=false
|
||||
cleanup240() {
|
||||
docker rm mmapp-s3app240-owned >/dev/null 2>&1 || true
|
||||
if $mm240_db_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP DATABASE \"$mm240_db\" WITH (FORCE)"; fi
|
||||
if $mm240_role_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP ROLE \"$mm240_db\""; fi
|
||||
remaining=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm240_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm240_db')")
|
||||
[ "$remaining" = 0 ]
|
||||
python3 - <<'PY'
|
||||
import json,pathlib
|
||||
p=pathlib.Path('/opt/mmapp-test/artifacts/s3-retention240/s3-retention240.json')
|
||||
if p.exists():
|
||||
r=json.loads(p.read_text());r['ownedDatabaseAndRoleRemoved']=True;p.write_text(json.dumps(r,indent=2)+'\n')
|
||||
PY
|
||||
printf 'OWNED_DATABASE_AND_ROLE_REMOVED\n'
|
||||
unset mm240_pass
|
||||
}
|
||||
trap cleanup240 EXIT
|
||||
[[ "$mm240_db" =~ ^mm_accept_166_[0-9]+$ ]]
|
||||
existing=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm240_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm240_db')")
|
||||
[ "$existing" = 0 ]
|
||||
printf "CREATE ROLE \"%s\" LOGIN PASSWORD '%s' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;\n" "$mm240_db" "$mm240_pass" | docker exec -i mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1
|
||||
mm240_role_created=true
|
||||
docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$mm240_db\" OWNER \"$mm240_db\""
|
||||
mm240_db_created=true
|
||||
docker run --name mmapp-s3app240-owned --network mmapp-test_default --cap-drop ALL --security-opt no-new-privileges -e "MMAPP_ORIGIN_BASELINE=${2:-false}" --env-file /opt/mmapp-test/artifacts/s3-retention240/private.env -e NODE_ENV=test -e "DATABASE_URL=postgres://$mm240_db:$mm240_pass@postgres:5432/$mm240_db" -e "MMAPP_OWN_SQL_DB=$mm240_db" -e "DATABASE_PATH=/tmp/$mm240_db/legacy.db" -e "GEOIP_DIR=/tmp/$mm240_db/geoip" -v /opt/mmapp-test/artifacts/s3-retention240:/checks --entrypoint node "${1:-mmapp-test/backend:ux-backups237}" /checks/s3-backup-retention.mjs
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
cd /opt/mmapp-test
|
||||
mm229_db="mm_accept_166_$(date +%s%3N)"
|
||||
mm229_pass="$(openssl rand -hex 24)"
|
||||
mm229_role_created=false
|
||||
mm229_db_created=false
|
||||
cleanup229() {
|
||||
docker rm mmapp-s3app229-owned >/dev/null 2>&1 || true
|
||||
if $mm229_db_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP DATABASE \"$mm229_db\" WITH (FORCE)"; fi
|
||||
if $mm229_role_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP ROLE \"$mm229_db\""; fi
|
||||
remaining=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm229_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm229_db')")
|
||||
[ "$remaining" = 0 ]
|
||||
python3 - <<'PY'
|
||||
import json,pathlib
|
||||
p=pathlib.Path('/opt/mmapp-test/artifacts/s3-229/s3-application229.json')
|
||||
if p.exists():
|
||||
r=json.loads(p.read_text());r['ownedDatabaseAndRoleRemoved']=True;p.write_text(json.dumps(r,indent=2)+'\n')
|
||||
PY
|
||||
printf 'OWNED_DATABASE_AND_ROLE_REMOVED\n'
|
||||
unset mm229_pass
|
||||
}
|
||||
trap cleanup229 EXIT
|
||||
[[ "$mm229_db" =~ ^mm_accept_166_[0-9]+$ ]]
|
||||
existing=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm229_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm229_db')")
|
||||
[ "$existing" = 0 ]
|
||||
printf "CREATE ROLE \"%s\" LOGIN PASSWORD '%s' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;\n" "$mm229_db" "$mm229_pass" | docker exec -i mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1
|
||||
mm229_role_created=true
|
||||
docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$mm229_db\" OWNER \"$mm229_db\""
|
||||
mm229_db_created=true
|
||||
docker run --name mmapp-s3app229-owned --network mmapp-test_default --cap-drop ALL --security-opt no-new-privileges --env-file /opt/mmapp-test/artifacts/s3-228-private.env -e NODE_ENV=test -e "DATABASE_URL=postgres://$mm229_db:$mm229_pass@postgres:5432/$mm229_db" -e "MMAPP_OWN_SQL_DB=$mm229_db" -e "DATABASE_PATH=/tmp/$mm229_db/legacy.db" -e "GEOIP_DIR=/tmp/$mm229_db/geoip" -v /opt/mmapp-test/artifacts/s3-229:/checks --entrypoint node mmapp-test/backend:ux-regression222 /checks/s3-backups-api.mjs
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
cd /opt/mmapp-test
|
||||
mm245_db="mm_accept_166_$(date +%s%3N)"
|
||||
mm245_pass="$(openssl rand -hex 24)"
|
||||
mm245_role_created=false
|
||||
mm245_db_created=false
|
||||
cleanup245() {
|
||||
docker rm mmapp-s3app245-owned >/dev/null 2>&1 || true
|
||||
if $mm245_db_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP DATABASE \"$mm245_db\" WITH (FORCE)"; fi
|
||||
if $mm245_role_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP ROLE \"$mm245_db\""; fi
|
||||
remaining=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm245_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm245_db')")
|
||||
[ "$remaining" = 0 ]
|
||||
python3 - <<'PY'
|
||||
import json,pathlib
|
||||
p=pathlib.Path('/opt/mmapp-test/artifacts/traffic-flow245/traffic-flow245.json')
|
||||
if p.exists():
|
||||
r=json.loads(p.read_text());r['ownedDatabaseAndRoleRemoved']=True;p.write_text(json.dumps(r,indent=2)+'\n')
|
||||
PY
|
||||
printf 'OWNED_DATABASE_AND_ROLE_REMOVED\n'
|
||||
unset mm245_pass
|
||||
}
|
||||
trap cleanup245 EXIT
|
||||
[[ "$mm245_db" =~ ^mm_accept_166_[0-9]+$ ]]
|
||||
existing=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm245_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm245_db')")
|
||||
[ "$existing" = 0 ]
|
||||
printf "CREATE ROLE \"%s\" LOGIN PASSWORD '%s' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;\n" "$mm245_db" "$mm245_pass" | docker exec -i mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1
|
||||
mm245_role_created=true
|
||||
docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$mm245_db\" OWNER \"$mm245_db\""
|
||||
mm245_db_created=true
|
||||
docker run --name mmapp-s3app245-owned --network mmapp-test_default --cap-drop ALL --security-opt no-new-privileges -e "MMAPP_ORIGIN_BASELINE=${2:-false}" -e NODE_ENV=test -e "DATABASE_URL=postgres://$mm245_db:$mm245_pass@postgres:5432/$mm245_db" -e "MMAPP_OWN_SQL_DB=$mm245_db" -e "DATABASE_PATH=/tmp/$mm245_db/legacy.db" -e "GEOIP_DIR=/tmp/$mm245_db/geoip" -v /opt/mmapp-test/artifacts/traffic-flow245:/checks --entrypoint node "${1:-mmapp-test/backend:ux-geoip242-acceptance}" /checks/traffic-flow-regression.mjs
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
const database = process.env.MMAPP_OWN_SQL_DB;
|
||||
assert(/^mm_accept_166_\d+$/.test(database));
|
||||
const url = new URL(process.env.DATABASE_URL); assert.equal(url.pathname, '/' + database); assert.equal(url.username, database);
|
||||
fs.mkdirSync('/tmp/' + database, { recursive: true }); process.chdir('/tmp/' + database);
|
||||
const require = createRequire('/app/backend/package.json'), { ListObjectVersionsCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { pool, db, closePool } = await import('/app/backend/dist/db/index.js');
|
||||
const { initDatabase } = await import('/app/backend/dist/db/bootstrap.js');
|
||||
const { servers } = await import('/app/backend/dist/db/schema.js');
|
||||
const { databaseBackgroundWork } = await import('/app/backend/dist/services/database-background-work.js');
|
||||
const { createS3ClientFromConfig, s3PutObject, s3GetObject } = await import('/app/backend/dist/services/s3-backup-client.js');
|
||||
const { updateBackupStorageSettings, pruneBackupsForServer, readBackupContent, listBackups } = await import('/app/backend/dist/services/backup-service.js');
|
||||
const role = (await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];
|
||||
assert(Object.values(role).every(v => v === false)); await initDatabase();
|
||||
const prefix = 'mmapp-acceptance/' + randomUUID(), secret = process.env.MMAPP_TEST_S3_SECRET_KEY;
|
||||
const cfg = { endpoint: process.env.MMAPP_TEST_S3_ENDPOINT, region: process.env.MMAPP_TEST_S3_REGION, bucket: process.env.MMAPP_TEST_S3_BUCKET, accessKeyId: process.env.MMAPP_TEST_S3_ACCESS_KEY, secretAccessKey: secret, forcePathStyle: false };
|
||||
assert.equal(cfg.endpoint, 'https://s3.ru-1.storage.selcloud.ru'); assert.equal(cfg.bucket, 'mikrotik-test-s3'); assert(secret);
|
||||
const s3 = createS3ClientFromConfig(cfg), plannedKeys = [], seededIds = [], payloads = new Map();
|
||||
const report = { date: '2026-10-14', block: '12.5.240', scope: 'Owned PostgreSQL + compiled retention service + real Selectel S3; no CHR contacts', prefix, passed: false, checks: [], cleanup: {}, chrContacts: 0, bucketSettingsWrites: 0 };
|
||||
const save = () => fs.writeFileSync('/checks/s3-retention240.json', JSON.stringify(report, null, 2) + '\n');
|
||||
const check = (name, details = {}) => { report.checks.push({ name, ...details }); save(); };
|
||||
const catalog = async () => (await pool.query('SELECT id,storage,s3_key FROM backup_entries ORDER BY id')).rows;
|
||||
async function versions() {
|
||||
let keyMarker, versionMarker; const all = [];
|
||||
for (let n = 0; n < 10; n++) {
|
||||
const r = await s3.send(new ListObjectVersionsCommand({ Bucket: cfg.bucket, Prefix: prefix + '/', KeyMarker: keyMarker, VersionIdMarker: versionMarker }));
|
||||
all.push(...(r.Versions ?? []).map(v => ({ ...v, kind: 'version' })), ...(r.DeleteMarkers ?? []).map(v => ({ ...v, kind: 'delete-marker' })));
|
||||
if (!r.IsTruncated) return all;
|
||||
assert(r.NextKeyMarker || r.NextVersionIdMarker); keyMarker = r.NextKeyMarker; versionMarker = r.NextVersionIdMarker;
|
||||
} throw Error('Own pagination limit');
|
||||
}
|
||||
try {
|
||||
assert.equal((await versions()).length, 0);
|
||||
const hosts = await db.insert(servers).values(['a', 'b', 'c'].map(s => ({ name: 'owned-retention240-' + s, host: '127.0.0.1', port: 9, useSsl: false, username: 'fixture', password: 'fixture', enabled: true, type: 'exit-node' }))).returning();
|
||||
await updateBackupStorageSettings({ provider: 's3', s3Endpoint: cfg.endpoint, s3Region: cfg.region, s3Bucket: cfg.bucket, s3Prefix: prefix, s3AccessKeyId: cfg.accessKeyId, s3SecretAccessKey: secret, s3ForcePathStyle: false, keepLocalCopy: true });
|
||||
fs.mkdirSync('storage/backups', { recursive: true });
|
||||
const fixtures = [['a4',0,4,'both'],['a3',0,3,'s3'],['a2',0,2,'local'],['a1',0,1,'s3'],['b2',1,2,'both'],['b1',1,1,'local'],['b0',1,0,'s3'],['c0',2,0,'local']];
|
||||
for (const [id, hostIndex, seconds, storage] of fixtures) {
|
||||
const filename = id + '-retention240.rsc', bytes = Buffer.from('# Own retention240 ' + id + '\n'), key = storage === 'local' ? null : prefix + '/' + filename;
|
||||
seededIds.push(id); payloads.set(id, bytes);
|
||||
if (key) { plannedKeys.push(key); await s3PutObject(s3, cfg.bucket, key, bytes); assert.deepEqual(await s3GetObject(s3, cfg.bucket, key), bytes); }
|
||||
if (storage !== 's3') fs.writeFileSync('storage/backups/' + filename, bytes);
|
||||
await pool.query('INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)', [id, hosts[hostIndex].id, hosts[hostIndex].name, filename, bytes.length, id.endsWith('0') ? 'manual' : 'auto', storage, key, new Date(Date.UTC(2026,9,14,0,0,seconds)).toISOString()]);
|
||||
}
|
||||
check('eight-fixtures-three-servers', { remoteObjects: plannedKeys.length, localFiles: fixtures.filter(f => f[3] !== 's3').length });
|
||||
const untouched = (await catalog()).filter(r => !r.id.startsWith('a'));
|
||||
await updateBackupStorageSettings({ s3SecretAccessKey: secret + '-invalid' });
|
||||
let failure;
|
||||
try { await pruneBackupsForServer(String(hosts[0].id), 2); } catch (e) { failure = e; }
|
||||
assert.equal(failure?.name, 'BackupPruneError'); assert.equal(failure.pruned, 1);
|
||||
let remaining = await catalog(); assert.deepEqual(remaining.filter(r => !r.id.startsWith('a')), untouched);
|
||||
assert.deepEqual(remaining.filter(r => r.id.startsWith('a')).map(r => r.id), ['a1','a3','a4']);
|
||||
assert(!fs.existsSync('storage/backups/a2-retention240.rsc'));
|
||||
assert.deepEqual(await s3GetObject(s3, cfg.bucket, prefix + '/a1-retention240.rsc'), payloads.get('a1'));
|
||||
check('partial-failure-acknowledges-one-local-deletion', { pruned: failure.pruned, failedRowRetained: true, remoteObjectRetained: true, otherServersUnchanged: true });
|
||||
await updateBackupStorageSettings({ s3SecretAccessKey: secret });
|
||||
assert.equal(await pruneBackupsForServer(String(hosts[0].id), 2), 1);
|
||||
assert.equal(await pruneBackupsForServer(String(hosts[0].id), 2), 0);
|
||||
assert.deepEqual((await catalog()).filter(r => r.id.startsWith('a')).map(r => r.id), ['a3','a4']);
|
||||
check('retry-retains-latest-two-and-repeat-noop', { retryPruned: 1, repeatPruned: 0 });
|
||||
assert.equal(await pruneBackupsForServer(String(hosts[1].id), 1), 2);
|
||||
assert.deepEqual((await catalog()).map(r => r.id), ['a3','a4','b2','c0']);
|
||||
check('second-server-retains-latest-and-unselected-manual-copy', { pruned: 2, retained: ['a3','a4','b2','c0'] });
|
||||
const copies = await listBackups();
|
||||
for (const copy of copies) assert.deepEqual(await readBackupContent(copy), payloads.get(copy.id));
|
||||
assert.equal(copies.find(r => r.id === 'a3').localFileStatus, 'not-stored');
|
||||
assert(copies.filter(r => r.id !== 'a3').every(r => r.localFileStatus === 'available'));
|
||||
check('retained-content-hashes-and-local-states', { copies: copies.map(r => ({ id: r.id, localFileStatus: r.localFileStatus, sha256: createHash('sha256').update(payloads.get(r.id)).digest('hex') })) });
|
||||
report.passed = true;
|
||||
} catch (e) { report.failure = { name: e.name }; process.exitCode = 1; }
|
||||
finally {
|
||||
try {
|
||||
const existing = await versions(); assert(existing.every(v => plannedKeys.includes(v.Key) && v.VersionId));
|
||||
report.cleanup = { plannedKeys, versionsBefore: existing.filter(v => v.kind === 'version').length, deleteMarkersBefore: existing.filter(v => v.kind === 'delete-marker').length, removed: 0 }; save();
|
||||
for (const v of existing) { await s3.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: v.Key, VersionId: v.VersionId })); report.cleanup.removed++; save(); }
|
||||
assert.equal((await versions()).length, 0); report.cleanup.versionsAndMarkersRemaining = 0;
|
||||
if (seededIds.length) await pool.query('DELETE FROM backup_entries WHERE id=ANY($1::text[])', [seededIds]);
|
||||
report.cleanup.ownCatalogRemaining = Number((await pool.query('SELECT count(*) AS n FROM backup_entries')).rows[0].n); assert.equal(report.cleanup.ownCatalogRemaining, 0);
|
||||
} catch (e) { report.cleanup.failure = { name: e.name }; report.passed = false; process.exitCode = 1; }
|
||||
await databaseBackgroundWork.drain(); await closePool(); s3.destroy(); save(); console.log(JSON.stringify(report));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';import fs from 'node:fs';import{randomUUID,createHash}from'node:crypto';import{createServer}from'node:http';import{createRequire}from'node:module';
|
||||
assert.equal(process.env.MMAPP_TEST_S3_OWNED_SCOPE,'yes');assert.equal(process.env.MMAPP_TEST_S3_RUN,'confirmed');assert.equal(process.env.MMAPP_TEST_S3_PATH_STYLE,'false');const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
|
||||
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{db,pool,closePool}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');const{servers}=await import('/app/backend/dist/db/schema.js');const{eq}=await import('/app/node_modules/drizzle-orm/index.js');
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();
|
||||
const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');const{s3ListObjects,s3PutObject,s3DeleteObject,createS3ClientFromConfig}=await import('/app/backend/dist/services/s3-backup-client.js');
|
||||
const prefix='mmapp-acceptance/'+randomUUID(),secret=process.env.MMAPP_TEST_S3_SECRET_KEY,config={provider:'s3',s3Endpoint:process.env.MMAPP_TEST_S3_ENDPOINT,s3Region:process.env.MMAPP_TEST_S3_REGION,s3Bucket:process.env.MMAPP_TEST_S3_BUCKET,s3Prefix:prefix,s3AccessKeyId:process.env.MMAPP_TEST_S3_ACCESS_KEY,s3SecretAccessKey:secret,s3ForcePathStyle:false,keepLocalCopy:true};assert(secret&&config.s3Endpoint==='https://s3.ru-1.storage.selcloud.ru'&&config.s3Bucket==='mikrotik-test-s3');const client=createS3ClientFromConfig({endpoint:config.s3Endpoint,region:config.s3Region,bucket:config.s3Bucket,prefix,accessKeyId:config.s3AccessKeyId,secretAccessKey:secret,forcePathStyle:false});
|
||||
const report={date:'2026-10-14',block:'12.5.229',scope:'Compiled backup API + owned PostgreSQL + real Selectel S3; synthetic RouterOS HTTP export fixture, no CHR contacts',prefix,checks:[],passed:false,cleanup:{},nativeChrContacts:0,nativeRestoreCalls:0};const file='/checks/s3-application229.json';const save=()=>fs.writeFileSync(file,JSON.stringify(report,null,2)+'\n');const check=(name,extra={})=>{report.checks.push({name,passed:true,...extra});save()};const sha=b=>createHash('sha256').update(b).digest('hex');let exportCalls=0;const body='# Owned MikroTik Manager S3 application acceptance\n';
|
||||
const fixture=createServer((req,res)=>{if(req.method==='POST'&&req.url==='/rest/console/export'){exportCalls++;res.setHeader('content-type','application/json');res.end(JSON.stringify({ret:body}));}else{res.statusCode=404;res.end('{}')}});await new Promise(r=>fixture.listen(0,'127.0.0.1',r));const names=['s3-positive-'+randomUUID(),'s3-negative-'+randomUUID(),'s3-only-'+randomUUID()];const [server]=await db.insert(servers).values({name:names[0],host:'127.0.0.1',port:fixture.address().port,useSsl:false,username:'fixture',password:'fixture',enabled:true,type:'exit-node'}).returning();const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();
|
||||
async function api(method,path,payload,status=200){const r=await app.inject({method,url:'/api'+path,...(payload?{payload}:{})});assert.equal(r.statusCode,status,method+' '+path+' HTTP '+r.statusCode);return r}
|
||||
async function job(){const requestId=randomUUID(),before=exportCalls;await api('POST','/backups/create',{serverIds:[server.id],requestId},202);for(let n=0;n<100;n++){const r=(await api('GET','/backups/jobs/'+requestId)).json();if(r.status==='done'){assert.equal(r.completed,1);assert.equal(r.failures.length,0);assert.equal(r.created.length,1);assert.equal(exportCalls,before+1);await api('POST','/backups/create',{serverIds:[server.id],requestId},202);assert.equal(exportCalls,before+1);return r.created[0]}await new Promise(r=>setTimeout(r,100))}throw Error('Job did not complete')}
|
||||
try{
|
||||
assert.equal((await s3ListObjects(client,config.s3Bucket,prefix)).length,0);check('empty-prefix-preflight');
|
||||
let settings=(await api('PUT','/backups/storage',config)).json();assert(settings.secretConfigured&&settings.lastTestAt===null);assert(!JSON.stringify(settings).includes(secret));check('settings-save-without-secret-disclosure');
|
||||
settings=(await api('POST','/backups/storage/test')).json();assert(settings.lastTestAt&&!settings.lastTestError);check('real-headbucket-test-receipt');
|
||||
const both=await job();assert.equal(both.storage,'both');assert(!both.uploadError&&both.s3Key.startsWith(prefix+'/'));check('async-create-both-and-request-id-no-repeat');
|
||||
const downloaded=await api('GET','/backups/'+both.id+'/download');assert.equal(sha(downloaded.rawPayload),sha(Buffer.from(body)));check('download-exact-bytes');
|
||||
await api('PUT','/backups/storage',{s3SecretAccessKey:secret+'-invalid'});const denied=(await api('POST','/backups/storage/test',undefined,400)).json();assert(denied.settings.lastTestError&&denied.settings.secretConfigured);check('denied-credentials-test-preserves-settings');
|
||||
const fallback=await api('GET','/backups/'+both.id+'/download');assert.equal(sha(fallback.rawPayload),sha(Buffer.from(body)));check('download-local-fallback-during-s3-denial');
|
||||
await db.update(servers).set({name:names[1]}).where(eq(servers.id,server.id));const partial=await job();assert.equal(partial.storage,'local');assert(partial.uploadError&&!partial.s3Key);check('upload-denial-keeps-local-backup-and-error');
|
||||
await api('PUT','/backups/storage',{s3SecretAccessKey:secret,keepLocalCopy:false});await db.update(servers).set({name:names[2]}).where(eq(servers.id,server.id));const remote=await job();assert.equal(remote.storage,'s3');assert(!remote.uploadError);assert(!fs.existsSync('storage/backups/'+remote.filename));const remoteBytes=await api('GET','/backups/'+remote.id+'/download');assert.equal(sha(remoteBytes.rawPayload),sha(Buffer.from(body)));check('s3-only-removes-local-copy-and-downloads-remote');
|
||||
const key=prefix+'/seed/probe-sync.rsc';await s3PutObject(client,config.s3Bucket,key,body);const sync=(await api('POST','/backups/storage/sync')).json();assert.equal(sync.imported,1);const again=(await api('POST','/backups/storage/sync')).json();assert.equal(again.imported,0);check('sync-import-and-idempotent-repeat');
|
||||
const catalog=(await api('GET','/backups')).json();assert.equal(catalog.length,4);const imported=catalog.find(r=>r.s3Key===key);assert(imported&&imported.serverId===null);assert.equal((await api('GET','/backups/'+imported.id+'/download')).payload,body);check('catalog-and-imported-object-download');
|
||||
for(const row of catalog)await api('DELETE','/backups/'+row.id,undefined,204);assert.equal((await api('GET','/backups')).json().length,0);assert.equal((await s3ListObjects(client,config.s3Bucket,prefix)).length,0);await api('DELETE','/backups/'+both.id,undefined,404);check('delete-catalog-and-current-remote-objects');report.passed=true;
|
||||
}catch(e){report.failure={name:e.name};}
|
||||
finally{
|
||||
try{await api('PUT','/backups/storage',{s3SecretAccessKey:secret});const rows=(await api('GET','/backups')).json();for(const row of rows){assert(!row.s3Key||row.s3Key.startsWith(prefix+'/'));await api('DELETE','/backups/'+row.id,undefined,204)}const leftovers=await s3ListObjects(client,config.s3Bucket,prefix);for(const row of leftovers){assert(row.key===prefix+'/seed/probe-sync.rsc'||names.some(n=>row.key.startsWith(prefix+'/'+n+'/'+n+'_')),'Unexpected key; do not delete');await s3DeleteObject(client,config.s3Bucket,row.key)}report.cleanup.currentObjectsRemaining=(await s3ListObjects(client,config.s3Bucket,prefix)).length;report.cleanup.catalogRowsRemaining=(await api('GET','/backups')).json().length;report.cleanup.localFilesRemaining=fs.existsSync('storage/backups')?fs.readdirSync('storage/backups').length:0;assert.equal(report.cleanup.currentObjectsRemaining,0);assert.equal(report.cleanup.localFilesRemaining,0)}catch(e){report.cleanup.failure={name:e.name};report.passed=false}
|
||||
await databaseBackgroundWork.drain();await app.close();await new Promise(r=>fixture.close(r));await closePool();client.destroy();report.fixtureExportCalls=exportCalls;report.completedAt=new Date().toISOString();report.bounds=['Native export is HTTP fixture; no real device backup/restore or UI repeated','RBAC not repeated in this standalone injected API','S3 current-object deletion only; retained versions/retention not inspected'];save();console.log(JSON.stringify({passed:report.passed,checks:report.checks.length,cleanup:report.cleanup,fixtureExportCalls:exportCalls,nativeChrContacts:0}));if(!report.passed)process.exitCode=1;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import assert from 'node:assert/strict';import fs from 'node:fs';import{createRequire}from'node:module';import{createS3ClientFromConfig}from'/app/backend/dist/services/s3-backup-client.js';const require=createRequire('/app/backend/package.json'),{ListObjectVersionsCommand,DeleteObjectCommand}=require('@aws-sdk/client-s3');const bucket=process.env.MMAPP_TEST_S3_BUCKET;assert.equal(bucket,'mikrotik-test-s3');const client=createS3ClientFromConfig({endpoint:process.env.MMAPP_TEST_S3_ENDPOINT,region:process.env.MMAPP_TEST_S3_REGION,bucket,prefix:'mmapp-acceptance',accessKeyId:process.env.MMAPP_TEST_S3_ACCESS_KEY,secretAccessKey:process.env.MMAPP_TEST_S3_SECRET_KEY,forcePathStyle:false});const reports=JSON.parse(fs.readFileSync('/checks/owned-prefixes-public.json'));const plans=[],out={at:new Date().toISOString(),bucketConfigurationWrites:0,prefixes:[],passed:false};
|
||||
async function list(prefix){let keyMarker,versionMarker,rows=[];for(let page=0;page<50;page++){const r=await client.send(new ListObjectVersionsCommand({Bucket:bucket,Prefix:prefix+'/',KeyMarker:keyMarker,VersionIdMarker:versionMarker}));rows.push(...(r.Versions??[]).map(v=>({...v,kind:'version'})),...(r.DeleteMarkers??[]).map(v=>({...v,kind:'delete-marker'})));if(!r.IsTruncated)return rows;assert(r.NextKeyMarker||r.NextVersionIdMarker);keyMarker=r.NextKeyMarker;versionMarker=r.NextVersionIdMarker;}throw Error('Version pagination limit')}
|
||||
function isOwn(r,key){if(r.plannedKeys)return r.plannedKeys.includes(key);const relative=key.slice(r.prefix.length+1);return relative==='seed/probe-sync.rsc'||/^(s3-(?:positive|only)-[a-f0-9-]{36})\/\1_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.rsc$/.test(relative)}
|
||||
try{for(const r of reports){assert(/^mmapp-acceptance\/[a-f0-9-]{36}$/.test(r.prefix));const rows=await list(r.prefix);for(const row of rows)assert(row.Key?.startsWith(r.prefix+'/')&&isOwn(r,row.Key)&&typeof row.VersionId==='string'&&row.VersionId.length>0,'Unexpected version: do not delete');plans.push({r,rows});}for(const{r,rows}of plans){let removed=0;for(const row of rows){await client.send(new DeleteObjectCommand({Bucket:bucket,Key:row.Key,VersionId:row.VersionId}));removed++;}const after=await list(r.prefix);assert.equal(after.length,0);out.prefixes.push({prefix:r.prefix,versionsBefore:rows.filter(r=>r.kind==='version').length,deleteMarkersBefore:rows.filter(r=>r.kind==='delete-marker').length,removed,versionsAndMarkersRemaining:0});}out.passed=true}catch(e){out.failure={name:e.name,httpStatus:e.$metadata?.httpStatusCode??null}}finally{client.destroy();fs.writeFileSync('/checks/owned-versions-cleanup-public.json',JSON.stringify(out,null,2)+'\n');console.log(JSON.stringify(out));if(!out.passed)process.exitCode=1}
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
|
||||
// Run in the compiled test backend image. Never upload a device configuration here.
|
||||
async function main() {
|
||||
const endpoint = new URL(process.env.MMAPP_TEST_S3_ENDPOINT ?? '')
|
||||
assert.equal(endpoint.protocol, 'https:', 'Verified HTTPS endpoint required')
|
||||
assert(!endpoint.username && !endpoint.password && !endpoint.search && !endpoint.hash, 'Endpoint must not contain credentials or query parameters')
|
||||
assert.equal(process.env.MMAPP_TEST_S3_OWNED_SCOPE, 'yes', 'Explicit bucket/prefix authorization required')
|
||||
const bucket = process.env.MMAPP_TEST_S3_BUCKET
|
||||
assert(bucket && bucket.trim() === bucket, 'Explicit bucket required')
|
||||
const accessKeyId = process.env.MMAPP_TEST_S3_ACCESS_KEY, secretAccessKey = process.env.MMAPP_TEST_S3_SECRET_KEY
|
||||
assert(accessKeyId && secretAccessKey, 'Explicit test credentials required')
|
||||
const region = process.env.MMAPP_TEST_S3_REGION
|
||||
assert(region, 'Explicit region required')
|
||||
const pathStyle = process.env.MMAPP_TEST_S3_PATH_STYLE
|
||||
assert(['true', 'false'].includes(pathStyle), 'Explicit path-style choice required')
|
||||
const prefix = 'mmapp-acceptance/' + randomUUID()
|
||||
const keys = [prefix + '/probe-1.rsc', prefix + '/probe-2.rsc']
|
||||
if (process.env.MMAPP_TEST_S3_DRY_RUN === 'true') {
|
||||
console.log(JSON.stringify({ dryRun: true, networkRequests: 0, plannedKeys: keys, deviceConfigurationUploads: 0 }))
|
||||
} else {
|
||||
assert.equal(process.env.MMAPP_TEST_S3_RUN, 'confirmed', 'Explicit live execution required')
|
||||
const output = process.env.MMAPP_TEST_REPORT
|
||||
assert(output, 'Explicit report path required')
|
||||
const { createS3ClientFromConfig, s3PutObject, s3GetObject, s3ListObjects, s3DeleteObject } = await import('/app/backend/dist/services/s3-backup-client.js')
|
||||
const client = createS3ClientFromConfig({ endpoint: endpoint.toString(), region, bucket, prefix, accessKeyId, secretAccessKey, forcePathStyle: pathStyle === 'true' })
|
||||
const report = { startedAt: new Date().toISOString(), scope: 'Compiled application S3 client; harmless synthetic bodies; dedicated UUID prefix', prefix, passed: false, checks: [], cleanup: [], deviceConfigurationUploads: 0, bounds: ['No application backup jobs, SQL catalog, native restore or UI acceptance', 'Cleanup covers current objects; retained versions/delete markers/retention policies are not inspected'] }
|
||||
const attempted = []
|
||||
report.plannedKeys = keys
|
||||
report.attemptedKeys = attempted
|
||||
const save = () => fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n')
|
||||
const failure = error => ({ name: /^[\w:.-]+$/.test(error.name) ? error.name : 'Error', httpStatus: error.$metadata?.httpStatusCode ?? null })
|
||||
const sha = bytes => createHash('sha256').update(bytes).digest('hex')
|
||||
save()
|
||||
try {
|
||||
// An empty namespace is checked before recording ownership or attempting a write.
|
||||
assert.equal((await s3ListObjects(client, bucket, prefix)).length, 0, 'Namespace already contains objects; no writes allowed')
|
||||
report.checks.push({ operation: 'empty-prefix-preflight', passed: true }); save()
|
||||
for (const key of keys) {
|
||||
const body = Buffer.from('# MikroTik Manager owned S3 roundtrip probe\n# ' + key + '\n')
|
||||
attempted.push(key); save()
|
||||
await s3PutObject(client, bucket, key, body)
|
||||
const received = await s3GetObject(client, bucket, key)
|
||||
assert.equal(sha(received), sha(body), 'Object content mismatch')
|
||||
report.checks.push({ operation: 'put-get-content', key, bytes: received.length, passed: true }); save()
|
||||
}
|
||||
const listed = (await s3ListObjects(client, bucket, prefix)).map(row => row.key).sort()
|
||||
assert.deepEqual(listed, [...keys].sort(), 'Unexpected listing in owned namespace')
|
||||
report.checks.push({ operation: 'list-exact-owned-keys', passed: true })
|
||||
report.passed = true
|
||||
} catch (error) { report.failure = failure(error) }
|
||||
finally {
|
||||
for (const key of attempted) {
|
||||
try { await s3DeleteObject(client, bucket, key); report.cleanup.push({ key, acknowledged: true }) }
|
||||
catch (error) { report.cleanup.push({ key, acknowledged: false, failure: failure(error) }); report.passed = false }
|
||||
}
|
||||
if (attempted.length) {
|
||||
try {
|
||||
const remaining = (await s3ListObjects(client, bucket, prefix)).filter(row => attempted.includes(row.key)).length
|
||||
report.currentOwnedObjectsRemaining = remaining
|
||||
if (remaining) report.passed = false
|
||||
} catch (error) { report.cleanupReadbackFailure = failure(error); report.passed = false }
|
||||
}
|
||||
report.completedAt = new Date().toISOString(); save(); client.destroy()
|
||||
}
|
||||
console.log(JSON.stringify({ passed: report.passed, checks: report.checks.length, attemptedObjects: attempted.length, currentOwnedObjectsRemaining: report.currentOwnedObjectsRemaining ?? null }))
|
||||
if (!report.passed) process.exitCode = 1
|
||||
}
|
||||
}
|
||||
await main().catch(error => {
|
||||
const name = /^[\w:.-]+$/.test(error.name) ? error.name : 'Error'
|
||||
console.error('S3_ACCEPTANCE_FAILED: ' + name)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
const fs = require('fs'), assert = require('node:assert/strict'), { createHash } = require('crypto');
|
||||
const { chromium, expect } = require('@playwright/test');
|
||||
(async () => {
|
||||
const phase = process.argv[2], portal = JSON.parse(fs.readFileSync('tmp/portal195-private.json'));
|
||||
const state = JSON.parse(fs.readFileSync('tmp/state239-public.json'));
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }), errors = [], receipts = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
page.on('response', r => { if (new URL(r.url()).pathname.startsWith('/api/backups') && r.request().method() !== 'GET') receipts.push({ method: r.request().method(), path: new URL(r.url()).pathname, status: r.status() }); });
|
||||
await page.goto('http://localhost:3000/backups');
|
||||
await page.getByLabel('Email', { exact: true }).fill(portal.email);
|
||||
await page.getByLabel('Пароль', { exact: true }).fill(portal.password);
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Резервные копии', exact: true })).toBeVisible({ timeout: 30000 });
|
||||
if (phase === 'configure') {
|
||||
const s = JSON.parse(fs.readFileSync('tmp/s3-ui239-private.json'));
|
||||
const paused = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/backups/schedule', { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + localStorage.getItem('mmapp_token') }, body: JSON.stringify({ enabled: false }) });
|
||||
return { status: r.status, body: await r.json() };
|
||||
});
|
||||
assert.equal(paused.status, 200); assert.equal(paused.body.enabled, false);
|
||||
await page.getByRole('tab', { name: 'Настройки', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'S3', exact: true }).click();
|
||||
for (const [label, value] of Object.entries({ Endpoint: s.endpoint, Region: s.region, Bucket: s.bucket, Prefix: state.prefix, 'Access key': s.accessKey, 'Secret key': s.secretKey })) await page.getByLabel(label, { exact: true }).fill(value);
|
||||
const path = page.getByRole('switch', { name: 'Path-style', exact: true });
|
||||
if (await path.getAttribute('aria-checked') === 'true') await path.click();
|
||||
const saved = page.waitForResponse(r => new URL(r.url()).pathname === '/api/backups/storage' && r.request().method() === 'PUT');
|
||||
await page.getByRole('button', { name: 'Сохранить хранилище', exact: true }).click();
|
||||
assert.equal((await saved).status(), 200);
|
||||
await expect(page.getByLabel('Secret key', { exact: true })).toHaveValue('');
|
||||
const tested = page.waitForResponse(r => new URL(r.url()).pathname === '/api/backups/storage/test' && r.request().method() === 'POST');
|
||||
await page.getByRole('button', { name: 'Проверить', exact: true }).click();
|
||||
assert.equal((await tested).status(), 200);
|
||||
} else {
|
||||
await page.getByRole('button', { name: 'Обновить', exact: true }).click();
|
||||
await expect(page.getByText(state.filename, { exact: true })).toBeVisible();
|
||||
if (phase === 'restore') {
|
||||
const download = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Скачать ' + state.filename, exact: true }).click();
|
||||
const d = await download; assert.equal(d.suggestedFilename(), state.filename);
|
||||
const bytes = fs.readFileSync(await d.path());
|
||||
const expected = Buffer.from(`# Own limited UI restore 239\n/interface bridge add name=${state.bridge} comment=mm-ui239\n`);
|
||||
assert.deepEqual(bytes, expected);
|
||||
await page.getByRole('button', { name: 'Восстановить ' + state.filename, exact: true }).click();
|
||||
const dialog = page.getByRole('alertdialog');
|
||||
await expect(dialog).toContainText(state.filename);
|
||||
const restored = page.waitForResponse(r => new URL(r.url()).pathname === '/api/backups/' + state.id + '/restore' && r.request().method() === 'POST');
|
||||
await dialog.getByRole('button', { name: 'Восстановить', exact: true }).click();
|
||||
const r = await restored; assert.equal(r.status(), 200);
|
||||
const result = await r.json(); assert.equal(result.backupId, state.id); assert.equal(result.serverId, state.serverId);
|
||||
await expect(dialog).toBeHidden();
|
||||
fs.writeFileSync('docs/ux-ui/acceptance/s3-ui239-browser.json', JSON.stringify({ date: '2026-10-14', block: '12.5.239', realPortalLogin: true, noMocks: true, passed: true, downloadBytes: bytes.length, downloadSha256: createHash('sha256').update(bytes).digest('hex'), restoreStatus: 200, restoreReceiptMatched: true, receipts, pageErrors: errors, fullConfigurationApplied: false }, null, 2) + '\n');
|
||||
} else if (phase === 'delete') {
|
||||
await page.getByRole('button', { name: 'Удалить ' + state.filename, exact: true }).click();
|
||||
const deleted = page.waitForResponse(r => new URL(r.url()).pathname === '/api/backups/' + state.id && r.request().method() === 'DELETE');
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Удалить', exact: true }).click();
|
||||
assert.equal((await deleted).status(), 204); await expect(page.getByRole('alertdialog')).toBeHidden();
|
||||
await expect(page.getByText(state.filename, { exact: true })).toHaveCount(0);
|
||||
const p = 'docs/ux-ui/acceptance/s3-ui239-browser.json', report = JSON.parse(fs.readFileSync(p));
|
||||
Object.assign(report, { deleteStatus: 204, ownRowRemovedThroughUi: true, deleteReceipts: receipts }); fs.writeFileSync(p, JSON.stringify(report, null, 2) + '\n');
|
||||
} else throw Error('Unsupported phase');
|
||||
}
|
||||
assert.deepEqual(errors, []); console.log('S3 UI239 ' + phase + ' passed');
|
||||
} finally { await browser.close(); }
|
||||
})().catch(e => { console.error(e.message); process.exitCode = 1; });
|
||||
@@ -0,0 +1,89 @@
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire('/app/backend/package.json');
|
||||
const { ListObjectVersionsCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { pool } = await import('/app/backend/dist/db/index.js');
|
||||
const { MikrotikClient } = await import('/app/backend/dist/services/mikrotik.js');
|
||||
const { createS3ClientFromConfig, s3PutObject, s3GetObject } = await import('/app/backend/dist/services/s3-backup-client.js');
|
||||
const phase = process.argv[2], statePath = '/checks/state239-private.json', publicPath = '/checks/s3-ui239-native.json';
|
||||
const config = { endpoint: process.env.MMAPP_TEST_S3_ENDPOINT, region: process.env.MMAPP_TEST_S3_REGION, bucket: process.env.MMAPP_TEST_S3_BUCKET, accessKeyId: process.env.MMAPP_TEST_S3_ACCESS_KEY, secretAccessKey: process.env.MMAPP_TEST_S3_SECRET_KEY, forcePathStyle: false };
|
||||
assert.equal(config.endpoint, 'https://s3.ru-1.storage.selcloud.ru');
|
||||
assert.equal(config.bucket, 'mikrotik-test-s3'); assert(config.secretAccessKey);
|
||||
const s3 = createS3ClientFromConfig(config);
|
||||
const server = (await pool.query("SELECT * FROM servers WHERE host='194.226.121.150' AND enabled=true")).rows;
|
||||
assert.equal(server.length, 1);
|
||||
const client = new MikrotikClient({ host: server[0].host, port: server[0].port, useSsl: server[0].use_ssl, verifySsl: false, username: server[0].username, password: server[0].password });
|
||||
const rows = async table => (await pool.query(`SELECT to_jsonb(${table}) AS row FROM ${table} ORDER BY id`)).rows.map(r => r.row);
|
||||
const read = endpoint => client.get(endpoint, 10000);
|
||||
const save = state => fs.writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 });
|
||||
async function versions(state) {
|
||||
const result = []; let keyMarker, versionMarker;
|
||||
for (let n = 0; n < 10; n++) {
|
||||
const r = await s3.send(new ListObjectVersionsCommand({ Bucket: config.bucket, Prefix: state.prefix + '/', KeyMarker: keyMarker, VersionIdMarker: versionMarker }));
|
||||
result.push(...(r.Versions ?? []).map(v => ({ ...v, kind: 'version' })), ...(r.DeleteMarkers ?? []).map(v => ({ ...v, kind: 'delete-marker' })));
|
||||
if (!r.IsTruncated) return result;
|
||||
assert(r.NextKeyMarker || r.NextVersionIdMarker); keyMarker = r.NextKeyMarker; versionMarker = r.NextVersionIdMarker;
|
||||
} throw Error('Own version pagination limit');
|
||||
}
|
||||
async function restoreTable(table, original) {
|
||||
assert.equal(original.length, 1); assert.equal(original[0].id, 1);
|
||||
const columns = Object.keys(original[0]).filter(k => k !== 'id');
|
||||
assert(columns.every(k => /^[a-z][a-z0-9_]*$/.test(k)));
|
||||
await pool.query(`UPDATE ${table} SET ${columns.map((k, n) => `"${k}"=$${n + 1}`).join(',')} WHERE id=1`, columns.map(k => {
|
||||
const value = original[0][k]; return value && typeof value === 'object' ? JSON.stringify(value) : value;
|
||||
}));
|
||||
assert.deepEqual(await rows(table), original);
|
||||
}
|
||||
try {
|
||||
if (phase === 'init') {
|
||||
assert(!fs.existsSync(statePath));
|
||||
const uuid = randomUUID(), bridge = 'mm-ui239-' + uuid.slice(0, 12), filename = 'mm-ui239-' + uuid + '.rsc';
|
||||
const state = { id: 'mm-ui239-' + uuid, prefix: 'mmapp-acceptance/' + uuid, bridge, filename, key: 'mmapp-acceptance/' + uuid + '/' + filename, serverId: Number(server[0].id), serverName: server[0].name, storage: await rows('backup_storage_settings'), schedule: await rows('backup_schedule_settings'), catalog: await rows('backup_entries'), bridges: await read('/interface/bridge'), files: await read('/file') };
|
||||
assert.equal(state.storage[0].provider, 'local'); assert(state.catalog.every(r => r.storage === 'local' && !r.s3_key));
|
||||
assert(!state.bridges.some(b => b.name === bridge)); assert(!state.files.some(f => f.name === filename));
|
||||
assert.equal((await versions(state)).length, 0); save(state);
|
||||
fs.writeFileSync('/checks/state239-public.json', JSON.stringify({ id: state.id, prefix: state.prefix, filename, bridge, serverId: state.serverId }));
|
||||
console.log('Own scope initialized; original settings/catalog saved privately');
|
||||
} else {
|
||||
const state = JSON.parse(fs.readFileSync(statePath));
|
||||
assert(/^mm-ui239-[a-f0-9-]+\.rsc$/.test(state.filename)); assert(state.key === state.prefix + '/' + state.filename);
|
||||
if (phase === 'upload') {
|
||||
const storage = (await rows('backup_storage_settings'))[0];
|
||||
assert.equal(storage.provider, 's3'); assert.equal(storage.s3_prefix, state.prefix); assert.equal(storage.s3_bucket, config.bucket);
|
||||
assert.equal((await rows('backup_schedule_settings'))[0].enabled, false);
|
||||
const script = Buffer.from(`# Own limited UI restore 239\n/interface bridge add name=${state.bridge} comment=mm-ui239\n`);
|
||||
assert.equal((await versions(state)).length, 0);
|
||||
await s3PutObject(s3, config.bucket, state.key, script);
|
||||
assert.deepEqual(await s3GetObject(s3, config.bucket, state.key), script);
|
||||
await pool.query('INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,notes,storage,s3_key,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,now())', [state.id, state.serverId, state.serverName, state.filename, script.length, 'manual', 'Own limited UI restore 239: bridge only', 's3', state.key]);
|
||||
state.bytes = script.length; state.sha256 = createHash('sha256').update(script).digest('hex'); save(state);
|
||||
console.log(JSON.stringify({ ownS3ScriptUploaded: true, bytes: state.bytes, catalogRowCreated: true }));
|
||||
} else if (phase === 'verify') {
|
||||
const bridges = await read('/interface/bridge'), own = bridges.filter(b => b.name === state.bridge);
|
||||
assert.equal(own.length, 1); assert.equal(own[0].comment, 'mm-ui239');
|
||||
assert.equal((await read('/interface/bridge/port')).filter(p => p.bridge === state.bridge).length, 0);
|
||||
assert.equal((await read('/ip/address')).filter(p => p.interface === state.bridge).length, 0);
|
||||
assert.deepEqual(bridges.filter(b => b.name !== state.bridge), state.bridges);
|
||||
state.nativeVerified = true; save(state);
|
||||
console.log('Native bridge confirmed; no ports/addresses, original bridges unchanged');
|
||||
} else if (phase === 'cleanup') {
|
||||
const own = (await read('/interface/bridge')).filter(b => b.name === state.bridge);
|
||||
assert(own.length <= 1);
|
||||
if (own.length) { assert.equal(own[0].comment, 'mm-ui239'); await client.delete('/interface/bridge/' + encodeURIComponent(own[0]['.id'])); }
|
||||
const ownFiles = (await read('/file')).filter(f => f.name === state.filename); assert(ownFiles.length <= 1);
|
||||
for (const file of ownFiles) await client.removeFile(file['.id']);
|
||||
await pool.query('DELETE FROM backup_entries WHERE id=$1 AND filename=$2 AND s3_key=$3', [state.id, state.filename, state.key]);
|
||||
const existing = await versions(state); assert(existing.every(v => v.Key === state.key && v.VersionId));
|
||||
for (const v of existing) await s3.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: v.Key, VersionId: v.VersionId }));
|
||||
assert.equal((await versions(state)).length, 0);
|
||||
assert.deepEqual(await rows('backup_entries'), state.catalog);
|
||||
assert.deepEqual(await read('/interface/bridge'), state.bridges);
|
||||
assert(!(await read('/file')).some(f => f.name === state.filename));
|
||||
await restoreTable('backup_storage_settings', state.storage); await restoreTable('backup_schedule_settings', state.schedule);
|
||||
const report = { date: '2026-10-14', block: '12.5.239', host: server[0].host, prefix: state.prefix, limitedScriptBytes: state.bytes ?? null, sha256: state.sha256 ?? null, nativeBridgeVerified: Boolean(state.nativeVerified), bridgeAndUploadedFileRemoved: true, originalBridgesUnchanged: true, originalCatalogAllFieldsMatched: true, originalCatalogRows: state.catalog.length, originalSettingsAllFieldsMatched: true, s3VersionsAndMarkersRemoved: existing.length, s3VersionsAndMarkersRemaining: 0, passwordChanges: 0, fullConfigurationApplied: false };
|
||||
fs.writeFileSync(publicPath, JSON.stringify(report, null, 2) + '\n'); fs.unlinkSync(statePath); console.log(JSON.stringify(report));
|
||||
} else throw Error('Unsupported phase');
|
||||
}
|
||||
} finally { s3.destroy(); await pool.end(); }
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"readOnlyDockerAndProc": true,
|
||||
"serviceChanges": 0,
|
||||
"summary": {
|
||||
"samples": 1,
|
||||
"elapsedMs": 0,
|
||||
"hostMemTotalBytes": 2097152,
|
||||
"minHostAvailableBytes": 1048576,
|
||||
"maxObservedContainerUsageIncludingCacheBytes": 100,
|
||||
"containerIdentityAndStartStable": true,
|
||||
"allObservedRunning": true,
|
||||
"anyObservedOomKilled": false,
|
||||
"allCgroupV2MetricsAvailable": true,
|
||||
"pswpinDelta": 0,
|
||||
"pswpoutDelta": 0,
|
||||
"oom_killDelta": 0,
|
||||
"acceptancePassed": false,
|
||||
"scope": "sampled evidence only; correlate with workload, limits, errors and latency; unsampled peaks are unknown"
|
||||
},
|
||||
"observations": [
|
||||
{
|
||||
"elapsedMs": 0,
|
||||
"host": {
|
||||
"MemTotalBytes": 2097152,
|
||||
"MemAvailableBytes": 1048576,
|
||||
"SwapTotalBytes": 4194304,
|
||||
"SwapFreeBytes": 3145728,
|
||||
"pswpin": 20,
|
||||
"pswpout": 30,
|
||||
"oom_kill": 0
|
||||
},
|
||||
"containers": [
|
||||
{
|
||||
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"imageId": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"startedAt": "2026-11-02T00:00:00Z",
|
||||
"running": true,
|
||||
"oomKilled": false,
|
||||
"restartCount": 0,
|
||||
"usageIncludingCacheBytes": 100,
|
||||
"inactiveFileBytes": 30,
|
||||
"usageMinusInactiveFileBytes": 70,
|
||||
"configuredLimitBytes": 512,
|
||||
"reportedLimitBytes": 2048,
|
||||
"swapUsageBytes": null,
|
||||
"pid": 123,
|
||||
"identityStableDuringSample": true,
|
||||
"runningAfter": true,
|
||||
"oomKilledAfter": false,
|
||||
"cgroupV2": {
|
||||
"version": 2,
|
||||
"status": "available",
|
||||
"reason": null,
|
||||
"metrics": {
|
||||
"memory.current": 100,
|
||||
"memory.peak": 200,
|
||||
"memory.events": {
|
||||
"low": 0,
|
||||
"high": 0,
|
||||
"max": 0,
|
||||
"oom": 0,
|
||||
"oom_kill": 0
|
||||
},
|
||||
"memory.swap.current": 0,
|
||||
"memory.swap.peak": 0,
|
||||
"memory.swap.events": {
|
||||
"high": 0,
|
||||
"max": 0,
|
||||
"fail": 0
|
||||
}
|
||||
},
|
||||
"unavailable": {},
|
||||
"peakScope": "cgroup lifetime or since external reset; not an isolated workload peak",
|
||||
"provenance": "fixed host cgroup2 mount + exact Docker ID membership + process/root generation"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loaded 100 of 3835 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user