postgresql-charms-single-kernel 16.3.8__tar.gz → 16.3.9__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 (68) hide show
  1. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/PKG-INFO +3 -1
  2. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/pyproject.toml +6 -4
  3. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/pyproject.toml.orig +6 -4
  4. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/charms/abstract_charm.py +7 -2
  5. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/config/exceptions.py +12 -0
  6. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/config/literals.py +10 -3
  7. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/core/peer_relation.py +166 -0
  8. postgresql_charms_single_kernel-16.3.9/single_kernel_postgresql/core/s3.py +62 -0
  9. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/core/state.py +36 -0
  10. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/events/tls_transfer.py +1 -1
  11. postgresql_charms_single_kernel-16.3.9/single_kernel_postgresql/lib/charms/data_platform_libs/v0/s3.py +792 -0
  12. postgresql_charms_single_kernel-16.3.9/single_kernel_postgresql/managers/backup.py +489 -0
  13. postgresql_charms_single_kernel-16.3.9/single_kernel_postgresql/templates/k8s/pgbackrest.conf.j2 +54 -0
  14. postgresql_charms_single_kernel-16.3.9/single_kernel_postgresql/templates/k8s/pgbackrest.logrotate.j2 +10 -0
  15. postgresql_charms_single_kernel-16.3.9/single_kernel_postgresql/templates/vm/pgbackrest.conf.j2 +58 -0
  16. postgresql_charms_single_kernel-16.3.9/single_kernel_postgresql/templates/vm/pgbackrest.logrotate.j2 +10 -0
  17. postgresql_charms_single_kernel-16.3.9/single_kernel_postgresql/utils/backup.py +240 -0
  18. postgresql_charms_single_kernel-16.3.9/single_kernel_postgresql/utils/s3.py +178 -0
  19. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/workload/base.py +114 -5
  20. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/workload/k8s.py +85 -9
  21. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/workload/paths/base.py +6 -0
  22. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/workload/paths/k8s.py +6 -0
  23. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/workload/paths/vm.py +6 -0
  24. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/workload/vm.py +137 -12
  25. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/LICENSE +0 -0
  26. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/README.md +0 -0
  27. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/__init__.py +0 -0
  28. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/charmcraft.yaml +0 -0
  29. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/charms/__init__.py +0 -0
  30. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/charms/k8s_charm.py +0 -0
  31. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/charms/vm_charm.py +0 -0
  32. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/compat/__init__.py +0 -0
  33. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/compat/postgresql.py +0 -0
  34. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/compat/pyproject.toml +0 -0
  35. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/config/__init__.py +0 -0
  36. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/config/enums.py +0 -0
  37. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/config/locales.py +0 -0
  38. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/config/pyproject.toml +0 -0
  39. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/config/statuses.py +0 -0
  40. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/core/__init__.py +0 -0
  41. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/core/config.py +0 -0
  42. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/core/relation_state.py +0 -0
  43. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/events/__init__.py +0 -0
  44. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/events/database.py +0 -0
  45. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/events/ldap.py +0 -0
  46. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/events/postgresql.py +0 -0
  47. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/events/tls.py +0 -0
  48. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/lib/charms/data_platform_libs/v0/data_interfaces.py +0 -0
  49. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/lib/charms/data_platform_libs/v1/data_models.py +0 -0
  50. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/lib/charms/glauth_k8s/v0/ldap.py +0 -0
  51. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/managers/__init__.py +0 -0
  52. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/managers/base.py +0 -0
  53. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/managers/cluster.py +0 -0
  54. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/managers/config.py +0 -0
  55. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/managers/database.py +0 -0
  56. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/managers/k8s.py +0 -0
  57. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/managers/patroni.py +0 -0
  58. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/managers/tls.py +0 -0
  59. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/templates/__init__.py +0 -0
  60. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/templates/patroni.yml.j2 +0 -0
  61. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/utils/__init__.py +0 -0
  62. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/utils/arch.py +0 -0
  63. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/utils/filesystem.py +0 -0
  64. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/utils/postgresql.py +0 -0
  65. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/utils/secret.py +0 -0
  66. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/utils/status.py +0 -0
  67. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/single_kernel_postgresql/workload/__init__.py +0 -0
  68. {postgresql_charms_single_kernel-16.3.8 → postgresql_charms_single_kernel-16.3.9}/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.9
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.9"
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.9"
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,6 @@ 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"
@@ -72,6 +72,11 @@ class PostgreSQLPeer(RelationState):
72
72
  """Returns whether the unit is in a blocked state."""
