Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ jobs:
- name: Sync dependencies
run: uv sync --dev

- name: Generate .env and secrets
run: ./scripts/manage.sh create-env --non-interactive --force

- name: Preflight stack bring-up
run: |
set -euo pipefail
cleanup() { ./scripts/manage.sh down >/dev/null 2>&1 || true; }
trap cleanup EXIT
Comment on lines +55 to +57
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trap cleanup EXIT will execute when this step finishes, immediately tearing down the stack after it's brought up. This means the next step "Run core_data smoke workflow" will try to interact with a stack that has already been brought down, causing the test to fail.

Remove the cleanup trap and the set -euo pipefail line from this preflight step, or remove the preflight step entirely if the test suite handles stack bring-up itself.

Suggested change
set -euo pipefail
cleanup() { ./scripts/manage.sh down >/dev/null 2>&1 || true; }
trap cleanup EXIT

Copilot uses AI. Check for mistakes.
./scripts/manage.sh build-image
./scripts/manage.sh up

- name: Run core_data smoke workflow
run: uv run python -m pytest -k full_workflow

Expand All @@ -56,6 +67,17 @@ jobs:
docker ps -a
docker compose logs || true

- name: Collect diagnostics bundle
if: failure()
run: ./scripts/collect_diagnostics.sh --output diagnostics-smoke-${{ matrix.profile_name }}

- name: Upload diagnostics bundle
if: failure()
uses: actions/upload-artifact@v4
with:
name: diagnostics-smoke-${{ matrix.profile_name }}
path: diagnostics-smoke-${{ matrix.profile_name }}

- name: Upload generated backups
if: always()
uses: actions/upload-artifact@v4
Expand Down Expand Up @@ -102,6 +124,17 @@ jobs:
- name: Sync dependencies
run: uv sync --dev

- name: Generate .env and secrets
run: ./scripts/manage.sh create-env --non-interactive --force

- name: Preflight stack bring-up
run: |
set -euo pipefail
cleanup() { ./scripts/manage.sh down >/dev/null 2>&1 || true; }
trap cleanup EXIT
Comment on lines +132 to +134
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trap cleanup EXIT will execute when this step finishes, immediately tearing down the stack after it's brought up. This means the next step "Run marker tests" will try to interact with a stack that has already been brought down, causing tests to fail.

Remove the cleanup trap and the set -euo pipefail line from this preflight step, or remove the preflight step entirely if the test suite handles stack bring-up itself.

Suggested change
set -euo pipefail
cleanup() { ./scripts/manage.sh down >/dev/null 2>&1 || true; }
trap cleanup EXIT

Copilot uses AI. Check for mistakes.
./scripts/manage.sh build-image
./scripts/manage.sh up

- name: Run marker tests
run: uv run python -m pytest -m ${{ matrix.marker }}

Expand All @@ -111,6 +144,16 @@ jobs:
docker ps -a
docker compose logs || true

- name: Collect diagnostics bundle
if: failure()
run: ./scripts/collect_diagnostics.sh --output diagnostics-marker-${{ matrix.marker }}

- name: Upload diagnostics bundle
if: failure()
uses: actions/upload-artifact@v4
with:
name: diagnostics-marker-${{ matrix.marker }}
path: diagnostics-marker-${{ matrix.marker }}
docker-build:
name: Validate Docker Build
runs-on: ubuntu-latest
Expand All @@ -128,10 +171,52 @@ jobs:
file: ./postgres/Dockerfile
platforms: linux/amd64
push: false
load: true
tags: core-data-postgres:test
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Smoke-test Docker image
env:
PGPASSWORD: thinice-test
Comment on lines +180 to +181
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposing credentials in plaintext environment variables is a security risk. While this is a test environment, it's better practice to avoid plaintext passwords in CI configuration files.

Consider using GitHub secrets or at minimum, use the PGPASSFILE mechanism instead of PGPASSWORD.

Copilot uses AI. Check for mistakes.
run: |
set -euo pipefail
cleanup() { docker rm -f postgres-smoke >/dev/null 2>&1 || true; }
trap cleanup EXIT
docker run -d --name postgres-smoke \
-e POSTGRES_USER=thinice-test \
-e POSTGRES_PASSWORD=thinice-test \
-e POSTGRES_DB=thinice-test \
-e CORE_DATA_SKIP_CONFIG_RENDER=1 \
core-data-postgres:test
tries=0
max_tries=150
until docker exec postgres-smoke pg_isready -h localhost -U thinice-test >/dev/null 2>&1; do
tries=$((tries + 1))
if ((tries >= max_tries)); then
echo "[smoke] postgres-smoke never became ready; printing logs."
docker logs postgres-smoke || true
exit 1
fi
sleep 2
done
docker exec postgres-smoke psql -U thinice-test -d thinice-test -c "SELECT 1" >/dev/null

- name: Stop Docker smoke container
if: always()
run: docker rm -f postgres-smoke >/dev/null 2>&1 || true

