From 2c1415597229d95434de4fa7229f059eee9d5db0 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Tue, 29 Sep 2026 11:27:16 +0200 Subject: [PATCH 01/10] fix: dont use removed pg fields --- internal/naisapi/gql/generated.go | 59 ++++++++---------------- internal/postgres/list.go | 16 +++---- schema.graphql | 75 +++++++------------------------ 3 files changed, 41 insertions(+), 109 deletions(-) diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index 68b407d8..49e904a9 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -29906,12 +29906,7 @@ func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnection type GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance struct { Name string `json:"name"` TeamEnvironment GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironment `json:"teamEnvironment"` - // Major version of PostgreSQL. - MajorVersion string `json:"majorVersion"` - // Indicates whether the Postgres cluster is configured for high availability. - HighAvailability bool `json:"highAvailability"` - // Audit logging configuration for the Postgres cluster. - Audit GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceAudit `json:"audit"` + Postgres GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres `json:"postgres"` // Current state of the Postgres cluster. State PostgresInstanceState `json:"state"` } @@ -29926,19 +29921,9 @@ func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnection return v.TeamEnvironment } -// GetMajorVersion returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance.MajorVersion, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance) GetMajorVersion() string { - return v.MajorVersion -} - -// GetHighAvailability returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance.HighAvailability, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance) GetHighAvailability() bool { - return v.HighAvailability -} - -// GetAudit returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance.Audit, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance) GetAudit() GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceAudit { - return v.Audit +// GetPostgres returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance.Postgres, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance) GetPostgres() GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres { + return v.Postgres } // GetState returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance.State, and is useful for accessing the field via an interface. @@ -29946,15 +29931,20 @@ func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnection return v.State } -// GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceAudit includes the requested fields of the GraphQL type PostgresInstanceAudit. -type GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceAudit struct { - // Indicates whether audit logging is enabled for the Postgres cluster. - Enabled bool `json:"enabled"` +// GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres includes the requested fields of the GraphQL type Postgres. +type GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres struct { + MajorVersion string `json:"majorVersion"` + HighAvailability bool `json:"highAvailability"` } -// GetEnabled returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceAudit.Enabled, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceAudit) GetEnabled() bool { - return v.Enabled +// GetMajorVersion returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres.MajorVersion, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres) GetMajorVersion() string { + return v.MajorVersion +} + +// GetHighAvailability returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres.HighAvailability, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres) GetHighAvailability() bool { + return v.HighAvailability } // GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. @@ -31932,10 +31922,6 @@ type PostgresInstanceFilter struct { // Input for filtering Postgres instances. States []PostgresInstanceState `json:"states"` // Input for filtering Postgres instances. - HighAvailability *bool `json:"highAvailability"` - // Input for filtering Postgres instances. - MajorVersions []string `json:"majorVersions"` - // Input for filtering Postgres instances. Labels []LabelFilter `json:"labels"` } @@ -31948,12 +31934,6 @@ func (v *PostgresInstanceFilter) GetEnvironments() []string { return v.Environme // GetStates returns PostgresInstanceFilter.States, and is useful for accessing the field via an interface. func (v *PostgresInstanceFilter) GetStates() []PostgresInstanceState { return v.States } -// GetHighAvailability returns PostgresInstanceFilter.HighAvailability, and is useful for accessing the field via an interface. -func (v *PostgresInstanceFilter) GetHighAvailability() *bool { return v.HighAvailability } - -// GetMajorVersions returns PostgresInstanceFilter.MajorVersions, and is useful for accessing the field via an interface. -func (v *PostgresInstanceFilter) GetMajorVersions() []string { return v.MajorVersions } - // GetLabels returns PostgresInstanceFilter.Labels, and is useful for accessing the field via an interface. func (v *PostgresInstanceFilter) GetLabels() []LabelFilter { return v.Labels } @@ -37720,10 +37700,9 @@ query GetTeamPostgresInstances ($team: Slug!, $postgresFilter: PostgresInstanceF name } } - majorVersion - highAvailability - audit { - enabled + postgres { + majorVersion + highAvailability } state } diff --git a/internal/postgres/list.go b/internal/postgres/list.go index 1322e7f5..46ddef64 100644 --- a/internal/postgres/list.go +++ b/internal/postgres/list.go @@ -20,7 +20,7 @@ type Instance struct { Environment string `json:"environment"` Version string `heading:"Version" json:"version"` HighAvailability bool `heading:"HA" json:"high_availability"` - Audit bool `json:"audit"` + Audit *bool `json:"audit,omitempty"` State State `json:"state"` } @@ -70,10 +70,9 @@ func GetTeamPostgresInstances(ctx context.Context, team string, environments []s name } } - majorVersion - highAvailability - audit { - enabled + postgres { + majorVersion + highAvailability } state } @@ -132,9 +131,8 @@ func GetTeamPostgresInstances(ctx context.Context, team string, environments []s }, Type: "PostgreSQL", Environment: env, - Version: p.MajorVersion, - HighAvailability: p.HighAvailability, - Audit: p.Audit.Enabled, + Version: p.Postgres.MajorVersion, + HighAvailability: p.Postgres.HighAvailability, State: State(p.State), }) } @@ -154,7 +152,7 @@ func GetTeamPostgresInstances(ctx context.Context, team string, environments []s Environment: env, Version: ptr.Deref(s.Version, ""), HighAvailability: s.HighAvailability, - Audit: s.AuditLog != nil, + Audit: ptr.To(s.AuditLog != nil), State: State(s.State), }) } diff --git a/schema.graphql b/schema.graphql index 017f979f..bf86a877 100644 --- a/schema.graphql +++ b/schema.graphql @@ -7016,6 +7016,7 @@ type PostgresInstance implements Persistence & Node{ name: String! team: Team! teamEnvironment: TeamEnvironment! + postgres: Postgres! """ Workloads that reference the Postgres instance. """ @@ -7038,48 +7039,29 @@ Get items before this cursor. before: Cursor ): WorkloadConnection! """ -Resource allocation for the Postgres cluster. -""" - resources: PostgresInstanceResources! -""" -Major version of PostgreSQL. -""" - majorVersion: String! -""" -Audit logging configuration for the Postgres cluster. -""" - audit: PostgresInstanceAudit! -""" -Indicates whether the Postgres cluster is configured for high availability. -""" - highAvailability: Boolean! -""" Current state of the Postgres cluster. """ state: PostgresInstanceState! """ -Maintenance window for the Postgres cluster, if configured. -""" - maintenanceWindow: PostgresInstanceMaintenanceWindow -""" User-defined labels attached to this instance. """ labels: [ResourceLabel!]! } -type PostgresInstanceAudit { -""" -Indicates whether audit logging is enabled for the Postgres cluster. -""" - enabled: Boolean! -""" -URL for accessing the audit logs. -""" - url: String -""" -List of statement classes that are being logged, such as `ddl`, `dml`, and `read`. -""" - statementClasses: [String!] +type Postgres implements Node { + id: ID! + name: String! + majorVersion: String! + highAvailability: Boolean! + resources: PostgresResources! + activeInstance: String + labels: [ResourceLabel!]! +} + +type PostgresResources { + cpu: String + memory: String + diskSize: String } type PostgresInstanceConnection { @@ -7111,14 +7093,6 @@ Distribution of instances by state. """ states: [PostgresInstanceStateFacetItem!]! """ -Distribution of instances by high availability. -""" - highAvailability: [BooleanFacetItem!]! -""" -Distribution of instances by major version. -""" - majorVersions: [StringFacetItem!]! -""" Distribution of instances by user-defined labels. """ labels: [LabelFacetItem!]! @@ -7142,23 +7116,10 @@ Input for filtering Postgres instances. states: [PostgresInstanceState!] """ Input for filtering Postgres instances. -""" - highAvailability: Boolean -""" -Input for filtering Postgres instances. -""" - majorVersions: [String!] -""" -Input for filtering Postgres instances. """ labels: [LabelFilter!] } -type PostgresInstanceMaintenanceWindow { - day: Int! - hour: Int! -} - input PostgresInstanceOrder { field: PostgresInstanceOrderField! direction: OrderDirection! @@ -7169,12 +7130,6 @@ enum PostgresInstanceOrderField { ENVIRONMENT } -type PostgresInstanceResources { - cpu: String! - memory: String! - diskSize: String! -} - enum PostgresInstanceState { AVAILABLE PROGRESSING From 414b96ab27736bfd3a18aeabedb25cebd92630c0 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Tue, 29 Sep 2026 15:40:36 +0200 Subject: [PATCH 02/10] fix: instances -> branches --- internal/naisapi/gql/generated.go | 252 ++++++++++++------------- internal/postgres/command/flag/flag.go | 2 +- internal/postgres/command/list.go | 8 +- internal/postgres/list.go | 20 +- schema.graphql | 92 ++++----- 5 files changed, 187 insertions(+), 187 deletions(-) diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index 49e904a9..b7f2aa6e 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -29858,16 +29858,16 @@ func (v *GetTeamKafkaTopicsTeamKafkaTopicsKafkaTopicConnectionNodesKafkaTopicTea return v.Name } -// GetTeamPostgresInstancesResponse is returned by GetTeamPostgresInstances on success. -type GetTeamPostgresInstancesResponse struct { +// GetTeamPostgresBranchesResponse is returned by GetTeamPostgresBranches on success. +type GetTeamPostgresBranchesResponse struct { // Get a team by its slug. - Team GetTeamPostgresInstancesTeam `json:"team"` + Team GetTeamPostgresBranchesTeam `json:"team"` } -// GetTeam returns GetTeamPostgresInstancesResponse.Team, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesResponse) GetTeam() GetTeamPostgresInstancesTeam { return v.Team } +// GetTeam returns GetTeamPostgresBranchesResponse.Team, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesResponse) GetTeam() GetTeamPostgresBranchesTeam { return v.Team } -// GetTeamPostgresInstancesTeam includes the requested fields of the GraphQL type Team. +// GetTeamPostgresBranchesTeam includes the requested fields of the GraphQL type Team. // The GraphQL type's documentation follows. // // The team type represents a team on the [Nais platform](https://nais.io/). @@ -29875,191 +29875,191 @@ func (v *GetTeamPostgresInstancesResponse) GetTeam() GetTeamPostgresInstancesTea // Learn more about what Nais teams are and what they can be used for in the [official Nais documentation](https://docs.nais.io/explanations/team/). // // External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). -type GetTeamPostgresInstancesTeam struct { +type GetTeamPostgresBranchesTeam struct { // Postgres instances owned by the team. - PostgresInstances GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnection `json:"postgresInstances"` + PostgresBranches GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection `json:"postgresBranches"` // SQL instances owned by the team. - SqlInstances GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnection `json:"sqlInstances"` + SqlInstances GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnection `json:"sqlInstances"` } -// GetPostgresInstances returns GetTeamPostgresInstancesTeam.PostgresInstances, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeam) GetPostgresInstances() GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnection { - return v.PostgresInstances +// GetPostgresBranches returns GetTeamPostgresBranchesTeam.PostgresBranches, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeam) GetPostgresBranches() GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection { + return v.PostgresBranches } -// GetSqlInstances returns GetTeamPostgresInstancesTeam.SqlInstances, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeam) GetSqlInstances() GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnection { +// GetSqlInstances returns GetTeamPostgresBranchesTeam.SqlInstances, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeam) GetSqlInstances() GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnection { return v.SqlInstances } -// GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnection includes the requested fields of the GraphQL type PostgresInstanceConnection. -type GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnection struct { - Nodes []GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance `json:"nodes"` +// GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection includes the requested fields of the GraphQL type PostgresBranchConnection. +type GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection struct { + Nodes []GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch `json:"nodes"` } -// GetNodes returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnection.Nodes, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnection) GetNodes() []GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance { +// GetNodes returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection.Nodes, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection) GetNodes() []GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch { return v.Nodes } -// GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance includes the requested fields of the GraphQL type PostgresInstance. -type GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance struct { - Name string `json:"name"` - TeamEnvironment GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironment `json:"teamEnvironment"` - Postgres GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres `json:"postgres"` +// GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch includes the requested fields of the GraphQL type PostgresBranch. +type GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch struct { + Name string `json:"name"` + TeamEnvironment GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment `json:"teamEnvironment"` + Postgres GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres `json:"postgres"` // Current state of the Postgres cluster. - State PostgresInstanceState `json:"state"` + State PostgresBranchState `json:"state"` } -// GetName returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance.Name, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance) GetName() string { +// GetName returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch.Name, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch) GetName() string { return v.Name } -// GetTeamEnvironment returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance.TeamEnvironment, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance) GetTeamEnvironment() GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironment { +// GetTeamEnvironment returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch.TeamEnvironment, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch) GetTeamEnvironment() GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment { return v.TeamEnvironment } -// GetPostgres returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance.Postgres, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance) GetPostgres() GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres { +// GetPostgres returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch.Postgres, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch) GetPostgres() GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres { return v.Postgres } -// GetState returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance.State, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstance) GetState() PostgresInstanceState { +// GetState returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch.State, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch) GetState() PostgresBranchState { return v.State } -// GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres includes the requested fields of the GraphQL type Postgres. -type GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres struct { +// GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres includes the requested fields of the GraphQL type Postgres. +type GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres struct { MajorVersion string `json:"majorVersion"` HighAvailability bool `json:"highAvailability"` } -// GetMajorVersion returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres.MajorVersion, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres) GetMajorVersion() string { +// GetMajorVersion returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres.MajorVersion, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres) GetMajorVersion() string { return v.MajorVersion } -// GetHighAvailability returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres.HighAvailability, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstancePostgres) GetHighAvailability() bool { +// GetHighAvailability returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres.HighAvailability, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres) GetHighAvailability() bool { return v.HighAvailability } -// GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. -type GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironment struct { +// GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. +type GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment struct { // Get the environment. - Environment GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironmentEnvironment `json:"environment"` + Environment GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment `json:"environment"` } -// GetEnvironment returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironment.Environment, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironment) GetEnvironment() GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironmentEnvironment { +// GetEnvironment returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment.Environment, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment) GetEnvironment() GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment { return v.Environment } -// GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironmentEnvironment includes the requested fields of the GraphQL type Environment. +// GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment includes the requested fields of the GraphQL type Environment. // The GraphQL type's documentation follows. // // An environment represents a runtime environment for workloads. // // Learn more in the [official Nais documentation](https://docs.nais.io/workloads/explanations/environment/). -type GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironmentEnvironment struct { +type GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment struct { // Unique name of the environment. Name string `json:"name"` } -// GetName returns GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironmentEnvironment.Name, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamPostgresInstancesPostgresInstanceConnectionNodesPostgresInstanceTeamEnvironmentEnvironment) GetName() string { +// GetName returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment.Name, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment) GetName() string { return v.Name } -// GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnection includes the requested fields of the GraphQL type SqlInstanceConnection. -type GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnection struct { - Nodes []GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance `json:"nodes"` +// GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnection includes the requested fields of the GraphQL type SqlInstanceConnection. +type GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnection struct { + Nodes []GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance `json:"nodes"` } -// GetNodes returns GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnection.Nodes, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnection) GetNodes() []GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance { +// GetNodes returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnection.Nodes, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnection) GetNodes() []GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance { return v.Nodes } -// GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance includes the requested fields of the GraphQL type SqlInstance. -type GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance struct { - Name string `json:"name"` - TeamEnvironment GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment `json:"teamEnvironment"` - Version *string `json:"version"` - HighAvailability bool `json:"highAvailability"` +// GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance includes the requested fields of the GraphQL type SqlInstance. +type GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance struct { + Name string `json:"name"` + TeamEnvironment GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment `json:"teamEnvironment"` + Version *string `json:"version"` + HighAvailability bool `json:"highAvailability"` // Indicates whether audit logging is enabled for this SQL instance and provides a link to the logs if set. - AuditLog *GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog `json:"auditLog"` - State SqlInstanceState `json:"state"` + AuditLog *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog `json:"auditLog"` + State SqlInstanceState `json:"state"` } -// GetName returns GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.Name, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetName() string { +// GetName returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.Name, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetName() string { return v.Name } -// GetTeamEnvironment returns GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.TeamEnvironment, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetTeamEnvironment() GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment { +// GetTeamEnvironment returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.TeamEnvironment, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetTeamEnvironment() GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment { return v.TeamEnvironment } -// GetVersion returns GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.Version, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetVersion() *string { +// GetVersion returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.Version, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetVersion() *string { return v.Version } -// GetHighAvailability returns GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.HighAvailability, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetHighAvailability() bool { +// GetHighAvailability returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.HighAvailability, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetHighAvailability() bool { return v.HighAvailability } -// GetAuditLog returns GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.AuditLog, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetAuditLog() *GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog { +// GetAuditLog returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.AuditLog, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetAuditLog() *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog { return v.AuditLog } -// GetState returns GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.State, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetState() SqlInstanceState { +// GetState returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.State, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetState() SqlInstanceState { return v.State } -// GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog includes the requested fields of the GraphQL type AuditLog. -type GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog struct { +// GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog includes the requested fields of the GraphQL type AuditLog. +type GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog struct { // Link to the audit log for this SQL instance. LogUrl string `json:"logUrl"` } -// GetLogUrl returns GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog.LogUrl, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog) GetLogUrl() string { +// GetLogUrl returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog.LogUrl, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog) GetLogUrl() string { return v.LogUrl } -// GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. -type GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment struct { +// GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. +type GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment struct { // Get the environment. - Environment GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment `json:"environment"` + Environment GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment `json:"environment"` } -// GetEnvironment returns GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment.Environment, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment) GetEnvironment() GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment { +// GetEnvironment returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment.Environment, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment) GetEnvironment() GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment { return v.Environment } -// GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment includes the requested fields of the GraphQL type Environment. +// GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment includes the requested fields of the GraphQL type Environment. // The GraphQL type's documentation follows. // // An environment represents a runtime environment for workloads. // // Learn more in the [official Nais documentation](https://docs.nais.io/workloads/explanations/environment/). -type GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment struct { +type GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment struct { // Unique name of the environment. Name string `json:"name"` } -// GetName returns GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment.Name, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment) GetName() string { +// GetName returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment.Name, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment) GetName() string { return v.Name } @@ -31914,41 +31914,41 @@ var AllOrderDirection = []OrderDirection{ } // Input for filtering Postgres instances. -type PostgresInstanceFilter struct { +type PostgresBranchFilter struct { // Input for filtering Postgres instances. Name *string `json:"name"` // Input for filtering Postgres instances. Environments []string `json:"environments"` // Input for filtering Postgres instances. - States []PostgresInstanceState `json:"states"` + States []PostgresBranchState `json:"states"` // Input for filtering Postgres instances. Labels []LabelFilter `json:"labels"` } -// GetName returns PostgresInstanceFilter.Name, and is useful for accessing the field via an interface. -func (v *PostgresInstanceFilter) GetName() *string { return v.Name } +// GetName returns PostgresBranchFilter.Name, and is useful for accessing the field via an interface. +func (v *PostgresBranchFilter) GetName() *string { return v.Name } -// GetEnvironments returns PostgresInstanceFilter.Environments, and is useful for accessing the field via an interface. -func (v *PostgresInstanceFilter) GetEnvironments() []string { return v.Environments } +// GetEnvironments returns PostgresBranchFilter.Environments, and is useful for accessing the field via an interface. +func (v *PostgresBranchFilter) GetEnvironments() []string { return v.Environments } -// GetStates returns PostgresInstanceFilter.States, and is useful for accessing the field via an interface. -func (v *PostgresInstanceFilter) GetStates() []PostgresInstanceState { return v.States } +// GetStates returns PostgresBranchFilter.States, and is useful for accessing the field via an interface. +func (v *PostgresBranchFilter) GetStates() []PostgresBranchState { return v.States } -// GetLabels returns PostgresInstanceFilter.Labels, and is useful for accessing the field via an interface. -func (v *PostgresInstanceFilter) GetLabels() []LabelFilter { return v.Labels } +// GetLabels returns PostgresBranchFilter.Labels, and is useful for accessing the field via an interface. +func (v *PostgresBranchFilter) GetLabels() []LabelFilter { return v.Labels } -type PostgresInstanceState string +type PostgresBranchState string const ( - PostgresInstanceStateAvailable PostgresInstanceState = "AVAILABLE" - PostgresInstanceStateProgressing PostgresInstanceState = "PROGRESSING" - PostgresInstanceStateDegraded PostgresInstanceState = "DEGRADED" + PostgresBranchStateAvailable PostgresBranchState = "AVAILABLE" + PostgresBranchStateProgressing PostgresBranchState = "PROGRESSING" + PostgresBranchStateDegraded PostgresBranchState = "DEGRADED" ) -var AllPostgresInstanceState = []PostgresInstanceState{ - PostgresInstanceStateAvailable, - PostgresInstanceStateProgressing, - PostgresInstanceStateDegraded, +var AllPostgresBranchState = []PostgresBranchState{ + PostgresBranchStateAvailable, + PostgresBranchStateProgressing, + PostgresBranchStateDegraded, } // RemoveConfigValueRemoveConfigValueRemoveConfigValuePayload includes the requested fields of the GraphQL type RemoveConfigValuePayload. @@ -34601,23 +34601,23 @@ func (v *__GetTeamKafkaTopicsInput) GetTeam() string { return v.Team } // GetFilter returns __GetTeamKafkaTopicsInput.Filter, and is useful for accessing the field via an interface. func (v *__GetTeamKafkaTopicsInput) GetFilter() *KafkaTopicFilter { return v.Filter } -// __GetTeamPostgresInstancesInput is used internally by genqlient -type __GetTeamPostgresInstancesInput struct { - Team string `json:"team"` - PostgresFilter *PostgresInstanceFilter `json:"postgresFilter"` - SqlFilter *SqlInstanceFilter `json:"sqlFilter"` +// __GetTeamPostgresBranchesInput is used internally by genqlient +type __GetTeamPostgresBranchesInput struct { + Team string `json:"team"` + PostgresFilter *PostgresBranchFilter `json:"postgresFilter"` + SqlFilter *SqlInstanceFilter `json:"sqlFilter"` } -// GetTeam returns __GetTeamPostgresInstancesInput.Team, and is useful for accessing the field via an interface. -func (v *__GetTeamPostgresInstancesInput) GetTeam() string { return v.Team } +// GetTeam returns __GetTeamPostgresBranchesInput.Team, and is useful for accessing the field via an interface. +func (v *__GetTeamPostgresBranchesInput) GetTeam() string { return v.Team } -// GetPostgresFilter returns __GetTeamPostgresInstancesInput.PostgresFilter, and is useful for accessing the field via an interface. -func (v *__GetTeamPostgresInstancesInput) GetPostgresFilter() *PostgresInstanceFilter { +// GetPostgresFilter returns __GetTeamPostgresBranchesInput.PostgresFilter, and is useful for accessing the field via an interface. +func (v *__GetTeamPostgresBranchesInput) GetPostgresFilter() *PostgresBranchFilter { return v.PostgresFilter } -// GetSqlFilter returns __GetTeamPostgresInstancesInput.SqlFilter, and is useful for accessing the field via an interface. -func (v *__GetTeamPostgresInstancesInput) GetSqlFilter() *SqlInstanceFilter { return v.SqlFilter } +// GetSqlFilter returns __GetTeamPostgresBranchesInput.SqlFilter, and is useful for accessing the field via an interface. +func (v *__GetTeamPostgresBranchesInput) GetSqlFilter() *SqlInstanceFilter { return v.SqlFilter } // __GetTeamVulnerabilitySummaryInput is used internally by genqlient type __GetTeamVulnerabilitySummaryInput struct { @@ -37688,11 +37688,11 @@ func GetTeamKafkaTopics( return data_, err_ } -// The query executed by GetTeamPostgresInstances. -const GetTeamPostgresInstances_Operation = ` -query GetTeamPostgresInstances ($team: Slug!, $postgresFilter: PostgresInstanceFilter, $sqlFilter: SqlInstanceFilter) { +// The query executed by GetTeamPostgresBranches. +const GetTeamPostgresBranches_Operation = ` +query GetTeamPostgresBranches ($team: Slug!, $postgresFilter: PostgresBranchFilter, $sqlFilter: SqlInstanceFilter) { team(slug: $team) { - postgresInstances(first: 1000, filter: $postgresFilter) { + postgresBranches(first: 1000, filter: $postgresFilter) { nodes { name teamEnvironment { @@ -37727,24 +37727,24 @@ query GetTeamPostgresInstances ($team: Slug!, $postgresFilter: PostgresInstanceF } ` -func GetTeamPostgresInstances( +func GetTeamPostgresBranches( ctx_ context.Context, client_ graphql.Client, team string, - postgresFilter *PostgresInstanceFilter, + postgresFilter *PostgresBranchFilter, sqlFilter *SqlInstanceFilter, -) (data_ *GetTeamPostgresInstancesResponse, err_ error) { +) (data_ *GetTeamPostgresBranchesResponse, err_ error) { req_ := &graphql.Request{ - OpName: "GetTeamPostgresInstances", - Query: GetTeamPostgresInstances_Operation, - Variables: &__GetTeamPostgresInstancesInput{ + OpName: "GetTeamPostgresBranches", + Query: GetTeamPostgresBranches_Operation, + Variables: &__GetTeamPostgresBranchesInput{ Team: team, PostgresFilter: postgresFilter, SqlFilter: sqlFilter, }, } - data_ = &GetTeamPostgresInstancesResponse{} + data_ = &GetTeamPostgresBranchesResponse{} resp_ := &graphql.Response{Data: data_} err_ = client_.MakeRequest( diff --git a/internal/postgres/command/flag/flag.go b/internal/postgres/command/flag/flag.go index 6c93e20c..7999331e 100644 --- a/internal/postgres/command/flag/flag.go +++ b/internal/postgres/command/flag/flag.go @@ -104,7 +104,7 @@ type List struct { Labels labels.LabelFilters `name:"label" short:"l" usage:"Filter by label in |KEY=VALUE| form. Can be repeated."` } -func (*List) LabelFacetResource() string { return "postgresInstances" } +func (*List) LabelFacetResource() string { return "postgresBranches" } type Output string diff --git a/internal/postgres/command/list.go b/internal/postgres/command/list.go index 9f7857db..e0fa971a 100644 --- a/internal/postgres/command/list.go +++ b/internal/postgres/command/list.go @@ -15,8 +15,8 @@ func listCommand(parentFlags *flag.Postgres) *naistrix.Command { return &naistrix.Command{ Name: "list", - Title: "List postgres instances for a team.", - Description: "List all Google Cloud SQL Postgres instances owned by a team, showing instance details.", + Title: "List Postgres branches and Cloud SQL instances for a team.", + Description: "List NAIS Postgres branches and Google Cloud SQL Postgres instances owned by a team.", Flags: flags, RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { labelFilters, err := labels.ParseFilters(flags.Labels) @@ -29,7 +29,7 @@ func listCommand(parentFlags *flag.Postgres) *naistrix.Command { environments = []string{string(flags.Environment)} } - ret, err := postgres.GetTeamPostgresInstances(ctx, flags.Team, environments, labelFilters) + ret, err := postgres.GetTeamPostgresBranches(ctx, flags.Team, environments, labelFilters) if err != nil { return err } @@ -39,7 +39,7 @@ func listCommand(parentFlags *flag.Postgres) *naistrix.Command { } if len(ret) == 0 { - out.Println("Team has no postgres instances.") + out.Println("Team has no Postgres branches or Cloud SQL instances.") return nil } diff --git a/internal/postgres/list.go b/internal/postgres/list.go index 46ddef64..3700df4f 100644 --- a/internal/postgres/list.go +++ b/internal/postgres/list.go @@ -27,13 +27,13 @@ type Instance struct { type State string func (s State) String() string { - // PostgresInstance states + // PostgresBranch states switch s { - case State(gql.PostgresInstanceStateAvailable): + case State(gql.PostgresBranchStateAvailable): return "Available" - case State(gql.PostgresInstanceStateProgressing): + case State(gql.PostgresBranchStateProgressing): return "Progressing" - case State(gql.PostgresInstanceStateDegraded): + case State(gql.PostgresBranchStateDegraded): return "Degraded" } @@ -58,11 +58,11 @@ func (s State) String() string { return "Unknown" } -func GetTeamPostgresInstances(ctx context.Context, team string, environments []string, labelFilters []gql.LabelFilter) ([]Instance, error) { +func GetTeamPostgresBranches(ctx context.Context, team string, environments []string, labelFilters []gql.LabelFilter) ([]Instance, error) { _ = `# @genqlient - query GetTeamPostgresInstances($team: Slug!, $postgresFilter: PostgresInstanceFilter, $sqlFilter: SqlInstanceFilter) { + query GetTeamPostgresBranches($team: Slug!, $postgresFilter: PostgresBranchFilter, $sqlFilter: SqlInstanceFilter) { team(slug: $team) { - postgresInstances(first: 1000, filter: $postgresFilter) { + postgresBranches(first: 1000, filter: $postgresFilter) { nodes { name teamEnvironment { @@ -103,7 +103,7 @@ func GetTeamPostgresInstances(ctx context.Context, team string, environments []s return nil, err } - postgresFilter := gql.PostgresInstanceFilter{ + postgresFilter := gql.PostgresBranchFilter{ Environments: environments, Labels: labelFilters, } @@ -111,14 +111,14 @@ func GetTeamPostgresInstances(ctx context.Context, team string, environments []s Labels: labelFilters, } - resp, err := gql.GetTeamPostgresInstances(ctx, client, team, new(postgresFilter), new(sqlFilter)) + resp, err := gql.GetTeamPostgresBranches(ctx, client, team, new(postgresFilter), new(sqlFilter)) if err != nil { return nil, err } var ret []Instance - for _, p := range resp.Team.PostgresInstances.Nodes { + for _, p := range resp.Team.PostgresBranches.Nodes { env := p.TeamEnvironment.Environment.Name if len(environments) > 0 && !slices.Contains(environments, env) { continue diff --git a/schema.graphql b/schema.graphql index bf86a877..1138e12b 100644 --- a/schema.graphql +++ b/schema.graphql @@ -986,12 +986,12 @@ OpenSearch instance referenced by the workload. """ Postgres instances referenced by the application. This does not currently support pagination, but will return all available Postgres instances. """ - postgresInstances( + postgresBranches( """ Ordering options for items returned from the connection. """ - orderBy: PostgresInstanceOrder - ): PostgresInstanceConnection! + orderBy: PostgresBranchOrder + ): PostgresBranchConnection! """ Secrets used by the application. """ @@ -2907,17 +2907,17 @@ Whether or not the OpenSearch instance was deleted. openSearchDeleted: Boolean } -input DeletePostgresInput { +input DeletePostgresBranchInput { name: String! environmentName: String! teamSlug: Slug! } -type DeletePostgresPayload { +type DeletePostgresBranchPayload { """ -Whether or not the Postgres instance was deleted. +Whether or not the Postgres branch was deleted. """ - postgresDeleted: Boolean + postgresBranchDeleted: Boolean } input DeleteSecretInput { @@ -4623,12 +4623,12 @@ OpenSearch instance referenced by the workload. """ Postgres instances referenced by the job. This does not currently support pagination, but will return all available Postgres instances. """ - postgresInstances( + postgresBranches( """ Ordering options for items returned from the connection. """ - orderBy: PostgresInstanceOrder - ): PostgresInstanceConnection! + orderBy: PostgresBranchOrder + ): PostgresBranchConnection! """ Secrets used by the job. """ @@ -5925,11 +5925,11 @@ Grant temporary access to a Postgres cluster. input: GrantPostgresAccessInput! ): GrantPostgresAccessPayload! """ -Delete an existing Postgres instance. +Delete an inactive Postgres branch. """ - deletePostgres( - input: DeletePostgresInput! - ): DeletePostgresPayload! + deletePostgresBranch( + input: DeletePostgresBranchInput! + ): DeletePostgresBranchPayload! """ Enable a reconciler @@ -7011,7 +7011,7 @@ type PostgresGrantAccessActivityLogEntryData { until: Time! } -type PostgresInstance implements Persistence & Node{ +type PostgresBranch implements Persistence & Node{ id: ID! name: String! team: Team! @@ -7041,7 +7041,7 @@ Get items before this cursor. """ Current state of the Postgres cluster. """ - state: PostgresInstanceState! + state: PostgresBranchState! """ User-defined labels attached to this instance. """ @@ -7054,7 +7054,7 @@ type Postgres implements Node { majorVersion: String! highAvailability: Boolean! resources: PostgresResources! - activeInstance: String + activeBranch: String labels: [ResourceLabel!]! } @@ -7064,26 +7064,26 @@ type PostgresResources { diskSize: String } -type PostgresInstanceConnection { +type PostgresBranchConnection { pageInfo: PageInfo! - nodes: [PostgresInstance!]! - edges: [PostgresInstanceEdge!]! + nodes: [PostgresBranch!]! + edges: [PostgresBranchEdge!]! """ Facets for Postgres instances. Provides distribution counts to help narrow down results. Facet counts are computed over the full result set (ignoring pagination) but respect the current filter. """ - facets: PostgresInstanceFacets + facets: PostgresBranchFacets } -type PostgresInstanceEdge { +type PostgresBranchEdge { cursor: Cursor! - node: PostgresInstance! + node: PostgresBranch! } """ Facets for Postgres instances, providing distribution counts across different dimensions. """ -type PostgresInstanceFacets { +type PostgresBranchFacets { """ Distribution of instances by environment. """ @@ -7091,7 +7091,7 @@ Distribution of instances by environment. """ Distribution of instances by state. """ - states: [PostgresInstanceStateFacetItem!]! + states: [PostgresBranchStateFacetItem!]! """ Distribution of instances by user-defined labels. """ @@ -7101,7 +7101,7 @@ Distribution of instances by user-defined labels. """ Input for filtering Postgres instances. """ -input PostgresInstanceFilter { +input PostgresBranchFilter { """ Input for filtering Postgres instances. """ @@ -7113,24 +7113,24 @@ Input for filtering Postgres instances. """ Input for filtering Postgres instances. """ - states: [PostgresInstanceState!] + states: [PostgresBranchState!] """ Input for filtering Postgres instances. """ labels: [LabelFilter!] } -input PostgresInstanceOrder { - field: PostgresInstanceOrderField! +input PostgresBranchOrder { + field: PostgresBranchOrderField! direction: OrderDirection! } -enum PostgresInstanceOrderField { +enum PostgresBranchOrderField { NAME ENVIRONMENT } -enum PostgresInstanceState { +enum PostgresBranchState { AVAILABLE PROGRESSING DEGRADED @@ -7139,11 +7139,11 @@ enum PostgresInstanceState { """ A single facet item for Postgres instance states. """ -type PostgresInstanceStateFacetItem { +type PostgresBranchStateFacetItem { """ The Postgres instance state. """ - state: PostgresInstanceState! + state: PostgresBranchState! """ Number of matching instances. """ @@ -8469,7 +8469,7 @@ Search filter for filtering search results. """ Types that can be searched for. """ -union SearchNode =Team | Application | BigQueryDataset | Bucket | Job | KafkaTopic | OpenSearch | PostgresInstance | SqlInstance | Valkey +union SearchNode =Team | Application | BigQueryDataset | Bucket | Job | KafkaTopic | OpenSearch | PostgresBranch | SqlInstance | Valkey """ Search node connection. @@ -8520,7 +8520,7 @@ Search for applications. JOB KAFKA_TOPIC OPENSEARCH - POSTGRES + POSTGRES_BRANCH SQL_INSTANCE VALKEY } @@ -10569,7 +10569,7 @@ Filtering options for items returned from the connection. """ Postgres instances owned by the team. """ - postgresInstances( + postgresBranches( """ Get the first n items in the connection. This can be used in combination with the after parameter. """ @@ -10589,12 +10589,12 @@ Get items before this cursor. """ Ordering options for items returned from the connection. """ - orderBy: PostgresInstanceOrder + orderBy: PostgresBranchOrder """ Filtering options for items returned from the connection. """ - filter: PostgresInstanceFilter - ): PostgresInstanceConnection! + filter: PostgresBranchFilter + ): PostgresBranchConnection! repositories( """ Get the first n items in the connection. This can be used in combination with the after parameter. @@ -11213,9 +11213,9 @@ OpenSearch instance in the team environment. """ Postgres instance in the team environment. """ - postgresInstance( + postgresBranch( name: String! - ): PostgresInstance! + ): PostgresBranch! """ Get a secret by name. """ @@ -11473,7 +11473,7 @@ Total number of OpenSearch instances. total: Int! } -type TeamInventoryCountPostgresInstances { +type TeamInventoryCountPostgresBranches { """ Total number of Postgres instances. """ @@ -11518,7 +11518,7 @@ Config inventory count for a team. jobs: TeamInventoryCountJobs! kafkaTopics: TeamInventoryCountKafkaTopics! openSearches: TeamInventoryCountOpenSearches! - postgresInstances: TeamInventoryCountPostgresInstances! + postgresBranches: TeamInventoryCountPostgresBranches! """ Secret inventory count for a team. """ @@ -14090,9 +14090,9 @@ Interface for workloads. """ Interface for workloads. """ - postgresInstances( - orderBy: PostgresInstanceOrder - ): PostgresInstanceConnection! + postgresBranches( + orderBy: PostgresBranchOrder + ): PostgresBranchConnection! """ Interface for workloads. """ From 309c8997642c5774099a695204e9ce99146420b1 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 1 Oct 2026 07:44:23 +0200 Subject: [PATCH 03/10] feat(postgres): show local branch names in `postgres list` Match the API schema where PostgresBranch.name is local to its Postgres. List Nais branches as / and link to the Postgres resource. Update the schema snapshot, regenerate the client, and cover the Nais + Cloud SQL merge and environment filtering with a table test. --- internal/naisapi/gql/generated.go | 632 +++++++++++++++++++++++++++++- internal/postgres/list.go | 15 +- internal/postgres/list_test.go | 61 +++ schema.graphql | 300 ++++++++++---- 4 files changed, 922 insertions(+), 86 deletions(-) create mode 100644 internal/postgres/list_test.go diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index b7f2aa6e..3ab917bb 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -61,7 +61,11 @@ const ( ActivityLogActivityTypeOpensearchCredentialsCreated ActivityLogActivityType = "OPENSEARCH_CREDENTIALS_CREATED" // A user was granted access to a Postgres cluster ActivityLogActivityTypePostgresGrantAccess ActivityLogActivityType = "POSTGRES_GRANT_ACCESS" - // A Postgres instance was deleted + // A personal Postgres access was created through the API broker + ActivityLogActivityTypePostgresPersonalAccessCreated ActivityLogActivityType = "POSTGRES_PERSONAL_ACCESS_CREATED" + // Personal Postgres connection materials were retrieved + ActivityLogActivityTypePostgresPersonalAccessConnection ActivityLogActivityType = "POSTGRES_PERSONAL_ACCESS_CONNECTION" + // A Postgres branch was deleted ActivityLogActivityTypePostgresDeleted ActivityLogActivityType = "POSTGRES_DELETED" // Reconciler enabled activity log entry. ActivityLogActivityTypeReconcilerEnabled ActivityLogActivityType = "RECONCILER_ENABLED" @@ -171,6 +175,8 @@ var AllActivityLogActivityType = []ActivityLogActivityType{ ActivityLogActivityTypeOpensearchMaintenanceStarted, ActivityLogActivityTypeOpensearchCredentialsCreated, ActivityLogActivityTypePostgresGrantAccess, + ActivityLogActivityTypePostgresPersonalAccessCreated, + ActivityLogActivityTypePostgresPersonalAccessConnection, ActivityLogActivityTypePostgresDeleted, ActivityLogActivityTypeReconcilerEnabled, ActivityLogActivityTypeReconcilerDisabled, @@ -5383,6 +5389,8 @@ func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplica // GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesOpenSearchUpdatedActivityLogEntry // GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresDeletedActivityLogEntry // GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry +// GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry +// GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry // GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry // GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesReconcilerDisabledActivityLogEntry // GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesReconcilerEnabledActivityLogEntry @@ -5502,6 +5510,10 @@ func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplica } func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry) implementsGraphQLInterfaceGetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { } +func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) implementsGraphQLInterfaceGetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { +} +func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) implementsGraphQLInterfaceGetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { +} func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry) implementsGraphQLInterfaceGetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { } func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesReconcilerDisabledActivityLogEntry) implementsGraphQLInterfaceGetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { @@ -5673,6 +5685,12 @@ func __unmarshalGetApplicationActivityTeamApplicationsApplicationConnectionNodes case "PostgresGrantAccessActivityLogEntry": *v = new(GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry) return json.Unmarshal(b, *v) + case "PostgresPersonalAccessConnectionActivityLogEntry": + *v = new(GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) + return json.Unmarshal(b, *v) + case "PostgresPersonalAccessCreatedActivityLogEntry": + *v = new(GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) + return json.Unmarshal(b, *v) case "ReconcilerConfiguredActivityLogEntry": *v = new(GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry) return json.Unmarshal(b, *v) @@ -6004,6 +6022,22 @@ func __marshalGetApplicationActivityTeamApplicationsApplicationConnectionNodesAp *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry }{typename, v} return json.Marshal(result) + case *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry: + typename = "PostgresPersonalAccessConnectionActivityLogEntry" + + result := struct { + TypeName string `json:"__typename"` + *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry + }{typename, v} + return json.Marshal(result) + case *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry: + typename = "PostgresPersonalAccessCreatedActivityLogEntry" + + result := struct { + TypeName string `json:"__typename"` + *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry + }{typename, v} + return json.Marshal(result) case *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry: typename = "ReconcilerConfiguredActivityLogEntry" @@ -7270,6 +7304,88 @@ func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplica return v.EnvironmentName } +// GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry includes the requested fields of the GraphQL type PostgresPersonalAccessConnectionActivityLogEntry. +// The GraphQL type's documentation follows. +// +// An audit-log entry for retrieval of personal Postgres connection materials. +type GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry struct { + Typename *string `json:"__typename"` + // Interface for activity log entries. + Actor string `json:"actor"` + // Interface for activity log entries. + CreatedAt time.Time `json:"createdAt"` + // Interface for activity log entries. + Message string `json:"message"` + // Interface for activity log entries. + EnvironmentName *string `json:"environmentName"` +} + +// GetTypename returns GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Typename, and is useful for accessing the field via an interface. +func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetTypename() *string { + return v.Typename +} + +// GetActor returns GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Actor, and is useful for accessing the field via an interface. +func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetActor() string { + return v.Actor +} + +// GetCreatedAt returns GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetCreatedAt() time.Time { + return v.CreatedAt +} + +// GetMessage returns GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Message, and is useful for accessing the field via an interface. +func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetMessage() string { + return v.Message +} + +// GetEnvironmentName returns GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.EnvironmentName, and is useful for accessing the field via an interface. +func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetEnvironmentName() *string { + return v.EnvironmentName +} + +// GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry includes the requested fields of the GraphQL type PostgresPersonalAccessCreatedActivityLogEntry. +// The GraphQL type's documentation follows. +// +// An audit-log entry for personal Postgres access created through the API broker. +type GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry struct { + Typename *string `json:"__typename"` + // Interface for activity log entries. + Actor string `json:"actor"` + // Interface for activity log entries. + CreatedAt time.Time `json:"createdAt"` + // Interface for activity log entries. + Message string `json:"message"` + // Interface for activity log entries. + EnvironmentName *string `json:"environmentName"` +} + +// GetTypename returns GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Typename, and is useful for accessing the field via an interface. +func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetTypename() *string { + return v.Typename +} + +// GetActor returns GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Actor, and is useful for accessing the field via an interface. +func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetActor() string { + return v.Actor +} + +// GetCreatedAt returns GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetCreatedAt() time.Time { + return v.CreatedAt +} + +// GetMessage returns GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Message, and is useful for accessing the field via an interface. +func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetMessage() string { + return v.Message +} + +// GetEnvironmentName returns GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.EnvironmentName, and is useful for accessing the field via an interface. +func (v *GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetEnvironmentName() *string { + return v.EnvironmentName +} + // GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry includes the requested fields of the GraphQL type ReconcilerConfiguredActivityLogEntry. type GetApplicationActivityTeamApplicationsApplicationConnectionNodesApplicationActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry struct { Typename *string `json:"__typename"` @@ -10707,6 +10823,8 @@ func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActiv // GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesOpenSearchUpdatedActivityLogEntry // GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresDeletedActivityLogEntry // GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry +// GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry +// GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry // GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry // GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesReconcilerDisabledActivityLogEntry // GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesReconcilerEnabledActivityLogEntry @@ -10826,6 +10944,10 @@ func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActiv } func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry) implementsGraphQLInterfaceGetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { } +func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) implementsGraphQLInterfaceGetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { +} +func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) implementsGraphQLInterfaceGetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { +} func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry) implementsGraphQLInterfaceGetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { } func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesReconcilerDisabledActivityLogEntry) implementsGraphQLInterfaceGetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { @@ -10997,6 +11119,12 @@ func __unmarshalGetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityL case "PostgresGrantAccessActivityLogEntry": *v = new(GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry) return json.Unmarshal(b, *v) + case "PostgresPersonalAccessConnectionActivityLogEntry": + *v = new(GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) + return json.Unmarshal(b, *v) + case "PostgresPersonalAccessCreatedActivityLogEntry": + *v = new(GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) + return json.Unmarshal(b, *v) case "ReconcilerConfiguredActivityLogEntry": *v = new(GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry) return json.Unmarshal(b, *v) @@ -11328,6 +11456,22 @@ func __marshalGetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLog *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry }{typename, v} return json.Marshal(result) + case *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry: + typename = "PostgresPersonalAccessConnectionActivityLogEntry" + + result := struct { + TypeName string `json:"__typename"` + *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry + }{typename, v} + return json.Marshal(result) + case *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry: + typename = "PostgresPersonalAccessCreatedActivityLogEntry" + + result := struct { + TypeName string `json:"__typename"` + *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry + }{typename, v} + return json.Marshal(result) case *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry: typename = "ReconcilerConfiguredActivityLogEntry" @@ -12594,6 +12738,88 @@ func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActiv return v.EnvironmentName } +// GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry includes the requested fields of the GraphQL type PostgresPersonalAccessConnectionActivityLogEntry. +// The GraphQL type's documentation follows. +// +// An audit-log entry for retrieval of personal Postgres connection materials. +type GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry struct { + Typename *string `json:"__typename"` + // Interface for activity log entries. + Actor string `json:"actor"` + // Interface for activity log entries. + CreatedAt time.Time `json:"createdAt"` + // Interface for activity log entries. + Message string `json:"message"` + // Interface for activity log entries. + EnvironmentName *string `json:"environmentName"` +} + +// GetTypename returns GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Typename, and is useful for accessing the field via an interface. +func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetTypename() *string { + return v.Typename +} + +// GetActor returns GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Actor, and is useful for accessing the field via an interface. +func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetActor() string { + return v.Actor +} + +// GetCreatedAt returns GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetCreatedAt() time.Time { + return v.CreatedAt +} + +// GetMessage returns GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Message, and is useful for accessing the field via an interface. +func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetMessage() string { + return v.Message +} + +// GetEnvironmentName returns GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.EnvironmentName, and is useful for accessing the field via an interface. +func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetEnvironmentName() *string { + return v.EnvironmentName +} + +// GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry includes the requested fields of the GraphQL type PostgresPersonalAccessCreatedActivityLogEntry. +// The GraphQL type's documentation follows. +// +// An audit-log entry for personal Postgres access created through the API broker. +type GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry struct { + Typename *string `json:"__typename"` + // Interface for activity log entries. + Actor string `json:"actor"` + // Interface for activity log entries. + CreatedAt time.Time `json:"createdAt"` + // Interface for activity log entries. + Message string `json:"message"` + // Interface for activity log entries. + EnvironmentName *string `json:"environmentName"` +} + +// GetTypename returns GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Typename, and is useful for accessing the field via an interface. +func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetTypename() *string { + return v.Typename +} + +// GetActor returns GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Actor, and is useful for accessing the field via an interface. +func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetActor() string { + return v.Actor +} + +// GetCreatedAt returns GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetCreatedAt() time.Time { + return v.CreatedAt +} + +// GetMessage returns GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Message, and is useful for accessing the field via an interface. +func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetMessage() string { + return v.Message +} + +// GetEnvironmentName returns GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.EnvironmentName, and is useful for accessing the field via an interface. +func (v *GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetEnvironmentName() *string { + return v.EnvironmentName +} + // GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry includes the requested fields of the GraphQL type ReconcilerConfiguredActivityLogEntry. type GetConfigActivityTeamConfigsConfigConnectionNodesConfigActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry struct { Typename *string `json:"__typename"` @@ -14879,6 +15105,8 @@ func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryC // GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesOpenSearchUpdatedActivityLogEntry // GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresDeletedActivityLogEntry // GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry +// GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry +// GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry // GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry // GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesReconcilerDisabledActivityLogEntry // GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesReconcilerEnabledActivityLogEntry @@ -14998,6 +15226,10 @@ func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryC } func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry) implementsGraphQLInterfaceGetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { } +func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) implementsGraphQLInterfaceGetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { +} +func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) implementsGraphQLInterfaceGetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { +} func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry) implementsGraphQLInterfaceGetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { } func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesReconcilerDisabledActivityLogEntry) implementsGraphQLInterfaceGetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { @@ -15169,6 +15401,12 @@ func __unmarshalGetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLo case "PostgresGrantAccessActivityLogEntry": *v = new(GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry) return json.Unmarshal(b, *v) + case "PostgresPersonalAccessConnectionActivityLogEntry": + *v = new(GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) + return json.Unmarshal(b, *v) + case "PostgresPersonalAccessCreatedActivityLogEntry": + *v = new(GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) + return json.Unmarshal(b, *v) case "ReconcilerConfiguredActivityLogEntry": *v = new(GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry) return json.Unmarshal(b, *v) @@ -15500,6 +15738,22 @@ func __marshalGetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogE *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry }{typename, v} return json.Marshal(result) + case *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry: + typename = "PostgresPersonalAccessConnectionActivityLogEntry" + + result := struct { + TypeName string `json:"__typename"` + *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry + }{typename, v} + return json.Marshal(result) + case *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry: + typename = "PostgresPersonalAccessCreatedActivityLogEntry" + + result := struct { + TypeName string `json:"__typename"` + *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry + }{typename, v} + return json.Marshal(result) case *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry: typename = "ReconcilerConfiguredActivityLogEntry" @@ -16766,6 +17020,88 @@ func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryC return v.EnvironmentName } +// GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry includes the requested fields of the GraphQL type PostgresPersonalAccessConnectionActivityLogEntry. +// The GraphQL type's documentation follows. +// +// An audit-log entry for retrieval of personal Postgres connection materials. +type GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry struct { + Typename *string `json:"__typename"` + // Interface for activity log entries. + Actor string `json:"actor"` + // Interface for activity log entries. + CreatedAt time.Time `json:"createdAt"` + // Interface for activity log entries. + Message string `json:"message"` + // Interface for activity log entries. + EnvironmentName *string `json:"environmentName"` +} + +// GetTypename returns GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Typename, and is useful for accessing the field via an interface. +func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetTypename() *string { + return v.Typename +} + +// GetActor returns GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Actor, and is useful for accessing the field via an interface. +func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetActor() string { + return v.Actor +} + +// GetCreatedAt returns GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetCreatedAt() time.Time { + return v.CreatedAt +} + +// GetMessage returns GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Message, and is useful for accessing the field via an interface. +func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetMessage() string { + return v.Message +} + +// GetEnvironmentName returns GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.EnvironmentName, and is useful for accessing the field via an interface. +func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetEnvironmentName() *string { + return v.EnvironmentName +} + +// GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry includes the requested fields of the GraphQL type PostgresPersonalAccessCreatedActivityLogEntry. +// The GraphQL type's documentation follows. +// +// An audit-log entry for personal Postgres access created through the API broker. +type GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry struct { + Typename *string `json:"__typename"` + // Interface for activity log entries. + Actor string `json:"actor"` + // Interface for activity log entries. + CreatedAt time.Time `json:"createdAt"` + // Interface for activity log entries. + Message string `json:"message"` + // Interface for activity log entries. + EnvironmentName *string `json:"environmentName"` +} + +// GetTypename returns GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Typename, and is useful for accessing the field via an interface. +func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetTypename() *string { + return v.Typename +} + +// GetActor returns GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Actor, and is useful for accessing the field via an interface. +func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetActor() string { + return v.Actor +} + +// GetCreatedAt returns GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetCreatedAt() time.Time { + return v.CreatedAt +} + +// GetMessage returns GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Message, and is useful for accessing the field via an interface. +func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetMessage() string { + return v.Message +} + +// GetEnvironmentName returns GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.EnvironmentName, and is useful for accessing the field via an interface. +func (v *GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetEnvironmentName() *string { + return v.EnvironmentName +} + // GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry includes the requested fields of the GraphQL type ReconcilerConfiguredActivityLogEntry. type GetJobActivityTeamJobsJobConnectionNodesJobActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry struct { Typename *string `json:"__typename"` @@ -20282,6 +20618,8 @@ func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActiv // GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesOpenSearchUpdatedActivityLogEntry // GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresDeletedActivityLogEntry // GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry +// GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry +// GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry // GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry // GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesReconcilerDisabledActivityLogEntry // GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesReconcilerEnabledActivityLogEntry @@ -20401,6 +20739,10 @@ func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActiv } func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry) implementsGraphQLInterfaceGetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { } +func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) implementsGraphQLInterfaceGetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { +} +func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) implementsGraphQLInterfaceGetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { +} func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry) implementsGraphQLInterfaceGetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { } func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesReconcilerDisabledActivityLogEntry) implementsGraphQLInterfaceGetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { @@ -20572,6 +20914,12 @@ func __unmarshalGetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityL case "PostgresGrantAccessActivityLogEntry": *v = new(GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry) return json.Unmarshal(b, *v) + case "PostgresPersonalAccessConnectionActivityLogEntry": + *v = new(GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) + return json.Unmarshal(b, *v) + case "PostgresPersonalAccessCreatedActivityLogEntry": + *v = new(GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) + return json.Unmarshal(b, *v) case "ReconcilerConfiguredActivityLogEntry": *v = new(GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry) return json.Unmarshal(b, *v) @@ -20903,6 +21251,22 @@ func __marshalGetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLog *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry }{typename, v} return json.Marshal(result) + case *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry: + typename = "PostgresPersonalAccessConnectionActivityLogEntry" + + result := struct { + TypeName string `json:"__typename"` + *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry + }{typename, v} + return json.Marshal(result) + case *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry: + typename = "PostgresPersonalAccessCreatedActivityLogEntry" + + result := struct { + TypeName string `json:"__typename"` + *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry + }{typename, v} + return json.Marshal(result) case *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry: typename = "ReconcilerConfiguredActivityLogEntry" @@ -22169,6 +22533,88 @@ func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActiv return v.EnvironmentName } +// GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry includes the requested fields of the GraphQL type PostgresPersonalAccessConnectionActivityLogEntry. +// The GraphQL type's documentation follows. +// +// An audit-log entry for retrieval of personal Postgres connection materials. +type GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry struct { + Typename *string `json:"__typename"` + // Interface for activity log entries. + Actor string `json:"actor"` + // Interface for activity log entries. + CreatedAt time.Time `json:"createdAt"` + // Interface for activity log entries. + Message string `json:"message"` + // Interface for activity log entries. + EnvironmentName *string `json:"environmentName"` +} + +// GetTypename returns GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Typename, and is useful for accessing the field via an interface. +func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetTypename() *string { + return v.Typename +} + +// GetActor returns GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Actor, and is useful for accessing the field via an interface. +func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetActor() string { + return v.Actor +} + +// GetCreatedAt returns GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetCreatedAt() time.Time { + return v.CreatedAt +} + +// GetMessage returns GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Message, and is useful for accessing the field via an interface. +func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetMessage() string { + return v.Message +} + +// GetEnvironmentName returns GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.EnvironmentName, and is useful for accessing the field via an interface. +func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetEnvironmentName() *string { + return v.EnvironmentName +} + +// GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry includes the requested fields of the GraphQL type PostgresPersonalAccessCreatedActivityLogEntry. +// The GraphQL type's documentation follows. +// +// An audit-log entry for personal Postgres access created through the API broker. +type GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry struct { + Typename *string `json:"__typename"` + // Interface for activity log entries. + Actor string `json:"actor"` + // Interface for activity log entries. + CreatedAt time.Time `json:"createdAt"` + // Interface for activity log entries. + Message string `json:"message"` + // Interface for activity log entries. + EnvironmentName *string `json:"environmentName"` +} + +// GetTypename returns GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Typename, and is useful for accessing the field via an interface. +func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetTypename() *string { + return v.Typename +} + +// GetActor returns GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Actor, and is useful for accessing the field via an interface. +func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetActor() string { + return v.Actor +} + +// GetCreatedAt returns GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetCreatedAt() time.Time { + return v.CreatedAt +} + +// GetMessage returns GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Message, and is useful for accessing the field via an interface. +func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetMessage() string { + return v.Message +} + +// GetEnvironmentName returns GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.EnvironmentName, and is useful for accessing the field via an interface. +func (v *GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetEnvironmentName() *string { + return v.EnvironmentName +} + // GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry includes the requested fields of the GraphQL type ReconcilerConfiguredActivityLogEntry. type GetSecretActivityTeamSecretsSecretConnectionNodesSecretActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry struct { Typename *string `json:"__typename"` @@ -24261,6 +24707,8 @@ func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnection) __premarshalJ // GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesOpenSearchUpdatedActivityLogEntry // GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresDeletedActivityLogEntry // GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry +// GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry +// GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry // GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry // GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesReconcilerDisabledActivityLogEntry // GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesReconcilerEnabledActivityLogEntry @@ -24390,6 +24838,10 @@ func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresDe } func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry) implementsGraphQLInterfaceGetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { } +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) implementsGraphQLInterfaceGetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { +} +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) implementsGraphQLInterfaceGetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { +} func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry) implementsGraphQLInterfaceGetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { } func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesReconcilerDisabledActivityLogEntry) implementsGraphQLInterfaceGetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesActivityLogEntry() { @@ -24561,6 +25013,12 @@ func __unmarshalGetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesAct case "PostgresGrantAccessActivityLogEntry": *v = new(GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry) return json.Unmarshal(b, *v) + case "PostgresPersonalAccessConnectionActivityLogEntry": + *v = new(GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) + return json.Unmarshal(b, *v) + case "PostgresPersonalAccessCreatedActivityLogEntry": + *v = new(GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) + return json.Unmarshal(b, *v) case "ReconcilerConfiguredActivityLogEntry": *v = new(GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry) return json.Unmarshal(b, *v) @@ -24892,6 +25350,22 @@ func __marshalGetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesActiv *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresGrantAccessActivityLogEntry }{typename, v} return json.Marshal(result) + case *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry: + typename = "PostgresPersonalAccessConnectionActivityLogEntry" + + result := struct { + TypeName string `json:"__typename"` + *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry + }{typename, v} + return json.Marshal(result) + case *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry: + typename = "PostgresPersonalAccessCreatedActivityLogEntry" + + result := struct { + TypeName string `json:"__typename"` + *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry + }{typename, v} + return json.Marshal(result) case *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry: typename = "ReconcilerConfiguredActivityLogEntry" @@ -26494,6 +26968,116 @@ func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresGr return v.ResourceName } +// GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry includes the requested fields of the GraphQL type PostgresPersonalAccessConnectionActivityLogEntry. +// The GraphQL type's documentation follows. +// +// An audit-log entry for retrieval of personal Postgres connection materials. +type GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry struct { + Typename *string `json:"__typename"` + // Interface for activity log entries. + Actor string `json:"actor"` + // Interface for activity log entries. + CreatedAt time.Time `json:"createdAt"` + // Interface for activity log entries. + Message string `json:"message"` + // Interface for activity log entries. + EnvironmentName *string `json:"environmentName"` + // Interface for activity log entries. + ResourceType ActivityLogEntryResourceType `json:"resourceType"` + // Interface for activity log entries. + ResourceName string `json:"resourceName"` +} + +// GetTypename returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Typename, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetTypename() *string { + return v.Typename +} + +// GetActor returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Actor, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetActor() string { + return v.Actor +} + +// GetCreatedAt returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetCreatedAt() time.Time { + return v.CreatedAt +} + +// GetMessage returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.Message, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetMessage() string { + return v.Message +} + +// GetEnvironmentName returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.EnvironmentName, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetEnvironmentName() *string { + return v.EnvironmentName +} + +// GetResourceType returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.ResourceType, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetResourceType() ActivityLogEntryResourceType { + return v.ResourceType +} + +// GetResourceName returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry.ResourceName, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessConnectionActivityLogEntry) GetResourceName() string { + return v.ResourceName +} + +// GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry includes the requested fields of the GraphQL type PostgresPersonalAccessCreatedActivityLogEntry. +// The GraphQL type's documentation follows. +// +// An audit-log entry for personal Postgres access created through the API broker. +type GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry struct { + Typename *string `json:"__typename"` + // Interface for activity log entries. + Actor string `json:"actor"` + // Interface for activity log entries. + CreatedAt time.Time `json:"createdAt"` + // Interface for activity log entries. + Message string `json:"message"` + // Interface for activity log entries. + EnvironmentName *string `json:"environmentName"` + // Interface for activity log entries. + ResourceType ActivityLogEntryResourceType `json:"resourceType"` + // Interface for activity log entries. + ResourceName string `json:"resourceName"` +} + +// GetTypename returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Typename, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetTypename() *string { + return v.Typename +} + +// GetActor returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Actor, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetActor() string { + return v.Actor +} + +// GetCreatedAt returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetCreatedAt() time.Time { + return v.CreatedAt +} + +// GetMessage returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.Message, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetMessage() string { + return v.Message +} + +// GetEnvironmentName returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.EnvironmentName, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetEnvironmentName() *string { + return v.EnvironmentName +} + +// GetResourceType returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.ResourceType, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetResourceType() ActivityLogEntryResourceType { + return v.ResourceType +} + +// GetResourceName returns GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry.ResourceName, and is useful for accessing the field via an interface. +func (v *GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesPostgresPersonalAccessCreatedActivityLogEntry) GetResourceName() string { + return v.ResourceName +} + // GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry includes the requested fields of the GraphQL type ReconcilerConfiguredActivityLogEntry. type GetTeamActivityTeamActivityLogActivityLogEntryConnectionNodesReconcilerConfiguredActivityLogEntry struct { Typename *string `json:"__typename"` @@ -29876,7 +30460,7 @@ func (v *GetTeamPostgresBranchesResponse) GetTeam() GetTeamPostgresBranchesTeam // // External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). type GetTeamPostgresBranchesTeam struct { - // Postgres instances owned by the team. + // Postgres branches owned by the team. PostgresBranches GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection `json:"postgresBranches"` // SQL instances owned by the team. SqlInstances GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnection `json:"sqlInstances"` @@ -29903,11 +30487,16 @@ func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection) Ge } // GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch includes the requested fields of the GraphQL type PostgresBranch. +// The GraphQL type's documentation follows. +// +// A named PostgresBranch belonging to a Postgres. type GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch struct { + // Local name of this branch within its Postgres. Name string `json:"name"` TeamEnvironment GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment `json:"teamEnvironment"` - Postgres GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres `json:"postgres"` - // Current state of the Postgres cluster. + // Postgres owning this PostgresBranch. + Postgres GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres `json:"postgres"` + // Current observed state of the branch. State PostgresBranchState `json:"state"` } @@ -29932,9 +30521,21 @@ func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNode } // GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres includes the requested fields of the GraphQL type Postgres. +// The GraphQL type's documentation follows. +// +// A Postgres whose active branch can change. type GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres struct { - MajorVersion string `json:"majorVersion"` - HighAvailability bool `json:"highAvailability"` + // Name of this Postgres. + Name string `json:"name"` + // Configured PostgreSQL major version. + MajorVersion string `json:"majorVersion"` + // Whether high availability is configured. + HighAvailability bool `json:"highAvailability"` +} + +// GetName returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres.Name, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres) GetName() string { + return v.Name } // GetMajorVersion returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres.MajorVersion, and is useful for accessing the field via an interface. @@ -31913,15 +32514,15 @@ var AllOrderDirection = []OrderDirection{ OrderDirectionDesc, } -// Input for filtering Postgres instances. +// Input for filtering Postgres branches. type PostgresBranchFilter struct { - // Input for filtering Postgres instances. + // Filter by the name of the branch. Name *string `json:"name"` - // Input for filtering Postgres instances. + // Filter by environments. Environments []string `json:"environments"` - // Input for filtering Postgres instances. + // Filter by branch state. States []PostgresBranchState `json:"states"` - // Input for filtering Postgres instances. + // Filter by user-defined labels. All listed labels must match. Labels []LabelFilter `json:"labels"` } @@ -31937,12 +32538,16 @@ func (v *PostgresBranchFilter) GetStates() []PostgresBranchState { return v.Stat // GetLabels returns PostgresBranchFilter.Labels, and is useful for accessing the field via an interface. func (v *PostgresBranchFilter) GetLabels() []LabelFilter { return v.Labels } +// Reconciliation and observed health of a PostgresBranch. type PostgresBranchState string const ( - PostgresBranchStateAvailable PostgresBranchState = "AVAILABLE" + // The branch is healthy and ready. + PostgresBranchStateAvailable PostgresBranchState = "AVAILABLE" + // The branch is provisioning or its state has not been observed yet. PostgresBranchStateProgressing PostgresBranchState = "PROGRESSING" - PostgresBranchStateDegraded PostgresBranchState = "DEGRADED" + // The branch has reported a failure. + PostgresBranchStateDegraded PostgresBranchState = "DEGRADED" ) var AllPostgresBranchState = []PostgresBranchState{ @@ -37701,6 +38306,7 @@ query GetTeamPostgresBranches ($team: Slug!, $postgresFilter: PostgresBranchFilt } } postgres { + name majorVersion highAvailability } diff --git a/internal/postgres/list.go b/internal/postgres/list.go index 3700df4f..7774f19e 100644 --- a/internal/postgres/list.go +++ b/internal/postgres/list.go @@ -71,6 +71,7 @@ func GetTeamPostgresBranches(ctx context.Context, team string, environments []st } } postgres { + name majorVersion highAvailability } @@ -116,9 +117,13 @@ func GetTeamPostgresBranches(ctx context.Context, team string, environments []st return nil, err } + return instancesFromTeam(resp.Team, team, environments), nil +} + +func instancesFromTeam(teamData gql.GetTeamPostgresBranchesTeam, team string, environments []string) []Instance { var ret []Instance - for _, p := range resp.Team.PostgresBranches.Nodes { + for _, p := range teamData.PostgresBranches.Nodes { env := p.TeamEnvironment.Environment.Name if len(environments) > 0 && !slices.Contains(environments, env) { continue @@ -126,8 +131,8 @@ func GetTeamPostgresBranches(ctx context.Context, team string, environments []st ret = append(ret, Instance{ Name: output.Link{ - Name: p.Name, - URL: fmt.Sprintf("%s/team/%s/%s/postgres/%s", consoleBaseURL, team, env, p.Name), + Name: p.Postgres.Name + "/" + p.Name, + URL: fmt.Sprintf("%s/team/%s/%s/postgres/%s", consoleBaseURL, team, env, p.Postgres.Name), }, Type: "PostgreSQL", Environment: env, @@ -137,7 +142,7 @@ func GetTeamPostgresBranches(ctx context.Context, team string, environments []st }) } - for _, s := range resp.Team.SqlInstances.Nodes { + for _, s := range teamData.SqlInstances.Nodes { env := s.TeamEnvironment.Environment.Name if len(environments) > 0 && !slices.Contains(environments, env) { continue @@ -164,5 +169,5 @@ func GetTeamPostgresBranches(ctx context.Context, team string, environments []st return ret[i].Name.Name < ret[j].Name.Name }) - return ret, nil + return ret } diff --git a/internal/postgres/list_test.go b/internal/postgres/list_test.go new file mode 100644 index 00000000..dfe9e3d0 --- /dev/null +++ b/internal/postgres/list_test.go @@ -0,0 +1,61 @@ +package postgres + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/nais/cli/internal/naisapi/gql" + "github.com/nais/naistrix/output" +) + +func TestInstancesFromTeam(t *testing.T) { + const teamData = `{ + "postgresBranches": {"nodes": [ + {"name":"main","teamEnvironment":{"environment":{"name":"dev"}},"postgres":{"name":"orders","majorVersion":"16","highAvailability":true},"state":"AVAILABLE"}, + {"name":"preview","teamEnvironment":{"environment":{"name":"prod"}},"postgres":{"name":"orders","majorVersion":"16","highAvailability":true},"state":"PROGRESSING"} + ]}, + "sqlInstances": {"nodes": [ + {"name":"legacy","teamEnvironment":{"environment":{"name":"dev"}},"version":"POSTGRES_14","highAvailability":false,"auditLog":{"logUrl":"https://example.test"},"state":"RUNNABLE"}, + {"name":"other","teamEnvironment":{"environment":{"name":"prod"}},"version":null,"highAvailability":true,"auditLog":null,"state":"STOPPED"} + ]} + }` + var data gql.GetTeamPostgresBranchesTeam + if err := json.Unmarshal([]byte(teamData), &data); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + environments []string + want []Instance + }{ + { + name: "merged and sorted", + want: []Instance{ + {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: boolPtr(true), State: State(gql.SqlInstanceStateRunnable)}, + {Name: output.Link{Name: "orders/main", URL: consoleBaseURL + "/team/my-team/dev/postgres/orders"}, Type: "PostgreSQL", Environment: "dev", Version: "16", HighAvailability: true, State: State(gql.PostgresBranchStateAvailable)}, + {Name: output.Link{Name: "orders/preview", URL: consoleBaseURL + "/team/my-team/prod/postgres/orders"}, Type: "PostgreSQL", Environment: "prod", Version: "16", HighAvailability: true, State: State(gql.PostgresBranchStateProgressing)}, + {Name: output.Link{Name: "other", URL: consoleBaseURL + "/team/my-team/prod/cloudsql/other"}, Type: "Cloud SQL", Environment: "prod", HighAvailability: true, Audit: boolPtr(false), State: State(gql.SqlInstanceStateStopped)}, + }, + }, + { + name: "environment filter applies to both providers", + environments: []string{"dev"}, + want: []Instance{ + {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: boolPtr(true), State: State(gql.SqlInstanceStateRunnable)}, + {Name: output.Link{Name: "orders/main", URL: consoleBaseURL + "/team/my-team/dev/postgres/orders"}, Type: "PostgreSQL", Environment: "dev", Version: "16", HighAvailability: true, State: State(gql.PostgresBranchStateAvailable)}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := instancesFromTeam(data, "my-team", tt.environments) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("instancesFromTeam() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func boolPtr(value bool) *bool { return &value } diff --git a/schema.graphql b/schema.graphql index 1138e12b..9c186046 100644 --- a/schema.graphql +++ b/schema.graphql @@ -150,9 +150,11 @@ Filter for credential creation events. A user was granted access to a Postgres cluster """ POSTGRES_GRANT_ACCESS -""" -A Postgres instance was deleted -""" + "A personal Postgres access was created through the API broker" + POSTGRES_PERSONAL_ACCESS_CREATED + "Personal Postgres connection materials were retrieved" + POSTGRES_PERSONAL_ACCESS_CONNECTION + "A Postgres branch was deleted" POSTGRES_DELETED """ Reconciler enabled activity log entry. @@ -983,9 +985,7 @@ Network policies for the application. OpenSearch instance referenced by the workload. """ openSearch: OpenSearch -""" -Postgres instances referenced by the application. This does not currently support pagination, but will return all available Postgres instances. -""" +"Active PostgresBranches for all Postgres entries in uses.postgres." postgresBranches( """ Ordering options for items returned from the connection. @@ -2907,16 +2907,55 @@ Whether or not the OpenSearch instance was deleted. openSearchDeleted: Boolean } -input DeletePostgresBranchInput { +"Result of creating a personal Postgres access." +type CreatePostgresAccessPayload { + "Name of the newly created PostgresAccess resource." name: String! + "Server-controlled expiry for this personal access." + expiresAt: Time! +} + +"Input for creating a time-limited personal Postgres access." +input CreatePostgresAccessInput { + "Name of the Postgres containing the branch." + postgres: String! + "Local name of the branch to access." + branch: String! + "Team that owns the Postgres branch." + teamSlug: Slug! + "Environment containing the Postgres branch." environmentName: String! + "Privileges requested for the personal database role." + accessLevel: PostgresAccessLevel! + "Reason for personal database access. Must be at least 10 characters." + reason: String! + "Requested access lifetime (for example '30m' or '1h'). Defaults to '1h' and cannot exceed '1h'." + ttl: String +} + +"Privilege level granted to a personal Postgres database role." +enum PostgresAccessLevel { + "Read data without modifying it." + READ + "Read and modify existing data." + READWRITE + "Read, modify, and create database objects where supported." + READWRITECREATE +} + +input DeletePostgresBranchInput { + "Name of the Postgres containing the branch." + postgres: String! + "Local name of the branch to delete." + branch: String! + "The environment containing the PostgresBranch." + environmentName: String! + "The team that owns the PostgresBranch." teamSlug: Slug! } type DeletePostgresBranchPayload { -""" -Whether or not the Postgres branch was deleted. -""" +"Whether the PostgresBranch was deleted." postgresBranchDeleted: Boolean } @@ -4620,9 +4659,7 @@ Network policies for the job. OpenSearch instance referenced by the workload. """ openSearch: OpenSearch -""" -Postgres instances referenced by the job. This does not currently support pagination, but will return all available Postgres instances. -""" +"Active PostgresBranches for all Postgres entries in uses.postgres." postgresBranches( """ Ordering options for items returned from the connection. @@ -5924,9 +5961,13 @@ Grant temporary access to a Postgres cluster. grantPostgresAccess( input: GrantPostgresAccessInput! ): GrantPostgresAccessPayload! -""" -Delete an inactive Postgres branch. -""" + """ + EXPERIMENTAL: DO NOT USE + Create time-limited personal access to a NAIS Postgres branch through the brokered PostgresAccess and relay flow. + When the access is ready, retrieve its connection materials through PostgresAccess.connection. + """ + createPostgresAccess(input: CreatePostgresAccessInput!): CreatePostgresAccessPayload! +"Delete a PostgresBranch that is not active on its Postgres." deletePostgresBranch( input: DeletePostgresBranchInput! ): DeletePostgresBranchPayload! @@ -6941,6 +6982,8 @@ ID of the entry. 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. """ @@ -6976,6 +7019,8 @@ ID of the entry. 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. """ @@ -7011,15 +7056,74 @@ type PostgresGrantAccessActivityLogEntryData { until: Time! } +"An audit-log entry for personal Postgres access created through the API broker." +type PostgresPersonalAccessCreatedActivityLogEntry implements ActivityLogEntry & Node { + "ID of the entry." + id: ID! + "The identity of the actor who created the personal access." + 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 affected resource." + resourceType: ActivityLogEntryResourceType! + "Name of the affected Postgres branch." + resourceName: String! + "The team slug that the entry belongs to." + teamSlug: Slug! + "The environment name that the entry belongs to." + environmentName: String + "Personal-access specific audit data." + data: PostgresPersonalAccessCreatedActivityLogEntryData! +} + +"Personal-access-specific audit data." +type PostgresPersonalAccessCreatedActivityLogEntryData { + "Identity that owns the new personal access." + username: String! + "Requested privilege level; null for events recorded before this field was added." + accessLevel: PostgresAccessLevel + "Server-controlled expiry of the access." + expiresAt: Time! + "Caller-provided audit reason." + reason: String! +} + +"An audit-log entry for retrieval of personal Postgres connection materials." +type PostgresPersonalAccessConnectionActivityLogEntry implements ActivityLogEntry & Node { + "ID of the entry." + id: ID! + "Identity that retrieved the connection materials." + 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 affected resource." + resourceType: ActivityLogEntryResourceType! + "Name of the affected PostgresAccess resource." + resourceName: String! + "Team slug that the entry belongs to." + teamSlug: Slug! + "Environment name that the entry belongs to." + environmentName: String +} + +"A named PostgresBranch belonging to a Postgres." type PostgresBranch implements Persistence & Node{ id: ID! + "Local name of this branch within its Postgres." name: String! team: Team! teamEnvironment: TeamEnvironment! + "Postgres owning this PostgresBranch." postgres: Postgres! -""" -Workloads that reference the Postgres instance. -""" + "Workloads using this branch while it is active." workloads( """ Get the first n items in the connection. This can be used in combination with the after parameter. @@ -7038,38 +7142,109 @@ Get items before this cursor. """ before: Cursor ): WorkloadConnection! -""" -Current state of the Postgres cluster. -""" +"Current observed state of the branch." state: PostgresBranchState! -""" -User-defined labels attached to this instance. -""" + "User-defined labels on this branch." labels: [ResourceLabel!]! } +"A Postgres whose active branch can change." type Postgres implements Node { + "Opaque identifier for this Postgres." id: ID! + "Name of this Postgres." name: String! + "Configured PostgreSQL major version." majorVersion: String! + "Whether high availability is configured." highAvailability: Boolean! + "Requested CPU, memory and disk size, when present on this Postgres." resources: PostgresResources! - activeBranch: String + "Currently active branch, if selected." + activeBranch: PostgresBranch + "Branch with this local name in this Postgres." + branch(name: String!): PostgresBranch! + "Branches belonging to this Postgres." + branches(first: Int, after: Cursor, last: Int, before: Cursor, orderBy: PostgresBranchOrder): PostgresBranchConnection! + "User-defined labels on this Postgres." labels: [ResourceLabel!]! } +"Resource requests configured on Postgres. Omitted requests are null." type PostgresResources { + "Requested CPU." cpu: String + "Requested memory." memory: String + "Requested disk size." diskSize: String } +"A time-limited personal access request for a Postgres branch." +type PostgresAccess implements Node { + "Opaque ID for this PostgresAccess resource." + id: ID! + "Name of the PostgresAccess resource." + name: String! + "Team that owns the access." + team: Team! + "Environment for the access." + teamEnvironment: TeamEnvironment! + "PostgresBranch selected by this access." + postgresBranch: PostgresBranch! + "Requested access level." + accessLevel: PostgresAccessLevel! + "Server-controlled expiry for this personal access." + expiresAt: Time! + "High-level state of the access." + state: PostgresAccessState! + "Human-readable message for the current state." + message: String + "Name of the controller-owned relay mapping, once created. Contains no credential." + relayAccess: String + """ + EXPERIMENTAL: DO NOT USE + Get connection materials for this ready access. Only its owner can read them. + """ + connection: PostgresAccessConnectionDetails +} + +"High-level reconciliation state of a personal Postgres access." +enum PostgresAccessState { + "The controller has not finished provisioning the access." + PENDING + "The access and its connection materials are ready." + READY + "The controller cannot provision the requested access." + FAILED + "The server-controlled expiry time has passed." + EXPIRED +} + +"Sensitive connection materials for a ready personal Postgres access." +type PostgresAccessConnectionDetails { + "Database username for the caller's personal role." + username: String! + "Short-lived password for the caller's database role." + password: String! + "CA certificate required to verify the PostgreSQL server certificate." + caCertificate: String! + "PostgreSQL server name used for TLS verification." + serverName: String! + "Public HTTP/3 relay endpoint." + relayEndpoint: String! + "Relay-Access header value (namespace/name)." + relayAccess: String! + "Owner-only bearer token for this access; never log it." + relayToken: String! +} + type PostgresBranchConnection { pageInfo: PageInfo! nodes: [PostgresBranch!]! edges: [PostgresBranchEdge!]! """ -Facets for Postgres instances. Provides distribution counts to help narrow down results. +Facets for Postgres branches. Provides distribution counts to help narrow down results. Facet counts are computed over the full result set (ignoring pagination) but respect the current filter. """ facets: PostgresBranchFacets @@ -7081,42 +7256,26 @@ type PostgresBranchEdge { } """ -Facets for Postgres instances, providing distribution counts across different dimensions. +Facets for Postgres branches, providing distribution counts across different dimensions. """ type PostgresBranchFacets { -""" -Distribution of instances by environment. -""" + "Distribution of branches by environment." environments: [StringFacetItem!]! -""" -Distribution of instances by state. -""" + "Distribution of branches by state." states: [PostgresBranchStateFacetItem!]! -""" -Distribution of instances by user-defined labels. -""" + "Distribution of branches by user-defined labels." labels: [LabelFacetItem!]! } -""" -Input for filtering Postgres instances. -""" +"Input for filtering Postgres branches." input PostgresBranchFilter { -""" -Input for filtering Postgres instances. -""" + "Filter by the name of the branch." name: String -""" -Input for filtering Postgres instances. -""" + "Filter by environments." environments: [String!] -""" -Input for filtering Postgres instances. -""" + "Filter by branch state." states: [PostgresBranchState!] -""" -Input for filtering Postgres instances. -""" + "Filter by user-defined labels. All listed labels must match." labels: [LabelFilter!] } @@ -7130,23 +7289,21 @@ enum PostgresBranchOrderField { ENVIRONMENT } +"Reconciliation and observed health of a PostgresBranch." enum PostgresBranchState { + "The branch is healthy and ready." AVAILABLE + "The branch is provisioning or its state has not been observed yet." PROGRESSING + "The branch has reported a failure." DEGRADED } -""" -A single facet item for Postgres instance states. -""" +"A single facet item for Postgres branch states." type PostgresBranchStateFacetItem { -""" -The Postgres instance state. -""" + "The Postgres branch state." state: PostgresBranchState! -""" -Number of matching instances. -""" + "Number of matching branches." count: Int! } @@ -10566,9 +10723,7 @@ Filtering options for items returned from the connection. """ filter: OpenSearchFilter ): OpenSearchConnection! -""" -Postgres instances owned by the team. -""" +"Postgres branches owned by the team." postgresBranches( """ Get the first n items in the connection. This can be used in combination with the after parameter. @@ -11211,11 +11366,20 @@ OpenSearch instance in the team environment. name: String! ): OpenSearch! """ -Postgres instance in the team environment. +Postgres in the team environment. """ - postgresBranch( + postgres( + "Name of the Postgres in this team environment." + name: String! + ): Postgres! + """ + EXPERIMENTAL: DO NOT USE + Get a PostgresAccess and its state. Available to authorized team members. + """ + postgresAccess( + "Name of the PostgresAccess in this team environment." name: String! - ): PostgresBranch! + ): PostgresAccess! """ Get a secret by name. """ @@ -11475,7 +11639,7 @@ Total number of OpenSearch instances. type TeamInventoryCountPostgresBranches { """ -Total number of Postgres instances. +Total number of Postgres branches. """ total: Int! } From 97032aac897cd6b19c92db7624915ecaa76a7c78 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 1 Oct 2026 09:49:44 +0200 Subject: [PATCH 04/10] feat: split and prepare postgres subcmds --- go.mod | 2 + go.sum | 6 + internal/alpha/command/alpha.go | 2 + internal/alpha/postgres/access.go | 133 +++ internal/alpha/postgres/access_test.go | 71 ++ internal/alpha/postgres/command/access.go | 172 ++++ .../alpha/postgres/command/access_test.go | 14 + internal/alpha/postgres/command/flag/flag.go | 47 + internal/alpha/postgres/command/postgres.go | 51 ++ internal/alpha/postgres/list.go | 85 ++ internal/alpha/postgres/list_test.go | 37 + internal/alpha/postgres/relay/relay.go | 113 +++ internal/alpha/postgres/relay/relay_test.go | 133 +++ internal/application/application.go | 2 + internal/cloudsql/access.go | 99 +++ internal/cloudsql/audit.go | 305 +++++++ internal/cloudsql/cloudsqldbinfo.go | 314 +++++++ internal/cloudsql/command/cloudsql.go | 39 + internal/cloudsql/command/enable_audit.go | 32 + internal/cloudsql/command/flag/flag.go | 115 +++ internal/cloudsql/command/list.go | 49 + internal/cloudsql/command/migrate.go | 125 +++ internal/cloudsql/command/password.go | 34 + internal/cloudsql/command/prepare.go | 47 + internal/cloudsql/command/proxy.go | 31 + internal/cloudsql/command/psql.go | 27 + internal/cloudsql/command/revoke.go | 46 + internal/cloudsql/command/users.go | 80 ++ internal/cloudsql/command/verify_audit.go | 32 + internal/cloudsql/dbinfo.go | 87 ++ internal/cloudsql/iam.go | 353 ++++++++ internal/cloudsql/list.go | 98 ++ internal/cloudsql/list_test.go | 41 + internal/cloudsql/migrate/config/config.go | 209 +++++ .../cloudsql/migrate/config/config_test.go | 325 +++++++ internal/cloudsql/migrate/finalize.go | 51 ++ internal/cloudsql/migrate/finalize/command.go | 34 + internal/cloudsql/migrate/migrate.go | 642 ++++++++++++++ internal/cloudsql/migrate/migrate_test.go | 55 ++ internal/cloudsql/migrate/promote.go | 83 ++ internal/cloudsql/migrate/promote/command.go | 35 + internal/cloudsql/migrate/rollback.go | 48 + internal/cloudsql/migrate/rollback/command.go | 35 + internal/cloudsql/migrate/setup.go | 196 ++++ internal/cloudsql/migrate/setup/command.go | 66 ++ internal/cloudsql/migrate/setup_test.go | 321 +++++++ internal/cloudsql/migrate/ui/ui.go | 228 +++++ internal/cloudsql/migrate/ui/ui_test.go | 221 +++++ internal/cloudsql/password.go | 142 +++ internal/cloudsql/password_test.go | 234 +++++ internal/cloudsql/proxy.go | 51 ++ internal/cloudsql/psql.go | 73 ++ internal/cloudsql/secret.go | 72 ++ internal/naisapi/gql/generated.go | 837 ++++++++++++++++++ internal/postgres/command/enable_audit.go | 4 +- internal/postgres/command/grant.go | 4 +- internal/postgres/command/legacy.go | 29 + internal/postgres/command/legacy_test.go | 100 +++ internal/postgres/command/list.go | 4 +- internal/postgres/command/migrate.go | 16 +- internal/postgres/command/password.go | 4 +- internal/postgres/command/prepare.go | 4 +- internal/postgres/command/proxy.go | 4 +- internal/postgres/command/psql.go | 4 +- internal/postgres/command/revoke.go | 4 +- internal/postgres/command/users.go | 12 +- internal/postgres/command/verify_audit.go | 4 +- 67 files changed, 6941 insertions(+), 32 deletions(-) create mode 100644 internal/alpha/postgres/access.go create mode 100644 internal/alpha/postgres/access_test.go create mode 100644 internal/alpha/postgres/command/access.go create mode 100644 internal/alpha/postgres/command/access_test.go create mode 100644 internal/alpha/postgres/command/flag/flag.go create mode 100644 internal/alpha/postgres/command/postgres.go create mode 100644 internal/alpha/postgres/list.go create mode 100644 internal/alpha/postgres/list_test.go create mode 100644 internal/alpha/postgres/relay/relay.go create mode 100644 internal/alpha/postgres/relay/relay_test.go create mode 100644 internal/cloudsql/access.go create mode 100644 internal/cloudsql/audit.go create mode 100644 internal/cloudsql/cloudsqldbinfo.go create mode 100644 internal/cloudsql/command/cloudsql.go create mode 100644 internal/cloudsql/command/enable_audit.go create mode 100644 internal/cloudsql/command/flag/flag.go create mode 100644 internal/cloudsql/command/list.go create mode 100644 internal/cloudsql/command/migrate.go create mode 100644 internal/cloudsql/command/password.go create mode 100644 internal/cloudsql/command/prepare.go create mode 100644 internal/cloudsql/command/proxy.go create mode 100644 internal/cloudsql/command/psql.go create mode 100644 internal/cloudsql/command/revoke.go create mode 100644 internal/cloudsql/command/users.go create mode 100644 internal/cloudsql/command/verify_audit.go create mode 100644 internal/cloudsql/dbinfo.go create mode 100644 internal/cloudsql/iam.go create mode 100644 internal/cloudsql/list.go create mode 100644 internal/cloudsql/list_test.go create mode 100644 internal/cloudsql/migrate/config/config.go create mode 100644 internal/cloudsql/migrate/config/config_test.go create mode 100644 internal/cloudsql/migrate/finalize.go create mode 100644 internal/cloudsql/migrate/finalize/command.go create mode 100644 internal/cloudsql/migrate/migrate.go create mode 100644 internal/cloudsql/migrate/migrate_test.go create mode 100644 internal/cloudsql/migrate/promote.go create mode 100644 internal/cloudsql/migrate/promote/command.go create mode 100644 internal/cloudsql/migrate/rollback.go create mode 100644 internal/cloudsql/migrate/rollback/command.go create mode 100644 internal/cloudsql/migrate/setup.go create mode 100644 internal/cloudsql/migrate/setup/command.go create mode 100644 internal/cloudsql/migrate/setup_test.go create mode 100644 internal/cloudsql/migrate/ui/ui.go create mode 100644 internal/cloudsql/migrate/ui/ui_test.go create mode 100644 internal/cloudsql/password.go create mode 100644 internal/cloudsql/password_test.go create mode 100644 internal/cloudsql/proxy.go create mode 100644 internal/cloudsql/psql.go create mode 100644 internal/cloudsql/secret.go create mode 100644 internal/postgres/command/legacy.go create mode 100644 internal/postgres/command/legacy_test.go diff --git a/go.mod b/go.mod index f8a6c17d..411a7521 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/nais/naistrix v0.35.0 github.com/pkg/errors v0.9.1 github.com/pterm/pterm v0.12.83 + github.com/quic-go/quic-go v0.59.1 github.com/sethvargo/go-retry v0.3.0 github.com/stretchr/testify v1.11.1 github.com/suessflorian/gqlfetch v0.7.0 @@ -198,6 +199,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/quic-go/qpack v0.6.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect diff --git a/go.sum b/go.sum index b6770883..ec9c9769 100644 --- a/go.sum +++ b/go.sum @@ -457,6 +457,12 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4 github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA= github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE= +github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/internal/alpha/command/alpha.go b/internal/alpha/command/alpha.go index 016137af..0c617226 100644 --- a/internal/alpha/command/alpha.go +++ b/internal/alpha/command/alpha.go @@ -2,6 +2,7 @@ package command import ( "github.com/nais/cli/internal/alpha/command/flag" + postgrescmd "github.com/nais/cli/internal/alpha/postgres/command" "github.com/nais/cli/internal/flags" krakend "github.com/nais/cli/internal/krakend/command" mcpcmd "github.com/nais/cli/internal/mcp/command" @@ -18,6 +19,7 @@ func Alpha(parentFlags *flags.GlobalFlags) *naistrix.Command { SubCommands: []*naistrix.Command{ krakend.Krakend(flags), mcpcmd.MCP(flags), + postgrescmd.Postgres(parentFlags), }, } } diff --git a/internal/alpha/postgres/access.go b/internal/alpha/postgres/access.go new file mode 100644 index 00000000..6f072a8d --- /dev/null +++ b/internal/alpha/postgres/access.go @@ -0,0 +1,133 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/Khan/genqlient/graphql" + "github.com/nais/cli/internal/naisapi" + "github.com/nais/cli/internal/naisapi/gql" +) + +// Access contains the brokered connection materials. Do not log this value. +type Access struct { + State gql.PostgresAccessState + Message string + Connection *Connection +} + +type Connection struct { + Username, Password, CACertificate, ServerName, RelayEndpoint, RelayAccess, RelayToken string +} + +type AccessAPI interface { + ActiveBranch(context.Context, string, string, string) (string, error) + Create(context.Context, gql.CreatePostgresAccessInput) (string, error) + Get(context.Context, string, string, string) (Access, error) +} + +type graphqlAccessAPI struct{ client graphql.Client } + +func NewAPI(ctx context.Context) (AccessAPI, error) { + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, err + } + return graphqlAccessAPI{client}, nil +} + +func (a graphqlAccessAPI) ActiveBranch(ctx context.Context, team, environment, name string) (string, error) { + _ = `# @genqlient + query GetActivePostgresBranchAlpha($team: Slug!, $environment: String!, $postgres: String!) { + team(slug: $team) { environment(name: $environment) { postgres(name: $postgres) { activeBranch { name } } } } + } + ` + result, err := gql.GetActivePostgresBranchAlpha(ctx, a.client, team, environment, name) + if err != nil { + return "", err + } + if result.Team.Environment.Postgres.ActiveBranch == nil { + return "", fmt.Errorf("postgres %q has no active branch; specify --branch", name) + } + return result.Team.Environment.Postgres.ActiveBranch.Name, nil +} + +func (a graphqlAccessAPI) Create(ctx context.Context, input gql.CreatePostgresAccessInput) (string, error) { + _ = `# @genqlient + mutation CreatePostgresAccessAlpha($input: CreatePostgresAccessInput!) { + createPostgresAccess(input: $input) { name } + } + ` + result, err := gql.CreatePostgresAccessAlpha(ctx, a.client, input) + if err != nil { + return "", err + } + return result.CreatePostgresAccess.Name, nil +} + +func (a graphqlAccessAPI) Get(ctx context.Context, team, environment, name string) (Access, error) { + _ = `# @genqlient + query GetPostgresAccessAlpha($team: Slug!, $environment: String!, $name: String!) { + team(slug: $team) { environment(name: $environment) { postgresAccess(name: $name) { + state message connection { username password caCertificate serverName relayEndpoint relayAccess relayToken } + } } } + } + ` + result, err := gql.GetPostgresAccessAlpha(ctx, a.client, team, environment, name) + if err != nil { + return Access{}, err + } + got := result.Team.Environment.PostgresAccess + access := Access{State: got.State} + if got.Message != nil { + access.Message = *got.Message + } + if got.Connection != nil { + c := got.Connection + access.Connection = &Connection{c.Username, c.Password, c.CaCertificate, c.ServerName, c.RelayEndpoint, c.RelayAccess, c.RelayToken} + } + return access, nil +} + +func waitForAccess(ctx context.Context, api AccessAPI, team, environment, name string, interval time.Duration) (Connection, error) { + for { + access, err := api.Get(ctx, team, environment, name) + if err != nil { + return Connection{}, fmt.Errorf("retrieve postgres access: %w", err) + } + switch access.State { + case gql.PostgresAccessStateReady: + if access.Connection == nil { + return Connection{}, fmt.Errorf("postgres access %q is ready without connection materials", name) + } + return *access.Connection, nil + case gql.PostgresAccessStateFailed, gql.PostgresAccessStateExpired: + return Connection{}, fmt.Errorf("postgres access %q is %s: %s", name, access.State, access.Message) + case gql.PostgresAccessStatePending: + default: + return Connection{}, fmt.Errorf("postgres access %q has unknown state %q", name, access.State) + } + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return Connection{}, fmt.Errorf("waiting for postgres access %q: %w", name, ctx.Err()) + case <-timer.C: + } + } +} + +func CreateAndWait(ctx context.Context, api AccessAPI, input gql.CreatePostgresAccessInput) (Connection, error) { + name, err := api.Create(ctx, input) + if err != nil { + return Connection{}, fmt.Errorf("create postgres access: %w", err) + } + pollCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + connection, err := waitForAccess(pollCtx, api, input.TeamSlug, input.EnvironmentName, name, time.Second) + if err != nil { + return Connection{}, fmt.Errorf("access %q was created but is not ready (it expires after its requested TTL): %w", name, err) + } + return connection, nil +} diff --git a/internal/alpha/postgres/access_test.go b/internal/alpha/postgres/access_test.go new file mode 100644 index 00000000..a8b5aec2 --- /dev/null +++ b/internal/alpha/postgres/access_test.go @@ -0,0 +1,71 @@ +package postgres + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/nais/cli/internal/naisapi/gql" +) + +type fakeAccessAPI struct { + states []Access + calls int +} + +func (f *fakeAccessAPI) ActiveBranch(context.Context, string, string, string) (string, error) { + return "main", nil +} + +func (f *fakeAccessAPI) Create(context.Context, gql.CreatePostgresAccessInput) (string, error) { + return "access-1", nil +} + +func (f *fakeAccessAPI) Get(context.Context, string, string, string) (Access, error) { + index := f.calls + f.calls++ + if index >= len(f.states) { + return Access{}, errors.New("unexpected poll") + } + return f.states[index], nil +} + +func TestWaitForAccess(t *testing.T) { + for _, tt := range []struct { + name string + states []Access + want string + }{ + {"ready", []Access{{State: gql.PostgresAccessStatePending}, {State: gql.PostgresAccessStateReady, Connection: &Connection{Username: "alice"}}}, ""}, + {"failed", []Access{{State: gql.PostgresAccessStateFailed, Message: "database unavailable"}}, "database unavailable"}, + {"expired", []Access{{State: gql.PostgresAccessStateExpired}}, "EXPIRED"}, + {"missing materials", []Access{{State: gql.PostgresAccessStateReady}}, "without connection materials"}, + } { + t.Run(tt.name, func(t *testing.T) { + fake := &fakeAccessAPI{states: tt.states} + got, err := waitForAccess(context.Background(), fake, "team", "dev", "access-1", time.Millisecond) + if tt.want == "" { + if err != nil || got.Username != "alice" { + t.Fatalf("got %+v, err %v", got, err) + } + } else if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected %q, got %v", tt.want, err) + } + if fake.calls != len(tt.states) { + t.Fatalf("polled %d times, want %d", fake.calls, len(tt.states)) + } + }) + } +} + +func TestWaitCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + fake := &fakeAccessAPI{states: []Access{{State: gql.PostgresAccessStatePending}}} + _, err := waitForAccess(ctx, fake, "team", "dev", "access-1", time.Hour) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected cancellation, got %v", err) + } +} diff --git a/internal/alpha/postgres/command/access.go b/internal/alpha/postgres/command/access.go new file mode 100644 index 00000000..b8a77ddf --- /dev/null +++ b/internal/alpha/postgres/command/access.go @@ -0,0 +1,172 @@ +package command + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "os/signal" + "strings" + "time" + + "github.com/nais/cli/internal/alpha/postgres" + "github.com/nais/cli/internal/alpha/postgres/command/flag" + "github.com/nais/cli/internal/alpha/postgres/relay" + "github.com/nais/cli/internal/naisapi/gql" + "github.com/nais/naistrix" +) + +func accessFlags(parent *flag.Postgres) *flag.Access { + return &flag.Access{Postgres: parent, AccessLevel: "read", TTL: 30 * time.Minute, Database: "app"} +} + +func psqlCommand(parent *flag.Postgres) *naistrix.Command { + f := accessFlags(parent) + return &naistrix.Command{ + Name: "psql", Title: "Connect to Nais Postgres via psql (experimental).", + Description: "Request personal access, open a local relay tunnel and start psql with end-to-end TLS verification.", + Args: []naistrix.Argument{{Name: "postgres"}}, Flags: f, + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + connection, err := requestAccess(ctx, args.Get("postgres"), f) + if err != nil { + return err + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return err + } + tunnelCtx, cancel := context.WithCancel(ctx) + defer cancel() + done := make(chan error, 1) + go func() { + done <- relay.Serve(tunnelCtx, listener, relay.Tunnel{Endpoint: connection.RelayEndpoint, Access: connection.RelayAccess, Token: connection.RelayToken}) + }() + defer func() { cancel(); <-done }() + ca, err := os.CreateTemp("", "nais-postgres-ca-*.crt") + if err != nil { + return err + } + defer os.Remove(ca.Name()) + defer ca.Close() + if err := ca.Chmod(0o600); err != nil { + return err + } + if _, err := ca.WriteString(connection.CACertificate); err != nil { + return err + } + if err := ca.Close(); err != nil { + return err + } + path, err := exec.LookPath("psql") + if err != nil { + return fmt.Errorf("psql not found: %w", err) + } + cmd := exec.CommandContext(ctx, path, "-X", "-w") + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd.Env = append(withoutPostgresEnv(os.Environ()), + "PGHOST="+connection.ServerName, "PGHOSTADDR=127.0.0.1", fmt.Sprintf("PGPORT=%d", listener.Addr().(*net.TCPAddr).Port), + "PGUSER="+connection.Username, "PGPASSWORD="+connection.Password, "PGDATABASE="+f.Database, + "PGSSLMODE=verify-full", "PGSSLROOTCERT="+ca.Name(), "PGCONNECT_TIMEOUT=10") + out.Println("Connecting with verified PostgreSQL TLS through the local relay...") + // psql handles Ctrl-C itself (query cancellation); do not terminate its relay. + interrupts := make(chan os.Signal, 1) + signal.Notify(interrupts, os.Interrupt) + defer signal.Stop(interrupts) + return cmd.Run() + }, + } +} + +func proxyCommand(parent *flag.Postgres) *naistrix.Command { + f := &flag.Proxy{Postgres: parent, AccessLevel: "read", TTL: 30 * time.Minute, Host: "127.0.0.1"} + return &naistrix.Command{ + Name: "proxy", Title: "Expose a Nais Postgres relay tunnel locally (experimental).", + Description: "Request personal access and listen on loopback. PostgreSQL clients must verify the server certificate; use psql for automatic TLS setup. Credentials are not printed unless --print-password is set.", + Args: []naistrix.Argument{{Name: "postgres"}}, Flags: f, + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + if net.ParseIP(f.Host) == nil || !net.ParseIP(f.Host).IsLoopback() { + return fmt.Errorf("--host must be a loopback IP address") + } + if f.Port < 0 || f.Port > 65535 { + return fmt.Errorf("--port must be between 0 and 65535") + } + connection, err := requestAccess(ctx, args.Get("postgres"), &flag.Access{ + Postgres: f.Postgres, Branch: f.Branch, AccessLevel: f.AccessLevel, Reason: f.Reason, TTL: f.TTL, + }) + if err != nil { + return err + } + listener, err := net.Listen("tcp", net.JoinHostPort(f.Host, fmt.Sprint(f.Port))) + if err != nil { + return err + } + ca, err := os.CreateTemp("", "nais-postgres-ca-*.crt") + if err != nil { + _ = listener.Close() + return err + } + defer os.Remove(ca.Name()) + if _, err := ca.WriteString(connection.CACertificate); err != nil { + _ = ca.Close() + _ = listener.Close() + return err + } + if err := ca.Close(); err != nil { + _ = listener.Close() + return err + } + out.Printf("Postgres relay listening at %s; user: %s; TLS server name: %s; CA: %s\n", listener.Addr(), connection.Username, connection.ServerName, ca.Name()) + out.Println("Use host= hostaddr= sslmode=verify-full sslrootcert=. For automatic setup use 'nais alpha postgres psql'.") + out.Println("Database password is only available here with --print-password (sensitive output).") + if f.PrintPassword { + out.Errorf("Warning: printing a database password; avoid terminal capture and shell history.\n") + out.Printf("Database password (sensitive): %s\n", connection.Password) + } + return relay.Serve(ctx, listener, relay.Tunnel{Endpoint: connection.RelayEndpoint, Access: connection.RelayAccess, Token: connection.RelayToken}) + }, + } +} + +func withoutPostgresEnv(env []string) []string { + ret := make([]string, 0, len(env)) + for _, item := range env { + if !strings.HasPrefix(item, "PG") { + ret = append(ret, item) + } + } + return ret +} + +func requestAccess(ctx context.Context, name string, f *flag.Access) (postgres.Connection, error) { + if f.Team == "" || f.Environment == "" { + return postgres.Connection{}, fmt.Errorf("--team and --environment are required") + } + if len(strings.TrimSpace(f.Reason)) < 10 { + return postgres.Connection{}, fmt.Errorf("--reason must contain at least 10 characters") + } + if f.TTL < time.Second || f.TTL > time.Hour { + return postgres.Connection{}, fmt.Errorf("--ttl must be between 1s and 1h") + } + levels := map[string]gql.PostgresAccessLevel{"read": gql.PostgresAccessLevelRead, "write": gql.PostgresAccessLevelReadwrite, "admin": gql.PostgresAccessLevelReadwritecreate} + level, ok := levels[f.AccessLevel] + if !ok { + return postgres.Connection{}, fmt.Errorf("--access-level must be read, write, or admin") + } + api, err := postgres.NewAPI(ctx) + if err != nil { + return postgres.Connection{}, err + } + branch := f.Branch + if branch == "" { + branch, err = api.ActiveBranch(ctx, f.Team, string(f.Environment), name) + if err != nil { + return postgres.Connection{}, err + } + } + ttl := f.TTL.String() + return postgres.CreateAndWait(ctx, api, gql.CreatePostgresAccessInput{ + Postgres: name, Branch: branch, TeamSlug: f.Team, EnvironmentName: string(f.Environment), + AccessLevel: level, Reason: f.Reason, Ttl: &ttl, + }) +} diff --git a/internal/alpha/postgres/command/access_test.go b/internal/alpha/postgres/command/access_test.go new file mode 100644 index 00000000..0eead293 --- /dev/null +++ b/internal/alpha/postgres/command/access_test.go @@ -0,0 +1,14 @@ +package command + +import ( + "slices" + "testing" +) + +func TestPsqlEnvironmentIgnoresInheritedPostgresSettings(t *testing.T) { + got := withoutPostgresEnv([]string{"PATH=/bin", "PGSERVICE=unsafe", "PGSSLMODE=disable", "PGPASSWORD=old", "HOME=/home/user"}) + want := []string{"PATH=/bin", "HOME=/home/user"} + if !slices.Equal(got, want) { + t.Fatalf("environment = %v, want %v", got, want) + } +} diff --git a/internal/alpha/postgres/command/flag/flag.go b/internal/alpha/postgres/command/flag/flag.go new file mode 100644 index 00000000..55fca69c --- /dev/null +++ b/internal/alpha/postgres/command/flag/flag.go @@ -0,0 +1,47 @@ +package flag + +import ( + "context" + "time" + + "github.com/nais/cli/internal/flags" + "github.com/nais/cli/internal/labels" + "github.com/nais/naistrix" +) + +type ( + Postgres struct{ *flags.GlobalFlags } + Access struct { + *Postgres + Branch string `name:"branch" usage:"Branch to access (defaults to the active branch)."` + AccessLevel string `name:"access-level" usage:"Access level: read, write, or admin."` + Reason string `name:"reason" usage:"Reason for personal access (at least 10 characters)."` + TTL time.Duration `name:"ttl" usage:"Requested lifetime (default 30m, maximum 1h)."` + Database string `name:"database" usage:"Database name for psql (default app)."` + } + Proxy struct { + *Postgres + Branch string `name:"branch" usage:"Branch to access (defaults to the active branch)."` + AccessLevel string `name:"access-level" usage:"Access level: read, write, or admin."` + Reason string `name:"reason" usage:"Reason for personal access (at least 10 characters)."` + TTL time.Duration `name:"ttl" usage:"Requested lifetime (default 30m, maximum 1h)."` + Host string `name:"host" usage:"Local loopback address (default 127.0.0.1)."` + Port int `name:"port" usage:"Local port (default random)."` + PrintPassword bool `name:"print-password" usage:"Print the database password to stdout (sensitive)."` + } + List struct { + *Postgres + Output Output `name:"output" short:"o" usage:"Format output (table or json)."` + Labels labels.LabelFilters `name:"label" short:"l" usage:"Filter by label in |KEY=VALUE| form. Can be repeated."` + } +) + +func (*List) LabelFacetResource() string { return "postgresBranches" } + +type Output string + +var _ naistrix.FlagAutoCompleter = (*Output)(nil) + +func (o *Output) AutoComplete(context.Context, *naistrix.Arguments, string, any) ([]string, string) { + return []string{"table", "json"}, "Available output formats." +} diff --git a/internal/alpha/postgres/command/postgres.go b/internal/alpha/postgres/command/postgres.go new file mode 100644 index 00000000..25d84a59 --- /dev/null +++ b/internal/alpha/postgres/command/postgres.go @@ -0,0 +1,51 @@ +package command + +import ( + "context" + + "github.com/nais/cli/internal/alpha/postgres" + "github.com/nais/cli/internal/alpha/postgres/command/flag" + "github.com/nais/cli/internal/flags" + "github.com/nais/cli/internal/labels" + "github.com/nais/naistrix" + "github.com/nais/naistrix/output" +) + +func Postgres(parentFlags *flags.GlobalFlags) *naistrix.Command { + flags := &flag.Postgres{GlobalFlags: parentFlags} + return &naistrix.Command{ + Name: "postgres", Title: "Manage Nais Postgres instances (experimental).", + Description: "Experimental commands for Nais Postgres branches and brokered personal access.", StickyFlags: flags, + SubCommands: []*naistrix.Command{listCommand(flags), psqlCommand(flags), proxyCommand(flags)}, + } +} + +func listCommand(parentFlags *flag.Postgres) *naistrix.Command { + flags := &flag.List{Postgres: parentFlags} + return &naistrix.Command{ + Name: "list", Title: "List Nais Postgres branches for a team.", + Description: "List Nais Postgres branches owned by a team.", Flags: flags, + RunFunc: func(ctx context.Context, _ *naistrix.Arguments, out *naistrix.OutputWriter) error { + labelFilters, err := labels.ParseFilters(flags.Labels) + if err != nil { + return err + } + var environments []string + if flags.Environment != "" { + environments = []string{string(flags.Environment)} + } + ret, err := postgres.GetTeamPostgresBranches(ctx, flags.Team, environments, labelFilters) + if err != nil { + return err + } + if flags.Output == "json" { + return out.JSON(output.JSONWithPrettyOutput()).Render(ret) + } + if len(ret) == 0 { + out.Println("Team has no Nais Postgres branches.") + return nil + } + return out.Table().Render(ret) + }, + } +} diff --git a/internal/alpha/postgres/list.go b/internal/alpha/postgres/list.go new file mode 100644 index 00000000..860a5852 --- /dev/null +++ b/internal/alpha/postgres/list.go @@ -0,0 +1,85 @@ +package postgres + +import ( + "context" + "fmt" + "slices" + "sort" + + "github.com/nais/cli/internal/naisapi" + "github.com/nais/cli/internal/naisapi/gql" + "github.com/nais/naistrix/output" +) + +const consoleBaseURL = "https://console.nav.cloud.nais.io" + +type Instance struct { + Name output.Link `json:"name"` + Type string `json:"type"` + Environment string `json:"environment"` + Version string `heading:"Version" json:"version"` + HighAvailability bool `heading:"HA" json:"high_availability"` + State State `json:"state"` +} + +type State string + +func (s State) String() string { + switch s { + case State(gql.PostgresBranchStateAvailable): + return "Available" + case State(gql.PostgresBranchStateProgressing): + return "Progressing" + case State(gql.PostgresBranchStateDegraded): + return "Degraded" + } + return "Unknown" +} + +func GetTeamPostgresBranches(ctx context.Context, team string, environments []string, labelFilters []gql.LabelFilter) ([]Instance, error) { + _ = `# @genqlient + query GetTeamPostgresBranchesAlpha($team: Slug!, $postgresFilter: PostgresBranchFilter) { + team(slug: $team) { + postgresBranches(first: 1000, filter: $postgresFilter) { + nodes { + name + teamEnvironment { environment { name } } + postgres { name majorVersion highAvailability } + state + } + } + } + } + ` + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, err + } + resp, err := gql.GetTeamPostgresBranchesAlpha(ctx, client, team, &gql.PostgresBranchFilter{Environments: environments, Labels: labelFilters}) + if err != nil { + return nil, err + } + return instancesFromTeam(resp.Team, team, environments), nil +} + +func instancesFromTeam(data gql.GetTeamPostgresBranchesAlphaTeam, team string, environments []string) []Instance { + var ret []Instance + for _, p := range data.PostgresBranches.Nodes { + env := p.TeamEnvironment.Environment.Name + if len(environments) > 0 && !slices.Contains(environments, env) { + continue + } + ret = append(ret, Instance{ + Name: output.Link{Name: p.Postgres.Name + "/" + p.Name, URL: fmt.Sprintf("%s/team/%s/%s/postgres/%s", consoleBaseURL, team, env, p.Postgres.Name)}, + Type: "PostgreSQL", Environment: env, Version: p.Postgres.MajorVersion, + HighAvailability: p.Postgres.HighAvailability, State: State(p.State), + }) + } + sort.Slice(ret, func(i, j int) bool { + if ret[i].Name.Name == ret[j].Name.Name { + return ret[i].Environment < ret[j].Environment + } + return ret[i].Name.Name < ret[j].Name.Name + }) + return ret +} diff --git a/internal/alpha/postgres/list_test.go b/internal/alpha/postgres/list_test.go new file mode 100644 index 00000000..52a96bcf --- /dev/null +++ b/internal/alpha/postgres/list_test.go @@ -0,0 +1,37 @@ +package postgres + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/nais/cli/internal/naisapi/gql" + "github.com/nais/naistrix/output" +) + +func TestInstancesFromTeam(t *testing.T) { + const teamData = `{"postgresBranches":{"nodes":[ + {"name":"preview","teamEnvironment":{"environment":{"name":"prod"}},"postgres":{"name":"orders","majorVersion":"16","highAvailability":true},"state":"PROGRESSING"}, + {"name":"main","teamEnvironment":{"environment":{"name":"dev"}},"postgres":{"name":"orders","majorVersion":"16","highAvailability":true},"state":"AVAILABLE"} + ]}}` + var data gql.GetTeamPostgresBranchesAlphaTeam + if err := json.Unmarshal([]byte(teamData), &data); err != nil { + t.Fatal(err) + } + dev := Instance{Name: output.Link{Name: "orders/main", URL: consoleBaseURL + "/team/my-team/dev/postgres/orders"}, Type: "PostgreSQL", Environment: "dev", Version: "16", HighAvailability: true, State: State(gql.PostgresBranchStateAvailable)} + prod := Instance{Name: output.Link{Name: "orders/preview", URL: consoleBaseURL + "/team/my-team/prod/postgres/orders"}, Type: "PostgreSQL", Environment: "prod", Version: "16", HighAvailability: true, State: State(gql.PostgresBranchStateProgressing)} + for _, tt := range []struct { + name string + environments []string + want []Instance + }{ + {name: "sorted branches", want: []Instance{dev, prod}}, + {name: "environment filter", environments: []string{"dev"}, want: []Instance{dev}}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := instancesFromTeam(data, "my-team", tt.environments); !reflect.DeepEqual(got, tt.want) { + t.Errorf("instancesFromTeam() = %#v, want %#v", got, tt.want) + } + }) + } +} diff --git a/internal/alpha/postgres/relay/relay.go b/internal/alpha/postgres/relay/relay.go new file mode 100644 index 00000000..58e03c17 --- /dev/null +++ b/internal/alpha/postgres/relay/relay.go @@ -0,0 +1,113 @@ +// Package relay forwards local TCP connections over authenticated HTTP/3 CONNECT streams. +package relay + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + + "github.com/quic-go/quic-go/http3" +) + +// Tunnel is a brokered relay endpoint and owner-only proof. Never log its token. +type Tunnel struct{ Endpoint, Access, Token string } + +func (t Tunnel) request(ctx context.Context, body io.Reader) (*http.Request, error) { + u, err := url.Parse(t.Endpoint) + if err != nil || u.Scheme != "https" || u.Host == "" || u.Path != "" || u.RawQuery != "" || u.User != nil || u.Fragment != "" { + return nil, fmt.Errorf("invalid relay endpoint") + } + if !strings.Contains(t.Access, "/") || t.Token == "" { + return nil, fmt.Errorf("invalid relay access credentials") + } + req, err := http.NewRequestWithContext(ctx, http.MethodConnect, t.Endpoint, body) + if err != nil { + return nil, err + } + req.Host = u.Host // CONNECT authority is the relay, not the database target. + req.Header.Set("Authorization", "Bearer "+t.Token) + req.Header.Set("Relay-Access", t.Access) + return req, nil +} + +// Serve forwards each TCP connection to the relay until ctx is cancelled. +func Serve(ctx context.Context, listener net.Listener, tunnel Tunnel) error { + transport := &http3.Transport{} + defer transport.Close() + defer listener.Close() + stop := context.AfterFunc(ctx, func() { _ = listener.Close() }) + defer stop() + for { + conn, err := listener.Accept() + if err != nil { + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("accept local connection: %w", err) + } + go func() { + defer conn.Close() + if err := forward(ctx, transport, tunnel, conn); err != nil && ctx.Err() == nil { + // An individual connection must not terminate other local clients. + // Callers can retry; no credentials are included in the error. + fmt.Fprintf(os.Stderr, "postgres relay connection failed: %v\n", err) + } + }() + } +} + +func forward(ctx context.Context, transport *http3.Transport, tunnel Tunnel, local net.Conn) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + stop := context.AfterFunc(ctx, func() { _ = local.Close() }) + defer stop() + reader, writer := io.Pipe() + defer reader.Close() + defer writer.Close() + req, err := tunnel.request(ctx, reader) + if err != nil { + return err + } + done := make(chan error, 1) + go func() { + _, err := io.Copy(writer, local) + _ = writer.CloseWithError(err) // Send HTTP request FIN when TCP input is closed. + done <- err + }() + response, err := transport.RoundTrip(req) + if err != nil { + _ = local.Close() + _ = reader.CloseWithError(err) + <-done + return fmt.Errorf("relay CONNECT: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _ = local.Close() + _ = reader.Close() + <-done + return fmt.Errorf("relay CONNECT returned HTTP %d", response.StatusCode) + } + _, downloadErr := io.Copy(local, response.Body) + if tcp, ok := local.(interface{ CloseWrite() error }); ok && downloadErr == nil { + downloadErr = tcp.CloseWrite() + } + if downloadErr != nil { + _ = local.Close() + _ = reader.CloseWithError(downloadErr) + } + uploadErr := <-done + if downloadErr != nil { + return downloadErr + } + if uploadErr != nil && !errors.Is(uploadErr, net.ErrClosed) { + return uploadErr + } + return nil +} diff --git a/internal/alpha/postgres/relay/relay_test.go b/internal/alpha/postgres/relay/relay_test.go new file mode 100644 index 00000000..2ebd809d --- /dev/null +++ b/internal/alpha/postgres/relay/relay_test.go @@ -0,0 +1,133 @@ +package relay + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "io" + "math/big" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/quic-go/quic-go/http3" +) + +func TestConnectStreamsAfterHalfClose(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + certDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, DNSNames: []string{"localhost"}, + NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }, &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, DNSNames: []string{"localhost"}, + NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) + if err != nil { + t.Fatal(err) + } + roots := x509.NewCertPool() + roots.AddCert(cert.Leaf) + packet, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "Bearer denied" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.Method != http.MethodConnect || r.Host != "localhost:"+strings.Split(packet.LocalAddr().String(), ":")[1] || r.Header.Get("Authorization") != "Bearer proof" || r.Header.Get("Relay-Access") != "team/access" { + t.Errorf("unexpected CONNECT: method=%s host=%s auth=%s access=%s", r.Method, r.Host, r.Header.Get("Authorization"), r.Header.Get("Relay-Access")) + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + _ = http.NewResponseController(w).Flush() + data, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + return + } + _, _ = w.Write([]byte("reply:" + string(data))) + }) + server := &http3.Server{Handler: handler, TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}} + serverDone := make(chan struct{}) + go func() { defer close(serverDone); _ = server.Serve(packet) }() + defer func() { _ = server.Close(); <-serverDone; _ = packet.Close() }() + transport := &http3.Transport{TLSClientConfig: &tls.Config{RootCAs: roots, ServerName: "localhost"}} + defer transport.Close() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + client, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer client.Close() + _ = client.SetDeadline(time.Now().Add(5 * time.Second)) + local, err := listener.Accept() + if err != nil { + t.Fatal(err) + } + defer local.Close() + done := make(chan error, 1) + go func() { + done <- forward(context.Background(), transport, Tunnel{Endpoint: "https://localhost:" + strings.Split(packet.LocalAddr().String(), ":")[1], Access: "team/access", Token: "proof"}, local) + }() + _, _ = client.Write([]byte("hello")) + _ = client.(*net.TCPConn).CloseWrite() + data, err := io.ReadAll(client) + if err != nil || string(data) != "reply:hello" { + t.Fatalf("reply %q: %v", data, err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + deniedClient, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer deniedClient.Close() + deniedLocal, err := listener.Accept() + if err != nil { + t.Fatal(err) + } + defer deniedLocal.Close() + denied := make(chan error, 1) + go func() { + denied <- forward(context.Background(), transport, Tunnel{Endpoint: "https://localhost:" + strings.Split(packet.LocalAddr().String(), ":")[1], Access: "team/access", Token: "denied"}, deniedLocal) + }() + select { + case err := <-denied: + if err == nil || !strings.Contains(err.Error(), "401") { + t.Fatalf("expected 401, got %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("denied stream did not close") + } +} + +func TestRequestRejectsUntrustedEndpoint(t *testing.T) { + for _, endpoint := range []string{"http://relay", "https://relay/path", "https://user@relay", "https://relay?target=db"} { + if _, err := (Tunnel{Endpoint: endpoint, Access: "team/access", Token: "proof"}).request(context.Background(), nil); err == nil { + t.Errorf("accepted %q", endpoint) + } + } +} diff --git a/internal/application/application.go b/internal/application/application.go index 2c3d90ff..f88aa193 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -12,6 +12,7 @@ import ( appCommand "github.com/nais/cli/internal/app/command" applyCommand "github.com/nais/cli/internal/apply/command" authCommand "github.com/nais/cli/internal/auth/command" + cloudsqlCommand "github.com/nais/cli/internal/cloudsql/command" configCommand "github.com/nais/cli/internal/config/command" debugCommand "github.com/nais/cli/internal/debug/command" "github.com/nais/cli/internal/flags" @@ -86,6 +87,7 @@ func New(w io.Writer) (*Application, *flags.GlobalFlags, error) { naisdeviceCommand.Naisdevice(globalFlags), opensearchCommand.OpenSearch(globalFlags), postgresCommand.Postgres(globalFlags), + cloudsqlCommand.CloudSQL(globalFlags), secretCommand.Secrets(globalFlags), statusCommand.Status(globalFlags), validateCommand.Validate(globalFlags), diff --git a/internal/cloudsql/access.go b/internal/cloudsql/access.go new file mode 100644 index 00000000..18074b19 --- /dev/null +++ b/internal/cloudsql/access.go @@ -0,0 +1,99 @@ +package cloudsql + +import ( + "context" + "database/sql" + "strings" + + "github.com/lib/pq" + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/naistrix" +) + +var grantAllPrivs = `ALTER DEFAULT PRIVILEGES IN SCHEMA $schema GRANT ALL ON TABLES TO cloudsqliamuser; + ALTER DEFAULT PRIVILEGES IN SCHEMA $schema GRANT ALL ON SEQUENCES TO cloudsqliamuser; + GRANT ALL ON ALL TABLES IN SCHEMA $schema TO cloudsqliamuser; + GRANT ALL ON ALL SEQUENCES IN SCHEMA $schema TO cloudsqliamuser; + GRANT CREATE ON SCHEMA $schema TO cloudsqliamuser;` + +var grantSelectPrivs = `GRANT USAGE ON SCHEMA $schema TO cloudsqliamuser; + ALTER DEFAULT PRIVILEGES IN SCHEMA $schema GRANT SELECT ON TABLES TO cloudsqliamuser; + ALTER DEFAULT PRIVILEGES IN SCHEMA $schema GRANT SELECT ON SEQUENCES TO cloudsqliamuser; + GRANT SELECT ON ALL TABLES IN SCHEMA $schema TO cloudsqliamuser; + GRANT SELECT ON ALL SEQUENCES IN SCHEMA $schema TO cloudsqliamuser;` + +// this is used for all privileges and select, as it covers both cases +var revokeAllPrivs = `ALTER DEFAULT PRIVILEGES IN SCHEMA $schema REVOKE ALL ON TABLES FROM cloudsqliamuser; + ALTER DEFAULT PRIVILEGES IN SCHEMA $schema REVOKE ALL ON SEQUENCES FROM cloudsqliamuser; + REVOKE ALL ON ALL TABLES IN SCHEMA $schema FROM cloudsqliamuser; + REVOKE ALL ON ALL SEQUENCES IN SCHEMA $schema FROM cloudsqliamuser; + REVOKE CREATE ON SCHEMA $schema FROM cloudsqliamuser;` + +var ( + grantUsage = `GRANT USAGE ON SCHEMA $schema TO cloudsqliamuser;` + revokeUsage = `REVOKE USAGE ON SCHEMA $schema FROM cloudsqliamuser;` +) + +func PrepareAccess(ctx context.Context, appName, team, environment string, fl *flag.Prepare, out *naistrix.OutputWriter) error { + // Get secret values (access is logged for audit purposes) + sv, err := GetSecretValues(ctx, appName, team, environment, fl.CloudSQL, ReasonPrepareAccess, out) + if err != nil { + return err + } + + prependUsageIfNotPublic := func(statement string) string { + if fl.Schema != "public" { + return grantUsage + "\n" + statement + } + return statement + } + + if fl.AllPrivileges { + return sqlExecAsAppUser(ctx, appName, team, environment, fl.Schema, prependUsageIfNotPublic(grantAllPrivs), sv) + } else { + return sqlExecAsAppUser(ctx, appName, team, environment, fl.Schema, prependUsageIfNotPublic(grantSelectPrivs), sv) + } +} + +func RevokeAccess(ctx context.Context, appName, team, environment string, fl *flag.Revoke, out *naistrix.OutputWriter) error { + // Get secret values (access is logged for audit purposes) + sv, err := GetSecretValues(ctx, appName, team, environment, fl.CloudSQL, ReasonRevokeAccess, out) + if err != nil { + return err + } + + q := revokeAllPrivs + if fl.Schema != "public" { + q += "\n" + revokeUsage + } + return sqlExecAsAppUser(ctx, appName, team, environment, fl.Schema, q, sv) +} + +func sqlExecAsAppUser(ctx context.Context, appName, team, environment string, schema, statement string, sv *SecretValues) error { + dbInfo, err := NewDBInfo(ctx, appName, team, environment) + if err != nil { + return err + } + + dbInfo.SetSecretValues(sv) + + connectionInfo, err := dbInfo.DBConnection(ctx) + if err != nil { + return err + } + + schema = pq.QuoteIdentifier(schema) + statement = strings.ReplaceAll(statement, "$schema", schema) + db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) + if err != nil { + return err + } + defer func() { _ = db.Close() }() + + _, err = db.ExecContext(ctx, statement) + if err != nil { + return formatInvalidGrantError(err) + } + + return nil +} diff --git a/internal/cloudsql/audit.go b/internal/cloudsql/audit.go new file mode 100644 index 00000000..f9545b29 --- /dev/null +++ b/internal/cloudsql/audit.go @@ -0,0 +1,305 @@ +package cloudsql + +import ( + "context" + "database/sql" + "fmt" + + "github.com/lib/pq" + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/naistrix" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func EnableAuditLogging(ctx context.Context, appName, team, environment string, fl *flag.EnableAudit, out *naistrix.OutputWriter) error { + // Get secret values (access is logged for audit purposes) + sv, err := GetSecretValues(ctx, appName, team, environment, fl.CloudSQL, ReasonEnableAudit, out) + if err != nil { + return err + } + return enableAuditAsAppUser(ctx, appName, team, environment, sv, out) +} + +func VerifyAuditLogging(ctx context.Context, appName, team, environment string, fl *flag.VerifyAudit, out *naistrix.OutputWriter) error { + // Get secret values (access is logged for audit purposes) + sv, err := GetSecretValues(ctx, appName, team, environment, fl.CloudSQL, ReasonVerifyAudit, out) + if err != nil { + return err + } + _, err = verifyAuditAsAppUser(ctx, appName, team, environment, sv, out) + return err +} + +func enableAuditAsAppUser(ctx context.Context, appName, team, environment string, sv *SecretValues, out *naistrix.OutputWriter) error { + dbInfo, err := NewDBInfo(ctx, appName, team, environment) + if err != nil { + return err + } + + dbInfo.SetSecretValues(sv) + + connectionInfo, err := dbInfo.DBConnection(ctx) + if err != nil { + return err + } + + cloudSQLDbInfo := dbInfo + + err = validateAuditFlags(ctx, cloudSQLDbInfo) + if err != nil { + return fmt.Errorf("required flags missing for instance: %v", err) + } + + isConfigured, err := checkAuditConfigured(ctx, connectionInfo) + if err != nil { + return fmt.Errorf("error checking audit configuration: %w", err) + } + + if isConfigured { + out.Println("✅ Audit is already properly configured. No changes needed.") + return nil + } + + // If we get here, we need to enable audit + out.Println("Audit configuration needs to be updated...\n") + + db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) + if err != nil { + return err + } + + defer func() { _ = db.Close() }() + + _, err = db.ExecContext(ctx, "CREATE EXTENSION IF NOT EXISTS pgaudit") + if err != nil { + return fmt.Errorf("enableAuditAsAppUser: error creating pgaudit extension: %w", err) + } + + alterUserQuery := fmt.Sprintf( + "ALTER USER %s IN DATABASE %s SET pgaudit.log TO 'none'", + pq.QuoteIdentifier(connectionInfo.username), + pq.QuoteIdentifier(connectionInfo.dbName), + ) + _, err = db.ExecContext(ctx, alterUserQuery) + if err != nil { + return fmt.Errorf("enableAuditAsAppUser: error configuring pgaudit.log: %w", err) + } + + out.Println("✅ Successfully enabled audit extension and configured pgaudit.log for application user") + return nil +} + +func checkAuditConfigured(ctx context.Context, connectionInfo *ConnectionInfo) (bool, error) { + db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) + if err != nil { + return false, err + } + defer func() { _ = db.Close() }() + + // Check if pgaudit extension is installed + var extensionExists bool + checkExtensionQuery := "SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pgaudit')" + err = db.QueryRowContext(ctx, checkExtensionQuery).Scan(&extensionExists) + if err != nil { + return false, err + } + + if !extensionExists { + return false, nil + } + + // Check pgaudit.log setting for the application user + var pgauditLogValue string + checkSettingQuery := "SELECT setting FROM pg_settings WHERE name = 'pgaudit.log'" + err = db.QueryRowContext(ctx, checkSettingQuery).Scan(&pgauditLogValue) + if err != nil { + return false, err + } + + // Check if it's set to 'none' + return pgauditLogValue == "none", nil +} + +func validateAuditFlags(ctx context.Context, info *CloudSQLDBInfo) error { + dbFlags, err := getDBFlags(ctx, info) + if err != nil { + return fmt.Errorf("validateAuditFlags: error getting db flags: %w", err) + } + + requiredFlags := []string{ + "cloudsql.enable_pgaudit", + "pgaudit.log", + "pgaudit.log_parameter", + "pgaudit.log_relation", + } + + err = validateRequiredFlags(dbFlags, requiredFlags) + if err != nil { + return fmt.Errorf("validateAuditFlags: %v", err) + } + return nil +} + +func validateRequiredFlags(dbFlags map[string]string, requiredFlags []string) error { + for _, reqFlag := range requiredFlags { + if _, exists := dbFlags[reqFlag]; !exists { + return fmt.Errorf("required flag %q missing", reqFlag) + } + } + + return nil +} + +func getDBFlags(ctx context.Context, info *CloudSQLDBInfo) (map[string]string, error) { + dbFlags := make(map[string]string) + sqlInstances, err := info.dynamicClient.Resource(schema.GroupVersionResource{ + Group: "sql.cnrm.cloud.google.com", + Version: "v1beta1", + Resource: "sqlinstances", + }).Namespace(info.namespace).List(ctx, v1.ListOptions{ + LabelSelector: "app=" + info.appName, + }) + if err != nil { + return dbFlags, fmt.Errorf("GetDBInstance: error looking for sqlinstance %q in %q: %w", info.appName, info.namespace, err) + } + + if len(sqlInstances.Items) != 1 { + return dbFlags, fmt.Errorf("GetDBInstance: expected one sqlinstance for app %q in %q, got %d", info.appName, info.namespace, len(sqlInstances.Items)) + } + + spec, ok := sqlInstances.Items[0].Object["spec"].(map[string]any) + if !ok { + return dbFlags, fmt.Errorf("GetDBInstance: error accessing spec for app %q in %q", info.appName, info.namespace) + } + + settings, ok := spec["settings"].(map[string]any) + if !ok { + return dbFlags, fmt.Errorf("GetDBInstance: error accessing settings for app %q in %q", info.appName, info.namespace) + } + + databaseFlags, ok := settings["databaseFlags"].([]any) + if !ok { + return dbFlags, fmt.Errorf("GetDBInstance: error accessing databaseFlags for app %q in %q", info.appName, info.namespace) + } + + for _, flag := range databaseFlags { + f, ok := flag.(map[string]any) + if !ok { + return dbFlags, fmt.Errorf("GetDBInstance: error accessing databaseFlags for app %q in %q", info.appName, info.namespace) + } + name, nameOk := f["name"].(string) + value, valueOk := f["value"].(string) + if nameOk && valueOk { + dbFlags[name] = value + } + + } + + return dbFlags, nil +} + +func verifyAuditAsAppUser(ctx context.Context, appName, team, environment string, sv *SecretValues, out *naistrix.OutputWriter) (bool, error) { + dbInfo, err := NewDBInfo(ctx, appName, team, environment) + if err != nil { + return false, err + } + + dbInfo.SetSecretValues(sv) + + connectionInfo, err := dbInfo.DBConnection(ctx) + if err != nil { + return false, err + } + + cloudSQLDbInfo := dbInfo + + out.Println("\nVerifying audit configuration for application: " + appName + "\n") + + dbFlags, err := getDBFlags(ctx, cloudSQLDbInfo) + if err != nil { + return false, fmt.Errorf("error getting db flags: %w", err) + } + + enablePgaudit, enableExists := dbFlags["cloudsql.enable_pgaudit"] + if !enableExists { + out.Println(" ❌ Flag cloudsql.enable_pgaudit is missing") + return false, fmt.Errorf("cloudsql.enable_pgaudit flag is not set") + } + if enablePgaudit != "on" && enablePgaudit != "true" { + out.Printf(" ❌ Flag cloudsql.enable_pgaudit: expected on or true, got %s\n", enablePgaudit) + return false, fmt.Errorf("cloudsql.enable_pgaudit must be set to 'on' or 'true'") + } + out.Printf(" ✅ Flag cloudsql.enable_pgaudit = %s\n", enablePgaudit) + + pgauditLog, logExists := dbFlags["pgaudit.log"] + if !logExists { + out.Println(" ❌ Flag pgaudit.log is missing") + return false, fmt.Errorf("pgaudit.log flag is not set") + } + out.Printf(" ✅ Flag pgaudit.log = %s\n", pgauditLog) + + logParameter, paramExists := dbFlags["pgaudit.log_parameter"] + if !paramExists { + out.Println(" ❌ Flag pgaudit.log_parameter is missing") + return false, fmt.Errorf("pgaudit.log_parameter flag is not set") + } + if logParameter != "on" && logParameter != "true" { + out.Printf(" ❌ Flag pgaudit.log_parameter: expected on or true, got %s\n", logParameter) + return false, fmt.Errorf("pgaudit.log_parameter must be set to 'on' or 'true'") + } + out.Printf(" ✅ Flag pgaudit.log_parameter = %s\n", logParameter) + + logRelation, relationExists := dbFlags["pgaudit.log_relation"] + if !relationExists { + out.Println(" ❌ Flag pgaudit.log_relation is missing") + return false, fmt.Errorf("pgaudit.log_relation flag is not set") + } + if logRelation != "on" && logRelation != "true" { + out.Printf(" ❌ Flag pgaudit.log_relation: expected on, got %s\n", logRelation) + return false, fmt.Errorf("pgaudit.log_relation must be set to 'on'") + } + out.Printf(" ✅ Flag pgaudit.log_relation = %s\n", logRelation) + + db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) + if err != nil { + return false, fmt.Errorf("error connecting to database: %w", err) + } + defer func() { _ = db.Close() }() + + err = db.PingContext(ctx) + if err != nil { + return false, fmt.Errorf("error pinging database: %w", err) + } + + var extensionExists bool + checkExtensionQuery := "SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pgaudit')" + err = db.QueryRowContext(ctx, checkExtensionQuery).Scan(&extensionExists) + if err != nil { + return false, fmt.Errorf("error checking pgaudit extension: %w", err) + } + + if !extensionExists { + out.Println("\n ❌ pgaudit extension is not installed") + return false, nil + } + out.Println("\n ✅ pgaudit extension is installed") + + var pgauditLogValue string + checkSettingQuery := "SELECT setting FROM pg_settings WHERE name = 'pgaudit.log'" + err = db.QueryRowContext(ctx, checkSettingQuery).Scan(&pgauditLogValue) + if err != nil { + return false, fmt.Errorf("error checking pgaudit.log setting from pg_settings: %w", err) + } + + expectedValue := "none" + if pgauditLogValue != expectedValue { + out.Printf(" ❌ pgaudit.log setting for application user: expected %s, got %s\n", expectedValue, pgauditLogValue) + return false, nil + } + + out.Printf(" ✅ pgaudit.log setting for application user: %s\n", pgauditLogValue) + out.Println("\n✅ All audit configurations are correct!") + + return true, nil +} diff --git a/internal/cloudsql/cloudsqldbinfo.go b/internal/cloudsql/cloudsqldbinfo.go new file mode 100644 index 00000000..48e6fba9 --- /dev/null +++ b/internal/cloudsql/cloudsqldbinfo.go @@ -0,0 +1,314 @@ +package cloudsql + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "cloud.google.com/go/cloudsqlconn" + "github.com/GoogleCloudPlatform/cloudsql-proxy/logging" + "github.com/nais/naistrix" + core_v1 "k8s.io/api/core/v1" + meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type CloudSQLDBInfo struct { + *DBInfo + projectID string + connectionName string + secretValues *SecretValues +} + +func (i *CloudSQLDBInfo) SetSecretValues(sv *SecretValues) { + i.secretValues = sv +} + +func (i *CloudSQLDBInfo) ProjectID(ctx context.Context) (string, error) { + if i.projectID == "" { + err := i.fetchDBInstance(ctx) + if err != nil { + return "", err + } + } + return i.projectID, nil +} + +func (i *CloudSQLDBInfo) ConnectionName(ctx context.Context) (string, error) { + if i.connectionName == "" { + err := i.fetchDBInstance(ctx) + if err != nil { + return "", err + } + } + return i.connectionName, nil +} + +func (i *CloudSQLDBInfo) DBConnection(ctx context.Context) (*ConnectionInfo, error) { + if i.secretValues == nil { + return nil, fmt.Errorf("secret values not set for %q; call SetSecretValues before DBConnection", i.appName) + } + + connectionName, err := i.ConnectionName(ctx) + if err != nil { + return nil, err + } + + return createConnectionInfoFromSecretValues(ctx, i.secretValues, connectionName) +} + +func createConnectionInfoFromSecretValues(ctx context.Context, sv *SecretValues, instance string) (*ConnectionInfo, error) { + var pgUrl *url.URL + var jdbcUrl *url.URL + for name, val := range sv.values { + if strings.HasSuffix(name, "_URL") { + u, err := url.Parse(val) + if err != nil { + continue + } + if strings.HasSuffix(name, "_JDBC_URL") { + jdbcUrl = u + } else { + pgUrl = u + } + } + } + + email, err := currentEmail(ctx) + if err != nil { + return nil, err + } + + return &ConnectionInfo{ + username: sv.Get("_USERNAME"), + email: email, + password: sv.Get("_PASSWORD"), + dbName: sv.Get("_DATABASE"), + port: sv.Get("_PORT"), + url: pgUrl, + jdbcUrl: jdbcUrl, + instance: instance, + }, nil +} + +func createConnectionInfo(ctx context.Context, secret core_v1.Secret, instance string) (*ConnectionInfo, error) { + var pgUrl *url.URL + var jdbcUrl *url.URL + var err error + for name, val := range secret.Data { + if strings.HasSuffix(name, "_URL") { + value := string(val) + if strings.HasSuffix(name, "_JDBC_URL") { + jdbcUrl, err = url.Parse(value) + } else { + pgUrl, err = url.Parse(value) + } + if err != nil { + panic(err) + } + } + } + + email, err := currentEmail(ctx) + if err != nil { + return nil, err + } + + return &ConnectionInfo{ + username: getSecretDataValue(secret, "_USERNAME"), + email: email, + password: getSecretDataValue(secret, "_PASSWORD"), + dbName: getSecretDataValue(secret, "_DATABASE"), + port: getSecretDataValue(secret, "_PORT"), + url: pgUrl, + jdbcUrl: jdbcUrl, + instance: instance, + }, nil +} + +func getSecretDataValue(secret core_v1.Secret, suffix string) string { + for name, val := range secret.Data { + if strings.HasSuffix(name, suffix) { + return string(val) + } + } + return "" +} + +func (i *CloudSQLDBInfo) fetchDBInstance(ctx context.Context) error { + sqlInstances, err := i.dynamicClient.Resource(schema.GroupVersionResource{ + Group: "sql.cnrm.cloud.google.com", + Version: "v1beta1", + Resource: "sqlinstances", + }).Namespace(string(i.namespace)).List(ctx, meta_v1.ListOptions{ + LabelSelector: "app=" + i.appName, + }) + if err != nil { + return fmt.Errorf("fetchDBInstance: error looking for sqlinstance %q in %q: %w", i.appName, i.namespace, err) + } + + if len(sqlInstances.Items) == 0 { + return fmt.Errorf("fetchDBInstance: no sqlinstance found for app %q in %q", i.appName, i.namespace) + } else if len(sqlInstances.Items) > 1 { + return fmt.Errorf("fetchDBInstance: multiple sqlinstances found for app %q in %q", i.appName, i.namespace) + } + + sqlInstance := sqlInstances.Items[0] + + connectionName, ok, err := unstructured.NestedString(sqlInstance.Object, "status", "connectionName") + if !ok || err != nil { + return fmt.Errorf("missing 'connectionName' status field; run 'kubectl describe sqlinstance %s' and check for status failures", sqlInstance.GetName()) + } + + i.connectionName = connectionName + i.projectID = sqlInstance.GetAnnotations()["cnrm.cloud.google.com/project-id"] + return nil +} + +func (d *CloudSQLDBInfo) RunProxy(ctx context.Context, host string, port *uint, portCh chan<- int, out *naistrix.OutputWriter, printInstructions bool) error { + projectID, err := d.ProjectID(ctx) + if err != nil { + return err + } + + connectionName, err := d.ConnectionName(ctx) + if err != nil { + return err + } + + if port == nil { + port = new(uint(0)) + } + address := fmt.Sprintf("%s:%d", host, *port) + + if printInstructions { + connectionInfo, err := d.DBConnection(ctx) + if err != nil { + return err + } + + email, err := currentEmail(ctx) + if err != nil { + return err + } + + out.Printf("Starting proxy on %v\n", address) + out.Println("If you are using psql, you can connect to the database by running:") + out.Printf("psql -h %v -p %d -U %v %v\n", host, *port, email, connectionInfo.dbName) + out.Println() + out.Println("If you are using a JDBC client, you can connect to the database by using the following connection string:") + out.Printf("Connection URL: jdbc:postgresql://%v/%v?user=%v\n", address, connectionInfo.dbName, email) + out.Println() + out.Println("If you get asked for a password, you can leave it blank. Check your Cloud SQL IAM access if authentication fails.") + } + + err = runProxy(ctx, projectID, connectionName, address, portCh, out) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil + } + + fmt.Fprintln(os.Stderr, "\nERROR:", err) + } + + return nil +} + +func runProxy(ctx context.Context, projectID, connectionName, address string, port chan<- int, out *naistrix.OutputWriter) error { + err := checkDatabasePassword(out) + if err != nil { + return err + } + + logging.Verbosef = func(format string, v ...any) { out.Verbosef(format, v...) } + logging.Infof = func(format string, v ...any) { out.Infof(format, v...) } + logging.Errorf = func(format string, v ...any) { out.Errorf(format, v...) } + + if err := grantUserAccess(ctx, projectID, "roles/cloudsql.instanceUser", 1*time.Hour, out); err != nil { + return err + } + + opts := []cloudsqlconn.Option{ + cloudsqlconn.WithIAMAuthN(), + } + d, err := cloudsqlconn.NewDialer(ctx, opts...) + if err != nil { + return fmt.Errorf("failed to create dialer: %w", err) + } + + if err := d.Warmup(ctx, connectionName); err != nil { + return fmt.Errorf("failed to warmup connection: %w", err) + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + ctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) + defer stop() + + lc := net.ListenConfig{} + listener, err := lc.Listen(ctx, "tcp", address) + if err != nil { + return fmt.Errorf("failed to listen on TCP address: %w", err) + } + + out.Infof("Listening on %s\n", listener.Addr().String()) + + port <- listener.Addr().(*net.TCPAddr).Port + + go func() { + <-ctx.Done() + if err := listener.Close(); err != nil { + out.Println("error closing listener", err) + } + }() + + wg := sync.WaitGroup{} + for ctx.Err() == nil { + conn, err := listener.Accept() + if err != nil { + out.Println("error accepting connection", err) + time.Sleep(100 * time.Millisecond) + continue + } + + out.Infof("New connection %s\n", conn.RemoteAddr()) + wg.Go(func() { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + go func() { + <-ctx.Done() + if err := conn.Close(); err != nil { + out.Println("error closing connection", err) + } + }() + + conn2, err := d.Dial(ctx, connectionName) + if err != nil { + out.Println("error dialing connection", err) + return + } + defer func() { _ = conn2.Close() }() + + closer := make(chan struct{}, 2) + go copy(closer, conn2, conn) + go copy(closer, conn, conn2) + <-closer + out.Infof("Connection complete %s\n", conn.RemoteAddr()) + }) + } + + out.Infof("Waiting for connections to close\n") + wg.Wait() + + return nil +} diff --git a/internal/cloudsql/command/cloudsql.go b/internal/cloudsql/command/cloudsql.go new file mode 100644 index 00000000..4e6dc313 --- /dev/null +++ b/internal/cloudsql/command/cloudsql.go @@ -0,0 +1,39 @@ +package command + +import ( + "context" + + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/flags" + "github.com/nais/cli/internal/gcloud" + "github.com/nais/naistrix" +) + +func CloudSQL(parentFlags *flags.GlobalFlags) *naistrix.Command { + flags := &flag.CloudSQL{ + GlobalFlags: parentFlags, + } + + return &naistrix.Command{ + Name: "cloudsql", + Title: "Manage Google Cloud SQL instances.", + Description: "Manage Google Cloud SQL instances, including listing, migration, user management, password rotation, and direct database access.", + StickyFlags: flags, + SubCommands: []*naistrix.Command{ + listCommand(flags), + migrateCommand(flags), + passwordCommand(flags), + usersCommand(flags), + enableAuditCommand(flags), + verifyAuditCommand(flags), + prepareCommand(flags), + proxyCommand(flags), + psqlCommand(flags), + revokeCommand(flags), + }, + ValidateFunc: func(ctx context.Context, _ *naistrix.Arguments) error { + _, err := gcloud.ValidateAndGetUserLogin(ctx, false) + return err + }, + } +} diff --git a/internal/cloudsql/command/enable_audit.go b/internal/cloudsql/command/enable_audit.go new file mode 100644 index 00000000..3240f624 --- /dev/null +++ b/internal/cloudsql/command/enable_audit.go @@ -0,0 +1,32 @@ +package command + +import ( + "context" + + "github.com/nais/cli/internal/cloudsql" + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/metric" + "github.com/nais/cli/internal/validation" + "github.com/nais/naistrix" +) + +func enableAuditCommand(parentFlags *flag.CloudSQL) *naistrix.Command { + flags := &flag.EnableAudit{CloudSQL: parentFlags} + return &naistrix.Command{ + Name: "enable-audit", + Title: "Enable audit extension in SQL instance database.", + Description: "This is done by creating pgaudit extension in the database and enabling audit logging for personal user accounts.", + Args: []naistrix.Argument{ + {Name: "app_name"}, + }, + Flags: flags, + ValidateFunc: validation.RequireTeamAndEnvironment(flags), + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + err := cloudsql.EnableAuditLogging(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) + if err != nil { + metric.CreateAndIncreaseCounter(ctx, "enable_audit_logging_error") + } + return err + }, + } +} diff --git a/internal/cloudsql/command/flag/flag.go b/internal/cloudsql/command/flag/flag.go new file mode 100644 index 00000000..a540ca69 --- /dev/null +++ b/internal/cloudsql/command/flag/flag.go @@ -0,0 +1,115 @@ +package flag + +import ( + "context" + + "github.com/nais/cli/internal/flags" + "github.com/nais/cli/internal/labels" + "github.com/nais/naistrix" +) + +type CloudSQL struct { + *flags.GlobalFlags + Reason string `name:"reason" short:"r" usage:"Justification for accessing the database. Required for audit logging."` +} + +type Migrate struct { + *CloudSQL + DryRun bool `name:"dry-run" usage:"Perform a dry run of the migration without applying changes."` +} + +type MigrateSetup struct { + *Migrate + Tier string `name:"tier" usage:"The |TIER| of the new instance."` + DiskAutoResize bool `name:"disk-auto-resize" usage:"Enable automatic disk resizing for the new instance."` + DiskSize int `name:"disk-size" usage:"The |DISK_SIZE| of the new instance."` + InstanceType string `name:"instance-type" usage:"The |TYPE| of the new instance."` + NoWait bool `name:"no-wait" usage:"Do not wait for the job to complete."` +} + +type MigratePromote struct { + *Migrate + NoWait bool `name:"no-wait" usage:"Do not wait for the job to complete."` +} + +type MigrateFinalize struct { + *Migrate +} + +type MigrateRollback struct { + *Migrate +} + +type Password struct { + *CloudSQL +} + +type PasswordRotate struct { + *Password +} + +type User struct { + *CloudSQL +} + +type UserAdd struct { + *User + Privilege string `name:"privilege" usage:"The privilege to grant to the user."` +} + +type UserDrop struct { + *User +} + +type UserList struct { + *User +} + +type EnableAudit struct { + *CloudSQL +} + +type VerifyAudit struct { + *CloudSQL +} + +type Grant struct { + *CloudSQL +} + +type Prepare struct { + *CloudSQL + AllPrivileges bool `name:"all-privileges" usage:"Grant all privileges on the schema to the current user."` + Schema string `name:"schema" usage:"Schema to grant access to."` +} + +type Proxy struct { + *CloudSQL + Port uint `name:"port" short:"p" usage:"Port to use for the proxy. Defaults to 5432."` + Host string `name:"host" short:"H" usage:"Host to proxy to. Defaults to localhost."` +} + +type Psql struct { + *CloudSQL +} + +type Revoke struct { + *CloudSQL + Schema string `name:"schema" usage:"The schema to revoke privileges from."` +} + +type List struct { + *CloudSQL + Output Output `name:"output" short:"o" usage:"Format output (table or json)."` + Labels labels.LabelFilters `name:"label" short:"l" usage:"Filter by label in |KEY=VALUE| form. Can be repeated."` +} + +func (*List) LabelFacetResource() string { return "sqlInstances" } + +type Output string + +var _ naistrix.FlagAutoCompleter = (*Output)(nil) + +func (o *Output) AutoComplete(context.Context, *naistrix.Arguments, string, any) ([]string, string) { + return []string{"table", "json"}, "Available output formats." +} diff --git a/internal/cloudsql/command/list.go b/internal/cloudsql/command/list.go new file mode 100644 index 00000000..57181b83 --- /dev/null +++ b/internal/cloudsql/command/list.go @@ -0,0 +1,49 @@ +package command + +import ( + "context" + + "github.com/nais/cli/internal/cloudsql" + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/labels" + "github.com/nais/naistrix" + "github.com/nais/naistrix/output" +) + +func listCommand(parentFlags *flag.CloudSQL) *naistrix.Command { + flags := &flag.List{CloudSQL: parentFlags} + + return &naistrix.Command{ + Name: "list", + Title: "List Cloud SQL instances for a team.", + Description: "List Google Cloud SQL instances owned by a team.", + Flags: flags, + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + labelFilters, err := labels.ParseFilters(flags.Labels) + if err != nil { + return err + } + + environments := []string(nil) + if flags.Environment != "" { + environments = []string{string(flags.Environment)} + } + + ret, err := cloudsql.GetTeamCloudSQLInstances(ctx, flags.Team, environments, labelFilters) + if err != nil { + return err + } + + if flags.Output == "json" { + return out.JSON(output.JSONWithPrettyOutput()).Render(ret) + } + + if len(ret) == 0 { + out.Println("Team has no Cloud SQL instances.") + return nil + } + + return out.Table().Render(ret) + }, + } +} diff --git a/internal/cloudsql/command/migrate.go b/internal/cloudsql/command/migrate.go new file mode 100644 index 00000000..0e4cedd9 --- /dev/null +++ b/internal/cloudsql/command/migrate.go @@ -0,0 +1,125 @@ +package command + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/cloudsql/migrate/finalize" + "github.com/nais/cli/internal/cloudsql/migrate/promote" + "github.com/nais/cli/internal/cloudsql/migrate/rollback" + "github.com/nais/cli/internal/cloudsql/migrate/setup" + "github.com/nais/cli/internal/validation" + "github.com/nais/naistrix" +) + +func migrateCommand(parentFlags *flag.CloudSQL) *naistrix.Command { + flags := &flag.Migrate{CloudSQL: parentFlags} + return &naistrix.Command{ + Name: "migrate", + Title: "Migrate to a new SQL instance.", + Description: "Commands for migrating a database to a new Cloud SQL instance, including setup, promotion, finalization, and rollback.", + StickyFlags: flags, + ValidateFunc: validation.RequireTeamAndEnvironment(flags), + SubCommands: []*naistrix.Command{ + migrateSetupCommand(flags), + migratePromoteCommand(flags), + migrateFinalizeCommand(flags), + migrateRollbackCommand(flags), + }, + } +} + +func migrateSetupCommand(parentFlags *flag.Migrate) *naistrix.Command { + flags := &flag.MigrateSetup{ + Migrate: parentFlags, + Tier: os.Getenv("TARGET_INSTANCE_TIER"), + } + + if v, err := strconv.ParseBool(os.Getenv("TARGET_INSTANCE_DISK_AUTORESIZE")); err == nil { + flags.DiskAutoResize = v + } + + if v, err := strconv.Atoi(os.Getenv("TARGET_INSTANCE_DISK_SIZE")); err == nil { + flags.DiskSize = v + } + + return &naistrix.Command{ + Name: "setup", + Title: "Make necessary setup for a new SQL instance migration.", + Description: "Setup will create a new (target) instance with updated configuration, and enable continuous replication of data from the source instance.", + Args: []naistrix.Argument{ + {Name: "app_name"}, + {Name: "target_sql_instance_name"}, + }, + ValidateFunc: func(ctx context.Context, args *naistrix.Arguments) error { + if flags.Tier != "" && !strings.HasPrefix(flags.Tier, "db-") { + return fmt.Errorf("tier must start with `db-`") + } + + if flags.InstanceType != "" && !strings.HasPrefix(flags.InstanceType, "POSTGRES_") { + return fmt.Errorf("instance type must start with `POSTGRES_`") + } + + return nil + }, + Flags: flags, + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + return setup.Run(ctx, args.Get("app_name"), args.Get("target_sql_instance_name"), flags.Team, string(flags.Environment), flags) + }, + } +} + +func migratePromoteCommand(parentFlags *flag.Migrate) *naistrix.Command { + flags := &flag.MigratePromote{Migrate: parentFlags} + return &naistrix.Command{ + Name: "promote", + Title: "Promote the migrated instance to the new primary instance.", + Description: "Promote will promote the target instance to the new primary instance, and update the application to use the new instance.", + Flags: flags, + Args: []naistrix.Argument{ + {Name: "app_name"}, + {Name: "target_sql_instance_name"}, + }, + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + return promote.Run(ctx, args.Get("app_name"), args.Get("target_sql_instance_name"), flags.Team, string(flags.Environment), flags) + }, + } +} + +func migrateFinalizeCommand(parentFlags *flag.Migrate) *naistrix.Command { + flags := &flag.MigrateFinalize{Migrate: parentFlags} + return &naistrix.Command{ + Name: "finalize", + Title: "Finalize the migration.", + Description: "Finalize will remove the source instance and associated resources after a successful migration.", + Args: []naistrix.Argument{ + {Name: "app_name"}, + {Name: "target_sql_instance_name"}, + }, + Flags: flags, + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + return finalize.Run(ctx, args.Get("app_name"), args.Get("target_sql_instance_name"), flags.Team, string(flags.Environment), flags.DryRun) + }, + } +} + +func migrateRollbackCommand(parentFlags *flag.Migrate) *naistrix.Command { + flags := &flag.MigrateRollback{Migrate: parentFlags} + return &naistrix.Command{ + Name: "rollback", + Title: "Roll back the migration.", + Description: "Rollback will roll back the migration, and restore the application to use the original instance.", + Args: []naistrix.Argument{ + {Name: "app_name"}, + {Name: "target_sql_instance_name"}, + }, + Flags: flags, + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + return rollback.Run(ctx, args.Get("app_name"), args.Get("target_sql_instance_name"), flags.Team, string(flags.Environment), flags) + }, + } +} diff --git a/internal/cloudsql/command/password.go b/internal/cloudsql/command/password.go new file mode 100644 index 00000000..6644f1bf --- /dev/null +++ b/internal/cloudsql/command/password.go @@ -0,0 +1,34 @@ +package command + +import ( + "context" + + "github.com/nais/cli/internal/cloudsql" + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/validation" + "github.com/nais/naistrix" +) + +func passwordCommand(parentFlags *flag.CloudSQL) *naistrix.Command { + flags := &flag.Password{CloudSQL: parentFlags} + return &naistrix.Command{ + Name: "password", + Title: "Manage SQL instance passwords.", + Description: "Commands for managing Cloud SQL instance passwords, including password rotation.", + StickyFlags: flags, + SubCommands: []*naistrix.Command{ + { + Name: "rotate", + Title: "Rotate the SQL instance password.", + Description: "The rotation is done in GCP and in the Kubernetes secret.", + Args: []naistrix.Argument{ + {Name: "app_name"}, + }, + ValidateFunc: validation.RequireTeamAndEnvironment(flags), + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + return cloudsql.RotatePassword(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) + }, + }, + }, + } +} diff --git a/internal/cloudsql/command/prepare.go b/internal/cloudsql/command/prepare.go new file mode 100644 index 00000000..57bbad9f --- /dev/null +++ b/internal/cloudsql/command/prepare.go @@ -0,0 +1,47 @@ +package command + +import ( + "context" + "fmt" + + _ "github.com/GoogleCloudPlatform/cloudsql-proxy/proxy/dialers/postgres" + "github.com/MakeNowJust/heredoc/v2" + "github.com/nais/cli/internal/cloudsql" + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/validation" + "github.com/nais/naistrix" + "github.com/nais/naistrix/input" +) + +func prepareCommand(parentFlags *flag.CloudSQL) *naistrix.Command { + flags := &flag.Prepare{ + CloudSQL: parentFlags, + Schema: "public", + } + + return &naistrix.Command{ + Name: "prepare", + Title: "Prepare your SQL instance for use with personal accounts.", + Description: heredoc.Doc(` + Prepare will prepare the SQL instance by connecting using the application credentials and modify the permissions on the public schema. + + All IAM users in your GCP project will be able to connect to the instance. + + This operation is only required to run once for each SQL instance. + `), + Args: []naistrix.Argument{ + {Name: "app_name"}, + }, + Flags: flags, + ValidateFunc: validation.RequireTeamAndEnvironment(flags), + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + if result, err := input.Confirm("Are you sure you want to continue?"); err != nil { + return err + } else if !result { + return fmt.Errorf("cancelled by user") + } + + return cloudsql.PrepareAccess(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) + }, + } +} diff --git a/internal/cloudsql/command/proxy.go b/internal/cloudsql/command/proxy.go new file mode 100644 index 00000000..44a57d97 --- /dev/null +++ b/internal/cloudsql/command/proxy.go @@ -0,0 +1,31 @@ +package command + +import ( + "context" + + "github.com/nais/cli/internal/cloudsql" + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/validation" + "github.com/nais/naistrix" +) + +func proxyCommand(parentFlags *flag.CloudSQL) *naistrix.Command { + flags := &flag.Proxy{ + CloudSQL: parentFlags, + Port: 5432, + Host: "localhost", + } + return &naistrix.Command{ + Name: "proxy", + Title: "Create a proxy to a SQL instance.", + Description: "Allows your user to connect to databases and starts a proxy.", + Args: []naistrix.Argument{ + {Name: "app_name"}, + }, + Flags: flags, + ValidateFunc: validation.RequireTeamAndEnvironment(flags), + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + return cloudsql.RunProxy(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) + }, + } +} diff --git a/internal/cloudsql/command/psql.go b/internal/cloudsql/command/psql.go new file mode 100644 index 00000000..ea6c41ab --- /dev/null +++ b/internal/cloudsql/command/psql.go @@ -0,0 +1,27 @@ +package command + +import ( + "context" + + "github.com/nais/cli/internal/cloudsql" + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/validation" + "github.com/nais/naistrix" +) + +func psqlCommand(parentFlags *flag.CloudSQL) *naistrix.Command { + flags := &flag.Psql{CloudSQL: parentFlags} + return &naistrix.Command{ + Name: "psql", + Title: "Connect to the database using psql.", + Description: "Create a shell to the SQL instance by opening a proxy on a random port (see the proxy command for more info) and opening a psql shell.", + Args: []naistrix.Argument{ + {Name: "app_name"}, + }, + Flags: flags, + ValidateFunc: validation.RequireTeamAndEnvironment(flags), + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + return cloudsql.RunPSQL(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) + }, + } +} diff --git a/internal/cloudsql/command/revoke.go b/internal/cloudsql/command/revoke.go new file mode 100644 index 00000000..9d64f89f --- /dev/null +++ b/internal/cloudsql/command/revoke.go @@ -0,0 +1,46 @@ +package command + +import ( + "context" + "fmt" + + _ "github.com/GoogleCloudPlatform/cloudsql-proxy/proxy/dialers/postgres" + "github.com/MakeNowJust/heredoc/v2" + "github.com/nais/cli/internal/cloudsql" + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/validation" + "github.com/nais/naistrix" + "github.com/nais/naistrix/input" +) + +func revokeCommand(parentFlags *flag.CloudSQL) *naistrix.Command { + flags := &flag.Revoke{ + CloudSQL: parentFlags, + Schema: "public", + } + return &naistrix.Command{ + Name: "revoke", + Title: `Revoke access to your SQL instance for the role "cloudsqliamuser".`, + Description: heredoc.Doc(` + Revoke will revoke the role "cloudsqliamuser" access to the tables in the SQL instance. + + This is done by connecting using the application credentials and modify the permissions on the public schema. + + This operation is only required to run once for each SQL instance. + `), + Args: []naistrix.Argument{ + {Name: "app_name"}, + }, + Flags: flags, + ValidateFunc: validation.RequireTeamAndEnvironment(flags), + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + if result, err := input.Confirm("Are you sure you want to continue?"); err != nil { + return err + } else if !result { + return fmt.Errorf("cancelled by user") + } + + return cloudsql.RevokeAccess(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) + }, + } +} diff --git a/internal/cloudsql/command/users.go b/internal/cloudsql/command/users.go new file mode 100644 index 00000000..c09e3cac --- /dev/null +++ b/internal/cloudsql/command/users.go @@ -0,0 +1,80 @@ +package command + +import ( + "context" + + "github.com/nais/cli/internal/cloudsql" + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/validation" + "github.com/nais/naistrix" +) + +func usersCommand(parentFlags *flag.CloudSQL) *naistrix.Command { + flags := &flag.User{CloudSQL: parentFlags} + return &naistrix.Command{ + Name: "users", + Title: "Manage users in your SQL instance.", + Description: "Commands for adding, listing, and dropping users in a Cloud SQL instance.", + StickyFlags: flags, + ValidateFunc: validation.RequireTeamAndEnvironment(flags), + SubCommands: []*naistrix.Command{ + addCommand(flags), + dropCommand(flags), + listUsersCommand(flags), + }, + } +} + +func addCommand(parentFlags *flag.User) *naistrix.Command { + flags := &flag.UserAdd{ + User: parentFlags, + Privilege: "select", + } + return &naistrix.Command{ + Name: "add", + Title: "Add a user to a SQL instance.", + Description: "Will grant a user access to tables in public schema.", + Args: []naistrix.Argument{ + {Name: "app_name"}, + {Name: "username"}, + {Name: "password"}, + }, + Flags: flags, + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + return cloudsql.AddUser(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), args.Get("username"), args.Get("password"), flags, out) + }, + } +} + +func listUsersCommand(parentFlags *flag.User) *naistrix.Command { + flags := &flag.UserList{User: parentFlags} + return &naistrix.Command{ + Name: "list", + Title: "List users in a SQL instance database.", + Description: "List all users in a Cloud SQL instance database for a given application.", + Args: []naistrix.Argument{ + {Name: "app_name"}, + }, + Flags: flags, + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + return cloudsql.ListUsers(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) + }, + } +} + +func dropCommand(parentFlags *flag.User) *naistrix.Command { + flags := &flag.UserDrop{User: parentFlags} + return &naistrix.Command{ + Name: "drop", + Title: "Drop a user from a SQL instance database.", + Description: "Remove a user from a Cloud SQL instance database.", + Args: []naistrix.Argument{ + {Name: "app_name"}, + {Name: "username"}, + }, + Flags: flags, + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + return cloudsql.DropUser(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), args.Get("username"), flags, out) + }, + } +} diff --git a/internal/cloudsql/command/verify_audit.go b/internal/cloudsql/command/verify_audit.go new file mode 100644 index 00000000..de9f7613 --- /dev/null +++ b/internal/cloudsql/command/verify_audit.go @@ -0,0 +1,32 @@ +package command + +import ( + "context" + + "github.com/nais/cli/internal/cloudsql" + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/metric" + "github.com/nais/cli/internal/validation" + "github.com/nais/naistrix" +) + +func verifyAuditCommand(parentFlags *flag.CloudSQL) *naistrix.Command { + flags := &flag.VerifyAudit{CloudSQL: parentFlags} + return &naistrix.Command{ + Name: "verify-audit", + Title: "Verify audit extension and configuration in SQL instance database.", + Description: "This verifies that the pgaudit extension is installed and that audit logging is properly configured for the application user.", + Args: []naistrix.Argument{ + {Name: "app_name"}, + }, + Flags: flags, + ValidateFunc: validation.RequireTeamAndEnvironment(flags), + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + err := cloudsql.VerifyAuditLogging(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) + if err != nil { + metric.CreateAndIncreaseCounter(ctx, "verify_audit_logging_error") + } + return err + }, + } +} diff --git a/internal/cloudsql/dbinfo.go b/internal/cloudsql/dbinfo.go new file mode 100644 index 00000000..ca02add3 --- /dev/null +++ b/internal/cloudsql/dbinfo.go @@ -0,0 +1,87 @@ +package cloudsql + +import ( + "context" + "errors" + "fmt" + "net/url" + + "golang.org/x/oauth2" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +type DBInfo struct { + k8sClient kubernetes.Interface + dynamicClient dynamic.Interface + config clientcmd.ClientConfig + namespace string + appName string +} + +func (d *DBInfo) AppName() string { return d.appName } + +func NewDBInfo(_ context.Context, appName, team, environment string) (*CloudSQLDBInfo, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{CurrentContext: environment}) + config, err := kubeConfig.ClientConfig() + if err != nil { + return nil, fmt.Errorf("NewDBInfo: unable to get kubeconfig: %w", err) + } + if team == "" { + team, _, err = kubeConfig.Namespace() + if err != nil { + return nil, fmt.Errorf("NewDBInfo: unable to get namespace: %w", err) + } + } + k8sClient, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("NewDBInfo: load kubeclient configuration: %w", err) + } + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("NewDBInfo: load kubeclient configuration: %w", err) + } + return &CloudSQLDBInfo{DBInfo: &DBInfo{k8sClient: k8sClient, dynamicClient: dynamicClient, config: kubeConfig, namespace: team, appName: appName}}, nil +} + +type ConnectionInfo struct { + username string + email string + password string + dbName string + instance string + port string + url *url.URL + jdbcUrl *url.URL +} + +func (c *ConnectionInfo) ProxyConnectionString() string { + return fmt.Sprintf("host=%v user=%v dbname=%v password=%v sslmode=disable", c.instance, c.username, c.dbName, c.password) +} + +func (c *ConnectionInfo) SetPassword(password string) { + c.password = password + if c.url != nil { + c.url.User = url.UserPassword(c.username, password) + } + if c.jdbcUrl != nil { + queries := c.jdbcUrl.Query() + queries.Set("password", password) + c.jdbcUrl.RawQuery = queries.Encode() + } else if c.url != nil { + queries := c.url.Query() + queries.Set("password", password) + queries.Set("user", c.username) + c.jdbcUrl = &url.URL{Scheme: "jdbc:postgresql", Host: c.url.Host, Path: c.dbName, RawQuery: queries.Encode()} + } +} + +func formatInvalidGrantError(err error) error { + var retrieve *oauth2.RetrieveError + if errors.As(err, &retrieve) && retrieve.ErrorCode == "invalid_grant" { + return fmt.Errorf("looks like you are missing Application Default Credentials, run `gcloud auth login --update-adc` first") + } + return err +} diff --git a/internal/cloudsql/iam.go b/internal/cloudsql/iam.go new file mode 100644 index 00000000..a99c9ada --- /dev/null +++ b/internal/cloudsql/iam.go @@ -0,0 +1,353 @@ +package cloudsql + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/naistrix" +) + +func GrantAndCreateSQLUser(ctx context.Context, appName, team, environment string, out *naistrix.OutputWriter) error { + dbInfo, err := NewDBInfo(ctx, appName, team, environment) + if err != nil { + return err + } + + cloudSQLDBInfo := dbInfo + + projectID, err := cloudSQLDBInfo.ProjectID(ctx) + if err != nil { + return err + } + + connectionName, err := cloudSQLDBInfo.ConnectionName(ctx) + if err != nil { + return err + } + + out.Println("Grant user access") + err = grantUserAccess(ctx, projectID, "roles/cloudsql.admin", 5*time.Minute, out) + if err != nil { + return err + } + + out.Println("Create sql user") + err = createSQLUser(ctx, projectID, connectionName) + if err != nil { + return fmt.Errorf("error creating SQL user. One might already exist: %v", err) + } + + return nil +} + +func createSQLUser(ctx context.Context, projectID, instance string) error { + email, err := currentEmail(ctx) + if err != nil { + return err + } + + args := []string{ + "sql", + "users", + "create", + email, + "--instance", strings.Split(instance, ":")[2], + "--type", "cloud_iam_user", + "--project", projectID, + } + + buf := &bytes.Buffer{} + cmd := exec.CommandContext(ctx, "gcloud", args...) + cmd.Stdout = buf + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + _, _ = io.Copy(os.Stdout, buf) + return fmt.Errorf("error running gcloud command: %w", err) + } + return nil +} + +func currentEmail(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "gcloud", "config", "get-value", "account") + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("currentEmail: unable to retrieve email: %w\n%v", err, string(out)) + } + return strings.TrimSpace(string(out)), nil +} + +func grantUserAccess(ctx context.Context, projectID, role string, duration time.Duration, out *naistrix.OutputWriter) error { + email, err := currentEmail(ctx) + if err != nil { + return err + } + + exists, err := cleanupPermissions(ctx, projectID, email, role, "nais_cli_access") + if err != nil { + return err + } + + if exists { + out.Println("User already has permanent access to database, will not grant temporary access") + return nil + } + + args := []string{ + "projects", + "add-iam-policy-binding", + projectID, + "--member", "user:" + email, + "--role", role, + "--billing-project", projectID, + } + + if duration > 0 { + timestamp := time.Now().Add(duration).UTC().Format(time.RFC3339) + args = append( + args, + "--condition", + formatCondition("request.time < timestamp('"+timestamp+"')", "nais_cli_access"), + ) + } + + cmd := exec.CommandContext(ctx, "gcloud", args...) + buf := &bytes.Buffer{} + cmd.Stdout = buf + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + _, _ = io.Copy(os.Stdout, buf) + return fmt.Errorf("grantUserAccess: error running gcloud command: %w", err) + } + return nil +} + +func cleanupPermissions(ctx context.Context, projectID, email, role, conditionName string) (exists bool, err error) { + args := []string{ + "projects", + "get-iam-policy", + projectID, + "--format", "json", + "--billing-project", projectID, + } + cmd := exec.CommandContext(ctx, "gcloud", args...) + out, err := cmd.Output() + if err != nil { + if e, ok := err.(*exec.ExitError); ok { + _, _ = fmt.Fprintln(os.Stderr, string(e.Stderr)) + } + return false, fmt.Errorf("cleanupPermissions: error getting permissions: %w", err) + } + bindings := &policyBindings{} + if err := json.Unmarshal(out, bindings); err != nil { + return false, fmt.Errorf("cleanupPermissions: error unmarshaling json: %w", err) + } + + expr := "" +OUTER: + for _, binding := range bindings.Bindings { + if binding.Role == role { + for _, member := range binding.Members { + if member == "user:"+email { + if binding.Condition == nil { + return true, nil + } + if binding.Condition.Title == conditionName { + expr = formatCondition(binding.Condition.Expression, binding.Condition.Title) + break OUTER + } + } + } + } + } + + if expr == "" { + return false, nil + } + + args = []string{ + "projects", + "remove-iam-policy-binding", + projectID, + "--member", "user:" + email, + "--role", role, + "--condition", expr, + "--billing-project", projectID, + } + cmd = exec.CommandContext(ctx, "gcloud", args...) + buf := &bytes.Buffer{} + cmd.Stdout = buf + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + _, _ = io.Copy(os.Stdout, buf) + return false, fmt.Errorf("cleanupPermissions: error running gcloud command: %w", err) + } + return false, nil +} + +type policyBindings struct { + Bindings []struct { + Role string `json:"role"` + Members []string `json:"members"` + Condition *struct { + Title string `json:"title"` + Expression string `json:"expression"` + } `json:"condition"` + } `json:"bindings"` +} + +func formatCondition(expr, title string) string { + return fmt.Sprintf("expression=%v,title=%v", expr, title) +} + +func ListUsers(ctx context.Context, appName, team, environment string, fl *flag.UserList, out *naistrix.OutputWriter) error { + // Get secret values (access is logged for audit purposes) + sv, err := GetSecretValues(ctx, appName, team, environment, fl.CloudSQL, ReasonListUsers, out) + if err != nil { + return err + } + + dbInfo, err := NewDBInfo(ctx, appName, team, environment) + if err != nil { + return err + } + + dbInfo.SetSecretValues(sv) + + connectionInfo, err := dbInfo.DBConnection(ctx) + if err != nil { + return err + } + + db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) + if err != nil { + return err + } + + rows, err := db.QueryContext(ctx, "SELECT usename FROM pg_catalog.pg_user;") + if err != nil { + return formatInvalidGrantError(err) + } + defer func() { + _ = rows.Close() + }() + + out.Println("Users in database:") + for rows.Next() { + var d struct { + User string `field:"usename"` + } + if err := rows.Scan(&d.User); err != nil { + return err + } + + out.Println(d.User) + } + + return err +} + +func AddUser(ctx context.Context, appName, team, environment, username, password string, fl *flag.UserAdd, out *naistrix.OutputWriter) error { + err := validateSQLVariables(username, password, fl.Privilege) + if err != nil { + return err + } + + // Get secret values (access is logged for audit purposes) + sv, err := GetSecretValues(ctx, appName, team, environment, fl.CloudSQL, ReasonAddUser, out) + if err != nil { + return err + } + + dbInfo, err := NewDBInfo(ctx, appName, team, environment) + if err != nil { + return err + } + + dbInfo.SetSecretValues(sv) + + connectionInfo, err := dbInfo.DBConnection(ctx) + if err != nil { + return err + } + + db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) + if err != nil { + return err + } + + _, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE USER %q WITH ENCRYPTED PASSWORD '%v' NOCREATEDB;`, username, password)) + if err != nil { + return formatInvalidGrantError(err) + } + out.Printf("Created user: %v", username) + + _, err = db.ExecContext(ctx, fmt.Sprintf(`alter default privileges in schema public grant %v on tables to %q;`, fl.Privilege, username)) + if err != nil { + return formatInvalidGrantError(err) + } + + _, err = db.ExecContext(ctx, fmt.Sprintf(`grant %v on all tables in schema public to %q;`, fl.Privilege, username)) + if err != nil { + return formatInvalidGrantError(err) + } + + return nil +} + +func DropUser(ctx context.Context, appName, team, environment, username string, fl *flag.UserDrop, out *naistrix.OutputWriter) error { + // Get secret values (access is logged for audit purposes) + sv, err := GetSecretValues(ctx, appName, team, environment, fl.CloudSQL, ReasonDropUser, out) + if err != nil { + return err + } + + dbInfo, err := NewDBInfo(ctx, appName, team, environment) + if err != nil { + return err + } + + dbInfo.SetSecretValues(sv) + + connectionInfo, err := dbInfo.DBConnection(ctx) + if err != nil { + return err + } + + db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) + if err != nil { + return err + } + + _, err = db.ExecContext(ctx, fmt.Sprintf(`drop role %q;`, username)) + if err != nil { + return formatInvalidGrantError(err) + } + out.Printf("User %v has been dropped", username) + + return nil +} + +func validateSQLVariables(variables ...string) error { + r, err := regexp.Compile("^([A-Za-z0-9-_]+)$") + if err != nil { + return err + } + + for _, v := range variables { + if match := r.MatchString(v); !match { + return fmt.Errorf("invalid sql argument: %v (only letters, numbers, - and _ are allowed)", v) + } + } + + return nil +} diff --git a/internal/cloudsql/list.go b/internal/cloudsql/list.go new file mode 100644 index 00000000..9819f406 --- /dev/null +++ b/internal/cloudsql/list.go @@ -0,0 +1,98 @@ +package cloudsql + +import ( + "context" + "fmt" + "slices" + "sort" + + "github.com/nais/cli/internal/naisapi" + "github.com/nais/cli/internal/naisapi/gql" + "github.com/nais/naistrix/output" + "k8s.io/utils/ptr" +) + +const consoleBaseURL = "https://console.nav.cloud.nais.io" + +type Instance struct { + Name output.Link `json:"name"` + Type string `json:"type"` + Environment string `json:"environment"` + Version string `heading:"Version" json:"version"` + HighAvailability bool `heading:"HA" json:"high_availability"` + Audit *bool `json:"audit,omitempty"` + State State `json:"state"` +} + +type State string + +func (s State) String() string { + switch s { + case State(gql.SqlInstanceStateRunnable): + return "Runnable" + case State(gql.SqlInstanceStateStopped): + return "Stopped" + case State(gql.SqlInstanceStateSuspended): + return "Suspended" + case State(gql.SqlInstanceStatePendingCreate): + return "Pending Create" + case State(gql.SqlInstanceStatePendingDelete): + return "Pending Delete" + case State(gql.SqlInstanceStateMaintenance): + return "Maintenance" + case State(gql.SqlInstanceStateFailed): + return "Failed" + } + return "Unknown" +} + +func GetTeamCloudSQLInstances(ctx context.Context, team string, environments []string, labelFilters []gql.LabelFilter) ([]Instance, error) { + _ = `# @genqlient + query GetTeamCloudSQLInstances($team: Slug!, $sqlFilter: SqlInstanceFilter) { + team(slug: $team) { + sqlInstances(first: 1000, filter: $sqlFilter) { + nodes { + name + teamEnvironment { environment { name } } + version + highAvailability + # @genqlient(pointer: true) + auditLog { logUrl } + state + } + } + } + } + ` + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, err + } + resp, err := gql.GetTeamCloudSQLInstances(ctx, client, team, &gql.SqlInstanceFilter{Labels: labelFilters}) + if err != nil { + return nil, err + } + return instancesFromTeam(resp.Team, team, environments), nil +} + +func instancesFromTeam(teamData gql.GetTeamCloudSQLInstancesTeam, team string, environments []string) []Instance { + var ret []Instance + for _, s := range teamData.SqlInstances.Nodes { + env := s.TeamEnvironment.Environment.Name + if len(environments) > 0 && !slices.Contains(environments, env) { + continue + } + ret = append(ret, Instance{ + Name: output.Link{Name: s.Name, URL: fmt.Sprintf("%s/team/%s/%s/cloudsql/%s", consoleBaseURL, team, env, s.Name)}, + Type: "Cloud SQL", Environment: env, Version: ptr.Deref(s.Version, ""), + HighAvailability: s.HighAvailability, Audit: ptr.To(s.AuditLog != nil), State: State(s.State), + }) + } + sort.Slice(ret, func(i, j int) bool { + if ret[i].Name.Name == ret[j].Name.Name { + return ret[i].Environment < ret[j].Environment + } + return ret[i].Name.Name < ret[j].Name.Name + }) + return ret +} diff --git a/internal/cloudsql/list_test.go b/internal/cloudsql/list_test.go new file mode 100644 index 00000000..ef42705c --- /dev/null +++ b/internal/cloudsql/list_test.go @@ -0,0 +1,41 @@ +package cloudsql + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/nais/cli/internal/naisapi/gql" + "github.com/nais/naistrix/output" +) + +func TestInstancesFromTeam(t *testing.T) { + const teamData = `{"sqlInstances":{"nodes":[ + {"name":"other","teamEnvironment":{"environment":{"name":"prod"}},"version":null,"highAvailability":true,"auditLog":null,"state":"STOPPED"}, + {"name":"legacy","teamEnvironment":{"environment":{"name":"dev"}},"version":"POSTGRES_14","highAvailability":false,"auditLog":{"logUrl":"https://example.test"},"state":"RUNNABLE"} + ]}}` + var data gql.GetTeamCloudSQLInstancesTeam + if err := json.Unmarshal([]byte(teamData), &data); err != nil { + t.Fatal(err) + } + for _, tt := range []struct { + name string + environments []string + want []Instance + }{ + {name: "sorted", want: []Instance{ + {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: boolPtr(true), State: State(gql.SqlInstanceStateRunnable)}, + {Name: output.Link{Name: "other", URL: consoleBaseURL + "/team/my-team/prod/cloudsql/other"}, Type: "Cloud SQL", Environment: "prod", HighAvailability: true, Audit: boolPtr(false), State: State(gql.SqlInstanceStateStopped)}, + }}, + {name: "environment filter", environments: []string{"dev"}, want: []Instance{ + {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: boolPtr(true), State: State(gql.SqlInstanceStateRunnable)}, + }}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := instancesFromTeam(data, "my-team", tt.environments); !reflect.DeepEqual(got, tt.want) { + t.Errorf("instancesFromTeam() = %#v, want %#v", got, tt.want) + } + }) + } +} +func boolPtr(value bool) *bool { return &value } diff --git a/internal/cloudsql/migrate/config/config.go b/internal/cloudsql/migrate/config/config.go new file mode 100644 index 00000000..19cfbf47 --- /dev/null +++ b/internal/cloudsql/migrate/config/config.go @@ -0,0 +1,209 @@ +package config + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/nais/cli/internal/option" + nais_io_v1alpha1 "github.com/nais/liberator/pkg/apis/nais.io/v1alpha1" + "github.com/nais/liberator/pkg/namegen" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation" + ctrl "sigs.k8s.io/controller-runtime/pkg/client" +) + +type Config struct { + AppName string + Team string + Target InstanceConfig + Source InstanceConfig + cfgMap *corev1.ConfigMap +} + +type InstanceConfig struct { + InstanceName option.Option[string] + Tier option.Option[string] + DiskAutoresize option.Option[bool] + DiskSize option.Option[int] + Type option.Option[string] +} + +var ErrMissingSqlInstance = errors.New("ErrMissingSqlInstance") + +func (ic *InstanceConfig) String() string { + return fmt.Sprintf("Name: %v\nTier: %v\nDiskSize: %v\nType: %v\n", ic.InstanceName, ic.Tier, ic.DiskSize, ic.Type) +} + +func (ic *InstanceConfig) Resolve(ctx context.Context, client ctrl.Client, appName, team string) error { + app := &nais_io_v1alpha1.Application{} + err := client.Get(ctx, ctrl.ObjectKey{Namespace: team, Name: appName}, app) + if err != nil { + return err + } + + if app.Spec.GCP == nil || len(app.Spec.GCP.SqlInstances) == 0 { + return fmt.Errorf("no sql instances found in app spec, %w", ErrMissingSqlInstance) + } + + ic.InstanceName = ic.InstanceName.Or(func() string { + name := app.Spec.GCP.SqlInstances[0].Name + if len(name) == 0 { + name = app.GetName() + } + return name + }) + + ic.Tier = ic.Tier.OrMaybe(func() option.Option[string] { + tier := app.Spec.GCP.SqlInstances[0].Tier + if len(tier) == 0 { + return option.None[string]() + } + return option.Some(tier) + }) + + ic.DiskAutoresize = ic.DiskAutoresize.OrMaybe(func() option.Option[bool] { + autoresize := app.Spec.GCP.SqlInstances[0].DiskAutoresize + if autoresize { + return option.Some(true) + } + return option.None[bool]() + }) + + ic.DiskSize = ic.DiskSize.OrMaybe(func() option.Option[int] { + diskSize := app.Spec.GCP.SqlInstances[0].DiskSize + if diskSize == 0 { + return option.None[int]() + } + return option.Some(diskSize) + }) + + ic.Type = ic.Type.OrMaybe(func() option.Option[string] { + instanceType := app.Spec.GCP.SqlInstances[0].Type + if len(instanceType) == 0 { + return option.None[string]() + } + return option.Some(string(instanceType)) + }) + + return nil +} + +func makeKey(prefix, key string) string { + return fmt.Sprintf("%s_%s", prefix, key) +} + +func (ic *InstanceConfig) PopulateFromConfigMap(configMap *corev1.ConfigMap, prefix string) { + ic.InstanceName = option.Some(configMap.Data[makeKey(prefix, "INSTANCE_NAME")]) + ic.Tier = ic.Tier.OrMaybe(func() option.Option[string] { + configTier, ok := configMap.Data[makeKey(prefix, "INSTANCE_TIER")] + if !ok { + return option.None[string]() + } + return option.Some(configTier) + }) + ic.DiskAutoresize = ic.DiskAutoresize.OrMaybe(func() option.Option[bool] { + configAutoresize, ok := configMap.Data[makeKey(prefix, "INSTANCE_DISK_AUTORESIZE")] + if !ok { + return option.None[bool]() + } + + autoresize, err := strconv.ParseBool(configAutoresize) + if err != nil { + panic(fmt.Sprintf("BUG: converting %s disk autoresize: %v", prefix, err.Error())) + } + return option.Some(autoresize) + }) + ic.DiskSize = ic.DiskSize.OrMaybe(func() option.Option[int] { + configDiskSize, ok := configMap.Data[makeKey(prefix, "INSTANCE_DISKSIZE")] + if !ok { + return option.None[int]() + } + + diskSize, err := strconv.Atoi(configDiskSize) + if err != nil { + panic(fmt.Sprintf("BUG: converting %s disk size: %v", prefix, err.Error())) + } + return option.Some(diskSize) + }) + ic.Type = ic.Type.OrMaybe(func() option.Option[string] { + configType, ok := configMap.Data[makeKey(prefix, "INSTANCE_TYPE")] + if !ok { + return option.None[string]() + } + return option.Some(configType) + }) +} + +func (c *Config) MigrationName() string { + name := fmt.Sprintf("migration-%s-%s", c.AppName, c.Target.InstanceName) + maxlen := validation.DNS1123LabelMaxLength + + if len(name) > maxlen { + truncated, err := namegen.ShortName(name, maxlen) + if err != nil { + panic(fmt.Sprintf("BUG: generating migration name: %v", err.Error())) + } + return truncated + } + + return name +} + +func (c *Config) CreateConfigMap() *corev1.ConfigMap { + data := map[string]string{ + "APP_NAME": c.AppName, + "NAMESPACE": c.Team, + } + + c.Target.InstanceName.Do(dataBuilder[string](data, "TARGET_INSTANCE_NAME")) + c.Target.Tier.Do(dataBuilder[string](data, "TARGET_INSTANCE_TIER")) + c.Target.DiskAutoresize.Do(dataBuilder[bool](data, "TARGET_INSTANCE_DISK_AUTORESIZE")) + c.Target.DiskSize.Do(dataBuilder[int](data, "TARGET_INSTANCE_DISK_SIZE")) + c.Target.Type.Do(dataBuilder[string](data, "TARGET_INSTANCE_TYPE")) + + c.Source.InstanceName.Do(dataBuilder[string](data, "SOURCE_INSTANCE_NAME")) + c.Source.Tier.Do(dataBuilder[string](data, "SOURCE_INSTANCE_TIER")) + c.Source.DiskAutoresize.Do(dataBuilder[bool](data, "SOURCE_INSTANCE_DISK_AUTORESIZE")) + c.Source.DiskSize.Do(dataBuilder[int](data, "SOURCE_INSTANCE_DISKSIZE")) + c.Source.Type.Do(dataBuilder[string](data, "SOURCE_INSTANCE_TYPE")) + + c.cfgMap = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: c.MigrationName(), + Namespace: c.Team, + Labels: map[string]string{ + "migrator.nais.io/migration-name": c.MigrationName(), + "migrator.nais.io/app-name": c.AppName, + "migrator.nais.io/target-instance-name": c.Target.InstanceName.String(), + }, + Annotations: map[string]string{ + "migrator.nais.io/created-by": "nais/cli", + }, + }, + Data: data, + } + return c.cfgMap +} + +func (c *Config) PopulateFromConfigMap(ctx context.Context, client ctrl.Client) (*corev1.ConfigMap, error) { + configMap := &corev1.ConfigMap{} + err := client.Get(ctx, ctrl.ObjectKey{Namespace: c.Team, Name: c.MigrationName()}, configMap) + if err != nil { + return nil, err + } + + c.Source.PopulateFromConfigMap(configMap, "SOURCE") + c.Target.PopulateFromConfigMap(configMap, "TARGET") + + c.cfgMap = configMap + return c.cfgMap, nil +} + +func dataBuilder[T any](data map[string]string, key string) func(T) { + return func(v T) { + data[key] = fmt.Sprintf("%v", v) + } +} diff --git a/internal/cloudsql/migrate/config/config_test.go b/internal/cloudsql/migrate/config/config_test.go new file mode 100644 index 00000000..2bc1d251 --- /dev/null +++ b/internal/cloudsql/migrate/config/config_test.go @@ -0,0 +1,325 @@ +package config_test + +import ( + "context" + "errors" + "strconv" + "testing" + + "github.com/nais/cli/internal/cloudsql/migrate/config" + "github.com/nais/cli/internal/option" + nais_io_v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" + nais_io_v1alpha1 "github.com/nais/liberator/pkg/apis/nais.io/v1alpha1" + liberatorscheme "github.com/nais/liberator/pkg/scheme" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const ( + appName = "myapp" + namespace = "mynamespace" + + initialInstanceName = "myinstance" + initialInstanceTier = "db-f1-micro" + initialDiskSize = 11 + initialAutoresize = true + initialInstanceType = nais_io_v1.CloudSqlInstanceTypePostgres15 +) + +type tableEntry struct { + InstanceName option.Option[string] + Tier option.Option[string] + DiskSize option.Option[int] + DiskAutoresize option.Option[bool] + Type option.Option[string] +} + +func (t tableEntry) Apply(iCfg *config.InstanceConfig) { + t.InstanceName.Do(func(v string) { + iCfg.InstanceName = option.Some(v) + }) + t.Tier.Do(func(v string) { + iCfg.Tier = option.Some(v) + }) + t.DiskSize.Do(func(v int) { + iCfg.DiskSize = option.Some(v) + }) + t.DiskAutoresize.Do(func(v bool) { + iCfg.DiskAutoresize = option.Some(v) + }) + t.Type.Do(func(v string) { + iCfg.Type = option.Some(v) + }) +} + +func fakeClient(t *testing.T, objects ...ctrl.Object) ctrl.Client { + t.Helper() + scheme, err := liberatorscheme.All() + if err != nil { + t.Fatalf("failed to create scheme: %v", err) + } + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() +} + +func TestConfig(t *testing.T) { + ctx := context.Background() + + t.Run("instance config", func(t *testing.T) { + t.Run("resolve when app is not found", func(t *testing.T) { + iCfg := &config.InstanceConfig{} + client := fakeClient(t) + + t.Run("it returns an error", func(t *testing.T) { + err := iCfg.Resolve(ctx, client, appName, namespace) + if err == nil { + t.Error("expected error, got nil") + } + }) + }) + + t.Run("resolve when app is found", func(t *testing.T) { + t.Run("without sqlinstances", func(t *testing.T) { + client := fakeClient(t, &nais_io_v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "nais.io/v1alpha1", + Kind: "Application", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: appName, + Namespace: namespace, + }, + Spec: nais_io_v1alpha1.ApplicationSpec{ + Image: "myimage", + }, + }) + + t.Run("returns correct error", func(t *testing.T) { + iCfg := &config.InstanceConfig{} + err := iCfg.Resolve(ctx, client, appName, namespace) + if !errors.Is(err, config.ErrMissingSqlInstance) { + t.Errorf("expected ErrMissingSqlInstance, got: %v", err) + } + }) + }) + + t.Run("with sqlinstances", func(t *testing.T) { + client := fakeClient(t, &nais_io_v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "nais.io/v1alpha1", + Kind: "Application", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: appName, + Namespace: namespace, + }, + Spec: nais_io_v1alpha1.ApplicationSpec{ + Image: "myimage", + GCP: &nais_io_v1.GCP{ + SqlInstances: []nais_io_v1.CloudSqlInstance{ + { + Type: initialInstanceType, + Name: initialInstanceName, + Tier: initialInstanceTier, + DiskSize: initialDiskSize, + DiskAutoresize: initialAutoresize, + }, + }, + }, + }, + }) + initialEntry := tableEntry{ + InstanceName: option.Some(initialInstanceName), + Tier: option.Some(initialInstanceTier), + DiskSize: option.Some(initialDiskSize), + DiskAutoresize: option.Some(initialAutoresize), + Type: option.Some(string(initialInstanceType)), + } + passedEntry := tableEntry{ + InstanceName: option.Some("passedName"), + Tier: option.Some("passedTier"), + DiskSize: option.Some(999), + DiskAutoresize: option.Some(false), + Type: option.Some("passedType"), + } + for _, tc := range []struct { + description string + source tableEntry + target tableEntry + }{ + {description: "resolves config from app", source: tableEntry{}, target: initialEntry}, + {description: "resolves config from passed config", source: passedEntry, target: passedEntry}, + } { + t.Run(tc.description, func(t *testing.T) { + iCfg := &config.InstanceConfig{} + tc.source.Apply(iCfg) + err := iCfg.Resolve(ctx, client, appName, namespace) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if iCfg.InstanceName != tc.target.InstanceName { + t.Errorf("expected InstanceName %v, got %v", tc.target.InstanceName, iCfg.InstanceName) + } + if iCfg.Tier != tc.target.Tier { + t.Errorf("expected Tier %v, got %v", tc.target.Tier, iCfg.Tier) + } + if iCfg.DiskSize != tc.target.DiskSize { + t.Errorf("expected DiskSize %v, got %v", tc.target.DiskSize, iCfg.DiskSize) + } + if iCfg.DiskAutoresize != tc.target.DiskAutoresize { + t.Errorf("expected DiskAutoresize %v, got %v", tc.target.DiskAutoresize, iCfg.DiskAutoresize) + } + if iCfg.Type != tc.target.Type { + t.Errorf("expected Type %v, got %v", tc.target.Type, iCfg.Type) + } + }) + } + }) + + t.Run("with sqlinstance without optional values", func(t *testing.T) { + client := fakeClient(t, &nais_io_v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "nais.io/v1alpha1", + Kind: "Application", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: appName, + Namespace: namespace, + }, + Spec: nais_io_v1alpha1.ApplicationSpec{ + Image: "myimage", + GCP: &nais_io_v1.GCP{ + SqlInstances: []nais_io_v1.CloudSqlInstance{ + { + Type: initialInstanceType, + }, + }, + }, + }, + }) + initialEntry := tableEntry{ + InstanceName: option.Some(appName), + Tier: option.None[string](), + DiskSize: option.None[int](), + DiskAutoresize: option.None[bool](), + Type: option.Some(string(initialInstanceType)), + } + passedEntry := tableEntry{ + InstanceName: option.Some("passedName"), + Tier: option.Some("passedTier"), + DiskSize: option.Some(999), + DiskAutoresize: option.Some(false), + Type: option.Some("passedType"), + } + for _, tc := range []struct { + description string + source tableEntry + target tableEntry + }{ + {description: "resolve config from app", source: tableEntry{}, target: initialEntry}, + {description: "resolves config from passed config", source: passedEntry, target: passedEntry}, + } { + t.Run(tc.description, func(t *testing.T) { + iCfg := &config.InstanceConfig{} + tc.source.Apply(iCfg) + err := iCfg.Resolve(ctx, client, appName, namespace) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if iCfg.InstanceName != tc.target.InstanceName { + t.Errorf("expected InstanceName %v, got %v", tc.target.InstanceName, iCfg.InstanceName) + } + if iCfg.Tier != tc.target.Tier { + t.Errorf("expected Tier %v, got %v", tc.target.Tier, iCfg.Tier) + } + if iCfg.DiskSize != tc.target.DiskSize { + t.Errorf("expected DiskSize %v, got %v", tc.target.DiskSize, iCfg.DiskSize) + } + if iCfg.DiskAutoresize != tc.target.DiskAutoresize { + t.Errorf("expected DiskAutoresize %v, got %v", tc.target.DiskAutoresize, iCfg.DiskAutoresize) + } + if iCfg.Type != tc.target.Type { + t.Errorf("expected Type %v, got %v", tc.target.Type, iCfg.Type) + } + }) + } + }) + }) + + t.Run("populate from config map", func(t *testing.T) { + t.Run("it populates the instance config", func(t *testing.T) { + iCfg := &config.InstanceConfig{} + configMap := &corev1.ConfigMap{ + Data: map[string]string{ + "PREFIX_INSTANCE_NAME": initialInstanceName, + "PREFIX_INSTANCE_TIER": initialInstanceTier, + "PREFIX_INSTANCE_DISKSIZE": strconv.Itoa(initialDiskSize), + "PREFIX_INSTANCE_DISK_AUTORESIZE": strconv.FormatBool(initialAutoresize), + "PREFIX_INSTANCE_TYPE": string(initialInstanceType), + }, + } + iCfg.PopulateFromConfigMap(configMap, "PREFIX") + if iCfg.InstanceName != option.Some(initialInstanceName) { + t.Errorf("expected InstanceName %v, got %v", initialInstanceName, iCfg.InstanceName) + } + if iCfg.Tier != option.Some(initialInstanceTier) { + t.Errorf("expected Tier %v, got %v", initialInstanceTier, iCfg.Tier) + } + if iCfg.DiskSize != option.Some(initialDiskSize) { + t.Errorf("expected DiskSize %v, got %v", initialDiskSize, iCfg.DiskSize) + } + if iCfg.DiskAutoresize != option.Some(initialAutoresize) { + t.Errorf("expected DiskAutoresize %v, got %v", initialAutoresize, iCfg.DiskAutoresize) + } + if iCfg.Type != option.Some(string(initialInstanceType)) { + t.Errorf("expected Type %v, got %v", initialInstanceType, iCfg.Type) + } + }) + }) + }) + + t.Run("test Config", func(t *testing.T) { + t.Run("migration name", func(t *testing.T) { + getConfig := func() config.Config { + return config.Config{ + AppName: "some-app", + Team: "test-namespace", + Target: config.InstanceConfig{ + InstanceName: option.Some("target-instance"), + }, + } + } + + t.Run("generates valid migration name", func(t *testing.T) { + verify := func(t *testing.T, cfg config.Config, expected string) { + t.Helper() + actual := cfg.MigrationName() + if len(actual) > 63 { + t.Errorf("expected length <= 63, got %d", len(actual)) + } + if actual != expected { + t.Errorf("expected %s, got %s", expected, actual) + } + } + + t.Run("happy path with reasonable lengths for app and instance", func(t *testing.T) { + cfg := getConfig() + verify(t, cfg, "migration-some-app-target-instance") + }) + + t.Run("very long app name", func(t *testing.T) { + cfg := getConfig() + cfg.AppName = "some-unnecessarily-long-app-name-that-should-be-truncated" + verify(t, cfg, "migration-some-unnecessarily-long-app-name-that-should-377bba1c") + }) + + t.Run("very long instance name", func(t *testing.T) { + cfg := getConfig() + cfg.Target.InstanceName = option.Some("some-unnecessarily-long-instance-name-that-should-be-truncated") + verify(t, cfg, "migration-some-app-some-unnecessarily-long-instance-na-59326cd8") + }) + }) + }) + }) +} diff --git a/internal/cloudsql/migrate/finalize.go b/internal/cloudsql/migrate/finalize.go new file mode 100644 index 00000000..50709aaf --- /dev/null +++ b/internal/cloudsql/migrate/finalize.go @@ -0,0 +1,51 @@ +package migrate + +import ( + "context" + + "github.com/pterm/pterm" + "github.com/pterm/pterm/putils" +) + +func (m *Migrator) Finalize(ctx context.Context) error { + cfgMap, err := m.cfg.PopulateFromConfigMap(ctx, m.client) + if err != nil { + return err + } + + m.printConfig() + pterm.Warning.Print(`This will delete the old database instance. Rollback after this point is not possible. +Only proceed if you are sure that the migration was successful and that your application is working as expected. +`) + + err = confirmContinue() + if err != nil { + return err + } + + jobName, err := m.doNaisJob(ctx, cfgMap, CommandFinalize) + if err != nil { + return err + } + + printWaitingForJobHeader() + err = m.waitForJobCompletion(ctx, jobName, CommandFinalize) + if err != nil { + return err + } + + err = m.deleteMigrationConfig(ctx, cfgMap) + if err != nil { + return err + } + + pterm.Println() + pterm.DefaultHeader.Println("Finalize has completed successfully") + pterm.Println() + pterm.Println("The old instance has been deleted and the migration is complete.") + pterm.Println() + _ = pterm.DefaultBigText.WithLetters(putils.LettersFromString("Congrats!")).Render() + pterm.Println("You are all done! 🎉") + + return nil +} diff --git a/internal/cloudsql/migrate/finalize/command.go b/internal/cloudsql/migrate/finalize/command.go new file mode 100644 index 00000000..6509dc2a --- /dev/null +++ b/internal/cloudsql/migrate/finalize/command.go @@ -0,0 +1,34 @@ +package finalize + +import ( + "context" + "fmt" + + "github.com/nais/cli/internal/cloudsql/migrate" + "github.com/nais/cli/internal/cloudsql/migrate/config" + "github.com/nais/cli/internal/k8s" + "github.com/nais/cli/internal/option" +) + +func Run(ctx context.Context, applicationName, targetInstanceName, team, environment string, dryRun bool) error { + cfg := config.Config{ + AppName: applicationName, + Target: config.InstanceConfig{ + InstanceName: option.Some(targetInstanceName), + }, + } + + client := k8s.SetupControllerRuntimeClient(k8s.WithKubeContext(environment)) + cfg.Team = team + clientSet, err := k8s.SetupClientGo(environment) + if err != nil { + return err + } + + migrator := migrate.NewMigrator(client, clientSet, cfg, dryRun, false) + if err := migrator.Finalize(ctx); err != nil { + return fmt.Errorf("error cleaning up instance: %w", err) + } + + return nil +} diff --git a/internal/cloudsql/migrate/migrate.go b/internal/cloudsql/migrate/migrate.go new file mode 100644 index 00000000..c2023674 --- /dev/null +++ b/internal/cloudsql/migrate/migrate.go @@ -0,0 +1,642 @@ +package migrate + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "strconv" + "strings" + "time" + + "github.com/nais/cli/internal/cloudsql/migrate/config" + nais_io_v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" + "github.com/nais/liberator/pkg/namegen" + "github.com/pterm/pterm" + "github.com/sethvargo/go-retry" + "golang.org/x/sync/errgroup" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + ctrl "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +type Command string + +// maxJobNameLength is the maximum length for a Kubernetes CronJob name +const maxJobNameLength = 52 + +func (c Command) JobName(cfg config.Config) string { + base := cfg.MigrationName() + suffix := string(c) + name := fmt.Sprintf("%s-%s", base, suffix) + maxlen := maxJobNameLength + + if len(name) > maxlen { + truncated, err := namegen.SuffixedShortName(base, suffix, maxlen) + if err != nil { + panic(fmt.Sprintf("BUG: generating job name: %v", err.Error())) + } + return truncated + } + + return name +} + +const ( + CommandFinalize Command = "finalize" + CommandPromote Command = "promote" + CommandRollback Command = "rollback" + CommandSetup Command = "setup" +) + +const MigratorImage = "europe-north1-docker.pkg.dev/nais-io/nais/images/cloudsql-migrator" + +type logEntry struct { + Msg string `json:"msg"` + Level string `json:"level"` + MigrationStep int `json:"migrationStep"` + MigrationStepsTotal int `json:"migrationStepsTotal"` + extra map[string]any +} + +var irrelevantExtraLogEntryKeys = []string{ + "msg", + "time", + "level", + "source", + "migrationApp", + "migrationTarget", + "migrationPhase", + "migrationStep", + "migrationStepsTotal", + "config", +} + +type Migrator struct { + client ctrl.Client + clientset kubernetes.Interface + cfg config.Config + dryRun bool + wait bool +} + +func NewMigrator(client ctrl.Client, clientset kubernetes.Interface, cfg config.Config, dryRun bool, noWait bool) *Migrator { + return &Migrator{ + client: client, + clientset: clientset, + cfg: cfg, + dryRun: dryRun, + wait: !noWait, + } +} + +func (m *Migrator) Create(ctx context.Context, obj ctrl.Object) error { + if m.dryRun { + v := reflect.Indirect(reflect.ValueOf(obj)) + pterm.Printf("Dry run: Skipping creation of %s: %s\n", v.Type().Name(), obj.GetName()) + return nil + } + return m.client.Create(ctx, obj) +} + +func (m *Migrator) Delete(ctx context.Context, obj ctrl.Object) error { + if m.dryRun { + v := reflect.Indirect(reflect.ValueOf(obj)) + pterm.Printf("Dry run: Skipping deletion of %s: %s\n", v.Type().Name(), obj.GetName()) + return nil + } + + opts := []ctrl.DeleteOption{ + ctrl.PropagationPolicy(metav1.DeletePropagationForeground), + } + return m.client.Delete(ctx, obj, opts...) +} + +func (m *Migrator) doNaisJob(ctx context.Context, cfgMap *corev1.ConfigMap, command Command) (string, error) { + imageTag, err := getLatestImageTag() + if err != nil { + return "", fmt.Errorf("failed to get latest image tag for cloudsql-migrator: %w", err) + } + + job := makeNaisjob(m.cfg, imageTag, command) + err = createObject(ctx, m, cfgMap, job, command) + if err != nil { + return "", err + } + + return job.Name, nil +} + +func (m *Migrator) kubectlLabelSelector(command Command) string { + return fmt.Sprintf("migrator.nais.io/migration-name=%s,migrator.nais.io/command=%s", m.cfg.MigrationName(), command) +} + +func (m *Migrator) deleteMigrationConfig(ctx context.Context, cfgMap *corev1.ConfigMap) error { + err := ctrl.IgnoreNotFound(m.Delete(ctx, cfgMap)) + if err != nil { + return fmt.Errorf("failed to delete ConfigMap: %w", err) + } + + return nil +} + +func (m *Migrator) LookupGcpProjectId(ctx context.Context) (string, error) { + ns := &corev1.Namespace{} + err := m.client.Get(ctx, ctrl.ObjectKey{Name: m.cfg.Team}, ns) + if err != nil { + return "", fmt.Errorf("failed to get namespace: %w", err) + } + if gcpProjectId, ok := ns.Annotations["cnrm.cloud.google.com/project-id"]; ok { + return gcpProjectId, nil + } + return "", fmt.Errorf("namespace %s does not have a GCP project ID annotation", m.cfg.Team) +} + +func (m *Migrator) getJobLogs(ctx context.Context, command Command, jobName string, logChannel chan<- string) error { + defer close(logChannel) + + if m.dryRun { + send := func(entry logEntry) { + entry.Msg = fmt.Sprintf("Dry run: %s", entry.Msg) + b, _ := json.Marshal(entry) + logChannel <- string(b) + time.Sleep(500 * time.Millisecond) + } + send(logEntry{Msg: fmt.Sprintf("Starting %s", command), Level: "info", MigrationStep: 1, MigrationStepsTotal: 3}) + send(logEntry{Msg: "Running", Level: "info", MigrationStep: 2}) + send(logEntry{Msg: "Simulating log output", Level: "info"}) + send(logEntry{Msg: "Simulating more log output", Level: "warn"}) + send(logEntry{Msg: "Simulating even more log output", Level: "error"}) + send(logEntry{Msg: "Job completed", Level: "info"}) + send(logEntry{Msg: "Finished", Level: "info", MigrationStep: 3}) + return nil + } + + seenPods := make(map[string]bool) + + for ctx.Err() == nil { + pod, err := m.findLatestPod(ctx, command) + if err != nil { + return fmt.Errorf("error finding pod: %w", err) + } + + switch { + case pod == nil: + // No pod found; wait for it to be created. + time.Sleep(1 * time.Second) + continue + case pod.Status.Phase == corev1.PodSucceeded: + // Pod (and thus Job) has completed successfully. + logChannel <- `{"msg": ">>> Pod succeeded", "level": "info", "pod": "` + pod.Name + `"}` + return nil + case pod.Status.Phase != corev1.PodRunning: + // Pod is not running yet; wait for it to start. + logChannel <- `{"msg": ">>> Pod not running yet, waiting...", "level": "info", "pod": "` + pod.Name + `", "phase": "` + string(pod.Status.Phase) + `"}` + time.Sleep(1 * time.Second) + continue + case seenPods[pod.Name]: + // We've already printed logs for this pod; wait for a new pod to be created. + time.Sleep(1 * time.Second) + continue + } + + logs, err := m.clientset.CoreV1().Pods(m.cfg.Team).GetLogs(pod.Name, &corev1.PodLogOptions{ + Container: jobName, + Follow: true, + }).Stream(ctx) + if err != nil { + return fmt.Errorf("error getting job logs: %w", err) + } + + logChannel <- `{"msg": ">>> Log stream started", "level": "info", "pod": "` + pod.Name + `"}` + scanner := bufio.NewScanner(logs) + for scanner.Scan() { + logChannel <- scanner.Text() + } + _ = logs.Close() + logChannel <- `{"msg": ">>> Log stream ended", "level": "info", "pod": "` + pod.Name + `"}` + + // The stream ended, which likely means the pod either exited (whether successful or not) or was deleted. + // Mark the pod as seen to avoid printing its logs again. + seenPods[pod.Name] = true + + err = scanner.Err() + if err != nil { + return fmt.Errorf("error reading job logs: %w", err) + } + } + return nil +} + +// findLatestPod returns the latest pod for the given command. If no pods are found, nil is returned. +func (m *Migrator) findLatestPod(ctx context.Context, command Command) (*corev1.Pod, error) { + pods, err := m.clientset.CoreV1().Pods(m.cfg.Team).List(ctx, metav1.ListOptions{ + LabelSelector: m.kubectlLabelSelector(command), + }) + if err != nil { + return nil, fmt.Errorf("listing pods: %w", err) + } + + var latest *corev1.Pod + latestTime := metav1.Time{} + + for _, pod := range pods.Items { + if pod.GetCreationTimestamp().After(latestTime.Time) { + latest = &pod + latestTime = pod.GetCreationTimestamp() + } + } + + return latest, nil +} + +func (m *Migrator) waitForJobCompletion(ctx context.Context, jobName string, command Command) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + logChannel := make(chan string) + + // ctx is now canceled if any goroutine within the errgroup returns an error, or all of them complete successfully. + eg, ctx := errgroup.WithContext(ctx) + eg.Go(func() error { + return m.getJobLogs(ctx, command, jobName, logChannel) + }) + + startingMessage, err := m.waitForStartingMessage(ctx, logChannel) + if err != nil { + return err + } + + logOutput := pterm.DefaultLogger.WithMaxWidth(120) + logOutput.Info(startingMessage.Msg) + + progress, _ := pterm.DefaultProgressbar.WithTotal(startingMessage.MigrationStepsTotal).WithMaxWidth(120).Start() + defer func() { + _, _ = progress.Stop() + }() + + // this runs outside the errgroup as it does not return an error + go renderJobLogs(ctx, logChannel, logOutput, progress) + + if m.dryRun { + logOutput.Info(fmt.Sprintf("Dry run: Artificial waiting for job %s/%s to complete, 5 seconds\n", m.cfg.Team, jobName)) + time.Sleep(5 * time.Second) + return nil + } + + eg.Go(func() error { + return m.pollJobCompletion(ctx, jobName, command) + }) + + if err := eg.Wait(); err != nil { + if errors.Is(err, context.Canceled) { + err = context.Cause(ctx) + } + logOutput.Error(err.Error()) + return fmt.Errorf("error waiting for job completion: %w", err) + } + + return nil +} + +func (m *Migrator) waitForStartingMessage(ctx context.Context, logChannel <-chan string) (*logEntry, error) { + spinner, _ := pterm.DefaultSpinner.Start("Waiting for job to start ...") + defer func() { + _ = spinner.Stop() + }() + + for { + select { + case <-ctx.Done(): + spinner.Fail() + err := context.Cause(ctx) + pterm.Error.Println(err) + return nil, err + case line := <-logChannel: + l, err := parseLogLine(line) + if err != nil { + spinner.Fail() + pterm.Error.Println(err) + return nil, err + } + + if l.MigrationStepsTotal > 0 { + return &l, nil + } + } + } +} + +func (m *Migrator) pollJobCompletion(ctx context.Context, jobName string, command Command) error { + listOptions := []ctrl.ListOption{ + ctrl.InNamespace(m.cfg.Team), + ctrl.MatchingLabels{ + "migrator.nais.io/migration-name": m.cfg.MigrationName(), + "migrator.nais.io/command": string(command), + }, + } + + b := retry.NewConstant(10 * time.Second) + return retry.Do(ctx, b, func(ctx context.Context) error { + jobs := &batchv1.JobList{} + err := m.client.List(ctx, jobs, listOptions...) + if err != nil { + return retry.RetryableError(err) + } + if len(jobs.Items) < 1 { + return retry.RetryableError(fmt.Errorf("no jobs found")) + } + if len(jobs.Items) > 1 { + return fmt.Errorf("multiple jobs found %s/%s, contact nais team", m.cfg.Team, jobName) + } + for _, job := range jobs.Items { + if job.Status.Succeeded == 1 { + return nil + } + } + return retry.RetryableError(fmt.Errorf("job %s/%s has not completed yet", m.cfg.Team, jobName)) + }) +} + +func (m *Migrator) printConfig() { + pterm.DefaultSection.Println("Migration configuration") + pterm.Printfln("Application: %s", m.cfg.AppName) + pterm.Printfln("Namespace: %s", m.cfg.Team) + pterm.DefaultSection.Println("Instance configuration") + sourceDiskSize := "" + m.cfg.Source.DiskSize.Do(func(diskSize int) { + sourceDiskSize = fmt.Sprintf("%d GB", diskSize) + }) + targetDiskSize := "" + m.cfg.Target.DiskSize.Do(func(diskSize int) { + targetDiskSize = fmt.Sprintf("%d GB", diskSize) + }) + sourceAutoresize := "" + m.cfg.Source.DiskAutoresize.Do(func(autoresize bool) { + if autoresize { + sourceAutoresize = "enabled" + } else { + sourceAutoresize = "disabled" + } + }) + targetAutoresize := "" + m.cfg.Target.DiskAutoresize.Do(func(autoresize bool) { + if autoresize { + targetAutoresize = "enabled" + } else { + targetAutoresize = "disabled" + } + }) + + tableHeaderStyle := pterm.ThemeDefault.TableHeaderStyle + _ = pterm.DefaultTable.WithHasHeader().WithData(pterm.TableData{ + {"", "Name", "Tier", "Disk autoresize", "Disk size", "Type"}, + {tableHeaderStyle.Sprint("Source"), m.cfg.Source.InstanceName.String(), m.cfg.Source.Tier.String(), sourceAutoresize, sourceDiskSize, m.cfg.Source.Type.String()}, + {tableHeaderStyle.Sprint("Target"), m.cfg.Target.InstanceName.String(), m.cfg.Target.Tier.String(), targetAutoresize, targetDiskSize, m.cfg.Target.Type.String()}, + }).Render() +} + +func createObject[T interface { + ctrl.Object + *P +}, P any](ctx context.Context, m *Migrator, owner metav1.Object, obj T, Command Command) error { + err := controllerutil.SetOwnerReference(owner, obj, m.client.Scheme(), controllerutil.WithBlockOwnerDeletion(true)) + if err != nil { + return fmt.Errorf("failed to set owner reference: %w", err) + } + + labels := obj.GetLabels() + if labels == nil { + labels = make(map[string]string) + } + labels["migrator.nais.io/migration-name"] = m.cfg.MigrationName() + labels["migrator.nais.io/app-name"] = m.cfg.AppName + labels["migrator.nais.io/target-instance-name"] = m.cfg.Target.InstanceName.String() + labels["migrator.nais.io/command"] = string(Command) + obj.SetLabels(labels) + + err = m.Create(ctx, obj) + if err != nil { + return fmt.Errorf("failed to create Object: %w", err) + } + return nil +} + +// makeRoleBinding binds the migrator job ServiceAccounts to the nais:developer +// ClusterRole, granting them the same platform permissions developers have: +// applications, sqlinstances, deployments/scale, networkpolicies, etc. +func makeRoleBinding(cfg config.Config) *rbacv1.RoleBinding { + return &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: cfg.MigrationName(), + Namespace: cfg.Team, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: CommandSetup.JobName(cfg), + }, + { + Kind: "ServiceAccount", + Name: CommandPromote.JobName(cfg), + }, + { + Kind: "ServiceAccount", + Name: CommandFinalize.JobName(cfg), + }, + { + Kind: "ServiceAccount", + Name: CommandRollback.JobName(cfg), + }, + }, + RoleRef: rbacv1.RoleRef{ + Kind: "ClusterRole", + Name: "nais:developer", + APIGroup: "rbac.authorization.k8s.io", + }, + } +} + +func makeNaisjob(cfg config.Config, imageTag string, command Command) *nais_io_v1.Naisjob { + return &nais_io_v1.Naisjob{ + ObjectMeta: metav1.ObjectMeta{ + Name: command.JobName(cfg), + Namespace: cfg.Team, + Labels: map[string]string{ + "apiserver-access": "enabled", + }, + }, + Spec: nais_io_v1.NaisjobSpec{ + Command: []string{"/" + string(command)}, + Env: nais_io_v1.EnvVars{ + { + Name: "LOG_FORMAT", + Value: "JSON", + }, + }, + EnvFrom: []nais_io_v1.EnvFrom{{ + ConfigMap: cfg.MigrationName(), + }}, + GCP: &nais_io_v1.GCP{ + Permissions: []nais_io_v1.CloudIAMPermission{ + { + Role: "roles/cloudsql.admin", + Resource: nais_io_v1.CloudIAMResource{ + APIVersion: "resourcemanager.cnrm.cloud.google.com/v1beta1", + Kind: "Project", + }, + }, { + Role: "roles/datamigration.admin", + Resource: nais_io_v1.CloudIAMResource{ + APIVersion: "resourcemanager.cnrm.cloud.google.com/v1beta1", + Kind: "Project", + }, + }, { + Role: "roles/monitoring.viewer", + Resource: nais_io_v1.CloudIAMResource{ + APIVersion: "resourcemanager.cnrm.cloud.google.com/v1beta1", + Kind: "Project", + }, + }, + }, + }, + Image: fmt.Sprintf("%s:%s", MigratorImage, imageTag), + }, + } +} + +func getLatestImageTag() (string, error) { + resp, err := http.Get("https://api.github.com/repos/nais/cloudsql-migrator/releases/latest") + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + switch resp.StatusCode { + case http.StatusTooManyRequests: + fallthrough + case http.StatusForbidden: + retryTime, err := calculateRetryTime(resp) + if err != nil { + return "", fmt.Errorf("rate limit error when attempting to query GitHub for latest migrator image. Additionally, an error occurred when attempting to find a suitable retry time: %w", err) + } + return "", fmt.Errorf("rate limit exceeded when attempting to query GitHub for latest migrator image, retry after %s", retryTime.Format(time.RFC1123)) + case http.StatusOK: + // do nothing + default: + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + decoder := json.NewDecoder(resp.Body) + v := map[string]any{} + err = decoder.Decode(&v) + if err != nil { + return "", err + } + + return v["tag_name"].(string), nil +} + +// calculateRetryTime calculates when it is ok to retry a request based on the available headers. +// See more in GitHub API documentation: +// https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api?apiVersion=2022-11-28#rate-limit-errors +func calculateRetryTime(resp *http.Response) (time.Time, error) { + retryAfter := resp.Header.Get("retry-after") + if retryAfter != "" { + retryAfterSeconds, err := strconv.Atoi(retryAfter) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse retry-after header: %w", err) + } + return time.Now().Add(time.Duration(retryAfterSeconds) * time.Second), nil + } else if resp.Header.Get("x-ratelimit-remaining") == "0" { + rateLimitReset := resp.Header.Get("x-ratelimit-reset") + retryEpoch, err := strconv.Atoi(rateLimitReset) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse rate limit reset epoch: %w", err) + } + return time.Unix(int64(retryEpoch), 0), nil + } + return time.Now().Add(1 * time.Minute), nil +} + +func confirmContinue() error { + pterm.Println() + result, _ := pterm.DefaultInteractiveConfirm.Show("Are you sure you want to continue?") + pterm.Println() + + if !result { + return fmt.Errorf("cancelled by user") + } + + return nil +} + +func printWaitingForJobHeader() { + pterm.Println("Several of the operations done by the migrator are eventually consistent, and may fail a few times before succeeding.") + pterm.Println("This leads to some log messages about errors or failures, but the operations will typically be retried and eventually succeed.") + pterm.Println("If there is an unrecoverable error, the migrator will exit with an error message.") +} + +func parseLogLine(line string) (logEntry, error) { + var le logEntry + err := json.Unmarshal([]byte(line), &le) + if err != nil { + return logEntry{}, err + } + + // pick up additional log fields that are not part of the logEntry struct + extra := make(map[string]any) + // this error should be caught above in previous Unmarshal + _ = json.Unmarshal([]byte(line), &extra) + + for _, key := range irrelevantExtraLogEntryKeys { + delete(extra, key) + } + + le.extra = extra + return le, nil +} + +func renderJobLogs(ctx context.Context, logChannel <-chan string, logOutput *pterm.Logger, progress *pterm.ProgressbarPrinter) { + lastMsg := "" + for { + select { + case <-ctx.Done(): + return + case line := <-logChannel: + le, err := parseLogLine(line) + if err != nil { + logOutput.Debug(fmt.Sprintf("failed to unmarshal log entry: %s (was %q); ignoring...", err, line)) + continue + } + + if le.MigrationStep > 0 { + progress.Current = le.MigrationStep + progress.UpdateTitle(le.Msg) + continue + } + + if lastMsg != le.Msg { + args := logOutput.ArgsFromMap(le.extra) + switch strings.ToLower(le.Level) { + case "error": + logOutput.Error(le.Msg, args) + case "warn": + logOutput.Warn(le.Msg, args) + case "info": + logOutput.Info(le.Msg, args) + default: + logOutput.Print(le.Msg, args) + } + } + lastMsg = le.Msg + } + } +} diff --git a/internal/cloudsql/migrate/migrate_test.go b/internal/cloudsql/migrate/migrate_test.go new file mode 100644 index 00000000..69faf16c --- /dev/null +++ b/internal/cloudsql/migrate/migrate_test.go @@ -0,0 +1,55 @@ +package migrate + +import ( + "testing" + + "github.com/nais/cli/internal/cloudsql/migrate/config" + "github.com/nais/cli/internal/option" +) + +func TestCommand(t *testing.T) { + tests := map[string]struct { + mutateFn func(cfg *config.Config) + expected string + }{ + "happy path with reasonable lengths for app and instance": { + mutateFn: func(cfg *config.Config) {}, + expected: "migration-some-app-target-instance-setup", + }, + "very long app name": { + mutateFn: func(cfg *config.Config) { + cfg.AppName = "some-unnecessarily-long-app-name-that-should-be-truncated" + }, + expected: "migration-some-unnecessarily-long-app-eb4938d8-setup", + }, + "very long instance name": { + mutateFn: func(cfg *config.Config) { + cfg.Target.InstanceName = option.Some("some-unnecessarily-long-instance-name-that-should-be-truncated") + }, + expected: "migration-some-app-some-unnecessarily-63093bcb-setup", + }, + } + + const cmd = CommandSetup + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cfg := config.Config{ + AppName: "some-app", + Team: "test-namespace", + Target: config.InstanceConfig{ + InstanceName: option.Some("target-instance"), + }, + } + tc.mutateFn(&cfg) + + actual := cmd.JobName(cfg) + if len(actual) > maxJobNameLength { + t.Errorf("job name exceeds 52 characters: %s", actual) + } + if actual != tc.expected { + t.Errorf("expected job name %q, got %q", tc.expected, actual) + } + }) + } +} diff --git a/internal/cloudsql/migrate/promote.go b/internal/cloudsql/migrate/promote.go new file mode 100644 index 00000000..5ab7c7f9 --- /dev/null +++ b/internal/cloudsql/migrate/promote.go @@ -0,0 +1,83 @@ +package migrate + +import ( + "context" + "fmt" + + "github.com/nais/cli/internal/cloudsql/migrate/ui" + "github.com/pterm/pterm" +) + +func (m *Migrator) Promote(ctx context.Context) error { + cfgMap, err := m.cfg.PopulateFromConfigMap(ctx, m.client) + if err != nil { + return err + } + + m.printConfig() + pterm.Warning.Print(`Your application will not be able to reach the database during promotion. +The database will be unavailable for a short period of time while the promotion is in progress. +`) + + err = confirmContinue() + if err != nil { + return err + } + + jobName, err := m.doNaisJob(ctx, cfgMap, CommandPromote) + if err != nil { + return err + } + + label := m.kubectlLabelSelector(CommandPromote) + + if m.wait { + printWaitingForJobHeader() + err = m.waitForJobCompletion(ctx, jobName, CommandPromote) + if err != nil { + return err + } + + pterm.Println() + pterm.DefaultHeader.Println("Promotion completed successfully") + pterm.Println() + pterm.Println("Promotion is complete, your application should be up and running with the new database instance.") + } else { + pterm.Println() + pterm.DefaultHeader.Println("Promotion has been started successfully") + pterm.Println() + pterm.Println("To monitor the migration, run the following command:") + ui.CmdStyle.Printfln("\tkubectl logs -f -l %s", label) + pterm.Println() + pterm.Println("The promote will take some time to complete, you can check completion status with the following command:") + ui.CmdStyle.Printfln("\tkubectl get job %s", jobName) + pterm.Println() + pterm.Println("When promotion is complete, your application should be up and running with the new database instance.") + } + + pterm.Println() + pterm.Info.Println(`At this point it is important to verify that your application works as expected, and that all data is present. +It is now possible to deploy changes to your application, but you must update the manifest to use the new database instance before doing so (see below). +Once you are satisfied that everything works as expected, you must perform the final finalize step:`) + ui.CmdStyle.Printfln("\tnais cloudsql migrate finalize %s %s", m.cfg.AppName, m.cfg.Target.InstanceName) + pterm.Println() + pterm.Info.Println("Your next application deploy must update your manifests to use the new database instance:") + diskSizeLine := "" + m.cfg.Target.DiskSize.Do(func(diskSize int) { + diskSizeLine = fmt.Sprintf("diskSize: %d", diskSize) + }) + ui.YamlStyle.Printfln(` + ... + spec: + gcp: + sqlInstances: + - name: %s + type: %s + tier: %s + %s +`, m.cfg.Target.InstanceName, m.cfg.Target.Type, m.cfg.Target.Tier, diskSizeLine) + pterm.Println() + pterm.Println("If things are not working as expected, and you need to rollback to the previous database instance, you can run:") + ui.CmdStyle.Printfln("\tnais cloudsql migrate rollback %s %s", m.cfg.AppName, m.cfg.Target.InstanceName) + return nil +} diff --git a/internal/cloudsql/migrate/promote/command.go b/internal/cloudsql/migrate/promote/command.go new file mode 100644 index 00000000..107deff2 --- /dev/null +++ b/internal/cloudsql/migrate/promote/command.go @@ -0,0 +1,35 @@ +package promote + +import ( + "context" + "fmt" + + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/cloudsql/migrate" + "github.com/nais/cli/internal/cloudsql/migrate/config" + "github.com/nais/cli/internal/k8s" + "github.com/nais/cli/internal/option" +) + +func Run(ctx context.Context, applicationName, targetInstanceName, team, environment string, flags *flag.MigratePromote) error { + cfg := config.Config{ + AppName: applicationName, + Target: config.InstanceConfig{ + InstanceName: option.Some(targetInstanceName), + }, + } + + client := k8s.SetupControllerRuntimeClient(k8s.WithKubeContext(environment)) + cfg.Team = team + clientSet, err := k8s.SetupClientGo(environment) + if err != nil { + return err + } + + migrator := migrate.NewMigrator(client, clientSet, cfg, flags.DryRun, flags.NoWait) + if err := migrator.Promote(ctx); err != nil { + return fmt.Errorf("error promoting instance: %w", err) + } + + return nil +} diff --git a/internal/cloudsql/migrate/rollback.go b/internal/cloudsql/migrate/rollback.go new file mode 100644 index 00000000..3ea31e86 --- /dev/null +++ b/internal/cloudsql/migrate/rollback.go @@ -0,0 +1,48 @@ +package migrate + +import ( + "context" + + "github.com/pterm/pterm" +) + +func (m *Migrator) Rollback(ctx context.Context) error { + cfgMap, err := m.cfg.PopulateFromConfigMap(ctx, m.client) + if err != nil { + return err + } + + m.printConfig() + pterm.Warning.Println("This will roll back the migration, and restore the application to use the original instance.") + + err = confirmContinue() + if err != nil { + return err + } + + jobName, err := m.doNaisJob(ctx, cfgMap, CommandRollback) + if err != nil { + return err + } + + printWaitingForJobHeader() + err = m.waitForJobCompletion(ctx, jobName, CommandRollback) + if err != nil { + return err + } + + err = m.deleteMigrationConfig(ctx, cfgMap) + if err != nil { + return err + } + + pterm.Println() + pterm.DefaultHeader.Println("Rollback has completed successfully") + pterm.Println() + pterm.Println("Your application should be up and running with the original database instance.") + pterm.Println("The new instance has been deleted and the migration is stopped.") + pterm.Println() + pterm.Println("You are now free to start another attempt if you wish.") + + return nil +} diff --git a/internal/cloudsql/migrate/rollback/command.go b/internal/cloudsql/migrate/rollback/command.go new file mode 100644 index 00000000..2acc7d87 --- /dev/null +++ b/internal/cloudsql/migrate/rollback/command.go @@ -0,0 +1,35 @@ +package rollback + +import ( + "context" + "fmt" + + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/cloudsql/migrate" + "github.com/nais/cli/internal/cloudsql/migrate/config" + "github.com/nais/cli/internal/k8s" + "github.com/nais/cli/internal/option" +) + +func Run(ctx context.Context, applicationName, targetInstanceName, team, environment string, flags *flag.MigrateRollback) error { + cfg := config.Config{ + AppName: applicationName, + Target: config.InstanceConfig{ + InstanceName: option.Some(targetInstanceName), + }, + } + + client := k8s.SetupControllerRuntimeClient(k8s.WithKubeContext(environment)) + cfg.Team = team + clientset, err := k8s.SetupClientGo(environment) + if err != nil { + return err + } + + migrator := migrate.NewMigrator(client, clientset, cfg, flags.DryRun, false) + if err := migrator.Rollback(ctx); err != nil { + return fmt.Errorf("error rolling back instance: %w", err) + } + + return nil +} diff --git a/internal/cloudsql/migrate/setup.go b/internal/cloudsql/migrate/setup.go new file mode 100644 index 00000000..d148bc86 --- /dev/null +++ b/internal/cloudsql/migrate/setup.go @@ -0,0 +1,196 @@ +package migrate + +import ( + "context" + "errors" + "fmt" + + "github.com/nais/cli/internal/cloudsql/migrate/config" + "github.com/nais/cli/internal/cloudsql/migrate/ui" + "github.com/nais/cli/internal/option" + "github.com/nais/liberator/pkg/namegen" + "github.com/pterm/pterm" + v1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (m *Migrator) Setup(ctx context.Context) error { + cfgMapList := &v1.ConfigMapList{} + listOptions := []client.ListOption{ + client.InNamespace(m.cfg.Team), + client.MatchingLabels{"migrator.nais.io/app-name": m.cfg.AppName}, + } + err := m.client.List(ctx, cfgMapList, listOptions...) + if err != nil { + return err + } + + if len(cfgMapList.Items) > 0 { + return fmt.Errorf("migration config already exists for this application") + } + + err = m.cfg.Source.Resolve(ctx, m.client, m.cfg.AppName, m.cfg.Team) + if err != nil { + if k8serrors.IsNotFound(err) { + pterm.Println() + pterm.Error.Printfln("Application %s not found for team %s", m.cfg.AppName, m.cfg.Team) + pterm.Println() + pterm.Println("Make sure you have specified the correct team with the --team flag") + pterm.Println() + return fmt.Errorf("app %s not found for team %s", m.cfg.AppName, m.cfg.Team) + } else if errors.Is(err, config.ErrMissingSqlInstance) { + pterm.Println() + pterm.Error.Printfln("The Application %s does not have any SQL instances defined in the spec", m.cfg.AppName) + pterm.Println() + } + return err + } + + m.ConfigureTarget() + + err = m.cfg.Target.Resolve(ctx, m.client, m.cfg.AppName, m.cfg.Team) + if err != nil { + return err + } + + m.clearDiskSizeIfDiskAutoresizeEnabled() + + err = m.validateInstanceNames() + if err != nil { + return err + } + + m.printConfig() + pterm.Warning.Println("Do not make structural database changes during migration!\nDo not deploy the application unless instructed to do so by the tool!\nThis is not supported, and will cause problems!") + err = confirmContinue() + if err != nil { + return err + } + + gcpProjectId, err := m.LookupGcpProjectId(ctx) + if err != nil { + return fmt.Errorf("failed to lookup GCP project ID: %w", err) + } + + cfgMap := m.cfg.CreateConfigMap() + err = m.Create(ctx, cfgMap) + if err != nil { + return fmt.Errorf("failed to create ConfigMap: %w", err) + } + + roleBinding := makeRoleBinding(m.cfg) + err = createObject(ctx, m, cfgMap, roleBinding, CommandSetup) + if err != nil { + return err + } + + jobName, err := m.doNaisJob(ctx, cfgMap, CommandSetup) + if err != nil { + return err + } + + // Make sure this logic is in sync with the corresponding logic in cloudsql-migrator... + migrationJobName := fmt.Sprintf("%s-%s", m.cfg.Source.InstanceName, m.cfg.Target.InstanceName) + maxlen := 60 // Google limit for migration job names + if len(migrationJobName) > maxlen { + var err error + migrationJobName, err = namegen.ShortName(migrationJobName, maxlen) + if err != nil { + return fmt.Errorf("failed to shorten migration job name: %w", err) + } + } + + cloudConsoleUrl := fmt.Sprintf("https://console.cloud.google.com/dbmigration/migrations/locations/europe-north1/instances/%s?project=%s", migrationJobName, gcpProjectId) + label := m.kubectlLabelSelector(CommandSetup) + + if m.wait { + printWaitingForJobHeader() + err = m.waitForJobCompletion(ctx, jobName, CommandSetup) + if err != nil { + return err + } + pterm.Println() + pterm.DefaultHeader.Println("Migration setup completed successfully") + pterm.Println() + pterm.Println("Setup is now complete, a new instance has been created and replication of data has started.") + } else { + pterm.Println() + pterm.DefaultHeader.Println("Migration setup has been started successfully") + pterm.Println() + pterm.Println("To monitor the migration, run the following command:") + ui.CmdStyle.Printfln("\tkubectl logs -f -l %s", label) + pterm.Println() + pterm.Println("The setup will take some time to complete, you can check completion status with the following command:") + ui.CmdStyle.Printfln("\tkubectl get job %s", jobName) + pterm.Println() + pterm.Println("When setup is complete, a new instance has been created and replication of data has started.") + } + + helperName, err := helperAppName(m.cfg.AppName) + if err != nil { + return fmt.Errorf("failed to generate helper app name: %w", err) + } + + pterm.Println("You can check the replication progress in the Google Cloud Console:") + ui.LinkStyle.Printfln("\t%s", cloudConsoleUrl) + pterm.Println() + pterm.DefaultParagraph.Println("When the migration has status 'Running' and is in the 'CDC' or 'Ready to Promote' phase, everything is ready for the next step of the migration.") + pterm.DefaultParagraph.Println("If you want to check that the replication is working as expected before proceeding, you can connect to the new instance and check that everything looks correct.") + pterm.DefaultParagraph.Printfln("To connect to the new instance, follow the guide for personal database access using the helper application (%s) created for this migration.", helperName) + ui.LinkStyle.Println("\thttps://docs.nais.io/persistence/cloudsql/how-to/personal-access/") + pterm.Println() + pterm.DefaultParagraph.Println("When you are ready to proceed with the next step of the migration, run the promote command:") + ui.CmdStyle.Printfln("\tnais cloudsql migrate promote %s %s", m.cfg.AppName, m.cfg.Target.InstanceName) + pterm.Println() + pterm.Info.Println("Be aware that during promotion (the next step), your instance will be unavailable for some time.") + return nil +} + +func (m *Migrator) validateInstanceNames() error { + sourceInstanceName := m.cfg.Source.InstanceName.String() + if sourceInstanceName == "" { + return fmt.Errorf("source instance name is empty") + } + + targetInstanceName := m.cfg.Target.InstanceName.String() + if targetInstanceName == "" { + return fmt.Errorf("target instance name is required") + } + + if sourceInstanceName == targetInstanceName { + return fmt.Errorf("source and target instance names cannot be the same") + } + return nil +} + +func (m *Migrator) clearDiskSizeIfDiskAutoresizeEnabled() { + m.cfg.Target.DiskAutoresize.Do(func(v bool) { + if v { + m.cfg.Target.DiskSize = option.None[int]() + } + }) +} + +func (m *Migrator) ConfigureTarget() { + m.cfg.Target.Tier = m.cfg.Target.Tier.OrMaybe(ui.AskForTier(m.cfg.Source.Tier.String())) + m.cfg.Target.Type = m.cfg.Target.Type.OrMaybe(ui.AskForType(m.cfg.Source.Type.String())) + m.cfg.Target.DiskAutoresize = m.cfg.Target.DiskAutoresize.OrMaybe(ui.AskForDiskAutoresize(m.cfg.Source.DiskAutoresize)) + m.cfg.Target.DiskAutoresize.Do(func(v bool) { + if !v { + m.cfg.Target.DiskSize = m.cfg.Target.DiskSize.OrMaybe(ui.AskForDiskSize(m.cfg.Source.DiskSize)) + } + }) +} + +// helperAppName generates a name for the helper application, based on the application name. +// This is a copy of the corresponding function in nais/cloudsql-migrator/internal/pkg/common_main/main.go +// If a functional change is made here, it should be made in both places. +func helperAppName(basename string) (string, error) { + helperName, err := namegen.ShortName(fmt.Sprintf("migrator-%s", basename), 63) + if err != nil { + return "", err + } + + return helperName, nil +} diff --git a/internal/cloudsql/migrate/setup/command.go b/internal/cloudsql/migrate/setup/command.go new file mode 100644 index 00000000..5a43b8b0 --- /dev/null +++ b/internal/cloudsql/migrate/setup/command.go @@ -0,0 +1,66 @@ +package setup + +import ( + "context" + "fmt" + + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/cloudsql/migrate" + "github.com/nais/cli/internal/cloudsql/migrate/config" + "github.com/nais/cli/internal/k8s" + "github.com/nais/cli/internal/option" +) + +func Run(ctx context.Context, applicationName, targetInstanceName, team, environment string, flags *flag.MigrateSetup) error { + cfg := config.Config{ + AppName: applicationName, + Target: config.InstanceConfig{ + InstanceName: option.Some(targetInstanceName), + }, + } + + tier := flags.Tier + diskAutoresize := flags.DiskAutoResize + diskSize := flags.DiskSize + instanceType := flags.InstanceType + + cfg.Target.Tier = isSet(tier) + cfg.Target.DiskAutoresize = isSetBool(diskAutoresize) + cfg.Target.DiskSize = isSetInt(diskSize) + cfg.Target.Type = isSet(instanceType) + + client := k8s.SetupControllerRuntimeClient(k8s.WithKubeContext(environment)) + cfg.Team = team + clientSet, err := k8s.SetupClientGo(environment) + if err != nil { + return err + } + + migrator := migrate.NewMigrator(client, clientSet, cfg, flags.DryRun, flags.NoWait) + if err := migrator.Setup(ctx); err != nil { + return fmt.Errorf("error setting up migration: %w", err) + } + + return nil +} + +func isSet(v string) option.Option[string] { + if v == "" { + return option.None[string]() + } + return option.Some(v) +} + +func isSetBool(autoresize bool) option.Option[bool] { + if autoresize { + return option.Some(true) + } + return option.None[bool]() +} + +func isSetInt(v int) option.Option[int] { + if v == 0 { + return option.None[int]() + } + return option.Some(v) +} diff --git a/internal/cloudsql/migrate/setup_test.go b/internal/cloudsql/migrate/setup_test.go new file mode 100644 index 00000000..8dc88ecb --- /dev/null +++ b/internal/cloudsql/migrate/setup_test.go @@ -0,0 +1,321 @@ +package migrate_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/nais/cli/internal/cloudsql/migrate" + "github.com/nais/cli/internal/cloudsql/migrate/config" + "github.com/nais/cli/internal/cloudsql/migrate/ui" + "github.com/nais/cli/internal/option" + nais_io_v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" + nais_io_v1alpha1 "github.com/nais/liberator/pkg/apis/nais.io/v1alpha1" + liberatorscheme "github.com/nais/liberator/pkg/scheme" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + ctrl_fake "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const namespace = "test-namespace" + +const ( + sourceName = "source-instance" + sourceType = "source-type" + sourceDiskSize = 15 + sourceTier = "source-tier" + + targetName = "target-instance" + targetType = "target-type" + targetDiskSize = 20 + targetTier = "target-tier" +) + +func TestMigrator_Setup(t *testing.T) { + test := map[string]struct { + appName string + errContains string + }{ + "return an error if application is not found": { + appName: "no-such-app", + errContains: "not found for team", + }, + "return an error if application has no sql instance": { + appName: "no-instance", + errContains: "no sql instances found in app spec", + }, + "return an error if migration config already exists": { + appName: "already-migrating", + errContains: "migration config already exists for this application", + }, + } + + for name, tc := range test { + t.Run(name, func(t *testing.T) { + scheme, err := liberatorscheme.All() + if err != nil { + t.Fatalf("failed to create scheme: %v", err) + } + clientBuilder := ctrl_fake.NewClientBuilder().WithScheme(scheme) + clientset := fake.NewClientset() + + cfg := config.Config{ + Team: namespace, + Source: config.InstanceConfig{}, + Target: config.InstanceConfig{InstanceName: option.Some(targetName)}, + AppName: tc.appName, + } + noInstanceApp := &nais_io_v1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-instance", + Namespace: namespace, + }, + Spec: nais_io_v1alpha1.ApplicationSpec{}, + } + alreadyMigratingApp := &nais_io_v1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "already-migrating", + Namespace: namespace, + }, + Spec: nais_io_v1alpha1.ApplicationSpec{ + GCP: &nais_io_v1.GCP{ + SqlInstances: []nais_io_v1.CloudSqlInstance{{ + Name: targetName, + }}, + }, + }, + } + cfgMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "migration-already-exists-config", + Namespace: namespace, + Labels: map[string]string{"migrator.nais.io/app-name": "already-migrating"}, + }, + } + app := &nais_io_v1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: namespace, + }, + Spec: nais_io_v1alpha1.ApplicationSpec{ + GCP: &nais_io_v1.GCP{ + SqlInstances: []nais_io_v1.CloudSqlInstance{{ + Name: targetName, + }}, + }, + }, + } + + clientBuilder.WithObjects(noInstanceApp, alreadyMigratingApp, app, cfgMap) + + migrator := migrate.NewMigrator(clientBuilder.Build(), clientset, cfg, true, true) + + err = migrator.Setup(context.Background()) + if tc.errContains != "" { + if err == nil { + t.Errorf("expected error containing %q, got nil", tc.errContains) + } else if !strings.Contains(err.Error(), tc.errContains) { + t.Errorf("expected error to contain %q, got %q", tc.errContains, err.Error()) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +func TestConfigureTarget_instance_type(t *testing.T) { + scheme, err := liberatorscheme.All() + if err != nil { + t.Fatalf("failed to create scheme: %v", err) + } + clientBuilder := ctrl_fake.NewClientBuilder().WithScheme(scheme) + clientset := fake.NewClientset() + client := clientBuilder.Build() + + tests := map[string]struct { + instance config.InstanceConfig + }{ + "only default values": { + instance: config.InstanceConfig{InstanceName: option.Some(sourceName)}, + }, + "all values, no autoresize": { + instance: config.InstanceConfig{ + InstanceName: option.Some(sourceName), + Tier: option.Some(sourceTier), + DiskAutoresize: option.None[bool](), + DiskSize: option.Some(sourceDiskSize), + Type: option.Some(sourceType), + }, + }, + "autoresize, no disk size": { + instance: config.InstanceConfig{ + InstanceName: option.Some(sourceName), + Tier: option.Some(sourceTier), + DiskAutoresize: option.Some(true), + DiskSize: option.None[int](), + Type: option.Some(sourceType), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cfg := config.Config{ + Team: namespace, + Source: tc.instance, + } + migratorBuilder := migrate.NewMigrator(client, clientset, cfg, true, true) + + ui.AskForDiskAutoresize = func(sourceDiskAutoresize option.Option[bool]) func() option.Option[bool] { + return func() option.Option[bool] { + return sourceDiskAutoresize + } + } + ui.AskForDiskSize = func(sourceDiskSize option.Option[int]) func() option.Option[int] { + return func() option.Option[int] { + return sourceDiskSize + } + } + ui.AskForTier = func(sourceTier string) func() option.Option[string] { + return func() option.Option[string] { + return option.Some(sourceTier) + } + } + ui.AskForType = func(sourceType string) func() option.Option[string] { + return func() option.Option[string] { + return option.Some(sourceType) + } + } + + t.Run("instance type target type is set", func(t *testing.T) { + cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName), Type: option.Some(targetType)} + m := migratorBuilder + m.ConfigureTarget() + if cfg.Target.Type.String() != targetType { + t.Errorf("expected target type %q, got %q", targetType, cfg.Target.Type.String()) + } + }) + + t.Run("instance type target type is not set", func(t *testing.T) { + cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName)} + m := migratorBuilder + m.ConfigureTarget() + if cfg.Target.Type != option.None[string]() { + t.Errorf("expected target type to be None, got %q", cfg.Target.Type.String()) + } + }) + + t.Run("instance tier target tier is set", func(t *testing.T) { + cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName), Tier: option.Some(targetTier)} + m := migratorBuilder + m.ConfigureTarget() + if cfg.Target.Tier.String() != targetTier { + t.Errorf("expected target tier %q, got %q", targetTier, cfg.Target.Tier.String()) + } + }) + t.Run("instance tier target tier is not set", func(t *testing.T) { + cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName)} + m := migratorBuilder + m.ConfigureTarget() + if cfg.Target.Tier != option.None[string]() { + t.Errorf("expected target tier to be None, got %q", cfg.Target.Tier.String()) + } + }) + t.Run("instance disk size target disk size is set", func(t *testing.T) { + cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName), DiskSize: option.Some(targetDiskSize)} + m := migratorBuilder + m.ConfigureTarget() + if cfg.Target.DiskSize.String() != fmt.Sprintf("%v", targetDiskSize) { + t.Errorf("expected target disk size %d, got %s", targetDiskSize, cfg.Target.DiskSize.String()) + } + }) + t.Run("instance disk size target disk size is not set", func(t *testing.T) { + cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName)} + m := migratorBuilder + m.ConfigureTarget() + if cfg.Target.DiskSize != option.None[int]() { + t.Errorf("expected target disk size to be None, got %s", cfg.Target.DiskSize.String()) + } + }) + t.Run("instance disk autoresize target disk autoresize is set to false and target disk size is set", func(t *testing.T) { + cfg.Target = config.InstanceConfig{ + InstanceName: option.Some(targetName), + DiskAutoresize: option.Some(false), + DiskSize: option.Some(targetDiskSize), + } + m := migratorBuilder + m.ConfigureTarget() + if cfg.Target.DiskAutoresize.String() != "false" { + t.Errorf("expected target disk autoresize to be false, got %s", cfg.Target.DiskAutoresize.String()) + } + if cfg.Target.DiskSize.String() != fmt.Sprintf("%v", targetDiskSize) { + t.Errorf("expected target disk size %d, got %s", targetDiskSize, cfg.Target.DiskSize.String()) + } + }) + t.Run("instance disk autoresize target disk autoresize is set to false and target disk size is not set", func(t *testing.T) { + cfg.Target = config.InstanceConfig{ + InstanceName: option.Some(targetName), + DiskAutoresize: option.Some(false), + } + m := migratorBuilder + m.ConfigureTarget() + if cfg.Target.DiskAutoresize.String() != "false" { + t.Errorf("expected target disk autoresize to be false, got %s", cfg.Target.DiskAutoresize.String()) + } + if cfg.Target.DiskSize != option.None[int]() { + t.Errorf("expected target disk size to be None, got %s", cfg.Target.DiskSize.String()) + } + }) + t.Run("instance disk autoresize target disk autoresize is set to true and target disk size is set", func(t *testing.T) { + cfg.Target = config.InstanceConfig{ + InstanceName: option.Some(targetName), + DiskAutoresize: option.Some(true), + DiskSize: option.Some(targetDiskSize), + } + m := migratorBuilder + m.ConfigureTarget() + if cfg.Target.DiskAutoresize.String() != "true" { + t.Errorf("expected target disk autoresize to be true, got %s", cfg.Target.DiskAutoresize.String()) + } + }) + t.Run("instance disk autoresize target disk autoresize is set to true and target disk size is not set", func(t *testing.T) { + cfg.Target = config.InstanceConfig{ + InstanceName: option.Some(targetName), + DiskAutoresize: option.Some(true), + } + m := migratorBuilder + m.ConfigureTarget() + if cfg.Target.DiskAutoresize.String() != "true" { + t.Errorf("expected target disk autoresize to be true, got %s", cfg.Target.DiskAutoresize.String()) + } + }) + t.Run("instance disk autoresize target disk autoresize is not set and target disk size is set", func(t *testing.T) { + cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName), DiskSize: option.Some(targetDiskSize)} + m := migratorBuilder + m.ConfigureTarget() + if cfg.Target.DiskAutoresize != option.None[bool]() { + t.Errorf("expected target disk autoresize to be None, got %s", cfg.Target.DiskAutoresize.String()) + } + if cfg.Target.DiskSize.String() != fmt.Sprintf("%v", targetDiskSize) { + t.Errorf("expected target disk size %d, got %s", targetDiskSize, cfg.Target.DiskSize.String()) + } + }) + t.Run("instance disk autoresize target disk autoresize is not set and target disk size is not set", func(t *testing.T) { + cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName)} + m := migratorBuilder + m.ConfigureTarget() + if cfg.Target.DiskAutoresize != option.None[bool]() { + t.Errorf("expected target disk autoresize to be None, got %s", cfg.Target.DiskAutoresize.String()) + } + if cfg.Target.DiskSize != option.None[int]() { + t.Errorf("expected target disk size to be None, got %s", cfg.Target.DiskSize.String()) + } + }) + }) + } +} diff --git a/internal/cloudsql/migrate/ui/ui.go b/internal/cloudsql/migrate/ui/ui.go new file mode 100644 index 00000000..141a98e9 --- /dev/null +++ b/internal/cloudsql/migrate/ui/ui.go @@ -0,0 +1,228 @@ +package ui + +import ( + "fmt" + "log" + "slices" + "strconv" + "strings" + + "github.com/nais/cli/internal/option" + "github.com/pterm/pterm" +) + +const ( + otherOption = "Other" + sameAsSourceOptionPrefix = "Same as source" +) + +var ( + CmdStyle = pterm.NewStyle(pterm.FgLightMagenta) + LinkStyle = pterm.NewStyle(pterm.FgLightBlue, pterm.Underscore) + YamlStyle = pterm.NewStyle(pterm.FgLightYellow) +) + +func stringCaster(s string) string { return s } +func boolCaster(s string) bool { return s == "true" } + +type Prompter interface { + Show(text ...string) (string, error) +} + +var TextInput Prompter = pterm.DefaultInteractiveTextInput + +type Selector interface { + Prompter + WithOptions(options []string) Selector +} + +type textSelector struct { + defaultSelector *pterm.InteractiveSelectPrinter +} + +func (t *textSelector) Show(text ...string) (string, error) { + return t.defaultSelector.Show(text...) +} + +func (t *textSelector) WithOptions(options []string) Selector { + return &textSelector{ + defaultSelector: pterm.DefaultInteractiveSelect. + WithOptions(options). + WithMaxHeight(len(options)), + } +} + +var TextSelector Selector = &textSelector{defaultSelector: &pterm.DefaultInteractiveSelect} + +// askForOption is a generic function to ask for an option from a list of options. +// +// It returns a function that can be called to ask for the option. +// The function returns the selected option as an Option[T]. +// If the selected option is the "Same as source" option, it returns None[T]. +// If the selected option is "Other", it calls the otherHandler function to ask for the value. +// The selected value is then cast to the desired type T using caster function, and returned as Some[T]. +func askForOption[T any](prompt string, sourceValue T, options []string, caster func(string) T, otherHandler func() string) func() option.Option[T] { + return func() option.Option[T] { + source := fmt.Sprintf("%s (%v)", sameAsSourceOptionPrefix, sourceValue) + options = append([]string{source}, options...) + if otherHandler != nil { + options = append(options, otherOption) + } + pterm.Println() + selected, err := TextSelector. + WithOptions(options). + Show(prompt) + if err != nil { + log.Fatalf("Error while creating text UI: %v", err) + return option.None[T]() + } + if selected == otherOption { + selected = otherHandler() + } + if strings.HasPrefix(selected, sameAsSourceOptionPrefix) { + return option.None[T]() + } + return option.Some(caster(selected)) + } +} + +// Suggested options for tier when asking user for a target tier. +var tierOptions = []string{ + "db-custom-1-3840", + "db-custom-2-5120", + "db-custom-2-7680", + "db-custom-4-15360", +} + +var AskForTier = askForTier + +// askForTier asks for a tier for the target instance. +// +// It returns a function that can be called to ask for the tier. +// The function returns the selected tier as an Option[string]. +// If the selected tier is the "Same as source" tier, it returns None[string]. +// If the selected tier is "Other", it asks the user to enter a custom tier. +// The selected value is returned as Some[string]. +func askForTier(sourceTier string) func() option.Option[string] { + var options []string + for _, tier := range tierOptions { + if tier != sourceTier { + options = append(options, tier) + } + } + return askForOption("Select a tier for the target instance", sourceTier, options, stringCaster, func() string { + pterm.Println("Check the documentation for possible options:") + LinkStyle.Printfln("\thttps://docs.nais.io/persistence/cloudsql/reference/#server-size") + tier, err := TextInput.Show("Enter the tier for the target instance") + if err != nil { + log.Fatalf("Error while creating text UI: %v", err) + return "" + } + return tier + }) +} + +// Mapping from instance type to version. +var typeToVersion = map[string]int{ + "POSTGRES_11": 11, + "POSTGRES_12": 12, + "POSTGRES_13": 13, + "POSTGRES_14": 14, + "POSTGRES_15": 15, + "POSTGRES_16": 16, + "POSTGRES_17": 17, + "POSTGRES_18": 18, +} + +var AskForType = askForType + +// askForType asks for a type for the target instance. +// +// It returns a function that can be called to ask for the type. +// The function returns the selected type as an Option[string]. +// If the selected type is the "Same as source" type, it returns None[string]. +// It is not possible to select a type (database version) less than source. +// The selected value is returned as Some[string]. +func askForType(sourceType string) func() option.Option[string] { + sourceVersion := typeToVersion[sourceType] + var options []string + for k, v := range typeToVersion { + if v > sourceVersion { + options = append(options, k) + } + } + if len(options) == 0 { + return func() option.Option[string] { return option.None[string]() } + } + slices.Sort(options) + slices.Reverse(options) + return askForOption("Select a type for the target instance", sourceType, options, stringCaster, nil) +} + +var AskForDiskAutoresize = askForDiskAutoresize + +// askForDiskAutoresize asks for disk autoresize for the target instance. +// +// It returns a function that can be called to ask for disk autoresize. +// The function returns the selected disk autoresize as an Option[bool]. +// If the source was unset, source is considered false (the nais default), and the "Same as source" option returns Some(false). +// It always returns Some(value), where value is the selected option. +func askForDiskAutoresize(sourceDiskAutoresize option.Option[bool]) func() option.Option[bool] { + var options []string + autoresize := false + sourceDiskAutoresize.Do(func(v bool) { + autoresize = v + }) + if autoresize { + options = append(options, "false") + } else { + options = append(options, "true") + } + return func() option.Option[bool] { + targetDiskAutoresize := askForOption("Enable disk autoresize for the target instance?", autoresize, options, boolCaster, nil)() + sourceDiskAutoresize.OrValue(false).Do(func(v bool) { + targetDiskAutoresize = targetDiskAutoresize.OrValue(v) + }) + return targetDiskAutoresize + } +} + +var AskForDiskSize = askForDiskSize + +// askForDiskSize asks for disk size for the target instance. +// +// It returns a function that can be called to ask for the disk size. +// The function returns the selected disk size as an Option[int]. +// If the user enters a blank string, it returns None[int]. +// If the user enters a number, it returns Some(value), where value is the entered number. +func askForDiskSize(sourceDiskSize option.Option[int]) func() option.Option[int] { + sourceSize := "" + sourceDiskSize.Do(func(v int) { + sourceSize = fmt.Sprintf("%d GB", v) + }) + var ask func() option.Option[int] + ask = func() option.Option[int] { + pterm.Println() + pterm.Println("Disk size is in GB, and must be greater than or equal to 10.") + msg := fmt.Sprintf("Enter the disk size for the target instance. Leave empty to use same as source (%s)", sourceSize) + diskSize, err := TextInput.Show(msg) + if err != nil { + log.Fatalf("Error while creating text UI: %v", err) + return option.None[int]() + } + if diskSize == "" { + return option.None[int]() + } + size, err := strconv.Atoi(diskSize) + if err != nil { + pterm.Error.Println("Disk size must be a whole number") + return ask() + } + if size < 10 { + pterm.Error.Println("Disk size must be greater than or equal to 10") + return ask() + } + return option.Some(size) + } + return ask +} diff --git a/internal/cloudsql/migrate/ui/ui_test.go b/internal/cloudsql/migrate/ui/ui_test.go new file mode 100644 index 00000000..d4db7b25 --- /dev/null +++ b/internal/cloudsql/migrate/ui/ui_test.go @@ -0,0 +1,221 @@ +package ui_test + +import ( + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/nais/cli/internal/cloudsql/migrate/ui" + "github.com/nais/cli/internal/option" +) + +type fakeTextInput struct { + text string +} + +func (f *fakeTextInput) Show(_ ...string) (string, error) { + return f.text, nil +} + +type fakeTextSelector struct { + t *testing.T + selected string + options []string +} + +func (f *fakeTextSelector) Show(_ ...string) (string, error) { + return f.selected, nil +} + +func (f *fakeTextSelector) WithOptions(options []string) ui.Selector { + if !slices.ContainsFunc(options, func(e string) bool { return strings.Contains(e, f.selected) }) { + f.t.Helper() + f.t.Fatalf("selected value not in options, got %q, options: %#v", f.selected, options) + } + f.options = options + return f +} + +func TestUIAskForDiskSize(t *testing.T) { + tests := map[string]struct { + source option.Option[int] + enteredValue string + expected option.Option[int] + }{ + "source has value and user presses Enter": { + source: option.Some(100), + enteredValue: "", + expected: option.None[int](), + }, + "source has value and user types in 200": { + source: option.Some(100), + enteredValue: "200", + expected: option.Some(200), + }, + "source has no value and user presses Enter": { + source: option.None[int](), + enteredValue: "", + expected: option.None[int](), + }, + "source has no value and user types in 200": { + source: option.None[int](), + enteredValue: "200", + expected: option.Some(200), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ui.TextInput = &fakeTextInput{text: test.enteredValue} + result := ui.AskForDiskSize(test.source)() + if result != test.expected { + t.Errorf("expected %v, got %v", test.expected, result) + } + }) + } +} + +func TestUIAskForDiskAutoresize(t *testing.T) { + tests := map[string]struct { + source option.Option[bool] + selectedValue string + expected option.Option[bool] + }{ + "source true and user presses Enter": { + source: option.Some(true), + selectedValue: "Same as source (true)", + expected: option.Some(true), + }, + "source true and user selects false": { + source: option.Some(true), + selectedValue: "false", + expected: option.Some(false), + }, + "source false and user presses Enter": { + source: option.Some(false), + selectedValue: "Same as source (false)", + expected: option.Some(false), + }, + "source false and user selects true": { + source: option.Some(false), + selectedValue: "true", + expected: option.Some(true), + }, + "source unset and user presses Enter": { + source: option.None[bool](), + selectedValue: "Same as source (false)", + expected: option.Some(false), + }, + "source unset and user selects true": { + source: option.None[bool](), + selectedValue: "true", + expected: option.Some(true), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ui.TextSelector = &fakeTextSelector{t: t, selected: test.selectedValue} + result := ui.AskForDiskAutoresize(test.source)() + if result != test.expected { + t.Errorf("expected %v, got %v", test.expected, result) + } + }) + } +} + +func TestUIAskForTier_when_source_has_a_value_and(t *testing.T) { + tests := map[string]struct { + selectedValue string + expected option.Option[string] + }{ + "user presses Enter": { + selectedValue: "Same as source (db-f1-micro)", + expected: option.None[string](), + }, + "user selects db-custom-2-5120": { + selectedValue: "db-custom-2-5120", + expected: option.Some("db-custom-2-5120"), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ui.TextSelector = &fakeTextSelector{t: t, selected: test.selectedValue} + result := ui.AskForTier("db-f1-micro")() + if result != test.expected { + t.Errorf("expected %v, got %v", test.expected, result) + } + }) + } +} + +func TestUIAskForTier_user_selects_Other_and_enters_a_value_it_returns_the_entered_value(t *testing.T) { + ui.TextSelector = &fakeTextSelector{t: t, selected: "Other"} + ui.TextInput = &fakeTextInput{text: "db-custom-16-8192"} + result := ui.AskForTier("db-f1-micro")() + expected := option.Some("db-custom-16-8192") + + if result != expected { + t.Errorf("expected %v, got %v", expected, result) + } +} + +func TestUIAskForTier_source_value_is_in_preset_list_of_options_it_is_only_listed_once(t *testing.T) { + f := &fakeTextSelector{t: t, selected: "db-custom-2-5120"} + ui.TextSelector = f + ui.AskForTier("db-custom-2-5120")() + if !slices.Contains(f.options, "Same as source (db-custom-2-5120)") { + t.Errorf("expected options to contain 'Same as source (db-custom-2-5120)', got %v", f.options) + } + if slices.Contains(f.options, "db-custom-2-5120") { + t.Errorf("expected options to not contain 'db-custom-2-5120', got %v", f.options) + } +} + +func TestUIAskForType(t *testing.T) { + tests := map[string]struct { + source string + selectedValue string + expected option.Option[string] + }{ + "same as source": { + source: "POSTGRES_13", + selectedValue: "Same as source (POSTGRES_13)", + expected: option.None[string](), + }, + "selects POSTGRES_14": { + source: "POSTGRES_13", + selectedValue: "POSTGRES_14", + expected: option.Some("POSTGRES_14"), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ui.TextSelector = &fakeTextSelector{t: t, selected: test.selectedValue} + result := ui.AskForType(test.source)() + if result != test.expected { + t.Errorf("expected %v, got %v", test.expected, result) + } + }) + } +} + +func TestUIAskForType_source_is_POSTGRES_14_only_list_newer_versions(t *testing.T) { + f := &fakeTextSelector{selected: "POSTGRES_15"} + ui.TextSelector = f + ui.AskForType("POSTGRES_14")() + + expected := []string{ + "Same as source (POSTGRES_14)", + "POSTGRES_18", + "POSTGRES_17", + "POSTGRES_16", + "POSTGRES_15", + } + if diff := cmp.Diff(f.options, expected); diff != "" { + t.Errorf("options mismatch (-got +want):\n%s", diff) + } +} diff --git a/internal/cloudsql/password.go b/internal/cloudsql/password.go new file mode 100644 index 00000000..116bc0e9 --- /dev/null +++ b/internal/cloudsql/password.go @@ -0,0 +1,142 @@ +package cloudsql + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/liberator/pkg/keygen" + "github.com/nais/naistrix" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func RotatePassword(ctx context.Context, appName, team, environment string, fl *flag.Password, out *naistrix.OutputWriter) error { + // Get secret values (access is logged for audit purposes) + sv, err := GetSecretValues(ctx, appName, team, environment, fl.CloudSQL, ReasonPasswordRotate, out) + if err != nil { + return err + } + + dbInfo, err := NewDBInfo(ctx, appName, team, environment) + if err != nil { + return err + } + + dbInfo.SetSecretValues(sv) + + cloudSQLDBInfo := dbInfo + + projectID, err := cloudSQLDBInfo.ProjectID(ctx) + if err != nil { + return err + } + + dbConnectionInfo, err := cloudSQLDBInfo.DBConnection(ctx) + if err != nil { + return err + } + + out.Println("Grant user cloudsql.admin access for 5 minutes") + err = grantUserAccess(ctx, projectID, "roles/cloudsql.admin", 5*time.Minute, out) + if err != nil { + return err + } + + out.Println("Generating new password") + newPassword, err := generatePassword() + if err != nil { + return err + } + + dbConnectionInfo.SetPassword(newPassword) + + out.Printf("Rotating password for user %v in database %v\n", dbConnectionInfo.username, dbConnectionInfo.dbName) + err = rotatePasswordForDatabaseUser(ctx, projectID, dbConnectionInfo.instance, dbConnectionInfo.username, dbConnectionInfo.password) + if err != nil { + return err + } + + out.Printf("Updating password in k8s secret google-sql-%v\n", cloudSQLDBInfo.appName) + err = updateKubernetesSecret(ctx, cloudSQLDBInfo, dbConnectionInfo) + if err != nil { + return err + } + + out.Println("Password rotated") + return nil +} + +func updateKubernetesSecret(ctx context.Context, dbInfo *CloudSQLDBInfo, dbConnectionInfo *ConnectionInfo) error { + secret, err := dbInfo.k8sClient.CoreV1().Secrets(string(dbInfo.namespace)).Get(ctx, "google-sql-"+dbInfo.appName, v1.GetOptions{}) + if err != nil { + return fmt.Errorf("unable to the k8s secret %q in %q: %w", "google-sql-"+dbInfo.appName, dbInfo.namespace, err) + } + + jdbcUrlSet := false + prefix := "" + for key := range secret.Data { + if strings.HasSuffix(key, "_PASSWORD") { + secret.Data[key] = []byte(dbConnectionInfo.password) + } + if before, ok := strings.CutSuffix(key, "_URL"); ok { + if strings.HasSuffix(key, "_JDBC_URL") && dbConnectionInfo.jdbcUrl != nil { + secret.Data[key] = []byte(dbConnectionInfo.jdbcUrl.String()) + jdbcUrlSet = true + } else if dbConnectionInfo.url != nil { + secret.Data[key] = []byte(dbConnectionInfo.url.String()) + prefix = before + } + } + } + + if !jdbcUrlSet && dbConnectionInfo.jdbcUrl != nil && len(prefix) > 0 { + key := prefix + "_JDBC_URL" + secret.Data[key] = []byte(dbConnectionInfo.jdbcUrl.String()) + } + + _, err = dbInfo.k8sClient.CoreV1().Secrets(string(dbInfo.namespace)).Update(ctx, secret, v1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed updating k8s secret %q in %q with new password: %w", "google-sql-"+dbInfo.appName, dbInfo.namespace, err) + } + + return nil +} + +func rotatePasswordForDatabaseUser(ctx context.Context, projectID, instance, username, password string) error { + args := []string{ + "sql", + "users", + "set-password", + username, + "--password", password, + "--instance", strings.Split(instance, ":")[2], + "--project", projectID, + } + + buf := &bytes.Buffer{} + cmd := exec.CommandContext(ctx, "gcloud", args...) + cmd.Stdout = buf + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + _, _ = io.Copy(os.Stdout, buf) + return fmt.Errorf("error running gcloud command: %w", err) + } + + return nil +} + +func generatePassword() (string, error) { + key, err := keygen.Keygen(32) + if err != nil { + return "", fmt.Errorf("unable to generate secret for sql user: %s", err) + } + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(key), nil +} diff --git a/internal/cloudsql/password_test.go b/internal/cloudsql/password_test.go new file mode 100644 index 00000000..65fca7fc --- /dev/null +++ b/internal/cloudsql/password_test.go @@ -0,0 +1,234 @@ +package cloudsql + +import ( + "fmt" + "net/url" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" +) + +const ( + namespace = "password-ns" + secretName = "google-sql-password-app" + appName = "password-app" + newPassword = "new-password" + oldPassword = "old-password" + + jdbcUrlTmpl = "jdbc:postgresql://localhost:5432/my-database?user=my-user&password=%s" + pgUrlTmpl = "postgresql://my-user:%s@localhost:5432/my-database" +) + +var ( + newJdbcUrl *url.URL + newPgUrl *url.URL +) + +func init() { + var err error + newJdbcUrl, err = url.Parse(fmt.Sprintf(jdbcUrlTmpl, newPassword)) + if err != nil { + panic(err) + } + + newPgUrl, err = url.Parse(fmt.Sprintf(pgUrlTmpl, newPassword)) + if err != nil { + panic(err) + } +} + +type test struct { + secretPrep []SecretPrep + assertSecret []AssertSecret +} + +func TestPassword(t *testing.T) { + tests := map[string]test{ + "has only password": { + secretPrep: []SecretPrep{AddPassword}, + assertSecret: []AssertSecret{HasPassword, HasNoUrl, HasNoJdbcUrl}, + }, + "has password and url": { + secretPrep: []SecretPrep{AddPassword, AddUrl}, + assertSecret: []AssertSecret{HasPassword, HasUrl, HasJdbcUrl}, + }, + "has all": { + secretPrep: []SecretPrep{AddPassword, AddUrl, AddJdbcUrl}, + assertSecret: []AssertSecret{HasPassword, HasUrl, HasJdbcUrl}, + }, + "has password and jdbc url": { + secretPrep: []SecretPrep{AddPassword, AddJdbcUrl}, + assertSecret: []AssertSecret{HasPassword, HasNoUrl, HasJdbcUrl}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + k8sClient := fake.NewClientset() + secret := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: namespace, + }, + Data: map[string][]byte{ + "DB_HOST": []byte("localhost"), + "DB_PORT": []byte("5432"), + "DB_DATABASE": []byte("my-database"), + "DB_USERNAME": []byte("my-user"), + }, + } + + for _, prep := range tc.secretPrep { + prep(secret) + } + + err := k8sClient.Tracker().Add(secret) + if err != nil { + t.Fatalf("failed to add secret to tracker: %v", err) + } + + dbInfo := createDbInfo(k8sClient) + dbConnectionInfo, err := createConnectionInfo(t.Context(), *secret, dbInfo.connectionName) + if err != nil { + t.Fatalf("failed to create connectionInfo: %v", err) + } + + dbConnectionInfo.SetPassword(newPassword) + + if err := updateKubernetesSecret(t.Context(), dbInfo, dbConnectionInfo); err != nil { + t.Fatalf("failed to update Kubernetes secret: %v", err) + } + + gvr, _ := meta.UnsafeGuessKindToResource(secret.GroupVersionKind()) + actual, err := k8sClient.Tracker().Get(gvr, secret.Namespace, secret.Name) + if err != nil { + t.Fatalf("failed to get secret: %v", err) + } + + actualSecret, ok := actual.(*corev1.Secret) + if !ok { + t.Fatalf("expected *corev1.Secret, got %T", actual) + } + + for _, assert := range tc.assertSecret { + assert(t, actualSecret) + } + }) + } +} + +func createDbInfo(k8sClient kubernetes.Interface) *CloudSQLDBInfo { + return &CloudSQLDBInfo{ + DBInfo: &DBInfo{ + k8sClient: k8sClient, + dynamicClient: nil, + config: nil, + namespace: namespace, + appName: appName, + }, + projectID: "project-id", + connectionName: "connection:name", + } +} + +type SecretPrep func(secret *corev1.Secret) + +func AddPassword(secret *corev1.Secret) { + secret.Data["DB_PASSWORD"] = []byte(oldPassword) +} + +func AddUrl(secret *corev1.Secret) { + secret.Data["DB_URL"] = fmt.Appendf(nil, pgUrlTmpl, oldPassword) +} + +func AddJdbcUrl(secret *corev1.Secret) { + secret.Data["DB_JDBC_URL"] = fmt.Appendf(nil, jdbcUrlTmpl, oldPassword) +} + +type AssertSecret func(t *testing.T, actual *corev1.Secret) + +func EqualUrlNoQuery(t *testing.T, expected *url.URL) { + t.Helper() + + expectedNoQuery, _, _ := strings.Cut(expected.String(), "?") + actualNoQuery, _, _ := strings.Cut(expected.String(), "?") + if expectedNoQuery != actualNoQuery { + t.Fatalf("expected URL without query to be '%s', but got '%s'", expectedNoQuery, actualNoQuery) + } + if actualNoQuery != expectedNoQuery { + t.Fatalf("expected URL without query to be '%s', but got '%s'", expectedNoQuery, actualNoQuery) + } +} + +func EqualQuery(t *testing.T, expected *url.URL) { + t.Helper() + + expectedQuery := expected.Query() + actualQuery := expected.Query() + if actualQuery.Encode() != expectedQuery.Encode() { + t.Fatalf("expected URL query to be '%s', but got '%s'", expectedQuery.Encode(), actualQuery.Encode()) + } +} + +func HasPassword(t *testing.T, actual *corev1.Secret) { + t.Helper() + + if actual.Data["DB_PASSWORD"] == nil { + t.Fatalf("expected DB_PASSWORD to be set, but it is not") + } + + if string(actual.Data["DB_PASSWORD"]) != newPassword { + t.Fatalf("expected DB_PASSWORD to be '%s', but got '%s'", newPassword, actual.Data["DB_PASSWORD"]) + } +} + +func HasUrl(t *testing.T, actual *corev1.Secret) { + t.Helper() + u, err := url.Parse(string(actual.Data["DB_URL"])) + if err != nil { + t.Fatalf("failed to parse DB_URL: %v", err) + } + if u == nil { + t.Fatalf("DB_URL is nil") + } + EqualUrlNoQuery(t, newPgUrl) + EqualQuery(t, newPgUrl) +} + +func HasNoUrl(t *testing.T, actual *corev1.Secret) { + t.Helper() + _, ok := actual.Data["DB_URL"] + if ok { + t.Fatalf("expected DB_URL to not be set, but it is") + } +} + +func HasJdbcUrl(t *testing.T, actual *corev1.Secret) { + t.Helper() + u, err := url.Parse(string(actual.Data["DB_JDBC_URL"])) + if err != nil { + t.Fatalf("failed to parse DB_JDBC_URL: %v", err) + } + if u == nil { + t.Fatalf("DB_JDBC_URL is nil") + } + EqualUrlNoQuery(t, newJdbcUrl) + EqualQuery(t, newJdbcUrl) +} + +func HasNoJdbcUrl(t *testing.T, actual *corev1.Secret) { + t.Helper() + _, ok := actual.Data["DB_JDBC_URL"] + if ok { + t.Fatalf("expected DB_JDBC_URL to not be set, but it is") + } +} diff --git a/internal/cloudsql/proxy.go b/internal/cloudsql/proxy.go new file mode 100644 index 00000000..fdcac14f --- /dev/null +++ b/internal/cloudsql/proxy.go @@ -0,0 +1,51 @@ +package cloudsql + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/naistrix" +) + +func RunProxy(ctx context.Context, appName, team, environment string, fl *flag.Proxy, out *naistrix.OutputWriter) error { + // Get secret values with user-provided reason (access is logged for audit purposes) + sv, err := GetSecretValuesWithUserReason(ctx, appName, team, environment, fl.CloudSQL, fl.Reason, out) + if err != nil { + return err + } + + dbInfo, err := NewDBInfo(ctx, appName, team, environment) + if err != nil { + return err + } + + dbInfo.SetSecretValues(sv) + + return dbInfo.RunProxy(ctx, fl.Host, &fl.Port, make(chan<- int, 1), out, true) +} + +func copy(closer chan struct{}, dst io.Writer, src io.Reader) { + _, _ = io.Copy(dst, src) + closer <- struct{}{} // connection is closed, send signal to stop proxy +} + +func checkDatabasePassword(out *naistrix.OutputWriter) error { + if _, ok := os.LookupEnv("PGPASSWORD"); ok { + return fmt.Errorf("PGPASSWORD is set, please unset it before running this command") + } + + dirname, err := os.UserHomeDir() + if err != nil { + out.Println("could not get home directory, can not check for .pgpass file") + return nil + } + + if s, err := os.Stat(filepath.Join(dirname, ".pgpass")); err == nil && !s.IsDir() { + return fmt.Errorf("found .pgpass file in home directory, please remove it before running this command") + } + return nil +} diff --git a/internal/cloudsql/psql.go b/internal/cloudsql/psql.go new file mode 100644 index 00000000..7a7afdb0 --- /dev/null +++ b/internal/cloudsql/psql.go @@ -0,0 +1,73 @@ +package cloudsql + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/naistrix" +) + +func RunPSQL(ctx context.Context, appName, team, environment string, fl *flag.Psql, out *naistrix.OutputWriter) error { + // Get secret values with user-provided reason (access is logged for audit purposes) + sv, err := GetSecretValuesWithUserReason(ctx, appName, team, environment, fl.CloudSQL, fl.Reason, out) + if err != nil { + return err + } + + psqlPath, err := exec.LookPath("psql") + if err != nil { + return err + } + + dbInfo, err := NewDBInfo(ctx, appName, team, environment) + if err != nil { + return err + } + + dbInfo.SetSecretValues(sv) + + connectionInfo, err := dbInfo.DBConnection(ctx) + if err != nil { + return err + } + + portCh := make(chan int, 1) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + go func() { + err := dbInfo.RunProxy(ctx, "localhost", nil, portCh, out, false) + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + + out.Printf("ERROR: %v", err) + cancel() + } + }() + port := <-portCh + + out.Printf("Running proxy on localhost:%v\n", port) + + arguments := []string{ + "--host", "localhost", + "--port", fmt.Sprintf("%d", port), + "--username", connectionInfo.email, + "--dbname", connectionInfo.dbName, + } + + cmd := exec.CommandContext(ctx, psqlPath, arguments...) + + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + environ := os.Environ() + environ = append(environ, fmt.Sprintf("PGPASSWORD=%s", connectionInfo.password)) + cmd.Env = environ + + return cmd.Run() +} diff --git a/internal/cloudsql/secret.go b/internal/cloudsql/secret.go new file mode 100644 index 00000000..af6079e5 --- /dev/null +++ b/internal/cloudsql/secret.go @@ -0,0 +1,72 @@ +package cloudsql + +import ( + "context" + "fmt" + "strings" + + "github.com/nais/cli/internal/cloudsql/command/flag" + "github.com/nais/cli/internal/naisapi" + "github.com/nais/naistrix" +) + +const ( + ReasonPasswordRotate = "Rotating database password via nais CLI" + ReasonPrepareAccess = "Preparing database for IAM user access via nais CLI" + ReasonRevokeAccess = "Revoking IAM user access from database via nais CLI" + ReasonListUsers = "Listing database users via nais CLI" + ReasonAddUser = "Adding database user via nais CLI" + ReasonDropUser = "Dropping database user via nais CLI" + ReasonEnableAudit = "Enabling audit logging via nais CLI" + ReasonVerifyAudit = "Verifying audit configuration via nais CLI" +) + +type SecretValues struct{ values map[string]string } + +func (s *SecretValues) Get(suffix string) string { + for name, val := range s.values { + if strings.HasSuffix(name, suffix) { + return val + } + } + return "" +} + +// GetSecretValues retrieves Cloud SQL secret values through the audited API. +func GetSecretValues(ctx context.Context, appName, team, environment string, fl *flag.CloudSQL, reason string, out *naistrix.OutputWriter) (*SecretValues, error) { + if reason == "" { + reason = fl.Reason + if reason == "" { + return nil, fmt.Errorf("reason is required for accessing database secrets") + } + } + out.Printf("Using team %q\n", team) + secretName := "google-sql-" + appName + out.Debugf("Requesting access to Cloud SQL secret %q...\n", secretName) + values, err := naisapi.ViewSecretValues(ctx, team, environment, secretName, reason) + if err != nil { + if strings.Contains(err.Error(), "not authorized") || strings.Contains(err.Error(), "Not authorized") { + return nil, fmt.Errorf("you are not authorized to access this database. Make sure you are a member of team %q", team) + } + return nil, err + } + out.Debugf("✅ Access granted.\n") + result := &SecretValues{values: make(map[string]string, len(values))} + for _, v := range values { + result.values[v.Name] = v.Value + } + return result, nil +} + +func GetSecretValuesWithUserReason(ctx context.Context, appName, team, environment string, fl *flag.CloudSQL, reason string, out *naistrix.OutputWriter) (*SecretValues, error) { + if reason == "" { + reason = fl.Reason + if reason == "" { + return nil, fmt.Errorf("reason is required for accessing database secrets (use --reason flag)") + } + } + if len(reason) < 10 { + return nil, fmt.Errorf("reason must be at least 10 characters") + } + return GetSecretValues(ctx, appName, team, environment, fl, reason, out) +} diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index 3ab917bb..0f0b0f8f 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -1044,6 +1044,72 @@ func (v *CreateOpenSearchResponse) GetCreateOpenSearch() CreateOpenSearchCreateO return v.CreateOpenSearch } +// CreatePostgresAccessAlphaCreatePostgresAccessCreatePostgresAccessPayload includes the requested fields of the GraphQL type CreatePostgresAccessPayload. +// The GraphQL type's documentation follows. +// +// Result of creating a personal Postgres access. +type CreatePostgresAccessAlphaCreatePostgresAccessCreatePostgresAccessPayload struct { + // Name of the newly created PostgresAccess resource. + Name string `json:"name"` +} + +// GetName returns CreatePostgresAccessAlphaCreatePostgresAccessCreatePostgresAccessPayload.Name, and is useful for accessing the field via an interface. +func (v *CreatePostgresAccessAlphaCreatePostgresAccessCreatePostgresAccessPayload) GetName() string { + return v.Name +} + +// CreatePostgresAccessAlphaResponse is returned by CreatePostgresAccessAlpha on success. +type CreatePostgresAccessAlphaResponse struct { + // EXPERIMENTAL: DO NOT USE + // Create time-limited personal access to a NAIS Postgres branch through the brokered PostgresAccess and relay flow. + // When the access is ready, retrieve its connection materials through PostgresAccess.connection. + CreatePostgresAccess CreatePostgresAccessAlphaCreatePostgresAccessCreatePostgresAccessPayload `json:"createPostgresAccess"` +} + +// GetCreatePostgresAccess returns CreatePostgresAccessAlphaResponse.CreatePostgresAccess, and is useful for accessing the field via an interface. +func (v *CreatePostgresAccessAlphaResponse) GetCreatePostgresAccess() CreatePostgresAccessAlphaCreatePostgresAccessCreatePostgresAccessPayload { + return v.CreatePostgresAccess +} + +// Input for creating a time-limited personal Postgres access. +type CreatePostgresAccessInput struct { + // Name of the Postgres containing the branch. + Postgres string `json:"postgres"` + // Local name of the branch to access. + Branch string `json:"branch"` + // Team that owns the Postgres branch. + TeamSlug string `json:"teamSlug"` + // Environment containing the Postgres branch. + EnvironmentName string `json:"environmentName"` + // Privileges requested for the personal database role. + AccessLevel PostgresAccessLevel `json:"accessLevel"` + // Reason for personal database access. Must be at least 10 characters. + Reason string `json:"reason"` + // Requested access lifetime (for example '30m' or '1h'). Defaults to '1h' and cannot exceed '1h'. + Ttl *string `json:"ttl"` +} + +// GetPostgres returns CreatePostgresAccessInput.Postgres, and is useful for accessing the field via an interface. +func (v *CreatePostgresAccessInput) GetPostgres() string { return v.Postgres } + +// GetBranch returns CreatePostgresAccessInput.Branch, and is useful for accessing the field via an interface. +func (v *CreatePostgresAccessInput) GetBranch() string { return v.Branch } + +// GetTeamSlug returns CreatePostgresAccessInput.TeamSlug, and is useful for accessing the field via an interface. +func (v *CreatePostgresAccessInput) GetTeamSlug() string { return v.TeamSlug } + +// GetEnvironmentName returns CreatePostgresAccessInput.EnvironmentName, and is useful for accessing the field via an interface. +func (v *CreatePostgresAccessInput) GetEnvironmentName() string { return v.EnvironmentName } + +// GetAccessLevel returns CreatePostgresAccessInput.AccessLevel, and is useful for accessing the field via an interface. +func (v *CreatePostgresAccessInput) GetAccessLevel() PostgresAccessLevel { return v.AccessLevel } + +// GetReason returns CreatePostgresAccessInput.Reason, and is useful for accessing the field via an interface. +func (v *CreatePostgresAccessInput) GetReason() string { return v.Reason } + +// GetTtl returns CreatePostgresAccessInput.Ttl, and is useful for accessing the field via an interface. +func (v *CreatePostgresAccessInput) GetTtl() *string { return v.Ttl } + // CreateSecretCreateSecretCreateSecretPayload includes the requested fields of the GraphQL type CreateSecretPayload. type CreateSecretCreateSecretCreateSecretPayload struct { // The created secret. @@ -1795,6 +1861,74 @@ type FindWorkloadsForCveResponse struct { // GetCve returns FindWorkloadsForCveResponse.Cve, and is useful for accessing the field via an interface. func (v *FindWorkloadsForCveResponse) GetCve() FindWorkloadsForCveCveCVE { return v.Cve } +// GetActivePostgresBranchAlphaResponse is returned by GetActivePostgresBranchAlpha on success. +type GetActivePostgresBranchAlphaResponse struct { + // Get a team by its slug. + Team GetActivePostgresBranchAlphaTeam `json:"team"` +} + +// GetTeam returns GetActivePostgresBranchAlphaResponse.Team, and is useful for accessing the field via an interface. +func (v *GetActivePostgresBranchAlphaResponse) GetTeam() GetActivePostgresBranchAlphaTeam { + return v.Team +} + +// GetActivePostgresBranchAlphaTeam includes the requested fields of the GraphQL type Team. +// The GraphQL type's documentation follows. +// +// The team type represents a team on the [Nais platform](https://nais.io/). +// +// Learn more about what Nais teams are and what they can be used for in the [official Nais documentation](https://docs.nais.io/explanations/team/). +// +// External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). +type GetActivePostgresBranchAlphaTeam struct { + // Get a specific environment for the team. + Environment GetActivePostgresBranchAlphaTeamEnvironment `json:"environment"` +} + +// GetEnvironment returns GetActivePostgresBranchAlphaTeam.Environment, and is useful for accessing the field via an interface. +func (v *GetActivePostgresBranchAlphaTeam) GetEnvironment() GetActivePostgresBranchAlphaTeamEnvironment { + return v.Environment +} + +// GetActivePostgresBranchAlphaTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. +type GetActivePostgresBranchAlphaTeamEnvironment struct { + // Postgres in the team environment. + Postgres GetActivePostgresBranchAlphaTeamEnvironmentPostgres `json:"postgres"` +} + +// GetPostgres returns GetActivePostgresBranchAlphaTeamEnvironment.Postgres, and is useful for accessing the field via an interface. +func (v *GetActivePostgresBranchAlphaTeamEnvironment) GetPostgres() GetActivePostgresBranchAlphaTeamEnvironmentPostgres { + return v.Postgres +} + +// GetActivePostgresBranchAlphaTeamEnvironmentPostgres includes the requested fields of the GraphQL type Postgres. +// The GraphQL type's documentation follows. +// +// A Postgres whose active branch can change. +type GetActivePostgresBranchAlphaTeamEnvironmentPostgres struct { + // Currently active branch, if selected. + ActiveBranch *GetActivePostgresBranchAlphaTeamEnvironmentPostgresActiveBranchPostgresBranch `json:"activeBranch"` +} + +// GetActiveBranch returns GetActivePostgresBranchAlphaTeamEnvironmentPostgres.ActiveBranch, and is useful for accessing the field via an interface. +func (v *GetActivePostgresBranchAlphaTeamEnvironmentPostgres) GetActiveBranch() *GetActivePostgresBranchAlphaTeamEnvironmentPostgresActiveBranchPostgresBranch { + return v.ActiveBranch +} + +// GetActivePostgresBranchAlphaTeamEnvironmentPostgresActiveBranchPostgresBranch includes the requested fields of the GraphQL type PostgresBranch. +// The GraphQL type's documentation follows. +// +// A named PostgresBranch belonging to a Postgres. +type GetActivePostgresBranchAlphaTeamEnvironmentPostgresActiveBranchPostgresBranch struct { + // Local name of this branch within its Postgres. + Name string `json:"name"` +} + +// GetName returns GetActivePostgresBranchAlphaTeamEnvironmentPostgresActiveBranchPostgresBranch.Name, and is useful for accessing the field via an interface. +func (v *GetActivePostgresBranchAlphaTeamEnvironmentPostgresActiveBranchPostgresBranch) GetName() string { + return v.Name +} + // GetAllConfigsResponse is returned by GetAllConfigs on success. type GetAllConfigsResponse struct { // Get a team by its slug. @@ -20438,6 +20572,128 @@ func (v *GetOpenSearchTeamEnvironmentOpenSearchVersion) GetDesiredMajor() OpenSe return v.DesiredMajor } +// GetPostgresAccessAlphaResponse is returned by GetPostgresAccessAlpha on success. +type GetPostgresAccessAlphaResponse struct { + // Get a team by its slug. + Team GetPostgresAccessAlphaTeam `json:"team"` +} + +// GetTeam returns GetPostgresAccessAlphaResponse.Team, and is useful for accessing the field via an interface. +func (v *GetPostgresAccessAlphaResponse) GetTeam() GetPostgresAccessAlphaTeam { return v.Team } + +// GetPostgresAccessAlphaTeam includes the requested fields of the GraphQL type Team. +// The GraphQL type's documentation follows. +// +// The team type represents a team on the [Nais platform](https://nais.io/). +// +// Learn more about what Nais teams are and what they can be used for in the [official Nais documentation](https://docs.nais.io/explanations/team/). +// +// External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). +type GetPostgresAccessAlphaTeam struct { + // Get a specific environment for the team. + Environment GetPostgresAccessAlphaTeamEnvironment `json:"environment"` +} + +// GetEnvironment returns GetPostgresAccessAlphaTeam.Environment, and is useful for accessing the field via an interface. +func (v *GetPostgresAccessAlphaTeam) GetEnvironment() GetPostgresAccessAlphaTeamEnvironment { + return v.Environment +} + +// GetPostgresAccessAlphaTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. +type GetPostgresAccessAlphaTeamEnvironment struct { + // EXPERIMENTAL: DO NOT USE + // Get a PostgresAccess and its state. Available to authorized team members. + PostgresAccess GetPostgresAccessAlphaTeamEnvironmentPostgresAccess `json:"postgresAccess"` +} + +// GetPostgresAccess returns GetPostgresAccessAlphaTeamEnvironment.PostgresAccess, and is useful for accessing the field via an interface. +func (v *GetPostgresAccessAlphaTeamEnvironment) GetPostgresAccess() GetPostgresAccessAlphaTeamEnvironmentPostgresAccess { + return v.PostgresAccess +} + +// GetPostgresAccessAlphaTeamEnvironmentPostgresAccess includes the requested fields of the GraphQL type PostgresAccess. +// The GraphQL type's documentation follows. +// +// A time-limited personal access request for a Postgres branch. +type GetPostgresAccessAlphaTeamEnvironmentPostgresAccess struct { + // High-level state of the access. + State PostgresAccessState `json:"state"` + // Human-readable message for the current state. + Message *string `json:"message"` + // EXPERIMENTAL: DO NOT USE + // Get connection materials for this ready access. Only its owner can read them. + Connection *GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails `json:"connection"` +} + +// GetState returns GetPostgresAccessAlphaTeamEnvironmentPostgresAccess.State, and is useful for accessing the field via an interface. +func (v *GetPostgresAccessAlphaTeamEnvironmentPostgresAccess) GetState() PostgresAccessState { + return v.State +} + +// GetMessage returns GetPostgresAccessAlphaTeamEnvironmentPostgresAccess.Message, and is useful for accessing the field via an interface. +func (v *GetPostgresAccessAlphaTeamEnvironmentPostgresAccess) GetMessage() *string { return v.Message } + +// GetConnection returns GetPostgresAccessAlphaTeamEnvironmentPostgresAccess.Connection, and is useful for accessing the field via an interface. +func (v *GetPostgresAccessAlphaTeamEnvironmentPostgresAccess) GetConnection() *GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails { + return v.Connection +} + +// GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails includes the requested fields of the GraphQL type PostgresAccessConnectionDetails. +// The GraphQL type's documentation follows. +// +// Sensitive connection materials for a ready personal Postgres access. +type GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails struct { + // Database username for the caller's personal role. + Username string `json:"username"` + // Short-lived password for the caller's database role. + Password string `json:"password"` + // CA certificate required to verify the PostgreSQL server certificate. + CaCertificate string `json:"caCertificate"` + // PostgreSQL server name used for TLS verification. + ServerName string `json:"serverName"` + // Public HTTP/3 relay endpoint. + RelayEndpoint string `json:"relayEndpoint"` + // Relay-Access header value (namespace/name). + RelayAccess string `json:"relayAccess"` + // Owner-only bearer token for this access; never log it. + RelayToken string `json:"relayToken"` +} + +// GetUsername returns GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails.Username, and is useful for accessing the field via an interface. +func (v *GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails) GetUsername() string { + return v.Username +} + +// GetPassword returns GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails.Password, and is useful for accessing the field via an interface. +func (v *GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails) GetPassword() string { + return v.Password +} + +// GetCaCertificate returns GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails.CaCertificate, and is useful for accessing the field via an interface. +func (v *GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails) GetCaCertificate() string { + return v.CaCertificate +} + +// GetServerName returns GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails.ServerName, and is useful for accessing the field via an interface. +func (v *GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails) GetServerName() string { + return v.ServerName +} + +// GetRelayEndpoint returns GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails.RelayEndpoint, and is useful for accessing the field via an interface. +func (v *GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails) GetRelayEndpoint() string { + return v.RelayEndpoint +} + +// GetRelayAccess returns GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails.RelayAccess, and is useful for accessing the field via an interface. +func (v *GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails) GetRelayAccess() string { + return v.RelayAccess +} + +// GetRelayToken returns GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails.RelayToken, and is useful for accessing the field via an interface. +func (v *GetPostgresAccessAlphaTeamEnvironmentPostgresAccessConnectionPostgresAccessConnectionDetails) GetRelayToken() string { + return v.RelayToken +} + // GetSecretActivityResponse is returned by GetSecretActivity on success. type GetSecretActivityResponse struct { // Get a team by its slug. @@ -30101,6 +30357,122 @@ func (v *GetTeamApplicationsTeamApplicationsApplicationConnectionNodesApplicatio return v.Name } +// GetTeamCloudSQLInstancesResponse is returned by GetTeamCloudSQLInstances on success. +type GetTeamCloudSQLInstancesResponse struct { + // Get a team by its slug. + Team GetTeamCloudSQLInstancesTeam `json:"team"` +} + +// GetTeam returns GetTeamCloudSQLInstancesResponse.Team, and is useful for accessing the field via an interface. +func (v *GetTeamCloudSQLInstancesResponse) GetTeam() GetTeamCloudSQLInstancesTeam { return v.Team } + +// GetTeamCloudSQLInstancesTeam includes the requested fields of the GraphQL type Team. +// The GraphQL type's documentation follows. +// +// The team type represents a team on the [Nais platform](https://nais.io/). +// +// Learn more about what Nais teams are and what they can be used for in the [official Nais documentation](https://docs.nais.io/explanations/team/). +// +// External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). +type GetTeamCloudSQLInstancesTeam struct { + // SQL instances owned by the team. + SqlInstances GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnection `json:"sqlInstances"` +} + +// GetSqlInstances returns GetTeamCloudSQLInstancesTeam.SqlInstances, and is useful for accessing the field via an interface. +func (v *GetTeamCloudSQLInstancesTeam) GetSqlInstances() GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnection { + return v.SqlInstances +} + +// GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnection includes the requested fields of the GraphQL type SqlInstanceConnection. +type GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnection struct { + Nodes []GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance `json:"nodes"` +} + +// GetNodes returns GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnection.Nodes, and is useful for accessing the field via an interface. +func (v *GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnection) GetNodes() []GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance { + return v.Nodes +} + +// GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance includes the requested fields of the GraphQL type SqlInstance. +type GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance struct { + Name string `json:"name"` + TeamEnvironment GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment `json:"teamEnvironment"` + Version *string `json:"version"` + HighAvailability bool `json:"highAvailability"` + // Indicates whether audit logging is enabled for this SQL instance and provides a link to the logs if set. + AuditLog *GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog `json:"auditLog"` + State SqlInstanceState `json:"state"` +} + +// GetName returns GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.Name, and is useful for accessing the field via an interface. +func (v *GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetName() string { + return v.Name +} + +// GetTeamEnvironment returns GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.TeamEnvironment, and is useful for accessing the field via an interface. +func (v *GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetTeamEnvironment() GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment { + return v.TeamEnvironment +} + +// GetVersion returns GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.Version, and is useful for accessing the field via an interface. +func (v *GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetVersion() *string { + return v.Version +} + +// GetHighAvailability returns GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.HighAvailability, and is useful for accessing the field via an interface. +func (v *GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetHighAvailability() bool { + return v.HighAvailability +} + +// GetAuditLog returns GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.AuditLog, and is useful for accessing the field via an interface. +func (v *GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetAuditLog() *GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog { + return v.AuditLog +} + +// GetState returns GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.State, and is useful for accessing the field via an interface. +func (v *GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetState() SqlInstanceState { + return v.State +} + +// GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog includes the requested fields of the GraphQL type AuditLog. +type GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog struct { + // Link to the audit log for this SQL instance. + LogUrl *string `json:"logUrl"` +} + +// GetLogUrl returns GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog.LogUrl, and is useful for accessing the field via an interface. +func (v *GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog) GetLogUrl() *string { + return v.LogUrl +} + +// GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. +type GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment struct { + // Get the environment. + Environment GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment `json:"environment"` +} + +// GetEnvironment returns GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment.Environment, and is useful for accessing the field via an interface. +func (v *GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment) GetEnvironment() GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment { + return v.Environment +} + +// GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment includes the requested fields of the GraphQL type Environment. +// The GraphQL type's documentation follows. +// +// An environment represents a runtime environment for workloads. +// +// Learn more in the [official Nais documentation](https://docs.nais.io/workloads/explanations/environment/). +type GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment struct { + // Unique name of the environment. + Name string `json:"name"` +} + +// GetName returns GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment.Name, and is useful for accessing the field via an interface. +func (v *GetTeamCloudSQLInstancesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment) GetName() string { + return v.Name +} + // GetTeamJobsResponse is returned by GetTeamJobs on success. type GetTeamJobsResponse struct { // Get a team by its slug. @@ -30442,6 +30814,134 @@ func (v *GetTeamKafkaTopicsTeamKafkaTopicsKafkaTopicConnectionNodesKafkaTopicTea return v.Name } +// GetTeamPostgresBranchesAlphaResponse is returned by GetTeamPostgresBranchesAlpha on success. +type GetTeamPostgresBranchesAlphaResponse struct { + // Get a team by its slug. + Team GetTeamPostgresBranchesAlphaTeam `json:"team"` +} + +// GetTeam returns GetTeamPostgresBranchesAlphaResponse.Team, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesAlphaResponse) GetTeam() GetTeamPostgresBranchesAlphaTeam { + return v.Team +} + +// GetTeamPostgresBranchesAlphaTeam includes the requested fields of the GraphQL type Team. +// The GraphQL type's documentation follows. +// +// The team type represents a team on the [Nais platform](https://nais.io/). +// +// Learn more about what Nais teams are and what they can be used for in the [official Nais documentation](https://docs.nais.io/explanations/team/). +// +// External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). +type GetTeamPostgresBranchesAlphaTeam struct { + // Postgres branches owned by the team. + PostgresBranches GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnection `json:"postgresBranches"` +} + +// GetPostgresBranches returns GetTeamPostgresBranchesAlphaTeam.PostgresBranches, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesAlphaTeam) GetPostgresBranches() GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnection { + return v.PostgresBranches +} + +// GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnection includes the requested fields of the GraphQL type PostgresBranchConnection. +type GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnection struct { + Nodes []GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch `json:"nodes"` +} + +// GetNodes returns GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnection.Nodes, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnection) GetNodes() []GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch { + return v.Nodes +} + +// GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch includes the requested fields of the GraphQL type PostgresBranch. +// The GraphQL type's documentation follows. +// +// A named PostgresBranch belonging to a Postgres. +type GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch struct { + // Local name of this branch within its Postgres. + Name string `json:"name"` + TeamEnvironment GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment `json:"teamEnvironment"` + // Postgres owning this PostgresBranch. + Postgres GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres `json:"postgres"` + // Current observed state of the branch. + State PostgresBranchState `json:"state"` +} + +// GetName returns GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch.Name, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch) GetName() string { + return v.Name +} + +// GetTeamEnvironment returns GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch.TeamEnvironment, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch) GetTeamEnvironment() GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment { + return v.TeamEnvironment +} + +// GetPostgres returns GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch.Postgres, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch) GetPostgres() GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres { + return v.Postgres +} + +// GetState returns GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch.State, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch) GetState() PostgresBranchState { + return v.State +} + +// GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres includes the requested fields of the GraphQL type Postgres. +// The GraphQL type's documentation follows. +// +// A Postgres whose active branch can change. +type GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres struct { + // Name of this Postgres. + Name string `json:"name"` + // Configured PostgreSQL major version. + MajorVersion string `json:"majorVersion"` + // Whether high availability is configured. + HighAvailability bool `json:"highAvailability"` +} + +// GetName returns GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres.Name, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres) GetName() string { + return v.Name +} + +// GetMajorVersion returns GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres.MajorVersion, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres) GetMajorVersion() string { + return v.MajorVersion +} + +// GetHighAvailability returns GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres.HighAvailability, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres) GetHighAvailability() bool { + return v.HighAvailability +} + +// GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. +type GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment struct { + // Get the environment. + Environment GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment `json:"environment"` +} + +// GetEnvironment returns GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment.Environment, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment) GetEnvironment() GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment { + return v.Environment +} + +// GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment includes the requested fields of the GraphQL type Environment. +// The GraphQL type's documentation follows. +// +// An environment represents a runtime environment for workloads. +// +// Learn more in the [official Nais documentation](https://docs.nais.io/workloads/explanations/environment/). +type GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment struct { + // Unique name of the environment. + Name string `json:"name"` +} + +// GetName returns GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment.Name, and is useful for accessing the field via an interface. +func (v *GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment) GetName() string { + return v.Name +} + // GetTeamPostgresBranchesResponse is returned by GetTeamPostgresBranches on success. type GetTeamPostgresBranchesResponse struct { // Get a team by its slug. @@ -32514,6 +33014,45 @@ var AllOrderDirection = []OrderDirection{ OrderDirectionDesc, } +// Privilege level granted to a personal Postgres database role. +type PostgresAccessLevel string + +const ( + // Read data without modifying it. + PostgresAccessLevelRead PostgresAccessLevel = "READ" + // Read and modify existing data. + PostgresAccessLevelReadwrite PostgresAccessLevel = "READWRITE" + // Read, modify, and create database objects where supported. + PostgresAccessLevelReadwritecreate PostgresAccessLevel = "READWRITECREATE" +) + +var AllPostgresAccessLevel = []PostgresAccessLevel{ + PostgresAccessLevelRead, + PostgresAccessLevelReadwrite, + PostgresAccessLevelReadwritecreate, +} + +// High-level reconciliation state of a personal Postgres access. +type PostgresAccessState string + +const ( + // The controller has not finished provisioning the access. + PostgresAccessStatePending PostgresAccessState = "PENDING" + // The access and its connection materials are ready. + PostgresAccessStateReady PostgresAccessState = "READY" + // The controller cannot provision the requested access. + PostgresAccessStateFailed PostgresAccessState = "FAILED" + // The server-controlled expiry time has passed. + PostgresAccessStateExpired PostgresAccessState = "EXPIRED" +) + +var AllPostgresAccessState = []PostgresAccessState{ + PostgresAccessStatePending, + PostgresAccessStateReady, + PostgresAccessStateFailed, + PostgresAccessStateExpired, +} + // Input for filtering Postgres branches. type PostgresBranchFilter struct { // Filter by the name of the branch. @@ -34560,6 +35099,14 @@ type __CreateOpenSearchInput struct { // GetInput returns __CreateOpenSearchInput.Input, and is useful for accessing the field via an interface. func (v *__CreateOpenSearchInput) GetInput() CreateOpenSearchInput { return v.Input } +// __CreatePostgresAccessAlphaInput is used internally by genqlient +type __CreatePostgresAccessAlphaInput struct { + Input CreatePostgresAccessInput `json:"input"` +} + +// GetInput returns __CreatePostgresAccessAlphaInput.Input, and is useful for accessing the field via an interface. +func (v *__CreatePostgresAccessAlphaInput) GetInput() CreatePostgresAccessInput { return v.Input } + // __CreateSecretInput is used internally by genqlient type __CreateSecretInput struct { Name string `json:"name"` @@ -34720,6 +35267,22 @@ type __FindWorkloadsForCveInput struct { // GetIdentifier returns __FindWorkloadsForCveInput.Identifier, and is useful for accessing the field via an interface. func (v *__FindWorkloadsForCveInput) GetIdentifier() string { return v.Identifier } +// __GetActivePostgresBranchAlphaInput is used internally by genqlient +type __GetActivePostgresBranchAlphaInput struct { + Team string `json:"team"` + Environment string `json:"environment"` + Postgres string `json:"postgres"` +} + +// GetTeam returns __GetActivePostgresBranchAlphaInput.Team, and is useful for accessing the field via an interface. +func (v *__GetActivePostgresBranchAlphaInput) GetTeam() string { return v.Team } + +// GetEnvironment returns __GetActivePostgresBranchAlphaInput.Environment, and is useful for accessing the field via an interface. +func (v *__GetActivePostgresBranchAlphaInput) GetEnvironment() string { return v.Environment } + +// GetPostgres returns __GetActivePostgresBranchAlphaInput.Postgres, and is useful for accessing the field via an interface. +func (v *__GetActivePostgresBranchAlphaInput) GetPostgres() string { return v.Postgres } + // __GetAllConfigsInput is used internally by genqlient type __GetAllConfigsInput struct { TeamSlug string `json:"teamSlug"` @@ -35096,6 +35659,22 @@ func (v *__GetOpenSearchInput) GetEnvironmentName() string { return v.Environmen // GetTeamSlug returns __GetOpenSearchInput.TeamSlug, and is useful for accessing the field via an interface. func (v *__GetOpenSearchInput) GetTeamSlug() string { return v.TeamSlug } +// __GetPostgresAccessAlphaInput is used internally by genqlient +type __GetPostgresAccessAlphaInput struct { + Team string `json:"team"` + Environment string `json:"environment"` + Name string `json:"name"` +} + +// GetTeam returns __GetPostgresAccessAlphaInput.Team, and is useful for accessing the field via an interface. +func (v *__GetPostgresAccessAlphaInput) GetTeam() string { return v.Team } + +// GetEnvironment returns __GetPostgresAccessAlphaInput.Environment, and is useful for accessing the field via an interface. +func (v *__GetPostgresAccessAlphaInput) GetEnvironment() string { return v.Environment } + +// GetName returns __GetPostgresAccessAlphaInput.Name, and is useful for accessing the field via an interface. +func (v *__GetPostgresAccessAlphaInput) GetName() string { return v.Name } + // __GetSecretActivityInput is used internally by genqlient type __GetSecretActivityInput struct { Team string `json:"team"` @@ -35166,6 +35745,18 @@ func (v *__GetTeamApplicationsInput) GetOrderBy() *ApplicationOrder { return v.O // GetFilter returns __GetTeamApplicationsInput.Filter, and is useful for accessing the field via an interface. func (v *__GetTeamApplicationsInput) GetFilter() *TeamApplicationsFilter { return v.Filter } +// __GetTeamCloudSQLInstancesInput is used internally by genqlient +type __GetTeamCloudSQLInstancesInput struct { + Team string `json:"team"` + SqlFilter *SqlInstanceFilter `json:"sqlFilter"` +} + +// GetTeam returns __GetTeamCloudSQLInstancesInput.Team, and is useful for accessing the field via an interface. +func (v *__GetTeamCloudSQLInstancesInput) GetTeam() string { return v.Team } + +// GetSqlFilter returns __GetTeamCloudSQLInstancesInput.SqlFilter, and is useful for accessing the field via an interface. +func (v *__GetTeamCloudSQLInstancesInput) GetSqlFilter() *SqlInstanceFilter { return v.SqlFilter } + // __GetTeamJobsInput is used internally by genqlient type __GetTeamJobsInput struct { Team string `json:"team"` @@ -35206,6 +35797,20 @@ func (v *__GetTeamKafkaTopicsInput) GetTeam() string { return v.Team } // GetFilter returns __GetTeamKafkaTopicsInput.Filter, and is useful for accessing the field via an interface. func (v *__GetTeamKafkaTopicsInput) GetFilter() *KafkaTopicFilter { return v.Filter } +// __GetTeamPostgresBranchesAlphaInput is used internally by genqlient +type __GetTeamPostgresBranchesAlphaInput struct { + Team string `json:"team"` + PostgresFilter *PostgresBranchFilter `json:"postgresFilter"` +} + +// GetTeam returns __GetTeamPostgresBranchesAlphaInput.Team, and is useful for accessing the field via an interface. +func (v *__GetTeamPostgresBranchesAlphaInput) GetTeam() string { return v.Team } + +// GetPostgresFilter returns __GetTeamPostgresBranchesAlphaInput.PostgresFilter, and is useful for accessing the field via an interface. +func (v *__GetTeamPostgresBranchesAlphaInput) GetPostgresFilter() *PostgresBranchFilter { + return v.PostgresFilter +} + // __GetTeamPostgresBranchesInput is used internally by genqlient type __GetTeamPostgresBranchesInput struct { Team string `json:"team"` @@ -36013,6 +36618,40 @@ func CreateOpenSearchCredentials( return data_, err_ } +// The mutation executed by CreatePostgresAccessAlpha. +const CreatePostgresAccessAlpha_Operation = ` +mutation CreatePostgresAccessAlpha ($input: CreatePostgresAccessInput!) { + createPostgresAccess(input: $input) { + name + } +} +` + +func CreatePostgresAccessAlpha( + ctx_ context.Context, + client_ graphql.Client, + input CreatePostgresAccessInput, +) (data_ *CreatePostgresAccessAlphaResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "CreatePostgresAccessAlpha", + Query: CreatePostgresAccessAlpha_Operation, + Variables: &__CreatePostgresAccessAlphaInput{ + Input: input, + }, + } + + data_ = &CreatePostgresAccessAlphaResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by CreateSecret. const CreateSecret_Operation = ` mutation CreateSecret ($name: String!, $environment: String!, $team: Slug!) { @@ -36495,6 +37134,50 @@ func FindWorkloadsForCve( return data_, err_ } +// The query executed by GetActivePostgresBranchAlpha. +const GetActivePostgresBranchAlpha_Operation = ` +query GetActivePostgresBranchAlpha ($team: Slug!, $environment: String!, $postgres: String!) { + team(slug: $team) { + environment(name: $environment) { + postgres(name: $postgres) { + activeBranch { + name + } + } + } + } +} +` + +func GetActivePostgresBranchAlpha( + ctx_ context.Context, + client_ graphql.Client, + team string, + environment string, + postgres string, +) (data_ *GetActivePostgresBranchAlphaResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetActivePostgresBranchAlpha", + Query: GetActivePostgresBranchAlpha_Operation, + Variables: &__GetActivePostgresBranchAlphaInput{ + Team: team, + Environment: environment, + Postgres: postgres, + }, + } + + data_ = &GetActivePostgresBranchAlphaResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by GetAllConfigs. const GetAllConfigs_Operation = ` query GetAllConfigs ($teamSlug: Slug!, $filter: ConfigFilter) { @@ -37908,6 +38591,58 @@ func GetOpenSearch( return data_, err_ } +// The query executed by GetPostgresAccessAlpha. +const GetPostgresAccessAlpha_Operation = ` +query GetPostgresAccessAlpha ($team: Slug!, $environment: String!, $name: String!) { + team(slug: $team) { + environment(name: $environment) { + postgresAccess(name: $name) { + state + message + connection { + username + password + caCertificate + serverName + relayEndpoint + relayAccess + relayToken + } + } + } + } +} +` + +func GetPostgresAccessAlpha( + ctx_ context.Context, + client_ graphql.Client, + team string, + environment string, + name string, +) (data_ *GetPostgresAccessAlphaResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetPostgresAccessAlpha", + Query: GetPostgresAccessAlpha_Operation, + Variables: &__GetPostgresAccessAlphaInput{ + Team: team, + Environment: environment, + Name: name, + }, + } + + data_ = &GetPostgresAccessAlphaResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by GetSecret. const GetSecret_Operation = ` query GetSecret ($name: String!, $environmentName: String!, $teamSlug: Slug!) { @@ -38138,6 +38873,57 @@ func GetTeamApplications( return data_, err_ } +// The query executed by GetTeamCloudSQLInstances. +const GetTeamCloudSQLInstances_Operation = ` +query GetTeamCloudSQLInstances ($team: Slug!, $sqlFilter: SqlInstanceFilter) { + team(slug: $team) { + sqlInstances(first: 1000, filter: $sqlFilter) { + nodes { + name + teamEnvironment { + environment { + name + } + } + version + highAvailability + auditLog { + logUrl + } + state + } + } + } +} +` + +func GetTeamCloudSQLInstances( + ctx_ context.Context, + client_ graphql.Client, + team string, + sqlFilter *SqlInstanceFilter, +) (data_ *GetTeamCloudSQLInstancesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetTeamCloudSQLInstances", + Query: GetTeamCloudSQLInstances_Operation, + Variables: &__GetTeamCloudSQLInstancesInput{ + Team: team, + SqlFilter: sqlFilter, + }, + } + + data_ = &GetTeamCloudSQLInstancesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by GetTeamJobs. const GetTeamJobs_Operation = ` query GetTeamJobs ($team: Slug!, $orderBy: JobOrder, $filter: TeamJobsFilter) { @@ -38362,6 +39148,57 @@ func GetTeamPostgresBranches( return data_, err_ } +// The query executed by GetTeamPostgresBranchesAlpha. +const GetTeamPostgresBranchesAlpha_Operation = ` +query GetTeamPostgresBranchesAlpha ($team: Slug!, $postgresFilter: PostgresBranchFilter) { + team(slug: $team) { + postgresBranches(first: 1000, filter: $postgresFilter) { + nodes { + name + teamEnvironment { + environment { + name + } + } + postgres { + name + majorVersion + highAvailability + } + state + } + } + } +} +` + +func GetTeamPostgresBranchesAlpha( + ctx_ context.Context, + client_ graphql.Client, + team string, + postgresFilter *PostgresBranchFilter, +) (data_ *GetTeamPostgresBranchesAlphaResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetTeamPostgresBranchesAlpha", + Query: GetTeamPostgresBranchesAlpha_Operation, + Variables: &__GetTeamPostgresBranchesAlphaInput{ + Team: team, + PostgresFilter: postgresFilter, + }, + } + + data_ = &GetTeamPostgresBranchesAlphaResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by GetTeamVulnerabilitySummary. const GetTeamVulnerabilitySummary_Operation = ` query GetTeamVulnerabilitySummary ($team: Slug!, $filter: TeamVulnerabilitySummaryFilter) { diff --git a/internal/postgres/command/enable_audit.go b/internal/postgres/command/enable_audit.go index dfb78644..0cfce6f9 100644 --- a/internal/postgres/command/enable_audit.go +++ b/internal/postgres/command/enable_audit.go @@ -12,7 +12,7 @@ import ( func enableAuditCommand(parentFlags *flag.Postgres) *naistrix.Command { flags := &flag.EnableAudit{Postgres: parentFlags} - return &naistrix.Command{ + return legacyCommand("postgres enable-audit", "nais cloudsql enable-audit", &naistrix.Command{ Name: "enable-audit", Title: "Enable audit extension in SQL instance database.", Description: "This is done by creating pgaudit extension in the database and enabling audit logging for personal user accounts.", @@ -28,5 +28,5 @@ func enableAuditCommand(parentFlags *flag.Postgres) *naistrix.Command { } return err }, - } + }) } diff --git a/internal/postgres/command/grant.go b/internal/postgres/command/grant.go index 8cf39f73..ad8ab355 100644 --- a/internal/postgres/command/grant.go +++ b/internal/postgres/command/grant.go @@ -11,7 +11,7 @@ import ( func grantCommand(parentFlags *flag.Postgres) *naistrix.Command { flags := &flag.Grant{Postgres: parentFlags} - return &naistrix.Command{ + return legacyCommand("postgres grant", "", &naistrix.Command{ Name: "grant", Title: "Grant yourself access to a SQL instance database.", Description: "This is done by temporarily adding your user to the list of users that can administrate Cloud SQL instances and creating a user with your email.", @@ -23,5 +23,5 @@ func grantCommand(parentFlags *flag.Postgres) *naistrix.Command { RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { return postgres.GrantAndCreateSQLUser(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), out) }, - } + }) } diff --git a/internal/postgres/command/legacy.go b/internal/postgres/command/legacy.go new file mode 100644 index 00000000..ce3bb35f --- /dev/null +++ b/internal/postgres/command/legacy.go @@ -0,0 +1,29 @@ +package command + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/nais/naistrix" +) + +// legacyCommand warns about the new command path without intercepting the old execution. +// naistrix.Deprecated cannot be used here: it replaces RunFunc instead of running it. +func legacyCommand(path, replacement string, cmd *naistrix.Command) *naistrix.Command { + run := cmd.RunFunc + cmd.Title += " (DEPRECATED)" + cmd.RunFunc = func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + warning := fmt.Sprintf("Warning: nais %s is deprecated and will be removed in a future release", path) + if replacement != "" { + warning += "; use " + replacement + " instead" + } + if strings.HasSuffix(path, " grant") || strings.HasSuffix(path, " prepare") || strings.HasSuffix(path, " revoke") || strings.HasSuffix(path, " proxy") || strings.HasSuffix(path, " psql") { + warning += "; the legacy in-cluster access path will be removed" + } + _, _ = fmt.Fprintln(os.Stderr, warning+".") + return run(ctx, args, out) + } + return cmd +} diff --git a/internal/postgres/command/legacy_test.go b/internal/postgres/command/legacy_test.go new file mode 100644 index 00000000..056f9a9e --- /dev/null +++ b/internal/postgres/command/legacy_test.go @@ -0,0 +1,100 @@ +package command + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "strings" + "testing" + + "github.com/nais/cli/internal/flags" + "github.com/nais/naistrix" +) + +func TestLegacyCommandWarnsAndRunsOriginal(t *testing.T) { + for _, tt := range []struct { + name string + path string + replacement string + want string + }{ + {"replacement", "postgres prepare", "nais cloudsql prepare", "Warning: nais postgres prepare is deprecated and will be removed in a future release; use nais cloudsql prepare instead; the legacy in-cluster access path will be removed.\n"}, + {"no replacement", "postgres grant", "", "Warning: nais postgres grant is deprecated and will be removed in a future release; the legacy in-cluster access path will be removed.\n"}, + } { + t.Run(tt.name, func(t *testing.T) { + originalErr := errors.New("original error") + ctx := context.Background() + args := &naistrix.Arguments{} + var stdout bytes.Buffer + out := naistrix.NewOutputWriter(&stdout, nil) + called := false + cmd := legacyCommand(tt.path, tt.replacement, &naistrix.Command{ + Name: "prepare", + RunFunc: func(gotCtx context.Context, gotArgs *naistrix.Arguments, gotOut *naistrix.OutputWriter) error { + called = true + if gotCtx != ctx || gotArgs != args || gotOut != out { + t.Error("original command received different inputs") + } + return originalErr + }, + }) + + previous := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stderr = w + gotErr := cmd.RunFunc(ctx, args, out) + os.Stderr = previous + if err := w.Close(); err != nil { + t.Fatal(err) + } + warning, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if err := r.Close(); err != nil { + t.Fatal(err) + } + if string(warning) != tt.want { + t.Errorf("stderr = %q, want %q", warning, tt.want) + } + if stdout.Len() != 0 { + t.Errorf("stdout = %q, want empty", stdout.String()) + } + if !called || !errors.Is(gotErr, originalErr) { + t.Errorf("original called = %v, error = %v; want original error", called, gotErr) + } + }) + } +} + +func TestLegacyPostgresLeavesRemainRunnable(t *testing.T) { + root := Postgres(&flags.GlobalFlags{}) + var paths []string + var walk func(*naistrix.Command, string) + walk = func(cmd *naistrix.Command, prefix string) { + path := strings.TrimSpace(prefix + " " + cmd.Name) + if len(cmd.SubCommands) == 0 { + paths = append(paths, path) + if cmd.Deprecated != nil || cmd.RunFunc == nil || !strings.Contains(cmd.Title, "(DEPRECATED)") { + t.Errorf("%s must execute its RunFunc without naistrix deprecation interception", path) + } + } + for _, child := range cmd.SubCommands { + walk(child, path) + } + } + walk(root, "") + want := []string{ + "postgres list", "postgres migrate setup", "postgres migrate promote", "postgres migrate finalize", "postgres migrate rollback", + "postgres password rotate", "postgres users add", "postgres users drop", "postgres users list", + "postgres enable-audit", "postgres verify-audit", "postgres grant", "postgres prepare", "postgres proxy", "postgres psql", "postgres revoke", + } + if strings.Join(paths, ",") != strings.Join(want, ",") { + t.Errorf("leaves = %v, want %v", paths, want) + } +} diff --git a/internal/postgres/command/list.go b/internal/postgres/command/list.go index e0fa971a..c796196a 100644 --- a/internal/postgres/command/list.go +++ b/internal/postgres/command/list.go @@ -13,7 +13,7 @@ import ( func listCommand(parentFlags *flag.Postgres) *naistrix.Command { flags := &flag.List{Postgres: parentFlags} - return &naistrix.Command{ + return legacyCommand("postgres list", "nais cloudsql list for Cloud SQL or nais alpha postgres list for Nais Postgres", &naistrix.Command{ Name: "list", Title: "List Postgres branches and Cloud SQL instances for a team.", Description: "List NAIS Postgres branches and Google Cloud SQL Postgres instances owned by a team.", @@ -45,5 +45,5 @@ func listCommand(parentFlags *flag.Postgres) *naistrix.Command { return out.Table().Render(ret) }, - } + }) } diff --git a/internal/postgres/command/migrate.go b/internal/postgres/command/migrate.go index 3d600d82..24eeb378 100644 --- a/internal/postgres/command/migrate.go +++ b/internal/postgres/command/migrate.go @@ -47,7 +47,7 @@ func migrateSetupCommand(parentFlags *flag.Migrate) *naistrix.Command { flags.DiskSize = v } - return &naistrix.Command{ + return legacyCommand("postgres migrate setup", "nais cloudsql migrate setup", &naistrix.Command{ Name: "setup", Title: "Make necessary setup for a new SQL instance migration.", Description: "Setup will create a new (target) instance with updated configuration, and enable continuous replication of data from the source instance.", @@ -70,12 +70,12 @@ func migrateSetupCommand(parentFlags *flag.Migrate) *naistrix.Command { RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { return setup.Run(ctx, args.Get("app_name"), args.Get("target_sql_instance_name"), flags.Team, string(flags.Environment), flags) }, - } + }) } func migratePromoteCommand(parentFlags *flag.Migrate) *naistrix.Command { flags := &flag.MigratePromote{Migrate: parentFlags} - return &naistrix.Command{ + return legacyCommand("postgres migrate promote", "nais cloudsql migrate promote", &naistrix.Command{ Name: "promote", Title: "Promote the migrated instance to the new primary instance.", Description: "Promote will promote the target instance to the new primary instance, and update the application to use the new instance.", @@ -87,12 +87,12 @@ func migratePromoteCommand(parentFlags *flag.Migrate) *naistrix.Command { RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { return promote.Run(ctx, args.Get("app_name"), args.Get("target_sql_instance_name"), flags.Team, string(flags.Environment), flags) }, - } + }) } func migrateFinalizeCommand(parentFlags *flag.Migrate) *naistrix.Command { flags := &flag.MigrateFinalize{Migrate: parentFlags} - return &naistrix.Command{ + return legacyCommand("postgres migrate finalize", "nais cloudsql migrate finalize", &naistrix.Command{ Name: "finalize", Title: "Finalize the migration.", Description: "Finalize will remove the source instance and associated resources after a successful migration.", @@ -104,12 +104,12 @@ func migrateFinalizeCommand(parentFlags *flag.Migrate) *naistrix.Command { RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { return finalize.Run(ctx, args.Get("app_name"), args.Get("target_sql_instance_name"), flags.Team, string(flags.Environment), flags.DryRun) }, - } + }) } func migrateRollbackCommand(parentFlags *flag.Migrate) *naistrix.Command { flags := &flag.MigrateRollback{Migrate: parentFlags} - return &naistrix.Command{ + return legacyCommand("postgres migrate rollback", "nais cloudsql migrate rollback", &naistrix.Command{ Name: "rollback", Title: "Roll back the migration.", Description: "Rollback will roll back the migration, and restore the application to use the original instance.", @@ -121,5 +121,5 @@ func migrateRollbackCommand(parentFlags *flag.Migrate) *naistrix.Command { RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { return rollback.Run(ctx, args.Get("app_name"), args.Get("target_sql_instance_name"), flags.Team, string(flags.Environment), flags) }, - } + }) } diff --git a/internal/postgres/command/password.go b/internal/postgres/command/password.go index 778675cb..7632b42e 100644 --- a/internal/postgres/command/password.go +++ b/internal/postgres/command/password.go @@ -17,7 +17,7 @@ func passwordCommand(parentFlags *flag.Postgres) *naistrix.Command { Description: "Commands for managing Postgres instance passwords, including password rotation.", StickyFlags: flags, SubCommands: []*naistrix.Command{ - { + legacyCommand("postgres password rotate", "nais cloudsql password rotate", &naistrix.Command{ Name: "rotate", Title: "Rotate the SQL instance password.", Description: "The rotation is done in GCP and in the Kubernetes secret.", @@ -28,7 +28,7 @@ func passwordCommand(parentFlags *flag.Postgres) *naistrix.Command { RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { return postgres.RotatePassword(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) }, - }, + }), }, } } diff --git a/internal/postgres/command/prepare.go b/internal/postgres/command/prepare.go index 83095005..97dc0378 100644 --- a/internal/postgres/command/prepare.go +++ b/internal/postgres/command/prepare.go @@ -19,7 +19,7 @@ func prepareCommand(parentFlags *flag.Postgres) *naistrix.Command { Schema: "public", } - return &naistrix.Command{ + return legacyCommand("postgres prepare", "nais cloudsql prepare", &naistrix.Command{ Name: "prepare", Title: "Prepare your SQL instance for use with personal accounts.", Description: heredoc.Doc(` @@ -43,5 +43,5 @@ func prepareCommand(parentFlags *flag.Postgres) *naistrix.Command { return postgres.PrepareAccess(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) }, - } + }) } diff --git a/internal/postgres/command/proxy.go b/internal/postgres/command/proxy.go index 701828f6..770e6011 100644 --- a/internal/postgres/command/proxy.go +++ b/internal/postgres/command/proxy.go @@ -15,7 +15,7 @@ func proxyCommand(parentFlags *flag.Postgres) *naistrix.Command { Port: 5432, Host: "localhost", } - return &naistrix.Command{ + return legacyCommand("postgres proxy", "nais cloudsql proxy", &naistrix.Command{ Name: "proxy", Title: "Create a proxy to a SQL instance.", Description: "Allows your user to connect to databases and starts a proxy.", @@ -27,5 +27,5 @@ func proxyCommand(parentFlags *flag.Postgres) *naistrix.Command { RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { return postgres.RunProxy(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) }, - } + }) } diff --git a/internal/postgres/command/psql.go b/internal/postgres/command/psql.go index 505e7edf..c70d00d3 100644 --- a/internal/postgres/command/psql.go +++ b/internal/postgres/command/psql.go @@ -11,7 +11,7 @@ import ( func psqlCommand(parentFlags *flag.Postgres) *naistrix.Command { flags := &flag.Psql{Postgres: parentFlags} - return &naistrix.Command{ + return legacyCommand("postgres psql", "nais cloudsql psql", &naistrix.Command{ Name: "psql", Title: "Connect to the database using psql.", Description: "Create a shell to the SQL instance by opening a proxy on a random port (see the proxy command for more info) and opening a psql shell.", @@ -23,5 +23,5 @@ func psqlCommand(parentFlags *flag.Postgres) *naistrix.Command { RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { return postgres.RunPSQL(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) }, - } + }) } diff --git a/internal/postgres/command/revoke.go b/internal/postgres/command/revoke.go index 1a45a10f..cd97a1d5 100644 --- a/internal/postgres/command/revoke.go +++ b/internal/postgres/command/revoke.go @@ -18,7 +18,7 @@ func revokeCommand(parentFlags *flag.Postgres) *naistrix.Command { Postgres: parentFlags, Schema: "public", } - return &naistrix.Command{ + return legacyCommand("postgres revoke", "nais cloudsql revoke", &naistrix.Command{ Name: "revoke", Title: `Revoke access to your SQL instance for the role "cloudsqliamuser".`, Description: heredoc.Doc(` @@ -42,5 +42,5 @@ func revokeCommand(parentFlags *flag.Postgres) *naistrix.Command { return postgres.RevokeAccess(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) }, - } + }) } diff --git a/internal/postgres/command/users.go b/internal/postgres/command/users.go index cda82424..cf811744 100644 --- a/internal/postgres/command/users.go +++ b/internal/postgres/command/users.go @@ -30,7 +30,7 @@ func addCommand(parentFlags *flag.User) *naistrix.Command { User: parentFlags, Privilege: "select", } - return &naistrix.Command{ + return legacyCommand("postgres users add", "nais cloudsql users add", &naistrix.Command{ Name: "add", Title: "Add a user to a SQL instance.", Description: "Will grant a user access to tables in public schema.", @@ -43,12 +43,12 @@ func addCommand(parentFlags *flag.User) *naistrix.Command { RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { return postgres.AddUser(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), args.Get("username"), args.Get("password"), flags, out) }, - } + }) } func listUsersCommand(parentFlags *flag.User) *naistrix.Command { flags := &flag.UserList{User: parentFlags} - return &naistrix.Command{ + return legacyCommand("postgres users list", "nais cloudsql users list", &naistrix.Command{ Name: "list", Title: "List users in a SQL instance database.", Description: "List all users in a Postgres SQL instance database for a given application.", @@ -59,12 +59,12 @@ func listUsersCommand(parentFlags *flag.User) *naistrix.Command { RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { return postgres.ListUsers(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) }, - } + }) } func dropCommand(parentFlags *flag.User) *naistrix.Command { flags := &flag.UserDrop{User: parentFlags} - return &naistrix.Command{ + return legacyCommand("postgres users drop", "nais cloudsql users drop", &naistrix.Command{ Name: "drop", Title: "Drop a user from a SQL instance database.", Description: "Remove a user from a Postgres SQL instance database.", @@ -76,5 +76,5 @@ func dropCommand(parentFlags *flag.User) *naistrix.Command { RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { return postgres.DropUser(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), args.Get("username"), flags, out) }, - } + }) } diff --git a/internal/postgres/command/verify_audit.go b/internal/postgres/command/verify_audit.go index ca735c14..80e9ad47 100644 --- a/internal/postgres/command/verify_audit.go +++ b/internal/postgres/command/verify_audit.go @@ -12,7 +12,7 @@ import ( func verifyAuditCommand(parentFlags *flag.Postgres) *naistrix.Command { flags := &flag.VerifyAudit{Postgres: parentFlags} - return &naistrix.Command{ + return legacyCommand("postgres verify-audit", "nais cloudsql verify-audit", &naistrix.Command{ Name: "verify-audit", Title: "Verify audit extension and configuration in SQL instance database.", Description: "This verifies that the pgaudit extension is installed and that audit logging is properly configured for the application user.", @@ -28,5 +28,5 @@ func verifyAuditCommand(parentFlags *flag.Postgres) *naistrix.Command { } return err }, - } + }) } From 1b5ea3ddb9a9fa15ebb450eca865af08eb0524f7 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 1 Oct 2026 10:04:50 +0200 Subject: [PATCH 05/10] fix: fmt --- internal/postgres/list.go | 2 +- internal/postgres/list_test.go | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/postgres/list.go b/internal/postgres/list.go index 7774f19e..9d7fc978 100644 --- a/internal/postgres/list.go +++ b/internal/postgres/list.go @@ -157,7 +157,7 @@ func instancesFromTeam(teamData gql.GetTeamPostgresBranchesTeam, team string, en Environment: env, Version: ptr.Deref(s.Version, ""), HighAvailability: s.HighAvailability, - Audit: ptr.To(s.AuditLog != nil), + Audit: new(s.AuditLog != nil), State: State(s.State), }) } diff --git a/internal/postgres/list_test.go b/internal/postgres/list_test.go index dfe9e3d0..99226faf 100644 --- a/internal/postgres/list_test.go +++ b/internal/postgres/list_test.go @@ -33,17 +33,17 @@ func TestInstancesFromTeam(t *testing.T) { { name: "merged and sorted", want: []Instance{ - {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: boolPtr(true), State: State(gql.SqlInstanceStateRunnable)}, + {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: new(true), State: State(gql.SqlInstanceStateRunnable)}, {Name: output.Link{Name: "orders/main", URL: consoleBaseURL + "/team/my-team/dev/postgres/orders"}, Type: "PostgreSQL", Environment: "dev", Version: "16", HighAvailability: true, State: State(gql.PostgresBranchStateAvailable)}, {Name: output.Link{Name: "orders/preview", URL: consoleBaseURL + "/team/my-team/prod/postgres/orders"}, Type: "PostgreSQL", Environment: "prod", Version: "16", HighAvailability: true, State: State(gql.PostgresBranchStateProgressing)}, - {Name: output.Link{Name: "other", URL: consoleBaseURL + "/team/my-team/prod/cloudsql/other"}, Type: "Cloud SQL", Environment: "prod", HighAvailability: true, Audit: boolPtr(false), State: State(gql.SqlInstanceStateStopped)}, + {Name: output.Link{Name: "other", URL: consoleBaseURL + "/team/my-team/prod/cloudsql/other"}, Type: "Cloud SQL", Environment: "prod", HighAvailability: true, Audit: new(false), State: State(gql.SqlInstanceStateStopped)}, }, }, { name: "environment filter applies to both providers", environments: []string{"dev"}, want: []Instance{ - {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: boolPtr(true), State: State(gql.SqlInstanceStateRunnable)}, + {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: new(true), State: State(gql.SqlInstanceStateRunnable)}, {Name: output.Link{Name: "orders/main", URL: consoleBaseURL + "/team/my-team/dev/postgres/orders"}, Type: "PostgreSQL", Environment: "dev", Version: "16", HighAvailability: true, State: State(gql.PostgresBranchStateAvailable)}, }, }, @@ -58,4 +58,5 @@ func TestInstancesFromTeam(t *testing.T) { } } -func boolPtr(value bool) *bool { return &value } +//go:fix inline +func boolPtr(value bool) *bool { return new(value) } From 53b0cce2651154ed19dd24b925dde51e936720c5 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 1 Oct 2026 10:11:09 +0200 Subject: [PATCH 06/10] fix: fmt and combine --- internal/alpha/postgres/command/access.go | 6 +-- internal/alpha/postgres/relay/relay.go | 12 ++--- internal/alpha/postgres/relay/relay_test.go | 12 ++--- internal/cloudsql/dbinfo.go | 2 - internal/cloudsql/iam.go | 60 --------------------- internal/cloudsql/list.go | 2 +- internal/cloudsql/list_test.go | 7 ++- internal/postgres/list_test.go | 3 -- 8 files changed, 19 insertions(+), 85 deletions(-) diff --git a/internal/alpha/postgres/command/access.go b/internal/alpha/postgres/command/access.go index b8a77ddf..05e86262 100644 --- a/internal/alpha/postgres/command/access.go +++ b/internal/alpha/postgres/command/access.go @@ -47,8 +47,8 @@ func psqlCommand(parent *flag.Postgres) *naistrix.Command { if err != nil { return err } - defer os.Remove(ca.Name()) - defer ca.Close() + defer func() { _ = os.Remove(ca.Name()) }() + defer func() { _ = ca.Close() }() if err := ca.Chmod(0o600); err != nil { return err } @@ -106,7 +106,7 @@ func proxyCommand(parent *flag.Postgres) *naistrix.Command { _ = listener.Close() return err } - defer os.Remove(ca.Name()) + defer func() { _ = os.Remove(ca.Name()) }() if _, err := ca.WriteString(connection.CACertificate); err != nil { _ = ca.Close() _ = listener.Close() diff --git a/internal/alpha/postgres/relay/relay.go b/internal/alpha/postgres/relay/relay.go index 58e03c17..20ab36a8 100644 --- a/internal/alpha/postgres/relay/relay.go +++ b/internal/alpha/postgres/relay/relay.go @@ -39,8 +39,8 @@ func (t Tunnel) request(ctx context.Context, body io.Reader) (*http.Request, err // Serve forwards each TCP connection to the relay until ctx is cancelled. func Serve(ctx context.Context, listener net.Listener, tunnel Tunnel) error { transport := &http3.Transport{} - defer transport.Close() - defer listener.Close() + defer func() { _ = transport.Close() }() + defer func() { _ = listener.Close() }() stop := context.AfterFunc(ctx, func() { _ = listener.Close() }) defer stop() for { @@ -52,7 +52,7 @@ func Serve(ctx context.Context, listener net.Listener, tunnel Tunnel) error { return fmt.Errorf("accept local connection: %w", err) } go func() { - defer conn.Close() + defer func() { _ = conn.Close() }() if err := forward(ctx, transport, tunnel, conn); err != nil && ctx.Err() == nil { // An individual connection must not terminate other local clients. // Callers can retry; no credentials are included in the error. @@ -68,8 +68,8 @@ func forward(ctx context.Context, transport *http3.Transport, tunnel Tunnel, loc stop := context.AfterFunc(ctx, func() { _ = local.Close() }) defer stop() reader, writer := io.Pipe() - defer reader.Close() - defer writer.Close() + defer func() { _ = reader.Close() }() + defer func() { _ = writer.Close() }() req, err := tunnel.request(ctx, reader) if err != nil { return err @@ -87,7 +87,7 @@ func forward(ctx context.Context, transport *http3.Transport, tunnel Tunnel, loc <-done return fmt.Errorf("relay CONNECT: %w", err) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { _ = local.Close() _ = reader.Close() diff --git a/internal/alpha/postgres/relay/relay_test.go b/internal/alpha/postgres/relay/relay_test.go index 2ebd809d..aa5b5f03 100644 --- a/internal/alpha/postgres/relay/relay_test.go +++ b/internal/alpha/postgres/relay/relay_test.go @@ -70,23 +70,23 @@ func TestConnectStreamsAfterHalfClose(t *testing.T) { go func() { defer close(serverDone); _ = server.Serve(packet) }() defer func() { _ = server.Close(); <-serverDone; _ = packet.Close() }() transport := &http3.Transport{TLSClientConfig: &tls.Config{RootCAs: roots, ServerName: "localhost"}} - defer transport.Close() + defer func() { _ = transport.Close() }() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } - defer listener.Close() + defer func() { _ = listener.Close() }() client, err := net.Dial("tcp", listener.Addr().String()) if err != nil { t.Fatal(err) } - defer client.Close() + defer func() { _ = client.Close() }() _ = client.SetDeadline(time.Now().Add(5 * time.Second)) local, err := listener.Accept() if err != nil { t.Fatal(err) } - defer local.Close() + defer func() { _ = local.Close() }() done := make(chan error, 1) go func() { done <- forward(context.Background(), transport, Tunnel{Endpoint: "https://localhost:" + strings.Split(packet.LocalAddr().String(), ":")[1], Access: "team/access", Token: "proof"}, local) @@ -104,12 +104,12 @@ func TestConnectStreamsAfterHalfClose(t *testing.T) { if err != nil { t.Fatal(err) } - defer deniedClient.Close() + defer func() { _ = deniedClient.Close() }() deniedLocal, err := listener.Accept() if err != nil { t.Fatal(err) } - defer deniedLocal.Close() + defer func() { _ = deniedLocal.Close() }() denied := make(chan error, 1) go func() { denied <- forward(context.Background(), transport, Tunnel{Endpoint: "https://localhost:" + strings.Split(packet.LocalAddr().String(), ":")[1], Access: "team/access", Token: "denied"}, deniedLocal) diff --git a/internal/cloudsql/dbinfo.go b/internal/cloudsql/dbinfo.go index ca02add3..ef8a47f9 100644 --- a/internal/cloudsql/dbinfo.go +++ b/internal/cloudsql/dbinfo.go @@ -20,8 +20,6 @@ type DBInfo struct { appName string } -func (d *DBInfo) AppName() string { return d.appName } - func NewDBInfo(_ context.Context, appName, team, environment string) (*CloudSQLDBInfo, error) { loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{CurrentContext: environment}) diff --git a/internal/cloudsql/iam.go b/internal/cloudsql/iam.go index a99c9ada..f222504f 100644 --- a/internal/cloudsql/iam.go +++ b/internal/cloudsql/iam.go @@ -17,66 +17,6 @@ import ( "github.com/nais/naistrix" ) -func GrantAndCreateSQLUser(ctx context.Context, appName, team, environment string, out *naistrix.OutputWriter) error { - dbInfo, err := NewDBInfo(ctx, appName, team, environment) - if err != nil { - return err - } - - cloudSQLDBInfo := dbInfo - - projectID, err := cloudSQLDBInfo.ProjectID(ctx) - if err != nil { - return err - } - - connectionName, err := cloudSQLDBInfo.ConnectionName(ctx) - if err != nil { - return err - } - - out.Println("Grant user access") - err = grantUserAccess(ctx, projectID, "roles/cloudsql.admin", 5*time.Minute, out) - if err != nil { - return err - } - - out.Println("Create sql user") - err = createSQLUser(ctx, projectID, connectionName) - if err != nil { - return fmt.Errorf("error creating SQL user. One might already exist: %v", err) - } - - return nil -} - -func createSQLUser(ctx context.Context, projectID, instance string) error { - email, err := currentEmail(ctx) - if err != nil { - return err - } - - args := []string{ - "sql", - "users", - "create", - email, - "--instance", strings.Split(instance, ":")[2], - "--type", "cloud_iam_user", - "--project", projectID, - } - - buf := &bytes.Buffer{} - cmd := exec.CommandContext(ctx, "gcloud", args...) - cmd.Stdout = buf - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - _, _ = io.Copy(os.Stdout, buf) - return fmt.Errorf("error running gcloud command: %w", err) - } - return nil -} - func currentEmail(ctx context.Context) (string, error) { cmd := exec.CommandContext(ctx, "gcloud", "config", "get-value", "account") out, err := cmd.Output() diff --git a/internal/cloudsql/list.go b/internal/cloudsql/list.go index 9819f406..24904e43 100644 --- a/internal/cloudsql/list.go +++ b/internal/cloudsql/list.go @@ -85,7 +85,7 @@ func instancesFromTeam(teamData gql.GetTeamCloudSQLInstancesTeam, team string, e ret = append(ret, Instance{ Name: output.Link{Name: s.Name, URL: fmt.Sprintf("%s/team/%s/%s/cloudsql/%s", consoleBaseURL, team, env, s.Name)}, Type: "Cloud SQL", Environment: env, Version: ptr.Deref(s.Version, ""), - HighAvailability: s.HighAvailability, Audit: ptr.To(s.AuditLog != nil), State: State(s.State), + HighAvailability: s.HighAvailability, Audit: new(s.AuditLog != nil), State: State(s.State), }) } sort.Slice(ret, func(i, j int) bool { diff --git a/internal/cloudsql/list_test.go b/internal/cloudsql/list_test.go index ef42705c..8c496624 100644 --- a/internal/cloudsql/list_test.go +++ b/internal/cloudsql/list_test.go @@ -24,11 +24,11 @@ func TestInstancesFromTeam(t *testing.T) { want []Instance }{ {name: "sorted", want: []Instance{ - {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: boolPtr(true), State: State(gql.SqlInstanceStateRunnable)}, - {Name: output.Link{Name: "other", URL: consoleBaseURL + "/team/my-team/prod/cloudsql/other"}, Type: "Cloud SQL", Environment: "prod", HighAvailability: true, Audit: boolPtr(false), State: State(gql.SqlInstanceStateStopped)}, + {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: new(true), State: State(gql.SqlInstanceStateRunnable)}, + {Name: output.Link{Name: "other", URL: consoleBaseURL + "/team/my-team/prod/cloudsql/other"}, Type: "Cloud SQL", Environment: "prod", HighAvailability: true, Audit: new(false), State: State(gql.SqlInstanceStateStopped)}, }}, {name: "environment filter", environments: []string{"dev"}, want: []Instance{ - {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: boolPtr(true), State: State(gql.SqlInstanceStateRunnable)}, + {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: new(true), State: State(gql.SqlInstanceStateRunnable)}, }}, } { t.Run(tt.name, func(t *testing.T) { @@ -38,4 +38,3 @@ func TestInstancesFromTeam(t *testing.T) { }) } } -func boolPtr(value bool) *bool { return &value } diff --git a/internal/postgres/list_test.go b/internal/postgres/list_test.go index 99226faf..b9457e40 100644 --- a/internal/postgres/list_test.go +++ b/internal/postgres/list_test.go @@ -57,6 +57,3 @@ func TestInstancesFromTeam(t *testing.T) { }) } } - -//go:fix inline -func boolPtr(value bool) *bool { return new(value) } From ad886bdab06bac8a4742cd5b24cc717a021eda6e Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 1 Oct 2026 10:42:13 +0200 Subject: [PATCH 07/10] fix(alpha postgres): validate relay token, clean up on signals, bound setup Reject anything but unpadded base64url of 32 bytes before building the Authorization header, since the HTTP stack echoes invalid header values in errors. Run cleanup on SIGTERM and Ctrl-C, bound access creation with the polling timeout, and drop an unused Cloud SQL flag type. --- internal/alpha/postgres/access.go | 9 ++--- internal/alpha/postgres/command/access.go | 23 +++++++++---- internal/alpha/postgres/relay/relay.go | 11 +++++- internal/alpha/postgres/relay/relay_test.go | 38 ++++++++++++++++++--- internal/cloudsql/command/flag/flag.go | 4 --- 5 files changed, 65 insertions(+), 20 deletions(-) diff --git a/internal/alpha/postgres/access.go b/internal/alpha/postgres/access.go index 6f072a8d..3d6d1208 100644 --- a/internal/alpha/postgres/access.go +++ b/internal/alpha/postgres/access.go @@ -119,13 +119,14 @@ func waitForAccess(ctx context.Context, api AccessAPI, team, environment, name s } func CreateAndWait(ctx context.Context, api AccessAPI, input gql.CreatePostgresAccessInput) (Connection, error) { - name, err := api.Create(ctx, input) + // Bound creation and polling together; a stalled API must not hang the command. + setupCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + name, err := api.Create(setupCtx, input) if err != nil { return Connection{}, fmt.Errorf("create postgres access: %w", err) } - pollCtx, cancel := context.WithTimeout(ctx, 60*time.Second) - defer cancel() - connection, err := waitForAccess(pollCtx, api, input.TeamSlug, input.EnvironmentName, name, time.Second) + connection, err := waitForAccess(setupCtx, api, input.TeamSlug, input.EnvironmentName, name, time.Second) if err != nil { return Connection{}, fmt.Errorf("access %q was created but is not ready (it expires after its requested TTL): %w", name, err) } diff --git a/internal/alpha/postgres/command/access.go b/internal/alpha/postgres/command/access.go index 05e86262..701f67f7 100644 --- a/internal/alpha/postgres/command/access.go +++ b/internal/alpha/postgres/command/access.go @@ -8,6 +8,7 @@ import ( "os/exec" "os/signal" "strings" + "syscall" "time" "github.com/nais/cli/internal/alpha/postgres" @@ -28,7 +29,17 @@ func psqlCommand(parent *flag.Postgres) *naistrix.Command { Description: "Request personal access, open a local relay tunnel and start psql with end-to-end TLS verification.", Args: []naistrix.Argument{{Name: "postgres"}}, Flags: f, RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - connection, err := requestAccess(ctx, args.Get("postgres"), f) + // SIGTERM must run the deferred cleanup (CA file, relay). Ctrl-C aborts the setup, but once + // psql runs it is left to psql (query cancellation) and ignored here until cleanup is done. + ctx, stopTerm := signal.NotifyContext(ctx, syscall.SIGTERM) + defer stopTerm() + setupCtx, stopSetup := signal.NotifyContext(ctx, os.Interrupt) + connection, err := requestAccess(setupCtx, args.Get("postgres"), f) + // Register the ignore-channel before releasing the setup handler so there is no unhandled window. + interrupts := make(chan os.Signal, 1) + signal.Notify(interrupts, os.Interrupt) + defer signal.Stop(interrupts) + stopSetup() if err != nil { return err } @@ -69,10 +80,6 @@ func psqlCommand(parent *flag.Postgres) *naistrix.Command { "PGUSER="+connection.Username, "PGPASSWORD="+connection.Password, "PGDATABASE="+f.Database, "PGSSLMODE=verify-full", "PGSSLROOTCERT="+ca.Name(), "PGCONNECT_TIMEOUT=10") out.Println("Connecting with verified PostgreSQL TLS through the local relay...") - // psql handles Ctrl-C itself (query cancellation); do not terminate its relay. - interrupts := make(chan os.Signal, 1) - signal.Notify(interrupts, os.Interrupt) - defer signal.Stop(interrupts) return cmd.Run() }, } @@ -85,6 +92,8 @@ func proxyCommand(parent *flag.Postgres) *naistrix.Command { Description: "Request personal access and listen on loopback. PostgreSQL clients must verify the server certificate; use psql for automatic TLS setup. Credentials are not printed unless --print-password is set.", Args: []naistrix.Argument{{Name: "postgres"}}, Flags: f, RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() if net.ParseIP(f.Host) == nil || !net.ParseIP(f.Host).IsLoopback() { return fmt.Errorf("--host must be a loopback IP address") } @@ -159,7 +168,9 @@ func requestAccess(ctx context.Context, name string, f *flag.Access) (postgres.C } branch := f.Branch if branch == "" { - branch, err = api.ActiveBranch(ctx, f.Team, string(f.Environment), name) + lookupCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + branch, err = api.ActiveBranch(lookupCtx, f.Team, string(f.Environment), name) if err != nil { return postgres.Connection{}, err } diff --git a/internal/alpha/postgres/relay/relay.go b/internal/alpha/postgres/relay/relay.go index 20ab36a8..66bf76c0 100644 --- a/internal/alpha/postgres/relay/relay.go +++ b/internal/alpha/postgres/relay/relay.go @@ -3,6 +3,7 @@ package relay import ( "context" + "encoding/base64" "errors" "fmt" "io" @@ -23,7 +24,8 @@ func (t Tunnel) request(ctx context.Context, body io.Reader) (*http.Request, err if err != nil || u.Scheme != "https" || u.Host == "" || u.Path != "" || u.RawQuery != "" || u.User != nil || u.Fragment != "" { return nil, fmt.Errorf("invalid relay endpoint") } - if !strings.Contains(t.Access, "/") || t.Token == "" { + // Validate before building headers: the HTTP stack echoes invalid header values in its errors. + if !strings.Contains(t.Access, "/") || !validToken(t.Token) { return nil, fmt.Errorf("invalid relay access credentials") } req, err := http.NewRequestWithContext(ctx, http.MethodConnect, t.Endpoint, body) @@ -36,6 +38,13 @@ func (t Tunnel) request(ctx context.Context, body io.Reader) (*http.Request, err return req, nil } +// validToken accepts only the relay contract: unpadded base64url of 32 bytes. +func validToken(token string) bool { + raw, err := base64.RawURLEncoding.DecodeString(token) + // The decoder silently skips \r and \n, so also require the canonical encoding. + return err == nil && len(raw) == 32 && base64.RawURLEncoding.EncodeToString(raw) == token +} + // Serve forwards each TCP connection to the relay until ctx is cancelled. func Serve(ctx context.Context, listener net.Listener, tunnel Tunnel) error { transport := &http3.Transport{} diff --git a/internal/alpha/postgres/relay/relay_test.go b/internal/alpha/postgres/relay/relay_test.go index aa5b5f03..edfdad8c 100644 --- a/internal/alpha/postgres/relay/relay_test.go +++ b/internal/alpha/postgres/relay/relay_test.go @@ -1,12 +1,14 @@ package relay import ( + "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/base64" "encoding/pem" "io" "math/big" @@ -19,6 +21,12 @@ import ( "github.com/quic-go/quic-go/http3" ) +// 32 bytes, unpadded base64url, as issued by the relay contract. +var ( + proofToken = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)) + deniedToken = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32)) +) + func TestConnectStreamsAfterHalfClose(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { @@ -47,11 +55,11 @@ func TestConnectStreamsAfterHalfClose(t *testing.T) { t.Fatal(err) } handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") == "Bearer denied" { + if r.Header.Get("Authorization") == "Bearer "+deniedToken { w.WriteHeader(http.StatusUnauthorized) return } - if r.Method != http.MethodConnect || r.Host != "localhost:"+strings.Split(packet.LocalAddr().String(), ":")[1] || r.Header.Get("Authorization") != "Bearer proof" || r.Header.Get("Relay-Access") != "team/access" { + if r.Method != http.MethodConnect || r.Host != "localhost:"+strings.Split(packet.LocalAddr().String(), ":")[1] || r.Header.Get("Authorization") != "Bearer "+proofToken || r.Header.Get("Relay-Access") != "team/access" { t.Errorf("unexpected CONNECT: method=%s host=%s auth=%s access=%s", r.Method, r.Host, r.Header.Get("Authorization"), r.Header.Get("Relay-Access")) w.WriteHeader(http.StatusUnauthorized) return @@ -89,7 +97,7 @@ func TestConnectStreamsAfterHalfClose(t *testing.T) { defer func() { _ = local.Close() }() done := make(chan error, 1) go func() { - done <- forward(context.Background(), transport, Tunnel{Endpoint: "https://localhost:" + strings.Split(packet.LocalAddr().String(), ":")[1], Access: "team/access", Token: "proof"}, local) + done <- forward(context.Background(), transport, Tunnel{Endpoint: "https://localhost:" + strings.Split(packet.LocalAddr().String(), ":")[1], Access: "team/access", Token: proofToken}, local) }() _, _ = client.Write([]byte("hello")) _ = client.(*net.TCPConn).CloseWrite() @@ -112,7 +120,7 @@ func TestConnectStreamsAfterHalfClose(t *testing.T) { defer func() { _ = deniedLocal.Close() }() denied := make(chan error, 1) go func() { - denied <- forward(context.Background(), transport, Tunnel{Endpoint: "https://localhost:" + strings.Split(packet.LocalAddr().String(), ":")[1], Access: "team/access", Token: "denied"}, deniedLocal) + denied <- forward(context.Background(), transport, Tunnel{Endpoint: "https://localhost:" + strings.Split(packet.LocalAddr().String(), ":")[1], Access: "team/access", Token: deniedToken}, deniedLocal) }() select { case err := <-denied: @@ -126,8 +134,28 @@ func TestConnectStreamsAfterHalfClose(t *testing.T) { func TestRequestRejectsUntrustedEndpoint(t *testing.T) { for _, endpoint := range []string{"http://relay", "https://relay/path", "https://user@relay", "https://relay?target=db"} { - if _, err := (Tunnel{Endpoint: endpoint, Access: "team/access", Token: "proof"}).request(context.Background(), nil); err == nil { + if _, err := (Tunnel{Endpoint: endpoint, Access: "team/access", Token: proofToken}).request(context.Background(), nil); err == nil { t.Errorf("accepted %q", endpoint) } } } + +func TestRequestRejectsMalformedTokensWithoutEchoingThem(t *testing.T) { + for name, token := range map[string]string{ + "empty": "", + "short": "c2hvcnQ", + "padded": base64.URLEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)), + "newline": proofToken + "\n", + "not-base64url": strings.Repeat("!", 43), + } { + t.Run(name, func(t *testing.T) { + _, err := (Tunnel{Endpoint: "https://relay.example:8443", Access: "team/access", Token: token}).request(context.Background(), nil) + if err == nil { + t.Fatal("expected malformed token to be rejected") + } + if token != "" && strings.Contains(err.Error(), token) { + t.Fatalf("error leaks token: %v", err) + } + }) + } +} diff --git a/internal/cloudsql/command/flag/flag.go b/internal/cloudsql/command/flag/flag.go index a540ca69..c34684a9 100644 --- a/internal/cloudsql/command/flag/flag.go +++ b/internal/cloudsql/command/flag/flag.go @@ -73,10 +73,6 @@ type VerifyAudit struct { *CloudSQL } -type Grant struct { - *CloudSQL -} - type Prepare struct { *CloudSQL AllPrivileges bool `name:"all-privileges" usage:"Grant all privileges on the schema to the current user."` From c73144bdbe61ca750adfdb14754c3b58df9caeea Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 1 Oct 2026 11:28:22 +0200 Subject: [PATCH 08/10] feat(cli)!: drop Zalando Postgres, alias nais postgres to cloudsql Remove the legacy in-cluster Postgres code and grantPostgresAccess client. nais postgres and nais pg now run the Cloud SQL commands so the transition is soft; Nais Postgres lives under nais alpha postgres. --- internal/application/application.go | 2 - internal/cloudsql/command/cloudsql.go | 4 +- internal/naisapi/gql/generated.go | 395 ----------- internal/postgres/access.go | 99 --- internal/postgres/audit.go | 311 --------- internal/postgres/cloudsqldbinfo.go | 318 --------- internal/postgres/command/enable_audit.go | 32 - internal/postgres/command/flag/flag.go | 115 ---- internal/postgres/command/grant.go | 27 - internal/postgres/command/legacy.go | 29 - internal/postgres/command/legacy_test.go | 100 --- internal/postgres/command/list.go | 49 -- internal/postgres/command/migrate.go | 125 ---- internal/postgres/command/password.go | 34 - internal/postgres/command/postgres.go | 41 -- internal/postgres/command/prepare.go | 47 -- internal/postgres/command/proxy.go | 31 - internal/postgres/command/psql.go | 27 - internal/postgres/command/revoke.go | 46 -- internal/postgres/command/users.go | 80 --- internal/postgres/command/verify_audit.go | 32 - internal/postgres/dbinfo.go | 160 ----- internal/postgres/iam.go | 356 ---------- internal/postgres/list.go | 173 ----- internal/postgres/list_test.go | 59 -- internal/postgres/migrate/config/config.go | 209 ------ .../postgres/migrate/config/config_test.go | 325 --------- internal/postgres/migrate/finalize.go | 51 -- internal/postgres/migrate/finalize/command.go | 34 - internal/postgres/migrate/migrate.go | 642 ------------------ internal/postgres/migrate/migrate_test.go | 55 -- internal/postgres/migrate/promote.go | 83 --- internal/postgres/migrate/promote/command.go | 35 - internal/postgres/migrate/rollback.go | 48 -- internal/postgres/migrate/rollback/command.go | 35 - internal/postgres/migrate/setup.go | 196 ------ internal/postgres/migrate/setup/command.go | 66 -- internal/postgres/migrate/setup_test.go | 321 --------- internal/postgres/migrate/ui/ui.go | 228 ------- internal/postgres/migrate/ui/ui_test.go | 221 ------ internal/postgres/password.go | 145 ---- internal/postgres/password_test.go | 234 ------- internal/postgres/postgresinfo.go | 259 ------- internal/postgres/proxy.go | 51 -- internal/postgres/psql.go | 73 -- internal/postgres/secret.go | 288 -------- 46 files changed, 3 insertions(+), 6288 deletions(-) delete mode 100644 internal/postgres/access.go delete mode 100644 internal/postgres/audit.go delete mode 100644 internal/postgres/cloudsqldbinfo.go delete mode 100644 internal/postgres/command/enable_audit.go delete mode 100644 internal/postgres/command/flag/flag.go delete mode 100644 internal/postgres/command/grant.go delete mode 100644 internal/postgres/command/legacy.go delete mode 100644 internal/postgres/command/legacy_test.go delete mode 100644 internal/postgres/command/list.go delete mode 100644 internal/postgres/command/migrate.go delete mode 100644 internal/postgres/command/password.go delete mode 100644 internal/postgres/command/postgres.go delete mode 100644 internal/postgres/command/prepare.go delete mode 100644 internal/postgres/command/proxy.go delete mode 100644 internal/postgres/command/psql.go delete mode 100644 internal/postgres/command/revoke.go delete mode 100644 internal/postgres/command/users.go delete mode 100644 internal/postgres/command/verify_audit.go delete mode 100644 internal/postgres/dbinfo.go delete mode 100644 internal/postgres/iam.go delete mode 100644 internal/postgres/list.go delete mode 100644 internal/postgres/list_test.go delete mode 100644 internal/postgres/migrate/config/config.go delete mode 100644 internal/postgres/migrate/config/config_test.go delete mode 100644 internal/postgres/migrate/finalize.go delete mode 100644 internal/postgres/migrate/finalize/command.go delete mode 100644 internal/postgres/migrate/migrate.go delete mode 100644 internal/postgres/migrate/migrate_test.go delete mode 100644 internal/postgres/migrate/promote.go delete mode 100644 internal/postgres/migrate/promote/command.go delete mode 100644 internal/postgres/migrate/rollback.go delete mode 100644 internal/postgres/migrate/rollback/command.go delete mode 100644 internal/postgres/migrate/setup.go delete mode 100644 internal/postgres/migrate/setup/command.go delete mode 100644 internal/postgres/migrate/setup_test.go delete mode 100644 internal/postgres/migrate/ui/ui.go delete mode 100644 internal/postgres/migrate/ui/ui_test.go delete mode 100644 internal/postgres/password.go delete mode 100644 internal/postgres/password_test.go delete mode 100644 internal/postgres/postgresinfo.go delete mode 100644 internal/postgres/proxy.go delete mode 100644 internal/postgres/psql.go delete mode 100644 internal/postgres/secret.go diff --git a/internal/application/application.go b/internal/application/application.go index f88aa193..90d19f0a 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -28,7 +28,6 @@ import ( naisapiCommand "github.com/nais/cli/internal/naisapi/command" naisdeviceCommand "github.com/nais/cli/internal/naisdevice/command" opensearchCommand "github.com/nais/cli/internal/opensearch/command" - postgresCommand "github.com/nais/cli/internal/postgres/command" secretCommand "github.com/nais/cli/internal/secret/command" statusCommand "github.com/nais/cli/internal/status/command" validateCommand "github.com/nais/cli/internal/validate/command" @@ -86,7 +85,6 @@ func New(w io.Writer) (*Application, *flags.GlobalFlags, error) { naisapiCommand.Api(globalFlags), naisdeviceCommand.Naisdevice(globalFlags), opensearchCommand.OpenSearch(globalFlags), - postgresCommand.Postgres(globalFlags), cloudsqlCommand.CloudSQL(globalFlags), secretCommand.Secrets(globalFlags), statusCommand.Status(globalFlags), diff --git a/internal/cloudsql/command/cloudsql.go b/internal/cloudsql/command/cloudsql.go index 4e6dc313..c9d4e888 100644 --- a/internal/cloudsql/command/cloudsql.go +++ b/internal/cloudsql/command/cloudsql.go @@ -15,7 +15,9 @@ func CloudSQL(parentFlags *flags.GlobalFlags) *naistrix.Command { } return &naistrix.Command{ - Name: "cloudsql", + Name: "cloudsql", + // TODO: Remove the aliases once users have moved from the old `nais postgres` (Cloud SQL) commands. + Aliases: []string{"postgres", "pg"}, Title: "Manage Google Cloud SQL instances.", Description: "Manage Google Cloud SQL instances, including listing, migration, user management, password rotation, and direct database access.", StickyFlags: flags, diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index 0f0b0f8f..9f942e6b 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -30942,228 +30942,6 @@ func (v *GetTeamPostgresBranchesAlphaTeamPostgresBranchesPostgresBranchConnectio return v.Name } -// GetTeamPostgresBranchesResponse is returned by GetTeamPostgresBranches on success. -type GetTeamPostgresBranchesResponse struct { - // Get a team by its slug. - Team GetTeamPostgresBranchesTeam `json:"team"` -} - -// GetTeam returns GetTeamPostgresBranchesResponse.Team, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesResponse) GetTeam() GetTeamPostgresBranchesTeam { return v.Team } - -// GetTeamPostgresBranchesTeam includes the requested fields of the GraphQL type Team. -// The GraphQL type's documentation follows. -// -// The team type represents a team on the [Nais platform](https://nais.io/). -// -// Learn more about what Nais teams are and what they can be used for in the [official Nais documentation](https://docs.nais.io/explanations/team/). -// -// External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). -type GetTeamPostgresBranchesTeam struct { - // Postgres branches owned by the team. - PostgresBranches GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection `json:"postgresBranches"` - // SQL instances owned by the team. - SqlInstances GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnection `json:"sqlInstances"` -} - -// GetPostgresBranches returns GetTeamPostgresBranchesTeam.PostgresBranches, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeam) GetPostgresBranches() GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection { - return v.PostgresBranches -} - -// GetSqlInstances returns GetTeamPostgresBranchesTeam.SqlInstances, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeam) GetSqlInstances() GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnection { - return v.SqlInstances -} - -// GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection includes the requested fields of the GraphQL type PostgresBranchConnection. -type GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection struct { - Nodes []GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch `json:"nodes"` -} - -// GetNodes returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection.Nodes, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnection) GetNodes() []GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch { - return v.Nodes -} - -// GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch includes the requested fields of the GraphQL type PostgresBranch. -// The GraphQL type's documentation follows. -// -// A named PostgresBranch belonging to a Postgres. -type GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch struct { - // Local name of this branch within its Postgres. - Name string `json:"name"` - TeamEnvironment GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment `json:"teamEnvironment"` - // Postgres owning this PostgresBranch. - Postgres GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres `json:"postgres"` - // Current observed state of the branch. - State PostgresBranchState `json:"state"` -} - -// GetName returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch.Name, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch) GetName() string { - return v.Name -} - -// GetTeamEnvironment returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch.TeamEnvironment, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch) GetTeamEnvironment() GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment { - return v.TeamEnvironment -} - -// GetPostgres returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch.Postgres, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch) GetPostgres() GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres { - return v.Postgres -} - -// GetState returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch.State, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranch) GetState() PostgresBranchState { - return v.State -} - -// GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres includes the requested fields of the GraphQL type Postgres. -// The GraphQL type's documentation follows. -// -// A Postgres whose active branch can change. -type GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres struct { - // Name of this Postgres. - Name string `json:"name"` - // Configured PostgreSQL major version. - MajorVersion string `json:"majorVersion"` - // Whether high availability is configured. - HighAvailability bool `json:"highAvailability"` -} - -// GetName returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres.Name, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres) GetName() string { - return v.Name -} - -// GetMajorVersion returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres.MajorVersion, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres) GetMajorVersion() string { - return v.MajorVersion -} - -// GetHighAvailability returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres.HighAvailability, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchPostgres) GetHighAvailability() bool { - return v.HighAvailability -} - -// GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. -type GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment struct { - // Get the environment. - Environment GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment `json:"environment"` -} - -// GetEnvironment returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment.Environment, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironment) GetEnvironment() GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment { - return v.Environment -} - -// GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment includes the requested fields of the GraphQL type Environment. -// The GraphQL type's documentation follows. -// -// An environment represents a runtime environment for workloads. -// -// Learn more in the [official Nais documentation](https://docs.nais.io/workloads/explanations/environment/). -type GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment struct { - // Unique name of the environment. - Name string `json:"name"` -} - -// GetName returns GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment.Name, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamPostgresBranchesPostgresBranchConnectionNodesPostgresBranchTeamEnvironmentEnvironment) GetName() string { - return v.Name -} - -// GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnection includes the requested fields of the GraphQL type SqlInstanceConnection. -type GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnection struct { - Nodes []GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance `json:"nodes"` -} - -// GetNodes returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnection.Nodes, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnection) GetNodes() []GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance { - return v.Nodes -} - -// GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance includes the requested fields of the GraphQL type SqlInstance. -type GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance struct { - Name string `json:"name"` - TeamEnvironment GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment `json:"teamEnvironment"` - Version *string `json:"version"` - HighAvailability bool `json:"highAvailability"` - // Indicates whether audit logging is enabled for this SQL instance and provides a link to the logs if set. - AuditLog *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog `json:"auditLog"` - State SqlInstanceState `json:"state"` -} - -// GetName returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.Name, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetName() string { - return v.Name -} - -// GetTeamEnvironment returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.TeamEnvironment, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetTeamEnvironment() GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment { - return v.TeamEnvironment -} - -// GetVersion returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.Version, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetVersion() *string { - return v.Version -} - -// GetHighAvailability returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.HighAvailability, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetHighAvailability() bool { - return v.HighAvailability -} - -// GetAuditLog returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.AuditLog, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetAuditLog() *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog { - return v.AuditLog -} - -// GetState returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance.State, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstance) GetState() SqlInstanceState { - return v.State -} - -// GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog includes the requested fields of the GraphQL type AuditLog. -type GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog struct { - // Link to the audit log for this SQL instance. - LogUrl string `json:"logUrl"` -} - -// GetLogUrl returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog.LogUrl, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceAuditLog) GetLogUrl() string { - return v.LogUrl -} - -// GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. -type GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment struct { - // Get the environment. - Environment GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment `json:"environment"` -} - -// GetEnvironment returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment.Environment, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironment) GetEnvironment() GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment { - return v.Environment -} - -// GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment includes the requested fields of the GraphQL type Environment. -// The GraphQL type's documentation follows. -// -// An environment represents a runtime environment for workloads. -// -// Learn more in the [official Nais documentation](https://docs.nais.io/workloads/explanations/environment/). -type GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment struct { - // Unique name of the environment. - Name string `json:"name"` -} - -// GetName returns GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment.Name, and is useful for accessing the field via an interface. -func (v *GetTeamPostgresBranchesTeamSqlInstancesSqlInstanceConnectionNodesSqlInstanceTeamEnvironmentEnvironment) GetName() string { - return v.Name -} - // GetTeamVulnerabilitySummaryResponse is returned by GetTeamVulnerabilitySummary on success. type GetTeamVulnerabilitySummaryResponse struct { // Get a team by its slug. @@ -31636,50 +31414,6 @@ func (v *GrantAccessToKafkaTopicUpdateKafkaTopicUpdateKafkaTopicPayloadKafkaTopi return v.Id } -// GrantPostgresAccessGrantPostgresAccessGrantPostgresAccessPayload includes the requested fields of the GraphQL type GrantPostgresAccessPayload. -type GrantPostgresAccessGrantPostgresAccessGrantPostgresAccessPayload struct { - Error *string `json:"error"` -} - -// GetError returns GrantPostgresAccessGrantPostgresAccessGrantPostgresAccessPayload.Error, and is useful for accessing the field via an interface. -func (v *GrantPostgresAccessGrantPostgresAccessGrantPostgresAccessPayload) GetError() *string { - return v.Error -} - -type GrantPostgresAccessInput struct { - ClusterName string `json:"clusterName"` - TeamSlug string `json:"teamSlug"` - EnvironmentName string `json:"environmentName"` - Grantee string `json:"grantee"` - Duration string `json:"duration"` -} - -// GetClusterName returns GrantPostgresAccessInput.ClusterName, and is useful for accessing the field via an interface. -func (v *GrantPostgresAccessInput) GetClusterName() string { return v.ClusterName } - -// GetTeamSlug returns GrantPostgresAccessInput.TeamSlug, and is useful for accessing the field via an interface. -func (v *GrantPostgresAccessInput) GetTeamSlug() string { return v.TeamSlug } - -// GetEnvironmentName returns GrantPostgresAccessInput.EnvironmentName, and is useful for accessing the field via an interface. -func (v *GrantPostgresAccessInput) GetEnvironmentName() string { return v.EnvironmentName } - -// GetGrantee returns GrantPostgresAccessInput.Grantee, and is useful for accessing the field via an interface. -func (v *GrantPostgresAccessInput) GetGrantee() string { return v.Grantee } - -// GetDuration returns GrantPostgresAccessInput.Duration, and is useful for accessing the field via an interface. -func (v *GrantPostgresAccessInput) GetDuration() string { return v.Duration } - -// GrantPostgresAccessResponse is returned by GrantPostgresAccess on success. -type GrantPostgresAccessResponse struct { - // Grant temporary access to a Postgres cluster. - GrantPostgresAccess GrantPostgresAccessGrantPostgresAccessGrantPostgresAccessPayload `json:"grantPostgresAccess"` -} - -// GetGrantPostgresAccess returns GrantPostgresAccessResponse.GrantPostgresAccess, and is useful for accessing the field via an interface. -func (v *GrantPostgresAccessResponse) GetGrantPostgresAccess() GrantPostgresAccessGrantPostgresAccessGrantPostgresAccessPayload { - return v.GrantPostgresAccess -} - type ImageVulnerabilitySeverity string const ( @@ -35811,24 +35545,6 @@ func (v *__GetTeamPostgresBranchesAlphaInput) GetPostgresFilter() *PostgresBranc return v.PostgresFilter } -// __GetTeamPostgresBranchesInput is used internally by genqlient -type __GetTeamPostgresBranchesInput struct { - Team string `json:"team"` - PostgresFilter *PostgresBranchFilter `json:"postgresFilter"` - SqlFilter *SqlInstanceFilter `json:"sqlFilter"` -} - -// GetTeam returns __GetTeamPostgresBranchesInput.Team, and is useful for accessing the field via an interface. -func (v *__GetTeamPostgresBranchesInput) GetTeam() string { return v.Team } - -// GetPostgresFilter returns __GetTeamPostgresBranchesInput.PostgresFilter, and is useful for accessing the field via an interface. -func (v *__GetTeamPostgresBranchesInput) GetPostgresFilter() *PostgresBranchFilter { - return v.PostgresFilter -} - -// GetSqlFilter returns __GetTeamPostgresBranchesInput.SqlFilter, and is useful for accessing the field via an interface. -func (v *__GetTeamPostgresBranchesInput) GetSqlFilter() *SqlInstanceFilter { return v.SqlFilter } - // __GetTeamVulnerabilitySummaryInput is used internally by genqlient type __GetTeamVulnerabilitySummaryInput struct { Team string `json:"team"` @@ -35879,14 +35595,6 @@ func (v *__GrantAccessToKafkaTopicInput) GetEnvironmentName() string { return v. // GetGrant returns __GrantAccessToKafkaTopicInput.Grant, and is useful for accessing the field via an interface. func (v *__GrantAccessToKafkaTopicInput) GetGrant() KafkaTopicGrantInput { return v.Grant } -// __GrantPostgresAccessInput is used internally by genqlient -type __GrantPostgresAccessInput struct { - Input GrantPostgresAccessInput `json:"input"` -} - -// GetInput returns __GrantPostgresAccessInput.Input, and is useful for accessing the field via an interface. -func (v *__GrantPostgresAccessInput) GetInput() GrantPostgresAccessInput { return v.Input } - // __ListCVEsInput is used internally by genqlient type __ListCVEsInput struct { Team string `json:"team"` @@ -39079,75 +38787,6 @@ func GetTeamKafkaTopics( return data_, err_ } -// The query executed by GetTeamPostgresBranches. -const GetTeamPostgresBranches_Operation = ` -query GetTeamPostgresBranches ($team: Slug!, $postgresFilter: PostgresBranchFilter, $sqlFilter: SqlInstanceFilter) { - team(slug: $team) { - postgresBranches(first: 1000, filter: $postgresFilter) { - nodes { - name - teamEnvironment { - environment { - name - } - } - postgres { - name - majorVersion - highAvailability - } - state - } - } - sqlInstances(first: 1000, filter: $sqlFilter) { - nodes { - name - teamEnvironment { - environment { - name - } - } - version - highAvailability - auditLog { - logUrl - } - state - } - } - } -} -` - -func GetTeamPostgresBranches( - ctx_ context.Context, - client_ graphql.Client, - team string, - postgresFilter *PostgresBranchFilter, - sqlFilter *SqlInstanceFilter, -) (data_ *GetTeamPostgresBranchesResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "GetTeamPostgresBranches", - Query: GetTeamPostgresBranches_Operation, - Variables: &__GetTeamPostgresBranchesInput{ - Team: team, - PostgresFilter: postgresFilter, - SqlFilter: sqlFilter, - }, - } - - data_ = &GetTeamPostgresBranchesResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The query executed by GetTeamPostgresBranchesAlpha. const GetTeamPostgresBranchesAlpha_Operation = ` query GetTeamPostgresBranchesAlpha ($team: Slug!, $postgresFilter: PostgresBranchFilter) { @@ -39349,40 +38988,6 @@ func GrantAccessToKafkaTopic( return data_, err_ } -// The mutation executed by GrantPostgresAccess. -const GrantPostgresAccess_Operation = ` -mutation GrantPostgresAccess ($input: GrantPostgresAccessInput!) { - grantPostgresAccess(input: $input) { - error - } -} -` - -func GrantPostgresAccess( - ctx_ context.Context, - client_ graphql.Client, - input GrantPostgresAccessInput, -) (data_ *GrantPostgresAccessResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "GrantPostgresAccess", - Query: GrantPostgresAccess_Operation, - Variables: &__GrantPostgresAccessInput{ - Input: input, - }, - } - - data_ = &GrantPostgresAccessResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The query executed by IsAdmin. const IsAdmin_Operation = ` query IsAdmin { diff --git a/internal/postgres/access.go b/internal/postgres/access.go deleted file mode 100644 index 424de863..00000000 --- a/internal/postgres/access.go +++ /dev/null @@ -1,99 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "strings" - - "github.com/lib/pq" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/naistrix" -) - -var grantAllPrivs = `ALTER DEFAULT PRIVILEGES IN SCHEMA $schema GRANT ALL ON TABLES TO cloudsqliamuser; - ALTER DEFAULT PRIVILEGES IN SCHEMA $schema GRANT ALL ON SEQUENCES TO cloudsqliamuser; - GRANT ALL ON ALL TABLES IN SCHEMA $schema TO cloudsqliamuser; - GRANT ALL ON ALL SEQUENCES IN SCHEMA $schema TO cloudsqliamuser; - GRANT CREATE ON SCHEMA $schema TO cloudsqliamuser;` - -var grantSelectPrivs = `GRANT USAGE ON SCHEMA $schema TO cloudsqliamuser; - ALTER DEFAULT PRIVILEGES IN SCHEMA $schema GRANT SELECT ON TABLES TO cloudsqliamuser; - ALTER DEFAULT PRIVILEGES IN SCHEMA $schema GRANT SELECT ON SEQUENCES TO cloudsqliamuser; - GRANT SELECT ON ALL TABLES IN SCHEMA $schema TO cloudsqliamuser; - GRANT SELECT ON ALL SEQUENCES IN SCHEMA $schema TO cloudsqliamuser;` - -// this is used for all privileges and select, as it covers both cases -var revokeAllPrivs = `ALTER DEFAULT PRIVILEGES IN SCHEMA $schema REVOKE ALL ON TABLES FROM cloudsqliamuser; - ALTER DEFAULT PRIVILEGES IN SCHEMA $schema REVOKE ALL ON SEQUENCES FROM cloudsqliamuser; - REVOKE ALL ON ALL TABLES IN SCHEMA $schema FROM cloudsqliamuser; - REVOKE ALL ON ALL SEQUENCES IN SCHEMA $schema FROM cloudsqliamuser; - REVOKE CREATE ON SCHEMA $schema FROM cloudsqliamuser;` - -var ( - grantUsage = `GRANT USAGE ON SCHEMA $schema TO cloudsqliamuser;` - revokeUsage = `REVOKE USAGE ON SCHEMA $schema FROM cloudsqliamuser;` -) - -func PrepareAccess(ctx context.Context, appName, team, environment string, fl *flag.Prepare, out *naistrix.OutputWriter) error { - // Get secret values (access is logged for audit purposes) - sv, err := GetSecretValues(ctx, appName, team, environment, fl.Postgres, ReasonPrepareAccess, out) - if err != nil { - return err - } - - prependUsageIfNotPublic := func(statement string) string { - if fl.Schema != "public" { - return grantUsage + "\n" + statement - } - return statement - } - - if fl.AllPrivileges { - return sqlExecAsAppUser(ctx, appName, team, environment, fl.Schema, prependUsageIfNotPublic(grantAllPrivs), sv) - } else { - return sqlExecAsAppUser(ctx, appName, team, environment, fl.Schema, prependUsageIfNotPublic(grantSelectPrivs), sv) - } -} - -func RevokeAccess(ctx context.Context, appName, team, environment string, fl *flag.Revoke, out *naistrix.OutputWriter) error { - // Get secret values (access is logged for audit purposes) - sv, err := GetSecretValues(ctx, appName, team, environment, fl.Postgres, ReasonRevokeAccess, out) - if err != nil { - return err - } - - q := revokeAllPrivs - if fl.Schema != "public" { - q += "\n" + revokeUsage - } - return sqlExecAsAppUser(ctx, appName, team, environment, fl.Schema, q, sv) -} - -func sqlExecAsAppUser(ctx context.Context, appName, team, environment string, schema, statement string, sv *SecretValues) error { - dbInfo, err := NewDBInfo(ctx, appName, team, environment) - if err != nil { - return err - } - - dbInfo.SetSecretValues(sv) - - connectionInfo, err := dbInfo.DBConnection(ctx) - if err != nil { - return err - } - - schema = pq.QuoteIdentifier(schema) - statement = strings.ReplaceAll(statement, "$schema", schema) - db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) - if err != nil { - return err - } - defer func() { _ = db.Close() }() - - _, err = db.ExecContext(ctx, statement) - if err != nil { - return formatInvalidGrantError(err) - } - - return nil -} diff --git a/internal/postgres/audit.go b/internal/postgres/audit.go deleted file mode 100644 index 6495e10f..00000000 --- a/internal/postgres/audit.go +++ /dev/null @@ -1,311 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "fmt" - - "github.com/lib/pq" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/naistrix" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" -) - -func EnableAuditLogging(ctx context.Context, appName, team, environment string, fl *flag.EnableAudit, out *naistrix.OutputWriter) error { - // Get secret values (access is logged for audit purposes) - sv, err := GetSecretValues(ctx, appName, team, environment, fl.Postgres, ReasonEnableAudit, out) - if err != nil { - return err - } - return enableAuditAsAppUser(ctx, appName, team, environment, sv, out) -} - -func VerifyAuditLogging(ctx context.Context, appName, team, environment string, fl *flag.VerifyAudit, out *naistrix.OutputWriter) error { - // Get secret values (access is logged for audit purposes) - sv, err := GetSecretValues(ctx, appName, team, environment, fl.Postgres, ReasonVerifyAudit, out) - if err != nil { - return err - } - _, err = verifyAuditAsAppUser(ctx, appName, team, environment, sv, out) - return err -} - -func enableAuditAsAppUser(ctx context.Context, appName, team, environment string, sv *SecretValues, out *naistrix.OutputWriter) error { - dbInfo, err := NewDBInfo(ctx, appName, team, environment) - if err != nil { - return err - } - - dbInfo.SetSecretValues(sv) - - connectionInfo, err := dbInfo.DBConnection(ctx) - if err != nil { - return err - } - - cloudSQLDbInfo, err := dbInfo.ToCloudSQLDBInfo() - if err != nil { - return err - } - - err = validateAuditFlags(ctx, cloudSQLDbInfo) - if err != nil { - return fmt.Errorf("required flags missing for instance: %v", err) - } - - isConfigured, err := checkAuditConfigured(ctx, connectionInfo) - if err != nil { - return fmt.Errorf("error checking audit configuration: %w", err) - } - - if isConfigured { - out.Println("✅ Audit is already properly configured. No changes needed.") - return nil - } - - // If we get here, we need to enable audit - out.Println("Audit configuration needs to be updated...\n") - - db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) - if err != nil { - return err - } - - defer func() { _ = db.Close() }() - - _, err = db.ExecContext(ctx, "CREATE EXTENSION IF NOT EXISTS pgaudit") - if err != nil { - return fmt.Errorf("enableAuditAsAppUser: error creating pgaudit extension: %w", err) - } - - alterUserQuery := fmt.Sprintf( - "ALTER USER %s IN DATABASE %s SET pgaudit.log TO 'none'", - pq.QuoteIdentifier(connectionInfo.username), - pq.QuoteIdentifier(connectionInfo.dbName), - ) - _, err = db.ExecContext(ctx, alterUserQuery) - if err != nil { - return fmt.Errorf("enableAuditAsAppUser: error configuring pgaudit.log: %w", err) - } - - out.Println("✅ Successfully enabled audit extension and configured pgaudit.log for application user") - return nil -} - -func checkAuditConfigured(ctx context.Context, connectionInfo *ConnectionInfo) (bool, error) { - db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) - if err != nil { - return false, err - } - defer func() { _ = db.Close() }() - - // Check if pgaudit extension is installed - var extensionExists bool - checkExtensionQuery := "SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pgaudit')" - err = db.QueryRowContext(ctx, checkExtensionQuery).Scan(&extensionExists) - if err != nil { - return false, err - } - - if !extensionExists { - return false, nil - } - - // Check pgaudit.log setting for the application user - var pgauditLogValue string - checkSettingQuery := "SELECT setting FROM pg_settings WHERE name = 'pgaudit.log'" - err = db.QueryRowContext(ctx, checkSettingQuery).Scan(&pgauditLogValue) - if err != nil { - return false, err - } - - // Check if it's set to 'none' - return pgauditLogValue == "none", nil -} - -func validateAuditFlags(ctx context.Context, info *CloudSQLDBInfo) error { - dbFlags, err := getDBFlags(ctx, info) - if err != nil { - return fmt.Errorf("validateAuditFlags: error getting db flags: %w", err) - } - - requiredFlags := []string{ - "cloudsql.enable_pgaudit", - "pgaudit.log", - "pgaudit.log_parameter", - "pgaudit.log_relation", - } - - err = validateRequiredFlags(dbFlags, requiredFlags) - if err != nil { - return fmt.Errorf("validateAuditFlags: %v", err) - } - return nil -} - -func validateRequiredFlags(dbFlags map[string]string, requiredFlags []string) error { - for _, reqFlag := range requiredFlags { - if _, exists := dbFlags[reqFlag]; !exists { - return fmt.Errorf("required flag %q missing", reqFlag) - } - } - - return nil -} - -func getDBFlags(ctx context.Context, info *CloudSQLDBInfo) (map[string]string, error) { - dbFlags := make(map[string]string) - sqlInstances, err := info.dynamicClient.Resource(schema.GroupVersionResource{ - Group: "sql.cnrm.cloud.google.com", - Version: "v1beta1", - Resource: "sqlinstances", - }).Namespace(info.namespace).List(ctx, v1.ListOptions{ - LabelSelector: "app=" + info.appName, - }) - if err != nil { - return dbFlags, fmt.Errorf("GetDBInstance: error looking for sqlinstance %q in %q: %w", info.appName, info.namespace, err) - } - - if len(sqlInstances.Items) != 1 { - return dbFlags, fmt.Errorf("GetDBInstance: expected one sqlinstance for app %q in %q, got %d", info.appName, info.namespace, len(sqlInstances.Items)) - } - - spec, ok := sqlInstances.Items[0].Object["spec"].(map[string]any) - if !ok { - return dbFlags, fmt.Errorf("GetDBInstance: error accessing spec for app %q in %q", info.appName, info.namespace) - } - - settings, ok := spec["settings"].(map[string]any) - if !ok { - return dbFlags, fmt.Errorf("GetDBInstance: error accessing settings for app %q in %q", info.appName, info.namespace) - } - - databaseFlags, ok := settings["databaseFlags"].([]any) - if !ok { - return dbFlags, fmt.Errorf("GetDBInstance: error accessing databaseFlags for app %q in %q", info.appName, info.namespace) - } - - for _, flag := range databaseFlags { - f, ok := flag.(map[string]any) - if !ok { - return dbFlags, fmt.Errorf("GetDBInstance: error accessing databaseFlags for app %q in %q", info.appName, info.namespace) - } - name, nameOk := f["name"].(string) - value, valueOk := f["value"].(string) - if nameOk && valueOk { - dbFlags[name] = value - } - - } - - return dbFlags, nil -} - -func verifyAuditAsAppUser(ctx context.Context, appName, team, environment string, sv *SecretValues, out *naistrix.OutputWriter) (bool, error) { - dbInfo, err := NewDBInfo(ctx, appName, team, environment) - if err != nil { - return false, err - } - - dbInfo.SetSecretValues(sv) - - connectionInfo, err := dbInfo.DBConnection(ctx) - if err != nil { - return false, err - } - - cloudSQLDbInfo, err := dbInfo.ToCloudSQLDBInfo() - if err != nil { - return false, err - } - - out.Println("\nVerifying audit configuration for application: " + appName + "\n") - - dbFlags, err := getDBFlags(ctx, cloudSQLDbInfo) - if err != nil { - return false, fmt.Errorf("error getting db flags: %w", err) - } - - enablePgaudit, enableExists := dbFlags["cloudsql.enable_pgaudit"] - if !enableExists { - out.Println(" ❌ Flag cloudsql.enable_pgaudit is missing") - return false, fmt.Errorf("cloudsql.enable_pgaudit flag is not set") - } - if enablePgaudit != "on" && enablePgaudit != "true" { - out.Printf(" ❌ Flag cloudsql.enable_pgaudit: expected on or true, got %s\n", enablePgaudit) - return false, fmt.Errorf("cloudsql.enable_pgaudit must be set to 'on' or 'true'") - } - out.Printf(" ✅ Flag cloudsql.enable_pgaudit = %s\n", enablePgaudit) - - pgauditLog, logExists := dbFlags["pgaudit.log"] - if !logExists { - out.Println(" ❌ Flag pgaudit.log is missing") - return false, fmt.Errorf("pgaudit.log flag is not set") - } - out.Printf(" ✅ Flag pgaudit.log = %s\n", pgauditLog) - - logParameter, paramExists := dbFlags["pgaudit.log_parameter"] - if !paramExists { - out.Println(" ❌ Flag pgaudit.log_parameter is missing") - return false, fmt.Errorf("pgaudit.log_parameter flag is not set") - } - if logParameter != "on" && logParameter != "true" { - out.Printf(" ❌ Flag pgaudit.log_parameter: expected on or true, got %s\n", logParameter) - return false, fmt.Errorf("pgaudit.log_parameter must be set to 'on' or 'true'") - } - out.Printf(" ✅ Flag pgaudit.log_parameter = %s\n", logParameter) - - logRelation, relationExists := dbFlags["pgaudit.log_relation"] - if !relationExists { - out.Println(" ❌ Flag pgaudit.log_relation is missing") - return false, fmt.Errorf("pgaudit.log_relation flag is not set") - } - if logRelation != "on" && logRelation != "true" { - out.Printf(" ❌ Flag pgaudit.log_relation: expected on, got %s\n", logRelation) - return false, fmt.Errorf("pgaudit.log_relation must be set to 'on'") - } - out.Printf(" ✅ Flag pgaudit.log_relation = %s\n", logRelation) - - db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) - if err != nil { - return false, fmt.Errorf("error connecting to database: %w", err) - } - defer func() { _ = db.Close() }() - - err = db.PingContext(ctx) - if err != nil { - return false, fmt.Errorf("error pinging database: %w", err) - } - - var extensionExists bool - checkExtensionQuery := "SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pgaudit')" - err = db.QueryRowContext(ctx, checkExtensionQuery).Scan(&extensionExists) - if err != nil { - return false, fmt.Errorf("error checking pgaudit extension: %w", err) - } - - if !extensionExists { - out.Println("\n ❌ pgaudit extension is not installed") - return false, nil - } - out.Println("\n ✅ pgaudit extension is installed") - - var pgauditLogValue string - checkSettingQuery := "SELECT setting FROM pg_settings WHERE name = 'pgaudit.log'" - err = db.QueryRowContext(ctx, checkSettingQuery).Scan(&pgauditLogValue) - if err != nil { - return false, fmt.Errorf("error checking pgaudit.log setting from pg_settings: %w", err) - } - - expectedValue := "none" - if pgauditLogValue != expectedValue { - out.Printf(" ❌ pgaudit.log setting for application user: expected %s, got %s\n", expectedValue, pgauditLogValue) - return false, nil - } - - out.Printf(" ✅ pgaudit.log setting for application user: %s\n", pgauditLogValue) - out.Println("\n✅ All audit configurations are correct!") - - return true, nil -} diff --git a/internal/postgres/cloudsqldbinfo.go b/internal/postgres/cloudsqldbinfo.go deleted file mode 100644 index 64b33ae1..00000000 --- a/internal/postgres/cloudsqldbinfo.go +++ /dev/null @@ -1,318 +0,0 @@ -package postgres - -import ( - "context" - "errors" - "fmt" - "net" - "net/url" - "os" - "os/signal" - "strings" - "sync" - "syscall" - "time" - - "cloud.google.com/go/cloudsqlconn" - "github.com/GoogleCloudPlatform/cloudsql-proxy/logging" - "github.com/nais/naistrix" - core_v1 "k8s.io/api/core/v1" - meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" -) - -type CloudSQLDBInfo struct { - *DBInfo - projectID string - connectionName string - secretValues *SecretValues -} - -func (i *CloudSQLDBInfo) ToCloudSQLDBInfo() (*CloudSQLDBInfo, error) { - return i, nil -} - -func (i *CloudSQLDBInfo) SetSecretValues(sv *SecretValues) { - i.secretValues = sv -} - -func (i *CloudSQLDBInfo) ProjectID(ctx context.Context) (string, error) { - if i.projectID == "" { - err := i.fetchDBInstance(ctx) - if err != nil { - return "", err - } - } - return i.projectID, nil -} - -func (i *CloudSQLDBInfo) ConnectionName(ctx context.Context) (string, error) { - if i.connectionName == "" { - err := i.fetchDBInstance(ctx) - if err != nil { - return "", err - } - } - return i.connectionName, nil -} - -func (i *CloudSQLDBInfo) DBConnection(ctx context.Context) (*ConnectionInfo, error) { - if i.secretValues == nil { - return nil, fmt.Errorf("secret values not set for %q; call SetSecretValues before DBConnection", i.appName) - } - - connectionName, err := i.ConnectionName(ctx) - if err != nil { - return nil, err - } - - return createConnectionInfoFromSecretValues(ctx, i.secretValues, connectionName) -} - -func createConnectionInfoFromSecretValues(ctx context.Context, sv *SecretValues, instance string) (*ConnectionInfo, error) { - var pgUrl *url.URL - var jdbcUrl *url.URL - for name, val := range sv.values { - if strings.HasSuffix(name, "_URL") { - u, err := url.Parse(val) - if err != nil { - continue - } - if strings.HasSuffix(name, "_JDBC_URL") { - jdbcUrl = u - } else { - pgUrl = u - } - } - } - - email, err := currentEmail(ctx) - if err != nil { - return nil, err - } - - return &ConnectionInfo{ - username: sv.Get("_USERNAME"), - email: email, - password: sv.Get("_PASSWORD"), - dbName: sv.Get("_DATABASE"), - port: sv.Get("_PORT"), - url: pgUrl, - jdbcUrl: jdbcUrl, - instance: instance, - }, nil -} - -func createConnectionInfo(ctx context.Context, secret core_v1.Secret, instance string) (*ConnectionInfo, error) { - var pgUrl *url.URL - var jdbcUrl *url.URL - var err error - for name, val := range secret.Data { - if strings.HasSuffix(name, "_URL") { - value := string(val) - if strings.HasSuffix(name, "_JDBC_URL") { - jdbcUrl, err = url.Parse(value) - } else { - pgUrl, err = url.Parse(value) - } - if err != nil { - panic(err) - } - } - } - - email, err := currentEmail(ctx) - if err != nil { - return nil, err - } - - return &ConnectionInfo{ - username: getSecretDataValue(secret, "_USERNAME"), - email: email, - password: getSecretDataValue(secret, "_PASSWORD"), - dbName: getSecretDataValue(secret, "_DATABASE"), - port: getSecretDataValue(secret, "_PORT"), - url: pgUrl, - jdbcUrl: jdbcUrl, - instance: instance, - }, nil -} - -func getSecretDataValue(secret core_v1.Secret, suffix string) string { - for name, val := range secret.Data { - if strings.HasSuffix(name, suffix) { - return string(val) - } - } - return "" -} - -func (i *CloudSQLDBInfo) fetchDBInstance(ctx context.Context) error { - sqlInstances, err := i.dynamicClient.Resource(schema.GroupVersionResource{ - Group: "sql.cnrm.cloud.google.com", - Version: "v1beta1", - Resource: "sqlinstances", - }).Namespace(string(i.namespace)).List(ctx, meta_v1.ListOptions{ - LabelSelector: "app=" + i.appName, - }) - if err != nil { - return fmt.Errorf("fetchDBInstance: error looking for sqlinstance %q in %q: %w", i.appName, i.namespace, err) - } - - if len(sqlInstances.Items) == 0 { - return fmt.Errorf("fetchDBInstance: no sqlinstance found for app %q in %q", i.appName, i.namespace) - } else if len(sqlInstances.Items) > 1 { - return fmt.Errorf("fetchDBInstance: multiple sqlinstances found for app %q in %q", i.appName, i.namespace) - } - - sqlInstance := sqlInstances.Items[0] - - connectionName, ok, err := unstructured.NestedString(sqlInstance.Object, "status", "connectionName") - if !ok || err != nil { - return fmt.Errorf("missing 'connectionName' status field; run 'kubectl describe sqlinstance %s' and check for status failures", sqlInstance.GetName()) - } - - i.connectionName = connectionName - i.projectID = sqlInstance.GetAnnotations()["cnrm.cloud.google.com/project-id"] - return nil -} - -func (d *CloudSQLDBInfo) RunProxy(ctx context.Context, host string, port *uint, portCh chan<- int, out *naistrix.OutputWriter, printInstructions bool) error { - projectID, err := d.ProjectID(ctx) - if err != nil { - return err - } - - connectionName, err := d.ConnectionName(ctx) - if err != nil { - return err - } - - if port == nil { - port = new(uint(0)) - } - address := fmt.Sprintf("%s:%d", host, *port) - - if printInstructions { - connectionInfo, err := d.DBConnection(ctx) - if err != nil { - return err - } - - email, err := currentEmail(ctx) - if err != nil { - return err - } - - out.Printf("Starting proxy on %v\n", address) - out.Println("If you are using psql, you can connect to the database by running:") - out.Printf("psql -h %v -p %d -U %v %v\n", host, *port, email, connectionInfo.dbName) - out.Println() - out.Println("If you are using a JDBC client, you can connect to the database by using the following connection string:") - out.Printf("Connection URL: jdbc:postgresql://%v/%v?user=%v\n", address, connectionInfo.dbName, email) - out.Println() - out.Println("If you get asked for a password, you can leave it blank. If that doesn't work, try running 'nais postgres grant", d.AppName()+"' again.") - } - - err = runProxy(ctx, projectID, connectionName, address, portCh, out) - if err != nil { - if errors.Is(err, context.Canceled) { - return nil - } - - fmt.Fprintln(os.Stderr, "\nERROR:", err) - } - - return nil -} - -func runProxy(ctx context.Context, projectID, connectionName, address string, port chan<- int, out *naistrix.OutputWriter) error { - err := checkPostgresqlPassword(out) - if err != nil { - return err - } - - logging.Verbosef = func(format string, v ...any) { out.Verbosef(format, v...) } - logging.Infof = func(format string, v ...any) { out.Infof(format, v...) } - logging.Errorf = func(format string, v ...any) { out.Errorf(format, v...) } - - if err := grantUserAccess(ctx, projectID, "roles/cloudsql.instanceUser", 1*time.Hour, out); err != nil { - return err - } - - opts := []cloudsqlconn.Option{ - cloudsqlconn.WithIAMAuthN(), - } - d, err := cloudsqlconn.NewDialer(ctx, opts...) - if err != nil { - return fmt.Errorf("failed to create dialer: %w", err) - } - - if err := d.Warmup(ctx, connectionName); err != nil { - return fmt.Errorf("failed to warmup connection: %w", err) - } - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - ctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) - defer stop() - - lc := net.ListenConfig{} - listener, err := lc.Listen(ctx, "tcp", address) - if err != nil { - return fmt.Errorf("failed to listen on TCP address: %w", err) - } - - out.Infof("Listening on %s\n", listener.Addr().String()) - - port <- listener.Addr().(*net.TCPAddr).Port - - go func() { - <-ctx.Done() - if err := listener.Close(); err != nil { - out.Println("error closing listener", err) - } - }() - - wg := sync.WaitGroup{} - for ctx.Err() == nil { - conn, err := listener.Accept() - if err != nil { - out.Println("error accepting connection", err) - time.Sleep(100 * time.Millisecond) - continue - } - - out.Infof("New connection %s\n", conn.RemoteAddr()) - wg.Go(func() { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - go func() { - <-ctx.Done() - if err := conn.Close(); err != nil { - out.Println("error closing connection", err) - } - }() - - conn2, err := d.Dial(ctx, connectionName) - if err != nil { - out.Println("error dialing connection", err) - return - } - defer func() { _ = conn2.Close() }() - - closer := make(chan struct{}, 2) - go copy(closer, conn2, conn) - go copy(closer, conn, conn2) - <-closer - out.Infof("Connection complete %s\n", conn.RemoteAddr()) - }) - } - - out.Infof("Waiting for connections to close\n") - wg.Wait() - - return nil -} diff --git a/internal/postgres/command/enable_audit.go b/internal/postgres/command/enable_audit.go deleted file mode 100644 index 0cfce6f9..00000000 --- a/internal/postgres/command/enable_audit.go +++ /dev/null @@ -1,32 +0,0 @@ -package command - -import ( - "context" - - "github.com/nais/cli/internal/metric" - "github.com/nais/cli/internal/postgres" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/cli/internal/validation" - "github.com/nais/naistrix" -) - -func enableAuditCommand(parentFlags *flag.Postgres) *naistrix.Command { - flags := &flag.EnableAudit{Postgres: parentFlags} - return legacyCommand("postgres enable-audit", "nais cloudsql enable-audit", &naistrix.Command{ - Name: "enable-audit", - Title: "Enable audit extension in SQL instance database.", - Description: "This is done by creating pgaudit extension in the database and enabling audit logging for personal user accounts.", - Args: []naistrix.Argument{ - {Name: "app_name"}, - }, - Flags: flags, - ValidateFunc: validation.RequireTeamAndEnvironment(flags), - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - err := postgres.EnableAuditLogging(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) - if err != nil { - metric.CreateAndIncreaseCounter(ctx, "enable_audit_logging_error") - } - return err - }, - }) -} diff --git a/internal/postgres/command/flag/flag.go b/internal/postgres/command/flag/flag.go deleted file mode 100644 index 7999331e..00000000 --- a/internal/postgres/command/flag/flag.go +++ /dev/null @@ -1,115 +0,0 @@ -package flag - -import ( - "context" - - "github.com/nais/cli/internal/flags" - "github.com/nais/cli/internal/labels" - "github.com/nais/naistrix" -) - -type Postgres struct { - *flags.GlobalFlags - Reason string `name:"reason" short:"r" usage:"Justification for accessing the database. Required for audit logging."` -} - -type Migrate struct { - *Postgres - DryRun bool `name:"dry-run" usage:"Perform a dry run of the migration without applying changes."` -} - -type MigrateSetup struct { - *Migrate - Tier string `name:"tier" usage:"The |TIER| of the new instance."` - DiskAutoResize bool `name:"disk-auto-resize" usage:"Enable automatic disk resizing for the new instance."` - DiskSize int `name:"disk-size" usage:"The |DISK_SIZE| of the new instance."` - InstanceType string `name:"instance-type" usage:"The |TYPE| of the new instance."` - NoWait bool `name:"no-wait" usage:"Do not wait for the job to complete."` -} - -type MigratePromote struct { - *Migrate - NoWait bool `name:"no-wait" usage:"Do not wait for the job to complete."` -} - -type MigrateFinalize struct { - *Migrate -} - -type MigrateRollback struct { - *Migrate -} - -type Password struct { - *Postgres -} - -type PasswordRotate struct { - *Password -} - -type User struct { - *Postgres -} - -type UserAdd struct { - *User - Privilege string `name:"privilege" usage:"The privilege to grant to the user."` -} - -type UserDrop struct { - *User -} - -type UserList struct { - *User -} - -type EnableAudit struct { - *Postgres -} - -type VerifyAudit struct { - *Postgres -} - -type Grant struct { - *Postgres -} - -type Prepare struct { - *Postgres - AllPrivileges bool `name:"all-privileges" usage:"Grant all privileges on the schema to the current user."` - Schema string `name:"schema" usage:"Schema to grant access to."` -} - -type Proxy struct { - *Postgres - Port uint `name:"port" short:"p" usage:"Port to use for the proxy. Defaults to 5432."` - Host string `name:"host" short:"H" usage:"Host to proxy to. Defaults to localhost."` -} - -type Psql struct { - *Postgres -} - -type Revoke struct { - *Postgres - Schema string `name:"schema" usage:"The schema to revoke privileges from."` -} - -type List struct { - *Postgres - Output Output `name:"output" short:"o" usage:"Format output (table or json)."` - Labels labels.LabelFilters `name:"label" short:"l" usage:"Filter by label in |KEY=VALUE| form. Can be repeated."` -} - -func (*List) LabelFacetResource() string { return "postgresBranches" } - -type Output string - -var _ naistrix.FlagAutoCompleter = (*Output)(nil) - -func (o *Output) AutoComplete(context.Context, *naistrix.Arguments, string, any) ([]string, string) { - return []string{"table", "json"}, "Available output formats." -} diff --git a/internal/postgres/command/grant.go b/internal/postgres/command/grant.go deleted file mode 100644 index ad8ab355..00000000 --- a/internal/postgres/command/grant.go +++ /dev/null @@ -1,27 +0,0 @@ -package command - -import ( - "context" - - "github.com/nais/cli/internal/postgres" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/cli/internal/validation" - "github.com/nais/naistrix" -) - -func grantCommand(parentFlags *flag.Postgres) *naistrix.Command { - flags := &flag.Grant{Postgres: parentFlags} - return legacyCommand("postgres grant", "", &naistrix.Command{ - Name: "grant", - Title: "Grant yourself access to a SQL instance database.", - Description: "This is done by temporarily adding your user to the list of users that can administrate Cloud SQL instances and creating a user with your email.", - Args: []naistrix.Argument{ - {Name: "app_name"}, - }, - Flags: flags, - ValidateFunc: validation.RequireTeamAndEnvironment(flags), - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - return postgres.GrantAndCreateSQLUser(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), out) - }, - }) -} diff --git a/internal/postgres/command/legacy.go b/internal/postgres/command/legacy.go deleted file mode 100644 index ce3bb35f..00000000 --- a/internal/postgres/command/legacy.go +++ /dev/null @@ -1,29 +0,0 @@ -package command - -import ( - "context" - "fmt" - "os" - "strings" - - "github.com/nais/naistrix" -) - -// legacyCommand warns about the new command path without intercepting the old execution. -// naistrix.Deprecated cannot be used here: it replaces RunFunc instead of running it. -func legacyCommand(path, replacement string, cmd *naistrix.Command) *naistrix.Command { - run := cmd.RunFunc - cmd.Title += " (DEPRECATED)" - cmd.RunFunc = func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - warning := fmt.Sprintf("Warning: nais %s is deprecated and will be removed in a future release", path) - if replacement != "" { - warning += "; use " + replacement + " instead" - } - if strings.HasSuffix(path, " grant") || strings.HasSuffix(path, " prepare") || strings.HasSuffix(path, " revoke") || strings.HasSuffix(path, " proxy") || strings.HasSuffix(path, " psql") { - warning += "; the legacy in-cluster access path will be removed" - } - _, _ = fmt.Fprintln(os.Stderr, warning+".") - return run(ctx, args, out) - } - return cmd -} diff --git a/internal/postgres/command/legacy_test.go b/internal/postgres/command/legacy_test.go deleted file mode 100644 index 056f9a9e..00000000 --- a/internal/postgres/command/legacy_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package command - -import ( - "bytes" - "context" - "errors" - "io" - "os" - "strings" - "testing" - - "github.com/nais/cli/internal/flags" - "github.com/nais/naistrix" -) - -func TestLegacyCommandWarnsAndRunsOriginal(t *testing.T) { - for _, tt := range []struct { - name string - path string - replacement string - want string - }{ - {"replacement", "postgres prepare", "nais cloudsql prepare", "Warning: nais postgres prepare is deprecated and will be removed in a future release; use nais cloudsql prepare instead; the legacy in-cluster access path will be removed.\n"}, - {"no replacement", "postgres grant", "", "Warning: nais postgres grant is deprecated and will be removed in a future release; the legacy in-cluster access path will be removed.\n"}, - } { - t.Run(tt.name, func(t *testing.T) { - originalErr := errors.New("original error") - ctx := context.Background() - args := &naistrix.Arguments{} - var stdout bytes.Buffer - out := naistrix.NewOutputWriter(&stdout, nil) - called := false - cmd := legacyCommand(tt.path, tt.replacement, &naistrix.Command{ - Name: "prepare", - RunFunc: func(gotCtx context.Context, gotArgs *naistrix.Arguments, gotOut *naistrix.OutputWriter) error { - called = true - if gotCtx != ctx || gotArgs != args || gotOut != out { - t.Error("original command received different inputs") - } - return originalErr - }, - }) - - previous := os.Stderr - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - os.Stderr = w - gotErr := cmd.RunFunc(ctx, args, out) - os.Stderr = previous - if err := w.Close(); err != nil { - t.Fatal(err) - } - warning, err := io.ReadAll(r) - if err != nil { - t.Fatal(err) - } - if err := r.Close(); err != nil { - t.Fatal(err) - } - if string(warning) != tt.want { - t.Errorf("stderr = %q, want %q", warning, tt.want) - } - if stdout.Len() != 0 { - t.Errorf("stdout = %q, want empty", stdout.String()) - } - if !called || !errors.Is(gotErr, originalErr) { - t.Errorf("original called = %v, error = %v; want original error", called, gotErr) - } - }) - } -} - -func TestLegacyPostgresLeavesRemainRunnable(t *testing.T) { - root := Postgres(&flags.GlobalFlags{}) - var paths []string - var walk func(*naistrix.Command, string) - walk = func(cmd *naistrix.Command, prefix string) { - path := strings.TrimSpace(prefix + " " + cmd.Name) - if len(cmd.SubCommands) == 0 { - paths = append(paths, path) - if cmd.Deprecated != nil || cmd.RunFunc == nil || !strings.Contains(cmd.Title, "(DEPRECATED)") { - t.Errorf("%s must execute its RunFunc without naistrix deprecation interception", path) - } - } - for _, child := range cmd.SubCommands { - walk(child, path) - } - } - walk(root, "") - want := []string{ - "postgres list", "postgres migrate setup", "postgres migrate promote", "postgres migrate finalize", "postgres migrate rollback", - "postgres password rotate", "postgres users add", "postgres users drop", "postgres users list", - "postgres enable-audit", "postgres verify-audit", "postgres grant", "postgres prepare", "postgres proxy", "postgres psql", "postgres revoke", - } - if strings.Join(paths, ",") != strings.Join(want, ",") { - t.Errorf("leaves = %v, want %v", paths, want) - } -} diff --git a/internal/postgres/command/list.go b/internal/postgres/command/list.go deleted file mode 100644 index c796196a..00000000 --- a/internal/postgres/command/list.go +++ /dev/null @@ -1,49 +0,0 @@ -package command - -import ( - "context" - - "github.com/nais/cli/internal/labels" - "github.com/nais/cli/internal/postgres" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/naistrix" - "github.com/nais/naistrix/output" -) - -func listCommand(parentFlags *flag.Postgres) *naistrix.Command { - flags := &flag.List{Postgres: parentFlags} - - return legacyCommand("postgres list", "nais cloudsql list for Cloud SQL or nais alpha postgres list for Nais Postgres", &naistrix.Command{ - Name: "list", - Title: "List Postgres branches and Cloud SQL instances for a team.", - Description: "List NAIS Postgres branches and Google Cloud SQL Postgres instances owned by a team.", - Flags: flags, - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - labelFilters, err := labels.ParseFilters(flags.Labels) - if err != nil { - return err - } - - environments := []string(nil) - if flags.Environment != "" { - environments = []string{string(flags.Environment)} - } - - ret, err := postgres.GetTeamPostgresBranches(ctx, flags.Team, environments, labelFilters) - if err != nil { - return err - } - - if flags.Output == "json" { - return out.JSON(output.JSONWithPrettyOutput()).Render(ret) - } - - if len(ret) == 0 { - out.Println("Team has no Postgres branches or Cloud SQL instances.") - return nil - } - - return out.Table().Render(ret) - }, - }) -} diff --git a/internal/postgres/command/migrate.go b/internal/postgres/command/migrate.go deleted file mode 100644 index 24eeb378..00000000 --- a/internal/postgres/command/migrate.go +++ /dev/null @@ -1,125 +0,0 @@ -package command - -import ( - "context" - "fmt" - "os" - "strconv" - "strings" - - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/cli/internal/postgres/migrate/finalize" - "github.com/nais/cli/internal/postgres/migrate/promote" - "github.com/nais/cli/internal/postgres/migrate/rollback" - "github.com/nais/cli/internal/postgres/migrate/setup" - "github.com/nais/cli/internal/validation" - "github.com/nais/naistrix" -) - -func migrateCommand(parentFlags *flag.Postgres) *naistrix.Command { - flags := &flag.Migrate{Postgres: parentFlags} - return &naistrix.Command{ - Name: "migrate", - Title: "Migrate to a new SQL instance.", - Description: "Commands for migrating a Postgres database to a new Cloud SQL instance, including setup, promotion, finalization, and rollback.", - StickyFlags: flags, - ValidateFunc: validation.RequireTeamAndEnvironment(flags), - SubCommands: []*naistrix.Command{ - migrateSetupCommand(flags), - migratePromoteCommand(flags), - migrateFinalizeCommand(flags), - migrateRollbackCommand(flags), - }, - } -} - -func migrateSetupCommand(parentFlags *flag.Migrate) *naistrix.Command { - flags := &flag.MigrateSetup{ - Migrate: parentFlags, - Tier: os.Getenv("TARGET_INSTANCE_TIER"), - } - - if v, err := strconv.ParseBool(os.Getenv("TARGET_INSTANCE_DISK_AUTORESIZE")); err == nil { - flags.DiskAutoResize = v - } - - if v, err := strconv.Atoi(os.Getenv("TARGET_INSTANCE_DISK_SIZE")); err == nil { - flags.DiskSize = v - } - - return legacyCommand("postgres migrate setup", "nais cloudsql migrate setup", &naistrix.Command{ - Name: "setup", - Title: "Make necessary setup for a new SQL instance migration.", - Description: "Setup will create a new (target) instance with updated configuration, and enable continuous replication of data from the source instance.", - Args: []naistrix.Argument{ - {Name: "app_name"}, - {Name: "target_sql_instance_name"}, - }, - ValidateFunc: func(ctx context.Context, args *naistrix.Arguments) error { - if flags.Tier != "" && !strings.HasPrefix(flags.Tier, "db-") { - return fmt.Errorf("tier must start with `db-`") - } - - if flags.InstanceType != "" && !strings.HasPrefix(flags.InstanceType, "POSTGRES_") { - return fmt.Errorf("instance type must start with `POSTGRES_`") - } - - return nil - }, - Flags: flags, - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - return setup.Run(ctx, args.Get("app_name"), args.Get("target_sql_instance_name"), flags.Team, string(flags.Environment), flags) - }, - }) -} - -func migratePromoteCommand(parentFlags *flag.Migrate) *naistrix.Command { - flags := &flag.MigratePromote{Migrate: parentFlags} - return legacyCommand("postgres migrate promote", "nais cloudsql migrate promote", &naistrix.Command{ - Name: "promote", - Title: "Promote the migrated instance to the new primary instance.", - Description: "Promote will promote the target instance to the new primary instance, and update the application to use the new instance.", - Flags: flags, - Args: []naistrix.Argument{ - {Name: "app_name"}, - {Name: "target_sql_instance_name"}, - }, - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - return promote.Run(ctx, args.Get("app_name"), args.Get("target_sql_instance_name"), flags.Team, string(flags.Environment), flags) - }, - }) -} - -func migrateFinalizeCommand(parentFlags *flag.Migrate) *naistrix.Command { - flags := &flag.MigrateFinalize{Migrate: parentFlags} - return legacyCommand("postgres migrate finalize", "nais cloudsql migrate finalize", &naistrix.Command{ - Name: "finalize", - Title: "Finalize the migration.", - Description: "Finalize will remove the source instance and associated resources after a successful migration.", - Args: []naistrix.Argument{ - {Name: "app_name"}, - {Name: "target_sql_instance_name"}, - }, - Flags: flags, - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - return finalize.Run(ctx, args.Get("app_name"), args.Get("target_sql_instance_name"), flags.Team, string(flags.Environment), flags.DryRun) - }, - }) -} - -func migrateRollbackCommand(parentFlags *flag.Migrate) *naistrix.Command { - flags := &flag.MigrateRollback{Migrate: parentFlags} - return legacyCommand("postgres migrate rollback", "nais cloudsql migrate rollback", &naistrix.Command{ - Name: "rollback", - Title: "Roll back the migration.", - Description: "Rollback will roll back the migration, and restore the application to use the original instance.", - Args: []naistrix.Argument{ - {Name: "app_name"}, - {Name: "target_sql_instance_name"}, - }, - Flags: flags, - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - return rollback.Run(ctx, args.Get("app_name"), args.Get("target_sql_instance_name"), flags.Team, string(flags.Environment), flags) - }, - }) -} diff --git a/internal/postgres/command/password.go b/internal/postgres/command/password.go deleted file mode 100644 index 7632b42e..00000000 --- a/internal/postgres/command/password.go +++ /dev/null @@ -1,34 +0,0 @@ -package command - -import ( - "context" - - "github.com/nais/cli/internal/postgres" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/cli/internal/validation" - "github.com/nais/naistrix" -) - -func passwordCommand(parentFlags *flag.Postgres) *naistrix.Command { - flags := &flag.Password{Postgres: parentFlags} - return &naistrix.Command{ - Name: "password", - Title: "Manage SQL instance passwords.", - Description: "Commands for managing Postgres instance passwords, including password rotation.", - StickyFlags: flags, - SubCommands: []*naistrix.Command{ - legacyCommand("postgres password rotate", "nais cloudsql password rotate", &naistrix.Command{ - Name: "rotate", - Title: "Rotate the SQL instance password.", - Description: "The rotation is done in GCP and in the Kubernetes secret.", - Args: []naistrix.Argument{ - {Name: "app_name"}, - }, - ValidateFunc: validation.RequireTeamAndEnvironment(flags), - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - return postgres.RotatePassword(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) - }, - }), - }, - } -} diff --git a/internal/postgres/command/postgres.go b/internal/postgres/command/postgres.go deleted file mode 100644 index 06e0bb96..00000000 --- a/internal/postgres/command/postgres.go +++ /dev/null @@ -1,41 +0,0 @@ -package command - -import ( - "context" - - "github.com/nais/cli/internal/flags" - "github.com/nais/cli/internal/gcloud" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/naistrix" -) - -func Postgres(parentFlags *flags.GlobalFlags) *naistrix.Command { - flags := &flag.Postgres{ - GlobalFlags: parentFlags, - } - - return &naistrix.Command{ - Name: "postgres", - Title: "Manage postgres instances.", - Description: "Commands for managing Google Cloud SQL Postgres instances, including listing, migration, user management, password rotation, and direct database access.", - Aliases: []string{"pg"}, - StickyFlags: flags, - SubCommands: []*naistrix.Command{ - listCommand(flags), - migrateCommand(flags), - passwordCommand(flags), - usersCommand(flags), - enableAuditCommand(flags), - verifyAuditCommand(flags), - grantCommand(flags), - prepareCommand(flags), - proxyCommand(flags), - psqlCommand(flags), - revokeCommand(flags), - }, - ValidateFunc: func(ctx context.Context, _ *naistrix.Arguments) error { - _, err := gcloud.ValidateAndGetUserLogin(ctx, false) - return err - }, - } -} diff --git a/internal/postgres/command/prepare.go b/internal/postgres/command/prepare.go deleted file mode 100644 index 97dc0378..00000000 --- a/internal/postgres/command/prepare.go +++ /dev/null @@ -1,47 +0,0 @@ -package command - -import ( - "context" - "fmt" - - _ "github.com/GoogleCloudPlatform/cloudsql-proxy/proxy/dialers/postgres" - "github.com/MakeNowJust/heredoc/v2" - "github.com/nais/cli/internal/postgres" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/cli/internal/validation" - "github.com/nais/naistrix" - "github.com/nais/naistrix/input" -) - -func prepareCommand(parentFlags *flag.Postgres) *naistrix.Command { - flags := &flag.Prepare{ - Postgres: parentFlags, - Schema: "public", - } - - return legacyCommand("postgres prepare", "nais cloudsql prepare", &naistrix.Command{ - Name: "prepare", - Title: "Prepare your SQL instance for use with personal accounts.", - Description: heredoc.Doc(` - Prepare will prepare the SQL instance by connecting using the application credentials and modify the permissions on the public schema. - - All IAM users in your GCP project will be able to connect to the instance. - - This operation is only required to run once for each SQL instance. - `), - Args: []naistrix.Argument{ - {Name: "app_name"}, - }, - Flags: flags, - ValidateFunc: validation.RequireTeamAndEnvironment(flags), - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - if result, err := input.Confirm("Are you sure you want to continue?"); err != nil { - return err - } else if !result { - return fmt.Errorf("cancelled by user") - } - - return postgres.PrepareAccess(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) - }, - }) -} diff --git a/internal/postgres/command/proxy.go b/internal/postgres/command/proxy.go deleted file mode 100644 index 770e6011..00000000 --- a/internal/postgres/command/proxy.go +++ /dev/null @@ -1,31 +0,0 @@ -package command - -import ( - "context" - - "github.com/nais/cli/internal/postgres" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/cli/internal/validation" - "github.com/nais/naistrix" -) - -func proxyCommand(parentFlags *flag.Postgres) *naistrix.Command { - flags := &flag.Proxy{ - Postgres: parentFlags, - Port: 5432, - Host: "localhost", - } - return legacyCommand("postgres proxy", "nais cloudsql proxy", &naistrix.Command{ - Name: "proxy", - Title: "Create a proxy to a SQL instance.", - Description: "Allows your user to connect to databases and starts a proxy.", - Args: []naistrix.Argument{ - {Name: "app_name"}, - }, - Flags: flags, - ValidateFunc: validation.RequireTeamAndEnvironment(flags), - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - return postgres.RunProxy(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) - }, - }) -} diff --git a/internal/postgres/command/psql.go b/internal/postgres/command/psql.go deleted file mode 100644 index c70d00d3..00000000 --- a/internal/postgres/command/psql.go +++ /dev/null @@ -1,27 +0,0 @@ -package command - -import ( - "context" - - "github.com/nais/cli/internal/postgres" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/cli/internal/validation" - "github.com/nais/naistrix" -) - -func psqlCommand(parentFlags *flag.Postgres) *naistrix.Command { - flags := &flag.Psql{Postgres: parentFlags} - return legacyCommand("postgres psql", "nais cloudsql psql", &naistrix.Command{ - Name: "psql", - Title: "Connect to the database using psql.", - Description: "Create a shell to the SQL instance by opening a proxy on a random port (see the proxy command for more info) and opening a psql shell.", - Args: []naistrix.Argument{ - {Name: "app_name"}, - }, - Flags: flags, - ValidateFunc: validation.RequireTeamAndEnvironment(flags), - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - return postgres.RunPSQL(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) - }, - }) -} diff --git a/internal/postgres/command/revoke.go b/internal/postgres/command/revoke.go deleted file mode 100644 index cd97a1d5..00000000 --- a/internal/postgres/command/revoke.go +++ /dev/null @@ -1,46 +0,0 @@ -package command - -import ( - "context" - "fmt" - - _ "github.com/GoogleCloudPlatform/cloudsql-proxy/proxy/dialers/postgres" - "github.com/MakeNowJust/heredoc/v2" - "github.com/nais/cli/internal/postgres" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/cli/internal/validation" - "github.com/nais/naistrix" - "github.com/nais/naistrix/input" -) - -func revokeCommand(parentFlags *flag.Postgres) *naistrix.Command { - flags := &flag.Revoke{ - Postgres: parentFlags, - Schema: "public", - } - return legacyCommand("postgres revoke", "nais cloudsql revoke", &naistrix.Command{ - Name: "revoke", - Title: `Revoke access to your SQL instance for the role "cloudsqliamuser".`, - Description: heredoc.Doc(` - Revoke will revoke the role "cloudsqliamuser" access to the tables in the SQL instance. - - This is done by connecting using the application credentials and modify the permissions on the public schema. - - This operation is only required to run once for each SQL instance. - `), - Args: []naistrix.Argument{ - {Name: "app_name"}, - }, - Flags: flags, - ValidateFunc: validation.RequireTeamAndEnvironment(flags), - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - if result, err := input.Confirm("Are you sure you want to continue?"); err != nil { - return err - } else if !result { - return fmt.Errorf("cancelled by user") - } - - return postgres.RevokeAccess(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) - }, - }) -} diff --git a/internal/postgres/command/users.go b/internal/postgres/command/users.go deleted file mode 100644 index cf811744..00000000 --- a/internal/postgres/command/users.go +++ /dev/null @@ -1,80 +0,0 @@ -package command - -import ( - "context" - - "github.com/nais/cli/internal/postgres" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/cli/internal/validation" - "github.com/nais/naistrix" -) - -func usersCommand(parentFlags *flag.Postgres) *naistrix.Command { - flags := &flag.User{Postgres: parentFlags} - return &naistrix.Command{ - Name: "users", - Title: "Manage users in your SQL instance.", - Description: "Commands for adding, listing, and dropping users in a Postgres SQL instance.", - StickyFlags: flags, - ValidateFunc: validation.RequireTeamAndEnvironment(flags), - SubCommands: []*naistrix.Command{ - addCommand(flags), - dropCommand(flags), - listUsersCommand(flags), - }, - } -} - -func addCommand(parentFlags *flag.User) *naistrix.Command { - flags := &flag.UserAdd{ - User: parentFlags, - Privilege: "select", - } - return legacyCommand("postgres users add", "nais cloudsql users add", &naistrix.Command{ - Name: "add", - Title: "Add a user to a SQL instance.", - Description: "Will grant a user access to tables in public schema.", - Args: []naistrix.Argument{ - {Name: "app_name"}, - {Name: "username"}, - {Name: "password"}, - }, - Flags: flags, - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - return postgres.AddUser(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), args.Get("username"), args.Get("password"), flags, out) - }, - }) -} - -func listUsersCommand(parentFlags *flag.User) *naistrix.Command { - flags := &flag.UserList{User: parentFlags} - return legacyCommand("postgres users list", "nais cloudsql users list", &naistrix.Command{ - Name: "list", - Title: "List users in a SQL instance database.", - Description: "List all users in a Postgres SQL instance database for a given application.", - Args: []naistrix.Argument{ - {Name: "app_name"}, - }, - Flags: flags, - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - return postgres.ListUsers(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) - }, - }) -} - -func dropCommand(parentFlags *flag.User) *naistrix.Command { - flags := &flag.UserDrop{User: parentFlags} - return legacyCommand("postgres users drop", "nais cloudsql users drop", &naistrix.Command{ - Name: "drop", - Title: "Drop a user from a SQL instance database.", - Description: "Remove a user from a Postgres SQL instance database.", - Args: []naistrix.Argument{ - {Name: "app_name"}, - {Name: "username"}, - }, - Flags: flags, - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - return postgres.DropUser(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), args.Get("username"), flags, out) - }, - }) -} diff --git a/internal/postgres/command/verify_audit.go b/internal/postgres/command/verify_audit.go deleted file mode 100644 index 80e9ad47..00000000 --- a/internal/postgres/command/verify_audit.go +++ /dev/null @@ -1,32 +0,0 @@ -package command - -import ( - "context" - - "github.com/nais/cli/internal/metric" - "github.com/nais/cli/internal/postgres" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/cli/internal/validation" - "github.com/nais/naistrix" -) - -func verifyAuditCommand(parentFlags *flag.Postgres) *naistrix.Command { - flags := &flag.VerifyAudit{Postgres: parentFlags} - return legacyCommand("postgres verify-audit", "nais cloudsql verify-audit", &naistrix.Command{ - Name: "verify-audit", - Title: "Verify audit extension and configuration in SQL instance database.", - Description: "This verifies that the pgaudit extension is installed and that audit logging is properly configured for the application user.", - Args: []naistrix.Argument{ - {Name: "app_name"}, - }, - Flags: flags, - ValidateFunc: validation.RequireTeamAndEnvironment(flags), - RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - err := postgres.VerifyAuditLogging(ctx, args.Get("app_name"), flags.Team, string(flags.Environment), flags, out) - if err != nil { - metric.CreateAndIncreaseCounter(ctx, "verify_audit_logging_error") - } - return err - }, - }) -} diff --git a/internal/postgres/dbinfo.go b/internal/postgres/dbinfo.go deleted file mode 100644 index 2829a64e..00000000 --- a/internal/postgres/dbinfo.go +++ /dev/null @@ -1,160 +0,0 @@ -package postgres - -import ( - "context" - "errors" - "fmt" - "net/url" - - "github.com/nais/naistrix" - "golang.org/x/oauth2" - meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/tools/clientcmd" -) - -type DB interface { - DBConnection(ctx context.Context) (*ConnectionInfo, error) - RunProxy(ctx context.Context, host string, port *uint, portCh chan<- int, out *naistrix.OutputWriter, printInstructions bool) error - - AppName() string - SetSecretValues(sv *SecretValues) - - // TODO: Remove when interface migration complete - ToCloudSQLDBInfo() (*CloudSQLDBInfo, error) -} - -type DBInfo struct { - k8sClient kubernetes.Interface - dynamicClient dynamic.Interface - config clientcmd.ClientConfig - namespace string - appName string -} - -func (d *DBInfo) AppName() string { - return d.appName -} - -func NewDBInfo(ctx context.Context, appName, team, environment string) (DB, error) { - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - configOverrides := &clientcmd.ConfigOverrides{ - CurrentContext: environment, - } - kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) - config, err := kubeConfig.ClientConfig() - if err != nil { - return nil, fmt.Errorf("NewDBInfo: unable to get kubeconfig: %w", err) - } - - if team == "" { - ns, _, err := kubeConfig.Namespace() - if err != nil { - return nil, fmt.Errorf("NewDBInfo: unable to get namespace: %w", err) - } - team = ns - } - - k8sClient, err := kubernetes.NewForConfig(config) - if err != nil { - return nil, fmt.Errorf("NewDBInfo: load kubeclient configuration: %w", err) - } - - dynamicClient, err := dynamic.NewForConfig(config) - if err != nil { - return nil, fmt.Errorf("NewDBInfo: load kubeclient configuration: %w", err) - } - - dbInfo := &DBInfo{ - k8sClient: k8sClient, - dynamicClient: dynamicClient, - config: kubeConfig, - namespace: team, - appName: appName, - } - - isCloudSQL, err := IsCloudSQL(ctx, dbInfo) - if err != nil { - return nil, err - } - if isCloudSQL { - return &CloudSQLDBInfo{ - DBInfo: dbInfo, - }, nil - } else { - return NewPostgresDBInfo(ctx, dbInfo) - } -} - -func IsCloudSQL(ctx context.Context, i *DBInfo) (bool, error) { - sqlInstances, err := i.dynamicClient.Resource(schema.GroupVersionResource{ - Group: "sql.cnrm.cloud.google.com", - Version: "v1beta1", - Resource: "sqlinstances", - }).Namespace(string(i.namespace)).List(ctx, meta_v1.ListOptions{ - LabelSelector: "app=" + i.appName, - }) - if err != nil { - return false, fmt.Errorf("fetchDBInstance: error looking for sqlinstance %q in %q: %w", i.appName, i.namespace, err) - } - - if len(sqlInstances.Items) == 1 { - return true, nil - } else if len(sqlInstances.Items) > 1 { - return true, fmt.Errorf("fetchDBInstance: multiple sqlinstances found for app %q in %q", i.appName, i.namespace) - } - - return false, nil -} - -type ConnectionInfo struct { - username string - email string - password string - dbName string - instance string - port string - url *url.URL - jdbcUrl *url.URL -} - -func (c *ConnectionInfo) ProxyConnectionString() string { - return fmt.Sprintf("host=%v user=%v dbname=%v password=%v sslmode=disable", c.instance, c.username, c.dbName, c.password) -} - -func (c *ConnectionInfo) SetPassword(password string) { - c.password = password - if c.url != nil { - c.url.User = url.UserPassword(c.username, password) - } - if c.jdbcUrl != nil { - queries := c.jdbcUrl.Query() - queries.Set("password", password) - c.jdbcUrl.RawQuery = queries.Encode() - } else if c.url != nil { - queries := c.url.Query() - queries.Set("password", password) - queries.Set("user", c.username) - c.jdbcUrl = &url.URL{ - Scheme: "jdbc:postgresql", - Host: c.url.Host, - Path: c.dbName, - RawQuery: queries.Encode(), - } - } -} - -// formatInvalidGrantError returns a custom error message if the error is of type oauth2.RetrieveError and if it has the -// error code invalid_grant. If not it returns the error. -func formatInvalidGrantError(err error) error { - var retrieve *oauth2.RetrieveError - if errors.As(err, &retrieve) { - if retrieve.ErrorCode == "invalid_grant" { - return fmt.Errorf("looks like you are missing Application Default Credentials, run `gcloud auth login --update-adc` first") - } - } - - return err -} diff --git a/internal/postgres/iam.go b/internal/postgres/iam.go deleted file mode 100644 index 6daa8f6a..00000000 --- a/internal/postgres/iam.go +++ /dev/null @@ -1,356 +0,0 @@ -package postgres - -import ( - "bytes" - "context" - "database/sql" - "encoding/json" - "fmt" - "io" - "os" - "os/exec" - "regexp" - "strings" - "time" - - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/naistrix" -) - -func GrantAndCreateSQLUser(ctx context.Context, appName, team, environment string, out *naistrix.OutputWriter) error { - dbInfo, err := NewDBInfo(ctx, appName, team, environment) - if err != nil { - return err - } - - cloudSQLDBInfo, err := dbInfo.ToCloudSQLDBInfo() - if err != nil { - return err - } - - projectID, err := cloudSQLDBInfo.ProjectID(ctx) - if err != nil { - return err - } - - connectionName, err := cloudSQLDBInfo.ConnectionName(ctx) - if err != nil { - return err - } - - out.Println("Grant user access") - err = grantUserAccess(ctx, projectID, "roles/cloudsql.admin", 5*time.Minute, out) - if err != nil { - return err - } - - out.Println("Create sql user") - err = createSQLUser(ctx, projectID, connectionName) - if err != nil { - return fmt.Errorf("error creating SQL user. One might already exist: %v", err) - } - - return nil -} - -func createSQLUser(ctx context.Context, projectID, instance string) error { - email, err := currentEmail(ctx) - if err != nil { - return err - } - - args := []string{ - "sql", - "users", - "create", - email, - "--instance", strings.Split(instance, ":")[2], - "--type", "cloud_iam_user", - "--project", projectID, - } - - buf := &bytes.Buffer{} - cmd := exec.CommandContext(ctx, "gcloud", args...) - cmd.Stdout = buf - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - _, _ = io.Copy(os.Stdout, buf) - return fmt.Errorf("error running gcloud command: %w", err) - } - return nil -} - -func currentEmail(ctx context.Context) (string, error) { - cmd := exec.CommandContext(ctx, "gcloud", "config", "get-value", "account") - out, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("currentEmail: unable to retrieve email: %w\n%v", err, string(out)) - } - return strings.TrimSpace(string(out)), nil -} - -func grantUserAccess(ctx context.Context, projectID, role string, duration time.Duration, out *naistrix.OutputWriter) error { - email, err := currentEmail(ctx) - if err != nil { - return err - } - - exists, err := cleanupPermissions(ctx, projectID, email, role, "nais_cli_access") - if err != nil { - return err - } - - if exists { - out.Println("User already has permanent access to database, will not grant temporary access") - return nil - } - - args := []string{ - "projects", - "add-iam-policy-binding", - projectID, - "--member", "user:" + email, - "--role", role, - "--billing-project", projectID, - } - - if duration > 0 { - timestamp := time.Now().Add(duration).UTC().Format(time.RFC3339) - args = append( - args, - "--condition", - formatCondition("request.time < timestamp('"+timestamp+"')", "nais_cli_access"), - ) - } - - cmd := exec.CommandContext(ctx, "gcloud", args...) - buf := &bytes.Buffer{} - cmd.Stdout = buf - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - _, _ = io.Copy(os.Stdout, buf) - return fmt.Errorf("grantUserAccess: error running gcloud command: %w", err) - } - return nil -} - -func cleanupPermissions(ctx context.Context, projectID, email, role, conditionName string) (exists bool, err error) { - args := []string{ - "projects", - "get-iam-policy", - projectID, - "--format", "json", - "--billing-project", projectID, - } - cmd := exec.CommandContext(ctx, "gcloud", args...) - out, err := cmd.Output() - if err != nil { - if e, ok := err.(*exec.ExitError); ok { - _, _ = fmt.Fprintln(os.Stderr, string(e.Stderr)) - } - return false, fmt.Errorf("cleanupPermissions: error getting permissions: %w", err) - } - bindings := &policyBindings{} - if err := json.Unmarshal(out, bindings); err != nil { - return false, fmt.Errorf("cleanupPermissions: error unmarshaling json: %w", err) - } - - expr := "" -OUTER: - for _, binding := range bindings.Bindings { - if binding.Role == role { - for _, member := range binding.Members { - if member == "user:"+email { - if binding.Condition == nil { - return true, nil - } - if binding.Condition.Title == conditionName { - expr = formatCondition(binding.Condition.Expression, binding.Condition.Title) - break OUTER - } - } - } - } - } - - if expr == "" { - return false, nil - } - - args = []string{ - "projects", - "remove-iam-policy-binding", - projectID, - "--member", "user:" + email, - "--role", role, - "--condition", expr, - "--billing-project", projectID, - } - cmd = exec.CommandContext(ctx, "gcloud", args...) - buf := &bytes.Buffer{} - cmd.Stdout = buf - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - _, _ = io.Copy(os.Stdout, buf) - return false, fmt.Errorf("cleanupPermissions: error running gcloud command: %w", err) - } - return false, nil -} - -type policyBindings struct { - Bindings []struct { - Role string `json:"role"` - Members []string `json:"members"` - Condition *struct { - Title string `json:"title"` - Expression string `json:"expression"` - } `json:"condition"` - } `json:"bindings"` -} - -func formatCondition(expr, title string) string { - return fmt.Sprintf("expression=%v,title=%v", expr, title) -} - -func ListUsers(ctx context.Context, appName, team, environment string, fl *flag.UserList, out *naistrix.OutputWriter) error { - // Get secret values (access is logged for audit purposes) - sv, err := GetSecretValues(ctx, appName, team, environment, fl.Postgres, ReasonListUsers, out) - if err != nil { - return err - } - - dbInfo, err := NewDBInfo(ctx, appName, team, environment) - if err != nil { - return err - } - - dbInfo.SetSecretValues(sv) - - connectionInfo, err := dbInfo.DBConnection(ctx) - if err != nil { - return err - } - - db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) - if err != nil { - return err - } - - rows, err := db.QueryContext(ctx, "SELECT usename FROM pg_catalog.pg_user;") - if err != nil { - return formatInvalidGrantError(err) - } - defer func() { - _ = rows.Close() - }() - - out.Println("Users in database:") - for rows.Next() { - var d struct { - User string `field:"usename"` - } - if err := rows.Scan(&d.User); err != nil { - return err - } - - out.Println(d.User) - } - - return err -} - -func AddUser(ctx context.Context, appName, team, environment, username, password string, fl *flag.UserAdd, out *naistrix.OutputWriter) error { - err := validateSQLVariables(username, password, fl.Privilege) - if err != nil { - return err - } - - // Get secret values (access is logged for audit purposes) - sv, err := GetSecretValues(ctx, appName, team, environment, fl.Postgres, ReasonAddUser, out) - if err != nil { - return err - } - - dbInfo, err := NewDBInfo(ctx, appName, team, environment) - if err != nil { - return err - } - - dbInfo.SetSecretValues(sv) - - connectionInfo, err := dbInfo.DBConnection(ctx) - if err != nil { - return err - } - - db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) - if err != nil { - return err - } - - _, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE USER %q WITH ENCRYPTED PASSWORD '%v' NOCREATEDB;`, username, password)) - if err != nil { - return formatInvalidGrantError(err) - } - out.Printf("Created user: %v", username) - - _, err = db.ExecContext(ctx, fmt.Sprintf(`alter default privileges in schema public grant %v on tables to %q;`, fl.Privilege, username)) - if err != nil { - return formatInvalidGrantError(err) - } - - _, err = db.ExecContext(ctx, fmt.Sprintf(`grant %v on all tables in schema public to %q;`, fl.Privilege, username)) - if err != nil { - return formatInvalidGrantError(err) - } - - return nil -} - -func DropUser(ctx context.Context, appName, team, environment, username string, fl *flag.UserDrop, out *naistrix.OutputWriter) error { - // Get secret values (access is logged for audit purposes) - sv, err := GetSecretValues(ctx, appName, team, environment, fl.Postgres, ReasonDropUser, out) - if err != nil { - return err - } - - dbInfo, err := NewDBInfo(ctx, appName, team, environment) - if err != nil { - return err - } - - dbInfo.SetSecretValues(sv) - - connectionInfo, err := dbInfo.DBConnection(ctx) - if err != nil { - return err - } - - db, err := sql.Open("cloudsqlpostgres", connectionInfo.ProxyConnectionString()) - if err != nil { - return err - } - - _, err = db.ExecContext(ctx, fmt.Sprintf(`drop role %q;`, username)) - if err != nil { - return formatInvalidGrantError(err) - } - out.Printf("User %v has been dropped", username) - - return nil -} - -func validateSQLVariables(variables ...string) error { - r, err := regexp.Compile("^([A-Za-z0-9-_]+)$") - if err != nil { - return err - } - - for _, v := range variables { - if match := r.MatchString(v); !match { - return fmt.Errorf("invalid sql argument: %v (only letters, numbers, - and _ are allowed)", v) - } - } - - return nil -} diff --git a/internal/postgres/list.go b/internal/postgres/list.go deleted file mode 100644 index 9d7fc978..00000000 --- a/internal/postgres/list.go +++ /dev/null @@ -1,173 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - "slices" - "sort" - - "github.com/nais/cli/internal/naisapi" - "github.com/nais/cli/internal/naisapi/gql" - "github.com/nais/naistrix/output" - "k8s.io/utils/ptr" -) - -const consoleBaseURL = "https://console.nav.cloud.nais.io" - -type Instance struct { - Name output.Link `json:"name"` - Type string `json:"type"` - Environment string `json:"environment"` - Version string `heading:"Version" json:"version"` - HighAvailability bool `heading:"HA" json:"high_availability"` - Audit *bool `json:"audit,omitempty"` - State State `json:"state"` -} - -type State string - -func (s State) String() string { - // PostgresBranch states - switch s { - case State(gql.PostgresBranchStateAvailable): - return "Available" - case State(gql.PostgresBranchStateProgressing): - return "Progressing" - case State(gql.PostgresBranchStateDegraded): - return "Degraded" - } - - // SqlInstance states - switch s { - case State(gql.SqlInstanceStateRunnable): - return "Runnable" - case State(gql.SqlInstanceStateStopped): - return "Stopped" - case State(gql.SqlInstanceStateSuspended): - return "Suspended" - case State(gql.SqlInstanceStatePendingCreate): - return "Pending Create" - case State(gql.SqlInstanceStatePendingDelete): - return "Pending Delete" - case State(gql.SqlInstanceStateMaintenance): - return "Maintenance" - case State(gql.SqlInstanceStateFailed): - return "Failed" - } - - return "Unknown" -} - -func GetTeamPostgresBranches(ctx context.Context, team string, environments []string, labelFilters []gql.LabelFilter) ([]Instance, error) { - _ = `# @genqlient - query GetTeamPostgresBranches($team: Slug!, $postgresFilter: PostgresBranchFilter, $sqlFilter: SqlInstanceFilter) { - team(slug: $team) { - postgresBranches(first: 1000, filter: $postgresFilter) { - nodes { - name - teamEnvironment { - environment { - name - } - } - postgres { - name - majorVersion - highAvailability - } - state - } - } - sqlInstances(first: 1000, filter: $sqlFilter) { - nodes { - name - teamEnvironment { - environment { - name - } - } - version - highAvailability - # @genqlient(pointer: true) - auditLog { - logUrl - } - state - } - } - } - } - ` - - client, err := naisapi.GraphqlClient(ctx) - if err != nil { - return nil, err - } - - postgresFilter := gql.PostgresBranchFilter{ - Environments: environments, - Labels: labelFilters, - } - sqlFilter := gql.SqlInstanceFilter{ - Labels: labelFilters, - } - - resp, err := gql.GetTeamPostgresBranches(ctx, client, team, new(postgresFilter), new(sqlFilter)) - if err != nil { - return nil, err - } - - return instancesFromTeam(resp.Team, team, environments), nil -} - -func instancesFromTeam(teamData gql.GetTeamPostgresBranchesTeam, team string, environments []string) []Instance { - var ret []Instance - - for _, p := range teamData.PostgresBranches.Nodes { - env := p.TeamEnvironment.Environment.Name - if len(environments) > 0 && !slices.Contains(environments, env) { - continue - } - - ret = append(ret, Instance{ - Name: output.Link{ - Name: p.Postgres.Name + "/" + p.Name, - URL: fmt.Sprintf("%s/team/%s/%s/postgres/%s", consoleBaseURL, team, env, p.Postgres.Name), - }, - Type: "PostgreSQL", - Environment: env, - Version: p.Postgres.MajorVersion, - HighAvailability: p.Postgres.HighAvailability, - State: State(p.State), - }) - } - - for _, s := range teamData.SqlInstances.Nodes { - env := s.TeamEnvironment.Environment.Name - if len(environments) > 0 && !slices.Contains(environments, env) { - continue - } - - ret = append(ret, Instance{ - Name: output.Link{ - Name: s.Name, - URL: fmt.Sprintf("%s/team/%s/%s/cloudsql/%s", consoleBaseURL, team, env, s.Name), - }, - Type: "Cloud SQL", - Environment: env, - Version: ptr.Deref(s.Version, ""), - HighAvailability: s.HighAvailability, - Audit: new(s.AuditLog != nil), - State: State(s.State), - }) - } - - sort.Slice(ret, func(i, j int) bool { - if ret[i].Name.Name == ret[j].Name.Name { - return ret[i].Environment < ret[j].Environment - } - return ret[i].Name.Name < ret[j].Name.Name - }) - - return ret -} diff --git a/internal/postgres/list_test.go b/internal/postgres/list_test.go deleted file mode 100644 index b9457e40..00000000 --- a/internal/postgres/list_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package postgres - -import ( - "encoding/json" - "reflect" - "testing" - - "github.com/nais/cli/internal/naisapi/gql" - "github.com/nais/naistrix/output" -) - -func TestInstancesFromTeam(t *testing.T) { - const teamData = `{ - "postgresBranches": {"nodes": [ - {"name":"main","teamEnvironment":{"environment":{"name":"dev"}},"postgres":{"name":"orders","majorVersion":"16","highAvailability":true},"state":"AVAILABLE"}, - {"name":"preview","teamEnvironment":{"environment":{"name":"prod"}},"postgres":{"name":"orders","majorVersion":"16","highAvailability":true},"state":"PROGRESSING"} - ]}, - "sqlInstances": {"nodes": [ - {"name":"legacy","teamEnvironment":{"environment":{"name":"dev"}},"version":"POSTGRES_14","highAvailability":false,"auditLog":{"logUrl":"https://example.test"},"state":"RUNNABLE"}, - {"name":"other","teamEnvironment":{"environment":{"name":"prod"}},"version":null,"highAvailability":true,"auditLog":null,"state":"STOPPED"} - ]} - }` - var data gql.GetTeamPostgresBranchesTeam - if err := json.Unmarshal([]byte(teamData), &data); err != nil { - t.Fatal(err) - } - - tests := []struct { - name string - environments []string - want []Instance - }{ - { - name: "merged and sorted", - want: []Instance{ - {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: new(true), State: State(gql.SqlInstanceStateRunnable)}, - {Name: output.Link{Name: "orders/main", URL: consoleBaseURL + "/team/my-team/dev/postgres/orders"}, Type: "PostgreSQL", Environment: "dev", Version: "16", HighAvailability: true, State: State(gql.PostgresBranchStateAvailable)}, - {Name: output.Link{Name: "orders/preview", URL: consoleBaseURL + "/team/my-team/prod/postgres/orders"}, Type: "PostgreSQL", Environment: "prod", Version: "16", HighAvailability: true, State: State(gql.PostgresBranchStateProgressing)}, - {Name: output.Link{Name: "other", URL: consoleBaseURL + "/team/my-team/prod/cloudsql/other"}, Type: "Cloud SQL", Environment: "prod", HighAvailability: true, Audit: new(false), State: State(gql.SqlInstanceStateStopped)}, - }, - }, - { - name: "environment filter applies to both providers", - environments: []string{"dev"}, - want: []Instance{ - {Name: output.Link{Name: "legacy", URL: consoleBaseURL + "/team/my-team/dev/cloudsql/legacy"}, Type: "Cloud SQL", Environment: "dev", Version: "POSTGRES_14", Audit: new(true), State: State(gql.SqlInstanceStateRunnable)}, - {Name: output.Link{Name: "orders/main", URL: consoleBaseURL + "/team/my-team/dev/postgres/orders"}, Type: "PostgreSQL", Environment: "dev", Version: "16", HighAvailability: true, State: State(gql.PostgresBranchStateAvailable)}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := instancesFromTeam(data, "my-team", tt.environments) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("instancesFromTeam() = %#v, want %#v", got, tt.want) - } - }) - } -} diff --git a/internal/postgres/migrate/config/config.go b/internal/postgres/migrate/config/config.go deleted file mode 100644 index 19cfbf47..00000000 --- a/internal/postgres/migrate/config/config.go +++ /dev/null @@ -1,209 +0,0 @@ -package config - -import ( - "context" - "errors" - "fmt" - "strconv" - - "github.com/nais/cli/internal/option" - nais_io_v1alpha1 "github.com/nais/liberator/pkg/apis/nais.io/v1alpha1" - "github.com/nais/liberator/pkg/namegen" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/validation" - ctrl "sigs.k8s.io/controller-runtime/pkg/client" -) - -type Config struct { - AppName string - Team string - Target InstanceConfig - Source InstanceConfig - cfgMap *corev1.ConfigMap -} - -type InstanceConfig struct { - InstanceName option.Option[string] - Tier option.Option[string] - DiskAutoresize option.Option[bool] - DiskSize option.Option[int] - Type option.Option[string] -} - -var ErrMissingSqlInstance = errors.New("ErrMissingSqlInstance") - -func (ic *InstanceConfig) String() string { - return fmt.Sprintf("Name: %v\nTier: %v\nDiskSize: %v\nType: %v\n", ic.InstanceName, ic.Tier, ic.DiskSize, ic.Type) -} - -func (ic *InstanceConfig) Resolve(ctx context.Context, client ctrl.Client, appName, team string) error { - app := &nais_io_v1alpha1.Application{} - err := client.Get(ctx, ctrl.ObjectKey{Namespace: team, Name: appName}, app) - if err != nil { - return err - } - - if app.Spec.GCP == nil || len(app.Spec.GCP.SqlInstances) == 0 { - return fmt.Errorf("no sql instances found in app spec, %w", ErrMissingSqlInstance) - } - - ic.InstanceName = ic.InstanceName.Or(func() string { - name := app.Spec.GCP.SqlInstances[0].Name - if len(name) == 0 { - name = app.GetName() - } - return name - }) - - ic.Tier = ic.Tier.OrMaybe(func() option.Option[string] { - tier := app.Spec.GCP.SqlInstances[0].Tier - if len(tier) == 0 { - return option.None[string]() - } - return option.Some(tier) - }) - - ic.DiskAutoresize = ic.DiskAutoresize.OrMaybe(func() option.Option[bool] { - autoresize := app.Spec.GCP.SqlInstances[0].DiskAutoresize - if autoresize { - return option.Some(true) - } - return option.None[bool]() - }) - - ic.DiskSize = ic.DiskSize.OrMaybe(func() option.Option[int] { - diskSize := app.Spec.GCP.SqlInstances[0].DiskSize - if diskSize == 0 { - return option.None[int]() - } - return option.Some(diskSize) - }) - - ic.Type = ic.Type.OrMaybe(func() option.Option[string] { - instanceType := app.Spec.GCP.SqlInstances[0].Type - if len(instanceType) == 0 { - return option.None[string]() - } - return option.Some(string(instanceType)) - }) - - return nil -} - -func makeKey(prefix, key string) string { - return fmt.Sprintf("%s_%s", prefix, key) -} - -func (ic *InstanceConfig) PopulateFromConfigMap(configMap *corev1.ConfigMap, prefix string) { - ic.InstanceName = option.Some(configMap.Data[makeKey(prefix, "INSTANCE_NAME")]) - ic.Tier = ic.Tier.OrMaybe(func() option.Option[string] { - configTier, ok := configMap.Data[makeKey(prefix, "INSTANCE_TIER")] - if !ok { - return option.None[string]() - } - return option.Some(configTier) - }) - ic.DiskAutoresize = ic.DiskAutoresize.OrMaybe(func() option.Option[bool] { - configAutoresize, ok := configMap.Data[makeKey(prefix, "INSTANCE_DISK_AUTORESIZE")] - if !ok { - return option.None[bool]() - } - - autoresize, err := strconv.ParseBool(configAutoresize) - if err != nil { - panic(fmt.Sprintf("BUG: converting %s disk autoresize: %v", prefix, err.Error())) - } - return option.Some(autoresize) - }) - ic.DiskSize = ic.DiskSize.OrMaybe(func() option.Option[int] { - configDiskSize, ok := configMap.Data[makeKey(prefix, "INSTANCE_DISKSIZE")] - if !ok { - return option.None[int]() - } - - diskSize, err := strconv.Atoi(configDiskSize) - if err != nil { - panic(fmt.Sprintf("BUG: converting %s disk size: %v", prefix, err.Error())) - } - return option.Some(diskSize) - }) - ic.Type = ic.Type.OrMaybe(func() option.Option[string] { - configType, ok := configMap.Data[makeKey(prefix, "INSTANCE_TYPE")] - if !ok { - return option.None[string]() - } - return option.Some(configType) - }) -} - -func (c *Config) MigrationName() string { - name := fmt.Sprintf("migration-%s-%s", c.AppName, c.Target.InstanceName) - maxlen := validation.DNS1123LabelMaxLength - - if len(name) > maxlen { - truncated, err := namegen.ShortName(name, maxlen) - if err != nil { - panic(fmt.Sprintf("BUG: generating migration name: %v", err.Error())) - } - return truncated - } - - return name -} - -func (c *Config) CreateConfigMap() *corev1.ConfigMap { - data := map[string]string{ - "APP_NAME": c.AppName, - "NAMESPACE": c.Team, - } - - c.Target.InstanceName.Do(dataBuilder[string](data, "TARGET_INSTANCE_NAME")) - c.Target.Tier.Do(dataBuilder[string](data, "TARGET_INSTANCE_TIER")) - c.Target.DiskAutoresize.Do(dataBuilder[bool](data, "TARGET_INSTANCE_DISK_AUTORESIZE")) - c.Target.DiskSize.Do(dataBuilder[int](data, "TARGET_INSTANCE_DISK_SIZE")) - c.Target.Type.Do(dataBuilder[string](data, "TARGET_INSTANCE_TYPE")) - - c.Source.InstanceName.Do(dataBuilder[string](data, "SOURCE_INSTANCE_NAME")) - c.Source.Tier.Do(dataBuilder[string](data, "SOURCE_INSTANCE_TIER")) - c.Source.DiskAutoresize.Do(dataBuilder[bool](data, "SOURCE_INSTANCE_DISK_AUTORESIZE")) - c.Source.DiskSize.Do(dataBuilder[int](data, "SOURCE_INSTANCE_DISKSIZE")) - c.Source.Type.Do(dataBuilder[string](data, "SOURCE_INSTANCE_TYPE")) - - c.cfgMap = &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: c.MigrationName(), - Namespace: c.Team, - Labels: map[string]string{ - "migrator.nais.io/migration-name": c.MigrationName(), - "migrator.nais.io/app-name": c.AppName, - "migrator.nais.io/target-instance-name": c.Target.InstanceName.String(), - }, - Annotations: map[string]string{ - "migrator.nais.io/created-by": "nais/cli", - }, - }, - Data: data, - } - return c.cfgMap -} - -func (c *Config) PopulateFromConfigMap(ctx context.Context, client ctrl.Client) (*corev1.ConfigMap, error) { - configMap := &corev1.ConfigMap{} - err := client.Get(ctx, ctrl.ObjectKey{Namespace: c.Team, Name: c.MigrationName()}, configMap) - if err != nil { - return nil, err - } - - c.Source.PopulateFromConfigMap(configMap, "SOURCE") - c.Target.PopulateFromConfigMap(configMap, "TARGET") - - c.cfgMap = configMap - return c.cfgMap, nil -} - -func dataBuilder[T any](data map[string]string, key string) func(T) { - return func(v T) { - data[key] = fmt.Sprintf("%v", v) - } -} diff --git a/internal/postgres/migrate/config/config_test.go b/internal/postgres/migrate/config/config_test.go deleted file mode 100644 index 1140c389..00000000 --- a/internal/postgres/migrate/config/config_test.go +++ /dev/null @@ -1,325 +0,0 @@ -package config_test - -import ( - "context" - "errors" - "strconv" - "testing" - - "github.com/nais/cli/internal/option" - "github.com/nais/cli/internal/postgres/migrate/config" - nais_io_v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" - nais_io_v1alpha1 "github.com/nais/liberator/pkg/apis/nais.io/v1alpha1" - liberatorscheme "github.com/nais/liberator/pkg/scheme" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - ctrl "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -const ( - appName = "myapp" - namespace = "mynamespace" - - initialInstanceName = "myinstance" - initialInstanceTier = "db-f1-micro" - initialDiskSize = 11 - initialAutoresize = true - initialInstanceType = nais_io_v1.CloudSqlInstanceTypePostgres15 -) - -type tableEntry struct { - InstanceName option.Option[string] - Tier option.Option[string] - DiskSize option.Option[int] - DiskAutoresize option.Option[bool] - Type option.Option[string] -} - -func (t tableEntry) Apply(iCfg *config.InstanceConfig) { - t.InstanceName.Do(func(v string) { - iCfg.InstanceName = option.Some(v) - }) - t.Tier.Do(func(v string) { - iCfg.Tier = option.Some(v) - }) - t.DiskSize.Do(func(v int) { - iCfg.DiskSize = option.Some(v) - }) - t.DiskAutoresize.Do(func(v bool) { - iCfg.DiskAutoresize = option.Some(v) - }) - t.Type.Do(func(v string) { - iCfg.Type = option.Some(v) - }) -} - -func fakeClient(t *testing.T, objects ...ctrl.Object) ctrl.Client { - t.Helper() - scheme, err := liberatorscheme.All() - if err != nil { - t.Fatalf("failed to create scheme: %v", err) - } - return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() -} - -func TestConfig(t *testing.T) { - ctx := context.Background() - - t.Run("instance config", func(t *testing.T) { - t.Run("resolve when app is not found", func(t *testing.T) { - iCfg := &config.InstanceConfig{} - client := fakeClient(t) - - t.Run("it returns an error", func(t *testing.T) { - err := iCfg.Resolve(ctx, client, appName, namespace) - if err == nil { - t.Error("expected error, got nil") - } - }) - }) - - t.Run("resolve when app is found", func(t *testing.T) { - t.Run("without sqlinstances", func(t *testing.T) { - client := fakeClient(t, &nais_io_v1alpha1.Application{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "nais.io/v1alpha1", - Kind: "Application", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: appName, - Namespace: namespace, - }, - Spec: nais_io_v1alpha1.ApplicationSpec{ - Image: "myimage", - }, - }) - - t.Run("returns correct error", func(t *testing.T) { - iCfg := &config.InstanceConfig{} - err := iCfg.Resolve(ctx, client, appName, namespace) - if !errors.Is(err, config.ErrMissingSqlInstance) { - t.Errorf("expected ErrMissingSqlInstance, got: %v", err) - } - }) - }) - - t.Run("with sqlinstances", func(t *testing.T) { - client := fakeClient(t, &nais_io_v1alpha1.Application{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "nais.io/v1alpha1", - Kind: "Application", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: appName, - Namespace: namespace, - }, - Spec: nais_io_v1alpha1.ApplicationSpec{ - Image: "myimage", - GCP: &nais_io_v1.GCP{ - SqlInstances: []nais_io_v1.CloudSqlInstance{ - { - Type: initialInstanceType, - Name: initialInstanceName, - Tier: initialInstanceTier, - DiskSize: initialDiskSize, - DiskAutoresize: initialAutoresize, - }, - }, - }, - }, - }) - initialEntry := tableEntry{ - InstanceName: option.Some(initialInstanceName), - Tier: option.Some(initialInstanceTier), - DiskSize: option.Some(initialDiskSize), - DiskAutoresize: option.Some(initialAutoresize), - Type: option.Some(string(initialInstanceType)), - } - passedEntry := tableEntry{ - InstanceName: option.Some("passedName"), - Tier: option.Some("passedTier"), - DiskSize: option.Some(999), - DiskAutoresize: option.Some(false), - Type: option.Some("passedType"), - } - for _, tc := range []struct { - description string - source tableEntry - target tableEntry - }{ - {description: "resolves config from app", source: tableEntry{}, target: initialEntry}, - {description: "resolves config from passed config", source: passedEntry, target: passedEntry}, - } { - t.Run(tc.description, func(t *testing.T) { - iCfg := &config.InstanceConfig{} - tc.source.Apply(iCfg) - err := iCfg.Resolve(ctx, client, appName, namespace) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if iCfg.InstanceName != tc.target.InstanceName { - t.Errorf("expected InstanceName %v, got %v", tc.target.InstanceName, iCfg.InstanceName) - } - if iCfg.Tier != tc.target.Tier { - t.Errorf("expected Tier %v, got %v", tc.target.Tier, iCfg.Tier) - } - if iCfg.DiskSize != tc.target.DiskSize { - t.Errorf("expected DiskSize %v, got %v", tc.target.DiskSize, iCfg.DiskSize) - } - if iCfg.DiskAutoresize != tc.target.DiskAutoresize { - t.Errorf("expected DiskAutoresize %v, got %v", tc.target.DiskAutoresize, iCfg.DiskAutoresize) - } - if iCfg.Type != tc.target.Type { - t.Errorf("expected Type %v, got %v", tc.target.Type, iCfg.Type) - } - }) - } - }) - - t.Run("with sqlinstance without optional values", func(t *testing.T) { - client := fakeClient(t, &nais_io_v1alpha1.Application{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "nais.io/v1alpha1", - Kind: "Application", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: appName, - Namespace: namespace, - }, - Spec: nais_io_v1alpha1.ApplicationSpec{ - Image: "myimage", - GCP: &nais_io_v1.GCP{ - SqlInstances: []nais_io_v1.CloudSqlInstance{ - { - Type: initialInstanceType, - }, - }, - }, - }, - }) - initialEntry := tableEntry{ - InstanceName: option.Some(appName), - Tier: option.None[string](), - DiskSize: option.None[int](), - DiskAutoresize: option.None[bool](), - Type: option.Some(string(initialInstanceType)), - } - passedEntry := tableEntry{ - InstanceName: option.Some("passedName"), - Tier: option.Some("passedTier"), - DiskSize: option.Some(999), - DiskAutoresize: option.Some(false), - Type: option.Some("passedType"), - } - for _, tc := range []struct { - description string - source tableEntry - target tableEntry - }{ - {description: "resolve config from app", source: tableEntry{}, target: initialEntry}, - {description: "resolves config from passed config", source: passedEntry, target: passedEntry}, - } { - t.Run(tc.description, func(t *testing.T) { - iCfg := &config.InstanceConfig{} - tc.source.Apply(iCfg) - err := iCfg.Resolve(ctx, client, appName, namespace) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if iCfg.InstanceName != tc.target.InstanceName { - t.Errorf("expected InstanceName %v, got %v", tc.target.InstanceName, iCfg.InstanceName) - } - if iCfg.Tier != tc.target.Tier { - t.Errorf("expected Tier %v, got %v", tc.target.Tier, iCfg.Tier) - } - if iCfg.DiskSize != tc.target.DiskSize { - t.Errorf("expected DiskSize %v, got %v", tc.target.DiskSize, iCfg.DiskSize) - } - if iCfg.DiskAutoresize != tc.target.DiskAutoresize { - t.Errorf("expected DiskAutoresize %v, got %v", tc.target.DiskAutoresize, iCfg.DiskAutoresize) - } - if iCfg.Type != tc.target.Type { - t.Errorf("expected Type %v, got %v", tc.target.Type, iCfg.Type) - } - }) - } - }) - }) - - t.Run("populate from config map", func(t *testing.T) { - t.Run("it populates the instance config", func(t *testing.T) { - iCfg := &config.InstanceConfig{} - configMap := &corev1.ConfigMap{ - Data: map[string]string{ - "PREFIX_INSTANCE_NAME": initialInstanceName, - "PREFIX_INSTANCE_TIER": initialInstanceTier, - "PREFIX_INSTANCE_DISKSIZE": strconv.Itoa(initialDiskSize), - "PREFIX_INSTANCE_DISK_AUTORESIZE": strconv.FormatBool(initialAutoresize), - "PREFIX_INSTANCE_TYPE": string(initialInstanceType), - }, - } - iCfg.PopulateFromConfigMap(configMap, "PREFIX") - if iCfg.InstanceName != option.Some(initialInstanceName) { - t.Errorf("expected InstanceName %v, got %v", initialInstanceName, iCfg.InstanceName) - } - if iCfg.Tier != option.Some(initialInstanceTier) { - t.Errorf("expected Tier %v, got %v", initialInstanceTier, iCfg.Tier) - } - if iCfg.DiskSize != option.Some(initialDiskSize) { - t.Errorf("expected DiskSize %v, got %v", initialDiskSize, iCfg.DiskSize) - } - if iCfg.DiskAutoresize != option.Some(initialAutoresize) { - t.Errorf("expected DiskAutoresize %v, got %v", initialAutoresize, iCfg.DiskAutoresize) - } - if iCfg.Type != option.Some(string(initialInstanceType)) { - t.Errorf("expected Type %v, got %v", initialInstanceType, iCfg.Type) - } - }) - }) - }) - - t.Run("test Config", func(t *testing.T) { - t.Run("migration name", func(t *testing.T) { - getConfig := func() config.Config { - return config.Config{ - AppName: "some-app", - Team: "test-namespace", - Target: config.InstanceConfig{ - InstanceName: option.Some("target-instance"), - }, - } - } - - t.Run("generates valid migration name", func(t *testing.T) { - verify := func(t *testing.T, cfg config.Config, expected string) { - t.Helper() - actual := cfg.MigrationName() - if len(actual) > 63 { - t.Errorf("expected length <= 63, got %d", len(actual)) - } - if actual != expected { - t.Errorf("expected %s, got %s", expected, actual) - } - } - - t.Run("happy path with reasonable lengths for app and instance", func(t *testing.T) { - cfg := getConfig() - verify(t, cfg, "migration-some-app-target-instance") - }) - - t.Run("very long app name", func(t *testing.T) { - cfg := getConfig() - cfg.AppName = "some-unnecessarily-long-app-name-that-should-be-truncated" - verify(t, cfg, "migration-some-unnecessarily-long-app-name-that-should-377bba1c") - }) - - t.Run("very long instance name", func(t *testing.T) { - cfg := getConfig() - cfg.Target.InstanceName = option.Some("some-unnecessarily-long-instance-name-that-should-be-truncated") - verify(t, cfg, "migration-some-app-some-unnecessarily-long-instance-na-59326cd8") - }) - }) - }) - }) -} diff --git a/internal/postgres/migrate/finalize.go b/internal/postgres/migrate/finalize.go deleted file mode 100644 index 50709aaf..00000000 --- a/internal/postgres/migrate/finalize.go +++ /dev/null @@ -1,51 +0,0 @@ -package migrate - -import ( - "context" - - "github.com/pterm/pterm" - "github.com/pterm/pterm/putils" -) - -func (m *Migrator) Finalize(ctx context.Context) error { - cfgMap, err := m.cfg.PopulateFromConfigMap(ctx, m.client) - if err != nil { - return err - } - - m.printConfig() - pterm.Warning.Print(`This will delete the old database instance. Rollback after this point is not possible. -Only proceed if you are sure that the migration was successful and that your application is working as expected. -`) - - err = confirmContinue() - if err != nil { - return err - } - - jobName, err := m.doNaisJob(ctx, cfgMap, CommandFinalize) - if err != nil { - return err - } - - printWaitingForJobHeader() - err = m.waitForJobCompletion(ctx, jobName, CommandFinalize) - if err != nil { - return err - } - - err = m.deleteMigrationConfig(ctx, cfgMap) - if err != nil { - return err - } - - pterm.Println() - pterm.DefaultHeader.Println("Finalize has completed successfully") - pterm.Println() - pterm.Println("The old instance has been deleted and the migration is complete.") - pterm.Println() - _ = pterm.DefaultBigText.WithLetters(putils.LettersFromString("Congrats!")).Render() - pterm.Println("You are all done! 🎉") - - return nil -} diff --git a/internal/postgres/migrate/finalize/command.go b/internal/postgres/migrate/finalize/command.go deleted file mode 100644 index 8ee95394..00000000 --- a/internal/postgres/migrate/finalize/command.go +++ /dev/null @@ -1,34 +0,0 @@ -package finalize - -import ( - "context" - "fmt" - - "github.com/nais/cli/internal/k8s" - "github.com/nais/cli/internal/option" - "github.com/nais/cli/internal/postgres/migrate" - "github.com/nais/cli/internal/postgres/migrate/config" -) - -func Run(ctx context.Context, applicationName, targetInstanceName, team, environment string, dryRun bool) error { - cfg := config.Config{ - AppName: applicationName, - Target: config.InstanceConfig{ - InstanceName: option.Some(targetInstanceName), - }, - } - - client := k8s.SetupControllerRuntimeClient(k8s.WithKubeContext(environment)) - cfg.Team = team - clientSet, err := k8s.SetupClientGo(environment) - if err != nil { - return err - } - - migrator := migrate.NewMigrator(client, clientSet, cfg, dryRun, false) - if err := migrator.Finalize(ctx); err != nil { - return fmt.Errorf("error cleaning up instance: %w", err) - } - - return nil -} diff --git a/internal/postgres/migrate/migrate.go b/internal/postgres/migrate/migrate.go deleted file mode 100644 index 58618294..00000000 --- a/internal/postgres/migrate/migrate.go +++ /dev/null @@ -1,642 +0,0 @@ -package migrate - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "reflect" - "strconv" - "strings" - "time" - - "github.com/nais/cli/internal/postgres/migrate/config" - nais_io_v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" - "github.com/nais/liberator/pkg/namegen" - "github.com/pterm/pterm" - "github.com/sethvargo/go-retry" - "golang.org/x/sync/errgroup" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" - ctrl "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" -) - -type Command string - -// maxJobNameLength is the maximum length for a Kubernetes CronJob name -const maxJobNameLength = 52 - -func (c Command) JobName(cfg config.Config) string { - base := cfg.MigrationName() - suffix := string(c) - name := fmt.Sprintf("%s-%s", base, suffix) - maxlen := maxJobNameLength - - if len(name) > maxlen { - truncated, err := namegen.SuffixedShortName(base, suffix, maxlen) - if err != nil { - panic(fmt.Sprintf("BUG: generating job name: %v", err.Error())) - } - return truncated - } - - return name -} - -const ( - CommandFinalize Command = "finalize" - CommandPromote Command = "promote" - CommandRollback Command = "rollback" - CommandSetup Command = "setup" -) - -const MigratorImage = "europe-north1-docker.pkg.dev/nais-io/nais/images/cloudsql-migrator" - -type logEntry struct { - Msg string `json:"msg"` - Level string `json:"level"` - MigrationStep int `json:"migrationStep"` - MigrationStepsTotal int `json:"migrationStepsTotal"` - extra map[string]any -} - -var irrelevantExtraLogEntryKeys = []string{ - "msg", - "time", - "level", - "source", - "migrationApp", - "migrationTarget", - "migrationPhase", - "migrationStep", - "migrationStepsTotal", - "config", -} - -type Migrator struct { - client ctrl.Client - clientset kubernetes.Interface - cfg config.Config - dryRun bool - wait bool -} - -func NewMigrator(client ctrl.Client, clientset kubernetes.Interface, cfg config.Config, dryRun bool, noWait bool) *Migrator { - return &Migrator{ - client: client, - clientset: clientset, - cfg: cfg, - dryRun: dryRun, - wait: !noWait, - } -} - -func (m *Migrator) Create(ctx context.Context, obj ctrl.Object) error { - if m.dryRun { - v := reflect.Indirect(reflect.ValueOf(obj)) - pterm.Printf("Dry run: Skipping creation of %s: %s\n", v.Type().Name(), obj.GetName()) - return nil - } - return m.client.Create(ctx, obj) -} - -func (m *Migrator) Delete(ctx context.Context, obj ctrl.Object) error { - if m.dryRun { - v := reflect.Indirect(reflect.ValueOf(obj)) - pterm.Printf("Dry run: Skipping deletion of %s: %s\n", v.Type().Name(), obj.GetName()) - return nil - } - - opts := []ctrl.DeleteOption{ - ctrl.PropagationPolicy(metav1.DeletePropagationForeground), - } - return m.client.Delete(ctx, obj, opts...) -} - -func (m *Migrator) doNaisJob(ctx context.Context, cfgMap *corev1.ConfigMap, command Command) (string, error) { - imageTag, err := getLatestImageTag() - if err != nil { - return "", fmt.Errorf("failed to get latest image tag for cloudsql-migrator: %w", err) - } - - job := makeNaisjob(m.cfg, imageTag, command) - err = createObject(ctx, m, cfgMap, job, command) - if err != nil { - return "", err - } - - return job.Name, nil -} - -func (m *Migrator) kubectlLabelSelector(command Command) string { - return fmt.Sprintf("migrator.nais.io/migration-name=%s,migrator.nais.io/command=%s", m.cfg.MigrationName(), command) -} - -func (m *Migrator) deleteMigrationConfig(ctx context.Context, cfgMap *corev1.ConfigMap) error { - err := ctrl.IgnoreNotFound(m.Delete(ctx, cfgMap)) - if err != nil { - return fmt.Errorf("failed to delete ConfigMap: %w", err) - } - - return nil -} - -func (m *Migrator) LookupGcpProjectId(ctx context.Context) (string, error) { - ns := &corev1.Namespace{} - err := m.client.Get(ctx, ctrl.ObjectKey{Name: m.cfg.Team}, ns) - if err != nil { - return "", fmt.Errorf("failed to get namespace: %w", err) - } - if gcpProjectId, ok := ns.Annotations["cnrm.cloud.google.com/project-id"]; ok { - return gcpProjectId, nil - } - return "", fmt.Errorf("namespace %s does not have a GCP project ID annotation", m.cfg.Team) -} - -func (m *Migrator) getJobLogs(ctx context.Context, command Command, jobName string, logChannel chan<- string) error { - defer close(logChannel) - - if m.dryRun { - send := func(entry logEntry) { - entry.Msg = fmt.Sprintf("Dry run: %s", entry.Msg) - b, _ := json.Marshal(entry) - logChannel <- string(b) - time.Sleep(500 * time.Millisecond) - } - send(logEntry{Msg: fmt.Sprintf("Starting %s", command), Level: "info", MigrationStep: 1, MigrationStepsTotal: 3}) - send(logEntry{Msg: "Running", Level: "info", MigrationStep: 2}) - send(logEntry{Msg: "Simulating log output", Level: "info"}) - send(logEntry{Msg: "Simulating more log output", Level: "warn"}) - send(logEntry{Msg: "Simulating even more log output", Level: "error"}) - send(logEntry{Msg: "Job completed", Level: "info"}) - send(logEntry{Msg: "Finished", Level: "info", MigrationStep: 3}) - return nil - } - - seenPods := make(map[string]bool) - - for ctx.Err() == nil { - pod, err := m.findLatestPod(ctx, command) - if err != nil { - return fmt.Errorf("error finding pod: %w", err) - } - - switch { - case pod == nil: - // No pod found; wait for it to be created. - time.Sleep(1 * time.Second) - continue - case pod.Status.Phase == corev1.PodSucceeded: - // Pod (and thus Job) has completed successfully. - logChannel <- `{"msg": ">>> Pod succeeded", "level": "info", "pod": "` + pod.Name + `"}` - return nil - case pod.Status.Phase != corev1.PodRunning: - // Pod is not running yet; wait for it to start. - logChannel <- `{"msg": ">>> Pod not running yet, waiting...", "level": "info", "pod": "` + pod.Name + `", "phase": "` + string(pod.Status.Phase) + `"}` - time.Sleep(1 * time.Second) - continue - case seenPods[pod.Name]: - // We've already printed logs for this pod; wait for a new pod to be created. - time.Sleep(1 * time.Second) - continue - } - - logs, err := m.clientset.CoreV1().Pods(m.cfg.Team).GetLogs(pod.Name, &corev1.PodLogOptions{ - Container: jobName, - Follow: true, - }).Stream(ctx) - if err != nil { - return fmt.Errorf("error getting job logs: %w", err) - } - - logChannel <- `{"msg": ">>> Log stream started", "level": "info", "pod": "` + pod.Name + `"}` - scanner := bufio.NewScanner(logs) - for scanner.Scan() { - logChannel <- scanner.Text() - } - _ = logs.Close() - logChannel <- `{"msg": ">>> Log stream ended", "level": "info", "pod": "` + pod.Name + `"}` - - // The stream ended, which likely means the pod either exited (whether successful or not) or was deleted. - // Mark the pod as seen to avoid printing its logs again. - seenPods[pod.Name] = true - - err = scanner.Err() - if err != nil { - return fmt.Errorf("error reading job logs: %w", err) - } - } - return nil -} - -// findLatestPod returns the latest pod for the given command. If no pods are found, nil is returned. -func (m *Migrator) findLatestPod(ctx context.Context, command Command) (*corev1.Pod, error) { - pods, err := m.clientset.CoreV1().Pods(m.cfg.Team).List(ctx, metav1.ListOptions{ - LabelSelector: m.kubectlLabelSelector(command), - }) - if err != nil { - return nil, fmt.Errorf("listing pods: %w", err) - } - - var latest *corev1.Pod - latestTime := metav1.Time{} - - for _, pod := range pods.Items { - if pod.GetCreationTimestamp().After(latestTime.Time) { - latest = &pod - latestTime = pod.GetCreationTimestamp() - } - } - - return latest, nil -} - -func (m *Migrator) waitForJobCompletion(ctx context.Context, jobName string, command Command) error { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - logChannel := make(chan string) - - // ctx is now canceled if any goroutine within the errgroup returns an error, or all of them complete successfully. - eg, ctx := errgroup.WithContext(ctx) - eg.Go(func() error { - return m.getJobLogs(ctx, command, jobName, logChannel) - }) - - startingMessage, err := m.waitForStartingMessage(ctx, logChannel) - if err != nil { - return err - } - - logOutput := pterm.DefaultLogger.WithMaxWidth(120) - logOutput.Info(startingMessage.Msg) - - progress, _ := pterm.DefaultProgressbar.WithTotal(startingMessage.MigrationStepsTotal).WithMaxWidth(120).Start() - defer func() { - _, _ = progress.Stop() - }() - - // this runs outside the errgroup as it does not return an error - go renderJobLogs(ctx, logChannel, logOutput, progress) - - if m.dryRun { - logOutput.Info(fmt.Sprintf("Dry run: Artificial waiting for job %s/%s to complete, 5 seconds\n", m.cfg.Team, jobName)) - time.Sleep(5 * time.Second) - return nil - } - - eg.Go(func() error { - return m.pollJobCompletion(ctx, jobName, command) - }) - - if err := eg.Wait(); err != nil { - if errors.Is(err, context.Canceled) { - err = context.Cause(ctx) - } - logOutput.Error(err.Error()) - return fmt.Errorf("error waiting for job completion: %w", err) - } - - return nil -} - -func (m *Migrator) waitForStartingMessage(ctx context.Context, logChannel <-chan string) (*logEntry, error) { - spinner, _ := pterm.DefaultSpinner.Start("Waiting for job to start ...") - defer func() { - _ = spinner.Stop() - }() - - for { - select { - case <-ctx.Done(): - spinner.Fail() - err := context.Cause(ctx) - pterm.Error.Println(err) - return nil, err - case line := <-logChannel: - l, err := parseLogLine(line) - if err != nil { - spinner.Fail() - pterm.Error.Println(err) - return nil, err - } - - if l.MigrationStepsTotal > 0 { - return &l, nil - } - } - } -} - -func (m *Migrator) pollJobCompletion(ctx context.Context, jobName string, command Command) error { - listOptions := []ctrl.ListOption{ - ctrl.InNamespace(m.cfg.Team), - ctrl.MatchingLabels{ - "migrator.nais.io/migration-name": m.cfg.MigrationName(), - "migrator.nais.io/command": string(command), - }, - } - - b := retry.NewConstant(10 * time.Second) - return retry.Do(ctx, b, func(ctx context.Context) error { - jobs := &batchv1.JobList{} - err := m.client.List(ctx, jobs, listOptions...) - if err != nil { - return retry.RetryableError(err) - } - if len(jobs.Items) < 1 { - return retry.RetryableError(fmt.Errorf("no jobs found")) - } - if len(jobs.Items) > 1 { - return fmt.Errorf("multiple jobs found %s/%s, contact nais team", m.cfg.Team, jobName) - } - for _, job := range jobs.Items { - if job.Status.Succeeded == 1 { - return nil - } - } - return retry.RetryableError(fmt.Errorf("job %s/%s has not completed yet", m.cfg.Team, jobName)) - }) -} - -func (m *Migrator) printConfig() { - pterm.DefaultSection.Println("Migration configuration") - pterm.Printfln("Application: %s", m.cfg.AppName) - pterm.Printfln("Namespace: %s", m.cfg.Team) - pterm.DefaultSection.Println("Instance configuration") - sourceDiskSize := "" - m.cfg.Source.DiskSize.Do(func(diskSize int) { - sourceDiskSize = fmt.Sprintf("%d GB", diskSize) - }) - targetDiskSize := "" - m.cfg.Target.DiskSize.Do(func(diskSize int) { - targetDiskSize = fmt.Sprintf("%d GB", diskSize) - }) - sourceAutoresize := "" - m.cfg.Source.DiskAutoresize.Do(func(autoresize bool) { - if autoresize { - sourceAutoresize = "enabled" - } else { - sourceAutoresize = "disabled" - } - }) - targetAutoresize := "" - m.cfg.Target.DiskAutoresize.Do(func(autoresize bool) { - if autoresize { - targetAutoresize = "enabled" - } else { - targetAutoresize = "disabled" - } - }) - - tableHeaderStyle := pterm.ThemeDefault.TableHeaderStyle - _ = pterm.DefaultTable.WithHasHeader().WithData(pterm.TableData{ - {"", "Name", "Tier", "Disk autoresize", "Disk size", "Type"}, - {tableHeaderStyle.Sprint("Source"), m.cfg.Source.InstanceName.String(), m.cfg.Source.Tier.String(), sourceAutoresize, sourceDiskSize, m.cfg.Source.Type.String()}, - {tableHeaderStyle.Sprint("Target"), m.cfg.Target.InstanceName.String(), m.cfg.Target.Tier.String(), targetAutoresize, targetDiskSize, m.cfg.Target.Type.String()}, - }).Render() -} - -func createObject[T interface { - ctrl.Object - *P -}, P any](ctx context.Context, m *Migrator, owner metav1.Object, obj T, Command Command) error { - err := controllerutil.SetOwnerReference(owner, obj, m.client.Scheme(), controllerutil.WithBlockOwnerDeletion(true)) - if err != nil { - return fmt.Errorf("failed to set owner reference: %w", err) - } - - labels := obj.GetLabels() - if labels == nil { - labels = make(map[string]string) - } - labels["migrator.nais.io/migration-name"] = m.cfg.MigrationName() - labels["migrator.nais.io/app-name"] = m.cfg.AppName - labels["migrator.nais.io/target-instance-name"] = m.cfg.Target.InstanceName.String() - labels["migrator.nais.io/command"] = string(Command) - obj.SetLabels(labels) - - err = m.Create(ctx, obj) - if err != nil { - return fmt.Errorf("failed to create Object: %w", err) - } - return nil -} - -// makeRoleBinding binds the migrator job ServiceAccounts to the nais:developer -// ClusterRole, granting them the same platform permissions developers have: -// applications, sqlinstances, deployments/scale, networkpolicies, etc. -func makeRoleBinding(cfg config.Config) *rbacv1.RoleBinding { - return &rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: cfg.MigrationName(), - Namespace: cfg.Team, - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: CommandSetup.JobName(cfg), - }, - { - Kind: "ServiceAccount", - Name: CommandPromote.JobName(cfg), - }, - { - Kind: "ServiceAccount", - Name: CommandFinalize.JobName(cfg), - }, - { - Kind: "ServiceAccount", - Name: CommandRollback.JobName(cfg), - }, - }, - RoleRef: rbacv1.RoleRef{ - Kind: "ClusterRole", - Name: "nais:developer", - APIGroup: "rbac.authorization.k8s.io", - }, - } -} - -func makeNaisjob(cfg config.Config, imageTag string, command Command) *nais_io_v1.Naisjob { - return &nais_io_v1.Naisjob{ - ObjectMeta: metav1.ObjectMeta{ - Name: command.JobName(cfg), - Namespace: cfg.Team, - Labels: map[string]string{ - "apiserver-access": "enabled", - }, - }, - Spec: nais_io_v1.NaisjobSpec{ - Command: []string{"/" + string(command)}, - Env: nais_io_v1.EnvVars{ - { - Name: "LOG_FORMAT", - Value: "JSON", - }, - }, - EnvFrom: []nais_io_v1.EnvFrom{{ - ConfigMap: cfg.MigrationName(), - }}, - GCP: &nais_io_v1.GCP{ - Permissions: []nais_io_v1.CloudIAMPermission{ - { - Role: "roles/cloudsql.admin", - Resource: nais_io_v1.CloudIAMResource{ - APIVersion: "resourcemanager.cnrm.cloud.google.com/v1beta1", - Kind: "Project", - }, - }, { - Role: "roles/datamigration.admin", - Resource: nais_io_v1.CloudIAMResource{ - APIVersion: "resourcemanager.cnrm.cloud.google.com/v1beta1", - Kind: "Project", - }, - }, { - Role: "roles/monitoring.viewer", - Resource: nais_io_v1.CloudIAMResource{ - APIVersion: "resourcemanager.cnrm.cloud.google.com/v1beta1", - Kind: "Project", - }, - }, - }, - }, - Image: fmt.Sprintf("%s:%s", MigratorImage, imageTag), - }, - } -} - -func getLatestImageTag() (string, error) { - resp, err := http.Get("https://api.github.com/repos/nais/cloudsql-migrator/releases/latest") - if err != nil { - return "", err - } - defer func() { _ = resp.Body.Close() }() - - switch resp.StatusCode { - case http.StatusTooManyRequests: - fallthrough - case http.StatusForbidden: - retryTime, err := calculateRetryTime(resp) - if err != nil { - return "", fmt.Errorf("rate limit error when attempting to query GitHub for latest migrator image. Additionally, an error occurred when attempting to find a suitable retry time: %w", err) - } - return "", fmt.Errorf("rate limit exceeded when attempting to query GitHub for latest migrator image, retry after %s", retryTime.Format(time.RFC1123)) - case http.StatusOK: - // do nothing - default: - return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - decoder := json.NewDecoder(resp.Body) - v := map[string]any{} - err = decoder.Decode(&v) - if err != nil { - return "", err - } - - return v["tag_name"].(string), nil -} - -// calculateRetryTime calculates when it is ok to retry a request based on the available headers. -// See more in GitHub API documentation: -// https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api?apiVersion=2022-11-28#rate-limit-errors -func calculateRetryTime(resp *http.Response) (time.Time, error) { - retryAfter := resp.Header.Get("retry-after") - if retryAfter != "" { - retryAfterSeconds, err := strconv.Atoi(retryAfter) - if err != nil { - return time.Time{}, fmt.Errorf("failed to parse retry-after header: %w", err) - } - return time.Now().Add(time.Duration(retryAfterSeconds) * time.Second), nil - } else if resp.Header.Get("x-ratelimit-remaining") == "0" { - rateLimitReset := resp.Header.Get("x-ratelimit-reset") - retryEpoch, err := strconv.Atoi(rateLimitReset) - if err != nil { - return time.Time{}, fmt.Errorf("failed to parse rate limit reset epoch: %w", err) - } - return time.Unix(int64(retryEpoch), 0), nil - } - return time.Now().Add(1 * time.Minute), nil -} - -func confirmContinue() error { - pterm.Println() - result, _ := pterm.DefaultInteractiveConfirm.Show("Are you sure you want to continue?") - pterm.Println() - - if !result { - return fmt.Errorf("cancelled by user") - } - - return nil -} - -func printWaitingForJobHeader() { - pterm.Println("Several of the operations done by the migrator are eventually consistent, and may fail a few times before succeeding.") - pterm.Println("This leads to some log messages about errors or failures, but the operations will typically be retried and eventually succeed.") - pterm.Println("If there is an unrecoverable error, the migrator will exit with an error message.") -} - -func parseLogLine(line string) (logEntry, error) { - var le logEntry - err := json.Unmarshal([]byte(line), &le) - if err != nil { - return logEntry{}, err - } - - // pick up additional log fields that are not part of the logEntry struct - extra := make(map[string]any) - // this error should be caught above in previous Unmarshal - _ = json.Unmarshal([]byte(line), &extra) - - for _, key := range irrelevantExtraLogEntryKeys { - delete(extra, key) - } - - le.extra = extra - return le, nil -} - -func renderJobLogs(ctx context.Context, logChannel <-chan string, logOutput *pterm.Logger, progress *pterm.ProgressbarPrinter) { - lastMsg := "" - for { - select { - case <-ctx.Done(): - return - case line := <-logChannel: - le, err := parseLogLine(line) - if err != nil { - logOutput.Debug(fmt.Sprintf("failed to unmarshal log entry: %s (was %q); ignoring...", err, line)) - continue - } - - if le.MigrationStep > 0 { - progress.Current = le.MigrationStep - progress.UpdateTitle(le.Msg) - continue - } - - if lastMsg != le.Msg { - args := logOutput.ArgsFromMap(le.extra) - switch strings.ToLower(le.Level) { - case "error": - logOutput.Error(le.Msg, args) - case "warn": - logOutput.Warn(le.Msg, args) - case "info": - logOutput.Info(le.Msg, args) - default: - logOutput.Print(le.Msg, args) - } - } - lastMsg = le.Msg - } - } -} diff --git a/internal/postgres/migrate/migrate_test.go b/internal/postgres/migrate/migrate_test.go deleted file mode 100644 index 4c06127d..00000000 --- a/internal/postgres/migrate/migrate_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package migrate - -import ( - "testing" - - "github.com/nais/cli/internal/option" - "github.com/nais/cli/internal/postgres/migrate/config" -) - -func TestCommand(t *testing.T) { - tests := map[string]struct { - mutateFn func(cfg *config.Config) - expected string - }{ - "happy path with reasonable lengths for app and instance": { - mutateFn: func(cfg *config.Config) {}, - expected: "migration-some-app-target-instance-setup", - }, - "very long app name": { - mutateFn: func(cfg *config.Config) { - cfg.AppName = "some-unnecessarily-long-app-name-that-should-be-truncated" - }, - expected: "migration-some-unnecessarily-long-app-eb4938d8-setup", - }, - "very long instance name": { - mutateFn: func(cfg *config.Config) { - cfg.Target.InstanceName = option.Some("some-unnecessarily-long-instance-name-that-should-be-truncated") - }, - expected: "migration-some-app-some-unnecessarily-63093bcb-setup", - }, - } - - const cmd = CommandSetup - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - cfg := config.Config{ - AppName: "some-app", - Team: "test-namespace", - Target: config.InstanceConfig{ - InstanceName: option.Some("target-instance"), - }, - } - tc.mutateFn(&cfg) - - actual := cmd.JobName(cfg) - if len(actual) > maxJobNameLength { - t.Errorf("job name exceeds 52 characters: %s", actual) - } - if actual != tc.expected { - t.Errorf("expected job name %q, got %q", tc.expected, actual) - } - }) - } -} diff --git a/internal/postgres/migrate/promote.go b/internal/postgres/migrate/promote.go deleted file mode 100644 index 342ee05a..00000000 --- a/internal/postgres/migrate/promote.go +++ /dev/null @@ -1,83 +0,0 @@ -package migrate - -import ( - "context" - "fmt" - - "github.com/nais/cli/internal/postgres/migrate/ui" - "github.com/pterm/pterm" -) - -func (m *Migrator) Promote(ctx context.Context) error { - cfgMap, err := m.cfg.PopulateFromConfigMap(ctx, m.client) - if err != nil { - return err - } - - m.printConfig() - pterm.Warning.Print(`Your application will not be able to reach the database during promotion. -The database will be unavailable for a short period of time while the promotion is in progress. -`) - - err = confirmContinue() - if err != nil { - return err - } - - jobName, err := m.doNaisJob(ctx, cfgMap, CommandPromote) - if err != nil { - return err - } - - label := m.kubectlLabelSelector(CommandPromote) - - if m.wait { - printWaitingForJobHeader() - err = m.waitForJobCompletion(ctx, jobName, CommandPromote) - if err != nil { - return err - } - - pterm.Println() - pterm.DefaultHeader.Println("Promotion completed successfully") - pterm.Println() - pterm.Println("Promotion is complete, your application should be up and running with the new database instance.") - } else { - pterm.Println() - pterm.DefaultHeader.Println("Promotion has been started successfully") - pterm.Println() - pterm.Println("To monitor the migration, run the following command:") - ui.CmdStyle.Printfln("\tkubectl logs -f -l %s", label) - pterm.Println() - pterm.Println("The promote will take some time to complete, you can check completion status with the following command:") - ui.CmdStyle.Printfln("\tkubectl get job %s", jobName) - pterm.Println() - pterm.Println("When promotion is complete, your application should be up and running with the new database instance.") - } - - pterm.Println() - pterm.Info.Println(`At this point it is important to verify that your application works as expected, and that all data is present. -It is now possible to deploy changes to your application, but you must update the manifest to use the new database instance before doing so (see below). -Once you are satisfied that everything works as expected, you must perform the final finalize step:`) - ui.CmdStyle.Printfln("\tnais postgres migrate finalize %s %s", m.cfg.AppName, m.cfg.Target.InstanceName) - pterm.Println() - pterm.Info.Println("Your next application deploy must update your manifests to use the new database instance:") - diskSizeLine := "" - m.cfg.Target.DiskSize.Do(func(diskSize int) { - diskSizeLine = fmt.Sprintf("diskSize: %d", diskSize) - }) - ui.YamlStyle.Printfln(` - ... - spec: - gcp: - sqlInstances: - - name: %s - type: %s - tier: %s - %s -`, m.cfg.Target.InstanceName, m.cfg.Target.Type, m.cfg.Target.Tier, diskSizeLine) - pterm.Println() - pterm.Println("If things are not working as expected, and you need to rollback to the previous database instance, you can run:") - ui.CmdStyle.Printfln("\tnais postgres migrate rollback %s %s", m.cfg.AppName, m.cfg.Target.InstanceName) - return nil -} diff --git a/internal/postgres/migrate/promote/command.go b/internal/postgres/migrate/promote/command.go deleted file mode 100644 index 3fcf1de0..00000000 --- a/internal/postgres/migrate/promote/command.go +++ /dev/null @@ -1,35 +0,0 @@ -package promote - -import ( - "context" - "fmt" - - "github.com/nais/cli/internal/k8s" - "github.com/nais/cli/internal/option" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/cli/internal/postgres/migrate" - "github.com/nais/cli/internal/postgres/migrate/config" -) - -func Run(ctx context.Context, applicationName, targetInstanceName, team, environment string, flags *flag.MigratePromote) error { - cfg := config.Config{ - AppName: applicationName, - Target: config.InstanceConfig{ - InstanceName: option.Some(targetInstanceName), - }, - } - - client := k8s.SetupControllerRuntimeClient(k8s.WithKubeContext(environment)) - cfg.Team = team - clientSet, err := k8s.SetupClientGo(environment) - if err != nil { - return err - } - - migrator := migrate.NewMigrator(client, clientSet, cfg, flags.DryRun, flags.NoWait) - if err := migrator.Promote(ctx); err != nil { - return fmt.Errorf("error promoting instance: %w", err) - } - - return nil -} diff --git a/internal/postgres/migrate/rollback.go b/internal/postgres/migrate/rollback.go deleted file mode 100644 index 3ea31e86..00000000 --- a/internal/postgres/migrate/rollback.go +++ /dev/null @@ -1,48 +0,0 @@ -package migrate - -import ( - "context" - - "github.com/pterm/pterm" -) - -func (m *Migrator) Rollback(ctx context.Context) error { - cfgMap, err := m.cfg.PopulateFromConfigMap(ctx, m.client) - if err != nil { - return err - } - - m.printConfig() - pterm.Warning.Println("This will roll back the migration, and restore the application to use the original instance.") - - err = confirmContinue() - if err != nil { - return err - } - - jobName, err := m.doNaisJob(ctx, cfgMap, CommandRollback) - if err != nil { - return err - } - - printWaitingForJobHeader() - err = m.waitForJobCompletion(ctx, jobName, CommandRollback) - if err != nil { - return err - } - - err = m.deleteMigrationConfig(ctx, cfgMap) - if err != nil { - return err - } - - pterm.Println() - pterm.DefaultHeader.Println("Rollback has completed successfully") - pterm.Println() - pterm.Println("Your application should be up and running with the original database instance.") - pterm.Println("The new instance has been deleted and the migration is stopped.") - pterm.Println() - pterm.Println("You are now free to start another attempt if you wish.") - - return nil -} diff --git a/internal/postgres/migrate/rollback/command.go b/internal/postgres/migrate/rollback/command.go deleted file mode 100644 index aad1ba25..00000000 --- a/internal/postgres/migrate/rollback/command.go +++ /dev/null @@ -1,35 +0,0 @@ -package rollback - -import ( - "context" - "fmt" - - "github.com/nais/cli/internal/k8s" - "github.com/nais/cli/internal/option" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/cli/internal/postgres/migrate" - "github.com/nais/cli/internal/postgres/migrate/config" -) - -func Run(ctx context.Context, applicationName, targetInstanceName, team, environment string, flags *flag.MigrateRollback) error { - cfg := config.Config{ - AppName: applicationName, - Target: config.InstanceConfig{ - InstanceName: option.Some(targetInstanceName), - }, - } - - client := k8s.SetupControllerRuntimeClient(k8s.WithKubeContext(environment)) - cfg.Team = team - clientset, err := k8s.SetupClientGo(environment) - if err != nil { - return err - } - - migrator := migrate.NewMigrator(client, clientset, cfg, flags.DryRun, false) - if err := migrator.Rollback(ctx); err != nil { - return fmt.Errorf("error rolling back instance: %w", err) - } - - return nil -} diff --git a/internal/postgres/migrate/setup.go b/internal/postgres/migrate/setup.go deleted file mode 100644 index 4d886727..00000000 --- a/internal/postgres/migrate/setup.go +++ /dev/null @@ -1,196 +0,0 @@ -package migrate - -import ( - "context" - "errors" - "fmt" - - "github.com/nais/cli/internal/option" - "github.com/nais/cli/internal/postgres/migrate/config" - "github.com/nais/cli/internal/postgres/migrate/ui" - "github.com/nais/liberator/pkg/namegen" - "github.com/pterm/pterm" - v1 "k8s.io/api/core/v1" - k8serrors "k8s.io/apimachinery/pkg/api/errors" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -func (m *Migrator) Setup(ctx context.Context) error { - cfgMapList := &v1.ConfigMapList{} - listOptions := []client.ListOption{ - client.InNamespace(m.cfg.Team), - client.MatchingLabels{"migrator.nais.io/app-name": m.cfg.AppName}, - } - err := m.client.List(ctx, cfgMapList, listOptions...) - if err != nil { - return err - } - - if len(cfgMapList.Items) > 0 { - return fmt.Errorf("migration config already exists for this application") - } - - err = m.cfg.Source.Resolve(ctx, m.client, m.cfg.AppName, m.cfg.Team) - if err != nil { - if k8serrors.IsNotFound(err) { - pterm.Println() - pterm.Error.Printfln("Application %s not found for team %s", m.cfg.AppName, m.cfg.Team) - pterm.Println() - pterm.Println("Make sure you have specified the correct team with the --team flag") - pterm.Println() - return fmt.Errorf("app %s not found for team %s", m.cfg.AppName, m.cfg.Team) - } else if errors.Is(err, config.ErrMissingSqlInstance) { - pterm.Println() - pterm.Error.Printfln("The Application %s does not have any SQL instances defined in the spec", m.cfg.AppName) - pterm.Println() - } - return err - } - - m.ConfigureTarget() - - err = m.cfg.Target.Resolve(ctx, m.client, m.cfg.AppName, m.cfg.Team) - if err != nil { - return err - } - - m.clearDiskSizeIfDiskAutoresizeEnabled() - - err = m.validateInstanceNames() - if err != nil { - return err - } - - m.printConfig() - pterm.Warning.Println("Do not make structural database changes during migration!\nDo not deploy the application unless instructed to do so by the tool!\nThis is not supported, and will cause problems!") - err = confirmContinue() - if err != nil { - return err - } - - gcpProjectId, err := m.LookupGcpProjectId(ctx) - if err != nil { - return fmt.Errorf("failed to lookup GCP project ID: %w", err) - } - - cfgMap := m.cfg.CreateConfigMap() - err = m.Create(ctx, cfgMap) - if err != nil { - return fmt.Errorf("failed to create ConfigMap: %w", err) - } - - roleBinding := makeRoleBinding(m.cfg) - err = createObject(ctx, m, cfgMap, roleBinding, CommandSetup) - if err != nil { - return err - } - - jobName, err := m.doNaisJob(ctx, cfgMap, CommandSetup) - if err != nil { - return err - } - - // Make sure this logic is in sync with the corresponding logic in cloudsql-migrator... - migrationJobName := fmt.Sprintf("%s-%s", m.cfg.Source.InstanceName, m.cfg.Target.InstanceName) - maxlen := 60 // Google limit for migration job names - if len(migrationJobName) > maxlen { - var err error - migrationJobName, err = namegen.ShortName(migrationJobName, maxlen) - if err != nil { - return fmt.Errorf("failed to shorten migration job name: %w", err) - } - } - - cloudConsoleUrl := fmt.Sprintf("https://console.cloud.google.com/dbmigration/migrations/locations/europe-north1/instances/%s?project=%s", migrationJobName, gcpProjectId) - label := m.kubectlLabelSelector(CommandSetup) - - if m.wait { - printWaitingForJobHeader() - err = m.waitForJobCompletion(ctx, jobName, CommandSetup) - if err != nil { - return err - } - pterm.Println() - pterm.DefaultHeader.Println("Migration setup completed successfully") - pterm.Println() - pterm.Println("Setup is now complete, a new instance has been created and replication of data has started.") - } else { - pterm.Println() - pterm.DefaultHeader.Println("Migration setup has been started successfully") - pterm.Println() - pterm.Println("To monitor the migration, run the following command:") - ui.CmdStyle.Printfln("\tkubectl logs -f -l %s", label) - pterm.Println() - pterm.Println("The setup will take some time to complete, you can check completion status with the following command:") - ui.CmdStyle.Printfln("\tkubectl get job %s", jobName) - pterm.Println() - pterm.Println("When setup is complete, a new instance has been created and replication of data has started.") - } - - helperName, err := helperAppName(m.cfg.AppName) - if err != nil { - return fmt.Errorf("failed to generate helper app name: %w", err) - } - - pterm.Println("You can check the replication progress in the Google Cloud Console:") - ui.LinkStyle.Printfln("\t%s", cloudConsoleUrl) - pterm.Println() - pterm.DefaultParagraph.Println("When the migration has status 'Running' and is in the 'CDC' or 'Ready to Promote' phase, everything is ready for the next step of the migration.") - pterm.DefaultParagraph.Println("If you want to check that the replication is working as expected before proceeding, you can connect to the new instance and check that everything looks correct.") - pterm.DefaultParagraph.Printfln("To connect to the new instance, follow the guide for personal database access using the helper application (%s) created for this migration.", helperName) - ui.LinkStyle.Println("\thttps://docs.nais.io/persistence/cloudsql/how-to/personal-access/") - pterm.Println() - pterm.DefaultParagraph.Println("When you are ready to proceed with the next step of the migration, run the promote command:") - ui.CmdStyle.Printfln("\tnais postgres migrate promote %s %s", m.cfg.AppName, m.cfg.Target.InstanceName) - pterm.Println() - pterm.Info.Println("Be aware that during promotion (the next step), your instance will be unavailable for some time.") - return nil -} - -func (m *Migrator) validateInstanceNames() error { - sourceInstanceName := m.cfg.Source.InstanceName.String() - if sourceInstanceName == "" { - return fmt.Errorf("source instance name is empty") - } - - targetInstanceName := m.cfg.Target.InstanceName.String() - if targetInstanceName == "" { - return fmt.Errorf("target instance name is required") - } - - if sourceInstanceName == targetInstanceName { - return fmt.Errorf("source and target instance names cannot be the same") - } - return nil -} - -func (m *Migrator) clearDiskSizeIfDiskAutoresizeEnabled() { - m.cfg.Target.DiskAutoresize.Do(func(v bool) { - if v { - m.cfg.Target.DiskSize = option.None[int]() - } - }) -} - -func (m *Migrator) ConfigureTarget() { - m.cfg.Target.Tier = m.cfg.Target.Tier.OrMaybe(ui.AskForTier(m.cfg.Source.Tier.String())) - m.cfg.Target.Type = m.cfg.Target.Type.OrMaybe(ui.AskForType(m.cfg.Source.Type.String())) - m.cfg.Target.DiskAutoresize = m.cfg.Target.DiskAutoresize.OrMaybe(ui.AskForDiskAutoresize(m.cfg.Source.DiskAutoresize)) - m.cfg.Target.DiskAutoresize.Do(func(v bool) { - if !v { - m.cfg.Target.DiskSize = m.cfg.Target.DiskSize.OrMaybe(ui.AskForDiskSize(m.cfg.Source.DiskSize)) - } - }) -} - -// helperAppName generates a name for the helper application, based on the application name. -// This is a copy of the corresponding function in nais/cloudsql-migrator/internal/pkg/common_main/main.go -// If a functional change is made here, it should be made in both places. -func helperAppName(basename string) (string, error) { - helperName, err := namegen.ShortName(fmt.Sprintf("migrator-%s", basename), 63) - if err != nil { - return "", err - } - - return helperName, nil -} diff --git a/internal/postgres/migrate/setup/command.go b/internal/postgres/migrate/setup/command.go deleted file mode 100644 index c0816bde..00000000 --- a/internal/postgres/migrate/setup/command.go +++ /dev/null @@ -1,66 +0,0 @@ -package setup - -import ( - "context" - "fmt" - - "github.com/nais/cli/internal/k8s" - "github.com/nais/cli/internal/option" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/cli/internal/postgres/migrate" - "github.com/nais/cli/internal/postgres/migrate/config" -) - -func Run(ctx context.Context, applicationName, targetInstanceName, team, environment string, flags *flag.MigrateSetup) error { - cfg := config.Config{ - AppName: applicationName, - Target: config.InstanceConfig{ - InstanceName: option.Some(targetInstanceName), - }, - } - - tier := flags.Tier - diskAutoresize := flags.DiskAutoResize - diskSize := flags.DiskSize - instanceType := flags.InstanceType - - cfg.Target.Tier = isSet(tier) - cfg.Target.DiskAutoresize = isSetBool(diskAutoresize) - cfg.Target.DiskSize = isSetInt(diskSize) - cfg.Target.Type = isSet(instanceType) - - client := k8s.SetupControllerRuntimeClient(k8s.WithKubeContext(environment)) - cfg.Team = team - clientSet, err := k8s.SetupClientGo(environment) - if err != nil { - return err - } - - migrator := migrate.NewMigrator(client, clientSet, cfg, flags.DryRun, flags.NoWait) - if err := migrator.Setup(ctx); err != nil { - return fmt.Errorf("error setting up migration: %w", err) - } - - return nil -} - -func isSet(v string) option.Option[string] { - if v == "" { - return option.None[string]() - } - return option.Some(v) -} - -func isSetBool(autoresize bool) option.Option[bool] { - if autoresize { - return option.Some(true) - } - return option.None[bool]() -} - -func isSetInt(v int) option.Option[int] { - if v == 0 { - return option.None[int]() - } - return option.Some(v) -} diff --git a/internal/postgres/migrate/setup_test.go b/internal/postgres/migrate/setup_test.go deleted file mode 100644 index fe1b2a02..00000000 --- a/internal/postgres/migrate/setup_test.go +++ /dev/null @@ -1,321 +0,0 @@ -package migrate_test - -import ( - "context" - "fmt" - "strings" - "testing" - - "github.com/nais/cli/internal/option" - "github.com/nais/cli/internal/postgres/migrate" - "github.com/nais/cli/internal/postgres/migrate/config" - "github.com/nais/cli/internal/postgres/migrate/ui" - nais_io_v1 "github.com/nais/liberator/pkg/apis/nais.io/v1" - nais_io_v1alpha1 "github.com/nais/liberator/pkg/apis/nais.io/v1alpha1" - liberatorscheme "github.com/nais/liberator/pkg/scheme" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes/fake" - ctrl_fake "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -const namespace = "test-namespace" - -const ( - sourceName = "source-instance" - sourceType = "source-type" - sourceDiskSize = 15 - sourceTier = "source-tier" - - targetName = "target-instance" - targetType = "target-type" - targetDiskSize = 20 - targetTier = "target-tier" -) - -func TestMigrator_Setup(t *testing.T) { - test := map[string]struct { - appName string - errContains string - }{ - "return an error if application is not found": { - appName: "no-such-app", - errContains: "not found for team", - }, - "return an error if application has no sql instance": { - appName: "no-instance", - errContains: "no sql instances found in app spec", - }, - "return an error if migration config already exists": { - appName: "already-migrating", - errContains: "migration config already exists for this application", - }, - } - - for name, tc := range test { - t.Run(name, func(t *testing.T) { - scheme, err := liberatorscheme.All() - if err != nil { - t.Fatalf("failed to create scheme: %v", err) - } - clientBuilder := ctrl_fake.NewClientBuilder().WithScheme(scheme) - clientset := fake.NewClientset() - - cfg := config.Config{ - Team: namespace, - Source: config.InstanceConfig{}, - Target: config.InstanceConfig{InstanceName: option.Some(targetName)}, - AppName: tc.appName, - } - noInstanceApp := &nais_io_v1alpha1.Application{ - ObjectMeta: metav1.ObjectMeta{ - Name: "no-instance", - Namespace: namespace, - }, - Spec: nais_io_v1alpha1.ApplicationSpec{}, - } - alreadyMigratingApp := &nais_io_v1alpha1.Application{ - ObjectMeta: metav1.ObjectMeta{ - Name: "already-migrating", - Namespace: namespace, - }, - Spec: nais_io_v1alpha1.ApplicationSpec{ - GCP: &nais_io_v1.GCP{ - SqlInstances: []nais_io_v1.CloudSqlInstance{{ - Name: targetName, - }}, - }, - }, - } - cfgMap := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "migration-already-exists-config", - Namespace: namespace, - Labels: map[string]string{"migrator.nais.io/app-name": "already-migrating"}, - }, - } - app := &nais_io_v1alpha1.Application{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-app", - Namespace: namespace, - }, - Spec: nais_io_v1alpha1.ApplicationSpec{ - GCP: &nais_io_v1.GCP{ - SqlInstances: []nais_io_v1.CloudSqlInstance{{ - Name: targetName, - }}, - }, - }, - } - - clientBuilder.WithObjects(noInstanceApp, alreadyMigratingApp, app, cfgMap) - - migrator := migrate.NewMigrator(clientBuilder.Build(), clientset, cfg, true, true) - - err = migrator.Setup(context.Background()) - if tc.errContains != "" { - if err == nil { - t.Errorf("expected error containing %q, got nil", tc.errContains) - } else if !strings.Contains(err.Error(), tc.errContains) { - t.Errorf("expected error to contain %q, got %q", tc.errContains, err.Error()) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - } - }) - } -} - -func TestConfigureTarget_instance_type(t *testing.T) { - scheme, err := liberatorscheme.All() - if err != nil { - t.Fatalf("failed to create scheme: %v", err) - } - clientBuilder := ctrl_fake.NewClientBuilder().WithScheme(scheme) - clientset := fake.NewClientset() - client := clientBuilder.Build() - - tests := map[string]struct { - instance config.InstanceConfig - }{ - "only default values": { - instance: config.InstanceConfig{InstanceName: option.Some(sourceName)}, - }, - "all values, no autoresize": { - instance: config.InstanceConfig{ - InstanceName: option.Some(sourceName), - Tier: option.Some(sourceTier), - DiskAutoresize: option.None[bool](), - DiskSize: option.Some(sourceDiskSize), - Type: option.Some(sourceType), - }, - }, - "autoresize, no disk size": { - instance: config.InstanceConfig{ - InstanceName: option.Some(sourceName), - Tier: option.Some(sourceTier), - DiskAutoresize: option.Some(true), - DiskSize: option.None[int](), - Type: option.Some(sourceType), - }, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - cfg := config.Config{ - Team: namespace, - Source: tc.instance, - } - migratorBuilder := migrate.NewMigrator(client, clientset, cfg, true, true) - - ui.AskForDiskAutoresize = func(sourceDiskAutoresize option.Option[bool]) func() option.Option[bool] { - return func() option.Option[bool] { - return sourceDiskAutoresize - } - } - ui.AskForDiskSize = func(sourceDiskSize option.Option[int]) func() option.Option[int] { - return func() option.Option[int] { - return sourceDiskSize - } - } - ui.AskForTier = func(sourceTier string) func() option.Option[string] { - return func() option.Option[string] { - return option.Some(sourceTier) - } - } - ui.AskForType = func(sourceType string) func() option.Option[string] { - return func() option.Option[string] { - return option.Some(sourceType) - } - } - - t.Run("instance type target type is set", func(t *testing.T) { - cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName), Type: option.Some(targetType)} - m := migratorBuilder - m.ConfigureTarget() - if cfg.Target.Type.String() != targetType { - t.Errorf("expected target type %q, got %q", targetType, cfg.Target.Type.String()) - } - }) - - t.Run("instance type target type is not set", func(t *testing.T) { - cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName)} - m := migratorBuilder - m.ConfigureTarget() - if cfg.Target.Type != option.None[string]() { - t.Errorf("expected target type to be None, got %q", cfg.Target.Type.String()) - } - }) - - t.Run("instance tier target tier is set", func(t *testing.T) { - cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName), Tier: option.Some(targetTier)} - m := migratorBuilder - m.ConfigureTarget() - if cfg.Target.Tier.String() != targetTier { - t.Errorf("expected target tier %q, got %q", targetTier, cfg.Target.Tier.String()) - } - }) - t.Run("instance tier target tier is not set", func(t *testing.T) { - cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName)} - m := migratorBuilder - m.ConfigureTarget() - if cfg.Target.Tier != option.None[string]() { - t.Errorf("expected target tier to be None, got %q", cfg.Target.Tier.String()) - } - }) - t.Run("instance disk size target disk size is set", func(t *testing.T) { - cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName), DiskSize: option.Some(targetDiskSize)} - m := migratorBuilder - m.ConfigureTarget() - if cfg.Target.DiskSize.String() != fmt.Sprintf("%v", targetDiskSize) { - t.Errorf("expected target disk size %d, got %s", targetDiskSize, cfg.Target.DiskSize.String()) - } - }) - t.Run("instance disk size target disk size is not set", func(t *testing.T) { - cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName)} - m := migratorBuilder - m.ConfigureTarget() - if cfg.Target.DiskSize != option.None[int]() { - t.Errorf("expected target disk size to be None, got %s", cfg.Target.DiskSize.String()) - } - }) - t.Run("instance disk autoresize target disk autoresize is set to false and target disk size is set", func(t *testing.T) { - cfg.Target = config.InstanceConfig{ - InstanceName: option.Some(targetName), - DiskAutoresize: option.Some(false), - DiskSize: option.Some(targetDiskSize), - } - m := migratorBuilder - m.ConfigureTarget() - if cfg.Target.DiskAutoresize.String() != "false" { - t.Errorf("expected target disk autoresize to be false, got %s", cfg.Target.DiskAutoresize.String()) - } - if cfg.Target.DiskSize.String() != fmt.Sprintf("%v", targetDiskSize) { - t.Errorf("expected target disk size %d, got %s", targetDiskSize, cfg.Target.DiskSize.String()) - } - }) - t.Run("instance disk autoresize target disk autoresize is set to false and target disk size is not set", func(t *testing.T) { - cfg.Target = config.InstanceConfig{ - InstanceName: option.Some(targetName), - DiskAutoresize: option.Some(false), - } - m := migratorBuilder - m.ConfigureTarget() - if cfg.Target.DiskAutoresize.String() != "false" { - t.Errorf("expected target disk autoresize to be false, got %s", cfg.Target.DiskAutoresize.String()) - } - if cfg.Target.DiskSize != option.None[int]() { - t.Errorf("expected target disk size to be None, got %s", cfg.Target.DiskSize.String()) - } - }) - t.Run("instance disk autoresize target disk autoresize is set to true and target disk size is set", func(t *testing.T) { - cfg.Target = config.InstanceConfig{ - InstanceName: option.Some(targetName), - DiskAutoresize: option.Some(true), - DiskSize: option.Some(targetDiskSize), - } - m := migratorBuilder - m.ConfigureTarget() - if cfg.Target.DiskAutoresize.String() != "true" { - t.Errorf("expected target disk autoresize to be true, got %s", cfg.Target.DiskAutoresize.String()) - } - }) - t.Run("instance disk autoresize target disk autoresize is set to true and target disk size is not set", func(t *testing.T) { - cfg.Target = config.InstanceConfig{ - InstanceName: option.Some(targetName), - DiskAutoresize: option.Some(true), - } - m := migratorBuilder - m.ConfigureTarget() - if cfg.Target.DiskAutoresize.String() != "true" { - t.Errorf("expected target disk autoresize to be true, got %s", cfg.Target.DiskAutoresize.String()) - } - }) - t.Run("instance disk autoresize target disk autoresize is not set and target disk size is set", func(t *testing.T) { - cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName), DiskSize: option.Some(targetDiskSize)} - m := migratorBuilder - m.ConfigureTarget() - if cfg.Target.DiskAutoresize != option.None[bool]() { - t.Errorf("expected target disk autoresize to be None, got %s", cfg.Target.DiskAutoresize.String()) - } - if cfg.Target.DiskSize.String() != fmt.Sprintf("%v", targetDiskSize) { - t.Errorf("expected target disk size %d, got %s", targetDiskSize, cfg.Target.DiskSize.String()) - } - }) - t.Run("instance disk autoresize target disk autoresize is not set and target disk size is not set", func(t *testing.T) { - cfg.Target = config.InstanceConfig{InstanceName: option.Some(targetName)} - m := migratorBuilder - m.ConfigureTarget() - if cfg.Target.DiskAutoresize != option.None[bool]() { - t.Errorf("expected target disk autoresize to be None, got %s", cfg.Target.DiskAutoresize.String()) - } - if cfg.Target.DiskSize != option.None[int]() { - t.Errorf("expected target disk size to be None, got %s", cfg.Target.DiskSize.String()) - } - }) - }) - } -} diff --git a/internal/postgres/migrate/ui/ui.go b/internal/postgres/migrate/ui/ui.go deleted file mode 100644 index d940cc9a..00000000 --- a/internal/postgres/migrate/ui/ui.go +++ /dev/null @@ -1,228 +0,0 @@ -package ui - -import ( - "fmt" - "log" - "slices" - "strconv" - "strings" - - "github.com/nais/cli/internal/option" - "github.com/pterm/pterm" -) - -const ( - otherOption = "Other" - sameAsSourceOptionPrefix = "Same as source" -) - -var ( - CmdStyle = pterm.NewStyle(pterm.FgLightMagenta) - LinkStyle = pterm.NewStyle(pterm.FgLightBlue, pterm.Underscore) - YamlStyle = pterm.NewStyle(pterm.FgLightYellow) -) - -func stringCaster(s string) string { return s } -func boolCaster(s string) bool { return s == "true" } - -type Prompter interface { - Show(text ...string) (string, error) -} - -var TextInput Prompter = pterm.DefaultInteractiveTextInput - -type Selector interface { - Prompter - WithOptions(options []string) Selector -} - -type textSelector struct { - defaultSelector *pterm.InteractiveSelectPrinter -} - -func (t *textSelector) Show(text ...string) (string, error) { - return t.defaultSelector.Show(text...) -} - -func (t *textSelector) WithOptions(options []string) Selector { - return &textSelector{ - defaultSelector: pterm.DefaultInteractiveSelect. - WithOptions(options). - WithMaxHeight(len(options)), - } -} - -var TextSelector Selector = &textSelector{defaultSelector: &pterm.DefaultInteractiveSelect} - -// askForOption is a generic function to ask for an option from a list of options. -// -// It returns a function that can be called to ask for the option. -// The function returns the selected option as an Option[T]. -// If the selected option is the "Same as source" option, it returns None[T]. -// If the selected option is "Other", it calls the otherHandler function to ask for the value. -// The selected value is then cast to the desired type T using caster function, and returned as Some[T]. -func askForOption[T any](prompt string, sourceValue T, options []string, caster func(string) T, otherHandler func() string) func() option.Option[T] { - return func() option.Option[T] { - source := fmt.Sprintf("%s (%v)", sameAsSourceOptionPrefix, sourceValue) - options = append([]string{source}, options...) - if otherHandler != nil { - options = append(options, otherOption) - } - pterm.Println() - selected, err := TextSelector. - WithOptions(options). - Show(prompt) - if err != nil { - log.Fatalf("Error while creating text UI: %v", err) - return option.None[T]() - } - if selected == otherOption { - selected = otherHandler() - } - if strings.HasPrefix(selected, sameAsSourceOptionPrefix) { - return option.None[T]() - } - return option.Some(caster(selected)) - } -} - -// Suggested options for tier when asking user for a target tier. -var tierOptions = []string{ - "db-custom-1-3840", - "db-custom-2-5120", - "db-custom-2-7680", - "db-custom-4-15360", -} - -var AskForTier = askForTier - -// askForTier asks for a tier for the target instance. -// -// It returns a function that can be called to ask for the tier. -// The function returns the selected tier as an Option[string]. -// If the selected tier is the "Same as source" tier, it returns None[string]. -// If the selected tier is "Other", it asks the user to enter a custom tier. -// The selected value is returned as Some[string]. -func askForTier(sourceTier string) func() option.Option[string] { - var options []string - for _, tier := range tierOptions { - if tier != sourceTier { - options = append(options, tier) - } - } - return askForOption("Select a tier for the target instance", sourceTier, options, stringCaster, func() string { - pterm.Println("Check the documentation for possible options:") - LinkStyle.Printfln("\thttps://doc.nais.io/persistence/postgres/reference/#server-size") - tier, err := TextInput.Show("Enter the tier for the target instance") - if err != nil { - log.Fatalf("Error while creating text UI: %v", err) - return "" - } - return tier - }) -} - -// Mapping from instance type to version. -var typeToVersion = map[string]int{ - "POSTGRES_11": 11, - "POSTGRES_12": 12, - "POSTGRES_13": 13, - "POSTGRES_14": 14, - "POSTGRES_15": 15, - "POSTGRES_16": 16, - "POSTGRES_17": 17, - "POSTGRES_18": 18, -} - -var AskForType = askForType - -// askForType asks for a type for the target instance. -// -// It returns a function that can be called to ask for the type. -// The function returns the selected type as an Option[string]. -// If the selected type is the "Same as source" type, it returns None[string]. -// It is not possible to select a type (postgres version) less than source. -// The selected value is returned as Some[string]. -func askForType(sourceType string) func() option.Option[string] { - sourceVersion := typeToVersion[sourceType] - var options []string - for k, v := range typeToVersion { - if v > sourceVersion { - options = append(options, k) - } - } - if len(options) == 0 { - return func() option.Option[string] { return option.None[string]() } - } - slices.Sort(options) - slices.Reverse(options) - return askForOption("Select a type for the target instance", sourceType, options, stringCaster, nil) -} - -var AskForDiskAutoresize = askForDiskAutoresize - -// askForDiskAutoresize asks for disk autoresize for the target instance. -// -// It returns a function that can be called to ask for disk autoresize. -// The function returns the selected disk autoresize as an Option[bool]. -// If the source was unset, source is considered false (the nais default), and the "Same as source" option returns Some(false). -// It always returns Some(value), where value is the selected option. -func askForDiskAutoresize(sourceDiskAutoresize option.Option[bool]) func() option.Option[bool] { - var options []string - autoresize := false - sourceDiskAutoresize.Do(func(v bool) { - autoresize = v - }) - if autoresize { - options = append(options, "false") - } else { - options = append(options, "true") - } - return func() option.Option[bool] { - targetDiskAutoresize := askForOption("Enable disk autoresize for the target instance?", autoresize, options, boolCaster, nil)() - sourceDiskAutoresize.OrValue(false).Do(func(v bool) { - targetDiskAutoresize = targetDiskAutoresize.OrValue(v) - }) - return targetDiskAutoresize - } -} - -var AskForDiskSize = askForDiskSize - -// askForDiskSize asks for disk size for the target instance. -// -// It returns a function that can be called to ask for the disk size. -// The function returns the selected disk size as an Option[int]. -// If the user enters a blank string, it returns None[int]. -// If the user enters a number, it returns Some(value), where value is the entered number. -func askForDiskSize(sourceDiskSize option.Option[int]) func() option.Option[int] { - sourceSize := "" - sourceDiskSize.Do(func(v int) { - sourceSize = fmt.Sprintf("%d GB", v) - }) - var ask func() option.Option[int] - ask = func() option.Option[int] { - pterm.Println() - pterm.Println("Disk size is in GB, and must be greater than or equal to 10.") - msg := fmt.Sprintf("Enter the disk size for the target instance. Leave empty to use same as source (%s)", sourceSize) - diskSize, err := TextInput.Show(msg) - if err != nil { - log.Fatalf("Error while creating text UI: %v", err) - return option.None[int]() - } - if diskSize == "" { - return option.None[int]() - } - size, err := strconv.Atoi(diskSize) - if err != nil { - pterm.Error.Println("Disk size must be a whole number") - return ask() - } - if size < 10 { - pterm.Error.Println("Disk size must be greater than or equal to 10") - return ask() - } - return option.Some(size) - } - return ask -} diff --git a/internal/postgres/migrate/ui/ui_test.go b/internal/postgres/migrate/ui/ui_test.go deleted file mode 100644 index 087ff58e..00000000 --- a/internal/postgres/migrate/ui/ui_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package ui_test - -import ( - "slices" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/nais/cli/internal/option" - "github.com/nais/cli/internal/postgres/migrate/ui" -) - -type fakeTextInput struct { - text string -} - -func (f *fakeTextInput) Show(_ ...string) (string, error) { - return f.text, nil -} - -type fakeTextSelector struct { - t *testing.T - selected string - options []string -} - -func (f *fakeTextSelector) Show(_ ...string) (string, error) { - return f.selected, nil -} - -func (f *fakeTextSelector) WithOptions(options []string) ui.Selector { - if !slices.ContainsFunc(options, func(e string) bool { return strings.Contains(e, f.selected) }) { - f.t.Helper() - f.t.Fatalf("selected value not in options, got %q, options: %#v", f.selected, options) - } - f.options = options - return f -} - -func TestUIAskForDiskSize(t *testing.T) { - tests := map[string]struct { - source option.Option[int] - enteredValue string - expected option.Option[int] - }{ - "source has value and user presses Enter": { - source: option.Some(100), - enteredValue: "", - expected: option.None[int](), - }, - "source has value and user types in 200": { - source: option.Some(100), - enteredValue: "200", - expected: option.Some(200), - }, - "source has no value and user presses Enter": { - source: option.None[int](), - enteredValue: "", - expected: option.None[int](), - }, - "source has no value and user types in 200": { - source: option.None[int](), - enteredValue: "200", - expected: option.Some(200), - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - ui.TextInput = &fakeTextInput{text: test.enteredValue} - result := ui.AskForDiskSize(test.source)() - if result != test.expected { - t.Errorf("expected %v, got %v", test.expected, result) - } - }) - } -} - -func TestUIAskForDiskAutoresize(t *testing.T) { - tests := map[string]struct { - source option.Option[bool] - selectedValue string - expected option.Option[bool] - }{ - "source true and user presses Enter": { - source: option.Some(true), - selectedValue: "Same as source (true)", - expected: option.Some(true), - }, - "source true and user selects false": { - source: option.Some(true), - selectedValue: "false", - expected: option.Some(false), - }, - "source false and user presses Enter": { - source: option.Some(false), - selectedValue: "Same as source (false)", - expected: option.Some(false), - }, - "source false and user selects true": { - source: option.Some(false), - selectedValue: "true", - expected: option.Some(true), - }, - "source unset and user presses Enter": { - source: option.None[bool](), - selectedValue: "Same as source (false)", - expected: option.Some(false), - }, - "source unset and user selects true": { - source: option.None[bool](), - selectedValue: "true", - expected: option.Some(true), - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - ui.TextSelector = &fakeTextSelector{t: t, selected: test.selectedValue} - result := ui.AskForDiskAutoresize(test.source)() - if result != test.expected { - t.Errorf("expected %v, got %v", test.expected, result) - } - }) - } -} - -func TestUIAskForTier_when_source_has_a_value_and(t *testing.T) { - tests := map[string]struct { - selectedValue string - expected option.Option[string] - }{ - "user presses Enter": { - selectedValue: "Same as source (db-f1-micro)", - expected: option.None[string](), - }, - "user selects db-custom-2-5120": { - selectedValue: "db-custom-2-5120", - expected: option.Some("db-custom-2-5120"), - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - ui.TextSelector = &fakeTextSelector{t: t, selected: test.selectedValue} - result := ui.AskForTier("db-f1-micro")() - if result != test.expected { - t.Errorf("expected %v, got %v", test.expected, result) - } - }) - } -} - -func TestUIAskForTier_user_selects_Other_and_enters_a_value_it_returns_the_entered_value(t *testing.T) { - ui.TextSelector = &fakeTextSelector{t: t, selected: "Other"} - ui.TextInput = &fakeTextInput{text: "db-custom-16-8192"} - result := ui.AskForTier("db-f1-micro")() - expected := option.Some("db-custom-16-8192") - - if result != expected { - t.Errorf("expected %v, got %v", expected, result) - } -} - -func TestUIAskForTier_source_value_is_in_preset_list_of_options_it_is_only_listed_once(t *testing.T) { - f := &fakeTextSelector{t: t, selected: "db-custom-2-5120"} - ui.TextSelector = f - ui.AskForTier("db-custom-2-5120")() - if !slices.Contains(f.options, "Same as source (db-custom-2-5120)") { - t.Errorf("expected options to contain 'Same as source (db-custom-2-5120)', got %v", f.options) - } - if slices.Contains(f.options, "db-custom-2-5120") { - t.Errorf("expected options to not contain 'db-custom-2-5120', got %v", f.options) - } -} - -func TestUIAskForType(t *testing.T) { - tests := map[string]struct { - source string - selectedValue string - expected option.Option[string] - }{ - "same as source": { - source: "POSTGRES_13", - selectedValue: "Same as source (POSTGRES_13)", - expected: option.None[string](), - }, - "selects POSTGRES_14": { - source: "POSTGRES_13", - selectedValue: "POSTGRES_14", - expected: option.Some("POSTGRES_14"), - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - ui.TextSelector = &fakeTextSelector{t: t, selected: test.selectedValue} - result := ui.AskForType(test.source)() - if result != test.expected { - t.Errorf("expected %v, got %v", test.expected, result) - } - }) - } -} - -func TestUIAskForType_source_is_POSTGRES_14_only_list_newer_versions(t *testing.T) { - f := &fakeTextSelector{selected: "POSTGRES_15"} - ui.TextSelector = f - ui.AskForType("POSTGRES_14")() - - expected := []string{ - "Same as source (POSTGRES_14)", - "POSTGRES_18", - "POSTGRES_17", - "POSTGRES_16", - "POSTGRES_15", - } - if diff := cmp.Diff(f.options, expected); diff != "" { - t.Errorf("options mismatch (-got +want):\n%s", diff) - } -} diff --git a/internal/postgres/password.go b/internal/postgres/password.go deleted file mode 100644 index 1497c029..00000000 --- a/internal/postgres/password.go +++ /dev/null @@ -1,145 +0,0 @@ -package postgres - -import ( - "bytes" - "context" - "encoding/base64" - "fmt" - "io" - "os" - "os/exec" - "strings" - "time" - - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/liberator/pkg/keygen" - "github.com/nais/naistrix" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func RotatePassword(ctx context.Context, appName, team, environment string, fl *flag.Password, out *naistrix.OutputWriter) error { - // Get secret values (access is logged for audit purposes) - sv, err := GetSecretValues(ctx, appName, team, environment, fl.Postgres, ReasonPasswordRotate, out) - if err != nil { - return err - } - - dbInfo, err := NewDBInfo(ctx, appName, team, environment) - if err != nil { - return err - } - - dbInfo.SetSecretValues(sv) - - cloudSQLDBInfo, err := dbInfo.ToCloudSQLDBInfo() - if err != nil { - return err - } - - projectID, err := cloudSQLDBInfo.ProjectID(ctx) - if err != nil { - return err - } - - dbConnectionInfo, err := cloudSQLDBInfo.DBConnection(ctx) - if err != nil { - return err - } - - out.Println("Grant user cloudsql.admin access for 5 minutes") - err = grantUserAccess(ctx, projectID, "roles/cloudsql.admin", 5*time.Minute, out) - if err != nil { - return err - } - - out.Println("Generating new password") - newPassword, err := generatePassword() - if err != nil { - return err - } - - dbConnectionInfo.SetPassword(newPassword) - - out.Printf("Rotating password for user %v in database %v\n", dbConnectionInfo.username, dbConnectionInfo.dbName) - err = rotatePasswordForDatabaseUser(ctx, projectID, dbConnectionInfo.instance, dbConnectionInfo.username, dbConnectionInfo.password) - if err != nil { - return err - } - - out.Printf("Updating password in k8s secret google-sql-%v\n", cloudSQLDBInfo.appName) - err = updateKubernetesSecret(ctx, cloudSQLDBInfo, dbConnectionInfo) - if err != nil { - return err - } - - out.Println("Password rotated") - return nil -} - -func updateKubernetesSecret(ctx context.Context, dbInfo *CloudSQLDBInfo, dbConnectionInfo *ConnectionInfo) error { - secret, err := dbInfo.k8sClient.CoreV1().Secrets(string(dbInfo.namespace)).Get(ctx, "google-sql-"+dbInfo.appName, v1.GetOptions{}) - if err != nil { - return fmt.Errorf("unable to the k8s secret %q in %q: %w", "google-sql-"+dbInfo.appName, dbInfo.namespace, err) - } - - jdbcUrlSet := false - prefix := "" - for key := range secret.Data { - if strings.HasSuffix(key, "_PASSWORD") { - secret.Data[key] = []byte(dbConnectionInfo.password) - } - if before, ok := strings.CutSuffix(key, "_URL"); ok { - if strings.HasSuffix(key, "_JDBC_URL") && dbConnectionInfo.jdbcUrl != nil { - secret.Data[key] = []byte(dbConnectionInfo.jdbcUrl.String()) - jdbcUrlSet = true - } else if dbConnectionInfo.url != nil { - secret.Data[key] = []byte(dbConnectionInfo.url.String()) - prefix = before - } - } - } - - if !jdbcUrlSet && dbConnectionInfo.jdbcUrl != nil && len(prefix) > 0 { - key := prefix + "_JDBC_URL" - secret.Data[key] = []byte(dbConnectionInfo.jdbcUrl.String()) - } - - _, err = dbInfo.k8sClient.CoreV1().Secrets(string(dbInfo.namespace)).Update(ctx, secret, v1.UpdateOptions{}) - if err != nil { - return fmt.Errorf("failed updating k8s secret %q in %q with new password: %w", "google-sql-"+dbInfo.appName, dbInfo.namespace, err) - } - - return nil -} - -func rotatePasswordForDatabaseUser(ctx context.Context, projectID, instance, username, password string) error { - args := []string{ - "sql", - "users", - "set-password", - username, - "--password", password, - "--instance", strings.Split(instance, ":")[2], - "--project", projectID, - } - - buf := &bytes.Buffer{} - cmd := exec.CommandContext(ctx, "gcloud", args...) - cmd.Stdout = buf - cmd.Stderr = os.Stderr - err := cmd.Run() - if err != nil { - _, _ = io.Copy(os.Stdout, buf) - return fmt.Errorf("error running gcloud command: %w", err) - } - - return nil -} - -func generatePassword() (string, error) { - key, err := keygen.Keygen(32) - if err != nil { - return "", fmt.Errorf("unable to generate secret for sql user: %s", err) - } - return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(key), nil -} diff --git a/internal/postgres/password_test.go b/internal/postgres/password_test.go deleted file mode 100644 index f9f0de5a..00000000 --- a/internal/postgres/password_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package postgres - -import ( - "fmt" - "net/url" - "strings" - "testing" - - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/kubernetes/fake" -) - -const ( - namespace = "password-ns" - secretName = "google-sql-password-app" - appName = "password-app" - newPassword = "new-password" - oldPassword = "old-password" - - jdbcUrlTmpl = "jdbc:postgresql://localhost:5432/my-database?user=my-user&password=%s" - pgUrlTmpl = "postgresql://my-user:%s@localhost:5432/my-database" -) - -var ( - newJdbcUrl *url.URL - newPgUrl *url.URL -) - -func init() { - var err error - newJdbcUrl, err = url.Parse(fmt.Sprintf(jdbcUrlTmpl, newPassword)) - if err != nil { - panic(err) - } - - newPgUrl, err = url.Parse(fmt.Sprintf(pgUrlTmpl, newPassword)) - if err != nil { - panic(err) - } -} - -type test struct { - secretPrep []SecretPrep - assertSecret []AssertSecret -} - -func TestPassword(t *testing.T) { - tests := map[string]test{ - "has only password": { - secretPrep: []SecretPrep{AddPassword}, - assertSecret: []AssertSecret{HasPassword, HasNoUrl, HasNoJdbcUrl}, - }, - "has password and url": { - secretPrep: []SecretPrep{AddPassword, AddUrl}, - assertSecret: []AssertSecret{HasPassword, HasUrl, HasJdbcUrl}, - }, - "has all": { - secretPrep: []SecretPrep{AddPassword, AddUrl, AddJdbcUrl}, - assertSecret: []AssertSecret{HasPassword, HasUrl, HasJdbcUrl}, - }, - "has password and jdbc url": { - secretPrep: []SecretPrep{AddPassword, AddJdbcUrl}, - assertSecret: []AssertSecret{HasPassword, HasNoUrl, HasJdbcUrl}, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - k8sClient := fake.NewClientset() - secret := &corev1.Secret{ - TypeMeta: metav1.TypeMeta{ - Kind: "Secret", - APIVersion: "v1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: namespace, - }, - Data: map[string][]byte{ - "DB_HOST": []byte("localhost"), - "DB_PORT": []byte("5432"), - "DB_DATABASE": []byte("my-database"), - "DB_USERNAME": []byte("my-user"), - }, - } - - for _, prep := range tc.secretPrep { - prep(secret) - } - - err := k8sClient.Tracker().Add(secret) - if err != nil { - t.Fatalf("failed to add secret to tracker: %v", err) - } - - dbInfo := createDbInfo(k8sClient) - dbConnectionInfo, err := createConnectionInfo(t.Context(), *secret, dbInfo.connectionName) - if err != nil { - t.Fatalf("failed to create connectionInfo: %v", err) - } - - dbConnectionInfo.SetPassword(newPassword) - - if err := updateKubernetesSecret(t.Context(), dbInfo, dbConnectionInfo); err != nil { - t.Fatalf("failed to update Kubernetes secret: %v", err) - } - - gvr, _ := meta.UnsafeGuessKindToResource(secret.GroupVersionKind()) - actual, err := k8sClient.Tracker().Get(gvr, secret.Namespace, secret.Name) - if err != nil { - t.Fatalf("failed to get secret: %v", err) - } - - actualSecret, ok := actual.(*corev1.Secret) - if !ok { - t.Fatalf("expected *corev1.Secret, got %T", actual) - } - - for _, assert := range tc.assertSecret { - assert(t, actualSecret) - } - }) - } -} - -func createDbInfo(k8sClient kubernetes.Interface) *CloudSQLDBInfo { - return &CloudSQLDBInfo{ - DBInfo: &DBInfo{ - k8sClient: k8sClient, - dynamicClient: nil, - config: nil, - namespace: namespace, - appName: appName, - }, - projectID: "project-id", - connectionName: "connection:name", - } -} - -type SecretPrep func(secret *corev1.Secret) - -func AddPassword(secret *corev1.Secret) { - secret.Data["DB_PASSWORD"] = []byte(oldPassword) -} - -func AddUrl(secret *corev1.Secret) { - secret.Data["DB_URL"] = fmt.Appendf(nil, pgUrlTmpl, oldPassword) -} - -func AddJdbcUrl(secret *corev1.Secret) { - secret.Data["DB_JDBC_URL"] = fmt.Appendf(nil, jdbcUrlTmpl, oldPassword) -} - -type AssertSecret func(t *testing.T, actual *corev1.Secret) - -func EqualUrlNoQuery(t *testing.T, expected *url.URL) { - t.Helper() - - expectedNoQuery, _, _ := strings.Cut(expected.String(), "?") - actualNoQuery, _, _ := strings.Cut(expected.String(), "?") - if expectedNoQuery != actualNoQuery { - t.Fatalf("expected URL without query to be '%s', but got '%s'", expectedNoQuery, actualNoQuery) - } - if actualNoQuery != expectedNoQuery { - t.Fatalf("expected URL without query to be '%s', but got '%s'", expectedNoQuery, actualNoQuery) - } -} - -func EqualQuery(t *testing.T, expected *url.URL) { - t.Helper() - - expectedQuery := expected.Query() - actualQuery := expected.Query() - if actualQuery.Encode() != expectedQuery.Encode() { - t.Fatalf("expected URL query to be '%s', but got '%s'", expectedQuery.Encode(), actualQuery.Encode()) - } -} - -func HasPassword(t *testing.T, actual *corev1.Secret) { - t.Helper() - - if actual.Data["DB_PASSWORD"] == nil { - t.Fatalf("expected DB_PASSWORD to be set, but it is not") - } - - if string(actual.Data["DB_PASSWORD"]) != newPassword { - t.Fatalf("expected DB_PASSWORD to be '%s', but got '%s'", newPassword, actual.Data["DB_PASSWORD"]) - } -} - -func HasUrl(t *testing.T, actual *corev1.Secret) { - t.Helper() - u, err := url.Parse(string(actual.Data["DB_URL"])) - if err != nil { - t.Fatalf("failed to parse DB_URL: %v", err) - } - if u == nil { - t.Fatalf("DB_URL is nil") - } - EqualUrlNoQuery(t, newPgUrl) - EqualQuery(t, newPgUrl) -} - -func HasNoUrl(t *testing.T, actual *corev1.Secret) { - t.Helper() - _, ok := actual.Data["DB_URL"] - if ok { - t.Fatalf("expected DB_URL to not be set, but it is") - } -} - -func HasJdbcUrl(t *testing.T, actual *corev1.Secret) { - t.Helper() - u, err := url.Parse(string(actual.Data["DB_JDBC_URL"])) - if err != nil { - t.Fatalf("failed to parse DB_JDBC_URL: %v", err) - } - if u == nil { - t.Fatalf("DB_JDBC_URL is nil") - } - EqualUrlNoQuery(t, newJdbcUrl) - EqualQuery(t, newJdbcUrl) -} - -func HasNoJdbcUrl(t *testing.T, actual *corev1.Secret) { - t.Helper() - _, ok := actual.Data["DB_JDBC_URL"] - if ok { - t.Fatalf("expected DB_JDBC_URL to not be set, but it is") - } -} diff --git a/internal/postgres/postgresinfo.go b/internal/postgres/postgresinfo.go deleted file mode 100644 index add98d15..00000000 --- a/internal/postgres/postgresinfo.go +++ /dev/null @@ -1,259 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - "io" - "net/http" - "net/url" - - "github.com/nais/cli/internal/naisapi" - nais_io_v1alpha1 "github.com/nais/liberator/pkg/apis/nais.io/v1alpha1" - "github.com/nais/naistrix" - "github.com/pkg/errors" - apierrors "k8s.io/apimachinery/pkg/api/errors" - meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/tools/portforward" - "k8s.io/client-go/transport/spdy" -) - -type postgresDBInfo struct { - *DBInfo - clusterName string -} - -func NewPostgresDBInfo(ctx context.Context, dbInfo *DBInfo) (DB, error) { - p := &postgresDBInfo{ - DBInfo: dbInfo, - } - err := p.fetchClusterInfo(ctx) - if err != nil { - return nil, err - } - return p, nil -} - -func (p *postgresDBInfo) DBConnection(ctx context.Context) (*ConnectionInfo, error) { - user, err := naisapi.GetAuthenticatedUser(ctx) - if err != nil { - return nil, err - } - token, err := user.AccessToken() - if err != nil { - return nil, err - } - - email := user.Email() - - queries := url.Values{} - queries.Add("sslmode", "required") - pgUrl := &url.URL{ - Scheme: "postgresql", - User: url.UserPassword(email, token), - Host: "localhost", - Path: "app", - RawQuery: queries.Encode(), - } - - queries.Add("user", email) - queries.Add("password", token) - jdbcUrl := &url.URL{ - Scheme: "jdbc:postgresql", - Host: "localhost:5432", - Path: "app", - RawQuery: queries.Encode(), - } - - return &ConnectionInfo{ - username: email, - email: email, - password: token, - dbName: "app", - instance: "localhost", - port: "5432", - url: pgUrl, - jdbcUrl: jdbcUrl, - }, nil -} - -func (p *postgresDBInfo) RunProxy(ctx context.Context, host string, port *uint, portCh chan<- int, out *naistrix.OutputWriter, printInstructions bool) error { - cfg, err := p.config.ClientConfig() - if err != nil { - return err - } - - pods, err := p.k8sClient.CoreV1().Pods(fmt.Sprintf("pg-%s", p.namespace)).List(ctx, meta_v1.ListOptions{ - LabelSelector: fmt.Sprintf("spilo-role=master,application=spilo,cluster-name=%s", p.clusterName), - }) - if err != nil { - return err - } - if len(pods.Items) != 1 { - return fmt.Errorf("found %d pods marked as master for cluster %s", len(pods.Items), p.clusterName) - } - - user, err := naisapi.GetAuthenticatedUser(ctx) - if err != nil { - return err - } - email := user.Email() - - masterPod := pods.Items[0] - pfUrl := p.k8sClient.CoreV1().RESTClient().Post(). - Resource("pods"). - Namespace(masterPod.GetNamespace()). - Name(masterPod.GetName()). - SubResource("portforward"). - URL() - - out.Verbosef("attempting port forward with URL: %s\n", pfUrl.String()) - transport, upgrader, err := spdy.RoundTripperFor(cfg) - if err != nil { - return errors.Wrap(err, "Could not create round tripper") - } - - dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", pfUrl) - - stopChan := make(chan struct{}, 1) - readyChan := make(chan struct{}, 1) - errChan := make(chan error, 1) - - ports := []string{":5432"} - if port != nil { - ports = []string{fmt.Sprintf("%d:5432", *port)} - } - - out.Verbosef("Creating new portforward on %s for ports %v\n", host, ports) - pf, err := portforward.NewOnAddresses(dialer, []string{host}, ports, stopChan, readyChan, NewNaisOut(out), NewNaisErr(out)) - if err != nil { - return err - } - - go func() { - out.Verbosef("forwarding ports ...\n") - errChan <- pf.ForwardPorts() - }() - - out.Verbosef("Waiting for forwarding to be ready ...\n") - select { - case err = <-errChan: - return errors.Wrap(err, "Could not create port forward") - case <-readyChan: - } - - if printInstructions { - connectionInfo, err := p.DBConnection(ctx) - if err != nil { - return err - } - - out.Printf("Starting proxy on %s:%d\n", host, *port) - out.Println() - out.Println("Before you can connect, you need to request an access token:") - out.Println("nais login --nais") - out.Println("After logging in, you can get the current password using this command:") - out.Println("nais auth print-access-token --nais") - out.Println() - out.Println("To connect to the database using psql, use the following command:") - out.Printf("PGPASSWORD=$(nais auth print-access-token --nais) psql -h %v -p %v -U %v %v\n", host, *port, email, connectionInfo.dbName) - out.Println() - out.Println("If you are using a JDBC client, you can connect to the database by using the following connection string:") - out.Printf("Connection URL: %s\n", connectionInfo.jdbcUrl) - } - - forwardedPorts, err := pf.GetPorts() - if err != nil { - return err - } - for _, forwardedPort := range forwardedPorts { - out.Infof("Listening on %s:%d\n", host, forwardedPort.Local) - portCh <- int(forwardedPort.Local) - } - - select { - case <-ctx.Done(): - return ctx.Err() - case err = <-errChan: - return errors.Wrap(err, "Could not create port forward") - } -} - -func (p *postgresDBInfo) ToCloudSQLDBInfo() (*CloudSQLDBInfo, error) { - return nil, fmt.Errorf("not a CloudSQL instance") -} - -func (p *postgresDBInfo) SetSecretValues(_ *SecretValues) { - // No-op for in-cluster postgres; authentication uses OAuth tokens -} - -func (p *postgresDBInfo) fetchClusterInfo(ctx context.Context) error { - unstructuredApp, err := p.dynamicClient.Resource(schema.GroupVersionResource{ - Group: "nais.io", - Version: "v1alpha1", - Resource: "applications", - }).Namespace(string(p.namespace)).Get(ctx, p.appName, meta_v1.GetOptions{}) - if err != nil { - if apierrors.IsNotFound(err) { - return p.fetchClusterInfoFromCluster(ctx) - } - return fmt.Errorf("fetchClusterInfo: error looking for Application %q in %q: %w", p.appName, p.namespace, err) - } - - app := &nais_io_v1alpha1.Application{} - err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredApp.Object, app) - if err != nil { - return fmt.Errorf("fetchClusterInfo: error converting to Application %q in %q: %w", p.appName, p.namespace, err) - } - - if app.Spec.Postgres == nil { - return fmt.Errorf("fetchClusterInfo: application %q in %q does not have a Postgres cluster", p.appName, p.namespace) - } - - p.clusterName = app.Spec.Postgres.ClusterName - - return nil -} - -// fetchClusterInfoFromCluster assumes the given "appname" is in reality the name of a postgres cluster directly -// Attempts to verify that this is the case by looking for such a cluster and using it if found -func (p *postgresDBInfo) fetchClusterInfoFromCluster(ctx context.Context) error { - _, err := p.dynamicClient.Resource(schema.GroupVersionResource{ - Group: "data.nais.io", - Version: "v1", - Resource: "postgres", - }).Namespace(string(p.namespace)).Get(ctx, p.appName, meta_v1.GetOptions{}) - if err != nil { - if apierrors.IsNotFound(err) { - return fmt.Errorf("unable to find either Application or Postgres cluster named %q in %q: %w", p.appName, p.namespace, err) - } - return fmt.Errorf("fetchClusterInfo: error looking for Postgres %q in %q: %w", p.appName, p.namespace, err) - } - - p.clusterName = p.appName - - return nil -} - -func NewNaisOut(out *naistrix.OutputWriter) io.Writer { - return &NaisWriter{ - writeFunc: func(format string, v ...any) { out.Infof(format, v...) }, - } -} - -func NewNaisErr(out *naistrix.OutputWriter) io.Writer { - return &NaisWriter{ - writeFunc: func(format string, v ...any) { out.Errorf(format, v...) }, - } -} - -type NaisWriter struct { - writeFunc func(string, ...any) -} - -func (o *NaisWriter) Write(p []byte) (n int, err error) { - msg := string(p) - o.writeFunc(msg) - return len(msg), nil -} diff --git a/internal/postgres/proxy.go b/internal/postgres/proxy.go deleted file mode 100644 index 76100235..00000000 --- a/internal/postgres/proxy.go +++ /dev/null @@ -1,51 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/naistrix" -) - -func RunProxy(ctx context.Context, appName, team, environment string, fl *flag.Proxy, out *naistrix.OutputWriter) error { - // Get secret values with user-provided reason (access is logged for audit purposes) - sv, err := GetSecretValuesWithUserReason(ctx, appName, team, environment, fl.Postgres, fl.Reason, out) - if err != nil { - return err - } - - dbInfo, err := NewDBInfo(ctx, appName, team, environment) - if err != nil { - return err - } - - dbInfo.SetSecretValues(sv) - - return dbInfo.RunProxy(ctx, fl.Host, &fl.Port, make(chan<- int, 1), out, true) -} - -func copy(closer chan struct{}, dst io.Writer, src io.Reader) { - _, _ = io.Copy(dst, src) - closer <- struct{}{} // connection is closed, send signal to stop proxy -} - -func checkPostgresqlPassword(out *naistrix.OutputWriter) error { - if _, ok := os.LookupEnv("PGPASSWORD"); ok { - return fmt.Errorf("PGPASSWORD is set, please unset it before running this command") - } - - dirname, err := os.UserHomeDir() - if err != nil { - out.Println("could not get home directory, can not check for .pgpass file") - return nil - } - - if s, err := os.Stat(filepath.Join(dirname, ".pgpass")); err == nil && !s.IsDir() { - return fmt.Errorf("found .pgpass file in home directory, please remove it before running this command") - } - return nil -} diff --git a/internal/postgres/psql.go b/internal/postgres/psql.go deleted file mode 100644 index 8bb0786e..00000000 --- a/internal/postgres/psql.go +++ /dev/null @@ -1,73 +0,0 @@ -package postgres - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/naistrix" -) - -func RunPSQL(ctx context.Context, appName, team, environment string, fl *flag.Psql, out *naistrix.OutputWriter) error { - // Get secret values with user-provided reason (access is logged for audit purposes) - sv, err := GetSecretValuesWithUserReason(ctx, appName, team, environment, fl.Postgres, fl.Reason, out) - if err != nil { - return err - } - - psqlPath, err := exec.LookPath("psql") - if err != nil { - return err - } - - dbInfo, err := NewDBInfo(ctx, appName, team, environment) - if err != nil { - return err - } - - dbInfo.SetSecretValues(sv) - - connectionInfo, err := dbInfo.DBConnection(ctx) - if err != nil { - return err - } - - portCh := make(chan int, 1) - ctx, cancel := context.WithCancel(ctx) - defer cancel() - go func() { - err := dbInfo.RunProxy(ctx, "localhost", nil, portCh, out, false) - if err != nil { - if errors.Is(err, context.Canceled) { - return - } - - out.Printf("ERROR: %v", err) - cancel() - } - }() - port := <-portCh - - out.Printf("Running proxy on localhost:%v\n", port) - - arguments := []string{ - "--host", "localhost", - "--port", fmt.Sprintf("%d", port), - "--username", connectionInfo.email, - "--dbname", connectionInfo.dbName, - } - - cmd := exec.CommandContext(ctx, psqlPath, arguments...) - - cmd.Stderr = os.Stderr - cmd.Stdout = os.Stdout - cmd.Stdin = os.Stdin - environ := os.Environ() - environ = append(environ, fmt.Sprintf("PGPASSWORD=%s", connectionInfo.password)) - cmd.Env = environ - - return cmd.Run() -} diff --git a/internal/postgres/secret.go b/internal/postgres/secret.go deleted file mode 100644 index 3e086c7c..00000000 --- a/internal/postgres/secret.go +++ /dev/null @@ -1,288 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - "strings" - - "github.com/nais/cli/internal/naisapi" - "github.com/nais/cli/internal/naisapi/gql" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/naistrix" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/tools/clientcmd" - "k8s.io/utils/ptr" -) - -// Hardcoded reasons for administrative operations -const ( - ReasonPasswordRotate = "Rotating database password via nais CLI" - ReasonPrepareAccess = "Preparing database for IAM user access via nais CLI" - ReasonRevokeAccess = "Revoking IAM user access from database via nais CLI" - ReasonListUsers = "Listing database users via nais CLI" - ReasonAddUser = "Adding database user via nais CLI" - ReasonDropUser = "Dropping database user via nais CLI" - ReasonEnableAudit = "Enabling audit logging via nais CLI" - ReasonVerifyAudit = "Verifying audit configuration via nais CLI" -) - -// Default duration for in-cluster postgres access grants -const defaultPostgresAccessDuration = "1h" - -// SecretValues holds the secret values retrieved from the API -type SecretValues struct { - values map[string]string -} - -// Get returns the value for a key with the given suffix (e.g. "_PASSWORD", "_USERNAME"). -// Keys are matched by suffix to handle prefixed key names like "NAIS_DATABASE_MYAPP_PASSWORD". -func (s *SecretValues) Get(suffix string) string { - for name, val := range s.values { - if strings.HasSuffix(name, suffix) { - return val - } - } - return "" -} - -// GetSecretValues retrieves the values of a database secret via the API. -// For CloudSQL databases, this retrieves the secret values directly. -// For in-cluster postgres, this grants temporary access to the database. -// In both cases, the access is logged for audit purposes. -func GetSecretValues(ctx context.Context, appName, team, environment string, fl *flag.Postgres, reason string, out *naistrix.OutputWriter) (*SecretValues, error) { - if reason == "" { - reason = fl.Reason - if reason == "" { - return nil, fmt.Errorf("reason is required for accessing database secrets") - } - } - - out.Printf("Using team %q\n", team) - - // Check if this is a CloudSQL or in-cluster postgres database - isCloudSQL, err := isCloudSQLDatabase(ctx, appName, fl) - if err != nil { - return nil, fmt.Errorf("checking database type: %w", err) - } - - if isCloudSQL { - return getCloudSQLSecretValues(ctx, appName, team, environment, reason, out) - } - - return grantInClusterPostgresAccess(ctx, appName, fl, team, environment, reason, out) -} - -// GetSecretValuesWithUserReason retrieves secret values with a user-provided reason. -// This should be used for interactive operations like proxy and psql where the user -// should provide justification for accessing the database. -func GetSecretValuesWithUserReason(ctx context.Context, appName, team, environment string, fl *flag.Postgres, reason string, out *naistrix.OutputWriter) (*SecretValues, error) { - if reason == "" { - reason = fl.Reason - if reason == "" { - return nil, fmt.Errorf("reason is required for accessing database secrets (use --reason flag)") - } - } - - if len(reason) < 10 { - return nil, fmt.Errorf("reason must be at least 10 characters") - } - - return GetSecretValues(ctx, appName, team, environment, fl, reason, out) -} - -// isCloudSQLDatabase checks if the given app uses CloudSQL or in-cluster postgres -func isCloudSQLDatabase(ctx context.Context, appName string, fl *flag.Postgres) (bool, error) { - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - // Use Context if set, otherwise fall back to Environment (they often map to the same thing) - kubeContext := string(fl.Environment) - if kubeContext == "" { - kubeContext = string(fl.Environment) - } - configOverrides := &clientcmd.ConfigOverrides{ - CurrentContext: kubeContext, - } - kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) - - ns := fl.Team - - config, err := kubeConfig.ClientConfig() - if err != nil { - return false, fmt.Errorf("unable to get kubeconfig: %w", err) - } - - dynamicClient, err := dynamic.NewForConfig(config) - if err != nil { - return false, fmt.Errorf("unable to create dynamic client: %w", err) - } - - // Check for CloudSQL SQLInstance resources - sqlInstances, err := dynamicClient.Resource(schema.GroupVersionResource{ - Group: "sql.cnrm.cloud.google.com", - Version: "v1beta1", - Resource: "sqlinstances", - }).Namespace(ns).List(ctx, v1.ListOptions{ - LabelSelector: "app=" + appName, - }) - if err != nil { - return false, fmt.Errorf("error looking for sqlinstance for application %q in %q: %w", appName, ns, err) - } - - return len(sqlInstances.Items) >= 1, nil -} - -// getCloudSQLSecretValues retrieves secret values for CloudSQL databases -func getCloudSQLSecretValues(ctx context.Context, appName, team, environment, reason string, out *naistrix.OutputWriter) (*SecretValues, error) { - // The secret name follows the pattern "google-sql-" - secretName := "google-sql-" + appName - - out.Debugf("Requesting access to CloudSQL secret %q...\n", secretName) - - values, err := naisapi.ViewSecretValues(ctx, team, environment, secretName, reason) - if err != nil { - // Check if the error indicates the user is not authorized - if strings.Contains(err.Error(), "not authorized") || strings.Contains(err.Error(), "Not authorized") { - return nil, fmt.Errorf("you are not authorized to access this database. Make sure you are a member of team %q", team) - } - return nil, err - } - - out.Debugf("✅ Access granted.\n") - - // Convert to SecretValues - result := &SecretValues{ - values: make(map[string]string, len(values)), - } - for _, v := range values { - result.values[v.Name] = v.Value - } - - return result, nil -} - -// getPostgresClusterName retrieves the postgres cluster name for an app -func getPostgresClusterName(ctx context.Context, appName string, fl *flag.Postgres) (string, error) { - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - // Use Context if set, otherwise fall back to Environment (they often map to the same thing) - kubeContext := string(fl.Environment) - if kubeContext == "" { - kubeContext = string(fl.Environment) - } - configOverrides := &clientcmd.ConfigOverrides{ - CurrentContext: kubeContext, - } - kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) - - ns := fl.Team - - config, err := kubeConfig.ClientConfig() - if err != nil { - return "", fmt.Errorf("unable to get kubeconfig: %w", err) - } - - dynamicClient, err := dynamic.NewForConfig(config) - if err != nil { - return "", fmt.Errorf("unable to create dynamic client: %w", err) - } - - // First try to get the cluster name from the Application spec - unstructuredApp, err := dynamicClient.Resource(schema.GroupVersionResource{ - Group: "nais.io", - Version: "v1alpha1", - Resource: "applications", - }).Namespace(ns).Get(ctx, appName, v1.GetOptions{}) - if err == nil { - spec, ok := unstructuredApp.Object["spec"].(map[string]any) - if ok { - postgres, ok := spec["postgres"].(map[string]any) - if ok { - clusterName, ok := postgres["clusterName"].(string) - if ok && clusterName != "" { - return clusterName, nil - } - } - } - } - - // If no Application found or no clusterName in spec, check if there's a Postgres resource with this name - _, err = dynamicClient.Resource(schema.GroupVersionResource{ - Group: "data.nais.io", - Version: "v1", - Resource: "postgres", - }).Namespace(ns).Get(ctx, appName, v1.GetOptions{}) - if err == nil { - // The appName is actually a postgres cluster name - return appName, nil - } - - return "", fmt.Errorf("unable to find postgres cluster for application %q in %q", appName, ns) -} - -// grantPostgresAccess grants temporary access to an in-cluster postgres database. -// This creates a time-limited grant for the user and logs the access for auditing purposes. -func grantPostgresAccess(ctx context.Context, clusterName, teamSlug, environmentName, grantee, duration string) error { - _ = `# @genqlient -mutation GrantPostgresAccess($input: GrantPostgresAccessInput!) { - grantPostgresAccess(input: $input) { - error - } -} -` - - client, err := naisapi.GraphqlClient(ctx) - if err != nil { - return fmt.Errorf("creating GraphQL client: %w", err) - } - - resp, err := gql.GrantPostgresAccess(ctx, client, gql.GrantPostgresAccessInput{ - ClusterName: clusterName, - TeamSlug: teamSlug, - EnvironmentName: environmentName, - Grantee: grantee, - Duration: duration, - }) - if err != nil { - return fmt.Errorf("granting postgres access: %w", err) - } - - if ptr.Deref(resp.GrantPostgresAccess.Error, "") != "" { - return fmt.Errorf("granting postgres access: %s", ptr.Deref(resp.GrantPostgresAccess.Error, "")) - } - - return nil -} - -// grantInClusterPostgresAccess grants access to in-cluster postgres databases -func grantInClusterPostgresAccess(ctx context.Context, appName string, fl *flag.Postgres, team, environment, reason string, out *naistrix.OutputWriter) (*SecretValues, error) { - // Get the postgres cluster name - clusterName, err := getPostgresClusterName(ctx, appName, fl) - if err != nil { - return nil, err - } - - // Get the authenticated user's email - user, err := naisapi.GetAuthenticatedUser(ctx) - if err != nil { - return nil, fmt.Errorf("getting authenticated user: %w", err) - } - grantee := user.Email() - - out.Debugf("Requesting access to in-cluster postgres %q for user %q...\n", clusterName, grantee) - - // Grant access via the API (this logs the access for audit purposes) - err = grantPostgresAccess(ctx, clusterName, team, environment, grantee, defaultPostgresAccessDuration) - if err != nil { - // Check if the error indicates the user is not authorized - if strings.Contains(err.Error(), "not authorized") || strings.Contains(err.Error(), "Not authorized") { - return nil, fmt.Errorf("you are not authorized to access this database. Make sure you are a member of team %q", team) - } - return nil, fmt.Errorf("granting postgres access: %w", err) - } - - out.Debugf("✅ Access granted for %s.\n", defaultPostgresAccessDuration) - - // For in-cluster postgres, we don't return secret values as authentication - // happens via OAuth tokens, not via secrets - return &SecretValues{values: make(map[string]string)}, nil -} From 7d0afa941c9615bb49d70fc9d27abe8c830eadd3 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 1 Oct 2026 11:37:36 +0200 Subject: [PATCH 09/10] feat(cli)!: drop Zalando Postgres, alias nais postgres to cloudsql Remove the legacy in-cluster Postgres code and grantPostgresAccess client. nais postgres and nais pg now run the Cloud SQL commands and print a deprecation warning; Nais Postgres lives under nais alpha postgres. --- internal/cloudsql/command/cloudsql.go | 19 ++++++++++++++ internal/cloudsql/command/cloudsql_test.go | 30 ++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 internal/cloudsql/command/cloudsql_test.go diff --git a/internal/cloudsql/command/cloudsql.go b/internal/cloudsql/command/cloudsql.go index c9d4e888..e2953298 100644 --- a/internal/cloudsql/command/cloudsql.go +++ b/internal/cloudsql/command/cloudsql.go @@ -2,6 +2,10 @@ package command import ( "context" + "fmt" + "io" + "os" + "strings" "github.com/nais/cli/internal/cloudsql/command/flag" "github.com/nais/cli/internal/flags" @@ -34,8 +38,23 @@ func CloudSQL(parentFlags *flags.GlobalFlags) *naistrix.Command { revokeCommand(flags), }, ValidateFunc: func(ctx context.Context, _ *naistrix.Arguments) error { + warnIfLegacyAlias(os.Args[1:], os.Stderr) _, err := gcloud.ValidateAndGetUserLogin(ctx, false) return err }, } } + +// warnIfLegacyAlias tells users who typed `nais postgres` or `nais pg` that these now mean Cloud SQL. +// naistrix does not expose which alias was used, so the first non-flag argument is inspected. +func warnIfLegacyAlias(args []string, w io.Writer) { + for _, arg := range args { + if strings.HasPrefix(arg, "-") { + continue + } + if arg == "postgres" || arg == "pg" { + _, _ = fmt.Fprintf(w, "Warning: nais %s is deprecated and now only manages Cloud SQL; use nais cloudsql instead. For Nais Postgres, use nais alpha postgres.\n", arg) + } + return + } +} diff --git a/internal/cloudsql/command/cloudsql_test.go b/internal/cloudsql/command/cloudsql_test.go new file mode 100644 index 00000000..a7d55e20 --- /dev/null +++ b/internal/cloudsql/command/cloudsql_test.go @@ -0,0 +1,30 @@ +package command + +import ( + "bytes" + "strings" + "testing" +) + +func TestWarnIfLegacyAlias(t *testing.T) { + for name, tt := range map[string]struct { + args []string + warn bool + }{ + "postgres": {[]string{"postgres", "list"}, true}, + "pg": {[]string{"pg", "psql", "app"}, true}, + "flag before": {[]string{"--team", "x", "postgres"}, false}, + "cloudsql": {[]string{"cloudsql", "list"}, false}, + "alpha postgres": {[]string{"alpha", "postgres", "list"}, false}, + "only flags": {[]string{"--help"}, false}, + "no args": {nil, false}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + warnIfLegacyAlias(tt.args, &buf) + if got := strings.Contains(buf.String(), "deprecated"); got != tt.warn { + t.Fatalf("warned=%v, want %v (%q)", got, tt.warn, buf.String()) + } + }) + } +} From fcf42e086573041ee401ade616fe1a9f0dcba605 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 1 Oct 2026 11:47:08 +0200 Subject: [PATCH 10/10] fix: deprecation msg --- internal/cloudsql/command/cloudsql.go | 2 +- internal/cloudsql/command/cloudsql_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cloudsql/command/cloudsql.go b/internal/cloudsql/command/cloudsql.go index e2953298..7831e829 100644 --- a/internal/cloudsql/command/cloudsql.go +++ b/internal/cloudsql/command/cloudsql.go @@ -53,7 +53,7 @@ func warnIfLegacyAlias(args []string, w io.Writer) { continue } if arg == "postgres" || arg == "pg" { - _, _ = fmt.Fprintf(w, "Warning: nais %s is deprecated and now only manages Cloud SQL; use nais cloudsql instead. For Nais Postgres, use nais alpha postgres.\n", arg) + _, _ = fmt.Fprintf(w, "Warning: nais %s has moved to nais cloudsql. Use nais cloudsql instead, as nais %[1]s will stop working for Cloud SQL in the future.\n", arg) } return } diff --git a/internal/cloudsql/command/cloudsql_test.go b/internal/cloudsql/command/cloudsql_test.go index a7d55e20..71ce8b7e 100644 --- a/internal/cloudsql/command/cloudsql_test.go +++ b/internal/cloudsql/command/cloudsql_test.go @@ -22,7 +22,7 @@ func TestWarnIfLegacyAlias(t *testing.T) { t.Run(name, func(t *testing.T) { var buf bytes.Buffer warnIfLegacyAlias(tt.args, &buf) - if got := strings.Contains(buf.String(), "deprecated"); got != tt.warn { + if got := strings.Contains(buf.String(), "has moved to nais cloudsql"); got != tt.warn { t.Fatalf("warned=%v, want %v (%q)", got, tt.warn, buf.String()) } })