diff --git a/changes/unreleased/Fixed-20260611-090000.yaml b/changes/unreleased/Fixed-20260611-090000.yaml index 3ba99541..e823c70e 100644 --- a/changes/unreleased/Fixed-20260611-090000.yaml +++ b/changes/unreleased/Fixed-20260611-090000.yaml @@ -1,3 +1,3 @@ kind: Fixed -body: Fixed global Patroni parameters not being applied to running systemd-managed 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. +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/e2e/database_test.go b/e2e/database_test.go index f60a8e8b..bce282e2 100644 --- a/e2e/database_test.go +++ b/e2e/database_test.go @@ -262,6 +262,18 @@ func (d *DatabaseFixture) WithConnection(ctx context.Context, opts ConnectionOpt } } +func (d *DatabaseFixture) HeartBeat(t testing.TB, username, password string, nodeNames ...string) { + if len(nodeNames) == 0 { + for _, node := range d.Spec.Nodes { + RunHeartBeat(t, d, username, password, node.Name) + } + } else { + for _, nodeName := range nodeNames { + RunHeartBeat(t, d, username, password, nodeName) + } + } +} + type InstanceMatcher func(inst *controlplane.Instance) bool func WithID(instanceID string) InstanceMatcher { diff --git a/e2e/db_update_add_node_test.go b/e2e/db_update_add_node_test.go index 07d55652..8ecdb36f 100644 --- a/e2e/db_update_add_node_test.go +++ b/e2e/db_update_add_node_test.go @@ -9,9 +9,10 @@ import ( "time" "github.com/jackc/pgx/v5" - controlplane "github.com/pgEdge/control-plane/api/apiv1/gen/control_plane" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + controlplane "github.com/pgEdge/control-plane/api/apiv1/gen/control_plane" ) func TestUpdateAddNode23(t *testing.T) { @@ -28,7 +29,7 @@ func testUpdateAddNode(t *testing.T, nodeCount int, deployReplicas bool) { username := "admin" password := "password" - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute) defer cancel() logDeploymentConfig(t, nodeCount, deployReplicas) @@ -86,7 +87,7 @@ func buildNodeSpecs(nodeCount int) []*controlplane.DatabaseNodeSpec { func createDatabaseFixture(ctx context.Context, t *testing.T, username, password string, nodes []*controlplane.DatabaseNodeSpec) *DatabaseFixture { t.Helper() - return fixture.NewDatabaseFixture(ctx, t, &controlplane.CreateDatabaseRequest{ + db := fixture.NewDatabaseFixture(ctx, t, &controlplane.CreateDatabaseRequest{ Spec: &controlplane.DatabaseSpec{ DatabaseName: "test_db_create", DatabaseUsers: []*controlplane.DatabaseUserSpec{{ @@ -100,6 +101,11 @@ func createDatabaseFixture(ctx context.Context, t *testing.T, username, password Nodes: nodes, }, }) + // The peer sync and replication slot advance steps can take an _extremely_ + // long time in an idle database. This heartbeat provides consistent + // activity and makes the test run faster. + db.HeartBeat(t, username, password, nodes[0].Name) + return db } func verifyPrimaryNode(ctx context.Context, t *testing.T, db *DatabaseFixture, node *controlplane.DatabaseNodeSpec, username, password string, deployReplicas bool) { diff --git a/e2e/heartbeat_test.go b/e2e/heartbeat_test.go new file mode 100644 index 00000000..db46ae2c --- /dev/null +++ b/e2e/heartbeat_test.go @@ -0,0 +1,113 @@ +//go:build e2e_test + +package e2e + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +// HeartBeat is a helper to create a low, consistent volume of activity on a +// node to avoid replication stalls and waits. +type HeartBeat struct { + DB *DatabaseFixture + NodeName string + Username string + Password string + wg sync.WaitGroup + cancel context.CancelFunc + done chan struct{} +} + +// RunHeartBeat starts a heartbeat for the given node. The heartbeat is +// automatically stopped during test cleanup. +func RunHeartBeat(t testing.TB, db *DatabaseFixture, username, password, nodeName string) { + t.Helper() + + heartbeat := &HeartBeat{ + DB: db, + NodeName: nodeName, + Username: username, + Password: password, + } + heartbeat.Start(t) + t.Cleanup(func() { + heartbeat.Stop(t) + }) +} + +func (l *HeartBeat) Start(t testing.TB) { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + l.cancel = cancel + l.done = make(chan struct{}) + l.wg.Go(func() { + defer l.cancel() + + l.DB.WithConnection(ctx, l.connOpts(), t, func(conn *pgx.Conn) { + // Create table if not exists + l.createTable(ctx, t, conn) + + // Persist heartbeat until Stop() is called + l.heartbeat(ctx, t, conn) + }) + }) +} + +func (l *HeartBeat) Stop(t testing.TB) { + tLogf(t, "HeartBeat: stopping heartbeat on primary instance of node %s", l.NodeName) + + close(l.done) + l.wg.Wait() +} + +func (l *HeartBeat) connOpts() ConnectionOptions { + return ConnectionOptions{ + Matcher: And( + WithNode(l.NodeName), + WithRole("primary"), + ), + Username: l.Username, + Password: l.Password, + } +} + +func (l *HeartBeat) createTable(ctx context.Context, t testing.TB, conn *pgx.Conn) { + sql := `CREATE TABLE IF NOT EXISTS heartbeat ( + node_name TEXT PRIMARY KEY, + updated_at TIMESTAMPTZ + );` + + _, err := conn.Exec(ctx, sql) + require.NoError(t, err) +} + +func (l *HeartBeat) heartbeat(ctx context.Context, t testing.TB, conn *pgx.Conn) { + tLogf(t, "HeartBeat: starting heartbeat on primary instance of node %s", l.NodeName) + + sql := `INSERT INTO heartbeat (node_name, updated_at) + VALUES (@node_name, now()) + ON CONFLICT (node_name) + DO UPDATE SET updated_at = EXCLUDED.updated_at;` + + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-l.done: + return + case <-ticker.C: + _, err := conn.Exec(ctx, sql, pgx.NamedArgs{"node_name": l.NodeName}) + require.NoError(t, err) + } + } +} diff --git a/server/internal/database/instance_resource.go b/server/internal/database/instance_resource.go index d624f8b1..32ba8256 100644 --- a/server/internal/database/instance_resource.go +++ b/server/internal/database/instance_resource.go @@ -16,7 +16,6 @@ import ( "github.com/pgEdge/control-plane/server/internal/patroni" "github.com/pgEdge/control-plane/server/internal/postgres" "github.com/pgEdge/control-plane/server/internal/resource" - "github.com/pgEdge/control-plane/server/internal/utils" ) var _ resource.Resource = (*InstanceResource)(nil) @@ -213,15 +212,6 @@ func (r *InstanceResource) initializeInstance(ctx context.Context, rc *resource. return nil } - // Enable failsafe mode if this instance is the only one in the node. - // Otherwise, disable it. - _, err := r.patroniClient().PatchDynamicConfig(ctx, &patroni.DynamicConfig{ - FailsafeMode: utils.PointerTo(r.Spec.NodeSize == 1), - }) - if err != nil { - return fmt.Errorf("failed to configure failsafe mode: %w", err) - } - conn, err := r.Connection(ctx, rc, "postgres") if err != nil { return err diff --git a/server/internal/docker/docker.go b/server/internal/docker/docker.go index 00882ef0..7ce35c9a 100644 --- a/server/internal/docker/docker.go +++ b/server/internal/docker/docker.go @@ -601,10 +601,10 @@ func (d *Docker) ensureDockerImage(ctx context.Context, img string) error { } type NetworkInfo struct { - Name string - ID string - Subnet netip.Prefix - Gateway netip.Addr + Name string `json:"name"` + ID string `json:"id"` + Subnet netip.Prefix `json:"subnet"` + Gateway netip.Addr `json:"gateway"` } func ExtractNetworkInfo(info network.Inspect) (*NetworkInfo, error) { diff --git a/server/internal/orchestrator/common/patroni_config.go b/server/internal/orchestrator/common/patroni_config.go index 9af2451b..e78184d7 100644 --- a/server/internal/orchestrator/common/patroni_config.go +++ b/server/internal/orchestrator/common/patroni_config.go @@ -16,6 +16,7 @@ import ( "github.com/pgEdge/control-plane/server/internal/database" "github.com/pgEdge/control-plane/server/internal/filesystem" "github.com/pgEdge/control-plane/server/internal/patroni" + "github.com/pgEdge/control-plane/server/internal/pgbackrest" "github.com/pgEdge/control-plane/server/internal/postgres/hba" "github.com/pgEdge/control-plane/server/internal/resource" ) @@ -37,6 +38,12 @@ func (c *PatroniConfig) Dependencies() []resource.Identifier { PatroniMemberResourceIdentifier(c.InstanceID), PatroniClusterResourceIdentifier(c.NodeName), } + if c.Generator.ArchiveCommand != "" { + deps = append(deps, PgBackRestConfigIdentifier(c.InstanceID, pgbackrest.ConfigTypeBackup)) + } + if c.Generator.RestoreCommand != "" { + deps = append(deps, PgBackRestConfigIdentifier(c.InstanceID, pgbackrest.ConfigTypeRestore)) + } return deps } diff --git a/server/internal/orchestrator/common/patroni_config_generator.go b/server/internal/orchestrator/common/patroni_config_generator.go index 3f7dd182..abc03b13 100644 --- a/server/internal/orchestrator/common/patroni_config_generator.go +++ b/server/internal/orchestrator/common/patroni_config_generator.go @@ -195,6 +195,16 @@ func (p *PatroniConfigGenerator) Generate( } } +func (p *PatroniConfigGenerator) AuthMethod() hba.AuthMethod { + encryption, ok := utils.TypedFromMap[string](p.parameters(), "password_encryption") + if !ok { + // ScramSHA256 is the default password encryption for all of our + // supported Postgres versions. + return hba.AuthMethodScramSHA256 + } + return hba.AuthMethod(encryption) +} + func (p *PatroniConfigGenerator) parameters() map[string]any { parameters := postgres.DefaultGUCs() maps.Copy(parameters, postgres.SpockDefaultGUCs()) @@ -319,12 +329,8 @@ func (p *PatroniConfigGenerator) postgreSQL( } // The catch-all authenticates non-system users by password, so its auth - // method follows password_encryption to match how passwords are stored. It - // defaults to md5 (our DefaultGUCs value) when unset. - passwordAuthMethod := hba.AuthMethodMD5 - if pe, ok := parameters["password_encryption"].(string); ok && pe != "" { - passwordAuthMethod = hba.AuthMethod(pe) - } + // method follows password_encryption to match how passwords are stored. + passwordAuthMethod := p.AuthMethod() return &patroni.PostgreSQL{ ConnectAddress: utils.PointerTo(net.JoinHostPort(p.FQDN, strconv.Itoa(p.PostgresPort))), diff --git a/server/internal/orchestrator/swarm/etcd_creds.go b/server/internal/orchestrator/swarm/etcd_creds.go deleted file mode 100644 index c88f0a33..00000000 --- a/server/internal/orchestrator/swarm/etcd_creds.go +++ /dev/null @@ -1,216 +0,0 @@ -package swarm - -import ( - "context" - "errors" - "fmt" - "path/filepath" - - "github.com/samber/do" - "github.com/spf13/afero" - clientv3 "go.etcd.io/etcd/client/v3" - - "github.com/pgEdge/control-plane/server/internal/certificates" - "github.com/pgEdge/control-plane/server/internal/etcd" - "github.com/pgEdge/control-plane/server/internal/filesystem" - "github.com/pgEdge/control-plane/server/internal/patroni" - "github.com/pgEdge/control-plane/server/internal/resource" -) - -var _ resource.Resource = (*EtcdCreds)(nil) - -const ResourceTypeEtcdCreds resource.Type = "swarm.etcd_creds" - -func EtcdCredsIdentifier(instanceID string) resource.Identifier { - return resource.Identifier{ - ID: instanceID, - Type: ResourceTypeEtcdCreds, - } -} - -type EtcdCreds struct { - InstanceID string `json:"instance_id"` - DatabaseID string `json:"database_id"` - HostID string `json:"host_id"` - NodeName string `json:"node_name"` - ParentID string `json:"parent_id"` - OwnerUID int `json:"owner_uid"` - OwnerGID int `json:"owner_gid"` - Username string `json:"username"` - Password string `json:"password"` - CaCert []byte `json:"ca_cert"` - ClientCert []byte `json:"server_cert"` - ClientKey []byte `json:"server_key"` -} - -func (c *EtcdCreds) ResourceVersion() string { - return "1" -} - -func (c *EtcdCreds) DiffIgnore() []string { - return []string{ - "/username", - "/password", - "/ca_cert", - "/server_cert", - "/server_key", - } -} - -func (c *EtcdCreds) Executor() resource.Executor { - return resource.HostExecutor(c.HostID) -} - -func (c *EtcdCreds) Identifier() resource.Identifier { - return EtcdCredsIdentifier(c.InstanceID) -} - -func (c *EtcdCreds) Dependencies() []resource.Identifier { - return []resource.Identifier{ - filesystem.DirResourceIdentifier(c.ParentID), - } -} - -func (c *EtcdCreds) TypeDependencies() []resource.Type { - return nil -} - -func (c *EtcdCreds) Refresh(ctx context.Context, rc *resource.Context) error { - fs, err := do.Invoke[afero.Fs](rc.Injector) - if err != nil { - return err - } - - parentFullPath, err := filesystem.DirResourceFullPath(rc, c.ParentID) - if err != nil { - return fmt.Errorf("failed to get parent full path: %w", err) - } - etcdDir := filepath.Join(parentFullPath, "etcd") - - caCert, err := readResourceFile(fs, filepath.Join(etcdDir, "ca.crt")) - if err != nil { - return fmt.Errorf("failed to read CA cert: %w", err) - } - clientCert, err := readResourceFile(fs, filepath.Join(etcdDir, "client.crt")) - if err != nil { - return fmt.Errorf("failed to read client cert: %w", err) - } - clientKey, err := readResourceFile(fs, filepath.Join(etcdDir, "client.key")) - if err != nil { - return fmt.Errorf("failed to read client key: %w", err) - } - - c.CaCert = caCert - c.ClientCert = clientCert - c.ClientKey = clientKey - - return nil -} - -func (c *EtcdCreds) Create(ctx context.Context, rc *resource.Context) error { - fs, err := do.Invoke[afero.Fs](rc.Injector) - if err != nil { - return err - } - certService, err := do.Invoke[*certificates.Service](rc.Injector) - if err != nil { - return err - } - etcdClient, err := do.Invoke[*clientv3.Client](rc.Injector) - if err != nil { - return err - } - - parentFullPath, err := filesystem.DirResourceFullPath(rc, c.ParentID) - if err != nil { - return fmt.Errorf("failed to get parent full path: %w", err) - } - etcdDir := filepath.Join(parentFullPath, "etcd") - - etcdCreds, err := etcd.CreateInstanceEtcdUser(ctx, - etcdClient, - certService, - etcd.InstanceUserOptions{ - InstanceID: c.InstanceID, - KeyPrefix: patroni.ClusterPrefix(c.DatabaseID, c.NodeName), - Password: c.Password, - }, - ) - if err != nil { - return fmt.Errorf("failed to create etcd user: %w", err) - } - - c.Username = etcdCreds.Username - c.Password = etcdCreds.Password - c.CaCert = etcdCreds.CaCert - c.ClientCert = etcdCreds.ClientCert - c.ClientKey = etcdCreds.ClientKey - - if err := fs.MkdirAll(etcdDir, 0o700); err != nil { - return fmt.Errorf("failed to create etcd certificates directory: %w", err) - } - if err := fs.Chown(etcdDir, c.OwnerUID, c.OwnerGID); err != nil { - return fmt.Errorf("failed to change ownership for certificates directory: %w", err) - } - - files := map[string][]byte{ - "ca.crt": c.CaCert, - "client.crt": c.ClientCert, - "client.key": c.ClientKey, - } - - for name, content := range files { - if err := afero.WriteFile(fs, filepath.Join(etcdDir, name), content, 0o600); err != nil { - return fmt.Errorf("failed to write %s: %w", name, err) - } - if err := fs.Chown(filepath.Join(etcdDir, name), c.OwnerUID, c.OwnerGID); err != nil { - return fmt.Errorf("failed to change ownership for %s: %w", name, err) - } - } - - return nil -} - -func (c *EtcdCreds) Update(ctx context.Context, rc *resource.Context) error { - return c.Create(ctx, rc) -} - -func (c *EtcdCreds) Delete(ctx context.Context, rc *resource.Context) error { - fs, err := do.Invoke[afero.Fs](rc.Injector) - if err != nil { - return err - } - certService, err := do.Invoke[*certificates.Service](rc.Injector) - if err != nil { - return err - } - etcdClient, err := do.Invoke[*clientv3.Client](rc.Injector) - if err != nil { - return err - } - - parentFullPath, err := filesystem.DirResourceFullPath(rc, c.ParentID) - if err != nil { - return fmt.Errorf("failed to get parent full path: %w", err) - } - etcdDir := filepath.Join(parentFullPath, "etcd") - - if err := fs.RemoveAll(etcdDir); err != nil { - return fmt.Errorf("failed to remove certificates directory: %w", err) - } - if err := etcd.RemoveInstanceEtcdUser(ctx, etcdClient, certService, c.InstanceID); err != nil { - return fmt.Errorf("failed to delete etcd user: %w", err) - } - - return nil -} - -func readResourceFile(fs afero.Fs, path string) ([]byte, error) { - contents, err := afero.ReadFile(fs, path) - if errors.Is(err, afero.ErrFileNotFound) { - return nil, resource.ErrNotFound - } else if err != nil { - return nil, err - } - return contents, nil -} diff --git a/server/internal/orchestrator/swarm/golden_test/TestGeneratePatroniConfig/no_user_entries_defaults_to_md5.yaml b/server/internal/orchestrator/swarm/golden_test/TestGeneratePatroniConfig/no_user_entries_defaults_to_md5.yaml deleted file mode 100644 index 24e45230..00000000 --- a/server/internal/orchestrator/swarm/golden_test/TestGeneratePatroniConfig/no_user_entries_defaults_to_md5.yaml +++ /dev/null @@ -1,122 +0,0 @@ -name: plain-n1-689qacsi -namespace: /patroni/ -scope: plain:n1 -log: - type: json - level: INFO - static_fields: - database_id: plain - instance_id: plain-n1-689qacsi - node_name: n1 -bootstrap: - dcs: - loop_wait: 10 - ttl: 30 - retry_timeout: 10 - postgresql: - parameters: - max_connections: 901 - max_replication_slots: 16 - max_wal_senders: 16 - max_worker_processes: 12 - track_commit_timestamp: "on" - wal_level: logical - ignore_slots: - - plugin: spock_output - initdb: - - data-checksums -etcd3: - hosts: - - 10.0.0.1:2379 - protocol: https - username: instance.pghba-n1-689qacsi - password: password - cacert: /opt/pgedge/certificates/etcd/ca.crt - cert: /opt/pgedge/certificates/etcd/client.crt - key: /opt/pgedge/certificates/etcd/client.key -postgresql: - authentication: - superuser: - username: pgedge - sslmode: verify-full - sslkey: /opt/pgedge/certificates/postgres/superuser.key - sslcert: /opt/pgedge/certificates/postgres/superuser.crt - sslrootcert: /opt/pgedge/certificates/postgres/ca.crt - replication: - username: patroni_replicator - sslmode: verify-full - sslkey: /opt/pgedge/certificates/postgres/replication.key - sslcert: /opt/pgedge/certificates/postgres/replication.crt - sslrootcert: /opt/pgedge/certificates/postgres/ca.crt - callbacks: {} - connect_address: postgres-plain-n1-689qacsi:5432 - data_dir: /opt/pgedge/data/pgdata - listen: "*:5432" - parameters: - archive_command: /bin/true - archive_mode: "on" - autovacuum_max_workers: 3 - autovacuum_vacuum_cost_limit: 200 - autovacuum_work_mem: 262144 - checkpoint_completion_target: "0.9" - checkpoint_timeout: 15min - dynamic_shared_memory_type: posix - effective_cache_size: 524288 - hot_standby_feedback: "on" - log_destination: stderr - log_directory: log - log_filename: postgresql-%a.log - log_line_prefix: "%m [%p] " - log_rotation_age: 1d - log_rotation_size: "0" - log_truncate_on_rotation: "on" - logging_collector: "on" - lolor.node: 1 - maintenance_work_mem: 137518 - max_parallel_workers: 8 - password_encryption: md5 - shared_buffers: 262144 - shared_preload_libraries: pg_stat_statements,snowflake,spock,postgis-3 - snowflake.node: 1 - spock.allow_ddl_from_functions: "on" - spock.conflict_log_level: DEBUG - spock.conflict_resolution: last_update_wins - spock.enable_ddl_replication: "on" - spock.include_ddl_repset: "on" - spock.save_resolutions: "on" - ssl: "on" - ssl_ca_file: /opt/pgedge/certificates/postgres/ca.crt - ssl_cert_file: /opt/pgedge/certificates/postgres/server.crt - ssl_key_file: /opt/pgedge/certificates/postgres/server.key - track_io_timing: "on" - wal_log_hints: "on" - wal_sender_timeout: 5s - pg_hba: - - local all all trust - - host all all 127.0.0.1/32 trust - - host all all ::1/128 trust - - local replication all trust - - host replication all 127.0.0.1/32 trust - - host replication all ::1/128 trust - - hostssl all pgedge,patroni_replicator 172.17.0.1/32 cert clientcert=verify-full - - hostssl all pgedge,patroni_replicator 10.128.170.0/26 cert clientcert=verify-full - - hostssl replication pgedge,patroni_replicator 172.17.0.1/32 cert clientcert=verify-full - - hostssl replication pgedge,patroni_replicator 10.128.170.0/26 cert clientcert=verify-full - - host all pgedge,patroni_replicator 0.0.0.0/0 reject - - host all pgedge,patroni_replicator ::/0 reject - - host all all 172.17.0.1/32 md5 - - host all all 172.17.0.0/16 reject - - host all all 0.0.0.0/0 md5 - use_pg_rewind: true - remove_data_directory_on_rewind_failure: true - remove_data_directory_on_diverged_timelines: true -restapi: - connect_address: postgres-plain-n1-689qacsi.plain-database:8888 - listen: 0.0.0.0:8888 - allowlist: - - 172.17.0.1 - - 10.128.170.0/26 - - 127.0.0.1 - - localhost -watchdog: - mode: "off" diff --git a/server/internal/orchestrator/swarm/golden_test/TestGeneratePatroniConfig/user_pg_hba_pg_ident_and_scram.yaml b/server/internal/orchestrator/swarm/golden_test/TestGeneratePatroniConfig/user_pg_hba_pg_ident_and_scram.yaml deleted file mode 100644 index 056907c0..00000000 --- a/server/internal/orchestrator/swarm/golden_test/TestGeneratePatroniConfig/user_pg_hba_pg_ident_and_scram.yaml +++ /dev/null @@ -1,126 +0,0 @@ -name: pghba-n1-689qacsi -namespace: /patroni/ -scope: pghba:n1 -log: - type: json - level: INFO - static_fields: - database_id: pghba - instance_id: pghba-n1-689qacsi - node_name: n1 -bootstrap: - dcs: - loop_wait: 10 - ttl: 30 - retry_timeout: 10 - postgresql: - parameters: - max_connections: 901 - max_replication_slots: 16 - max_wal_senders: 16 - max_worker_processes: 12 - track_commit_timestamp: "on" - wal_level: logical - ignore_slots: - - plugin: spock_output - initdb: - - data-checksums -etcd3: - hosts: - - 10.0.0.1:2379 - protocol: https - username: instance.pghba-n1-689qacsi - password: password - cacert: /opt/pgedge/certificates/etcd/ca.crt - cert: /opt/pgedge/certificates/etcd/client.crt - key: /opt/pgedge/certificates/etcd/client.key -postgresql: - authentication: - superuser: - username: pgedge - sslmode: verify-full - sslkey: /opt/pgedge/certificates/postgres/superuser.key - sslcert: /opt/pgedge/certificates/postgres/superuser.crt - sslrootcert: /opt/pgedge/certificates/postgres/ca.crt - replication: - username: patroni_replicator - sslmode: verify-full - sslkey: /opt/pgedge/certificates/postgres/replication.key - sslcert: /opt/pgedge/certificates/postgres/replication.crt - sslrootcert: /opt/pgedge/certificates/postgres/ca.crt - callbacks: {} - connect_address: postgres-pghba-n1-689qacsi:5432 - data_dir: /opt/pgedge/data/pgdata - listen: "*:5432" - parameters: - archive_command: /bin/true - archive_mode: "on" - autovacuum_max_workers: 3 - autovacuum_vacuum_cost_limit: 200 - autovacuum_work_mem: 262144 - checkpoint_completion_target: "0.9" - checkpoint_timeout: 15min - dynamic_shared_memory_type: posix - effective_cache_size: 524288 - hot_standby_feedback: "on" - log_destination: stderr - log_directory: log - log_filename: postgresql-%a.log - log_line_prefix: "%m [%p] " - log_rotation_age: 1d - log_rotation_size: "0" - log_truncate_on_rotation: "on" - logging_collector: "on" - lolor.node: 1 - maintenance_work_mem: 137518 - max_parallel_workers: 8 - password_encryption: scram-sha-256 - shared_buffers: 262144 - shared_preload_libraries: pg_stat_statements,snowflake,spock,postgis-3 - snowflake.node: 1 - spock.allow_ddl_from_functions: "on" - spock.conflict_log_level: DEBUG - spock.conflict_resolution: last_update_wins - spock.enable_ddl_replication: "on" - spock.include_ddl_repset: "on" - spock.save_resolutions: "on" - ssl: "on" - ssl_ca_file: /opt/pgedge/certificates/postgres/ca.crt - ssl_cert_file: /opt/pgedge/certificates/postgres/server.crt - ssl_key_file: /opt/pgedge/certificates/postgres/server.key - track_io_timing: "on" - wal_log_hints: "on" - wal_sender_timeout: 5s - pg_hba: - - local all all trust - - host all all 127.0.0.1/32 trust - - host all all ::1/128 trust - - local replication all trust - - host replication all 127.0.0.1/32 trust - - host replication all ::1/128 trust - - hostssl all pgedge,patroni_replicator 172.17.0.1/32 cert clientcert=verify-full - - hostssl all pgedge,patroni_replicator 10.128.170.0/26 cert clientcert=verify-full - - hostssl replication pgedge,patroni_replicator 172.17.0.1/32 cert clientcert=verify-full - - hostssl replication pgedge,patroni_replicator 10.128.170.0/26 cert clientcert=verify-full - - host all pgedge,patroni_replicator 0.0.0.0/0 reject - - host all pgedge,patroni_replicator ::/0 reject - - host all all 172.17.0.1/32 md5 - - host all all 172.17.0.0/16 reject - - host testdb myapp_user 10.0.0.0/8 scram-sha-256 - - hostssl all myapp_user 203.0.113.0/24 scram-sha-256 - - host all all 0.0.0.0/0 scram-sha-256 - pg_ident: - - ssl_users CN=alice,O=example alice - use_pg_rewind: true - remove_data_directory_on_rewind_failure: true - remove_data_directory_on_diverged_timelines: true -restapi: - connect_address: postgres-pghba-n1-689qacsi.pghba-database:8888 - listen: 0.0.0.0:8888 - allowlist: - - 172.17.0.1 - - 10.128.170.0/26 - - 127.0.0.1 - - localhost -watchdog: - mode: "off" diff --git a/server/internal/orchestrator/swarm/mcp_config_resource.go b/server/internal/orchestrator/swarm/mcp_config_resource.go index e2821a49..b0888fc7 100644 --- a/server/internal/orchestrator/swarm/mcp_config_resource.go +++ b/server/internal/orchestrator/swarm/mcp_config_resource.go @@ -13,6 +13,7 @@ import ( "github.com/pgEdge/control-plane/server/internal/database" "github.com/pgEdge/control-plane/server/internal/docker" "github.com/pgEdge/control-plane/server/internal/filesystem" + "github.com/pgEdge/control-plane/server/internal/orchestrator/common" "github.com/pgEdge/control-plane/server/internal/resource" ) @@ -48,7 +49,7 @@ type MCPConfigResource struct { ConnectAsPassword string `json:"connect_as_password"` // KBHostPath is the full path to the KB SQLite file on the host. When non-empty, // Create and Update verify the file exists before allowing deployment to proceed. - KBHostPath string `json:"kb_host_path,omitempty"` + KBHostPath string `json:"kb_host_path,omitempty"` } func (r *MCPConfigResource) ResourceVersion() string { @@ -89,7 +90,7 @@ func (r *MCPConfigResource) Refresh(ctx context.Context, rc *resource.Context) e } // Check if config.yaml exists; ErrNotFound here triggers Create. - _, err = readResourceFile(fs, filepath.Join(dirPath, "config.yaml")) + _, err = common.ReadResourceFile(fs, filepath.Join(dirPath, "config.yaml")) if err != nil { return fmt.Errorf("failed to read MCP config: %w", err) } diff --git a/server/internal/orchestrator/swarm/orchestrator.go b/server/internal/orchestrator/swarm/orchestrator.go index dc429798..5f3c4eea 100644 --- a/server/internal/orchestrator/swarm/orchestrator.go +++ b/server/internal/orchestrator/swarm/orchestrator.go @@ -32,6 +32,7 @@ import ( "github.com/pgEdge/control-plane/server/internal/filesystem" "github.com/pgEdge/control-plane/server/internal/healthcheck" "github.com/pgEdge/control-plane/server/internal/host" + "github.com/pgEdge/control-plane/server/internal/orchestrator/common" "github.com/pgEdge/control-plane/server/internal/patroni" "github.com/pgEdge/control-plane/server/internal/pgbackrest" "github.com/pgEdge/control-plane/server/internal/postgres" @@ -46,6 +47,13 @@ const ( DefaultDatabaseOwnerGID int = DefaultDatabaseOwnerUID ) +// Each of these constants represents the port exposed by the container. These +// are separate from the published ports, which are user-specified and optional. +const ( + PostgresContainerPort int = 5432 + PatroniContainerPort int = 8888 +) + type Orchestrator struct { cfg config.Config versions *Versions @@ -224,19 +232,18 @@ func (o *Orchestrator) instanceResources(spec *database.InstanceSpec, scripts da } // patroni resources - used to clean up etcd on deletion - patroniCluster := &PatroniCluster{ - DatabaseID: spec.DatabaseID, - NodeName: spec.NodeName, - PatroniClusterPrefix: patroni.ClusterPrefix(spec.DatabaseID, spec.NodeName), + patroniCluster := &common.PatroniCluster{ + DatabaseID: spec.DatabaseID, + NodeName: spec.NodeName, } - patroniMember := &PatroniMember{ + patroniMember := &common.PatroniMember{ DatabaseID: spec.DatabaseID, NodeName: spec.NodeName, InstanceID: spec.InstanceID, } // file resources - etcdCreds := &EtcdCreds{ + etcdCreds := &common.EtcdCreds{ InstanceID: spec.InstanceID, HostID: spec.HostID, DatabaseID: spec.DatabaseID, @@ -245,7 +252,7 @@ func (o *Orchestrator) instanceResources(spec *database.InstanceSpec, scripts da OwnerUID: databaseOwnerUID, OwnerGID: databaseOwnerGID, } - postgresCerts := &PostgresCerts{ + postgresCerts := &common.PostgresCerts{ InstanceID: spec.InstanceID, HostID: spec.HostID, ParentID: certificatesDir.ID, @@ -259,16 +266,29 @@ func (o *Orchestrator) instanceResources(spec *database.InstanceSpec, scripts da OwnerUID: databaseOwnerUID, OwnerGID: databaseOwnerGID, } + paths := o.instancePaths(spec.InstanceID) patroniConfig := &PatroniConfig{ - Spec: spec, - HostCPUs: float64(o.cpus), - HostMemoryBytes: o.memBytes, + DatabaseID: spec.DatabaseID, DatabaseNetworkName: databaseNetwork.Name, BridgeNetworkInfo: o.bridgeNetwork, - ParentID: configsDir.ID, - OwnerUID: databaseOwnerUID, - OwnerGID: databaseOwnerGID, - InstanceHostname: instanceHostname, + Base: &common.PatroniConfig{ + InstanceID: spec.InstanceID, + HostID: spec.HostID, + NodeName: spec.NodeName, + ParentID: configsDir.ID, + OwnerUID: databaseOwnerUID, + OwnerGID: databaseOwnerGID, + Generator: common.NewPatroniConfigGenerator(common.PatroniConfigGeneratorOptions{ + Instance: spec, + HostCPUs: float64(o.cpus), + HostMemoryBytes: o.memBytes, + PatroniPort: PatroniContainerPort, + PostgresPort: PostgresContainerPort, + FQDN: instanceHostname, + LogType: patroni.LogTypeJson, + Paths: paths, + }), + }, } serviceSpec := &PostgresServiceSpecResource{ @@ -324,21 +344,24 @@ func (o *Orchestrator) instanceResources(spec *database.InstanceSpec, scripts da if spec.BackupConfig != nil { instanceDependencies = append(instanceDependencies, - &PgBackRestConfig{ + &common.PgBackRestConfig{ InstanceID: spec.InstanceID, HostID: spec.HostID, DatabaseID: spec.DatabaseID, NodeName: spec.NodeName, Repositories: spec.BackupConfig.Repositories, ParentID: configsDir.ID, - Type: PgBackRestConfigTypeBackup, + Type: pgbackrest.ConfigTypeBackup, OwnerUID: databaseOwnerUID, OwnerGID: databaseOwnerGID, + Paths: paths, + Port: PostgresContainerPort, }, ) nodeDependents = append(nodeDependents, - &PgBackRestStanza{ - NodeName: spec.NodeName, + &common.PgBackRestStanza{ + NodeName: spec.NodeName, + DatabaseID: spec.DatabaseID, }, ) for _, schedule := range spec.BackupConfig.Schedules { @@ -351,22 +374,24 @@ func (o *Orchestrator) instanceResources(spec *database.InstanceSpec, scripts da "node_name": spec.NodeName, "type": pgbackrest.BackupType(schedule.Type).String(), }, - []resource.Identifier{PgBackRestStanzaIdentifier(spec.NodeName)}, + []resource.Identifier{common.PgBackRestStanzaIdentifier(spec.NodeName)}, )) } } if spec.RestoreConfig != nil { - instanceDependencies = append(instanceDependencies, &PgBackRestConfig{ + instanceDependencies = append(instanceDependencies, &common.PgBackRestConfig{ InstanceID: spec.InstanceID, HostID: spec.HostID, DatabaseID: spec.RestoreConfig.SourceDatabaseID, NodeName: spec.RestoreConfig.SourceNodeName, Repositories: []*pgbackrest.Repository{spec.RestoreConfig.Repository}, ParentID: configsDir.ID, - Type: PgBackRestConfigTypeRestore, + Type: pgbackrest.ConfigTypeRestore, OwnerUID: databaseOwnerUID, OwnerGID: databaseOwnerGID, + Paths: paths, + Port: PostgresContainerPort, }) } @@ -775,7 +800,7 @@ func (o *Orchestrator) GetInstanceConnectionInfo(ctx context.Context, if !ok { return nil, fmt.Errorf("no bridge network found for postgres container %q", container.ID) } - dbPort, err := nat.NewPort("tcp", "5432") + dbPort, err := nat.NewPort("tcp", strconv.Itoa(PostgresContainerPort)) if err != nil { return nil, fmt.Errorf("failed to construct postgres nat port: %w", err) } @@ -794,13 +819,13 @@ func (o *Orchestrator) GetInstanceConnectionInfo(ctx context.Context, return &database.ConnectionInfo{ AdminHost: bridge.IPAddress, - AdminPort: 5432, + AdminPort: PostgresContainerPort, PeerHost: fmt.Sprintf("%s.%s-database", inspect.Config.Hostname, databaseID), - PeerPort: 5432, + PeerPort: PostgresContainerPort, PeerSSLCert: "/opt/pgedge/certificates/postgres/superuser.crt", PeerSSLKey: "/opt/pgedge/certificates/postgres/superuser.key", PeerSSLRootCert: "/opt/pgedge/certificates/postgres/ca.crt", - PatroniPort: 8888, + PatroniPort: PatroniContainerPort, ClientAddresses: o.cfg.ClientAddresses, ClientPort: clientPort, InstanceHostname: inspect.Config.Hostname, @@ -991,12 +1016,16 @@ func (o *Orchestrator) NodeDSN(ctx context.Context, rc *resource.Context, nodeNa } func (o *Orchestrator) InstancePaths(_ *ds.Version, instanceID string) (database.InstancePaths, error) { + return o.instancePaths(instanceID), nil +} + +func (o *Orchestrator) instancePaths(instanceID string) database.InstancePaths { return database.InstancePaths{ Instance: database.Paths{BaseDir: "/opt/pgedge"}, Host: database.Paths{BaseDir: filepath.Join(o.cfg.DataDir, "instances", instanceID)}, PgBackRestPath: "/usr/bin/pgbackrest", PatroniPath: "/usr/local/bin/patroni", - }, nil + } } func (o *Orchestrator) scaleInstance( diff --git a/server/internal/orchestrator/swarm/patroni_cluster.go b/server/internal/orchestrator/swarm/patroni_cluster.go deleted file mode 100644 index 2addfd17..00000000 --- a/server/internal/orchestrator/swarm/patroni_cluster.go +++ /dev/null @@ -1,78 +0,0 @@ -package swarm - -import ( - "context" - "fmt" - - "github.com/pgEdge/control-plane/server/internal/resource" - "github.com/pgEdge/control-plane/server/internal/storage" - "github.com/samber/do" - clientv3 "go.etcd.io/etcd/client/v3" -) - -var _ resource.Resource = (*PatroniCluster)(nil) - -const ResourceTypePatroniCluster resource.Type = "swarm.patroni_cluster" - -func PatroniClusterResourceIdentifier(nodeName string) resource.Identifier { - return resource.Identifier{ - ID: nodeName, - Type: ResourceTypePatroniCluster, - } -} - -type PatroniCluster struct { - DatabaseID string `json:"database_id"` - NodeName string `json:"node_name"` - PatroniClusterPrefix string `json:"patroni_namespace"` -} - -func (p *PatroniCluster) ResourceVersion() string { - return "1" -} - -func (p *PatroniCluster) DiffIgnore() []string { - return nil -} - -func (p *PatroniCluster) Executor() resource.Executor { - return resource.AnyExecutor() -} - -func (p *PatroniCluster) Identifier() resource.Identifier { - return PatroniClusterResourceIdentifier(p.NodeName) -} - -func (p *PatroniCluster) Dependencies() []resource.Identifier { - return nil -} - -func (p *PatroniCluster) TypeDependencies() []resource.Type { - return nil -} - -func (p *PatroniCluster) Refresh(ctx context.Context, rc *resource.Context) error { - return nil -} - -func (p *PatroniCluster) Create(ctx context.Context, rc *resource.Context) error { - return nil -} - -func (p *PatroniCluster) Update(ctx context.Context, rc *resource.Context) error { - return nil -} - -func (p *PatroniCluster) Delete(ctx context.Context, rc *resource.Context) error { - client, err := do.Invoke[*clientv3.Client](rc.Injector) - if err != nil { - return err - } - - _, err = storage.NewDeletePrefixOp(client, p.PatroniClusterPrefix).Exec(ctx) - if err != nil { - return fmt.Errorf("failed to delete patroni namespace from DCS: %w", err) - } - - return nil -} diff --git a/server/internal/orchestrator/swarm/patroni_config.go b/server/internal/orchestrator/swarm/patroni_config.go index a90c01fd..44cc2c7b 100644 --- a/server/internal/orchestrator/swarm/patroni_config.go +++ b/server/internal/orchestrator/swarm/patroni_config.go @@ -2,24 +2,14 @@ package swarm import ( "context" - "encoding/json" "errors" "fmt" - "maps" - "net/url" - "path/filepath" "time" - "github.com/alessio/shellescape" "github.com/samber/do" - "github.com/spf13/afero" - clientv3 "go.etcd.io/etcd/client/v3" - "github.com/pgEdge/control-plane/server/internal/database" "github.com/pgEdge/control-plane/server/internal/docker" - "github.com/pgEdge/control-plane/server/internal/filesystem" - "github.com/pgEdge/control-plane/server/internal/patroni" - "github.com/pgEdge/control-plane/server/internal/postgres" + "github.com/pgEdge/control-plane/server/internal/orchestrator/common" "github.com/pgEdge/control-plane/server/internal/postgres/hba" "github.com/pgEdge/control-plane/server/internal/resource" "github.com/pgEdge/control-plane/server/internal/utils" @@ -37,15 +27,10 @@ func PatroniConfigIdentifier(instanceID string) resource.Identifier { } type PatroniConfig struct { - Spec *database.InstanceSpec `json:"spec"` - ParentID string `json:"parent_id"` - HostCPUs float64 `json:"host_cpus"` - HostMemoryBytes uint64 `json:"host_memory_bytes"` - BridgeNetworkInfo *docker.NetworkInfo `json:"host_network_info"` - DatabaseNetworkName string `json:"database_network_name"` - OwnerUID int `json:"owner_uid"` - OwnerGID int `json:"owner_gid"` - InstanceHostname string `json:"instance_hostname"` + DatabaseID string `json:"database_id"` + Base *common.PatroniConfig `json:"base"` + BridgeNetworkInfo *docker.NetworkInfo `json:"host_network_info"` + DatabaseNetworkName string `json:"database_network_name"` } func (c *PatroniConfig) ResourceVersion() string { @@ -57,27 +42,17 @@ func (c *PatroniConfig) DiffIgnore() []string { } func (c *PatroniConfig) Executor() resource.Executor { - return resource.HostExecutor(c.Spec.HostID) + return resource.HostExecutor(c.Base.HostID) } func (c *PatroniConfig) Identifier() resource.Identifier { - return PatroniConfigIdentifier(c.Spec.InstanceID) + return PatroniConfigIdentifier(c.Base.InstanceID) } func (c *PatroniConfig) Dependencies() []resource.Identifier { - deps := []resource.Identifier{ - filesystem.DirResourceIdentifier(c.ParentID), - NetworkResourceIdentifier(c.DatabaseNetworkName), - EtcdCredsIdentifier(c.Spec.InstanceID), - PatroniMemberResourceIdentifier(c.Spec.InstanceID), - PatroniClusterResourceIdentifier(c.Spec.NodeName), - } - if c.Spec.RestoreConfig != nil { - deps = append(deps, PgBackRestConfigIdentifier(c.Spec.InstanceID, PgBackRestConfigTypeRestore)) - } - if c.Spec.BackupConfig != nil { - deps = append(deps, PgBackRestConfigIdentifier(c.Spec.InstanceID, PgBackRestConfigTypeBackup)) - } + deps := c.Base.Dependencies() + deps = append(deps, NetworkResourceIdentifier(c.DatabaseNetworkName)) + return deps } @@ -86,146 +61,66 @@ func (r *PatroniConfig) TypeDependencies() []resource.Type { } func (c *PatroniConfig) Refresh(ctx context.Context, rc *resource.Context) error { - fs, err := do.Invoke[afero.Fs](rc.Injector) - if err != nil { - return err - } - - parentFullPath, err := filesystem.DirResourceFullPath(rc, c.ParentID) - if err != nil { - return fmt.Errorf("failed to get parent full path: %w", err) - } - - contents, err := readResourceFile(fs, filepath.Join(parentFullPath, "patroni.yaml")) - if err != nil { - return fmt.Errorf("failed to read patroni config: %w", err) - } - - var config *patroni.Config - if err := json.Unmarshal(contents, &config); err != nil { - return fmt.Errorf("failed to unmarshal patroni config: %w", err) - } - - return nil + return c.Base.Refresh(ctx, rc) } func (c *PatroniConfig) Create(ctx context.Context, rc *resource.Context) error { - fs, err := do.Invoke[afero.Fs](rc.Injector) - if err != nil { - return err - } - etcdClient, err := do.Invoke[*clientv3.Client](rc.Injector) + addresses, extraHBA, err := c.getAddressesAndHBA(rc) if err != nil { return err } - parentFullPath, err := filesystem.DirResourceFullPath(rc, c.ParentID) - if err != nil { - return fmt.Errorf("failed to get parent full path: %w", err) - } - - network, err := resource.FromContext[*Network](rc, NetworkResourceIdentifier(c.DatabaseNetworkName)) - if err != nil { - return fmt.Errorf("failed to get database network from state: %w", err) - } - etcdCreds, err := resource.FromContext[*EtcdCreds](rc, EtcdCredsIdentifier(c.Spec.InstanceID)) - if err != nil { - return fmt.Errorf("failed to get etcd creds from state: %w", err) - } - - members, err := etcdClient.MemberList(ctx) - if err != nil { - return fmt.Errorf("failed to list etcd cluster members: %w", err) - } - var endpoints []string - for _, member := range members.Members { - endpoints = append(endpoints, member.GetClientURLs()...) - } - - enableFastBasebackup, err := c.isNewNode(rc) - if err != nil { - return err - } - - config, err := generatePatroniConfig( - c.Spec, - c.InstanceHostname, - c.HostCPUs, - c.HostMemoryBytes, - endpoints, - etcdCreds, - c.BridgeNetworkInfo, - network, - enableFastBasebackup, - ) - if err != nil { - return fmt.Errorf("failed to generate patroni config: %w", err) - } - - content, err := json.MarshalIndent(config, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal patroni config: %w", err) - } - - configPath := filepath.Join(parentFullPath, "patroni.yaml") - if err := afero.WriteFile(fs, configPath, content, 0o600); err != nil { - return fmt.Errorf("failed to write %s: %w", configPath, err) - } - if err := fs.Chown(configPath, c.OwnerUID, c.OwnerGID); err != nil { - return fmt.Errorf("failed to change ownership for %s: %w", configPath, err) - } - - return nil + return c.Base.Create(ctx, rc, addresses, extraHBA) } func (c *PatroniConfig) Update(ctx context.Context, rc *resource.Context) error { - if err := c.Create(ctx, rc); err != nil { + addresses, extraHBA, err := c.getAddressesAndHBA(rc) + if err != nil { return err } - return c.signalReload(ctx, rc) + return c.Base.Update(ctx, rc, addresses, extraHBA, c.signalReload) } func (c *PatroniConfig) Delete(ctx context.Context, rc *resource.Context) error { - fs, err := do.Invoke[afero.Fs](rc.Injector) - if err != nil { - return err - } - - parentFullPath, err := filesystem.DirResourceFullPath(rc, c.ParentID) - if err != nil { - return fmt.Errorf("failed to get parent full path: %w", err) - } - - err = fs.Remove(filepath.Join(parentFullPath, "patroni.yaml")) - if errors.Is(err, afero.ErrFileNotFound) { - return nil - } else if err != nil { - return fmt.Errorf("failed to remove patroni.yaml: %w", err) - } - - return nil + return c.Base.Delete(ctx, rc) } -func (c *PatroniConfig) isNewNode(rc *resource.Context) (bool, error) { - _, err := resource.FromContext[*database.NodeResource](rc, database.NodeResourceIdentifier(c.Spec.NodeName)) - switch { - case errors.Is(err, resource.ErrNotFound): - return true, nil - case err != nil: - return false, fmt.Errorf("failed to check if node already exists: %w", err) - default: - return false, nil +func (c *PatroniConfig) getAddressesAndHBA(rc *resource.Context) ([]string, []hba.Entry, error) { + network, err := resource.FromContext[*Network](rc, NetworkResourceIdentifier(c.DatabaseNetworkName)) + if err != nil { + return nil, nil, fmt.Errorf("failed to get database network from state: %w", err) + } + addresses := []string{ + c.BridgeNetworkInfo.Gateway.String(), + network.Subnet.String(), + } + extraHBA := []hba.Entry{ + { + Type: hba.EntryTypeHost, + Database: "all", + User: "all", + Address: c.BridgeNetworkInfo.Gateway.String(), + AuthMethod: c.Base.Generator.AuthMethod(), + }, + { + Type: hba.EntryTypeHost, + Database: "all", + User: "all", + Address: c.BridgeNetworkInfo.Subnet.String(), + AuthMethod: hba.AuthMethodReject, + }, } + return addresses, extraHBA, nil } -func (c *PatroniConfig) signalReload(ctx context.Context, rc *resource.Context) error { +func (c *PatroniConfig) signalReload(ctx context.Context, rc *resource.Context, wait time.Duration) error { client, err := do.Invoke[*docker.Docker](rc.Injector) if err != nil { return err } // Signal the container if it exists - container, err := GetPostgresContainer(ctx, client, c.Spec.InstanceID) + container, err := GetPostgresContainer(ctx, client, c.Base.InstanceID) if errors.Is(err, ErrNoPostgresContainer) { return nil } else if err != nil { @@ -234,325 +129,6 @@ func (c *PatroniConfig) signalReload(ctx context.Context, rc *resource.Context) if err := client.ContainerSignal(ctx, container.ID, "SIGHUP"); err != nil { return fmt.Errorf("failed to signal patroni to reload: %w", err) } - // It can take up to loop_wait seconds for Patroni to reload the config. - // We'll want to update this code to read loop_wait from c.Spec if we make - // loop_wait configurable. - return utils.SleepContext(ctx, patroni.DefaultLoopWaitSeconds*time.Second) -} - -func generatePatroniConfig( - spec *database.InstanceSpec, - instanceHostname string, - hostCPUs float64, - hostMemoryBytes uint64, - etcdEndpoints []string, - etcdCreds *EtcdCreds, - bridgeInfo *docker.NetworkInfo, - dbNetworkInfo *Network, - enableFastBasebackup bool, -) (*patroni.Config, error) { - memoryBytes := spec.MemoryBytes - if memoryBytes == 0 { - memoryBytes = hostMemoryBytes - } - cpus := spec.CPUs - if cpus == 0 { - cpus = hostCPUs - } - - parameters := postgres.DefaultGUCs() - maps.Copy(parameters, postgres.SpockDefaultGUCs()) - maps.Copy(parameters, postgres.DefaultTunableGUCs(memoryBytes, cpus, spec.ClusterSize)) - maps.Copy(parameters, map[string]any{ - "shared_preload_libraries": "pg_stat_statements,snowflake,spock,postgis-3", // The docker image includes postgis-3 - "ssl": "on", - "ssl_ca_file": "/opt/pgedge/certificates/postgres/ca.crt", - "ssl_cert_file": "/opt/pgedge/certificates/postgres/server.crt", - "ssl_key_file": "/opt/pgedge/certificates/postgres/server.key", - }) - if spec.BackupConfig != nil { - maps.Copy(parameters, map[string]any{ - // It's safe to set this to "on" on every instance in the node - // because "on" (as opposed to "always") will only archive from the - // primary instance. - "archive_mode": "on", - "archive_command": PgBackRestBackupCmd("archive-push", `"%p"`).String(), - }) - } - maps.Copy(parameters, postgres.SnowflakeLolorGUCs(spec.NodeOrdinal)) - maps.Copy(parameters, spec.PostgreSQLConf) - dcsParameters := patroni.ExtractPatroniControlledGUCs(parameters) - - staticLogFields := map[string]string{ - "database_id": spec.DatabaseID, - "instance_id": spec.InstanceID, - "node_name": spec.NodeName, - } - if spec.TenantID != nil { - staticLogFields["tenant_id"] = *spec.TenantID - } - - // Patroni requires the etcd endpoints to be in the format "host:port" - etcdHosts := make([]string, len(etcdEndpoints)) - for i, endpoint := range etcdEndpoints { - u, err := url.Parse(endpoint) - if err != nil { - return nil, fmt.Errorf("got invalid etcd endpoint %q: %w", endpoint, err) - } - etcdHosts[i] = u.Host - } - - var baseBackup *[]any - if enableFastBasebackup { - // Causes basebackup to request an immediate checkpoint. The tradeoff - // is that the checkpoint operation can disrupt clients. We enable it - // by default for new nodes because the primary shouldn't have any - // clients outside the control plane. - baseBackup = &[]any{ - map[string]string{"checkpoint": "fast"}, - } - } - - // The gateway and catch-all rules authenticate non-system users by - // password, so their auth method follows password_encryption to match how - // passwords are stored. It defaults to md5 (Postgres' historical default - // and our DefaultGUCs value) when unset. - passwordAuthMethod := hba.AuthMethodMD5 - if pe, ok := parameters["password_encryption"].(string); ok && pe != "" { - passwordAuthMethod = hba.AuthMethod(pe) - } - - cfg := &patroni.Config{ - Name: utils.PointerTo(spec.InstanceID), - Namespace: utils.PointerTo(patroni.Namespace()), - Scope: utils.PointerTo(patroni.ClusterName(spec.DatabaseID, spec.NodeName)), - Log: &patroni.Log{ - Type: utils.PointerTo(patroni.LogTypeJson), - Level: utils.PointerTo(patroni.LogLevelInfo), - StaticFields: &staticLogFields, - }, - Bootstrap: &patroni.Bootstrap{ - DCS: &patroni.DCS{ - Postgresql: &patroni.DCSPostgreSQL{ - Parameters: &dcsParameters, - }, - IgnoreSlots: &[]patroni.IgnoreSlot{ - {Plugin: utils.PointerTo("spock_output")}, - }, - TTL: utils.PointerTo(30), - LoopWait: utils.PointerTo(int(patroni.DefaultLoopWaitSeconds)), - RetryTimeout: utils.PointerTo(10), - }, - InitDB: utils.PointerTo([]string{"data-checksums"}), - }, - Etcd3: &patroni.Etcd{ - Hosts: &etcdHosts, - CACert: utils.PointerTo("/opt/pgedge/certificates/etcd/ca.crt"), - Cert: utils.PointerTo("/opt/pgedge/certificates/etcd/client.crt"), - Key: utils.PointerTo("/opt/pgedge/certificates/etcd/client.key"), - Username: &etcdCreds.Username, - Password: &etcdCreds.Password, - Protocol: utils.PointerTo("https"), - }, - RestAPI: &patroni.RestAPI{ - ConnectAddress: utils.PointerTo(fmt.Sprintf("%s.%s-database:8888", instanceHostname, spec.DatabaseID)), - Listen: utils.PointerTo("0.0.0.0:8888"), - Allowlist: &[]string{ - bridgeInfo.Gateway.String(), // Control plane will connect from this address - dbNetworkInfo.Subnet.String(), // Other cluster members will come from this CIDR - "127.0.0.1", // Local connections for docker exec use - "localhost", - }, - }, - Watchdog: &patroni.Watchdog{ - Mode: utils.PointerTo(patroni.WatchdogModeOff), - }, - Postgresql: &patroni.PostgreSQL{ - ConnectAddress: utils.PointerTo(fmt.Sprintf("%s:5432", instanceHostname)), - DataDir: utils.PointerTo("/opt/pgedge/data/pgdata"), - Parameters: ¶meters, - Listen: utils.PointerTo("*:5432"), - Callbacks: &patroni.Callbacks{}, - BaseBackup: baseBackup, - UsePgRewind: utils.PointerTo(true), - RemoveDataDirectoryOnRewindFailure: utils.PointerTo(true), - RemoveDataDirectoryOnDivergedTimelines: utils.PointerTo(true), - Authentication: &patroni.Authentication{ - Superuser: &patroni.User{ - Username: utils.PointerTo("pgedge"), - SSLRootCert: utils.PointerTo("/opt/pgedge/certificates/postgres/ca.crt"), - SSLCert: utils.PointerTo("/opt/pgedge/certificates/postgres/superuser.crt"), - SSLKey: utils.PointerTo("/opt/pgedge/certificates/postgres/superuser.key"), - SSLMode: utils.PointerTo("verify-full"), - }, - Replication: &patroni.User{ - Username: utils.PointerTo("patroni_replicator"), - SSLRootCert: utils.PointerTo("/opt/pgedge/certificates/postgres/ca.crt"), - SSLCert: utils.PointerTo("/opt/pgedge/certificates/postgres/replication.crt"), - SSLKey: utils.PointerTo("/opt/pgedge/certificates/postgres/replication.key"), - SSLMode: utils.PointerTo("verify-full"), - }, - }, - PgHba: &[]string{ - // Trust local connections - hba.Entry{ - Type: hba.EntryTypeLocal, - Database: "all", - User: "all", - AuthMethod: hba.AuthMethodTrust, - }.String(), - hba.Entry{ - Type: hba.EntryTypeHost, - Database: "all", - User: "all", - Address: "127.0.0.1/32", - AuthMethod: hba.AuthMethodTrust, - }.String(), - hba.Entry{ - Type: hba.EntryTypeHost, - Database: "all", - User: "all", - Address: "::1/128", - AuthMethod: hba.AuthMethodTrust, - }.String(), - hba.Entry{ - Type: hba.EntryTypeLocal, - Database: "replication", - User: "all", - AuthMethod: hba.AuthMethodTrust, - }.String(), - hba.Entry{ - Type: hba.EntryTypeHost, - Database: "replication", - User: "all", - Address: "127.0.0.1/32", - AuthMethod: hba.AuthMethodTrust, - }.String(), - hba.Entry{ - Type: hba.EntryTypeHost, - Database: "replication", - User: "all", - Address: "::1/128", - AuthMethod: hba.AuthMethodTrust, - }.String(), - // Reject connections for system users except for SSL - // connections from the bridge network gateway (the control - // plane server) or SSL connections from peers. - hba.Entry{ - Type: hba.EntryTypeHostSSL, - Database: "all", - User: "pgedge,patroni_replicator", - Address: fmt.Sprintf("%s/32", bridgeInfo.Gateway.String()), - AuthMethod: hba.AuthMethodCert, - AuthOptions: "clientcert=verify-full", - }.String(), - hba.Entry{ - Type: hba.EntryTypeHostSSL, - Database: "all", - User: "pgedge,patroni_replicator", - Address: dbNetworkInfo.Subnet.String(), - AuthMethod: hba.AuthMethodCert, - AuthOptions: "clientcert=verify-full", - }.String(), - hba.Entry{ - Type: hba.EntryTypeHostSSL, - Database: "replication", - User: "pgedge,patroni_replicator", - Address: fmt.Sprintf("%s/32", bridgeInfo.Gateway.String()), - AuthMethod: hba.AuthMethodCert, - AuthOptions: "clientcert=verify-full", - }.String(), - hba.Entry{ - Type: hba.EntryTypeHostSSL, - Database: "replication", - User: "pgedge,patroni_replicator", - Address: dbNetworkInfo.Subnet.String(), - AuthMethod: hba.AuthMethodCert, - AuthOptions: "clientcert=verify-full", - }.String(), - hba.Entry{ - Type: hba.EntryTypeHost, - Database: "all", - User: "pgedge,patroni_replicator", - Address: "0.0.0.0/0", - AuthMethod: hba.AuthMethodReject, - }.String(), - // The IPv6 counterpart of the reject above. Without it, a - // permissive user rule in the zone below could authenticate a - // system user over IPv6. - hba.Entry{ - Type: hba.EntryTypeHost, - Database: "all", - User: "pgedge,patroni_replicator", - Address: "::/0", - AuthMethod: hba.AuthMethodReject, - }.String(), - // Use MD5 for non-system users from the gateway. External - // connections will originate from this address when we publish - // a host port. - hba.Entry{ - Type: hba.EntryTypeHost, - Database: "all", - User: "all", - Address: fmt.Sprintf("%s/32", bridgeInfo.Gateway.String()), - AuthMethod: hba.AuthMethodMD5, - }.String(), - // Reject all other connections on the bridge network to prevent - // connections from other databases. The user zone and catch-all - // are appended after this literal (see below). - hba.Entry{ - Type: hba.EntryTypeHost, - Database: "all", - User: "all", - Address: bridgeInfo.Subnet.String(), - AuthMethod: hba.AuthMethodReject, - }.String(), - }, - }, - } - - // User-supplied pg_hba entries form a zone after the CP's system-user and - // bridge-isolation rules and before the catch-all. By this point system - // users are already matched or rejected and cross-container traffic is - // blocked, so user rules cannot affect CP-internal connectivity. - // spec.PgHbaConf already has node-level entries prepended ahead of the - // database-level entries. - *cfg.Postgresql.PgHba = append(*cfg.Postgresql.PgHba, spec.PgHbaConf...) - // Catch-all for non-system users; the auth method follows password_encryption. - *cfg.Postgresql.PgHba = append(*cfg.Postgresql.PgHba, hba.Entry{ - Type: hba.EntryTypeHost, - Database: "all", - User: "all", - Address: "0.0.0.0/0", - AuthMethod: passwordAuthMethod, - }.String()) - - // pg_ident mappings are purely user-supplied; the CP writes none of its own. - if len(spec.PgIdentConf) > 0 { - cfg.Postgresql.PgIdent = &spec.PgIdentConf - } - - if spec.RestoreConfig != nil { - cfg.Bootstrap.Method = utils.PointerTo(patroni.BootstrapMethodNameRestore) - - if spec.InPlaceRestore { - cfg.Bootstrap.Restore = &patroni.BootstrapMethodConf{ - Command: utils.PointerTo("mv /opt/pgedge/data/pgdata-restore /opt/pgedge/data/pgdata"), - NoParams: utils.PointerTo(true), - KeepExistingRecoveryConf: utils.PointerTo(true), - } - } else { - restoreOptions := utils.BuildOptionArgs(spec.RestoreConfig.RestoreOptions) - for i, o := range restoreOptions { - restoreOptions[i] = shellescape.Quote(o) - } - cfg.Bootstrap.Restore = &patroni.BootstrapMethodConf{ - Command: utils.PointerTo(PgBackRestRestoreCmd("restore", restoreOptions...).String()), - NoParams: utils.PointerTo(true), - KeepExistingRecoveryConf: utils.PointerTo(true), - } - } - } - return cfg, nil + return utils.SleepContext(ctx, wait) } diff --git a/server/internal/orchestrator/swarm/patroni_config_golden_test.go b/server/internal/orchestrator/swarm/patroni_config_golden_test.go deleted file mode 100644 index 2f93a502..00000000 --- a/server/internal/orchestrator/swarm/patroni_config_golden_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package swarm - -import ( - "net/netip" - "testing" - - "github.com/goccy/go-yaml" - "github.com/stretchr/testify/require" - - "github.com/pgEdge/control-plane/server/internal/database" - "github.com/pgEdge/control-plane/server/internal/docker" - "github.com/pgEdge/control-plane/server/internal/ds" - "github.com/pgEdge/control-plane/server/internal/patroni" - "github.com/pgEdge/control-plane/server/internal/testutils" -) - -// TestGeneratePatroniConfig golden-tests the Swarm patroni config generator, -// focused on the pg_hba/pg_ident wiring: the user zone sits between the -// bridge-subnet reject and the catch-all, system users are rejected over both -// IPv4 and IPv6, the catch-all auth method follows password_encryption, and -// pg_ident is populated from the spec. -func TestGeneratePatroniConfig(t *testing.T) { - golden := &testutils.GoldenTest[patroni.Config]{ - Compare: func(t testing.TB, expected, actual patroni.Config) { - // Number types are lost in the YAML round-trip, so normalize the - // actual value the same way before comparing. - raw, err := yaml.Marshal(actual) - require.NoError(t, err) - - var roundTripped patroni.Config - require.NoError(t, yaml.Unmarshal(raw, &roundTripped)) - - require.Equal(t, expected, roundTripped) - }, - FileExtension: ".yaml", - Marshal: yaml.Marshal, - Unmarshal: yaml.Unmarshal, - } - - bridgeInfo := &docker.NetworkInfo{ - Name: "bridge", - Subnet: netip.MustParsePrefix("172.17.0.0/16"), - Gateway: netip.MustParseAddr("172.17.0.1"), - } - dbNetwork := &Network{ - Name: "pghba-database", - Subnet: netip.MustParsePrefix("10.128.170.0/26"), - Gateway: netip.MustParseAddr("10.128.170.1"), - } - etcdCreds := &EtcdCreds{ - Username: "instance.pghba-n1-689qacsi", - Password: "password", - } - - for _, tc := range []struct { - name string - spec *database.InstanceSpec - }{ - { - name: "user pg_hba pg_ident and scram", - spec: &database.InstanceSpec{ - InstanceID: "pghba-n1-689qacsi", - DatabaseID: "pghba", - HostID: "host-1", - DatabaseName: "testdb", - NodeName: "n1", - NodeOrdinal: 1, - PgEdgeVersion: ds.MustParsePgEdgeVersion("18.4", "5.0.8"), - ClusterSize: 3, - CPUs: 4, - MemoryBytes: 1024 * 1024 * 1024 * 8, - // password_encryption drives the gateway and catch-all auth method. - PostgreSQLConf: map[string]any{ - "password_encryption": "scram-sha-256", - }, - // Node-level entries are already prepended ahead of - // database-level entries by NodeInstances() before reaching the - // generator. - PgHbaConf: []string{ - "host testdb myapp_user 10.0.0.0/8 scram-sha-256", - "hostssl all myapp_user 203.0.113.0/24 scram-sha-256", - }, - PgIdentConf: []string{"ssl_users CN=alice,O=example alice"}, - }, - }, - { - name: "no user entries defaults to md5", - spec: &database.InstanceSpec{ - InstanceID: "plain-n1-689qacsi", - DatabaseID: "plain", - HostID: "host-1", - DatabaseName: "testdb", - NodeName: "n1", - NodeOrdinal: 1, - PgEdgeVersion: ds.MustParsePgEdgeVersion("18.4", "5.0.8"), - ClusterSize: 3, - CPUs: 4, - MemoryBytes: 1024 * 1024 * 1024 * 8, - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - cfg, err := generatePatroniConfig( - tc.spec, - "postgres-"+tc.spec.InstanceID, - 4, - 1024*1024*1024*8, - []string{"https://10.0.0.1:2379"}, - etcdCreds, - bridgeInfo, - dbNetwork, - false, - ) - require.NoError(t, err) - - golden.Run(t, *cfg, update) - }) - } -} diff --git a/server/internal/orchestrator/swarm/patroni_member.go b/server/internal/orchestrator/swarm/patroni_member.go deleted file mode 100644 index 2949df31..00000000 --- a/server/internal/orchestrator/swarm/patroni_member.go +++ /dev/null @@ -1,83 +0,0 @@ -package swarm - -import ( - "context" - "fmt" - - "github.com/samber/do" - clientv3 "go.etcd.io/etcd/client/v3" - - "github.com/pgEdge/control-plane/server/internal/patroni" - "github.com/pgEdge/control-plane/server/internal/resource" - "github.com/pgEdge/control-plane/server/internal/storage" -) - -var _ resource.Resource = (*PatroniMember)(nil) - -const ResourceTypePatroniMember resource.Type = "swarm.patroni_member" - -func PatroniMemberResourceIdentifier(instanceID string) resource.Identifier { - return resource.Identifier{ - ID: instanceID, - Type: ResourceTypePatroniMember, - } -} - -type PatroniMember struct { - DatabaseID string `json:"database_id"` - NodeName string `json:"node_name"` - InstanceID string `json:"instance_id"` -} - -func (p *PatroniMember) ResourceVersion() string { - return "1" -} - -func (p *PatroniMember) DiffIgnore() []string { - return nil -} - -func (p *PatroniMember) Executor() resource.Executor { - return resource.AnyExecutor() -} - -func (p *PatroniMember) Identifier() resource.Identifier { - return PatroniMemberResourceIdentifier(p.InstanceID) -} - -func (p *PatroniMember) Dependencies() []resource.Identifier { - return []resource.Identifier{ - PatroniClusterResourceIdentifier(p.NodeName), - } -} - -func (p *PatroniMember) TypeDependencies() []resource.Type { - return nil -} - -func (p *PatroniMember) Refresh(ctx context.Context, rc *resource.Context) error { - return nil -} - -func (p *PatroniMember) Create(ctx context.Context, rc *resource.Context) error { - return nil -} - -func (p *PatroniMember) Update(ctx context.Context, rc *resource.Context) error { - return nil -} - -func (p *PatroniMember) Delete(ctx context.Context, rc *resource.Context) error { - client, err := do.Invoke[*clientv3.Client](rc.Injector) - if err != nil { - return err - } - - key := patroni.MemberKey(p.DatabaseID, p.NodeName, p.InstanceID) - _, err = storage.NewDeleteKeyOp(client, key).Exec(ctx) - if err != nil { - return fmt.Errorf("failed to delete patroni cluster member from DCS: %w", err) - } - - return nil -} diff --git a/server/internal/orchestrator/swarm/pgbackrest_config.go b/server/internal/orchestrator/swarm/pgbackrest_config.go deleted file mode 100644 index 69fb4aa0..00000000 --- a/server/internal/orchestrator/swarm/pgbackrest_config.go +++ /dev/null @@ -1,171 +0,0 @@ -package swarm - -import ( - "bytes" - "context" - "errors" - "fmt" - "path/filepath" - - "github.com/samber/do" - "github.com/spf13/afero" - - "github.com/pgEdge/control-plane/server/internal/filesystem" - "github.com/pgEdge/control-plane/server/internal/pgbackrest" - "github.com/pgEdge/control-plane/server/internal/resource" -) - -type PgBackRestConfigType string - -func (t PgBackRestConfigType) String() string { - return string(t) -} - -const ( - PgBackRestConfigTypeBackup PgBackRestConfigType = "backup" - PgBackRestConfigTypeRestore PgBackRestConfigType = "restore" -) - -var _ resource.Resource = (*PgBackRestConfig)(nil) - -const ResourceTypePgBackRestConfig resource.Type = "swarm.pgbackrest_config" - -func PgBackRestConfigIdentifier(instanceID string, configType PgBackRestConfigType) resource.Identifier { - return resource.Identifier{ - ID: instanceID + "-" + configType.String(), - Type: ResourceTypePgBackRestConfig, - } -} - -type PgBackRestConfig struct { - InstanceID string `json:"instance_id"` - HostID string `json:"host_id"` - DatabaseID string `json:"database_id"` - NodeName string `json:"node_name"` - Repositories []*pgbackrest.Repository `json:"repositories"` - ParentID string `json:"parent_id"` - Type PgBackRestConfigType `json:"type"` - OwnerUID int `json:"owner_uid"` - OwnerGID int `json:"owner_gid"` -} - -func (c *PgBackRestConfig) ResourceVersion() string { - return "1" -} - -func (c *PgBackRestConfig) DiffIgnore() []string { - return nil -} - -func (c *PgBackRestConfig) Executor() resource.Executor { - return resource.HostExecutor(c.HostID) -} - -func (c *PgBackRestConfig) Identifier() resource.Identifier { - return PgBackRestConfigIdentifier(c.InstanceID, c.Type) -} - -func (c *PgBackRestConfig) Dependencies() []resource.Identifier { - return []resource.Identifier{ - filesystem.DirResourceIdentifier(c.ParentID), - } -} - -func (c *PgBackRestConfig) TypeDependencies() []resource.Type { - return nil -} - -func (c *PgBackRestConfig) Refresh(ctx context.Context, rc *resource.Context) error { - fs, err := do.Invoke[afero.Fs](rc.Injector) - if err != nil { - return err - } - - hostPath, err := c.HostPath(rc) - if err != nil { - return err - } - - _, err = readResourceFile(fs, hostPath) - if err != nil { - return fmt.Errorf("failed to read pgbackrest config: %w", err) - } - - return nil -} - -func (c *PgBackRestConfig) Create(ctx context.Context, rc *resource.Context) error { - fs, err := do.Invoke[afero.Fs](rc.Injector) - if err != nil { - return err - } - - var b bytes.Buffer - if err := pgbackrest.WriteConfig(&b, pgbackrest.ConfigOptions{ - Repositories: c.Repositories, - DatabaseID: c.DatabaseID, - NodeName: c.NodeName, - InstanceID: c.InstanceID, - PgDataPath: "/opt/pgedge/data/pgdata", - HostUser: "pgedge", - User: "pgedge", - }); err != nil { - return fmt.Errorf("failed to generate pgBackRest backup configuration: %w", err) - } - - hostPath, err := c.HostPath(rc) - if err != nil { - return err - } - - if err := afero.WriteFile(fs, hostPath, b.Bytes(), 0o600); err != nil { - return fmt.Errorf("failed to write %s: %w", hostPath, err) - } - if err := fs.Chown(hostPath, c.OwnerUID, c.OwnerGID); err != nil { - return fmt.Errorf("failed to change ownership for %s: %w", hostPath, err) - } - - return nil -} - -func (c *PgBackRestConfig) Update(ctx context.Context, rc *resource.Context) error { - return c.Create(ctx, rc) -} - -func (c *PgBackRestConfig) Delete(ctx context.Context, rc *resource.Context) error { - fs, err := do.Invoke[afero.Fs](rc.Injector) - if err != nil { - return err - } - - hostPath, err := c.HostPath(rc) - if err != nil { - return err - } - - err = fs.Remove(hostPath) - if errors.Is(err, afero.ErrFileNotFound) { - return nil - } else if err != nil { - return fmt.Errorf("failed to remove pgbackrest config: %w", err) - } - - return nil -} - -func (c *PgBackRestConfig) BaseName() string { - return fmt.Sprintf("pgbackrest.%s.conf", c.Type) -} - -func (c *PgBackRestConfig) HostPath(rc *resource.Context) (string, error) { - parentFullPath, err := filesystem.DirResourceFullPath(rc, c.ParentID) - if err != nil { - return "", fmt.Errorf("failed to get parent full path: %w", err) - } - - return filepath.Join(parentFullPath, c.BaseName()), nil -} - -func (c *PgBackRestConfig) ContainerPath() string { - return filepath.Join("/opt/pgedge/configs", c.BaseName()) -} diff --git a/server/internal/orchestrator/swarm/pgbackrest_restore.go b/server/internal/orchestrator/swarm/pgbackrest_restore.go index 115dbe94..abf1fe5e 100644 --- a/server/internal/orchestrator/swarm/pgbackrest_restore.go +++ b/server/internal/orchestrator/swarm/pgbackrest_restore.go @@ -12,6 +12,8 @@ import ( "github.com/google/uuid" "github.com/pgEdge/control-plane/server/internal/docker" "github.com/pgEdge/control-plane/server/internal/filesystem" + "github.com/pgEdge/control-plane/server/internal/orchestrator/common" + "github.com/pgEdge/control-plane/server/internal/pgbackrest" "github.com/pgEdge/control-plane/server/internal/resource" "github.com/pgEdge/control-plane/server/internal/task" "github.com/pgEdge/control-plane/server/internal/utils" @@ -62,8 +64,8 @@ func (p *PgBackRestRestore) Dependencies() []resource.Identifier { filesystem.DirResourceIdentifier(p.DataDirID), PostgresServiceResourceIdentifier(p.InstanceID), PostgresServiceSpecResourceIdentifier(p.InstanceID), - PgBackRestConfigIdentifier(p.InstanceID, PgBackRestConfigTypeRestore), - PatroniClusterResourceIdentifier(p.NodeName), + common.PgBackRestConfigIdentifier(p.InstanceID, pgbackrest.ConfigTypeRestore), + common.PatroniClusterResourceIdentifier(p.NodeName), ScaleServiceResourceIdentifier(p.InstanceID, ScaleDirectionDOWN), } } @@ -195,7 +197,7 @@ func (p *PgBackRestRestore) stopPostgres( if err != nil { return fmt.Errorf("failed to get data dir resource from state: %w", err) } - patroniCluster, err := resource.FromContext[*PatroniCluster](rc, PatroniClusterResourceIdentifier(p.NodeName)) + patroniCluster, err := resource.FromContext[*common.PatroniCluster](rc, common.PatroniClusterResourceIdentifier(p.NodeName)) if err != nil { return fmt.Errorf("failed to get patroni cluster resource from state: %w", err) } diff --git a/server/internal/orchestrator/swarm/pgbackrest_stanza.go b/server/internal/orchestrator/swarm/pgbackrest_stanza.go deleted file mode 100644 index 3c601abc..00000000 --- a/server/internal/orchestrator/swarm/pgbackrest_stanza.go +++ /dev/null @@ -1,127 +0,0 @@ -package swarm - -import ( - "bytes" - "context" - "fmt" - - "github.com/pgEdge/control-plane/server/internal/database" - "github.com/pgEdge/control-plane/server/internal/docker" - "github.com/pgEdge/control-plane/server/internal/pgbackrest" - "github.com/pgEdge/control-plane/server/internal/resource" - "github.com/samber/do" -) - -var _ resource.Resource = (*PgBackRestStanza)(nil) - -const ResourceTypePgBackRestStanza resource.Type = "swarm.pgbackrest_stanza" - -func PgBackRestStanzaIdentifier(nodeName string) resource.Identifier { - return resource.Identifier{ - ID: nodeName, - Type: ResourceTypePgBackRestStanza, - } -} - -type PgBackRestStanza struct { - NodeName string `json:"node_name"` -} - -func (p *PgBackRestStanza) ResourceVersion() string { - return "1" -} - -func (p *PgBackRestStanza) DiffIgnore() []string { - return nil -} - -func (p *PgBackRestStanza) Executor() resource.Executor { - return resource.PrimaryExecutor(p.NodeName) -} - -func (p *PgBackRestStanza) Identifier() resource.Identifier { - return PgBackRestStanzaIdentifier(p.NodeName) -} - -func (p *PgBackRestStanza) Dependencies() []resource.Identifier { - return []resource.Identifier{ - database.NodeResourceIdentifier(p.NodeName), - } -} - -func (p *PgBackRestStanza) TypeDependencies() []resource.Type { - return nil -} - -func (p *PgBackRestStanza) Refresh(ctx context.Context, rc *resource.Context) error { - client, err := do.Invoke[*docker.Docker](rc.Injector) - if err != nil { - return err - } - node, err := resource.FromContext[*database.NodeResource](rc, database.NodeResourceIdentifier(p.NodeName)) - if err != nil { - return fmt.Errorf("failed to get node %q: %w", p.NodeName, err) - } - - infoCmd := PgBackRestBackupCmd("info", "--output=json").StringSlice() - var output bytes.Buffer - err = PostgresContainerExec(ctx, &output, client, node.PrimaryInstanceID, infoCmd) - if err != nil { - // pgbackrest info returns a 0 exit code even if the stanza doesn't - // exist, so an error here means something else went wrong. - return fmt.Errorf("failed to exec pgbackrest info: %w, output: %s", err, output.String()) - } - info, err := pgbackrest.ParseInfoOutput(output.Bytes()) - if err != nil { - return fmt.Errorf("failed to parse pgbackrest info output: %w, output: %s", err, output.String()) - } - stanza := info.Stanza("db") - if stanza == nil { - // the stanza will be in the output even if it doesn't exist. - return fmt.Errorf("stanza %q not found in pgbackrest info output", "db") - } - // This status code will be non-zero if the repository is empty, even if - // it's otherwise configured correctly. - if stanza.Status.Code != 0 && stanza.Status.Message != "no valid backups" { - return resource.ErrNotFound - } - - return nil -} - -func (p *PgBackRestStanza) Create(ctx context.Context, rc *resource.Context) error { - client, err := do.Invoke[*docker.Docker](rc.Injector) - if err != nil { - return err - } - node, err := resource.FromContext[*database.NodeResource](rc, database.NodeResourceIdentifier(p.NodeName)) - if err != nil { - return fmt.Errorf("failed to get node %q: %w", p.NodeName, err) - } - - var stanzaCreateOut bytes.Buffer - stanzaCreateCmd := PgBackRestBackupCmd("stanza-create", "--io-timeout=10s").StringSlice() - err = PostgresContainerExec(ctx, &stanzaCreateOut, client, node.PrimaryInstanceID, stanzaCreateCmd) - if err != nil { - return fmt.Errorf("failed to exec pgbackrest stanza-create: %w, output: %s", err, stanzaCreateOut.String()) - } - var checkOut bytes.Buffer - checkCmd := PgBackRestBackupCmd("check").StringSlice() - err = PostgresContainerExec(ctx, &checkOut, client, node.PrimaryInstanceID, checkCmd) - if err != nil { - return fmt.Errorf("failed to exec pgbackrest check: %w, output: %s", err, checkOut.String()) - } - - return nil -} - -func (p *PgBackRestStanza) Update(ctx context.Context, rc *resource.Context) error { - return p.Create(ctx, rc) -} - -func (p *PgBackRestStanza) Delete(ctx context.Context, rc *resource.Context) error { - // Removing the stanza will delete all backups, so we don't do this - // automatically. Users can delete the stanza manually once the database is - // deleted. - return nil -} diff --git a/server/internal/orchestrator/swarm/postgres_certs.go b/server/internal/orchestrator/swarm/postgres_certs.go deleted file mode 100644 index 6df2af4c..00000000 --- a/server/internal/orchestrator/swarm/postgres_certs.go +++ /dev/null @@ -1,240 +0,0 @@ -package swarm - -import ( - "context" - "fmt" - "path/filepath" - "strings" - - "github.com/pgEdge/control-plane/server/internal/certificates" - "github.com/pgEdge/control-plane/server/internal/ds" - "github.com/pgEdge/control-plane/server/internal/filesystem" - "github.com/pgEdge/control-plane/server/internal/resource" - "github.com/samber/do" - "github.com/spf13/afero" -) - -var _ resource.Resource = (*PostgresCerts)(nil) - -const ResourceTypePostgresCerts resource.Type = "swarm.postgres_certs" - -func PostgresCertsIdentifier(instanceID string) resource.Identifier { - return resource.Identifier{ - ID: instanceID, - Type: ResourceTypePostgresCerts, - } -} - -type PostgresCerts struct { - InstanceID string `json:"instance_id"` - HostID string `json:"host_id"` - InstanceAddresses []string `json:"instance_addresses"` - ParentID string `json:"parent_id"` - OwnerUID int `json:"owner_uid"` - OwnerGID int `json:"owner_gid"` - CaCert []byte `json:"ca_cert"` - ServerCert []byte `json:"server_cert"` - ServerKey []byte `json:"server_key"` - SuperuserCert []byte `json:"superuser_cert"` - SuperuserKey []byte `json:"superuser_key"` - ReplicationCert []byte `json:"replication_cert"` - ReplicationKey []byte `json:"replication_key"` -} - -func (c *PostgresCerts) ResourceVersion() string { - return "1" -} - -func (c *PostgresCerts) DiffIgnore() []string { - return []string{ - "/ca_cert", - "/server_cert", - "/server_key", - "/superuser_cert", - "/superuser_key", - "/replication_cert", - "/replication_key", - } -} - -func (c *PostgresCerts) Executor() resource.Executor { - return resource.HostExecutor(c.HostID) -} - -func (c *PostgresCerts) Identifier() resource.Identifier { - return PostgresCertsIdentifier(c.InstanceID) -} - -func (c *PostgresCerts) Dependencies() []resource.Identifier { - return []resource.Identifier{ - filesystem.DirResourceIdentifier(c.ParentID), - } -} - -func (c *PostgresCerts) TypeDependencies() []resource.Type { - return nil -} - -func (c *PostgresCerts) Refresh(ctx context.Context, rc *resource.Context) error { - fs, err := do.Invoke[afero.Fs](rc.Injector) - if err != nil { - return err - } - - parentFullPath, err := filesystem.DirResourceFullPath(rc, c.ParentID) - if err != nil { - return fmt.Errorf("failed to get parent full path: %w", err) - } - postgresDir := filepath.Join(parentFullPath, "postgres") - - caCert, err := readResourceFile(fs, filepath.Join(postgresDir, "ca.crt")) - if err != nil { - return fmt.Errorf("failed to read CA cert: %w", err) - } - serverCert, err := readResourceFile(fs, filepath.Join(postgresDir, "server.crt")) - if err != nil { - return fmt.Errorf("failed to read server cert: %w", err) - } - serverKey, err := readResourceFile(fs, filepath.Join(postgresDir, "server.key")) - if err != nil { - return fmt.Errorf("failed to read server key: %w", err) - } - superuserCert, err := readResourceFile(fs, filepath.Join(postgresDir, "superuser.crt")) - if err != nil { - return fmt.Errorf("failed to read superuser cert: %w", err) - } - superuserKey, err := readResourceFile(fs, filepath.Join(postgresDir, "superuser.key")) - if err != nil { - return fmt.Errorf("failed to read superuser key: %w", err) - } - - replicationCert, err := readResourceFile(fs, filepath.Join(postgresDir, "replication.crt")) - if err != nil { - return fmt.Errorf("failed to read replication cert: %w", err) - } - - replicationKey, err := readResourceFile(fs, filepath.Join(postgresDir, "replication.key")) - if err != nil { - return fmt.Errorf("failed to read replication key: %w", err) - } - - c.CaCert = caCert - c.ServerCert = serverCert - c.ServerKey = serverKey - c.SuperuserCert = superuserCert - c.SuperuserKey = superuserKey - c.ReplicationCert = replicationCert - c.ReplicationKey = replicationKey - - return nil -} - -func (c *PostgresCerts) Create(ctx context.Context, rc *resource.Context) error { - fs, err := do.Invoke[afero.Fs](rc.Injector) - if err != nil { - return err - } - certService, err := do.Invoke[*certificates.Service](rc.Injector) - if err != nil { - return err - } - - parentFullPath, err := filesystem.DirResourceFullPath(rc, c.ParentID) - if err != nil { - return fmt.Errorf("failed to get parent full path: %w", err) - } - postgresDir := filepath.Join(parentFullPath, "postgres") - - // Ensure that localhost is included in the addresses - combined := ds.NewSet(c.InstanceAddresses...) - combined.Add("127.0.0.1", "localhost", "::1") - - pgServerPrincipal, err := certService.PostgresServer(ctx, - c.InstanceID, - combined.ToSortedSlice(strings.Compare), - ) - if err != nil { - return fmt.Errorf("failed to create postgres server principal: %w", err) - } - pgSuperuserPrincipal, err := certService.PostgresUser(ctx, c.InstanceID, "pgedge") - if err != nil { - return fmt.Errorf("failed to create pgedge postgres user principal: %w", err) - } - pgReplicatorPrincipal, err := certService.PostgresUser(ctx, c.InstanceID, "patroni_replicator") - if err != nil { - return fmt.Errorf("failed to create patroni_replicator postgres user principal: %w", err) - } - - c.CaCert = certService.CACert() - c.ServerCert = pgServerPrincipal.CertPEM - c.ServerKey = pgServerPrincipal.KeyPEM - c.SuperuserCert = pgSuperuserPrincipal.CertPEM - c.SuperuserKey = pgSuperuserPrincipal.KeyPEM - c.ReplicationCert = pgReplicatorPrincipal.CertPEM - c.ReplicationKey = pgReplicatorPrincipal.KeyPEM - - if err := fs.MkdirAll(postgresDir, 0o700); err != nil { - return fmt.Errorf("failed to create postgres certificates directory: %w", err) - } - if err := fs.Chown(postgresDir, c.OwnerUID, c.OwnerGID); err != nil { - return fmt.Errorf("failed to change ownership for certificates directory: %w", err) - } - - files := map[string][]byte{ - "ca.crt": c.CaCert, - "server.crt": c.ServerCert, - "server.key": c.ServerKey, - "superuser.crt": c.SuperuserCert, - "superuser.key": c.SuperuserKey, - "replication.crt": c.ReplicationCert, - "replication.key": c.ReplicationKey, - } - - for name, content := range files { - if err := afero.WriteFile(fs, filepath.Join(postgresDir, name), content, 0o600); err != nil { - return fmt.Errorf("failed to write %s: %w", name, err) - } - if err := fs.Chown(filepath.Join(postgresDir, name), c.OwnerUID, c.OwnerGID); err != nil { - return fmt.Errorf("failed to change ownership for %s: %w", name, err) - } - } - - return nil -} - -func (c *PostgresCerts) Update(ctx context.Context, rc *resource.Context) error { - return c.Create(ctx, rc) -} - -func (c *PostgresCerts) Delete(ctx context.Context, rc *resource.Context) error { - fs, err := do.Invoke[afero.Fs](rc.Injector) - if err != nil { - return err - } - certService, err := do.Invoke[*certificates.Service](rc.Injector) - if err != nil { - return err - } - - parentFullPath, err := filesystem.DirResourceFullPath(rc, c.ParentID) - if err != nil { - return fmt.Errorf("failed to get parent full path: %w", err) - } - postgresDir := filepath.Join(parentFullPath, "postgres") - - if err := fs.RemoveAll(postgresDir); err != nil { - return fmt.Errorf("failed to remove certificates directory: %w", err) - } - - if err := certService.RemovePostgresUser(ctx, c.InstanceID, "pgedge"); err != nil { - return fmt.Errorf("failed to remove pgedge postgres user principal: %w", err) - } - if err := certService.RemovePostgresUser(ctx, c.InstanceID, "patroni_replicator"); err != nil { - return fmt.Errorf("failed to remove patroni_replicator postgres user principal: %w", err) - } - if err := certService.RemovePostgresServer(ctx, c.InstanceID); err != nil { - return fmt.Errorf("failed to remove postgres server principal: %w", err) - } - - return nil -} diff --git a/server/internal/orchestrator/swarm/postgres_service_spec.go b/server/internal/orchestrator/swarm/postgres_service_spec.go index a6ba8eb3..28952dff 100644 --- a/server/internal/orchestrator/swarm/postgres_service_spec.go +++ b/server/internal/orchestrator/swarm/postgres_service_spec.go @@ -8,6 +8,7 @@ import ( "github.com/pgEdge/control-plane/server/internal/database" "github.com/pgEdge/control-plane/server/internal/filesystem" + "github.com/pgEdge/control-plane/server/internal/orchestrator/common" "github.com/pgEdge/control-plane/server/internal/resource" ) @@ -59,8 +60,8 @@ func (s *PostgresServiceSpecResource) Dependencies() []resource.Identifier { filesystem.DirResourceIdentifier(s.ConfigsDirID), filesystem.DirResourceIdentifier(s.CertificatesDirID), NetworkResourceIdentifier(s.DatabaseNetworkName), - EtcdCredsIdentifier(s.Instance.InstanceID), - PostgresCertsIdentifier(s.Instance.InstanceID), + common.EtcdCredsIdentifier(s.Instance.InstanceID), + common.PostgresCertsIdentifier(s.Instance.InstanceID), PatroniConfigIdentifier(s.Instance.InstanceID), } } diff --git a/server/internal/orchestrator/swarm/postgrest_config_resource.go b/server/internal/orchestrator/swarm/postgrest_config_resource.go index 12a5e34d..6f1ab58f 100644 --- a/server/internal/orchestrator/swarm/postgrest_config_resource.go +++ b/server/internal/orchestrator/swarm/postgrest_config_resource.go @@ -10,6 +10,7 @@ import ( "github.com/pgEdge/control-plane/server/internal/database" "github.com/pgEdge/control-plane/server/internal/filesystem" + "github.com/pgEdge/control-plane/server/internal/orchestrator/common" "github.com/pgEdge/control-plane/server/internal/resource" ) @@ -77,7 +78,7 @@ func (r *PostgRESTConfigResource) Refresh(ctx context.Context, rc *resource.Cont return fmt.Errorf("failed to get service data dir path: %w", err) } - _, err = readResourceFile(fs, filepath.Join(dirPath, "postgrest.conf")) + _, err = common.ReadResourceFile(fs, filepath.Join(dirPath, "postgrest.conf")) if err != nil { return fmt.Errorf("failed to read PostgREST config: %w", err) } diff --git a/server/internal/orchestrator/swarm/rag_config_resource.go b/server/internal/orchestrator/swarm/rag_config_resource.go index 62d6ecb2..1be4f519 100644 --- a/server/internal/orchestrator/swarm/rag_config_resource.go +++ b/server/internal/orchestrator/swarm/rag_config_resource.go @@ -10,6 +10,7 @@ import ( "github.com/pgEdge/control-plane/server/internal/database" "github.com/pgEdge/control-plane/server/internal/filesystem" + "github.com/pgEdge/control-plane/server/internal/orchestrator/common" "github.com/pgEdge/control-plane/server/internal/resource" ) @@ -88,7 +89,7 @@ func (r *RAGConfigResource) Refresh(ctx context.Context, rc *resource.Context) e return fmt.Errorf("failed to get service data dir path: %w", err) } - _, err = readResourceFile(fs, filepath.Join(dirPath, ragConfigFilename)) + _, err = common.ReadResourceFile(fs, filepath.Join(dirPath, ragConfigFilename)) if err != nil { return fmt.Errorf("failed to read RAG config: %w", err) } diff --git a/server/internal/orchestrator/swarm/resources.go b/server/internal/orchestrator/swarm/resources.go index 53873cad..d943daef 100644 --- a/server/internal/orchestrator/swarm/resources.go +++ b/server/internal/orchestrator/swarm/resources.go @@ -8,14 +8,8 @@ func RegisterResourceTypes(registry *resource.Registry) { resource.RegisterResourceType[*ServiceInstanceSpecResource](registry, ResourceTypeServiceInstanceSpec) resource.RegisterResourceType[*ServiceInstanceResource](registry, ResourceTypeServiceInstance) resource.RegisterResourceType[*Network](registry, ResourceTypeNetwork) - resource.RegisterResourceType[*EtcdCreds](registry, ResourceTypeEtcdCreds) resource.RegisterResourceType[*PatroniConfig](registry, ResourceTypePatroniConfig) - resource.RegisterResourceType[*PostgresCerts](registry, ResourceTypePostgresCerts) - resource.RegisterResourceType[*PgBackRestConfig](registry, ResourceTypePgBackRestConfig) resource.RegisterResourceType[*PgBackRestRestore](registry, ResourceTypePgBackRestRestore) - resource.RegisterResourceType[*PgBackRestStanza](registry, ResourceTypePgBackRestStanza) - resource.RegisterResourceType[*PatroniCluster](registry, ResourceTypePatroniCluster) - resource.RegisterResourceType[*PatroniMember](registry, ResourceTypePatroniMember) resource.RegisterResourceType[*CheckWillRestart](registry, ResourceTypeCheckWillRestart) resource.RegisterResourceType[*Switchover](registry, ResourceTypeSwitchover) resource.RegisterResourceType[*ScaleService](registry, ResourceTypeScaleService) diff --git a/server/internal/orchestrator/swarm/spec.go b/server/internal/orchestrator/swarm/spec.go index 6ffa7640..4f252261 100644 --- a/server/internal/orchestrator/swarm/spec.go +++ b/server/internal/orchestrator/swarm/spec.go @@ -1,6 +1,7 @@ package swarm import ( + "fmt" "time" "github.com/docker/docker/api/types/container" @@ -96,7 +97,7 @@ func DatabaseServiceSpec( "PATRONICTL_CONFIG_FILE=/opt/pgedge/configs/patroni.yaml", }, Healthcheck: &container.HealthConfig{ - Test: []string{"CMD-SHELL", "curl -Ssf http://localhost:8888/liveness"}, + Test: []string{"CMD-SHELL", fmt.Sprintf("curl -Ssf http://localhost:%d/liveness", PatroniContainerPort)}, StartPeriod: time.Second * 5, Interval: time.Second * 5, Timeout: time.Second * 3, @@ -136,7 +137,7 @@ func buildPostgresPortConfig(port *int) []swarm.PortConfig { config := swarm.PortConfig{ PublishMode: swarm.PortConfigPublishModeHost, - TargetPort: 5432, + TargetPort: uint32(PostgresContainerPort), Name: "postgres", Protocol: swarm.PortConfigProtocolTCP, } diff --git a/server/internal/resource/migrations/1_0_0.go b/server/internal/resource/migrations/1_0_0.go index 19bad223..f8ae42db 100644 --- a/server/internal/resource/migrations/1_0_0.go +++ b/server/internal/resource/migrations/1_0_0.go @@ -22,7 +22,7 @@ func (v *Version_1_0_0) Version() *ds.Version { return resource.StateVersion_1_0_0 } -func (v *Version_1_0_0) Run(state *resource.State) error { +func (v *Version_1_0_0) Run(_ string, state *resource.State) error { instances, ok := state.Resources[v0_0_0.ResourceTypeInstance] if !ok || len(instances) == 0 { // All of the changes in this version are to resources that depend on diff --git a/server/internal/resource/migrations/1_0_0_test.go b/server/internal/resource/migrations/1_0_0_test.go index 5a6fdf4b..820ce543 100644 --- a/server/internal/resource/migrations/1_0_0_test.go +++ b/server/internal/resource/migrations/1_0_0_test.go @@ -10,23 +10,9 @@ import ( "github.com/pgEdge/control-plane/server/internal/resource" "github.com/pgEdge/control-plane/server/internal/resource/migrations" "github.com/pgEdge/control-plane/server/internal/resource/migrations/schemas/v0_0_0" - "github.com/pgEdge/control-plane/server/internal/testutils" ) func TestVersion_1_0_0(t *testing.T) { - golden := &testutils.GoldenTest[*resource.State]{ - Compare: func(t testing.TB, expected, actual *resource.State) { - // The json.RawValue ends up indented in our actual, so we'll round - // trip the actual value to get the same indentation. - raw, err := json.MarshalIndent(actual, "", " ") - require.NoError(t, err) - - var roundTrippedActual *resource.State - require.NoError(t, json.Unmarshal(raw, &roundTrippedActual)) - - require.Equal(t, expected, roundTrippedActual) - }, - } for _, tc := range []struct { name string in []*resource.ResourceData @@ -165,7 +151,7 @@ func TestVersion_1_0_0(t *testing.T) { state.Add(tc.in...) migration := &migrations.Version_1_0_0{} - migration.Run(state) + migration.Run("database-1", state) golden.Run(t, state, update) diff --git a/server/internal/resource/migrations/1_1_0.go b/server/internal/resource/migrations/1_1_0.go index b2691e5a..13404bc1 100644 --- a/server/internal/resource/migrations/1_1_0.go +++ b/server/internal/resource/migrations/1_1_0.go @@ -16,7 +16,7 @@ func (v *Version_1_1_0) Version() *ds.Version { return resource.StateVersion_1_1_0 } -func (v *Version_1_1_0) Run(state *resource.State) error { +func (v *Version_1_1_0) Run(_ string, state *resource.State) error { const serviceUserRoleType resource.Type = "swarm.service_user_role" // 1. Delete all service_user_role resources from state diff --git a/server/internal/resource/migrations/1_1_0_test.go b/server/internal/resource/migrations/1_1_0_test.go index 3dad2147..539c564e 100644 --- a/server/internal/resource/migrations/1_1_0_test.go +++ b/server/internal/resource/migrations/1_1_0_test.go @@ -46,7 +46,7 @@ func TestVersion_1_1_0(t *testing.T) { } migration := &migrations.Version_1_1_0{} - err := migration.Run(state) + err := migration.Run("database-1", state) require.NoError(t, err) // service_user_role resources should be gone @@ -112,7 +112,7 @@ func TestVersion_1_1_0(t *testing.T) { } migration := &migrations.Version_1_1_0{} - err := migration.Run(state) + err := migration.Run("database-1", state) require.NoError(t, err) // All service_user_role resources should be gone @@ -146,7 +146,7 @@ func TestVersion_1_1_0(t *testing.T) { } migration := &migrations.Version_1_1_0{} - err := migration.Run(state) + err := migration.Run("database-1", state) require.NoError(t, err) // mcp_config should be untouched @@ -162,7 +162,7 @@ func TestVersion_1_1_0(t *testing.T) { } migration := &migrations.Version_1_1_0{} - err := migration.Run(state) + err := migration.Run("database-1", state) require.NoError(t, err) }) } diff --git a/server/internal/resource/migrations/1_2_0.go b/server/internal/resource/migrations/1_2_0.go new file mode 100644 index 00000000..5398c177 --- /dev/null +++ b/server/internal/resource/migrations/1_2_0.go @@ -0,0 +1,598 @@ +package migrations + +import ( + "encoding/json" + "errors" + "fmt" + "net/netip" + "slices" + "strings" + + "github.com/alessio/shellescape" + + "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/ds" + "github.com/pgEdge/control-plane/server/internal/pgbackrest" + "github.com/pgEdge/control-plane/server/internal/resource" + "github.com/pgEdge/control-plane/server/internal/resource/migrations/schemas/v1_1_0" + "github.com/pgEdge/control-plane/server/internal/resource/migrations/schemas/v1_2_0" + "github.com/pgEdge/control-plane/server/internal/utils" +) + +var _ resource.StateMigration = (*Version_1_2_0)(nil) + +// Version_1_2_0 migrates the swarm orchestrator to use the common orchestrator +// resources. +type Version_1_2_0 struct{} + +func (v *Version_1_2_0) Version() *ds.Version { + return resource.StateVersion_1_2_0 +} + +func (v *Version_1_2_0) Run(databaseID string, state *resource.State) error { + err := errors.Join( + v.migrateEtcdCreds(state), + v.migratePatroniCluster(state), + v.migratePatroniMember(state), + v.migratePgBackRestConfig(state), + v.migratePgBackRestStanza(databaseID, state), + v.migratePostgresCerts(state), + v.migratePatroniConfig(state), + ) + v.updateDependencies(state) + return err +} + +func (v *Version_1_2_0) updateDependencies(state *resource.State) { + // This is a catch-all to update dependencies for the removed resources + // types. + 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, + } + } + } + } +} + +func (v *Version_1_2_0) migrateEtcdCreds(state *resource.State) error { + resources, ok := state.Resources[v1_1_0.ResourceTypeEtcdCreds] + if !ok { + return nil + } + // Deleting from a map while iterating is safe, but adding to it is + // unpredictable. We'll delete old resources as we go and then add new ones + // back at the end. + adds := make([]*resource.ResourceData, 0, len(resources)) + for oldID, data := range resources { + var old v1_1_0.EtcdCreds + if err := json.Unmarshal(data.Attributes, &old); err != nil { + return fmt.Errorf("failed to unmarshal %s resource: %w", v1_1_0.ResourceTypeEtcdCreds, err) + } + new := v1_2_0.EtcdCreds{ + InstanceID: old.InstanceID, + DatabaseID: old.DatabaseID, + HostID: old.HostID, + NodeName: old.NodeName, + ParentID: old.ParentID, + OwnerUID: old.OwnerUID, + OwnerGID: old.OwnerGID, + Username: old.Username, + Password: old.Password, + CaCert: old.CaCert, + ClientCert: old.ClientCert, + ClientKey: old.ClientKey, + } + attrs, err := json.Marshal(new) + if err != nil { + return fmt.Errorf("failed to marshal %s resource: %w", v1_2_0.ResourceTypeEtcdCreds, err) + } + adds = append(adds, &resource.ResourceData{ + Identifier: v1_2_0.EtcdCredsIdentifier(new.InstanceID), + Attributes: attrs, + Dependencies: []resource.Identifier{ + v1_2_0.DirResourceIdentifier(new.ParentID), + }, + Executor: data.Executor, + ResourceVersion: "1", + NeedsRecreate: data.NeedsRecreate, + // client_cert and client_key were misnamed as server_cert and + // server_key in the old swarm resource. + DiffIgnore: []string{ + "/username", + "/password", + "/ca_cert", + "/client_cert", + "/client_key", + }, + PendingDeletion: data.PendingDeletion, + Error: data.Error, + TypeDependencies: data.TypeDependencies, + }) + state.RemoveByIdentifier(resource.Identifier{ + Type: v1_1_0.ResourceTypeEtcdCreds, + ID: oldID, + }) + } + state.Add(adds...) + + return nil +} + +func (v *Version_1_2_0) migratePatroniCluster(state *resource.State) error { + resources, ok := state.Resources[v1_1_0.ResourceTypePatroniCluster] + if !ok { + return nil + } + adds := make([]*resource.ResourceData, 0, len(resources)) + for oldID, data := range resources { + var old v1_1_0.PatroniCluster + if err := json.Unmarshal(data.Attributes, &old); err != nil { + return fmt.Errorf("failed to unmarshal %s resource: %w", v1_1_0.ResourceTypePatroniCluster, err) + } + new := v1_2_0.PatroniCluster{ + DatabaseID: old.DatabaseID, + NodeName: old.NodeName, + } + attrs, err := json.Marshal(new) + if err != nil { + return fmt.Errorf("failed to marshal %s resource: %w", v1_2_0.ResourceTypePatroniCluster, err) + } + adds = append(adds, &resource.ResourceData{ + Identifier: v1_2_0.PatroniClusterResourceIdentifier(new.NodeName), + Attributes: attrs, + 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.ResourceTypePatroniCluster, + ID: oldID, + }) + } + state.Add(adds...) + + return nil +} + +func (v *Version_1_2_0) migratePatroniMember(state *resource.State) error { + resources, ok := state.Resources[v1_1_0.ResourceTypePatroniMember] + if !ok { + return nil + } + adds := make([]*resource.ResourceData, 0, len(resources)) + for oldID, data := range resources { + var old v1_1_0.PatroniMember + if err := json.Unmarshal(data.Attributes, &old); err != nil { + return fmt.Errorf("failed to unmarshal %s resource: %w", v1_1_0.ResourceTypePatroniMember, err) + } + new := v1_2_0.PatroniMember{ + DatabaseID: old.DatabaseID, + NodeName: old.NodeName, + InstanceID: old.InstanceID, + } + attrs, err := json.Marshal(new) + if err != nil { + return fmt.Errorf("failed to marshal %s resource: %w", v1_2_0.ResourceTypePatroniMember, err) + } + adds = append(adds, &resource.ResourceData{ + Identifier: v1_2_0.PatroniMemberResourceIdentifier(new.InstanceID), + Attributes: attrs, + Dependencies: []resource.Identifier{ + v1_2_0.PatroniClusterResourceIdentifier(new.NodeName), + }, + 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.ResourceTypePatroniMember, + ID: oldID, + }) + } + state.Add(adds...) + + return nil +} + +func (v *Version_1_2_0) migratePgBackRestConfig(state *resource.State) error { + resources, ok := state.Resources[v1_1_0.ResourceTypePgBackRestConfig] + if !ok { + return nil + } + adds := make([]*resource.ResourceData, 0, len(resources)) + for oldID, data := range resources { + var old v1_1_0.PgBackRestConfig + if err := json.Unmarshal(data.Attributes, &old); err != nil { + return fmt.Errorf("failed to unmarshal %s resource: %w", v1_1_0.ResourceTypePgBackRestConfig, err) + } + paths := v.swarmInstancePaths() + new := v1_2_0.PgBackRestConfig{ + InstanceID: old.InstanceID, + HostID: old.HostID, + DatabaseID: old.DatabaseID, + NodeName: old.NodeName, + Repositories: old.Repositories, + ParentID: old.ParentID, + Type: old.Type, + OwnerUID: old.OwnerUID, + OwnerGID: old.OwnerGID, + Port: 5432, + Paths: struct { + Instance struct { + BaseDir string `json:"base_dir"` + } `json:"instance"` + Host struct { + BaseDir string `json:"base_dir"` + } `json:"host"` + PgBackRestPath string `json:"pg_backrest_path"` + PatroniPath string `json:"patroni_path"` + }{ + Instance: struct { + BaseDir string `json:"base_dir"` + }{ + BaseDir: paths.Instance.BaseDir, + }, + PgBackRestPath: paths.PgBackRestPath, + PatroniPath: paths.PatroniPath, + }, + } + attrs, err := json.Marshal(new) + if err != nil { + return fmt.Errorf("failed to marshal %s resource: %w", v1_2_0.ResourceTypePgBackRestConfig, err) + } + adds = append(adds, &resource.ResourceData{ + Identifier: v1_2_0.PgBackRestConfigIdentifier(new.InstanceID, pgbackrest.ConfigType(new.Type)), + Attributes: attrs, + Dependencies: []resource.Identifier{ + v1_2_0.DirResourceIdentifier(new.ParentID), + }, + 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.ResourceTypePgBackRestConfig, + 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 { + return nil + } + adds := make([]*resource.ResourceData, 0, len(resources)) + for oldID, data := range resources { + var old v1_1_0.PgBackRestStanza + if err := json.Unmarshal(data.Attributes, &old); err != nil { + return fmt.Errorf("failed to unmarshal %s resource: %w", v1_1_0.ResourceTypePgBackRestStanza, err) + } + new := v1_2_0.PgBackRestStanza{ + DatabaseID: databaseID, + NodeName: old.NodeName, + } + attrs, err := json.Marshal(new) + if err != nil { + return fmt.Errorf("failed to marshal %s resource: %w", v1_2_0.ResourceTypePgBackRestStanza, err) + } + adds = append(adds, &resource.ResourceData{ + Identifier: v1_2_0.PgBackRestStanzaIdentifier(new.NodeName), + Attributes: attrs, + Dependencies: []resource.Identifier{ + v1_2_0.NodeResourceIdentifier(new.NodeName), + }, + 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.ResourceTypePgBackRestStanza, + ID: oldID, + }) + } + state.Add(adds...) + + return nil +} + +func (v *Version_1_2_0) migratePostgresCerts(state *resource.State) error { + resources, ok := state.Resources[v1_1_0.ResourceTypePostgresCerts] + if !ok { + return nil + } + adds := make([]*resource.ResourceData, 0, len(resources)) + for oldID, data := range resources { + var old v1_1_0.PostgresCerts + if err := json.Unmarshal(data.Attributes, &old); err != nil { + return fmt.Errorf("failed to unmarshal %s resource: %w", v1_1_0.ResourceTypePostgresCerts, err) + } + new := v1_2_0.PostgresCerts{ + InstanceID: old.InstanceID, + HostID: old.HostID, + InstanceAddresses: old.InstanceAddresses, + ParentID: old.ParentID, + OwnerUID: old.OwnerUID, + OwnerGID: old.OwnerGID, + CaCert: old.CaCert, + ServerCert: old.ServerCert, + ServerKey: old.ServerKey, + SuperuserCert: old.SuperuserCert, + SuperuserKey: old.SuperuserKey, + ReplicationCert: old.ReplicationCert, + ReplicationKey: old.ReplicationKey, + } + attrs, err := json.Marshal(new) + if err != nil { + return fmt.Errorf("failed to marshal %s resource: %w", v1_2_0.ResourceTypePostgresCerts, err) + } + adds = append(adds, &resource.ResourceData{ + Identifier: v1_2_0.PostgresCertsIdentifier(new.InstanceID), + Attributes: attrs, + Dependencies: []resource.Identifier{ + v1_2_0.DirResourceIdentifier(new.ParentID), + }, + 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.ResourceTypePostgresCerts, + ID: oldID, + }) + } + state.Add(adds...) + + return nil +} + +func (v *Version_1_2_0) migratePatroniConfig(state *resource.State) error { + resources, ok := state.Resources[v1_1_0.ResourceTypePatroniConfig] + if !ok { + return nil + } + adds := make([]*resource.ResourceData, 0, len(resources)) + for oldID, data := range resources { + var old v1_1_0.PatroniConfig + if err := json.Unmarshal(data.Attributes, &old); err != nil { + return fmt.Errorf("failed to unmarshal %s resource: %w", v1_1_0.ResourceTypePatroniConfig, err) + } + if old.Spec == nil { + return fmt.Errorf("old patroni config '%s' is missing a spec", oldID) + } + paths := database.InstancePaths{ + // We can't easily get the host paths here. Luckily, they're + // irrelevant for this migration. + Instance: database.Paths{BaseDir: "/opt/pgedge"}, + PgBackRestPath: "/usr/bin/pgbackrest", + PatroniPath: "/usr/local/bin/patroni", + } + var archiveCommand, restoreCommand string + if old.Spec.BackupConfig != nil { + archiveCommand = paths.PgBackRestBackupCmd("archive-push", `"%p"`).String() + } + if old.Spec.RestoreConfig != nil { + if old.Spec.InPlaceRestore { + restoreCommand = strings.Join(paths.InstanceMvRestoreToDataCmd(), " ") + } else { + restoreOptions := utils.BuildOptionArgs(old.Spec.RestoreConfig.RestoreOptions) + for i, o := range restoreOptions { + restoreOptions[i] = shellescape.Quote(o) + } + restoreCommand = paths.PgBackRestRestoreCmd("restore", restoreOptions...).String() + } + } + cpus := old.Spec.CPUs + if cpus == 0 { + cpus = old.HostCPUs + } + memoryBytes := old.Spec.MemoryBytes + if memoryBytes == 0 { + memoryBytes = old.HostMemoryBytes + } + var bridgeNetworkInfo *struct { + Name string `json:"name"` + ID string `json:"id"` + Subnet netip.Prefix `json:"subnet"` + Gateway netip.Addr `json:"gateway"` + } + if old.BridgeNetworkInfo != nil { + bridgeNetworkInfo = &struct { + Name string `json:"name"` + ID string `json:"id"` + Subnet netip.Prefix `json:"subnet"` + Gateway netip.Addr `json:"gateway"` + }{ + Name: old.BridgeNetworkInfo.Name, + ID: old.BridgeNetworkInfo.ID, + Subnet: old.BridgeNetworkInfo.Subnet, + Gateway: old.BridgeNetworkInfo.Gateway, + } + } + new := v1_2_0.PatroniConfig{ + DatabaseID: old.Spec.DatabaseID, + BridgeNetworkInfo: bridgeNetworkInfo, + DatabaseNetworkName: old.DatabaseNetworkName, + Base: &struct { + InstanceID string `json:"instance_id"` + HostID string `json:"host_id"` + NodeName string `json:"node_name"` + Generator *struct { + ArchiveCommand string `json:"archive_command,omitempty"` + ClusterSize int `json:"cluster_size"` + CPUs float64 `json:"cpus,omitempty"` + DatabaseID string `json:"database_id"` + DataDir string `json:"data_dir"` + EtcdCertsDir string `json:"etcd_certs_dir"` + FQDN string `json:"fqdn"` + InstanceID string `json:"instance_id"` + LogType string `json:"log_type"` + MemoryBytes uint64 `json:"memory_bytes,omitempty"` + NodeName string `json:"node_name"` + NodeOrdinal int `json:"node_ordinal"` + NodeSize int `json:"node_size"` + OrchestratorParameters map[string]any `json:"orchestrator_parameters,omitempty"` + PatroniAllowlist []string `json:"patroni_allowlist"` + PatroniPort int `json:"patroni_port"` + PgHbaConf []string `json:"pg_hba_conf,omitempty"` + PgIdentConf []string `json:"pg_ident_conf,omitempty"` + PostgresCertsDir string `json:"postgres_certs_dir"` + PostgresPort int `json:"postgres_port"` + RestoreCommand string `json:"restore_command"` + SpecParameters map[string]any `json:"spec_parameters,omitempty"` + TenantID *string `json:"tenant_id,omitempty"` + } `json:"generator"` + ParentID string `json:"parent_id"` + OwnerUID int `json:"owner_uid"` + OwnerGID int `json:"owner_gid"` + }{ + InstanceID: old.Spec.InstanceID, + HostID: old.Spec.HostID, + NodeName: old.Spec.NodeName, + ParentID: old.ParentID, + OwnerUID: old.OwnerUID, + OwnerGID: old.OwnerGID, + Generator: &struct { + ArchiveCommand string `json:"archive_command,omitempty"` + ClusterSize int `json:"cluster_size"` + CPUs float64 `json:"cpus,omitempty"` + DatabaseID string `json:"database_id"` + DataDir string `json:"data_dir"` + EtcdCertsDir string `json:"etcd_certs_dir"` + FQDN string `json:"fqdn"` + InstanceID string `json:"instance_id"` + LogType string `json:"log_type"` + MemoryBytes uint64 `json:"memory_bytes,omitempty"` + NodeName string `json:"node_name"` + NodeOrdinal int `json:"node_ordinal"` + NodeSize int `json:"node_size"` + OrchestratorParameters map[string]any `json:"orchestrator_parameters,omitempty"` + PatroniAllowlist []string `json:"patroni_allowlist"` + PatroniPort int `json:"patroni_port"` + PgHbaConf []string `json:"pg_hba_conf,omitempty"` + PgIdentConf []string `json:"pg_ident_conf,omitempty"` + PostgresCertsDir string `json:"postgres_certs_dir"` + PostgresPort int `json:"postgres_port"` + RestoreCommand string `json:"restore_command"` + SpecParameters map[string]any `json:"spec_parameters,omitempty"` + TenantID *string `json:"tenant_id,omitempty"` + }{ + ArchiveCommand: archiveCommand, + ClusterSize: old.Spec.ClusterSize, + CPUs: cpus, + DatabaseID: old.Spec.DatabaseID, + DataDir: paths.Instance.PgData(), + EtcdCertsDir: paths.Instance.EtcdCertificates(), + FQDN: old.InstanceHostname, + InstanceID: old.Spec.InstanceID, + LogType: "json", // Swarm always uses JSON logging + MemoryBytes: memoryBytes, + NodeName: old.Spec.NodeName, + NodeOrdinal: old.Spec.NodeOrdinal, + NodeSize: old.Spec.NodeSize, + PatroniPort: 8888, + PgHbaConf: old.Spec.PgHbaConf, + PgIdentConf: old.Spec.PgIdentConf, + PostgresCertsDir: paths.Instance.PostgresCertificates(), + PostgresPort: 5432, + RestoreCommand: restoreCommand, + SpecParameters: old.Spec.PostgreSQLConf, + TenantID: old.Spec.TenantID, + }, + }, + } + attrs, err := json.Marshal(new) + if err != nil { + return fmt.Errorf("failed to marshal %s resource: %w", v1_2_0.ResourceTypePatroniConfig, err) + } + var pgBackRestDeps []resource.Identifier + if new.Base.Generator.ArchiveCommand != "" { + pgBackRestDeps = append(pgBackRestDeps, v1_2_0.PgBackRestConfigIdentifier(new.Base.InstanceID, pgbackrest.ConfigTypeBackup)) + } + if new.Base.Generator.RestoreCommand != "" { + pgBackRestDeps = append(pgBackRestDeps, v1_2_0.PgBackRestConfigIdentifier(new.Base.InstanceID, pgbackrest.ConfigTypeRestore)) + } + adds = append(adds, &resource.ResourceData{ + Identifier: v1_2_0.PatroniConfigIdentifier(new.Base.InstanceID), + Attributes: attrs, + Dependencies: slices.Concat( + []resource.Identifier{ + v1_2_0.DirResourceIdentifier(new.Base.ParentID), + v1_2_0.EtcdCredsIdentifier(new.Base.InstanceID), + v1_2_0.PatroniMemberResourceIdentifier(new.Base.InstanceID), + v1_2_0.PatroniClusterResourceIdentifier(new.Base.NodeName), + v1_2_0.NetworkResourceIdentifier(new.DatabaseNetworkName), + }, + pgBackRestDeps, + ), + 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.ResourceTypePatroniConfig, + ID: oldID, + }) + } + state.Add(adds...) + + return nil +} + +func (v *Version_1_2_0) swarmInstancePaths() database.InstancePaths { + return database.InstancePaths{ + // We can't easily get the host paths here. Luckily, they're + // irrelevant for this migration. + Instance: database.Paths{BaseDir: "/opt/pgedge"}, + PgBackRestPath: "/usr/bin/pgbackrest", + PatroniPath: "/usr/local/bin/patroni", + } +} diff --git a/server/internal/resource/migrations/1_2_0_test.go b/server/internal/resource/migrations/1_2_0_test.go new file mode 100644 index 00000000..239a699b --- /dev/null +++ b/server/internal/resource/migrations/1_2_0_test.go @@ -0,0 +1,700 @@ +package migrations_test + +import ( + "net/netip" + "slices" + "testing" + + "github.com/pgEdge/control-plane/server/internal/ds" + "github.com/pgEdge/control-plane/server/internal/pgbackrest" + "github.com/pgEdge/control-plane/server/internal/resource" + "github.com/pgEdge/control-plane/server/internal/resource/migrations" + "github.com/pgEdge/control-plane/server/internal/resource/migrations/schemas/v1_1_0" + "github.com/stretchr/testify/require" +) + +func TestVersion_1_2_0(t *testing.T) { + databaseID := "database-1" + + for _, tc := range []struct { + name string + in []*resource.ResourceData + }{ + { + name: "simple instance", + 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", false, false, false), + v1_1_0_configsDir(t, "host-1"), + v1_1_0_network(t, databaseID), + }, + }, + { + name: "with backup config", + 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), + }, + }, + { + name: "with restore config", + 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", false, true, false), + v1_1_0_pgBackRestConfig(t, databaseID, "instance-1", "host-1", "n1", pgbackrest.ConfigTypeRestore), + v1_1_0_pgBackRestStanza(t, "n1"), + v1_1_0_configsDir(t, "host-1"), + v1_1_0_network(t, databaseID), + }, + }, + { + name: "with in-place restore config", + 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", false, true, true), + v1_1_0_pgBackRestConfig(t, databaseID, "instance-1", "host-1", "n1", pgbackrest.ConfigTypeRestore), + v1_1_0_pgBackRestStanza(t, "n1"), + v1_1_0_configsDir(t, "host-1"), + v1_1_0_network(t, databaseID), + }, + }, + { + name: "with backup and restore configs", + 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, true, false), + v1_1_0_pgBackRestConfig(t, databaseID, "instance-1", "host-1", "n1", pgbackrest.ConfigTypeBackup), + v1_1_0_pgBackRestConfig(t, databaseID, "instance-1", "host-1", "n1", pgbackrest.ConfigTypeRestore), + v1_1_0_pgBackRestStanza(t, "n1"), + v1_1_0_configsDir(t, "host-1"), + v1_1_0_network(t, databaseID), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + state := &resource.State{ + Version: resource.StateVersion_1_1_0, + Resources: map[resource.Type]map[string]*resource.ResourceData{}, + } + state.Add(tc.in...) + + migration := &migrations.Version_1_2_0{} + migration.Run(databaseID, state) + state.Version = migration.Version() + + golden.Run(t, state, update) + + // Validate that the dependencies are correct. + _, err := state.PlanRefresh() + require.NoError(t, err) + + _, err = state.PlanAll(resource.PlanOptions{}, state) + require.NoError(t, err) + }) + } +} + +func v1_1_0_node(t testing.TB, nodeName string, instanceIDs ...string) *resource.ResourceData { + deps := make([]resource.Identifier, len(instanceIDs)) + for i, id := range instanceIDs { + deps[i] = v1_1_0.InstanceResourceIdentifier(id) + } + + return &resource.ResourceData{ + Executor: resource.AnyExecutor(), + Identifier: v1_1_0.NodeResourceIdentifier(nodeName), + ResourceVersion: "1", + Attributes: mustJSON(t, v1_1_0.NodeResource{ + Name: nodeName, + InstanceIDs: instanceIDs, + PrimaryInstanceID: instanceIDs[0], + }), + Dependencies: deps, + } +} + +func v1_1_0_instance(t testing.TB, databaseID, instanceID, hostID, nodeName string) *resource.ResourceData { + return &resource.ResourceData{ + Executor: resource.HostExecutor(hostID), + Identifier: v1_1_0.InstanceResourceIdentifier(instanceID), + ResourceVersion: "1", + DiffIgnore: []string{ + "/primary_instance_id", + "/connection_info", + }, + // We don't need every property here. The instance isn't part of the + // migration, we just need it for the dependencies in our test plan. + Attributes: mustJSON(t, map[string]any{ + "spec": map[string]any{ + "database_id": databaseID, + "instance_id": instanceID, + "database_name": "test", + "host_id": hostID, + "node_name": nodeName, + "database_users": []map[string]any{ + { + "username": "admin", + "db_owner": true, + }, + }, + }, + }), + } +} + +func v1_1_0_etcdCreds(t testing.TB, databaseID, instanceID, hostID, nodeName string) *resource.ResourceData { + return &resource.ResourceData{ + Executor: resource.HostExecutor(hostID), + Identifier: v1_1_0.EtcdCredsIdentifier(instanceID), + ResourceVersion: "1", + DiffIgnore: []string{ + "/username", + "/password", + "/ca_cert", + "/client_cert", + "/client_key", + }, + Attributes: mustJSON(t, v1_1_0.EtcdCreds{ + InstanceID: instanceID, + DatabaseID: databaseID, + HostID: hostID, + NodeName: nodeName, + ParentID: v1_1_0_configsDirID(hostID), + OwnerUID: 123, + OwnerGID: 124, + Username: "username", + Password: "password", + CaCert: []byte("ca_cert"), + ClientCert: []byte("client_cert"), + ClientKey: []byte("client_key"), + }), + Dependencies: []resource.Identifier{ + v1_1_0.DirResourceIdentifier(v1_1_0_configsDirID(hostID)), + }, + } +} + +func v1_1_0_patroniCluster(t testing.TB, databaseID, nodeName string) *resource.ResourceData { + return &resource.ResourceData{ + Executor: resource.AnyExecutor(), + Identifier: v1_1_0.PatroniClusterResourceIdentifier(nodeName), + ResourceVersion: "1", + DiffIgnore: nil, + Attributes: mustJSON(t, v1_1_0.PatroniCluster{ + DatabaseID: databaseID, + PatroniClusterPrefix: "/patroni/" + databaseID + ":" + nodeName, + NodeName: nodeName, + }), + } +} + +func v1_1_0_patroniMember(t testing.TB, databaseID, instanceID, nodeName string) *resource.ResourceData { + return &resource.ResourceData{ + Executor: resource.AnyExecutor(), + Identifier: v1_1_0.PatroniMemberResourceIdentifier(nodeName), + ResourceVersion: "1", + DiffIgnore: nil, + Attributes: mustJSON(t, v1_1_0.PatroniMember{ + DatabaseID: databaseID, + InstanceID: instanceID, + NodeName: nodeName, + }), + Dependencies: []resource.Identifier{ + v1_1_0.PatroniClusterResourceIdentifier(nodeName), + }, + } +} + +func v1_1_0_pgBackRestConfig(t testing.TB, databaseID, instanceID, hostID, nodeName string, configType pgbackrest.ConfigType) *resource.ResourceData { + return &resource.ResourceData{ + Executor: resource.HostExecutor(hostID), + Identifier: v1_1_0.PgBackRestConfigIdentifier(instanceID, configType), + ResourceVersion: "1", + DiffIgnore: nil, + Attributes: mustJSON(t, v1_1_0.PgBackRestConfig{ + InstanceID: instanceID, + HostID: hostID, + DatabaseID: databaseID, + NodeName: nodeName, + ParentID: v1_1_0_configsDirID(hostID), + Type: configType.String(), + OwnerUID: 123, + OwnerGID: 124, + Repositories: []*struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + }{ + { + ID: "default", + Type: "s3", + S3Bucket: "bucket", + S3Region: "us-east-1", + S3Endpoint: "s3.us-east-1.amazonaws.com", + }, + }, + }), + Dependencies: []resource.Identifier{ + v1_1_0.DirResourceIdentifier(v1_1_0_configsDirID(hostID)), + }, + } +} + +func v1_1_0_pgBackRestStanza(t testing.TB, nodeName string) *resource.ResourceData { + return &resource.ResourceData{ + Executor: resource.PrimaryExecutor(nodeName), + Identifier: v1_1_0.PgBackRestStanzaIdentifier(nodeName), + ResourceVersion: "1", + DiffIgnore: nil, + Attributes: mustJSON(t, v1_1_0.PgBackRestStanza{ + NodeName: nodeName, + }), + Dependencies: []resource.Identifier{ + v1_1_0.NodeResourceIdentifier(nodeName), + }, + } +} + +func v1_1_0_postgresCerts(t testing.TB, instanceID, hostID string) *resource.ResourceData { + return &resource.ResourceData{ + Executor: resource.HostExecutor(hostID), + Identifier: v1_1_0.PostgresCertsIdentifier(instanceID), + ResourceVersion: "1", + DiffIgnore: []string{ + "/ca_cert", + "/server_cert", + "/server_key", + "/superuser_cert", + "/superuser_key", + "/replication_cert", + "/replication_key", + }, + Attributes: mustJSON(t, v1_1_0.PostgresCerts{ + InstanceID: instanceID, + HostID: hostID, + InstanceAddresses: []string{"127.0.0.1", "localhost"}, + ParentID: v1_1_0_configsDirID(hostID), + OwnerUID: 123, + OwnerGID: 124, + CaCert: []byte("ca_cert"), + ServerCert: []byte("server_cert"), + ServerKey: []byte("server_key"), + SuperuserCert: []byte("superuser_cert"), + SuperuserKey: []byte("superuser_key"), + ReplicationCert: []byte("replication_cert"), + ReplicationKey: []byte("replication_key"), + }), + Dependencies: []resource.Identifier{ + v1_1_0.DirResourceIdentifier(v1_1_0_configsDirID(hostID)), + }, + } +} + +func v1_1_0_patroniConfig(t testing.TB, databaseID, instanceID, hostID, nodeName string, hasBackupConfig, hasRestoreConfig, inPlaceRestore bool) *resource.ResourceData { + var extraDeps []resource.Identifier + var backupConfig *struct { + Repositories []*struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + } `json:"repositories"` + Schedules []*struct { + ID string `json:"id"` + Type string `json:"type"` + CronExpression string `json:"cron_expression"` + } `json:"schedules"` + } + if hasBackupConfig { + extraDeps = append(extraDeps, v1_1_0.PgBackRestConfigIdentifier(instanceID, pgbackrest.ConfigTypeBackup)) + backupConfig = &struct { + Repositories []*struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + } `json:"repositories"` + Schedules []*struct { + ID string `json:"id"` + Type string `json:"type"` + CronExpression string `json:"cron_expression"` + } `json:"schedules"` + }{ + Repositories: []*struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + }{ + { + ID: "default", + Type: "s3", + S3Bucket: "bucket", + S3Region: "us-east-1", + S3Endpoint: "s3.us-east-1.amazonaws.com", + }, + }, + } + } + var restoreConfig *struct { + SourceDatabaseID string `json:"source_database_id"` + SourceNodeName string `json:"source_node_name"` + SourceDatabaseName string `json:"source_database_name"` + Repository *struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + } `json:"repository"` + RestoreOptions map[string]string `json:"restore_options"` + } + if hasRestoreConfig { + extraDeps = append(extraDeps, v1_1_0.PgBackRestConfigIdentifier(instanceID, pgbackrest.ConfigTypeRestore)) + restoreConfig = &struct { + SourceDatabaseID string `json:"source_database_id"` + SourceNodeName string `json:"source_node_name"` + SourceDatabaseName string `json:"source_database_name"` + Repository *struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + } `json:"repository"` + RestoreOptions map[string]string `json:"restore_options"` + }{ + SourceDatabaseID: "old-database", + SourceNodeName: "n1", + SourceDatabaseName: "source_database", + RestoreOptions: map[string]string{ + "type": "time", + "target": "2026-01-01T01:30:00Z", + }, + Repository: &struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + }{ + ID: "default", + Type: "s3", + S3Bucket: "bucket", + S3Region: "us-east-1", + S3Endpoint: "s3.us-east-1.amazonaws.com", + }, + } + } + return &resource.ResourceData{ + Executor: resource.HostExecutor(hostID), + Identifier: v1_1_0.PatroniConfigIdentifier(instanceID), + ResourceVersion: "1", + DiffIgnore: nil, + Attributes: mustJSON(t, v1_1_0.PatroniConfig{ + HostCPUs: 4, + HostMemoryBytes: 1024 * 1024 * 1024, + ParentID: v1_1_0_configsDirID(hostID), + OwnerUID: 123, + OwnerGID: 124, + BridgeNetworkInfo: &struct { + Name string + ID string + Subnet netip.Prefix + Gateway netip.Addr + }{ + Name: "bridge", + ID: "bridge_id", + Subnet: netip.MustParsePrefix("172.16.0.0/12"), + Gateway: netip.MustParseAddr("172.16.0.1"), + }, + DatabaseNetworkName: databaseID, + InstanceHostname: databaseID + "-" + nodeName, + Spec: &struct { + InstanceID string `json:"instance_id"` + TenantID *string `json:"tenant_id,omitempty"` + DatabaseID string `json:"database_id"` + HostID string `json:"host_id"` + DatabaseName string `json:"database_name"` + NodeName string `json:"node_name"` + NodeOrdinal int `json:"node_ordinal"` + PgEdgeVersion *ds.PgEdgeVersion `json:"pg_edge_version"` + Port *int `json:"port"` + PatroniPort *int `json:"patroni_port"` + CPUs float64 `json:"cpus"` + MemoryBytes uint64 `json:"memory"` + DatabaseUsers []*struct { + Username string `json:"username"` + Password string `json:"password"` + DBOwner bool `json:"db_owner,omitempty"` + Attributes []string `json:"attributes,omitempty"` + Roles []string `json:"roles,omitempty"` + } `json:"database_users"` + BackupConfig *struct { + Repositories []*struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + } `json:"repositories"` + Schedules []*struct { + ID string `json:"id"` + Type string `json:"type"` + CronExpression string `json:"cron_expression"` + } `json:"schedules"` + } `json:"backup_config"` + RestoreConfig *struct { + SourceDatabaseID string `json:"source_database_id"` + SourceNodeName string `json:"source_node_name"` + SourceDatabaseName string `json:"source_database_name"` + Repository *struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + } `json:"repository"` + RestoreOptions map[string]string `json:"restore_options"` + } `json:"restore_config"` + PostgreSQLConf map[string]any `json:"postgresql_conf"` + PgHbaConf []string `json:"pg_hba_conf,omitempty"` + PgIdentConf []string `json:"pg_ident_conf,omitempty"` + ClusterSize int `json:"cluster_size"` + NodeSize int `json:"node_size"` + OrchestratorOpts *struct { + Swarm *struct { + ExtraVolumes []struct { + HostPath string `json:"host_path"` + DestinationPath string `json:"destination_path"` + } `json:"extra_volumes,omitempty"` + ExtraNetworks []struct { + ID string `json:"id"` + Aliases []string `json:"aliases,omitempty"` + DriverOpts map[string]string `json:"driver_opts,omitempty"` + } `json:"extra_networks,omitempty"` + ExtraLabels map[string]string `json:"extra_labels,omitempty"` + } `json:"docker,omitempty"` + } `json:"orchestrator_opts,omitempty"` + InPlaceRestore bool `json:"in_place_restore,omitempty"` + AllHostIDs []string `json:"all_host_ids"` + }{ + InstanceID: instanceID, + DatabaseID: databaseID, + HostID: hostID, + DatabaseName: "test_database", + NodeName: nodeName, + NodeOrdinal: 1, + CPUs: 4, + MemoryBytes: 1024 * 1024 * 1024, + ClusterSize: 3, + PostgreSQLConf: map[string]any{ + "max_connections": 1000, + }, + PgHbaConf: []string{ + "hostssl all myapp_user 203.0.113.0/24 scram-sha-256", + }, + PgIdentConf: []string{ + "ssl_users CN=alice,O=example alice", + }, + NodeSize: 1, + InPlaceRestore: inPlaceRestore, + BackupConfig: backupConfig, + RestoreConfig: restoreConfig, + }, + }), + Dependencies: slices.Concat( + []resource.Identifier{ + v1_1_0.DirResourceIdentifier(v1_1_0_configsDirID(hostID)), + v1_1_0.NetworkResourceIdentifier(databaseID), + v1_1_0.EtcdCredsIdentifier(instanceID), + v1_1_0.PatroniMemberResourceIdentifier(instanceID), + v1_1_0.PatroniClusterResourceIdentifier(nodeName), + }, + extraDeps, + ), + } +} + +func v1_1_0_network(t testing.TB, databaseID string) *resource.ResourceData { + return &resource.ResourceData{ + Executor: resource.ManagerExecutor(), + Identifier: v1_1_0.NetworkResourceIdentifier(databaseID), + ResourceVersion: "1", + // We don't need all properties for this test. + Attributes: mustJSON(t, v1_1_0.Network{ + Scope: "swarm", + Driver: "overlay", + Name: databaseID, + }), + } +} + +func v1_1_0_configsDir(t testing.TB, hostID string) *resource.ResourceData { + id := v1_1_0_configsDirID(hostID) + return &resource.ResourceData{ + Executor: resource.HostExecutor(hostID), + Identifier: v1_1_0.DirResourceIdentifier(id), + ResourceVersion: "1", + DiffIgnore: nil, + Attributes: mustJSON(t, v1_1_0.DirResource{ + ID: id, + Path: "/configs", + }), + } +} + +func v1_1_0_configsDirID(hostID string) string { + return hostID + "-configs" +} diff --git a/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/simple_instance.json b/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/simple_instance.json new file mode 100644 index 00000000..59db5d59 --- /dev/null +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/simple_instance.json @@ -0,0 +1,365 @@ +{ + "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.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": "" + } + }, + "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": { + "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" + } + ], + "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/golden_test/TestVersion_1_2_0/with_backup_and_restore_configs.json b/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/with_backup_and_restore_configs.json new file mode 100644 index 00000000..0d9d9306 --- /dev/null +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/with_backup_and_restore_configs.json @@ -0,0 +1,512 @@ +{ + "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": "" + }, + "instance-1-restore": { + "needs_recreate": false, + "executor": { + "type": "host", + "id": "host-1" + }, + "identifier": { + "id": "instance-1-restore", + "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": "restore", + "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": "" + } + }, + "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": "/usr/bin/pgbackrest --config /opt/pgedge/configs/pgbackrest.restore.conf --stanza db restore --target=2026-01-01T01:30:00Z --type=time --target-action=promote", + "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" + }, + { + "id": "instance-1-restore", + "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/golden_test/TestVersion_1_2_0/with_backup_config.json b/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/with_backup_config.json new file mode 100644 index 00000000..18898ec1 --- /dev/null +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/with_backup_config.json @@ -0,0 +1,454 @@ +{ + "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": "" + } + }, + "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/golden_test/TestVersion_1_2_0/with_in-place_restore_config.json b/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/with_in-place_restore_config.json new file mode 100644 index 00000000..c9eed6f6 --- /dev/null +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/with_in-place_restore_config.json @@ -0,0 +1,453 @@ +{ + "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-restore": { + "needs_recreate": false, + "executor": { + "type": "host", + "id": "host-1" + }, + "identifier": { + "id": "instance-1-restore", + "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": "restore", + "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": "" + } + }, + "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": { + "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": "mv /opt/pgedge/data/pgdata-restore /opt/pgedge/data/pgdata", + "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-restore", + "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/golden_test/TestVersion_1_2_0/with_restore_config.json b/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/with_restore_config.json new file mode 100644 index 00000000..025a9f37 --- /dev/null +++ b/server/internal/resource/migrations/golden_test/TestVersion_1_2_0/with_restore_config.json @@ -0,0 +1,453 @@ +{ + "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-restore": { + "needs_recreate": false, + "executor": { + "type": "host", + "id": "host-1" + }, + "identifier": { + "id": "instance-1-restore", + "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": "restore", + "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": "" + } + }, + "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": { + "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": "/usr/bin/pgbackrest --config /opt/pgedge/configs/pgbackrest.restore.conf --stanza db restore --target=2026-01-01T01:30:00Z --type=time --target-action=promote", + "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-restore", + "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/main_test.go b/server/internal/resource/migrations/main_test.go index f8331d35..46eeedc4 100644 --- a/server/internal/resource/migrations/main_test.go +++ b/server/internal/resource/migrations/main_test.go @@ -1,12 +1,30 @@ package migrations_test import ( + "encoding/json" "flag" "os" "testing" + + "github.com/pgEdge/control-plane/server/internal/resource" + "github.com/pgEdge/control-plane/server/internal/testutils" + "github.com/stretchr/testify/require" ) var update bool +var golden = &testutils.GoldenTest[*resource.State]{ + Compare: func(t testing.TB, expected, actual *resource.State) { + // The json.RawValue ends up indented in our actual, so we'll round + // trip the actual value to get the same indentation. + raw, err := json.MarshalIndent(actual, "", " ") + require.NoError(t, err) + + var roundTrippedActual *resource.State + require.NoError(t, json.Unmarshal(raw, &roundTrippedActual)) + + require.Equal(t, expected, roundTrippedActual) + }, +} func TestMain(m *testing.M) { flag.BoolVar(&update, "update", false, "update golden test outputs") diff --git a/server/internal/resource/migrations/provide.go b/server/internal/resource/migrations/provide.go index ec9797b0..f88cc028 100644 --- a/server/internal/resource/migrations/provide.go +++ b/server/internal/resource/migrations/provide.go @@ -15,6 +15,7 @@ func provideStateMigrations(i *do.Injector) { return resource.NewStateMigrations([]resource.StateMigration{ &Version_1_0_0{}, &Version_1_1_0{}, + &Version_1_2_0{}, }), nil }) } diff --git a/server/internal/resource/migrations/schemas/v1_1_0/database.go b/server/internal/resource/migrations/schemas/v1_1_0/database.go new file mode 100644 index 00000000..47f51082 --- /dev/null +++ b/server/internal/resource/migrations/schemas/v1_1_0/database.go @@ -0,0 +1,158 @@ +// produced by schematool c2ccdd8969fbc7b26675a9ce183092ec9444d877 server/internal/database NodeResource InstanceResource +package v1_1_0 + +import ( + "github.com/pgEdge/control-plane/server/internal/ds" + "github.com/pgEdge/control-plane/server/internal/resource" + "time" +) + +const ResourceTypeNode resource.Type = "database.node" + +func NodeResourceIdentifier(nodeName string) resource.Identifier { + return resource.Identifier{ + ID: nodeName, + Type: ResourceTypeNode, + } +} + +type NodeResource struct { + Name string `json:"name"` + InstanceIDs []string `json:"instance_ids"` + PrimaryInstanceID string `json:"primary_instance_id"` +} + +const ResourceTypeInstance resource.Type = "database.instance" + +func InstanceResourceIdentifier(instanceID string) resource.Identifier { + return resource.Identifier{ + ID: instanceID, + Type: ResourceTypeInstance, + } +} + +type InstanceResource struct { + Spec *struct { + InstanceID string `json:"instance_id"` + TenantID *string `json:"tenant_id,omitempty"` + DatabaseID string `json:"database_id"` + HostID string `json:"host_id"` + DatabaseName string `json:"database_name"` + NodeName string `json:"node_name"` + NodeOrdinal int `json:"node_ordinal"` + PgEdgeVersion *ds.PgEdgeVersion `json:"pg_edge_version"` + Port *int `json:"port"` + PatroniPort *int `json:"patroni_port"` + CPUs float64 `json:"cpus"` + MemoryBytes uint64 `json:"memory"` + DatabaseUsers []*struct { + Username string `json:"username"` + Password string `json:"password"` + DBOwner bool `json:"db_owner,omitempty"` + Attributes []string `json:"attributes,omitempty"` + Roles []string `json:"roles,omitempty"` + } `json:"database_users"` + BackupConfig *struct { + Repositories []*struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + } `json:"repositories"` + Schedules []*struct { + ID string `json:"id"` + Type string `json:"type"` + CronExpression string `json:"cron_expression"` + } `json:"schedules"` + } `json:"backup_config"` + RestoreConfig *struct { + SourceDatabaseID string `json:"source_database_id"` + SourceNodeName string `json:"source_node_name"` + SourceDatabaseName string `json:"source_database_name"` + Repository *struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + } `json:"repository"` + RestoreOptions map[string]string `json:"restore_options"` + } `json:"restore_config"` + PostgreSQLConf map[string]any `json:"postgresql_conf"` + PgHbaConf []string `json:"pg_hba_conf,omitempty"` + PgIdentConf []string `json:"pg_ident_conf,omitempty"` + ClusterSize int `json:"cluster_size"` + NodeSize int `json:"node_size"` + OrchestratorOpts *struct { + Swarm *struct { + ExtraVolumes []struct { + HostPath string `json:"host_path"` + DestinationPath string `json:"destination_path"` + } `json:"extra_volumes,omitempty"` + ExtraNetworks []struct { + ID string `json:"id"` + Aliases []string `json:"aliases,omitempty"` + DriverOpts map[string]string `json:"driver_opts,omitempty"` + } `json:"extra_networks,omitempty"` + ExtraLabels map[string]string `json:"extra_labels,omitempty"` + } `json:"docker,omitempty"` + } `json:"orchestrator_opts,omitempty"` + InPlaceRestore bool `json:"in_place_restore,omitempty"` + AllHostIDs []string `json:"all_host_ids"` + } `json:"spec"` + InstanceHostname string `json:"instance_hostname"` + PrimaryInstanceID string `json:"primary_instance_id"` + PrimaryInstanceIDUpdatedAt time.Time `json:"primary_instance_id_updated_at"` + OrchestratorDependencies []struct { + ID string `json:"id"` + Type string `json:"type"` + } `json:"dependencies"` + ConnectionInfo *struct { + AdminHost string + AdminPort int + PeerHost string + PeerPort int + PeerSSLCert string + PeerSSLKey string + PeerSSLRootCert string + PatroniPort int + ClientAddresses []string + ClientPort int + InstanceHostname string + } `json:"connection_info"` + PostInit *struct { + DatabaseID string `json:"database_id"` + NodeName string `json:"node_name"` + Name string `json:"name"` + Statements []string `json:"statements"` + Succeeded bool `json:"succeeded"` + NeedsToRun bool `json:"needs_to_run"` + } `json:"post_init"` +} diff --git a/server/internal/resource/migrations/schemas/v1_1_0/filesystem.go b/server/internal/resource/migrations/schemas/v1_1_0/filesystem.go new file mode 100644 index 00000000..2345bb6f --- /dev/null +++ b/server/internal/resource/migrations/schemas/v1_1_0/filesystem.go @@ -0,0 +1,27 @@ +// produced by schematool c43a9aef76656d6e5ae6bb1c93755edb8f213cb9 server/internal/filesystem DirResource +package v1_1_0 + +import ( + "github.com/pgEdge/control-plane/server/internal/resource" + "os" +) + +const ResourceTypeDir resource.Type = "filesystem.dir" + +func DirResourceIdentifier(id string) resource.Identifier { + return resource.Identifier{ + ID: id, + Type: ResourceTypeDir, + } +} + +type DirResource struct { + ID string `json:"id"` + ParentID string `json:"parent_id"` + HostID string `json:"host_id"` + Path string `json:"path"` + OwnerUID int `json:"owner_uid"` + OwnerGID int `json:"owner_gid"` + Perm os.FileMode `json:"perm"` + FullPath string `json:"full_path"` +} diff --git a/server/internal/resource/migrations/schemas/v1_1_0/swarm.go b/server/internal/resource/migrations/schemas/v1_1_0/swarm.go new file mode 100644 index 00000000..09292909 --- /dev/null +++ b/server/internal/resource/migrations/schemas/v1_1_0/swarm.go @@ -0,0 +1,284 @@ +// produced by schematool c2ccdd8969fbc7b26675a9ce183092ec9444d877 server/internal/orchestrator/swarm PatroniConfig EtcdCreds PatroniCluster PatroniMember PgBackRestConfig PgBackRestStanza PostgresCerts Network +package v1_1_0 + +import ( + "net/netip" + + "github.com/pgEdge/control-plane/server/internal/ds" + "github.com/pgEdge/control-plane/server/internal/pgbackrest" + "github.com/pgEdge/control-plane/server/internal/resource" +) + +const ResourceTypePatroniConfig resource.Type = "swarm.patroni_config" + +func PatroniConfigIdentifier(instanceID string) resource.Identifier { + return resource.Identifier{ + ID: instanceID, + Type: ResourceTypePatroniConfig, + } +} + +type PatroniConfig struct { + Spec *struct { + InstanceID string `json:"instance_id"` + TenantID *string `json:"tenant_id,omitempty"` + DatabaseID string `json:"database_id"` + HostID string `json:"host_id"` + DatabaseName string `json:"database_name"` + NodeName string `json:"node_name"` + NodeOrdinal int `json:"node_ordinal"` + PgEdgeVersion *ds.PgEdgeVersion `json:"pg_edge_version"` + Port *int `json:"port"` + PatroniPort *int `json:"patroni_port"` + CPUs float64 `json:"cpus"` + MemoryBytes uint64 `json:"memory"` + DatabaseUsers []*struct { + Username string `json:"username"` + Password string `json:"password"` + DBOwner bool `json:"db_owner,omitempty"` + Attributes []string `json:"attributes,omitempty"` + Roles []string `json:"roles,omitempty"` + } `json:"database_users"` + BackupConfig *struct { + Repositories []*struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + } `json:"repositories"` + Schedules []*struct { + ID string `json:"id"` + Type string `json:"type"` + CronExpression string `json:"cron_expression"` + } `json:"schedules"` + } `json:"backup_config"` + RestoreConfig *struct { + SourceDatabaseID string `json:"source_database_id"` + SourceNodeName string `json:"source_node_name"` + SourceDatabaseName string `json:"source_database_name"` + Repository *struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + } `json:"repository"` + RestoreOptions map[string]string `json:"restore_options"` + } `json:"restore_config"` + PostgreSQLConf map[string]any `json:"postgresql_conf"` + PgHbaConf []string `json:"pg_hba_conf,omitempty"` + PgIdentConf []string `json:"pg_ident_conf,omitempty"` + ClusterSize int `json:"cluster_size"` + NodeSize int `json:"node_size"` + OrchestratorOpts *struct { + Swarm *struct { + ExtraVolumes []struct { + HostPath string `json:"host_path"` + DestinationPath string `json:"destination_path"` + } `json:"extra_volumes,omitempty"` + ExtraNetworks []struct { + ID string `json:"id"` + Aliases []string `json:"aliases,omitempty"` + DriverOpts map[string]string `json:"driver_opts,omitempty"` + } `json:"extra_networks,omitempty"` + ExtraLabels map[string]string `json:"extra_labels,omitempty"` + } `json:"docker,omitempty"` + } `json:"orchestrator_opts,omitempty"` + InPlaceRestore bool `json:"in_place_restore,omitempty"` + AllHostIDs []string `json:"all_host_ids"` + } `json:"spec"` + ParentID string `json:"parent_id"` + HostCPUs float64 `json:"host_cpus"` + HostMemoryBytes uint64 `json:"host_memory_bytes"` + BridgeNetworkInfo *struct { + Name string + ID string + Subnet netip.Prefix + Gateway netip.Addr + } `json:"host_network_info"` + DatabaseNetworkName string `json:"database_network_name"` + OwnerUID int `json:"owner_uid"` + OwnerGID int `json:"owner_gid"` + InstanceHostname string `json:"instance_hostname"` +} + +const ResourceTypeEtcdCreds resource.Type = "swarm.etcd_creds" + +func EtcdCredsIdentifier(instanceID string) resource.Identifier { + return resource.Identifier{ + ID: instanceID, + Type: ResourceTypeEtcdCreds, + } +} + +type EtcdCreds struct { + InstanceID string `json:"instance_id"` + DatabaseID string `json:"database_id"` + HostID string `json:"host_id"` + NodeName string `json:"node_name"` + ParentID string `json:"parent_id"` + OwnerUID int `json:"owner_uid"` + OwnerGID int `json:"owner_gid"` + Username string `json:"username"` + Password string `json:"password"` + CaCert []byte `json:"ca_cert"` + ClientCert []byte `json:"server_cert"` + ClientKey []byte `json:"server_key"` +} + +const ResourceTypePatroniCluster resource.Type = "swarm.patroni_cluster" + +func PatroniClusterResourceIdentifier(nodeName string) resource.Identifier { + return resource.Identifier{ + ID: nodeName, + Type: ResourceTypePatroniCluster, + } +} + +type PatroniCluster struct { + DatabaseID string `json:"database_id"` + NodeName string `json:"node_name"` + PatroniClusterPrefix string `json:"patroni_namespace"` +} + +const ResourceTypePatroniMember resource.Type = "swarm.patroni_member" + +func PatroniMemberResourceIdentifier(instanceID string) resource.Identifier { + return resource.Identifier{ + ID: instanceID, + Type: ResourceTypePatroniMember, + } +} + +type PatroniMember struct { + DatabaseID string `json:"database_id"` + NodeName string `json:"node_name"` + InstanceID string `json:"instance_id"` +} + +const ResourceTypePgBackRestConfig resource.Type = "swarm.pgbackrest_config" + +func PgBackRestConfigIdentifier(instanceID string, configType pgbackrest.ConfigType) resource.Identifier { + return resource.Identifier{ + ID: instanceID + "-" + configType.String(), + Type: ResourceTypePgBackRestConfig, + } +} + +type PgBackRestConfig struct { + InstanceID string `json:"instance_id"` + HostID string `json:"host_id"` + DatabaseID string `json:"database_id"` + NodeName string `json:"node_name"` + Repositories []*struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + } `json:"repositories"` + ParentID string `json:"parent_id"` + Type string `json:"type"` + OwnerUID int `json:"owner_uid"` + OwnerGID int `json:"owner_gid"` +} + +const ResourceTypePgBackRestStanza resource.Type = "swarm.pgbackrest_stanza" + +func PgBackRestStanzaIdentifier(nodeName string) resource.Identifier { + return resource.Identifier{ + ID: nodeName, + Type: ResourceTypePgBackRestStanza, + } +} + +type PgBackRestStanza struct { + NodeName string `json:"node_name"` +} + +const ResourceTypePostgresCerts resource.Type = "swarm.postgres_certs" + +func PostgresCertsIdentifier(instanceID string) resource.Identifier { + return resource.Identifier{ + ID: instanceID, + Type: ResourceTypePostgresCerts, + } +} + +type PostgresCerts struct { + InstanceID string `json:"instance_id"` + HostID string `json:"host_id"` + InstanceAddresses []string `json:"instance_addresses"` + ParentID string `json:"parent_id"` + OwnerUID int `json:"owner_uid"` + OwnerGID int `json:"owner_gid"` + CaCert []byte `json:"ca_cert"` + ServerCert []byte `json:"server_cert"` + ServerKey []byte `json:"server_key"` + SuperuserCert []byte `json:"superuser_cert"` + SuperuserKey []byte `json:"superuser_key"` + ReplicationCert []byte `json:"replication_cert"` + ReplicationKey []byte `json:"replication_key"` +} + +const ResourceTypeNetwork resource.Type = "swarm.network" + +func NetworkResourceIdentifier(name string) resource.Identifier { + return resource.Identifier{ + ID: name, + Type: ResourceTypeNetwork, + } +} + +type Network struct { + Scope string `json:"scope"` + Driver string `json:"driver"` + Allocator struct { + Prefix netip.Prefix `json:"prefix"` + Bits int `json:"bits"` + } `json:"allocator"` + Name string `json:"name"` + NetworkID string `json:"network_id"` + Subnet netip.Prefix `json:"subnet"` + Gateway netip.Addr `json:"gateway"` +} diff --git a/server/internal/resource/migrations/schemas/v1_2_0/common.go b/server/internal/resource/migrations/schemas/v1_2_0/common.go new file mode 100644 index 00000000..d1d41a30 --- /dev/null +++ b/server/internal/resource/migrations/schemas/v1_2_0/common.go @@ -0,0 +1,150 @@ +// produced by schematool 20b82249f8734cd7aa4ed88b2a0e60a68c7bf058 server/internal/orchestrator/common EtcdCreds PatroniCluster PatroniMember PgBackRestConfig PgBackRestStanza PostgresCerts +package v1_2_0 + +import ( + "github.com/pgEdge/control-plane/server/internal/pgbackrest" + "github.com/pgEdge/control-plane/server/internal/resource" +) + +const ResourceTypeEtcdCreds resource.Type = "common.etcd_creds" + +func EtcdCredsIdentifier(instanceID string) resource.Identifier { + return resource.Identifier{ + ID: instanceID, + Type: ResourceTypeEtcdCreds, + } +} + +type EtcdCreds struct { + InstanceID string `json:"instance_id"` + DatabaseID string `json:"database_id"` + HostID string `json:"host_id"` + NodeName string `json:"node_name"` + ParentID string `json:"parent_id"` + OwnerUID int `json:"owner_uid"` + OwnerGID int `json:"owner_gid"` + Username string `json:"username"` + Password string `json:"password"` + CaCert []byte `json:"ca_cert"` + ClientCert []byte `json:"client_cert"` + ClientKey []byte `json:"client_key"` +} + +const ResourceTypePatroniCluster resource.Type = "common.patroni_cluster" + +func PatroniClusterResourceIdentifier(nodeName string) resource.Identifier { + return resource.Identifier{ + ID: nodeName, + Type: ResourceTypePatroniCluster, + } +} + +type PatroniCluster struct { + DatabaseID string `json:"database_id"` + NodeName string `json:"node_name"` +} + +const ResourceTypePatroniMember resource.Type = "common.patroni_member" + +func PatroniMemberResourceIdentifier(instanceID string) resource.Identifier { + return resource.Identifier{ + ID: instanceID, + Type: ResourceTypePatroniMember, + } +} + +type PatroniMember struct { + DatabaseID string `json:"database_id"` + NodeName string `json:"node_name"` + InstanceID string `json:"instance_id"` +} + +const ResourceTypePgBackRestConfig resource.Type = "common.pgbackrest_config" + +func PgBackRestConfigIdentifier(instanceID string, configType pgbackrest.ConfigType) resource.Identifier { + return resource.Identifier{ + ID: instanceID + "-" + configType.String(), + Type: ResourceTypePgBackRestConfig, + } +} + +type PgBackRestConfig struct { + InstanceID string `json:"instance_id"` + HostID string `json:"host_id"` + DatabaseID string `json:"database_id"` + NodeName string `json:"node_name"` + Repositories []*struct { + ID string `json:"id"` + Type string `json:"type"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Key string `json:"s3_key,omitempty"` + S3KeySecret string `json:"s3_key_secret,omitempty"` + GCSBucket string `json:"gcs_bucket,omitempty"` + GCSEndpoint string `json:"gcs_endpoint,omitempty"` + GCSKey string `json:"gcs_key,omitempty"` + AzureAccount string `json:"azure_account,omitempty"` + AzureContainer string `json:"azure_container,omitempty"` + AzureEndpoint string `json:"azure_endpoint,omitempty"` + AzureKey string `json:"azure_key,omitempty"` + RetentionFull int `json:"retention_full"` + RetentionFullType string `json:"retention_full_type"` + BasePath string `json:"base_path,omitempty"` + CustomOptions map[string]string `json:"custom_options,omitempty"` + } `json:"repositories"` + ParentID string `json:"parent_id"` + Type string `json:"type"` + OwnerUID int `json:"owner_uid"` + OwnerGID int `json:"owner_gid"` + Paths struct { + Instance struct { + BaseDir string `json:"base_dir"` + } `json:"instance"` + Host struct { + BaseDir string `json:"base_dir"` + } `json:"host"` + PgBackRestPath string `json:"pg_backrest_path"` + PatroniPath string `json:"patroni_path"` + } `json:"paths"` + Port int `json:"port"` +} + +const ResourceTypePgBackRestStanza resource.Type = "common.pgbackrest_stanza" + +func PgBackRestStanzaIdentifier(nodeName string) resource.Identifier { + return resource.Identifier{ + ID: nodeName, + Type: ResourceTypePgBackRestStanza, + } +} + +type PgBackRestStanza struct { + DatabaseID string `json:"database_id"` + NodeName string `json:"node_name"` +} + +const ResourceTypePostgresCerts resource.Type = "common.postgres_certs" + +func PostgresCertsIdentifier(instanceID string) resource.Identifier { + return resource.Identifier{ + ID: instanceID, + Type: ResourceTypePostgresCerts, + } +} + +type PostgresCerts struct { + InstanceID string `json:"instance_id"` + HostID string `json:"host_id"` + InstanceAddresses []string `json:"instance_addresses"` + ParentID string `json:"parent_id"` + OwnerUID int `json:"owner_uid"` + OwnerGID int `json:"owner_gid"` + CaCert []byte `json:"ca_cert"` + ServerCert []byte `json:"server_cert"` + ServerKey []byte `json:"server_key"` + SuperuserCert []byte `json:"superuser_cert"` + SuperuserKey []byte `json:"superuser_key"` + ReplicationCert []byte `json:"replication_cert"` + ReplicationKey []byte `json:"replication_key"` +} diff --git a/server/internal/resource/migrations/schemas/v1_2_0/database.go b/server/internal/resource/migrations/schemas/v1_2_0/database.go new file mode 100644 index 00000000..e0fbec8b --- /dev/null +++ b/server/internal/resource/migrations/schemas/v1_2_0/database.go @@ -0,0 +1,21 @@ +// produced by schematool 20b82249f8734cd7aa4ed88b2a0e60a68c7bf058 server/internal/database NodeResource +package v1_2_0 + +import ( + "github.com/pgEdge/control-plane/server/internal/resource" +) + +const ResourceTypeNode resource.Type = "database.node" + +func NodeResourceIdentifier(nodeName string) resource.Identifier { + return resource.Identifier{ + ID: nodeName, + Type: ResourceTypeNode, + } +} + +type NodeResource struct { + Name string `json:"name"` + InstanceIDs []string `json:"instance_ids"` + PrimaryInstanceID string `json:"primary_instance_id"` +} diff --git a/server/internal/resource/migrations/schemas/v1_2_0/filesystem.go b/server/internal/resource/migrations/schemas/v1_2_0/filesystem.go new file mode 100644 index 00000000..48d3eb5e --- /dev/null +++ b/server/internal/resource/migrations/schemas/v1_2_0/filesystem.go @@ -0,0 +1,27 @@ +// produced by schematool 20b82249f8734cd7aa4ed88b2a0e60a68c7bf058 server/internal/filesystem DirResource +package v1_2_0 + +import ( + "github.com/pgEdge/control-plane/server/internal/resource" + "os" +) + +const ResourceTypeDir resource.Type = "filesystem.dir" + +func DirResourceIdentifier(id string) resource.Identifier { + return resource.Identifier{ + ID: id, + Type: ResourceTypeDir, + } +} + +type DirResource struct { + ID string `json:"id"` + ParentID string `json:"parent_id"` + HostID string `json:"host_id"` + Path string `json:"path"` + OwnerUID int `json:"owner_uid"` + OwnerGID int `json:"owner_gid"` + Perm os.FileMode `json:"perm"` + FullPath string `json:"full_path"` +} diff --git a/server/internal/resource/migrations/schemas/v1_2_0/swarm.go b/server/internal/resource/migrations/schemas/v1_2_0/swarm.go new file mode 100644 index 00000000..86727a12 --- /dev/null +++ b/server/internal/resource/migrations/schemas/v1_2_0/swarm.go @@ -0,0 +1,82 @@ +// produced by schematool f1eff819726b8c2319bc80eca945b80ea811b8b5 server/internal/orchestrator/swarm PatroniConfig Network +package v1_2_0 + +import ( + "github.com/pgEdge/control-plane/server/internal/resource" + "net/netip" +) + +const ResourceTypePatroniConfig resource.Type = "swarm.patroni_config" + +func PatroniConfigIdentifier(instanceID string) resource.Identifier { + return resource.Identifier{ + ID: instanceID, + Type: ResourceTypePatroniConfig, + } +} + +type PatroniConfig struct { + DatabaseID string `json:"database_id"` + Base *struct { + InstanceID string `json:"instance_id"` + HostID string `json:"host_id"` + NodeName string `json:"node_name"` + Generator *struct { + ArchiveCommand string `json:"archive_command,omitempty"` + ClusterSize int `json:"cluster_size"` + CPUs float64 `json:"cpus,omitempty"` + DatabaseID string `json:"database_id"` + DataDir string `json:"data_dir"` + EtcdCertsDir string `json:"etcd_certs_dir"` + FQDN string `json:"fqdn"` + InstanceID string `json:"instance_id"` + LogType string `json:"log_type"` + MemoryBytes uint64 `json:"memory_bytes,omitempty"` + NodeName string `json:"node_name"` + NodeOrdinal int `json:"node_ordinal"` + NodeSize int `json:"node_size"` + OrchestratorParameters map[string]any `json:"orchestrator_parameters,omitempty"` + PatroniAllowlist []string `json:"patroni_allowlist"` + PatroniPort int `json:"patroni_port"` + PgHbaConf []string `json:"pg_hba_conf,omitempty"` + PgIdentConf []string `json:"pg_ident_conf,omitempty"` + PostgresCertsDir string `json:"postgres_certs_dir"` + PostgresPort int `json:"postgres_port"` + RestoreCommand string `json:"restore_command"` + SpecParameters map[string]any `json:"spec_parameters,omitempty"` + TenantID *string `json:"tenant_id,omitempty"` + } `json:"generator"` + ParentID string `json:"parent_id"` + OwnerUID int `json:"owner_uid"` + OwnerGID int `json:"owner_gid"` + } `json:"base"` + BridgeNetworkInfo *struct { + Name string `json:"name"` + ID string `json:"id"` + Subnet netip.Prefix `json:"subnet"` + Gateway netip.Addr `json:"gateway"` + } `json:"host_network_info"` + DatabaseNetworkName string `json:"database_network_name"` +} + +const ResourceTypeNetwork resource.Type = "swarm.network" + +func NetworkResourceIdentifier(name string) resource.Identifier { + return resource.Identifier{ + ID: name, + Type: ResourceTypeNetwork, + } +} + +type Network struct { + Scope string `json:"scope"` + Driver string `json:"driver"` + Allocator struct { + Prefix netip.Prefix `json:"prefix"` + Bits int `json:"bits"` + } `json:"allocator"` + Name string `json:"name"` + NetworkID string `json:"network_id"` + Subnet netip.Prefix `json:"subnet"` + Gateway netip.Addr `json:"gateway"` +} diff --git a/server/internal/resource/service.go b/server/internal/resource/service.go index 3a0a2351..762d105f 100644 --- a/server/internal/resource/service.go +++ b/server/internal/resource/service.go @@ -52,7 +52,7 @@ func (s *Service) Start(ctx context.Context) error { logger.Warn().Err(err).Msg("unable to use this resource state") continue } - if err := s.migrations.Run(stored.State); err != nil { + if err := s.migrations.Run(stored.DatabaseID, stored.State); err != nil { logger.Warn().Err(err).Msg("failed to upgrade resource state") continue } @@ -84,7 +84,7 @@ func (s *Service) GetState(ctx context.Context, databaseID string) (*State, erro err = stored.State.ValidateVersion() if errors.Is(err, ErrStateNeedsUpgrade) { - if err := s.migrations.Run(stored.State); err != nil { + if err := s.migrations.Run(stored.DatabaseID, stored.State); err != nil { return nil, fmt.Errorf("failed to upgrade resource state: %w", err) } s.logger.Info(). @@ -135,7 +135,7 @@ func (s *Service) DeleteDatabase(ctx context.Context, databaseID string) error { type StateMigration interface { Version() *ds.Version - Run(state *State) error + Run(databaseID string, state *State) error } type StateMigrations struct { @@ -152,7 +152,7 @@ func NewStateMigrations(migrations []StateMigration) *StateMigrations { } } -func (m *StateMigrations) Run(state *State) error { +func (m *StateMigrations) Run(databaseID string, state *State) error { for _, migration := range m.migrations { version := migration.Version() @@ -160,7 +160,7 @@ func (m *StateMigrations) Run(state *State) error { // Skip migrations for older or current versions continue } - if err := migration.Run(state); err != nil { + if err := migration.Run(databaseID, state); err != nil { return fmt.Errorf("failed to upgrade database state to version '%s': %w", version, err) } state.Version = version diff --git a/server/internal/resource/state.go b/server/internal/resource/state.go index b562ccbd..ce4520a8 100644 --- a/server/internal/resource/state.go +++ b/server/internal/resource/state.go @@ -16,8 +16,9 @@ import ( var ( StateVersion_1_0_0 = ds.MustParseVersion("1.0.0") StateVersion_1_1_0 = ds.MustParseVersion("1.1.0") + StateVersion_1_2_0 = ds.MustParseVersion("1.2.0") - CurrentVersion = StateVersion_1_1_0 + CurrentVersion = StateVersion_1_2_0 ) var ( diff --git a/server/internal/utils/utils.go b/server/internal/utils/utils.go index 0c9ca2d4..bded78a8 100644 --- a/server/internal/utils/utils.go +++ b/server/internal/utils/utils.go @@ -6,8 +6,10 @@ import ( "encoding/base64" "errors" "fmt" + "maps" "path" "regexp" + "slices" "strings" "time" "unicode" @@ -117,9 +119,27 @@ func Clean(s string) string { }, s) } +// TypedFromMap reads a typed value from the given map. If the value does not +// exist or does not match the given type, it will return the zero value for the +// type and 'false'. +func TypedFromMap[T any](m map[string]any, key string) (T, bool) { + var zero T + v, ok := m[key] + if !ok { + return zero, false + } + switch val := v.(type) { + case T: + return val, true + default: + return zero, false + } +} + func BuildOptionArgs(options map[string]string) []string { var res []string - for k, v := range options { + for _, k := range slices.Sorted(maps.Keys(options)) { + v := options[k] prefix := "" if !strings.HasPrefix(k, "--") { prefix = "--"