- name: Collect diagnostics bundle
if: failure()
run: ./scripts/collect_diagnostics.sh --output diagnostics-docker-build

- name: Upload diagnostics bundle
if: failure()
uses: actions/upload-artifact@v4
with:
name: diagnostics-docker-build
path: diagnostics-docker-build

- name: Validate Dockerfile with hadolint
uses: hadolint/[email protected]
with:
Expand Down
6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,11 @@ services:
networks:
- core_data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h $${POSTGRES_HOST:-postgres} -U $${POSTGRES_SUPERUSER:-postgres}"]
test:
[
"CMD-SHELL",
"PGPASSWORD=$(cat /run/secrets/postgres_superuser_password 2>/dev/null) pg_isready -h $${POSTGRES_HOST:-postgres} -U $${POSTGRES_SUPERUSER:-postgres} -d $${POSTGRES_DB:-postgres}",
]
interval: 30s
timeout: 5s
retries: 5
Expand Down
32 changes: 32 additions & 0 deletions pgbouncer/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,36 @@ export POSTGRES_HOST=${POSTGRES_HOST:-postgres}
export POSTGRES_PORT=${POSTGRES_PORT:-5432}
export PGBOUNCER_AUTH_USER=${PGBOUNCER_AUTH_USER:-pgbouncer_auth}

wait_for_backend() {
local attempts=${PGBOUNCER_BACKEND_WAIT_ATTEMPTS:-120}
local delay=2
local attempt=1
while ((attempt <= attempts)); do
if command -v pg_isready >/dev/null 2>&1; then
if pg_isready -h "${POSTGRES_HOST}" -p "${POSTGRES_PORT}" >/dev/null 2>&1; then
return 0
fi
elif command -v nc >/dev/null 2>&1; then
if nc -z "${POSTGRES_HOST}" "${POSTGRES_PORT}" >/dev/null 2>&1; then
return 0
fi
else
if bash -c "exec 3<>/dev/tcp/${POSTGRES_HOST}/${POSTGRES_PORT}" >/dev/null 2>&1; then
exec 3>&-
return 0
fi
fi
echo "[pgbouncer] waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT} (attempt ${attempt})" >&2
sleep "${delay}"
if ((attempt % 10 == 0 && delay < 10)); then
delay=$((delay + 1))
fi
attempt=$((attempt + 1))
done
echo "[pgbouncer] timed out waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}" >&2
exit 1
}

NETWORK_ACCESS_DIR=${NETWORK_ACCESS_DIR:-/opt/core_data/network_access}
NETWORK_ALLOW_FILE=${NETWORK_ALLOW_FILE:-${NETWORK_ACCESS_DIR}/allow.list}

Expand Down Expand Up @@ -53,6 +83,8 @@ fi
mkdir -p "${log_dir}" "${run_dir}" "$(dirname "${config_path}")" "$(dirname "${userlist_path}")" "$(dirname "${hba_path}")"
umask 077

wait_for_backend

