postgresql-charms-single-kernel 16.3.8__tar.gz → 16.3.10__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 (74) hide show
  1. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/PKG-INFO +3 -1
  2. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/pyproject.toml +6 -4
  3. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/pyproject.toml.orig +6 -4
  4. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/charms/abstract_charm.py +7 -2
  5. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/config/exceptions.py +12 -0
  6. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/config/literals.py +29 -3
  7. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/core/peer_relation.py +202 -2
  8. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/core/s3.py +62 -0
  9. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/core/state.py +36 -0
  10. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/events/backup.py +172 -0
  11. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/events/postgresql.py +4 -6
  12. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/events/tls_transfer.py +1 -1
  13. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/lib/charms/data_platform_libs/v0/s3.py +792 -0
  14. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/managers/backup.py +1241 -0
  15. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/managers/database.py +4 -4
  16. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/managers/k8s.py +123 -0
  17. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/managers/patroni.py +3 -1
  18. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/managers/restore.py +685 -0
  19. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/scripts/rotate_logs.py +21 -0
  20. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/templates/k8s/pgbackrest.conf.j2 +54 -0
  21. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/templates/k8s/pgbackrest.logrotate.j2 +10 -0
  22. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/templates/vm/pgbackrest.conf.j2 +58 -0
  23. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/templates/vm/pgbackrest.logrotate.j2 +10 -0
  24. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/utils/backup.py +253 -0
  25. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/utils/s3.py +190 -0
  26. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/workload/base.py +214 -7
  27. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/workload/k8s.py +671 -0
  28. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/workload/paths/base.py +6 -0
  29. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/workload/paths/k8s.py +6 -0
  30. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/workload/paths/vm.py +6 -0
  31. postgresql_charms_single_kernel-16.3.10/single_kernel_postgresql/workload/vm.py +470 -0
  32. postgresql_charms_single_kernel-16.3.8/single_kernel_postgresql/managers/k8s.py +0 -244
  33. postgresql_charms_single_kernel-16.3.8/single_kernel_postgresql/workload/k8s.py +0 -223
  34. postgresql_charms_single_kernel-16.3.8/single_kernel_postgresql/workload/vm.py +0 -245
  35. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/LICENSE +0 -0
  36. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/README.md +0 -0
  37. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/__init__.py +0 -0
  38. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/charmcraft.yaml +0 -0
  39. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/charms/__init__.py +0 -0
  40. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/charms/k8s_charm.py +0 -0
  41. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/charms/vm_charm.py +0 -0
  42. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/compat/__init__.py +0 -0
  43. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/compat/postgresql.py +0 -0
  44. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/compat/pyproject.toml +0 -0
  45. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/config/__init__.py +0 -0
  46. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/config/enums.py +0 -0
  47. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/config/locales.py +0 -0
  48. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/config/pyproject.toml +0 -0
  49. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/config/statuses.py +0 -0
  50. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/core/__init__.py +0 -0
  51. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/core/config.py +0 -0
  52. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/core/relation_state.py +0 -0
  53. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/events/__init__.py +0 -0
  54. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/events/database.py +0 -0
  55. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/events/ldap.py +0 -0
  56. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/events/tls.py +0 -0
  57. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/lib/charms/data_platform_libs/v0/data_interfaces.py +0 -0
  58. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/lib/charms/data_platform_libs/v1/data_models.py +0 -0
  59. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/lib/charms/glauth_k8s/v0/ldap.py +0 -0
  60. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/managers/__init__.py +0 -0
  61. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/managers/base.py +0 -0
  62. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/managers/cluster.py +0 -0
  63. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/managers/config.py +0 -0
  64. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/managers/tls.py +0 -0
  65. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/templates/__init__.py +0 -0
  66. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/templates/patroni.yml.j2 +0 -0
  67. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/utils/__init__.py +0 -0
  68. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/utils/arch.py +0 -0
  69. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/utils/filesystem.py +0 -0
  70. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/utils/postgresql.py +0 -0
  71. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/utils/secret.py +0 -0
  72. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/utils/status.py +0 -0
  73. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/workload/__init__.py +0 -0
  74. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.10}/single_kernel_postgresql/workload/paths/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: postgresql-charms-single-kernel
