From 558df2bbcd7b3fc52d0d8f708306dd17790e26f2 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Thu, 10 Sep 2026 09:29:59 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20PostgreSQL=20?= =?UTF-8?q?=E6=89=80=E6=9C=89=E6=9D=83=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 6 + go.mod | 4 + go.sum | 9 + internal/postgresql/registry/registry.go | 307 ++++++++++++++++++ .../registry/registry_integration_test.go | 132 ++++++++ internal/postgresql/registry/sql.go | 86 +++++ 6 files changed, 544 insertions(+) create mode 100644 internal/postgresql/registry/registry.go create mode 100644 internal/postgresql/registry/registry_integration_test.go create mode 100644 internal/postgresql/registry/sql.go diff --git a/Makefile b/Makefile index 83909af..998a3f2 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,8 @@ CONTAINER_TOOL ?= docker # DEV_COMPOSE manages disposable PostgreSQL and OpenBao dependencies for local work. DEV_COMPOSE ?= docker compose -f hack/dev/compose.yaml +POSTGRES_DEV_PORT ?= 15432 +POSTGRES_TEST_DSN ?= postgres://postgres:postgres-dev-only@127.0.0.1:$(POSTGRES_DEV_PORT)/postgres?sslmode=disable # Setting SHELL to bash allows bash commands to be executed by recipes. # Options are set to exit when a recipe line exits non-zero or a piped command fails. @@ -59,6 +61,10 @@ dev-smoke: dev-up ## Verify PostgreSQL and OpenBao development dependencies. dev-down: ## Remove disposable development dependencies and their data. $(DEV_COMPOSE) down --volumes --remove-orphans +.PHONY: test-integration +test-integration: dev-up ## Run adapter integration tests against disposable dependencies. + POSTGRES_TEST_DSN="$(POSTGRES_TEST_DSN)" go test ./internal/postgresql/... + .PHONY: manifests manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. "$(CONTROLLER_GEN)" rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases diff --git a/go.mod b/go.mod index 6c268b5..d04cef6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.ddupan.top/panxiao81/postgresql-tenant-operator go 1.26.0 require ( + github.com/jackc/pgx/v5 v5.11.0 github.com/onsi/ginkgo/v2 v2.27.4 github.com/onsi/gomega v1.39.0 k8s.io/apimachinery v0.36.0 @@ -38,6 +39,9 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect diff --git a/go.sum b/go.sum index 690c70d..05f6150 100644 --- a/go.sum +++ b/go.sum @@ -74,6 +74,14 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -137,6 +145,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= diff --git a/internal/postgresql/registry/registry.go b/internal/postgresql/registry/registry.go new file mode 100644 index 0000000..1846ec8 --- /dev/null +++ b/internal/postgresql/registry/registry.go @@ -0,0 +1,307 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package registry persists controller ownership evidence in the PostgreSQL +// management database. It deliberately does not persist reconciliation phases; +// those belong to the Kubernetes resource status. +package registry + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +const schemaVersion = 1 + +var ( + // ErrNotFound indicates that the registry has no matching ownership record. + ErrNotFound = errors.New("registry ownership record not found") + // ErrConflict indicates that a requested name or path belongs to another Tenant UID. + ErrConflict = errors.New("registry ownership conflict") +) + +// Beginner is implemented by pgx.Conn and pgxpool.Pool. +type Beginner interface { + Begin(context.Context) (pgx.Tx, error) +} + +// Store manages ownership records in one PostgreSQLInstance management database. +type Store struct { + db Beginner +} + +// NewStore creates a registry store backed by a PostgreSQL connection or pool. +func NewStore(db Beginner) *Store { + return &Store{db: db} +} + +// Ownership identifies every external resource reserved for one Tenant UID. +type Ownership struct { + InstanceUID string + TenantUID string + TenantNamespace string + TenantName string + DatabaseName string + RoleName string + CredentialPath string +} + +// Record is the persisted ownership state. +type Record struct { + Ownership + Managed bool + CreatedAt time.Time + UpdatedAt time.Time + RetainedAt *time.Time +} + +// ClaimResult reports whether Claim inserted a new record or found the same claim. +type ClaimResult string + +const ( + ClaimCreated ClaimResult = "Created" + ClaimOwned ClaimResult = "Owned" +) + +// Bootstrap creates the private schema and the current registry table idempotently. +func (s *Store) Bootstrap(ctx context.Context) error { + if s == nil || s.db == nil { + return errors.New("bootstrap registry: nil database") + } + + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("begin registry bootstrap: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + for _, statement := range bootstrapStatements { + if _, err := tx.Exec(ctx, statement); err != nil { + return fmt.Errorf("bootstrap registry schema version %d: %w", schemaVersion, err) + } + } + var version int + if err := tx.QueryRow(ctx, `SELECT version FROM postgresql_tenant_operator.schema_version WHERE singleton`).Scan(&version); err != nil { + return fmt.Errorf("read registry schema version: %w", err) + } + if version != schemaVersion { + return fmt.Errorf("registry schema version %d is unsupported; expected %d", version, schemaVersion) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit registry bootstrap: %w", err) + } + return nil +} + +// Claim reserves all names for an owner. Repeating an identical claim is idempotent. +func (s *Store) Claim(ctx context.Context, owner Ownership) (ClaimResult, error) { + if s == nil || s.db == nil { + return "", errors.New("claim registry ownership: nil database") + } + if err := owner.validate(); err != nil { + return "", err + } + + tx, err := s.db.Begin(ctx) + if err != nil { + return "", fmt.Errorf("begin registry claim: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + commandTag, err := tx.Exec(ctx, claimStatement, + owner.InstanceUID, + owner.TenantUID, + owner.TenantNamespace, + owner.TenantName, + owner.DatabaseName, + owner.RoleName, + owner.CredentialPath, + ) + if err != nil { + return "", fmt.Errorf("insert registry claim: %w", err) + } + + record, err := getByTenantUID(ctx, tx, owner.InstanceUID, owner.TenantUID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return "", fmt.Errorf("%w: database, role, tenant identity, or credential path is already reserved", ErrConflict) + } + return "", err + } + if !record.equal(owner) || !record.Managed { + return "", fmt.Errorf("%w: existing record does not match the requested managed owner", ErrConflict) + } + + if err := tx.Commit(ctx); err != nil { + return "", fmt.Errorf("commit registry claim: %w", err) + } + if commandTag.RowsAffected() == 1 { + return ClaimCreated, nil + } + return ClaimOwned, nil +} + +// Get returns the ownership record for an Instance UID and Tenant UID. +func (s *Store) Get(ctx context.Context, instanceUID, tenantUID string) (Record, error) { + if s == nil || s.db == nil { + return Record{}, errors.New("get registry record: nil database") + } + if instanceUID == "" || tenantUID == "" { + return Record{}, errors.New("get registry record: instance UID and tenant UID are required") + } + + tx, err := s.db.Begin(ctx) + if err != nil { + return Record{}, fmt.Errorf("begin registry read: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + record, err := getByTenantUID(ctx, tx, instanceUID, tenantUID) + if err != nil { + return Record{}, err + } + if err := tx.Commit(ctx); err != nil { + return Record{}, fmt.Errorf("commit registry read: %w", err) + } + return record, nil +} + +// MarkRetained changes a matching managed ownership record into an unmanaged tombstone. +// Repeating the operation for the same tombstone is safe. +func (s *Store) MarkRetained(ctx context.Context, owner Ownership) error { + return s.changeOwnership(ctx, owner, "mark registry record retained", func(ctx context.Context, tx pgx.Tx, record Record) error { + if !record.Managed { + return nil + } + tag, err := tx.Exec(ctx, markRetainedStatement, owner.InstanceUID, owner.TenantUID) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return ErrConflict + } + return nil + }) +} + +// Delete removes a matching managed record after its external resources have been deleted. +// An already absent record is treated as a successful retry; a retained record is never deleted. +func (s *Store) Delete(ctx context.Context, owner Ownership) error { + return s.changeOwnership(ctx, owner, "delete registry record", func(ctx context.Context, tx pgx.Tx, record Record) error { + if !record.Managed { + return ErrConflict + } + tag, err := tx.Exec(ctx, deleteStatement, owner.InstanceUID, owner.TenantUID) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return ErrConflict + } + return nil + }) +} + +func (s *Store) changeOwnership( + ctx context.Context, + owner Ownership, + operation string, + change func(context.Context, pgx.Tx, Record) error, +) error { + if s == nil || s.db == nil { + return fmt.Errorf("%s: nil database", operation) + } + if err := owner.validate(); err != nil { + return fmt.Errorf("%s: %w", operation, err) + } + + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("begin %s: %w", operation, err) + } + defer func() { _ = tx.Rollback(ctx) }() + + record, err := getByTenantUIDForUpdate(ctx, tx, owner.InstanceUID, owner.TenantUID) + if errors.Is(err, ErrNotFound) && operation == "delete registry record" { + return nil + } + if err != nil { + return fmt.Errorf("%s: %w", operation, err) + } + if !record.equal(owner) { + return fmt.Errorf("%s: %w", operation, ErrConflict) + } + if err := change(ctx, tx, record); err != nil { + return fmt.Errorf("%s: %w", operation, err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit %s: %w", operation, err) + } + return nil +} + +func getByTenantUIDForUpdate(ctx context.Context, tx pgx.Tx, instanceUID, tenantUID string) (Record, error) { + return scanRecord(tx.QueryRow(ctx, getByTenantUIDForUpdateStatement, instanceUID, tenantUID)) +} + +func getByTenantUID(ctx context.Context, tx pgx.Tx, instanceUID, tenantUID string) (Record, error) { + return scanRecord(tx.QueryRow(ctx, getByTenantUIDStatement, instanceUID, tenantUID)) +} + +type rowScanner interface { + Scan(...any) error +} + +func scanRecord(row rowScanner) (Record, error) { + var record Record + err := row.Scan( + &record.InstanceUID, + &record.TenantUID, + &record.TenantNamespace, + &record.TenantName, + &record.DatabaseName, + &record.RoleName, + &record.CredentialPath, + &record.Managed, + &record.CreatedAt, + &record.UpdatedAt, + &record.RetainedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return Record{}, ErrNotFound + } + if err != nil { + return Record{}, fmt.Errorf("read registry record: %w", err) + } + return record, nil +} + +func (o Ownership) validate() error { + if o.InstanceUID == "" || o.TenantUID == "" || o.TenantNamespace == "" || o.TenantName == "" || + o.DatabaseName == "" || o.RoleName == "" || o.CredentialPath == "" { + return errors.New("claim registry ownership: all ownership fields are required") + } + return nil +} + +func (o Ownership) equal(other Ownership) bool { + return o == other +} diff --git a/internal/postgresql/registry/registry_integration_test.go b/internal/postgresql/registry/registry_integration_test.go new file mode 100644 index 0000000..5239518 --- /dev/null +++ b/internal/postgresql/registry/registry_integration_test.go @@ -0,0 +1,132 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestRegistryLifecycle(t *testing.T) { + dsn := os.Getenv("POSTGRES_TEST_DSN") + if dsn == "" { + t.Skip("POSTGRES_TEST_DSN is not set") + } + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("create PostgreSQL pool: %v", err) + } + t.Cleanup(pool.Close) + if err := pool.Ping(ctx); err != nil { + t.Fatalf("ping PostgreSQL: %v", err) + } + + dropRegistrySchema(t, ctx, pool) + t.Cleanup(func() { dropRegistrySchema(t, ctx, pool) }) + + store := NewStore(pool) + if err := store.Bootstrap(ctx); err != nil { + t.Fatalf("Bootstrap() error = %v", err) + } + if err := store.Bootstrap(ctx); err != nil { + t.Fatalf("second Bootstrap() error = %v", err) + } + + owner := testOwnership("tenant-uid-1", "netbox", "netbox", "netbox", "postgresql-tenants/netbox/netbox") + result, err := store.Claim(ctx, owner) + if err != nil || result != ClaimCreated { + t.Fatalf("first Claim() = %q, %v; want %q, nil", result, err, ClaimCreated) + } + result, err = store.Claim(ctx, owner) + if err != nil || result != ClaimOwned { + t.Fatalf("second Claim() = %q, %v; want %q, nil", result, err, ClaimOwned) + } + + record, err := store.Get(ctx, owner.InstanceUID, owner.TenantUID) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if record.Ownership != owner || !record.Managed || record.RetainedAt != nil { + t.Fatalf("Get() = %#v; want matching managed owner", record) + } + + conflict := testOwnership("tenant-uid-2", "other", owner.DatabaseName, "other", "postgresql-tenants/other/other") + if _, err := store.Claim(ctx, conflict); !errors.Is(err, ErrConflict) { + t.Fatalf("conflicting Claim() error = %v, want ErrConflict", err) + } + + if err := store.MarkRetained(ctx, owner); err != nil { + t.Fatalf("MarkRetained() error = %v", err) + } + if err := store.MarkRetained(ctx, owner); err != nil { + t.Fatalf("second MarkRetained() error = %v", err) + } + record, err = store.Get(ctx, owner.InstanceUID, owner.TenantUID) + if err != nil || record.Managed || record.RetainedAt == nil { + t.Fatalf("retained Get() = %#v, %v; want unmanaged tombstone", record, err) + } + if err := store.Delete(ctx, owner); !errors.Is(err, ErrConflict) { + t.Fatalf("Delete(retained) error = %v, want ErrConflict", err) + } + if _, err := store.Claim(ctx, owner); !errors.Is(err, ErrConflict) { + t.Fatalf("Claim(retained) error = %v, want ErrConflict", err) + } + + deletable := testOwnership("tenant-uid-3", "gitea", "gitea", "gitea", "postgresql-tenants/gitea/gitea") + if _, err := store.Claim(ctx, deletable); err != nil { + t.Fatalf("Claim(deletable) error = %v", err) + } + if err := store.Delete(ctx, deletable); err != nil { + t.Fatalf("Delete() error = %v", err) + } + if err := store.Delete(ctx, deletable); err != nil { + t.Fatalf("second Delete() error = %v", err) + } + if _, err := store.Get(ctx, deletable.InstanceUID, deletable.TenantUID); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get(deleted) error = %v, want ErrNotFound", err) + } +} + +func testOwnership(tenantUID, tenantName, databaseName, roleName, credentialPath string) Ownership { + return Ownership{ + InstanceUID: "instance-uid-1", + TenantUID: tenantUID, + TenantNamespace: tenantName, + TenantName: tenantName, + DatabaseName: databaseName, + RoleName: roleName, + CredentialPath: credentialPath, + } +} + +type schemaDropper interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) +} + +func dropRegistrySchema(t *testing.T, ctx context.Context, db schemaDropper) { + t.Helper() + if _, err := db.Exec(ctx, `DROP SCHEMA IF EXISTS postgresql_tenant_operator CASCADE`); err != nil { + t.Fatalf("drop registry schema: %v", err) + } +} diff --git a/internal/postgresql/registry/sql.go b/internal/postgresql/registry/sql.go new file mode 100644 index 0000000..3e59047 --- /dev/null +++ b/internal/postgresql/registry/sql.go @@ -0,0 +1,86 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +var bootstrapStatements = []string{ + `CREATE SCHEMA IF NOT EXISTS postgresql_tenant_operator`, + `CREATE TABLE IF NOT EXISTS postgresql_tenant_operator.schema_version ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + version integer NOT NULL +)`, + `INSERT INTO postgresql_tenant_operator.schema_version (singleton, version) +VALUES (true, 1) +ON CONFLICT (singleton) DO NOTHING`, + `CREATE TABLE IF NOT EXISTS postgresql_tenant_operator.tenant_ownership ( + instance_uid text NOT NULL, + tenant_uid text NOT NULL, + tenant_namespace text NOT NULL, + tenant_name text NOT NULL, + database_name text NOT NULL, + role_name text NOT NULL, + credential_path text NOT NULL, + managed boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + retained_at timestamptz, + PRIMARY KEY (instance_uid, tenant_uid), + UNIQUE (instance_uid, tenant_namespace, tenant_name), + UNIQUE (instance_uid, database_name), + UNIQUE (instance_uid, role_name), + UNIQUE (credential_path), + CHECK (managed OR retained_at IS NOT NULL) +)`, +} + +const claimStatement = ` +INSERT INTO postgresql_tenant_operator.tenant_ownership ( + instance_uid, + tenant_uid, + tenant_namespace, + tenant_name, + database_name, + role_name, + credential_path +) VALUES ($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT DO NOTHING` + +const getByTenantUIDStatement = ` +SELECT + instance_uid, + tenant_uid, + tenant_namespace, + tenant_name, + database_name, + role_name, + credential_path, + managed, + created_at, + updated_at, + retained_at +FROM postgresql_tenant_operator.tenant_ownership +WHERE instance_uid = $1 AND tenant_uid = $2` + +const getByTenantUIDForUpdateStatement = getByTenantUIDStatement + ` FOR UPDATE` + +const markRetainedStatement = ` +UPDATE postgresql_tenant_operator.tenant_ownership +SET managed = false, retained_at = clock_timestamp(), updated_at = clock_timestamp() +WHERE instance_uid = $1 AND tenant_uid = $2 AND managed = true` + +const deleteStatement = ` +DELETE FROM postgresql_tenant_operator.tenant_ownership +WHERE instance_uid = $1 AND tenant_uid = $2 AND managed = true` From 9e5fbe6dfb62f805be5f0e5c3ea6b73d5206b9b6 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Thu, 10 Sep 2026 09:51:51 +0000 Subject: [PATCH 2/2] fix: address PostgreSQL registry review --- go.mod | 30 ++++-- go.sum | 64 +++++++++---- .../migrations/001_create_registry.sql | 25 +++++ internal/postgresql/registry/registry.go | 95 +++++++++++-------- internal/postgresql/registry/sql.go | 44 +++------ 5 files changed, 159 insertions(+), 99 deletions(-) create mode 100644 internal/postgresql/registry/migrations/001_create_registry.sql diff --git a/go.mod b/go.mod index d04cef6..9e7e1b6 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( github.com/jackc/pgx/v5 v5.11.0 + github.com/jackc/tern/v2 v2.4.3 github.com/onsi/ginkgo/v2 v2.27.4 github.com/onsi/gomega v1.39.0 k8s.io/apimachinery v0.36.0 @@ -13,7 +14,10 @@ require ( require ( cel.dev/expr v0.25.1 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -38,6 +42,7 @@ require ( github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -45,6 +50,8 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -53,8 +60,10 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cobra v1.10.2 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -69,17 +78,18 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/tools v0.48.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect diff --git a/go.sum b/go.sum index 05f6150..ceb715e 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,13 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -26,6 +32,8 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -72,6 +80,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -82,6 +92,8 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/tern/v2 v2.4.3 h1:g293d3OZgW7OFEhsYXgEv0C21jea2boNr0VR4K5I7OY= +github.com/jackc/tern/v2 v2.4.3/go.mod h1:rMpMuRYcff5wWLptoTSO1qcDxJ4OodysvK17i2SVBys= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -105,6 +117,10 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -133,10 +149,15 @@ github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05Zp github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -149,8 +170,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -189,28 +210,31 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= diff --git a/internal/postgresql/registry/migrations/001_create_registry.sql b/internal/postgresql/registry/migrations/001_create_registry.sql new file mode 100644 index 0000000..24889bb --- /dev/null +++ b/internal/postgresql/registry/migrations/001_create_registry.sql @@ -0,0 +1,25 @@ +CREATE SCHEMA postgresql_tenant_operator; + +CREATE TABLE postgresql_tenant_operator.tenant_ownership ( + instance_uid text NOT NULL, + tenant_uid text NOT NULL, + tenant_namespace text NOT NULL, + tenant_name text NOT NULL, + database_name text NOT NULL, + role_name text NOT NULL, + credential_path text NOT NULL, + managed boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + retained_at timestamptz, + PRIMARY KEY (instance_uid, tenant_uid), + UNIQUE (instance_uid, tenant_namespace, tenant_name), + UNIQUE (instance_uid, database_name), + UNIQUE (instance_uid, role_name), + UNIQUE (credential_path), + CHECK (managed OR retained_at IS NOT NULL) +); + +---- create above / drop below ---- + +DROP SCHEMA postgresql_tenant_operator CASCADE; diff --git a/internal/postgresql/registry/registry.go b/internal/postgresql/registry/registry.go index 1846ec8..486099a 100644 --- a/internal/postgresql/registry/registry.go +++ b/internal/postgresql/registry/registry.go @@ -21,14 +21,21 @@ package registry import ( "context" + "embed" "errors" "fmt" + "io/fs" "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/tern/v2/migrate" ) -const schemaVersion = 1 +const migrationVersionTable = "public.postgresql_tenant_operator_schema_version" + +//go:embed migrations/*.sql +var migrationFiles embed.FS var ( // ErrNotFound indicates that the registry has no matching ownership record. @@ -80,35 +87,29 @@ const ( ClaimOwned ClaimResult = "Owned" ) -// Bootstrap creates the private schema and the current registry table idempotently. +// Bootstrap applies all pending versioned registry migrations idempotently. func (s *Store) Bootstrap(ctx context.Context) error { if s == nil || s.db == nil { return errors.New("bootstrap registry: nil database") } - tx, err := s.db.Begin(ctx) - if err != nil { - return fmt.Errorf("begin registry bootstrap: %w", err) - } - defer func() { _ = tx.Rollback(ctx) }() - - for _, statement := range bootstrapStatements { - if _, err := tx.Exec(ctx, statement); err != nil { - return fmt.Errorf("bootstrap registry schema version %d: %w", schemaVersion, err) + return s.withMigrationConnection(ctx, func(conn *pgx.Conn) error { + migrations, err := fs.Sub(migrationFiles, "migrations") + if err != nil { + return fmt.Errorf("open embedded registry migrations: %w", err) } - } - var version int - if err := tx.QueryRow(ctx, `SELECT version FROM postgresql_tenant_operator.schema_version WHERE singleton`).Scan(&version); err != nil { - return fmt.Errorf("read registry schema version: %w", err) - } - if version != schemaVersion { - return fmt.Errorf("registry schema version %d is unsupported; expected %d", version, schemaVersion) - } - - if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("commit registry bootstrap: %w", err) - } - return nil + migrator, err := migrate.NewMigrator(ctx, conn, migrationVersionTable) + if err != nil { + return fmt.Errorf("initialize registry migrator: %w", err) + } + if err := migrator.LoadMigrations(migrations); err != nil { + return fmt.Errorf("load registry migrations: %w", err) + } + if err := migrator.Migrate(ctx); err != nil { + return fmt.Errorf("apply registry migrations: %w", err) + } + return nil + }) } // Claim reserves all names for an owner. Repeating an identical claim is idempotent. @@ -126,7 +127,7 @@ func (s *Store) Claim(ctx context.Context, owner Ownership) (ClaimResult, error) } defer func() { _ = tx.Rollback(ctx) }() - commandTag, err := tx.Exec(ctx, claimStatement, + record, err := scanRecord(tx.QueryRow(ctx, claimStatement, owner.InstanceUID, owner.TenantUID, owner.TenantNamespace, @@ -134,29 +135,47 @@ func (s *Store) Claim(ctx context.Context, owner Ownership) (ClaimResult, error) owner.DatabaseName, owner.RoleName, owner.CredentialPath, - ) - if err != nil { + )) + if err != nil && !errors.Is(err, ErrNotFound) { return "", fmt.Errorf("insert registry claim: %w", err) } - - record, err := getByTenantUID(ctx, tx, owner.InstanceUID, owner.TenantUID) - if err != nil { - if errors.Is(err, ErrNotFound) { + if errors.Is(err, ErrNotFound) { + record, err = getByTenantUID(ctx, tx, owner.InstanceUID, owner.TenantUID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return "", fmt.Errorf("%w: database, role, tenant identity, or credential path is already reserved", ErrConflict) + } + return "", err + } + if !record.equal(owner) || !record.Managed { return "", fmt.Errorf("%w: database, role, tenant identity, or credential path is already reserved", ErrConflict) } - return "", err - } - if !record.equal(owner) || !record.Managed { - return "", fmt.Errorf("%w: existing record does not match the requested managed owner", ErrConflict) + if err := tx.Commit(ctx); err != nil { + return "", fmt.Errorf("commit registry claim: %w", err) + } + return ClaimOwned, nil } if err := tx.Commit(ctx); err != nil { return "", fmt.Errorf("commit registry claim: %w", err) } - if commandTag.RowsAffected() == 1 { - return ClaimCreated, nil + return ClaimCreated, nil +} + +func (s *Store) withMigrationConnection(ctx context.Context, run func(*pgx.Conn) error) error { + switch db := s.db.(type) { + case *pgx.Conn: + return run(db) + case *pgxpool.Pool: + conn, err := db.Acquire(ctx) + if err != nil { + return fmt.Errorf("acquire registry migration connection: %w", err) + } + defer conn.Release() + return run(conn.Conn()) + default: + return fmt.Errorf("bootstrap registry: database type %T cannot provide a migration connection", s.db) } - return ClaimOwned, nil } // Get returns the ownership record for an Instance UID and Tenant UID. diff --git a/internal/postgresql/registry/sql.go b/internal/postgresql/registry/sql.go index 3e59047..fe75d57 100644 --- a/internal/postgresql/registry/sql.go +++ b/internal/postgresql/registry/sql.go @@ -16,36 +16,6 @@ limitations under the License. package registry -var bootstrapStatements = []string{ - `CREATE SCHEMA IF NOT EXISTS postgresql_tenant_operator`, - `CREATE TABLE IF NOT EXISTS postgresql_tenant_operator.schema_version ( - singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), - version integer NOT NULL -)`, - `INSERT INTO postgresql_tenant_operator.schema_version (singleton, version) -VALUES (true, 1) -ON CONFLICT (singleton) DO NOTHING`, - `CREATE TABLE IF NOT EXISTS postgresql_tenant_operator.tenant_ownership ( - instance_uid text NOT NULL, - tenant_uid text NOT NULL, - tenant_namespace text NOT NULL, - tenant_name text NOT NULL, - database_name text NOT NULL, - role_name text NOT NULL, - credential_path text NOT NULL, - managed boolean NOT NULL DEFAULT true, - created_at timestamptz NOT NULL DEFAULT clock_timestamp(), - updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), - retained_at timestamptz, - PRIMARY KEY (instance_uid, tenant_uid), - UNIQUE (instance_uid, tenant_namespace, tenant_name), - UNIQUE (instance_uid, database_name), - UNIQUE (instance_uid, role_name), - UNIQUE (credential_path), - CHECK (managed OR retained_at IS NOT NULL) -)`, -} - const claimStatement = ` INSERT INTO postgresql_tenant_operator.tenant_ownership ( instance_uid, @@ -56,7 +26,19 @@ INSERT INTO postgresql_tenant_operator.tenant_ownership ( role_name, credential_path ) VALUES ($1, $2, $3, $4, $5, $6, $7) -ON CONFLICT DO NOTHING` +ON CONFLICT DO NOTHING +RETURNING + instance_uid, + tenant_uid, + tenant_namespace, + tenant_name, + database_name, + role_name, + credential_path, + managed, + created_at, + updated_at, + retained_at` const getByTenantUIDStatement = ` SELECT