feat: add native fnOS PostgreSQL shared service
This commit is contained in:
Executable
+180
@@ -0,0 +1,180 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
VERSION=15.1.0
|
||||
PGVECTOR_VERSION=0.8.6
|
||||
PGVECTOR_PACKAGE_VERSION=0.8.6-1.pgdg12%2B1
|
||||
PGVECTOR_SHA256=b27ff894d1e2d23ebd7528fcb986923391977cbd5c5379ed74527875246854ca
|
||||
OUTPUT="${1:-$ROOT_DIR/artifacts/fnos/nxsir-postgresql-${VERSION}-x86_64.fpk}"
|
||||
DOTNET_BIN="${DOTNET:-dotnet}"
|
||||
NUGET_FEED="${POSTGRES_SERVICE_NUGET_FEED:-}"
|
||||
NUGET_PACKAGES="${NUGET_PACKAGES:-$ROOT_DIR/.cache/nuget-packages}"
|
||||
DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$ROOT_DIR/.cache/dotnet-cli-home}"
|
||||
BUILD_TMP_ROOT="${POSTGRES_SERVICE_BUILD_TMPDIR:-$ROOT_DIR/.cache/fnos-build-tmp}"
|
||||
|
||||
if [ -n "${FNPACK:-}" ]; then
|
||||
FNPACK_BIN="$FNPACK"
|
||||
elif [ -x "$ROOT_DIR/.tools/fnpack" ]; then
|
||||
FNPACK_BIN="$ROOT_DIR/.tools/fnpack"
|
||||
else
|
||||
FNPACK_BIN=fnpack
|
||||
fi
|
||||
FNPACK_BIN=$(command -v "$FNPACK_BIN") || {
|
||||
printf 'fnpack is required; install it from the fnOS developer portal or set FNPACK.\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for command_name in npm apt-get curl dpkg-deb node sha256sum tar find realpath; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || {
|
||||
printf 'required build command is missing: %s\n' "$command_name" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
DOTNET_BIN=$(command -v "$DOTNET_BIN") || { printf 'missing .NET SDK: %s\n' "$DOTNET_BIN" >&2; exit 1; }
|
||||
if [ -n "$NUGET_FEED" ] && [ ! -d "$NUGET_FEED" ]; then
|
||||
printf 'offline NuGet feed does not exist: %s\n' "$NUGET_FEED" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BUILD_TMP_ROOT" "$(dirname -- "$OUTPUT")"
|
||||
WORK_DIR=$(mktemp -d "${BUILD_TMP_ROOT%/}/postgres-service-fnos-build.XXXXXX")
|
||||
trap 'rm -rf -- "$WORK_DIR"' EXIT
|
||||
STAGE="$WORK_DIR/stage"
|
||||
PACKED_ROOT="$WORK_DIR/packed"
|
||||
FNPACK_TMP_ROOT="$WORK_DIR/fnpack-tmp"
|
||||
RUNTIME_ROOT="$STAGE/app/runtime"
|
||||
EXTRACT_ROOT="$WORK_DIR/runtime-extract"
|
||||
mkdir -p "$STAGE/app/server/wwwroot" "$RUNTIME_ROOT" "$PACKED_ROOT" "$FNPACK_TMP_ROOT" "$EXTRACT_ROOT"
|
||||
cp -a "$ROOT_DIR/fnos-postgresql/." "$STAGE/"
|
||||
|
||||
printf 'Building PostgreSQL management frontend...\n'
|
||||
npm run build:postgres-admin --prefix "$ROOT_DIR/frontend"
|
||||
cp -a "$ROOT_DIR/frontend/dist-postgres/." "$STAGE/app/server/wwwroot/"
|
||||
|
||||
printf 'Publishing self-contained PostgreSQL management API...\n'
|
||||
export NUGET_PACKAGES DOTNET_CLI_HOME
|
||||
restore_args=(-r linux-x64 --source "https://api.nuget.org/v3/index.json" --disable-parallel)
|
||||
if [ -n "$NUGET_FEED" ]; then
|
||||
restore_args+=(--source "$NUGET_FEED")
|
||||
fi
|
||||
"$DOTNET_BIN" restore "$ROOT_DIR/src/PostgresService.WebApi/PostgresService.WebApi.csproj" "${restore_args[@]}"
|
||||
"$DOTNET_BIN" publish "$ROOT_DIR/src/PostgresService.WebApi/PostgresService.WebApi.csproj" \
|
||||
-c Release \
|
||||
-r linux-x64 \
|
||||
--self-contained true \
|
||||
--no-restore \
|
||||
-p:DebugType=None \
|
||||
-p:DebugSymbols=false \
|
||||
-p:PublishSingleFile=false \
|
||||
-p:PublishReadyToRun=false \
|
||||
-o "$STAGE/app/server" \
|
||||
/maxcpucount:1
|
||||
rm -f "$STAGE/app/server/"*.pdb
|
||||
|
||||
printf 'Downloading pinned Debian Bookworm PostgreSQL 15 runtime...\n'
|
||||
APT_ROOT="$WORK_DIR/apt"
|
||||
mkdir -p \
|
||||
"$APT_ROOT/etc/apt" \
|
||||
"$APT_ROOT/var/lib/apt/lists/partial" \
|
||||
"$APT_ROOT/var/lib/dpkg" \
|
||||
"$APT_ROOT/var/cache/apt/archives/partial"
|
||||
cp "$ROOT_DIR/scripts/fnos-bookworm.sources.list" "$APT_ROOT/etc/apt/sources.list"
|
||||
touch "$APT_ROOT/var/lib/dpkg/status"
|
||||
APT_OPTIONS=(
|
||||
-o "Dir::Etc::sourcelist=$APT_ROOT/etc/apt/sources.list"
|
||||
-o "Dir::Etc::sourceparts=-"
|
||||
-o "Dir::State::status=$APT_ROOT/var/lib/dpkg/status"
|
||||
-o "Dir::State::lists=$APT_ROOT/var/lib/apt/lists"
|
||||
-o "Dir::Cache=$APT_ROOT/var/cache/apt"
|
||||
-o "Dir::Cache::archives=$APT_ROOT/var/cache/apt/archives"
|
||||
-o "Debug::NoLocking=1"
|
||||
-o "APT::Architecture=amd64"
|
||||
-o "Acquire::Languages=none"
|
||||
)
|
||||
apt-get "${APT_OPTIONS[@]}" update
|
||||
apt-get "${APT_OPTIONS[@]}" --download-only --no-install-recommends --yes install \
|
||||
postgresql-15 postgresql-client-15
|
||||
|
||||
shopt -s nullglob
|
||||
runtime_packages=("$APT_ROOT"/var/cache/apt/archives/*.deb)
|
||||
test "${#runtime_packages[@]}" -gt 0 || { printf 'APT did not download PostgreSQL runtime packages\n' >&2; exit 1; }
|
||||
for package_file in "${runtime_packages[@]}"; do
|
||||
dpkg-deb -x "$package_file" "$EXTRACT_ROOT"
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
printf 'Downloading pinned pgvector %s extension...\n' "$PGVECTOR_VERSION"
|
||||
PGVECTOR_DEB="$WORK_DIR/postgresql-15-pgvector.deb"
|
||||
curl --fail --location --retry 3 \
|
||||
"https://apt.postgresql.org/pub/repos/apt/pool/main/p/pgvector/postgresql-15-pgvector_${PGVECTOR_PACKAGE_VERSION}_amd64.deb" \
|
||||
--output "$PGVECTOR_DEB"
|
||||
printf '%s %s\n' "$PGVECTOR_SHA256" "$PGVECTOR_DEB" | sha256sum --check --status
|
||||
test "$(dpkg-deb -f "$PGVECTOR_DEB" Package)" = "postgresql-15-pgvector"
|
||||
test "$(dpkg-deb -f "$PGVECTOR_DEB" Version)" = "${PGVECTOR_PACKAGE_VERSION//%2B/+}"
|
||||
dpkg-deb -x "$PGVECTOR_DEB" "$EXTRACT_ROOT"
|
||||
|
||||
printf 'Assembling relocatable PostgreSQL runtime...\n'
|
||||
cp -a "$EXTRACT_ROOT/." "$RUNTIME_ROOT/"
|
||||
rm -rf \
|
||||
"$RUNTIME_ROOT/usr/share/doc" \
|
||||
"$RUNTIME_ROOT/usr/share/man" \
|
||||
"$RUNTIME_ROOT/usr/share/locale" \
|
||||
"$RUNTIME_ROOT/var" \
|
||||
"$RUNTIME_ROOT/etc/init.d" \
|
||||
"$RUNTIME_ROOT/usr/sbin"
|
||||
|
||||
# fnOS provides the matching glibc and loader. Keep other Debian libraries but
|
||||
# never package a second libc implementation into the application runtime.
|
||||
find "$RUNTIME_ROOT" -type f \( \
|
||||
-name 'ld-linux-*.so.*' -o -name 'libc.so.*' -o -name 'libm.so.*' -o \
|
||||
-name 'libmvec.so.*' -o -name 'libpthread.so.*' -o -name 'libdl.so.*' -o \
|
||||
-name 'librt.so.*' -o -name 'libresolv.so.*' -o -name 'libutil.so.*' \
|
||||
\) -delete
|
||||
|
||||
# Official fnOS packages reject absolute links. Rewrite Debian links to remain
|
||||
# inside the staged runtime and fail if a link target was not packaged.
|
||||
while IFS= read -r -d '' link_path; do
|
||||
target=$(readlink -- "$link_path")
|
||||
case "$target" in
|
||||
/*)
|
||||
staged_target="$RUNTIME_ROOT$target"
|
||||
if [ ! -e "$staged_target" ]; then
|
||||
rm "$link_path"
|
||||
continue
|
||||
fi
|
||||
relative_target=$(realpath --relative-to="$(dirname -- "$link_path")" "$staged_target")
|
||||
rm "$link_path"
|
||||
ln -s "$relative_target" "$link_path"
|
||||
;;
|
||||
esac
|
||||
done < <(find "$RUNTIME_ROOT" -type l -print0)
|
||||
|
||||
for required_file in \
|
||||
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/postgres" \
|
||||
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/initdb" \
|
||||
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/pg_ctl" \
|
||||
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/pg_dump" \
|
||||
"$RUNTIME_ROOT/usr/lib/postgresql/15/lib/vector.so" \
|
||||
"$RUNTIME_ROOT/usr/share/postgresql/15/extension/vector.control"; do
|
||||
test -e "$required_file" || { printf 'runtime file is missing: %s\n' "$required_file" >&2; exit 1; }
|
||||
done
|
||||
|
||||
node "$ROOT_DIR/scripts/generate-fnos-icons.mjs" "$STAGE" "$STAGE/app/ui/images"
|
||||
chmod 0755 "$STAGE/cmd/"*
|
||||
|
||||
printf 'Packing PostgreSQL service with official fnOS fnpack...\n'
|
||||
(
|
||||
cd "$PACKED_ROOT"
|
||||
TMPDIR="$FNPACK_TMP_ROOT" "$FNPACK_BIN" build --directory "$STAGE"
|
||||
)
|
||||
built_package=$(find "$PACKED_ROOT" -maxdepth 1 -type f -name '*.fpk' -print -quit)
|
||||
test -n "$built_package" || { printf 'fnpack did not create an FPK\n' >&2; exit 1; }
|
||||
mv "$built_package" "$OUTPUT"
|
||||
(
|
||||
cd "$(dirname -- "$OUTPUT")"
|
||||
sha256sum "$(basename -- "$OUTPUT")" >"$(basename -- "$OUTPUT").sha256"
|
||||
)
|
||||
|
||||
"$ROOT_DIR/scripts/verify-fnos-package.sh" "$OUTPUT"
|
||||
printf 'Built %s\n' "$OUTPUT"
|
||||
@@ -0,0 +1,3 @@
|
||||
deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm main
|
||||
deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates main
|
||||
deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main
|
||||
@@ -0,0 +1,131 @@
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const [packageRoot, uiImageRoot] = process.argv.slice(2);
|
||||
if (!packageRoot || !uiImageRoot) {
|
||||
throw new Error("usage: node generate-fnos-icons.mjs package-root ui-image-root");
|
||||
}
|
||||
|
||||
const crcTable = new Uint32Array(256);
|
||||
for (let index = 0; index < 256; index++) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
}
|
||||
crcTable[index] = value >>> 0;
|
||||
}
|
||||
|
||||
function crc32(buffer) {
|
||||
let value = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
|
||||
}
|
||||
return (value ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const typeBuffer = Buffer.from(type, "ascii");
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length);
|
||||
const checksum = Buffer.alloc(4);
|
||||
checksum.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])));
|
||||
return Buffer.concat([length, typeBuffer, data, checksum]);
|
||||
}
|
||||
|
||||
function roundedRectangleDistance(x, y, left, top, right, bottom, radius) {
|
||||
const centerX = (left + right) / 2;
|
||||
const centerY = (top + bottom) / 2;
|
||||
const halfWidth = (right - left) / 2 - radius;
|
||||
const halfHeight = (bottom - top) / 2 - radius;
|
||||
const dx = Math.max(Math.abs(x - centerX) - halfWidth, 0);
|
||||
const dy = Math.max(Math.abs(y - centerY) - halfHeight, 0);
|
||||
return Math.hypot(dx, dy) - radius;
|
||||
}
|
||||
|
||||
function render(size) {
|
||||
const pixels = Buffer.alloc(size * size * 4);
|
||||
const samples = 3;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const rgba = [0, 0, 0, 0];
|
||||
for (let sy = 0; sy < samples; sy++) {
|
||||
for (let sx = 0; sx < samples; sx++) {
|
||||
const px = x + (sx + 0.5) / samples;
|
||||
const py = y + (sy + 0.5) / samples;
|
||||
const scale = size / 256;
|
||||
let color = [0, 0, 0, 0];
|
||||
const background = roundedRectangleDistance(px, py, 12 * scale, 12 * scale, 244 * scale, 244 * scale, 50 * scale);
|
||||
if (background <= 0) {
|
||||
const mix = Math.min(1, Math.max(0, (px + py) / (512 * scale)));
|
||||
color = [Math.round(27 + 33 * mix), Math.round(94 + 66 * mix), Math.round(180 + 49 * mix), 255];
|
||||
}
|
||||
|
||||
const body = roundedRectangleDistance(px, py, 51 * scale, 75 * scale, 190 * scale, 185 * scale, 22 * scale);
|
||||
if (body <= 0) {
|
||||
color = [245, 249, 255, 255];
|
||||
}
|
||||
|
||||
const lensDistance = Math.hypot(px - 120 * scale, py - 130 * scale);
|
||||
if (lensDistance <= 36 * scale) {
|
||||
color = lensDistance <= 23 * scale ? [42, 109, 196, 255] : [128, 185, 239, 255];
|
||||
}
|
||||
|
||||
const viewfinder = roundedRectangleDistance(px, py, 73 * scale, 56 * scale, 122 * scale, 86 * scale, 9 * scale);
|
||||
if (viewfinder <= 0) {
|
||||
color = [232, 241, 253, 255];
|
||||
}
|
||||
|
||||
if (px >= 190 * scale && px <= 222 * scale && py >= 101 * scale && py <= 159 * scale) {
|
||||
const edge = Math.abs(py - 130 * scale) / (29 * scale);
|
||||
const leftEdge = 190 * scale + edge * 16 * scale;
|
||||
if (px >= leftEdge) {
|
||||
color = [237, 244, 253, 255];
|
||||
}
|
||||
}
|
||||
|
||||
const statusDistance = Math.hypot(px - 165 * scale, py - 98 * scale);
|
||||
if (statusDistance <= 9 * scale) {
|
||||
color = [244, 85, 93, 255];
|
||||
}
|
||||
|
||||
for (let channel = 0; channel < 4; channel++) {
|
||||
rgba[channel] += color[channel];
|
||||
}
|
||||
}
|
||||
}
|
||||
const offset = (y * size + x) * 4;
|
||||
for (let channel = 0; channel < 4; channel++) {
|
||||
pixels[offset + channel] = Math.round(rgba[channel] / (samples * samples));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scanlines = Buffer.alloc((size * 4 + 1) * size);
|
||||
for (let row = 0; row < size; row++) {
|
||||
const target = row * (size * 4 + 1);
|
||||
scanlines[target] = 0;
|
||||
pixels.copy(scanlines, target + 1, row * size * 4, (row + 1) * size * 4);
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(size, 0);
|
||||
header.writeUInt32BE(size, 4);
|
||||
header[8] = 8;
|
||||
header[9] = 6;
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
chunk("IHDR", header),
|
||||
chunk("IDAT", deflateSync(scanlines, { level: 9 })),
|
||||
chunk("IEND", Buffer.alloc(0))
|
||||
]);
|
||||
}
|
||||
|
||||
mkdirSync(packageRoot, { recursive: true });
|
||||
mkdirSync(uiImageRoot, { recursive: true });
|
||||
const icon64 = render(64);
|
||||
const icon256 = render(256);
|
||||
writeFileSync(join(packageRoot, "ICON.PNG"), icon64);
|
||||
writeFileSync(join(packageRoot, "ICON_256.PNG"), icon256);
|
||||
writeFileSync(join(uiImageRoot, "icon_64.png"), icon64);
|
||||
writeFileSync(join(uiImageRoot, "icon_256.png"), icon256);
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PACKAGE=${1:?usage: smoke-postgresql-fnos-package.sh postgresql-service.fpk [temporary-directory]}
|
||||
SMOKE_TMP_ROOT="${2:-${POSTGRES_SERVICE_SMOKE_TMPDIR:-${TMPDIR:-/tmp}}}"
|
||||
mkdir -p "$SMOKE_TMP_ROOT"
|
||||
SMOKE_TMP_ROOT=$(CDPATH= cd -- "$SMOKE_TMP_ROOT" && pwd)
|
||||
WORK_DIR=$(mktemp -d "${SMOKE_TMP_ROOT%/}/postgres-service-fnos-smoke.XXXXXX")
|
||||
PACKAGE_ROOT="$WORK_DIR/package"
|
||||
APP_ROOT="$WORK_DIR/app"
|
||||
DATA_ROOT="$WORK_DIR/var"
|
||||
VOLUME_ROOT="$WORK_DIR/volume"
|
||||
API_PORT=${POSTGRES_SERVICE_SMOKE_API_PORT:-19433}
|
||||
PG_PORT=${POSTGRES_SERVICE_SMOKE_PG_PORT:-19432}
|
||||
ADMIN_PASSWORD='Postgres-Admin-Smoke-2026!'
|
||||
ENROLLMENT_TOKEN='Postgres-Enrollment-Smoke-2026!'
|
||||
CONTROL="$PACKAGE_ROOT/cmd/main"
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if [ -x "$CONTROL" ]; then
|
||||
TRIM_APPDEST="$APP_ROOT" TRIM_PKGVAR="$DATA_ROOT" TRIM_APPDEST_VOL="$VOLUME_ROOT" \
|
||||
TRIM_SERVICE_PORT="$API_PORT" POSTGRES_SERVICE_PORT="$PG_PORT" \
|
||||
"$CONTROL" stop >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ "$status" -ne 0 ]; then
|
||||
printf '%s\n' 'PostgreSQL fnOS smoke test failed; service logs follow:' >&2
|
||||
for log_file in "$DATA_ROOT/log/postgresql.log" "$DATA_ROOT/log/postgres-service.log"; do
|
||||
if [ -f "$log_file" ]; then
|
||||
printf '%s\n' "--- $log_file ---" >&2
|
||||
tail -n 160 "$log_file" >&2 || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
rm -rf -- "$WORK_DIR"
|
||||
return "$status"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
mkdir -p "$PACKAGE_ROOT" "$APP_ROOT" "$VOLUME_ROOT"
|
||||
tar -xzf "$PACKAGE" -C "$PACKAGE_ROOT"
|
||||
tar -xzf "$PACKAGE_ROOT/app.tgz" -C "$APP_ROOT"
|
||||
if [ -n "${POSTGRES_SERVICE_SMOKE_SERVER_OVERLAY:-}" ]; then
|
||||
test -x "$POSTGRES_SERVICE_SMOKE_SERVER_OVERLAY/PostgresService.WebApi" || {
|
||||
printf 'invalid API overlay: %s\n' "$POSTGRES_SERVICE_SMOKE_SERVER_OVERLAY" >&2
|
||||
exit 1
|
||||
}
|
||||
cp -a "$POSTGRES_SERVICE_SMOKE_SERVER_OVERLAY/." "$APP_ROOT/server/"
|
||||
fi
|
||||
|
||||
export TRIM_APPDEST="$APP_ROOT"
|
||||
export TRIM_PKGVAR="$DATA_ROOT"
|
||||
export TRIM_APPDEST_VOL="$VOLUME_ROOT"
|
||||
export TRIM_SERVICE_PORT="$API_PORT"
|
||||
export POSTGRES_SERVICE_PORT="$PG_PORT"
|
||||
|
||||
wizard_postgres_admin_password="$ADMIN_PASSWORD" \
|
||||
wizard_postgres_admin_password_confirm="$ADMIN_PASSWORD" \
|
||||
wizard_postgres_enrollment_token="$ENROLLMENT_TOKEN" \
|
||||
wizard_postgres_enrollment_token_confirm="$ENROLLMENT_TOKEN" \
|
||||
"$PACKAGE_ROOT/cmd/install_callback"
|
||||
"$CONTROL" start
|
||||
"$CONTROL" status
|
||||
|
||||
BASE_URL="http://127.0.0.1:$API_PORT"
|
||||
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
|
||||
curl -fsS "$BASE_URL/" | grep -q '<div id="app"></div>'
|
||||
|
||||
COOKIE_JAR="$WORK_DIR/cookies.txt"
|
||||
curl -fsS -c "$COOKIE_JAR" -H 'Content-Type: application/json' \
|
||||
--data "{\"username\":\"admin\",\"password\":\"$ADMIN_PASSWORD\"}" \
|
||||
"$BASE_URL/api/v1/auth/login" >/dev/null
|
||||
curl -fsS -b "$COOKIE_JAR" "$BASE_URL/api/v1/overview" | grep -q '"version"'
|
||||
|
||||
enroll() {
|
||||
app_id=$1
|
||||
display_name=$2
|
||||
extensions=$3
|
||||
destination=$4
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $ENROLLMENT_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "{\"appId\":\"$app_id\",\"displayName\":\"$display_name\",\"requestedExtensions\":$extensions}" \
|
||||
"$BASE_URL/internal/v1/enroll" >"$destination"
|
||||
}
|
||||
|
||||
enroll liverecorder 'Live Recorder' '[]' "$WORK_DIR/live.json"
|
||||
enroll imagefind-test 'ImageFind Test Client' '["vector"]' "$WORK_DIR/image.json"
|
||||
|
||||
json_field() {
|
||||
field=$1
|
||||
file=$2
|
||||
sed -n "s/.*\"$field\":\"\([^\"]*\)\".*/\1/p" "$file"
|
||||
}
|
||||
|
||||
LIVE_DB=$(json_field database "$WORK_DIR/live.json")
|
||||
LIVE_USER=$(json_field username "$WORK_DIR/live.json")
|
||||
LIVE_PASSWORD=$(json_field password "$WORK_DIR/live.json")
|
||||
IMAGE_DB=$(json_field database "$WORK_DIR/image.json")
|
||||
IMAGE_USER=$(json_field username "$WORK_DIR/image.json")
|
||||
IMAGE_PASSWORD=$(json_field password "$WORK_DIR/image.json")
|
||||
test -n "$LIVE_DB" && test -n "$LIVE_USER" && test -n "$LIVE_PASSWORD"
|
||||
test -n "$IMAGE_DB" && test -n "$IMAGE_USER" && test -n "$IMAGE_PASSWORD"
|
||||
test "$LIVE_DB" != "$IMAGE_DB"
|
||||
test "$LIVE_USER" != "$IMAGE_USER"
|
||||
|
||||
PG_BIN="$APP_ROOT/runtime/usr/lib/postgresql/15/bin"
|
||||
PG_LIB="$APP_ROOT/runtime/usr/lib/postgresql/15/lib"
|
||||
RUNTIME_LIBS="$APP_ROOT/runtime/usr/lib/x86_64-linux-gnu:$APP_ROOT/runtime/lib/x86_64-linux-gnu:$PG_LIB"
|
||||
run_client_psql() {
|
||||
password=$1
|
||||
shift
|
||||
env LD_LIBRARY_PATH="$RUNTIME_LIBS" PGPASSWORD="$password" "$PG_BIN/psql" "$@"
|
||||
}
|
||||
|
||||
run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -d "$LIVE_DB" \
|
||||
-v ON_ERROR_STOP=1 -c 'CREATE TABLE smoke_live(id integer PRIMARY KEY, value text); INSERT INTO smoke_live VALUES (1, '\''live'\'');' >/dev/null
|
||||
run_client_psql "$IMAGE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$IMAGE_USER" -d "$IMAGE_DB" \
|
||||
-v ON_ERROR_STOP=1 -c 'CREATE TABLE smoke_vectors(id integer PRIMARY KEY, embedding vector(3)); INSERT INTO smoke_vectors VALUES (1, '\''[1,2,3]'\''); SELECT embedding <-> '\''[1,2,4]'\'' FROM smoke_vectors;' >/dev/null
|
||||
|
||||
if run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -d "$IMAGE_DB" -Atqc 'SELECT 1' >/dev/null 2>&1; then
|
||||
printf '%s\n' 'role isolation failed: Live Recorder connected to ImageFind database' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -fsS -b "$COOKIE_JAR" -H 'Content-Type: application/json' \
|
||||
--data "{\"database\":\"$IMAGE_DB\",\"sql\":\"SELECT count(*) FROM smoke_vectors\"}" \
|
||||
"$BASE_URL/api/v1/query" | grep -q '"rowCount":1'
|
||||
if curl -fsS -b "$COOKIE_JAR" -H 'Content-Type: application/json' \
|
||||
--data "{\"database\":\"$IMAGE_DB\",\"sql\":\"DELETE FROM smoke_vectors\"}" \
|
||||
"$BASE_URL/api/v1/query" >/dev/null 2>&1; then
|
||||
printf '%s\n' 'read-only SQL console accepted a write statement' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -fsS -b "$COOKIE_JAR" -H 'Content-Type: application/json' \
|
||||
--data "{\"database\":\"$LIVE_DB\"}" "$BASE_URL/api/v1/backups" | grep -q '"sha256"'
|
||||
|
||||
POSTMASTER_PID=$(sed -n '1p' "$DATA_ROOT/postgres/postmaster.pid")
|
||||
test -n "$POSTMASTER_PID"
|
||||
kill -0 "$POSTMASTER_PID"
|
||||
|
||||
"$CONTROL" restart
|
||||
"$CONTROL" status
|
||||
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
|
||||
run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -d "$LIVE_DB" -Atqc 'SELECT value FROM smoke_live WHERE id = 1' | grep -q '^live$'
|
||||
|
||||
printf '%s\n' 'PostgreSQL fnOS smoke test passed: shared process, SCRAM isolation, pgvector, read-only SQL, backup and restart are ready'
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PACKAGE=${1:?usage: verify-fnos-package.sh package.fpk}
|
||||
VERIFY_TMP_ROOT="${LIVERECORDER_VERIFY_TMPDIR:-${TMPDIR:-/tmp}}"
|
||||
MAX_APP_UNCOMPRESSED_BYTES=$((512 * 1024 * 1024))
|
||||
mkdir -p "$VERIFY_TMP_ROOT"
|
||||
WORK_DIR=$(mktemp -d "${VERIFY_TMP_ROOT%/}/fnos-package-verify.XXXXXX")
|
||||
trap 'rm -rf -- "$WORK_DIR"' EXIT
|
||||
|
||||
manifest_value() {
|
||||
sed -n "s/^$1[[:space:]]*=[[:space:]]*//p" "$WORK_DIR/manifest" | head -n 1 | tr -d '\r'
|
||||
}
|
||||
|
||||
tar -xzf "$PACKAGE" -C "$WORK_DIR"
|
||||
appname=$(manifest_value appname)
|
||||
version=$(manifest_value version)
|
||||
test "$appname" = "nxsir.postgresql" || { printf 'unexpected fnOS appname: %s\n' "$appname" >&2; exit 1; }
|
||||
test -n "$version"
|
||||
test "$(manifest_value platform)" = "x86"
|
||||
test -x "$WORK_DIR/cmd/main"
|
||||
test -x "$WORK_DIR/cmd/install_callback"
|
||||
test -s "$WORK_DIR/wizard/install"
|
||||
test -s "$WORK_DIR/wizard/upgrade"
|
||||
test -s "$WORK_DIR/ICON.PNG"
|
||||
test -s "$WORK_DIR/ICON_256.PNG"
|
||||
|
||||
expected=$(manifest_value checksum)
|
||||
actual=$(md5sum "$WORK_DIR/app.tgz" | cut -d' ' -f1)
|
||||
test -n "$expected"
|
||||
test "$expected" = "$actual"
|
||||
gzip -t "$WORK_DIR/app.tgz"
|
||||
|
||||
gzip -dc "$WORK_DIR/app.tgz" >"$WORK_DIR/app.tar"
|
||||
uncompressed_bytes=$(wc -c <"$WORK_DIR/app.tar")
|
||||
if [ "$uncompressed_bytes" -gt "$MAX_APP_UNCOMPRESSED_BYTES" ]; then
|
||||
printf 'app.tgz expands to %s bytes; limit is %s bytes\n' \
|
||||
"$uncompressed_bytes" "$MAX_APP_UNCOMPRESSED_BYTES" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tar -tf "$WORK_DIR/app.tar" >"$WORK_DIR/app-files.txt"
|
||||
if awk '/^\// || /(^|\/)\.\.($|\/)/ { unsafe = 1; exit } END { exit unsafe ? 0 : 1 }' "$WORK_DIR/app-files.txt"; then
|
||||
printf 'app.tgz contains an unsafe member path\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tar -tvf "$WORK_DIR/app.tar" >"$WORK_DIR/app-metadata.txt"
|
||||
if awk '
|
||||
/^l/ {
|
||||
marker = " -> "
|
||||
offset = index($0, marker)
|
||||
if (offset > 0) {
|
||||
target = substr($0, offset + length(marker))
|
||||
if (target ~ /^\//) { unsafe = 1; exit }
|
||||
}
|
||||
}
|
||||
/^h/ {
|
||||
marker = " link to "
|
||||
offset = index($0, marker)
|
||||
if (offset > 0) {
|
||||
target = substr($0, offset + length(marker))
|
||||
if (target ~ /^\//) { unsafe = 1; exit }
|
||||
}
|
||||
}
|
||||
END { exit unsafe ? 0 : 1 }
|
||||
' "$WORK_DIR/app-metadata.txt"; then
|
||||
printf 'app.tgz contains an unsafe symbolic or hard link\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -q '^server/wwwroot/index.html$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^runtime/usr/lib/postgresql/15/bin/postgres$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^runtime/usr/lib/postgresql/15/bin/initdb$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^runtime/usr/lib/postgresql/15/bin/pg_ctl$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^runtime/usr/share/postgresql/15/postgresql.conf.sample$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^ui/config$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^ui/images/icon_64.png$' "$WORK_DIR/app-files.txt"
|
||||
|
||||
grep -q '^server/PostgresService.WebApi$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^runtime/usr/lib/postgresql/15/lib/vector.so$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^runtime/usr/share/postgresql/15/extension/vector.control$' "$WORK_DIR/app-files.txt"
|
||||
|
||||
if grep -Eq '^runtime/.*/(ffmpeg|ffprobe)$' "$WORK_DIR/app-files.txt"; then
|
||||
printf 'ffmpeg and ffprobe must come from the fnOS system environment\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Eq '^runtime/lib/(ld-linux-.*|libc\.so\..*|libBrokenLocale\.so\..*|libanl\.so\..*|libdl\.so\..*|libm(vec)?\.so\..*|libnss_(compat|dns|files|hesiod)\.so\..*|libpthread\.so\..*|libresolv\.so\..*|librt\.so\..*|libthread_db\.so\..*|libutil\.so\..*)$' "$WORK_DIR/app-files.txt"; then
|
||||
printf 'the FPK must use the fnOS glibc and matching system loader\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Eq '^(data|records|postgres|log|var)/' "$WORK_DIR/app-files.txt"; then
|
||||
printf 'persistent data must not be included in app.tgz\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$WORK_DIR/app"
|
||||
tar -xf "$WORK_DIR/app.tar" -C "$WORK_DIR/app"
|
||||
while IFS= read -r -d '' link_path; do
|
||||
target=$(readlink -- "$link_path")
|
||||
case "$target" in
|
||||
/*) printf 'absolute symbolic link in app.tgz: %s -> %s\n' "$link_path" "$target" >&2; exit 1 ;;
|
||||
esac
|
||||
resolved=$(realpath -m -- "$(dirname -- "$link_path")/$target")
|
||||
case "$resolved" in
|
||||
"$WORK_DIR/app"/*) ;;
|
||||
*) printf 'escaping symbolic link in app.tgz: %s -> %s\n' "$link_path" "$target" >&2; exit 1 ;;
|
||||
esac
|
||||
done < <(find "$WORK_DIR/app" -type l -print0)
|
||||
|
||||
printf 'fnOS package verified: %s (%s bytes uncompressed)\n' "$PACKAGE" "$uncompressed_bytes"
|
||||
Reference in New Issue
Block a user