3
- Version: 16.3.8
3
+ Version: 16.3.10
4
4
  Summary: Shared and reusable code for PostgreSQL-related charms
5
5
  Author: Canonical Data Platform
6
6
  Author-email: Canonical Data Platform <data-platform@lists.launchpad.net>
@@ -224,6 +224,8 @@ Requires-Dist: tomli ; python_full_version >= '3.12' and extra == 'postgresql'
224
224
  Requires-Dist: pydantic>=2.0 ; python_full_version >= '3.12' and extra == 'postgresql'
225
225
  Requires-Dist: jinja2>=3.1.6 ; python_full_version >= '3.12' and extra == 'postgresql'
226
226
  Requires-Dist: charmlibs-rollingops>=1.1.1 ; python_full_version >= '3.12' and extra == 'postgresql'
227
+ Requires-Dist: boto3>=1.43.85 ; python_full_version >= '3.12' and extra == 'postgresql'
228
+ Requires-Dist: botocore>=1.43.85 ; python_full_version >= '3.12' and extra == 'postgresql'
227
229
  Requires-Dist: pysyncobj>=0.3.15 ; python_full_version >= '3.12' and extra == 'vm'
228
230
  Requires-Dist: psutil>=7.2.2 ; python_full_version >= '3.12' and extra == 'vm'
229
231
  Requires-Dist: charmlibs-snap>=1.0.1 ; python_full_version >= '3.12' and extra == 'vm'
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "postgresql-charms-single-kernel"
3
3
  description = "Shared and reusable code for PostgreSQL-related charms"
4
- version = "16.3.8"
4
+ version = "16.3.10"
5
5
  readme = "README.md"
