diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index cff77a6d..47cb4776 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -37,15 +37,15 @@ nfpms:
- rpm
- deb
bindir: /usr/sbin
- scripts:
- preremove: packaging/preremove.sh
overrides:
rpm:
scripts:
postinstall: packaging/rpm/postinstall.sh
+ preremove: packaging/rpm/preremove.sh
deb:
scripts:
postinstall: packaging/deb/postinstall.sh
+ preremove: packaging/deb/preremove.sh
contents:
- src: packaging/pgedge-control-plane.service
dst: /usr/lib/systemd/system/pgedge-control-plane.service
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4247be08..b32c62f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,28 @@
# Changelog
+## v0.9.0 - 2026-06-16
+
+### Added
+
+- Added a feature to enable manual Postgres minor version updates in systemd clusters. The Control Plane will now update its copy of the database spec when it detects changes to an instance's Postgres or Spock version.
+- Added post-update and pre-remove scriptlets to our RPM and deb packages to automatically restart the Control Plane service during upgrades and to stop and disable the service during uninstallation.
+- Added support for Debian-based distributions to the systemd orchestrator.
+- Added deb packages to our releases for Debian-based distributions.
+- Added the ability to configure a separate override for the database owner GID to complement the existing UID override.
+- Added support for Spock 5.0.9 on Postgres 16.14, 17.10, and 18.4.
+- Added `pg_hba_conf` and `pg_ident_conf` fields to the database spec — Operators can now supply custom `pg_hba.conf` and `pg_ident.conf` entries at the database or per-node level, giving full control over client authentication rules and ident mappings. Per-node entries take first-match priority over database-level entries.
+- Added Knowledge Base search support to the MCP service — Enable the `search_knowledgebase` tool via the `kb_enabled` option in the MCP service configuration, with support for OpenAI and Voyage AI embedding providers. The Knowledge Base file is loaded from a configurable host path and mounted read-only into the container.
+
+### Changed
+
+- Changed the instance monitoring system to query the Postgres and Spock versions for replica instances and report them in the databases API.
+
+### Fixed
+
+- Fixed global Patroni parameters not being applied to running database clusters. Dynamic configuration changes are now patched directly via the Patroni API on the primary instance, ensuring parameters that cannot be changed via config file reload take effect immediately.
+- Fixed valid instance IDs being incorrectly rejected by the API when the database ID was at its maximum allowed length — Instance IDs are an internal aggregate of the database ID, node name, and host hash, and are no longer subject to the same length limit as user-supplied identifiers.
+- Database specs with duplicate allocated ports on a single host are now rejected by the API.
+
## v0.8.1 - 2026-05-19
### Added
diff --git a/api/apiv1/gen/control_plane/service.go b/api/apiv1/gen/control_plane/service.go
index 9d82a879..54e78b37 100644
--- a/api/apiv1/gen/control_plane/service.go
+++ b/api/apiv1/gen/control_plane/service.go
@@ -81,7 +81,7 @@ type Service interface {
const APIName = "control-plane"
// APIVersion is the version of the API as defined in the design.
-const APIVersion = "v0.8.1"
+const APIVersion = "v0.9.0"
// ServiceName is the name of the service as defined in the design. This is the
// same value that is set in the endpoint request contexts under the ServiceKey
diff --git a/api/apiv1/gen/http/openapi.json b/api/apiv1/gen/http/openapi.json
index dbdb3ca6..6ad3ca98 100644
--- a/api/apiv1/gen/http/openapi.json
+++ b/api/apiv1/gen/http/openapi.json
@@ -3,7 +3,7 @@
"info": {
"title": "pgEdge Control Plane v1 API",
"description": "A distributed application that provides a declarative API to deploy and manage Postgres databases.",
- "version": "v0.8.1"
+ "version": "v0.9.0"
},
"host": "localhost:3000",
"consumes": [
diff --git a/api/apiv1/gen/http/openapi.yaml b/api/apiv1/gen/http/openapi.yaml
index 11b9e288..1b86fa7b 100644
--- a/api/apiv1/gen/http/openapi.yaml
+++ b/api/apiv1/gen/http/openapi.yaml
@@ -2,7 +2,7 @@ swagger: "2.0"
info:
title: pgEdge Control Plane v1 API
description: A distributed application that provides a declarative API to deploy and manage Postgres databases.
- version: v0.8.1
+ version: v0.9.0
host: localhost:3000
consumes:
- application/json
diff --git a/api/apiv1/gen/http/openapi3.json b/api/apiv1/gen/http/openapi3.json
index 871b1691..cd629941 100644
--- a/api/apiv1/gen/http/openapi3.json
+++ b/api/apiv1/gen/http/openapi3.json
@@ -3,7 +3,7 @@
"info": {
"title": "pgEdge Control Plane v1 API",
"description": "A distributed application that provides a declarative API to deploy and manage Postgres databases.",
- "version": "v0.8.1"
+ "version": "v0.9.0"
},
"servers": [
{
diff --git a/api/apiv1/gen/http/openapi3.yaml b/api/apiv1/gen/http/openapi3.yaml
index 4873fb0b..98efeaf1 100644
--- a/api/apiv1/gen/http/openapi3.yaml
+++ b/api/apiv1/gen/http/openapi3.yaml
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: pgEdge Control Plane v1 API
description: A distributed application that provides a declarative API to deploy and manage Postgres databases.
- version: v0.8.1
+ version: v0.9.0
servers:
- url: http://localhost:3000
paths:
diff --git a/api/version.txt b/api/version.txt
index 349ca0e1..7965b36d 100644
--- a/api/version.txt
+++ b/api/version.txt
@@ -1 +1 @@
-v0.8.1
\ No newline at end of file
+v0.9.0
\ No newline at end of file
diff --git a/changes/unreleased/Added-20260504-150235.yaml b/changes/unreleased/Added-20260504-150235.yaml
deleted file mode 100644
index 05fa0862..00000000
--- a/changes/unreleased/Added-20260504-150235.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-kind: Added
-body: Added a feature to enable manual Postgres minor version updates in systemd clusters. The Control Plane will now update its copy of the database spec when it detects changes to an instance's Postgres or Spock version.
-time: 2026-05-04T15:02:35.045407-04:00
diff --git a/changes/unreleased/Added-20260511-090000.yaml b/changes/unreleased/Added-20260511-090000.yaml
deleted file mode 100644
index 35d14e98..00000000
--- a/changes/unreleased/Added-20260511-090000.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-kind: Added
-body: Added support for Debian-based distributions to the systemd orchestrator.
-time: 2026-05-11T09:28:06.065156-04:00
diff --git a/changes/unreleased/Added-20260511-090010.yaml b/changes/unreleased/Added-20260511-090010.yaml
deleted file mode 100644
index 4704965b..00000000
--- a/changes/unreleased/Added-20260511-090010.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-kind: Added
-body: Added deb packages to our releases for Debian-based distributions.
-time: 2026-05-11T10:07:07.636452-04:00
diff --git a/changes/unreleased/Added-20260511-090020.yaml b/changes/unreleased/Added-20260511-090020.yaml
deleted file mode 100644
index 37b27bd9..00000000
--- a/changes/unreleased/Added-20260511-090020.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-kind: Added
-body: Added post-update and pre-remove scriptlets to our RPM and deb packages to automatically restart the Control Plane service during upgrades and to stop and disable the service during uninstallation.
-time: 2026-05-11T09:26:22.435191-04:00
diff --git a/changes/unreleased/Added-20260511-100000.yaml b/changes/unreleased/Added-20260511-100000.yaml
deleted file mode 100644
index 08238066..00000000
--- a/changes/unreleased/Added-20260511-100000.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-kind: Added
-body: Added the ability to configure a separate override for the database owner GID to complement the existing UID override.
-time: 2026-05-11T10:09:04.640943-04:00
diff --git a/changes/unreleased/Added-20260610-000000.yaml b/changes/unreleased/Added-20260610-000000.yaml
deleted file mode 100644
index 18d7b5a2..00000000
--- a/changes/unreleased/Added-20260610-000000.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-kind: Added
-body: Added support for Spock 5.0.9 on Postgres 16.14, 17.10, and 18.4.
-time: 2026-06-10T00:00:00.000000-00:00
diff --git a/changes/unreleased/Added-20260611-090000.yaml b/changes/unreleased/Added-20260611-090000.yaml
deleted file mode 100644
index d4e93ba2..00000000
--- a/changes/unreleased/Added-20260611-090000.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-kind: Added
-body: Added `pg_hba_conf` and `pg_ident_conf` fields to the database spec — Operators can now supply custom `pg_hba.conf` and `pg_ident.conf` entries at the database or per-node level, giving full control over client authentication rules and ident mappings. Per-node entries take first-match priority over database-level entries.
-time: 2026-06-11T09:00:00.000000-04:00
diff --git a/changes/unreleased/Added-20260611-090010.yaml b/changes/unreleased/Added-20260611-090010.yaml
deleted file mode 100644
index 8a84e641..00000000
--- a/changes/unreleased/Added-20260611-090010.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-kind: Added
-body: Added Knowledge Base search support to the MCP service — Enable the `search_knowledgebase` tool via the `kb_enabled` option in the MCP service configuration, with support for OpenAI and Voyage AI embedding providers. The Knowledge Base file is loaded from a configurable host path and mounted read-only into the container.
-time: 2026-06-11T09:00:10.000000-04:00
diff --git a/changes/unreleased/Changed-20260504-152348.yaml b/changes/unreleased/Changed-20260504-152348.yaml
deleted file mode 100644
index fa9dc8bb..00000000
--- a/changes/unreleased/Changed-20260504-152348.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-kind: Changed
-body: Changed the instance monitoring system to query the Postgres and Spock versions for replica instances and report them in the databases API.
-time: 2026-05-04T15:23:48.324604-04:00
diff --git a/changes/unreleased/Fixed-20260611-090000.yaml b/changes/unreleased/Fixed-20260611-090000.yaml
deleted file mode 100644
index e823c70e..00000000
--- a/changes/unreleased/Fixed-20260611-090000.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-kind: Fixed
-body: Fixed global Patroni parameters not being applied to running database clusters. Dynamic configuration changes are now patched directly via the Patroni API on the primary instance, ensuring parameters that cannot be changed via config file reload take effect immediately.
-time: 2026-06-11T09:00:00.000000-04:00
diff --git a/changes/unreleased/Fixed-20260611-090010.yaml b/changes/unreleased/Fixed-20260611-090010.yaml
deleted file mode 100644
index bd21c7af..00000000
--- a/changes/unreleased/Fixed-20260611-090010.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-kind: Fixed
-body: Fixed valid instance IDs being incorrectly rejected by the API when the database ID was at its maximum allowed length — Instance IDs are an internal aggregate of the database ID, node name, and host hash, and are no longer subject to the same length limit as user-supplied identifiers.
-time: 2026-06-11T09:00:10.000000-04:00
diff --git a/changes/unreleased/Fixed-20260616-150152.yaml b/changes/unreleased/Fixed-20260616-150152.yaml
deleted file mode 100644
index 23098c1e..00000000
--- a/changes/unreleased/Fixed-20260616-150152.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-kind: Fixed
-body: Database specs with duplicate allocated ports on a single host are now rejected by the API.
-time: 2026-06-16T15:01:52.449574-04:00
diff --git a/changes/v0.9.0.md b/changes/v0.9.0.md
new file mode 100644
index 00000000..47b405d0
--- /dev/null
+++ b/changes/v0.9.0.md
@@ -0,0 +1,22 @@
+## v0.9.0 - 2026-06-16
+
+### Added
+
+- Added a feature to enable manual Postgres minor version updates in systemd clusters. The Control Plane will now update its copy of the database spec when it detects changes to an instance's Postgres or Spock version.
+- Added post-update and pre-remove scriptlets to our RPM and deb packages to automatically restart the Control Plane service during upgrades and to stop and disable the service during uninstallation.
+- Added support for Debian-based distributions to the systemd orchestrator.
+- Added deb packages to our releases for Debian-based distributions.
+- Added the ability to configure a separate override for the database owner GID to complement the existing UID override.
+- Added support for Spock 5.0.9 on Postgres 16.14, 17.10, and 18.4.
+- Added `pg_hba_conf` and `pg_ident_conf` fields to the database spec — Operators can now supply custom `pg_hba.conf` and `pg_ident.conf` entries at the database or per-node level, giving full control over client authentication rules and ident mappings. Per-node entries take first-match priority over database-level entries.
+- Added Knowledge Base search support to the MCP service — Enable the `search_knowledgebase` tool via the `kb_enabled` option in the MCP service configuration, with support for OpenAI and Voyage AI embedding providers. The Knowledge Base file is loaded from a configurable host path and mounted read-only into the container.
+
+### Changed
+
+- Changed the instance monitoring system to query the Postgres and Spock versions for replica instances and report them in the databases API.
+
+### Fixed
+
+- Fixed global Patroni parameters not being applied to running database clusters. Dynamic configuration changes are now patched directly via the Patroni API on the primary instance, ensuring parameters that cannot be changed via config file reload take effect immediately.
+- Fixed valid instance IDs being incorrectly rejected by the API when the database ID was at its maximum allowed length — Instance IDs are an internal aggregate of the database ID, node name, and host hash, and are no longer subject to the same length limit as user-supplied identifiers.
+- Database specs with duplicate allocated ports on a single host are now rejected by the API.
diff --git a/docs/api/openapi.md b/docs/api/openapi.md
index a3733b23..d2d53f54 100644
--- a/docs/api/openapi.md
+++ b/docs/api/openapi.md
@@ -7,7 +7,7 @@ The Control Plane server serves a JSON OpenAPI v3 specification from the
You can also access offline copies of the OpenAPI specification in the pgEdge Control Plane repository. We generate a few versions of the specification to accommodate different tools and use cases:
-- [OpenAPI v3 YAML](https://github.com/pgEdge/control-plane/blob/v0.8.1/api/apiv1/gen/http/openapi3.yaml)
-- [OpenAPI v3 JSON](https://github.com/pgEdge/control-plane/blob/v0.8.1/api/apiv1/gen/http/openapi3.json)
-- [OpenAPI v2 YAML](https://github.com/pgEdge/control-plane/blob/v0.8.1/api/apiv1/gen/http/openapi.yaml)
-- [OpenAPI v2 JSON](https://github.com/pgEdge/control-plane/blob/v0.8.1/api/apiv1/gen/http/openapi.json)
\ No newline at end of file
+- [OpenAPI v3 YAML](https://github.com/pgEdge/control-plane/blob/v0.9.0/api/apiv1/gen/http/openapi3.yaml)
+- [OpenAPI v3 JSON](https://github.com/pgEdge/control-plane/blob/v0.9.0/api/apiv1/gen/http/openapi3.json)
+- [OpenAPI v2 YAML](https://github.com/pgEdge/control-plane/blob/v0.9.0/api/apiv1/gen/http/openapi.yaml)
+- [OpenAPI v2 JSON](https://github.com/pgEdge/control-plane/blob/v0.9.0/api/apiv1/gen/http/openapi.json)
\ No newline at end of file
diff --git a/docs/api/reference.md b/docs/api/reference.md
index 434100e1..38508d1c 100644
--- a/docs/api/reference.md
+++ b/docs/api/reference.md
@@ -34,4 +34,4 @@ hide:
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/changelog.md b/docs/changelog.md
index 4247be08..b32c62f5 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,28 @@
# Changelog
+## v0.9.0 - 2026-06-16
+
+### Added
+
+- Added a feature to enable manual Postgres minor version updates in systemd clusters. The Control Plane will now update its copy of the database spec when it detects changes to an instance's Postgres or Spock version.
+- Added post-update and pre-remove scriptlets to our RPM and deb packages to automatically restart the Control Plane service during upgrades and to stop and disable the service during uninstallation.
+- Added support for Debian-based distributions to the systemd orchestrator.
+- Added deb packages to our releases for Debian-based distributions.
+- Added the ability to configure a separate override for the database owner GID to complement the existing UID override.
+- Added support for Spock 5.0.9 on Postgres 16.14, 17.10, and 18.4.
+- Added `pg_hba_conf` and `pg_ident_conf` fields to the database spec — Operators can now supply custom `pg_hba.conf` and `pg_ident.conf` entries at the database or per-node level, giving full control over client authentication rules and ident mappings. Per-node entries take first-match priority over database-level entries.
+- Added Knowledge Base search support to the MCP service — Enable the `search_knowledgebase` tool via the `kb_enabled` option in the MCP service configuration, with support for OpenAI and Voyage AI embedding providers. The Knowledge Base file is loaded from a configurable host path and mounted read-only into the container.
+
+### Changed
+
+- Changed the instance monitoring system to query the Postgres and Spock versions for replica instances and report them in the databases API.
+
+### Fixed
+
+- Fixed global Patroni parameters not being applied to running database clusters. Dynamic configuration changes are now patched directly via the Patroni API on the primary instance, ensuring parameters that cannot be changed via config file reload take effect immediately.
+- Fixed valid instance IDs being incorrectly rejected by the API when the database ID was at its maximum allowed length — Instance IDs are an internal aggregate of the database ID, node name, and host hash, and are no longer subject to the same length limit as user-supplied identifiers.
+- Database specs with duplicate allocated ports on a single host are now rejected by the API.
+
## v0.8.1 - 2026-05-19
### Added
diff --git a/docs/development/e2e-tests.md b/docs/development/e2e-tests.md
index ce0175a7..2f745cff 100644
--- a/docs/development/e2e-tests.md
+++ b/docs/development/e2e-tests.md
@@ -395,7 +395,7 @@ specifying `FIXTURE_CONTROL_PLANE_IMAGE` with the
`{deploy,update,reset}-{lima,ec2}-fixture` targets. For example:
```sh
-make update-lima-fixture FIXTURE_CONTROL_PLANE_IMAGE='ghcr.io/pgedge/control-plane:v0.8.1-rc.1'
+make update-lima-fixture FIXTURE_CONTROL_PLANE_IMAGE='ghcr.io/pgedge/control-plane:v0.9.0-rc.1'
```
### Fixture variants
diff --git a/docs/installation/installation.md b/docs/installation/installation.md
index 44dfe922..c4a1ed64 100644
--- a/docs/installation/installation.md
+++ b/docs/installation/installation.md
@@ -98,7 +98,7 @@ Given that output, the following stack definition file will deploy a single Cont
```yaml
services:
host-1:
- image: ghcr.io/pgedge/control-plane:v0.8.1
+ image: ghcr.io/pgedge/control-plane:v0.9.0
command: run
environment:
- PGEDGE_HOST_ID=host-1
@@ -113,7 +113,7 @@ services:
constraints:
- node.id==vzou89zyd4n3xz6p6jvoohqxx
host-2:
- image: ghcr.io/pgedge/control-plane:v0.8.1
+ image: ghcr.io/pgedge/control-plane:v0.9.0
command: run
environment:
- PGEDGE_HOST_ID=host-2
@@ -128,7 +128,7 @@ services:
constraints:
- node.id==5sa7m11ub62t1n22feuhg0mbp
host-3:
- image: ghcr.io/pgedge/control-plane:v0.8.1
+ image: ghcr.io/pgedge/control-plane:v0.9.0
command: run
environment:
- PGEDGE_HOST_ID=host-3
@@ -191,7 +191,7 @@ For example:
```yaml
host-4:
- image: ghcr.io/pgedge/control-plane:v0.8.1
+ image: ghcr.io/pgedge/control-plane:v0.9.0
command: run
environment:
- PGEDGE_HOST_ID=host-4
@@ -227,7 +227,7 @@ Paste the output below and click "Generate Stack." This generator is fully local
-
+
``` yaml {#global-output}
# Once submitted, the generated stack will appear here.
diff --git a/docs/installation/mtls.md b/docs/installation/mtls.md
index 2882abf5..b4eecc70 100644
--- a/docs/installation/mtls.md
+++ b/docs/installation/mtls.md
@@ -35,7 +35,7 @@ For example, if you've placed the certificates in a `/opt/pgedge/control-plane`
```yaml
services:
host-1:
- image: ghcr.io/pgedge/control-plane:v0.8.1
+ image: ghcr.io/pgedge/control-plane:v0.9.0
command: run
environment:
- PGEDGE_HOST_ID=host-1
@@ -195,7 +195,7 @@ Now that our certificates are available on each machine, we can update our stack
```yaml
services:
host-1:
- image: ghcr.io/pgedge/control-plane:v0.8.1
+ image: ghcr.io/pgedge/control-plane:v0.9.0
command: run
environment:
- PGEDGE_HOST_ID=host-1
@@ -219,7 +219,7 @@ services:
constraints:
- node.id==81kw1zwmh9y8hk4rd7igylry0
host-2:
- image: ghcr.io/pgedge/control-plane:v0.8.1
+ image: ghcr.io/pgedge/control-plane:v0.9.0
command: run
environment:
- PGEDGE_HOST_ID=host-2
@@ -240,7 +240,7 @@ services:
constraints:
- node.id==xz7069ytbdq7uzd2tvrj2wlf2
host-3:
- image: ghcr.io/pgedge/control-plane:v0.8.1
+ image: ghcr.io/pgedge/control-plane:v0.9.0
command: run
environment:
- PGEDGE_HOST_ID=host-3
diff --git a/docs/installation/systemd.md b/docs/installation/systemd.md
index e3349c59..ab5babb1 100644
--- a/docs/installation/systemd.md
+++ b/docs/installation/systemd.md
@@ -87,7 +87,7 @@ Run the following commands on each Debian-based host:
```sh
# Install prerequisites for the pgEdge Enterprise Postgres packages
sudo apt update
-sudo apt install curl gnupg2 lsb-release
+sudo apt install -y curl gnupg2 lsb-release
# Install the pgEdge Enterprise Postgres repository
curl -O --output-dir /tmp https://apt.pgedge.com/repodeb/pgedge-release_latest_all.deb
@@ -130,7 +130,7 @@ Use the following commands to download and install the RPM:
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
# Set the version to install
-VERSION="v0.8.1"
+VERSION="v0.9.0"
# Download the RPM
curl -LO "https://github.com/pgedge/control-plane/releases/download/${VERSION}/pgedge-control-plane_${VERSION#v}_linux_${ARCH}.rpm"
@@ -148,13 +148,13 @@ Use the following commands to download and install the deb package:
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
# Set the version to install
-VERSION="v0.8.0"
+VERSION="v0.9.0"
# Download the deb package
-curl -LO "https://github.com/pgedge/control-plane/releases/download/${VERSION}/pgedge-control-plane_${VERSION#v}_linux_${ARCH}.deb"
+curl -LO --output-dir /tmp "https://github.com/pgedge/control-plane/releases/download/${VERSION}/pgedge-control-plane_${VERSION#v}_linux_${ARCH}.deb"
# Install the deb package
-sudo apt install ./pgedge-control-plane_${VERSION#v}_linux_${ARCH}.deb
+sudo apt install /tmp/pgedge-control-plane_${VERSION#v}_linux_${ARCH}.deb
```
## Configuration
@@ -343,21 +343,58 @@ new versions to be reflected in the database spec in the Control Plane API.
Updating the Control Plane just involves installing the new package. This will
automatically restart the Control Plane service after the update is complete.
-Download the new RPM or deb from the
-[GitHub releases page](https://github.com/pgedge/control-plane/releases) and run
-one of the following commands:
+
+> [!NOTE]
+> The package upgrade will preserve any modifications to the configuration file
+> at `/etc/pgedge-control-plane/config.json`.
+
+### RPM Package
+
+Use the following commands to download and install the updated RPM:
```sh
-# If you're using the RPM package, run:
-sudo rpm -U pgedge-control-plane__linux_.rpm
+# (Optional) print the current version via the API
+curl http://localhost:3000/v1/version
-# If you're using the deb package, run:
-sudo apt install pgedge-control-plane__linux_.deb
+# Detect architecture
+ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
+
+# Set the new version to install
+VERSION="v0.9.0"
+
+# Download the RPM
+curl -LO "https://github.com/pgedge/control-plane/releases/download/${VERSION}/pgedge-control-plane_${VERSION#v}_linux_${ARCH}.rpm"
+
+# Install the RPM with the 'upgrade' flag
+sudo rpm -U pgedge-control-plane_${VERSION#v}_linux_${ARCH}.rpm
+
+# (Optional) print the updated version via the API
+curl http://localhost:3000/v1/version
```
-> [!NOTE]
-> The package upgrade will preserve any modifications to the configuration file
-> at `/etc/pgedge-control-plane/config.json`.
+### Deb Package
+
+Use the following commands to download and install the deb package:
+
+```sh
+# (Optional) print the current version via the API
+curl http://localhost:3000/v1/version
+
+# Detect architecture
+ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
+
+# Set the new version to install
+VERSION="v0.9.0"
+
+# Download the deb package
+curl -LO --output-dir /tmp "https://github.com/pgedge/control-plane/releases/download/${VERSION}/pgedge-control-plane_${VERSION#v}_linux_${ARCH}.deb"
+
+# Install the deb package
+sudo apt install /tmp/pgedge-control-plane_${VERSION#v}_linux_${ARCH}.deb
+
+# (Optional) print the updated version via the API
+curl http://localhost:3000/v1/version
+```
## Uninstalling the Control Plane
diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md
index a5651607..a7f09265 100644
--- a/docs/installation/upgrading.md
+++ b/docs/installation/upgrading.md
@@ -6,11 +6,11 @@ Plane. The Control Plane version is specified in the image property of the [stac
```yaml
services:
host-1:
- image: ghcr.io/pgedge/control-plane:v0.8.1
+ image: ghcr.io/pgedge/control-plane:v0.9.0
```
You can *pin* to a specific version by including a version in the `image`
-fields in your service specification, such as `ghcr.io/pgedge/control-plane:v0.8.1`.
+fields in your service specification, such as `ghcr.io/pgedge/control-plane:v0.9.0`.
If you do not include a version, Docker will pull the `ghcr.io/pgedge/control-plane:latest` tag by default.
diff --git a/packaging/deb/preremove.sh b/packaging/deb/preremove.sh
new file mode 100755
index 00000000..86e27398
--- /dev/null
+++ b/packaging/deb/preremove.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+
+if [ "$1" = "remove" ]; then
+ /bin/systemctl stop pgedge-control-plane.service >/dev/null 2>&1 || :
+ /bin/systemctl --no-reload disable pgedge-control-plane.service >/dev/null 2>&1 || :
+fi
diff --git a/packaging/preremove.sh b/packaging/preremove.sh
deleted file mode 100755
index 315bf5e4..00000000
--- a/packaging/preremove.sh
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/bin/sh
-
-/bin/systemctl stop pgedge-control-plane.service >/dev/null 2>&1 || :
-/bin/systemctl --no-reload disable pgedge-control-plane.service >/dev/null 2>&1 || :
diff --git a/packaging/rpm/preremove.sh b/packaging/rpm/preremove.sh
new file mode 100755
index 00000000..ce7c67e1
--- /dev/null
+++ b/packaging/rpm/preremove.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+
+if [ "$1" -eq 0 ]; then
+ /bin/systemctl stop pgedge-control-plane.service >/dev/null 2>&1 || :
+ /bin/systemctl --no-reload disable pgedge-control-plane.service >/dev/null 2>&1 || :
+fi
diff --git a/server/internal/api/apiv1/post_init_handlers.go b/server/internal/api/apiv1/post_init_handlers.go
index 38fb740f..56791d1b 100644
--- a/server/internal/api/apiv1/post_init_handlers.go
+++ b/server/internal/api/apiv1/post_init_handlers.go
@@ -470,6 +470,9 @@ func (s *PostInitHandlers) BackupDatabaseNode(ctx context.Context, req *api.Back
if err != nil {
return nil, apiErr(err)
}
+ if node.BackupConfig == nil && db.Spec.BackupConfig == nil {
+ return nil, makeInvalidInputErr(errors.New("backups are not configured for this database node"))
+ }
if err := validateBackupOptions(req.Options); err != nil {
return nil, apiErr(err)
diff --git a/server/internal/orchestrator/swarm/mcp_config_resource.go b/server/internal/orchestrator/swarm/mcp_config_resource.go
index b0888fc7..7cf3bbfa 100644
--- a/server/internal/orchestrator/swarm/mcp_config_resource.go
+++ b/server/internal/orchestrator/swarm/mcp_config_resource.go
@@ -99,9 +99,9 @@ func (r *MCPConfigResource) Refresh(ctx context.Context, rc *resource.Context) e
}
// checkKBFileExists blocks deployment when kb_enabled is set but the host
-// KB file is missing. Called from Create and Update — not Refresh, because
-// Refresh is only invoked for resources already in state, so a check there
-// would not fire on first deploy.
+// KB file is missing or is not a regular file. Called from Create and Update —
+// not Refresh, because Refresh is only invoked for resources already in state,
+// so a check there would not fire on first deploy.
func (r *MCPConfigResource) checkKBFileExists(fs afero.Fs) error {
if r.KBHostPath == "" {
return nil
@@ -113,6 +113,16 @@ func (r *MCPConfigResource) checkKBFileExists(fs afero.Fs) error {
if !exists {
return fmt.Errorf("KB database file not found at %s — stage the file on the host before deploying with kb_enabled: true", r.KBHostPath)
}
+ // A directory passes the existence check above but cannot be opened as a
+ // SQLite database, which would only surface as a confusing error at query
+ // time. Reject it here so the failure is clear at deploy time.
+ isDir, err := afero.IsDir(fs, r.KBHostPath)
+ if err != nil {
+ return fmt.Errorf("failed to check KB database file at %s: %w", r.KBHostPath, err)
+ }
+ if isDir {
+ return fmt.Errorf("KB database path %s is a directory, not a file — kb_database_host_path must point to the KB SQLite file", r.KBHostPath)
+ }
return nil
}
diff --git a/server/internal/orchestrator/swarm/mcp_config_resource_test.go b/server/internal/orchestrator/swarm/mcp_config_resource_test.go
index 5f6df823..58a101e0 100644
--- a/server/internal/orchestrator/swarm/mcp_config_resource_test.go
+++ b/server/internal/orchestrator/swarm/mcp_config_resource_test.go
@@ -141,6 +141,30 @@ func TestMCPConfigResource_Create_KBFileMissing(t *testing.T) {
assert.False(t, errors.Is(err, resource.ErrNotFound), "missing KB file must not return ErrNotFound")
}
+func TestMCPConfigResource_Create_KBPathIsDirectory(t *testing.T) {
+ // KBHostPath points to a directory → blocked at deploy time, since a
+ // directory cannot be opened as a SQLite database. Without this check the
+ // error would only surface as a confusing failure at query time.
+ dirID := "inst-data"
+ dirPath := "/var/lib/pgedge/services/inst-kb-dir"
+ kbPath := "/var/lib/pgedge/kb" // a directory, not a file
+ rc, fs := mcpRCAndFs(t, dirID, dirPath)
+
+ require.NoError(t, fs.MkdirAll(kbPath, 0o700))
+
+ r := &MCPConfigResource{
+ ServiceInstanceID: "inst-kb-dir",
+ HostID: "host-1",
+ DirResourceID: dirID,
+ Config: &database.MCPServiceConfig{},
+ KBHostPath: kbPath,
+ }
+ err := r.Create(context.Background(), rc)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "is a directory")
+ assert.False(t, errors.Is(err, resource.ErrNotFound), "a directory KB path must not return ErrNotFound")
+}
+
func TestMCPConfigResource_Update_KBFileMissing(t *testing.T) {
// KBHostPath set but file does not exist on the update path → blocked.
dirID := "inst-data"
diff --git a/server/internal/orchestrator/swarm/service_instance.go b/server/internal/orchestrator/swarm/service_instance.go
index 25786566..b94a978b 100644
--- a/server/internal/orchestrator/swarm/service_instance.go
+++ b/server/internal/orchestrator/swarm/service_instance.go
@@ -144,6 +144,24 @@ func (s *ServiceInstanceResource) Update(ctx context.Context, rc *resource.Conte
}
}
+ // Refresh UpdatedAt before redeploying so the monitor's running-state grace
+ // period covers the restart window. An update that changes the container
+ // (e.g. a config change) restarts it, during which the container has no
+ // status; without bumping UpdatedAt the monitor would briefly mark the
+ // instance failed mid-restart even though it recovers seconds later.
+ // Best-effort: a failure here only risks that transient false "failed"
+ // state, so we log and continue rather than block the redeploy.
+ logger, logErr := do.Invoke[zerolog.Logger](rc.Injector)
+ dbSvc, refreshErr := do.Invoke[*database.Service](rc.Injector)
+ if refreshErr == nil {
+ refreshErr = dbSvc.SetServiceInstanceState(ctx, s.DatabaseID, s.ServiceInstanceID, database.ServiceInstanceStateRunning)
+ }
+ if refreshErr != nil && logErr == nil {
+ logger.Warn().Err(refreshErr).
+ Str("service_instance_id", s.ServiceInstanceID).
+ Msg("failed to refresh service instance UpdatedAt before redeploy; monitor may briefly mark it failed during the restart")
+ }
+
return s.deploy(ctx, rc)
}
diff --git a/server/internal/orchestrator/swarm/service_spec.go b/server/internal/orchestrator/swarm/service_spec.go
index 7535d376..0a8ebe38 100644
--- a/server/internal/orchestrator/swarm/service_spec.go
+++ b/server/internal/orchestrator/swarm/service_spec.go
@@ -42,11 +42,14 @@ func buildPostgRESTEnvVars() []string {
}
}
-// ragConfigHash returns a short hex digest of the RAG service configuration.
+// serviceConfigHash returns a short hex digest of a service's configuration.
// It is embedded in the container spec as PGEDGE_CONFIG_VERSION so that Docker
// Swarm detects a TaskTemplate change and restarts the container whenever the
-// pipeline configuration or API keys change.
-func ragConfigHash(config map[string]any) string {
+// configuration or API keys change. This is required for services whose config
+// lives in a bind-mounted file (config.yaml / pipelines): editing the file is
+// invisible to Swarm, so without a TaskTemplate change the container is never
+// restarted and the new config is never re-read.
+func serviceConfigHash(config map[string]any) string {
b, _ := json.Marshal(config)
sum := sha256.Sum256(b)
return fmt.Sprintf("%x", sum[:8])
@@ -182,6 +185,17 @@ func ServiceContainerSpec(opts *ServiceContainerSpecOptions) (swarm.ServiceSpec,
// Override the default container entrypoint to specify config path on bind mount.
command = []string{"/app/pgedge-postgres-mcp"}
args = []string{"-config", "/app/data/config.yaml"}
+ // Embed a hash of the service config so that Docker Swarm detects a
+ // TaskTemplate change and restarts the container when the config changes.
+ // The MCP config (config.yaml) lives on a bind mount, so edits to it are
+ // invisible to Swarm. SIGHUP reloads the database client connections but
+ // does NOT re-initialize the knowledgebase, so KB config changes (path,
+ // provider, model, key) only take effect on a restart. Without this, a
+ // changed kb_database_host_path silently keeps using the old KB file.
+ // Connection details (hosts, target_session_attrs) are intentionally not
+ // part of the config map, so failover-driven reconnects still use SIGHUP
+ // without forcing a restart.
+ env = []string{"PGEDGE_CONFIG_VERSION=" + serviceConfigHash(opts.ServiceSpec.Config)}
healthcheck = &container.HealthConfig{
Test: []string{"CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"},
StartPeriod: serviceHealthCheckStartPeriod,
@@ -202,7 +216,7 @@ func ServiceContainerSpec(opts *ServiceContainerSpecOptions) (swarm.ServiceSpec,
// Embed a hash of the service config so that Docker Swarm detects a
// TaskTemplate change and restarts the container when pipelines or API
// keys change. Without this, bind-mount updates are invisible to Swarm.
- env = []string{"PGEDGE_CONFIG_VERSION=" + ragConfigHash(opts.ServiceSpec.Config)}
+ env = []string{"PGEDGE_CONFIG_VERSION=" + serviceConfigHash(opts.ServiceSpec.Config)}
// No curl in the RHEL minimal image — use a TCP probe instead.
healthcheck = &container.HealthConfig{
Test: []string{"CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080"},
diff --git a/server/internal/orchestrator/swarm/service_spec_test.go b/server/internal/orchestrator/swarm/service_spec_test.go
index 4a66e1b0..e7957f93 100644
--- a/server/internal/orchestrator/swarm/service_spec_test.go
+++ b/server/internal/orchestrator/swarm/service_spec_test.go
@@ -106,9 +106,11 @@ func TestServiceContainerSpec(t *testing.T) {
if m.Target != "/app/data" {
t.Errorf("mount target = %v, want /app/data", m.Target)
}
- // No env vars for config (config is via file)
- if len(spec.Env) > 0 {
- t.Errorf("expected no env vars, got %d: %v", len(spec.Env), spec.Env)
+ // Config is delivered via a bind-mounted file, but a config-version
+ // env var is set so Swarm restarts the container when the config
+ // changes (the file edit alone is invisible to Swarm).
+ if len(spec.Env) != 1 || !strings.HasPrefix(spec.Env[0], "PGEDGE_CONFIG_VERSION=") {
+ t.Errorf("expected only PGEDGE_CONFIG_VERSION env, got %d: %v", len(spec.Env), spec.Env)
}
// Healthcheck should be set
if spec.Healthcheck == nil {
@@ -361,8 +363,8 @@ func TestRagConfigHash(t *testing.T) {
},
}
- h1 := ragConfigHash(cfg)
- h2 := ragConfigHash(cfg)
+ h1 := serviceConfigHash(cfg)
+ h2 := serviceConfigHash(cfg)
assert.Equal(t, h1, h2, "same config must produce the same hash")
assert.Len(t, h1, 16, "hash should be 16 hex chars (8 bytes)")
@@ -371,7 +373,7 @@ func TestRagConfigHash(t *testing.T) {
map[string]any{"name": "default", "api_key": "sk-xyz"},
},
}
- assert.NotEqual(t, h1, ragConfigHash(changed), "different config must produce a different hash")
+ assert.NotEqual(t, h1, serviceConfigHash(changed), "different config must produce a different hash")
}
func TestServiceContainerSpec_RAGHasConfigVersionEnv(t *testing.T) {
@@ -412,7 +414,7 @@ func TestServiceContainerSpec_RAGHasConfigVersionEnv(t *testing.T) {
}
}
require.NotEmpty(t, configVersion, "RAG container spec must have PGEDGE_CONFIG_VERSION env var")
- assert.Equal(t, ragConfigHash(config), configVersion)
+ assert.Equal(t, serviceConfigHash(config), configVersion)
// Changing the config must produce a different env var value.
opts.ServiceSpec.Config = map[string]any{
@@ -633,3 +635,38 @@ func TestServiceContainerSpec_MCP_KBMount(t *testing.T) {
t.Error("KB mount must be read-only")
}
}
+
+func TestServiceContainerSpec_MCPHasConfigVersionEnv(t *testing.T) {
+ // The MCP config lives in a bind-mounted config.yaml. Editing it is invisible
+ // to Swarm, and SIGHUP does not re-initialize the knowledgebase, so a KB config
+ // change must alter the TaskTemplate to force a restart. We embed a config hash
+ // in PGEDGE_CONFIG_VERSION exactly as RAG does.
+ opts := makeMCPSpecOpts()
+ opts.ServiceSpec.Config = map[string]any{
+ "kb_enabled": true,
+ "kb_embedding_provider": "openai",
+ "kb_embedding_model": "text-embedding-3-small",
+ "kb_database_host_path": "/var/lib/pgedge/kb/nla-kb.db",
+ }
+
+ configVersion := func(o *ServiceContainerSpecOptions) string {
+ spec, err := ServiceContainerSpec(o)
+ require.NoError(t, err)
+ for _, e := range spec.TaskTemplate.ContainerSpec.Env {
+ if strings.HasPrefix(e, "PGEDGE_CONFIG_VERSION=") {
+ return strings.TrimPrefix(e, "PGEDGE_CONFIG_VERSION=")
+ }
+ }
+ return ""
+ }
+
+ v1 := configVersion(opts)
+ require.NotEmpty(t, v1, "MCP container spec must have PGEDGE_CONFIG_VERSION env var")
+ assert.Equal(t, serviceConfigHash(opts.ServiceSpec.Config), v1)
+
+ // Renaming the KB file (changing kb_database_host_path) must change the env
+ // var so Swarm restarts the container and the new KB path takes effect.
+ opts.ServiceSpec.Config["kb_database_host_path"] = "/var/lib/pgedge/kb/nla-kb-openai.db"
+ v2 := configVersion(opts)
+ assert.NotEqual(t, v1, v2, "changing the KB path must change PGEDGE_CONFIG_VERSION so the container restarts")
+}
diff --git a/server/internal/postgres/info.go b/server/internal/postgres/info.go
index c27c1672..a0c2fb8c 100644
--- a/server/internal/postgres/info.go
+++ b/server/internal/postgres/info.go
@@ -2,7 +2,14 @@ package postgres
func GetPostgresVersion() Query[string] {
return Query[string]{
- SQL: "SHOW server_version;",
+ // We're computing our own string from the numeric server version here
+ // because the text version from 'SHOW server_version' contains build
+ // information on Debian.
+ SQL: `WITH version_num AS (
+ SELECT current_setting('server_version_num')::integer AS num
+ )
+ SELECT (num / 10000)::text || '.' || (num % 10000)::text
+ FROM version_num;`,
}
}
diff --git a/server/internal/resource/migrations/1_2_0.go b/server/internal/resource/migrations/1_2_0.go
index 5398c177..77cb04a7 100644
--- a/server/internal/resource/migrations/1_2_0.go
+++ b/server/internal/resource/migrations/1_2_0.go
@@ -38,6 +38,7 @@ func (v *Version_1_2_0) Run(databaseID string, state *resource.State) error {
v.migratePgBackRestStanza(databaseID, state),
v.migratePostgresCerts(state),
v.migratePatroniConfig(state),
+ v.migrateScheduledJob(state),
)
v.updateDependencies(state)
return err
@@ -49,26 +50,9 @@ func (v *Version_1_2_0) updateDependencies(state *resource.State) {
for _, byID := range state.Resources {
for _, data := range byID {
for i, dep := range data.Dependencies {
- var newType resource.Type
- switch dep.Type {
- case v1_1_0.ResourceTypeEtcdCreds:
- newType = v1_2_0.ResourceTypeEtcdCreds
- case v1_1_0.ResourceTypePatroniCluster:
- newType = v1_2_0.ResourceTypePatroniCluster
- case v1_1_0.ResourceTypePatroniMember:
- newType = v1_2_0.ResourceTypePatroniMember
- case v1_1_0.ResourceTypePgBackRestConfig:
- newType = v1_2_0.ResourceTypePgBackRestConfig
- case v1_1_0.ResourceTypePgBackRestStanza:
- newType = v1_2_0.ResourceTypePgBackRestStanza
- case v1_1_0.ResourceTypePostgresCerts:
- newType = v1_2_0.ResourceTypePostgresCerts
- default:
- continue
- }
data.Dependencies[i] = resource.Identifier{
ID: dep.ID,
- Type: newType,
+ Type: v.transformType(dep.Type),
}
}
}
@@ -293,6 +277,63 @@ func (v *Version_1_2_0) migratePgBackRestConfig(state *resource.State) error {
return nil
}
+func (v *Version_1_2_0) migrateScheduledJob(state *resource.State) error {
+ resources, ok := state.Resources[v1_1_0.ResourceTypeScheduledJob]
+ if !ok {
+ return nil
+ }
+ adds := make([]*resource.ResourceData, 0, len(resources))
+ for oldID, data := range resources {
+ var old v1_1_0.ScheduledJobResource
+ if err := json.Unmarshal(data.Attributes, &old); err != nil {
+ return fmt.Errorf("failed to unmarshal %s resource: %w", v1_1_0.ResourceTypeScheduledJob, err)
+ }
+ deps := make([]struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ }, len(old.DependsOn))
+ for i, dep := range old.DependsOn {
+ deps[i] = struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ }{
+ ID: dep.ID,
+ Type: v.transformTypeString(dep.Type),
+ }
+ }
+ new := v1_2_0.ScheduledJobResource{
+ ID: old.ID,
+ CronExpr: old.CronExpr,
+ Workflow: old.Workflow,
+ Args: old.Args,
+ DependsOn: deps,
+ }
+ attrs, err := json.Marshal(new)
+ if err != nil {
+ return fmt.Errorf("failed to marshal %s resource: %w", v1_2_0.ResourceTypeScheduledJob, err)
+ }
+ adds = append(adds, &resource.ResourceData{
+ Identifier: v1_2_0.ScheduledJobResourceIdentifier(old.ID),
+ Attributes: attrs,
+ Dependencies: data.Dependencies, // these will get updated by updateDependencies
+ Executor: data.Executor,
+ ResourceVersion: "1",
+ NeedsRecreate: data.NeedsRecreate,
+ DiffIgnore: data.DiffIgnore,
+ PendingDeletion: data.PendingDeletion,
+ Error: data.Error,
+ TypeDependencies: data.TypeDependencies,
+ })
+ state.RemoveByIdentifier(resource.Identifier{
+ Type: v1_1_0.ResourceTypeScheduledJob,
+ ID: oldID,
+ })
+ }
+ state.Add(adds...)
+
+ return nil
+}
+
func (v *Version_1_2_0) migratePgBackRestStanza(databaseID string, state *resource.State) error {
resources, ok := state.Resources[v1_1_0.ResourceTypePgBackRestStanza]
if !ok {
@@ -596,3 +637,28 @@ func (v *Version_1_2_0) swarmInstancePaths() database.InstancePaths {
PatroniPath: "/usr/local/bin/patroni",
}
}
+
+func (v *Version_1_2_0) transformType(old resource.Type) resource.Type {
+ var new resource.Type
+ switch old {
+ case v1_1_0.ResourceTypeEtcdCreds:
+ new = v1_2_0.ResourceTypeEtcdCreds
+ case v1_1_0.ResourceTypePatroniCluster:
+ new = v1_2_0.ResourceTypePatroniCluster
+ case v1_1_0.ResourceTypePatroniMember:
+ new = v1_2_0.ResourceTypePatroniMember
+ case v1_1_0.ResourceTypePgBackRestConfig:
+ new = v1_2_0.ResourceTypePgBackRestConfig
+ case v1_1_0.ResourceTypePgBackRestStanza:
+ new = v1_2_0.ResourceTypePgBackRestStanza
+ case v1_1_0.ResourceTypePostgresCerts:
+ new = v1_2_0.ResourceTypePostgresCerts
+ default:
+ new = old
+ }
+ return new
+}
+
+func (v *Version_1_2_0) transformTypeString(old string) string {
+ return string(v.transformType(resource.Type(old)))
+}
diff --git a/server/internal/resource/migrations/1_2_0_test.go b/server/internal/resource/migrations/1_2_0_test.go
index 239a699b..f4fea312 100644
--- a/server/internal/resource/migrations/1_2_0_test.go
+++ b/server/internal/resource/migrations/1_2_0_test.go
@@ -99,6 +99,23 @@ func TestVersion_1_2_0(t *testing.T) {
v1_1_0_network(t, databaseID),
},
},
+ {
+ name: "with scheduled backups",
+ in: []*resource.ResourceData{
+ v1_1_0_node(t, "n1", "instance-1"),
+ v1_1_0_instance(t, databaseID, "instance-1", "host-1", "n1"),
+ v1_1_0_etcdCreds(t, databaseID, "instance-1", "host-1", "n1"),
+ v1_1_0_patroniCluster(t, databaseID, "n1"),
+ v1_1_0_patroniMember(t, databaseID, "instance-1", "n1"),
+ v1_1_0_postgresCerts(t, "instance-1", "host-1"),
+ v1_1_0_patroniConfig(t, databaseID, "instance-1", "host-1", "n1", true, false, false),
+ v1_1_0_pgBackRestConfig(t, databaseID, "instance-1", "host-1", "n1", pgbackrest.ConfigTypeBackup),
+ v1_1_0_pgBackRestStanza(t, "n1"),
+ v1_1_0_configsDir(t, "host-1"),
+ v1_1_0_network(t, databaseID),
+ v1_1_0_scheduledJob(t, databaseID, "n1"),
+ },
+ },
} {
t.Run(tc.name, func(t *testing.T) {
state := &resource.State{
@@ -695,6 +712,35 @@ func v1_1_0_configsDir(t testing.TB, hostID string) *resource.ResourceData {
}
}
+func v1_1_0_scheduledJob(t testing.TB, databaseID, nodeName string) *resource.ResourceData {
+ stanzaID := v1_1_0.PgBackRestStanzaIdentifier(nodeName)
+ return &resource.ResourceData{
+ Executor: resource.AnyExecutor(),
+ Identifier: v1_1_0.ScheduledJobResourceIdentifier("backups"),
+ ResourceVersion: "1",
+ Attributes: mustJSON(t, v1_1_0.ScheduledJobResource{
+ ID: "backups",
+ CronExpr: "0 * * * *",
+ Workflow: "CreatePgBackRestBackup",
+ Args: map[string]any{
+ "database_id": databaseID,
+ "node_name": nodeName,
+ "type": pgbackrest.BackupTypeFull.String(),
+ },
+ DependsOn: []struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ }{
+ {
+ ID: stanzaID.ID,
+ Type: stanzaID.Type.String(),
+ },
+ },
+ }),
+ Dependencies: []resource.Identifier{stanzaID},
+ }
+}
+
func v1_1_0_configsDirID(hostID string) string {
return hostID + "-configs"
}
diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/with_scheduled_backups.json b/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/with_scheduled_backups.json
new file mode 100644
index 00000000..acec8932
--- /dev/null
+++ b/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/with_scheduled_backups.json
@@ -0,0 +1,494 @@
+{
+ "version": "1.2.0",
+ "resources": {
+ "common.etcd_creds": {
+ "instance-1": {
+ "needs_recreate": false,
+ "executor": {
+ "type": "host",
+ "id": "host-1"
+ },
+ "identifier": {
+ "id": "instance-1",
+ "type": "common.etcd_creds"
+ },
+ "attributes": {
+ "instance_id": "instance-1",
+ "database_id": "database-1",
+ "host_id": "host-1",
+ "node_name": "n1",
+ "parent_id": "host-1-configs",
+ "owner_uid": 123,
+ "owner_gid": 124,
+ "username": "username",
+ "password": "password",
+ "ca_cert": "Y2FfY2VydA==",
+ "client_cert": "Y2xpZW50X2NlcnQ=",
+ "client_key": "Y2xpZW50X2tleQ=="
+ },
+ "dependencies": [
+ {
+ "id": "host-1-configs",
+ "type": "filesystem.dir"
+ }
+ ],
+ "type_dependencies": null,
+ "diff_ignore": [
+ "/username",
+ "/password",
+ "/ca_cert",
+ "/client_cert",
+ "/client_key"
+ ],
+ "resource_version": "1",
+ "pending_deletion": false,
+ "error": ""
+ }
+ },
+ "common.patroni_cluster": {
+ "n1": {
+ "needs_recreate": false,
+ "executor": {
+ "type": "any",
+ "id": ""
+ },
+ "identifier": {
+ "id": "n1",
+ "type": "common.patroni_cluster"
+ },
+ "attributes": {
+ "database_id": "database-1",
+ "node_name": "n1"
+ },
+ "dependencies": null,
+ "type_dependencies": null,
+ "diff_ignore": null,
+ "resource_version": "1",
+ "pending_deletion": false,
+ "error": ""
+ }
+ },
+ "common.patroni_member": {
+ "instance-1": {
+ "needs_recreate": false,
+ "executor": {
+ "type": "any",
+ "id": ""
+ },
+ "identifier": {
+ "id": "instance-1",
+ "type": "common.patroni_member"
+ },
+ "attributes": {
+ "database_id": "database-1",
+ "node_name": "n1",
+ "instance_id": "instance-1"
+ },
+ "dependencies": [
+ {
+ "id": "n1",
+ "type": "common.patroni_cluster"
+ }
+ ],
+ "type_dependencies": null,
+ "diff_ignore": null,
+ "resource_version": "1",
+ "pending_deletion": false,
+ "error": ""
+ }
+ },
+ "common.pgbackrest_config": {
+ "instance-1-backup": {
+ "needs_recreate": false,
+ "executor": {
+ "type": "host",
+ "id": "host-1"
+ },
+ "identifier": {
+ "id": "instance-1-backup",
+ "type": "common.pgbackrest_config"
+ },
+ "attributes": {
+ "instance_id": "instance-1",
+ "host_id": "host-1",
+ "database_id": "database-1",
+ "node_name": "n1",
+ "repositories": [
+ {
+ "id": "default",
+ "type": "s3",
+ "s3_bucket": "bucket",
+ "s3_region": "us-east-1",
+ "s3_endpoint": "s3.us-east-1.amazonaws.com",
+ "retention_full": 0,
+ "retention_full_type": ""
+ }
+ ],
+ "parent_id": "host-1-configs",
+ "type": "backup",
+ "owner_uid": 123,
+ "owner_gid": 124,
+ "paths": {
+ "instance": {
+ "base_dir": "/opt/pgedge"
+ },
+ "host": {
+ "base_dir": ""
+ },
+ "pg_backrest_path": "/usr/bin/pgbackrest",
+ "patroni_path": "/usr/local/bin/patroni"
+ },
+ "port": 5432
+ },
+ "dependencies": [
+ {
+ "id": "host-1-configs",
+ "type": "filesystem.dir"
+ }
+ ],
+ "type_dependencies": null,
+ "diff_ignore": null,
+ "resource_version": "1",
+ "pending_deletion": false,
+ "error": ""
+ }
+ },
+ "common.pgbackrest_stanza": {
+ "n1": {
+ "needs_recreate": false,
+ "executor": {
+ "type": "primary",
+ "id": "n1"
+ },
+ "identifier": {
+ "id": "n1",
+ "type": "common.pgbackrest_stanza"
+ },
+ "attributes": {
+ "database_id": "database-1",
+ "node_name": "n1"
+ },
+ "dependencies": [
+ {
+ "id": "n1",
+ "type": "database.node"
+ }
+ ],
+ "type_dependencies": null,
+ "diff_ignore": null,
+ "resource_version": "1",
+ "pending_deletion": false,
+ "error": ""
+ }
+ },
+ "common.postgres_certs": {
+ "instance-1": {
+ "needs_recreate": false,
+ "executor": {
+ "type": "host",
+ "id": "host-1"
+ },
+ "identifier": {
+ "id": "instance-1",
+ "type": "common.postgres_certs"
+ },
+ "attributes": {
+ "instance_id": "instance-1",
+ "host_id": "host-1",
+ "instance_addresses": [
+ "127.0.0.1",
+ "localhost"
+ ],
+ "parent_id": "host-1-configs",
+ "owner_uid": 123,
+ "owner_gid": 124,
+ "ca_cert": "Y2FfY2VydA==",
+ "server_cert": "c2VydmVyX2NlcnQ=",
+ "server_key": "c2VydmVyX2tleQ==",
+ "superuser_cert": "c3VwZXJ1c2VyX2NlcnQ=",
+ "superuser_key": "c3VwZXJ1c2VyX2tleQ==",
+ "replication_cert": "cmVwbGljYXRpb25fY2VydA==",
+ "replication_key": "cmVwbGljYXRpb25fa2V5"
+ },
+ "dependencies": [
+ {
+ "id": "host-1-configs",
+ "type": "filesystem.dir"
+ }
+ ],
+ "type_dependencies": null,
+ "diff_ignore": [
+ "/ca_cert",
+ "/server_cert",
+ "/server_key",
+ "/superuser_cert",
+ "/superuser_key",
+ "/replication_cert",
+ "/replication_key"
+ ],
+ "resource_version": "1",
+ "pending_deletion": false,
+ "error": ""
+ }
+ },
+ "database.instance": {
+ "instance-1": {
+ "needs_recreate": false,
+ "executor": {
+ "type": "host",
+ "id": "host-1"
+ },
+ "identifier": {
+ "id": "instance-1",
+ "type": "database.instance"
+ },
+ "attributes": {
+ "spec": {
+ "database_id": "database-1",
+ "database_name": "test",
+ "database_users": [
+ {
+ "db_owner": true,
+ "username": "admin"
+ }
+ ],
+ "host_id": "host-1",
+ "instance_id": "instance-1",
+ "node_name": "n1"
+ }
+ },
+ "dependencies": null,
+ "type_dependencies": null,
+ "diff_ignore": [
+ "/primary_instance_id",
+ "/connection_info"
+ ],
+ "resource_version": "1",
+ "pending_deletion": false,
+ "error": ""
+ }
+ },
+ "database.node": {
+ "n1": {
+ "needs_recreate": false,
+ "executor": {
+ "type": "any",
+ "id": ""
+ },
+ "identifier": {
+ "id": "n1",
+ "type": "database.node"
+ },
+ "attributes": {
+ "name": "n1",
+ "instance_ids": [
+ "instance-1"
+ ],
+ "primary_instance_id": "instance-1"
+ },
+ "dependencies": [
+ {
+ "id": "instance-1",
+ "type": "database.instance"
+ }
+ ],
+ "type_dependencies": null,
+ "diff_ignore": null,
+ "resource_version": "1",
+ "pending_deletion": false,
+ "error": ""
+ }
+ },
+ "filesystem.dir": {
+ "host-1-configs": {
+ "needs_recreate": false,
+ "executor": {
+ "type": "host",
+ "id": "host-1"
+ },
+ "identifier": {
+ "id": "host-1-configs",
+ "type": "filesystem.dir"
+ },
+ "attributes": {
+ "id": "host-1-configs",
+ "parent_id": "",
+ "host_id": "",
+ "path": "/configs",
+ "owner_uid": 0,
+ "owner_gid": 0,
+ "perm": 0,
+ "full_path": ""
+ },
+ "dependencies": null,
+ "type_dependencies": null,
+ "diff_ignore": null,
+ "resource_version": "1",
+ "pending_deletion": false,
+ "error": ""
+ }
+ },
+ "scheduler.job": {
+ "backups": {
+ "needs_recreate": false,
+ "executor": {
+ "type": "any",
+ "id": ""
+ },
+ "identifier": {
+ "id": "backups",
+ "type": "scheduler.job"
+ },
+ "attributes": {
+ "id": "backups",
+ "cron_expr": "0 * * * *",
+ "workflow": "CreatePgBackRestBackup",
+ "args": {
+ "database_id": "database-1",
+ "node_name": "n1",
+ "type": "full"
+ },
+ "depends_on": [
+ {
+ "id": "n1",
+ "type": "common.pgbackrest_stanza"
+ }
+ ]
+ },
+ "dependencies": [
+ {
+ "id": "n1",
+ "type": "common.pgbackrest_stanza"
+ }
+ ],
+ "type_dependencies": null,
+ "diff_ignore": null,
+ "resource_version": "1",
+ "pending_deletion": false,
+ "error": ""
+ }
+ },
+ "swarm.network": {
+ "database-1": {
+ "needs_recreate": false,
+ "executor": {
+ "type": "manager",
+ "id": ""
+ },
+ "identifier": {
+ "id": "database-1",
+ "type": "swarm.network"
+ },
+ "attributes": {
+ "scope": "swarm",
+ "driver": "overlay",
+ "allocator": {
+ "prefix": "",
+ "bits": 0
+ },
+ "name": "database-1",
+ "network_id": "",
+ "subnet": "",
+ "gateway": ""
+ },
+ "dependencies": null,
+ "type_dependencies": null,
+ "diff_ignore": null,
+ "resource_version": "1",
+ "pending_deletion": false,
+ "error": ""
+ }
+ },
+ "swarm.patroni_config": {
+ "instance-1": {
+ "needs_recreate": false,
+ "executor": {
+ "type": "host",
+ "id": "host-1"
+ },
+ "identifier": {
+ "id": "instance-1",
+ "type": "swarm.patroni_config"
+ },
+ "attributes": {
+ "database_id": "database-1",
+ "base": {
+ "instance_id": "instance-1",
+ "host_id": "host-1",
+ "node_name": "n1",
+ "generator": {
+ "archive_command": "/usr/bin/pgbackrest --config /opt/pgedge/configs/pgbackrest.backup.conf --stanza db archive-push \"%p\"",
+ "cluster_size": 3,
+ "cpus": 4,
+ "database_id": "database-1",
+ "data_dir": "/opt/pgedge/data/pgdata",
+ "etcd_certs_dir": "/opt/pgedge/certificates/etcd",
+ "fqdn": "database-1-n1",
+ "instance_id": "instance-1",
+ "log_type": "json",
+ "memory_bytes": 1073741824,
+ "node_name": "n1",
+ "node_ordinal": 1,
+ "node_size": 1,
+ "patroni_allowlist": null,
+ "patroni_port": 8888,
+ "pg_hba_conf": [
+ "hostssl all myapp_user 203.0.113.0/24 scram-sha-256"
+ ],
+ "pg_ident_conf": [
+ "ssl_users CN=alice,O=example alice"
+ ],
+ "postgres_certs_dir": "/opt/pgedge/certificates/postgres",
+ "postgres_port": 5432,
+ "restore_command": "",
+ "spec_parameters": {
+ "max_connections": 1000
+ }
+ },
+ "parent_id": "host-1-configs",
+ "owner_uid": 123,
+ "owner_gid": 124
+ },
+ "host_network_info": {
+ "name": "bridge",
+ "id": "bridge_id",
+ "subnet": "172.16.0.0/12",
+ "gateway": "172.16.0.1"
+ },
+ "database_network_name": "database-1"
+ },
+ "dependencies": [
+ {
+ "id": "host-1-configs",
+ "type": "filesystem.dir"
+ },
+ {
+ "id": "instance-1",
+ "type": "common.etcd_creds"
+ },
+ {
+ "id": "instance-1",
+ "type": "common.patroni_member"
+ },
+ {
+ "id": "n1",
+ "type": "common.patroni_cluster"
+ },
+ {
+ "id": "database-1",
+ "type": "swarm.network"
+ },
+ {
+ "id": "instance-1-backup",
+ "type": "common.pgbackrest_config"
+ }
+ ],
+ "type_dependencies": null,
+ "diff_ignore": null,
+ "resource_version": "1",
+ "pending_deletion": false,
+ "error": ""
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/server/internal/resource/migrations/schemas/v1_1_0/scheduler.go b/server/internal/resource/migrations/schemas/v1_1_0/scheduler.go
new file mode 100644
index 00000000..efd4ea81
--- /dev/null
+++ b/server/internal/resource/migrations/schemas/v1_1_0/scheduler.go
@@ -0,0 +1,26 @@
+// produced by schematool 7c318111f3fd9ddfce9b7fdf0bd32865e23d7cca server/internal/scheduler ScheduledJobResource
+package v1_1_0
+
+import (
+ "github.com/pgEdge/control-plane/server/internal/resource"
+)
+
+const ResourceTypeScheduledJob resource.Type = "scheduler.job"
+
+func ScheduledJobResourceIdentifier(id string) resource.Identifier {
+ return resource.Identifier{
+ ID: id,
+ Type: ResourceTypeScheduledJob,
+ }
+}
+
+type ScheduledJobResource struct {
+ ID string `json:"id"`
+ CronExpr string `json:"cron_expr"`
+ Workflow string `json:"workflow"`
+ Args map[string]any `json:"args"`
+ DependsOn []struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ } `json:"depends_on,omitempty"`
+}
diff --git a/server/internal/resource/migrations/schemas/v1_2_0/scheduler.go b/server/internal/resource/migrations/schemas/v1_2_0/scheduler.go
new file mode 100644
index 00000000..a4269dc1
--- /dev/null
+++ b/server/internal/resource/migrations/schemas/v1_2_0/scheduler.go
@@ -0,0 +1,26 @@
+// produced by schematool 4d916a246d1ef9e10e2b697e1aaf3b2bc1c2ea21 server/internal/scheduler ScheduledJobResource
+package v1_2_0
+
+import (
+ "github.com/pgEdge/control-plane/server/internal/resource"
+)
+
+const ResourceTypeScheduledJob resource.Type = "scheduler.job"
+
+func ScheduledJobResourceIdentifier(id string) resource.Identifier {
+ return resource.Identifier{
+ ID: id,
+ Type: ResourceTypeScheduledJob,
+ }
+}
+
+type ScheduledJobResource struct {
+ ID string `json:"id"`
+ CronExpr string `json:"cron_expr"`
+ Workflow string `json:"workflow"`
+ Args map[string]any `json:"args"`
+ DependsOn []struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ } `json:"depends_on,omitempty"`
+}