auth_hba_config=""
if [[ -r "${NETWORK_ALLOW_FILE}" ]]; then
{
Expand Down
31 changes: 26 additions & 5 deletions postgres/initdb/00-render-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

set -euo pipefail

if [[ "${CORE_DATA_SKIP_CONFIG_RENDER:-0}" == "1" ]]; then
exit 0
fi

TEMPLATE_DIR="/opt/core_data/conf"
SENTINEL="${PGDATA}/.core_data_config_rendered"
PGBACKREST_CONF_PATH="${PGDATA}/pgbackrest.conf"
Expand Down Expand Up @@ -81,19 +85,30 @@ export \

mkdir -p "${PGDATA}"

first_render=1
if [[ -f "${SENTINEL}" ]]; then
first_render=0
if [[ "${FORCE_RENDER_CONFIG}" != "1" ]]; then
echo "[core_data] Configuration already rendered; refreshing network allow entries." >&2
apply_network_allow_entries
if ! pg_ctl -D "${PGDATA}" reload >/dev/null 2>&1; then
echo "[core_data] WARNING: pg_ctl reload failed while refreshing network allow entries." >&2
if pg_ctl -D "${PGDATA}" status >/dev/null 2>&1; then
if ! pg_ctl -D "${PGDATA}" reload >/dev/null 2>&1; then
echo "[core_data] WARNING: pg_ctl reload failed while refreshing network allow entries." >&2
fi
fi
exit 0
fi
echo "[core_data] FORCE_RENDER_CONFIG=1 set; re-rendering templates." >&2
rm -f "${SENTINEL}"
fi

if [[ "${first_render}" -eq 1 ]]; then
if pg_ctl -D "${PGDATA}" status >/dev/null 2>&1; then
echo "[core_data] Stopping PostgreSQL before initial configuration render." >&2
pg_ctl -D "${PGDATA}" -m fast -w stop >/dev/null 2>&1 || true
fi
fi

if [[ "${POSTGRES_SSL_ENABLED}" == "on" ]]; then
CERT_DIR=$(dirname "${POSTGRES_SSL_CERT_FILE}")
KEY_DIR=$(dirname "${POSTGRES_SSL_KEY_FILE}")
Expand Down Expand Up @@ -161,8 +176,14 @@ CONF

echo "[core_data] Rendered PostgreSQL configs and pgBackRest configuration." >&2

pg_ctl -D "${PGDATA}" -m fast -w restart >/dev/null 2>&1 || {
echo "[core_data] WARNING: pg_ctl restart failed during initialization." >&2
}
if [[ "${first_render}" -eq 1 ]]; then
if ! pg_ctl -D "${PGDATA}" -w start >/dev/null 2>&1; then
echo "[core_data] WARNING: pg_ctl start failed during initial configuration." >&2
fi
elif pg_ctl -D "${PGDATA}" status >/dev/null 2>&1; then
if ! pg_ctl -D "${PGDATA}" -m fast -w restart >/dev/null 2>&1; then
echo "[core_data] WARNING: pg_ctl restart failed during configuration refresh." >&2
fi
fi

touch "${SENTINEL}"
8 changes: 8 additions & 0 deletions postgres/initdb/02-enable-extensions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

set -euo pipefail

if [[ "${CORE_DATA_SKIP_CONFIG_RENDER:-0}" == "1" ]]; then
exit 0
fi

BOOTSTRAP_SENTINEL=${CORE_DATA_BOOTSTRAP_SENTINEL:-${PGDATA}/.core_data_bootstrap_complete}

if [[ -z "${POSTGRES_PASSWORD:-}" && -n "${POSTGRES_PASSWORD_FILE:-}" && -r "${POSTGRES_PASSWORD_FILE}" ]]; then
POSTGRES_PASSWORD=$(<"${POSTGRES_PASSWORD_FILE}")
fi
Expand Down Expand Up @@ -121,3 +127,5 @@ SQL

# Ensure template1 ships with extensions and helper functions so new databases inherit them.
configure_database "template1"

touch "${BOOTSTRAP_SENTINEL}"
86 changes: 86 additions & 0 deletions scripts/collect_diagnostics.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2025 Blackcat Informatics® Inc.
# SPDX-License-Identifier: MIT

set -euo pipefail

output_dir="diagnostics"
logs_tail=${CORE_DATA_DIAG_LOG_TAIL:-400}

usage() {
cat <<'USAGE'
Usage: collect_diagnostics.sh [--output DIR]
Gather docker/container state, sanitized env vars, and service logs to help debug CI failures.
USAGE
}

while [[ $# -gt 0 ]]; do
case "$1" in
--output)
output_dir=$2
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
echo "[diagnostics] Unknown argument: $1" >&2
exit 1
;;
esac
done

mkdir -p "${output_dir}"
timestamp=$(date -Iseconds)

run_cmd() {
local name=$1
shift
if command -v "$1" >/dev/null 2>&1; then
"$@" >"${output_dir}/${name}.txt" 2>&1 || true
fi
}

run_cmd "docker-ps" docker ps -a
run_cmd "docker-compose-ls" docker compose ls
run_cmd "docker-network-ls" docker network ls

sanitize_env() {
local source_env=$1
local dest=$2
if [[ ! -f "${source_env}" ]]; then
return
fi
python3 - "$source_env" "$dest" <<'PY'
import os
import re
import sys
source, dest = sys.argv[1], sys.argv[2]
pattern = re.compile(r"(PASSWORD|SECRET|TOKEN|KEY|COOKIE)", re.IGNORECASE)
with open(source, "r", encoding="utf-8") as fh, open(dest, "w", encoding="utf-8") as out:
for line in fh:
if "=" in line and not line.lstrip().startswith("#"):
key, val = line.rstrip("\n").split("=", 1)
if pattern.search(key):
line = f"{key}=<redacted>\n"
out.write(line)
PY
}

sanitize_env "${ENV_FILE:-${PWD}/.env}" "${output_dir}/env.redacted"

collect_container_artifacts() {
local container=$1
local safe_name=${container//\//_}
docker inspect "${container}" >"${output_dir}/${safe_name}--inspect.json" 2>/dev/null || true
docker logs --tail "${logs_tail}" "${container}" >"${output_dir}/${safe_name}--logs.txt" 2>&1 || true
}

containers=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'core_data|postgres' || true)
for name in ${containers}; do
Comment on lines +81 to +82
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unquoted variable in the for loop can cause word splitting issues if container names contain spaces or special characters. While container names typically don't contain spaces, it's a best practice to properly quote variables in shell scripts.

Change line 82 to use a while read loop for safer iteration:

docker ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'core_data|postgres' | while IFS= read -r name; do
	collect_container_artifacts "${name}"
done
Suggested change
containers=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'core_data|postgres' || true)
for name in ${containers}; do
docker ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'core_data|postgres' | while IFS= read -r name; do

Copilot uses AI. Check for mistakes.
collect_container_artifacts "${name}"
done

echo "[diagnostics] Wrote troubleshooting bundle to ${output_dir} (${timestamp})."
Loading
Loading