Skip to content

Commit 90d5a2d

Browse files
authored
Merge pull request #25 from randoneering/bugfix/pgTAP
pgTAP bug fix and created GH Action to test with PRs
2 parents f374566 + c4602bb commit 90d5a2d

24 files changed

Lines changed: 1087 additions & 1801 deletions
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
name: Python + pgTAP Integration (PG15-PG18)
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- pgFirstAid.sql
7+
- view_pgFirstAid.sql
8+
- view_pgFirstAid_managed.sql
9+
- testing/integration/**
10+
- testing/pgTAP/**
11+
- .github/workflows/integration-pg-matrix.yml
12+
workflow_dispatch:
13+
14+
concurrency:
15+
group: integration-${{ github.ref }}
16+
cancel-in-progress: true
17+
18+
jobs:
19+
integration:
20+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
21+
runs-on: [self-hosted, linux, pgfirstaid-ci]
22+
permissions:
23+
contents: read
24+
strategy:
25+
fail-fast: false
26+
matrix:
27+
postgres_version: ["15", "16", "17", "18"]
28+
29+
name: PG${{ matrix.postgres_version }} integration
30+
31+
defaults:
32+
run:
33+
working-directory: testing/integration
34+
35+
env:
36+
PGHOST: ${{ matrix.postgres_version == '15' && secrets.PG15_HOST || matrix.postgres_version == '16' && secrets.PG16_HOST || matrix.postgres_version == '17' && secrets.PG17_HOST || secrets.PG18_HOST }}
37+
PGPORT: ${{ matrix.postgres_version == '15' && secrets.PG15_PORT || matrix.postgres_version == '16' && secrets.PG16_PORT || matrix.postgres_version == '17' && secrets.PG17_PORT || secrets.PG18_PORT }}
38+
PGUSER: ${{ matrix.postgres_version == '15' && secrets.PG15_USER || matrix.postgres_version == '16' && secrets.PG16_USER || matrix.postgres_version == '17' && secrets.PG17_USER || secrets.PG18_USER }}
39+
PGPASSWORD: ${{ matrix.postgres_version == '15' && secrets.PG15_PASSWORD || matrix.postgres_version == '16' && secrets.PG16_PASSWORD || matrix.postgres_version == '17' && secrets.PG17_PASSWORD || secrets.PG18_PASSWORD }}
40+
PGDATABASE: ${{ matrix.postgres_version == '15' && secrets.PG15_DATABASE || matrix.postgres_version == '16' && secrets.PG16_DATABASE || matrix.postgres_version == '17' && secrets.PG17_DATABASE || secrets.PG18_DATABASE }}
41+
PGSSLMODE: require
42+
PGFA_TEST_VIEW_MODE: managed
43+
PGFA_TEST_ACTIVE_CONN_TARGET: "52"
44+
PGFA_TEST_ACTIVE_CONN_SLEEP_SECONDS: "20"
45+
PGFA_TEST_WAIT_TIMEOUT_SECONDS: "45"
46+
47+
steps:
48+
- name: Checkout
49+
uses: actions/checkout@v4
50+
51+
- name: Setup Python
52+
uses: actions/setup-python@v5
53+
with:
54+
python-version: "3.14"
55+
56+
- name: Install uv
57+
uses: astral-sh/setup-uv@v4
58+
59+
- name: Validate required PG env vars
60+
run: |
61+
missing=0
62+
for var in PGHOST PGPORT PGUSER PGPASSWORD PGDATABASE; do
63+
if [ -z "${!var}" ]; then
64+
echo "::error::Missing required secret/env: ${var} for PG${{ matrix.postgres_version }}"
65+
missing=1
66+
fi
67+
done
68+
if [ "$missing" -ne 0 ]; then
69+
exit 1
70+
fi
71+
72+
- name: Verify PostgreSQL client is installed
73+
run: |
74+
if ! command -v psql >/dev/null 2>&1; then
75+
echo "::error::psql not found on runner. Install postgresql-client on the self-hosted VM."
76+
exit 1
77+
fi
78+
psql --version
79+
80+
- name: Sync dependencies
81+
run: uv sync
82+
83+
- name: Install pgFirstAid function
84+
run: |
85+
psql -v ON_ERROR_STOP=1 -f ../../pgFirstAid.sql
86+
87+
- name: Recreate managed view only
88+
run: |
89+
psql -v ON_ERROR_STOP=1 -c "DROP VIEW IF EXISTS v_pgfirstaid"
90+
psql -v ON_ERROR_STOP=1 -f ../../view_pgFirstAid_managed.sql
91+
92+
- name: Run integration tests
93+
run: uv run python -m pytest tests/integration -m integration

.gitignore

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,21 @@ terraform.tfstate.backup
1111
*.pem
1212
*.key
1313
credentials.json
14+
.pgpass
15+
pgpass
16+
# AI dirs
17+
.claude
18+
.agent
19+
.copilot
20+
21+
# AI Files
22+
CLAUDE.md
23+
24+
# Python
25+
__pycache__/
26+
.pytest_cache/
27+
*.pyc
28+
29+
# Test logs
30+
*.log
31+
testing/pgTAP/logs/

pgFirstAid.sql

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,8 @@ with q as (
8888
database,
8989
restart_lsn,
9090
case
91-
when invalidation_reason is not null then 'invalid'
92-
else
93-
case
94-
when active is true then 'active'
95-
else 'inactive'
96-
end
91+
when active is true then 'active'
92+
else 'inactive'
9793
end as "status",
9894
pg_size_pretty(
9995
pg_wal_lsn_diff(
@@ -326,28 +322,34 @@ order by
326322
from
327323
ts;
328324
-- HIGH: Duplicate or redundant indexes
325+
-- Compare actual index structure (columns, operator class) not string definitions
329326
insert
330327
into
331328
health_results
332329
select
333330
'HIGH' as severity,
334331
'Table Health' as category,
335332
'Duplicate Index' as check_name,
336-
quote_ident(i1.schemaname) || '.' || i1.indexname || ' & ' || i2.indexname as object_name,
337-
'Multiple indexes with identical or overlapping column sets' as issue_description,
338-
'Indexes: ' || i1.indexname || ', ' || i2.indexname as current_value,
333+
quote_ident(n1.nspname) || '.' || c1.relname || ': ' || i1.relname || ' & ' || i2.relname as object_name,
334+
'Multiple indexes with identical column sets and operator classes' as issue_description,
335+
'Indexes: ' || i1.relname || ', ' || i2.relname as current_value,
339336
'Review and consolidate duplicate indexes and focus on keeping the most efficient one' as recommended_action,
340337
'https://www.postgresql.org/docs/current/indexes-multicolumn.html' as documentation_link,
341338
2 as severity_order
342339
from
343-
pg_indexes i1
344-
join pg_indexes i2 on
345-
i1.schemaname = i2.schemaname
346-
and i1.tablename = i2.tablename
347-
and i1.indexname < i2.indexname
348-
and i1.indexdef = i2.indexdef
340+
pg_index idx1
341+
join pg_class i1 on idx1.indexrelid = i1.oid
342+
join pg_class c1 on idx1.indrelid = c1.oid
343+
join pg_namespace n1 on c1.relnamespace = n1.oid
344+
join pg_index idx2 on
345+
idx1.indrelid = idx2.indrelid -- same table
346+
and idx1.indexrelid < idx2.indexrelid -- avoid duplicates
347+
and idx1.indkey = idx2.indkey -- same columns
348+
and idx1.indclass = idx2.indclass -- same operator classes
349+
and idx1.indoption = idx2.indoption -- same options
350+
join pg_class i2 on idx2.indexrelid = i2.oid
349351
where
350-
i1.schemaname not like all(array['information_schema', 'pg_catalog', 'pg_toast', 'pg_temp%']);
352+
n1.nspname not like all(array['information_schema', 'pg_catalog', 'pg_toast', 'pg_temp%']);
351353
-- HIGH: Table with more than 200 columns
352354
with cc as (
353355
select
@@ -507,12 +509,8 @@ with q as (
507509
database,
508510
restart_lsn,
509511
case
510-
when invalidation_reason is not null then 'invalid'
511-
else
512-
case
513-
when active is true then 'active'
514-
else 'inactive'
515-
end
512+
when active is true then 'active'
513+
else 'inactive'
516514
end as "status",
517515
pg_size_pretty(
518516
pg_wal_lsn_diff(
@@ -666,8 +664,9 @@ from
666664
) as object_name,
667665
'The following query has been running for more than 5 minutes. Might be helpful to see if this is expected behavior' as issue_description,
668666
query as current_value,
669-
'Review query using EXPLAIN ANALYZE to identify any bottlenecks, such as full table scans, missing indexes, etc' as recommendation_action,
670-
'https://www.postgresql.org/docs/current/using-explain.html' as documentation_link
667+
'Review query using EXPLAIN ANALYZE to identify any bottlenecks, such as full table scans, missing indexes, etc' as recommended_action,
668+
'https://www.postgresql.org/docs/current/using-explain.html' as documentation_link,
669+
3 as severity_order
671670
from
672671
pg_stat_activity pgs
673672
where
@@ -814,8 +813,9 @@ select
814813
from
815814
pg_stat_user_tables
816815
where
817-
coalesce(seq_scan, 0) + coalesce(idx_scan, 0) = 0
818-
and coalesce(n_tup_ins, 0) + coalesce(n_tup_upd, 0) + coalesce(n_tup_del, 0) = 0
816+
schemaname not like all(array['pg_catalog', 'information_schema', 'pg_toast', 'pg_temp%'])
817+
and coalesce(seq_scan, 0) + coalesce(idx_scan, 0) = 0
818+
and coalesce(n_tup_ins, 0) + coalesce(n_tup_upd, 0) + coalesce(n_tup_del, 0) = 0
819819
)
820820
insert
821821
into

testing/integration/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Python Integration/Validation Tests for pgFirstAid
2+
3+
This test harness runs pgFirstAid integration tests with `pytest`. One of the things I wanted to make sure this project does is validate each health check. While these tests might not be perfect, but the aim is to reduce the chances of a health check making its way into `main` and it fails to do its job.
4+
5+
6+
It uses:
7+
8+
- Integration tests using (`psycopg`) for live runtime behavior(for checks looking for active connections)
9+
- Execution of the pgTAP SQL suite sing Python
10+
11+
## What is covered
12+
13+
- pgTAP assertions grouped by severity (`testing/pgTAP/01_*.sql` to `06_*.sql`)
14+
- Python integration scenarios that need concurrent sessions and timing control
15+
- Function/view parity assertions
16+
- A coverage guard test that ensures every `check_name` in `pgFirstAid.sql` is
17+
referenced by at least one pgTAP assertion
18+
19+
## Configure connection
20+
21+
Set standard PostgreSQL environment variables before running tests:
22+
23+
```bash
24+
export PGHOST=your-host
25+
export PGPORT=5432
26+
export PGUSER=your-user
27+
export PGPASSWORD=your-password
28+
export PGDATABASE=your-database
29+
```
30+
31+
Optional tuning variables:
32+
33+
```bash
34+
export PGFA_TEST_ACTIVE_CONN_TARGET=52
35+
export PGFA_TEST_ACTIVE_CONN_SLEEP_SECONDS=20
36+
export PGFA_TEST_WAIT_TIMEOUT_SECONDS=45
37+
```
38+
39+
## Run tests
40+
41+
```bash
42+
uv sync
43+
uv run pytest tests/integration -m integration
44+
```

testing/integration/pyproject.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[project]
2+
name = "pgfirstaid-python-tests"
3+
version = "0.1.0"
4+
description = "Python integration harness for pgFirstAid health checks"
5+
requires-python = ">=3.11"
6+
dependencies = [
7+
"psycopg[binary]>=3.2.0",
8+
"pytest>=8.3.0",
9+
]
10+
11+
[tool.pytest.ini_options]
12+
testpaths = ["tests"]
13+
markers = [
14+
"integration: integration tests against a live PostgreSQL instance",
15+
"slow: tests that open many connections or sleep",
16+
]
17+
18+
[tool.ruff]
19+
line-length = 88
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .config import TestConfig
2+
from .db import connect, execute_sql_file, wait_for_sql_true
3+
4+
__all__ = ["TestConfig", "connect", "execute_sql_file", "wait_for_sql_true"]
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from dataclasses import dataclass
2+
import os
3+
4+
5+
@dataclass(frozen=True)
6+
class TestConfig:
7+
host: str
8+
port: int
9+
user: str
10+
password: str | None
11+
database: str
12+
sslmode: str | None
13+
active_conn_target: int
14+
active_conn_sleep_seconds: int
15+
wait_timeout_seconds: int
16+
17+
@classmethod
18+
def from_env(cls) -> "TestConfig":
19+
return cls(
20+
host=os.getenv("PGHOST", "localhost"),
21+
port=int(os.getenv("PGPORT", "5432")),
22+
user=os.getenv("PGUSER", "postgres"),
23+
password=os.getenv("PGPASSWORD"),
24+
database=os.getenv("PGDATABASE", "postgres"),
25+
sslmode=os.getenv("PGSSLMODE"),
26+
active_conn_target=int(os.getenv("PGFA_TEST_ACTIVE_CONN_TARGET", "52")),
27+
active_conn_sleep_seconds=int(
28+
os.getenv("PGFA_TEST_ACTIVE_CONN_SLEEP_SECONDS", "20")
29+
),
30+
wait_timeout_seconds=int(os.getenv("PGFA_TEST_WAIT_TIMEOUT_SECONDS", "45")),
31+
)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from pathlib import Path
2+
import time
3+
from typing import Any
4+
5+
import psycopg
6+
7+
from .config import TestConfig
8+
9+
10+
def connect(
11+
config: TestConfig,
12+
*,
13+
autocommit: bool = True,
14+
application_name: str | None = None,
15+
) -> psycopg.Connection[Any]:
16+
kwargs: dict[str, Any] = {
17+
"host": config.host,
18+
"port": config.port,
19+
"user": config.user,
20+
"dbname": config.database,
21+
"autocommit": autocommit,
22+
}
23+
if config.password is not None:
24+
kwargs["password"] = config.password
25+
if config.sslmode is not None:
26+
kwargs["sslmode"] = config.sslmode
27+
if application_name is not None:
28+
kwargs["application_name"] = application_name
29+
return psycopg.connect(**kwargs)
30+
31+
32+
def execute_sql_file(conn: psycopg.Connection[Any], file_path: Path) -> None:
33+
sql_text = file_path.read_text(encoding="utf-8")
34+
with conn.cursor() as cur:
35+
cur.execute(sql_text)
36+
37+
38+
def wait_for_sql_true(
39+
conn: psycopg.Connection[Any],
40+
sql: str,
41+
params: tuple[Any, ...] | None = None,
42+
*,
43+
timeout_seconds: int,
44+
interval_seconds: float = 0.5,
45+
) -> bool:
46+
start = time.monotonic()
47+
with conn.cursor() as cur:
48+
while time.monotonic() - start < timeout_seconds:
49+
cur.execute(sql, params)
50+
row = cur.fetchone()
51+
if row and bool(row[0]):
52+
return True
53+
time.sleep(interval_seconds)
54+
return False

0 commit comments

Comments
 (0)