73
73
  return isinstance(self.unit.status, BlockedStatus)
74
74
 
75
+ @property
76
+ def status_message(self) -> str:
77
+ """Returns the current unit status message, if any."""
78
+ return self.unit.status.message
79
+
75
80
  @property
76
81
  def internal_cert(self) -> str | None:
77
82
  """Get internal certificate.
@@ -225,6 +230,55 @@ class PostgreSQLPeer(RelationState):
225
230
  return True
226
231
  return self.relation.data[self.unit].get("connectivity", "on") == "on"
227
232
 
233
+ @is_connectivity_enabled.setter
234
+ def is_connectivity_enabled(self, value: bool) -> None:
235
+ """Enable or disable external connectivity to this unit."""
236
+ if not self.relation:
237
+ return
238
+ self.relation.data[self.unit]["connectivity"] = "on" if value else "off"
239
+
240
+ @property
241
+ def stanza(self) -> str | None:
242
+ """Get the pgBackRest stanza name from the unit peer relation data."""
243
+ if not self.relation:
244
+ return None
245
+ return self.relation.data[self.unit].get("stanza")
246
+
247
+ @stanza.setter
248
+ def stanza(self, value: str) -> None:
249
+ """Set the pgBackRest stanza name in the unit peer relation data."""
250
+ if not self.relation:
251
+ return
252
+ self.relation.data[self.unit]["stanza"] = value
253
+
254
+ @property
255
+ def s3_initialization_done(self) -> str | None:
256
+ """Get the s3-initialization-done marker from the unit peer relation data."""
257
+ if not self.relation:
258
+ return None
259
+ return self.relation.data[self.unit].get("s3-initialization-done")
260
+
261
+ @s3_initialization_done.setter
262
+ def s3_initialization_done(self, value: str) -> None:
263
+ """Set the s3-initialization-done marker in the unit peer relation data."""
264
+ if not self.relation:
265
+ return
266
+ self.relation.data[self.unit]["s3-initialization-done"] = value
267
+
268
+ @property
269
+ def s3_initialization_block_message(self) -> str | None:
270
+ """Get the s3 initialization block message from the unit peer relation data."""
271
+ if not self.relation:
272
+ return None
273
+ return self.relation.data[self.unit].get("s3-initialization-block-message")
274
+
275
+ @s3_initialization_block_message.setter
276
+ def s3_initialization_block_message(self, value: str) -> None:
277
+ """Set the s3 initialization block message in the unit peer relation data."""
278
+ if not self.relation:
279
+ return
280
+ self.relation.data[self.unit]["s3-initialization-block-message"] = value
281
+
228
282
  @property
229
283
  def config_hash(self) -> str | None:
230
284
  """Get the last-applied PostgreSQL config hash from the peer relation data."""
@@ -474,6 +528,118 @@ class PostgreSQLApplication(RelationState):
474
528
  return
475
529
  self.relation.data[self.app]["user_hash"] = value
476
530
 
531
+ @property
532
+ def stanza(self) -> str | None:
533
+ """Get the pgBackRest stanza name from the app peer relation data."""
534
+ if not self.relation:
535
+ return None
536
+ return self.relation.data[self.app].get("stanza")
537
+
538
+ @stanza.setter
539
+ def stanza(self, value: str) -> None:
540
+ """Set the pgBackRest stanza name in the app peer relation data."""
541
+ if not self.relation:
542
+ return
543
+ self.relation.data[self.app]["stanza"] = value
544
+
545
+ @property
546
+ def s3_initialization_start(self) -> str | None:
547
+ """Get the s3-initialization-start timestamp from the app peer relation data."""
548
+ if not self.relation:
549
+ return None
550
+ return self.relation.data[self.app].get("s3-initialization-start")
551
+
552
+ @s3_initialization_start.setter
553
+ def s3_initialization_start(self, value: str) -> None:
554
+ """Set the s3-initialization-start timestamp in the app peer relation data."""
555
+ if not self.relation:
556
+ return
557
+ self.relation.data[self.app]["s3-initialization-start"] = value
558
+
559
+ @property
560
+ def s3_initialization_block_message(self) -> str | None:
561
+ """Get the s3 initialization block message from the app peer relation data."""
562
+ if not self.relation:
563
+ return None
564
+ return self.relation.data[self.app].get("s3-initialization-block-message")
565
+
566
+ @s3_initialization_block_message.setter
567
+ def s3_initialization_block_message(self, value: str) -> None:
568
+ """Set the s3 initialization block message in the app peer relation data."""
569
+ if not self.relation:
570
+ return
571
+ self.relation.data[self.app]["s3-initialization-block-message"] = value
572
+
573
+ @property
574
+ def s3_initialization_done(self) -> str | None:
575
+ """Get the s3-initialization-done marker from the app peer relation data."""
576
+ if not self.relation:
577
+ return None
578
+ return self.relation.data[self.app].get("s3-initialization-done")
579
+
580
+ @s3_initialization_done.setter
581
+ def s3_initialization_done(self, value: str) -> None:
582
+ """Set the s3-initialization-done marker in the app peer relation data."""
583
+ if not self.relation:
584
+ return
585
+ self.relation.data[self.app]["s3-initialization-done"] = value
586
+
587
+ @property
588
+ def restoring_backup(self) -> str | None:
589
+ """Get the restoring-backup marker from the app peer relation data."""
590
+ if not self.relation:
591
+ return None
592
+ return self.relation.data[self.app].get("restoring-backup")
593
+
594
+ @restoring_backup.setter
595
+ def restoring_backup(self, value: str) -> None:
596
+ """Set the restoring-backup marker in the app peer relation data."""
597
+ if not self.relation:
598
+ return
599
+ self.relation.data[self.app]["restoring-backup"] = value
600
+
601
+ @property
602
+ def restore_stanza(self) -> str | None:
603
+ """Get the restore-stanza name from the app peer relation data."""
604
+ if not self.relation:
605
+ return None
606
+ return self.relation.data[self.app].get("restore-stanza")
607
+
608
+ @restore_stanza.setter
609
+ def restore_stanza(self, value: str) -> None:
610
+ """Set the restore-stanza name in the app peer relation data."""
611
+ if not self.relation:
612
+ return
613
+ self.relation.data[self.app]["restore-stanza"] = value
614
+
615
+ @property
616
+ def restore_timeline(self) -> str | None:
617
+ """Get the restore-timeline from the app peer relation data."""
618
+ if not self.relation:
619
+ return None
620
+ return self.relation.data[self.app].get("restore-timeline")
621
+
622
+ @restore_timeline.setter
623
+ def restore_timeline(self, value: str) -> None:
624
+ """Set the restore-timeline in the app peer relation data."""
625
+ if not self.relation:
626
+ return
627
+ self.relation.data[self.app]["restore-timeline"] = value
628
+
629
+ @property
630
+ def restore_to_time(self) -> str | None:
631
+ """Get the restore-to-time target from the app peer relation data."""
632
+ if not self.relation:
633
+ return None
634
+ return self.relation.data[self.app].get("restore-to-time")
635
+
636
+ @restore_to_time.setter
637
+ def restore_to_time(self, value: str) -> None:
638
+ """Set the restore-to-time target in the app peer relation data."""
639
+ if not self.relation:
640
+ return
641
+ self.relation.data[self.app]["restore-to-time"] = value
642
+
477
643
  def get_secret(self, key: str) -> str | None:
478
644
  """Get the secret value for 'key' from the peer relation data."""
479
645
  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:
@@ -60,7 +60,7 @@ class TLSTransfer(Object):
60
60
  def _on_certificate_removed(self, event: CertificateRemovedEvent) -> None:
61
61
  """Disable TLS when TLS certificate is removed."""
62
62
  relation = self.charm.model.get_relation(TLS_TRANSFER_RELATION, event.relation_id)
63
- if relation is None:
63
+ if relation is None or relation.app is None:
64
64
  logger.error("Relationship not established anymore.")
65
65
  return
66
66