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.
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/PKG-INFO +2 -3
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/pyproject.toml +2 -3
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres/__init__.py +3 -1
- phlo_postgres-0.14.0/src/phlo_postgres/authorization.py +41 -0
- phlo_postgres-0.14.0/src/phlo_postgres/cli_plugin.py +17 -0
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres/exporter_service.yaml +1 -1
- phlo_postgres-0.14.0/src/phlo_postgres/plugin.py +141 -0
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres/service.yaml +3 -2
- {phlo_postgres-0.3.2 → 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.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/PKG-INFO +2 -3
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/SOURCES.txt +3 -1
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/requires.txt +1 -2
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/tests/test_authorization.py +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.3.2/src/phlo_postgres/authorization.py +0 -173
- phlo_postgres-0.3.2/src/phlo_postgres/cli_plugin.py +0 -88
- phlo_postgres-0.3.2/src/phlo_postgres/plugin.py +0 -213
- phlo_postgres-0.3.2/src/phlo_postgres/volume_setup.yaml +0 -20
- phlo_postgres-0.3.2/tests/test_postgres_plugin.py +0 -36
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/README.md +0 -0
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/setup.cfg +0 -0
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres/cli.py +0 -0
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres/publish_target.py +0 -0
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres/resource.py +0 -0
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/dependency_links.txt +0 -0
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/entry_points.txt +0 -0
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/src/phlo_postgres.egg-info/top_level.txt +0 -0
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/tests/test_integration_postgres.py +0 -0
- {phlo_postgres-0.3.2 → phlo_postgres-0.14.0}/tests/test_postgres_cli.py +0 -0
- {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
|
+
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
|
-
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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,14 +1,13 @@
|
|
|
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
|
-
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
|
|
@@ -111,7 +111,7 @@ class TestPostgresCliSurfaceAdapter:
|
|
|
111
111
|
mock_result = MagicMock()
|
|
112
112
|
mock_result.variant = "allow"
|
|
113
113
|
|
|
114
|
-
with patch("
|
|
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
|
|