phlo-postgres 0.4.0__tar.gz → 0.14.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/PKG-INFO +2 -2
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/pyproject.toml +2 -2
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/__init__.py +3 -1
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/authorization.py +2 -2
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/exporter_service.yaml +1 -1
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/plugin.py +12 -1
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/service.yaml +3 -2
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/settings.py +13 -10
- phlo_postgres-0.14.0/src/phlo_postgres/settings_store.py +224 -0
- phlo_postgres-0.14.0/src/phlo_postgres/volume_setup.yaml +24 -0
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/PKG-INFO +2 -2
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/SOURCES.txt +3 -1
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/requires.txt +1 -1
- phlo_postgres-0.14.0/tests/test_postgres_plugin.py +69 -0
- phlo_postgres-0.14.0/tests/test_settings_store.py +174 -0
- phlo_postgres-0.4.0/src/phlo_postgres/volume_setup.yaml +0 -20
- phlo_postgres-0.4.0/tests/test_postgres_plugin.py +0 -36
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/README.md +0 -0
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/setup.cfg +0 -0
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/cli.py +0 -0
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/cli_plugin.py +0 -0
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/publish_target.py +0 -0
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/resource.py +0 -0
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/dependency_links.txt +0 -0
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/entry_points.txt +0 -0
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/top_level.txt +0 -0
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/tests/test_authorization.py +0 -0
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/tests/test_integration_postgres.py +0 -0
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/tests/test_postgres_cli.py +0 -0
- {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/tests/test_resource.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phlo-postgres
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.0
|
|
4
4
|
Summary: Postgres service plugin for Phlo
|
|
5
5
|
Author-email: Phlo Team <team@phlo.dev>
|
|
6
6
|
License: MIT
|
|
7
7
|
Requires-Python: >=3.11
|
|
8
8
|
Description-Content-Type: text/plain
|
|
9
|
-
Requires-Dist: phlo
|
|
9
|
+
Requires-Dist: phlo<0.15,>=0.14.0
|
|
10
10
|
Requires-Dist: psycopg2-binary>=2.9.11
|
|
11
11
|
Provides-Extra: dev
|
|
12
12
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -7,13 +7,13 @@ requires = [
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
dependencies = [
|
|
10
|
-
"phlo>=0.
|
|
10
|
+
"phlo>=0.14.0,<0.15",
|
|
11
11
|
"psycopg2-binary>=2.9.11",
|
|
12
12
|
]
|
|
13
13
|
description = "Postgres service plugin for Phlo"
|
|
14
14
|
name = "phlo-postgres"
|
|
15
15
|
requires-python = ">=3.11"
|
|
16
|
-
version = "0.
|
|
16
|
+
version = "0.14.0"
|
|
17
17
|
|
|
18
18
|
[[project.authors]]
|
|
19
19
|
email = "team@phlo.dev"
|
|
@@ -19,12 +19,14 @@ from phlo_postgres.plugin import PostgresServicePlugin
|
|
|
19
19
|
from phlo_postgres.publish_target import PostgresPublishTarget
|
|
20
20
|
from phlo_postgres.resource import PostgresResource
|
|
21
21
|
from phlo_postgres.settings import PostgresSettings, get_settings
|
|
22
|
+
from phlo_postgres.settings_store import PostgresSettingsStore
|
|
22
23
|
|
|
23
24
|
__all__ = [
|
|
24
25
|
"PostgresPublishTarget",
|
|
25
26
|
"PostgresResource",
|
|
26
27
|
"PostgresServicePlugin",
|
|
27
28
|
"PostgresSettings",
|
|
29
|
+
"PostgresSettingsStore",
|
|
28
30
|
"get_settings",
|
|
29
31
|
]
|
|
30
|
-
__version__ = "0.
|
|
32
|
+
__version__ = "0.14.0"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from phlo.cli.authorization import cli_surface_adapter_class
|
|
5
|
+
from phlo.cli.authorization import CliSurfaceAdapter, cli_surface_adapter_class
|
|
6
6
|
|
|
7
7
|
SURFACE_NAME = "phlo-postgres-cli"
|
|
8
8
|
FRAMEWORK_TYPE = "cli"
|
|
@@ -37,5 +37,5 @@ PostgresCliSurfaceAdapter = cli_surface_adapter_class(
|
|
|
37
37
|
)
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def get_postgres_cli_adapter() ->
|
|
40
|
+
def get_postgres_cli_adapter() -> CliSurfaceAdapter:
|
|
41
41
|
return PostgresCliSurfaceAdapter.get_instance()
|
|
@@ -4,7 +4,7 @@ category: observability
|
|
|
4
4
|
default: false
|
|
5
5
|
profile: observability
|
|
6
6
|
|
|
7
|
-
image: quay.io/prometheuscommunity/postgres-exporter:v0.
|
|
7
|
+
image: quay.io/prometheuscommunity/postgres-exporter:v0.20.1@sha256:ac5ec343104fae0e2d84a27bb8d69b38430a11910c5382cad85d478d2bab713e
|
|
8
8
|
|
|
9
9
|
depends_on:
|
|
10
10
|
- postgres
|
|
@@ -16,11 +16,12 @@ Example:
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
-
from phlo.capabilities import PublishTargetSpec, ResourceSpec
|
|
19
|
+
from phlo.capabilities import PublishTargetSpec, ResourceSpec, SettingsStoreSpec
|
|
20
20
|
from phlo.plugins import PluginMetadata, ResourceProviderPlugin, service_plugin_class
|
|
21
21
|
|
|
22
22
|
from phlo_postgres.publish_target import PostgresPublishTarget
|
|
23
23
|
from phlo_postgres.resource import PostgresResource
|
|
24
|
+
from phlo_postgres.settings_store import get_settings_stores
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
PostgresServicePlugin = service_plugin_class(
|
|
@@ -128,3 +129,13 @@ class PostgresResourceProvider(ResourceProviderPlugin):
|
|
|
128
129
|
metadata={"target_system": "postgres", "role": "serving"},
|
|
129
130
|
)
|
|
130
131
|
]
|
|
132
|
+
|
|
133
|
+
def get_settings_stores(self) -> list[SettingsStoreSpec]:
|
|
134
|
+
"""Return settings store capability specs for durable Observatory settings.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
list[SettingsStoreSpec]: Capability spec wrapping a
|
|
138
|
+
:class:`~phlo_postgres.settings_store.PostgresSettingsStore`
|
|
139
|
+
that persists Observatory settings to PostgreSQL.
|
|
140
|
+
"""
|
|
141
|
+
return get_settings_stores()
|
|
@@ -6,7 +6,7 @@ default: true
|
|
|
6
6
|
depends_on:
|
|
7
7
|
- postgres-volume-setup
|
|
8
8
|
|
|
9
|
-
image: postgres:
|
|
9
|
+
image: postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
|
|
10
10
|
|
|
11
11
|
compose:
|
|
12
12
|
restart: unless-stopped
|
|
@@ -26,7 +26,7 @@ compose:
|
|
|
26
26
|
ports:
|
|
27
27
|
- "${POSTGRES_PORT:-10000}:5432"
|
|
28
28
|
volumes:
|
|
29
|
-
- postgres-data:/var/lib/postgresql
|
|
29
|
+
- postgres-data:/var/lib/postgresql
|
|
30
30
|
healthcheck:
|
|
31
31
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-phlo}"]
|
|
32
32
|
interval: 10s
|
|
@@ -47,6 +47,7 @@ env_vars:
|
|
|
47
47
|
POSTGRES_PORT:
|
|
48
48
|
default: 10000
|
|
49
49
|
description: PostgreSQL host port
|
|
50
|
+
|
|
50
51
|
# SSL/TLS
|
|
51
52
|
POSTGRES_SSL_MODE:
|
|
52
53
|
default: "prefer"
|
|
@@ -15,13 +15,14 @@ Example:
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
-
from
|
|
18
|
+
from pathlib import Path
|
|
19
19
|
from typing import Any
|
|
20
20
|
from urllib.parse import quote_plus
|
|
21
21
|
|
|
22
22
|
from pydantic import Field
|
|
23
23
|
|
|
24
24
|
from phlo.config.base import BaseConfig
|
|
25
|
+
from phlo.config.cache import project_root_cached
|
|
25
26
|
from phlo.config.network import resolve_host
|
|
26
27
|
|
|
27
28
|
|
|
@@ -123,25 +124,27 @@ class PostgresSettings(BaseConfig):
|
|
|
123
124
|
return f"postgresql://{user}:{password}@{self.postgres_host}:{self.postgres_port}{db_part}"
|
|
124
125
|
|
|
125
126
|
|
|
126
|
-
@
|
|
127
|
-
def get_settings() -> PostgresSettings:
|
|
128
|
-
"""Return cached PostgreSQL settings
|
|
127
|
+
@project_root_cached
|
|
128
|
+
def get_settings(project_root: Path) -> PostgresSettings:
|
|
129
|
+
"""Return cached PostgreSQL settings for the selected project root.
|
|
129
130
|
|
|
130
|
-
|
|
131
|
-
to avoid repeated parsing
|
|
131
|
+
Settings are cached per resolved project root, with up to 16 entries,
|
|
132
|
+
to avoid repeated parsing while isolating project configuration.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
project_root: Resolved project root used for cache selection.
|
|
132
136
|
|
|
133
137
|
Returns:
|
|
134
138
|
PostgresSettings: Cached settings instance.
|
|
135
139
|
|
|
136
140
|
Note:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
call and reused thereafter.
|
|
141
|
+
Calls for the same project root return the same settings object.
|
|
142
|
+
Call ``get_settings.cache_clear()`` after changing configuration.
|
|
140
143
|
|
|
141
144
|
Example:
|
|
142
145
|
>>> settings1 = get_settings()
|
|
143
146
|
>>> settings2 = get_settings()
|
|
144
|
-
>>> settings1 is settings2 # Same cached instance
|
|
147
|
+
>>> settings1 is settings2 # Same cached instance for this root
|
|
145
148
|
True
|
|
146
149
|
>>>
|
|
147
150
|
>>> # Access connection parameters
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""PostgreSQL settings store capability for Observatory settings.
|
|
2
|
+
|
|
3
|
+
This module is the sole owner of the durable Observatory settings
|
|
4
|
+
implementation: psycopg2 usage, SQL table creation, DSN handling, and
|
|
5
|
+
connection-failure sanitisation. It registers a ``SettingsStoreSpec``
|
|
6
|
+
with the phlo capability registry so that core's ``get_settings_service``
|
|
7
|
+
can resolve it without importing this package directly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from jsonschema import ValidationError, validate
|
|
16
|
+
|
|
17
|
+
from phlo.capabilities import SettingsStoreSpec
|
|
18
|
+
from phlo.logging import get_logger
|
|
19
|
+
from phlo.plugins.observatory_settings import (
|
|
20
|
+
ObservatorySettingsStorageConfig,
|
|
21
|
+
SettingsRecord,
|
|
22
|
+
SettingsScope,
|
|
23
|
+
StorageUnavailableError,
|
|
24
|
+
)
|
|
25
|
+
from phlo_postgres.settings import get_settings as get_postgres_settings
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_psycopg2():
|
|
31
|
+
try:
|
|
32
|
+
import psycopg2
|
|
33
|
+
import psycopg2.extras
|
|
34
|
+
except ModuleNotFoundError as exc:
|
|
35
|
+
raise ModuleNotFoundError(
|
|
36
|
+
"psycopg2 is required to use PostgreSQL-backed observatory settings storage."
|
|
37
|
+
) from exc
|
|
38
|
+
return psycopg2
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PostgresSettingsStore:
|
|
42
|
+
"""Durable Observatory settings store backed by PostgreSQL.
|
|
43
|
+
|
|
44
|
+
The DSN is resolved at construction time from
|
|
45
|
+
:class:`ObservatorySettingsStorageConfig` (explicit override) or
|
|
46
|
+
:mod:`phlo_postgres.settings` (default). No database connection is
|
|
47
|
+
opened until ``get`` or ``put`` is called, so a transient outage does
|
|
48
|
+
not poison the instance — the next call retries the connection.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
config = ObservatorySettingsStorageConfig()
|
|
53
|
+
if config.observatory_settings_db_url:
|
|
54
|
+
self._db_url = config.observatory_settings_db_url
|
|
55
|
+
else:
|
|
56
|
+
postgres_settings = get_postgres_settings()
|
|
57
|
+
self._db_url = postgres_settings.get_postgres_connection_string()
|
|
58
|
+
self._table_ensured = False
|
|
59
|
+
|
|
60
|
+
def get(self, scope: SettingsScope, namespace: str) -> SettingsRecord | None:
|
|
61
|
+
"""Get settings for a scope and namespace."""
|
|
62
|
+
psycopg2 = _get_psycopg2()
|
|
63
|
+
try:
|
|
64
|
+
with psycopg2.connect(self._db_url) as conn:
|
|
65
|
+
self._ensure_table(conn)
|
|
66
|
+
with conn.cursor() as cursor:
|
|
67
|
+
cursor.execute(
|
|
68
|
+
"""
|
|
69
|
+
SELECT settings, updated_at
|
|
70
|
+
FROM phlo_settings
|
|
71
|
+
WHERE scope = %s AND namespace = %s
|
|
72
|
+
""",
|
|
73
|
+
(scope.value, namespace),
|
|
74
|
+
)
|
|
75
|
+
row = cursor.fetchone()
|
|
76
|
+
if not row:
|
|
77
|
+
logger.debug(
|
|
78
|
+
"observatory_settings_not_found",
|
|
79
|
+
scope=scope.value,
|
|
80
|
+
namespace=namespace,
|
|
81
|
+
)
|
|
82
|
+
return None
|
|
83
|
+
settings, updated_at = row
|
|
84
|
+
return SettingsRecord(
|
|
85
|
+
scope=scope,
|
|
86
|
+
namespace=namespace,
|
|
87
|
+
settings=settings,
|
|
88
|
+
updated_at=updated_at.isoformat() if updated_at else None,
|
|
89
|
+
)
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
if isinstance(exc, StorageUnavailableError):
|
|
92
|
+
raise
|
|
93
|
+
logger.warning("observatory_settings_storage_unavailable", scope=scope.value)
|
|
94
|
+
raise StorageUnavailableError("Settings storage is unavailable") from exc
|
|
95
|
+
|
|
96
|
+
def put(
|
|
97
|
+
self,
|
|
98
|
+
scope: SettingsScope,
|
|
99
|
+
namespace: str,
|
|
100
|
+
settings: dict[str, Any],
|
|
101
|
+
schema: dict[str, Any] | None = None,
|
|
102
|
+
) -> SettingsRecord:
|
|
103
|
+
"""Upsert settings for a scope and namespace."""
|
|
104
|
+
self._validate(settings, schema)
|
|
105
|
+
psycopg2 = _get_psycopg2()
|
|
106
|
+
json_settings = psycopg2.extras.Json(settings)
|
|
107
|
+
try:
|
|
108
|
+
with psycopg2.connect(self._db_url) as conn:
|
|
109
|
+
self._ensure_table(conn)
|
|
110
|
+
with conn.cursor() as cursor:
|
|
111
|
+
cursor.execute(
|
|
112
|
+
"""
|
|
113
|
+
INSERT INTO phlo_settings (scope, namespace, settings, updated_at)
|
|
114
|
+
VALUES (%s, %s, %s, NOW())
|
|
115
|
+
ON CONFLICT (scope, namespace)
|
|
116
|
+
DO UPDATE SET settings = EXCLUDED.settings, updated_at = NOW()
|
|
117
|
+
RETURNING settings, updated_at
|
|
118
|
+
""",
|
|
119
|
+
(scope.value, namespace, json_settings),
|
|
120
|
+
)
|
|
121
|
+
stored_settings, updated_at = cursor.fetchone()
|
|
122
|
+
conn.commit()
|
|
123
|
+
return SettingsRecord(
|
|
124
|
+
scope=scope,
|
|
125
|
+
namespace=namespace,
|
|
126
|
+
settings=stored_settings,
|
|
127
|
+
updated_at=updated_at.isoformat() if updated_at else None,
|
|
128
|
+
)
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
if isinstance(exc, (StorageUnavailableError, ValueError)):
|
|
131
|
+
raise
|
|
132
|
+
logger.warning("observatory_settings_storage_unavailable", scope=scope.value)
|
|
133
|
+
raise StorageUnavailableError("Settings storage is unavailable") from exc
|
|
134
|
+
|
|
135
|
+
def mutate(
|
|
136
|
+
self,
|
|
137
|
+
scope: SettingsScope,
|
|
138
|
+
namespace: str,
|
|
139
|
+
mutation: Callable[[dict[str, Any] | None], dict[str, Any]],
|
|
140
|
+
) -> SettingsRecord:
|
|
141
|
+
"""Apply ``mutation`` while holding the row lock for one settings record."""
|
|
142
|
+
psycopg2 = _get_psycopg2()
|
|
143
|
+
try:
|
|
144
|
+
with psycopg2.connect(self._db_url) as conn:
|
|
145
|
+
self._ensure_table(conn)
|
|
146
|
+
with conn.cursor() as cursor:
|
|
147
|
+
# A row lock alone cannot lock an absent record. The advisory
|
|
148
|
+
# transaction lock also serialises first-write migration and
|
|
149
|
+
# mutation across independent API processes.
|
|
150
|
+
cursor.execute(
|
|
151
|
+
"SELECT pg_advisory_xact_lock(hashtext(%s))",
|
|
152
|
+
(f"{scope.value}:{namespace}",),
|
|
153
|
+
)
|
|
154
|
+
cursor.execute(
|
|
155
|
+
"""
|
|
156
|
+
SELECT settings FROM phlo_settings
|
|
157
|
+
WHERE scope = %s AND namespace = %s
|
|
158
|
+
FOR UPDATE
|
|
159
|
+
""",
|
|
160
|
+
(scope.value, namespace),
|
|
161
|
+
)
|
|
162
|
+
row = cursor.fetchone()
|
|
163
|
+
settings = mutation(row[0] if row else None)
|
|
164
|
+
cursor.execute(
|
|
165
|
+
"""
|
|
166
|
+
INSERT INTO phlo_settings (scope, namespace, settings, updated_at)
|
|
167
|
+
VALUES (%s, %s, %s, NOW())
|
|
168
|
+
ON CONFLICT (scope, namespace)
|
|
169
|
+
DO UPDATE SET settings = EXCLUDED.settings, updated_at = NOW()
|
|
170
|
+
RETURNING settings, updated_at
|
|
171
|
+
""",
|
|
172
|
+
(scope.value, namespace, psycopg2.extras.Json(settings)),
|
|
173
|
+
)
|
|
174
|
+
stored_settings, updated_at = cursor.fetchone()
|
|
175
|
+
conn.commit()
|
|
176
|
+
return SettingsRecord(
|
|
177
|
+
scope=scope,
|
|
178
|
+
namespace=namespace,
|
|
179
|
+
settings=stored_settings,
|
|
180
|
+
updated_at=updated_at.isoformat() if updated_at else None,
|
|
181
|
+
)
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
if isinstance(exc, StorageUnavailableError):
|
|
184
|
+
raise
|
|
185
|
+
logger.warning("observatory_settings_storage_unavailable", scope=scope.value)
|
|
186
|
+
raise StorageUnavailableError("Settings storage is unavailable") from exc
|
|
187
|
+
|
|
188
|
+
def _validate(self, settings: dict[str, Any], schema: dict[str, Any] | None) -> None:
|
|
189
|
+
if not schema:
|
|
190
|
+
return
|
|
191
|
+
try:
|
|
192
|
+
validate(instance=settings, schema=schema)
|
|
193
|
+
except ValidationError as exc:
|
|
194
|
+
logger.warning("observatory_settings_validation_failed", error=str(exc))
|
|
195
|
+
raise ValueError(str(exc)) from exc
|
|
196
|
+
|
|
197
|
+
def _ensure_table(self, conn) -> None:
|
|
198
|
+
if self._table_ensured:
|
|
199
|
+
return
|
|
200
|
+
with conn.cursor() as cursor:
|
|
201
|
+
cursor.execute(
|
|
202
|
+
"""
|
|
203
|
+
CREATE TABLE IF NOT EXISTS phlo_settings (
|
|
204
|
+
scope TEXT NOT NULL,
|
|
205
|
+
namespace TEXT NOT NULL,
|
|
206
|
+
settings JSONB NOT NULL,
|
|
207
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
208
|
+
PRIMARY KEY (scope, namespace)
|
|
209
|
+
)
|
|
210
|
+
"""
|
|
211
|
+
)
|
|
212
|
+
conn.commit()
|
|
213
|
+
self._table_ensured = True
|
|
214
|
+
logger.debug("observatory_settings_table_ensured")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_settings_stores() -> list[SettingsStoreSpec]:
|
|
218
|
+
"""Return capability specs for the PostgreSQL settings store."""
|
|
219
|
+
return [
|
|
220
|
+
SettingsStoreSpec(
|
|
221
|
+
name="postgres",
|
|
222
|
+
provider=PostgresSettingsStore(),
|
|
223
|
+
)
|
|
224
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: postgres-volume-setup
|
|
2
|
+
description: Initialize PostgreSQL data volume permissions
|
|
3
|
+
category: core
|
|
4
|
+
default: false
|
|
5
|
+
|
|
6
|
+
image: alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
|
|
7
|
+
|
|
8
|
+
compose:
|
|
9
|
+
restart: "no"
|
|
10
|
+
user: "0:0"
|
|
11
|
+
entrypoint: /bin/sh
|
|
12
|
+
command: >
|
|
13
|
+
-c "
|
|
14
|
+
if [ -f /var/lib/postgresql/PG_VERSION ]; then
|
|
15
|
+
echo 'PostgreSQL 16 data volume detected. Back it up with PostgreSQL 16, then restore it into a new PostgreSQL 18 volume before starting Phlo.' >&2;
|
|
16
|
+
exit 1;
|
|
17
|
+
fi &&
|
|
18
|
+
mkdir -p /var/lib/postgresql &&
|
|
19
|
+
chown -R 70:70 /var/lib/postgresql &&
|
|
20
|
+
chmod 700 /var/lib/postgresql &&
|
|
21
|
+
echo 'Postgres data volume ownership initialized'
|
|
22
|
+
"
|
|
23
|
+
volumes:
|
|
24
|
+
- postgres-data:/var/lib/postgresql
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phlo-postgres
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.0
|
|
4
4
|
Summary: Postgres service plugin for Phlo
|
|
5
5
|
Author-email: Phlo Team <team@phlo.dev>
|
|
6
6
|
License: MIT
|
|
7
7
|
Requires-Python: >=3.11
|
|
8
8
|
Description-Content-Type: text/plain
|
|
9
|
-
Requires-Dist: phlo
|
|
9
|
+
Requires-Dist: phlo<0.15,>=0.14.0
|
|
10
10
|
Requires-Dist: psycopg2-binary>=2.9.11
|
|
11
11
|
Provides-Extra: dev
|
|
12
12
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -10,6 +10,7 @@ src/phlo_postgres/publish_target.py
|
|
|
10
10
|
src/phlo_postgres/resource.py
|
|
11
11
|
src/phlo_postgres/service.yaml
|
|
12
12
|
src/phlo_postgres/settings.py
|
|
13
|
+
src/phlo_postgres/settings_store.py
|
|
13
14
|
src/phlo_postgres/volume_setup.yaml
|
|
14
15
|
src/phlo_postgres.egg-info/PKG-INFO
|
|
15
16
|
src/phlo_postgres.egg-info/SOURCES.txt
|
|
@@ -21,4 +22,5 @@ tests/test_authorization.py
|
|
|
21
22
|
tests/test_integration_postgres.py
|
|
22
23
|
tests/test_postgres_cli.py
|
|
23
24
|
tests/test_postgres_plugin.py
|
|
24
|
-
tests/test_resource.py
|
|
25
|
+
tests/test_resource.py
|
|
26
|
+
tests/test_settings_store.py
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Tests for Postgres service and resource plugins."""
|
|
2
|
+
|
|
3
|
+
from phlo.capabilities import PublishTargetSpec
|
|
4
|
+
from phlo_postgres.plugin import (
|
|
5
|
+
PostgresExporterServicePlugin,
|
|
6
|
+
PostgresResourceProvider,
|
|
7
|
+
PostgresServicePlugin,
|
|
8
|
+
PostgresVolumeSetupServicePlugin,
|
|
9
|
+
)
|
|
10
|
+
from phlo_postgres.publish_target import PostgresPublishTarget
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_postgres_service_definition():
|
|
14
|
+
"""Validate Postgres service definition fields."""
|
|
15
|
+
plugin = PostgresServicePlugin()
|
|
16
|
+
service_definition = plugin.service_definition
|
|
17
|
+
|
|
18
|
+
assert service_definition["name"] == "postgres"
|
|
19
|
+
assert service_definition["category"] == "core"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_postgres_service_uses_pinned_upstream_image() -> None:
|
|
23
|
+
service_definition = PostgresServicePlugin().service_definition
|
|
24
|
+
|
|
25
|
+
assert service_definition["image"] == (
|
|
26
|
+
"postgres:18.4-alpine3.24@"
|
|
27
|
+
"sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15"
|
|
28
|
+
)
|
|
29
|
+
assert "build" not in service_definition
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_postgres_volume_setup_rejects_pre_18_data_layout() -> None:
|
|
33
|
+
command = PostgresVolumeSetupServicePlugin().service_definition["compose"]["command"]
|
|
34
|
+
|
|
35
|
+
assert "/var/lib/postgresql/PG_VERSION" in command
|
|
36
|
+
assert "PostgreSQL 16 data volume detected" in command
|
|
37
|
+
assert "exit 1" in command
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_postgres_exporter_uses_pinned_upstream_image() -> None:
|
|
41
|
+
service_definition = PostgresExporterServicePlugin().service_definition
|
|
42
|
+
|
|
43
|
+
assert service_definition["image"] == (
|
|
44
|
+
"quay.io/prometheuscommunity/postgres-exporter:v0.20.1@"
|
|
45
|
+
"sha256:ac5ec343104fae0e2d84a27bb8d69b38430a11910c5382cad85d478d2bab713e"
|
|
46
|
+
)
|
|
47
|
+
assert "build" not in service_definition
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_postgres_resource_provider():
|
|
51
|
+
"""Validate Postgres resource provider output."""
|
|
52
|
+
provider = PostgresResourceProvider()
|
|
53
|
+
resources = provider.get_resources()
|
|
54
|
+
|
|
55
|
+
assert len(resources) == 1
|
|
56
|
+
assert resources[0].name == "postgres"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_postgres_resource_provider_exposes_publish_target() -> None:
|
|
60
|
+
provider = PostgresResourceProvider()
|
|
61
|
+
publish_targets = provider.get_publish_targets()
|
|
62
|
+
|
|
63
|
+
assert publish_targets == [
|
|
64
|
+
PublishTargetSpec(
|
|
65
|
+
name="postgres",
|
|
66
|
+
provider=PostgresPublishTarget(),
|
|
67
|
+
metadata={"target_system": "postgres", "role": "serving"},
|
|
68
|
+
)
|
|
69
|
+
]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Tests for the PostgreSQL settings store capability.
|
|
2
|
+
|
|
3
|
+
Verifies that phlo-postgres owns all psycopg2/SQL/DSN behavior for the
|
|
4
|
+
durable Observatory settings store, including connection failure
|
|
5
|
+
sanitisation, same-process recovery, and DSN override.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from unittest.mock import MagicMock, patch
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from phlo.capabilities import SettingsStoreSpec
|
|
15
|
+
from phlo.plugins.observatory_settings import (
|
|
16
|
+
SettingsScope,
|
|
17
|
+
SettingsStore,
|
|
18
|
+
StorageUnavailableError,
|
|
19
|
+
)
|
|
20
|
+
from phlo_postgres.plugin import PostgresResourceProvider
|
|
21
|
+
from phlo_postgres.settings_store import PostgresSettingsStore, get_settings_stores
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Construction and DSN resolution
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_postgres_settings_store_implements_settings_store_protocol() -> None:
|
|
30
|
+
with patch("phlo_postgres.settings_store.get_postgres_settings") as mock:
|
|
31
|
+
mock.return_value.get_postgres_connection_string.return_value = (
|
|
32
|
+
"postgresql://phlo:phlo@localhost:5432/phlo"
|
|
33
|
+
)
|
|
34
|
+
store = PostgresSettingsStore()
|
|
35
|
+
assert isinstance(store, SettingsStore)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_postgres_settings_store_uses_default_dsn() -> None:
|
|
39
|
+
with patch("phlo_postgres.settings_store.get_postgres_settings") as mock:
|
|
40
|
+
mock.return_value.get_postgres_connection_string.return_value = (
|
|
41
|
+
"postgresql://phlo:phlo@localhost:5432/phlo"
|
|
42
|
+
)
|
|
43
|
+
store = PostgresSettingsStore()
|
|
44
|
+
assert store._db_url == "postgresql://phlo:phlo@localhost:5432/phlo"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_postgres_settings_store_uses_dsn_override(monkeypatch) -> None:
|
|
48
|
+
"""Explicit PHLO_OBSERVATORY_SETTINGS_DB_URL overrides default PostgresSettings DSN."""
|
|
49
|
+
monkeypatch.setenv("PHLO_OBSERVATORY_SETTINGS_DB_URL", "postgresql://override:5432/phlo")
|
|
50
|
+
with patch("phlo_postgres.settings_store.get_postgres_settings") as mock:
|
|
51
|
+
store = PostgresSettingsStore()
|
|
52
|
+
mock.assert_not_called()
|
|
53
|
+
assert store._db_url == "postgresql://override:5432/phlo"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Capability registration
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_get_settings_stores_returns_capability_spec() -> None:
|
|
62
|
+
with patch("phlo_postgres.settings_store.get_postgres_settings") as mock:
|
|
63
|
+
mock.return_value.get_postgres_connection_string.return_value = (
|
|
64
|
+
"postgresql://phlo:phlo@localhost:5432/phlo"
|
|
65
|
+
)
|
|
66
|
+
specs = get_settings_stores()
|
|
67
|
+
assert len(specs) == 1
|
|
68
|
+
assert isinstance(specs[0], SettingsStoreSpec)
|
|
69
|
+
assert specs[0].name == "postgres"
|
|
70
|
+
assert isinstance(specs[0].provider, PostgresSettingsStore)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_postgres_resource_provider_exposes_settings_store() -> None:
|
|
74
|
+
with patch("phlo_postgres.settings_store.get_postgres_settings") as mock:
|
|
75
|
+
mock.return_value.get_postgres_connection_string.return_value = (
|
|
76
|
+
"postgresql://phlo:phlo@localhost:5432/phlo"
|
|
77
|
+
)
|
|
78
|
+
provider = PostgresResourceProvider()
|
|
79
|
+
specs = provider.get_settings_stores()
|
|
80
|
+
assert len(specs) == 1
|
|
81
|
+
assert specs[0].name == "postgres"
|
|
82
|
+
assert isinstance(specs[0].provider, SettingsStore)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Connection failure → StorageUnavailableError (no DSN leak)
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_connection_failure_raises_storage_unavailable() -> None:
|
|
91
|
+
"""A psycopg2 connection failure must surface as StorageUnavailableError."""
|
|
92
|
+
with patch("phlo_postgres.settings_store.get_postgres_settings") as mock:
|
|
93
|
+
mock.return_value.get_postgres_connection_string.return_value = (
|
|
94
|
+
"postgresql://invalid:5432/phlo"
|
|
95
|
+
)
|
|
96
|
+
store = PostgresSettingsStore()
|
|
97
|
+
|
|
98
|
+
with (
|
|
99
|
+
patch("psycopg2.connect", side_effect=OSError("connection refused")),
|
|
100
|
+
pytest.raises(StorageUnavailableError, match="Settings storage is unavailable"),
|
|
101
|
+
):
|
|
102
|
+
store.get(SettingsScope.GLOBAL, "observatory")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_storage_unavailable_error_contains_no_dsn() -> None:
|
|
106
|
+
"""The sanitised error must not contain the DSN or password."""
|
|
107
|
+
with patch("phlo_postgres.settings_store.get_postgres_settings") as mock:
|
|
108
|
+
mock.return_value.get_postgres_connection_string.return_value = (
|
|
109
|
+
"postgresql://user:secret@host:5432/db"
|
|
110
|
+
)
|
|
111
|
+
store = PostgresSettingsStore()
|
|
112
|
+
|
|
113
|
+
with patch("psycopg2.connect", side_effect=OSError("connection refused")):
|
|
114
|
+
try:
|
|
115
|
+
store.get(SettingsScope.GLOBAL, "observatory")
|
|
116
|
+
pytest.fail("expected StorageUnavailableError")
|
|
117
|
+
except StorageUnavailableError as exc:
|
|
118
|
+
msg = str(exc)
|
|
119
|
+
assert "user:secret" not in msg
|
|
120
|
+
assert "postgresql://" not in msg
|
|
121
|
+
assert "host:5432" not in msg
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Same-process recovery
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_recovery_after_database_becomes_available() -> None:
|
|
130
|
+
"""First call fails, second call succeeds in the same process."""
|
|
131
|
+
with patch("phlo_postgres.settings_store.get_postgres_settings") as mock:
|
|
132
|
+
mock.return_value.get_postgres_connection_string.return_value = (
|
|
133
|
+
"postgresql://localhost:5432/phlo"
|
|
134
|
+
)
|
|
135
|
+
store = PostgresSettingsStore()
|
|
136
|
+
|
|
137
|
+
mock_cursor = MagicMock()
|
|
138
|
+
mock_cursor.fetchone.return_value = ({"version": 1}, None)
|
|
139
|
+
mock_conn = MagicMock()
|
|
140
|
+
mock_conn.__enter__.return_value = mock_conn
|
|
141
|
+
mock_conn.__exit__.return_value = False
|
|
142
|
+
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
|
|
143
|
+
mock_conn.cursor.return_value.__exit__.return_value = False
|
|
144
|
+
|
|
145
|
+
call_count = {"n": 0}
|
|
146
|
+
|
|
147
|
+
def fake_connect(*_args, **_kwargs):
|
|
148
|
+
call_count["n"] += 1
|
|
149
|
+
if call_count["n"] == 1:
|
|
150
|
+
raise OSError("connection refused")
|
|
151
|
+
return mock_conn
|
|
152
|
+
|
|
153
|
+
with patch("psycopg2.connect", side_effect=fake_connect):
|
|
154
|
+
with pytest.raises(StorageUnavailableError):
|
|
155
|
+
store.get(SettingsScope.GLOBAL, "observatory")
|
|
156
|
+
|
|
157
|
+
record = store.get(SettingsScope.GLOBAL, "observatory")
|
|
158
|
+
assert record is not None
|
|
159
|
+
assert record.settings == {"version": 1}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_put_connection_failure_raises_storage_unavailable() -> None:
|
|
163
|
+
"""PUT must also surface connection failures as StorageUnavailableError."""
|
|
164
|
+
with patch("phlo_postgres.settings_store.get_postgres_settings") as mock:
|
|
165
|
+
mock.return_value.get_postgres_connection_string.return_value = (
|
|
166
|
+
"postgresql://invalid:5432/phlo"
|
|
167
|
+
)
|
|
168
|
+
store = PostgresSettingsStore()
|
|
169
|
+
|
|
170
|
+
with (
|
|
171
|
+
patch("psycopg2.connect", side_effect=OSError("connection refused")),
|
|
172
|
+
pytest.raises(StorageUnavailableError, match="Settings storage is unavailable"),
|
|
173
|
+
):
|
|
174
|
+
store.put(SettingsScope.GLOBAL, "observatory", {"v": 1})
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
name: postgres-volume-setup
|
|
2
|
-
description: Initialize PostgreSQL data volume permissions
|
|
3
|
-
category: core
|
|
4
|
-
default: false
|
|
5
|
-
|
|
6
|
-
image: alpine:3.20
|
|
7
|
-
|
|
8
|
-
compose:
|
|
9
|
-
restart: "no"
|
|
10
|
-
user: "0:0"
|
|
11
|
-
entrypoint: /bin/sh
|
|
12
|
-
command: >
|
|
13
|
-
-c "
|
|
14
|
-
mkdir -p /var/lib/postgresql/data &&
|
|
15
|
-
chown -R 70:70 /var/lib/postgresql/data &&
|
|
16
|
-
chmod 700 /var/lib/postgresql/data &&
|
|
17
|
-
echo 'Postgres data volume ownership initialized'
|
|
18
|
-
"
|
|
19
|
-
volumes:
|
|
20
|
-
- postgres-data:/var/lib/postgresql/data
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
"""Tests for Postgres service and resource plugins."""
|
|
2
|
-
|
|
3
|
-
from phlo.capabilities import PublishTargetSpec
|
|
4
|
-
from phlo_postgres.plugin import PostgresResourceProvider, PostgresServicePlugin
|
|
5
|
-
from phlo_postgres.publish_target import PostgresPublishTarget
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def test_postgres_service_definition():
|
|
9
|
-
"""Validate Postgres service definition fields."""
|
|
10
|
-
plugin = PostgresServicePlugin()
|
|
11
|
-
service_definition = plugin.service_definition
|
|
12
|
-
|
|
13
|
-
assert service_definition["name"] == "postgres"
|
|
14
|
-
assert service_definition["category"] == "core"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def test_postgres_resource_provider():
|
|
18
|
-
"""Validate Postgres resource provider output."""
|
|
19
|
-
provider = PostgresResourceProvider()
|
|
20
|
-
resources = provider.get_resources()
|
|
21
|
-
|
|
22
|
-
assert len(resources) == 1
|
|
23
|
-
assert resources[0].name == "postgres"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def test_postgres_resource_provider_exposes_publish_target() -> None:
|
|
27
|
-
provider = PostgresResourceProvider()
|
|
28
|
-
publish_targets = provider.get_publish_targets()
|
|
29
|
-
|
|
30
|
-
assert publish_targets == [
|
|
31
|
-
PublishTargetSpec(
|
|
32
|
-
name="postgres",
|
|
33
|
-
provider=PostgresPublishTarget(),
|
|
34
|
-
metadata={"target_system": "postgres", "role": "serving"},
|
|
35
|
-
)
|
|
36
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|