From b9334e5f1bd7aa16ec5aa38a6ca562920ba22034 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 1 Oct 2026 14:43:55 +0200 Subject: [PATCH 1/5] feat(postgres): add create/update mutations and reuse live personal access --- .../console-backend-rbac/templates/rbac.yaml | 12 + integration_tests/create_postgres_access.lua | 61 +- .../dev/pg-crud-team/postgres.yaml | 12 + integration_tests/postgres_crud.lua | 125 +++ internal/auth/authz/queries.go | 8 + ..._postgres_create_update_authorizations.sql | 33 + .../graph/gengql/activitylog.generated.go | 14 + internal/graph/gengql/postgres.generated.go | 987 +++++++++++++++++- internal/graph/gengql/root_.generated.go | 336 ++++++ internal/graph/gengql/schema.generated.go | 146 +++ internal/graph/postgres.resolvers.go | 14 + internal/graph/schema/postgres.graphqls | 120 +++ internal/kubernetes/fake/fake.go | 1 + internal/persistence/postgres/activitylog.go | 18 + internal/persistence/postgres/queries.go | 54 +- internal/persistence/postgres/write.go | 286 +++++ 16 files changed, 2177 insertions(+), 50 deletions(-) create mode 100644 integration_tests/k8s_resources/postgres_crud/dev/pg-crud-team/postgres.yaml create mode 100644 integration_tests/postgres_crud.lua create mode 100644 internal/database/migrations/0077_add_postgres_create_update_authorizations.sql create mode 100644 internal/persistence/postgres/write.go diff --git a/charts/console-backend-rbac/templates/rbac.yaml b/charts/console-backend-rbac/templates/rbac.yaml index 18244f802..753fa41a9 100644 --- a/charts/console-backend-rbac/templates/rbac.yaml +++ b/charts/console-backend-rbac/templates/rbac.yaml @@ -119,12 +119,23 @@ rules: - update - patch - delete + - apiGroups: + - "nais.io" + resources: + - postgres + verbs: + - get + - list + - watch + - create + - update - apiGroups: - "nais.io" resources: - postgresaccesses verbs: - get + - list - create - apiGroups: - "nais.io" @@ -301,6 +312,7 @@ rules: - postgresaccesses verbs: - get + - list - create - apiGroups: - "nais.io" diff --git a/integration_tests/create_postgres_access.lua b/integration_tests/create_postgres_access.lua index b4e042cdc..cbd709f09 100644 --- a/integration_tests/create_postgres_access.lua +++ b/integration_tests/create_postgres_access.lua @@ -1,10 +1,12 @@ local user = User.new("user", "user@usersen.com") local otherMemberUser = User.new("othermember", "othermember@usersen.com") +local creatorUser = User.new("creator", "creator@usersen.com") local nonMemberUser = User.new("nonmember", "other@user.com") local mainTeam = Team.new("someteamname", "purpose", "#slack_channel") mainTeam:addMember(user) mainTeam:addMember(otherMemberUser) +mainTeam:addMember(creatorUser) Helper.readK8sResources("k8s_resources/create_postgres_access") @@ -151,7 +153,7 @@ Test.gql("Create personal postgres access rejects an unavailable instance", func end) Test.gql("Create personal postgres access", function(t) - t.addHeader("x-user-email", user:email()) + t.addHeader("x-user-email", creatorUser:email()) t.query [[ mutation CreatePostgresAccess { createPostgresAccess(input: { @@ -179,7 +181,7 @@ Test.gql("Create personal postgres access", function(t) end) Test.gql("Personal postgres access is audited as a self-grant", function(t) - t.addHeader("x-user-email", user:email()) + t.addHeader("x-user-email", creatorUser:email()) t.query [[ { team(slug: "someteamname") { @@ -206,9 +208,9 @@ Test.gql("Personal postgres access is audited as a self-grant", function(t) activityLog = { nodes = { { - message = Contains("Requested READWRITE personal Postgres access for user@usersen.com"), + message = Contains("Requested READWRITE personal Postgres access for creator@usersen.com"), data = { - username = "user@usersen.com", + username = "creator@usersen.com", accessLevel = "READWRITE", expiresAt = NotNull(), reason = "Testing personal database access", @@ -221,6 +223,38 @@ Test.gql("Personal postgres access is audited as a self-grant", function(t) } end) +Test.gql("Requesting access again returns the live access instead of creating another", function(t) + t.addHeader("x-user-email", creatorUser:email()) + local request = [[ + mutation { createPostgresAccess(input: { + postgres: "foobar", branch: "main", environmentName: "dev", teamSlug: "someteamname", + accessLevel: READWRITE, reason: "Testing personal database access" + }) { name expiresAt } } + ]] + t.query(request) + t.check { data = { createPostgresAccess = { name = NotNull(), expiresAt = NotNull() } } } + t.query(request) + t.check { data = { createPostgresAccess = { name = NotNull(), expiresAt = NotNull() } } } + + -- A reused access is not a new request, so it must not be audited again. + t.query [[{ team(slug: "someteamname") { activityLog(filter: { activityTypes: [POSTGRES_PERSONAL_ACCESS_CREATED] }) { nodes { message } } } }]] + t.check { data = { team = { activityLog = { nodes = { { message = Contains("creator@usersen.com") } } } } } } +end) + +Test.gql("Requesting a different access level while one is live is rejected", function(t) + t.addHeader("x-user-email", creatorUser:email()) + t.query [[ + mutation { createPostgresAccess(input: { + postgres: "foobar", branch: "main", environmentName: "dev", teamSlug: "someteamname", + accessLevel: READ, reason: "Testing personal database access" + }) { name } } + ]] + t.check { + errors = { { locations = NotNull(), path = { "createPostgresAccess" }, message = Contains("already have readwrite access") } }, + data = Null, + } +end) + Test.gql("Personal access targets a physical instance, even when its name differs from logical Postgres", function(t) t.addHeader("x-user-email", user:email()) t.query [[ @@ -340,24 +374,31 @@ Test.gql("PostgresAccess credentials cannot be read through generic Secret eleva end end) -Test.gql("PostgresAccess connection rejects expired, unready, and missing-secret access", function(t) +Test.gql("PostgresAccess connection is null without error until the access is ready", function(t) t.addHeader("x-user-email", user:email()) for _, test in ipairs({ - { name = "expired-access", state = "EXPIRED", message = "has expired" }, - { name = "pending-access", state = "PENDING", message = "is not ready" }, - { name = "failed-access", state = "FAILED", message = "is not ready" }, - { name = "missing-secret-access", state = "READY", message = "secrets for PostgresAccess is not available" }, + { name = "expired-access", state = "EXPIRED" }, + { name = "pending-access", state = "PENDING" }, + { name = "failed-access", state = "FAILED" }, }) do t.query(string.format( [[query { team(slug: "someteamname") { environment(name: "dev") { postgresAccess(name: "%s") { state connection { password } } } } }]], test.name)) t.check { - errors = { { locations = NotNull(), path = { "team", "environment", "postgresAccess", "connection" }, message = Contains(test.message) } }, data = { team = { environment = { postgresAccess = { state = test.state, connection = Null } } } }, } end end) +Test.gql("PostgresAccess connection fails when a ready access has no secrets", function(t) + t.addHeader("x-user-email", user:email()) + t.query [[query { team(slug: "someteamname") { environment(name: "dev") { postgresAccess(name: "missing-secret-access") { state connection { password } } } } }]] + t.check { + errors = { { locations = NotNull(), path = { "team", "environment", "postgresAccess", "connection" }, message = Contains("secrets for PostgresAccess is not available") } }, + data = { team = { environment = { postgresAccess = { state = "READY", connection = Null } } } }, + } +end) + Test.gql("Personal postgres connection retrieval is audited", function(t) t.addHeader("x-user-email", user:email()) t.query [[ diff --git a/integration_tests/k8s_resources/postgres_crud/dev/pg-crud-team/postgres.yaml b/integration_tests/k8s_resources/postgres_crud/dev/pg-crud-team/postgres.yaml new file mode 100644 index 000000000..fc445a3e4 --- /dev/null +++ b/integration_tests/k8s_resources/postgres_crud/dev/pg-crud-team/postgres.yaml @@ -0,0 +1,12 @@ +apiVersion: nais.io/v1 +kind: Postgres +metadata: + name: existing + namespace: pg-crud-team +spec: + majorVersion: "17" + highAvailability: false + resources: + cpu: 100m + memory: 512Mi + diskSize: 10Gi diff --git a/integration_tests/postgres_crud.lua b/integration_tests/postgres_crud.lua new file mode 100644 index 000000000..7f355e03d --- /dev/null +++ b/integration_tests/postgres_crud.lua @@ -0,0 +1,125 @@ +local member = User.new("postgres-crud-user", "postgres-crud-user@usersen.com") +local outsider = User.new("postgres-crud-outsider", "postgres-crud-outsider@usersen.com") +local team = Team.new("pg-crud-team", "Testing Postgres create and update", "#postgres-crud") +team:addMember(member) +Helper.readK8sResources("k8s_resources/postgres_crud") + +local function create(name, extra) + return string.format( + [[mutation { createPostgres(input: { + name: "%s", environmentName: "dev", teamSlug: "pg-crud-team", %s + }) { postgres { name majorVersion highAvailability resources { cpu memory diskSize } } } }]], + name, + extra + ) +end + +Test.gql("Non-members cannot create Postgres", function(t) + t.addHeader("x-user-email", outsider:email()) + t.query(create("denied", 'majorVersion: "18"')) + t.check { + errors = { { locations = NotNull(), path = { "createPostgres" }, message = Contains("You are authenticated") } }, + data = Null, + } +end) + +Test.gql("Team members can create Postgres with defaults left to the platform", function(t) + t.addHeader("x-user-email", member:email()) + t.query(create("fresh", 'majorVersion: "18"')) + t.check { + data = { + createPostgres = { + postgres = { + name = "fresh", + majorVersion = "18", + highAvailability = false, + resources = { cpu = Null, memory = Null, diskSize = Null }, + }, + }, + }, + } +end) + +Test.gql("Team members can create Postgres with explicit resources", function(t) + t.addHeader("x-user-email", member:email()) + t.query(create("sized", 'majorVersion: "16", highAvailability: true, cpu: "250m", memory: "1Gi", diskSize: "20Gi"')) + t.check { + data = { + createPostgres = { + postgres = { + name = "sized", + majorVersion = "16", + highAvailability = true, + resources = { cpu = "250m", memory = "1Gi", diskSize = "20Gi" }, + }, + }, + }, + } +end) + +Test.gql("Creating an existing Postgres fails", function(t) + t.addHeader("x-user-email", member:email()) + t.query(create("existing", 'majorVersion: "17"')) + t.check { + errors = { { locations = NotNull(), path = { "createPostgres" }, message = NotNull() } }, + data = Null, + } +end) + +Test.gql("Unsupported major version is rejected", function(t) + t.addHeader("x-user-email", member:email()) + t.query(create("badversion", 'majorVersion: "9"')) + t.check { + errors = { { extensions = { field = "majorVersion" }, path = { "createPostgres" }, message = Contains("Major version must be one of") } }, + data = Null, + } +end) + +Test.gql("Invalid resource quantity is rejected", function(t) + t.addHeader("x-user-email", member:email()) + t.query(create("badquantity", 'majorVersion: "18", cpu: "lots"')) + t.check { + errors = { { extensions = { field = "cpu" }, path = { "createPostgres" }, message = Contains("is not a valid quantity") } }, + data = Null, + } +end) + +Test.gql("Team members can update only the fields they provide", function(t) + t.addHeader("x-user-email", member:email()) + t.query [[mutation { updatePostgres(input: { + name: "existing", environmentName: "dev", teamSlug: "pg-crud-team", highAvailability: true, diskSize: "30Gi" + }) { postgres { majorVersion highAvailability resources { cpu memory diskSize } } } }]] + t.check { + data = { + updatePostgres = { + postgres = { + majorVersion = "17", + highAvailability = true, + resources = { cpu = "100m", memory = "512Mi", diskSize = "30Gi" }, + }, + }, + }, + } +end) + +Test.gql("Non-members cannot update Postgres", function(t) + t.addHeader("x-user-email", outsider:email()) + t.query [[mutation { updatePostgres(input: { + name: "existing", environmentName: "dev", teamSlug: "pg-crud-team", highAvailability: true + }) { postgres { name } } }]] + t.check { + errors = { { locations = NotNull(), path = { "updatePostgres" }, message = Contains("You are authenticated") } }, + data = Null, + } +end) + +Test.gql("Updating a missing Postgres fails", function(t) + t.addHeader("x-user-email", member:email()) + t.query [[mutation { updatePostgres(input: { + name: "missing", environmentName: "dev", teamSlug: "pg-crud-team", highAvailability: true + }) { postgres { name } } }]] + t.check { + errors = { { locations = NotNull(), path = { "updatePostgres" }, message = Contains("not found") } }, + data = Null, + } +end) diff --git a/internal/auth/authz/queries.go b/internal/auth/authz/queries.go index b62feb0b2..745d4bcca 100644 --- a/internal/auth/authz/queries.go +++ b/internal/auth/authz/queries.go @@ -320,6 +320,14 @@ func CanGrantPostgresAccess(ctx context.Context, teamSlug slug.Slug) error { return requireStrictTeamAuthorization(ctx, teamSlug, "postgres:access:grant") } +func CanCreatePostgres(ctx context.Context, teamSlug slug.Slug) error { + return requireTeamAuthorization(ctx, teamSlug, "postgres:create") +} + +func CanUpdatePostgres(ctx context.Context, teamSlug slug.Slug) error { + return requireTeamAuthorization(ctx, teamSlug, "postgres:update") +} + func CanDeletePostgres(ctx context.Context, teamSlug slug.Slug) error { return requireTeamAuthorization(ctx, teamSlug, "postgres:delete") } diff --git a/internal/database/migrations/0077_add_postgres_create_update_authorizations.sql b/internal/database/migrations/0077_add_postgres_create_update_authorizations.sql new file mode 100644 index 000000000..f8245c806 --- /dev/null +++ b/internal/database/migrations/0077_add_postgres_create_update_authorizations.sql @@ -0,0 +1,33 @@ +-- +goose Up +INSERT INTO + authorizations (name, description) +VALUES + ( + 'postgres:create', + 'Permission to create Postgres instances.' + ), + ( + 'postgres:update', + 'Permission to update Postgres instances.' + ) +; + +INSERT INTO + role_authorizations (role_name, authorization_name) +VALUES + ('Team member', 'postgres:create'), + ('Team member', 'postgres:update'), + ('Team owner', 'postgres:create'), + ('Team owner', 'postgres:update') +; + +-- +goose Down +DELETE FROM role_authorizations +WHERE + authorization_name IN ('postgres:create', 'postgres:update') +; + +DELETE FROM authorizations +WHERE + name IN ('postgres:create', 'postgres:update') +; diff --git a/internal/graph/gengql/activitylog.generated.go b/internal/graph/gengql/activitylog.generated.go index dbdf65c8c..a79520cce 100644 --- a/internal/graph/gengql/activitylog.generated.go +++ b/internal/graph/gengql/activitylog.generated.go @@ -789,6 +789,13 @@ func (ec *executionContext) _ActivityLogEntry(ctx context.Context, sel ast.Selec return graphql.Null } return ec._ReconcilerConfiguredActivityLogEntry(ctx, sel, obj) + case postgres.PostgresUpdatedActivityLogEntry: + return ec._PostgresUpdatedActivityLogEntry(ctx, sel, &obj) + case *postgres.PostgresUpdatedActivityLogEntry: + if obj == nil { + return graphql.Null + } + return ec._PostgresUpdatedActivityLogEntry(ctx, sel, obj) case postgres.PostgresPersonalAccessCreatedActivityLogEntry: return ec._PostgresPersonalAccessCreatedActivityLogEntry(ctx, sel, &obj) case *postgres.PostgresPersonalAccessCreatedActivityLogEntry: @@ -817,6 +824,13 @@ func (ec *executionContext) _ActivityLogEntry(ctx context.Context, sel ast.Selec return graphql.Null } return ec._PostgresDeletedActivityLogEntry(ctx, sel, obj) + case postgres.PostgresCreatedActivityLogEntry: + return ec._PostgresCreatedActivityLogEntry(ctx, sel, &obj) + case *postgres.PostgresCreatedActivityLogEntry: + if obj == nil { + return graphql.Null + } + return ec._PostgresCreatedActivityLogEntry(ctx, sel, obj) case opensearch.OpenSearchUpdatedActivityLogEntry: return ec._OpenSearchUpdatedActivityLogEntry(ctx, sel, &obj) case *opensearch.OpenSearchUpdatedActivityLogEntry: diff --git a/internal/graph/gengql/postgres.generated.go b/internal/graph/gengql/postgres.generated.go index 880e62c65..59ad84ea4 100644 --- a/internal/graph/gengql/postgres.generated.go +++ b/internal/graph/gengql/postgres.generated.go @@ -203,6 +203,38 @@ func (ec *executionContext) fieldContext_CreatePostgresAccessPayload_expiresAt(_ return graphql.NewScalarFieldContext("CreatePostgresAccessPayload", field, false, false, errors.New("field of type Time does not have child fields")) } +func (ec *executionContext) _CreatePostgresPayload_postgres(ctx context.Context, field graphql.CollectedField, obj *postgres.CreatePostgresPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_CreatePostgresPayload_postgres(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Postgres, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *postgres.Postgres) graphql.Marshaler { + return ec.marshalNPostgres2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐPostgres(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_CreatePostgresPayload_postgres(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CreatePostgresPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_Postgres(ctx, field) + }, + } + return fc, nil +} + func (ec *executionContext) _DeletePostgresBranchPayload_postgresBranchDeleted(ctx context.Context, field graphql.CollectedField, obj *postgres.DeletePostgresBranchPayload) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1518,6 +1550,222 @@ func (ec *executionContext) fieldContext_PostgresBranchStateFacetItem_count(_ co return graphql.NewScalarFieldContext("PostgresBranchStateFacetItem", field, false, false, errors.New("field of type Int does not have child fields")) } +func (ec *executionContext) _PostgresCreatedActivityLogEntry_id(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresCreatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresCreatedActivityLogEntry_id(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ID(), nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v ident.Ident) graphql.Marshaler { + return ec.marshalNID2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋidentᚐIdent(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresCreatedActivityLogEntry_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresCreatedActivityLogEntry", field, true, false, errors.New("field of type ID does not have child fields")) +} + +func (ec *executionContext) _PostgresCreatedActivityLogEntry_actor(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresCreatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresCreatedActivityLogEntry_actor(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Actor, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresCreatedActivityLogEntry_actor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresCreatedActivityLogEntry", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _PostgresCreatedActivityLogEntry_gitHubActorClaims(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresCreatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresCreatedActivityLogEntry_gitHubActorClaims(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.GitHubActorClaims, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *github.GitHubActorClaims) graphql.Marshaler { + return ec.marshalOGitHubActorClaims2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋauthᚋmiddlewareᚋgithubᚐGitHubActorClaims(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_PostgresCreatedActivityLogEntry_gitHubActorClaims(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "PostgresCreatedActivityLogEntry", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_GitHubActorClaims(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _PostgresCreatedActivityLogEntry_createdAt(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresCreatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresCreatedActivityLogEntry_createdAt(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.CreatedAt, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v time.Time) graphql.Marshaler { + return ec.marshalNTime2timeᚐTime(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresCreatedActivityLogEntry_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresCreatedActivityLogEntry", field, false, false, errors.New("field of type Time does not have child fields")) +} + +func (ec *executionContext) _PostgresCreatedActivityLogEntry_message(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresCreatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresCreatedActivityLogEntry_message(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Message, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresCreatedActivityLogEntry_message(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresCreatedActivityLogEntry", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _PostgresCreatedActivityLogEntry_resourceType(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresCreatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresCreatedActivityLogEntry_resourceType(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ResourceType, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v activitylog.ActivityLogEntryResourceType) graphql.Marshaler { + return ec.marshalNActivityLogEntryResourceType2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogEntryResourceType(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresCreatedActivityLogEntry_resourceType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresCreatedActivityLogEntry", field, false, false, errors.New("field of type ActivityLogEntryResourceType does not have child fields")) +} + +func (ec *executionContext) _PostgresCreatedActivityLogEntry_resourceName(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresCreatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresCreatedActivityLogEntry_resourceName(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ResourceName, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresCreatedActivityLogEntry_resourceName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresCreatedActivityLogEntry", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _PostgresCreatedActivityLogEntry_teamSlug(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresCreatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresCreatedActivityLogEntry_teamSlug(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.TeamSlug, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *slug.Slug) graphql.Marshaler { + return ec.marshalNSlug2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋslugᚐSlug(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresCreatedActivityLogEntry_teamSlug(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresCreatedActivityLogEntry", field, false, false, errors.New("field of type Slug does not have child fields")) +} + +func (ec *executionContext) _PostgresCreatedActivityLogEntry_environmentName(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresCreatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresCreatedActivityLogEntry_environmentName(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.EnvironmentName, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *string) graphql.Marshaler { + return ec.marshalOString2ᚖstring(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_PostgresCreatedActivityLogEntry_environmentName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresCreatedActivityLogEntry", field, false, false, errors.New("field of type String does not have child fields")) +} + func (ec *executionContext) _PostgresDeletedActivityLogEntry_id(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresDeletedActivityLogEntry) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -2653,51 +2901,299 @@ func (ec *executionContext) fieldContext_PostgresResources_diskSize(_ context.Co return graphql.NewScalarFieldContext("PostgresResources", field, false, false, errors.New("field of type String does not have child fields")) } -func (ec *executionContext) _TeamInventoryCountPostgresBranches_total(ctx context.Context, field graphql.CollectedField, obj *postgres.TeamInventoryCountPostgresBranches) (ret graphql.Marshaler) { +func (ec *executionContext) _PostgresUpdatedActivityLogEntry_id(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntry) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, ec.OperationContext, field, func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return ec.fieldContext_TeamInventoryCountPostgresBranches_total(ctx, field) + return ec.fieldContext_PostgresUpdatedActivityLogEntry_id(ctx, field) }, func(ctx context.Context) (any, error) { - return obj.Total, nil + return obj.ID(), nil }, nil, - func(ctx context.Context, selections ast.SelectionSet, v int) graphql.Marshaler { - return ec.marshalNInt2int(ctx, selections, v) + func(ctx context.Context, selections ast.SelectionSet, v ident.Ident) graphql.Marshaler { + return ec.marshalNID2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋidentᚐIdent(ctx, selections, v) }, true, true, ) } -func (ec *executionContext) fieldContext_TeamInventoryCountPostgresBranches_total(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - return graphql.NewScalarFieldContext("TeamInventoryCountPostgresBranches", field, false, false, errors.New("field of type Int does not have child fields")) +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntry_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresUpdatedActivityLogEntry", field, true, false, errors.New("field of type ID does not have child fields")) } -// endregion **************************** field.gotpl ***************************** - -// region **************************** input.gotpl ***************************** - -func (ec *executionContext) unmarshalInputCreatePostgresAccessInput(ctx context.Context, obj any) (postgres.CreatePostgresAccessInput, error) { - var it postgres.CreatePostgresAccessInput - if obj == nil { - return it, nil - } - - asMap := map[string]any{} - for k, v := range obj.(map[string]any) { - asMap[k] = v - } +func (ec *executionContext) _PostgresUpdatedActivityLogEntry_actor(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresUpdatedActivityLogEntry_actor(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Actor, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntry_actor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresUpdatedActivityLogEntry", field, false, false, errors.New("field of type String does not have child fields")) +} - fieldsInOrder := [...]string{"postgres", "branch", "teamSlug", "environmentName", "accessLevel", "reason", "ttl"} - for _, k := range fieldsInOrder { - v, ok := asMap[k] - if !ok { - continue - } - switch k { +func (ec *executionContext) _PostgresUpdatedActivityLogEntry_gitHubActorClaims(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresUpdatedActivityLogEntry_gitHubActorClaims(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.GitHubActorClaims, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *github.GitHubActorClaims) graphql.Marshaler { + return ec.marshalOGitHubActorClaims2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋauthᚋmiddlewareᚋgithubᚐGitHubActorClaims(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntry_gitHubActorClaims(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "PostgresUpdatedActivityLogEntry", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_GitHubActorClaims(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _PostgresUpdatedActivityLogEntry_createdAt(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresUpdatedActivityLogEntry_createdAt(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.CreatedAt, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v time.Time) graphql.Marshaler { + return ec.marshalNTime2timeᚐTime(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntry_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresUpdatedActivityLogEntry", field, false, false, errors.New("field of type Time does not have child fields")) +} + +func (ec *executionContext) _PostgresUpdatedActivityLogEntry_message(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresUpdatedActivityLogEntry_message(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Message, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntry_message(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresUpdatedActivityLogEntry", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _PostgresUpdatedActivityLogEntry_resourceType(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresUpdatedActivityLogEntry_resourceType(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ResourceType, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v activitylog.ActivityLogEntryResourceType) graphql.Marshaler { + return ec.marshalNActivityLogEntryResourceType2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogEntryResourceType(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntry_resourceType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresUpdatedActivityLogEntry", field, false, false, errors.New("field of type ActivityLogEntryResourceType does not have child fields")) +} + +func (ec *executionContext) _PostgresUpdatedActivityLogEntry_resourceName(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresUpdatedActivityLogEntry_resourceName(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ResourceName, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntry_resourceName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresUpdatedActivityLogEntry", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _PostgresUpdatedActivityLogEntry_teamSlug(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresUpdatedActivityLogEntry_teamSlug(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.TeamSlug, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *slug.Slug) graphql.Marshaler { + return ec.marshalNSlug2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋslugᚐSlug(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntry_teamSlug(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresUpdatedActivityLogEntry", field, false, false, errors.New("field of type Slug does not have child fields")) +} + +func (ec *executionContext) _PostgresUpdatedActivityLogEntry_environmentName(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresUpdatedActivityLogEntry_environmentName(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.EnvironmentName, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *string) graphql.Marshaler { + return ec.marshalOString2ᚖstring(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntry_environmentName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresUpdatedActivityLogEntry", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _TeamInventoryCountPostgresBranches_total(ctx context.Context, field graphql.CollectedField, obj *postgres.TeamInventoryCountPostgresBranches) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_TeamInventoryCountPostgresBranches_total(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Total, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v int) graphql.Marshaler { + return ec.marshalNInt2int(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_TeamInventoryCountPostgresBranches_total(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("TeamInventoryCountPostgresBranches", field, false, false, errors.New("field of type Int does not have child fields")) +} + +func (ec *executionContext) _UpdatePostgresPayload_postgres(ctx context.Context, field graphql.CollectedField, obj *postgres.UpdatePostgresPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_UpdatePostgresPayload_postgres(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Postgres, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *postgres.Postgres) graphql.Marshaler { + return ec.marshalNPostgres2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐPostgres(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_UpdatePostgresPayload_postgres(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UpdatePostgresPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_Postgres(ctx, field) + }, + } + return fc, nil +} + +// endregion **************************** field.gotpl ***************************** + +// region **************************** input.gotpl ***************************** + +func (ec *executionContext) unmarshalInputCreatePostgresAccessInput(ctx context.Context, obj any) (postgres.CreatePostgresAccessInput, error) { + var it postgres.CreatePostgresAccessInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"postgres", "branch", "teamSlug", "environmentName", "accessLevel", "reason", "ttl"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { case "postgres": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("postgres")) data, err := ec.unmarshalNString2string(ctx, v) @@ -2752,6 +3248,85 @@ func (ec *executionContext) unmarshalInputCreatePostgresAccessInput(ctx context. return it, nil } +func (ec *executionContext) unmarshalInputCreatePostgresInput(ctx context.Context, obj any) (postgres.CreatePostgresInput, error) { + var it postgres.CreatePostgresInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"name", "environmentName", "teamSlug", "majorVersion", "highAvailability", "cpu", "memory", "diskSize"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "name": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Name = data + case "environmentName": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("environmentName")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.EnvironmentName = data + case "teamSlug": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teamSlug")) + data, err := ec.unmarshalNSlug2githubᚗcomᚋnaisᚋapiᚋinternalᚋslugᚐSlug(ctx, v) + if err != nil { + return it, err + } + it.TeamSlug = data + case "majorVersion": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("majorVersion")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.MajorVersion = data + case "highAvailability": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("highAvailability")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.HighAvailability = data + case "cpu": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("cpu")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.CPU = data + case "memory": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("memory")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Memory = data + case "diskSize": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("diskSize")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.DiskSize = data + } + } + return it, nil +} + func (ec *executionContext) unmarshalInputDeletePostgresBranchInput(ctx context.Context, obj any) (postgres.DeletePostgresBranchInput, error) { var it postgres.DeletePostgresBranchInput if obj == nil { @@ -2854,8 +3429,45 @@ func (ec *executionContext) unmarshalInputPostgresBranchFilter(ctx context.Conte return it, nil } -func (ec *executionContext) unmarshalInputPostgresBranchOrder(ctx context.Context, obj any) (postgres.PostgresBranchOrder, error) { - var it postgres.PostgresBranchOrder +func (ec *executionContext) unmarshalInputPostgresBranchOrder(ctx context.Context, obj any) (postgres.PostgresBranchOrder, error) { + var it postgres.PostgresBranchOrder + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"field", "direction"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "field": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("field")) + data, err := ec.unmarshalNPostgresBranchOrderField2githubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐPostgresBranchOrderField(ctx, v) + if err != nil { + return it, err + } + it.Field = data + case "direction": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("direction")) + data, err := ec.unmarshalNOrderDirection2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐOrderDirection(ctx, v) + if err != nil { + return it, err + } + it.Direction = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputUpdatePostgresInput(ctx context.Context, obj any) (postgres.UpdatePostgresInput, error) { + var it postgres.UpdatePostgresInput if obj == nil { return it, nil } @@ -2865,27 +3477,62 @@ func (ec *executionContext) unmarshalInputPostgresBranchOrder(ctx context.Contex asMap[k] = v } - fieldsInOrder := [...]string{"field", "direction"} + fieldsInOrder := [...]string{"name", "environmentName", "teamSlug", "highAvailability", "cpu", "memory", "diskSize"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { continue } switch k { - case "field": - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("field")) - data, err := ec.unmarshalNPostgresBranchOrderField2githubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐPostgresBranchOrderField(ctx, v) + case "name": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) + data, err := ec.unmarshalNString2string(ctx, v) if err != nil { return it, err } - it.Field = data - case "direction": - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("direction")) - data, err := ec.unmarshalNOrderDirection2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐOrderDirection(ctx, v) + it.Name = data + case "environmentName": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("environmentName")) + data, err := ec.unmarshalNString2string(ctx, v) if err != nil { return it, err } - it.Direction = data + it.EnvironmentName = data + case "teamSlug": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teamSlug")) + data, err := ec.unmarshalNSlug2githubᚗcomᚋnaisᚋapiᚋinternalᚋslugᚐSlug(ctx, v) + if err != nil { + return it, err + } + it.TeamSlug = data + case "highAvailability": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("highAvailability")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.HighAvailability = data + case "cpu": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("cpu")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.CPU = data + case "memory": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("memory")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Memory = data + case "diskSize": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("diskSize")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.DiskSize = data } } return it, nil @@ -2943,6 +3590,45 @@ func (ec *executionContext) _CreatePostgresAccessPayload(ctx context.Context, se return out } +var createPostgresPayloadImplementors = []string{"CreatePostgresPayload"} + +func (ec *executionContext) _CreatePostgresPayload(ctx context.Context, sel ast.SelectionSet, obj *postgres.CreatePostgresPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, createPostgresPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("CreatePostgresPayload") + case "postgres": + out.Values[i] = ec._CreatePostgresPayload_postgres(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var deletePostgresBranchPayloadImplementors = []string{"DeletePostgresBranchPayload"} func (ec *executionContext) _DeletePostgresBranchPayload(ctx context.Context, sel ast.SelectionSet, obj *postgres.DeletePostgresBranchPayload) graphql.Marshaler { @@ -3931,6 +4617,79 @@ func (ec *executionContext) _PostgresBranchStateFacetItem(ctx context.Context, s return out } +var postgresCreatedActivityLogEntryImplementors = []string{"PostgresCreatedActivityLogEntry", "ActivityLogEntry", "Node"} + +func (ec *executionContext) _PostgresCreatedActivityLogEntry(ctx context.Context, sel ast.SelectionSet, obj *postgres.PostgresCreatedActivityLogEntry) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, postgresCreatedActivityLogEntryImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("PostgresCreatedActivityLogEntry") + case "id": + out.Values[i] = ec._PostgresCreatedActivityLogEntry_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "actor": + out.Values[i] = ec._PostgresCreatedActivityLogEntry_actor(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "gitHubActorClaims": + out.Values[i] = ec._PostgresCreatedActivityLogEntry_gitHubActorClaims(ctx, field, obj) + case "createdAt": + out.Values[i] = ec._PostgresCreatedActivityLogEntry_createdAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "message": + out.Values[i] = ec._PostgresCreatedActivityLogEntry_message(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "resourceType": + out.Values[i] = ec._PostgresCreatedActivityLogEntry_resourceType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "resourceName": + out.Values[i] = ec._PostgresCreatedActivityLogEntry_resourceName(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "teamSlug": + out.Values[i] = ec._PostgresCreatedActivityLogEntry_teamSlug(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "environmentName": + out.Values[i] = ec._PostgresCreatedActivityLogEntry_environmentName(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var postgresDeletedActivityLogEntryImplementors = []string{"PostgresDeletedActivityLogEntry", "ActivityLogEntry", "Node"} func (ec *executionContext) _PostgresDeletedActivityLogEntry(ctx context.Context, sel ast.SelectionSet, obj *postgres.PostgresDeletedActivityLogEntry) graphql.Marshaler { @@ -4368,6 +5127,79 @@ func (ec *executionContext) _PostgresResources(ctx context.Context, sel ast.Sele return out } +var postgresUpdatedActivityLogEntryImplementors = []string{"PostgresUpdatedActivityLogEntry", "ActivityLogEntry", "Node"} + +func (ec *executionContext) _PostgresUpdatedActivityLogEntry(ctx context.Context, sel ast.SelectionSet, obj *postgres.PostgresUpdatedActivityLogEntry) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, postgresUpdatedActivityLogEntryImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("PostgresUpdatedActivityLogEntry") + case "id": + out.Values[i] = ec._PostgresUpdatedActivityLogEntry_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "actor": + out.Values[i] = ec._PostgresUpdatedActivityLogEntry_actor(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "gitHubActorClaims": + out.Values[i] = ec._PostgresUpdatedActivityLogEntry_gitHubActorClaims(ctx, field, obj) + case "createdAt": + out.Values[i] = ec._PostgresUpdatedActivityLogEntry_createdAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "message": + out.Values[i] = ec._PostgresUpdatedActivityLogEntry_message(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "resourceType": + out.Values[i] = ec._PostgresUpdatedActivityLogEntry_resourceType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "resourceName": + out.Values[i] = ec._PostgresUpdatedActivityLogEntry_resourceName(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "teamSlug": + out.Values[i] = ec._PostgresUpdatedActivityLogEntry_teamSlug(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "environmentName": + out.Values[i] = ec._PostgresUpdatedActivityLogEntry_environmentName(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var teamInventoryCountPostgresBranchesImplementors = []string{"TeamInventoryCountPostgresBranches"} func (ec *executionContext) _TeamInventoryCountPostgresBranches(ctx context.Context, sel ast.SelectionSet, obj *postgres.TeamInventoryCountPostgresBranches) graphql.Marshaler { @@ -4407,6 +5239,45 @@ func (ec *executionContext) _TeamInventoryCountPostgresBranches(ctx context.Cont return out } +var updatePostgresPayloadImplementors = []string{"UpdatePostgresPayload"} + +func (ec *executionContext) _UpdatePostgresPayload(ctx context.Context, sel ast.SelectionSet, obj *postgres.UpdatePostgresPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, updatePostgresPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("UpdatePostgresPayload") + case "postgres": + out.Values[i] = ec._UpdatePostgresPayload_postgres(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + // endregion **************************** object.gotpl **************************** // region ***************************** type.gotpl ***************************** @@ -4430,6 +5301,25 @@ func (ec *executionContext) marshalNCreatePostgresAccessPayload2ᚖgithubᚗcom return ec._CreatePostgresAccessPayload(ctx, sel, v) } +func (ec *executionContext) unmarshalNCreatePostgresInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐCreatePostgresInput(ctx context.Context, v any) (postgres.CreatePostgresInput, error) { + res, err := ec.unmarshalInputCreatePostgresInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNCreatePostgresPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐCreatePostgresPayload(ctx context.Context, sel ast.SelectionSet, v postgres.CreatePostgresPayload) graphql.Marshaler { + return ec._CreatePostgresPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNCreatePostgresPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐCreatePostgresPayload(ctx context.Context, sel ast.SelectionSet, v *postgres.CreatePostgresPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._CreatePostgresPayload(ctx, sel, v) +} + func (ec *executionContext) unmarshalNDeletePostgresBranchInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐDeletePostgresBranchInput(ctx context.Context, v any) (postgres.DeletePostgresBranchInput, error) { res, err := ec.unmarshalInputDeletePostgresBranchInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -4639,6 +5529,25 @@ func (ec *executionContext) marshalNTeamInventoryCountPostgresBranches2ᚖgithub return ec._TeamInventoryCountPostgresBranches(ctx, sel, v) } +func (ec *executionContext) unmarshalNUpdatePostgresInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐUpdatePostgresInput(ctx context.Context, v any) (postgres.UpdatePostgresInput, error) { + res, err := ec.unmarshalInputUpdatePostgresInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNUpdatePostgresPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐUpdatePostgresPayload(ctx context.Context, sel ast.SelectionSet, v postgres.UpdatePostgresPayload) graphql.Marshaler { + return ec._UpdatePostgresPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNUpdatePostgresPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐUpdatePostgresPayload(ctx context.Context, sel ast.SelectionSet, v *postgres.UpdatePostgresPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._UpdatePostgresPayload(ctx, sel, v) +} + func (ec *executionContext) marshalOPostgresAccessConnectionDetails2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐPostgresAccessConnectionDetails(ctx context.Context, sel ast.SelectionSet, v *postgres.PostgresAccessConnectionDetails) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index 950ae0e89..50bef77be 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -737,6 +737,10 @@ type ComplexityRoot struct { Name func(childComplexity int) int } + CreatePostgresPayload struct { + Postgres func(childComplexity int) int + } + CreateSecretPayload struct { Secret func(childComplexity int) int } @@ -1622,6 +1626,7 @@ type ComplexityRoot struct { CreateKafkaCredentials func(childComplexity int, input kafkatopic.CreateKafkaCredentialsInput) int CreateOpenSearch func(childComplexity int, input opensearch.CreateOpenSearchInput) int CreateOpenSearchCredentials func(childComplexity int, input opensearch.CreateOpenSearchCredentialsInput) int + CreatePostgres func(childComplexity int, input postgres.CreatePostgresInput) int CreatePostgresAccess func(childComplexity int, input postgres.CreatePostgresAccessInput) int CreateSecret func(childComplexity int, input secret.CreateSecretInput) int CreateServiceAccount func(childComplexity int, input serviceaccount.CreateServiceAccountInput) int @@ -1665,6 +1670,7 @@ type ComplexityRoot struct { UpdateJob func(childComplexity int, input job.UpdateJobInput) int UpdateKafkaTopic func(childComplexity int, input kafkatopic.UpdateKafkaTopicInput) int UpdateOpenSearch func(childComplexity int, input opensearch.UpdateOpenSearchInput) int + UpdatePostgres func(childComplexity int, input postgres.UpdatePostgresInput) int UpdateSecret func(childComplexity int, input secret.UpdateSecretInput) int UpdateSecretValue func(childComplexity int, input secret.UpdateSecretValueInput) int UpdateServiceAccount func(childComplexity int, input serviceaccount.UpdateServiceAccountInput) int @@ -1959,6 +1965,18 @@ type ComplexityRoot struct { State func(childComplexity int) int } + PostgresCreatedActivityLogEntry struct { + Actor func(childComplexity int) int + CreatedAt func(childComplexity int) int + EnvironmentName func(childComplexity int) int + GitHubActorClaims func(childComplexity int) int + ID func(childComplexity int) int + Message func(childComplexity int) int + ResourceName func(childComplexity int) int + ResourceType func(childComplexity int) int + TeamSlug func(childComplexity int) int + } + PostgresDeletedActivityLogEntry struct { Actor func(childComplexity int) int CreatedAt func(childComplexity int) int @@ -2027,6 +2045,18 @@ type ComplexityRoot struct { Memory func(childComplexity int) int } + PostgresUpdatedActivityLogEntry struct { + Actor func(childComplexity int) int + CreatedAt func(childComplexity int) int + EnvironmentName func(childComplexity int) int + GitHubActorClaims func(childComplexity int) int + ID func(childComplexity int) int + Message func(childComplexity int) int + ResourceName func(childComplexity int) int + ResourceType func(childComplexity int) int + TeamSlug func(childComplexity int) int + } + Price struct { Value func(childComplexity int) int } @@ -3514,6 +3544,10 @@ type ComplexityRoot struct { OpenSearch func(childComplexity int) int } + UpdatePostgresPayload struct { + Postgres func(childComplexity int) int + } + UpdateSecretPayload struct { Secret func(childComplexity int) int } @@ -6382,6 +6416,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.CreatePostgresAccessPayload.Name(childComplexity), true + case "CreatePostgresPayload.postgres": + if e.ComplexityRoot.CreatePostgresPayload.Postgres == nil { + break + } + + return e.ComplexityRoot.CreatePostgresPayload.Postgres(childComplexity), true + case "CreateSecretPayload.secret": if e.ComplexityRoot.CreateSecretPayload.Secret == nil { break @@ -10073,6 +10114,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Mutation.CreateOpenSearchCredentials(childComplexity, args["input"].(opensearch.CreateOpenSearchCredentialsInput)), true + case "Mutation.createPostgres": + if e.ComplexityRoot.Mutation.CreatePostgres == nil { + break + } + + args, err := ec.field_Mutation_createPostgres_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.CreatePostgres(childComplexity, args["input"].(postgres.CreatePostgresInput)), true + case "Mutation.createPostgresAccess": if e.ComplexityRoot.Mutation.CreatePostgresAccess == nil { break @@ -10589,6 +10642,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Mutation.UpdateOpenSearch(childComplexity, args["input"].(opensearch.UpdateOpenSearchInput)), true + case "Mutation.updatePostgres": + if e.ComplexityRoot.Mutation.UpdatePostgres == nil { + break + } + + args, err := ec.field_Mutation_updatePostgres_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.UpdatePostgres(childComplexity, args["input"].(postgres.UpdatePostgresInput)), true + case "Mutation.updateSecret": if e.ComplexityRoot.Mutation.UpdateSecret == nil { break @@ -11957,6 +12022,69 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.PostgresBranchStateFacetItem.State(childComplexity), true + case "PostgresCreatedActivityLogEntry.actor": + if e.ComplexityRoot.PostgresCreatedActivityLogEntry.Actor == nil { + break + } + + return e.ComplexityRoot.PostgresCreatedActivityLogEntry.Actor(childComplexity), true + + case "PostgresCreatedActivityLogEntry.createdAt": + if e.ComplexityRoot.PostgresCreatedActivityLogEntry.CreatedAt == nil { + break + } + + return e.ComplexityRoot.PostgresCreatedActivityLogEntry.CreatedAt(childComplexity), true + + case "PostgresCreatedActivityLogEntry.environmentName": + if e.ComplexityRoot.PostgresCreatedActivityLogEntry.EnvironmentName == nil { + break + } + + return e.ComplexityRoot.PostgresCreatedActivityLogEntry.EnvironmentName(childComplexity), true + + case "PostgresCreatedActivityLogEntry.gitHubActorClaims": + if e.ComplexityRoot.PostgresCreatedActivityLogEntry.GitHubActorClaims == nil { + break + } + + return e.ComplexityRoot.PostgresCreatedActivityLogEntry.GitHubActorClaims(childComplexity), true + + case "PostgresCreatedActivityLogEntry.id": + if e.ComplexityRoot.PostgresCreatedActivityLogEntry.ID == nil { + break + } + + return e.ComplexityRoot.PostgresCreatedActivityLogEntry.ID(childComplexity), true + + case "PostgresCreatedActivityLogEntry.message": + if e.ComplexityRoot.PostgresCreatedActivityLogEntry.Message == nil { + break + } + + return e.ComplexityRoot.PostgresCreatedActivityLogEntry.Message(childComplexity), true + + case "PostgresCreatedActivityLogEntry.resourceName": + if e.ComplexityRoot.PostgresCreatedActivityLogEntry.ResourceName == nil { + break + } + + return e.ComplexityRoot.PostgresCreatedActivityLogEntry.ResourceName(childComplexity), true + + case "PostgresCreatedActivityLogEntry.resourceType": + if e.ComplexityRoot.PostgresCreatedActivityLogEntry.ResourceType == nil { + break + } + + return e.ComplexityRoot.PostgresCreatedActivityLogEntry.ResourceType(childComplexity), true + + case "PostgresCreatedActivityLogEntry.teamSlug": + if e.ComplexityRoot.PostgresCreatedActivityLogEntry.TeamSlug == nil { + break + } + + return e.ComplexityRoot.PostgresCreatedActivityLogEntry.TeamSlug(childComplexity), true + case "PostgresDeletedActivityLogEntry.actor": if e.ComplexityRoot.PostgresDeletedActivityLogEntry.Actor == nil { break @@ -12286,6 +12414,69 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.PostgresResources.Memory(childComplexity), true + case "PostgresUpdatedActivityLogEntry.actor": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntry.Actor == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntry.Actor(childComplexity), true + + case "PostgresUpdatedActivityLogEntry.createdAt": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntry.CreatedAt == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntry.CreatedAt(childComplexity), true + + case "PostgresUpdatedActivityLogEntry.environmentName": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntry.EnvironmentName == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntry.EnvironmentName(childComplexity), true + + case "PostgresUpdatedActivityLogEntry.gitHubActorClaims": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntry.GitHubActorClaims == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntry.GitHubActorClaims(childComplexity), true + + case "PostgresUpdatedActivityLogEntry.id": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntry.ID == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntry.ID(childComplexity), true + + case "PostgresUpdatedActivityLogEntry.message": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntry.Message == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntry.Message(childComplexity), true + + case "PostgresUpdatedActivityLogEntry.resourceName": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntry.ResourceName == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntry.ResourceName(childComplexity), true + + case "PostgresUpdatedActivityLogEntry.resourceType": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntry.ResourceType == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntry.ResourceType(childComplexity), true + + case "PostgresUpdatedActivityLogEntry.teamSlug": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntry.TeamSlug == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntry.TeamSlug(childComplexity), true + case "Price.value": if e.ComplexityRoot.Price.Value == nil { break @@ -19068,6 +19259,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.UpdateOpenSearchPayload.OpenSearch(childComplexity), true + case "UpdatePostgresPayload.postgres": + if e.ComplexityRoot.UpdatePostgresPayload.Postgres == nil { + break + } + + return e.ComplexityRoot.UpdatePostgresPayload.Postgres(childComplexity), true + case "UpdateSecretPayload.secret": if e.ComplexityRoot.UpdateSecretPayload.Secret == nil { break @@ -20879,6 +21077,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputCreateOpenSearchCredentialsInput, ec.unmarshalInputCreateOpenSearchInput, ec.unmarshalInputCreatePostgresAccessInput, + ec.unmarshalInputCreatePostgresInput, ec.unmarshalInputCreateSecretInput, ec.unmarshalInputCreateServiceAccountInput, ec.unmarshalInputCreateServiceAccountTokenInput, @@ -20968,6 +21167,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputUpdateJobInput, ec.unmarshalInputUpdateKafkaTopicInput, ec.unmarshalInputUpdateOpenSearchInput, + ec.unmarshalInputUpdatePostgresInput, ec.unmarshalInputUpdateSecretInput, ec.unmarshalInputUpdateSecretValueInput, ec.unmarshalInputUpdateServiceAccountInput, @@ -27735,7 +27935,73 @@ type PostgresDeletedActivityLogEntry implements ActivityLogEntry & Node { environmentName: String } +type PostgresCreatedActivityLogEntry implements ActivityLogEntry & Node { + "ID of the entry." + id: ID! + + "The identity of the actor who performed the action. The value is either the name of a service account, or the email address of a user." + actor: String! + + "GitHub Actions OIDC claims when authenticated by a GitHub repository." + gitHubActorClaims: GitHubActorClaims + + "Creation time of the entry." + createdAt: Time! + + "Message that summarizes the entry." + message: String! + + "Type of the resource that was affected by the action." + resourceType: ActivityLogEntryResourceType! + + "Name of the resource that was affected by the action." + resourceName: String! + + "The team slug that the entry belongs to." + teamSlug: Slug! + + "The environment name that the entry belongs to." + environmentName: String +} + +type PostgresUpdatedActivityLogEntry implements ActivityLogEntry & Node { + "ID of the entry." + id: ID! + + "The identity of the actor who performed the action. The value is either the name of a service account, or the email address of a user." + actor: String! + + "GitHub Actions OIDC claims when authenticated by a GitHub repository." + gitHubActorClaims: GitHubActorClaims + + "Creation time of the entry." + createdAt: Time! + + "Message that summarizes the entry." + message: String! + + "Type of the resource that was affected by the action." + resourceType: ActivityLogEntryResourceType! + + "Name of the resource that was affected by the action." + resourceName: String! + + "The team slug that the entry belongs to." + teamSlug: Slug! + + "The environment name that the entry belongs to." + environmentName: String +} + extend enum ActivityLogActivityType { + """ + A Postgres was created + """ + POSTGRES_CREATED + """ + A Postgres was updated + """ + POSTGRES_UPDATED """ A user was granted access to a Postgres cluster """ @@ -27761,6 +28027,10 @@ extend type Mutation { When the access is ready, retrieve its connection materials through PostgresAccess.connection. """ createPostgresAccess(input: CreatePostgresAccessInput!): CreatePostgresAccessPayload! + "Create a new Postgres. Its first branch, main, is created and activated by the platform." + createPostgres(input: CreatePostgresInput!): CreatePostgresPayload! + "Update an existing Postgres. The PostgreSQL major version cannot be changed." + updatePostgres(input: UpdatePostgresInput!): UpdatePostgresPayload! "Delete a PostgresBranch that is not active on its Postgres." deletePostgresBranch(input: DeletePostgresBranchInput!): DeletePostgresBranchPayload! } @@ -27884,6 +28154,56 @@ type PostgresAccessConnectionDetails { "Owner-only bearer token for this access; never log it." relayToken: String! } + +"Input for creating a Postgres." +input CreatePostgresInput { + "Name of the Postgres." + name: String! + "The environment name that the Postgres belongs to." + environmentName: String! + "The team that owns the Postgres." + teamSlug: Slug! + "PostgreSQL major version. Supported versions are 16, 17 and 18." + majorVersion: String! + "Add a third instance and enable synchronous replication. Defaults to false." + highAvailability: Boolean + "Requested CPU, for example '100m'. Defaults to the platform default." + cpu: String + "Requested memory, for example '512Mi'. Defaults to the platform default." + memory: String + "Requested disk size, for example '10Gi'. Defaults to the platform default." + diskSize: String +} + +"Result of creating a Postgres." +type CreatePostgresPayload { + "The Postgres that was created." + postgres: Postgres! +} + +"Input for updating a Postgres. Omitted fields are left unchanged." +input UpdatePostgresInput { + "Name of the Postgres." + name: String! + "The environment name that the Postgres belongs to." + environmentName: String! + "The team that owns the Postgres." + teamSlug: Slug! + "Add a third instance and enable synchronous replication." + highAvailability: Boolean + "Requested CPU, for example '100m'." + cpu: String + "Requested memory, for example '512Mi'." + memory: String + "Requested disk size, for example '10Gi'." + diskSize: String +} + +"Result of updating a Postgres." +type UpdatePostgresPayload { + "The Postgres that was updated." + postgres: Postgres! +} `, BuiltIn: false}, {Name: "../schema/price.graphqls", Input: `extend type Query { """ @@ -36112,6 +36432,14 @@ func (ec *executionContext) childFields_CreatePostgresAccessPayload(ctx context. return nil, fmt.Errorf("no field named %q was found under type CreatePostgresAccessPayload", field.Name) } +func (ec *executionContext) childFields_CreatePostgresPayload(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "postgres": + return ec.fieldContext_CreatePostgresPayload_postgres(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type CreatePostgresPayload", field.Name) +} + func (ec *executionContext) childFields_CreateSecretPayload(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "secret": @@ -39714,6 +40042,14 @@ func (ec *executionContext) childFields_UpdateOpenSearchPayload(ctx context.Cont return nil, fmt.Errorf("no field named %q was found under type UpdateOpenSearchPayload", field.Name) } +func (ec *executionContext) childFields_UpdatePostgresPayload(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "postgres": + return ec.fieldContext_UpdatePostgresPayload_postgres(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type UpdatePostgresPayload", field.Name) +} + func (ec *executionContext) childFields_UpdateSecretPayload(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "secret": diff --git a/internal/graph/gengql/schema.generated.go b/internal/graph/gengql/schema.generated.go index 6c8b9e196..d16ba505a 100644 --- a/internal/graph/gengql/schema.generated.go +++ b/internal/graph/gengql/schema.generated.go @@ -85,6 +85,8 @@ type MutationResolver interface { DeleteOpenSearch(ctx context.Context, input opensearch.DeleteOpenSearchInput) (*opensearch.DeleteOpenSearchPayload, error) CreateOpenSearchCredentials(ctx context.Context, input opensearch.CreateOpenSearchCredentialsInput) (*opensearch.CreateOpenSearchCredentialsPayload, error) CreatePostgresAccess(ctx context.Context, input postgres.CreatePostgresAccessInput) (*postgres.CreatePostgresAccessPayload, error) + CreatePostgres(ctx context.Context, input postgres.CreatePostgresInput) (*postgres.CreatePostgresPayload, error) + UpdatePostgres(ctx context.Context, input postgres.UpdatePostgresInput) (*postgres.UpdatePostgresPayload, error) DeletePostgresBranch(ctx context.Context, input postgres.DeletePostgresBranchInput) (*postgres.DeletePostgresBranchPayload, error) EnableReconciler(ctx context.Context, input reconciler.EnableReconcilerInput) (*reconciler.Reconciler, error) DisableReconciler(ctx context.Context, input reconciler.DisableReconcilerInput) (*reconciler.Reconciler, error) @@ -378,6 +380,20 @@ func (ec *executionContext) field_Mutation_createPostgresAccess_args(ctx context return args, nil } +func (ec *executionContext) field_Mutation_createPostgres_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", + func(ctx context.Context, v any) (postgres.CreatePostgresInput, error) { + return ec.unmarshalNCreatePostgresInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐCreatePostgresInput(ctx, v) + }) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_createSecret_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -966,6 +982,20 @@ func (ec *executionContext) field_Mutation_updateOpenSearch_args(ctx context.Con return args, nil } +func (ec *executionContext) field_Mutation_updatePostgres_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", + func(ctx context.Context, v any) (postgres.UpdatePostgresInput, error) { + return ec.unmarshalNUpdatePostgresInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐUpdatePostgresInput(ctx, v) + }) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_updateSecretValue_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -2680,6 +2710,94 @@ func (ec *executionContext) fieldContext_Mutation_createPostgresAccess(ctx conte return fc, nil } +func (ec *executionContext) _Mutation_createPostgres(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Mutation_createPostgres(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().CreatePostgres(ctx, fc.Args["input"].(postgres.CreatePostgresInput)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *postgres.CreatePostgresPayload) graphql.Marshaler { + return ec.marshalNCreatePostgresPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐCreatePostgresPayload(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Mutation_createPostgres(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_CreatePostgresPayload(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_createPostgres_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_updatePostgres(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Mutation_updatePostgres(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().UpdatePostgres(ctx, fc.Args["input"].(postgres.UpdatePostgresInput)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *postgres.UpdatePostgresPayload) graphql.Marshaler { + return ec.marshalNUpdatePostgresPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐUpdatePostgresPayload(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Mutation_updatePostgres(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_UpdatePostgresPayload(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_updatePostgres_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_deletePostgresBranch(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6525,6 +6643,13 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj return graphql.Null } return ec._PrometheusAlert(ctx, sel, obj) + case postgres.PostgresUpdatedActivityLogEntry: + return ec._PostgresUpdatedActivityLogEntry(ctx, sel, &obj) + case *postgres.PostgresUpdatedActivityLogEntry: + if obj == nil { + return graphql.Null + } + return ec._PostgresUpdatedActivityLogEntry(ctx, sel, obj) case postgres.PostgresPersonalAccessCreatedActivityLogEntry: return ec._PostgresPersonalAccessCreatedActivityLogEntry(ctx, sel, &obj) case *postgres.PostgresPersonalAccessCreatedActivityLogEntry: @@ -6553,6 +6678,13 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj return graphql.Null } return ec._PostgresDeletedActivityLogEntry(ctx, sel, obj) + case postgres.PostgresCreatedActivityLogEntry: + return ec._PostgresCreatedActivityLogEntry(ctx, sel, &obj) + case *postgres.PostgresCreatedActivityLogEntry: + if obj == nil { + return graphql.Null + } + return ec._PostgresCreatedActivityLogEntry(ctx, sel, obj) case postgres.PostgresBranch: return ec._PostgresBranch(ctx, sel, &obj) case *postgres.PostgresBranch: @@ -7269,6 +7401,20 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "createPostgres": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_createPostgres(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "updatePostgres": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_updatePostgres(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "deletePostgresBranch": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_deletePostgresBranch(ctx, field) diff --git a/internal/graph/postgres.resolvers.go b/internal/graph/postgres.resolvers.go index 381052242..ea2b456aa 100644 --- a/internal/graph/postgres.resolvers.go +++ b/internal/graph/postgres.resolvers.go @@ -43,6 +43,20 @@ func (r *mutationResolver) CreatePostgresAccess(ctx context.Context, input postg return postgres.CreatePostgresAccess(ctx, input) } +func (r *mutationResolver) CreatePostgres(ctx context.Context, input postgres.CreatePostgresInput) (*postgres.CreatePostgresPayload, error) { + if err := authz.CanCreatePostgres(ctx, input.TeamSlug); err != nil { + return nil, err + } + return postgres.Create(ctx, input) +} + +func (r *mutationResolver) UpdatePostgres(ctx context.Context, input postgres.UpdatePostgresInput) (*postgres.UpdatePostgresPayload, error) { + if err := authz.CanUpdatePostgres(ctx, input.TeamSlug); err != nil { + return nil, err + } + return postgres.Update(ctx, input) +} + func (r *mutationResolver) DeletePostgresBranch(ctx context.Context, input postgres.DeletePostgresBranchInput) (*postgres.DeletePostgresBranchPayload, error) { if err := authz.CanDeletePostgres(ctx, input.TeamSlug); err != nil { return nil, err diff --git a/internal/graph/schema/postgres.graphqls b/internal/graph/schema/postgres.graphqls index 9d636eb16..2bdab019c 100644 --- a/internal/graph/schema/postgres.graphqls +++ b/internal/graph/schema/postgres.graphqls @@ -341,7 +341,73 @@ type PostgresDeletedActivityLogEntry implements ActivityLogEntry & Node { environmentName: String } +type PostgresCreatedActivityLogEntry implements ActivityLogEntry & Node { + "ID of the entry." + id: ID! + + "The identity of the actor who performed the action. The value is either the name of a service account, or the email address of a user." + actor: String! + + "GitHub Actions OIDC claims when authenticated by a GitHub repository." + gitHubActorClaims: GitHubActorClaims + + "Creation time of the entry." + createdAt: Time! + + "Message that summarizes the entry." + message: String! + + "Type of the resource that was affected by the action." + resourceType: ActivityLogEntryResourceType! + + "Name of the resource that was affected by the action." + resourceName: String! + + "The team slug that the entry belongs to." + teamSlug: Slug! + + "The environment name that the entry belongs to." + environmentName: String +} + +type PostgresUpdatedActivityLogEntry implements ActivityLogEntry & Node { + "ID of the entry." + id: ID! + + "The identity of the actor who performed the action. The value is either the name of a service account, or the email address of a user." + actor: String! + + "GitHub Actions OIDC claims when authenticated by a GitHub repository." + gitHubActorClaims: GitHubActorClaims + + "Creation time of the entry." + createdAt: Time! + + "Message that summarizes the entry." + message: String! + + "Type of the resource that was affected by the action." + resourceType: ActivityLogEntryResourceType! + + "Name of the resource that was affected by the action." + resourceName: String! + + "The team slug that the entry belongs to." + teamSlug: Slug! + + "The environment name that the entry belongs to." + environmentName: String +} + extend enum ActivityLogActivityType { + """ + A Postgres was created + """ + POSTGRES_CREATED + """ + A Postgres was updated + """ + POSTGRES_UPDATED """ A user was granted access to a Postgres cluster """ @@ -367,6 +433,10 @@ extend type Mutation { When the access is ready, retrieve its connection materials through PostgresAccess.connection. """ createPostgresAccess(input: CreatePostgresAccessInput!): CreatePostgresAccessPayload! + "Create a new Postgres. Its first branch, main, is created and activated by the platform." + createPostgres(input: CreatePostgresInput!): CreatePostgresPayload! + "Update an existing Postgres. The PostgreSQL major version cannot be changed." + updatePostgres(input: UpdatePostgresInput!): UpdatePostgresPayload! "Delete a PostgresBranch that is not active on its Postgres." deletePostgresBranch(input: DeletePostgresBranchInput!): DeletePostgresBranchPayload! } @@ -490,3 +560,53 @@ type PostgresAccessConnectionDetails { "Owner-only bearer token for this access; never log it." relayToken: String! } + +"Input for creating a Postgres." +input CreatePostgresInput { + "Name of the Postgres." + name: String! + "The environment name that the Postgres belongs to." + environmentName: String! + "The team that owns the Postgres." + teamSlug: Slug! + "PostgreSQL major version. Supported versions are 16, 17 and 18." + majorVersion: String! + "Add a third instance and enable synchronous replication. Defaults to false." + highAvailability: Boolean + "Requested CPU, for example '100m'. Defaults to the platform default." + cpu: String + "Requested memory, for example '512Mi'. Defaults to the platform default." + memory: String + "Requested disk size, for example '10Gi'. Defaults to the platform default." + diskSize: String +} + +"Result of creating a Postgres." +type CreatePostgresPayload { + "The Postgres that was created." + postgres: Postgres! +} + +"Input for updating a Postgres. Omitted fields are left unchanged." +input UpdatePostgresInput { + "Name of the Postgres." + name: String! + "The environment name that the Postgres belongs to." + environmentName: String! + "The team that owns the Postgres." + teamSlug: Slug! + "Add a third instance and enable synchronous replication." + highAvailability: Boolean + "Requested CPU, for example '100m'." + cpu: String + "Requested memory, for example '512Mi'." + memory: String + "Requested disk size, for example '10Gi'." + diskSize: String +} + +"Result of updating a Postgres." +type UpdatePostgresPayload { + "The Postgres that was updated." + postgres: Postgres! +} diff --git a/internal/kubernetes/fake/fake.go b/internal/kubernetes/fake/fake.go index c1eae2182..071b2ac5a 100644 --- a/internal/kubernetes/fake/fake.go +++ b/internal/kubernetes/fake/fake.go @@ -219,6 +219,7 @@ func NewDynamicClient(scheme *runtime.Scheme) *dynfake.FakeDynamicClient { unleash_nais_io_v1.GroupVersion.WithResource("remoteunleashes"): "RemoteUnleashList", mapperatorv1.GroupVersion.WithResource("postgres"): "PostgresList", mapperatorv1.GroupVersion.WithResource("postgresbranches"): "PostgresBranchList", + mapperatorv1.GroupVersion.WithResource("postgresaccesses"): "PostgresAccessList", nais_io_v1alpha1.GroupVersion.WithResource("tunnels"): "TunnelList", mapperatorv1.GroupVersion.WithResource("valkeys"): "ValkeyList", mapperatorv1.GroupVersion.WithResource("opensearches"): "OpenSearchList", diff --git a/internal/persistence/postgres/activitylog.go b/internal/persistence/postgres/activitylog.go index 1c6999c74..a0d175676 100644 --- a/internal/persistence/postgres/activitylog.go +++ b/internal/persistence/postgres/activitylog.go @@ -18,6 +18,14 @@ const ( func init() { activitylog.RegisterTransformer(activityLogEntryResourceTypePostgres, func(entry activitylog.GenericActivityLogEntry) (activitylog.ActivityLogEntry, error) { switch entry.Action { + case activitylog.ActivityLogEntryActionCreated: + return PostgresCreatedActivityLogEntry{ + GenericActivityLogEntry: entry.WithMessage("Created Postgres"), + }, nil + case activitylog.ActivityLogEntryActionUpdated: + return PostgresUpdatedActivityLogEntry{ + GenericActivityLogEntry: entry.WithMessage("Updated Postgres"), + }, nil case activitylog.ActivityLogEntryActionDeleted: return PostgresDeletedActivityLogEntry{ GenericActivityLogEntry: entry.WithMessage("Deleted Postgres"), @@ -62,6 +70,8 @@ func init() { activitylog.RegisterFilter("POSTGRES_GRANT_ACCESS", activityLogEntryActionGrantAccess, activityLogEntryResourceTypePostgres) activitylog.RegisterFilter("POSTGRES_PERSONAL_ACCESS_CREATED", activityLogEntryActionCreatePersonalAccess, activityLogEntryResourceTypePostgres) activitylog.RegisterFilter("POSTGRES_PERSONAL_ACCESS_CONNECTION", activityLogEntryActionGetPersonalAccessConnection, activityLogEntryResourceTypePostgres) + activitylog.RegisterFilter("POSTGRES_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypePostgres) + activitylog.RegisterFilter("POSTGRES_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypePostgres) activitylog.RegisterFilter("POSTGRES_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypePostgres) } @@ -69,6 +79,14 @@ type PostgresDeletedActivityLogEntry struct { activitylog.GenericActivityLogEntry } +type PostgresCreatedActivityLogEntry struct { + activitylog.GenericActivityLogEntry +} + +type PostgresUpdatedActivityLogEntry struct { + activitylog.GenericActivityLogEntry +} + type PostgresGrantAccessActivityLogEntry struct { activitylog.GenericActivityLogEntry diff --git a/internal/persistence/postgres/queries.go b/internal/persistence/postgres/queries.go index 80fdc7874..be879cb70 100644 --- a/internal/persistence/postgres/queries.go +++ b/internal/persistence/postgres/queries.go @@ -28,6 +28,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" "k8s.io/utils/ptr" ) @@ -235,6 +236,12 @@ func GetPostgresAccessConnection(ctx context.Context, input PostgresAccessConnec return nil, authz.ErrUnauthorized } + // Status lives in PostgresAccess.state. Until the access is ready there are no + // connection materials, which is not an error for a caller polling for readiness. + if state, _ := postgresAccessState(resource, resource.Spec.ExpiresAt.Time); state != PostgresAccessStateReady || access.GetDeletionTimestamp() != nil { + return nil, nil + } + connection, credentialSecretName, err := postgresAccessConnectionDetails(access, time.Now()) if err != nil { return nil, err @@ -481,8 +488,25 @@ func CreatePostgresAccess(ctx context.Context, input CreatePostgresAccessInput) } expiresAt := time.Now().Add(accessTTL) + username := authz.ActorFromContext(ctx).User.Identity() + branchObjectName := nais_io_v1.PostgresBranchObjectName(input.Postgres, input.Branch) + + // One live access per user and branch: pgrator derives a single personal database + // role from them, so a second access could never become ready. + existing, err := findActivePostgresAccess(ctx, client.Namespace(input.TeamSlug.String()), username, branchObjectName) + if err != nil { + return nil, err + } + if existing != nil { + level, _, _ := unstructured.NestedString(existing.Object, "spec", "accessLevel") + if level != input.AccessLevel.CRDValue() { + return nil, apierror.Errorf("You already have %s access to this branch until %s. Wait for it to expire before requesting a different access level.", level, existingExpiry(existing).Format(time.RFC3339)) + } + return &CreatePostgresAccessPayload{Name: existing.GetName(), ExpiresAt: existingExpiry(existing)}, nil + } + name := fmt.Sprintf("postgres-access-%s", uuid.NewString()[:8]) - res := newPostgresAccessResource(input, authz.ActorFromContext(ctx).User.Identity(), name, expiresAt) + res := newPostgresAccessResource(input, username, name, expiresAt) if _, err := client.Namespace(input.TeamSlug.String()).Create(ctx, res, metav1.CreateOptions{}); err != nil { return nil, err @@ -508,6 +532,34 @@ func CreatePostgresAccess(ctx context.Context, input CreatePostgresAccessInput) return &CreatePostgresAccessPayload{Name: name, ExpiresAt: expiresAt}, nil } +func existingExpiry(access *unstructured.Unstructured) time.Time { + value, _, _ := unstructured.NestedString(access.Object, "spec", "expiresAt") + t, _ := time.Parse(time.RFC3339, value) + return t +} + +// findActivePostgresAccess returns the user's unexpired, non-deleting access to a branch. +// The check is not atomic with creation, so two simultaneous requests can still both create one. +func findActivePostgresAccess(ctx context.Context, client dynamic.ResourceInterface, username, branchObjectName string) (*unstructured.Unstructured, error) { + list, err := client.List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing postgres accesses: %w", err) + } + now := time.Now() + for i := range list.Items { + item := &list.Items[i] + if item.GetDeletionTimestamp() != nil || !existingExpiry(item).After(now) { + continue + } + user, _, _ := unstructured.NestedString(item.Object, "spec", "username") + branch, _, _ := unstructured.NestedString(item.Object, "spec", "postgresBranch") + if user == username && branch == branchObjectName { + return item, nil + } + } + return nil, nil +} + func (i CreatePostgresAccessInput) accessTTL() (time.Duration, error) { if i.TTL == "" { return defaultPostgresAccessTTL, nil diff --git a/internal/persistence/postgres/write.go b/internal/persistence/postgres/write.go new file mode 100644 index 000000000..af3b926eb --- /dev/null +++ b/internal/persistence/postgres/write.go @@ -0,0 +1,286 @@ +package postgres + +import ( + "context" + "fmt" + "strings" + + "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/auth/authz" + "github.com/nais/api/internal/graph/apierror" + "github.com/nais/api/internal/kubernetes" + "github.com/nais/api/internal/kubernetes/watcher" + "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/validate" + nais_io_v1 "github.com/nais/pgrator/pkg/api/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" +) + +// supportedMajorVersions mirrors the enum on the Postgres CRD's spec.majorVersion. +var supportedMajorVersions = []string{"16", "17", "18"} + +type CreatePostgresInput struct { + Name string `json:"name"` + EnvironmentName string `json:"environmentName"` + TeamSlug slug.Slug `json:"teamSlug"` + MajorVersion string `json:"majorVersion"` + HighAvailability *bool `json:"highAvailability,omitempty"` + CPU *string `json:"cpu,omitempty"` + Memory *string `json:"memory,omitempty"` + DiskSize *string `json:"diskSize,omitempty"` +} + +type CreatePostgresPayload struct { + Postgres *Postgres `json:"postgres"` +} + +type UpdatePostgresInput struct { + Name string `json:"name"` + EnvironmentName string `json:"environmentName"` + TeamSlug slug.Slug `json:"teamSlug"` + HighAvailability *bool `json:"highAvailability,omitempty"` + CPU *string `json:"cpu,omitempty"` + Memory *string `json:"memory,omitempty"` + DiskSize *string `json:"diskSize,omitempty"` +} + +type UpdatePostgresPayload struct { + Postgres *Postgres `json:"postgres"` +} + +func (i *CreatePostgresInput) Validate(ctx context.Context) error { + return i.ValidationErrors(ctx).NilIfEmpty() +} + +func (i *CreatePostgresInput) ValidationErrors(_ context.Context) *validate.ValidationErrors { + verr := validate.New() + i.Name = strings.TrimSpace(i.Name) + i.EnvironmentName = strings.TrimSpace(i.EnvironmentName) + i.MajorVersion = strings.TrimSpace(i.MajorVersion) + + if i.Name == "" { + verr.Add("name", "Name must not be empty.") + } + if i.EnvironmentName == "" { + verr.Add("environmentName", "Environment name must not be empty.") + } + if i.TeamSlug == "" { + verr.Add("teamSlug", "Team slug must not be empty.") + } + if !contains(supportedMajorVersions, i.MajorVersion) { + verr.Add("majorVersion", "Major version must be one of: %s.", strings.Join(supportedMajorVersions, ", ")) + } + validateQuantities(verr, i.CPU, i.Memory, i.DiskSize) + return verr +} + +func (i *UpdatePostgresInput) Validate(ctx context.Context) error { + return i.ValidationErrors(ctx).NilIfEmpty() +} + +func (i *UpdatePostgresInput) ValidationErrors(_ context.Context) *validate.ValidationErrors { + verr := validate.New() + i.Name = strings.TrimSpace(i.Name) + i.EnvironmentName = strings.TrimSpace(i.EnvironmentName) + + if i.Name == "" { + verr.Add("name", "Name must not be empty.") + } + if i.EnvironmentName == "" { + verr.Add("environmentName", "Environment name must not be empty.") + } + if i.TeamSlug == "" { + verr.Add("teamSlug", "Team slug must not be empty.") + } + validateQuantities(verr, i.CPU, i.Memory, i.DiskSize) + return verr +} + +func validateQuantities(verr *validate.ValidationErrors, cpu, memory, diskSize *string) { + for field, value := range map[string]*string{"cpu": cpu, "memory": memory, "diskSize": diskSize} { + if value == nil { + continue + } + if _, err := resource.ParseQuantity(*value); err != nil { + verr.Add(field, "%q is not a valid quantity.", *value) + } + } +} + +func contains(values []string, value string) bool { + for _, v := range values { + if v == value { + return true + } + } + return false +} + +func postgresGVR() schema.GroupVersionResource { + return schema.GroupVersionResource{Group: "nais.io", Version: "v1", Resource: "postgres"} +} + +// postgresClient returns a namespaced client for Postgres that writes with the API's own +// identity. Users never write the CRD directly; authorization is decided by the resolver. +func postgresClient(ctx context.Context, environmentName string, teamSlug slug.Slug) (dynamic.ResourceInterface, error) { + client, err := fromContext(ctx).postgresBranchWatcher.SystemAuthenticatedClient(ctx, environmentName, watcher.WithImpersonatedClientGVR(postgresGVR())) + if err != nil { + return nil, err + } + return client.Namespace(teamSlug.String()), nil +} + +func Create(ctx context.Context, input CreatePostgresInput) (*CreatePostgresPayload, error) { + if err := input.Validate(ctx); err != nil { + return nil, err + } + + client, err := postgresClient(ctx, input.EnvironmentName, input.TeamSlug) + if err != nil { + return nil, err + } + + pg := &nais_io_v1.Postgres{ + TypeMeta: metav1.TypeMeta{Kind: "Postgres", APIVersion: "nais.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: input.Name, + Namespace: input.TeamSlug.String(), + }, + Spec: nais_io_v1.PostgresSpec{MajorVersion: input.MajorVersion}, + } + pg.SetAnnotations(kubernetes.WithCommonAnnotations(nil, authz.ActorFromContext(ctx).User.Identity())) + kubernetes.SetManagedByConsoleLabel(pg) + if input.HighAvailability != nil { + pg.Spec.HighAvailability = *input.HighAvailability + } + if err := applyResources(&pg.Spec.Resources, input.CPU, input.Memory, input.DiskSize); err != nil { + return nil, err + } + + obj, err := kubernetes.ToUnstructured(pg) + if err != nil { + return nil, err + } + + created, err := client.Create(ctx, obj, metav1.CreateOptions{}) + if err != nil { + if k8serrors.IsAlreadyExists(err) { + return nil, apierror.ErrAlreadyExists + } + return nil, err + } + + if err := logPostgresChange(ctx, activitylog.ActivityLogEntryActionCreated, input.Name, input.EnvironmentName, input.TeamSlug); err != nil { + return nil, err + } + + ret, err := toPostgres(created, input.EnvironmentName) + if err != nil { + return nil, err + } + return &CreatePostgresPayload{Postgres: ret}, nil +} + +func Update(ctx context.Context, input UpdatePostgresInput) (*UpdatePostgresPayload, error) { + if err := input.Validate(ctx); err != nil { + return nil, err + } + + client, err := postgresClient(ctx, input.EnvironmentName, input.TeamSlug) + if err != nil { + return nil, err + } + + existing, err := client.Get(ctx, input.Name, metav1.GetOptions{}) + if k8serrors.IsNotFound(err) { + return nil, apierror.Errorf("Postgres %q not found.", input.Name) + } + if err != nil { + return nil, err + } + + pg, err := kubernetes.ToConcrete[nais_io_v1.Postgres](existing) + if err != nil { + return nil, err + } + + changed := false + if input.HighAvailability != nil && pg.Spec.HighAvailability != *input.HighAvailability { + pg.Spec.HighAvailability = *input.HighAvailability + changed = true + } + before := pg.Spec.Resources + if err := applyResources(&pg.Spec.Resources, input.CPU, input.Memory, input.DiskSize); err != nil { + return nil, err + } + if before.Cpu.Cmp(pg.Spec.Resources.Cpu) != 0 || before.Memory.Cmp(pg.Spec.Resources.Memory) != 0 || before.DiskSize.Cmp(pg.Spec.Resources.DiskSize) != 0 { + changed = true + } + + if !changed { + ret, err := toPostgres(existing, input.EnvironmentName) + if err != nil { + return nil, err + } + return &UpdatePostgresPayload{Postgres: ret}, nil + } + + obj, err := kubernetes.ToUnstructured(pg) + if err != nil { + return nil, err + } + obj.SetAnnotations(kubernetes.WithCommonAnnotations(obj.GetAnnotations(), authz.ActorFromContext(ctx).User.Identity())) + + updated, err := client.Update(ctx, obj, metav1.UpdateOptions{}) + if err != nil { + return nil, err + } + + if err := logPostgresChange(ctx, activitylog.ActivityLogEntryActionUpdated, input.Name, input.EnvironmentName, input.TeamSlug); err != nil { + return nil, err + } + + ret, err := toPostgres(updated, input.EnvironmentName) + if err != nil { + return nil, err + } + return &UpdatePostgresPayload{Postgres: ret}, nil +} + +// applyResources sets the quantities that were provided. Omitted values are left as they are, +// so the CRD defaults apply on create and existing values are kept on update. +func applyResources(r *nais_io_v1.PostgresResources, cpu, memory, diskSize *string) error { + set := func(field string, value *string, target *resource.Quantity) error { + if value == nil { + return nil + } + q, err := resource.ParseQuantity(*value) + if err != nil { + return fmt.Errorf("parsing %s: %w", field, err) + } + *target = q + return nil + } + if err := set("cpu", cpu, &r.Cpu); err != nil { + return err + } + if err := set("memory", memory, &r.Memory); err != nil { + return err + } + return set("diskSize", diskSize, &r.DiskSize) +} + +func logPostgresChange(ctx context.Context, action activitylog.ActivityLogEntryAction, name, environmentName string, teamSlug slug.Slug) error { + return activitylog.Create(ctx, activitylog.CreateInput{ + Action: action, + Actor: authz.ActorFromContext(ctx).User, + ResourceType: activityLogEntryResourceTypePostgres, + ResourceName: name, + EnvironmentName: new(environmentName), + TeamSlug: new(teamSlug), + }) +} From c96e3972fabceda9e490245e708eff9ad9f2aed1 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 1 Oct 2026 14:54:10 +0200 Subject: [PATCH 2/5] =?UTF-8?q?m=C3=A5=20v=C3=A6re=20i=20henhold=20si?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/graph/gengql/root_.generated.go | 2 ++ internal/graph/schema/postgres.graphqls | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index 50bef77be..d017b5d93 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -27935,6 +27935,7 @@ type PostgresDeletedActivityLogEntry implements ActivityLogEntry & Node { environmentName: String } +"Activity log entry for a Postgres that was created." type PostgresCreatedActivityLogEntry implements ActivityLogEntry & Node { "ID of the entry." id: ID! @@ -27964,6 +27965,7 @@ type PostgresCreatedActivityLogEntry implements ActivityLogEntry & Node { environmentName: String } +"Activity log entry for a Postgres that was updated." type PostgresUpdatedActivityLogEntry implements ActivityLogEntry & Node { "ID of the entry." id: ID! diff --git a/internal/graph/schema/postgres.graphqls b/internal/graph/schema/postgres.graphqls index 2bdab019c..f602559cc 100644 --- a/internal/graph/schema/postgres.graphqls +++ b/internal/graph/schema/postgres.graphqls @@ -341,6 +341,7 @@ type PostgresDeletedActivityLogEntry implements ActivityLogEntry & Node { environmentName: String } +"Activity log entry for a Postgres that was created." type PostgresCreatedActivityLogEntry implements ActivityLogEntry & Node { "ID of the entry." id: ID! @@ -370,6 +371,7 @@ type PostgresCreatedActivityLogEntry implements ActivityLogEntry & Node { environmentName: String } +"Activity log entry for a Postgres that was updated." type PostgresUpdatedActivityLogEntry implements ActivityLogEntry & Node { "ID of the entry." id: ID! From eca3a2cc3ead87da640d40357f80e5f18a6da9c1 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 1 Oct 2026 18:13:54 +0200 Subject: [PATCH 3/5] fix review comments --- .../console-backend-rbac/templates/rbac.yaml | 4 +- integration_tests/create_postgres_access.lua | 20 +++++ .../dev/someteamname/accesses.yaml | 12 +++ integration_tests/postgres_crud.lua | 45 ++++++++++++ internal/kubernetes/fake/fake.go | 1 - internal/persistence/postgres/connection.go | 2 +- internal/persistence/postgres/queries.go | 73 +++++++++---------- internal/persistence/postgres/queries_test.go | 21 ++++++ internal/persistence/postgres/write.go | 10 ++- 9 files changed, 145 insertions(+), 43 deletions(-) diff --git a/charts/console-backend-rbac/templates/rbac.yaml b/charts/console-backend-rbac/templates/rbac.yaml index 753fa41a9..0d6b55a16 100644 --- a/charts/console-backend-rbac/templates/rbac.yaml +++ b/charts/console-backend-rbac/templates/rbac.yaml @@ -135,8 +135,8 @@ rules: - postgresaccesses verbs: - get - - list - create + - delete - apiGroups: - "nais.io" resources: @@ -312,8 +312,8 @@ rules: - postgresaccesses verbs: - get - - list - create + - delete - apiGroups: - "nais.io" resources: diff --git a/integration_tests/create_postgres_access.lua b/integration_tests/create_postgres_access.lua index cbd709f09..c8952a564 100644 --- a/integration_tests/create_postgres_access.lua +++ b/integration_tests/create_postgres_access.lua @@ -1,12 +1,14 @@ local user = User.new("user", "user@usersen.com") local otherMemberUser = User.new("othermember", "othermember@usersen.com") local creatorUser = User.new("creator", "creator@usersen.com") +local staleUser = User.new("stale", "stale@usersen.com") local nonMemberUser = User.new("nonmember", "other@user.com") local mainTeam = Team.new("someteamname", "purpose", "#slack_channel") mainTeam:addMember(user) mainTeam:addMember(otherMemberUser) mainTeam:addMember(creatorUser) +mainTeam:addMember(staleUser) Helper.readK8sResources("k8s_resources/create_postgres_access") @@ -415,3 +417,21 @@ Test.gql("Personal postgres connection retrieval is audited", function(t) } } } }, } end) + +Test.gql("An expired access is cleaned up, after which a new one can be requested", function(t) + t.addHeader("x-user-email", staleUser:email()) + local request = [[ + mutation { createPostgresAccess(input: { + postgres: "foobar", branch: "main", environmentName: "dev", teamSlug: "someteamname", + accessLevel: READ, reason: "Testing personal database access" + }) { name } } + ]] + t.query(request) + t.check { + errors = { { locations = NotNull(), path = { "createPostgresAccess" }, message = Contains("being cleaned up") } }, + data = Null, + } + + t.query(request) + t.check { data = { createPostgresAccess = { name = "postgres-access-9269571e872b7c9d" } } } +end) diff --git a/integration_tests/k8s_resources/create_postgres_access/dev/someteamname/accesses.yaml b/integration_tests/k8s_resources/create_postgres_access/dev/someteamname/accesses.yaml index 394e2bae9..54d692020 100644 --- a/integration_tests/k8s_resources/create_postgres_access/dev/someteamname/accesses.yaml +++ b/integration_tests/k8s_resources/create_postgres_access/dev/someteamname/accesses.yaml @@ -104,3 +104,15 @@ status: conditions: - type: Ready status: "True" +--- +apiVersion: nais.io/v1 +kind: PostgresAccess +metadata: + name: postgres-access-9269571e872b7c9d + namespace: someteamname + uid: 33333333-3333-4333-8333-333333333333 +spec: + postgresBranch: foobar-main-a4f04c0c + username: stale@usersen.com + accessLevel: read + expiresAt: "2000-01-01T00:00:00Z" diff --git a/integration_tests/postgres_crud.lua b/integration_tests/postgres_crud.lua index 7f355e03d..4271b0df8 100644 --- a/integration_tests/postgres_crud.lua +++ b/integration_tests/postgres_crud.lua @@ -123,3 +123,48 @@ Test.gql("Updating a missing Postgres fails", function(t) data = Null, } end) + +Test.gql("Names that Kubernetes would reject are rejected as field errors", function(t) + t.addHeader("x-user-email", member:email()) + for _, name in ipairs({ "Upper", "has/slash", "-leading" }) do + t.query(create(name, 'majorVersion: "18"')) + t.check { + errors = { { extensions = { field = "name" }, path = { "createPostgres" }, message = Contains("lowercase letters") } }, + data = Null, + } + end + t.query [[mutation { updatePostgres(input: { + name: "Upper", environmentName: "dev", teamSlug: "pg-crud-team", highAvailability: true + }) { postgres { name } } }]] + t.check { + errors = { { extensions = { field = "name" }, path = { "updatePostgres" }, message = Contains("lowercase letters") } }, + data = Null, + } +end) + +Test.gql("Zero and negative resource quantities are rejected", function(t) + t.addHeader("x-user-email", member:email()) + for _, field in ipairs({ "cpu", "memory", "diskSize" }) do + t.query(create("badquantity", string.format('majorVersion: "18", %s: "-1"', field))) + t.check { + errors = { { extensions = { field = field }, path = { "createPostgres" }, message = Contains("must be greater than zero") } }, + data = Null, + } + end +end) + +Test.gql("Creating and updating Postgres is recorded in the activity log", function(t) + t.addHeader("x-user-email", member:email()) + t.query [[{ team(slug: "pg-crud-team") { + activityLog(first: 50, filter: { activityTypes: [POSTGRES_CREATED, POSTGRES_UPDATED] }) { + nodes { __typename message actor resourceType resourceName environmentName teamSlug } + } + } }]] + t.check { + data = { team = { activityLog = { nodes = { + { __typename = "PostgresUpdatedActivityLogEntry", message = "Updated Postgres", actor = member:email(), resourceType = "POSTGRES", resourceName = "existing", environmentName = "dev", teamSlug = "pg-crud-team" }, + { __typename = "PostgresCreatedActivityLogEntry", message = "Created Postgres", actor = member:email(), resourceType = "POSTGRES", resourceName = Ignore(), environmentName = "dev", teamSlug = "pg-crud-team" }, + { __typename = "PostgresCreatedActivityLogEntry", message = "Created Postgres", actor = member:email(), resourceType = "POSTGRES", resourceName = Ignore(), environmentName = "dev", teamSlug = "pg-crud-team" }, + } } } }, + } +end) diff --git a/internal/kubernetes/fake/fake.go b/internal/kubernetes/fake/fake.go index 071b2ac5a..c1eae2182 100644 --- a/internal/kubernetes/fake/fake.go +++ b/internal/kubernetes/fake/fake.go @@ -219,7 +219,6 @@ func NewDynamicClient(scheme *runtime.Scheme) *dynfake.FakeDynamicClient { unleash_nais_io_v1.GroupVersion.WithResource("remoteunleashes"): "RemoteUnleashList", mapperatorv1.GroupVersion.WithResource("postgres"): "PostgresList", mapperatorv1.GroupVersion.WithResource("postgresbranches"): "PostgresBranchList", - mapperatorv1.GroupVersion.WithResource("postgresaccesses"): "PostgresAccessList", nais_io_v1alpha1.GroupVersion.WithResource("tunnels"): "TunnelList", mapperatorv1.GroupVersion.WithResource("valkeys"): "ValkeyList", mapperatorv1.GroupVersion.WithResource("opensearches"): "OpenSearchList", diff --git a/internal/persistence/postgres/connection.go b/internal/persistence/postgres/connection.go index ef508c3f4..d23f585c5 100644 --- a/internal/persistence/postgres/connection.go +++ b/internal/persistence/postgres/connection.go @@ -30,7 +30,7 @@ func loadPostgresAccessConnection(ctx context.Context, access *unstructured.Unst if err != nil { return err } - // The broker creates short names (postgres-access-), so this is the + // The broker creates short names (postgres-access-), so this is the // pgrator-owned credential Secret for this access, not a client-supplied name. credential, err := getAccessResource(ctx, input.EnvironmentName, namespace, access.GetName()+"-credentials", schema.GroupVersionResource{Version: "v1", Resource: "secrets"}) if err != nil { diff --git a/internal/persistence/postgres/queries.go b/internal/persistence/postgres/queries.go index be879cb70..f6ddaf1cb 100644 --- a/internal/persistence/postgres/queries.go +++ b/internal/persistence/postgres/queries.go @@ -3,13 +3,14 @@ package postgres import ( "cmp" "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "slices" "strings" "time" - "github.com/google/uuid" "github.com/nais/api/internal/activitylog" "github.com/nais/api/internal/auth/authz" "github.com/nais/api/internal/graph/apierror" @@ -28,7 +29,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/dynamic" "k8s.io/utils/ptr" ) @@ -491,24 +491,35 @@ func CreatePostgresAccess(ctx context.Context, input CreatePostgresAccessInput) username := authz.ActorFromContext(ctx).User.Identity() branchObjectName := nais_io_v1.PostgresBranchObjectName(input.Postgres, input.Branch) - // One live access per user and branch: pgrator derives a single personal database - // role from them, so a second access could never become ready. - existing, err := findActivePostgresAccess(ctx, client.Namespace(input.TeamSlug.String()), username, branchObjectName) - if err != nil { - return nil, err - } - if existing != nil { - level, _, _ := unstructured.NestedString(existing.Object, "spec", "accessLevel") - if level != input.AccessLevel.CRDValue() { - return nil, apierror.Errorf("You already have %s access to this branch until %s. Wait for it to expire before requesting a different access level.", level, existingExpiry(existing).Format(time.RFC3339)) - } - return &CreatePostgresAccessPayload{Name: existing.GetName(), ExpiresAt: existingExpiry(existing)}, nil - } + // One access per user and branch: pgrator derives a single personal database role from + // them, so a second access could never become ready. The name is derived from the pair, so + // the API server enforces this atomically through AlreadyExists. + name := postgresAccessName(username, branchObjectName) + accesses := client.Namespace(input.TeamSlug.String()) - name := fmt.Sprintf("postgres-access-%s", uuid.NewString()[:8]) res := newPostgresAccessResource(input, username, name, expiresAt) - - if _, err := client.Namespace(input.TeamSlug.String()).Create(ctx, res, metav1.CreateOptions{}); err != nil { + _, err = accesses.Create(ctx, res, metav1.CreateOptions{}) + if k8serrors.IsAlreadyExists(err) { + existing, getErr := accesses.Get(ctx, name, metav1.GetOptions{}) + if getErr != nil { + return nil, getErr + } + if existing.GetDeletionTimestamp() == nil && existingExpiry(existing).After(time.Now()) { + level, _, _ := unstructured.NestedString(existing.Object, "spec", "accessLevel") + if level != input.AccessLevel.CRDValue() { + return nil, apierror.Errorf("You already have %s access to this branch until %s. Wait for it to expire before requesting a different access level.", level, existingExpiry(existing).Format(time.RFC3339)) + } + return &CreatePostgresAccessPayload{Name: name, ExpiresAt: existingExpiry(existing)}, nil + } + // The spec is immutable and nothing removes expired accesses, so clear it out of the way. + // Preconditions make sure we only delete the object we inspected. + uid := existing.GetUID() + if err := accesses.Delete(ctx, name, metav1.DeleteOptions{Preconditions: &metav1.Preconditions{UID: &uid}}); err != nil && !k8serrors.IsNotFound(err) { + return nil, err + } + return nil, apierror.Errorf("Your previous access to this branch is being cleaned up. Try again in a few seconds.") + } + if err != nil { return nil, err } @@ -538,26 +549,12 @@ func existingExpiry(access *unstructured.Unstructured) time.Time { return t } -// findActivePostgresAccess returns the user's unexpired, non-deleting access to a branch. -// The check is not atomic with creation, so two simultaneous requests can still both create one. -func findActivePostgresAccess(ctx context.Context, client dynamic.ResourceInterface, username, branchObjectName string) (*unstructured.Unstructured, error) { - list, err := client.List(ctx, metav1.ListOptions{}) - if err != nil { - return nil, fmt.Errorf("listing postgres accesses: %w", err) - } - now := time.Now() - for i := range list.Items { - item := &list.Items[i] - if item.GetDeletionTimestamp() != nil || !existingExpiry(item).After(now) { - continue - } - user, _, _ := unstructured.NestedString(item.Object, "spec", "username") - branch, _, _ := unstructured.NestedString(item.Object, "spec", "postgresBranch") - if user == username && branch == branchObjectName { - return item, nil - } - } - return nil, nil +// postgresAccessName derives the access name from user and branch, so the Kubernetes API +// server rejects a second concurrent access for the same pair. The NUL separator keeps +// different (user, branch) pairs from producing the same input. +func postgresAccessName(username, branchObjectName string) string { + sum := sha256.Sum256([]byte(username + "\x00" + branchObjectName)) + return "postgres-access-" + hex.EncodeToString(sum[:])[:16] } func (i CreatePostgresAccessInput) accessTTL() (time.Duration, error) { diff --git a/internal/persistence/postgres/queries_test.go b/internal/persistence/postgres/queries_test.go index f7077fe9c..fdce23f04 100644 --- a/internal/persistence/postgres/queries_test.go +++ b/internal/persistence/postgres/queries_test.go @@ -11,6 +11,7 @@ import ( nais_io_v1 "github.com/nais/pgrator/pkg/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/validation" ) func TestNewPostgresAccessResource(t *testing.T) { @@ -210,3 +211,23 @@ func TestPostgresAccessConnectionDetails(t *testing.T) { }) } } + +func TestPostgresAccessNameIsStablePerUserAndBranch(t *testing.T) { + name := postgresAccessName("user@example.com", "orders-main-abc") + if name != postgresAccessName("user@example.com", "orders-main-abc") { + t.Error("name must be stable, so a second request for the same pair conflicts") + } + for _, other := range []string{ + postgresAccessName("other@example.com", "orders-main-abc"), + postgresAccessName("user@example.com", "orders-recovered-abc"), + // Shifting the boundary between user and branch must not collide. + postgresAccessName("user@example.comorders", "-main-abc"), + } { + if other == name { + t.Errorf("different user/branch produced the same name %q", name) + } + } + if errs := validation.IsDNS1123Subdomain(name); len(errs) > 0 { + t.Errorf("name %q is not a valid resource name: %v", name, errs) + } +} diff --git a/internal/persistence/postgres/write.go b/internal/persistence/postgres/write.go index af3b926eb..1ecc8701b 100644 --- a/internal/persistence/postgres/write.go +++ b/internal/persistence/postgres/write.go @@ -17,6 +17,7 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation" "k8s.io/client-go/dynamic" ) @@ -64,6 +65,8 @@ func (i *CreatePostgresInput) ValidationErrors(_ context.Context) *validate.Vali if i.Name == "" { verr.Add("name", "Name must not be empty.") + } else if errs := validation.IsDNS1123Subdomain(i.Name); len(errs) > 0 { + verr.Add("name", "Name must consist of lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen.") } if i.EnvironmentName == "" { verr.Add("environmentName", "Environment name must not be empty.") @@ -89,6 +92,8 @@ func (i *UpdatePostgresInput) ValidationErrors(_ context.Context) *validate.Vali if i.Name == "" { verr.Add("name", "Name must not be empty.") + } else if errs := validation.IsDNS1123Subdomain(i.Name); len(errs) > 0 { + verr.Add("name", "Name must consist of lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen.") } if i.EnvironmentName == "" { verr.Add("environmentName", "Environment name must not be empty.") @@ -105,8 +110,11 @@ func validateQuantities(verr *validate.ValidationErrors, cpu, memory, diskSize * if value == nil { continue } - if _, err := resource.ParseQuantity(*value); err != nil { + quantity, err := resource.ParseQuantity(*value) + if err != nil { verr.Add(field, "%q is not a valid quantity.", *value) + } else if quantity.Sign() <= 0 { + verr.Add(field, "%q must be greater than zero.", *value) } } } From d5713d72e67cc18aac6881bc1725f6307fe00f85 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 1 Oct 2026 18:36:34 +0200 Subject: [PATCH 4/5] review comments --- internal/persistence/postgres/write.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/internal/persistence/postgres/write.go b/internal/persistence/postgres/write.go index 1ecc8701b..2a3640d4d 100644 --- a/internal/persistence/postgres/write.go +++ b/internal/persistence/postgres/write.go @@ -16,6 +16,7 @@ import ( k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/validation" "k8s.io/client-go/dynamic" @@ -169,7 +170,7 @@ func Create(ctx context.Context, input CreatePostgresInput) (*CreatePostgresPayl return nil, err } - obj, err := kubernetes.ToUnstructured(pg) + obj, err := toUnstructuredWithoutZeroResources(pg) if err != nil { return nil, err } @@ -237,7 +238,7 @@ func Update(ctx context.Context, input UpdatePostgresInput) (*UpdatePostgresPayl return &UpdatePostgresPayload{Postgres: ret}, nil } - obj, err := kubernetes.ToUnstructured(pg) + obj, err := toUnstructuredWithoutZeroResources(pg) if err != nil { return nil, err } @@ -282,6 +283,27 @@ func applyResources(r *nais_io_v1.PostgresResources, cpu, memory, diskSize *stri return set("diskSize", diskSize, &r.DiskSize) } +// toUnstructuredWithoutZeroResources converts pg and drops resource quantities that were never +// set. resource.Quantity is a struct, so omitempty does not skip it and it would otherwise be +// sent as "0", which stops the CRD from applying its defaults. +func toUnstructuredWithoutZeroResources(pg *nais_io_v1.Postgres) (*unstructured.Unstructured, error) { + obj, err := kubernetes.ToUnstructured(pg) + if err != nil { + return nil, err + } + for field, q := range map[string]resource.Quantity{ + "cpu": pg.Spec.Resources.Cpu, "memory": pg.Spec.Resources.Memory, "diskSize": pg.Spec.Resources.DiskSize, + } { + if q.IsZero() { + unstructured.RemoveNestedField(obj.Object, "spec", "resources", field) + } + } + if m, found, _ := unstructured.NestedMap(obj.Object, "spec", "resources"); found && len(m) == 0 { + unstructured.RemoveNestedField(obj.Object, "spec", "resources") + } + return obj, nil +} + func logPostgresChange(ctx context.Context, action activitylog.ActivityLogEntryAction, name, environmentName string, teamSlug slug.Slug) error { return activitylog.Create(ctx, activitylog.CreateInput{ Action: action, From 73eef82cf4bc2edf759b0b0b1bef17756be99690 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 1 Oct 2026 21:16:36 +0200 Subject: [PATCH 5/5] fixes review comment --- integration_tests/postgres_crud.lua | 21 +- internal/graph/gengql/postgres.generated.go | 256 ++++++++++++++ internal/graph/gengql/root_.generated.go | 95 +++++- internal/graph/schema/postgres.graphqls | 29 +- internal/persistence/postgres/activitylog.go | 16 + internal/persistence/postgres/models.go | 109 ++++++ internal/persistence/postgres/queries.go | 235 +++++++++++++ internal/persistence/postgres/queries_test.go | 31 ++ internal/persistence/postgres/write.go | 316 ------------------ 9 files changed, 777 insertions(+), 331 deletions(-) delete mode 100644 internal/persistence/postgres/write.go diff --git a/integration_tests/postgres_crud.lua b/integration_tests/postgres_crud.lua index 4271b0df8..587f73635 100644 --- a/integration_tests/postgres_crud.lua +++ b/integration_tests/postgres_crud.lua @@ -42,13 +42,13 @@ end) Test.gql("Team members can create Postgres with explicit resources", function(t) t.addHeader("x-user-email", member:email()) - t.query(create("sized", 'majorVersion: "16", highAvailability: true, cpu: "250m", memory: "1Gi", diskSize: "20Gi"')) + t.query(create("sized", 'majorVersion: "18", highAvailability: true, cpu: "250m", memory: "1Gi", diskSize: "20Gi"')) t.check { data = { createPostgres = { postgres = { name = "sized", - majorVersion = "16", + majorVersion = "18", highAvailability = true, resources = { cpu = "250m", memory = "1Gi", diskSize = "20Gi" }, }, @@ -59,7 +59,7 @@ end) Test.gql("Creating an existing Postgres fails", function(t) t.addHeader("x-user-email", member:email()) - t.query(create("existing", 'majorVersion: "17"')) + t.query(create("existing", 'majorVersion: "18"')) t.check { errors = { { locations = NotNull(), path = { "createPostgres" }, message = NotNull() } }, data = Null, @@ -157,14 +157,21 @@ Test.gql("Creating and updating Postgres is recorded in the activity log", funct t.addHeader("x-user-email", member:email()) t.query [[{ team(slug: "pg-crud-team") { activityLog(first: 50, filter: { activityTypes: [POSTGRES_CREATED, POSTGRES_UPDATED] }) { - nodes { __typename message actor resourceType resourceName environmentName teamSlug } + nodes { __typename message actor resourceType resourceName environmentName teamSlug + ... on PostgresUpdatedActivityLogEntry { data { updatedFields { field oldValue newValue } } } + } } } }]] t.check { data = { team = { activityLog = { nodes = { - { __typename = "PostgresUpdatedActivityLogEntry", message = "Updated Postgres", actor = member:email(), resourceType = "POSTGRES", resourceName = "existing", environmentName = "dev", teamSlug = "pg-crud-team" }, - { __typename = "PostgresCreatedActivityLogEntry", message = "Created Postgres", actor = member:email(), resourceType = "POSTGRES", resourceName = Ignore(), environmentName = "dev", teamSlug = "pg-crud-team" }, - { __typename = "PostgresCreatedActivityLogEntry", message = "Created Postgres", actor = member:email(), resourceType = "POSTGRES", resourceName = Ignore(), environmentName = "dev", teamSlug = "pg-crud-team" }, + { __typename = "PostgresUpdatedActivityLogEntry", message = "Updated Postgres", actor = member:email(), resourceType = "POSTGRES", resourceName = "existing", environmentName = "dev", teamSlug = "pg-crud-team", + data = { updatedFields = { + { field = "highAvailability", oldValue = "false", newValue = "true" }, + { field = "diskSize", oldValue = "10Gi", newValue = "30Gi" }, + } }, + }, + { __typename = "PostgresCreatedActivityLogEntry", message = "Created Postgres", actor = member:email(), resourceType = "POSTGRES", resourceName = Ignore(), environmentName = "dev", teamSlug = "pg-crud-team" }, + { __typename = "PostgresCreatedActivityLogEntry", message = "Created Postgres", actor = member:email(), resourceType = "POSTGRES", resourceName = Ignore(), environmentName = "dev", teamSlug = "pg-crud-team" }, } } } }, } end) diff --git a/internal/graph/gengql/postgres.generated.go b/internal/graph/gengql/postgres.generated.go index 59ad84ea4..a43724831 100644 --- a/internal/graph/gengql/postgres.generated.go +++ b/internal/graph/gengql/postgres.generated.go @@ -3117,6 +3117,139 @@ func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntry_environ return graphql.NewScalarFieldContext("PostgresUpdatedActivityLogEntry", field, false, false, errors.New("field of type String does not have child fields")) } +func (ec *executionContext) _PostgresUpdatedActivityLogEntry_data(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresUpdatedActivityLogEntry_data(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Data, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *postgres.PostgresUpdatedActivityLogEntryData) graphql.Marshaler { + return ec.marshalNPostgresUpdatedActivityLogEntryData2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐPostgresUpdatedActivityLogEntryData(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntry_data(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "PostgresUpdatedActivityLogEntry", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_PostgresUpdatedActivityLogEntryData(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _PostgresUpdatedActivityLogEntryData_updatedFields(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntryData) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresUpdatedActivityLogEntryData_updatedFields(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.UpdatedFields, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []*postgres.PostgresUpdatedActivityLogEntryDataUpdatedField) graphql.Marshaler { + return ec.marshalNPostgresUpdatedActivityLogEntryDataUpdatedField2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐPostgresUpdatedActivityLogEntryDataUpdatedFieldᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntryData_updatedFields(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "PostgresUpdatedActivityLogEntryData", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_PostgresUpdatedActivityLogEntryDataUpdatedField(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _PostgresUpdatedActivityLogEntryDataUpdatedField_field(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntryDataUpdatedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresUpdatedActivityLogEntryDataUpdatedField_field(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Field, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntryDataUpdatedField_field(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresUpdatedActivityLogEntryDataUpdatedField", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _PostgresUpdatedActivityLogEntryDataUpdatedField_oldValue(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntryDataUpdatedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresUpdatedActivityLogEntryDataUpdatedField_oldValue(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.OldValue, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *string) graphql.Marshaler { + return ec.marshalOString2ᚖstring(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntryDataUpdatedField_oldValue(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresUpdatedActivityLogEntryDataUpdatedField", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _PostgresUpdatedActivityLogEntryDataUpdatedField_newValue(ctx context.Context, field graphql.CollectedField, obj *postgres.PostgresUpdatedActivityLogEntryDataUpdatedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_PostgresUpdatedActivityLogEntryDataUpdatedField_newValue(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.NewValue, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *string) graphql.Marshaler { + return ec.marshalOString2ᚖstring(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_PostgresUpdatedActivityLogEntryDataUpdatedField_newValue(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("PostgresUpdatedActivityLogEntryDataUpdatedField", field, false, false, errors.New("field of type String does not have child fields")) +} + func (ec *executionContext) _TeamInventoryCountPostgresBranches_total(ctx context.Context, field graphql.CollectedField, obj *postgres.TeamInventoryCountPostgresBranches) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -5177,6 +5310,93 @@ func (ec *executionContext) _PostgresUpdatedActivityLogEntry(ctx context.Context } case "environmentName": out.Values[i] = ec._PostgresUpdatedActivityLogEntry_environmentName(ctx, field, obj) + case "data": + out.Values[i] = ec._PostgresUpdatedActivityLogEntry_data(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var postgresUpdatedActivityLogEntryDataImplementors = []string{"PostgresUpdatedActivityLogEntryData"} + +func (ec *executionContext) _PostgresUpdatedActivityLogEntryData(ctx context.Context, sel ast.SelectionSet, obj *postgres.PostgresUpdatedActivityLogEntryData) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, postgresUpdatedActivityLogEntryDataImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("PostgresUpdatedActivityLogEntryData") + case "updatedFields": + out.Values[i] = ec._PostgresUpdatedActivityLogEntryData_updatedFields(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var postgresUpdatedActivityLogEntryDataUpdatedFieldImplementors = []string{"PostgresUpdatedActivityLogEntryDataUpdatedField"} + +func (ec *executionContext) _PostgresUpdatedActivityLogEntryDataUpdatedField(ctx context.Context, sel ast.SelectionSet, obj *postgres.PostgresUpdatedActivityLogEntryDataUpdatedField) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, postgresUpdatedActivityLogEntryDataUpdatedFieldImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("PostgresUpdatedActivityLogEntryDataUpdatedField") + case "field": + out.Values[i] = ec._PostgresUpdatedActivityLogEntryDataUpdatedField_field(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "oldValue": + out.Values[i] = ec._PostgresUpdatedActivityLogEntryDataUpdatedField_oldValue(ctx, field, obj) + case "newValue": + out.Values[i] = ec._PostgresUpdatedActivityLogEntryDataUpdatedField_newValue(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -5515,6 +5735,42 @@ func (ec *executionContext) marshalNPostgresResources2githubᚗcomᚋnaisᚋapi return ec._PostgresResources(ctx, sel, &v) } +func (ec *executionContext) marshalNPostgresUpdatedActivityLogEntryData2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐPostgresUpdatedActivityLogEntryData(ctx context.Context, sel ast.SelectionSet, v *postgres.PostgresUpdatedActivityLogEntryData) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._PostgresUpdatedActivityLogEntryData(ctx, sel, v) +} + +func (ec *executionContext) marshalNPostgresUpdatedActivityLogEntryDataUpdatedField2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐPostgresUpdatedActivityLogEntryDataUpdatedFieldᚄ(ctx context.Context, sel ast.SelectionSet, v []*postgres.PostgresUpdatedActivityLogEntryDataUpdatedField) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNPostgresUpdatedActivityLogEntryDataUpdatedField2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐPostgresUpdatedActivityLogEntryDataUpdatedField(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNPostgresUpdatedActivityLogEntryDataUpdatedField2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐPostgresUpdatedActivityLogEntryDataUpdatedField(ctx context.Context, sel ast.SelectionSet, v *postgres.PostgresUpdatedActivityLogEntryDataUpdatedField) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._PostgresUpdatedActivityLogEntryDataUpdatedField(ctx, sel, v) +} + func (ec *executionContext) marshalNTeamInventoryCountPostgresBranches2githubᚗcomᚋnaisᚋapiᚋinternalᚋpersistenceᚋpostgresᚐTeamInventoryCountPostgresBranches(ctx context.Context, sel ast.SelectionSet, v postgres.TeamInventoryCountPostgresBranches) graphql.Marshaler { return ec._TeamInventoryCountPostgresBranches(ctx, sel, &v) } diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index d017b5d93..2c89a7c48 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -2048,6 +2048,7 @@ type ComplexityRoot struct { PostgresUpdatedActivityLogEntry struct { Actor func(childComplexity int) int CreatedAt func(childComplexity int) int + Data func(childComplexity int) int EnvironmentName func(childComplexity int) int GitHubActorClaims func(childComplexity int) int ID func(childComplexity int) int @@ -2057,6 +2058,16 @@ type ComplexityRoot struct { TeamSlug func(childComplexity int) int } + PostgresUpdatedActivityLogEntryData struct { + UpdatedFields func(childComplexity int) int + } + + PostgresUpdatedActivityLogEntryDataUpdatedField struct { + Field func(childComplexity int) int + NewValue func(childComplexity int) int + OldValue func(childComplexity int) int + } + Price struct { Value func(childComplexity int) int } @@ -12428,6 +12439,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.PostgresUpdatedActivityLogEntry.CreatedAt(childComplexity), true + case "PostgresUpdatedActivityLogEntry.data": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntry.Data == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntry.Data(childComplexity), true + case "PostgresUpdatedActivityLogEntry.environmentName": if e.ComplexityRoot.PostgresUpdatedActivityLogEntry.EnvironmentName == nil { break @@ -12477,6 +12495,34 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.PostgresUpdatedActivityLogEntry.TeamSlug(childComplexity), true + case "PostgresUpdatedActivityLogEntryData.updatedFields": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntryData.UpdatedFields == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntryData.UpdatedFields(childComplexity), true + + case "PostgresUpdatedActivityLogEntryDataUpdatedField.field": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntryDataUpdatedField.Field == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntryDataUpdatedField.Field(childComplexity), true + + case "PostgresUpdatedActivityLogEntryDataUpdatedField.newValue": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntryDataUpdatedField.NewValue == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntryDataUpdatedField.NewValue(childComplexity), true + + case "PostgresUpdatedActivityLogEntryDataUpdatedField.oldValue": + if e.ComplexityRoot.PostgresUpdatedActivityLogEntryDataUpdatedField.OldValue == nil { + break + } + + return e.ComplexityRoot.PostgresUpdatedActivityLogEntryDataUpdatedField.OldValue(childComplexity), true + case "Price.value": if e.ComplexityRoot.Price.Value == nil { break @@ -27993,6 +28039,27 @@ type PostgresUpdatedActivityLogEntry implements ActivityLogEntry & Node { "The environment name that the entry belongs to." environmentName: String + + "Data associated with the entry." + data: PostgresUpdatedActivityLogEntryData! +} + +"Data associated with a Postgres update." +type PostgresUpdatedActivityLogEntryData { + "The fields that were changed." + updatedFields: [PostgresUpdatedActivityLogEntryDataUpdatedField!]! +} + +"A single field that was changed by a Postgres update." +type PostgresUpdatedActivityLogEntryDataUpdatedField { + "The name of the field." + field: String! + + "The old value of the field, if it had one." + oldValue: String + + "The new value of the field." + newValue: String } extend enum ActivityLogActivityType { @@ -28165,15 +28232,15 @@ input CreatePostgresInput { environmentName: String! "The team that owns the Postgres." teamSlug: Slug! - "PostgreSQL major version. Supported versions are 16, 17 and 18." + "PostgreSQL major version." majorVersion: String! "Add a third instance and enable synchronous replication. Defaults to false." highAvailability: Boolean - "Requested CPU, for example '100m'. Defaults to the platform default." + "Requested CPU, for example '100m'. If omitted, the platform default is used." cpu: String - "Requested memory, for example '512Mi'. Defaults to the platform default." + "Requested memory, for example '512Mi'. If omitted, the platform default is used." memory: String - "Requested disk size, for example '10Gi'. Defaults to the platform default." + "Requested disk size, for example '10Gi'. If omitted, the platform default is used." diskSize: String } @@ -38198,6 +38265,26 @@ func (ec *executionContext) childFields_PostgresResources(ctx context.Context, f return nil, fmt.Errorf("no field named %q was found under type PostgresResources", field.Name) } +func (ec *executionContext) childFields_PostgresUpdatedActivityLogEntryData(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "updatedFields": + return ec.fieldContext_PostgresUpdatedActivityLogEntryData_updatedFields(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type PostgresUpdatedActivityLogEntryData", field.Name) +} + +func (ec *executionContext) childFields_PostgresUpdatedActivityLogEntryDataUpdatedField(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "field": + return ec.fieldContext_PostgresUpdatedActivityLogEntryDataUpdatedField_field(ctx, field) + case "oldValue": + return ec.fieldContext_PostgresUpdatedActivityLogEntryDataUpdatedField_oldValue(ctx, field) + case "newValue": + return ec.fieldContext_PostgresUpdatedActivityLogEntryDataUpdatedField_newValue(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type PostgresUpdatedActivityLogEntryDataUpdatedField", field.Name) +} + func (ec *executionContext) childFields_Price(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "value": diff --git a/internal/graph/schema/postgres.graphqls b/internal/graph/schema/postgres.graphqls index f602559cc..a55398f9b 100644 --- a/internal/graph/schema/postgres.graphqls +++ b/internal/graph/schema/postgres.graphqls @@ -399,6 +399,27 @@ type PostgresUpdatedActivityLogEntry implements ActivityLogEntry & Node { "The environment name that the entry belongs to." environmentName: String + + "Data associated with the entry." + data: PostgresUpdatedActivityLogEntryData! +} + +"Data associated with a Postgres update." +type PostgresUpdatedActivityLogEntryData { + "The fields that were changed." + updatedFields: [PostgresUpdatedActivityLogEntryDataUpdatedField!]! +} + +"A single field that was changed by a Postgres update." +type PostgresUpdatedActivityLogEntryDataUpdatedField { + "The name of the field." + field: String! + + "The old value of the field, if it had one." + oldValue: String + + "The new value of the field." + newValue: String } extend enum ActivityLogActivityType { @@ -571,15 +592,15 @@ input CreatePostgresInput { environmentName: String! "The team that owns the Postgres." teamSlug: Slug! - "PostgreSQL major version. Supported versions are 16, 17 and 18." + "PostgreSQL major version." majorVersion: String! "Add a third instance and enable synchronous replication. Defaults to false." highAvailability: Boolean - "Requested CPU, for example '100m'. Defaults to the platform default." + "Requested CPU, for example '100m'. If omitted, the platform default is used." cpu: String - "Requested memory, for example '512Mi'. Defaults to the platform default." + "Requested memory, for example '512Mi'. If omitted, the platform default is used." memory: String - "Requested disk size, for example '10Gi'. Defaults to the platform default." + "Requested disk size, for example '10Gi'. If omitted, the platform default is used." diskSize: String } diff --git a/internal/persistence/postgres/activitylog.go b/internal/persistence/postgres/activitylog.go index a0d175676..e0b533e1e 100644 --- a/internal/persistence/postgres/activitylog.go +++ b/internal/persistence/postgres/activitylog.go @@ -23,8 +23,13 @@ func init() { GenericActivityLogEntry: entry.WithMessage("Created Postgres"), }, nil case activitylog.ActivityLogEntryActionUpdated: + data, err := activitylog.UnmarshalData[PostgresUpdatedActivityLogEntryData](entry) + if err != nil { + return nil, fmt.Errorf("transforming postgres updated activity log entry data: %w", err) + } return PostgresUpdatedActivityLogEntry{ GenericActivityLogEntry: entry.WithMessage("Updated Postgres"), + Data: data, }, nil case activitylog.ActivityLogEntryActionDeleted: return PostgresDeletedActivityLogEntry{ @@ -85,6 +90,17 @@ type PostgresCreatedActivityLogEntry struct { type PostgresUpdatedActivityLogEntry struct { activitylog.GenericActivityLogEntry + Data *PostgresUpdatedActivityLogEntryData `json:"data"` +} + +type PostgresUpdatedActivityLogEntryData struct { + UpdatedFields []*PostgresUpdatedActivityLogEntryDataUpdatedField `json:"updatedFields"` +} + +type PostgresUpdatedActivityLogEntryDataUpdatedField struct { + Field string `json:"field"` + OldValue *string `json:"oldValue,omitempty"` + NewValue *string `json:"newValue,omitempty"` } type PostgresGrantAccessActivityLogEntry struct { diff --git a/internal/persistence/postgres/models.go b/internal/persistence/postgres/models.go index 76cc0a761..32f5b9099 100644 --- a/internal/persistence/postgres/models.go +++ b/internal/persistence/postgres/models.go @@ -24,6 +24,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation" ) type PostgresBranchEdge = pagination.Edge[*PostgresBranch] @@ -563,3 +564,111 @@ type PostgresAccessConnectionDetails struct { RelayAccess string `json:"relayAccess"` RelayToken string `json:"relayToken"` } + +// supportedMajorVersions are the versions the API accepts for new Postgres. The CRD enum +// also allows older versions, but those are not offered to users. +var supportedMajorVersions = []string{"18"} + +type CreatePostgresInput struct { + Name string `json:"name"` + EnvironmentName string `json:"environmentName"` + TeamSlug slug.Slug `json:"teamSlug"` + MajorVersion string `json:"majorVersion"` + HighAvailability *bool `json:"highAvailability,omitempty"` + CPU *string `json:"cpu,omitempty"` + Memory *string `json:"memory,omitempty"` + DiskSize *string `json:"diskSize,omitempty"` +} + +type CreatePostgresPayload struct { + Postgres *Postgres `json:"postgres"` +} + +type UpdatePostgresInput struct { + Name string `json:"name"` + EnvironmentName string `json:"environmentName"` + TeamSlug slug.Slug `json:"teamSlug"` + HighAvailability *bool `json:"highAvailability,omitempty"` + CPU *string `json:"cpu,omitempty"` + Memory *string `json:"memory,omitempty"` + DiskSize *string `json:"diskSize,omitempty"` +} + +type UpdatePostgresPayload struct { + Postgres *Postgres `json:"postgres"` +} + +func (i *CreatePostgresInput) Validate(ctx context.Context) error { + return i.ValidationErrors(ctx).NilIfEmpty() +} + +func (i *CreatePostgresInput) ValidationErrors(_ context.Context) *validate.ValidationErrors { + verr := validate.New() + i.Name = strings.TrimSpace(i.Name) + i.EnvironmentName = strings.TrimSpace(i.EnvironmentName) + i.MajorVersion = strings.TrimSpace(i.MajorVersion) + + if i.Name == "" { + verr.Add("name", "Name must not be empty.") + } else if errs := validation.IsDNS1123Subdomain(i.Name); len(errs) > 0 { + verr.Add("name", "Name must consist of lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen.") + } + if i.EnvironmentName == "" { + verr.Add("environmentName", "Environment name must not be empty.") + } + if i.TeamSlug == "" { + verr.Add("teamSlug", "Team slug must not be empty.") + } + if !contains(supportedMajorVersions, i.MajorVersion) { + verr.Add("majorVersion", "Major version must be one of: %s.", strings.Join(supportedMajorVersions, ", ")) + } + validateQuantities(verr, i.CPU, i.Memory, i.DiskSize) + return verr +} + +func (i *UpdatePostgresInput) Validate(ctx context.Context) error { + return i.ValidationErrors(ctx).NilIfEmpty() +} + +func (i *UpdatePostgresInput) ValidationErrors(_ context.Context) *validate.ValidationErrors { + verr := validate.New() + i.Name = strings.TrimSpace(i.Name) + i.EnvironmentName = strings.TrimSpace(i.EnvironmentName) + + if i.Name == "" { + verr.Add("name", "Name must not be empty.") + } else if errs := validation.IsDNS1123Subdomain(i.Name); len(errs) > 0 { + verr.Add("name", "Name must consist of lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen.") + } + if i.EnvironmentName == "" { + verr.Add("environmentName", "Environment name must not be empty.") + } + if i.TeamSlug == "" { + verr.Add("teamSlug", "Team slug must not be empty.") + } + validateQuantities(verr, i.CPU, i.Memory, i.DiskSize) + return verr +} + +func validateQuantities(verr *validate.ValidationErrors, cpu, memory, diskSize *string) { + for field, value := range map[string]*string{"cpu": cpu, "memory": memory, "diskSize": diskSize} { + if value == nil { + continue + } + quantity, err := resource.ParseQuantity(*value) + if err != nil { + verr.Add(field, "%q is not a valid quantity.", *value) + } else if quantity.Sign() <= 0 { + verr.Add(field, "%q must be greater than zero.", *value) + } + } +} + +func contains(values []string, value string) bool { + for _, v := range values { + if v == value { + return true + } + } + return false +} diff --git a/internal/persistence/postgres/queries.go b/internal/persistence/postgres/queries.go index f6ddaf1cb..439f206de 100644 --- a/internal/persistence/postgres/queries.go +++ b/internal/persistence/postgres/queries.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "slices" + "strconv" "strings" "time" @@ -26,9 +27,11 @@ import ( liberatorv1 "github.com/nais/liberator/pkg/apis/nais.io/v1" nais_io_v1 "github.com/nais/pgrator/pkg/api/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" "k8s.io/utils/ptr" ) @@ -625,3 +628,235 @@ func WorkloadsForInstance(ctx context.Context, teamSlug slug.Slug, environmentNa return ret } + +func postgresGVR() schema.GroupVersionResource { + return schema.GroupVersionResource{Group: "nais.io", Version: "v1", Resource: "postgres"} +} + +// postgresClient returns a namespaced client for Postgres that writes with the API's own +// identity. Users never write the CRD directly; authorization is decided by the resolver. +func postgresClient(ctx context.Context, environmentName string, teamSlug slug.Slug) (dynamic.ResourceInterface, error) { + client, err := fromContext(ctx).postgresBranchWatcher.SystemAuthenticatedClient(ctx, environmentName, watcher.WithImpersonatedClientGVR(postgresGVR())) + if err != nil { + return nil, err + } + return client.Namespace(teamSlug.String()), nil +} + +func Create(ctx context.Context, input CreatePostgresInput) (*CreatePostgresPayload, error) { + if err := input.Validate(ctx); err != nil { + return nil, err + } + + client, err := postgresClient(ctx, input.EnvironmentName, input.TeamSlug) + if err != nil { + return nil, err + } + + pg := &nais_io_v1.Postgres{ + TypeMeta: metav1.TypeMeta{Kind: "Postgres", APIVersion: "nais.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: input.Name, + Namespace: input.TeamSlug.String(), + }, + Spec: nais_io_v1.PostgresSpec{MajorVersion: input.MajorVersion}, + } + pg.SetAnnotations(kubernetes.WithCommonAnnotations(nil, authz.ActorFromContext(ctx).User.Identity())) + kubernetes.SetManagedByConsoleLabel(pg) + if input.HighAvailability != nil { + pg.Spec.HighAvailability = *input.HighAvailability + } + if err := applyResources(&pg.Spec.Resources, input.CPU, input.Memory, input.DiskSize); err != nil { + return nil, err + } + + obj, err := toUnstructuredWithoutZeroResources(pg) + if err != nil { + return nil, err + } + + created, err := client.Create(ctx, obj, metav1.CreateOptions{}) + if err != nil { + if k8serrors.IsAlreadyExists(err) { + return nil, apierror.ErrAlreadyExists + } + return nil, err + } + + if err := logPostgresChange(ctx, activitylog.ActivityLogEntryActionCreated, input.Name, input.EnvironmentName, input.TeamSlug, nil); err != nil { + return nil, err + } + + ret, err := toPostgres(created, input.EnvironmentName) + if err != nil { + return nil, err + } + return &CreatePostgresPayload{Postgres: ret}, nil +} + +func Update(ctx context.Context, input UpdatePostgresInput) (*UpdatePostgresPayload, error) { + if err := input.Validate(ctx); err != nil { + return nil, err + } + + client, err := postgresClient(ctx, input.EnvironmentName, input.TeamSlug) + if err != nil { + return nil, err + } + + existing, err := client.Get(ctx, input.Name, metav1.GetOptions{}) + if k8serrors.IsNotFound(err) { + return nil, apierror.Errorf("Postgres %q not found.", input.Name) + } + if err != nil { + return nil, err + } + + pg, err := kubernetes.ToConcrete[nais_io_v1.Postgres](existing) + if err != nil { + return nil, err + } + + var changes []*PostgresUpdatedActivityLogEntryDataUpdatedField + for _, f := range []func(*nais_io_v1.Postgres, UpdatePostgresInput) ([]*PostgresUpdatedActivityLogEntryDataUpdatedField, error){ + updateHighAvailability, + updateResources, + } { + res, err := f(pg, input) + if err != nil { + return nil, err + } + changes = append(changes, res...) + } + + if len(changes) == 0 { + ret, err := toPostgres(existing, input.EnvironmentName) + if err != nil { + return nil, err + } + return &UpdatePostgresPayload{Postgres: ret}, nil + } + + obj, err := toUnstructuredWithoutZeroResources(pg) + if err != nil { + return nil, err + } + obj.SetAnnotations(kubernetes.WithCommonAnnotations(obj.GetAnnotations(), authz.ActorFromContext(ctx).User.Identity())) + + updated, err := client.Update(ctx, obj, metav1.UpdateOptions{}) + if err != nil { + return nil, err + } + + if err := logPostgresChange(ctx, activitylog.ActivityLogEntryActionUpdated, input.Name, input.EnvironmentName, input.TeamSlug, PostgresUpdatedActivityLogEntryData{UpdatedFields: changes}); err != nil { + return nil, err + } + + ret, err := toPostgres(updated, input.EnvironmentName) + if err != nil { + return nil, err + } + return &UpdatePostgresPayload{Postgres: ret}, nil +} + +// applyResources sets the quantities that were provided. Omitted values are left as they are, +// so the CRD defaults apply on create and existing values are kept on update. +func applyResources(r *nais_io_v1.PostgresResources, cpu, memory, diskSize *string) error { + set := func(field string, value *string, target *resource.Quantity) error { + if value == nil { + return nil + } + q, err := resource.ParseQuantity(*value) + if err != nil { + return fmt.Errorf("parsing %s: %w", field, err) + } + *target = q + return nil + } + if err := set("cpu", cpu, &r.Cpu); err != nil { + return err + } + if err := set("memory", memory, &r.Memory); err != nil { + return err + } + return set("diskSize", diskSize, &r.DiskSize) +} + +// toUnstructuredWithoutZeroResources converts pg and drops resource quantities that were never +// set. resource.Quantity is a struct, so omitempty does not skip it and it would otherwise be +// sent as "0", which stops the CRD from applying its defaults. +func toUnstructuredWithoutZeroResources(pg *nais_io_v1.Postgres) (*unstructured.Unstructured, error) { + obj, err := kubernetes.ToUnstructured(pg) + if err != nil { + return nil, err + } + for field, q := range map[string]resource.Quantity{ + "cpu": pg.Spec.Resources.Cpu, "memory": pg.Spec.Resources.Memory, "diskSize": pg.Spec.Resources.DiskSize, + } { + if q.IsZero() { + unstructured.RemoveNestedField(obj.Object, "spec", "resources", field) + } + } + if m, found, _ := unstructured.NestedMap(obj.Object, "spec", "resources"); found && len(m) == 0 { + unstructured.RemoveNestedField(obj.Object, "spec", "resources") + } + return obj, nil +} + +func logPostgresChange(ctx context.Context, action activitylog.ActivityLogEntryAction, name, environmentName string, teamSlug slug.Slug, data any) error { + return activitylog.Create(ctx, activitylog.CreateInput{ + Action: action, + Actor: authz.ActorFromContext(ctx).User, + ResourceType: activityLogEntryResourceTypePostgres, + ResourceName: name, + EnvironmentName: new(environmentName), + TeamSlug: new(teamSlug), + Data: data, + }) +} + +func updateHighAvailability(pg *nais_io_v1.Postgres, input UpdatePostgresInput) ([]*PostgresUpdatedActivityLogEntryDataUpdatedField, error) { + if input.HighAvailability == nil || pg.Spec.HighAvailability == *input.HighAvailability { + return nil, nil + } + old := pg.Spec.HighAvailability + pg.Spec.HighAvailability = *input.HighAvailability + return []*PostgresUpdatedActivityLogEntryDataUpdatedField{{ + Field: "highAvailability", + OldValue: new(strconv.FormatBool(old)), + NewValue: new(strconv.FormatBool(pg.Spec.HighAvailability)), + }}, nil +} + +// updateResources applies the quantities that were provided. Omitted values are left as they +// are, and a value that is semantically unchanged (1024Mi vs 1Gi) is not reported as a change. +func updateResources(pg *nais_io_v1.Postgres, input UpdatePostgresInput) ([]*PostgresUpdatedActivityLogEntryDataUpdatedField, error) { + var changes []*PostgresUpdatedActivityLogEntryDataUpdatedField + for _, f := range []struct { + name string + value *string + target *resource.Quantity + }{ + {"cpu", input.CPU, &pg.Spec.Resources.Cpu}, + {"memory", input.Memory, &pg.Spec.Resources.Memory}, + {"diskSize", input.DiskSize, &pg.Spec.Resources.DiskSize}, + } { + if f.value == nil { + continue + } + q, err := resource.ParseQuantity(*f.value) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", f.name, err) + } + if f.target.Cmp(q) == 0 { + continue + } + change := &PostgresUpdatedActivityLogEntryDataUpdatedField{Field: f.name, NewValue: new(q.String())} + if !f.target.IsZero() { + change.OldValue = new(f.target.String()) + } + *f.target = q + changes = append(changes, change) + } + return changes, nil +} diff --git a/internal/persistence/postgres/queries_test.go b/internal/persistence/postgres/queries_test.go index fdce23f04..5df4e4751 100644 --- a/internal/persistence/postgres/queries_test.go +++ b/internal/persistence/postgres/queries_test.go @@ -231,3 +231,34 @@ func TestPostgresAccessNameIsStablePerUserAndBranch(t *testing.T) { t.Errorf("name %q is not a valid resource name: %v", name, errs) } } + +func TestOmittedResourcesAreNotSent(t *testing.T) { + cpu := "500m" + pg := &nais_io_v1.Postgres{} + if err := applyResources(&pg.Spec.Resources, &cpu, nil, nil); err != nil { + t.Fatal(err) + } + + obj, err := toUnstructuredWithoutZeroResources(pg) + if err != nil { + t.Fatal(err) + } + + resources, _, _ := unstructured.NestedMap(obj.Object, "spec", "resources") + if got := resources["cpu"]; got != "500m" { + t.Errorf("cpu = %v, want 500m", got) + } + for _, field := range []string{"memory", "diskSize"} { + if v, found := resources[field]; found { + t.Errorf("%s = %v was sent, but must be left out so the CRD default applies", field, v) + } + } + + empty, err := toUnstructuredWithoutZeroResources(&nais_io_v1.Postgres{}) + if err != nil { + t.Fatal(err) + } + if _, found, _ := unstructured.NestedMap(empty.Object, "spec", "resources"); found { + t.Error("spec.resources must be left out entirely when nothing is set") + } +} diff --git a/internal/persistence/postgres/write.go b/internal/persistence/postgres/write.go deleted file mode 100644 index 2a3640d4d..000000000 --- a/internal/persistence/postgres/write.go +++ /dev/null @@ -1,316 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - "strings" - - "github.com/nais/api/internal/activitylog" - "github.com/nais/api/internal/auth/authz" - "github.com/nais/api/internal/graph/apierror" - "github.com/nais/api/internal/kubernetes" - "github.com/nais/api/internal/kubernetes/watcher" - "github.com/nais/api/internal/slug" - "github.com/nais/api/internal/validate" - nais_io_v1 "github.com/nais/pgrator/pkg/api/v1" - k8serrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/util/validation" - "k8s.io/client-go/dynamic" -) - -// supportedMajorVersions mirrors the enum on the Postgres CRD's spec.majorVersion. -var supportedMajorVersions = []string{"16", "17", "18"} - -type CreatePostgresInput struct { - Name string `json:"name"` - EnvironmentName string `json:"environmentName"` - TeamSlug slug.Slug `json:"teamSlug"` - MajorVersion string `json:"majorVersion"` - HighAvailability *bool `json:"highAvailability,omitempty"` - CPU *string `json:"cpu,omitempty"` - Memory *string `json:"memory,omitempty"` - DiskSize *string `json:"diskSize,omitempty"` -} - -type CreatePostgresPayload struct { - Postgres *Postgres `json:"postgres"` -} - -type UpdatePostgresInput struct { - Name string `json:"name"` - EnvironmentName string `json:"environmentName"` - TeamSlug slug.Slug `json:"teamSlug"` - HighAvailability *bool `json:"highAvailability,omitempty"` - CPU *string `json:"cpu,omitempty"` - Memory *string `json:"memory,omitempty"` - DiskSize *string `json:"diskSize,omitempty"` -} - -type UpdatePostgresPayload struct { - Postgres *Postgres `json:"postgres"` -} - -func (i *CreatePostgresInput) Validate(ctx context.Context) error { - return i.ValidationErrors(ctx).NilIfEmpty() -} - -func (i *CreatePostgresInput) ValidationErrors(_ context.Context) *validate.ValidationErrors { - verr := validate.New() - i.Name = strings.TrimSpace(i.Name) - i.EnvironmentName = strings.TrimSpace(i.EnvironmentName) - i.MajorVersion = strings.TrimSpace(i.MajorVersion) - - if i.Name == "" { - verr.Add("name", "Name must not be empty.") - } else if errs := validation.IsDNS1123Subdomain(i.Name); len(errs) > 0 { - verr.Add("name", "Name must consist of lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen.") - } - if i.EnvironmentName == "" { - verr.Add("environmentName", "Environment name must not be empty.") - } - if i.TeamSlug == "" { - verr.Add("teamSlug", "Team slug must not be empty.") - } - if !contains(supportedMajorVersions, i.MajorVersion) { - verr.Add("majorVersion", "Major version must be one of: %s.", strings.Join(supportedMajorVersions, ", ")) - } - validateQuantities(verr, i.CPU, i.Memory, i.DiskSize) - return verr -} - -func (i *UpdatePostgresInput) Validate(ctx context.Context) error { - return i.ValidationErrors(ctx).NilIfEmpty() -} - -func (i *UpdatePostgresInput) ValidationErrors(_ context.Context) *validate.ValidationErrors { - verr := validate.New() - i.Name = strings.TrimSpace(i.Name) - i.EnvironmentName = strings.TrimSpace(i.EnvironmentName) - - if i.Name == "" { - verr.Add("name", "Name must not be empty.") - } else if errs := validation.IsDNS1123Subdomain(i.Name); len(errs) > 0 { - verr.Add("name", "Name must consist of lowercase letters, numbers, and hyphens only. It cannot start or end with a hyphen.") - } - if i.EnvironmentName == "" { - verr.Add("environmentName", "Environment name must not be empty.") - } - if i.TeamSlug == "" { - verr.Add("teamSlug", "Team slug must not be empty.") - } - validateQuantities(verr, i.CPU, i.Memory, i.DiskSize) - return verr -} - -func validateQuantities(verr *validate.ValidationErrors, cpu, memory, diskSize *string) { - for field, value := range map[string]*string{"cpu": cpu, "memory": memory, "diskSize": diskSize} { - if value == nil { - continue - } - quantity, err := resource.ParseQuantity(*value) - if err != nil { - verr.Add(field, "%q is not a valid quantity.", *value) - } else if quantity.Sign() <= 0 { - verr.Add(field, "%q must be greater than zero.", *value) - } - } -} - -func contains(values []string, value string) bool { - for _, v := range values { - if v == value { - return true - } - } - return false -} - -func postgresGVR() schema.GroupVersionResource { - return schema.GroupVersionResource{Group: "nais.io", Version: "v1", Resource: "postgres"} -} - -// postgresClient returns a namespaced client for Postgres that writes with the API's own -// identity. Users never write the CRD directly; authorization is decided by the resolver. -func postgresClient(ctx context.Context, environmentName string, teamSlug slug.Slug) (dynamic.ResourceInterface, error) { - client, err := fromContext(ctx).postgresBranchWatcher.SystemAuthenticatedClient(ctx, environmentName, watcher.WithImpersonatedClientGVR(postgresGVR())) - if err != nil { - return nil, err - } - return client.Namespace(teamSlug.String()), nil -} - -func Create(ctx context.Context, input CreatePostgresInput) (*CreatePostgresPayload, error) { - if err := input.Validate(ctx); err != nil { - return nil, err - } - - client, err := postgresClient(ctx, input.EnvironmentName, input.TeamSlug) - if err != nil { - return nil, err - } - - pg := &nais_io_v1.Postgres{ - TypeMeta: metav1.TypeMeta{Kind: "Postgres", APIVersion: "nais.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: input.Name, - Namespace: input.TeamSlug.String(), - }, - Spec: nais_io_v1.PostgresSpec{MajorVersion: input.MajorVersion}, - } - pg.SetAnnotations(kubernetes.WithCommonAnnotations(nil, authz.ActorFromContext(ctx).User.Identity())) - kubernetes.SetManagedByConsoleLabel(pg) - if input.HighAvailability != nil { - pg.Spec.HighAvailability = *input.HighAvailability - } - if err := applyResources(&pg.Spec.Resources, input.CPU, input.Memory, input.DiskSize); err != nil { - return nil, err - } - - obj, err := toUnstructuredWithoutZeroResources(pg) - if err != nil { - return nil, err - } - - created, err := client.Create(ctx, obj, metav1.CreateOptions{}) - if err != nil { - if k8serrors.IsAlreadyExists(err) { - return nil, apierror.ErrAlreadyExists - } - return nil, err - } - - if err := logPostgresChange(ctx, activitylog.ActivityLogEntryActionCreated, input.Name, input.EnvironmentName, input.TeamSlug); err != nil { - return nil, err - } - - ret, err := toPostgres(created, input.EnvironmentName) - if err != nil { - return nil, err - } - return &CreatePostgresPayload{Postgres: ret}, nil -} - -func Update(ctx context.Context, input UpdatePostgresInput) (*UpdatePostgresPayload, error) { - if err := input.Validate(ctx); err != nil { - return nil, err - } - - client, err := postgresClient(ctx, input.EnvironmentName, input.TeamSlug) - if err != nil { - return nil, err - } - - existing, err := client.Get(ctx, input.Name, metav1.GetOptions{}) - if k8serrors.IsNotFound(err) { - return nil, apierror.Errorf("Postgres %q not found.", input.Name) - } - if err != nil { - return nil, err - } - - pg, err := kubernetes.ToConcrete[nais_io_v1.Postgres](existing) - if err != nil { - return nil, err - } - - changed := false - if input.HighAvailability != nil && pg.Spec.HighAvailability != *input.HighAvailability { - pg.Spec.HighAvailability = *input.HighAvailability - changed = true - } - before := pg.Spec.Resources - if err := applyResources(&pg.Spec.Resources, input.CPU, input.Memory, input.DiskSize); err != nil { - return nil, err - } - if before.Cpu.Cmp(pg.Spec.Resources.Cpu) != 0 || before.Memory.Cmp(pg.Spec.Resources.Memory) != 0 || before.DiskSize.Cmp(pg.Spec.Resources.DiskSize) != 0 { - changed = true - } - - if !changed { - ret, err := toPostgres(existing, input.EnvironmentName) - if err != nil { - return nil, err - } - return &UpdatePostgresPayload{Postgres: ret}, nil - } - - obj, err := toUnstructuredWithoutZeroResources(pg) - if err != nil { - return nil, err - } - obj.SetAnnotations(kubernetes.WithCommonAnnotations(obj.GetAnnotations(), authz.ActorFromContext(ctx).User.Identity())) - - updated, err := client.Update(ctx, obj, metav1.UpdateOptions{}) - if err != nil { - return nil, err - } - - if err := logPostgresChange(ctx, activitylog.ActivityLogEntryActionUpdated, input.Name, input.EnvironmentName, input.TeamSlug); err != nil { - return nil, err - } - - ret, err := toPostgres(updated, input.EnvironmentName) - if err != nil { - return nil, err - } - return &UpdatePostgresPayload{Postgres: ret}, nil -} - -// applyResources sets the quantities that were provided. Omitted values are left as they are, -// so the CRD defaults apply on create and existing values are kept on update. -func applyResources(r *nais_io_v1.PostgresResources, cpu, memory, diskSize *string) error { - set := func(field string, value *string, target *resource.Quantity) error { - if value == nil { - return nil - } - q, err := resource.ParseQuantity(*value) - if err != nil { - return fmt.Errorf("parsing %s: %w", field, err) - } - *target = q - return nil - } - if err := set("cpu", cpu, &r.Cpu); err != nil { - return err - } - if err := set("memory", memory, &r.Memory); err != nil { - return err - } - return set("diskSize", diskSize, &r.DiskSize) -} - -// toUnstructuredWithoutZeroResources converts pg and drops resource quantities that were never -// set. resource.Quantity is a struct, so omitempty does not skip it and it would otherwise be -// sent as "0", which stops the CRD from applying its defaults. -func toUnstructuredWithoutZeroResources(pg *nais_io_v1.Postgres) (*unstructured.Unstructured, error) { - obj, err := kubernetes.ToUnstructured(pg) - if err != nil { - return nil, err - } - for field, q := range map[string]resource.Quantity{ - "cpu": pg.Spec.Resources.Cpu, "memory": pg.Spec.Resources.Memory, "diskSize": pg.Spec.Resources.DiskSize, - } { - if q.IsZero() { - unstructured.RemoveNestedField(obj.Object, "spec", "resources", field) - } - } - if m, found, _ := unstructured.NestedMap(obj.Object, "spec", "resources"); found && len(m) == 0 { - unstructured.RemoveNestedField(obj.Object, "spec", "resources") - } - return obj, nil -} - -func logPostgresChange(ctx context.Context, action activitylog.ActivityLogEntryAction, name, environmentName string, teamSlug slug.Slug) error { - return activitylog.Create(ctx, activitylog.CreateInput{ - Action: action, - Actor: authz.ActorFromContext(ctx).User, - ResourceType: activityLogEntryResourceTypePostgres, - ResourceName: name, - EnvironmentName: new(environmentName), - TeamSlug: new(teamSlug), - }) -}