6
6
  classifiers = [
7
7
  "Development Status :: 3 - Alpha",
@@ -33,6 +33,8 @@ postgresql = [
33
33
  "pydantic>=2.0; python_version >= '3.12'",
34
34
  "jinja2 >=3.1.6; python_version >= '3.12'",
35
35
  "charmlibs-rollingops>=1.1.1; python_version >= '3.12'",
36
+ "boto3>=1.43.85; python_version >= '3.12'",
37
+ "botocore>=1.43.85; python_version >= '3.12'",
36
38
  ]
37
39
  vm = [
38
40
  "pysyncobj>=0.3.15; python_version >= '3.12'",
@@ -155,12 +157,12 @@ exclude = [
155
157
  ]
156
158
 
157
159
  [dependency-groups]
158
- format = ["ruff==0.16.3"]
160
+ format = ["ruff==0.16.8"]
159
161
  lint = [
160
162
  "codespell==2.4.3",
161
- "ty==0.0.72",
163
+ "ty==0.0.82",
162
164
  ]
163
165
  unit = [
164
- "coverage[toml]==7.15.4",
166
+ "coverage[toml]==7.16.1",
165
167
  "pytest==9.1.1",
166
168
  ]
@@ -4,7 +4,7 @@
4
4
  [project]
5
5
  name = "postgresql-charms-single-kernel"
6
6
  description = "Shared and reusable code for PostgreSQL-related charms"
7
- version = "16.3.8"
7
+ version = "16.3.10"
8
8
  readme = "README.md"
9
9
  license = {file = "LICENSE"}
10
10
  authors = [
@@ -33,6 +33,8 @@ postgresql = [
33
33
  "pydantic>=2.0; python_version >= '3.12'",
34
34
  "jinja2 >=3.1.6; python_version >= '3.12'",
35
35
  "charmlibs-rollingops>=1.1.1; python_version >= '3.12'",
36
+ "boto3>=1.43.85; python_version >= '3.12'",
37
+ "botocore>=1.43.85; python_version >= '3.12'",
36
38
  ]
37
39
  vm = [
38
40
  "pysyncobj>=0.3.15; python_version >= '3.12'",
@@ -61,14 +63,14 @@ module-root = ""
61
63
 
62
64
  [dependency-groups]
63
65
  format = [
64
- "ruff==0.16.3"
66
+ "ruff==0.16.8"
65
67
  ]
66
68
  lint = [
67
69
  "codespell==2.4.3",
68
- "ty==0.0.72"
70
+ "ty==0.0.82"
69
71
  ]
70
72
  unit = [
71
- "coverage[toml]==7.15.4",
73
+ "coverage[toml]==7.16.1",
72
74
  "pytest==9.1.1"
73
75
  ]
74
76
 
@@ -16,6 +16,7 @@ from single_kernel_postgresql.events.tls import TLS
16
16
  from single_kernel_postgresql.lib.charms.data_platform_libs.v0.data_interfaces import (
17
17
  DatabaseProvides,
18
18
  )
19
+ from single_kernel_postgresql.lib.charms.data_platform_libs.v0.s3 import S3Requirer
19
20
  from single_kernel_postgresql.managers.cluster import ClusterManager
20
21
  from single_kernel_postgresql.managers.config import ConfigManager
21
22
  from single_kernel_postgresql.managers.database import DatabaseManager
@@ -24,7 +25,7 @@ from single_kernel_postgresql.managers.tls import TLSManager
24
25
  from single_kernel_postgresql.workload.base import BaseWorkload, ResourceProvider
25
26
 
26
27
  from ..config.enums import Substrates
27
- from ..config.literals import DATABASE
28
+ from ..config.literals import DATABASE, S3_RELATION_NAME
28
29
  from ..utils.postgresql import PostgreSQL
29
30
 
30
31
 
@@ -35,7 +36,11 @@ class AbstractPostgreSQLCharm(CharmBase, ABC):
35
36
  super().__init__(*args)
36
37
 
37
38
  # State
38
- self.state = CharmState(charm=self, substrate=self.substrate)
39
+ self.state = CharmState(
40
+ charm=self,
41
+ substrate=self.substrate,
42
+ s3_requirer=S3Requirer(self, relation_name=S3_RELATION_NAME),
43
+ )
39
44
 
40
45
  # TLS events handler owns the two certificate requirers; build it before the
41
46
  # TLS manager so the manager can constructor-inject them for its live-fetch getters.
@@ -77,3 +77,15 @@ class UpdateSyncNodeCountError(Exception):
77
77
 
78
78
  class DeployedWithoutTrustError(Exception):
79
79
  """Raised when the K8s API denies access because the app wasn't deployed with --trust."""
80
+
81
+
82
+ class StanzaOperationError(PostgreSQLBaseError):
83
+ """Raised when a pgBackRest stanza-create/check/info operation fails."""
84
+
85
+
86
+ class BackupRunError(PostgreSQLBaseError):
87
+ """Raised when a pgBackRest backup run fails and the logs were uploaded."""
88
+
89
+
90
+ class ListBackupsError(PostgreSQLBaseError):
91
+ """Raised when pgBackRest fails to list backups."""
@@ -10,9 +10,13 @@ from typing import Literal
10
10
  # Permissions.
11
11
  POSTGRESQL_STORAGE_PERMISSIONS = 0o700
12
12
 
13
+ # Snap
14
+ POSTGRESQL_SNAP_NAME = "charmed-postgresql"
15
+
13
16
  # Relations
14
17
  PEER_RELATION = "database-peers"
15
18
  STATUS_PEERS_RELATION = "status-peers"
19
+ S3_RELATION_NAME = "s3-parameters"
16
20
 
17
21
  # Users.
18
22
  BACKUP_USER = "backup"
@@ -25,18 +29,20 @@ SYSTEM_USERS = [BACKUP_USER, MONITORING_USER, REPLICATION_USER, REWIND_USER, USE
25
29
 
26
30
  # Paths
27
31
  ## VM Paths
28
- BASE_SNAP_DIR = "/var/snap/charmed-postgresql"
32
+ BASE_SNAP_DIR = f"/var/snap/{POSTGRESQL_SNAP_NAME}"
29
33
  SNAP_DATA = "current"
30
34
  SNAP_COMMON = "common"
31
- SNAP = "/snap/charmed-postgresql/current"
35
+ SNAP = f"/snap/{POSTGRESQL_SNAP_NAME}/current"
32
36
  VM_LOGS_PATH = "var/log/postgresql"
33
37
  VM_DATA_PATH = "var/lib/postgresql"
34
38
  VM_ARCHIVE_PATH = "data/archive"
35
39
  VM_DATA_LOGS_PATH = "data/logs"
36
40
  VM_TEMP_PATH = "data/temp"
41
+ VM_PGBACKREST_LOGS_PATH = "var/log/pgbackrest"
37
42
 
38
43
  ## K8s Paths
39
44
  K8S_DATA_PATH = "var/lib/pg/data"
45
+ K8S_PGBACKREST_LOGS_PATH = "16/main/pgbackrest_logs"
40
46
 
41
47
  ## Shared Paths
42
48
  # NOTE: The paths don't have leading slahes since pathops
@@ -179,5 +185,25 @@ RAFT_PORT = 2222
179
185
  RAFT_PARTNER_PREFIX = "partner_node_status_server_"
180
186
 
181
187
  # VM services
182
- VM_PATRONI_SERVICE_NAME = "snap.charmed-postgresql.patroni.service"
188
+ VM_PATRONI_SERVICE_NAME = f"snap.{POSTGRESQL_SNAP_NAME}.patroni.service"
183
189
  VM_PATRONI_SERVICE_DEFAULT_PATH = f"/etc/systemd/system/{VM_PATRONI_SERVICE_NAME}"
190
+ VM_PGBACKREST_SERVICE_NAME = "pgbackrest-service"
191
+ VM_ROTATE_LOGS_LOG_FILE = "/var/log/rotate_logs.log"
192
+
193
+ ## K8s restore storage paths (must match the metadata.yaml storage locations)
194
+ K8S_LOGS_STORAGE_PATH = "var/lib/pg/logs"
195
+ K8S_ARCHIVE_PATH = "var/lib/pg/archive"
196
+ K8S_TEMP_STORAGE_PATH = "var/lib/pg/temp"
197
+ K8S_WAL_DIR = "pg_wal"
198
+ K8S_TEMP_TABLESPACE_DIR = "pgsql_tmp"
199
+ K8S_PG_LOGS_PATH = "16/main/pg_logs"
200
+ K8S_PATRONI_LOGS_PATH = "16/main/patroni_logs"
201
+ K8S_DEBIAN_DATA_SYMLINK = "var/lib/postgresql/16"
202
+ K8S_POSTGRESQL_LOGS_SYMLINK_PATH = "var/log/postgresql"
203
+ K8S_PATRONI_LOGS_SYMLINK_PATH = "var/log/patroni"
204
+ K8S_PGBACKREST_LOGS_SYMLINK_PATH = "var/log/pgbackrest"
205
+
206
+ ## Restore
207
+ VM_PATRONICTL_EXECUTABLE = "charmed-postgresql.patronictl"
208
+ PATRONICTL_REMOVE_CONFIRMATION = "Yes I am aware"
209
+ RESTORE_REPEAT_CAUSE = "restore-backup"
@@ -9,7 +9,7 @@ import json
9
9
  from collections.abc import MutableMapping
10
10
  from functools import cached_property
11
11
 
12
- from ops import Application, BlockedStatus, Relation, Unit
12
+ from ops import ActiveStatus, Application, BlockedStatus, Relation, Unit
13
13
 
14
14
  from single_kernel_postgresql.config.enums import Substrates
15
15
  from single_kernel_postgresql.config.literals import (
@@ -68,10 +68,35 @@ class PostgreSQLPeer(RelationState):
68
68
  return self.unit.is_leader()
69
69
 
70
70
  @property
71
- def is_blocked(self) -> bool:
71
+ def is_active_status(self) -> bool:
72
+ """Returns whether the unit is in an active state."""
73
+ return isinstance(self.unit.status, ActiveStatus)
74
+
75
+ @property
76
+ def rotate_logs_pid(self) -> int | None:
77
+ """Get the rotate-logs process PID from the unit peer relation data."""
78
+ if not self.relation:
79
+ return None
80
+ stored = self.relation.data[self.unit].get("rotate-logs-pid")
81
+ return int(stored) if stored else None
82
+
83
+ @rotate_logs_pid.setter
84
+ def rotate_logs_pid(self, value: int | None) -> None:
85
+ """Set or clear the rotate-logs process PID in the unit peer relation data."""
86
+ if not self.relation:
87
+ return
88
+ self.relation.data[self.unit]["rotate-logs-pid"] = str(value) if value else ""
89
+
90
+ @property
91
+ def is_blocked_status(self) -> bool:
72
92
  """Returns whether the unit is in a blocked state."""
73
93
  return isinstance(self.unit.status, BlockedStatus)
74
94
 
95
+ @property
96
+ def status_message(self) -> str:
97
+ """Returns the current unit status message, if any."""
98
+ return self.unit.status.message
99
+
75
100
  @property
76
101
  def internal_cert(self) -> str | None:
77
102
  """Get internal certificate.
@@ -225,6 +250,69 @@ class PostgreSQLPeer(RelationState):
225
250
  return True
226
251
  return self.relation.data[self.unit].get("connectivity", "on") == "on"
227
252
 
253
+ @is_connectivity_enabled.setter
254
+ def is_connectivity_enabled(self, value: bool) -> None:
255
+ """Enable or disable external connectivity to this unit."""
256
+ if not self.relation:
257
+ return
258
+ self.relation.data[self.unit]["connectivity"] = "on" if value else "off"
259
+
260
+ @property
261
+ def stanza(self) -> str | None:
262
+ """Get the pgBackRest stanza name from the unit peer relation data."""
263
+ if not self.relation:
264
+ return None
265
+ return self.relation.data[self.unit].get("stanza")
266
+
267
+ @stanza.setter
268
+ def stanza(self, value: str) -> None:
269
+ """Set the pgBackRest stanza name in the unit peer relation data."""
270
+ if not self.relation:
271
+ return
272
+ self.relation.data[self.unit]["stanza"] = value
273
+
274
+ @property
275
+ def s3_initialization_start(self) -> str | None:
276
+ """Get the s3-initialization-start timestamp from the unit peer relation data."""
277
+ if not self.relation:
278
+ return None
279
+ return self.relation.data[self.unit].get("s3-initialization-start")
280
+
281
+ @s3_initialization_start.setter
282
+ def s3_initialization_start(self, value: str) -> None:
283
+ """Set the s3-initialization-start timestamp in the unit peer relation data."""
284
+ if not self.relation:
285
+ return
286
+ self.relation.data[self.unit]["s3-initialization-start"] = value
287
+
288
+ @property
289
+ def s3_initialization_done(self) -> str | None:
290
+ """Get the s3-initialization-done marker from the unit peer relation data."""
291
+ if not self.relation:
292
+ return None
293
+ return self.relation.data[self.unit].get("s3-initialization-done")
294
+
295
+ @s3_initialization_done.setter
296
+ def s3_initialization_done(self, value: str) -> None:
297
+ """Set the s3-initialization-done marker in the unit peer relation data."""
298
+ if not self.relation:
299
+ return
300
+ self.relation.data[self.unit]["s3-initialization-done"] = value
301
+
302
+ @property
303
+ def s3_initialization_block_message(self) -> str | None:
304
+ """Get the s3 initialization block message from the unit peer relation data."""
305
+ if not self.relation:
306
+ return None
307
+ return self.relation.data[self.unit].get("s3-initialization-block-message")
308
+
309
+ @s3_initialization_block_message.setter
310
+ def s3_initialization_block_message(self, value: str) -> None:
311
+ """Set the s3 initialization block message in the unit peer relation data."""
312
+ if not self.relation:
313
+ return
314
+ self.relation.data[self.unit]["s3-initialization-block-message"] = value
315
+
228
316
  @property
229
317
  def config_hash(self) -> str | None:
230
318
  """Get the last-applied PostgreSQL config hash from the peer relation data."""
@@ -474,6 +562,118 @@ class PostgreSQLApplication(RelationState):
474
562
  return
475
563
  self.relation.data[self.app]["user_hash"] = value
476
564
 
565
+ @property
566
+ def stanza(self) -> str | None:
567
+ """Get the pgBackRest stanza name from the app peer relation data."""
568
+ if not self.relation:
569
+ return None
570
+ return self.relation.data[self.app].get("stanza")
571
+
572
+ @stanza.setter
573
+ def stanza(self, value: str) -> None:
574
+ """Set the pgBackRest stanza name in the app peer relation data."""
575
+ if not self.relation:
576
+ return
577
+ self.relation.data[self.app]["stanza"] = value
578
+
579
+ @property
580
+ def s3_initialization_start(self) -> str | None:
581
+ """Get the s3-initialization-start timestamp from the app peer relation data."""
582
+ if not self.relation:
583
+ return None
584
+ return self.relation.data[self.app].get("s3-initialization-start")
585
+
586
+ @s3_initialization_start.setter
587
+ def s3_initialization_start(self, value: str) -> None:
588
+ """Set the s3-initialization-start timestamp in the app peer relation data."""
589
+ if not self.relation:
590
+ return
591
+ self.relation.data[self.app]["s3-initialization-start"] = value
592
+
593
+ @property
594
+ def s3_initialization_block_message(self) -> str | None:
595
+ """Get the s3 initialization block message from the app peer relation data."""
596
+ if not self.relation:
597
+ return None
598
+ return self.relation.data[self.app].get("s3-initialization-block-message")
599
+
600
+ @s3_initialization_block_message.setter
601
+ def s3_initialization_block_message(self, value: str) -> None:
602
+ """Set the s3 initialization block message in the app peer relation data."""
603
+ if not self.relation:
604
+ return
605
+ self.relation.data[self.app]["s3-initialization-block-message"] = value
606
+
607
+ @property
608
+ def s3_initialization_done(self) -> str | None:
609
+ """Get the s3-initialization-done marker from the app peer relation data."""
610
+ if not self.relation:
611
+ return None
612
+ return self.relation.data[self.app].get("s3-initialization-done")
613
+
614
+ @s3_initialization_done.setter
615
+ def s3_initialization_done(self, value: str) -> None:
616
+ """Set the s3-initialization-done marker in the app peer relation data."""
617
+ if not self.relation:
618
+ return
619
+ self.relation.data[self.app]["s3-initialization-done"] = value
620
+
621
+ @property
622
+ def restoring_backup(self) -> str | None:
623
+ """Get the restoring-backup marker from the app peer relation data."""
624
+ if not self.relation:
625
+ return None
626
+ return self.relation.data[self.app].get("restoring-backup")
627
+
628
+ @restoring_backup.setter
629
+ def restoring_backup(self, value: str) -> None:
630
+ """Set the restoring-backup marker in the app peer relation data."""
631
+ if not self.relation:
632
+ return
633
+ self.relation.data[self.app]["restoring-backup"] = value
634
+
635
+ @property
636
+ def restore_stanza(self) -> str | None:
637
+ """Get the restore-stanza name from the app peer relation data."""
638
+ if not self.relation:
639
+ return None
640
+ return self.relation.data[self.app].get("restore-stanza")
641
+
642
+ @restore_stanza.setter
643
+ def restore_stanza(self, value: str) -> None:
644
+ """Set the restore-stanza name in the app peer relation data."""
645
+ if not self.relation:
646
+ return
647
+ self.relation.data[self.app]["restore-stanza"] = value
648
+
649
+ @property
650
+ def restore_timeline(self) -> str | None:
651
+ """Get the restore-timeline from the app peer relation data."""
652
+ if not self.relation:
653
+ return None
654
+ return self.relation.data[self.app].get("restore-timeline")
655
+
656
+ @restore_timeline.setter
657
+ def restore_timeline(self, value: str) -> None:
658
+ """Set the restore-timeline in the app peer relation data."""
659
+ if not self.relation:
660
+ return
661
+ self.relation.data[self.app]["restore-timeline"] = value
662
+
663
+ @property
664
+ def restore_to_time(self) -> str | None:
665
+ """Get the restore-to-time target from the app peer relation data."""
666
+ if not self.relation:
667
+ return None
668
+ return self.relation.data[self.app].get("restore-to-time")
669
+
670
+ @restore_to_time.setter
671
+ def restore_to_time(self, value: str) -> None:
672
+ """Set the restore-to-time target in the app peer relation data."""
673
+ if not self.relation:
674
+ return
675
+ self.relation.data[self.app]["restore-to-time"] = value
676
+
477
677
  def get_secret(self, key: str) -> str | None:
478
678
  """Get the secret value for 'key' from the peer relation data."""
479
679
  if not self.relation:
@@ -0,0 +1,62 @@
1
+ # Copyright 2026 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ """Live-fetch view over the S3 (s3-parameters) relation data."""
5
+
6
+ import logging
7
+
8
+ from single_kernel_postgresql.lib.charms.data_platform_libs.v0.s3 import S3Requirer
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class S3ConnectionInfo:
14
+ """Reads and normalizes S3 connection parameters from the s3-parameters relation."""
15
+
16
+ def __init__(self, s3_requirer: S3Requirer):
17
+ """Initialize the S3 connection info with the requirer bound to the relation."""
18
+ self.s3_requirer = s3_requirer
19
+
20
+ def retrieve_s3_parameters(self) -> tuple[dict, list[str]]:
21
+ """Retrieve S3 parameters from the S3 integrator relation.
22
+
23
+ Returns:
24
+ a tuple of (parameters, missing_required_parameters). If any required
25
+ parameter is missing, parameters is empty and missing lists the absent
26
+ keys. Otherwise parameters has defaults applied and values normalized.
27
+ """
28
+ s3_parameters = self.s3_requirer.get_s3_connection_info()
29
+ required_parameters = [
30
+ "bucket",
31
+ "access-key",
32
+ "secret-key",
33
+ ]
34
+ missing_required_parameters = [
35
+ param for param in required_parameters if param not in s3_parameters
36
+ ]
37
+ if missing_required_parameters:
38
+ logger.warning(
39
+ f"Missing required S3 parameters in relation with S3 integrator: {missing_required_parameters}"
40
+ )
41
+ return {}, missing_required_parameters
42
+
43
+ # Add some sensible defaults (as expected by the code) for missing optional parameters
44
+ s3_parameters.setdefault("endpoint", "https://s3.amazonaws.com")
45
+ s3_parameters.setdefault("path", "")
46
+ s3_parameters.setdefault("s3-uri-style", "host")
47
+ s3_parameters.setdefault("delete-older-than-days", "9999999")
48
+
49
+ # Strip whitespaces from all parameters.
50
+ for key, value in s3_parameters.items():
51
+ if isinstance(value, str):
52
+ s3_parameters[key] = value.strip()
53
+
54
+ # Clean up extra slash symbols to avoid issues on 3rd-party storages
55
+ # like Ceph Object Gateway (radosgw).
56
+ s3_parameters["endpoint"] = s3_parameters["endpoint"].rstrip("/")
57
+ s3_parameters["path"] = (
58
+ f"/{s3_parameters['path'].strip('/')}" # The slash in the beginning is required by pgBackRest.
59
+ )
60
+ s3_parameters["bucket"] = s3_parameters["bucket"].strip("/")
61
+
62
+ return s3_parameters, []
@@ -31,15 +31,18 @@ from single_kernel_postgresql.config.literals import (
31
31
  PEER_RELATION,
32
32
  REPLICATION_CONSUMER_RELATION,
33
33
  REPLICATION_OFFER_RELATION,
34
+ S3_RELATION_NAME,
34
35
  SCOPES,
35
36
  STATUS_PEERS_RELATION,
36
37
  )
37
38
  from single_kernel_postgresql.core.config import CharmConfig
38
39
  from single_kernel_postgresql.core.peer_relation import PostgreSQLApplication, PostgreSQLPeer
40
+ from single_kernel_postgresql.core.s3 import S3ConnectionInfo
39
41
  from single_kernel_postgresql.lib.charms.data_platform_libs.v0.data_interfaces import (
40
42
  DataPeerData,
41
43
  DataPeerUnitData,
42
44
  )
45
+ from single_kernel_postgresql.lib.charms.data_platform_libs.v0.s3 import S3Requirer
43
46
  from single_kernel_postgresql.utils import unit_name_to_pod_name
44
47
  from single_kernel_postgresql.utils.secret import translate_field_to_secret_key
45
48
  from single_kernel_postgresql.utils.status import format_status
@@ -52,6 +55,7 @@ class CharmState(Object):
52
55
  self,
53
56
  charm: CharmBase,
54
57
  substrate: Substrates,
58
+ s3_requirer: S3Requirer,
55
59
  ) -> None:
56
60
  """Initialize the CharmState object."""
57
61
  super().__init__(charm, "charm_state")
@@ -60,6 +64,7 @@ class CharmState(Object):
60
64
  self.peer_unit_interface = DataPeerUnitData(model=charm.model, relation_name=PEER_RELATION)
61
65
 
62
66
  self.statuses = StatusesState(self, STATUS_PEERS_RELATION)
67
+ self.s3_requirer = s3_requirer
63
68
 
64
69
  # -- Charm Config
65
70
  @cached_property
@@ -93,6 +98,11 @@ class CharmState(Object):
93
98
  """Get status peers relation."""
94
99
  return self.model.get_relation(STATUS_PEERS_RELATION)
95
100
 
101
+ @property
102
+ def s3_relation(self) -> Relation | None:
103
+ """Get the S3 parameters relation."""
104
+ return self.model.get_relation(S3_RELATION_NAME)
105
+
96
106
  # -- Core State Components
97
107
 
98
108
  @property
@@ -133,6 +143,11 @@ class CharmState(Object):
133
143
  substrate=self.substrate,
134
144
  )
135
145
 
146
+ @property
147
+ def s3_connection_info(self) -> S3ConnectionInfo:
148
+ """Get the S3 connection info state."""
149
+ return S3ConnectionInfo(self.s3_requirer)
150
+
136
151
  # -- Cluster state utilities
137
152
  def _get_hostname_from_unit(self, member: str) -> str:
138
153
  """Create a DNS name for a PostgreSQL/Patroni cluster member.
@@ -216,6 +231,11 @@ class CharmState(Object):
216
231
  """Current model name."""
217
232
  return self.model.name
218
233
 
234
+ @property
235
+ def cluster_name(self) -> str:
236
+ """Patroni cluster name, substrate-conditional (delegates to the app peer state)."""
237
+ return self.application.cluster_name
238
+
219
239
  @cached_property
220
240
  def patroni_url(self) -> str:
221
241
  """Patroni REST API URL."""
@@ -325,6 +345,22 @@ class CharmState(Object):
325
345
  ips.append(self.replication_consumer_ip)
326
346
  return ips
327
347
 
348
+ @property
349
+ def cluster_stanza(self) -> str | None:
350
+ """Return the pgBackRest stanza of the cluster, whichever unit published it.
351
+
352
+ The stanza is written by the primary into its unit databag during S3
353
+ initialization and only later adopted by the leader into app data, so
354
+ while a cluster is mid-initialization (or mid-restore) the authoritative
355
+ copy may still live on a unit. Fetched live on every access.
356
+ """
357
+ if stanza := self.application.stanza:
358
+ return stanza
359
+ for peer in self.application_peers:
360
+ if stanza := peer.stanza:
361
+ return stanza
362
+ return None
363
+
328
364
  # -- Secrets
329
365
  # TODO: This is temporary till data interfaces v1 is integrated
330
366
  def get_secret(self, scope: SCOPES, key: str) -> str | None: