phlo-postgres 0.3.2__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 (33) hide show
  1. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/PKG-INFO +2 -3
  2. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/pyproject.toml +2 -3
  3. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres/__init__.py +3 -1
  4. phlo_postgres-0.14.0/src/phlo_postgres/authorization.py +41 -0
  5. phlo_postgres-0.14.0/src/phlo_postgres/cli_plugin.py +17 -0
  6. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres/exporter_service.yaml +1 -1
  7. phlo_postgres-0.14.0/src/phlo_postgres/plugin.py +141 -0
  8. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres/service.yaml +3 -2
  9. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres/settings.py +13 -10
  10. phlo_postgres-0.14.0/src/phlo_postgres/settings_store.py +224 -0
  11. phlo_postgres-0.14.0/src/phlo_postgres/volume_setup.yaml +24 -0
  12. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/PKG-INFO +2 -3
  13. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/SOURCES.txt +3 -1
  14. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/requires.txt +1 -2
  15. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/tests/test_authorization.py +1 -1
  16. phlo_postgres-0.14.0/tests/test_postgres_plugin.py +69 -0
  17. phlo_postgres-0.14.0/tests/test_settings_store.py +174 -0
  18. phlo_postgres-0.3.2/src/phlo_postgres/authorization.py +0 -173
  19. phlo_postgres-0.3.2/src/phlo_postgres/cli_plugin.py +0 -88
  20. phlo_postgres-0.3.2/src/phlo_postgres/plugin.py +0 -213
  21. phlo_postgres-0.3.2/src/phlo_postgres/volume_setup.yaml +0 -20
  22. phlo_postgres-0.3.2/tests/test_postgres_plugin.py +0 -36
  23. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/README.md +0 -0
  24. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/setup.cfg +0 -0
  25. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres/cli.py +0 -0
  26. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres/publish_target.py +0 -0
  27. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres/resource.py +0 -0
  28. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/dependency_links.txt +0 -0
  29. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/entry_points.txt +0 -0
  30. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/top_level.txt +0 -0
  31. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/tests/test_integration_postgres.py +0 -0
  32. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/tests/test_postgres_cli.py +0 -0
  33. {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/tests/test_resource.py +0 -0
@@ -1,14 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phlo-postgres
3
- Version: 0.3.2
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
- Requires-Dist: pyyaml>=6.0.1
12
11
  Provides-Extra: dev
13
12
  Requires-Dist: pytest>=7.0; extra == "dev"
14
13
  Requires-Dist: ruff>=0.1.0; extra == "dev"
@@ -7,14 +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
- "pyyaml>=6.0.1",
13
12
  ]
14
13
  description = "Postgres service plugin for Phlo"
15
14
  name = "phlo-postgres"
16
15
  requires-python = ">=3.11"
17
- version = "0.3.2"
16
+ version = "0.14.0"
18
17
 
19
18
  [[project.authors]]
