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.
Files changed (30) hide show
  1. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/PKG-INFO +2 -2
  2. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/pyproject.toml +2 -2
  3. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/__init__.py +3 -1
  4. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/authorization.py +2 -2
  5. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/exporter_service.yaml +1 -1
  6. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/plugin.py +12 -1
  7. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/service.yaml +3 -2
  8. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/settings.py +13 -10
  9. phlo_postgres-0.14.0/src/phlo_postgres/settings_store.py +224 -0
  10. phlo_postgres-0.14.0/src/phlo_postgres/volume_setup.yaml +24 -0
  11. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/PKG-INFO +2 -2
  12. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/SOURCES.txt +3 -1
  13. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/requires.txt +1 -1
  14. phlo_postgres-0.14.0/tests/test_postgres_plugin.py +69 -0
  15. phlo_postgres-0.14.0/tests/test_settings_store.py +174 -0
  16. phlo_postgres-0.4.0/src/phlo_postgres/volume_setup.yaml +0 -20
  17. phlo_postgres-0.4.0/tests/test_postgres_plugin.py +0 -36
  18. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/README.md +0 -0
  19. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/setup.cfg +0 -0
  20. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/cli.py +0 -0
  21. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/cli_plugin.py +0 -0
  22. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/publish_target.py +0 -0
  23. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres/resource.py +0 -0
  24. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/dependency_links.txt +0 -0
  25. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/entry_points.txt +0 -0
  26. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/top_level.txt +0 -0
  27. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/tests/test_authorization.py +0 -0
  28. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/tests/test_integration_postgres.py +0 -0
  29. {phlo_postgres-0.4.0 → phlo_postgres-0.14.0}/tests/test_postgres_cli.py +0 -0
  30. {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.4.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>=0.1.0
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.1.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.4.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.4.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() -> PostgresCliSurfaceAdapter:
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.15.0
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:16-alpine
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/data
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 functools import lru_cache
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
- @lru_cache(maxsize=1)
127
- def get_settings() -> PostgresSettings:
128
- """Return cached PostgreSQL settings instance.
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
- Provides a singleton-style access to PostgreSQL settings with LRU caching
131
- to avoid repeated parsing of environment variables and configuration files.
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
- The cache size of 1 ensures the same settings object is returned
138
- throughout the process lifetime. Settings are loaded once on first
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.4.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>=0.1.0
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
@@ -1,4 +1,4 @@
1
- phlo>=0.1.0
1
+ phlo<0.15,>=0.14.0
2
2
  psycopg2-binary>=2.9.11
3
3
 
4
4
  [dev]
@@ -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