diff --git a/docs/releases.md b/docs/releases.md index b0648e5..809cc6e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -73,3 +73,17 @@ and a dev tag on an off-main commit containing this workflow: both runs must succeed, with seven assets each, and only the dev release marked prerelease (and never Latest). Existing historical tags still contain their old workflow; rerunning those runs does not exercise this change. + +## Native shared-server qualification + +Before releasing a change to native shared servers, run the real Nix lifecycle +suite, including `TestRunServerRefusesTwoServicesClaimingOneDatabase` with distinct +owner passwords and `TestRunServerSerializesInitialDatabaseClaims`. A refused +claim must leave the accepted service's credentials intact. Provisioning reuses +the run-server lock and checks claims before mutating roles. `TestMain` isolates +these servers from the operator's run-server directory. + +Detached postmaster shutdown re-establishes the recorded boot/start identity and +uses Core's `runners/base.SignalProcess` for kernel-bound delivery. PostgreSQL +owns the shutdown signals and wait policy; it never signals a bare PID after +inspection. A stale birth is refused without signaling its replacement. diff --git a/go.mod b/go.mod index 4d8cde4..5b4055f 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ module github.com/codefly-dev/service-postgres -go 1.27.2 +go 1.27.3 require ( - github.com/codefly-dev/core v0.19.4 + github.com/codefly-dev/core v0.19.8-0.20261027211051-bc4e15cab5b0 github.com/codefly-dev/gortk v0.2.0 github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260915032422-272301033aed github.com/gofrs/flock v0.13.1 @@ -27,7 +27,7 @@ require ( github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect github.com/VividCortex/ewma v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect @@ -37,6 +37,7 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.5.2+incompatible // indirect @@ -106,6 +107,7 @@ require ( go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 // indirect go.opentelemetry.io/otel/log v1.47.0 // indirect go.opentelemetry.io/otel/metric v1.47.0 // indirect go.opentelemetry.io/otel/trace v1.47.0 // indirect diff --git a/go.sum b/go.sum index f12130e..de3fd18 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,8 @@ github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3Q github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= 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/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= @@ -50,8 +50,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/codefly-dev/core v0.19.4 h1:vUvaFrWkB8B3ksQY/qlgBTVj3jMNhdEpJca8aofaals= -github.com/codefly-dev/core v0.19.4/go.mod h1:0uX/7lTBVaGV0RI2Mb3mpEr/PPdx/jZqXHym/U8FVDI= +github.com/codefly-dev/core v0.19.8-0.20261027211051-bc4e15cab5b0 h1:r5xxRusbZ54Wpk2T6aqqqNWvHmVVJ8HMJNdofE7mqrc= +github.com/codefly-dev/core v0.19.8-0.20261027211051-bc4e15cab5b0/go.mod h1:NB7x1UZ01rYTfEZEl4vUFD1A+fgOXhsWbgA3M8x4ThA= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260915032422-272301033aed h1:P4H/FvViECu25UB7OEyCgyyDVsj6/Ql5rRPKCtcUklA= @@ -60,8 +60,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc= +github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -215,14 +215,14 @@ github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= -github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= -github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= -github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= -github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -253,8 +253,8 @@ github.com/shoenig/go-m1cpu v0.2.2 h1:4nc55oVv7nygGnfI9bhLCLzUEs4794y0Bkqx4q2zy7 github.com/shoenig/go-m1cpu v0.2.2/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -286,8 +286,8 @@ go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 h1:aXZXsZ012wOgVkqaiQv+Z/d8V0xKjmg70F6m4CE94lc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0/go.mod h1:ziu1gUIJhAaxM3EV1e3Ralk5AAyL1UOYz3lJJ4VSI38= go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= @@ -326,8 +326,8 @@ golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= diff --git a/nixpg.go b/nixpg.go index eea154d..e47865c 100644 --- a/nixpg.go +++ b/nixpg.go @@ -248,7 +248,7 @@ func acquireRunServer(ctx context.Context, key, service, logLevel string, out io } // lockRunServer serializes every start, attach, release and collection of one -// run server across the run's agent processes. +// run server across the run's agent processes, including database provisioning. func lockRunServer(ctx context.Context, root string) (func(), error) { lock := flock.New(filepath.Join(root, "lock")) locked, err := lock.TryLockContext(ctx, 100*time.Millisecond) @@ -673,12 +673,44 @@ func (n *nixPostgres) ping(ctx context.Context) error { // another service's database, copy it, or alter another service's roles. A // database another service of the run already claimed is refused by name. func (n *nixPostgres) provisionDatabase(ctx context.Context, service, database, owner, password string) error { + unlock, err := lockRunServer(ctx, n.root) + if err != nil { + return err + } + defer unlock() + db, err := sql.Open("postgres", n.adminDSN("postgres")) if err != nil { return err } defer db.Close() + // The database records the service it belongs to, and that record is a + // comment any role with CREATEDB could have written on a database of its + // own. Its current owner is the half that cannot be forged: a service + // creating a database owns it, and it cannot hand ownership to an owner + // role it is not a member of — which the membership check above is what + // keeps true. A database owned by this service's own owner with no comment + // yet is the one legitimate gap, between CREATE DATABASE and COMMENT, and + // the comment is simply written. + marker := databaseClaim(service) + var claim sql.NullString + var currentOwner string + err = db.QueryRowContext(ctx, + `SELECT shobj_description(oid, 'pg_database'), pg_get_userbyid(datdba) FROM pg_database WHERE datname = $1`, + database).Scan(&claim, ¤tOwner) + createDatabase := errors.Is(err, sql.ErrNoRows) + switch { + case err != nil && !createDatabase: + return fmt.Errorf("look up database %q: %w", database, err) + case !createDatabase && currentOwner != owner: + return fmt.Errorf("database %q on this run's postgres server is owned by %q, not by %s's migration owner: give each service of a run its own database-name", + database, currentOwner, service) + case !createDatabase && claim.Valid && claim.String != marker: + return fmt.Errorf("database %q on this run's postgres server belongs to %s, not %s: give each service of a run its own database-name", + database, strings.TrimPrefix(claim.String, databaseClaimPrefix), service) + } + // A migration owner may create roles, because its migrations create the // application's own NOLOGIN roles. On PostgreSQL 16 and later a role // created that way is granted back to its creator WITH ADMIN OPTION, so a @@ -710,38 +742,13 @@ func (n *nixPostgres) provisionDatabase(ctx context.Context, service, database, return fmt.Errorf("configure the migration owner: %w", err) } - // The database records the service it belongs to, and that record is a - // comment any role with CREATEDB could have written on a database of its - // own. Its current owner is the half that cannot be forged: a service - // creating a database owns it, and it cannot hand ownership to an owner - // role it is not a member of — which the membership check above is what - // keeps true. A database owned by this service's own owner with no comment - // yet is the one legitimate gap, between CREATE DATABASE and COMMENT, and - // the comment is simply written. - marker := databaseClaim(service) - var claim sql.NullString - var currentOwner string - err = db.QueryRowContext(ctx, - `SELECT shobj_description(oid, 'pg_database'), pg_get_userbyid(datdba) FROM pg_database WHERE datname = $1`, - database).Scan(&claim, ¤tOwner) quotedDatabase := pq.QuoteIdentifier(database) - switch { - case errors.Is(err, sql.ErrNoRows): + if createDatabase { if _, err := db.ExecContext(ctx, `CREATE DATABASE `+quotedDatabase+` OWNER `+quotedOwner); err != nil { return fmt.Errorf("create database %q: %w", database, err) } - if _, err := db.ExecContext(ctx, `COMMENT ON DATABASE `+quotedDatabase+` IS `+pq.QuoteLiteral(marker)); err != nil { - return fmt.Errorf("record database %q's service: %w", database, err) - } - case err != nil: - return fmt.Errorf("look up database %q: %w", database, err) - case currentOwner != owner: - return fmt.Errorf("database %q on this run's postgres server is owned by %q, not by %s's migration owner: give each service of a run its own database-name", - database, currentOwner, service) - case claim.Valid && claim.String != marker: - return fmt.Errorf("database %q on this run's postgres server belongs to %s, not %s: give each service of a run its own database-name", - database, strings.TrimPrefix(claim.String, databaseClaimPrefix), service) - case !claim.Valid: + } + if !claim.Valid { if _, err := db.ExecContext(ctx, `COMMENT ON DATABASE `+quotedDatabase+` IS `+pq.QuoteLiteral(marker)); err != nil { return fmt.Errorf("record database %q's service: %w", database, err) } @@ -942,25 +949,18 @@ func stopServer(root, dataDir string) (bool, error) { return true, nil } -// signalPostmaster delivers a signal to a recorded postmaster, and only while -// that exact birth is still the process behind the number. -// -// The confirmation and the delivery are two system calls, so a process that -// exits between them cannot be signalled by this call at all (the signal is -// then rejected, as the PID no longer resolves) unless the number is reused in -// that same window. Narrowing it to those microseconds is the most a caller -// can do for a process that is not its child; what this removes is the real -// exposure, which is a file on disk naming a PID whose process died minutes, -// hours or a reboot ago. +// signalPostmaster re-establishes the recorded birth, then delegates delivery +// to Core's kernel-bound process handle. A PID reused after inspection cannot +// receive the signal; PostgreSQL shutdown policy remains here. func signalPostmaster(postmaster processMark, signal syscall.Signal) error { - if !postmaster.confirmed() { - return fmt.Errorf("postgres server %d is no longer the process this run started", postmaster.PID) - } - process, err := os.FindProcess(postmaster.PID) + identity, err := runners.InspectProcess(postmaster.PID) if err != nil { return err } - return process.Signal(signal) + if identity.StartID != postmaster.StartID || identity.BootID != postmaster.BootID { + return fmt.Errorf("postgres server %d is no longer the process this run started: %w", postmaster.PID, runners.ErrProcessNotFound) + } + return runners.SignalProcess(context.Background(), identity, signal) } // forgetPostmasterRecord drops a record no server answers to any more, so a diff --git a/nixpg_shared_test.go b/nixpg_shared_test.go index 0ba217d..f9b859c 100644 --- a/nixpg_shared_test.go +++ b/nixpg_shared_test.go @@ -3,6 +3,7 @@ package main import ( "context" "database/sql" + "fmt" "net/url" "os" "path/filepath" @@ -127,6 +128,11 @@ func TestRunServerRefusesTwoServicesClaimingOneDatabase(t *testing.T) { workspace := t.TempDir() first := newPostgresFixtureAt(t, ctx, workspace, "shared", "first", resources.NewRuntimeContextNix()) second := newPostgresFixtureAt(t, ctx, workspace, "shared", "second", resources.NewRuntimeContextNix()) + for _, value := range second.configuration.Infos[0].ConfigurationValues { + if value.Key == "POSTGRES_PASSWORD" { + value.Value = "second-owner-password" + } + } run, _ := first.start(t, ctx) @@ -253,3 +259,37 @@ func TestRunServerRefusesAnOwnerRoleAnotherServiceCreated(t *testing.T) { require.Contains(t, init.GetStatus().GetMessage(), "was not created for victim/store") require.Nil(t, refused.nixRuntime, "a refused service holds no server") } + +// Competing initial claims must choose one service before either can alter the +// shared owner role. The losing service's password must never become usable. +func TestRunServerSerializesInitialDatabaseClaims(t *testing.T) { + requireNix(t) + ctx := t.Context() + fixture := newPostgresFixtureAt(t, ctx, t.TempDir(), "seed", "store", resources.NewRuntimeContextNix()) + run, _ := fixture.start(t, ctx) + const database = "contended" + owner := nativeOwnerRole(database) + start := make(chan struct{}) + results := make(chan int, 2) + for i := range 2 { + go func() { + <-start + err := run.nixRuntime.provisionDatabase(ctx, fmt.Sprintf("claimant/%d", i), database, owner, fmt.Sprintf("password-%d", i)) + if err != nil { + results <- -1 + } else { + results <- i + } + }() + } + close(start) + first, second := <-results, <-results + require.NotEqual(t, first == -1, second == -1, "exactly one initial claim succeeds") + winner := first + if winner == -1 { + winner = second + } + connection := withDatabase(t, run.connection, database) + require.NoError(t, connects(ctx, withUser(t, connection, owner, fmt.Sprintf("password-%d", winner)))) + require.Error(t, connects(ctx, withUser(t, connection, owner, fmt.Sprintf("password-%d", 1-winner)))) +} diff --git a/templates/agent/README.md.tmpl b/templates/agent/README.md.tmpl index 89bbde0..9efffcb 100644 --- a/templates/agent/README.md.tmpl +++ b/templates/agent/README.md.tmpl @@ -51,6 +51,10 @@ On the Nix runtime every Postgres service of a run shares **one** Postgres serve - **Endpoints.** The server listens on no TCP port, only on a private Unix socket. Each service's agent listens on the service's own allocated port, on loopback, and forwards every connection to that socket; authentication stays between the client and the server. - **Lifecycle.** The first service to start starts the server; each stop or destroy releases that service, and the last one stops the server, which frees its shared memory. A run killed without stopping leaves a server no live agent holds; the next service to start on the machine stops it. The cluster directory is retained per run, as each service's was. +Native shutdown signals the recorded process birth through Core, so a reused PID is not a shutdown target. + +Database provisioning is serialized under the run server lock. An existing database claim is checked before changing its owner role, so a refused second service cannot rotate the first service's password. + Existing per-service native clusters are not migrated: a run's first start on this version creates its databases empty and migrates them, and the old directories are left where they are. ## Lifecycle: stop, keep-running, destroy