20
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.3.2"
32
+ __version__ = "0.14.0"
@@ -0,0 +1,41 @@
1
+ """phlo_postgres CLI authorization table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from phlo.cli.authorization import CliSurfaceAdapter, cli_surface_adapter_class
6
+
7
+ SURFACE_NAME = "phlo-postgres-cli"
8
+ FRAMEWORK_TYPE = "cli"
9
+ MUTATION_COMMANDS: frozenset[str] = frozenset(
10
+ ["postgres", "postgres.dump", "postgres.query", "postgres.restore", "postgres.vacuum"]
11
+ )
12
+ READ_COMMANDS: frozenset[str] = frozenset([])
13
+ COMMAND_RESOURCE_MAP: dict[str, str] = {
14
+ "postgres.query": "dataset",
15
+ "postgres.dump": "dataset",
16
+ "postgres.restore": "dataset",
17
+ "postgres.vacuum": "dataset",
18
+ "postgres": "dataset",
19
+ }
20
+ COMMAND_ACTION_MAP: dict[str, str] = {
21
+ "postgres.query": "dataset.query",
22
+ "postgres.dump": "dataset.manage",
23
+ "postgres.restore": "dataset.manage",
24
+ "postgres.vacuum": "dataset.manage",
25
+ "postgres": "dataset.query",
26
+ }
27
+
28
+ PostgresCliSurfaceAdapter = cli_surface_adapter_class(
29
+ "PostgresCliSurfaceAdapter",
30
+ surface_name=SURFACE_NAME,
31
+ mutation_commands=MUTATION_COMMANDS,
32
+ read_commands=READ_COMMANDS,
33
+ command_resource_map=COMMAND_RESOURCE_MAP,
34
+ command_action_map=COMMAND_ACTION_MAP,
35
+ default_action_prefix="postgres",
36
+ default_resource_prefix="postgres",
37
+ )
38
+
39
+
40
+ def get_postgres_cli_adapter() -> CliSurfaceAdapter:
41
+ return PostgresCliSurfaceAdapter.get_instance()
@@ -0,0 +1,17 @@
1
+ """Postgres CLI plugin registration."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ from phlo.plugins.base import cli_command_plugin_class
7
+
8
+ from phlo_postgres.cli import postgres_group
9
+
10
+
11
+ PostgresCliPlugin = cli_command_plugin_class(
12
+ "PostgresCliPlugin",
13
+ name="postgres",
14
+ version="0.1.0",
15
+ description="CLI commands for PostgreSQL service access",
16
+ commands=[postgres_group],
17
+ )
@@ -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
@@ -0,0 +1,141 @@
1
+ """PostgreSQL service plugin implementations.
2
+
3
+ This module provides plugin implementations that integrate PostgreSQL with the
4
+ phlo plugin system. It includes service plugins for the PostgreSQL database,
5
+ Prometheus exporter, and volume setup, as well as a resource provider plugin
6
+ that exposes PostgreSQL capabilities to the rest of the system.
7
+
8
+ Example:
9
+ >>> from phlo_postgres.plugin import PostgresServicePlugin
10
+ >>> plugin = PostgresServicePlugin()
11
+ >>> print(plugin.metadata.name)
12
+ postgres
13
+ >>> definition = plugin.service_definition
14
+
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from phlo.capabilities import PublishTargetSpec, ResourceSpec, SettingsStoreSpec
20
+ from phlo.plugins import PluginMetadata, ResourceProviderPlugin, service_plugin_class
21
+
22
+ from phlo_postgres.publish_target import PostgresPublishTarget
23
+ from phlo_postgres.resource import PostgresResource
24
+ from phlo_postgres.settings_store import get_settings_stores
25
+
26
+
27
+ PostgresServicePlugin = service_plugin_class(
28
+ "PostgresServicePlugin",
29
+ name="postgres",
30
+ version="0.1.0",
31
+ description="PostgreSQL database for metadata and operational storage",
32
+ author="Phlo Team",
33
+ tags=["core", "database", "postgres"],
34
+ )
35
+
36
+
37
+ PostgresExporterServicePlugin = service_plugin_class(
38
+ "PostgresExporterServicePlugin",
39
+ name="postgres-exporter",
40
+ version="0.1.0",
41
+ description="Prometheus exporter for PostgreSQL metrics",
42
+ author="Phlo Team",
43
+ tags=["observability", "metrics", "postgres"],
44
+ service_definition_file="exporter_service.yaml",
45
+ )
46
+
47
+
48
+ PostgresVolumeSetupServicePlugin = service_plugin_class(
49
+ "PostgresVolumeSetupServicePlugin",
50
+ name="postgres-volume-setup",
51
+ version="0.1.0",
52
+ description="Initialize PostgreSQL data volume permissions",
53
+ author="Phlo Team",
54
+ tags=["core", "database", "postgres"],
55
+ service_definition_file="volume_setup.yaml",
56
+ )
57
+
58
+
59
+ class PostgresResourceProvider(ResourceProviderPlugin):
60
+ """Resource provider plugin that exposes PostgreSQL capabilities.
61
+
62
+ This plugin registers the PostgresResource and PostgresPublishTarget with
63
+ the phlo resource system, making them available to other components for
64
+ database operations and data publishing.
65
+
66
+ Example:
67
+ >>> provider = PostgresResourceProvider()
68
+ >>> resources = provider.get_resources()
69
+ >>> targets = provider.get_publish_targets()
70
+
71
+ """
72
+
73
+ @property
74
+ def metadata(self) -> PluginMetadata:
75
+ """Return plugin metadata for the PostgreSQL resource provider.
76
+
77
+ Returns:
78
+ PluginMetadata: Metadata describing the resource provider plugin.
79
+
80
+ Example:
81
+ >>> provider = PostgresResourceProvider()
82
+ >>> meta = provider.metadata
83
+ >>> print(meta.name)
84
+ postgres
85
+
86
+ """
87
+ return PluginMetadata(
88
+ name="postgres",
89
+ version="0.1.0",
90
+ description="Postgres resource for Phlo",
91
+ )
92
+
93
+ def get_resources(self) -> list[ResourceSpec]:
94
+ """Return resource specifications exposed by this provider.
95
+
96
+ Returns:
97
+ list[ResourceSpec]: List of registered resource specifications
98
+ that can be accessed by other phlo components.
99
+
100
+ Example:
101
+ >>> provider = PostgresResourceProvider()
102
+ >>> specs = provider.get_resources()
103
+ >>> print(specs[0].name)
104
+ postgres
105
+
106
+ """
107
+ return [ResourceSpec(name="postgres", resource=PostgresResource())]
108
+
109
+ def get_publish_targets(self) -> list[PublishTargetSpec]:
110
+ """Return publish target capability specs exposed by this provider.
111
+
112
+ Returns:
113
+ list[PublishTargetSpec]: List of publish target specifications
114
+ that define where data can be published to PostgreSQL.
115
+
116
+ Example:
117
+ >>> provider = PostgresResourceProvider()
118
+ >>> targets = provider.get_publish_targets()
119
+ >>> print(targets[0].name)
120
+ postgres
121
+ >>> print(targets[0].metadata)
122
+ {'target_system': 'postgres', 'role': 'serving'}
123
+
124
+ """
125
+ return [
126
+ PublishTargetSpec(
127
+ name="postgres",
128
+ provider=PostgresPublishTarget(),
129
+ metadata={"target_system": "postgres", "role": "serving"},
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,14 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phlo-postgres
3
- Version: 0.3.2
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
- Requires-Dist: pyyaml>=6.0.1
12
11
  Provides-Extra: dev
13
12
  Requires-Dist: pytest>=7.0; extra == "dev"
14
13
  Requires-Dist: ruff>=0.1.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,6 +1,5 @@
1
- phlo>=0.1.0
1
+ phlo<0.15,>=0.14.0
2
2
  psycopg2-binary>=2.9.11
3
- pyyaml>=6.0.1
4
3
 
5
4
  [dev]
6
5
  pytest>=7.0
@@ -111,7 +111,7 @@ class TestPostgresCliSurfaceAdapter:
111
111
  mock_result = MagicMock()
112
112
  mock_result.variant = "allow"
113
113
 
114
- with patch("phlo_postgres.authorization.enforce", return_value=mock_result):
114
+ with patch("phlo.cli.authorization.enforce", return_value=mock_result):
115
115
  with patch.object(adapter, "_resolver"):
116
116
  from phlo.capabilities.interfaces import AuthPrincipal
117
117